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 if (error) return const to = Math.min(offset + PAGE, total) return (

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.

{total === 0 ? (

{status ? 'Nothing matches that filter.' : 'Nothing has been sent yet.'}

) : ( <>
{rows.map((r) => ( ))}
When What To Channel Result Detail
{new Date(r.created_at).toLocaleString()} {/* 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 ? Test send from the template editor : {r.trigger_id}} {r.user_id ? user #{r.user_id} : } {r.channel} {r.transport && · {r.transport}} {STATUS_LABEL[r.status] || r.status} {r.detail || ''}
{offset + 1}–{to} of {total} {retainDays ? ` · entries older than ${retainDays} days are removed automatically` : ''}
)}
) }