ENGAGEMENT.md Phase 14, the last phase of the workstream. Four engagement
tables grew on every fire and nothing had ever deleted from any of them.
Three of them now have a horizon, swept nightly by one worker
(utils/engagementRetentionPrune.js — setInterval + unref + stop(), batched
1000 x 50, each table's failure caught on its own so a lock timeout on one
does not leave the other two unbounded):
engagement_sends 180 days engagement_sends_retain_days (7-3650)
engagement_cooldowns 30 days engagement_cooldowns_retain_days (2-3650)
engagement_outbox 30 days engagement_outbox_retain_days (2-3650)
The fourth, engagement_suppressions, does not expire, and that is the
recorded decision rather than an omission: a suppression is a standing
decision, and ageing out a hard bounce re-mails an address that already
bounced. The way out stays deliberate, and is now reachable per row.
Six decisions were settled by the org lead before any code. Two of them
widened the phase past what was offered:
* the send-log horizon is admin-configurable, so retention got a SCREEN
(Admin -> Engagement -> Retention) where team_activity and
user_notifications keep theirs in invisible settings rows. The send-log
horizon changes what an operator-facing page is able to show, so it has
to be visible; the other two came with it, because "what does this
deployment keep" is one question.
* the suppression purge, which cost a Phase 9 decision. The list
deliberately stripped address_hash from every row, so the only way out
was a window.prompt asking the operator to retype an address the screen
has never shown them. The row had no handle at all. The hash is now
returned: this route is admin-only and an admin can already suppress and
unsuppress any address they can name, so it grants no capability they
lack. GET /sends still strips its own.
The outbox sweep is TERMINAL-ONLY and that is a correctness rule: a
scheduled row is a send this deployment still intends to make (delay_seconds
can put one a day out) and a sending row may be mid-flight.
One shipped defect had to be fixed for the sweep to be a bound at all.
reclaimStale returned every stale sending row to scheduled, and MAX_ATTEMPTS
is consulted only on a graceful retry outcome — so a send that killed the
process mid-flight cycled sending -> scheduled -> sending forever, never
terminal, therefore never eligible for any sweep. It now fails an exhausted
row BEFORE reclaiming the rest; the order is the fix.
Two indexes (idx_engo_sweep, idx_engs_sweep): every existing index on those
tables has created_at in second position, which serves a per-rule window and
is useless to a whole-table horizon.
Proved twice: engagementRetentionSql.test.js against a real MariaDB (7
tests, incl. the acceptance case and the wrong reclaim order run
deliberately), and the live stack, where a 90-day-old cancelled row was
swept and a 90-day-old scheduled row survived.
Co-Authored-By: Claude <noreply@anthropic.com>
188 lines
7.8 KiB
JavaScript
188 lines
7.8 KiB
JavaScript
import { useCallback, useEffect, useState } from 'react'
|
||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||
import { api } from '../../../api/client.js'
|
||
|
||
// Admin → Engagement → Send Log (ENGAGEMENT.md §4.5, gap G15, Phase 5b).
|
||
//
|
||
// G15 was stated as: "no per-message record — no send log, no delivery status, no
|
||
// audit". The table has been filling since Phase 4a; this is the screen that reads
|
||
// it, and the question it exists to answer is the operator's, not the engine's:
|
||
// **did that person get that mail, and if not, why not?**
|
||
//
|
||
// Two things it deliberately does not show.
|
||
//
|
||
// • **The address.** The log stores a sha256 so a bounce can be correlated back
|
||
// to a recipient (Phase 9) without becoming a second address book. The route
|
||
// strips the column; this screen could not render it if it wanted to.
|
||
// • **A name for the user.** The `user_id` is what the log holds, and joining
|
||
// users in would make a delivery screen into a directory. The id is enough to
|
||
// paste into Moderation, which is where a person's record belongs.
|
||
//
|
||
// `failed` rows are the point of the screen, so the reason is a column and not a
|
||
// tooltip: a delivery log whose failures need a hover is a log nobody reads.
|
||
|
||
const STATUS_LABEL = {
|
||
sent: 'Sent',
|
||
failed: 'Failed',
|
||
suppressed: 'Not sent',
|
||
bounced: 'Bounced',
|
||
complained: 'Marked as spam',
|
||
}
|
||
|
||
const STATUS_COLOR = {
|
||
failed: '#d98b84',
|
||
bounced: '#d98b84',
|
||
complained: '#d98b84',
|
||
}
|
||
|
||
const PAGE = 50
|
||
|
||
export default function EngagementSendLog() {
|
||
const [rows, setRows] = useState([])
|
||
const [total, setTotal] = useState(0)
|
||
const [offset, setOffset] = useState(0)
|
||
const [status, setStatus] = useState('')
|
||
const [testTrigger, setTestTrigger] = useState('')
|
||
// Phase 14. `total` is now a truncated number, and a screen that shows a total
|
||
// without saying so is quietly wrong about the deployment's own history — this
|
||
// is the fix for that, and the reason the horizon got an operator-facing
|
||
// control rather than the invisible settings row the other two sweeps use.
|
||
const [retainDays, setRetainDays] = useState(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState(null)
|
||
|
||
const load = useCallback(async (nextOffset, nextStatus) => {
|
||
const result = await api.admin.listEngagementSends({
|
||
limit: PAGE,
|
||
offset: nextOffset,
|
||
status: nextStatus || undefined,
|
||
})
|
||
setRows(result.sends || [])
|
||
setTotal(result.total || 0)
|
||
setTestTrigger(result.testSendTrigger || '')
|
||
// Best-effort and non-blocking: the log is worth showing even if the policy
|
||
// cannot be read, so a failure here leaves the note off rather than the
|
||
// screen empty.
|
||
try {
|
||
const policy = await api.admin.getEngagementRetention()
|
||
setRetainDays(policy?.retention?.sends ?? null)
|
||
} catch {
|
||
setRetainDays(null)
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
let alive = true
|
||
;(async () => {
|
||
setLoading(true)
|
||
try {
|
||
await load(offset, status)
|
||
if (alive) setError(null)
|
||
} catch (err) {
|
||
if (alive) setError(err.message)
|
||
} finally {
|
||
if (alive) setLoading(false)
|
||
}
|
||
})()
|
||
return () => { alive = false }
|
||
}, [load, offset, status])
|
||
|
||
if (loading && rows.length === 0) return <Loading />
|
||
if (error) return <ErrorState message={error} />
|
||
|
||
const to = Math.min(offset + PAGE, total)
|
||
|
||
return (
|
||
<section>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16, marginBottom: 16, flexWrap: 'wrap' }}>
|
||
<p className="sans" style={{ margin: 0, fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 560 }}>
|
||
Every message this deployment tried to deliver, successful or not. Addresses are not kept
|
||
here — only a one-way hash, so a bounce can be matched back without the log becoming a
|
||
second address book.
|
||
</p>
|
||
<label>
|
||
<span className="field-label">Show</span>
|
||
<select className="select" value={status} onChange={(e) => { setOffset(0); setStatus(e.target.value) }}>
|
||
<option value="">Everything</option>
|
||
<option value="sent">Sent</option>
|
||
<option value="failed">Failed</option>
|
||
<option value="suppressed">Not sent</option>
|
||
<option value="bounced">Bounced</option>
|
||
<option value="complained">Marked as spam</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
|
||
{total === 0 ? (
|
||
<p className="sans dim" style={{ fontSize: '0.85rem' }}>
|
||
{status ? 'Nothing matches that filter.' : 'Nothing has been sent yet.'}
|
||
</p>
|
||
) : (
|
||
<>
|
||
<div className="panel-flat">
|
||
<table className="adm-table">
|
||
<thead>
|
||
<tr>
|
||
<th className="adm-th">When</th>
|
||
<th className="adm-th">What</th>
|
||
<th className="adm-th">To</th>
|
||
<th className="adm-th">Channel</th>
|
||
<th className="adm-th">Result</th>
|
||
<th className="adm-th">Detail</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((r) => (
|
||
<tr key={r.id}>
|
||
<td className="adm-td" style={{ whiteSpace: 'nowrap', fontSize: '0.8rem' }}>
|
||
{new Date(r.created_at).toLocaleString()}
|
||
</td>
|
||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||
{/* The synthetic test-send id is rendered by name: it is not a
|
||
registered trigger and will never appear in the catalog,
|
||
so showing the raw id would send someone looking for it. */}
|
||
{r.trigger_id === testTrigger
|
||
? <span>Test send <span className="dim">from the template editor</span></span>
|
||
: <code style={{ fontSize: '0.8rem' }}>{r.trigger_id}</code>}
|
||
</td>
|
||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||
{r.user_id ? <span className="dim">user #{r.user_id}</span> : <span className="dim">—</span>}
|
||
</td>
|
||
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
|
||
{r.channel}
|
||
{r.transport && <span className="dim"> · {r.transport}</span>}
|
||
</td>
|
||
<td className="adm-td" style={{ fontSize: '0.82rem', color: STATUS_COLOR[r.status] || undefined }}>
|
||
{STATUS_LABEL[r.status] || r.status}
|
||
</td>
|
||
<td className="adm-td" style={{ fontSize: '0.8rem', maxWidth: 320, overflowWrap: 'anywhere' }}>
|
||
{r.detail || ''}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 14 }}>
|
||
<span className="sans dim" style={{ fontSize: '0.82rem' }}>
|
||
{offset + 1}–{to} of {total}
|
||
{retainDays ? ` · entries older than ${retainDays} days are removed automatically` : ''}
|
||
</span>
|
||
<div style={{ display: 'flex', gap: 8 }}>
|
||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}
|
||
disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE))}>
|
||
Newer
|
||
</button>
|
||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}
|
||
disabled={to >= total} onClick={() => setOffset(offset + PAGE)}>
|
||
Older
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</section>
|
||
)
|
||
}
|