diff --git a/ROADMAP.md b/ROADMAP.md index 7496cfc..8365b2d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,25 @@ This is the living implementation record. A checked implementation item means co ## Release-only mode and merge gates -Feature work is frozen. Do not extend Milestone 2, add diagnostics code or make further feature commits to #28–#30. #25 and #31 have explicit release approval and are live; #26 and the remaining stack still require review before merge/deployment. +The latest staged-release brief governs release order. #28–#30 remain unchanged by this work. Aryan explicitly requested live staff-card status and an acceptance notice on October 11; that narrow feature is on the separate `feat/live-staff-case-status` draft branch, not deployed or blanket-approved for release. No diagnostics or new backend access accompanies it. + +### October 11 current production and review gate + +- #33 and #32 merged into #26's foundation; #26 merged into main as `2150383`. Its pre-release suite was 626/626, with Node 20/24 and database CI green. +- Initial release `565be7b7-66ab-48a9-97e5-2cd8dedbc48d` succeeded on October 10 at 13:20:42 UTC. Direct `node index.js` startup replaced the unset override/effective npm startup; draining went from unset to 100 s. `/health` and its default timeout are unchanged; no service variables changed. Auto-deploy remains disabled; this was an explicit deploy. +- Four normal smoke replies and two in-flight restart replies were observed on October 11. Each had exactly one source label and no link-only fragments; no missing/duplicate reply, schema/model/review/staff-delivery error was observed in the smoke window. Both old copies answered after SIGTERM and logged `drain completed=true telegram_completed=true`. The replacement card was screenshot-confirmed then intentionally deleted by Aryan; restart B's card was also independently read from Discord before its later removal. +- Phase 1 final same-image deployment: `8e9e453b-d60b-4001-86c0-8303deaf123d`, created October 11 at 11:01:55 UTC, SUCCESS. It is now Phase 2's immediate rollback target; the earlier `0955e078-42ea-4830-bba5-006dae841745` remains historical. +- Restart A/B: new-login → old-SIGTERM gaps 3.678/9.398 s; new-login → old drain-completed logs 23.675/29.395 s. The latter are connection-shutdown proxies, not exact socket disconnect times. This verifies the two observed drains, not every possible duplicate/uncertain-send failure mode. +- **Report 1 was graded PASS.** Quality exceptions remain: the first webhook answer points at the prompt-app guide, Spanish mixes conflicting language guidance, replacement intake still asks for tracking, restart A omits available Developer Mode steps, and restart B hands off an ordinary how-to. Do not represent the reliability smoke as blanket answer-quality approval. +- **Phase 2 is deployed; Report 2 awaits grading.** Approved fixes merged in order #37 → #40 → #34 → #36 → #38 → #39, with main CI green between every merge. Main `8321439` passes 638 tests and offline 8/8; the brief's 641 count is not this combined main's observed count. Deployment `5b25759b-d01e-4645-ade1-c869a482b323` is SUCCESS, created October 11 at 17:38:24 IST. HTTP 200, `status=ok`, `staffHandoff=configured`; no settings or variables changed. Aryan explicitly waived the 24-hour interval for this Phase 2 deploy only. +- All four Phase 2 synthetic posts have one observed bot reply. The extra long-guide reply exercises the 1,900-character limit: the body ends in an ellipsis and one complete Source line retains both Help Center URLs. Both handoff cards were screenshot-confirmed, then intentionally removed by Aryan; do not recreate them. Phone hidden on private intake, order retained by explicit approval. The public filing formatter hides both in an offline check, but no live GitHub draft was inspected or issue filed. No missing-env, boot, model, review, staff-delivery or unhandled errors, and no second looks, in the checked smoke window. Full replies and timing lines are retained outside Git in `phase2-production-smoke-oct11.json`. +- Phase 2 quality exceptions: the first guide omitted documented language/deletion steps; the clipped guide is incomplete despite preserving citations. The suggested test title containing “privacy” changed the crash report's route to privacy; a synthetic body-only comparison routes app, and Phase 2 did not change that classifier. Replacement intake still asks for tracking. These are not a blanket quality pass or evidence that the redaction fixes caused a new routing regression. #35 received the requested Changes requested review and remains unmerged. + +**Current staged release order:** Phase 2 → #41 live staff status (review first) → `fix/howto-not-escalated` (same-day 81×2/concurrency-3 main comparison and review) → #28 durable filing/private-destination checks (live read-only preflight and review) → #29 → #30. Each deploy has its own smoke report and 24 clean hours unless Aryan explicitly says go. #28–#30 receive only their separately authorized phase corrections; this #41 work does not modify them. Broader knowledge/access work remains after these reliability gates, with diagnostics still parked. + +### Historical release plan (superseded by the staged order above) + +The gate/comparison entries below are retained as history, not a claim that #26 is still undeployed or that the old two-week #29/#30 decision precedes the latest staged instructions. 1. Complete and report Aryan's four #24 smoke tests, their exact timing lines, second-look lines and errors, and the private staff card. Screenshots received on October 9 confirm the four replies and replacement card. Totals: deletion 24.760 s, replacement 30.631 s, Spanish 31.251 s, developer 53.540 s; no second looks or model/staff-delivery failures in that window. Functional checks pass, but do not convert this into a blanket answer-quality signoff: the Undo wording overpromises recovery and the replacement intake asks for tracking details. 2. Release approved #25 with a merge commit, deploy main, verify HTTP 200/status `ok`/staff handoff `configured`, and record a real successful rollback target. Read but do not change overlap/draining variables. Set only Healthcheck Path `/health`, leave timeout default, verify the health-gated deployment and measure new Discord login versus old SIGTERM. If overlap exceeds ten seconds, tell Aryan before any further deployments. @@ -18,15 +36,15 @@ Feature work is frozen. Do not extend Milestone 2, add diagnostics code or make Any change to answer/reviewer prompts or canned customer-answer replies must pass the full **81-case held-out gate before merge**. A focused live probe is additional evidence, never a substitute. Keep held-out questions unopened, preserve distinct checkout/commit provenance and do not alter code based on protected-case results. Merge commits only; no generated authorship trailers, model names in commit messages, force-pushes or direct pushes to main. -Product decision confirmed by Aryan: **keep** optional honest App Store/Google Play review buttons in #26, shown regardless of feedback. Their implementation is complete; release remains pending #26's gates. Store clicks are not internal support votes or verified review submissions. +Product decision confirmed by Aryan: **keep** optional honest App Store/Google Play review buttons in #26, shown regardless of feedback. They shipped with the foundation. Store clicks are not internal support votes or verified review submissions. Customer-impacting follow-ups take priority over deferred plumbing: developer pages cited for consumer questions; deletion how-to escalating unnecessarily; and speed. Chinese duplicate source labels are addressed by #31; the English footer following its localized citation was a recorded production smoke exception. The October 10 reviewer/lookup/footer fixes and separately approved planner-human-flag correction are implemented, tested and not deployed. The latest approval adds only raw-control recovery in the shared model JSON helper plus the same-day comparison above. Keep valid escapes/content/structural whitespace, escape unescaped controls inside strings, and reject other malformed JSON; do not use a blanket strip that changes meaning. #28–#30 remain frozen. Milestone 3 stays parked until Omi backend-team agreement and privacy review, before any implementation. ## Current milestone -**Workflow foundation is implemented on the working branch; production rollout is pending.** The earlier documentation-only proposal is now being extended with actual runtime changes. No GCP permissions or customer-content access have been granted. +**Workflow foundation plus the approved Phase 2 fixes are live at `8321439`; Report 1 passed and Report 2 awaits grading.** Live staff-card status and acceptance notices are implemented on the separate draft branch, not production. No GCP permissions or customer-content access have been granted. -The existing PR #24 smoke-test prerequisite still applies to the approved PR #25 readiness rollout. Do not redeploy over an unfinished smoke test or claim the new foundation is live before deployment and end-to-end checks succeed. +The earlier #24/#25 smoke prerequisites are complete. Subsequent releases still require their own review, smoke report and rollback evidence; never redeploy over an unfinished reply. ## Already working before this milestone @@ -56,11 +74,24 @@ These capabilities remain subject to answer-quality checks. Existing functionali - [x] Add PR checks with no provider/Discord secrets, and a disposable Postgres integration job. - [x] Complete hosted CI for runtime head `3960f24`: rules on Node 20/24 and the disposable Postgres job pass. - [x] Run a focused three-run communication evaluation: eight cases meet the pass threshold, one is flaky, none fail the threshold. This is not the 81-case held-out gate or a production smoke test. -- [ ] Deploy through the existing smoke-test and rollback gates; verify real private delivery, restart behavior and deployment overlap. +- [x] Deploy #26/#33/#32 and record private delivery, two in-flight drains, overlap and rollback evidence; Report 1 graded PASS, with the quality exceptions above. Approved Phase 2 fixes are subsequently deployed and reported separately. + +### Requested live staff-card follow-through — draft, not live + +Validation on October 11: 647 unit/regression tests pass, offline customer evaluation 8/8, and the isolated SQL check passes with an old-schema upgrade, concurrent notice claims and card/close/reopen synchronization. The first hosted Postgres job caught a real microsecond-watermark bug that the initial millisecond-resolution fixture missed. It was reproduced with a sub-millisecond timestamp and corrected by stamping the exact server timestamp only for the rendered projection; a newer status remains pending. Hosted PR CI on the corrected head and the protected customer-wording gate are separate checks, not implied by local results. + +Phase 3 correction: Phase 2 main merged into #41 with a merge commit. An unconfirmed acceptance notice now explicitly tells the accepting staff member, ephemerally: “The customer notice was not confirmed. Please reply in their thread yourself.” Regression coverage includes unconfirmed send, claim failure, stale acceptance, missing receipt, unavailable destination and a thrown notification. Boot and 30-second timer wiring are exercised directly; a previously synchronized card stays stale after an edit failure and is repaired on the next pending sweep. Current combined suite: 661 pass, offline 8/8. Hosted CI/reviewer approval remain separate gates; no protected run or production release accompanies this correction. + +- [x] Show awaiting acceptance, accepted/not started, in progress, waiting for customer, resolved and closed from the same durable case. A staff action—not ticket delivery or elapsed time—starts work. +- [x] Persist staff-card message references only; edit existing private cards immediately on transitions and recover missed edits through a bounded 30 s/25-case sweep. Serialize same-case edits across copies. Do not repost a card a person deleted. +- [x] Preserve the first accepting staff member per acceptance cycle, reset acceptance/work state on customer-authorized reopening, and separate the suggested specialist from the actual acceptor. +- [x] Send one localized, no-ping acceptance update in the bound customer thread (or verified original-message reply). No ETA, private intake, backend check or resolution claim. Cross-copy claims and a per-cycle nonce prevent repeated Accept clicks from repeating the notice. +- [x] On an unconfirmed notice, keep the real acceptance and tell staff to follow up via Jump; do not blindly replay an uncertain send or claim the customer was notified. A crash between the durable claim and Discord delivery can leave this optional notice unconfirmed; staff follow-up is the fallback, not an exactly-once guarantee. +- [ ] Send #41 for review before merge; run any required wording gate, then smoke-test accept/double-accept/start/wait/refresh/restart/close in the separately approved rollout. The latest staged order places #41 before #28; #28's audience validation is a later gate, not claimed as present here. No competing audience checker or new backend access was added. The protected file has not been opened or rerun for this feature. ## Milestone 2 Complete case handling and delivery recovery -Retained, not discarded. #28 (durable engineering approvals/destination checks) unfreezes only after #26 is live and verified, then ships alone after review and smoke tests. #29 (Discord receipt ledger) and #30 (tracked closure/reopening) stay unmerged and unchanged pending the two-week production go/no-go above. Their uncertain-send protection can hold a reply rather than retry it; weigh missing replies as well as duplicates. Existing branch code is not production deployment. Other open delivery-plumbing items remain deferred behind the access ladder. +Retained, not discarded. The current staged order puts #28 after #41 and the routing comparison, with its own live private-destination preflight/review/smoke. #29 preparation follows #28's merge; #30 preparation follows #29 approval. They remain unmerged and receive no unrelated feature additions. Their uncertain-send protection can hold a reply rather than retry it; the authorized phase corrections require same-nonce retries, outage fallback and staff-visible held-reply alerts before review. Existing branch code is not production deployment. Other open delivery-plumbing items remain deferred. - [ ] **Deferred:** complete delivery outbox, remaining adapters, bounded retries and uncertain-outcome reconciliation. Never blindly replay a request that may already have replied. - [ ] **Deferred:** encrypted/minimized cumulative case summaries and attempted steps, pending an approved retention/deletion policy. Current durable context is operational categories and sources, not raw conversation content. diff --git a/caseCards.js b/caseCards.js new file mode 100644 index 0000000..a8c3fb1 --- /dev/null +++ b/caseCards.js @@ -0,0 +1,220 @@ +const { createHash } = require('node:crypto'); +const cases = require('./supportCases'); +const NO_MENTIONS = { parse: [], users: [], roles: [], repliedUser: false }; +const STATUS_LABELS = { queued: 'Handoff pending', delivered: 'Awaiting staff acceptance', accepted: 'Accepted · not started', resolved: 'Resolved', closed: 'Closed' }; +const WORK_LABELS = { in_progress: 'In progress', waiting_customer: 'Waiting for customer' }; +const ACCEPTANCE_COPY = { + en: p => `${p} from Omi support has picked up your case. Please keep any follow-up in this thread.`, + es: p => `${p} del equipo de soporte de Omi se ha hecho cargo de tu caso. Continúa en este hilo con cualquier información adicional.`, + zh: p => `${p} 已接手你的 Omi 支持请求。后续补充信息请继续发在这个讨论串。`, + it: p => `${p} del supporto Omi ha preso in carico la tua richiesta. Continua in questa conversazione per eventuali aggiornamenti.`, + pt: p => `${p} da equipe de suporte Omi assumiu seu caso. Continue nesta conversa com qualquer informação adicional.`, + fr: p => `${p} du support Omi a pris en charge votre demande. Continuez dans ce fil pour toute information complémentaire.`, + de: p => `${p} vom Omi-Support hat deinen Fall übernommen. Bitte schreibe weitere Informationen in diesen Thread.`, + tr: p => `${p}, Omi destek ekibinden, talebinizi üstlendi. Ek bilgileri bu başlıkta paylaşabilirsiniz.`, + id: p => `${p} dari tim dukungan Omi sudah menangani kasusmu. Silakan lanjutkan informasi tambahan di utas ini.`, + ja: p => `Omiサポートの ${p} がこの件を担当します。追加情報は引き続きこのスレッドにお送りください。`, + ko: p => `Omi 지원팀의 ${p} 님이 이 요청을 맡았습니다. 추가 정보는 이 스레드에 남겨 주세요.`, + ru: p => `${p} из поддержки Omi принял ваше обращение. Дополнительную информацию отправляйте в этой ветке.`, + 'hi-Latn': p => `Omi support ke ${p} ne aapka mamla le liya hai. Aage ki jaankari isi thread mein bhejein.`, + 'hi-Deva': p => `Omi सहायता टीम के ${p} ने आपका मामला संभाल लिया है। आगे की जानकारी इसी थ्रेड में भेजें।`, +}; +const version = value => [value.status, value.workStatus || '', value.acceptedBy || '', String(value.acceptedAt || ''), String(value.updatedAt || '')].join('|'); +const plain = value => value?.toJSON ? value.toJSON() : JSON.parse(JSON.stringify(value?.data || value)); +const fieldCase = message => (message?.embeds || []).flatMap(e => plain(e)?.fields || []).find(f => f.name === 'Case')?.value; + +async function initSchema(client) { + await client.query(`CREATE TABLE IF NOT EXISTS support_case_cards ( + case_id TEXT NOT NULL REFERENCES support_cases(id) ON DELETE CASCADE, + channel_id TEXT NOT NULL, message_id TEXT NOT NULL, + synced_at TIMESTAMPTZ, synced_status TEXT, synced_work_status TEXT, synced_accepted_by TEXT, + PRIMARY KEY (case_id, message_id) + ); CREATE INDEX IF NOT EXISTS support_case_cards_case_idx ON support_case_cards(case_id);`); +} + +function createMemoryStore(state = { cards: new Map(), locks: new Map() }) { + return { + state, + async remember(caseId, channelId, messageId) { + const key = `${caseId}:${messageId}`; + if (!state.cards.has(key)) state.cards.set(key, { caseId, channelId, messageId }); + }, + async list(caseId) { return [...state.cards.values()].filter(c => c.caseId === caseId).map(c => ({ ...c })); }, + async forget(caseId, messageId) { state.cards.delete(`${caseId}:${messageId}`); }, + async synced(card, value) { const row = state.cards.get(`${card.caseId}:${card.messageId}`); if (row) row.version = version(value); }, + async dirty(service, limit) { + const ids = [...new Set([...state.cards.values()].map(c => c.caseId))]; const result = []; + for (const id of ids) { + const value = await service.getCaseById(id); + if (value && [...state.cards.values()].some(c => c.caseId === id && c.version !== version(value))) result.push(id); + if (result.length >= limit) break; + } + return result; + }, + async lock(caseId, work) { + const prior = state.locks.get(caseId) || Promise.resolve(); + const running = prior.catch(() => {}).then(() => work()); state.locks.set(caseId, running); + try { return await running; } finally { if (state.locks.get(caseId) === running) state.locks.delete(caseId); } + }, + }; +} + +function createPostgresStore(pool) { + return { + async remember(caseId, channelId, messageId) { + await pool.query(`INSERT INTO support_case_cards (case_id, channel_id, message_id) VALUES ($1,$2,$3) + ON CONFLICT (case_id, message_id) DO NOTHING`, [caseId, channelId, messageId]); + }, + async list(caseId, connection = pool) { + const { rows } = await connection.query('SELECT case_id, channel_id, message_id FROM support_case_cards WHERE case_id=$1', [caseId]); + return rows.map(r => ({ caseId: r.case_id, channelId: r.channel_id, messageId: r.message_id })); + }, + async forget(caseId, messageId, connection = pool) { await connection.query('DELETE FROM support_case_cards WHERE case_id=$1 AND message_id=$2', [caseId, messageId]); }, + async synced(card, value, connection = pool) { + // Preserve the database's microseconds rather than rounding its watermark + // through JS Date. Stamp only the same projection that was actually edited; + // a state change during the Discord request must remain pending. + await connection.query(`UPDATE support_case_cards c SET synced_at=s.updated_at, + synced_status=s.status, synced_work_status=s.work_status, synced_accepted_by=s.accepted_by + FROM support_cases s WHERE c.case_id=$1 AND c.message_id=$2 AND s.id=c.case_id + AND date_trunc('milliseconds', s.updated_at)=$3::timestamptz AND s.status=$4 + AND s.work_status IS NOT DISTINCT FROM $5::text AND s.accepted_by IS NOT DISTINCT FROM $6::text`, + [card.caseId, card.messageId, value.updatedAt, value.status, value.workStatus, value.acceptedBy]); + }, + async dirty(_service, limit) { + const { rows } = await pool.query(`SELECT DISTINCT c.case_id FROM support_case_cards c JOIN support_cases s ON s.id=c.case_id + WHERE c.synced_at IS NULL OR s.updated_at > c.synced_at OR s.status IS DISTINCT FROM c.synced_status + OR s.work_status IS DISTINCT FROM c.synced_work_status OR s.accepted_by IS DISTINCT FROM c.synced_accepted_by LIMIT $1`, [limit]); + return rows.map(r => r.case_id); + }, + async lock(caseId, work) { + const connection = await pool.connect(); + try { + await connection.query('BEGIN'); + await connection.query("SET LOCAL lock_timeout='5s'"); + await connection.query('SELECT pg_advisory_xact_lock(912857, hashtext($1))', [caseId]); + const result = await work(connection); await connection.query('COMMIT'); return result; + } catch (err) { await connection.query('ROLLBACK').catch(() => {}); throw err; } + finally { connection.release(); } + }, + }; +} + +function renderCaseCard(message, value) { + const label = value.status === 'accepted' ? WORK_LABELS[value.workStatus] || STATUS_LABELS.accepted : STATUS_LABELS[value.status]; + if (!label || fieldCase(message) !== value.id) throw new Error('Invalid status card'); + const embeds = (message.embeds || []).map(plain); + const embed = embeds.find(e => e.fields?.some(f => f.name === 'Case' && f.value === value.id)); + embed.fields = embed.fields.filter(f => !['Status', 'Picked up by', 'Status updated', 'Case updated'].includes(f.name)); + for (const field of embed.fields) if (field.name === 'Owner') field.name = 'Suggested specialist'; + embed.fields.unshift({ name: 'Status', value: label, inline: true }); + if (value.acceptedBy) { + const label = /^\d{17,20}$/.test(value.acceptedBy) ? `<@${value.acceptedBy}>` : 'Omi support team'; + embed.fields.push({ name: 'Picked up by', value: label, inline: true }); + } + const updated = Date.parse(value.updatedAt); + if (Number.isFinite(updated)) embed.fields.push({ name: 'Case updated', value: ``, inline: true }); + embed.title = `Support case · ${label}`; + embed.color = ['resolved', 'closed'].includes(value.status) ? 0x2ecc71 : value.status === 'accepted' ? 0x3498db : 0xe67e22; + const components = (message.components || []).map(plain).map(row => ({ ...row, + components: (row.components || []).filter(c => !String(c.custom_id || c.customId || '').startsWith('case:')), + })).filter(row => row.components.length); + const controls = []; + if (value.status === 'delivered') controls.push({ type: 2, style: 1, custom_id: `case:accept:${value.id}`, label: 'Accept case' }); + if (value.status === 'accepted') { + if (value.workStatus !== 'in_progress') controls.push({ type: 2, style: 1, custom_id: `case:work:in_progress:${value.id}`, label: value.workStatus ? 'Resume work' : 'Start work' }); + if (value.workStatus !== 'waiting_customer') controls.push({ type: 2, style: 2, custom_id: `case:work:waiting_customer:${value.id}`, label: 'Waiting for customer' }); + } + controls.push({ type: 2, style: 2, custom_id: `case:refresh:${value.id}`, label: 'Refresh status' }); + components.push({ type: 1, components: controls }); + return { embeds, components, allowedMentions: NO_MENTIONS }; +} + +function createCardService(store, { service = cases, log = line => console.error(line) } = {}) { + return { + async rememberCard(caseId, channelId, messageId) { + if (!/^[0-9a-f-]{36}$/i.test(caseId) || !/^\d{17,20}$/.test(String(channelId)) || !/^\d{17,20}$/.test(String(messageId))) return false; + if (!(await service.getCaseById(caseId))) return false; + await store.remember(caseId, channelId, messageId); return true; + }, + async syncCase(client, caseId) { + if (!client?.channels?.fetch || !client.user?.id) return { ok: false, code: 'client_unavailable' }; + return store.lock(caseId, async (connection) => { + const currentService = connection ? cases.createCaseService(cases.createPostgresStore(connection)) : service; + let edited = 0; const cards = await store.list(caseId, connection); + for (const card of cards) { + if (!process.env.STAFF_ALERT_CHANNEL_ID || card.channelId !== process.env.STAFF_ALERT_CHANNEL_ID) continue; + try { + const channel = await client.channels.fetch(card.channelId); + const message = await channel.messages.fetch(card.messageId); + if (message.author?.id !== client.user.id || fieldCase(message) !== caseId) continue; + // Read under the cross-copy edit lock, after the Discord fetch. + const value = await currentService.getCaseById(caseId); if (!value) continue; + await message.edit(renderCaseCard(message, value)); + await store.synced(card, value, connection); edited++; + } catch (err) { + if (Number(err.code) === 10008) await store.forget(caseId, card.messageId, connection); + else { log('[CaseCard] status refresh failed; pending retry'); return { ok: false, code: 'refresh_failed' }; } + } + } + return { ok: edited > 0, code: edited ? 'updated' : 'card_unavailable' }; + }); + }, + async syncPending(client, limit = 25) { + const dirty = await store.dirty(service, Math.min(25, Math.max(1, limit))); + for (const id of dirty) await this.syncCase(client, id); + return dirty.length; + }, + }; +} + +async function notifyAcceptance(client, value, { service = cases, log = line => console.error(line) } = {}) { + if (!client?.channels?.fetch || !value?.acceptedBy || !value.acceptedAt || !value.acceptanceGeneration) return { ok: false, code: 'unavailable' }; + const destination = value.customerThreadId || value.handoffThreadId || value.channelId; + if (!destination || destination === process.env.STAFF_ALERT_CHANNEL_ID) return { ok: false, code: 'invalid_destination' }; + let channel; + try { + channel = await client.channels.fetch(destination); + if (!channel?.send) return { ok: false, code: 'unavailable' }; + if (!value.customerThreadId && !value.handoffThreadId) { + if (!value.sourceMessageId || !channel.messages?.fetch) return { ok: false, code: 'source_unverifiable' }; + const source = await channel.messages.fetch(value.sourceMessageId); + if (source.author?.id !== value.customerId) return { ok: false, code: 'source_unverifiable' }; + } + } catch { return { ok: false, code: 'unavailable' }; } + let claimed; + try { claimed = await service.claimAcceptanceNotice(value.id, value.acceptanceGeneration); } + catch { return { ok: false, code: 'claim_failed' }; } + if (!claimed) { + let current; + try { current = await service.getCaseById(value.id); } catch { return { ok: false, code: 'read_failed' }; } + return current?.acceptanceGeneration === value.acceptanceGeneration && current?.acceptanceNoticeId + ? { ok: true, duplicate: true } : { ok: false, code: 'notice_unconfirmed' }; + } + const person = /^\d{17,20}$/.test(claimed.acceptedBy) ? `<@${claimed.acceptedBy}>` : 'Omi support'; + const language = claimed.context?.replyLanguage || 'en'; + const copy = ACCEPTANCE_COPY[language] || ACCEPTANCE_COPY[language.split('-')[0]] || ACCEPTANCE_COPY.en; + const payload = { content: copy(person), + allowedMentions: NO_MENTIONS, nonce: createHash('sha256').update(`${claimed.id}:accept:${claimed.acceptanceGeneration}`).digest('hex').slice(0, 24), enforceNonce: true }; + if (!value.customerThreadId && !value.handoffThreadId) payload.reply = { messageReference: value.sourceMessageId, failIfNotExists: true }; + let sent; + let current; + try { current = await service.getCaseById(value.id); } catch { return { ok: false, code: 'read_failed' }; } + if (current?.status !== 'accepted' || current.acceptanceGeneration !== claimed.acceptanceGeneration) return { ok: false, code: 'stale_acceptance' }; + try { sent = await channel.send(payload); } + catch { log('[CaseCard] acceptance notice not confirmed; staff follow-up needed'); return { ok: false, code: 'send_unconfirmed' }; } + if (!sent?.id) { log('[CaseCard] acceptance notice receipt missing; staff follow-up needed'); return { ok: false, code: 'send_unconfirmed' }; } + try { await service.saveAcceptanceNotice(value.id, claimed.acceptanceGeneration, sent.id); } + catch { log('[CaseCard] acceptance notice sent; receipt save failed'); return { ok: true, code: 'receipt_save_failed' }; } + return { ok: true, code: 'sent' }; +} + +const memory = createMemoryStore(); let postgres; let injected; +function service() { + const store = injected || (process.env.DATABASE_URL ? (postgres ||= createPostgresStore(require('./db').pool)) : memory); + return createCardService(store); +} +module.exports = { initSchema, createMemoryStore, createPostgresStore, createCardService, renderCaseCard, notifyAcceptance, + setStoreForTests: store => { injected = store; }, + ...Object.fromEntries(['rememberCard', 'syncCase', 'syncPending'].map(name => [name, (...args) => service()[name](...args)])), +}; diff --git a/commands.js b/commands.js index f201f56..f93f141 100644 --- a/commands.js +++ b/commands.js @@ -437,12 +437,13 @@ function canAcceptCase(interaction) { catch { return false; } } -async function handleAcceptCase(interaction, { cases = supportCases } = {}) { +async function handleCaseAction(interaction, { cases = supportCases, cards = require('./caseCards') } = {}) { if (!canAcceptCase(interaction)) { await interaction.reply({ content: 'Only authorized staff can accept a case in the configured staff channel.', flags: MessageFlags.Ephemeral }); return; } - const caseId = String(interaction.customId || '').match(/^case:accept:([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i)?.[1]; + const action = String(interaction.customId || '').match(/^case:(accept|refresh|work:(?:in_progress|waiting_customer)):([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i); + const caseId = action?.[2]; const botId = String(interaction.client?.user?.id || ''); const cardCase = (interaction.message?.embeds || []).flatMap((embed) => embed.fields || embed.data?.fields || []) .find((field) => field.name === 'Case')?.value; @@ -451,15 +452,44 @@ async function handleAcceptCase(interaction, { cases = supportCases } = {}) { return; } await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - const value = await cases.markAccepted(caseId, { staffId: interaction.user.id }); + let value; + try { + value = action[1] === 'accept' ? await cases.markAccepted(caseId, { staffId: interaction.user.id }) + : action[1] === 'refresh' ? await cases.getCaseById(caseId) : await cases.setWorkStatus(caseId, action[1].slice(5)); + } catch { + console.error('[CaseCard] staff action state not confirmed'); + await interaction.editReply({ content: 'I could not confirm the saved case state. Use Refresh status before trying the action again.', allowedMentions: { parse: [] } }); + return; + } + if (!value) { + await interaction.editReply({ content: 'This action is not available in the current case state. Accept a delivered case before starting work.', allowedMentions: { parse: [] } }); + return; + } + let refreshed = false; + try { + await cards.rememberCard(caseId, interaction.channelId, interaction.message.id); + refreshed = Boolean((await cards.syncCase(interaction.client, caseId))?.ok); + } catch { console.error('[CaseCard] staff action refresh failed; pending retry'); } + let notice = null; + if (action[1] === 'accept') { + try { notice = await cards.notifyAcceptance(interaction.client, value, { service: cases }); } + catch { console.error('[CaseCard] acceptance notice not confirmed'); notice = { ok: false }; } + } + const summary = action[1] === 'accept' ? `Case accepted by <@${value.acceptedBy}>.` + : action[1] === 'work:in_progress' ? 'Case marked In progress.' + : action[1] === 'work:waiting_customer' ? 'Case marked Waiting for customer.' : 'Case status refreshed.'; await interaction.editReply({ - content: value?.acceptedBy && value?.acceptedAt - ? `Case accepted by <@${value.acceptedBy}>.` - : 'This case is not awaiting acceptance. It must be delivered before staff can accept it.', + content: [summary, + refreshed ? 'The staff card is up to date.' : 'The card refresh is not confirmed; use Refresh status to check it.', + notice ? notice.ok ? notice.duplicate ? 'No duplicate customer notice was sent.' : 'The customer thread was updated.' + : 'The customer notice was not confirmed. Please reply in their thread yourself.' : '', + ].filter(Boolean).join(' '), allowedMentions: { parse: [] }, }); } +const handleAcceptCase = (interaction, options) => handleCaseAction(interaction, options); + async function handleInteraction(interaction) { try { if (await orderFlow.handleOrderInteraction(interaction)) return; @@ -475,8 +505,8 @@ async function handleInteraction(interaction) { await handleRating(interaction); return; } - if (interaction.isButton?.() && String(interaction.customId || '').startsWith('case:accept:')) { - await handleAcceptCase(interaction); + if (interaction.isButton?.() && /^case:(?:accept|refresh|work:)/.test(String(interaction.customId || ''))) { + await handleCaseAction(interaction); return; } if (interaction.isButton?.() && String(interaction.customId || '').startsWith('file:')) { @@ -532,6 +562,7 @@ module.exports = { handleRating, canAcceptCase, handleAcceptCase, + handleCaseAction, handleInteraction, notifyLinkedThreads, }; diff --git a/db.js b/db.js index fca4d83..45eea0e 100644 --- a/db.js +++ b/db.js @@ -99,6 +99,7 @@ async function initSchema() { `); await require('./supportRuntime').initSchema(client); await require('./supportCases').initSchema(client); + await require('./caseCards').initSchema(client); await require('./supportIdentityStore').initSchema(client); const chunks = await client.query(`SELECT count(*)::int AS n FROM doc_chunks`); const pages = await client.query(`SELECT count(*)::int AS n FROM doc_pages`); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 41601f6..39afe99 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Omi Support Bot Architecture -Omi Support Bot solves documented problems and routes unresolved cases toward a person. Customer reassurance must describe actions and evidence the system can verify. The workflow foundation described below is implemented on the working branch; production rollout is tracked separately in [the roadmap](../ROADMAP.md). The recording-diagnostics gateway remains a target design, not newly granted access. The existing official-source answer and review pipeline remains in place. +Omi Support Bot solves documented problems and routes unresolved cases toward a person. Customer reassurance must describe actions and evidence the system can verify. The workflow foundation and approved Phase 2 fixes are deployed at main `8321439`; Report 1 passed and the Phase 2 smoke/report evidence is recorded in [the roadmap](../ROADMAP.md). Live staff-card status/acceptance notices described below are draft-branch work, not newly deployed behavior. The recording-diagnostics gateway remains a target design, not newly granted access. The existing official-source answer and review pipeline remains in place. ## Current capabilities and gaps @@ -80,6 +80,14 @@ Use the exact verified order privately when available. Refund, replacement, bill The current case states are `queued`, `delivered`, `accepted`, `resolved`, and `closed`, with an explicit reopening transition. An authenticated staff acceptance records its actor and time; delivery alone does not count. Delivery failure is an attempt outcome, not resolution. Staff get the current intake and a link to the conversation; a retained cumulative summary and a distinct `awaiting_customer` state are follow-up work. Reminders and target response times belong to a real staffed queue; do not publish an SLA before an owner commits to it. +### Live staff cards and acceptance updates (draft) + +Keep the same five lifecycle states and active-case uniqueness rule. An accepted case has an optional `work_status` (`in_progress` or `waiting_customer`), set only through authorized staff actions. No activity/timer heuristic means a person is working. Reopening clears the previous acceptance/work state; the next acceptance gets its own generation ID while repeated clicks in that cycle retain the first actor/time. + +`caseCards.js` projects this state into existing bot-authored private staff cards. `support_case_cards` stores only case/channel/message references and synchronization metadata, never intake text. State-change events update immediately; a bounded startup/30-second sweep repairs unsynchronized cards after restarts or transient failures. A per-case Postgres advisory lock serializes cross-copy edits, using the locked connection for all database reads/writes. Edits preserve intake and engineering controls; deleted cards are retired instead of recreated. Public copies strip every case control and private status/identity reference. + +Acceptance posts a short localized update naming the actual acceptor in the bound customer destination, with all mentions disabled and no ETA or investigation/resolution claim. Shared-channel replies verify the original message's customer before sending. A durable per-acceptance-generation claim prevents repeated clicks from posting again; the Discord nonce is additional short-window deduplication, not a global exactly-once guarantee. Every unconfirmed notification result is surfaced in the accepting staff member's ephemeral response, explicitly asking them to reply in the customer's thread; a log line alone is not sufficient. Core case acceptance is not undone. This is a scoped case-state notification, not a second general delivery ledger or an extension of #29/#30. + ## Communication policy Lead with acknowledgment and a useful action, not repeated capability disclaimers. Use one clear next step and the customer's language. State important limits briefly without concealing uncertainty or data-loss risk. Never invent an investigation, staff receipt, acceptance, fix, deadline or guaranteed outcome. diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index d932d42..82ac1db 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -1,6 +1,6 @@ # Omi Support Bot Implementation Checklist -Implement the [architecture](ARCHITECTURE.md) incrementally, retaining the working answer/review, official retrieval, public commands and private staff delivery. [The roadmap](../ROADMAP.md) is the authoritative progress record. The first workflow foundation is implemented and tested on the branch; rollout and future diagnostics remain separate. Each runtime change needs regression checks and release verification. No cloud access grants accompany this foundation. +Implement the [architecture](ARCHITECTURE.md) incrementally, retaining the working answer/review, official retrieval, public commands and private staff delivery. [The roadmap](../ROADMAP.md) is the authoritative progress record. The foundation and approved Phase 2 fixes are deployed at `8321439`; Report 1 passed and Report 2 awaits grading. Live staff-card status and acceptance notices are implemented only on a separate draft branch. Each runtime change needs regression checks and release verification. No cloud access grants accompany this foundation. ## Release and security prerequisites @@ -18,6 +18,10 @@ Implement the [architecture](ARCHITECTURE.md) incrementally, retaining the worki - [x] Stop new work on shutdown; drain accepted work and queue unstarted requests. Coordinate overlapping copies through Postgres claims. Started requests with uncertain delivery remain an operator-reconciliation task. - [x] Make `/done`, ratings/reopening and Telegram delivery operate on the same case state. Failed customer delivery must not resolve a case. - [x] Record real staff acceptance through an authenticated private-card action; preserve the first accepting actor/time. +- [x] Draft: project accepted/work/waiting/resolved/closed state into the existing private cards; persist message references, serialize edits and recover missed updates after restart. Retire deleted cards without reposting. +- [x] Draft: send one localized customer-thread acceptance notice naming the actual staff member; retain uncertainty and direct staff to Jump on notification failure. Clear prior ownership on a new reopened acceptance cycle. +- [x] Phase 3 correction: every unconfirmed acceptance notification explicitly tells staff to reply in the customer's thread, in an ephemeral response. Confirmed boot/30-second sweep wiring and stale-card repair after a failed edit; Phase 2 main merged without a rebase. Combined unit suite 661 pass, offline 8/8. +- [ ] Review and run the full protected gate for the new canned acceptance wording, then smoke-test real accept/start/wait/close/reopen and restart recovery. No deployment or protected-file read accompanies this implementation. - [ ] Persist staff-approved issue drafts and action results so a restart does not invalidate the File workflow. Decide whether approved customer updates should reach linked GitHub issues; do not document an inactive path as working. - [x] Correct exact order targeting: an explicit number not found stays a miss, never another order. Move the retired Shopify API version to a supported tested contract and verify the actual response version. Shopify documents [version fallback](https://shopify.dev/docs/api/usage/versioning) and [order-access scopes](https://shopify.dev/docs/api/usage/access-scopes). - [ ] Add bounded historical-order pagination and explain accessible history privately. @@ -31,7 +35,7 @@ Acceptance: restart during OTP, overlapping copies, provider slowdown, failed/un - [ ] For each new adapter, define a fixed allowlist, customer/case authorization where needed, consent, limits, kill switch and metadata-only audit; validate synthetically, then pilot in the private staff card before reviewed customer enablement. - [ ] Extend order facts only through exact-owner-verified private/ephemeral delivery, never a normal customer-thread message or model prompt. Keep existing `/order` self-service enabled; do not add order/account writes. - [ ] Obtain backend-team agreement and privacy review before linked-account metadata/sign-in implementation. Define available/stale/unknown states for device/version/sync/plan and any provable unsynced count; exclude content and unrelated customers. -- [ ] Keep #29/#30 unmerged and unchanged until the two-week post-#26 duplicate/uncertain-send go/no-go. Retain their branches; release separately only if approved. Other open queue/monitoring/retention work follows access steps 2–3. +- [ ] Follow the latest staged order: Phase 2 → reviewed #41 → routing/intake comparison → reviewed/private-preflight #28 → reviewed #29 → reviewed #30. The previous two-week go/no-go order is superseded, not a reason to bypass review. Keep deployments separate, with 24 clean hours unless Aryan explicitly says go; add only the authorized phase corrections. Other open queue/monitoring/retention work remains deferred. ## Phase 2 Verified metadata diagnostics diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index 19c924e..cec5f36 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -22,6 +22,10 @@ Full staff cards contain a case reference, the source message link and the safe An authorized staff member can use **Accept case** in the configured staff channel. Acceptance records the first accepting staff identity/time; a delivered card is not accepted merely because it exists. Replies in the staff channel still do not reach the customer. Follow the card's Jump link and reply in the customer's thread, or to the linked customer message when there is no thread. +**Draft live-card upgrade, not deployed:** acceptance updates the shared card and posts one localized, no-ping notice naming the acceptor in the customer's thread. **Start work / Resume work** and **Waiting for customer** are explicit staff-controlled progress, not automatic inference. **Refresh status** rereads the durable case; `/done`, Telegram resolution and customer-authorized reopening update the same cards. The suggested specialist is not the actual accepting owner. Reopening clears old acceptance so a new staff member can pick up the next cycle. + +The draft repairs missed card edits at startup and every 30 seconds, bounded to 25 cases per sweep. It never recreates a deleted card. An unconfirmed customer notification gives the accepting staff member this ephemeral instruction: “The customer notice was not confirmed. Please reply in their thread yourself.” Use Jump and follow up yourself; do not repeat Accept to replay an uncertain send. A persisted acceptance does not prove a thread notice was delivered. No promised response time or recovery follows from any status. + Use `/done` in the resolved customer/Handoff thread. The customer notice must send before the database case and linked escalation close. “Still need help” is restricted to the owning customer and reopens the linked case/thread when possible. A newer active case prevents an old rating from replacing it. The closing card separates internal resolution feedback from an optional honest app review. Both store links are shown to everyone before any feedback selection; never require a positive vote or a particular star rating. App Store opens Omi's review destination. Google Play opens its official listing, where the customer selects “Write a review.” Store link clicks do not change support dashboard counts, and the bot cannot verify that a public review was submitted. If feedback storage fails, the response must say so; unavailable aggregate counts are not zero. diff --git a/handoff.js b/handoff.js index b59c4e3..ae7d02f 100644 --- a/handoff.js +++ b/handoff.js @@ -753,8 +753,10 @@ function formatStaffTicket({ ]; } if (staffOnly && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(caseId || ''))) { + embed.fields.unshift({ name: 'Status', value: 'Awaiting staff acceptance', inline: true }); if (!discord.components) discord.components = [{ type: 1, components: [] }]; discord.components[0].components.push({ type: 2, style: 1, custom_id: `case:accept:${caseId}`, label: 'Accept case' }); + discord.components[0].components.push({ type: 2, style: 2, custom_id: `case:refresh:${caseId}`, label: 'Refresh status' }); } return { @@ -825,14 +827,18 @@ async function postHandoffThread(message, payload, meta = {}) { return thread; } -async function sendToStaffChannel(client, payload, customerChannelId) { +async function sendToStaffChannel(client, payload, customerChannelId, caseId) { const staffId = process.env.STAFF_ALERT_CHANNEL_ID; if (!staffId || !client?.channels?.fetch) return false; if (String(staffId) === String(customerChannelId || '')) return false; const ch = await client.channels.fetch(staffId); if (!ch?.isTextBased?.() || typeof ch.send !== 'function') return false; await assertCurrentOwnership(); - await ch.send(payload); + const sent = await ch.send(payload); + if (caseId && sent?.id) { + try { await require('./caseCards').rememberCard(caseId, ch.id || staffId, sent.id); } + catch { console.error('[CaseCard] receipt persistence failed; staff can repair with Refresh status'); } + } return true; } @@ -853,7 +859,7 @@ function publicHandoffDiscord(discord) { const payload = { embeds: [ { - title: embed.title, + title: /^Support case ·/.test(String(embed.title || '')) ? 'Needs a human' : embed.title, color: embed.color, description: PUBLIC_HELP_CARD_DESCRIPTION, fields, @@ -867,7 +873,7 @@ function publicHandoffDiscord(discord) { }; if (Array.isArray(discord?.components) && discord.components.length) { const publicRows = discord.components.map((row) => ({ ...row, - components: (row.components || []).filter((component) => !String(component.custom_id || component.customId || '').startsWith('case:accept:')), + components: (row.components || []).filter((component) => !String(component.custom_id || component.customId || '').startsWith('case:')), })).filter((row) => row.components.length); if (publicRows.length) payload.components = publicRows; } @@ -916,7 +922,7 @@ async function notifyStaff({ let visibleCard = null; try { - if (await sendToStaffChannel(client, ticket.discord, channelId)) { + if (await sendToStaffChannel(client, ticket.discord, channelId, caseId)) { markHandedOff(channelId, userId, true); await assertCurrentOwnership(); await telegram.sendEscalation(ticket.plain); diff --git a/index.js b/index.js index a39999f..510543b 100644 --- a/index.js +++ b/index.js @@ -54,6 +54,7 @@ const { newUsageCounters, addStageUsage, timingLogLine } = require('./timing'); const { approvedReview, reviewWithSecondLook } = require('./reviewDecision'); const triage = require('./triage'); const supportCases = require('./supportCases'); +const caseCards = require('./caseCards'); const { SupportRuntime, PostgresRuntimeStore, MemoryRuntimeStore, questionFingerprint, assertCurrentOwnership, markCurrentReplySent, isLeaseLost } = require('./supportRuntime'); const HELP_FORUM_CHANNEL_ID = process.env.HELP_FORUM_CHANNEL_ID; @@ -67,6 +68,8 @@ let coordinationReady = !dbReady; let httpServer; let shutdownPromise; let recoveryTimer; +let caseCardTimer; +let unsubscribeCaseCards; const BOT_REPLY_MEMORY_MAX = 2000; const BOT_REPLY_MEMORY_MS = 24 * 60 * 60 * 1000; const botReplyOwners = new Map(); @@ -1017,7 +1020,10 @@ async function answerMessage(message, { directHistory = [] } = {}) { let supportCase = await supportCases.getOrCreateCase({ channelId: previousCase?.channelId || channel.id, customerId: message.author.id, customerThreadId: channel.isThread?.() ? channel.id : undefined, + sourceMessageId: message.id, context: { area: triaged.area, lane: triaged.lane, dataLossRisk, needsPerson: true, + replyLanguage: searchPlan.replyLanguage === 'hi' + ? /\p{Script=Devanagari}/u.test(asked || question) ? 'hi-Deva' : 'hi-Latn' : searchPlan.replyLanguage || 'en', attachmentCount: message.attachments?.size || 0, reasonCodes: dataLossRisk ? ['data_loss_risk'] : ['needs_person'] }, sources: cleanAnswer.match(/https:\/\/[^\s)>]+/g) || [], }); @@ -1238,6 +1244,17 @@ async function nickOmiSupport(client) { } client.once(Events.ClientReady, async () => { + const syncCards = (caseId) => runtime.track(() => caseId ? caseCards.syncCase(client, caseId) : caseCards.syncPending(client)) + .catch(() => console.error('[CaseCard] refresh worker failed; pending retry')); + unsubscribeCaseCards = supportCases.onChange(syncCards); + let refreshingCards = false; + const repairCards = async () => { + if (refreshingCards || runtime.stopping) return; + refreshingCards = true; + try { await syncCards(); } finally { refreshingCards = false; } + }; + caseCardTimer = setInterval(repairCards, 30000); caseCardTimer.unref(); + repairCards(); recoveryTimer = setInterval(() => { runtime.track(() => runtime.recoverQueued(recoverQueuedRequest)).catch(() => console.error('[SupportRuntime] queued request lookup failed')); }, 1000); @@ -1318,9 +1335,11 @@ async function shutdown(signal) { console.log(`[Bot] Received ${signal}, draining replies...`); runtime.stopAccepting(); clearInterval(recoveryTimer); + clearInterval(caseCardTimer); telegram.stopPolling(); const [drained, telegramDrained] = await Promise.all([runtime.drain(), telegram.drainPolling()]); console.log(`[SupportRuntime] drain completed=${drained} telegram_completed=${telegramDrained}`); + unsubscribeCaseCards?.(); client.destroy(); if (dbReady) await db.shutdown(); httpServer?.close(); diff --git a/scripts/verify-support-db.js b/scripts/verify-support-db.js index bcda981..0e23107 100644 --- a/scripts/verify-support-db.js +++ b/scripts/verify-support-db.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict'); const { randomUUID } = require('node:crypto'); const runtime = require('../supportRuntime'); const cases = require('../supportCases'); +const cards = require('../caseCards'); const { SupportIdentityStore, initSchema: initIdentity } = require('../supportIdentityStore'); async function isolatedPool() { @@ -34,10 +35,25 @@ async function isolatedPool() { } async function verify(pool) { + const prefix = `test-${randomUUID()}`; + // Exercise an additive upgrade without dropping existing rows or state. + await pool.query(`CREATE TABLE IF NOT EXISTS support_cases ( + id TEXT PRIMARY KEY, channel_id TEXT NOT NULL, customer_id TEXT NOT NULL, + customer_thread_id TEXT, handoff_thread_id TEXT, escalation_id INTEGER, + status TEXT NOT NULL CHECK (status IN ('queued','delivered','accepted','resolved','closed')), + context JSONB NOT NULL DEFAULT '{}', sources JSONB NOT NULL DEFAULT '[]', + delivery_id TEXT, delivery_destination TEXT, accepted_by TEXT, accepted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`); + const legacyId = randomUUID(); + await pool.query(`INSERT INTO support_cases (id, channel_id, customer_id, status, accepted_by, accepted_at) + VALUES ($1,$2,$3,'accepted',$4,NOW())`, [legacyId, `${prefix}-legacy`, `${prefix}-legacy-customer`, `${prefix}-legacy-staff`]); for (let count = 0; count < 2; count++) { - await runtime.initSchema(pool); await cases.initSchema(pool); await initIdentity(pool); + await runtime.initSchema(pool); await cases.initSchema(pool); await cards.initSchema(pool); await initIdentity(pool); } - const prefix = `test-${randomUUID()}`; + const legacy = (await pool.query('SELECT * FROM support_cases WHERE id=$1', [legacyId])).rows[0]; + assert.equal(legacy.status, 'accepted'); assert.equal(legacy.accepted_by, `${prefix}-legacy-staff`); + assert.equal(legacy.acceptance_notice_started, false); assert.equal(legacy.work_status, null); const store = new runtime.PostgresRuntimeStore(pool); const job = { messageId: `${prefix}-message`, caseKey: `${prefix}:alice`, owner: `${prefix}-owner`, fingerprint: runtime.questionFingerprint('synthetic private input') }; const duplicate = { ...job, owner: `${prefix}-second` }; @@ -98,8 +114,41 @@ async function verify(pool) { const accepted = await service.markAccepted(first.id, { staffId: `${prefix}-staff` }); assert.equal(accepted.acceptedBy, `${prefix}-staff`); assert.equal((await service.markAccepted(first.id, { staffId: `${prefix}-other-staff` })).acceptedBy, `${prefix}-staff`); - await service.resolveCase(first.id, { close: true, confirmed: true }); - assert.equal((await service.reopenByThread(`${prefix}-handoff`, aliceInput.customerId)).status, 'queued'); + await service.setWorkStatus(first.id, 'in_progress'); + assert.equal((await service.getCaseById(first.id)).workStatus, 'in_progress'); + const claimedNotices = await Promise.all([service.claimAcceptanceNotice(first.id, accepted.acceptanceGeneration), service.claimAcceptanceNotice(first.id, accepted.acceptanceGeneration)]); + assert.equal(claimedNotices.filter(Boolean).length, 1); + await service.saveAcceptanceNotice(first.id, accepted.acceptanceGeneration, `${prefix}-acceptance-receipt`); + assert.equal((await service.getCaseById(first.id)).acceptanceNoticeId, `${prefix}-acceptance-receipt`); + const cardStore = cards.createPostgresStore(pool); + // Native Postgres retains sub-millisecond precision; JS Date does not. + await pool.query("UPDATE support_cases SET updated_at=date_trunc('milliseconds', NOW()) + INTERVAL '456 microseconds' WHERE id=$1", [first.id]); + const cardService = cards.createCardService(cardStore, { service }); + const staffChannelId = '234567890123456789'; const staffCardId = '534567890123456789'; + const previousStaffChannel = process.env.STAFF_ALERT_CHANNEL_ID; process.env.STAFF_ALERT_CHANNEL_ID = staffChannelId; + let lastCard; + try { + await cardService.rememberCard(first.id, staffChannelId, staffCardId); + const message = { author: { id: 'fixture-bot' }, embeds: [{ fields: [{ name: 'Case', value: first.id }] }], + edit: async payload => { lastCard = payload; message.embeds = payload.embeds; message.components = payload.components; } }; + const client = { user: { id: 'fixture-bot' }, channels: { fetch: async () => ({ messages: { fetch: async () => message } }) } }; + assert.equal(await cardService.syncPending(client), 1); + assert.match(lastCard.embeds[0].title, /In progress/); + assert.equal(await cardService.syncPending(client), 0); + const staleSnapshot = await service.getCaseById(first.id); + await service.setWorkStatus(first.id, 'waiting_customer'); + await cardStore.synced({ caseId: first.id, messageId: staffCardId }, staleSnapshot); + assert.ok((await cardStore.dirty(service, 25)).includes(first.id)); + await cardService.syncPending(client); assert.match(lastCard.embeds[0].title, /Waiting for customer/); + await service.resolveCase(first.id, { close: true, confirmed: true }); + await cardService.syncPending(client); assert.match(lastCard.embeds[0].title, /Closed/); + const reopened = await service.reopenByThread(`${prefix}-handoff`, aliceInput.customerId); + assert.equal(reopened.status, 'queued'); assert.equal(reopened.acceptedBy, null); assert.equal(reopened.workStatus, null); + await cardService.syncPending(client); assert.match(lastCard.embeds[0].title, /Handoff pending/); + assert.equal(await service.claimAcceptanceNotice(first.id, accepted.acceptanceGeneration), null); + } finally { + if (previousStaffChannel === undefined) delete process.env.STAFF_ALERT_CHANNEL_ID; else process.env.STAFF_ALERT_CHANNEL_ID = previousStaffChannel; + } const identity = new SupportIdentityStore({ pool, key: Buffer.alloc(32, 17) }); const restarted = new SupportIdentityStore({ pool, key: Buffer.alloc(32, 17) }); @@ -113,7 +162,7 @@ async function verify(pool) { const encrypted = (await pool.query('SELECT * FROM support_verified_emails WHERE discord_user_id=$1', [user])).rows[0]; assert.doesNotMatch(JSON.stringify(encrypted), /fixture@example\.test/); await restarted.revoke(user); assert.equal(await identity.getBinding(user), null); - console.log('Support database integration: schema, claims, lease recovery, queue, case isolation, verification and revocation passed'); + console.log('Support database integration: schema upgrade, claims, lease recovery, queue, case isolation, live cards, acceptance notices, verification and revocation passed'); } (async () => { diff --git a/supportCases.js b/supportCases.js index 0a7ccea..287cc02 100644 --- a/supportCases.js +++ b/supportCases.js @@ -5,6 +5,15 @@ const STATUSES = ['queued', 'delivered', 'accepted', 'resolved', 'closed']; const ACTIVE = ['queued', 'delivered', 'accepted']; const AREAS = new Set(['general', 'shop', 'privacy', 'app', 'desktop', 'device', 'hardware', 'software', 'account', 'firmware', 'integrations', 'other', 'unknown']); const REASONS = new Set(['needs_person', 'low_confidence', 'data_loss_risk', 'delivery_failed', 'customer_followup', 'customer_still_needs_help', 'provider_error', 'no_official_source', 'order_lookup', 'billing', 'refund', 'replacement', 'account', 'privacy', 'bug']); +const WORK_STATUSES = new Set(['in_progress', 'waiting_customer']); +const changes = new (require('node:events').EventEmitter)(); +function changed(value) { + if (value) for (const listener of changes.listeners('change')) { + try { Promise.resolve(listener(value.id)).catch(() => console.error('[CaseCard] status refresh scheduling failed')); } + catch { console.error('[CaseCard] status refresh scheduling failed'); } + } + return value; +} function identifier(value, required = false) { const out = String(value || '').trim(); @@ -25,6 +34,7 @@ function safeContext(value = {}) { for (const key of ['dataLossRisk', 'needsPerson']) { if (typeof value[key] === 'boolean') result[key] = value[key]; } + if (typeof value.replyLanguage === 'string' && /^[a-z]{2,3}(?:-[A-Za-z]{4})?$/.test(value.replyLanguage)) result.replyLanguage = value.replyLanguage; if (Number.isInteger(value.attachmentCount) && value.attachmentCount >= 0) result.attachmentCount = Math.min(value.attachmentCount, 100); return result; } @@ -58,6 +68,9 @@ function rowCase(row) { escalationId: row.escalation_id, status: row.status, context: row.context || {}, sources: row.sources || [], deliveryId: row.delivery_id, deliveryDestination: row.delivery_destination, acceptedBy: row.accepted_by, acceptedAt: row.accepted_at, + sourceMessageId: row.source_message_id, workStatus: row.work_status, + acceptanceGeneration: row.acceptance_generation, + acceptanceNoticeStarted: row.acceptance_notice_started, acceptanceNoticeId: row.acceptance_notice_id, createdAt: row.created_at, updatedAt: row.updated_at, }; } @@ -83,6 +96,11 @@ async function initSchema(client) { ); ALTER TABLE support_cases ADD COLUMN IF NOT EXISTS accepted_by TEXT; ALTER TABLE support_cases ADD COLUMN IF NOT EXISTS accepted_at TIMESTAMPTZ; + ALTER TABLE support_cases ADD COLUMN IF NOT EXISTS source_message_id TEXT; + ALTER TABLE support_cases ADD COLUMN IF NOT EXISTS work_status TEXT CHECK (work_status IN ('in_progress', 'waiting_customer')); + ALTER TABLE support_cases ADD COLUMN IF NOT EXISTS acceptance_notice_started BOOLEAN NOT NULL DEFAULT false; + ALTER TABLE support_cases ADD COLUMN IF NOT EXISTS acceptance_notice_id TEXT; + ALTER TABLE support_cases ADD COLUMN IF NOT EXISTS acceptance_generation TEXT; CREATE UNIQUE INDEX IF NOT EXISTS support_cases_active_scope_idx ON support_cases(channel_id, customer_id) WHERE status IN ('queued', 'delivered', 'accepted'); CREATE INDEX IF NOT EXISTS support_cases_customer_thread_idx ON support_cases(customer_thread_id); @@ -94,13 +112,14 @@ function createPostgresStore(client) { return { async getOrCreate(input) { const { rows } = await client.query(` - INSERT INTO support_cases (id, channel_id, customer_id, customer_thread_id, status, context, sources) - VALUES ($1, $2, $3, $4, 'queued', $5::jsonb, $6::jsonb) + INSERT INTO support_cases (id, channel_id, customer_id, customer_thread_id, status, context, sources, source_message_id) + VALUES ($1, $2, $3, $4, 'queued', $5::jsonb, $6::jsonb, $7) ON CONFLICT (channel_id, customer_id) WHERE status IN ('queued', 'delivered', 'accepted') DO UPDATE SET context = support_cases.context || EXCLUDED.context, sources = CASE WHEN jsonb_array_length(EXCLUDED.sources) > 0 THEN EXCLUDED.sources ELSE support_cases.sources END, - customer_thread_id = COALESCE(EXCLUDED.customer_thread_id, support_cases.customer_thread_id), updated_at = NOW() - RETURNING *`, [input.id, input.channelId, input.customerId, input.customerThreadId, JSON.stringify(input.context), JSON.stringify(input.sources)]); + customer_thread_id = COALESCE(EXCLUDED.customer_thread_id, support_cases.customer_thread_id), + source_message_id = COALESCE(support_cases.source_message_id, EXCLUDED.source_message_id), updated_at = NOW() + RETURNING *`, [input.id, input.channelId, input.customerId, input.customerThreadId, JSON.stringify(input.context), JSON.stringify(input.sources), input.sourceMessageId]); return rowCase(rows[0]); }, async getActive(channelId, customerId) { @@ -111,14 +130,30 @@ function createPostgresStore(client) { const { rows } = await client.query('SELECT * FROM support_cases WHERE id = $1', [caseId]); return rowCase(rows[0]); }, - async accept(caseId, staffId) { + async accept(caseId, staffId, generation) { const { rows } = await client.query(`UPDATE support_cases SET status = 'accepted', - accepted_by = COALESCE(accepted_by, $2), accepted_at = COALESCE(accepted_at, NOW()), updated_at = NOW() - WHERE id = $1 AND status = 'delivered' RETURNING *`, [caseId, staffId]); + accepted_by = COALESCE(accepted_by, $2), accepted_at = COALESCE(accepted_at, NOW()), + acceptance_generation = $3, updated_at = NOW() + WHERE id = $1 AND status = 'delivered' RETURNING *`, [caseId, staffId, generation]); if (rows.length) return rowCase(rows[0]); const existing = await client.query("SELECT * FROM support_cases WHERE id = $1 AND status = 'accepted'", [caseId]); return rowCase(existing.rows[0]); }, + async setWorkStatus(caseId, workStatus) { + const { rows } = await client.query(`UPDATE support_cases SET work_status = $2, updated_at = NOW() + WHERE id = $1 AND status = 'accepted' RETURNING *`, [caseId, workStatus]); + return rowCase(rows[0]); + }, + async claimAcceptanceNotice(caseId, generation) { + const { rows } = await client.query(`UPDATE support_cases SET acceptance_notice_started = true + WHERE id = $1 AND status = 'accepted' AND accepted_by IS NOT NULL AND accepted_at IS NOT NULL + AND acceptance_notice_started = false AND acceptance_generation = $2 RETURNING *`, [caseId, generation]); + return rowCase(rows[0]); + }, + async saveAcceptanceNotice(caseId, generation, messageId) { + await client.query(`UPDATE support_cases SET acceptance_notice_id = $3 + WHERE id = $1 AND acceptance_generation = $2 AND acceptance_notice_started = true`, [caseId, generation, messageId]); + }, async getByThread(threadId, customerId) { const { rows } = await client.query(`SELECT * FROM support_cases WHERE (customer_thread_id = $1 OR handoff_thread_id = $1 OR (channel_id = $1 AND customer_id = $2)) @@ -137,6 +172,12 @@ function createPostgresStore(client) { async transition(caseId, status, from, delivery = {}) { try { const { rows } = await client.query(`UPDATE support_cases AS current_case SET status = $2, + accepted_by = CASE WHEN $2 = 'queued' THEN NULL ELSE accepted_by END, + accepted_at = CASE WHEN $2 = 'queued' THEN NULL ELSE accepted_at END, + work_status = CASE WHEN $2 = 'queued' THEN NULL ELSE work_status END, + acceptance_notice_started = CASE WHEN $2 = 'queued' THEN false ELSE acceptance_notice_started END, + acceptance_notice_id = CASE WHEN $2 = 'queued' THEN NULL ELSE acceptance_notice_id END, + acceptance_generation = CASE WHEN $2 = 'queued' THEN NULL ELSE acceptance_generation END, delivery_id = COALESCE($4, delivery_id), delivery_destination = COALESCE($5, delivery_destination), updated_at = NOW() WHERE current_case.id = $1 AND current_case.status = ANY($3::text[]) AND ($2 NOT IN ('queued', 'delivered', 'accepted') OR NOT EXISTS ( @@ -163,9 +204,10 @@ function createMemoryStore(state = { cases: new Map() }) { Object.assign(existing.context, input.context); if (input.sources.length) existing.sources = input.sources; if (input.customerThreadId) existing.customerThreadId = input.customerThreadId; + existing.sourceMessageId ||= input.sourceMessageId; return clone(existing); } - const value = { ...input, handoffThreadId: null, escalationId: null, status: 'queued', deliveryId: null, deliveryDestination: null, acceptedBy: null, acceptedAt: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; + const value = { ...input, handoffThreadId: null, escalationId: null, status: 'queued', deliveryId: null, deliveryDestination: null, acceptedBy: null, acceptedAt: null, acceptanceGeneration: null, workStatus: null, acceptanceNoticeStarted: false, acceptanceNoticeId: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; state.cases.set(value.id, value); return clone(value); }, @@ -173,17 +215,35 @@ function createMemoryStore(state = { cases: new Map() }) { return clone([...state.cases.values()].find((item) => item.channelId === channelId && item.customerId === customerId && ACTIVE.includes(item.status))); }, async getById(caseId) { return clone(state.cases.get(caseId)); }, - async accept(caseId, staffId) { + async accept(caseId, staffId, generation) { const value = state.cases.get(caseId); if (!value || !['delivered', 'accepted'].includes(value.status)) return null; if (value.status === 'delivered') { value.status = 'accepted'; value.acceptedBy ||= staffId; value.acceptedAt ||= new Date().toISOString(); + value.acceptanceGeneration = generation; value.updatedAt = new Date().toISOString(); } return clone(value); }, + async setWorkStatus(caseId, workStatus) { + const value = state.cases.get(caseId); + if (!value || value.status !== 'accepted') return null; + value.workStatus = workStatus; + value.updatedAt = new Date().toISOString(); + return clone(value); + }, + async claimAcceptanceNotice(caseId, generation) { + const value = state.cases.get(caseId); + if (!value || value.status !== 'accepted' || !value.acceptedBy || !value.acceptedAt || value.acceptanceNoticeStarted || value.acceptanceGeneration !== generation) return null; + value.acceptanceNoticeStarted = true; + return clone(value); + }, + async saveAcceptanceNotice(caseId, generation, messageId) { + const value = state.cases.get(caseId); + if (value && value.acceptanceGeneration === generation && value.acceptanceNoticeStarted) value.acceptanceNoticeId = messageId; + }, async getByThread(threadId, customerId) { const matches = [...state.cases.values()].filter((item) => (!customerId || item.customerId === customerId) && @@ -202,6 +262,11 @@ function createMemoryStore(state = { cases: new Map() }) { if (!value || !from.includes(value.status)) return null; if (ACTIVE.includes(status) && [...state.cases.values()].some((other) => other.id !== value.id && other.channelId === value.channelId && other.customerId === value.customerId && ACTIVE.includes(other.status))) return null; value.status = status; + if (status === 'queued') { + value.acceptedBy = null; value.acceptedAt = null; value.workStatus = null; + value.acceptanceNoticeStarted = false; value.acceptanceNoticeId = null; + value.acceptanceGeneration = null; + } if (delivery.messageId) value.deliveryId = delivery.messageId; if (delivery.destination) value.deliveryDestination = delivery.destination; value.updatedAt = new Date().toISOString(); @@ -213,7 +278,7 @@ function createMemoryStore(state = { cases: new Map() }) { function createCaseService(store) { return { async getOrCreateCase(input) { - return store.getOrCreate({ id: randomUUID(), channelId: identifier(input.channelId, true), customerId: identifier(input.customerId, true), customerThreadId: identifier(input.customerThreadId), context: safeContext(input.context), sources: safeSources(input.sources) }); + return store.getOrCreate({ id: randomUUID(), channelId: identifier(input.channelId, true), customerId: identifier(input.customerId, true), customerThreadId: identifier(input.customerThreadId), sourceMessageId: identifier(input.sourceMessageId), context: safeContext(input.context), sources: safeSources(input.sources) }); }, getActiveCase: (channelId, customerId) => store.getActive(identifier(channelId, true), identifier(customerId, true)), getCaseById: (caseId) => store.getById(identifier(caseId, true)), @@ -226,24 +291,30 @@ function createCaseService(store) { if (!delivery.messageId && delivery.confirmed !== true) throw new Error('Confirmed delivery is required'); return store.transition(identifier(caseId, true), 'delivered', ['queued', 'delivered'], { messageId: identifier(delivery.messageId), destination: identifier(delivery.destination), - }); + }).then(changed); + }, + markAccepted(caseId, { staffId } = {}) { return store.accept(identifier(caseId, true), identifier(staffId, true), randomUUID()).then(changed); }, + setWorkStatus(caseId, workStatus) { + if (!WORK_STATUSES.has(workStatus)) throw new Error('Invalid case work status'); + return store.setWorkStatus(identifier(caseId, true), workStatus).then(changed); }, - markAccepted: (caseId, { staffId } = {}) => store.accept(identifier(caseId, true), identifier(staffId, true)), + claimAcceptanceNotice: (caseId, generation) => store.claimAcceptanceNotice(identifier(caseId, true), identifier(generation, true)), + saveAcceptanceNotice: (caseId, generation, messageId) => store.saveAcceptanceNotice(identifier(caseId, true), identifier(generation, true), identifier(messageId, true)), resolveCase(caseId, { close = false, deliveryId, confirmed = false } = {}) { if (!deliveryId && !confirmed) throw new Error('Confirmed customer delivery is required'); - return store.transition(identifier(caseId, true), close ? 'closed' : 'resolved', STATUSES, { messageId: identifier(deliveryId) }); + return store.transition(identifier(caseId, true), close ? 'closed' : 'resolved', STATUSES, { messageId: identifier(deliveryId) }).then(changed); }, async resolveByThread(threadId, { customerId, close = false, deliveryId, confirmed = false } = {}) { if (!deliveryId && !confirmed) throw new Error('Confirmed customer delivery is required'); const value = await store.getByThread(identifier(threadId, true), identifier(customerId)); if (!value) return null; - return store.transition(value.id, close ? 'closed' : 'resolved', STATUSES, { messageId: identifier(deliveryId) }); + return changed(await store.transition(value.id, close ? 'closed' : 'resolved', STATUSES, { messageId: identifier(deliveryId) })); }, async reopenByThread(threadId, customerId) { const value = await store.getByThread(identifier(threadId, true), identifier(customerId, true)); if (!value) return null; if (ACTIVE.includes(value.status)) return value; - return store.transition(value.id, 'queued', ['resolved', 'closed']); + return changed(await store.transition(value.id, 'queued', ['resolved', 'closed'])); }, }; } @@ -261,5 +332,6 @@ function service() { module.exports = { initSchema, createPostgresStore, createMemoryStore, createCaseService, safeContext, safeSources, setStoreForTests: (store) => { injectedStore = store; }, - ...Object.fromEntries(['getOrCreateCase', 'getActiveCase', 'getCaseById', 'getCaseByThread', 'linkHandoff', 'markDelivered', 'markAccepted', 'resolveCase', 'resolveByThread', 'reopenByThread'].map((name) => [name, (...args) => service()[name](...args)])), + onChange(listener) { changes.on('change', listener); return () => changes.off('change', listener); }, + ...Object.fromEntries(['getOrCreateCase', 'getActiveCase', 'getCaseById', 'getCaseByThread', 'linkHandoff', 'markDelivered', 'markAccepted', 'setWorkStatus', 'claimAcceptanceNotice', 'saveAcceptanceNotice', 'resolveCase', 'resolveByThread', 'reopenByThread'].map((name) => [name, (...args) => service()[name](...args)])), }; diff --git a/test/caseCards.test.js b/test/caseCards.test.js new file mode 100644 index 0000000..4520e75 --- /dev/null +++ b/test/caseCards.test.js @@ -0,0 +1,218 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const supportCases = require('../supportCases'); +const { createMemoryStore, createCardService, renderCaseCard, notifyAcceptance, createPostgresStore } = require('../caseCards'); +const STAFF = '234567890123456789'; +const OWNER = '334567890123456789'; +const OTHER = '434567890123456789'; +const CARD = '534567890123456789'; + +async function fixture(t) { + const old = process.env.STAFF_ALERT_CHANNEL_ID; process.env.STAFF_ALERT_CHANNEL_ID = STAFF; + t.after(() => { if (old === undefined) delete process.env.STAFF_ALERT_CHANNEL_ID; else process.env.STAFF_ALERT_CHANNEL_ID = old; }); + const caseStore = supportCases.createMemoryStore(); const cases = supportCases.createCaseService(caseStore); + const value = await cases.getOrCreateCase({ channelId: 'help', customerThreadId: 'help', customerId: 'customer', sourceMessageId: 'source' }); + await cases.markDelivered(value.id, { confirmed: true, destination: 'staff-channel' }); + const store = createMemoryStore(); const logs = []; const cards = createCardService(store, { service: cases, log: l => logs.push(l) }); + const edits = []; const notices = []; + const message = { id: CARD, author: { id: 'bot' }, embeds: [{ title: 'Needs a human', description: 'Private existing intake', + fields: [{ name: 'Case', value: value.id }, { name: 'From', value: '<@customer>' }] }], + components: [{ type: 1, components: [{ type: 2, custom_id: 'file:draft', label: 'File issue', style: 1 }] }], + edit: async payload => { edits.push(payload); message.embeds = payload.embeds; message.components = payload.components; }, + }; + const client = { user: { id: 'bot' }, channels: { fetch: async id => id === STAFF ? { messages: { fetch: async () => message } } + : { send: async payload => { notices.push(payload); return { id: `notice-${notices.length}` }; } } } }; + await cards.rememberCard(value.id, STAFF, CARD); + return { cases, caseStore, value, store, cards, logs, edits, notices, message, client }; +} + +test('live card distinguishes delivery, acceptance, active work, waiting, and closure without adding pings', async t => { + const f = await fixture(t); + await f.cards.syncCase(f.client, f.value.id); + assert.match(f.edits.at(-1).embeds[0].title, /Awaiting staff acceptance/); + assert.equal(f.edits.at(-1).embeds[0].fields.some(x => x.name === 'Picked up by'), false); + await f.cases.markAccepted(f.value.id, { staffId: OWNER }); + await f.cards.syncCase(f.client, f.value.id); + assert.match(f.edits.at(-1).embeds[0].title, /Accepted · not started/); + await f.cases.setWorkStatus(f.value.id, 'in_progress'); await f.cards.syncCase(f.client, f.value.id); + assert.match(f.edits.at(-1).embeds[0].title, /In progress/); + await f.cases.setWorkStatus(f.value.id, 'waiting_customer'); await f.cards.syncCase(f.client, f.value.id); + assert.match(f.edits.at(-1).embeds[0].title, /Waiting for customer/); + await f.cases.resolveCase(f.value.id, { close: true, confirmed: true }); await f.cards.syncCase(f.client, f.value.id); + const last = f.edits.at(-1); + assert.match(last.embeds[0].title, /Closed/); + assert.equal(last.components.flatMap(r => r.components).some(c => /^case:(accept|work)/.test(c.custom_id)), false); + assert.ok(last.components.flatMap(r => r.components).some(c => c.custom_id === 'file:draft')); + assert.deepEqual(last.allowedMentions, { parse: [], users: [], roles: [], repliedUser: false }); +}); + +test('card receipts and changed case state survive recreation and recover on the pending sweep', async t => { + const f = await fixture(t); await f.cards.syncCase(f.client, f.value.id); + await f.cases.markAccepted(f.value.id, { staffId: OWNER }); await f.cases.setWorkStatus(f.value.id, 'in_progress'); + const restartedCases = supportCases.createCaseService(supportCases.createMemoryStore(f.caseStore.state)); + const restarted = createCardService(createMemoryStore(f.store.state), { service: restartedCases }); + assert.equal(await restarted.syncPending(f.client), 1); + assert.match(f.edits.at(-1).embeds[0].title, /In progress/); + assert.equal(await restarted.syncPending(f.client), 0); + const stored = JSON.stringify([...f.store.state.cards.values()]); + assert.doesNotMatch(stored, /Private existing intake|From|customer/); +}); + +test('card sync never edits a public destination, a different author, or another case card', async t => { + const f = await fixture(t); + await f.cards.rememberCard(f.value.id, OTHER, '634567890123456789'); + const originalFetch = f.client.channels.fetch; + f.client.channels.fetch = async id => { assert.equal(id, STAFF); return originalFetch(id); }; + f.message.author.id = 'someone-else'; assert.equal((await f.cards.syncCase(f.client, f.value.id)).ok, false); + f.message.author.id = 'bot'; f.message.embeds[0].fields[0].value = 'another-case'; + assert.equal((await f.cards.syncCase(f.client, f.value.id)).ok, false); + assert.equal(f.edits.length, 0); +}); + +test('an intentionally deleted staff card is retired, not reposted', async t => { + const f = await fixture(t); + f.client.channels.fetch = async () => ({ messages: { fetch: async () => { const error = new Error('gone'); error.code = 10008; throw error; } } }); + await f.cards.syncCase(f.client, f.value.id); + assert.equal((await f.store.list(f.value.id)).length, 0); + assert.equal(f.notices.length, 0); +}); + +test('failed card edit remains pending and a later sweep repairs it', async t => { + const f = await fixture(t); await f.cards.syncCase(f.client, f.value.id); + assert.match(f.message.embeds[0].title, /Awaiting staff acceptance/); + await f.cases.markAccepted(f.value.id, { staffId: OWNER }); + await f.cases.setWorkStatus(f.value.id, 'in_progress'); + const edit = f.message.edit; const beforeFailure = f.edits.length; + f.message.edit = async () => { throw new Error('unavailable'); }; + assert.equal((await f.cards.syncCase(f.client, f.value.id)).ok, false); + assert.equal(f.edits.length, beforeFailure); + assert.match(f.message.embeds[0].title, /Awaiting staff acceptance/); + f.message.edit = edit; assert.equal(await f.cards.syncPending(f.client), 1); + assert.equal(f.edits.length, beforeFailure + 1); + assert.match(f.message.embeds[0].title, /In progress/); + assert.equal(f.message.embeds[0].fields.find(field => field.name === 'Picked up by').value, `<@${OWNER}>`); + assert.equal(await f.cards.syncPending(f.client), 0); + assert.deepEqual(f.logs, ['[CaseCard] status refresh failed; pending retry']); +}); + +test('serialized edits reread the case and cannot leave an older progress state last', async t => { + const f = await fixture(t); await f.cases.markAccepted(f.value.id, { staffId: OWNER }); + let release; let started; const begun = new Promise(resolve => { started = resolve; }); + const original = f.message.edit; let first = true; + f.message.edit = async payload => { if (first) { first = false; started(); await new Promise(resolve => { release = resolve; }); } await original(payload); }; + const earlier = f.cards.syncCase(f.client, f.value.id); await begun; + await f.cases.setWorkStatus(f.value.id, 'in_progress'); const later = f.cards.syncCase(f.client, f.value.id); + release(); await Promise.all([earlier, later]); + assert.match(f.edits.at(-1).embeds[0].title, /In progress/); +}); + +test('acceptance announces the actual first staff member once, without customer details, pings, or an ETA', async t => { + const f = await fixture(t); const accepted = await f.cases.markAccepted(f.value.id, { staffId: OWNER }); + assert.equal((await notifyAcceptance(f.client, accepted, { service: f.cases })).code, 'sent'); + const repeated = await f.cases.markAccepted(f.value.id, { staffId: OTHER }); + assert.equal((await notifyAcceptance(f.client, repeated, { service: f.cases })).duplicate, true); + assert.equal(f.notices.length, 1); + assert.equal(f.notices[0].content, `<@${OWNER}> from Omi support has picked up your case. Please keep any follow-up in this thread.`); + assert.doesNotMatch(f.notices[0].content, /soon|minute|guarantee|customer|Private existing intake/); + assert.equal(f.notices[0].nonce.length, 24); assert.equal(f.notices[0].enforceNonce, true); + assert.deepEqual(f.notices[0].allowedMentions, { parse: [], users: [], roles: [], repliedUser: false }); +}); + +test('uncertain notice delivery is not retried or misreported as a sent notice', async t => { + const f = await fixture(t); const accepted = await f.cases.markAccepted(f.value.id, { staffId: OWNER }); let attempts = 0; + f.client.channels.fetch = async () => ({ send: async () => { attempts++; throw new Error('timeout'); } }); + const log = () => {}; + assert.equal((await notifyAcceptance(f.client, accepted, { service: f.cases, log })).code, 'send_unconfirmed'); + assert.equal((await notifyAcceptance(f.client, accepted, { service: f.cases, log })).ok, false); + assert.equal(attempts, 1); assert.equal((await f.cases.getCaseById(f.value.id)).status, 'accepted'); +}); + +test('an empty Discord send result is not called a delivered acceptance notice', async t => { + const f = await fixture(t); const accepted = await f.cases.markAccepted(f.value.id, { staffId: OWNER }); + f.client.channels.fetch = async () => ({ send: async () => undefined }); + assert.equal((await notifyAcceptance(f.client, accepted, { service: f.cases, log: () => {} })).ok, false); + assert.equal((await f.cases.getCaseById(f.value.id)).acceptanceNoticeId, null); +}); + +test('a sent notice with a receipt-save error still cannot be duplicated', async t => { + const f = await fixture(t); const accepted = await f.cases.markAccepted(f.value.id, { staffId: OWNER }); + const service = { ...f.cases, saveAcceptanceNotice: async () => { throw new Error('db down'); } }; + assert.equal((await notifyAcceptance(f.client, accepted, { service, log: () => {} })).code, 'receipt_save_failed'); + await notifyAcceptance(f.client, accepted, { service, log: () => {} }); assert.equal(f.notices.length, 1); +}); + +test('reopening clears old acceptance and a stale acceptance cannot announce the new owner', async t => { + const f = await fixture(t); const old = await f.cases.markAccepted(f.value.id, { staffId: OWNER }); + await f.cases.setWorkStatus(f.value.id, 'in_progress'); await f.cases.resolveCase(f.value.id, { close: true, confirmed: true }); + const reopened = await f.cases.reopenByThread('help', 'customer'); + assert.equal(reopened.acceptedBy, null); assert.equal(reopened.workStatus, null); + await f.cases.markDelivered(f.value.id, { confirmed: true }); + const current = await f.cases.markAccepted(f.value.id, { staffId: OTHER }); + assert.notEqual(old.acceptanceGeneration, current.acceptanceGeneration); + assert.equal((await notifyAcceptance(f.client, old, { service: f.cases })).ok, false); + assert.equal(f.notices.length, 0); + await notifyAcceptance(f.client, current, { service: f.cases }); assert.match(f.notices[0].content, new RegExp(OTHER)); +}); + +test('shared-channel acceptance verifies the original customer message before sending a reply', async t => { + const f = await fixture(t); const value = await f.cases.getOrCreateCase({ channelId: 'general', customerId: 'customer', sourceMessageId: 'original' }); + await f.cases.markDelivered(value.id, { confirmed: true }); const accepted = await f.cases.markAccepted(value.id, { staffId: OWNER }); + let author = 'another-customer'; const sent = []; + const client = { channels: { fetch: async () => ({ messages: { fetch: async () => ({ author: { id: author } }) }, send: async payload => { sent.push(payload); return { id: 'notice' }; } }) } }; + assert.equal((await notifyAcceptance(client, accepted, { service: f.cases })).code, 'source_unverifiable'); + author = 'customer'; await notifyAcceptance(client, accepted, { service: f.cases }); + assert.equal(sent.length, 1); assert.deepEqual(sent[0].reply, { messageReference: 'original', failIfNotExists: true }); +}); + +test('Postgres serializes cross-copy card edits and always releases its lock connection', async () => { + const statements = []; let released = false; + const connection = { query: async sql => { statements.push(sql); }, release: () => { released = true; } }; + const store = createPostgresStore({ connect: async () => connection }); + await assert.rejects(store.lock('case', async () => { throw new Error('edit failed'); }), /edit failed/); + assert.match(statements[2], /pg_advisory_xact_lock/); assert.equal(statements.at(-1), 'ROLLBACK'); assert.equal(released, true); +}); + +test('Postgres synchronization keeps its exact timestamp and refuses to acknowledge a newer state', async () => { + const calls = []; const store = createPostgresStore({ query: async (sql, args) => { calls.push({ sql, args }); } }); + await store.synced({ caseId: 'case', messageId: CARD }, { updatedAt: new Date(), status: 'accepted', workStatus: 'in_progress', acceptedBy: OWNER }); + assert.match(calls[0].sql, /synced_at=s.updated_at/); + assert.match(calls[0].sql, /date_trunc\('milliseconds', s.updated_at\)=\$3/); + assert.match(calls[0].sql, /s.work_status IS NOT DISTINCT FROM \$5/); + assert.match(calls[0].sql, /s.accepted_by IS NOT DISTINCT FROM \$6/); +}); + +test('acceptance uses the persisted customer language without a provider call', async t => { + const f = await fixture(t); + for (const [language, match] of [['es', /se ha hecho cargo/], ['zh', /已接手/], ['it', /preso in carico/], ['hi-Latn', /aapka mamla/]]) { + const value = await f.cases.getOrCreateCase({ channelId: `help-${language}`, customerThreadId: `help-${language}`, customerId: 'customer', context: { replyLanguage: language } }); + await f.cases.markDelivered(value.id, { confirmed: true }); const accepted = await f.cases.markAccepted(value.id, { staffId: OWNER }); + await notifyAcceptance(f.client, accepted, { service: f.cases }); + assert.match(f.notices.at(-1).content, match); assert.doesNotMatch(f.notices.at(-1).content, /from Omi support has picked up/); + } +}); + +test('public copies remove all live-case controls, status, identity, and private case references', async t => { + const f = await fixture(t); await f.cases.markAccepted(f.value.id, { staffId: OWNER }); + await f.cards.syncCase(f.client, f.value.id); + const publicCard = require('../handoff').publicHandoffDiscord(f.edits.at(-1)); + const serialized = JSON.stringify(publicCard); + for (const privateValue of [f.value.id, OWNER, 'case:refresh:', 'case:work:', 'Picked up by', 'Case updated', 'Accepted · not started', 'Private existing intake']) assert.equal(serialized.includes(privateValue), false); +}); + +test('a confirmed staff send registers its existing Discord card for later case updates', async t => { + const f = await fixture(t); const module = require('../caseCards'); + const handoff = require('../handoff'); const telegram = require('../telegram'); + const sendTelegram = telegram.sendEscalation; telegram.sendEscalation = async () => false; + supportCases.setStoreForTests(f.caseStore); module.setStoreForTests(f.store); + try { + const messageId = '734567890123456789'; + const result = await handoff.notifyStaff({ client: { channels: { fetch: async () => ({ id: STAFF, isTextBased: () => true, + send: async () => ({ id: messageId }) }) } }, caseId: f.value.id, + message: { author: { id: 'customer' }, channel: { id: 'help' } }, question: 'Synthetic support request', + area: 'app', route: { area: 'app', lane: 'tech' }, skipDedupe: true }); + assert.equal(result.ok, true); assert.equal(result.via, 'staff-channel'); + assert.ok((await f.store.list(f.value.id)).some(c => c.messageId === messageId && c.channelId === STAFF)); + } finally { + supportCases.setStoreForTests(null); module.setStoreForTests(null); telegram.sendEscalation = sendTelegram; handoff.resetHandoffMemory(); + } +}); diff --git a/test/commands.test.js b/test/commands.test.js index 6a5f91a..d73c766 100644 --- a/test/commands.test.js +++ b/test/commands.test.js @@ -577,10 +577,12 @@ test('case acceptance rejects customers, other channels, and forged cards withou ]; try { for (const interaction of forbidden) { - let denied = false; - await handleAcceptCase({ ...interaction, reply: async (payload) => { denied = true; assert.ok(payload.flags); } }, - { cases: { markAccepted: async () => assert.fail('unauthorized interactions must not touch a case') } }); - assert.equal(denied, true); + for (const action of ['accept', 'refresh', 'work:in_progress', 'work:waiting_customer']) { + let denied = false; + await handleAcceptCase({ ...interaction, customId: `case:${action}:${caseId}`, reply: async (payload) => { denied = true; assert.ok(payload.flags); } }, + { cases: { markAccepted: async () => assert.fail('unauthorized interactions must not touch a case'), getCaseById: async () => assert.fail('must not read'), setWorkStatus: async () => assert.fail('must not update') } }); + assert.equal(denied, true); + } } } finally { for (const [key, env] of [['channel', 'STAFF_ALERT_CHANNEL_ID'], ['users', 'STAFF_USER_IDS']]) { @@ -589,6 +591,69 @@ test('case acceptance rejects customers, other channels, and forged cards withou } }); +test('authorized live-card actions update the case and notify the customer only on first acceptance', async () => { + const { createCaseService, createMemoryStore } = require('../supportCases'); + const { handleCaseAction } = require('../commands'); + const cardModule = require('../caseCards'); + const previous = { channel: process.env.STAFF_ALERT_CHANNEL_ID, users: process.env.STAFF_USER_IDS }; + const staffChannel = '234567890123456789'; const staffId = '334567890123456789'; const messageId = '534567890123456789'; + process.env.STAFF_ALERT_CHANNEL_ID = staffChannel; process.env.STAFF_USER_IDS = staffId; + const cases = createCaseService(createMemoryStore()); + const value = await cases.getOrCreateCase({ channelId: 'help', customerThreadId: 'help', customerId: 'customer' }); + await cases.markDelivered(value.id, { confirmed: true }); + const notices = []; const edited = []; const card = { id: messageId, author: { id: 'bot' }, embeds: [{ fields: [{ name: 'Case', value: value.id }] }], + edit: async payload => { edited.push(payload); card.embeds = payload.embeds; card.components = payload.components; } }; + const client = { user: { id: 'bot' }, channels: { fetch: async id => id === staffChannel ? { messages: { fetch: async () => card } } + : { send: async payload => { notices.push(payload); return { id: 'notice' }; } } } }; + const cards = { ...cardModule.createCardService(cardModule.createMemoryStore(), { service: cases }), notifyAcceptance: cardModule.notifyAcceptance }; + const replies = []; + const interaction = action => ({ customId: `case:${action}:${value.id}`, channelId: staffChannel, channel: { id: staffChannel }, + message: card, client, user: { id: staffId }, deferReply: async () => {}, editReply: async payload => replies.push(payload), reply: async () => assert.fail('valid action') }); + try { + await handleCaseAction(interaction('accept'), { cases, cards }); + assert.match(edited.at(-1).embeds[0].title, /Accepted/); assert.equal(notices.length, 1); + await handleCaseAction(interaction('accept'), { cases, cards }); assert.equal(notices.length, 1); + await handleCaseAction(interaction('work:in_progress'), { cases, cards }); assert.match(edited.at(-1).embeds[0].title, /In progress/); + await handleCaseAction(interaction('work:waiting_customer'), { cases, cards }); assert.match(edited.at(-1).embeds[0].title, /Waiting for customer/); + assert.match(replies.at(-1).content, /marked Waiting for customer/); + await handleCaseAction(interaction('refresh'), { cases, cards }); assert.equal(notices.length, 1); + } finally { + if (previous.channel === undefined) delete process.env.STAFF_ALERT_CHANNEL_ID; else process.env.STAFF_ALERT_CHANNEL_ID = previous.channel; + if (previous.users === undefined) delete process.env.STAFF_USER_IDS; else process.env.STAFF_USER_IDS = previous.users; + } +}); + +test('an unconfirmed acceptance notice gives staff an explicit private follow-up instruction', async t => { + const { createCaseService, createMemoryStore } = require('../supportCases'); + const { handleAcceptCase } = require('../commands'); + const { MessageFlags } = require('discord.js'); + const previous = { channel: process.env.STAFF_ALERT_CHANNEL_ID, users: process.env.STAFF_USER_IDS }; + const staffChannel = '234567890123456789'; const staffId = '334567890123456789'; + process.env.STAFF_ALERT_CHANNEL_ID = staffChannel; process.env.STAFF_USER_IDS = staffId; + t.after(() => { + if (previous.channel === undefined) delete process.env.STAFF_ALERT_CHANNEL_ID; else process.env.STAFF_ALERT_CHANNEL_ID = previous.channel; + if (previous.users === undefined) delete process.env.STAFF_USER_IDS; else process.env.STAFF_USER_IDS = previous.users; + }); + for (const code of ['send_unconfirmed', 'claim_failed', 'stale_acceptance', 'notice_unconfirmed', 'unavailable', 'thrown']) { + const cases = createCaseService(createMemoryStore()); + const value = await cases.getOrCreateCase({ channelId: 'help', customerThreadId: 'help', customerId: 'customer' }); + await cases.markDelivered(value.id, { confirmed: true }); + let reply; let deferredFlags; + await handleAcceptCase({ customId: `case:accept:${value.id}`, channelId: staffChannel, channel: { id: staffChannel }, + user: { id: staffId }, client: { user: { id: 'bot' } }, + message: { id: '534567890123456789', author: { id: 'bot' }, embeds: [{ fields: [{ name: 'Case', value: value.id }] }] }, + deferReply: async payload => { deferredFlags = payload.flags; }, editReply: async payload => { reply = payload; }, + reply: async () => assert.fail('a valid staff acceptance must be deferred'), + }, { cases, cards: { rememberCard: async () => true, syncCase: async () => ({ ok: true }), + notifyAcceptance: async () => { if (code === 'thrown') throw new Error('unavailable'); return { ok: false, code }; } } }); + assert.equal(deferredFlags, MessageFlags.Ephemeral); + assert.ok(reply.content.includes('The customer notice was not confirmed. Please reply in their thread yourself.'), code); + assert.doesNotMatch(reply.content, /customer thread was updated|notice was sent/i); + assert.deepEqual(reply.allowedMentions, { parse: [] }); + assert.equal((await cases.getCaseById(value.id)).status, 'accepted'); + } +}); + test('case acceptance allows staff role or moderation permission but never the test-channel bypass', () => { const { canAcceptCase } = require('../commands'); const names = ['STAFF_ALERT_CHANNEL_ID', 'STAFF_USER_IDS', 'STAFF_ROLE_ID', 'VECTOR_TEST_CHANNEL_ID']; diff --git a/test/index.test.js b/test/index.test.js index 677578b..63a097c 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -400,6 +400,39 @@ test.beforeEach(() => { github.resetGithubMemory(); }); +test('staff cards recover on Discord ready and every 30 seconds, but not while stopping', async t => { + const caseCards = require('../caseCards'); + const originals = { setInterval: global.setInterval, syncPending: caseCards.syncPending, + onChange: supportCases.onChange, fetch: client.channels.fetch, + hydrate: knowledge.hydrateFromDiscord, register: commands.registerSlashCommands, stopping: runtime.stopping }; + t.after(() => { + global.setInterval = originals.setInterval; caseCards.syncPending = originals.syncPending; + supportCases.onChange = originals.onChange; client.channels.fetch = originals.fetch; + knowledge.hydrateFromDiscord = originals.hydrate; commands.registerSlashCommands = originals.register; + runtime.stopping = originals.stopping; + }); + const timers = []; let passes = 0; + global.setInterval = (callback, delay) => { const timer = { callback, delay, unreferenced: false, + unref() { this.unreferenced = true; } }; timers.push(timer); return timer; }; + caseCards.syncPending = async receivedClient => { assert.equal(receivedClient, client); passes++; return 0; }; + supportCases.onChange = () => () => {}; + client.channels.fetch = async () => null; + knowledge.hydrateFromDiscord = async () => 0; + commands.registerSlashCommands = async () => 0; + runtime.stopping = false; + const ready = client.listeners(Events.ClientReady)[0]; + assert.equal(typeof ready, 'function'); + await ready(); + assert.equal(passes, 1, 'boot must repair persisted stale cards'); + const sweep = timers.find(timer => timer.delay === 30000); + assert.ok(sweep); assert.equal(sweep.unreferenced, true); + await sweep.callback(); + assert.equal(passes, 2, 'the timer must run another pending-card sweep'); + runtime.stopping = true; + await sweep.callback(); + assert.equal(passes, 2, 'shutdown must not start another repair'); +}); + test('/health stays unavailable until Discord is ready and never reveals staff IDs', (t) => { const health = app._router.stack.find((layer) => layer.route?.path === '/health').route.stack[0].handle; const hadOwnIsReady = Object.hasOwn(client, 'isReady'); diff --git a/test/supportCases.test.js b/test/supportCases.test.js index 75bc7ac..8aa8882 100644 --- a/test/supportCases.test.js +++ b/test/supportCases.test.js @@ -103,3 +103,35 @@ test('Postgres initialization and case inserts use an injectable client and sani assert.equal(calls[1].args[4].includes('private transcript'), false); assert.deepEqual(JSON.parse(calls[1].args[4]), { area: 'app', reasonCodes: [] }); }); + +test('work cannot start before acceptance and does not replace the accepting owner', async () => { + const cases = createCaseService(createMemoryStore()); + const value = await cases.getOrCreateCase({ channelId: 'help', customerId: 'customer' }); + assert.equal(await cases.setWorkStatus(value.id, 'in_progress'), null); + await cases.markDelivered(value.id, { confirmed: true }); + assert.equal(await cases.setWorkStatus(value.id, 'waiting_customer'), null); + await cases.markAccepted(value.id, { staffId: 'original-owner' }); + const working = await cases.setWorkStatus(value.id, 'in_progress'); + assert.equal(working.status, 'accepted'); assert.equal(working.acceptedBy, 'original-owner'); + assert.throws(() => cases.setWorkStatus(value.id, 'made-up'), /Invalid case work status/); +}); + +test('case-change observers receive identifiers only and cannot undo a saved transition', async () => { + const cases = createCaseService(createMemoryStore()); const observed = []; + const unsubscribe = require('../supportCases').onChange(id => observed.push(id)); + try { + const value = await cases.getOrCreateCase({ channelId: 'help', customerId: 'customer' }); + await cases.markDelivered(value.id, { confirmed: true }); await cases.markAccepted(value.id, { staffId: 'staff' }); + assert.deepEqual(observed, [value.id, value.id]); + } finally { unsubscribe(); } +}); + +test('an observer failure does not turn a persisted acceptance into a failed case action', async () => { + const cases = createCaseService(createMemoryStore()); const value = await cases.getOrCreateCase({ channelId: 'help', customerId: 'customer' }); + await cases.markDelivered(value.id, { confirmed: true }); + const unsubscribe = require('../supportCases').onChange(() => { throw new Error('observer unavailable'); }); + try { + assert.equal((await cases.markAccepted(value.id, { staffId: 'staff' })).status, 'accepted'); + assert.equal((await cases.getCaseById(value.id)).acceptedBy, 'staff'); + } finally { unsubscribe(); } +});