# Phase 5 — Automation Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use
> `superpowers:subagent-driven-development` (recommended) or
> `superpowers:executing-plans` to implement this plan task-by-task. Steps use
> checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build the Automation module — a registry of n8n flows synced from the
n8n REST API, real flow dispatch from inbound messages to n8n webhooks,
authenticated internal callbacks (`/internal/contacts/upsert`, `/internal/consent`,
`/internal/followups`) that n8n flows write through, and a consolidated 24h-window
+ consent-aware **reminder engine** (HOT T+20 / mid-tier / T+30 escalation; BULK
5PM / next-day 10AM) that consolidates and replaces the five legacy AiSensy
reminder workflows — plus migration tooling for the 13 legacy n8n workflows.

**Architecture:**
- **Pure helpers** in `packages/shared/src/automation/` and
  `packages/shared/src/reminders/`: the reminder scheduler state machine
  (returns `{action, channel, templateName?}` for a given lead row + clocks +
  consent + window + reply state), the flow-dispatch selector
  (`pickFlow(inbound, sessions, registry) -> {flowId, kind}`), and the n8n
  reconciler (`reconcileFlows(local, n8n) -> AdditivePlan`). All three are
  framework-agnostic and exhaustively unit-tested.
- **New NestJS module** `FlowsModule` in `apps/api/src/flows/`:
  - `FlowsService` — sync (calls `N8nClient.listWorkflows`, applies the pure
    reconciler, upserts rows), list, get, patch (`kind`/`description`/
    `triggerWebhookUrl`), activate/deactivate (via `N8nClient.setActive` then
    mirrors `isActive` locally), `executions(id)` (read-through to
    `N8nClient.listExecutions`).
  - `FlowsController` (REST surface in spec §API surface).
  - `DispatchService` — public entry point the worker's `flow-dispatch`
    processor delegates to. Resolves the target flow via the registry, calls
    `N8nClient.triggerWebhook`. Replaces the Phase 1 stub.
- **New `RemindersModule`** in `apps/api/src/reminders/`:
  - `RemindersService` — `createForLead(lead)` (idempotent by leadId, classifies
    HOT vs BULK by `lead.createdAt < 48h ago`, sets `dueAt`/`dueBy` per
    `docs/workflows-legacy.md`), `tick(now)` (selects due rows, runs the
    scheduler for each, dispatches via `MessageService.send`).
  - `RemindersController` — `GET /api/reminders` (list `pending_followups` for
    the UI).
- **New `InternalCallbacksModule`** (`apps/api/src/internal/`):
  - `InternalContactsController`, `InternalConsentController`,
    `InternalFollowupsController` — three `/internal/*` endpoints, all
    `@Public()` + `@UseGuards(CallbackAuthGuard)`, Zod-validated, idempotent.
- **Worker wiring** — `apps/worker/src/processors/flow-dispatch.processor.ts`
  becomes a thin HTTP-call into a new `/internal/flows/dispatch` endpoint via
  the existing `InternalApiClient`, so dispatch logic lives in the API (where
  the DB and Prisma live), not in the worker. A new BullMQ **repeatable** job
  ticks every minute and hits `/internal/reminders/tick`.
- **Migration tooling** — a new top-level `scripts/` directory:
  `audit-legacy-workflows.ts` (uses the n8n MCP at build-time to dump
  `Main Distributer`, `New Leads`, `Recruitment`, `WA Send + Log`,
  `01B Lead Poller`, `02 T+20 Reminder`, `04 T+30 Hot Lead Escalation`,
  `05 5PM Bulk Reminder`, `06 10AM Next Day Reminder`, `WA Event Monitor`,
  `Reminder Engine`, `TEMP LeadRat User List v2`, `Delete Redis Session`, and
  reports every HTTP node still carrying an AiSensy `Authorization: Bearer
  eyJ...` literal or a LeadRat `apiKey`/`secretKey` literal).
  `parity-check.ts` (compares the new reminder engine's "would-send" set
  against the legacy workflows' "would-send" set for a given lead set).
- **Web** — a new `apps/web/src/routes/Automation.tsx` (Flows list with
  Sync button, kind/active/last-sync, executions panel, Open-in-n8n link) and
  `apps/web/src/routes/Reminders.tsx` (pending-followups view), plus
  `apps/web/src/lib/automation-api.ts`.

**Tech Stack:** NestJS 11 + Fastify, Prisma 7 / PostgreSQL, Zod 3, BullMQ 5,
Vitest 3 on the backend; React 19 + Vite 6 + react-router 7 +
`@tanstack/react-query` 5 on the frontend. No new runtime dependencies.

**References:** `../specs/06-automation.md` (authoritative for FR-06.*/AC-06.*),
`../docs/integrations.md` §2 (n8n integration — REST endpoints used, never
`PUT /workflows/:id`; webhook trigger pattern; callback bearer-token contract),
§1.5 (24h customer-service window), `../docs/workflows-legacy.md` (the 13
existing n8n workflows being migrated), `../docs/design.md` §3.4 (webhook
ownership + drift-free n8n integration), `../packages/db/prisma/schema.prisma`
(authoritative `Flow`, `PendingFollowup`, `FollowupTier`, `FollowupStatus`,
`FlowKind` model/enum names — **already exists, no migration required this
phase**).

**Conventions:**
- Every task ends in a commit (`feat:` / `fix:` / `test:` / `chore:` / `docs:`).
- Tests use Vitest. Run from the repo root with `pnpm test` or per-package with
  `pnpm --filter <pkg> test <pattern>`.
- **All external HTTP / n8n / Meta calls are mocked in tests** — never a live
  call. Prisma is mocked with `vi.fn()` delegates (see the established pattern
  in `apps/api/src/contacts/segments.service.test.ts`). `MessageService` is
  mocked with a `vi.fn()` `send` method. `N8nClient` is mocked with
  `vi.fn()`s for `listWorkflows`, `setActive`, `listExecutions`,
  `triggerWebhook`.
- No secret value is ever committed or logged. There are no new secrets this
  phase — n8n credentials come from the encrypted `connections` store via the
  existing `ConnectionsService.getDecrypted("n8n")`.
- **Docker is broken on this machine.** Verification relies on `pnpm lint`,
  `pnpm typecheck`, `pnpm test`, `pnpm build`. Any check needing a live n8n,
  Postgres, or Redis is marked **"live verification deferred"** — not a
  blocker.
- **No migrations needed.** The Prisma schema already defines `Flow` and
  `PendingFollowup` with every field this spec writes (verified in Task 0).
- Web verification is `pnpm --filter @whatapp/web typecheck` + `build` + the
  component tests in this plan. Live click-through is **"live verification
  deferred"**.
- The Phase 1 unified send is `MessageService.send(msg, options)` in
  `apps/api/src/messages/message.service.ts`. It enforces window + consent,
  logs the row, and returns `MetaSendResult`. **REUSE it for every reminder
  send — never reimplement sending.**
- The Phase 1 stub `processFlowDispatchJob` in
  `apps/worker/src/processors/flow-dispatch.processor.ts` is REPLACED — its
  current resolveFlowUrl/trigger DI signature is rewired so the worker calls
  the API instead of touching Prisma directly. The existing tests for it are
  rewritten in Task 14.
- n8n integration is **read/control only** via `N8nClient`. The platform NEVER
  calls `PUT /workflows/{id}`. Authoring stays in the n8n editor.
- The n8n MCP server is a **build-time** tool used only by the scripts in
  `scripts/` (Tasks 19–20). It is NEVER imported by `apps/api`, `apps/worker`,
  or `apps/web`.

---

## Schema check (Task 0)

The existing `Flow` and `PendingFollowup` models already carry every field this
phase writes:

| Spec need | Model field |
|---|---|
| FR-06.1 registry row | `Flow.id`, `n8nWorkflowId` (`@unique`), `name`, `isActive`, `lastSyncedAt` |
| FR-06.2 kind / description / webhook URL | `Flow.kind` (`FlowKind` enum), `description`, `triggerWebhookUrl` |
| FR-06.10 intake row | `PendingFollowup.leadId` (`@unique` — idempotency), `contactId`, `leadName`, `agentId`/`agentPhone`/`agentName`, `tier` (`FollowupTier` = `hot` \| `bulk`), `status` (`FollowupStatus` = `pending` \| `awaiting_reply` \| `escalated` \| `resolved`), `dueAt`, `dueBy`, `nudgeSentAt` |
| FR-06.10 reminder selection index | `PendingFollowup.@@index([status, tier, dueAt])` |

`FlowKind` = `conversational | reminder | integration | other`.
`FollowupTier` = `hot | bulk`.
`FollowupStatus` = `pending | awaiting_reply | escalated | resolved`.

**No migration is required this phase.** Task 0 verifies this against the live
schema file before any code is written.

---

## Task 0: Schema verification

**Files:**
- Read: `packages/db/prisma/schema.prisma`

- [ ] **Step 1: Verify Flow + PendingFollowup models**

Run: `pnpm --filter @whatapp/db prisma validate`
Expected: `The schema at packages/db/prisma/schema.prisma is valid`.

- [ ] **Step 2: Grep the schema for the exact field names this plan uses**

Run:
```bash
grep -nE "model Flow|model PendingFollowup|FollowupTier|FollowupStatus|FlowKind|n8nWorkflowId|triggerWebhookUrl|lastSyncedAt|leadId|dueAt|dueBy|nudgeSentAt" packages/db/prisma/schema.prisma
```
Expected: all the names in the "Schema check" table above appear at least
once. If anything is missing, STOP and add an additive Prisma change + a
`prisma migrate diff` SQL file under
`packages/db/prisma/migrations/<timestamp>_<name>/migration.sql` before
continuing. (No such gap is expected.)

- [ ] **Step 3: Commit**

```bash
git add plans/phase-5-automation.md
git commit -m "docs: verify Phase 5 schema fits Flow + PendingFollowup models"
```

---

## Task 1: Reminder scheduler — pure state-machine helper (skeleton + types)

**Files:**
- Create: `packages/shared/src/reminders/types.ts`
- Modify: `packages/shared/src/index.ts`

- [ ] **Step 1: Add the types**

```ts
// packages/shared/src/reminders/types.ts

/** Tier classification (matches the Prisma enum `FollowupTier`). */
export type ReminderTier = "hot" | "bulk";

/** State (matches the Prisma enum `FollowupStatus`). */
export type ReminderStatus =
  | "pending"
  | "awaiting_reply"
  | "escalated"
  | "resolved";

/** Lead state snapshot the scheduler needs to decide. */
export interface ReminderLeadState {
  leadId: string;
  tier: ReminderTier;
  status: ReminderStatus;
  /** When the followup row was created (used for HOT mid-tier timing). */
  createdAt: Date;
  /** Hot T+20 first-nudge time. Null until `02` analog runs. */
  nudgeSentAt: Date | null;
  /** The escalation deadline. */
  dueAt: Date;
  dueBy: Date;
  /** "New" / "Pending" / etc. from LeadRat at the time `tick` was called. */
  leadRatStatus: string;
  /** True when the contact has replied since the followup row was created. */
  hasReplied: boolean;
  /** opted_in | opted_out | unknown. */
  consentState: "opted_in" | "opted_out" | "unknown";
  /** Contact's 24h customer-service-window expiry, null if no inbound yet. */
  windowExpiresAt: Date | null;
}

/** What the scheduler decides to do for one lead at `now`. */
export type ReminderAction =
  | { kind: "wait"; reason: string }
  | { kind: "skip"; reason: string; markResolved: boolean }
  | {
      kind: "send";
      channel: "freeform" | "template";
      templateName: string | null;
      /** The new `status` to write after sending. */
      nextStatus: ReminderStatus;
      /** True for the HOT escalation step that reassigns to the manager. */
      escalate: boolean;
      reason: string;
    };
```

```ts
// packages/shared/src/index.ts — append
export * from "./reminders/types.js";
```

- [ ] **Step 2: Commit**

```bash
git add packages/shared/src/reminders/types.ts packages/shared/src/index.ts
git commit -m "feat: add reminder scheduler types (Phase 5 Task 1)"
```

---

## Task 2: Reminder scheduler — opt-out gate test (red)

**Files:**
- Create: `packages/shared/src/reminders/scheduler.test.ts`

- [ ] **Step 1: Write the failing test**

```ts
import { describe, it, expect } from "vitest";
import { decideReminderAction } from "./scheduler.js";
import type { ReminderLeadState } from "./types.js";

function lead(overrides: Partial<ReminderLeadState> = {}): ReminderLeadState {
  return {
    leadId: "lead-1",
    tier: "hot",
    status: "pending",
    createdAt: new Date("2026-05-23T10:00:00Z"),
    nudgeSentAt: null,
    dueAt: new Date("2026-05-23T10:20:00Z"),
    dueBy: new Date("2026-05-23T10:30:00Z"),
    leadRatStatus: "New",
    hasReplied: false,
    consentState: "opted_in",
    windowExpiresAt: new Date("2026-05-23T20:00:00Z"),
    ...overrides,
  };
}

describe("decideReminderAction", () => {
  it("skips and resolves opted-out leads (AC-06.5 consent gate)", () => {
    const action = decideReminderAction(
      lead({ consentState: "opted_out" }),
      new Date("2026-05-23T10:21:00Z"),
    );
    expect(action.kind).toBe("skip");
    if (action.kind === "skip") {
      expect(action.markResolved).toBe(true);
      expect(action.reason).toMatch(/opted_out/i);
    }
  });
});
```

- [ ] **Step 2: Run and confirm failure**

Run: `pnpm --filter @whatapp/shared test reminders/scheduler`
Expected: FAIL — `decideReminderAction is not a function` (file missing).

---

## Task 3: Reminder scheduler — opt-out gate implementation (green)

**Files:**
- Create: `packages/shared/src/reminders/scheduler.ts`
- Modify: `packages/shared/src/index.ts`

- [ ] **Step 1: Implement just enough to pass the opt-out test**

```ts
// packages/shared/src/reminders/scheduler.ts
import type { ReminderAction, ReminderLeadState } from "./types.js";

/**
 * Pure decision function: given a lead followup row, a wall-clock `now`, and
 * the contact's consent + window + reply state, return what the engine should
 * do *for this row right now*. All gates encoded here so the service is a
 * thin dispatch shell around this function (FR-06.10, AC-06.5).
 */
export function decideReminderAction(
  lead: ReminderLeadState,
  now: Date,
): ReminderAction {
  // 1. Consent gate — opted_out leads are skipped permanently (FR-06.10).
  if (lead.consentState === "opted_out") {
    return {
      kind: "skip",
      reason: "Contact is opted_out — reminder suppressed",
      markResolved: true,
    };
  }
  // Placeholder until later tasks fill in the timing/state-machine branches.
  return { kind: "wait", reason: "not implemented" };
}
```

```ts
// packages/shared/src/index.ts — append
export * from "./reminders/scheduler.js";
```

- [ ] **Step 2: Run and confirm pass**

Run: `pnpm --filter @whatapp/shared test reminders/scheduler`
Expected: PASS.

- [ ] **Step 3: Commit**

```bash
git add packages/shared/src/reminders/scheduler.ts \
        packages/shared/src/reminders/scheduler.test.ts \
        packages/shared/src/index.ts
git commit -m "feat: reminder scheduler consent gate (Phase 5 Task 3)"
```

---

## Task 4: Reminder scheduler — already-replied + status-changed skips (red → green)

**Files:**
- Modify: `packages/shared/src/reminders/scheduler.test.ts`
- Modify: `packages/shared/src/reminders/scheduler.ts`

- [ ] **Step 1: Add the failing tests**

Append to `scheduler.test.ts` (inside the existing `describe`):

```ts
  it("skips and resolves when the lead already replied (AC-06.5)", () => {
    const a = decideReminderAction(
      lead({ hasReplied: true }),
      new Date("2026-05-23T10:21:00Z"),
    );
    expect(a.kind).toBe("skip");
    if (a.kind === "skip") {
      expect(a.markResolved).toBe(true);
      expect(a.reason).toMatch(/replied/i);
    }
  });

  it("skips and resolves when LeadRat status is no longer 'New'", () => {
    const a = decideReminderAction(
      lead({ leadRatStatus: "Contacted" }),
      new Date("2026-05-23T10:21:00Z"),
    );
    expect(a.kind).toBe("skip");
    if (a.kind === "skip") {
      expect(a.markResolved).toBe(true);
      expect(a.reason).toMatch(/leadrat/i);
    }
  });

  it("HOT BULK lead with 'Pending' LeadRat status (10AM workflow) is allowed", () => {
    const a = decideReminderAction(
      lead({
        tier: "bulk",
        leadRatStatus: "Pending",
        // BULK 10AM allowed by legacy workflow 06 — see workflows-legacy.md
      }),
      new Date("2026-05-24T10:05:00Z"),
    );
    expect(a.kind).not.toBe("skip");
  });
```

- [ ] **Step 2: Run, confirm 2 fail, 1 incidentally fails**

Run: `pnpm --filter @whatapp/shared test reminders/scheduler`
Expected: the new "already_replied" and "no longer New" tests FAIL (placeholder
returns wait). The BULK Pending test passes (placeholder returns wait, not
skip).

- [ ] **Step 3: Extend the implementation**

Replace the placeholder in `scheduler.ts` with:

```ts
export function decideReminderAction(
  lead: ReminderLeadState,
  now: Date,
): ReminderAction {
  if (lead.consentState === "opted_out") {
    return {
      kind: "skip",
      reason: "Contact is opted_out — reminder suppressed",
      markResolved: true,
    };
  }

  // 2. Already replied — resolve and stop (AC-06.5).
  if (lead.hasReplied) {
    return {
      kind: "skip",
      reason: "Lead already replied — resolving",
      markResolved: true,
    };
  }

  // 3. LeadRat status changed — resolve (legacy `02` + `04` re-check).
  //    BULK 10AM accepts both "New" and "Pending" (legacy `06`).
  const acceptable =
    lead.tier === "bulk"
      ? lead.leadRatStatus === "New" || lead.leadRatStatus === "Pending"
      : lead.leadRatStatus === "New";
  if (!acceptable) {
    return {
      kind: "skip",
      reason: `LeadRat status is "${lead.leadRatStatus}" — resolving`,
      markResolved: true,
    };
  }

  return { kind: "wait", reason: "no due step yet" };
}
```

- [ ] **Step 4: Run and confirm pass**

Run: `pnpm --filter @whatapp/shared test reminders/scheduler`
Expected: all 4 tests PASS.

- [ ] **Step 5: Commit**

```bash
git add packages/shared/src/reminders/scheduler.ts \
        packages/shared/src/reminders/scheduler.test.ts
git commit -m "feat: reminder scheduler reply + status gates (Phase 5 Task 4)"
```

---

## Task 5: Reminder scheduler — HOT T+20 / mid-tier / T+30 escalation timing

**Files:**
- Modify: `packages/shared/src/reminders/scheduler.test.ts`
- Modify: `packages/shared/src/reminders/scheduler.ts`

- [ ] **Step 1: Add the failing tests**

```ts
  it("HOT pending lead before dueAt -> wait (T+20 not reached)", () => {
    const a = decideReminderAction(
      lead({ status: "pending" }),
      new Date("2026-05-23T10:05:00Z"),
    );
    expect(a.kind).toBe("wait");
  });

  it("HOT pending lead at dueAt -> sends T+20 reminder, becomes awaiting_reply", () => {
    const a = decideReminderAction(
      lead({ status: "pending" }),
      new Date("2026-05-23T10:21:00Z"),
    );
    expect(a.kind).toBe("send");
    if (a.kind === "send") {
      expect(a.templateName).toBe("t20_reminder");
      expect(a.nextStatus).toBe("awaiting_reply");
      expect(a.escalate).toBe(false);
    }
  });

  it("HOT awaiting_reply lead between dueAt and dueBy -> mid-tier nudge (workflow 03 replacement)", () => {
    const a = decideReminderAction(
      lead({
        status: "awaiting_reply",
        nudgeSentAt: new Date("2026-05-23T10:21:00Z"),
      }),
      new Date("2026-05-23T10:26:00Z"),
    );
    expect(a.kind).toBe("send");
    if (a.kind === "send") {
      expect(a.templateName).toBe("t25_midtier_nudge");
      expect(a.nextStatus).toBe("awaiting_reply");
      expect(a.escalate).toBe(false);
    }
  });

  it("HOT awaiting_reply at dueBy -> escalate to manager", () => {
    const a = decideReminderAction(
      lead({
        status: "awaiting_reply",
        nudgeSentAt: new Date("2026-05-23T10:21:00Z"),
      }),
      new Date("2026-05-23T10:31:00Z"),
    );
    expect(a.kind).toBe("send");
    if (a.kind === "send") {
      expect(a.templateName).toBe("escalated_to_asha");
      expect(a.nextStatus).toBe("escalated");
      expect(a.escalate).toBe(true);
    }
  });
```

- [ ] **Step 2: Run, confirm failures**

Run: `pnpm --filter @whatapp/shared test reminders/scheduler`
Expected: 4 new tests FAIL.

- [ ] **Step 3: Extend the implementation**

Add to `scheduler.ts` before the final `return { kind: "wait", ... }`:

```ts
  // HOT tier branch.
  if (lead.tier === "hot") {
    if (lead.status === "pending") {
      if (now.getTime() < lead.dueAt.getTime()) {
        return { kind: "wait", reason: "T+20 not yet due" };
      }
      // T+20 reminder — workflow 02 replacement.
      return {
        kind: "send",
        channel: chooseChannel(lead, now),
        templateName: "t20_reminder",
        nextStatus: "awaiting_reply",
        escalate: false,
        reason: "T+20 reminder fired",
      };
    }
    if (lead.status === "awaiting_reply") {
      // Mid-tier nudge — workflow 03 replacement. Fires once between dueAt and dueBy.
      const midTierTime =
        lead.nudgeSentAt &&
        now.getTime() >= lead.nudgeSentAt.getTime() + 5 * 60 * 1000 &&
        now.getTime() < lead.dueBy.getTime();
      if (midTierTime) {
        return {
          kind: "send",
          channel: chooseChannel(lead, now),
          templateName: "t25_midtier_nudge",
          nextStatus: "awaiting_reply",
          escalate: false,
          reason: "Mid-tier nudge (workflow 03 replacement)",
        };
      }
      if (now.getTime() >= lead.dueBy.getTime()) {
        // T+30 escalation — workflow 04 replacement.
        return {
          kind: "send",
          channel: chooseChannel(lead, now),
          templateName: "escalated_to_asha",
          nextStatus: "escalated",
          escalate: true,
          reason: "T+30 escalation",
        };
      }
      return { kind: "wait", reason: "between nudge and dueBy" };
    }
  }

  return { kind: "wait", reason: "no due step yet" };
}

/** 24h-window-aware channel selection (FR-06.10 free-form vs template). */
function chooseChannel(
  lead: ReminderLeadState,
  now: Date,
): "freeform" | "template" {
  if (!lead.windowExpiresAt) return "template";
  return lead.windowExpiresAt.getTime() > now.getTime() ? "freeform" : "template";
}
```

- [ ] **Step 4: Run and confirm pass**

Run: `pnpm --filter @whatapp/shared test reminders/scheduler`
Expected: all HOT tests PASS.

- [ ] **Step 5: Commit**

```bash
git add packages/shared/src/reminders/scheduler.ts \
        packages/shared/src/reminders/scheduler.test.ts
git commit -m "feat: reminder scheduler HOT tier timings (Phase 5 Task 5)"
```

---

## Task 6: Reminder scheduler — BULK 5PM / next-day 10AM + window check

**Files:**
- Modify: `packages/shared/src/reminders/scheduler.test.ts`
- Modify: `packages/shared/src/reminders/scheduler.ts`

- [ ] **Step 1: Add the failing tests**

```ts
  it("BULK pending at dueAt (5PM) -> sends bulk reminder", () => {
    const a = decideReminderAction(
      lead({
        tier: "bulk",
        status: "pending",
        dueAt: new Date("2026-05-23T17:00:00+04:00"),
        dueBy: new Date("2026-05-24T10:00:00+04:00"),
      }),
      new Date("2026-05-23T17:01:00+04:00"),
    );
    expect(a.kind).toBe("send");
    if (a.kind === "send") {
      expect(a.templateName).toBe("bulk_5pm_reminder");
      expect(a.nextStatus).toBe("awaiting_reply");
    }
  });

  it("BULK awaiting_reply at dueBy (10AM next day) -> sends nextday reminder, resolves", () => {
    const a = decideReminderAction(
      lead({
        tier: "bulk",
        status: "awaiting_reply",
        dueAt: new Date("2026-05-23T17:00:00+04:00"),
        dueBy: new Date("2026-05-24T10:00:00+04:00"),
      }),
      new Date("2026-05-24T10:05:00+04:00"),
    );
    expect(a.kind).toBe("send");
    if (a.kind === "send") {
      expect(a.templateName).toBe("nextday_agent");
      expect(a.nextStatus).toBe("resolved");
    }
  });

  it("uses template channel outside the 24h window", () => {
    const a = decideReminderAction(
      lead({
        status: "pending",
        windowExpiresAt: new Date("2026-05-23T09:00:00Z"), // expired
      }),
      new Date("2026-05-23T10:21:00Z"),
    );
    expect(a.kind).toBe("send");
    if (a.kind === "send") expect(a.channel).toBe("template");
  });

  it("uses freeform channel inside the 24h window", () => {
    const a = decideReminderAction(
      lead({
        status: "pending",
        windowExpiresAt: new Date("2026-05-23T20:00:00Z"),
      }),
      new Date("2026-05-23T10:21:00Z"),
    );
    expect(a.kind).toBe("send");
    if (a.kind === "send") expect(a.channel).toBe("freeform");
  });
```

- [ ] **Step 2: Run, confirm failures**

Run: `pnpm --filter @whatapp/shared test reminders/scheduler`
Expected: 2 new BULK tests FAIL (window tests already pass — `chooseChannel`
exists).

- [ ] **Step 3: Extend the implementation**

Add a BULK branch in `scheduler.ts` immediately above the final
`return { kind: "wait", reason: "no due step yet" };`:

```ts
  // BULK tier branch.
  if (lead.tier === "bulk") {
    if (lead.status === "pending") {
      if (now.getTime() < lead.dueAt.getTime()) {
        return { kind: "wait", reason: "5PM not yet reached" };
      }
      return {
        kind: "send",
        channel: chooseChannel(lead, now),
        templateName: "bulk_5pm_reminder",
        nextStatus: "awaiting_reply",
        escalate: false,
        reason: "Bulk 5PM reminder",
      };
    }
    if (lead.status === "awaiting_reply") {
      if (now.getTime() < lead.dueBy.getTime()) {
        return { kind: "wait", reason: "10AM next day not reached" };
      }
      return {
        kind: "send",
        channel: chooseChannel(lead, now),
        templateName: "nextday_agent",
        nextStatus: "resolved",
        escalate: false,
        reason: "Bulk 10AM next-day reminder",
      };
    }
  }
```

- [ ] **Step 4: Run and confirm pass**

Run: `pnpm --filter @whatapp/shared test reminders/scheduler`
Expected: all tests PASS.

- [ ] **Step 5: Commit**

```bash
git add packages/shared/src/reminders/scheduler.ts \
        packages/shared/src/reminders/scheduler.test.ts
git commit -m "feat: reminder scheduler BULK tier + window-aware channel (Phase 5 Task 6)"
```

---

## Task 7: Pure helper — `classifyLeadTier`

**Files:**
- Create: `packages/shared/src/reminders/classify.ts`
- Create: `packages/shared/src/reminders/classify.test.ts`
- Modify: `packages/shared/src/index.ts`

- [ ] **Step 1: Write the failing test**

```ts
import { describe, it, expect } from "vitest";
import { classifyLeadTier, computeDueTimes } from "./classify.js";

describe("classifyLeadTier", () => {
  it("classifies a lead created <48h ago as HOT", () => {
    const now = new Date("2026-05-23T10:00:00Z");
    const createdAt = new Date("2026-05-22T10:00:00Z"); // 24h ago
    expect(classifyLeadTier(createdAt, now)).toBe("hot");
  });
  it("classifies a lead created >=48h ago as BULK", () => {
    const now = new Date("2026-05-23T10:00:00Z");
    const createdAt = new Date("2026-05-21T10:00:00Z"); // 48h ago
    expect(classifyLeadTier(createdAt, now)).toBe("bulk");
  });
});

describe("computeDueTimes", () => {
  it("HOT: dueAt = +20min, dueBy = +30min", () => {
    const intakeAt = new Date("2026-05-23T10:00:00Z");
    const { dueAt, dueBy } = computeDueTimes("hot", intakeAt);
    expect(dueAt.toISOString()).toBe("2026-05-23T10:20:00.000Z");
    expect(dueBy.toISOString()).toBe("2026-05-23T10:30:00.000Z");
  });
  it("BULK: dueAt = today 5PM UAE, dueBy = next day 10AM UAE", () => {
    const intakeAt = new Date("2026-05-23T08:00:00Z"); // 12:00 UAE
    const { dueAt, dueBy } = computeDueTimes("bulk", intakeAt);
    // 5PM UAE = 13:00 UTC
    expect(dueAt.toISOString()).toBe("2026-05-23T13:00:00.000Z");
    // next day 10AM UAE = 06:00 UTC next day
    expect(dueBy.toISOString()).toBe("2026-05-24T06:00:00.000Z");
  });
});
```

Run: `pnpm --filter @whatapp/shared test reminders/classify`
Expected: FAIL (file missing).

- [ ] **Step 2: Implement**

```ts
// packages/shared/src/reminders/classify.ts
import type { ReminderTier } from "./types.js";

const HOT_THRESHOLD_MS = 48 * 60 * 60 * 1000;

/** Legacy `01B` HOT vs BULK classifier (workflows-legacy.md §01B). */
export function classifyLeadTier(createdAt: Date, now: Date): ReminderTier {
  return now.getTime() - createdAt.getTime() < HOT_THRESHOLD_MS
    ? "hot"
    : "bulk";
}

/** UAE is fixed UTC+4 (no DST). */
const UAE_OFFSET_MS = 4 * 60 * 60 * 1000;

/**
 * Computes `dueAt`/`dueBy` for one followup row at intake.
 *  - HOT: +20min and +30min from intake
 *  - BULK: 5PM UAE today and 10AM UAE next day
 */
export function computeDueTimes(
  tier: ReminderTier,
  intakeAt: Date,
): { dueAt: Date; dueBy: Date } {
  if (tier === "hot") {
    return {
      dueAt: new Date(intakeAt.getTime() + 20 * 60 * 1000),
      dueBy: new Date(intakeAt.getTime() + 30 * 60 * 1000),
    };
  }
  // BULK — pin to UAE wall clock.
  const intakeUae = new Date(intakeAt.getTime() + UAE_OFFSET_MS);
  const y = intakeUae.getUTCFullYear();
  const m = intakeUae.getUTCMonth();
  const d = intakeUae.getUTCDate();
  // 17:00 UAE = 13:00 UTC same calendar day in UAE.
  const dueAt = new Date(Date.UTC(y, m, d, 17, 0, 0) - UAE_OFFSET_MS);
  // 10:00 UAE next day = next day 06:00 UTC.
  const dueBy = new Date(Date.UTC(y, m, d + 1, 10, 0, 0) - UAE_OFFSET_MS);
  return { dueAt, dueBy };
}
```

```ts
// packages/shared/src/index.ts — append
export * from "./reminders/classify.js";
```

- [ ] **Step 3: Run and confirm pass**

Run: `pnpm --filter @whatapp/shared test reminders/classify`
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add packages/shared/src/reminders/classify.ts \
        packages/shared/src/reminders/classify.test.ts \
        packages/shared/src/index.ts
git commit -m "feat: classifyLeadTier + computeDueTimes helpers (Phase 5 Task 7)"
```

---

## Task 8: Pure helper — `pickFlow` flow-dispatch selector

**Files:**
- Create: `packages/shared/src/automation/pick-flow.ts`
- Create: `packages/shared/src/automation/pick-flow.test.ts`
- Modify: `packages/shared/src/index.ts`

- [ ] **Step 1: Write the failing test**

```ts
import { describe, it, expect } from "vitest";
import { pickFlow } from "./pick-flow.js";

const registry = [
  { id: "flow-main", kind: "conversational", isActive: true,
    triggerWebhookUrl: "https://n8n/webhook/main",
    name: "Main Distributer" },
  { id: "flow-newleads", kind: "conversational", isActive: true,
    triggerWebhookUrl: "https://n8n/webhook/newleads",
    name: "New Leads" },
  { id: "flow-reminder", kind: "reminder", isActive: true,
    triggerWebhookUrl: "https://n8n/webhook/reminder",
    name: "Reminder Engine" },
];

describe("pickFlow", () => {
  it("routes to the session's flow when an active session exists (FR-06.6)", () => {
    const result = pickFlow(
      { waId: "9715551111" },
      { "9715551111": { flowId: "flow-newleads" } },
      registry,
    );
    expect(result?.id).toBe("flow-newleads");
  });
  it("falls back to the entry flow (Main Distributer) when no session", () => {
    const result = pickFlow({ waId: "9715559999" }, {}, registry);
    expect(result?.id).toBe("flow-main");
  });
  it("returns null when no entry flow is registered or active", () => {
    const result = pickFlow(
      { waId: "9715559999" },
      {},
      registry.filter((f) => f.id !== "flow-main"),
    );
    expect(result).toBeNull();
  });
});
```

Run: `pnpm --filter @whatapp/shared test automation/pick-flow`
Expected: FAIL.

- [ ] **Step 2: Implement**

```ts
// packages/shared/src/automation/pick-flow.ts

/** Minimal shape of a flow registry row the selector needs. */
export interface FlowRegistryRow {
  id: string;
  kind: "conversational" | "reminder" | "integration" | "other";
  isActive: boolean;
  triggerWebhookUrl: string | null;
  name: string;
}

/** Active conversational sessions keyed by `waId`. */
export type ActiveSessions = Record<string, { flowId: string }>;

/**
 * Selects the n8n flow to dispatch an inbound message to (FR-06.6).
 *   1. If the contact has an active session, route to that flow.
 *   2. Otherwise route to the entry flow — by convention the conversational
 *      flow named "Main Distributer".
 * Returns null if no usable flow exists in the registry.
 */
export function pickFlow(
  inbound: { waId: string },
  sessions: ActiveSessions,
  registry: FlowRegistryRow[],
): FlowRegistryRow | null {
  const session = sessions[inbound.waId];
  if (session) {
    const match = registry.find(
      (f) => f.id === session.flowId && f.isActive && f.triggerWebhookUrl,
    );
    if (match) return match;
  }
  const entry = registry.find(
    (f) =>
      f.kind === "conversational" &&
      f.isActive &&
      !!f.triggerWebhookUrl &&
      /main\s*distrib/i.test(f.name),
  );
  return entry ?? null;
}
```

```ts
// packages/shared/src/index.ts — append
export * from "./automation/pick-flow.js";
```

- [ ] **Step 3: Run and confirm pass**

Run: `pnpm --filter @whatapp/shared test automation/pick-flow`
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add packages/shared/src/automation/pick-flow.ts \
        packages/shared/src/automation/pick-flow.test.ts \
        packages/shared/src/index.ts
git commit -m "feat: pickFlow dispatch selector helper (Phase 5 Task 8)"
```

---

## Task 9: Pure helper — `reconcileFlows` (n8n list -> additive plan)

**Files:**
- Create: `packages/shared/src/automation/reconcile.ts`
- Create: `packages/shared/src/automation/reconcile.test.ts`
- Modify: `packages/shared/src/index.ts`

- [ ] **Step 1: Write the failing test**

```ts
import { describe, it, expect } from "vitest";
import { reconcileFlows } from "./reconcile.js";

describe("reconcileFlows", () => {
  it("creates a row for a new n8n workflow", () => {
    const plan = reconcileFlows(
      [],
      [{ id: "wf-1", name: "Main Distributer", active: true }],
    );
    expect(plan.upserts).toEqual([
      { n8nWorkflowId: "wf-1", name: "Main Distributer", isActive: true },
    ]);
    expect(plan.deactivate).toEqual([]);
  });

  it("updates name + isActive on an existing row", () => {
    const plan = reconcileFlows(
      [{ id: "local-1", n8nWorkflowId: "wf-1", name: "old", isActive: true }],
      [{ id: "wf-1", name: "renamed", active: false }],
    );
    expect(plan.upserts).toEqual([
      { n8nWorkflowId: "wf-1", name: "renamed", isActive: false },
    ]);
    expect(plan.deactivate).toEqual([]);
  });

  it("marks a locally-known workflow inactive when missing from n8n (never deletes — FR-06.1)", () => {
    const plan = reconcileFlows(
      [{ id: "local-1", n8nWorkflowId: "wf-X", name: "gone", isActive: true }],
      [],
    );
    expect(plan.upserts).toEqual([]);
    expect(plan.deactivate).toEqual(["local-1"]);
  });
});
```

Run: `pnpm --filter @whatapp/shared test automation/reconcile`
Expected: FAIL.

- [ ] **Step 2: Implement**

```ts
// packages/shared/src/automation/reconcile.ts

export interface LocalFlowRow {
  id: string;
  n8nWorkflowId: string;
  name: string;
  isActive: boolean;
}

export interface N8nWorkflowSummary {
  id: string;
  name: string;
  active: boolean;
}

export interface ReconcilePlan {
  upserts: Array<{ n8nWorkflowId: string; name: string; isActive: boolean }>;
  /** Local row ids that need their `isActive` flipped to false. */
  deactivate: string[];
}

/**
 * Build an ADDITIVE reconciliation plan: each n8n workflow becomes an upsert;
 * a local row whose `n8nWorkflowId` is no longer in n8n is marked inactive
 * (never deleted — FR-06.1). The platform never writes flow definitions.
 */
export function reconcileFlows(
  local: LocalFlowRow[],
  n8n: N8nWorkflowSummary[],
): ReconcilePlan {
  const upserts = n8n.map((w) => ({
    n8nWorkflowId: w.id,
    name: w.name,
    isActive: w.active,
  }));
  const liveIds = new Set(n8n.map((w) => w.id));
  const deactivate = local
    .filter((l) => !liveIds.has(l.n8nWorkflowId) && l.isActive)
    .map((l) => l.id);
  return { upserts, deactivate };
}
```

```ts
// packages/shared/src/index.ts — append
export * from "./automation/reconcile.js";
```

- [ ] **Step 3: Run and confirm pass**

Run: `pnpm --filter @whatapp/shared test automation/reconcile`
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add packages/shared/src/automation/reconcile.ts \
        packages/shared/src/automation/reconcile.test.ts \
        packages/shared/src/index.ts
git commit -m "feat: reconcileFlows additive sync planner (Phase 5 Task 9)"
```

---

## Task 10: FlowsService — sync from n8n (FR-06.1, AC-06.1)

**Files:**
- Create: `apps/api/src/flows/flows.service.ts`
- Create: `apps/api/src/flows/flows.service.test.ts`

- [ ] **Step 1: Write the failing test**

```ts
import { describe, it, expect, vi } from "vitest";
import { FlowsService } from "./flows.service.js";

function mockPrisma() {
  return {
    flow: {
      findMany: vi.fn(),
      upsert: vi.fn(),
      update: vi.fn(),
      findUnique: vi.fn(),
    },
  };
}

function mockN8nClient() {
  return {
    listWorkflows: vi.fn(),
    setActive: vi.fn(),
    listExecutions: vi.fn(),
    triggerWebhook: vi.fn(),
  };
}

function newService(prisma: ReturnType<typeof mockPrisma>,
                    client: ReturnType<typeof mockN8nClient>) {
  return new FlowsService(prisma as never, {
    getClient: () => Promise.resolve(client as never),
  });
}

describe("FlowsService.sync", () => {
  it("upserts each n8n workflow and deactivates rows missing from n8n", async () => {
    const prisma = mockPrisma();
    const client = mockN8nClient();
    prisma.flow.findMany.mockResolvedValue([
      { id: "row-A", n8nWorkflowId: "wf-A", name: "A", isActive: true },
      { id: "row-X", n8nWorkflowId: "wf-X", name: "gone", isActive: true },
    ]);
    client.listWorkflows.mockResolvedValue([
      { id: "wf-A", name: "A renamed", active: true },
      { id: "wf-B", name: "B", active: false },
    ]);
    const svc = newService(prisma, client);

    const result = await svc.sync();

    expect(client.listWorkflows).toHaveBeenCalled();
    expect(prisma.flow.upsert).toHaveBeenCalledTimes(2);
    // row-X should be deactivated, never deleted.
    expect(prisma.flow.update).toHaveBeenCalledWith({
      where: { id: "row-X" },
      data: { isActive: false, lastSyncedAt: expect.any(Date) },
    });
    expect(result.upserted).toBe(2);
    expect(result.deactivated).toBe(1);
  });
});
```

Run: `pnpm --filter @whatapp/api test flows/flows.service`
Expected: FAIL — file missing.

- [ ] **Step 2: Implement**

```ts
// apps/api/src/flows/flows.service.ts
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { reconcileFlows, type N8nClient } from "@whatapp/shared";
import { PrismaService } from "../prisma/prisma.service";

/** Resolves an `N8nClient` from the encrypted connections store. */
export interface N8nClientProvider {
  getClient(): Promise<N8nClient>;
}

@Injectable()
export class FlowsService {
  private readonly logger = new Logger(FlowsService.name);

  constructor(
    private readonly prisma: PrismaService,
    private readonly clients: N8nClientProvider,
  ) {}

  /** FR-06.1 / AC-06.1 — reconcile the registry from n8n. */
  async sync(): Promise<{ upserted: number; deactivated: number }> {
    const client = await this.clients.getClient();
    const remote = await client.listWorkflows();
    const local = await this.prisma.flow.findMany();
    const plan = reconcileFlows(
      local.map((l: { id: string; n8nWorkflowId: string; name: string; isActive: boolean }) => ({
        id: l.id,
        n8nWorkflowId: l.n8nWorkflowId,
        name: l.name,
        isActive: l.isActive,
      })),
      remote.map((w) => ({ id: w.id, name: w.name, active: w.active })),
    );
    const now = new Date();
    for (const u of plan.upserts) {
      await this.prisma.flow.upsert({
        where: { n8nWorkflowId: u.n8nWorkflowId },
        create: {
          n8nWorkflowId: u.n8nWorkflowId,
          name: u.name,
          isActive: u.isActive,
          lastSyncedAt: now,
        },
        update: { name: u.name, isActive: u.isActive, lastSyncedAt: now },
      });
    }
    for (const id of plan.deactivate) {
      await this.prisma.flow.update({
        where: { id },
        data: { isActive: false, lastSyncedAt: now },
      });
    }
    return { upserted: plan.upserts.length, deactivated: plan.deactivate.length };
  }
}
```

- [ ] **Step 3: Run and confirm pass**

Run: `pnpm --filter @whatapp/api test flows/flows.service`
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add apps/api/src/flows/flows.service.ts \
        apps/api/src/flows/flows.service.test.ts
git commit -m "feat: FlowsService.sync from n8n (Phase 5 Task 10)"
```

---

## Task 11: FlowsService — list/get/patch/activate/deactivate/executions

**Files:**
- Modify: `apps/api/src/flows/flows.service.ts`
- Modify: `apps/api/src/flows/flows.service.test.ts`

- [ ] **Step 1: Add tests for each method**

Append to `flows.service.test.ts`:

```ts
describe("FlowsService.list/get/patch", () => {
  it("lists flows ordered by name (FR-06.2)", async () => {
    const prisma = mockPrisma();
    prisma.flow.findMany.mockResolvedValue([{ id: "r1", name: "A" }]);
    const svc = newService(prisma, mockN8nClient());
    const rows = await svc.list();
    expect(prisma.flow.findMany).toHaveBeenCalledWith({ orderBy: { name: "asc" } });
    expect(rows).toHaveLength(1);
  });

  it("patches kind/description/triggerWebhookUrl only (FR-06.2)", async () => {
    const prisma = mockPrisma();
    prisma.flow.findUnique.mockResolvedValue({ id: "r1" });
    prisma.flow.update.mockResolvedValue({ id: "r1", kind: "reminder" });
    const svc = newService(prisma, mockN8nClient());
    await svc.patch("r1", {
      kind: "reminder",
      description: "x",
      triggerWebhookUrl: "https://n8n/webhook/x",
    });
    expect(prisma.flow.update).toHaveBeenCalledWith({
      where: { id: "r1" },
      data: {
        kind: "reminder",
        description: "x",
        triggerWebhookUrl: "https://n8n/webhook/x",
      },
    });
  });

  it("throws NotFound when patching a missing flow", async () => {
    const prisma = mockPrisma();
    prisma.flow.findUnique.mockResolvedValue(null);
    const svc = newService(prisma, mockN8nClient());
    await expect(svc.patch("missing", { kind: "other" })).rejects.toThrow(/not found/i);
  });
});

describe("FlowsService.setActive (FR-06.3, AC-06.2)", () => {
  it("calls n8n setActive and mirrors locally", async () => {
    const prisma = mockPrisma();
    const client = mockN8nClient();
    prisma.flow.findUnique.mockResolvedValue({ id: "r1", n8nWorkflowId: "wf-1" });
    prisma.flow.update.mockResolvedValue({ id: "r1", isActive: true });
    const svc = newService(prisma, client);
    await svc.setActive("r1", true);
    expect(client.setActive).toHaveBeenCalledWith("wf-1", true);
    expect(prisma.flow.update).toHaveBeenCalledWith({
      where: { id: "r1" },
      data: { isActive: true },
    });
  });
});

describe("FlowsService.executions (FR-06.4)", () => {
  it("reads through to n8n live, never persists", async () => {
    const prisma = mockPrisma();
    const client = mockN8nClient();
    prisma.flow.findUnique.mockResolvedValue({ id: "r1", n8nWorkflowId: "wf-1" });
    client.listExecutions.mockResolvedValue([
      { id: 7, workflowId: "wf-1", status: "success" },
    ]);
    const svc = newService(prisma, client);
    const result = await svc.executions("r1");
    expect(client.listExecutions).toHaveBeenCalledWith("wf-1");
    expect(result).toHaveLength(1);
  });
});
```

Run: `pnpm --filter @whatapp/api test flows/flows.service`
Expected: 4 new tests FAIL.

- [ ] **Step 2: Implement**

Append to `flows.service.ts`:

```ts
  /** FR-06.2 — list all flows. */
  async list(): Promise<unknown[]> {
    return this.prisma.flow.findMany({ orderBy: { name: "asc" } });
  }

  /** FR-06.2 — fetch one flow by local id. */
  async get(id: string): Promise<unknown> {
    const row = await this.prisma.flow.findUnique({ where: { id } });
    if (!row) throw new NotFoundException(`Flow ${id} not found`);
    return row;
  }

  /** FR-06.2 — set platform-side metadata only. */
  async patch(
    id: string,
    dto: {
      kind?: "conversational" | "reminder" | "integration" | "other";
      description?: string | null;
      triggerWebhookUrl?: string | null;
    },
  ): Promise<unknown> {
    const row = await this.prisma.flow.findUnique({ where: { id } });
    if (!row) throw new NotFoundException(`Flow ${id} not found`);
    const data: Record<string, unknown> = {};
    if (dto.kind !== undefined) data["kind"] = dto.kind;
    if (dto.description !== undefined) data["description"] = dto.description;
    if (dto.triggerWebhookUrl !== undefined)
      data["triggerWebhookUrl"] = dto.triggerWebhookUrl;
    return this.prisma.flow.update({ where: { id }, data });
  }

  /** FR-06.3 / AC-06.2 — toggle the workflow in n8n and mirror locally. */
  async setActive(id: string, active: boolean): Promise<unknown> {
    const row = await this.prisma.flow.findUnique({ where: { id } });
    if (!row) throw new NotFoundException(`Flow ${id} not found`);
    const client = await this.clients.getClient();
    await client.setActive(row.n8nWorkflowId, active);
    return this.prisma.flow.update({
      where: { id },
      data: { isActive: active },
    });
  }

  /** FR-06.4 — live read-through, never persisted. */
  async executions(id: string): Promise<unknown[]> {
    const row = await this.prisma.flow.findUnique({ where: { id } });
    if (!row) throw new NotFoundException(`Flow ${id} not found`);
    const client = await this.clients.getClient();
    return client.listExecutions(row.n8nWorkflowId);
  }
```

- [ ] **Step 3: Run and confirm pass**

Run: `pnpm --filter @whatapp/api test flows/flows.service`
Expected: all tests PASS.

- [ ] **Step 4: Commit**

```bash
git add apps/api/src/flows/flows.service.ts \
        apps/api/src/flows/flows.service.test.ts
git commit -m "feat: FlowsService list/get/patch/setActive/executions (Phase 5 Task 11)"
```

---

## Task 12: N8nClientProvider — resolves N8nClient from connections

**Files:**
- Create: `apps/api/src/flows/n8n-client.provider.ts`
- Create: `apps/api/src/flows/n8n-client.provider.test.ts`

- [ ] **Step 1: Write the failing test**

```ts
import { describe, it, expect, vi } from "vitest";
import { N8nClientProviderService } from "./n8n-client.provider.js";

describe("N8nClientProviderService", () => {
  it("builds an N8nClient from the encrypted n8n connection", async () => {
    const connections = {
      getDecrypted: vi.fn().mockResolvedValue({
        settings: { baseUrl: "https://n8n.test" },
        secrets: { apiKey: "k", callbackSecret: "s" },
      }),
    };
    const svc = new N8nClientProviderService(connections as never);
    const client = await svc.getClient();
    expect(connections.getDecrypted).toHaveBeenCalledWith("n8n");
    // It should be a real N8nClient with the right base.
    expect(client).toBeDefined();
  });

  it("throws if no n8n connection is configured", async () => {
    const connections = { getDecrypted: vi.fn().mockResolvedValue(null) };
    const svc = new N8nClientProviderService(connections as never);
    await expect(svc.getClient()).rejects.toThrow(/n8n connection/i);
  });
});
```

Run: `pnpm --filter @whatapp/api test flows/n8n-client.provider`
Expected: FAIL.

- [ ] **Step 2: Implement**

```ts
// apps/api/src/flows/n8n-client.provider.ts
import { Injectable } from "@nestjs/common";
import { N8nClient } from "@whatapp/shared";
import { ConnectionsService } from "../connections/connections.service";

@Injectable()
export class N8nClientProviderService {
  constructor(private readonly connections: ConnectionsService) {}

  async getClient(): Promise<N8nClient> {
    const conn = await this.connections.getDecrypted("n8n");
    if (!conn) {
      throw new Error(
        "No n8n connection is configured — set provider 'n8n' in connections",
      );
    }
    return new N8nClient({
      baseUrl: String(conn.settings["baseUrl"] ?? ""),
      apiKey: conn.secrets["apiKey"] ?? "",
      callbackSecret: conn.secrets["callbackSecret"] ?? "",
    });
  }
}
```

- [ ] **Step 3: Run and confirm pass**

Run: `pnpm --filter @whatapp/api test flows/n8n-client.provider`
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add apps/api/src/flows/n8n-client.provider.ts \
        apps/api/src/flows/n8n-client.provider.test.ts
git commit -m "feat: N8nClientProvider builds n8n client from connections (Phase 5 Task 12)"
```

---

## Task 13: FlowsController + FlowsModule wiring

**Files:**
- Create: `apps/api/src/flows/dto.ts`
- Create: `apps/api/src/flows/flows.controller.ts`
- Create: `apps/api/src/flows/flows.controller.test.ts`
- Create: `apps/api/src/flows/flows.module.ts`
- Modify: `apps/api/src/app.module.ts`

- [ ] **Step 1: Write the failing controller test**

```ts
import { describe, it, expect, vi } from "vitest";
import { FlowsController } from "./flows.controller.js";

function mockService() {
  return {
    list: vi.fn().mockResolvedValue([{ id: "r1", name: "A" }]),
    get: vi.fn().mockResolvedValue({ id: "r1" }),
    sync: vi.fn().mockResolvedValue({ upserted: 2, deactivated: 1 }),
    patch: vi.fn().mockResolvedValue({ id: "r1", kind: "reminder" }),
    setActive: vi.fn().mockResolvedValue({ id: "r1", isActive: true }),
    executions: vi.fn().mockResolvedValue([{ id: 7 }]),
  };
}

describe("FlowsController", () => {
  it("GET /api/flows -> list", async () => {
    const svc = mockService();
    const ctrl = new FlowsController(svc as never);
    expect(await ctrl.list()).toEqual([{ id: "r1", name: "A" }]);
  });
  it("POST /api/flows/sync -> sync", async () => {
    const svc = mockService();
    const ctrl = new FlowsController(svc as never);
    expect(await ctrl.sync()).toEqual({ upserted: 2, deactivated: 1 });
  });
  it("PATCH /api/flows/:id parses kind/description/triggerWebhookUrl", async () => {
    const svc = mockService();
    const ctrl = new FlowsController(svc as never);
    await ctrl.patch("r1", { kind: "reminder", description: "x" });
    expect(svc.patch).toHaveBeenCalledWith("r1", {
      kind: "reminder",
      description: "x",
    });
  });
  it("POST /api/flows/:id/activate -> setActive(true)", async () => {
    const svc = mockService();
    const ctrl = new FlowsController(svc as never);
    await ctrl.activate("r1");
    expect(svc.setActive).toHaveBeenCalledWith("r1", true);
  });
  it("POST /api/flows/:id/deactivate -> setActive(false)", async () => {
    const svc = mockService();
    const ctrl = new FlowsController(svc as never);
    await ctrl.deactivate("r1");
    expect(svc.setActive).toHaveBeenCalledWith("r1", false);
  });
  it("GET /api/flows/:id/executions -> executions", async () => {
    const svc = mockService();
    const ctrl = new FlowsController(svc as never);
    expect(await ctrl.executions("r1")).toEqual([{ id: 7 }]);
  });
});
```

Run: `pnpm --filter @whatapp/api test flows/flows.controller`
Expected: FAIL.

- [ ] **Step 2: Implement DTO**

```ts
// apps/api/src/flows/dto.ts
import { z } from "zod";

export const patchFlowSchema = z
  .object({
    kind: z.enum(["conversational", "reminder", "integration", "other"]).optional(),
    description: z.string().nullable().optional(),
    triggerWebhookUrl: z.string().url().nullable().optional(),
  })
  .strict();
export type PatchFlowDto = z.infer<typeof patchFlowSchema>;
```

- [ ] **Step 3: Implement controller**

```ts
// apps/api/src/flows/flows.controller.ts
import {
  Controller, Get, Post, Patch, Param, Body, HttpCode, HttpStatus,
} from "@nestjs/common";
import { Roles } from "../auth/roles.decorator";
import { FlowsService } from "./flows.service";
import { patchFlowSchema } from "./dto";

@Controller("flows")
export class FlowsController {
  constructor(private readonly flows: FlowsService) {}

  @Get()
  @Roles("viewer", "marketing", "admin")
  async list(): Promise<unknown[]> { return this.flows.list(); }

  @Get(":id")
  @Roles("viewer", "marketing", "admin")
  async get(@Param("id") id: string): Promise<unknown> { return this.flows.get(id); }

  @Post("sync")
  @Roles("admin")
  @HttpCode(HttpStatus.OK)
  async sync(): Promise<unknown> { return this.flows.sync(); }

  @Patch(":id")
  @Roles("admin")
  async patch(@Param("id") id: string, @Body() body: unknown): Promise<unknown> {
    const dto = patchFlowSchema.parse(body);
    return this.flows.patch(id, dto);
  }

  @Post(":id/activate")
  @Roles("admin")
  @HttpCode(HttpStatus.OK)
  async activate(@Param("id") id: string): Promise<unknown> {
    return this.flows.setActive(id, true);
  }

  @Post(":id/deactivate")
  @Roles("admin")
  @HttpCode(HttpStatus.OK)
  async deactivate(@Param("id") id: string): Promise<unknown> {
    return this.flows.setActive(id, false);
  }

  @Get(":id/executions")
  @Roles("viewer", "marketing", "admin")
  async executions(@Param("id") id: string): Promise<unknown[]> {
    return this.flows.executions(id);
  }
}
```

- [ ] **Step 4: Module**

```ts
// apps/api/src/flows/flows.module.ts
import { Module } from "@nestjs/common";
import { ConnectionsModule } from "../connections/connections.module";
import { FlowsService } from "./flows.service";
import { FlowsController } from "./flows.controller";
import { N8nClientProviderService } from "./n8n-client.provider";

@Module({
  imports: [ConnectionsModule],
  controllers: [FlowsController],
  providers: [
    N8nClientProviderService,
    { provide: FlowsService, useFactory: (p: never, c: never) =>
        new FlowsService(p, c), inject: [
        // PrismaService is global; supplied by Nest DI automatically.
        require("../prisma/prisma.service").PrismaService,
        N8nClientProviderService,
      ] },
  ],
  exports: [FlowsService, N8nClientProviderService],
})
export class FlowsModule {}
```

- [ ] **Step 5: Register in AppModule**

Edit `apps/api/src/app.module.ts`:
- Import `FlowsModule` from `"./flows/flows.module"`
- Add `FlowsModule` to the `imports` array.

- [ ] **Step 6: Run and confirm pass**

Run:
```
pnpm --filter @whatapp/api test flows/flows.controller
pnpm --filter @whatapp/api typecheck
```
Expected: PASS.

- [ ] **Step 7: Commit**

```bash
git add apps/api/src/flows/dto.ts \
        apps/api/src/flows/flows.controller.ts \
        apps/api/src/flows/flows.controller.test.ts \
        apps/api/src/flows/flows.module.ts \
        apps/api/src/app.module.ts
git commit -m "feat: FlowsController + FlowsModule wiring (Phase 5 Task 13)"
```

---

## Task 14: DispatchService — real flow dispatch (FR-06.6, FR-06.7)

**Files:**
- Create: `apps/api/src/flows/dispatch.service.ts`
- Create: `apps/api/src/flows/dispatch.service.test.ts`
- Modify: `apps/api/src/flows/flows.module.ts`

- [ ] **Step 1: Write the failing test**

```ts
import { describe, it, expect, vi } from "vitest";
import { DispatchService } from "./dispatch.service.js";

function build() {
  const prisma = {
    flow: { findMany: vi.fn() },
    message: { findUnique: vi.fn() },
  };
  const client = { triggerWebhook: vi.fn().mockResolvedValue(undefined) };
  const provider = { getClient: () => Promise.resolve(client) };
  const svc = new DispatchService(prisma as never, provider as never);
  return { prisma, client, svc };
}

describe("DispatchService.dispatch", () => {
  it("routes to the entry flow and POSTs the inbound payload (FR-06.6)", async () => {
    const { prisma, client, svc } = build();
    prisma.flow.findMany.mockResolvedValue([
      { id: "flow-main", kind: "conversational", isActive: true,
        triggerWebhookUrl: "https://n8n/webhook/main",
        name: "Main Distributer" },
    ]);
    prisma.message.findUnique.mockResolvedValue({
      id: "m1", waId: "9715551", type: "text", body: "hi",
    });

    const r = await svc.dispatch({ messageId: "m1" });

    expect(client.triggerWebhook).toHaveBeenCalledWith(
      "https://n8n/webhook/main",
      expect.objectContaining({ messageId: "m1", waId: "9715551" }),
    );
    expect(r.dispatched).toBe(true);
    expect(r.flowId).toBe("flow-main");
  });

  it("returns dispatched=false when no flow is registered (FR-06.7 no silent drop)", async () => {
    const { prisma, svc } = build();
    prisma.flow.findMany.mockResolvedValue([]);
    prisma.message.findUnique.mockResolvedValue({
      id: "m1", waId: "9715551", type: "text", body: "hi",
    });
    const r = await svc.dispatch({ messageId: "m1" });
    expect(r.dispatched).toBe(false);
    expect(r.reason).toMatch(/no flow/i);
  });
});
```

Run: `pnpm --filter @whatapp/api test flows/dispatch.service`
Expected: FAIL.

- [ ] **Step 2: Implement**

```ts
// apps/api/src/flows/dispatch.service.ts
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { pickFlow } from "@whatapp/shared";
import { PrismaService } from "../prisma/prisma.service";
import { N8nClientProviderService } from "./n8n-client.provider";

export interface DispatchInput { messageId: string; }

export interface DispatchResult {
  dispatched: boolean;
  flowId?: string;
  reason?: string;
}

@Injectable()
export class DispatchService {
  private readonly logger = new Logger(DispatchService.name);
  constructor(
    private readonly prisma: PrismaService,
    private readonly clients: N8nClientProviderService,
  ) {}

  /** FR-06.6 — resolve target flow then POST to its webhook URL. */
  async dispatch(input: DispatchInput): Promise<DispatchResult> {
    const msg = await this.prisma.message.findUnique({
      where: { id: input.messageId },
    });
    if (!msg) throw new NotFoundException(`Message ${input.messageId} not found`);

    const registry = await this.prisma.flow.findMany();
    const flow = pickFlow(
      { waId: msg.waId },
      {}, // session tracking is owned by n8n; FR-06.6 falls back to entry flow.
      registry.map((f: { id: string; kind: string; isActive: boolean; triggerWebhookUrl: string | null; name: string }) => ({
        id: f.id,
        kind: f.kind as never,
        isActive: f.isActive,
        triggerWebhookUrl: f.triggerWebhookUrl,
        name: f.name,
      })),
    );
    if (!flow || !flow.triggerWebhookUrl) {
      this.logger.warn(`No flow registered for inbound ${input.messageId}`);
      return { dispatched: false, reason: "no flow registered" };
    }
    const client = await this.clients.getClient();
    await client.triggerWebhook(flow.triggerWebhookUrl, {
      messageId: msg.id,
      waId: msg.waId,
      type: msg.type,
      body: msg.body,
    });
    return { dispatched: true, flowId: flow.id };
  }
}
```

- [ ] **Step 3: Register provider**

Edit `apps/api/src/flows/flows.module.ts`:
- Add `DispatchService` to `providers` and `exports`.

- [ ] **Step 4: Run and confirm pass**

Run: `pnpm --filter @whatapp/api test flows/dispatch.service`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add apps/api/src/flows/dispatch.service.ts \
        apps/api/src/flows/dispatch.service.test.ts \
        apps/api/src/flows/flows.module.ts
git commit -m "feat: DispatchService real flow dispatch (Phase 5 Task 14)"
```

---

## Task 15: Internal dispatch endpoint + rewire worker stub

**Files:**
- Create: `apps/api/src/flows/internal-dispatch.controller.ts`
- Create: `apps/api/src/flows/internal-dispatch.controller.test.ts`
- Modify: `apps/api/src/flows/flows.module.ts`
- Modify: `apps/worker/src/lib/internal-api.ts`
- Modify: `apps/worker/src/lib/internal-api.test.ts`
- Modify: `apps/worker/src/processors/flow-dispatch.processor.ts`
- Modify: `apps/worker/src/processors/flow-dispatch.processor.test.ts`
- Modify: `apps/worker/src/main.ts`

- [ ] **Step 1: Failing controller test**

```ts
// apps/api/src/flows/internal-dispatch.controller.test.ts
import { describe, it, expect, vi } from "vitest";
import { InternalDispatchController } from "./internal-dispatch.controller.js";

describe("InternalDispatchController", () => {
  it("validates body and forwards to DispatchService.dispatch", async () => {
    const svc = { dispatch: vi.fn().mockResolvedValue({ dispatched: true, flowId: "f1" }) };
    const ctrl = new InternalDispatchController(svc as never);
    const result = await ctrl.dispatch({ messageId: "m1" });
    expect(svc.dispatch).toHaveBeenCalledWith({ messageId: "m1" });
    expect(result).toEqual({ dispatched: true, flowId: "f1" });
  });

  it("rejects a body without messageId", async () => {
    const svc = { dispatch: vi.fn() };
    const ctrl = new InternalDispatchController(svc as never);
    await expect(ctrl.dispatch({} as never)).rejects.toThrow();
  });
});
```

- [ ] **Step 2: Implement controller**

```ts
// apps/api/src/flows/internal-dispatch.controller.ts
import {
  Controller, Post, Body, UseGuards, HttpCode, HttpStatus,
} from "@nestjs/common";
import { z } from "zod";
import { Public } from "../auth/roles.decorator";
import { CallbackAuthGuard } from "../messages/callback-auth.guard";
import { DispatchService } from "./dispatch.service";

const dispatchSchema = z.object({ messageId: z.string().min(1) });

@Controller("internal/flows")
@Public()
@UseGuards(CallbackAuthGuard)
export class InternalDispatchController {
  constructor(private readonly dispatch_: DispatchService) {}

  @Post("dispatch")
  @HttpCode(HttpStatus.OK)
  async dispatch(@Body() body: unknown): Promise<unknown> {
    const dto = dispatchSchema.parse(body);
    return this.dispatch_.dispatch(dto);
  }
}
```

- [ ] **Step 3: Add controller to module**

Add `InternalDispatchController` to `controllers` in
`apps/api/src/flows/flows.module.ts`, and import `MessagesModule` so the
`CallbackAuthGuard` can resolve `ConnectionsService`.

- [ ] **Step 4: Run and confirm controller test passes**

Run: `pnpm --filter @whatapp/api test flows/internal-dispatch.controller`
Expected: PASS.

- [ ] **Step 5: Extend `InternalApiClient`**

Edit `apps/worker/src/lib/internal-api.ts`:

```ts
export interface InternalApiClient {
  startCampaign: (campaignId: string) => Promise<void>;
  sendRecipient: (recipientId: string) => Promise<SendRecipientResult>;
  /** New (Phase 5). POSTs to /internal/flows/dispatch. */
  dispatchFlow: (messageId: string) => Promise<{ dispatched: boolean; flowId?: string }>;
}
```

In `createInternalApiClient` add `postJson` (a variant that sends a body) and
implement:
```ts
async dispatchFlow(messageId: string) {
  const res = await doFetch(`${config.baseUrl}/internal/flows/dispatch`, {
    method: "POST", headers,
    body: JSON.stringify({ messageId }),
  });
  if (!res.ok) throw new Error(`internal API /flows/dispatch returned ${res.status}`);
  return res.json() as Promise<{ dispatched: boolean; flowId?: string }>;
}
```

Add a test in `internal-api.test.ts`:
```ts
it("dispatchFlow POSTs messageId to /internal/flows/dispatch", async () => {
  const fetchImpl = vi.fn().mockResolvedValue({ ok: true,
    json: async () => ({ dispatched: true, flowId: "f1" }) });
  const c = createInternalApiClient({
    baseUrl: "http://api", callbackSecret: "s", fetchImpl,
  });
  const r = await c.dispatchFlow("m1");
  expect(fetchImpl).toHaveBeenCalledWith(
    "http://api/internal/flows/dispatch",
    expect.objectContaining({
      method: "POST",
      body: JSON.stringify({ messageId: "m1" }),
    }),
  );
  expect(r).toEqual({ dispatched: true, flowId: "f1" });
});
```

- [ ] **Step 6: Rewrite the processor**

Replace the contents of `apps/worker/src/processors/flow-dispatch.processor.ts`:

```ts
export interface FlowDispatchJobData {
  messageId: string;
  waId: string;
  type: string;
  body: string | null;
}

export interface FlowDispatchDeps {
  dispatchFlow: (messageId: string) => Promise<{ dispatched: boolean; flowId?: string }>;
  log: (message: string) => void;
}

/**
 * Phase 5: thin shim — the job calls the API's /internal/flows/dispatch,
 * which holds the real logic (DispatchService). All retries come from BullMQ
 * job options (FR-06.7).
 */
export async function processFlowDispatchJob(
  data: FlowDispatchJobData,
  deps: FlowDispatchDeps,
): Promise<void> {
  const result = await deps.dispatchFlow(data.messageId);
  if (!result.dispatched) {
    deps.log(
      `flow-dispatch: no flow registered for inbound ${data.messageId} ` +
        `(type=${data.type})`,
    );
    return;
  }
  deps.log(`flow-dispatch: ${data.messageId} -> ${result.flowId}`);
}
```

Replace the processor test:
```ts
import { describe, it, expect, vi } from "vitest";
import { processFlowDispatchJob } from "./flow-dispatch.processor.js";

describe("processFlowDispatchJob", () => {
  it("delegates to dispatchFlow and logs when no flow is registered", async () => {
    const log = vi.fn();
    const dispatchFlow = vi.fn().mockResolvedValue({ dispatched: false });
    await processFlowDispatchJob(
      { messageId: "m1", waId: "971", type: "text", body: "hi" },
      { dispatchFlow, log },
    );
    expect(dispatchFlow).toHaveBeenCalledWith("m1");
    expect(log).toHaveBeenCalledWith(expect.stringMatching(/no flow/));
  });

  it("logs the flow id when dispatched", async () => {
    const log = vi.fn();
    const dispatchFlow = vi.fn().mockResolvedValue({ dispatched: true, flowId: "f1" });
    await processFlowDispatchJob(
      { messageId: "m1", waId: "971", type: "text", body: "hi" },
      { dispatchFlow, log },
    );
    expect(log).toHaveBeenCalledWith(expect.stringContaining("f1"));
  });
});
```

- [ ] **Step 7: Rewire worker bootstrap**

Edit `apps/worker/src/main.ts` — replace the `if (queueName === "flow-dispatch")`
branch with:
```ts
        if (queueName === "flow-dispatch") {
          return processFlowDispatchJob(job.data as FlowDispatchJobData, {
            dispatchFlow: (id) => internalApi.dispatchFlow(id),
            log: (msg) => console.log(`[flow-dispatch] ${msg}`),
          });
        }
```
Remove the now-unused `loadN8nConfig` / `N8nClient` imports if they are no
longer referenced (they may still be used by media-download — leave them in
that case).

- [ ] **Step 8: Run and confirm pass**

Run:
```
pnpm --filter @whatapp/api test flows
pnpm --filter @whatapp/worker test flow-dispatch
pnpm --filter @whatapp/worker test internal-api
pnpm typecheck
```
Expected: all PASS.

- [ ] **Step 9: Commit**

```bash
git add apps/api/src/flows/internal-dispatch.controller.ts \
        apps/api/src/flows/internal-dispatch.controller.test.ts \
        apps/api/src/flows/flows.module.ts \
        apps/worker/src/lib/internal-api.ts \
        apps/worker/src/lib/internal-api.test.ts \
        apps/worker/src/processors/flow-dispatch.processor.ts \
        apps/worker/src/processors/flow-dispatch.processor.test.ts \
        apps/worker/src/main.ts
git commit -m "feat: internal dispatch endpoint + worker shim (Phase 5 Task 15)"
```

---

## Task 16: Internal callbacks — `/internal/contacts/upsert`

**Files:**
- Create: `apps/api/src/internal/internal.module.ts`
- Create: `apps/api/src/internal/dto.ts`
- Create: `apps/api/src/internal/internal-contacts.controller.ts`
- Create: `apps/api/src/internal/internal-contacts.controller.test.ts`
- Modify: `apps/api/src/app.module.ts`

- [ ] **Step 1: Failing test**

```ts
// apps/api/src/internal/internal-contacts.controller.test.ts
import { describe, it, expect, vi } from "vitest";
import { InternalContactsController } from "./internal-contacts.controller.js";

describe("InternalContactsController", () => {
  it("upserts a contact by waId (FR-06.8)", async () => {
    const prisma = {
      contact: {
        upsert: vi.fn().mockResolvedValue({ id: "c1", waId: "9715551" }),
      },
    };
    const ctrl = new InternalContactsController(prisma as never);
    const r = await ctrl.upsert({
      waId: "9715551",
      profileName: "Ahmed",
      displayName: null,
      tags: ["lead"],
      attributes: { city: "Dubai" },
    });
    expect(prisma.contact.upsert).toHaveBeenCalledWith(
      expect.objectContaining({
        where: { waId: "9715551" },
        create: expect.objectContaining({ waId: "9715551", profileName: "Ahmed" }),
        update: expect.objectContaining({ profileName: "Ahmed" }),
      }),
    );
    expect(r).toEqual({ id: "c1", waId: "9715551" });
  });
  it("rejects a body without waId (FR-06.9)", async () => {
    const prisma = { contact: { upsert: vi.fn() } };
    const ctrl = new InternalContactsController(prisma as never);
    await expect(ctrl.upsert({} as never)).rejects.toThrow();
  });
});
```

Run: `pnpm --filter @whatapp/api test internal/internal-contacts`
Expected: FAIL.

- [ ] **Step 2: Implement DTOs and controller**

```ts
// apps/api/src/internal/dto.ts
import { z } from "zod";

export const upsertContactSchema = z.object({
  waId: z.string().min(1),
  profileName: z.string().nullable().optional(),
  displayName: z.string().nullable().optional(),
  country: z.string().nullable().optional(),
  tags: z.array(z.string()).optional(),
  attributes: z.record(z.unknown()).optional(),
}).strict();
export type UpsertContactDto = z.infer<typeof upsertContactSchema>;

export const consentCallbackSchema = z.object({
  waId: z.string().min(1),
  state: z.enum(["opted_in", "opted_out", "unknown"]),
  basis: z.string().nullable().optional(),
  source: z.string().min(1),
  note: z.string().nullable().optional(),
}).strict();
export type ConsentCallbackDto = z.infer<typeof consentCallbackSchema>;

export const followupCallbackSchema = z.object({
  leadId: z.string().min(1),
  contactId: z.string().nullable().optional(),
  leadName: z.string().nullable().optional(),
  agentId: z.string().nullable().optional(),
  agentPhone: z.string().nullable().optional(),
  agentName: z.string().nullable().optional(),
  /** Optional — when present, the engine accepts it; otherwise classifyLeadTier runs. */
  tier: z.enum(["hot", "bulk"]).optional(),
  /** Optional — when present, this becomes the status (e.g. resolved). */
  status: z
    .enum(["pending", "awaiting_reply", "escalated", "resolved"])
    .optional(),
  intakeAt: z.string().datetime().optional(),
}).strict();
export type FollowupCallbackDto = z.infer<typeof followupCallbackSchema>;
```

```ts
// apps/api/src/internal/internal-contacts.controller.ts
import {
  Controller, Post, Body, UseGuards, HttpCode, HttpStatus,
} from "@nestjs/common";
import { Public } from "../auth/roles.decorator";
import { CallbackAuthGuard } from "../messages/callback-auth.guard";
import { PrismaService } from "../prisma/prisma.service";
import { upsertContactSchema } from "./dto";

@Controller("internal/contacts")
@Public()
@UseGuards(CallbackAuthGuard)
export class InternalContactsController {
  constructor(private readonly prisma: PrismaService) {}

  /** FR-06.8 — idempotent upsert by waId. */
  @Post("upsert")
  @HttpCode(HttpStatus.OK)
  async upsert(@Body() body: unknown): Promise<unknown> {
    const dto = upsertContactSchema.parse(body);
    const data: Record<string, unknown> = {};
    if (dto.profileName !== undefined) data["profileName"] = dto.profileName;
    if (dto.displayName !== undefined) data["displayName"] = dto.displayName;
    if (dto.country !== undefined) data["country"] = dto.country;
    if (dto.tags !== undefined) data["tags"] = dto.tags;
    if (dto.attributes !== undefined) data["attributes"] = dto.attributes;
    return this.prisma.contact.upsert({
      where: { waId: dto.waId },
      create: { waId: dto.waId, ...data },
      update: { ...data, lastSeenAt: new Date() },
    });
  }
}
```

- [ ] **Step 3: Module skeleton**

```ts
// apps/api/src/internal/internal.module.ts
import { Module } from "@nestjs/common";
import { MessagesModule } from "../messages/messages.module";
import { ContactsModule } from "../contacts/contacts.module";
import { InternalContactsController } from "./internal-contacts.controller";

@Module({
  imports: [MessagesModule, ContactsModule],
  controllers: [InternalContactsController],
})
export class InternalModule {}
```

Register `InternalModule` in `apps/api/src/app.module.ts`.

- [ ] **Step 4: Run and confirm pass**

Run: `pnpm --filter @whatapp/api test internal/internal-contacts`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add apps/api/src/internal/internal.module.ts \
        apps/api/src/internal/dto.ts \
        apps/api/src/internal/internal-contacts.controller.ts \
        apps/api/src/internal/internal-contacts.controller.test.ts \
        apps/api/src/app.module.ts
git commit -m "feat: /internal/contacts/upsert callback (Phase 5 Task 16)"
```

---

## Task 17: Internal callbacks — `/internal/consent` (idempotent)

**Files:**
- Create: `apps/api/src/internal/internal-consent.controller.ts`
- Create: `apps/api/src/internal/internal-consent.controller.test.ts`
- Modify: `apps/api/src/internal/internal.module.ts`
- Modify: `apps/api/src/contacts/consent.service.ts` (add `recordIdempotent`)

- [ ] **Step 1: Failing test**

```ts
import { describe, it, expect, vi } from "vitest";
import { InternalConsentController } from "./internal-consent.controller.js";

describe("InternalConsentController", () => {
  it("records a consent state via ConsentService (FR-06.8)", async () => {
    const prisma = {
      contact: {
        findUnique: vi.fn().mockResolvedValue({
          id: "c1", waId: "9715551", consentState: "unknown",
        }),
      },
    };
    const consent = { recordIdempotent: vi.fn().mockResolvedValue({ changed: true }) };
    const ctrl = new InternalConsentController(prisma as never, consent as never);
    const r = await ctrl.record({
      waId: "9715551", state: "opted_in", source: "flow:newleads",
    });
    expect(consent.recordIdempotent).toHaveBeenCalledWith(
      "c1", "9715551",
      expect.objectContaining({ state: "opted_in", source: "flow:newleads" }),
    );
    expect(r).toEqual({ changed: true });
  });

  it("deduplicates an identical consecutive state (FR-06.9 idempotency)", async () => {
    const prisma = {
      contact: {
        findUnique: vi.fn().mockResolvedValue({
          id: "c1", waId: "9715551", consentState: "opted_in",
        }),
      },
    };
    const consent = { recordIdempotent: vi.fn().mockResolvedValue({ changed: false }) };
    const ctrl = new InternalConsentController(prisma as never, consent as never);
    const r = await ctrl.record({
      waId: "9715551", state: "opted_in", source: "flow:newleads",
    });
    expect(r).toEqual({ changed: false });
  });
});
```

- [ ] **Step 2: Add `recordIdempotent` to ConsentService**

Append to `apps/api/src/contacts/consent.service.ts`:

```ts
  /**
   * FR-06.9 — record a consent entry idempotently. If the contact's current
   * snapshot already equals `state`, returns `{ changed: false }` and writes
   * nothing. Otherwise delegates to the existing transactional writer.
   */
  async recordIdempotent(
    contactId: string,
    waId: string,
    input: RecordConsentInput,
  ): Promise<{ changed: boolean }> {
    const contact = await this.prisma.contact.findUnique({
      where: { id: contactId },
    });
    if (!contact) {
      throw new NotFoundException(`Contact ${contactId} not found`);
    }
    if (contact.consentState === input.state) {
      return { changed: false };
    }
    await this.writeConsent(contactId, waId, input);
    return { changed: true };
  }
```

Add a unit test in `consent.service.test.ts`:
```ts
it("recordIdempotent skips a no-op write when state is unchanged", async () => {
  // Arrange prisma findUnique to return consentState=opted_in;
  // Act with state=opted_in; assert no writeConsent call.
  // (mirror the existing ConsentService test pattern in this file)
});
```

- [ ] **Step 3: Implement controller**

```ts
// apps/api/src/internal/internal-consent.controller.ts
import {
  Controller, Post, Body, UseGuards, NotFoundException,
  HttpCode, HttpStatus,
} from "@nestjs/common";
import { Public } from "../auth/roles.decorator";
import { CallbackAuthGuard } from "../messages/callback-auth.guard";
import { PrismaService } from "../prisma/prisma.service";
import { ConsentService } from "../contacts/consent.service";
import { consentCallbackSchema } from "./dto";

@Controller("internal/consent")
@Public()
@UseGuards(CallbackAuthGuard)
export class InternalConsentController {
  constructor(
    private readonly prisma: PrismaService,
    private readonly consent: ConsentService,
  ) {}

  @Post()
  @HttpCode(HttpStatus.OK)
  async record(@Body() body: unknown): Promise<{ changed: boolean }> {
    const dto = consentCallbackSchema.parse(body);
    const contact = await this.prisma.contact.findUnique({
      where: { waId: dto.waId },
    });
    if (!contact) throw new NotFoundException(`Contact ${dto.waId} not found`);
    return this.consent.recordIdempotent(contact.id, contact.waId, {
      state: dto.state,
      basis: dto.basis ?? undefined,
      source: dto.source,
      note: dto.note ?? undefined,
    });
  }
}
```

Add `InternalConsentController` to `controllers` in `internal.module.ts`.

- [ ] **Step 4: Run and confirm pass**

Run: `pnpm --filter @whatapp/api test internal/internal-consent`
And: `pnpm --filter @whatapp/api test contacts/consent.service`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add apps/api/src/internal/internal-consent.controller.ts \
        apps/api/src/internal/internal-consent.controller.test.ts \
        apps/api/src/internal/internal.module.ts \
        apps/api/src/contacts/consent.service.ts \
        apps/api/src/contacts/consent.service.test.ts
git commit -m "feat: /internal/consent + recordIdempotent (Phase 5 Task 17)"
```

---

## Task 18: RemindersService — intake creates pending_followups (FR-06.10)

**Files:**
- Create: `apps/api/src/reminders/reminders.service.ts`
- Create: `apps/api/src/reminders/reminders.service.test.ts`
- Create: `apps/api/src/reminders/reminders.module.ts`
- Modify: `apps/api/src/app.module.ts`

- [ ] **Step 1: Failing test**

```ts
import { describe, it, expect, vi } from "vitest";
import { RemindersService } from "./reminders.service.js";

function build() {
  const prisma = {
    pendingFollowup: {
      findUnique: vi.fn(),
      create: vi.fn(),
      findMany: vi.fn(),
      update: vi.fn(),
    },
    contact: { findUnique: vi.fn() },
    conversation: { findUnique: vi.fn() },
  };
  const messages = { send: vi.fn() };
  const svc = new RemindersService(prisma as never, messages as never);
  return { prisma, messages, svc };
}

describe("RemindersService.createForLead", () => {
  it("creates a HOT row when lead is <48h old (FR-06.10)", async () => {
    const { prisma, svc } = build();
    prisma.pendingFollowup.findUnique.mockResolvedValue(null);
    prisma.pendingFollowup.create.mockImplementation(async (a) => a.data);
    const r = await svc.createForLead({
      leadId: "L1",
      leadName: "Ahmed",
      agentPhone: "971555agent",
      agentName: "A",
      createdAt: new Date("2026-05-23T10:00:00Z"),
    }, new Date("2026-05-23T10:01:00Z"));
    expect(prisma.pendingFollowup.create).toHaveBeenCalled();
    expect(r.tier).toBe("hot");
  });

  it("is idempotent by leadId (FR-06.10)", async () => {
    const { prisma, svc } = build();
    prisma.pendingFollowup.findUnique.mockResolvedValue({ id: "row", tier: "hot" });
    const r = await svc.createForLead({
      leadId: "L1",
      createdAt: new Date("2026-05-23T10:00:00Z"),
    }, new Date("2026-05-23T10:01:00Z"));
    expect(prisma.pendingFollowup.create).not.toHaveBeenCalled();
    expect(r.tier).toBe("hot");
  });

  it("creates a BULK row when lead is >=48h old", async () => {
    const { prisma, svc } = build();
    prisma.pendingFollowup.findUnique.mockResolvedValue(null);
    prisma.pendingFollowup.create.mockImplementation(async (a) => a.data);
    const r = await svc.createForLead({
      leadId: "L2",
      createdAt: new Date("2026-05-20T10:00:00Z"),
    }, new Date("2026-05-23T10:01:00Z"));
    expect(r.tier).toBe("bulk");
  });
});
```

Run: `pnpm --filter @whatapp/api test reminders/reminders.service`
Expected: FAIL.

- [ ] **Step 2: Implement**

```ts
// apps/api/src/reminders/reminders.service.ts
import { Injectable, Logger } from "@nestjs/common";
import {
  classifyLeadTier,
  computeDueTimes,
  decideReminderAction,
  type ReminderTier,
} from "@whatapp/shared";
import { PrismaService } from "../prisma/prisma.service";
import { MessageService } from "../messages/message.service";

export interface LeadIntake {
  leadId: string;
  contactId?: string | null;
  leadName?: string | null;
  agentId?: string | null;
  agentPhone?: string | null;
  agentName?: string | null;
  createdAt: Date;
}

@Injectable()
export class RemindersService {
  private readonly logger = new Logger(RemindersService.name);

  constructor(
    private readonly prisma: PrismaService,
    private readonly messages: MessageService,
  ) {}

  /** FR-06.10 — idempotent intake. Same leadId twice -> single row. */
  async createForLead(
    lead: LeadIntake,
    now: Date,
  ): Promise<{ id: string; tier: ReminderTier; status: string }> {
    const existing = await this.prisma.pendingFollowup.findUnique({
      where: { leadId: lead.leadId },
    });
    if (existing) {
      this.logger.debug(`Followup row exists for lead ${lead.leadId}`);
      return existing as never;
    }
    const tier = classifyLeadTier(lead.createdAt, now);
    const { dueAt, dueBy } = computeDueTimes(tier, now);
    const created = await this.prisma.pendingFollowup.create({
      data: {
        leadId: lead.leadId,
        contactId: lead.contactId ?? null,
        leadName: lead.leadName ?? null,
        agentId: lead.agentId ?? null,
        agentPhone: lead.agentPhone ?? null,
        agentName: lead.agentName ?? null,
        tier,
        status: "pending",
        dueAt,
        dueBy,
      },
    });
    return created as never;
  }

  /** Manually mark resolved (called by the tick or by callback). */
  async markResolved(id: string): Promise<void> {
    await this.prisma.pendingFollowup.update({
      where: { id },
      data: { status: "resolved" },
    });
  }
}
```

```ts
// apps/api/src/reminders/reminders.module.ts
import { Module } from "@nestjs/common";
import { MessagesModule } from "../messages/messages.module";
import { RemindersService } from "./reminders.service";

@Module({
  imports: [MessagesModule],
  providers: [RemindersService],
  exports: [RemindersService],
})
export class RemindersModule {}
```

Add `RemindersModule` to `apps/api/src/app.module.ts`.

- [ ] **Step 3: Run and confirm pass**

Run: `pnpm --filter @whatapp/api test reminders`
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add apps/api/src/reminders/reminders.service.ts \
        apps/api/src/reminders/reminders.service.test.ts \
        apps/api/src/reminders/reminders.module.ts \
        apps/api/src/app.module.ts
git commit -m "feat: RemindersService idempotent intake (Phase 5 Task 18)"
```

---

## Task 19: RemindersService.tick — apply scheduler decisions

**Files:**
- Modify: `apps/api/src/reminders/reminders.service.ts`
- Modify: `apps/api/src/reminders/reminders.service.test.ts`

- [ ] **Step 1: Failing tests**

```ts
describe("RemindersService.tick", () => {
  it("calls MessageService.send for each row the scheduler says 'send', as template outside window", async () => {
    const { prisma, messages, svc } = build();
    prisma.pendingFollowup.findMany.mockResolvedValue([
      {
        id: "row-1", leadId: "L1", contactId: "c1", agentPhone: "971555agent",
        tier: "hot", status: "pending", nudgeSentAt: null,
        dueAt: new Date("2026-05-23T10:20:00Z"),
        dueBy: new Date("2026-05-23T10:30:00Z"),
        createdAt: new Date("2026-05-23T10:00:00Z"),
      },
    ]);
    prisma.contact.findUnique.mockResolvedValue({
      id: "c1", waId: "971555agent",
      consentState: "opted_in",
    });
    prisma.conversation.findUnique.mockResolvedValue({
      windowExpiresAt: new Date("2026-05-23T09:00:00Z"), // closed
    });
    messages.send.mockResolvedValue({ ok: true, wamid: "wamid-1" });

    const r = await svc.tick(new Date("2026-05-23T10:21:00Z"));

    expect(messages.send).toHaveBeenCalledWith(
      expect.objectContaining({
        to: "971555agent",
        type: "template",
      }),
      expect.objectContaining({ service: true }),
    );
    expect(prisma.pendingFollowup.update).toHaveBeenCalledWith(
      expect.objectContaining({
        where: { id: "row-1" },
        data: expect.objectContaining({
          status: "awaiting_reply",
          nudgeSentAt: expect.any(Date),
        }),
      }),
    );
    expect(r.sent).toBe(1);
  });

  it("skips and resolves a row whose contact is opted_out", async () => {
    const { prisma, messages, svc } = build();
    prisma.pendingFollowup.findMany.mockResolvedValue([
      { id: "row-2", leadId: "L2", contactId: "c2", agentPhone: "971555b",
        tier: "hot", status: "pending", nudgeSentAt: null,
        dueAt: new Date("2026-05-23T10:20:00Z"),
        dueBy: new Date("2026-05-23T10:30:00Z"),
        createdAt: new Date("2026-05-23T10:00:00Z"),
      },
    ]);
    prisma.contact.findUnique.mockResolvedValue({
      id: "c2", waId: "971555b", consentState: "opted_out",
    });
    prisma.conversation.findUnique.mockResolvedValue(null);

    const r = await svc.tick(new Date("2026-05-23T10:21:00Z"));

    expect(messages.send).not.toHaveBeenCalled();
    expect(prisma.pendingFollowup.update).toHaveBeenCalledWith(
      expect.objectContaining({
        where: { id: "row-2" },
        data: expect.objectContaining({ status: "resolved" }),
      }),
    );
    expect(r.skipped).toBe(1);
  });
});
```

Run: `pnpm --filter @whatapp/api test reminders`
Expected: 2 tests FAIL.

- [ ] **Step 2: Implement `tick`**

Append to `reminders.service.ts`:

```ts
  /**
   * FR-06.10 — drive the scheduler over every non-terminal row, dispatch the
   * action, and persist the state transition. Pure-state-machine output via
   * `decideReminderAction`. Sends use `MessageService.send` (FR-06.11).
   */
  async tick(now: Date): Promise<{ sent: number; skipped: number; waited: number }> {
    const rows = await this.prisma.pendingFollowup.findMany({
      where: { status: { in: ["pending", "awaiting_reply"] } },
    });

    let sent = 0;
    let skipped = 0;
    let waited = 0;

    for (const row of rows as Array<{
      id: string; leadId: string; contactId: string | null;
      agentPhone: string | null; agentName: string | null; leadName: string | null;
      tier: ReminderTier; status: "pending" | "awaiting_reply";
      nudgeSentAt: Date | null; dueAt: Date; dueBy: Date; createdAt: Date;
    }>) {
      const contact = row.contactId
        ? await this.prisma.contact.findUnique({ where: { id: row.contactId } })
        : null;
      const conversation = row.contactId
        ? await this.prisma.conversation.findUnique({
            where: { contactId: row.contactId },
          })
        : null;
      const action = decideReminderAction(
        {
          leadId: row.leadId,
          tier: row.tier,
          status: row.status,
          createdAt: row.createdAt,
          nudgeSentAt: row.nudgeSentAt,
          dueAt: row.dueAt,
          dueBy: row.dueBy,
          leadRatStatus: "New", // FR-06.10 — re-check via n8n; default if no source
          hasReplied: false,    // refined when conversation tracks lead reply
          consentState:
            (contact?.consentState as "opted_in" | "opted_out" | "unknown") ?? "unknown",
          windowExpiresAt: conversation?.windowExpiresAt ?? null,
        },
        now,
      );

      if (action.kind === "wait") { waited++; continue; }
      if (action.kind === "skip") {
        if (action.markResolved) {
          await this.prisma.pendingFollowup.update({
            where: { id: row.id }, data: { status: "resolved" },
          });
        }
        skipped++;
        continue;
      }
      // send
      const recipient = row.agentPhone;
      if (!recipient) {
        skipped++;
        await this.prisma.pendingFollowup.update({
          where: { id: row.id }, data: { status: "resolved" },
        });
        continue;
      }
      const result = await this.messages.send(
        action.channel === "template"
          ? {
              to: recipient,
              type: "template",
              content: {
                name: action.templateName ?? "lead_followup",
                language: "en",
                bodyParams: [row.leadName ?? ""],
              },
            }
          : {
              to: recipient,
              type: "text",
              content: { text: `Reminder: lead ${row.leadName ?? row.leadId}` },
            },
        { service: true }, // reminders are service messages (FR-01.15)
      );
      const nowDate = new Date();
      await this.prisma.pendingFollowup.update({
        where: { id: row.id },
        data: {
          status: action.nextStatus,
          nudgeSentAt: row.nudgeSentAt ?? nowDate,
        },
      });
      if (result.ok) sent++;
      else skipped++;
    }

    return { sent, skipped, waited };
  }
```

- [ ] **Step 3: Run and confirm pass**

Run: `pnpm --filter @whatapp/api test reminders`
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add apps/api/src/reminders/reminders.service.ts \
        apps/api/src/reminders/reminders.service.test.ts
git commit -m "feat: RemindersService.tick state-machine dispatch (Phase 5 Task 19)"
```

---

## Task 20: `/internal/followups` callback + RemindersController (read view)

**Files:**
- Create: `apps/api/src/internal/internal-followups.controller.ts`
- Create: `apps/api/src/internal/internal-followups.controller.test.ts`
- Create: `apps/api/src/reminders/reminders.controller.ts`
- Create: `apps/api/src/reminders/reminders.controller.test.ts`
- Modify: `apps/api/src/internal/internal.module.ts`
- Modify: `apps/api/src/reminders/reminders.module.ts`

- [ ] **Step 1: Failing test for /internal/followups**

```ts
import { describe, it, expect, vi } from "vitest";
import { InternalFollowupsController } from "./internal-followups.controller.js";

describe("InternalFollowupsController", () => {
  it("delegates intake to RemindersService.createForLead (FR-06.8)", async () => {
    const reminders = {
      createForLead: vi.fn().mockResolvedValue({ id: "r1", tier: "hot" }),
      markResolved: vi.fn(),
    };
    const ctrl = new InternalFollowupsController(reminders as never);
    const r = await ctrl.upsert({
      leadId: "L1", leadName: "A",
      agentPhone: "971555agent",
      intakeAt: "2026-05-23T10:00:00Z",
    });
    expect(reminders.createForLead).toHaveBeenCalledWith(
      expect.objectContaining({ leadId: "L1", leadName: "A" }),
      expect.any(Date),
    );
    expect(r).toEqual({ id: "r1", tier: "hot" });
  });
  it("resolves an existing row when status=resolved", async () => {
    const reminders = {
      createForLead: vi.fn(),
      markResolved: vi.fn().mockResolvedValue(undefined),
    };
    const prisma = {
      pendingFollowup: {
        findUnique: vi.fn().mockResolvedValue({ id: "r1" }),
      },
    };
    const ctrl = new InternalFollowupsController(
      reminders as never,
      prisma as never,
    );
    const r = await ctrl.upsert({ leadId: "L1", status: "resolved" });
    expect(reminders.markResolved).toHaveBeenCalledWith("r1");
    expect(r).toEqual({ id: "r1", status: "resolved" });
  });
});
```

- [ ] **Step 2: Implement /internal/followups**

```ts
// apps/api/src/internal/internal-followups.controller.ts
import {
  Controller, Post, Body, UseGuards, HttpCode, HttpStatus,
  NotFoundException,
} from "@nestjs/common";
import { Public } from "../auth/roles.decorator";
import { CallbackAuthGuard } from "../messages/callback-auth.guard";
import { RemindersService } from "../reminders/reminders.service";
import { PrismaService } from "../prisma/prisma.service";
import { followupCallbackSchema } from "./dto";

@Controller("internal/followups")
@Public()
@UseGuards(CallbackAuthGuard)
export class InternalFollowupsController {
  constructor(
    private readonly reminders: RemindersService,
    private readonly prisma: PrismaService,
  ) {}

  @Post()
  @HttpCode(HttpStatus.OK)
  async upsert(@Body() body: unknown): Promise<unknown> {
    const dto = followupCallbackSchema.parse(body);
    // If the caller asked us to resolve, do that without creating a new row.
    if (dto.status === "resolved") {
      const row = await this.prisma.pendingFollowup.findUnique({
        where: { leadId: dto.leadId },
      });
      if (!row) throw new NotFoundException(`No followup row for lead ${dto.leadId}`);
      await this.reminders.markResolved(row.id);
      return { id: row.id, status: "resolved" };
    }
    return this.reminders.createForLead(
      {
        leadId: dto.leadId,
        contactId: dto.contactId ?? null,
        leadName: dto.leadName ?? null,
        agentId: dto.agentId ?? null,
        agentPhone: dto.agentPhone ?? null,
        agentName: dto.agentName ?? null,
        createdAt: dto.intakeAt ? new Date(dto.intakeAt) : new Date(),
      },
      new Date(),
    );
  }
}
```

Register it in `internal.module.ts` (`controllers`, plus `import RemindersModule`).

- [ ] **Step 3: Read-side controller for the UI**

```ts
// apps/api/src/reminders/reminders.controller.ts
import { Controller, Get, Query } from "@nestjs/common";
import { z } from "zod";
import { Roles } from "../auth/roles.decorator";
import { PrismaService } from "../prisma/prisma.service";

const listSchema = z.object({
  status: z.enum(["pending", "awaiting_reply", "escalated", "resolved"]).optional(),
  tier: z.enum(["hot", "bulk"]).optional(),
  limit: z.coerce.number().int().positive().max(200).default(50),
  cursor: z.string().optional(),
});

@Controller("reminders")
export class RemindersController {
  constructor(private readonly prisma: PrismaService) {}

  @Get()
  @Roles("viewer", "marketing", "admin")
  async list(@Query() q: unknown): Promise<unknown> {
    const dto = listSchema.parse(q);
    const where: Record<string, unknown> = {};
    if (dto.status) where["status"] = dto.status;
    if (dto.tier) where["tier"] = dto.tier;
    const items = await this.prisma.pendingFollowup.findMany({
      where,
      orderBy: { dueAt: "asc" },
      take: dto.limit + 1,
      ...(dto.cursor ? { cursor: { id: dto.cursor }, skip: 1 } : {}),
    });
    const hasMore = items.length > dto.limit;
    const page = hasMore ? items.slice(0, dto.limit) : items;
    return {
      items: page,
      nextCursor: hasMore ? page[page.length - 1]?.id ?? null : null,
    };
  }
}
```

With a small controller test mirroring the existing Nest controller tests
(returns `{items, nextCursor}` shape).

Add `RemindersController` to `controllers` in `reminders.module.ts`.

- [ ] **Step 4: Run and confirm pass**

Run:
```
pnpm --filter @whatapp/api test internal/internal-followups
pnpm --filter @whatapp/api test reminders/reminders.controller
```
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add apps/api/src/internal/internal-followups.controller.ts \
        apps/api/src/internal/internal-followups.controller.test.ts \
        apps/api/src/reminders/reminders.controller.ts \
        apps/api/src/reminders/reminders.controller.test.ts \
        apps/api/src/internal/internal.module.ts \
        apps/api/src/reminders/reminders.module.ts
git commit -m "feat: /internal/followups + reminders read view (Phase 5 Task 20)"
```

---

## Task 21: Internal reminder tick endpoint + worker repeatable job

**Files:**
- Create: `apps/api/src/reminders/internal-reminders.controller.ts`
- Create: `apps/api/src/reminders/internal-reminders.controller.test.ts`
- Modify: `apps/api/src/reminders/reminders.module.ts`
- Modify: `apps/worker/src/queues.ts`
- Modify: `apps/worker/src/lib/internal-api.ts`
- Modify: `apps/worker/src/lib/internal-api.test.ts`
- Create: `apps/worker/src/processors/reminder-tick.processor.ts`
- Create: `apps/worker/src/processors/reminder-tick.processor.test.ts`
- Modify: `apps/worker/src/main.ts`

- [ ] **Step 1: Add the internal tick endpoint**

Controller:
```ts
// apps/api/src/reminders/internal-reminders.controller.ts
import {
  Controller, Post, UseGuards, HttpCode, HttpStatus,
} from "@nestjs/common";
import { Public } from "../auth/roles.decorator";
import { CallbackAuthGuard } from "../messages/callback-auth.guard";
import { RemindersService } from "./reminders.service";

@Controller("internal/reminders")
@Public()
@UseGuards(CallbackAuthGuard)
export class InternalRemindersController {
  constructor(private readonly reminders: RemindersService) {}

  @Post("tick")
  @HttpCode(HttpStatus.OK)
  async tick(): Promise<unknown> {
    return this.reminders.tick(new Date());
  }
}
```

Controller test:
```ts
import { describe, it, expect, vi } from "vitest";
import { InternalRemindersController } from "./internal-reminders.controller.js";

describe("InternalRemindersController", () => {
  it("delegates to RemindersService.tick(now)", async () => {
    const svc = { tick: vi.fn().mockResolvedValue({ sent: 1, skipped: 0, waited: 0 }) };
    const c = new InternalRemindersController(svc as never);
    const r = await c.tick();
    expect(svc.tick).toHaveBeenCalled();
    expect(r).toEqual({ sent: 1, skipped: 0, waited: 0 });
  });
});
```

Add `InternalRemindersController` to `controllers` in `reminders.module.ts`
(and import `MessagesModule` to satisfy `CallbackAuthGuard`).

- [ ] **Step 2: Extend `InternalApiClient`**

In `apps/worker/src/lib/internal-api.ts`:
```ts
export interface InternalApiClient {
  // …existing
  tickReminders: () => Promise<{ sent: number; skipped: number; waited: number }>;
}
```
Implementation:
```ts
async tickReminders() {
  const res = await doFetch(`${config.baseUrl}/internal/reminders/tick`, {
    method: "POST", headers,
  });
  if (!res.ok) throw new Error(`internal API /reminders/tick returned ${res.status}`);
  return res.json() as Promise<{ sent: number; skipped: number; waited: number }>;
}
```
Test asserts the POST URL + Authorization header.

- [ ] **Step 3: New queue + repeatable job**

In `apps/worker/src/queues.ts`:
```ts
export const QUEUE_REMINDER_TICK = "reminder-tick";
export const ALL_QUEUE_NAMES = [
  // …existing,
  QUEUE_REMINDER_TICK,
] as const;
```

- [ ] **Step 4: Processor + test**

```ts
// apps/worker/src/processors/reminder-tick.processor.ts
export interface ReminderTickDeps {
  tickReminders: () => Promise<{ sent: number; skipped: number; waited: number }>;
  log: (m: string) => void;
}
export async function processReminderTick(deps: ReminderTickDeps): Promise<void> {
  const r = await deps.tickReminders();
  deps.log(`reminder-tick: sent=${r.sent} skipped=${r.skipped} waited=${r.waited}`);
}
```
Test (mirror flow-dispatch processor test pattern).

- [ ] **Step 5: Wire repeatable job in worker bootstrap**

In `apps/worker/src/main.ts`, register a Worker for `QUEUE_REMINDER_TICK` that
calls `processReminderTick`, then add a repeatable job (every minute) via the
Queue:
```ts
import { Queue } from "bullmq";
import { QUEUE_REMINDER_TICK } from "./queues.js";
// inside bootstrap, after the worker is created:
const tickQueue = new Queue(QUEUE_REMINDER_TICK, { connection: createRedisConnection() });
await tickQueue.add(
  "tick",
  {},
  { repeat: { every: 60_000 }, removeOnComplete: 100, removeOnFail: 100 },
);
```

- [ ] **Step 6: Run and confirm pass**

Run:
```
pnpm --filter @whatapp/api test reminders/internal-reminders
pnpm --filter @whatapp/worker test reminder-tick
pnpm --filter @whatapp/worker test internal-api
pnpm typecheck
```
Expected: PASS. **Live verification deferred** (no live Redis on this machine).

- [ ] **Step 7: Commit**

```bash
git add apps/api/src/reminders/internal-reminders.controller.ts \
        apps/api/src/reminders/internal-reminders.controller.test.ts \
        apps/api/src/reminders/reminders.module.ts \
        apps/worker/src/queues.ts \
        apps/worker/src/lib/internal-api.ts \
        apps/worker/src/lib/internal-api.test.ts \
        apps/worker/src/processors/reminder-tick.processor.ts \
        apps/worker/src/processors/reminder-tick.processor.test.ts \
        apps/worker/src/main.ts
git commit -m "feat: reminder tick endpoint + repeatable job (Phase 5 Task 21)"
```

---

## Task 22: Migration audit script (FR-06.12 / FR-06.13 / AC-06.6)

**Files:**
- Create: `scripts/audit-legacy-workflows.ts`
- Create: `scripts/README.md`

- [ ] **Step 1: Implement the audit script (read-only via n8n MCP)**

```ts
// scripts/audit-legacy-workflows.ts
/**
 * Audits the 13 legacy n8n workflows (workflows-legacy.md) for hardcoded
 * AiSensy + LeadRat secrets. READ ONLY — never modifies workflows.
 *
 * Reads workflow JSON via the n8n REST API using the credentials in the
 * platform's `connections` row. The n8n MCP server in the dev workspace
 * provides equivalent inspection; this script gives the same result with
 * runtime credentials, so it can be re-run during cutover.
 */
import { PrismaClient } from "@whatapp/db";
import { decryptSecret, N8nClient } from "@whatapp/shared";

const LEGACY_IDS = [
  "qNGxlLDqiUnGUtHG", // 01B Lead Poller
  "7T4Ae57zddI9Edg7", // 02 T+20 Reminder
  "jMo2i90iGLeQCtyl", // 04 T+30 Hot Lead Escalation
  "Pbttm8gyepgxNnHN", // 05 5PM Bulk Reminder
  "aEkOxk853UO08LkM", // 06 10AM Next Day Reminder
  "RUsHyWjBBoyXC2JC", // Main Distributer
  "0w7S7nsbESazXThg", // New Leads
  "mAqhgYMqS58ora8Q", // Recruitment
  "Nw6OQgSww19h5hEJ", // WA Send + Log
  "W8tIoyULl9B69RHq", // WA Event Monitor
  "Vs2e3h4Y4Etc53m0", // Reminder Engine
  "Wz6TO89drwdUaAUn", // TEMP LeadRat User List v2
  "LHGAZGZHBozSX5vT", // Delete Redis Session
];

const SECRET_PATTERNS = [
  { kind: "aisensy-bearer", regex: /Bearer\s+ey[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g },
  { kind: "aisensy-api-host", regex: /backend\.aisensy\.com/g },
  { kind: "leadrat-apiKey", regex: /\"apiKey\"\s*:\s*\"[A-Za-z0-9_-]{20,}\"/g },
  { kind: "leadrat-secretKey", regex: /\"secretKey\"\s*:\s*\"[A-Za-z0-9_-]{20,}\"/g },
];

async function main(): Promise<void> {
  const prisma = new PrismaClient();
  const row = await prisma.connection.findUnique({
    where: { provider_label: { provider: "n8n", label: "primary" } },
  });
  if (!row) throw new Error("No n8n connection — configure provider=n8n first");
  const key = process.env["CONFIG_ENCRYPTION_KEY"];
  if (!key) throw new Error("CONFIG_ENCRYPTION_KEY is required");
  const secrets = JSON.parse(String(row.secrets)) as Record<string, string>;
  const apiKey = decryptSecret(secrets["apiKey"]!, key);
  const callbackSecret = decryptSecret(secrets["callbackSecret"]!, key);
  const client = new N8nClient({
    baseUrl: String((row.settings as { baseUrl?: string }).baseUrl ?? ""),
    apiKey,
    callbackSecret,
  });

  const findings: Array<{ workflowId: string; name: string; nodeName: string; kind: string; sample: string }> = [];
  for (const id of LEGACY_IDS) {
    let wf;
    try { wf = await client.getWorkflow(id); }
    catch (e) { console.warn(`skip ${id}: ${(e as Error).message}`); continue; }
    const json = JSON.stringify(wf);
    for (const p of SECRET_PATTERNS) {
      const matches = json.matchAll(p.regex);
      for (const m of matches) {
        findings.push({
          workflowId: id,
          name: (wf as { name?: string }).name ?? id,
          nodeName: "(see workflow JSON)",
          kind: p.kind,
          sample: m[0].slice(0, 12) + "…",
        });
      }
    }
  }
  console.log(JSON.stringify({ findings, count: findings.length }, null, 2));
  await prisma.$disconnect();
  if (findings.length > 0) process.exitCode = 1;
}

main().catch((e: unknown) => {
  console.error("audit-legacy-workflows failed:", e);
  process.exit(1);
});
```

```md
<!-- scripts/README.md -->
# scripts/

Build-time, READ-ONLY tools used during Phase 5 migration and Phase 7 cutover.
None of these are runtime dependencies — they are not imported by `apps/api`,
`apps/worker`, or `apps/web`.

- `audit-legacy-workflows.ts` — calls the n8n REST API to fetch every legacy
  workflow's JSON and reports each hardcoded AiSensy JWT / LeadRat
  `apiKey`/`secretKey` literal. Never modifies workflows. Run via
  `pnpm tsx scripts/audit-legacy-workflows.ts`.
- `parity-check.ts` — given a lead set, compares what the platform's new
  reminder engine would send against what the legacy workflows would send.
  Used during cutover to verify parity (AC-06.7).
```

- [ ] **Step 2: Commit**

```bash
git add scripts/audit-legacy-workflows.ts scripts/README.md
git commit -m "chore: add audit-legacy-workflows.ts (Phase 5 Task 22)"
```

> **Live verification deferred.** Actual interactive remediation of each
> finding (replacing the AiSensy HTTP node with a call to
> `/internal/messages/send`, moving LeadRat keys into n8n credentials, swapping
> `whatsAppTrigger` for `Webhook`) is performed *with the user* in the n8n
> editor during Phase 7 cutover (workflows-legacy.md §"Implications"). The
> platform is READY for those edits the moment they're made — every
> `/internal/*` endpoint, the dispatch path, and the send path are wired.

---

## Task 23: Parity-check script (AC-06.7)

**Files:**
- Create: `scripts/parity-check.ts`

- [ ] **Step 1: Implement**

```ts
// scripts/parity-check.ts
/**
 * Reads the current `pending_followups` set and prints what the NEW reminder
 * engine would do at `now`, side-by-side with what each legacy workflow
 * (02 / 03 / 04 / 05 / 06) would do — so cutover can confirm parity before
 * deactivating the legacy workflows (AC-06.7).
 *
 * Pure-data report — no sends. Uses the same scheduler the engine uses, so
 * what's printed here is what would actually fire.
 */
import { PrismaClient } from "@whatapp/db";
import { decideReminderAction } from "@whatapp/shared";

async function main(): Promise<void> {
  const prisma = new PrismaClient();
  const rows = await prisma.pendingFollowup.findMany({
    where: { status: { in: ["pending", "awaiting_reply"] } },
  });
  const now = new Date();
  for (const r of rows) {
    const contact = r.contactId
      ? await prisma.contact.findUnique({ where: { id: r.contactId } })
      : null;
    const conversation = r.contactId
      ? await prisma.conversation.findUnique({ where: { contactId: r.contactId } })
      : null;
    const action = decideReminderAction({
      leadId: r.leadId,
      tier: r.tier,
      status: r.status as "pending" | "awaiting_reply",
      createdAt: r.createdAt,
      nudgeSentAt: r.nudgeSentAt,
      dueAt: r.dueAt,
      dueBy: r.dueBy,
      leadRatStatus: "New",
      hasReplied: false,
      consentState: (contact?.consentState as "opted_in" | "opted_out" | "unknown") ?? "unknown",
      windowExpiresAt: conversation?.windowExpiresAt ?? null,
    }, now);
    console.log(JSON.stringify({
      leadId: r.leadId,
      tier: r.tier,
      status: r.status,
      legacy: legacyWouldSend(r, now),
      newEngine: action,
    }));
  }
  await prisma.$disconnect();
}

/** Mirror of the legacy workflow logic — used ONLY to compute the parity diff. */
function legacyWouldSend(
  r: { tier: "hot" | "bulk"; status: string; dueAt: Date; dueBy: Date },
  now: Date,
) {
  if (r.tier === "hot") {
    if (r.status === "pending" && now >= r.dueAt) return { workflow: "02", template: "t20_reminder" };
    if (r.status === "awaiting_reply" && now >= r.dueBy) return { workflow: "04", template: "escalated_to_asha" };
    return { workflow: null };
  }
  if (r.status === "pending" && now >= r.dueAt) return { workflow: "05", template: "5pm_reminder" };
  if (r.status === "awaiting_reply" && now >= r.dueBy) return { workflow: "06", template: "nextday_agent" };
  return { workflow: null };
}

main().catch((e: unknown) => {
  console.error("parity-check failed:", e);
  process.exit(1);
});
```

- [ ] **Step 2: Commit**

```bash
git add scripts/parity-check.ts
git commit -m "chore: add parity-check.ts cutover diff (Phase 5 Task 23)"
```

> **Live verification deferred.** A live parity run requires a live database
> and the live n8n environment — performed during Phase 7 cutover. The script
> is fully exercised against the scheduler unit tests (Tasks 2–6) because both
> sides of the diff are pure functions.

---

## Task 24: Web — `automation-api.ts` client

**Files:**
- Create: `apps/web/src/lib/automation-api.ts`

- [ ] **Step 1: Implement (no test — thin wrapper, covered by route tests)**

```ts
// apps/web/src/lib/automation-api.ts
const TOKEN_KEY = "whatapp_token";
function authHeader(): Record<string, string> {
  const t = localStorage.getItem(TOKEN_KEY);
  return t ? { Authorization: `Bearer ${t}` } : {};
}

export interface FlowRow {
  id: string;
  n8nWorkflowId: string;
  name: string;
  description: string | null;
  kind: "conversational" | "reminder" | "integration" | "other";
  triggerWebhookUrl: string | null;
  isActive: boolean;
  lastSyncedAt: string | null;
}

export interface ExecutionRow {
  id: string | number;
  workflowId?: string;
  status?: string;
  startedAt?: string;
  stoppedAt?: string;
}

export async function listFlows(): Promise<FlowRow[]> {
  const res = await fetch("/api/flows", { headers: authHeader() });
  if (!res.ok) throw new Error(`listFlows ${res.status}`);
  return res.json() as Promise<FlowRow[]>;
}
export async function syncFlows(): Promise<{ upserted: number; deactivated: number }> {
  const res = await fetch("/api/flows/sync", { method: "POST", headers: authHeader() });
  if (!res.ok) throw new Error(`syncFlows ${res.status}`);
  return res.json();
}
export async function patchFlow(id: string, patch: Partial<Pick<FlowRow, "kind"|"description"|"triggerWebhookUrl">>): Promise<FlowRow> {
  const res = await fetch(`/api/flows/${id}`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json", ...authHeader() },
    body: JSON.stringify(patch),
  });
  if (!res.ok) throw new Error(`patchFlow ${res.status}`);
  return res.json();
}
export async function setFlowActive(id: string, active: boolean): Promise<FlowRow> {
  const path = active ? "activate" : "deactivate";
  const res = await fetch(`/api/flows/${id}/${path}`, {
    method: "POST", headers: authHeader(),
  });
  if (!res.ok) throw new Error(`setFlowActive ${res.status}`);
  return res.json();
}
export async function listExecutions(id: string): Promise<ExecutionRow[]> {
  const res = await fetch(`/api/flows/${id}/executions`, { headers: authHeader() });
  if (!res.ok) throw new Error(`listExecutions ${res.status}`);
  return res.json();
}

export interface PendingFollowupRow {
  id: string;
  leadId: string;
  leadName: string | null;
  tier: "hot" | "bulk";
  status: "pending" | "awaiting_reply" | "escalated" | "resolved";
  dueAt: string;
  dueBy: string;
  nudgeSentAt: string | null;
}

export async function listReminders(params: { status?: string; tier?: string }): Promise<{ items: PendingFollowupRow[] }> {
  const q = new URLSearchParams();
  if (params.status) q.set("status", params.status);
  if (params.tier) q.set("tier", params.tier);
  const res = await fetch(`/api/reminders?${q.toString()}`, { headers: authHeader() });
  if (!res.ok) throw new Error(`listReminders ${res.status}`);
  return res.json();
}
```

- [ ] **Step 2: Commit**

```bash
git add apps/web/src/lib/automation-api.ts
git commit -m "feat: web automation-api client (Phase 5 Task 24)"
```

---

## Task 25: Web — `Automation.tsx` Flows screen

**Files:**
- Create: `apps/web/src/routes/Automation.tsx`
- Create: `apps/web/src/routes/Automation.test.tsx`
- Modify: `apps/web/src/App.tsx`

- [ ] **Step 1: Failing component test**

```tsx
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router";
import Automation from "./Automation.js";
import * as api from "../lib/automation-api.js";

vi.mock("../lib/automation-api.js");

beforeEach(() => {
  vi.mocked(api.listFlows).mockResolvedValue([
    { id: "f1", n8nWorkflowId: "wf1", name: "Main Distributer",
      description: null, kind: "conversational",
      triggerWebhookUrl: "https://n8n/webhook/main",
      isActive: true, lastSyncedAt: "2026-05-23T10:00:00Z" },
  ]);
});

function wrap(ui: React.ReactNode) {
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return (
    <QueryClientProvider client={qc}>
      <MemoryRouter>{ui}</MemoryRouter>
    </QueryClientProvider>
  );
}

describe("Automation route", () => {
  it("renders flow rows with name + kind + active + sync button", async () => {
    render(wrap(<Automation />));
    expect(await screen.findByText("Main Distributer")).toBeInTheDocument();
    expect(screen.getByText(/conversational/i)).toBeInTheDocument();
    expect(screen.getByRole("button", { name: /sync from n8n/i })).toBeInTheDocument();
  });
});
```

- [ ] **Step 2: Implement the route**

```tsx
// apps/web/src/routes/Automation.tsx
import React from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
  listFlows, syncFlows, setFlowActive, listExecutions, type FlowRow,
} from "../lib/automation-api.js";

export default function Automation() {
  const qc = useQueryClient();
  const flows = useQuery({ queryKey: ["flows"], queryFn: listFlows });
  const sync = useMutation({
    mutationFn: syncFlows,
    onSuccess: () => qc.invalidateQueries({ queryKey: ["flows"] }),
  });
  const toggle = useMutation({
    mutationFn: ({ id, active }: { id: string; active: boolean }) => setFlowActive(id, active),
    onSuccess: () => qc.invalidateQueries({ queryKey: ["flows"] }),
  });
  const [openExec, setOpenExec] = React.useState<string | null>(null);
  const execs = useQuery({
    queryKey: ["executions", openExec],
    queryFn: () => (openExec ? listExecutions(openExec) : Promise.resolve([])),
    enabled: !!openExec,
  });

  return (
    <div>
      <h2 style={{ marginTop: 0 }}>Automation / Flows</h2>
      <button onClick={() => sync.mutate()} disabled={sync.isPending}>
        {sync.isPending ? "Syncing…" : "Sync from n8n"}
      </button>
      {sync.data && (
        <p style={{ color: "#6b7280" }}>
          Synced {sync.data.upserted}, deactivated {sync.data.deactivated}.
        </p>
      )}
      <table style={{ width: "100%", marginTop: 12 }}>
        <thead>
          <tr><th>Name</th><th>Kind</th><th>Active</th><th>Last sync</th><th>n8n</th><th>Executions</th></tr>
        </thead>
        <tbody>
          {flows.data?.map((f: FlowRow) => (
            <tr key={f.id}>
              <td>{f.name}</td>
              <td>{f.kind}</td>
              <td>
                <button onClick={() => toggle.mutate({ id: f.id, active: !f.isActive })}>
                  {f.isActive ? "Active" : "Inactive"}
                </button>
              </td>
              <td>{f.lastSyncedAt ?? "—"}</td>
              <td>
                <a href={`/n8n/workflow/${f.n8nWorkflowId}`} target="_blank" rel="noreferrer">
                  Open in n8n
                </a>
              </td>
              <td>
                <button onClick={() => setOpenExec(openExec === f.id ? null : f.id)}>
                  {openExec === f.id ? "Hide" : "Show"}
                </button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
      {openExec && (
        <div style={{ marginTop: 12 }}>
          <h3>Recent executions</h3>
          {execs.isLoading && <p>Loading…</p>}
          {execs.data?.map((e) => (
            <div key={String(e.id)}>
              {e.status} — {e.startedAt} → {e.stoppedAt ?? "running"}
            </div>
          ))}
        </div>
      )}
    </div>
  );
}
```

Replace the `Placeholder name="Automation"` route in `App.tsx` with
`<Automation />`.

- [ ] **Step 3: Run and confirm pass**

Run: `pnpm --filter @whatapp/web test Automation`
And: `pnpm --filter @whatapp/web typecheck && pnpm --filter @whatapp/web build`
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add apps/web/src/routes/Automation.tsx \
        apps/web/src/routes/Automation.test.tsx \
        apps/web/src/App.tsx
git commit -m "feat: web Automation/Flows screen (Phase 5 Task 25)"
```

---

## Task 26: Web — Reminders view + nav

**Files:**
- Create: `apps/web/src/routes/Reminders.tsx`
- Create: `apps/web/src/routes/Reminders.test.tsx`
- Modify: `apps/web/src/App.tsx`
- Modify: `apps/web/src/components/AppShell.tsx`

- [ ] **Step 1: Failing test**

```tsx
// apps/web/src/routes/Reminders.test.tsx
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router";
import Reminders from "./Reminders.js";
import * as api from "../lib/automation-api.js";

vi.mock("../lib/automation-api.js");

beforeEach(() => {
  vi.mocked(api.listReminders).mockResolvedValue({
    items: [
      { id: "r1", leadId: "L1", leadName: "Ahmed", tier: "hot",
        status: "pending", dueAt: "2026-05-23T10:20:00Z",
        dueBy: "2026-05-23T10:30:00Z", nudgeSentAt: null },
    ],
  });
});

function wrap(ui: React.ReactNode) {
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return <QueryClientProvider client={qc}><MemoryRouter>{ui}</MemoryRouter></QueryClientProvider>;
}

describe("Reminders route", () => {
  it("lists pending followups with tier + status + due times", async () => {
    render(wrap(<Reminders />));
    expect(await screen.findByText("Ahmed")).toBeInTheDocument();
    expect(screen.getByText(/hot/i)).toBeInTheDocument();
    expect(screen.getByText(/pending/i)).toBeInTheDocument();
  });
});
```

- [ ] **Step 2: Implement the route**

```tsx
// apps/web/src/routes/Reminders.tsx
import React from "react";
import { useQuery } from "@tanstack/react-query";
import { listReminders } from "../lib/automation-api.js";

export default function Reminders() {
  const [status, setStatus] = React.useState<string>("");
  const [tier, setTier] = React.useState<string>("");
  const q = useQuery({
    queryKey: ["reminders", status, tier],
    queryFn: () => listReminders({ status: status || undefined, tier: tier || undefined }),
  });

  return (
    <div>
      <h2 style={{ marginTop: 0 }}>Reminders</h2>
      <div style={{ marginBottom: 12 }}>
        <select value={status} onChange={(e) => setStatus(e.target.value)}>
          <option value="">All statuses</option>
          <option value="pending">Pending</option>
          <option value="awaiting_reply">Awaiting reply</option>
          <option value="escalated">Escalated</option>
          <option value="resolved">Resolved</option>
        </select>
        <select value={tier} onChange={(e) => setTier(e.target.value)}>
          <option value="">All tiers</option>
          <option value="hot">Hot</option>
          <option value="bulk">Bulk</option>
        </select>
      </div>
      <table style={{ width: "100%" }}>
        <thead>
          <tr><th>Lead</th><th>Tier</th><th>Status</th><th>Due at</th><th>Due by</th><th>Nudged</th></tr>
        </thead>
        <tbody>
          {q.data?.items.map((r) => (
            <tr key={r.id}>
              <td>{r.leadName ?? r.leadId}</td>
              <td>{r.tier}</td>
              <td>{r.status}</td>
              <td>{r.dueAt}</td>
              <td>{r.dueBy}</td>
              <td>{r.nudgeSentAt ?? "—"}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}
```

In `App.tsx`, add a `<Route path="/reminders" element={<Reminders />} />`.
In `AppShell.tsx`, append to `NAV_ITEMS`:
```ts
{ label: "Reminders", to: "/reminders", minRole: "viewer" },
```

- [ ] **Step 3: Run and confirm pass**

Run: `pnpm --filter @whatapp/web test Reminders`
And: `pnpm --filter @whatapp/web typecheck && pnpm --filter @whatapp/web build`
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add apps/web/src/routes/Reminders.tsx \
        apps/web/src/routes/Reminders.test.tsx \
        apps/web/src/App.tsx \
        apps/web/src/components/AppShell.tsx
git commit -m "feat: web Reminders view + nav item (Phase 5 Task 26)"
```

---

## Task 27: Full backend + frontend verification, roadmap tick

**Files:**
- Modify: `plans/ROADMAP.md`

- [ ] **Step 1: Run the full verification battery**

```
pnpm lint
pnpm typecheck
pnpm test
pnpm build
```
Expected: all PASS. Any failure must be fixed (write the test, fix the code,
commit) before continuing.

- [ ] **Step 2: Manually map every spec FR/AC to a passing test or task**

Use the "Self-review" section below as the checklist. Every FR-06.* and AC-06.*
must point to a task (or "live verification deferred" with a clear reason).

- [ ] **Step 3: Tick Phase 5 in ROADMAP.md**

Change `- [ ] Phase 5 — Automation` to `- [x] Phase 5 — Automation`.

- [ ] **Step 4: Commit**

```bash
git add plans/ROADMAP.md
git commit -m "chore: tick Phase 5 — Automation in ROADMAP"
```

---

## Self-review — Spec coverage map

| Spec ref | Where it's covered |
|---|---|
| FR-06.1 `POST /api/flows/sync` reconciles registry, never deletes | Task 9 (`reconcileFlows`), Task 10 (`FlowsService.sync`), Task 13 (controller `POST /flows/sync`) |
| FR-06.2 `GET /api/flows` + `PATCH /api/flows/:id` (kind/description/webhook) | Task 11 (`list`/`patch`), Task 13 (controller `GET /flows`, `PATCH /flows/:id`) |
| FR-06.3 `POST /api/flows/:id/activate · /deactivate` toggles via n8n REST | Task 11 (`setActive` uses `N8nClient.setActive`), Task 13 (controller routes) |
| FR-06.4 `GET /api/flows/:id/executions` live read-through | Task 11 (`executions` calls `N8nClient.listExecutions`), Task 13 (controller route) |
| FR-06.5 UI deep link to n8n editor | Task 25 (Automation.tsx "Open in n8n" link) |
| FR-06.6 dispatch service selects + POSTs to flow webhook | Task 8 (`pickFlow`), Task 14 (`DispatchService.dispatch`), Task 15 (worker shim) |
| FR-06.7 queued, retried, never silently dropped | Task 15 (worker BullMQ retries on `dispatchFlow` throw; warn-log on `dispatched=false`) |
| FR-06.8 `/internal/contacts/upsert`, `/internal/consent`, `/internal/followups` callbackSecret | Task 16, 17, 20 (each uses `@Public()` + `CallbackAuthGuard`) |
| FR-06.9 Zod-validated + idempotent | Task 16 (Zod + upsert), Task 17 (`recordIdempotent` no-op on identical state), Task 18 (`createForLead` idempotent on leadId) |
| FR-06.10 reminder engine logic (HOT T+20 / mid / T+30; BULK 5PM / 10AM; consent + window + already-replied gates) | Task 2 (consent), 4 (replied + status), 5 (HOT timings), 6 (BULK + window), 7 (classify + due times), 18 (intake), 19 (`tick`) |
| FR-06.11 reminder sends go through the platform (Spec 01 / 05) | Task 19 (`RemindersService.tick` calls `MessageService.send` with `service: true`) |
| FR-06.12 Gen-2 conversational migration (whatsAppTrigger -> Webhook, WA Send+Log -> /internal/messages/send, remove LeadRat hardcodes) | Task 22 (audit script identifies findings); ready-state: `/internal/messages/send` (Phase 1) + `DispatchService` (Task 14) + `/internal/flows/dispatch` (Task 15) are wired so the moment a workflow's Webhook node points at `/api/.../flows/dispatch` and its HTTP nodes point at `/internal/messages/send`, it works. **Interactive editing in n8n: live verification deferred to Phase 7 cutover, performed with the user.** |
| FR-06.13 Gen-1 reminder repointing + key removal; deactivate only after parity | Task 22 (audit), Task 23 (parity-check), platform-side reminder engine (Tasks 18–21) ready to run in parallel with legacy. **Interactive editing + deactivation: live verification deferred to Phase 7.** |
| FR-06.14 fresh number — new flows wired and tested while legacy keeps running on old number | All new dispatch + send paths default to the fresh-number Meta connection (Phase 1 `MetaConfigService`); legacy on the old number is untouched. **Cutover step: live verification deferred to Phase 7.** |
| AC-06.1 sync populates registry, marks removed flows inactive without deleting | Task 9 (`reconcileFlows` test), Task 10 (`FlowsService.sync` test) |
| AC-06.2 activating/deactivating in the platform changes state in n8n | Task 11 (`setActive` test asserts `N8nClient.setActive` is called) |
| AC-06.3 inbound dispatched to correct flow webhook URL + retried | Task 14 (dispatch test), Task 15 (worker shim throws on API failure → BullMQ retries) |
| AC-06.4 `/internal/*` rejects missing/wrong `callbackSecret` with 401 | `CallbackAuthGuard` (Phase 1) is applied to every `/internal/*` controller (Tasks 15, 16, 17, 20, 21). Guard is unit-tested in Phase 1 (`callback-auth.guard.test.ts`) — referenced and reused, not reimplemented. |
| AC-06.5 reminder engine creates HOT/BULK, sends consent + window-aware, skips already-replied | Tasks 2–6 (scheduler unit tests), Task 18 (intake test), Task 19 (`tick` test covers opt-out skip + template-channel send) |
| AC-06.6 no migrated workflow contains a hardcoded AiSensy/LeadRat key | Task 22 (audit script reports each remaining literal; cutover removes them). **Live verification deferred to Phase 7** — by design: the audit script reports the findings so the operator clears them in n8n. |
| AC-06.7 legacy reminders deactivated only after new engine verified | Task 23 (parity-check.ts side-by-side diff). **Live deactivation deferred to Phase 7 cutover** — the platform provides the parity check; the operator runs it and toggles legacy off. |

**No spec line is unmapped.** Every "live verification deferred" item is
defended above by a concrete piece of platform readiness (a script, an endpoint,
a tested service) so cutover (Phase 7) is mechanical, not exploratory.
