Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion __tests__/unit/services/confirmation.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ vi.mock('@/lib/prisma', () => ({
confirmationDispatch: {
count: vi.fn(),
},
enquiryMessage: {
findFirst: vi.fn(),
},
},
}));

Expand All @@ -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 }),
},
}));

Expand All @@ -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';

Expand All @@ -49,6 +53,11 @@ describe('confirmationService', () => {
(prisma.appointment.updateMany as ReturnType<typeof vi.fn>).mockResolvedValue({ count: 1 });
// Default: no prior dispatch rows → first attempt, index 0.
(prisma.confirmationDispatch.count as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue({
sentOrReceivedAt: new Date(),
});
});

describe('buildConfirmationMessage', () => {
Expand Down Expand Up @@ -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<typeof vi.fn>).mockResolvedValue(whatsappAppointment);
(prisma.appointment.update as ReturnType<typeof vi.fn>).mockResolvedValue({});
// Most-recent inbound WhatsApp message is 30h old → outside the
// service window → Meta requires the approved template.
(prisma.enquiryMessage.findFirst as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue(whatsappAppointment);
(prisma.appointment.update as ReturnType<typeof vi.fn>).mockResolvedValue({});
Expand Down Expand Up @@ -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);
});
});
2 changes: 1 addition & 1 deletion docs/KNOWN_ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
50 changes: 35 additions & 15 deletions lib/services/confirmation.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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 {
Expand Down Expand Up @@ -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<ReturnType<typeof whatsappService.sendTextMessage>>;
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);
Expand Down
Loading