From 435da6fad9e887110a7cff73be1a8b5e198ccfdf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 08:52:08 +0000 Subject: [PATCH] feat(confirmation): wire WhatsApp 24h-window template check (KI-031) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shouldUseTemplate() was DEMO-only — production fell through to free text unconditionally (a TODO(prod) that would fail real sends outside Meta's 24h customer-service window). It now queries the most-recent INBOUND WhatsApp EnquiryMessage for the enquiry and uses the approved template when that is >=24h old or absent; free text only within the window. Demo mode unchanged; a confirmation with no linked enquiry -> template. - Pure, unit-tested requiresTemplate(lastInboundAt, now) helper (boundary at exactly 24h) + a confirmation behaviour test for the outside-window template path. Unit suite 2,835 -> 2,839. - KI-031 marked resolved in KNOWN_ISSUES (still dormant until the live Meta WhatsApp path is enabled; the logic is now correct + tested). No schema change. https://claude.ai/code/session_01VzJJTUcvzZgS9jN8aap9iv --- .../services/confirmation.service.test.ts | 47 ++++++++++++++++- docs/KNOWN_ISSUES.md | 2 +- lib/services/confirmation.service.ts | 50 +++++++++++++------ 3 files changed, 82 insertions(+), 17 deletions(-) diff --git a/__tests__/unit/services/confirmation.service.test.ts b/__tests__/unit/services/confirmation.service.test.ts index bf3ed696..18f5923e 100644 --- a/__tests__/unit/services/confirmation.service.test.ts +++ b/__tests__/unit/services/confirmation.service.test.ts @@ -11,6 +11,9 @@ vi.mock('@/lib/prisma', () => ({ confirmationDispatch: { count: vi.fn(), }, + enquiryMessage: { + findFirst: vi.fn(), + }, }, })); @@ -23,6 +26,7 @@ vi.mock('@/lib/services/email.service', () => ({ vi.mock('@/lib/services/whatsapp.service', () => ({ whatsappService: { sendTextMessage: vi.fn().mockResolvedValue({ messageId: 'wa-1', success: true }), + sendTemplateMessage: vi.fn().mockResolvedValue({ messageId: 'wa-tmpl-1', success: true }), }, })); @@ -35,7 +39,7 @@ vi.mock('@/lib/services/appointment-audit.service', () => ({ }, })); -import { confirmationService } from '@/lib/services/confirmation.service'; +import { confirmationService, requiresTemplate } from '@/lib/services/confirmation.service'; import { whatsappService } from '@/lib/services/whatsapp.service'; import { prisma } from '@/lib/prisma'; @@ -49,6 +53,11 @@ describe('confirmationService', () => { (prisma.appointment.updateMany as ReturnType).mockResolvedValue({ count: 1 }); // Default: no prior dispatch rows → first attempt, index 0. (prisma.confirmationDispatch.count as ReturnType).mockResolvedValue(0); + // Default: a recent inbound WhatsApp message → inside the 24h service + // window → free-text path (KI-031). Template-path tests override this. + (prisma.enquiryMessage.findFirst as ReturnType).mockResolvedValue({ + sentOrReceivedAt: new Date(), + }); }); describe('buildConfirmationMessage', () => { @@ -194,6 +203,22 @@ describe('confirmationService', () => { ); }); + it('uses the approved template (not free text) outside the 24h window — KI-031', async () => { + (prisma.appointment.findUnique as ReturnType).mockResolvedValue(whatsappAppointment); + (prisma.appointment.update as ReturnType).mockResolvedValue({}); + // Most-recent inbound WhatsApp message is 30h old → outside the + // service window → Meta requires the approved template. + (prisma.enquiryMessage.findFirst as ReturnType).mockResolvedValue({ + sentOrReceivedAt: new Date(Date.now() - 30 * 60 * 60 * 1000), + }); + + const result = await confirmationService.sendConfirmation('appt-wa', { actor: 'rjk134' }); + + expect(result.sent).toBe(true); + expect(whatsappService.sendTemplateMessage).toHaveBeenCalled(); + expect(whatsappService.sendTextMessage).not.toHaveBeenCalled(); + }); + it('logs a ConfirmationDispatch row capturing channel + success + external message id', async () => { (prisma.appointment.findUnique as ReturnType).mockResolvedValue(whatsappAppointment); (prisma.appointment.update as ReturnType).mockResolvedValue({}); @@ -355,3 +380,23 @@ describe('confirmationService', () => { }); }); }); + +describe('requiresTemplate — WhatsApp 24h service window (KI-031)', () => { + const now = new Date('2026-06-16T12:00:00Z'); + + it('requires a template when there is no inbound message on record', () => { + expect(requiresTemplate(null, now)).toBe(true); + }); + + it('permits free text while the most-recent inbound is < 24h old', () => { + const twoHoursAgo = new Date(now.getTime() - 2 * 60 * 60 * 1000); + expect(requiresTemplate(twoHoursAgo, now)).toBe(false); + }); + + it('requires a template at or beyond the 24h boundary', () => { + const exactly24h = new Date(now.getTime() - 24 * 60 * 60 * 1000); + const wellOutside = new Date(now.getTime() - 48 * 60 * 60 * 1000); + expect(requiresTemplate(exactly24h, now)).toBe(true); + expect(requiresTemplate(wellOutside, now)).toBe(true); + }); +}); diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index 906cb826..65c9bc3c 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -5,7 +5,7 @@ | ID | Severity | Description | Resolution | |----|----------|-------------|------------| | KI-016 O | Test/CI | Full-flow intake → confirmation (→ completion) end-to-end was the last deferred slice of KI-016. The pipeline was already fully wired; what was missing was a single test proving every stage really connects against a real DB, plus CI coverage. | **Delivered.** New real-database test `__tests__/e2e/intake-to-completion.test.ts` drives the whole spine through the actual domain services (no `@/lib/prisma` / `@/lib/env` mocks): intake (`processWhatsAppPayload`) → inline auto-triage → route proposal (`routeProposalService.generateProposals`) → vet approval (DRAFT→APPROVED) → booking (`bookingService.bookRouteRun`) → confirmation (`confirmationService.sendConfirmationsForRouteRun`) → completion (`visitOutcomeService.completeAppointment`) → follow-up regeneration, asserting the exact status transition at every hand-off. A second test asserts the URGENT → `automationHold` path is held out of proposals. Self-contained `e2e-`-prefixed fixtures, child-first cleanup (idempotent / re-runnable). Gated by `describe.skipIf(!RUN_DB_E2E)` + excluded from the default `vitest.config.ts`, so `npm run test` (2,835 unit tests) never touches it; run via `npm run test:e2e` with its own `vitest.e2e.config.ts`. CI runs it in an isolated, additive `e2e` job (Postgres 16 service container + `prisma migrate deploy`) in `.github/workflows/ci.yml`. UI walkthrough: `docs/PHASE_46_KI_016_O_DEMO_WALKTHROUGH.md`. **No product/runtime code changed — the spine was already correct, and is now proven.** | -| KI-031 | Info | The confirmation service's `shouldUseTemplate()` (`lib/services/confirmation.service.ts`) is **DEMO-only** for the 24-hour customer-service-window decision: in demo mode it always uses the approved-template path, but the production branch is a `TODO(prod)` — it does not yet check `EnquiryMessage` for the customer's most-recent inbound message and fall back to a template when older than 24h, so production currently always falls through to free text. | **Deferred — Meta-gated.** Meta rejects free-text WhatsApp outside the 24h window, so this only matters once the live Meta WhatsApp path is enabled (which is itself externally blocked on Meta business verification). When wiring it: in `shouldUseTemplate()` (or a windowed variant) query the latest inbound `EnquiryMessage` for the customer and return `true` when it is older than 24h; the template path already exists and is exercised by demo mode + the Phase 46 e2e. No schema change needed. | +| KI-031 | ~~Info~~ **Resolved (Phase 47)** | The confirmation service's `shouldUseTemplate()` (`lib/services/confirmation.service.ts`) was **DEMO-only** for the 24-hour customer-service-window decision: in demo mode it always used the approved-template path, but the production branch was a `TODO(prod)` that fell through to free text unconditionally. | **Resolved (Phase 47).** `shouldUseTemplate(enquiryId)` now queries the most-recent **INBOUND WhatsApp** `EnquiryMessage` for the enquiry and uses the approved template when it is **≥24h old or absent**, free text only inside the window (demo mode still always templates; a confirmation with no linked enquiry → template). Decision extracted into a pure, unit-tested `requiresTemplate(lastInboundAt, now)` helper (boundary tested at exactly 24h) plus a confirmation behaviour test for the outside-window template path. Still **dormant until the live Meta WhatsApp path is enabled** (externally blocked on Meta verification), but the logic is now correct and tested. No schema change. | ## Phase 45 — Feature wave: deferral backlog (2026-06-13) diff --git a/lib/services/confirmation.service.ts b/lib/services/confirmation.service.ts index cb2c603f..89a5729d 100644 --- a/lib/services/confirmation.service.ts +++ b/lib/services/confirmation.service.ts @@ -15,20 +15,40 @@ import { env } from '@/lib/env'; import type { ActorContext } from '@/lib/types/actor'; import type { ConfirmationChannel, PreferredChannel } from '@prisma/client'; +/** WhatsApp's customer-service window: free text is only permitted within + * 24h of the customer's most-recent inbound message. */ +const WHATSAPP_SERVICE_WINDOW_MS = 24 * 60 * 60 * 1000; + /** - * DEMO-02 — In demo mode we always send via the approved template - * path so the demo workflow exercises the same call path that - * production needs (Meta rejects free-text outside the 24-hour - * customer-service window). Production will use this branch too once - * the conversation-window check is wired (deferred — see DEMO-02 - * task brief for rationale). + * Pure: does a WhatsApp confirmation require the approved *template* (rather + * than free text)? Outside the 24-hour customer-service window — no inbound + * on record, or the most-recent inbound is ≥24h old — Meta rejects free text, + * so the registered template must be used. Exported for testing. */ -function shouldUseTemplate(): boolean { +export function requiresTemplate(lastInboundAt: Date | null, now: Date = new Date()): boolean { + if (!lastInboundAt) return true; + return now.getTime() - lastInboundAt.getTime() >= WHATSAPP_SERVICE_WINDOW_MS; +} + +/** + * Decide template-vs-free-text for a WhatsApp confirmation send. + * + * Demo mode always uses the approved template path (there is no live Meta + * conversation window) so the demo exercises the same call path production + * needs. In production it honours WhatsApp's 24-hour customer-service window + * (KI-031): free text only while the customer's most-recent inbound WhatsApp + * message is <24h old; otherwise the approved template. A confirmation with + * no linked enquiry has no conversation to anchor the window to → template. + */ +async function shouldUseTemplate(enquiryId: string | null | undefined): Promise { if (isDemoMode()) return true; - // TODO(prod): check `EnquiryMessage` for the customer's most-recent - // inbound message; if older than 24h, return true. Until then, - // fall through to free text in production. - return false; + if (!enquiryId) return true; + const lastInbound = await prisma.enquiryMessage.findFirst({ + where: { enquiryId, direction: 'INBOUND', channel: 'WHATSAPP' }, + orderBy: { sentOrReceivedAt: 'desc' }, + select: { sentOrReceivedAt: true }, + }); + return requiresTemplate(lastInbound?.sentOrReceivedAt ?? null); } interface AppointmentWithDetails { @@ -269,11 +289,11 @@ export const confirmationService = { }); const confirmationOperationKey = `wa-confirmation:${appointmentId}:${priorDispatches}`; - // DEMO-02 — use the approved template path in demo mode (and, - // later, in production whenever the customer is outside the - // 24-hour service window). Falls back to free text otherwise. + // DEMO-02 / KI-031 — approved template in demo mode and in + // production whenever the customer is outside WhatsApp's 24-hour + // service window; free text only within the window. let result: Awaited>; - if (shouldUseTemplate()) { + if (await shouldUseTemplate(appointment.visitRequest.enquiryId)) { const yardName = appointment.visitRequest.yard?.yardName ?? ''; const date = formatDate(appointment.appointmentStart, lang); const startTime = formatTime(appointment.appointmentStart, lang);