import { useCallback, useEffect, useState } from 'react' import { Loading, ErrorState } from '../../../components/PageState.jsx' import { api } from '../../../api/client.js' // Admin → Engagement → Retention (ENGAGEMENT.md Phase 14). // // Three of the four engagement tables grew on every fire and nothing had ever // deleted from any of them. This screen is the policy: how long the deployment // keeps a cooldown row, a finished outbox row and a send-log entry. // // **Why it is a screen, when the other two retention workers in this codebase // (`team_activity`, `user_notifications`) are invisible settings rows.** The // send-log horizon changes what an operator-facing page is *able to show* — the // Send Log is the only answer to "was this person told" — so an operator has to // be able to see it and set it, not discover it by finding rows missing. Having // made one visible, hiding the other two would be the worse split: "what does // this deployment keep" is one question and deserves one answer. // // **The fourth table is on this page as prose, not as a control.** Suppressions // do not expire (org lead, 2026-09-01), and saying so here is the point: an // operator reading a retention screen that lists three tables would reasonably // assume the fourth was an oversight. const FIELDS = [ { name: 'sends', label: 'Send log', table: 'engagement_sends', // The one horizon the org lead asked to be pickable rather than typed — // and `custom` stays, because a deployment with a compliance answer to // give should not be limited to three numbers somebody chose. presets: [90, 180, 365], help: 'One row per delivery attempt. This is what Admin → Engagement → Send Log reads, so the ' + 'horizon is also how far back "was this person told" can be answered. The per-rule hourly ' + 'ceiling counts this table too, which is why it can never go below a week.', }, { name: 'cooldowns', label: 'Cooldowns', table: 'engagement_cooldowns', presets: [7, 30, 90], help: 'One row per rule, user, subject and channel, written on every fire. Deleting a row that ' + 'is still in force makes the next fire count as a first fire — that is a duplicate ' + 'message — so this must stay longer than the longest cooldown on any enabled rule.', }, { name: 'outbox', label: 'Outbox', table: 'engagement_outbox', presets: [7, 30, 90], help: 'Only finished rows are ever removed: sent, failed, cancelled and not-sent. A scheduled ' + 'row is a message this deployment still intends to send and is never swept, however old ' + 'the horizon.', }, ] export default function EngagementRetention() { const [policy, setPolicy] = useState(null) const [limits, setLimits] = useState({}) const [warnings, setWarnings] = useState([]) const [longestCooldown, setLongestCooldown] = useState(0) const [draft, setDraft] = useState({}) const [saving, setSaving] = useState(false) const [note, setNote] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const apply = useCallback((result) => { setPolicy(result.retention) setDraft(result.retention) setLimits(result.limits || {}) setWarnings(result.warnings || []) setLongestCooldown(result.longestCooldownSeconds || 0) }, []) useEffect(() => { let alive = true ;(async () => { try { const result = await api.admin.getEngagementRetention() if (alive) apply(result) } catch (err) { if (alive) setError(err.message) } finally { if (alive) setLoading(false) } })() return () => { alive = false } }, [apply]) async function save() { setSaving(true) setNote(null) try { // The whole draft, not the changed field: this screen is the one place the // three are set together, and a partial save would leave the warning line // (which is computed from the cooldown horizon) describing a policy that is // half saved. The route itself is sparse, so sending three is legal. const result = await api.admin.setEngagementRetention(draft) apply(result) setNote('Saved.') } catch (err) { setNote(err.message) } finally { setSaving(false) } } if (loading) return if (error) return const dirty = policy && FIELDS.some((f) => Number(draft[f.name]) !== Number(policy[f.name])) return (

How long this deployment keeps the engagement system’s own records. A nightly sweep removes anything older, in batches, and skips a table it cannot read rather than failing the run.

{warnings.map((w) => (

{w}

))}
{FIELDS.map((f) => { const spec = limits[f.name] || {} const value = draft[f.name] ?? '' const isPreset = f.presets.includes(Number(value)) return (
{f.label} {f.table}

{f.help}

{spec.min !== undefined && ( {spec.min}–{spec.max} days )}
) })}
{dirty && ( )} {note && {note}}

Suppressed addresses do not expire

A suppression is a standing decision, not a record of something that happened. Ageing one out would re-mail an address that already hard-bounced or asked to be left alone, which is how a sender loses a domain’s reputation. The way out of that list stays a deliberate act:{' '} Lift on the row, in Admin → Engagement → Suppressions.

{longestCooldown > 0 && (

The longest cooldown on an enabled rule right now is {longestCooldown} seconds.

)}
) }