import { AGENTS } from './agents.js' import { baseFieldId } from './config.js' /** * THE WHATSAPP THREAD, BUILT ONCE. * * The conversation was being reconstructed twice — the timeline read it per * audit row, the dialog read it per field — and the two disagreed. The trail * printed one entry per turn, so a four-message exchange took more of the page * than the entire underwriting chain, and a message the workflow recorded from * inside a trigger (the payment request, the issuance note) appeared in the * dialog and nowhere in the trail. One builder, two readers, one answer. * * WHY A THREAD IS NOT A LIST OF ROWS. The audit trail records activities; the * conversation is a thing that persists ACROSS them. Three facts make it awkward * and they are all handled here rather than in each screen's own guess: * * 1. The customer's turns and ours are separate activities — `customer-reply` * is performed by the channel, `answer-customer` by Engage — so a turn is * a FIELD on a row, not a row. * 2. Two of our sends are not chat activities at all. Request Premium and the * issuance step compose their message inside a trigger and write it to * `customer_answer` alongside their own work (see 98/99/100 in the seed * SQL). They belong in the thread and stay on their own step in the trail. * 3. One submission is recorded up to three times — the trigger commit, its * repeat, and the settle — so the same sentence arrives three times. * * `episodes` is what makes this renderable as a timeline entry. The thread is * one conversation, but it happens in bursts: the customer argues about the * quote, then KYC and underwriting run for twenty minutes, then the premium * request reopens the same thread. An episode is one burst. Collapsing the * whole thread into a single entry would date the payment exchange to the * moment of the quote objection and destroy the chronology the trail exists * for; leaving every turn as its own entry is what it did before. A burst is * the unit that is both honest and short. */ /** The two activities that ARE the thread — one turn each, either direction. */ export const CHAT_ACTS = new Set(['zk-act-customer-reply', 'zk-act-answer-customer']) const INBOUND = 'customer_reply' const OUTBOUND = 'customer_answer' /** * The fields the thread OWNS. A step that writes one is not asked to print it * as well — the message belongs to the conversation, and quoting it on the step * too is how the payment request came to say the same sentence twice. */ export const THREAD_FIELDS = new Set([INBOUND, OUTBOUND]) /** Bookkeeping rows. They interrupt nothing: the platform writing a field is * not a step that broke off a conversation. */ const SYSTEM_ACT = 'DATA_UPDATE' /** * Last resort for a step whose activity_name did not come back — without it the * trail prints a raw slug like "zk-act-doc-reminder" in the middle of an * otherwise readable story. A guess, but one that reads as English. */ export function prettyUid(uid) { if (!uid) return 'Step' return String(uid) .replace(/^zk-act-/, '') .split('-') .filter(Boolean) .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .join(' ') } /** What to call a step. The server's own name first; the slug, tidied, after. */ export function stepName(row) { return row?.activity_name || prettyUid(row?.activity_id) } /** * `fields[]` is the platform's typed rendering of a submission; `data` is the * raw fallback for a row the workflow could not resolve. */ function fieldsOf(row) { return Array.isArray(row.fields) && row.fields.length ? row.fields : Object.entries(row.data || {}).map(([k, v]) => ({ field_id: k, value: v })) } /** Who sent an outbound turn. Usually Engage; sometimes a person answering by * hand, which must not be attributed to the machine. */ function senderOf(row) { const key = (row.user_roles || []).find((r) => AGENTS[r]) || null return { agentKey: key, by: key ? AGENTS[key].short : (row.user_name || 'Zurich Kotak') } } /** The turns carried by one audit row, in field order. */ function turnsInRow(row) { const out = [] for (const f of fieldsOf(row)) { const base = baseFieldId(f.field_id) if (!THREAD_FIELDS.has(base)) continue const text = typeof f.value === 'string' ? f.value.trim() : '' if (!text) continue out.push({ side: base === INBOUND ? 'them' : 'us', text }) } return out } /** * The thread, its bursts, and which audit row carries what. * * Returns: * turns every message, oldest first * episodes contiguous bursts, each with the steps that preceded it * byRow row id -> { episodeId, sent, received }, so a step that sent a * message can link into the thread instead of quoting it */ export function buildThread(rows) { const empty = { turns: [], episodes: [], byRow: new Map() } if (!Array.isArray(rows) || !rows.length) return empty // Sorted on (timestamp, id). One submission writes three rows in the same // second, so a timestamp alone leaves their order to the sort's stability and // puts a reply before the message it answers whenever the two land together. const ordered = [...rows].sort((a, b) => { const t = String(a.created_at).localeCompare(String(b.created_at)) return t !== 0 ? t : (Number(a.id) || 0) - (Number(b.id) || 0) }) const turns = [] const episodes = [] const byRow = new Map() let current = null // The steps seen since the last message, and whether any of them broke off // the conversation. `pending` accumulates even before the first message so // that the first episode can drop it: there is nothing to bridge yet. let pending = [] let broke = false for (const row of ordered) { const mine = turnsInRow(row) if (!mine.length) { // A chat activity that recorded no text is not a step that interrupted // anything — it is an empty row. Bookkeeping is not a step either. if (row.activity_id === SYSTEM_ACT || CHAT_ACTS.has(row.activity_id)) continue if (current) broke = true const name = stepName(row) if (name && !pending.includes(name)) pending.push(name) continue } const who = senderOf(row) for (const t of mine) { const prev = turns[turns.length - 1] // De-duped against the PREVIOUS turn only. A customer who asks the same // thing twice because the first went unanswered has said it twice, and a // thread that showed it once would hide exactly the impatience an // operator needs to see. Only an immediate repeat is the platform // recording one submission three times. if (prev && prev.side === t.side && prev.text === t.text) continue if (!current || broke) { current = { id: `ep${episodes.length + 1}`, turns: [], // What ran between this burst and the one before it. Empty on the // first, which begins the conversation rather than resuming it. after: episodes.length ? pending : [], anchorRowId: row.id, anchorIsChat: CHAT_ACTS.has(row.activity_id), } episodes.push(current) broke = false } pending = [] const turn = { key: `${row.id}-${t.side}-${turns.length}`, side: t.side, text: t.text, at: row.created_at, rowId: row.id, episodeId: current.id, agentKey: t.side === 'us' ? who.agentKey : null, by: t.side === 'us' ? who.by : 'the customer', } current.turns.push(turn) turns.push(turn) const tally = byRow.get(row.id) || { episodeId: current.id, sent: 0, received: 0 } if (t.side === 'us') tally.sent += 1 else tally.received += 1 byRow.set(row.id, tally) } } for (const ep of episodes) { ep.count = ep.turns.length ep.from = ep.turns[0].at ep.to = ep.turns[ep.turns.length - 1].at // The last exchange, which is where the conversation GOT to — what an // operator scanning the trail is actually looking for. ep.preview = ep.turns.slice(-2) } return { turns, episodes, byRow } } /** * "Verify KYC and Underwriting Screen", "A, B +2 more" — a bridge, not a list. * Used for the steps that interrupted a conversation and for the voices in it. */ export function listOf(names, max = 2) { if (!names || !names.length) return '' if (names.length <= max) { return names.length === 1 ? names[0] : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}` } return `${names.slice(0, max).join(', ')} +${names.length - max} more` }