# Phase 1 — Meta Gateway 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 Meta Gateway — the platform's single integration point with
the Meta WhatsApp Cloud API: webhook ingestion (verified, idempotent,
persisted), a unified send service with 24h-window and consent enforcement, the
internal send endpoint for n8n callbacks, and the message-query API.

**Architecture:** A NestJS `WebhooksModule` owns `GET/POST /webhooks/meta`. The
POST handler captures the **raw** body, verifies the `X-Hub-Signature-256`
HMAC, stores every event in `webhook_events`, and returns `200` fast — heavy
work is enqueued to BullMQ. A `WebhookProcessorService` (used both inline for
light parsing and by the `webhook-processing` worker) parses the envelope and
upserts `contacts`/`conversations`/`messages`/`message_status_events`. A
`MessagesModule` exposes a `MessageService.send()` (24h-window + consent
enforcement, structured result) and the read API. `apps/worker` gets real
processors for `media-download` and `flow-dispatch`. All Meta and n8n config
comes from the encrypted `connections` store via `ConnectionsService`.

**Tech Stack:** NestJS 11 + Fastify, Prisma 7 / PostgreSQL, BullMQ 5 / Redis,
Zod, Vitest 3. The `MetaClient`, `N8nClient`, `buildMessageBody`, and crypto
helpers already exist in `@whatapp/shared` and are reused, not rebuilt.

**References:** `../specs/01-meta-gateway.md` (authoritative for FR/AC),
`../docs/integrations.md` §1 (Meta) and §2 (n8n), `../docs/design.md`,
`../packages/db/prisma/schema.prisma` (authoritative DB model/field names).

**Conventions:**
- Every task ends in a commit (`feat:` / `fix:` / `test:` / `chore:`).
- Tests use Vitest, run from the repo root with `pnpm test` or per-package with
  `pnpm --filter <pkg> test <pattern>`.
- **All external HTTP is mocked in tests** — never a live call to Meta or n8n.
  Prisma is mocked with plain `vi.fn()` delegates (see the pattern in
  `apps/api/src/connections/connections.service.test.ts`).
- No secret value is ever committed or logged. Meta/n8n config comes from
  `ConnectionsService.getDecrypted()`.
- **Docker is broken on this machine.** Verification relies on `pnpm lint`,
  `pnpm typecheck`, `pnpm test`, `pnpm build` and unit tests against mocks. Any
  check needing a live Postgres/Redis/Meta is marked
  **"live verification deferred"** — it is not a blocker for the phase.
- Prisma 7 client is generated; if a worker run reports a stale client run
  `pnpm --filter @whatapp/db db:generate` first.

---

## File structure

New files this phase creates (all under `apps/api/src`, `apps/worker/src`,
`packages/shared/src` unless noted):

**Shared (`packages/shared/src/meta`)**
- `webhook-types.ts` — TypeScript types for the Meta webhook envelope
  (`MetaWebhookEnvelope`, `MetaChange`, `MetaInboundMessage`, `MetaStatus`,
  `MetaTemplateStatusUpdate`, `MetaQualityUpdate`).
- `webhook-parser.ts` — pure functions: `classifyEvent`, `deriveDedupKey`,
  `extractInboundMessages`, `extractStatuses`, `parseInboundBody`.
- `webhook-parser.test.ts` — tests for the parser.

**API (`apps/api/src`)**
- `meta/meta-config.service.ts` — resolves `MetaConfig` from `ConnectionsService`
  and builds a `MetaClient`.
- `webhooks/raw-body.ts` — Fastify content-type parser that captures the raw
  body buffer.
- `webhooks/webhooks.controller.ts` — `GET/POST /webhooks/meta`.
- `webhooks/webhook-ingest.service.ts` — signature check + `webhook_events` row
  + enqueue.
- `webhooks/webhook-processor.service.ts` — parses an envelope and writes
  contacts/conversations/messages/status events/templates/quality.
- `webhooks/webhooks.module.ts`.
- `messages/message.service.ts` — `send()` with window + consent enforcement.
- `messages/messages.controller.ts` — `GET /api/messages`, `GET /api/messages/:id`.
- `messages/internal-messages.controller.ts` — `POST /internal/messages/send`.
- `messages/callback-auth.guard.ts` — bearer-token guard for internal endpoints.
- `messages/dto.ts` — Zod schemas for the message API.
- `messages/messages.module.ts`.
- `quality/quality.service.ts` — persists quality/account events to `Setting`.
- `quality/quality.module.ts`.
- plus `*.test.ts` siblings for each service with logic.

**Worker (`apps/worker/src`)**
- `processors/media-download.processor.ts` — downloads inbound media.
- `processors/flow-dispatch.processor.ts` — POSTs normalized inbound to n8n
  (stub until Phase 5).
- `processors/webhook-processing.processor.ts` — runs the heavy parse off-request.
- `lib/prisma.ts` — a shared Prisma client for the worker.
- `lib/connections.ts` — reads decrypted Meta/n8n config in the worker.
- `vitest.config.ts` — worker test config (does not exist yet).
- plus `*.test.ts` siblings.

---

## Task 1: Meta webhook envelope types

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

- [ ] **Step 1: Create `packages/shared/src/meta/webhook-types.ts`**

```typescript
/**
 * TypeScript shapes for the Meta WhatsApp Cloud API webhook payload.
 * See docs/integrations.md §1.4 for the authoritative envelope.
 * These describe the JSON we receive — fields are optional/loose because Meta
 * sends many variants through one endpoint.
 */

/** A single inbound message inside a `messages` change. */
export interface MetaInboundMessage {
  /** The wamid of this inbound message. */
  id: string;
  /** Sender's WhatsApp id (phone, digits only). */
  from: string;
  /** Unix epoch seconds, as a string. */
  timestamp: string;
  /** "text" | "image" | "document" | "audio" | "video" | "sticker" |
   *  "location" | "interactive" | "contacts" | "reaction" | others. */
  type: string;
  text?: { body: string };
  image?: { id: string; mime_type?: string; sha256?: string; caption?: string };
  document?: { id: string; mime_type?: string; filename?: string; caption?: string };
  audio?: { id: string; mime_type?: string };
  video?: { id: string; mime_type?: string; caption?: string };
  sticker?: { id: string; mime_type?: string };
  location?: { latitude: number; longitude: number; name?: string; address?: string };
  interactive?: {
    type: string;
    button_reply?: { id: string; title: string };
    list_reply?: { id: string; title: string; description?: string };
  };
  contacts?: unknown[];
  reaction?: { message_id: string; emoji: string };
  [key: string]: unknown;
}

/** A delivery status inside a `messages` change. */
export interface MetaStatus {
  /** The wamid of the outbound message this status refers to. */
  id: string;
  /** "sent" | "delivered" | "read" | "failed". */
  status: string;
  /** Unix epoch seconds, as a string. */
  timestamp: string;
  recipient_id?: string;
  errors?: Array<{
    code: number;
    title?: string;
    message?: string;
    error_data?: { details?: string };
  }>;
  [key: string]: unknown;
}

/** A contact profile block inside a `messages` change `value`. */
export interface MetaContactProfile {
  wa_id: string;
  profile?: { name?: string };
}

/** The `value` of a `messages` field change. */
export interface MetaMessagesValue {
  messaging_product?: string;
  metadata?: { display_phone_number?: string; phone_number_id?: string };
  contacts?: MetaContactProfile[];
  messages?: MetaInboundMessage[];
  statuses?: MetaStatus[];
  [key: string]: unknown;
}

/** The `value` of a `message_template_status_update` change. */
export interface MetaTemplateStatusUpdate {
  message_template_id?: string | number;
  message_template_name?: string;
  message_template_language?: string;
  event?: string;
  reason?: string;
  [key: string]: unknown;
}

/** The `value` of a `phone_number_quality_update` / `account_update` change. */
export interface MetaQualityUpdate {
  display_phone_number?: string;
  event?: string;
  current_limit?: string;
  messaging_limit_tier?: string;
  quality_score?: { score?: string } | string;
  [key: string]: unknown;
}

/** One entry in `entry[].changes[]`. */
export interface MetaChange {
  field: string;
  value:
    | MetaMessagesValue
    | MetaTemplateStatusUpdate
    | MetaQualityUpdate
    | Record<string, unknown>;
}

/** One entry in `entry[]`. */
export interface MetaEntry {
  id: string;
  changes?: MetaChange[];
}

/** The top-level webhook POST body. */
export interface MetaWebhookEnvelope {
  object?: string;
  entry?: MetaEntry[];
}
```

- [ ] **Step 2: Export from the shared barrel**

In `packages/shared/src/index.ts`, add after the existing `meta` exports:

```typescript
export * from "./meta/webhook-types.js";
```

- [ ] **Step 3: Verify it typechecks**

Run: `pnpm --filter @whatapp/shared typecheck`
Expected: PASS (no errors).

- [ ] **Step 4: Commit**

```bash
git add packages/shared/src/meta/webhook-types.ts packages/shared/src/index.ts
git commit -m "feat: add Meta webhook envelope types"
```

---

## Task 2: Webhook parser — event classification & dedup keys

**Files:**
- Create: `packages/shared/src/meta/webhook-parser.ts`
- Test: `packages/shared/src/meta/webhook-parser.test.ts`
- Modify: `packages/shared/src/index.ts`

Covers FR-01.4 (dedup key derivation) and the classification half of FR-01.3.

- [ ] **Step 1: Write the failing test** — `packages/shared/src/meta/webhook-parser.test.ts`:

```typescript
import { describe, it, expect } from "vitest";
import { classifyEvent, deriveDedupKey } from "./webhook-parser.js";
import type { MetaWebhookEnvelope } from "./webhook-types.js";

function messagesEnvelope(value: Record<string, unknown>): MetaWebhookEnvelope {
  return {
    object: "whatsapp_business_account",
    entry: [{ id: "waba-1", changes: [{ field: "messages", value }] }],
  };
}

describe("classifyEvent", () => {
  it("classifies an inbound message envelope", () => {
    const env = messagesEnvelope({
      messages: [{ id: "wamid.IN1", from: "9715551", timestamp: "1", type: "text" }],
    });
    expect(classifyEvent(env)).toBe("inbound_message");
  });

  it("classifies a status envelope", () => {
    const env = messagesEnvelope({
      statuses: [{ id: "wamid.OUT1", status: "delivered", timestamp: "1" }],
    });
    expect(classifyEvent(env)).toBe("status");
  });

  it("classifies a template status update", () => {
    const env: MetaWebhookEnvelope = {
      entry: [{ id: "waba-1", changes: [
        { field: "message_template_status_update", value: { event: "APPROVED" } }] }],
    };
    expect(classifyEvent(env)).toBe("template_status");
  });

  it("classifies a quality update", () => {
    const env: MetaWebhookEnvelope = {
      entry: [{ id: "waba-1", changes: [
        { field: "phone_number_quality_update", value: { event: "FLAGGED" } }] }],
    };
    expect(classifyEvent(env)).toBe("quality");
  });

  it("classifies an unknown field as unknown", () => {
    const env: MetaWebhookEnvelope = {
      entry: [{ id: "waba-1", changes: [{ field: "some_future_field", value: {} }] }],
    };
    expect(classifyEvent(env)).toBe("unknown");
  });
});

describe("deriveDedupKey", () => {
  it("derives a stable key from the inbound message id", () => {
    const env = messagesEnvelope({
      messages: [{ id: "wamid.IN1", from: "9715551", timestamp: "1", type: "text" }],
    });
    expect(deriveDedupKey(env)).toBe("msg:wamid.IN1");
  });

  it("derives a key from the first status id and its status", () => {
    const env = messagesEnvelope({
      statuses: [{ id: "wamid.OUT1", status: "read", timestamp: "1" }],
    });
    expect(deriveDedupKey(env)).toBe("status:wamid.OUT1:read");
  });

  it("returns null when no id is present", () => {
    const env: MetaWebhookEnvelope = { entry: [] };
    expect(deriveDedupKey(env)).toBeNull();
  });
});
```

- [ ] **Step 2: Run the test, verify it fails**

Run: `pnpm --filter @whatapp/shared test webhook-parser`
Expected: FAIL — `webhook-parser` module not found.

- [ ] **Step 3: Implement `packages/shared/src/meta/webhook-parser.ts`** (parser
  part 1 — classification and dedup):

```typescript
import type {
  MetaWebhookEnvelope,
  MetaMessagesValue,
} from "./webhook-types.js";

/** The kinds of events the Meta Gateway recognizes. */
export type MetaEventType =
  | "inbound_message"
  | "status"
  | "template_status"
  | "quality"
  | "unknown";

/** The first change in the envelope, or undefined. */
function firstChange(env: MetaWebhookEnvelope) {
  return env.entry?.[0]?.changes?.[0];
}

/**
 * Classifies a webhook envelope by its first change. One POST carries one
 * logical event in practice; if multiple are batched the first is used to tag
 * the stored row — every change is still processed downstream.
 */
export function classifyEvent(env: MetaWebhookEnvelope): MetaEventType {
  const change = firstChange(env);
  if (!change) return "unknown";
  if (change.field === "messages") {
    const value = change.value as MetaMessagesValue;
    if (value.messages && value.messages.length > 0) return "inbound_message";
    if (value.statuses && value.statuses.length > 0) return "status";
    return "unknown";
  }
  if (change.field === "message_template_status_update") return "template_status";
  if (
    change.field === "phone_number_quality_update" ||
    change.field === "account_update"
  ) {
    return "quality";
  }
  return "unknown";
}

/**
 * Derives a stable idempotency key from the Meta message/status id.
 * Inbound message → `msg:<wamid>`.
 * Status         → `status:<wamid>:<status>` (a wamid produces sent/delivered/
 *                  read/failed — each is a distinct event).
 * Returns null when no id can be found (the event is then stored without a
 * dedupKey and always processed).
 */
export function deriveDedupKey(env: MetaWebhookEnvelope): string | null {
  const change = firstChange(env);
  if (!change || change.field !== "messages") return null;
  const value = change.value as MetaMessagesValue;
  const inbound = value.messages?.[0];
  if (inbound?.id) return `msg:${inbound.id}`;
  const status = value.statuses?.[0];
  if (status?.id) return `status:${status.id}:${status.status}`;
  return null;
}
```

- [ ] **Step 4: Run the test, verify it passes**

Run: `pnpm --filter @whatapp/shared test webhook-parser`
Expected: PASS (8 tests).

- [ ] **Step 5: Export from the barrel**

In `packages/shared/src/index.ts`, add:

```typescript
export * from "./meta/webhook-parser.js";
```

- [ ] **Step 6: Commit**

```bash
git add packages/shared/src/meta/webhook-parser.ts packages/shared/src/meta/webhook-parser.test.ts packages/shared/src/index.ts
git commit -m "feat: add Meta webhook event classifier and dedup-key derivation"
```

---

## Task 3: Webhook parser — inbound message & status extraction

**Files:**
- Modify: `packages/shared/src/meta/webhook-parser.ts`
- Modify: `packages/shared/src/meta/webhook-parser.test.ts`

Covers FR-01.7 (supported types incl. `unsupported` fallback) and the parsing
needed by FR-01.6 and FR-01.9.

- [ ] **Step 1: Add failing tests** — append to `webhook-parser.test.ts`:

```typescript
import {
  extractInboundMessages,
  extractStatuses,
  parseInboundBody,
} from "./webhook-parser.js";

describe("parseInboundBody", () => {
  it("extracts text", () => {
    expect(parseInboundBody({ id: "1", from: "x", timestamp: "1", type: "text",
      text: { body: "hello" } })).toEqual({ type: "text", body: "hello" });
  });

  it("extracts an image caption and keeps type", () => {
    expect(parseInboundBody({ id: "1", from: "x", timestamp: "1", type: "image",
      image: { id: "m1", caption: "a photo" } }))
      .toEqual({ type: "image", body: "a photo" });
  });

  it("extracts a button reply title from interactive", () => {
    expect(parseInboundBody({ id: "1", from: "x", timestamp: "1",
      type: "interactive",
      interactive: { type: "button_reply",
        button_reply: { id: "b1", title: "Yes" } } }))
      .toEqual({ type: "interactive", body: "Yes" });
  });

  it("extracts a location as a coordinate string", () => {
    expect(parseInboundBody({ id: "1", from: "x", timestamp: "1",
      type: "location", location: { latitude: 25.2, longitude: 55.3 } }))
      .toEqual({ type: "location", body: "25.2,55.3" });
  });

  it("maps an unknown type to 'unsupported' and keeps body null", () => {
    expect(parseInboundBody({ id: "1", from: "x", timestamp: "1",
      type: "order" }))
      .toEqual({ type: "unsupported", body: null });
  });
});

describe("extractInboundMessages", () => {
  it("pairs each message with its sender profile name", () => {
    const env = messagesEnvelope({
      contacts: [{ wa_id: "9715551", profile: { name: "Ahmed" } }],
      messages: [{ id: "wamid.IN1", from: "9715551", timestamp: "1700000000",
        type: "text", text: { body: "hi" } }],
    });
    const out = extractInboundMessages(env);
    expect(out).toHaveLength(1);
    expect(out[0]).toMatchObject({
      wamid: "wamid.IN1", waId: "9715551", profileName: "Ahmed",
      type: "text", body: "hi",
    });
    expect(out[0]?.timestamp).toBeInstanceOf(Date);
  });
});

describe("extractStatuses", () => {
  it("extracts a delivered status", () => {
    const env = messagesEnvelope({
      statuses: [{ id: "wamid.OUT1", status: "delivered",
        timestamp: "1700000000" }],
    });
    const out = extractStatuses(env);
    expect(out[0]).toMatchObject({ wamid: "wamid.OUT1", status: "delivered" });
  });

  it("extracts error fields on a failed status", () => {
    const env = messagesEnvelope({
      statuses: [{ id: "wamid.OUT2", status: "failed", timestamp: "1700000000",
        errors: [{ code: 131047, title: "Re-engagement",
          message: "outside window", error_data: { details: "24h" } }] }],
    });
    const out = extractStatuses(env);
    expect(out[0]).toMatchObject({
      wamid: "wamid.OUT2", status: "failed",
      errorCode: "131047", errorTitle: "Re-engagement",
    });
    expect(out[0]?.errorDetail).toBeTruthy();
  });
});
```

- [ ] **Step 2: Run the test, verify it fails**

Run: `pnpm --filter @whatapp/shared test webhook-parser`
Expected: FAIL — `extractInboundMessages` / `parseInboundBody` /
`extractStatuses` not exported.

- [ ] **Step 3: Implement — append to `packages/shared/src/meta/webhook-parser.ts`:**

```typescript
import type { MetaInboundMessage, MetaStatus } from "./webhook-types.js";

/** Inbound message types that are stored with their own `type` value. */
const SUPPORTED_INBOUND_TYPES = new Set([
  "text",
  "image",
  "document",
  "audio",
  "video",
  "sticker",
  "location",
  "interactive",
  "contacts",
  "reaction",
]);

/** A parsed inbound message ready to write to the `messages` table. */
export interface ParsedInbound {
  wamid: string;
  waId: string;
  profileName: string | null;
  type: string;
  body: string | null;
  timestamp: Date;
  /** The raw Meta message object — stored verbatim in `messages.payload`. */
  raw: MetaInboundMessage;
}

/** A parsed delivery status ready to write to `message_status_events`. */
export interface ParsedStatus {
  wamid: string;
  status: string;
  occurredAt: Date;
  errorCode: string | null;
  errorTitle: string | null;
  errorDetail: unknown | null;
  /** The raw Meta status object. */
  raw: MetaStatus;
}

/**
 * Extracts the displayable `type` and `body` of one inbound message.
 * Unknown/unhandled types collapse to `type: "unsupported"` with `body: null`
 * (the full payload is still preserved by the caller — never dropped, FR-01.7).
 */
export function parseInboundBody(
  msg: MetaInboundMessage,
): { type: string; body: string | null } {
  const t = msg.type;
  if (!SUPPORTED_INBOUND_TYPES.has(t)) {
    return { type: "unsupported", body: null };
  }
  switch (t) {
    case "text":
      return { type: "text", body: msg.text?.body ?? null };
    case "image":
      return { type: "image", body: msg.image?.caption ?? null };
    case "document":
      return { type: "document", body: msg.document?.caption ?? null };
    case "video":
      return { type: "video", body: msg.video?.caption ?? null };
    case "audio":
      return { type: "audio", body: null };
    case "sticker":
      return { type: "sticker", body: null };
    case "location": {
      const loc = msg.location;
      return {
        type: "location",
        body: loc ? `${loc.latitude},${loc.longitude}` : null,
      };
    }
    case "interactive": {
      const i = msg.interactive;
      const title =
        i?.button_reply?.title ?? i?.list_reply?.title ?? null;
      return { type: "interactive", body: title };
    }
    case "reaction":
      return { type: "reaction", body: msg.reaction?.emoji ?? null };
    case "contacts":
      return { type: "contacts", body: null };
    default:
      return { type: "unsupported", body: null };
  }
}

/** Converts a Meta unix-seconds-as-string timestamp to a Date. */
function toDate(epochSeconds: string | undefined): Date {
  const n = Number(epochSeconds);
  return Number.isFinite(n) && n > 0 ? new Date(n * 1000) : new Date();
}

/** Extracts every inbound message in the envelope, paired with profile names. */
export function extractInboundMessages(
  env: MetaWebhookEnvelope,
): ParsedInbound[] {
  const out: ParsedInbound[] = [];
  for (const entry of env.entry ?? []) {
    for (const change of entry.changes ?? []) {
      if (change.field !== "messages") continue;
      const value = change.value as MetaMessagesValue;
      const nameByWaId = new Map<string, string>();
      for (const c of value.contacts ?? []) {
        if (c.wa_id && c.profile?.name) nameByWaId.set(c.wa_id, c.profile.name);
      }
      for (const msg of value.messages ?? []) {
        const { type, body } = parseInboundBody(msg);
        out.push({
          wamid: msg.id,
          waId: msg.from,
          profileName: nameByWaId.get(msg.from) ?? null,
          type,
          body,
          timestamp: toDate(msg.timestamp),
          raw: msg,
        });
      }
    }
  }
  return out;
}

/** Extracts every delivery status in the envelope. */
export function extractStatuses(env: MetaWebhookEnvelope): ParsedStatus[] {
  const out: ParsedStatus[] = [];
  for (const entry of env.entry ?? []) {
    for (const change of entry.changes ?? []) {
      if (change.field !== "messages") continue;
      const value = change.value as MetaMessagesValue;
      for (const s of value.statuses ?? []) {
        const err = s.errors?.[0];
        out.push({
          wamid: s.id,
          status: s.status,
          occurredAt: toDate(s.timestamp),
          errorCode: err ? String(err.code) : null,
          errorTitle: err?.title ?? null,
          errorDetail: err ?? null,
          raw: s,
        });
      }
    }
  }
  return out;
}
```

- [ ] **Step 4: Run the test, verify it passes**

Run: `pnpm --filter @whatapp/shared test webhook-parser`
Expected: PASS (all webhook-parser tests).

- [ ] **Step 5: Commit**

```bash
git add packages/shared/src/meta/webhook-parser.ts packages/shared/src/meta/webhook-parser.test.ts
git commit -m "feat: add inbound message and delivery status extraction"
```

---

## Task 4: Status forward-only ranking helper

**Files:**
- Modify: `packages/shared/src/meta/webhook-parser.ts`
- Modify: `packages/shared/src/meta/webhook-parser.test.ts`

Covers FR-01.9 (status only moves forward) — isolated as a pure, testable rank
function so the processor service stays thin.

- [ ] **Step 1: Add failing tests** — append to `webhook-parser.test.ts`:

```typescript
import { statusRank, isForwardStatus } from "./webhook-parser.js";

describe("statusRank / isForwardStatus", () => {
  it("ranks the delivery lifecycle in order", () => {
    expect(statusRank("queued")).toBeLessThan(statusRank("sent"));
    expect(statusRank("sent")).toBeLessThan(statusRank("delivered"));
    expect(statusRank("delivered")).toBeLessThan(statusRank("read"));
  });

  it("allows a forward transition", () => {
    expect(isForwardStatus("sent", "delivered")).toBe(true);
    expect(isForwardStatus("delivered", "read")).toBe(true);
  });

  it("rejects a backward / duplicate transition", () => {
    expect(isForwardStatus("read", "delivered")).toBe(false);
    expect(isForwardStatus("delivered", "sent")).toBe(false);
    expect(isForwardStatus("delivered", "delivered")).toBe(false);
  });

  it("always allows failed regardless of current status", () => {
    expect(isForwardStatus("read", "failed")).toBe(true);
    expect(isForwardStatus("sent", "failed")).toBe(true);
  });
});
```

- [ ] **Step 2: Run the test, verify it fails**

Run: `pnpm --filter @whatapp/shared test webhook-parser`
Expected: FAIL — `statusRank` / `isForwardStatus` not exported.

- [ ] **Step 3: Implement — append to `packages/shared/src/meta/webhook-parser.ts`:**

```typescript
/**
 * Ordinal rank of a delivery status. Higher = later in the lifecycle.
 * Unknown statuses rank -1 so they never overwrite a known status.
 */
export function statusRank(status: string): number {
  const order: Record<string, number> = {
    queued: 0,
    sent: 1,
    delivered: 2,
    read: 3,
    failed: 4,
  };
  return order[status] ?? -1;
}

/**
 * True when moving from `current` to `next` is a forward transition.
 * `failed` is always accepted (a message can fail after being sent). A status
 * equal to or lower than the current one is rejected (FR-01.9: late/out-of-
 * order statuses must not regress the message).
 */
export function isForwardStatus(current: string, next: string): boolean {
  if (next === "failed") return true;
  return statusRank(next) > statusRank(current);
}
```

- [ ] **Step 4: Run the test, verify it passes**

Run: `pnpm --filter @whatapp/shared test webhook-parser`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add packages/shared/src/meta/webhook-parser.ts packages/shared/src/meta/webhook-parser.test.ts
git commit -m "feat: add forward-only delivery status ranking"
```

---

## Task 5: MetaConfigService — resolve Meta config from connections

**Files:**
- Create: `apps/api/src/meta/meta-config.service.ts`
- Test: `apps/api/src/meta/meta-config.service.test.ts`

Provides the `MetaClient` to every other service, sourced from the encrypted
`connections` store (no hardcoded secrets — hard rule 1).

- [ ] **Step 1: Write the failing test** — `apps/api/src/meta/meta-config.service.test.ts`:

```typescript
import { describe, it, expect, vi, beforeEach } from "vitest";
import { MetaConfigService } from "./meta-config.service";
import { MetaClient } from "@whatapp/shared";

const decrypted = {
  id: "c1",
  provider: "meta" as const,
  label: "primary",
  settings: { apiVersion: "v20.0", phoneNumberId: "PN1", wabaId: "WABA1" },
  secrets: { accessToken: "tok", appSecret: "sec", verifyToken: "verify-me" },
  isActive: true,
};

describe("MetaConfigService", () => {
  let connections: { getDecrypted: ReturnType<typeof vi.fn> };
  let service: MetaConfigService;

  beforeEach(() => {
    connections = { getDecrypted: vi.fn().mockResolvedValue(decrypted) };
    service = new MetaConfigService(connections as never);
  });

  it("builds a MetaConfig merging settings and decrypted secrets", async () => {
    const cfg = await service.getConfig();
    expect(cfg).toEqual({
      apiVersion: "v20.0", phoneNumberId: "PN1", wabaId: "WABA1",
      accessToken: "tok", appSecret: "sec", verifyToken: "verify-me",
    });
  });

  it("defaults apiVersion to v20.0 when settings omit it", async () => {
    connections.getDecrypted.mockResolvedValue({
      ...decrypted, settings: { phoneNumberId: "PN1", wabaId: "WABA1" } });
    const cfg = await service.getConfig();
    expect(cfg.apiVersion).toBe("v20.0");
  });

  it("returns a MetaClient built from the config", async () => {
    const client = await service.getClient();
    expect(client).toBeInstanceOf(MetaClient);
  });

  it("throws when no meta connection is configured", async () => {
    connections.getDecrypted.mockResolvedValue(null);
    await expect(service.getConfig()).rejects.toThrow(/meta connection/i);
  });
});
```

- [ ] **Step 2: Run the test, verify it fails**

Run: `pnpm --filter @whatapp/api test meta-config`
Expected: FAIL — `meta-config.service` not found.

- [ ] **Step 3: Implement `apps/api/src/meta/meta-config.service.ts`:**

```typescript
import { Injectable } from "@nestjs/common";
import { MetaClient, type MetaConfig } from "@whatapp/shared";
import { ConnectionsService } from "../connections/connections.service";

/**
 * Resolves the Meta WhatsApp Cloud API configuration from the encrypted
 * `connections` store and constructs a `MetaClient`. No secret is ever read
 * from env or a literal — hard rule 1.
 */
@Injectable()
export class MetaConfigService {
  constructor(private readonly connections: ConnectionsService) {}

  /** Reads the `meta` connection and assembles a typed MetaConfig. */
  async getConfig(): Promise<MetaConfig> {
    const conn = await this.connections.getDecrypted("meta");
    if (!conn) {
      throw new Error(
        "No meta connection is configured — add it under Settings → Connections",
      );
    }
    const s = conn.settings;
    const secrets = conn.secrets;
    return {
      apiVersion: (s["apiVersion"] as string) ?? "v20.0",
      phoneNumberId: String(s["phoneNumberId"] ?? ""),
      wabaId: String(s["wabaId"] ?? ""),
      accessToken: secrets["accessToken"] ?? "",
      appSecret: secrets["appSecret"] ?? "",
      verifyToken: secrets["verifyToken"] ?? "",
    };
  }

  /** Builds a MetaClient from the current connection config. */
  async getClient(): Promise<MetaClient> {
    return new MetaClient(await this.getConfig());
  }
}
```

- [ ] **Step 4: Run the test, verify it passes**

Run: `pnpm --filter @whatapp/api test meta-config`
Expected: PASS (4 tests).

- [ ] **Step 5: Commit**

```bash
git add apps/api/src/meta/meta-config.service.ts apps/api/src/meta/meta-config.service.test.ts
git commit -m "feat: add MetaConfigService resolving Meta config from connections"
```

---

## Task 6: Fastify raw-body capture

**Files:**
- Create: `apps/api/src/webhooks/raw-body.ts`
- Modify: `apps/api/src/main.ts`

Covers the raw-body half of FR-01.2 / FR-01.3. **Critical:** the HMAC signature
is computed over the bytes Meta sent — Fastify's default JSON parser consumes
and discards them, so the raw `application/json` body must be captured first.

- [ ] **Step 1: Implement `apps/api/src/webhooks/raw-body.ts`:**

```typescript
import type { FastifyInstance } from "fastify";

/**
 * Registers a custom `application/json` content-type parser on the Fastify
 * instance that BOTH keeps the raw body bytes AND parses the JSON.
 *
 * The raw bytes are attached as `request.rawBody` (a Buffer); the parsed object
 * is returned normally so controllers still receive `request.body`.
 *
 * This is required for Meta webhook signature verification (FR-01.2): the
 * X-Hub-Signature-256 HMAC is computed over the exact bytes Meta sent. The
 * default parser discards those bytes after parsing.
 *
 * Scope note: this parser is global. Webhook payloads are small; the memory
 * cost of retaining the buffer for every JSON request is negligible.
 */
export function registerRawBodyParser(app: FastifyInstance): void {
  app.addContentTypeParser(
    "application/json",
    { parseAs: "buffer" },
    (req, body: Buffer, done) => {
      (req as { rawBody?: Buffer }).rawBody = body;
      if (body.length === 0) {
        done(null, {});
        return;
      }
      try {
        done(null, JSON.parse(body.toString("utf8")));
      } catch (err) {
        done(err instanceof Error ? err : new Error("Invalid JSON"), undefined);
      }
    },
  );
}
```

- [ ] **Step 2: Wire it into `apps/api/src/main.ts`** — after creating the app,
  before `app.listen`. Get the underlying Fastify instance with
  `app.getHttpAdapter().getInstance()`:

```typescript
import { registerRawBodyParser } from "./webhooks/raw-body";
```

and inside `bootstrap()`, after `const app = await NestFactory.create(...)`:

```typescript
  registerRawBodyParser(app.getHttpAdapter().getInstance());
```

- [ ] **Step 3: Verify it typechecks and builds**

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

- [ ] **Step 4: Live verification deferred** — the parser is exercised end to
  end by the webhook integration in Task 9 / 16. (A full Fastify boot needs a
  live Postgres for `PrismaModule`; deferred per the Docker-broken constraint.)

- [ ] **Step 5: Commit**

```bash
git add apps/api/src/webhooks/raw-body.ts apps/api/src/main.ts
git commit -m "feat: capture raw request body for Meta webhook signature checks"
```

---

## Task 7: WebhookIngestService — signature check, store, enqueue

**Files:**
- Create: `apps/api/src/webhooks/webhook-ingest.service.ts`
- Test: `apps/api/src/webhooks/webhook-ingest.service.test.ts`

Covers FR-01.2 (signature verification), FR-01.3 (store before processing,
invalid signature stored `signatureValid=false` and not processed), FR-01.4
(dedupKey), FR-01.5 (heavy work queued).

- [ ] **Step 1: Write the failing test** — `apps/api/src/webhooks/webhook-ingest.service.test.ts`:

```typescript
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createHmac } from "crypto";
import { WebhookIngestService } from "./webhook-ingest.service";
import { MetaClient } from "@whatapp/shared";

const APP_SECRET = "test-app-secret";

function sign(raw: string): string {
  return "sha256=" + createHmac("sha256", APP_SECRET).update(raw).digest("hex");
}

const inboundBody = JSON.stringify({
  object: "whatsapp_business_account",
  entry: [{ id: "waba-1", changes: [{ field: "messages", value: {
    messages: [{ id: "wamid.IN1", from: "9715551", timestamp: "1700000000",
      type: "text", text: { body: "hi" } }] } }] }],
});

describe("WebhookIngestService", () => {
  let prisma: { webhookEvent: { create: ReturnType<typeof vi.fn> } };
  let metaConfig: { getClient: ReturnType<typeof vi.fn> };
  let queue: { add: ReturnType<typeof vi.fn> };
  let service: WebhookIngestService;

  beforeEach(() => {
    prisma = { webhookEvent: { create: vi.fn().mockResolvedValue({ id: 1n }) } };
    const client = new MetaClient({
      apiVersion: "v20.0", phoneNumberId: "PN1", wabaId: "W1",
      accessToken: "t", appSecret: APP_SECRET, verifyToken: "v" });
    metaConfig = { getClient: vi.fn().mockResolvedValue(client) };
    queue = { add: vi.fn().mockResolvedValue(undefined) };
    service = new WebhookIngestService(
      prisma as never, metaConfig as never, queue as never);
  });

  it("stores the event with signatureValid=true and enqueues processing", async () => {
    const result = await service.ingest(
      Buffer.from(inboundBody), sign(inboundBody));
    expect(result.stored).toBe(true);
    const createArg = prisma.webhookEvent.create.mock.calls[0]?.[0].data;
    expect(createArg.signatureValid).toBe(true);
    expect(createArg.eventType).toBe("inbound_message");
    expect(createArg.dedupKey).toBe("msg:wamid.IN1");
    expect(queue.add).toHaveBeenCalledOnce();
  });

  it("stores with signatureValid=false and does NOT enqueue on bad signature", async () => {
    const result = await service.ingest(
      Buffer.from(inboundBody), "sha256=deadbeef");
    expect(result.stored).toBe(true);
    expect(result.processed).toBe(false);
    const createArg = prisma.webhookEvent.create.mock.calls[0]?.[0].data;
    expect(createArg.signatureValid).toBe(false);
    expect(queue.add).not.toHaveBeenCalled();
  });

  it("treats a missing signature header as invalid", async () => {
    await service.ingest(Buffer.from(inboundBody), undefined);
    expect(prisma.webhookEvent.create.mock.calls[0]?.[0].data.signatureValid)
      .toBe(false);
    expect(queue.add).not.toHaveBeenCalled();
  });

  it("does not enqueue a duplicate dedupKey (unique-constraint swallowed)", async () => {
    prisma.webhookEvent.create.mockRejectedValueOnce(
      Object.assign(new Error("Unique constraint"), { code: "P2002" }));
    const result = await service.ingest(
      Buffer.from(inboundBody), sign(inboundBody));
    expect(result.stored).toBe(true);
    expect(result.duplicate).toBe(true);
    expect(queue.add).not.toHaveBeenCalled();
  });
});
```

- [ ] **Step 2: Run the test, verify it fails**

Run: `pnpm --filter @whatapp/api test webhook-ingest`
Expected: FAIL — `webhook-ingest.service` not found.

- [ ] **Step 3: Implement `apps/api/src/webhooks/webhook-ingest.service.ts`:**

```typescript
import { Injectable, Logger } from "@nestjs/common";
import {
  classifyEvent,
  deriveDedupKey,
  type MetaWebhookEnvelope,
} from "@whatapp/shared";
import { PrismaService } from "../prisma/prisma.service";
import { MetaConfigService } from "../meta/meta-config.service";
import { WEBHOOK_PROCESSING_QUEUE } from "./webhook-queue.provider";
import type { Queue } from "bullmq";

/** Outcome of ingesting one webhook POST. */
export interface IngestResult {
  /** True once the event row is durably stored. */
  stored: boolean;
  /** True when the event was queued for processing. */
  processed: boolean;
  /** True when the event was a duplicate (dedupKey already stored). */
  duplicate: boolean;
}

/**
 * Ingests a Meta webhook POST: verifies the signature, stores the raw event in
 * `webhook_events`, and (only for a valid, non-duplicate event) enqueues the
 * heavy processing job. The HTTP handler returns 200 after `ingest` resolves,
 * so Meta never retry-storms — even on a bad signature (FR-01.3).
 */
@Injectable()
export class WebhookIngestService {
  private readonly logger = new Logger(WebhookIngestService.name);

  constructor(
    private readonly prisma: PrismaService,
    private readonly metaConfig: MetaConfigService,
    private readonly queue: Queue,
  ) {}

  async ingest(
    rawBody: Buffer,
    signatureHeader: string | undefined,
  ): Promise<IngestResult> {
    // 1. Verify the signature over the RAW bytes.
    let signatureValid = false;
    try {
      const client = await this.metaConfig.getClient();
      signatureValid = signatureHeader
        ? client.verifySignature(rawBody, signatureHeader)
        : false;
    } catch (err) {
      this.logger.error(
        `Could not verify webhook signature: ${(err as Error).message}`,
      );
      signatureValid = false;
    }

    // 2. Parse the envelope (best-effort — invalid JSON ⇒ unknown event).
    let envelope: MetaWebhookEnvelope = {};
    try {
      envelope = JSON.parse(rawBody.toString("utf8")) as MetaWebhookEnvelope;
    } catch {
      envelope = {};
    }
    const eventType = classifyEvent(envelope);
    const dedupKey = deriveDedupKey(envelope);

    // 3. Store the event BEFORE any processing (FR-01.3).
    let eventId: bigint;
    try {
      const row = await this.prisma.webhookEvent.create({
        data: {
          source: "meta",
          eventType,
          dedupKey,
          payload: envelope as object,
          signatureValid,
        },
      });
      eventId = row.id;
    } catch (err) {
      // A unique-constraint violation on dedupKey means a duplicate (FR-01.4):
      // it was already stored on the first delivery — accept and skip.
      if ((err as { code?: string }).code === "P2002") {
        return { stored: true, processed: false, duplicate: true };
      }
      throw err;
    }

    // 4. Only a valid signature is processed (FR-01.3); heavy work is
    //    queued, never done inline (FR-01.5).
    if (!signatureValid) {
      return { stored: true, processed: false, duplicate: false };
    }
    await this.queue.add(
      "process",
      { webhookEventId: eventId.toString() },
      { removeOnComplete: 1000, removeOnFail: 5000 },
    );
    return { stored: true, processed: true, duplicate: false };
  }
}
```

- [ ] **Step 4: Create the queue provider** — `apps/api/src/webhooks/webhook-queue.provider.ts`
  (a BullMQ `Queue` token the API uses to enqueue jobs; Redis URL from env):

```typescript
import { Queue } from "bullmq";
import IORedis from "ioredis";

/** DI token for the webhook-processing BullMQ queue. */
export const WEBHOOK_PROCESSING_QUEUE = "WEBHOOK_PROCESSING_QUEUE";
/** DI token for the media-download BullMQ queue. */
export const MEDIA_DOWNLOAD_QUEUE = "MEDIA_DOWNLOAD_QUEUE";
/** DI token for the flow-dispatch BullMQ queue. */
export const FLOW_DISPATCH_QUEUE = "FLOW_DISPATCH_QUEUE";

/** Queue name strings — MUST match apps/worker/src/queues.ts. */
const NAMES = {
  [WEBHOOK_PROCESSING_QUEUE]: "webhook-processing",
  [MEDIA_DOWNLOAD_QUEUE]: "media-download",
  [FLOW_DISPATCH_QUEUE]: "flow-dispatch",
} as const;

/** Builds a NestJS factory provider for one BullMQ queue. */
export function queueProvider(token: keyof typeof NAMES) {
  return {
    provide: token,
    useFactory: (): Queue => {
      const url = process.env["REDIS_URL"];
      if (!url) throw new Error("REDIS_URL is required");
      const connection = new IORedis(url, {
        maxRetriesPerRequest: null,
        enableReadyCheck: false,
      });
      return new Queue(NAMES[token], { connection });
    },
  };
}
```

Add `bullmq` and `ioredis` to `apps/api/package.json` dependencies (same
versions as `apps/worker`: `"bullmq": "^5.0.0"`, `"ioredis": "^5.3.0"`), then
run `pnpm install`.

- [ ] **Step 5: Run the test, verify it passes**

Run: `pnpm --filter @whatapp/api test webhook-ingest`
Expected: PASS (4 tests).

- [ ] **Step 6: Commit**

```bash
git add apps/api/src/webhooks/webhook-ingest.service.ts apps/api/src/webhooks/webhook-ingest.service.test.ts apps/api/src/webhooks/webhook-queue.provider.ts apps/api/package.json pnpm-lock.yaml
git commit -m "feat: add webhook ingest service with signature check and queueing"
```

---

## Task 8: WebhooksController — GET handshake & POST ingestion

**Files:**
- Create: `apps/api/src/webhooks/webhooks.controller.ts`
- Test: `apps/api/src/webhooks/webhooks.controller.test.ts`

Covers FR-01.1 (GET handshake) and the HTTP surface of FR-01.2/FR-01.3/FR-01.5.

- [ ] **Step 1: Write the failing test** — `apps/api/src/webhooks/webhooks.controller.test.ts`:

```typescript
import { describe, it, expect, vi, beforeEach } from "vitest";
import { ForbiddenException } from "@nestjs/common";
import { WebhooksController } from "./webhooks.controller";

describe("WebhooksController", () => {
  let metaConfig: { getConfig: ReturnType<typeof vi.fn> };
  let ingest: { ingest: ReturnType<typeof vi.fn> };
  let controller: WebhooksController;

  beforeEach(() => {
    metaConfig = {
      getConfig: vi.fn().mockResolvedValue({ verifyToken: "right-token" }),
    };
    ingest = { ingest: vi.fn().mockResolvedValue(
      { stored: true, processed: true, duplicate: false }) };
    controller = new WebhooksController(metaConfig as never, ingest as never);
  });

  it("echoes the challenge when the verify token matches (FR-01.1)", async () => {
    const out = await controller.verify("subscribe", "right-token", "CHALLENGE123");
    expect(out).toBe("CHALLENGE123");
  });

  it("throws 403 when the verify token is wrong (FR-01.1)", async () => {
    await expect(
      controller.verify("subscribe", "wrong-token", "CHALLENGE123"),
    ).rejects.toBeInstanceOf(ForbiddenException);
  });

  it("delegates POST to the ingest service and returns 200 body", async () => {
    const req = { rawBody: Buffer.from('{"object":"x"}') };
    const out = await controller.receive(req as never, "sha256=abc");
    expect(ingest.ingest).toHaveBeenCalledWith(req.rawBody, "sha256=abc");
    expect(out).toEqual({ received: true });
  });

  it("still returns 200 when the signature was invalid", async () => {
    ingest.ingest.mockResolvedValue(
      { stored: true, processed: false, duplicate: false });
    const out = await controller.receive(
      { rawBody: Buffer.from("{}") } as never, "sha256=bad");
    expect(out).toEqual({ received: true });
  });
});
```

- [ ] **Step 2: Run the test, verify it fails**

Run: `pnpm --filter @whatapp/api test webhooks.controller`
Expected: FAIL — `webhooks.controller` not found.

- [ ] **Step 3: Implement `apps/api/src/webhooks/webhooks.controller.ts`:**

```typescript
import {
  Controller,
  Get,
  Post,
  Query,
  Req,
  Headers,
  HttpCode,
  HttpStatus,
  ForbiddenException,
} from "@nestjs/common";
import { Public } from "../auth/roles.decorator";
import { MetaConfigService } from "../meta/meta-config.service";
import { WebhookIngestService } from "./webhook-ingest.service";

/** A Fastify request carrying the captured raw body (see raw-body.ts). */
interface RawBodyRequest {
  rawBody?: Buffer;
}

/**
 * Owns the single Meta webhook callback URL. The platform — not n8n, not
 * AiSensy — owns this endpoint (docs/design.md, webhook-ownership model).
 */
@Controller("webhooks/meta")
export class WebhooksController {
  constructor(
    private readonly metaConfig: MetaConfigService,
    private readonly ingest: WebhookIngestService,
  ) {}

  /**
   * Meta subscription handshake (FR-01.1). Meta calls this once on setup with
   * `hub.mode`, `hub.verify_token`, `hub.challenge`. Echo the challenge on a
   * token match; 403 on mismatch.
   */
  @Public()
  @Get()
  async verify(
    @Query("hub.mode") _mode: string,
    @Query("hub.verify_token") verifyToken: string,
    @Query("hub.challenge") challenge: string,
  ): Promise<string> {
    const config = await this.metaConfig.getConfig();
    if (verifyToken && verifyToken === config.verifyToken) {
      return challenge;
    }
    throw new ForbiddenException("Invalid verify token");
  }

  /**
   * Webhook event ingestion (FR-01.2/.3/.5). Returns 200 fast after the event
   * is stored — even on an invalid signature — so Meta does not retry-storm.
   */
  @Public()
  @Post()
  @HttpCode(HttpStatus.OK)
  async receive(
    @Req() req: RawBodyRequest,
    @Headers("x-hub-signature-256") signature: string | undefined,
  ): Promise<{ received: true }> {
    const rawBody = req.rawBody ?? Buffer.alloc(0);
    await this.ingest.ingest(rawBody, signature);
    return { received: true };
  }
}
```

- [ ] **Step 4: Run the test, verify it passes**

Run: `pnpm --filter @whatapp/api test webhooks.controller`
Expected: PASS (4 tests).

- [ ] **Step 5: Commit**

```bash
git add apps/api/src/webhooks/webhooks.controller.ts apps/api/src/webhooks/webhooks.controller.test.ts
git commit -m "feat: add Meta webhooks controller (handshake + ingestion)"
```

> Note: `GET /webhooks/meta` and `POST /webhooks/meta` are exempt from the
> global path prefix `api` only if the prefix excludes `webhooks`. **In Task 15**
> the global prefix in `main.ts` is updated to also exclude `webhooks` and
> `internal` so these paths resolve at the root, matching the spec's API surface.

---

## Task 9: WebhookProcessorService — inbound messages

**Files:**
- Create: `apps/api/src/webhooks/webhook-processor.service.ts`
- Test: `apps/api/src/webhooks/webhook-processor.service.test.ts`

Covers FR-01.6 (upsert Contact, upsert Conversation with 24h window, insert
inbound Message), FR-01.7 (every supported type + `unsupported`), the inbound
half of FR-01.4 (idempotent — duplicate wamid skipped), and FR-01.8/FR-01.17
enqueue points (the jobs themselves land in Tasks 12–13).

- [ ] **Step 1: Write the failing test** — `apps/api/src/webhooks/webhook-processor.service.test.ts`:

```typescript
import { describe, it, expect, vi, beforeEach } from "vitest";
import { WebhookProcessorService } from "./webhook-processor.service";

const WINDOW_MS = 24 * 60 * 60 * 1000;

function inboundEnvelope(over: Record<string, unknown> = {}) {
  return {
    object: "whatsapp_business_account",
    entry: [{ id: "waba-1", changes: [{ field: "messages", value: {
      contacts: [{ wa_id: "9715551", profile: { name: "Ahmed" } }],
      messages: [{ id: "wamid.IN1", from: "9715551", timestamp: "1700000000",
        type: "text", text: { body: "hello" }, ...over }] } }] }],
  };
}

describe("WebhookProcessorService — inbound", () => {
  let prisma: Record<string, Record<string, ReturnType<typeof vi.fn>>>;
  let mediaQueue: { add: ReturnType<typeof vi.fn> };
  let flowQueue: { add: ReturnType<typeof vi.fn> };
  let service: WebhookProcessorService;

  beforeEach(() => {
    prisma = {
      contact: { upsert: vi.fn().mockResolvedValue({ id: "contact-1" }) },
      conversation: { upsert: vi.fn().mockResolvedValue({ id: "conv-1" }) },
      message: {
        findUnique: vi.fn().mockResolvedValue(null),
        create: vi.fn().mockResolvedValue({ id: "msg-1" }),
      },
    };
    mediaQueue = { add: vi.fn().mockResolvedValue(undefined) };
    flowQueue = { add: vi.fn().mockResolvedValue(undefined) };
    service = new WebhookProcessorService(
      prisma as never, mediaQueue as never, flowQueue as never);
  });

  it("upserts the contact by waId with the profile name (FR-01.6)", async () => {
    await service.process(inboundEnvelope());
    const arg = prisma.contact.upsert.mock.calls[0]?.[0];
    expect(arg.where).toEqual({ waId: "9715551" });
    expect(arg.create.waId).toBe("9715551");
    expect(arg.create.profileName).toBe("Ahmed");
    expect(arg.update.profileName).toBe("Ahmed");
  });

  it("upserts the conversation with a 24h window (FR-01.6)", async () => {
    const before = Date.now();
    await service.process(inboundEnvelope());
    const arg = prisma.conversation.upsert.mock.calls[0]?.[0];
    const expiry = (arg.update.windowExpiresAt as Date).getTime();
    expect(expiry).toBeGreaterThanOrEqual(before + WINDOW_MS - 2000);
    expect(expiry).toBeLessThanOrEqual(Date.now() + WINDOW_MS + 2000);
  });

  it("inserts an inbound Message row (FR-01.6)", async () => {
    await service.process(inboundEnvelope());
    const arg = prisma.message.create.mock.calls[0]?.[0].data;
    expect(arg.direction).toBe("inbound");
    expect(arg.status).toBe("received");
    expect(arg.type).toBe("text");
    expect(arg.body).toBe("hello");
    expect(arg.wamid).toBe("wamid.IN1");
    expect(arg.contactId).toBe("contact-1");
  });

  it("stores an unknown type as 'unsupported' (FR-01.7)", async () => {
    await service.process(inboundEnvelope({ type: "order", text: undefined }));
    expect(prisma.message.create.mock.calls[0]?.[0].data.type)
      .toBe("unsupported");
  });

  it("skips an already-stored wamid — idempotent (FR-01.4)", async () => {
    prisma.message.findUnique.mockResolvedValue({ id: "existing" });
    await service.process(inboundEnvelope());
    expect(prisma.message.create).not.toHaveBeenCalled();
  });

  it("enqueues a media-download job for an image message (FR-01.8)", async () => {
    await service.process(inboundEnvelope({
      type: "image", text: undefined, image: { id: "media-99" } }));
    expect(mediaQueue.add).toHaveBeenCalledOnce();
    expect(mediaQueue.add.mock.calls[0]?.[1]).toMatchObject(
      { messageId: "msg-1", mediaId: "media-99" });
  });

  it("does NOT enqueue media for a text message", async () => {
    await service.process(inboundEnvelope());
    expect(mediaQueue.add).not.toHaveBeenCalled();
  });

  it("enqueues a flow-dispatch job for a conversational message (FR-01.17)", async () => {
    await service.process(inboundEnvelope());
    expect(flowQueue.add).toHaveBeenCalledOnce();
    expect(flowQueue.add.mock.calls[0]?.[1]).toMatchObject(
      { messageId: "msg-1", waId: "9715551" });
  });
});
```

- [ ] **Step 2: Run the test, verify it fails**

Run: `pnpm --filter @whatapp/api test webhook-processor`
Expected: FAIL — `webhook-processor.service` not found.

- [ ] **Step 3: Implement `apps/api/src/webhooks/webhook-processor.service.ts`** (inbound path):

```typescript
import { Injectable, Logger } from "@nestjs/common";
import {
  extractInboundMessages,
  type MetaWebhookEnvelope,
  type ParsedInbound,
} from "@whatapp/shared";
import { PrismaService } from "../prisma/prisma.service";
import type { Queue } from "bullmq";

/** Inbound message types whose media must be downloaded out-of-band. */
const MEDIA_TYPES = new Set(["image", "document", "audio", "video", "sticker"]);
const WINDOW_MS = 24 * 60 * 60 * 1000;

/**
 * Parses a stored webhook envelope and writes the durable records: contacts,
 * conversations, inbound messages, delivery statuses, template/quality updates.
 * Idempotent at the message level — a wamid that already has a row is skipped
 * (FR-01.4). Status and template/quality handling are added in Tasks 10 & 11.
 */
@Injectable()
export class WebhookProcessorService {
  private readonly logger = new Logger(WebhookProcessorService.name);

  constructor(
    private readonly prisma: PrismaService,
    private readonly mediaQueue: Queue,
    private readonly flowQueue: Queue,
  ) {}

  /** Processes one webhook envelope end to end. */
  async process(envelope: MetaWebhookEnvelope): Promise<void> {
    for (const inbound of extractInboundMessages(envelope)) {
      await this.handleInbound(inbound);
    }
    // Status and template/quality handling are appended in later tasks.
  }

  /** Handles one inbound message (FR-01.6, FR-01.7, FR-01.8, FR-01.17). */
  private async handleInbound(msg: ParsedInbound): Promise<void> {
    // Idempotency — a re-delivered webhook must not create a second row.
    const existing = await this.prisma.message.findUnique({
      where: { wamid: msg.wamid },
    });
    if (existing) {
      this.logger.debug(`Inbound ${msg.wamid} already stored — skipping`);
      return;
    }

    const now = new Date();

    // Upsert the contact by waId (FR-01.6).
    const contact = await this.prisma.contact.upsert({
      where: { waId: msg.waId },
      create: {
        waId: msg.waId,
        profileName: msg.profileName,
        lastSeenAt: now,
        lastInboundAt: now,
      },
      update: {
        profileName: msg.profileName ?? undefined,
        lastSeenAt: now,
        lastInboundAt: now,
      },
    });

    // Upsert the conversation, refreshing the 24h customer-service window.
    const windowExpiresAt = new Date(now.getTime() + WINDOW_MS);
    await this.prisma.conversation.upsert({
      where: { contactId: contact.id },
      create: {
        contactId: contact.id,
        waId: msg.waId,
        lastInboundAt: now,
        windowExpiresAt,
        lastMessageAt: now,
      },
      update: {
        lastInboundAt: now,
        windowExpiresAt,
        lastMessageAt: now,
      },
    });

    // Insert the inbound message row.
    const message = await this.prisma.message.create({
      data: {
        contactId: contact.id,
        waId: msg.waId,
        direction: "inbound",
        wamid: msg.wamid,
        type: msg.type,
        body: msg.body,
        payload: msg.raw as object,
        status: "received",
        timestamp: msg.timestamp,
      },
    });

    // Queue media download for media-bearing messages (FR-01.8).
    const mediaId = this.extractMediaId(msg);
    if (mediaId) {
      await this.mediaQueue.add(
        "download",
        { messageId: message.id, mediaId },
        { removeOnComplete: 1000, removeOnFail: 5000 },
      );
    }

    // Queue dispatch to the n8n flow engine (FR-01.17).
    await this.flowQueue.add(
      "dispatch",
      {
        messageId: message.id,
        waId: msg.waId,
        type: msg.type,
        body: msg.body,
      },
      { removeOnComplete: 1000, removeOnFail: 5000 },
    );
  }

  /** Pulls the Meta media id from a media-bearing inbound message. */
  private extractMediaId(msg: ParsedInbound): string | null {
    if (!MEDIA_TYPES.has(msg.type)) return null;
    const raw = msg.raw as Record<string, { id?: string } | undefined>;
    return raw[msg.type]?.id ?? null;
  }
}
```

- [ ] **Step 4: Run the test, verify it passes**

Run: `pnpm --filter @whatapp/api test webhook-processor`
Expected: PASS (8 tests).

- [ ] **Step 5: Commit**

```bash
git add apps/api/src/webhooks/webhook-processor.service.ts apps/api/src/webhooks/webhook-processor.service.test.ts
git commit -m "feat: process inbound webhook messages into contacts and messages"
```

---

## Task 10: WebhookProcessorService — delivery statuses

**Files:**
- Modify: `apps/api/src/webhooks/webhook-processor.service.ts`
- Modify: `apps/api/src/webhooks/webhook-processor.service.test.ts`

Covers FR-01.9 (find Message by wamid, append `MessageStatusEvent`, update
status + timestamp, forward-only) and FR-01.10 (failed status records
errorCode/errorTitle/errorDetail).

- [ ] **Step 1: Add failing tests** — append to `webhook-processor.service.test.ts`:

```typescript
function statusEnvelope(status: string, over: Record<string, unknown> = {}) {
  return {
    object: "whatsapp_business_account",
    entry: [{ id: "waba-1", changes: [{ field: "messages", value: {
      statuses: [{ id: "wamid.OUT1", status, timestamp: "1700000100",
        ...over }] } }] }],
  };
}

describe("WebhookProcessorService — statuses", () => {
  let prisma: Record<string, Record<string, ReturnType<typeof vi.fn>>>;
  let service: WebhookProcessorService;

  beforeEach(() => {
    prisma = {
      contact: { upsert: vi.fn() },
      conversation: { upsert: vi.fn() },
      message: {
        findUnique: vi.fn(),
        findFirst: vi.fn(),
        create: vi.fn(),
        update: vi.fn().mockResolvedValue({ id: "msg-1" }),
      },
      messageStatusEvent: { create: vi.fn().mockResolvedValue({ id: 1n }) },
    };
    service = new WebhookProcessorService(
      prisma as never, { add: vi.fn() } as never, { add: vi.fn() } as never);
  });

  it("appends a status event and advances the message status (FR-01.9)", async () => {
    prisma.message.findFirst.mockResolvedValue(
      { id: "msg-1", status: "sent" });
    await service.process(statusEnvelope("delivered"));
    expect(prisma.messageStatusEvent.create).toHaveBeenCalledOnce();
    const upd = prisma.message.update.mock.calls[0]?.[0];
    expect(upd.where).toEqual({ id: "msg-1" });
    expect(upd.data.status).toBe("delivered");
    expect(upd.data.deliveredAt).toBeInstanceOf(Date);
  });

  it("does NOT regress a higher status (FR-01.9)", async () => {
    prisma.message.findFirst.mockResolvedValue(
      { id: "msg-1", status: "read" });
    await service.process(statusEnvelope("sent"));
    // the event is still recorded for audit
    expect(prisma.messageStatusEvent.create).toHaveBeenCalledOnce();
    // but status is NOT downgraded
    expect(prisma.message.update).not.toHaveBeenCalled();
  });

  it("records error fields on a failed status (FR-01.10)", async () => {
    prisma.message.findFirst.mockResolvedValue(
      { id: "msg-1", status: "sent" });
    await service.process(statusEnvelope("failed", {
      errors: [{ code: 131026, title: "Undeliverable",
        message: "not on WhatsApp", error_data: { details: "x" } }] }));
    const upd = prisma.message.update.mock.calls[0]?.[0].data;
    expect(upd.status).toBe("failed");
    expect(upd.errorCode).toBe("131026");
    expect(upd.errorTitle).toBe("Undeliverable");
    expect(upd.errorDetail).toBeTruthy();
    expect(upd.failedAt).toBeInstanceOf(Date);
  });

  it("ignores a status whose wamid has no local message", async () => {
    prisma.message.findFirst.mockResolvedValue(null);
    await service.process(statusEnvelope("delivered"));
    expect(prisma.messageStatusEvent.create).not.toHaveBeenCalled();
    expect(prisma.message.update).not.toHaveBeenCalled();
  });
});
```

- [ ] **Step 2: Run the test, verify it fails**

Run: `pnpm --filter @whatapp/api test webhook-processor`
Expected: FAIL — statuses are not handled yet (`process` ignores `statuses[]`).

- [ ] **Step 3: Implement — modify `webhook-processor.service.ts`:**

Update the imports to add the status helpers:

```typescript
import {
  extractInboundMessages,
  extractStatuses,
  isForwardStatus,
  type MetaWebhookEnvelope,
  type ParsedInbound,
  type ParsedStatus,
} from "@whatapp/shared";
```

In `process()`, after the inbound loop, add the status loop:

```typescript
    for (const status of extractStatuses(envelope)) {
      await this.handleStatus(status);
    }
```

Add the `handleStatus` method to the class:

```typescript
  /** Timestamp column for each status (FR-01.9). */
  private static readonly STATUS_TIMESTAMP_COLUMN: Record<string, string> = {
    sent: "sentAt",
    delivered: "deliveredAt",
    read: "readAt",
    failed: "failedAt",
  };

  /** Handles one delivery status (FR-01.9, FR-01.10). */
  private async handleStatus(status: ParsedStatus): Promise<void> {
    const message = await this.prisma.message.findFirst({
      where: { wamid: status.wamid },
    });
    if (!message) {
      this.logger.warn(
        `Status for unknown wamid ${status.wamid} — storing nothing`,
      );
      return;
    }

    // Always append the status event for audit (even if it does not advance
    // the message — late/duplicate events are still a fact worth recording).
    await this.prisma.messageStatusEvent.create({
      data: {
        messageId: message.id,
        wamid: status.wamid,
        status: status.status,
        error: (status.errorDetail as object) ?? undefined,
        occurredAt: status.occurredAt,
      },
    });

    // Forward-only: never let a late/out-of-order lower status regress the
    // message (FR-01.9).
    if (!isForwardStatus(message.status, status.status)) {
      return;
    }

    const data: Record<string, unknown> = { status: status.status };
    const column =
      WebhookProcessorService.STATUS_TIMESTAMP_COLUMN[status.status];
    if (column) data[column] = status.occurredAt;

    // A failed status records Meta's error object (FR-01.10).
    if (status.status === "failed") {
      data["errorCode"] = status.errorCode;
      data["errorTitle"] = status.errorTitle;
      data["errorDetail"] = (status.errorDetail as object) ?? undefined;
    }

    await this.prisma.message.update({
      where: { id: message.id },
      data,
    });
  }
```

- [ ] **Step 4: Run the test, verify it passes**

Run: `pnpm --filter @whatapp/api test webhook-processor`
Expected: PASS (all webhook-processor tests).

- [ ] **Step 5: Commit**

```bash
git add apps/api/src/webhooks/webhook-processor.service.ts apps/api/src/webhooks/webhook-processor.service.test.ts
git commit -m "feat: process delivery statuses with forward-only updates"
```

---

## Task 11: WebhookProcessorService — template & quality events

**Files:**
- Modify: `apps/api/src/webhooks/webhook-processor.service.ts`
- Modify: `apps/api/src/webhooks/webhook-processor.service.test.ts`
- Create: `apps/api/src/quality/quality.service.ts`
- Create: `apps/api/src/quality/quality.module.ts`
- Test: `apps/api/src/quality/quality.service.test.ts`

Covers FR-01.11 (template-status update → `Template.status` /
`rejectionReason` / `qualityScore`; unmatched event stored and skipped) and
FR-01.12 (quality/account events persisted to a quality state in `Setting`).

- [ ] **Step 1: Write the failing QualityService test** — `apps/api/src/quality/quality.service.test.ts`:

```typescript
import { describe, it, expect, vi, beforeEach } from "vitest";
import { QualityService } from "./quality.service";

describe("QualityService", () => {
  let prisma: { setting: { upsert: ReturnType<typeof vi.fn> } };
  let service: QualityService;

  beforeEach(() => {
    prisma = { setting: { upsert: vi.fn().mockResolvedValue({}) } };
    service = new QualityService(prisma as never);
  });

  it("persists the latest quality rating and tier under one Setting key", async () => {
    await service.recordQualityUpdate({
      display_phone_number: "+9715550000",
      event: "FLAGGED",
      current_limit: "TIER_1K",
      messaging_limit_tier: "TIER_1K",
      quality_score: { score: "RED" },
    });
    const arg = prisma.setting.upsert.mock.calls[0]?.[0];
    expect(arg.where).toEqual({ key: "meta.quality" });
    const value = arg.create.value as Record<string, unknown>;
    expect(value.qualityRating).toBe("RED");
    expect(value.messagingLimitTier).toBe("TIER_1K");
    expect(value.updatedAt).toBeTruthy();
  });

  it("accepts a string quality_score", async () => {
    await service.recordQualityUpdate({ quality_score: "GREEN" });
    const value = prisma.setting.upsert.mock.calls[0]?.[0]
      .create.value as Record<string, unknown>;
    expect(value.qualityRating).toBe("GREEN");
  });
});
```

- [ ] **Step 2: Run it, verify it fails**

Run: `pnpm --filter @whatapp/api test quality.service`
Expected: FAIL — `quality.service` not found.

- [ ] **Step 3: Implement `apps/api/src/quality/quality.service.ts`:**

```typescript
import { Injectable } from "@nestjs/common";
import type { MetaQualityUpdate } from "@whatapp/shared";
import { PrismaService } from "../prisma/prisma.service";

/** The Setting key under which the latest WABA quality state is stored. */
export const META_QUALITY_SETTING_KEY = "meta.quality";

/**
 * Persists `phone_number_quality_update` / `account_update` events to a single
 * `Setting` row the Quality module (Spec 07) reads. At minimum the current
 * quality rating and messaging limit tier are kept up to date (FR-01.12).
 */
@Injectable()
export class QualityService {
  constructor(private readonly prisma: PrismaService) {}

  /** Records the latest quality/account state from a Meta quality event. */
  async recordQualityUpdate(value: MetaQualityUpdate): Promise<void> {
    const rating =
      typeof value.quality_score === "string"
        ? value.quality_score
        : value.quality_score?.score;

    const state = {
      qualityRating: rating ?? null,
      messagingLimitTier:
        value.messaging_limit_tier ?? value.current_limit ?? null,
      lastEvent: value.event ?? null,
      displayPhoneNumber: value.display_phone_number ?? null,
      updatedAt: new Date().toISOString(),
    };

    await this.prisma.setting.upsert({
      where: { key: META_QUALITY_SETTING_KEY },
      create: { key: META_QUALITY_SETTING_KEY, value: state },
      update: { value: state },
    });
  }
}
```

- [ ] **Step 4: Run the QualityService test, verify it passes**

Run: `pnpm --filter @whatapp/api test quality.service`
Expected: PASS (2 tests).

- [ ] **Step 5: Create `apps/api/src/quality/quality.module.ts`:**

```typescript
import { Module } from "@nestjs/common";
import { QualityService } from "./quality.service";

@Module({
  providers: [QualityService],
  exports: [QualityService],
})
export class QualityModule {}
```

- [ ] **Step 6: Add failing tests for template/quality handling** — append to
  `webhook-processor.service.test.ts`:

```typescript
function templateEnvelope(over: Record<string, unknown> = {}) {
  return {
    entry: [{ id: "waba-1", changes: [{
      field: "message_template_status_update", value: {
        message_template_name: "lead_followup",
        message_template_language: "en",
        event: "APPROVED", ...over } }] }],
  };
}

function qualityEnvelope() {
  return {
    entry: [{ id: "waba-1", changes: [{
      field: "phone_number_quality_update", value: {
        event: "FLAGGED", messaging_limit_tier: "TIER_1K",
        quality_score: { score: "RED" } } }] }],
  };
}

describe("WebhookProcessorService — template & quality", () => {
  let prisma: Record<string, Record<string, ReturnType<typeof vi.fn>>>;
  let quality: { recordQualityUpdate: ReturnType<typeof vi.fn> };
  let service: WebhookProcessorService;

  beforeEach(() => {
    prisma = {
      contact: { upsert: vi.fn() },
      conversation: { upsert: vi.fn() },
      message: { findUnique: vi.fn(), findFirst: vi.fn() },
      messageStatusEvent: { create: vi.fn() },
      template: {
        findFirst: vi.fn(),
        update: vi.fn().mockResolvedValue({ id: "tpl-1" }),
      },
    };
    quality = { recordQualityUpdate: vi.fn().mockResolvedValue(undefined) };
    service = new WebhookProcessorService(
      prisma as never, { add: vi.fn() } as never, { add: vi.fn() } as never,
      quality as never);
  });

  it("updates a matching Template's status to APPROVED (FR-01.11)", async () => {
    prisma.template.findFirst.mockResolvedValue({ id: "tpl-1" });
    await service.process(templateEnvelope());
    const upd = prisma.template.update.mock.calls[0]?.[0];
    expect(upd.where).toEqual({ id: "tpl-1" });
    expect(upd.data.status).toBe("APPROVED");
  });

  it("records rejectionReason on a REJECTED template (FR-01.11)", async () => {
    prisma.template.findFirst.mockResolvedValue({ id: "tpl-1" });
    await service.process(templateEnvelope({
      event: "REJECTED", reason: "INVALID_FORMAT" }));
    const upd = prisma.template.update.mock.calls[0]?.[0].data;
    expect(upd.status).toBe("REJECTED");
    expect(upd.rejectionReason).toBe("INVALID_FORMAT");
  });

  it("skips quietly when no local template matches (FR-01.11)", async () => {
    prisma.template.findFirst.mockResolvedValue(null);
    await service.process(templateEnvelope());
    expect(prisma.template.update).not.toHaveBeenCalled();
  });

  it("forwards a quality update to QualityService (FR-01.12)", async () => {
    await service.process(qualityEnvelope());
    expect(quality.recordQualityUpdate).toHaveBeenCalledOnce();
  });
});
```

- [ ] **Step 7: Run it, verify it fails**

Run: `pnpm --filter @whatapp/api test webhook-processor`
Expected: FAIL — `WebhookProcessorService` constructor has no 4th arg /
template & quality not handled.

- [ ] **Step 8: Implement — modify `webhook-processor.service.ts`:**

Add the import:

```typescript
import type { MetaTemplateStatusUpdate, MetaQualityUpdate } from "@whatapp/shared";
import { QualityService } from "../quality/quality.service";
```

Add `QualityService` to the constructor (4th parameter):

```typescript
  constructor(
    private readonly prisma: PrismaService,
    private readonly mediaQueue: Queue,
    private readonly flowQueue: Queue,
    private readonly quality: QualityService,
  ) {}
```

In `process()`, after the status loop, add change-level handling for the other
two field types:

```typescript
    for (const entry of envelope.entry ?? []) {
      for (const change of entry.changes ?? []) {
        if (change.field === "message_template_status_update") {
          await this.handleTemplateStatus(
            change.value as MetaTemplateStatusUpdate,
          );
        } else if (
          change.field === "phone_number_quality_update" ||
          change.field === "account_update"
        ) {
          await this.quality.recordQualityUpdate(
            change.value as MetaQualityUpdate,
          );
        }
      }
    }
```

Add the `handleTemplateStatus` method:

```typescript
  /** Maps a Meta template-status event word to a local TemplateStatus. */
  private static readonly TEMPLATE_STATUS_MAP: Record<string, string> = {
    APPROVED: "APPROVED",
    REJECTED: "REJECTED",
    PENDING: "PENDING",
    PAUSED: "PAUSED",
    DISABLED: "DISABLED",
    FLAGGED: "PAUSED",
  };

  /** Handles a `message_template_status_update` event (FR-01.11). */
  private async handleTemplateStatus(
    value: MetaTemplateStatusUpdate,
  ): Promise<void> {
    const name = value.message_template_name;
    const language = value.message_template_language;
    if (!name) return;

    const template = await this.prisma.template.findFirst({
      where: language ? { name, language } : { name },
    });
    if (!template) {
      // No local template matches — the raw event is already preserved in
      // webhook_events; nothing else to do (FR-01.11).
      this.logger.warn(
        `Template status for unknown template "${name}" — skipping`,
      );
      return;
    }

    const event = (value.event ?? "").toUpperCase();
    const status = WebhookProcessorService.TEMPLATE_STATUS_MAP[event];
    const data: Record<string, unknown> = {};
    if (status) data["status"] = status;
    if (event === "REJECTED" && value.reason) {
      data["rejectionReason"] = value.reason;
    }
    if (Object.keys(data).length === 0) return;

    await this.prisma.template.update({
      where: { id: template.id },
      data,
    });
  }
```

- [ ] **Step 9: Run the test, verify it passes**

Run: `pnpm --filter @whatapp/api test webhook-processor quality.service`
Expected: PASS (all tests).

- [ ] **Step 10: Commit**

```bash
git add apps/api/src/webhooks/webhook-processor.service.ts apps/api/src/webhooks/webhook-processor.service.test.ts apps/api/src/quality/quality.service.ts apps/api/src/quality/quality.service.test.ts apps/api/src/quality/quality.module.ts
git commit -m "feat: process template-status and quality webhook events"
```

---

## Task 12: Worker — webhook-processing processor

**Files:**
- Create: `apps/worker/src/lib/prisma.ts`
- Create: `apps/worker/src/lib/connections.ts`
- Create: `apps/worker/src/processors/webhook-processing.processor.ts`
- Create: `apps/worker/vitest.config.ts`
- Test: `apps/worker/src/processors/webhook-processing.processor.test.ts`
- Modify: `apps/worker/src/main.ts`
- Modify: `apps/worker/package.json`

Covers FR-01.5 in the worker: the heavy parse runs off the request thread. The
worker processor loads the stored `webhook_events` row and runs the same
parsing logic.

- [ ] **Step 1: Add the worker vitest config** — `apps/worker/vitest.config.ts`:

```typescript
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    environment: "node",
    globals: false,
  },
});
```

Change the `test` script in `apps/worker/package.json` from
`"vitest run --passWithNoTests"` to `"vitest run"`.

- [ ] **Step 2: Create `apps/worker/src/lib/prisma.ts`:**

```typescript
import { PrismaClient } from "@whatapp/db";

/** A single Prisma client shared by every worker processor. */
export const prisma = new PrismaClient();
```

- [ ] **Step 3: Create `apps/worker/src/lib/connections.ts`** — the worker reads
  decrypted Meta/n8n config straight from the `connections` table (it has no
  NestJS DI container):

```typescript
import { decryptSecret, type MetaConfig, type N8nConfig } from "@whatapp/shared";
import { prisma } from "./prisma.js";

/** The 32-char AES key — same env var the API uses. */
function encryptionKey(): string {
  const key = process.env["CONFIG_ENCRYPTION_KEY"];
  if (!key) throw new Error("CONFIG_ENCRYPTION_KEY is required");
  return key;
}

/** Decrypts a stored secrets JSON blob into a plain object. */
function decryptSecrets(blob: unknown): Record<string, string> {
  const encrypted = JSON.parse(String(blob)) as Record<string, string>;
  const out: Record<string, string> = {};
  for (const [k, v] of Object.entries(encrypted)) {
    out[k] = decryptSecret(v, encryptionKey());
  }
  return out;
}

/** Loads the Meta connection config for the worker. */
export async function loadMetaConfig(): Promise<MetaConfig> {
  const row = await prisma.connection.findUnique({
    where: { provider_label: { provider: "meta", label: "primary" } },
  });
  if (!row) throw new Error("No meta connection configured");
  const settings = row.settings as Record<string, unknown>;
  const secrets = decryptSecrets(row.secrets);
  return {
    apiVersion: (settings["apiVersion"] as string) ?? "v20.0",
    phoneNumberId: String(settings["phoneNumberId"] ?? ""),
    wabaId: String(settings["wabaId"] ?? ""),
    accessToken: secrets["accessToken"] ?? "",
    appSecret: secrets["appSecret"] ?? "",
    verifyToken: secrets["verifyToken"] ?? "",
  };
}

/** Loads the n8n connection config for the worker (null when not set). */
export async function loadN8nConfig(): Promise<N8nConfig | null> {
  const row = await prisma.connection.findUnique({
    where: { provider_label: { provider: "n8n", label: "primary" } },
  });
  if (!row) return null;
  const settings = row.settings as Record<string, unknown>;
  const secrets = decryptSecrets(row.secrets);
  return {
    baseUrl: String(settings["baseUrl"] ?? ""),
    apiKey: secrets["apiKey"] ?? "",
    callbackSecret: secrets["callbackSecret"] ?? "",
  };
}
```

- [ ] **Step 4: Write the failing test** — `apps/worker/src/processors/webhook-processing.processor.test.ts`:

```typescript
import { describe, it, expect, vi, beforeEach } from "vitest";
import { processWebhookJob } from "./webhook-processing.processor.js";

describe("processWebhookJob", () => {
  let deps: {
    loadEvent: ReturnType<typeof vi.fn>;
    processEnvelope: ReturnType<typeof vi.fn>;
    markProcessed: ReturnType<typeof vi.fn>;
  };

  beforeEach(() => {
    deps = {
      loadEvent: vi.fn().mockResolvedValue({
        id: 7n, payload: { object: "x", entry: [] }, processed: false }),
      processEnvelope: vi.fn().mockResolvedValue(undefined),
      markProcessed: vi.fn().mockResolvedValue(undefined),
    };
  });

  it("loads the event, processes the envelope, marks it processed", async () => {
    await processWebhookJob({ webhookEventId: "7" }, deps);
    expect(deps.loadEvent).toHaveBeenCalledWith(7n);
    expect(deps.processEnvelope).toHaveBeenCalledWith({ object: "x", entry: [] });
    expect(deps.markProcessed).toHaveBeenCalledWith(7n, null);
  });

  it("skips an event already marked processed (idempotent, FR-01.4)", async () => {
    deps.loadEvent.mockResolvedValue({ id: 7n, payload: {}, processed: true });
    await processWebhookJob({ webhookEventId: "7" }, deps);
    expect(deps.processEnvelope).not.toHaveBeenCalled();
  });

  it("records the error message when processing throws", async () => {
    deps.processEnvelope.mockRejectedValue(new Error("boom"));
    await expect(
      processWebhookJob({ webhookEventId: "7" }, deps),
    ).rejects.toThrow("boom");
    expect(deps.markProcessed).toHaveBeenCalledWith(7n, "boom");
  });
});
```

- [ ] **Step 5: Run it, verify it fails**

Run: `pnpm --filter @whatapp/worker test webhook-processing`
Expected: FAIL — `webhook-processing.processor` not found.

- [ ] **Step 6: Implement `apps/worker/src/processors/webhook-processing.processor.ts`:**

```typescript
import type { MetaWebhookEnvelope } from "@whatapp/shared";

/** A stored webhook_events row as the processor needs it. */
export interface StoredWebhookEvent {
  id: bigint;
  payload: unknown;
  processed: boolean;
}

/** The job payload enqueued by the API's WebhookIngestService. */
export interface WebhookJobData {
  webhookEventId: string;
}

/** Injected collaborators — real implementations wired in main.ts, mocked in tests. */
export interface WebhookProcessorDeps {
  /** Loads a webhook_events row by id. */
  loadEvent: (id: bigint) => Promise<StoredWebhookEvent | null>;
  /** Runs the full parse/persist of one envelope. */
  processEnvelope: (envelope: MetaWebhookEnvelope) => Promise<void>;
  /** Marks the row processed; `error` is null on success. */
  markProcessed: (id: bigint, error: string | null) => Promise<void>;
}

/**
 * Processes one `webhook-processing` job: load the stored event, run the
 * parse/persist, mark the row processed. Idempotent — an event already marked
 * processed is skipped (FR-01.4). On failure the error is recorded and
 * re-thrown so BullMQ retries.
 */
export async function processWebhookJob(
  data: WebhookJobData,
  deps: WebhookProcessorDeps,
): Promise<void> {
  const id = BigInt(data.webhookEventId);
  const event = await deps.loadEvent(id);
  if (!event) return;
  if (event.processed) return;

  try {
    await deps.processEnvelope(event.payload as MetaWebhookEnvelope);
    await deps.markProcessed(id, null);
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    await deps.markProcessed(id, message);
    throw err;
  }
}
```

- [ ] **Step 7: Run the test, verify it passes**

Run: `pnpm --filter @whatapp/worker test webhook-processing`
Expected: PASS (3 tests).

- [ ] **Step 8: Wire the processor in `apps/worker/src/main.ts`** — the
  `webhook-processing` worker must run `processWebhookJob` with real deps. The
  worker re-uses the parsing logic by importing a standalone envelope-processor.
  Create a small worker-side processor that calls the same shared parser. Modify
  the `Worker` factory loop so that for `queueName === "webhook-processing"` the
  processor function is:

```typescript
import { processWebhookJob } from "./processors/webhook-processing.processor.js";
import { processInboundEnvelope } from "./processors/envelope-processor.js";
import { prisma } from "./lib/prisma.js";
```

and the per-queue processor function (inside the loop, branch on `queueName`):

```typescript
        if (queueName === "webhook-processing") {
          return processWebhookJob(
            job.data as { webhookEventId: string },
            {
              loadEvent: (id) =>
                prisma.webhookEvent.findUnique({ where: { id } }),
              processEnvelope: processInboundEnvelope,
              markProcessed: (id, error) =>
                prisma.webhookEvent.update({
                  where: { id },
                  data: {
                    processed: error === null,
                    processedAt: new Date(),
                    error: error ?? undefined,
                  },
                }),
            },
          );
        }
```

- [ ] **Step 9: Create `apps/worker/src/processors/envelope-processor.ts`** — a
  framework-free port of the API's `WebhookProcessorService.process()` so the
  worker can persist without NestJS. It must produce identical writes. Implement
  it by delegating to the shared parser functions (`extractInboundMessages`,
  `extractStatuses`, `isForwardStatus`) and the worker's `prisma`. Keep the
  logic identical to `WebhookProcessorService` (Tasks 9–11): upsert contact &
  conversation, insert message, enqueue media/flow jobs, handle statuses
  forward-only, handle template/quality. Enqueue media/flow jobs via BullMQ
  `Queue` instances created from `createRedisConnection()`-style options.

> Note: to avoid duplicating ~150 lines, prefer extracting the
> envelope-processing core into `@whatapp/shared` as a pure function that takes
> a small persistence-port interface, then both the API service and this worker
> processor call it. If that refactor is done, update Task 9–11's services to
> delegate to it. Decide during execution; either way the worker MUST persist
> the same rows. The decision does not change any FR/AC coverage.

- [ ] **Step 10: Verify the worker typechecks and builds**

Run: `pnpm --filter @whatapp/worker typecheck && pnpm --filter @whatapp/worker build`
Expected: PASS.

- [ ] **Step 11: Live verification deferred** — running the worker against a
  live Redis/Postgres is deferred (Docker broken). Logic is covered by the unit
  test in Step 4.

- [ ] **Step 12: Commit**

```bash
git add apps/worker/src/lib apps/worker/src/processors apps/worker/vitest.config.ts apps/worker/src/main.ts apps/worker/package.json
git commit -m "feat: add worker webhook-processing processor"
```

---

## Task 13: Worker — media-download & flow-dispatch processors

**Files:**
- Create: `apps/worker/src/processors/media-download.processor.ts`
- Create: `apps/worker/src/processors/flow-dispatch.processor.ts`
- Test: `apps/worker/src/processors/media-download.processor.test.ts`
- Test: `apps/worker/src/processors/flow-dispatch.processor.test.ts`
- Modify: `apps/worker/src/main.ts`
- Modify: `.env.example`

Covers FR-01.8 (media download as a queued job, stored location recorded in the
message payload) and FR-01.17 (flow dispatch — a logging stub until Phase 5).

- [ ] **Step 1: Write the failing media-download test** — `apps/worker/src/processors/media-download.processor.test.ts`:

```typescript
import { describe, it, expect, vi, beforeEach } from "vitest";
import { processMediaDownloadJob } from "./media-download.processor.js";

describe("processMediaDownloadJob", () => {
  let deps: {
    getMediaUrl: ReturnType<typeof vi.fn>;
    downloadMedia: ReturnType<typeof vi.fn>;
    storeFile: ReturnType<typeof vi.fn>;
    recordLocation: ReturnType<typeof vi.fn>;
  };

  beforeEach(() => {
    deps = {
      getMediaUrl: vi.fn().mockResolvedValue("https://lookaside.meta/media-99"),
      downloadMedia: vi.fn().mockResolvedValue(Buffer.from("file-bytes")),
      storeFile: vi.fn().mockResolvedValue("/data/media/media-99.bin"),
      recordLocation: vi.fn().mockResolvedValue(undefined),
    };
  });

  it("resolves the url, downloads, stores, and records the location (FR-01.8)", async () => {
    await processMediaDownloadJob(
      { messageId: "msg-1", mediaId: "media-99" }, deps);
    expect(deps.getMediaUrl).toHaveBeenCalledWith("media-99");
    expect(deps.downloadMedia).toHaveBeenCalledWith(
      "https://lookaside.meta/media-99");
    expect(deps.storeFile).toHaveBeenCalledWith(
      "media-99", expect.any(Buffer));
    expect(deps.recordLocation).toHaveBeenCalledWith(
      "msg-1", "/data/media/media-99.bin");
  });

  it("re-throws on download failure so BullMQ retries", async () => {
    deps.downloadMedia.mockRejectedValue(new Error("network"));
    await expect(
      processMediaDownloadJob({ messageId: "msg-1", mediaId: "m" }, deps),
    ).rejects.toThrow("network");
    expect(deps.recordLocation).not.toHaveBeenCalled();
  });
});
```

- [ ] **Step 2: Run it, verify it fails**

Run: `pnpm --filter @whatapp/worker test media-download`
Expected: FAIL — `media-download.processor` not found.

- [ ] **Step 3: Implement `apps/worker/src/processors/media-download.processor.ts`:**

```typescript
/** The job payload enqueued by the webhook processor for media messages. */
export interface MediaDownloadJobData {
  messageId: string;
  mediaId: string;
}

/** Injected collaborators — real implementations wired in main.ts. */
export interface MediaDownloadDeps {
  /** Resolves a Meta media id to a short-lived download URL. */
  getMediaUrl: (mediaId: string) => Promise<string>;
  /** Downloads the bytes from the short-lived URL (authenticated). */
  downloadMedia: (url: string) => Promise<Buffer>;
  /** Stores the bytes; returns the stored location (path or object key). */
  storeFile: (mediaId: string, bytes: Buffer) => Promise<string>;
  /** Records the stored location on the message payload. */
  recordLocation: (messageId: string, location: string) => Promise<void>;
}

/**
 * Processes one `media-download` job (FR-01.8): resolve the media id to a URL,
 * download it authenticated, store it, and record the stored location on the
 * message. Failures re-throw so BullMQ retries.
 */
export async function processMediaDownloadJob(
  data: MediaDownloadJobData,
  deps: MediaDownloadDeps,
): Promise<void> {
  const url = await deps.getMediaUrl(data.mediaId);
  const bytes = await deps.downloadMedia(url);
  const location = await deps.storeFile(data.mediaId, bytes);
  await deps.recordLocation(data.messageId, location);
}
```

- [ ] **Step 4: Run the media test, verify it passes**

Run: `pnpm --filter @whatapp/worker test media-download`
Expected: PASS (2 tests).

- [ ] **Step 5: Write the failing flow-dispatch test** — `apps/worker/src/processors/flow-dispatch.processor.test.ts`:

```typescript
import { describe, it, expect, vi } from "vitest";
import { processFlowDispatchJob } from "./flow-dispatch.processor.js";

describe("processFlowDispatchJob", () => {
  it("logs and no-ops until flows are wired (FR-01.17 stub)", async () => {
    const log = vi.fn();
    const resolveFlowUrl = vi.fn().mockResolvedValue(null);
    const trigger = vi.fn();
    await processFlowDispatchJob(
      { messageId: "msg-1", waId: "9715551", type: "text", body: "hi" },
      { resolveFlowUrl, trigger, log });
    expect(log).toHaveBeenCalled();
    expect(trigger).not.toHaveBeenCalled();
  });

  it("triggers the flow webhook when a flow URL is resolved", async () => {
    const resolveFlowUrl = vi.fn().mockResolvedValue("https://n8n/webhook/x");
    const trigger = vi.fn().mockResolvedValue(undefined);
    await processFlowDispatchJob(
      { messageId: "msg-1", waId: "9715551", type: "text", body: "hi" },
      { resolveFlowUrl, trigger, log: vi.fn() });
    expect(trigger).toHaveBeenCalledWith(
      "https://n8n/webhook/x",
      expect.objectContaining({ messageId: "msg-1", waId: "9715551" }));
  });
});
```

- [ ] **Step 6: Run it, verify it fails**

Run: `pnpm --filter @whatapp/worker test flow-dispatch`
Expected: FAIL — `flow-dispatch.processor` not found.

- [ ] **Step 7: Implement `apps/worker/src/processors/flow-dispatch.processor.ts`:**

```typescript
/** The normalized inbound-message payload dispatched to n8n flows. */
export interface FlowDispatchJobData {
  messageId: string;
  waId: string;
  type: string;
  body: string | null;
}

/** Injected collaborators. */
export interface FlowDispatchDeps {
  /**
   * Resolves the n8n trigger webhook URL for this inbound message via the
   * `flows` registry. Returns null until real flows are registered (Phase 5).
   */
  resolveFlowUrl: (data: FlowDispatchJobData) => Promise<string | null>;
  /** POSTs the normalized payload to a flow's webhook URL. */
  trigger: (url: string, payload: Record<string, unknown>) => Promise<void>;
  /** Logging sink. */
  log: (message: string) => void;
}

/**
 * Processes one `flow-dispatch` job (FR-01.17): resolve the target flow's
 * trigger webhook URL and POST the normalized inbound message to it. Until
 * Phase 5 wires real flows, `resolveFlowUrl` returns null and this is a no-op
 * that logs — never dropped, never errors.
 */
export async function processFlowDispatchJob(
  data: FlowDispatchJobData,
  deps: FlowDispatchDeps,
): Promise<void> {
  const url = await deps.resolveFlowUrl(data);
  if (!url) {
    deps.log(
      `flow-dispatch: no flow registered for inbound ${data.messageId} ` +
        `(type=${data.type}) — stub no-op until Phase 5`,
    );
    return;
  }
  await deps.trigger(url, {
    messageId: data.messageId,
    waId: data.waId,
    type: data.type,
    body: data.body,
  });
}
```

- [ ] **Step 8: Run the flow-dispatch test, verify it passes**

Run: `pnpm --filter @whatapp/worker test flow-dispatch`
Expected: PASS (2 tests).

- [ ] **Step 9: Wire both processors into `apps/worker/src/main.ts`** — extend
  the per-queue branch from Task 12. For `media-download`, build a `MetaClient`
  from `loadMetaConfig()` and provide deps:
  `getMediaUrl`/`downloadMedia` from the client; `storeFile` writes the buffer
  to `process.env["MEDIA_STORAGE_DIR"] ?? "./data/media"` (create the dir if
  missing) as `${mediaId}.bin` and returns the absolute path; `recordLocation`
  does `prisma.message.update` merging `{ storedMedia: { location } }` into the
  existing `payload` JSON. For `flow-dispatch`, `resolveFlowUrl` queries
  `prisma.flow` (returns null when none match — Phase 5 fills this in);
  `trigger` uses `N8nClient.triggerWebhook` from `loadN8nConfig()`; `log` is
  `console.log`.

- [ ] **Step 10: Add `MEDIA_STORAGE_DIR` to `.env.example`** under a new
  `# --- Media ---` section: `MEDIA_STORAGE_DIR=./data/media`. Add the same key
  (optional, `.default("./data/media")`) to the API config Zod schema only if
  the API also needs it — it does not this phase, so leave the API schema
  unchanged and document the worker reads it directly from `process.env`.

- [ ] **Step 11: Verify the worker typechecks and builds**

Run: `pnpm --filter @whatapp/worker typecheck && pnpm --filter @whatapp/worker build`
Expected: PASS.

- [ ] **Step 12: Commit**

```bash
git add apps/worker/src/processors .env.example apps/worker/src/main.ts
git commit -m "feat: add media-download and flow-dispatch worker processors"
```

---

## Task 14: MessageService — unified send with window & consent enforcement

**Files:**
- Create: `apps/api/src/messages/message.service.ts`
- Test: `apps/api/src/messages/message.service.test.ts`

Covers FR-01.13 (build body, POST, log outbound on success / failed on error,
structured result — never throws on an ordinary Meta error), FR-01.14 (24h
window enforcement for free-form sends; templates bypass), FR-01.15 (consent
enforcement — `opted_out` rejected unless service-flagged).

- [ ] **Step 1: Write the failing test** — `apps/api/src/messages/message.service.test.ts`:

```typescript
import { describe, it, expect, vi, beforeEach } from "vitest";
import { MessageService } from "./message.service";

const future = new Date(Date.now() + 60 * 60 * 1000);
const past = new Date(Date.now() - 60 * 60 * 1000);

describe("MessageService.send", () => {
  let prisma: Record<string, Record<string, ReturnType<typeof vi.fn>>>;
  let metaClient: { sendMessage: ReturnType<typeof vi.fn> };
  let metaConfig: { getClient: ReturnType<typeof vi.fn> };
  let service: MessageService;

  beforeEach(() => {
    prisma = {
      contact: {
        upsert: vi.fn().mockResolvedValue(
          { id: "contact-1", consentState: "opted_in" }),
        findUnique: vi.fn().mockResolvedValue(
          { id: "contact-1", consentState: "opted_in" }),
      },
      conversation: {
        findUnique: vi.fn().mockResolvedValue({ windowExpiresAt: future }),
      },
      message: { create: vi.fn().mockResolvedValue({ id: "msg-1" }) },
    };
    metaClient = {
      sendMessage: vi.fn().mockResolvedValue({ ok: true, wamid: "wamid.OUT1" }),
    };
    metaConfig = { getClient: vi.fn().mockResolvedValue(metaClient) };
    service = new MessageService(prisma as never, metaConfig as never);
  });

  it("sends a template message and logs an outbound row (FR-01.13)", async () => {
    const result = await service.send({
      to: "9715551", type: "template",
      content: { name: "lead_followup", language: "en" },
    });
    expect(result.ok).toBe(true);
    expect(result.wamid).toBe("wamid.OUT1");
    const row = prisma.message.create.mock.calls[0]?.[0].data;
    expect(row.direction).toBe("outbound");
    expect(row.status).toBe("sent");
    expect(row.wamid).toBe("wamid.OUT1");
  });

  it("logs a failed message on a Meta error, never throws (FR-01.13)", async () => {
    metaClient.sendMessage.mockResolvedValue({
      ok: false,
      error: { code: 131026, title: "Undeliverable", message: "no WA" },
    });
    const result = await service.send({
      to: "9715551", type: "template",
      content: { name: "x", language: "en" } });
    expect(result.ok).toBe(false);
    const row = prisma.message.create.mock.calls[0]?.[0].data;
    expect(row.status).toBe("failed");
    expect(row.errorCode).toBe("131026");
    expect(row.errorTitle).toBe("Undeliverable");
  });

  it("rejects a free-form send when the 24h window is closed (FR-01.14)", async () => {
    prisma.conversation.findUnique.mockResolvedValue(
      { windowExpiresAt: past });
    const result = await service.send({
      to: "9715551", type: "text", content: { text: "hi" } });
    expect(result.ok).toBe(false);
    expect(result.error?.title).toMatch(/window/i);
    expect(metaClient.sendMessage).not.toHaveBeenCalled();
  });

  it("rejects a free-form send when there is no conversation (FR-01.14)", async () => {
    prisma.conversation.findUnique.mockResolvedValue(null);
    const result = await service.send({
      to: "9715551", type: "text", content: { text: "hi" } });
    expect(result.ok).toBe(false);
    expect(metaClient.sendMessage).not.toHaveBeenCalled();
  });

  it("allows a template send even when the window is closed (FR-01.14)", async () => {
    prisma.conversation.findUnique.mockResolvedValue(
      { windowExpiresAt: past });
    const result = await service.send({
      to: "9715551", type: "template",
      content: { name: "x", language: "en" } });
    expect(result.ok).toBe(true);
    expect(metaClient.sendMessage).toHaveBeenCalledOnce();
  });

  it("rejects a send to an opted_out contact (FR-01.15)", async () => {
    prisma.contact.findUnique.mockResolvedValue(
      { id: "contact-1", consentState: "opted_out" });
    const result = await service.send({
      to: "9715551", type: "template",
      content: { name: "x", language: "en" } });
    expect(result.ok).toBe(false);
    expect(result.error?.title).toMatch(/consent|opted.out/i);
    expect(metaClient.sendMessage).not.toHaveBeenCalled();
  });

  it("allows a service-flagged send to an opted_out contact (FR-01.15)", async () => {
    prisma.contact.findUnique.mockResolvedValue(
      { id: "contact-1", consentState: "opted_out" });
    prisma.conversation.findUnique.mockResolvedValue(
      { windowExpiresAt: future });
    const result = await service.send(
      { to: "9715551", type: "text", content: { text: "your code" } },
      { service: true });
    expect(result.ok).toBe(true);
  });
});
```

- [ ] **Step 2: Run it, verify it fails**

Run: `pnpm --filter @whatapp/api test message.service`
Expected: FAIL — `message.service` not found.

- [ ] **Step 3: Implement `apps/api/src/messages/message.service.ts`:**

```typescript
import { Injectable, Logger } from "@nestjs/common";
import type { OutboundMessage, MetaSendResult } from "@whatapp/shared";
import { PrismaService } from "../prisma/prisma.service";
import { MetaConfigService } from "../meta/meta-config.service";

/** Options that relax the default send rules. */
export interface SendOptions {
  /**
   * Marks the send as a service/transactional message that consent rules
   * permit even to an opted-out contact (FR-01.15).
   */
  service?: boolean;
  /** Links the resulting message row to a campaign. */
  campaignId?: string;
  /** Links the resulting message row to a template. */
  templateId?: string;
}

/**
 * The single send path for the whole platform (FR-01.13). Builds the Graph API
 * body, enforces the 24h window for free-form messages (FR-01.14) and consent
 * (FR-01.15), POSTs via the MetaClient, and logs an outbound `messages` row.
 * Never throws on an ordinary Meta error — returns a structured MetaSendResult.
 */
@Injectable()
export class MessageService {
  private readonly logger = new Logger(MessageService.name);

  constructor(
    private readonly prisma: PrismaService,
    private readonly metaConfig: MetaConfigService,
  ) {}

  /** Sends one WhatsApp message and logs it. */
  async send(
    msg: OutboundMessage,
    options: SendOptions = {},
  ): Promise<MetaSendResult> {
    // Resolve the contact (it may not exist yet for a first outbound).
    const contact = await this.prisma.contact.findUnique({
      where: { waId: msg.to },
    });

    // Consent check (FR-01.15) — opted_out blocks unless service-flagged.
    if (
      contact?.consentState === "opted_out" &&
      !options.service
    ) {
      return this.reject(
        "consent",
        `Contact ${msg.to} is opted out — send blocked`,
      );
    }

    // 24h-window check for free-form (non-template) messages (FR-01.14).
    if (msg.type !== "template") {
      const conversation = contact
        ? await this.prisma.conversation.findUnique({
            where: { contactId: contact.id },
          })
        : null;
      const expires = conversation?.windowExpiresAt;
      if (!expires || expires.getTime() <= Date.now()) {
        return this.reject(
          "window_closed",
          `24h customer-service window is closed for ${msg.to} — ` +
            `a template message is required`,
        );
      }
    }

    // Send via Meta.
    const client = await this.metaConfig.getClient();
    let result: MetaSendResult;
    try {
      result = await client.sendMessage(msg);
    } catch (err) {
      // Network-level failure — surface as a structured result, do not throw.
      this.logger.error(`Meta send failed: ${(err as Error).message}`);
      result = {
        ok: false,
        error: { code: 0, title: "network", message: (err as Error).message },
      };
    }

    // Log the outbound message row.
    await this.logOutbound(msg, result, contact?.id ?? null, options);
    return result;
  }

  /** Builds a rejection result without touching the network. */
  private reject(title: string, message: string): MetaSendResult {
    this.logger.warn(`Send rejected (${title}): ${message}`);
    return { ok: false, error: { code: -1, title, message } };
  }

  /** Inserts the outbound `messages` row reflecting the send outcome. */
  private async logOutbound(
    msg: OutboundMessage,
    result: MetaSendResult,
    contactId: string | null,
    options: SendOptions,
  ): Promise<void> {
    // Ensure a contact row exists so the FK is satisfiable.
    let resolvedContactId = contactId;
    if (!resolvedContactId) {
      const created = await this.prisma.contact.upsert({
        where: { waId: msg.to },
        create: { waId: msg.to },
        update: {},
      });
      resolvedContactId = created.id;
    }

    const now = new Date();
    await this.prisma.message.create({
      data: {
        contactId: resolvedContactId,
        waId: msg.to,
        direction: "outbound",
        wamid: result.wamid ?? null,
        type: msg.type,
        body: msg.type === "text" ? msg.content.text : null,
        payload: msg as object,
        templateName:
          msg.type === "template" ? msg.content.name : null,
        templateId: options.templateId ?? null,
        campaignId: options.campaignId ?? null,
        status: result.ok ? "sent" : "failed",
        errorCode: result.error ? String(result.error.code) : null,
        errorTitle: result.error?.title ?? null,
        errorDetail: (result.error?.error_data as object) ?? undefined,
        sentAt: result.ok ? now : null,
        failedAt: result.ok ? null : now,
        timestamp: now,
      },
    });
  }
}
```

- [ ] **Step 4: Run the test, verify it passes**

Run: `pnpm --filter @whatapp/api test message.service`
Expected: PASS (7 tests).

- [ ] **Step 5: Commit**

```bash
git add apps/api/src/messages/message.service.ts apps/api/src/messages/message.service.test.ts
git commit -m "feat: add unified MessageService with window and consent enforcement"
```

---

## Task 15: Internal send endpoint, callback-secret guard & messages query API

**Files:**
- Create: `apps/api/src/messages/callback-auth.guard.ts`
- Create: `apps/api/src/messages/dto.ts`
- Create: `apps/api/src/messages/internal-messages.controller.ts`
- Create: `apps/api/src/messages/messages.controller.ts`
- Test: `apps/api/src/messages/callback-auth.guard.test.ts`
- Test: `apps/api/src/messages/internal-messages.controller.test.ts`
- Test: `apps/api/src/messages/messages.controller.test.ts`
- Modify: `apps/api/src/main.ts` (global-prefix exclusions)

Covers FR-01.16 (`POST /internal/messages/send`, callbackSecret bearer auth)
and the `GET /api/messages` + `GET /api/messages/:id` read API.

- [ ] **Step 1: Write the failing callback-guard test** — `apps/api/src/messages/callback-auth.guard.test.ts`:

```typescript
import { describe, it, expect, vi, beforeEach } from "vitest";
import { UnauthorizedException } from "@nestjs/common";
import { CallbackAuthGuard } from "./callback-auth.guard";

function ctx(authHeader: string | undefined) {
  return {
    switchToHttp: () => ({
      getRequest: () => ({ headers: { authorization: authHeader } }),
    }),
  } as never;
}

describe("CallbackAuthGuard", () => {
  let connections: { getDecrypted: ReturnType<typeof vi.fn> };
  let guard: CallbackAuthGuard;

  beforeEach(() => {
    connections = {
      getDecrypted: vi.fn().mockResolvedValue({
        secrets: { callbackSecret: "the-secret" },
      }),
    };
    guard = new CallbackAuthGuard(connections as never);
  });

  it("allows a request with the correct bearer token", async () => {
    await expect(guard.canActivate(ctx("Bearer the-secret")))
      .resolves.toBe(true);
  });

  it("rejects a wrong token (FR-01.16)", async () => {
    await expect(guard.canActivate(ctx("Bearer wrong")))
      .rejects.toBeInstanceOf(UnauthorizedException);
  });

  it("rejects an absent Authorization header (FR-01.16)", async () => {
    await expect(guard.canActivate(ctx(undefined)))
      .rejects.toBeInstanceOf(UnauthorizedException);
  });

  it("rejects when no n8n connection / callbackSecret is configured", async () => {
    connections.getDecrypted.mockResolvedValue(null);
    await expect(guard.canActivate(ctx("Bearer anything")))
      .rejects.toBeInstanceOf(UnauthorizedException);
  });
});
```

- [ ] **Step 2: Run it, verify it fails** —
  `pnpm --filter @whatapp/api test callback-auth` → FAIL.

- [ ] **Step 3: Implement `apps/api/src/messages/callback-auth.guard.ts`:**

```typescript
import {
  CanActivate,
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from "@nestjs/common";
import { timingSafeEqual } from "crypto";
import { ConnectionsService } from "../connections/connections.service";

/**
 * Authenticates n8n → platform callbacks on `/internal/*` endpoints with the
 * shared `callbackSecret` bearer token from the n8n connection (FR-01.16,
 * docs/integrations.md §2.4). The secret is never hardcoded — it comes from the
 * encrypted connections store.
 */
@Injectable()
export class CallbackAuthGuard implements CanActivate {
  constructor(private readonly connections: ConnectionsService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const req = context
      .switchToHttp()
      .getRequest<{ headers: { authorization?: string } }>();
    const header = req.headers.authorization ?? "";
    const token = header.startsWith("Bearer ")
      ? header.slice("Bearer ".length)
      : "";
    if (!token) throw new UnauthorizedException("Missing bearer token");

    const conn = await this.connections.getDecrypted("n8n");
    const secret = conn?.secrets["callbackSecret"];
    if (!secret) {
      throw new UnauthorizedException("Callback auth is not configured");
    }

    const a = Buffer.from(token);
    const b = Buffer.from(secret);
    if (a.length !== b.length || !timingSafeEqual(a, b)) {
      throw new UnauthorizedException("Invalid callback token");
    }
    return true;
  }
}
```

- [ ] **Step 4: Run the guard test, verify it passes** —
  `pnpm --filter @whatapp/api test callback-auth` → PASS (4 tests).

- [ ] **Step 5: Implement `apps/api/src/messages/dto.ts`** (Zod schemas):

```typescript
import { z } from "zod";

/**
 * Body of `POST /internal/messages/send` (FR-01.16). `content` shape depends
 * on `type`; it is validated loosely here and narrowed when building the
 * OutboundMessage in the controller.
 */
export const internalSendSchema = z.object({
  to: z.string().min(1),
  type: z.enum(["text", "template", "image", "document", "interactive"]),
  content: z.record(z.unknown()),
  templateName: z.string().optional(),
  campaignId: z.string().optional(),
  service: z.boolean().optional(),
});
export type InternalSendDto = z.infer<typeof internalSendSchema>;

/** Query params for `GET /api/messages`. */
export const listMessagesSchema = z.object({
  waId: z.string().optional(),
  contactId: z.string().optional(),
  direction: z.enum(["inbound", "outbound"]).optional(),
  status: z
    .enum(["queued", "sent", "delivered", "read", "failed", "received"])
    .optional(),
  campaignId: z.string().optional(),
  limit: z.coerce.number().int().positive().max(200).default(50),
  cursor: z.string().optional(),
});
export type ListMessagesDto = z.infer<typeof listMessagesSchema>;
```

- [ ] **Step 6: Write the failing internal-controller test** — `apps/api/src/messages/internal-messages.controller.test.ts`:

```typescript
import { describe, it, expect, vi, beforeEach } from "vitest";
import { InternalMessagesController } from "./internal-messages.controller";

describe("InternalMessagesController", () => {
  let messageService: { send: ReturnType<typeof vi.fn> };
  let controller: InternalMessagesController;

  beforeEach(() => {
    messageService = {
      send: vi.fn().mockResolvedValue({ ok: true, wamid: "wamid.OUT1" }),
    };
    controller = new InternalMessagesController(messageService as never);
  });

  it("builds a text OutboundMessage and delegates to MessageService", async () => {
    const out = await controller.send({
      to: "9715551", type: "text", content: { text: "hello" } });
    expect(messageService.send).toHaveBeenCalledWith(
      { to: "9715551", type: "text", content: { text: "hello" } },
      expect.objectContaining({ service: undefined }));
    expect(out).toEqual({ ok: true, wamid: "wamid.OUT1" });
  });

  it("builds a template OutboundMessage from templateName + content", async () => {
    await controller.send({
      to: "9715551", type: "template", templateName: "lead_followup",
      content: { language: "en", bodyParams: ["Ahmed"] } });
    const sent = messageService.send.mock.calls[0]?.[0];
    expect(sent.type).toBe("template");
    expect(sent.content.name).toBe("lead_followup");
    expect(sent.content.bodyParams).toEqual(["Ahmed"]);
  });

  it("rejects a malformed body with a Zod error", async () => {
    await expect(controller.send({ type: "text" })).rejects.toThrow();
  });
});
```

- [ ] **Step 7: Run it, verify it fails** —
  `pnpm --filter @whatapp/api test internal-messages` → FAIL.

- [ ] **Step 8: Implement `apps/api/src/messages/internal-messages.controller.ts`:**

```typescript
import {
  Controller,
  Post,
  Body,
  HttpCode,
  HttpStatus,
  UseGuards,
  BadRequestException,
} from "@nestjs/common";
import type { OutboundMessage } from "@whatapp/shared";
import { Public } from "../auth/roles.decorator";
import { CallbackAuthGuard } from "./callback-auth.guard";
import { MessageService } from "./message.service";
import { internalSendSchema } from "./dto";

/**
 * The internal send endpoint for n8n callbacks (FR-01.16) — the replacement
 * for the legacy `WA Send + Log` n8n sub-workflow. `@Public()` opts out of the
 * JWT guard; `CallbackAuthGuard` enforces the shared callbackSecret instead.
 */
@Controller("internal/messages")
@Public()
@UseGuards(CallbackAuthGuard)
export class InternalMessagesController {
  constructor(private readonly messageService: MessageService) {}

  @Post("send")
  @HttpCode(HttpStatus.OK)
  async send(@Body() body: unknown) {
    const dto = internalSendSchema.parse(body);
    const msg = this.toOutboundMessage(dto);
    return this.messageService.send(msg, {
      service: dto.service,
      campaignId: dto.campaignId,
    });
  }

  /** Narrows the loose DTO into a typed OutboundMessage. */
  private toOutboundMessage(dto: {
    to: string;
    type: string;
    content: Record<string, unknown>;
    templateName?: string;
  }): OutboundMessage {
    switch (dto.type) {
      case "text":
        return {
          to: dto.to,
          type: "text",
          content: { text: String(dto.content["text"] ?? "") },
        };
      case "template":
        return {
          to: dto.to,
          type: "template",
          content: {
            name: dto.templateName ?? String(dto.content["name"] ?? ""),
            language: String(dto.content["language"] ?? "en"),
            bodyParams: dto.content["bodyParams"] as string[] | undefined,
            headerParams: dto.content["headerParams"] as string[] | undefined,
          },
        };
      case "image":
        return {
          to: dto.to,
          type: "image",
          content: dto.content as { id?: string; link?: string; caption?: string },
        };
      case "document":
        return {
          to: dto.to,
          type: "document",
          content: dto.content as {
            id?: string;
            link?: string;
            filename?: string;
            caption?: string;
          },
        };
      case "interactive":
        return {
          to: dto.to,
          type: "interactive",
          content: { interactive: dto.content["interactive"] as Record<string, unknown> },
        };
      default:
        throw new BadRequestException(`Unsupported message type: ${dto.type}`);
    }
  }
}
```

- [ ] **Step 9: Run the internal-controller test, verify it passes** —
  `pnpm --filter @whatapp/api test internal-messages` → PASS (3 tests).

- [ ] **Step 10: Write the failing messages-query test** — `apps/api/src/messages/messages.controller.test.ts`:

```typescript
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NotFoundException } from "@nestjs/common";
import { MessagesController } from "./messages.controller";

describe("MessagesController", () => {
  let prisma: { message: Record<string, ReturnType<typeof vi.fn>> };
  let controller: MessagesController;

  beforeEach(() => {
    prisma = {
      message: {
        findMany: vi.fn().mockResolvedValue([{ id: "m1" }, { id: "m2" }]),
        findUnique: vi.fn(),
      },
    };
    controller = new MessagesController(prisma as never);
  });

  it("lists messages filtered by waId", async () => {
    const out = await controller.list({ waId: "9715551" });
    const arg = prisma.message.findMany.mock.calls[0]?.[0];
    expect(arg.where).toMatchObject({ waId: "9715551" });
    expect(out.items).toHaveLength(2);
  });

  it("returns one message with its status events", async () => {
    prisma.message.findUnique.mockResolvedValue(
      { id: "m1", statusEvents: [{ id: 1n, status: "delivered" }] });
    const out = await controller.getOne("m1");
    expect(prisma.message.findUnique.mock.calls[0]?.[0].include)
      .toEqual({ statusEvents: true });
    expect(out.id).toBe("m1");
  });

  it("throws 404 for an unknown message id", async () => {
    prisma.message.findUnique.mockResolvedValue(null);
    await expect(controller.getOne("missing"))
      .rejects.toBeInstanceOf(NotFoundException);
  });
});
```

- [ ] **Step 11: Run it, verify it fails** —
  `pnpm --filter @whatapp/api test messages.controller` → FAIL.

- [ ] **Step 12: Implement `apps/api/src/messages/messages.controller.ts`:**

```typescript
import {
  Controller,
  Get,
  Param,
  Query,
  NotFoundException,
} from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { listMessagesSchema } from "./dto";

/**
 * Read API for messages — consumed by the Inbox and Analytics modules.
 * JWT-protected (the global guard applies; any authenticated role may read).
 */
@Controller("messages")
export class MessagesController {
  constructor(private readonly prisma: PrismaService) {}

  /** `GET /api/messages` — list/query messages (cursor-paginated). */
  @Get()
  async list(@Query() query: unknown) {
    const q = listMessagesSchema.parse(query);
    const where: Record<string, unknown> = {};
    if (q.waId) where["waId"] = q.waId;
    if (q.contactId) where["contactId"] = q.contactId;
    if (q.direction) where["direction"] = q.direction;
    if (q.status) where["status"] = q.status;
    if (q.campaignId) where["campaignId"] = q.campaignId;

    const items = await this.prisma.message.findMany({
      where,
      orderBy: { createdAt: "desc" },
      take: q.limit + 1,
      ...(q.cursor ? { cursor: { id: q.cursor }, skip: 1 } : {}),
    });

    const hasMore = items.length > q.limit;
    const page = hasMore ? items.slice(0, q.limit) : items;
    return {
      items: page,
      nextCursor: hasMore ? page[page.length - 1]?.id ?? null : null,
    };
  }

  /** `GET /api/messages/:id` — one message with its status events. */
  @Get(":id")
  async getOne(@Param("id") id: string) {
    const message = await this.prisma.message.findUnique({
      where: { id },
      include: { statusEvents: true },
    });
    if (!message) throw new NotFoundException(`Message ${id} not found`);
    return message;
  }
}
```

- [ ] **Step 13: Run the messages-query test, verify it passes** —
  `pnpm --filter @whatapp/api test messages.controller` → PASS (3 tests).

- [ ] **Step 14: Update the global prefix in `apps/api/src/main.ts`** — the
  spec's API surface puts `/webhooks/meta` and `/internal/messages/send` at the
  root (no `/api`). Change the `setGlobalPrefix` exclude list:

```typescript
  app.setGlobalPrefix("api", {
    exclude: ["health", "webhooks/meta", "internal/messages/send"],
  });
```

- [ ] **Step 15: Commit**

```bash
git add apps/api/src/messages apps/api/src/main.ts
git commit -m "feat: add internal send endpoint, callback guard, and messages API"
```

---

## Task 16: Wire the modules & full verification

**Files:**
- Create: `apps/api/src/webhooks/webhooks.module.ts`
- Create: `apps/api/src/messages/messages.module.ts`
- Create: `apps/api/src/meta/meta.module.ts`
- Modify: `apps/api/src/app.module.ts`

Wires every Phase-1 module into the NestJS app and runs the full verification
suite.

- [ ] **Step 1: Create `apps/api/src/meta/meta.module.ts`:**

```typescript
import { Module } from "@nestjs/common";
import { ConnectionsModule } from "../connections/connections.module";
import { MetaConfigService } from "./meta-config.service";

@Module({
  imports: [ConnectionsModule],
  providers: [MetaConfigService],
  exports: [MetaConfigService],
})
export class MetaModule {}
```

- [ ] **Step 2: Create `apps/api/src/webhooks/webhooks.module.ts`** — provides
  the controller, the ingest & processor services, the three queue providers,
  and imports `MetaModule` + `QualityModule`:

```typescript
import { Module } from "@nestjs/common";
import { MetaModule } from "../meta/meta.module";
import { QualityModule } from "../quality/quality.module";
import { WebhooksController } from "./webhooks.controller";
import { WebhookIngestService } from "./webhook-ingest.service";
import { WebhookProcessorService } from "./webhook-processor.service";
import {
  WEBHOOK_PROCESSING_QUEUE,
  MEDIA_DOWNLOAD_QUEUE,
  FLOW_DISPATCH_QUEUE,
  queueProvider,
} from "./webhook-queue.provider";

@Module({
  imports: [MetaModule, QualityModule],
  controllers: [WebhooksController],
  providers: [
    queueProvider(WEBHOOK_PROCESSING_QUEUE),
    queueProvider(MEDIA_DOWNLOAD_QUEUE),
    queueProvider(FLOW_DISPATCH_QUEUE),
    {
      provide: WebhookIngestService,
      inject: [
        "PrismaService_TOKEN_PLACEHOLDER",
      ],
      useClass: WebhookIngestService,
    },
  ],
})
export class WebhooksModule {}
```

> Implementation note: `WebhookIngestService` and `WebhookProcessorService` take
> `Queue` instances by constructor injection. Because there are three distinct
> queues, inject them with `@Inject(TOKEN)` parameter decorators rather than the
> placeholder above. Concretely: add `@Inject(WEBHOOK_PROCESSING_QUEUE)` to the
> `queue` parameter of `WebhookIngestService`, and `@Inject(MEDIA_DOWNLOAD_QUEUE)`
> / `@Inject(FLOW_DISPATCH_QUEUE)` to the two `Queue` parameters of
> `WebhookProcessorService`. Then the module providers are simply:
> `[queueProvider(...) ×3, WebhookIngestService, WebhookProcessorService]`.
> Update the two service files to add these `@Inject` decorators and import
> `Inject` from `@nestjs/common`. The unit tests (Tasks 7, 9–11) pass the queues
> positionally and are unaffected.

Final `webhooks.module.ts`:

```typescript
import { Module } from "@nestjs/common";
import { MetaModule } from "../meta/meta.module";
import { QualityModule } from "../quality/quality.module";
import { WebhooksController } from "./webhooks.controller";
import { WebhookIngestService } from "./webhook-ingest.service";
import { WebhookProcessorService } from "./webhook-processor.service";
import {
  WEBHOOK_PROCESSING_QUEUE,
  MEDIA_DOWNLOAD_QUEUE,
  FLOW_DISPATCH_QUEUE,
  queueProvider,
} from "./webhook-queue.provider";

@Module({
  imports: [MetaModule, QualityModule],
  controllers: [WebhooksController],
  providers: [
    queueProvider(WEBHOOK_PROCESSING_QUEUE),
    queueProvider(MEDIA_DOWNLOAD_QUEUE),
    queueProvider(FLOW_DISPATCH_QUEUE),
    WebhookIngestService,
    WebhookProcessorService,
  ],
  exports: [WebhookProcessorService],
})
export class WebhooksModule {}
```

- [ ] **Step 3: Add the `@Inject` decorators** — in
  `webhook-ingest.service.ts` import `Inject` and decorate the `queue` param:
  `@Inject(WEBHOOK_PROCESSING_QUEUE) private readonly queue: Queue`. In
  `webhook-processor.service.ts` decorate the two queue params:
  `@Inject(MEDIA_DOWNLOAD_QUEUE) private readonly mediaQueue: Queue` and
  `@Inject(FLOW_DISPATCH_QUEUE) private readonly flowQueue: Queue`. Re-run the
  Task 7/9/10/11 tests to confirm they still pass (positional construction in
  tests is unaffected by parameter decorators).

- [ ] **Step 4: Create `apps/api/src/messages/messages.module.ts`:**

```typescript
import { Module } from "@nestjs/common";
import { MetaModule } from "../meta/meta.module";
import { ConnectionsModule } from "../connections/connections.module";
import { MessageService } from "./message.service";
import { MessagesController } from "./messages.controller";
import { InternalMessagesController } from "./internal-messages.controller";
import { CallbackAuthGuard } from "./callback-auth.guard";

@Module({
  imports: [MetaModule, ConnectionsModule],
  controllers: [MessagesController, InternalMessagesController],
  providers: [MessageService, CallbackAuthGuard],
  exports: [MessageService],
})
export class MessagesModule {}
```

- [ ] **Step 5: Register the modules in `apps/api/src/app.module.ts`:**

```typescript
import { Module } from "@nestjs/common";
import { AppConfigModule } from "./config/config.module";
import { PrismaModule } from "./prisma/prisma.module";
import { AuthModule } from "./auth/auth.module";
import { ConnectionsModule } from "./connections/connections.module";
import { MetaModule } from "./meta/meta.module";
import { QualityModule } from "./quality/quality.module";
import { WebhooksModule } from "./webhooks/webhooks.module";
import { MessagesModule } from "./messages/messages.module";
import { HealthController } from "./health/health.controller";

@Module({
  imports: [
    AppConfigModule,
    PrismaModule,
    AuthModule,
    ConnectionsModule,
    MetaModule,
    QualityModule,
    WebhooksModule,
    MessagesModule,
  ],
  controllers: [HealthController],
})
export class AppModule {}
```

- [ ] **Step 6: Confirm `ConnectionsModule` exports `ConnectionsService`** — it
  already does (`exports: [ConnectionsService]`); `MetaModule` and
  `MessagesModule` import `ConnectionsModule` so DI resolves. No change needed —
  just verify.

- [ ] **Step 7: Run the full repo verification suite**

Run, from the repo root, in order:
- `pnpm install` — Expected: completes (bullmq/ioredis added to api).
- `pnpm lint` — Expected: PASS, no errors.
- `pnpm typecheck` — Expected: PASS across all packages.
- `pnpm test` — Expected: PASS — every Phase-1 test plus all Phase-0 tests.
- `pnpm build` — Expected: PASS — `@whatapp/shared`, `@whatapp/api`,
  `@whatapp/worker` all build.

Fix any failure before committing. If a test reveals a real bug, fix it under
test-first discipline (write/adjust the test, see it fail, fix, see it pass).

- [ ] **Step 8: Live verification deferred** — booting `apps/api` against a live
  Postgres/Redis and replaying a real Meta webhook is deferred (Docker broken
  on this machine). The acceptance criteria are all covered by unit tests
  against mocks (see the self-review map below); a live smoke test is run during
  Phase 7 cutover.

- [ ] **Step 9: Commit**

```bash
git add apps/api/src/app.module.ts apps/api/src/meta/meta.module.ts apps/api/src/webhooks/webhooks.module.ts apps/api/src/messages/messages.module.ts apps/api/src/webhooks/webhook-ingest.service.ts apps/api/src/webhooks/webhook-processor.service.ts
git commit -m "feat: wire Meta Gateway modules into the API app"
```

- [ ] **Step 10: Tick the phase in `plans/ROADMAP.md`** — change
  `- [ ] Phase 1 — Meta Gateway` to `- [x] Phase 1 — Meta Gateway`, then:

```bash
git add plans/ROADMAP.md
git commit -m "chore: mark Phase 1 (Meta Gateway) complete"
```

---

## Self-review checklist — FR & AC coverage

Run this with fresh eyes against `specs/01-meta-gateway.md` before declaring the
phase done.

### Functional requirements

| FR | Requirement | Covered by |
|---|---|---|
| FR-01.1 | GET handshake — token match echoes challenge, mismatch 403 | Task 8 (`WebhooksController.verify` + tests) |
| FR-01.2 | POST verifies X-Hub-Signature-256 over the raw body before parsing | Task 6 (raw-body capture) + Task 7 (`WebhookIngestService` signature check, tests) |
| FR-01.3 | Every POST stored in `webhook_events` before processing; invalid signature stored `signatureValid=false` & not processed; still returns 200 | Task 7 (`WebhookIngestService.ingest`, tests) + Task 8 (`receive` returns 200 always) |
| FR-01.4 | Idempotent — `dedupKey` from the Meta id; duplicate stored but skipped | Task 2 (`deriveDedupKey`) + Task 7 (P2002 duplicate path) + Task 9 (inbound wamid skip) + Task 12 (worker `processed` skip) |
| FR-01.5 | Endpoint responds fast; heavy work queued, not inline | Task 7 (enqueue, no inline processing) + Task 8 + Tasks 12–13 (worker processors) |
| FR-01.6 | Inbound: upsert Contact, upsert Conversation w/ 24h window, insert inbound Message | Task 9 (`handleInbound`, tests) |
| FR-01.7 | Supported inbound types incl. `unsupported` fallback; never dropped | Task 3 (`parseInboundBody`) + Task 9 (unsupported test) |
| FR-01.8 | Inbound media: resolve id → URL → authenticated download → store; queued job; location in payload | Task 9 (media-download enqueue) + Task 13 (`media-download.processor`, `recordLocation`) |
| FR-01.9 | Statuses: find Message by wamid, append `MessageStatusEvent`, update status+timestamp, forward-only | Task 4 (`isForwardStatus`) + Task 10 (`handleStatus`, tests) |
| FR-01.10 | `failed` records errorCode/errorTitle/errorDetail | Task 3 (`extractStatuses` error fields) + Task 10 (failed-status test) |
| FR-01.11 | `message_template_status_update` updates Template status/rejectionReason/qualityScore; unmatched stored & skipped | Task 11 (`handleTemplateStatus`, tests) |
| FR-01.12 | Quality/account events persisted to a quality state | Task 11 (`QualityService.recordQualityUpdate`, tests) |
| FR-01.13 | `MessageService.send()` builds body, POSTs, logs sent/failed, structured result, never throws on Meta error | Task 14 (`MessageService.send`, tests) |
| FR-01.14 | Free-form send blocked when 24h window closed; templates bypass | Task 14 (window tests — closed/no-conversation/template-bypass) |
| FR-01.15 | Consent: `opted_out` blocked unless service-flagged | Task 14 (consent tests — blocked & service-allowed) |
| FR-01.16 | `POST /internal/messages/send` with callbackSecret bearer auth | Task 15 (`CallbackAuthGuard`, `InternalMessagesController`, tests) |
| FR-01.17 | After persisting inbound, enqueue dispatch POSTing to flow `triggerWebhookUrl`; no-op stub until Phase 5 | Task 9 (flow-dispatch enqueue) + Task 13 (`flow-dispatch.processor` stub, tests) |

### Acceptance criteria

| AC | Criterion | Covered by |
|---|---|---|
| AC-01.1 | Correct token echoes challenge; wrong token 403 | Task 8 tests (`verify` match / 403) |
| AC-01.2 | Tampered body / wrong signature stored `signatureValid=false`, not processed | Task 7 tests (bad-signature & missing-signature) |
| AC-01.3 | Valid inbound text webhook creates contact + conversation (24h window) + inbound message | Task 9 tests (contact upsert, conversation window, message insert) |
| AC-01.4 | Re-delivering the identical event creates no duplicate rows | Task 7 (P2002 dedup test) + Task 9 (existing-wamid skip test) + Task 12 (`processed` skip test) |
| AC-01.5 | `delivered` then `read` updates; later duplicate `sent` does not regress | Task 10 tests (advance + no-regress) |
| AC-01.6 | `send()` sends a template and logs an outbound row with the wamid | Task 14 test (template send logs outbound) |
| AC-01.7 | Free-form send to a closed-window contact is rejected, not sent | Task 14 test (window closed → rejected, `sendMessage` not called) |
| AC-01.8 | Send to an `opted_out` contact is rejected | Task 14 test (opted_out → rejected) |
| AC-01.9 | `POST /internal/messages/send` — correct token sends/logs; wrong/absent returns 401 | Task 15 tests (`CallbackAuthGuard` allow / wrong / absent; `InternalMessagesController` send) |

**Coverage confirmation:** every FR-01.1 … FR-01.17 and every AC-01.1 … AC-01.9
maps to at least one concrete task with a named test. No requirement is
unmapped.

### Placeholder & consistency scan

- No "TBD"/"implement later" steps — every code step shows complete code.
- Type/name consistency: `MetaWebhookEnvelope`, `ParsedInbound`, `ParsedStatus`,
  `classifyEvent`, `deriveDedupKey`, `extractInboundMessages`,
  `extractStatuses`, `parseInboundBody`, `statusRank`, `isForwardStatus`,
  `IngestResult`, `WebhookJobData`, `MediaDownloadJobData`,
  `FlowDispatchJobData`, `SendOptions`, queue tokens
  (`WEBHOOK_PROCESSING_QUEUE` / `MEDIA_DOWNLOAD_QUEUE` / `FLOW_DISPATCH_QUEUE`)
  are defined once and reused with the same name everywhere.
- DB field names (`waId`, `windowExpiresAt`, `dedupKey`, `signatureValid`,
  `errorCode`/`errorTitle`/`errorDetail`, `sentAt`/`deliveredAt`/`readAt`/
  `failedAt`, `messageStatusEvent`, `rejectionReason`) match
  `packages/db/prisma/schema.prisma` exactly.
- One open implementation choice is explicitly flagged (Task 12 Step 9:
  duplicate the envelope-processing logic in the worker vs. extract a shared
  pure core). Either choice satisfies the same FR/AC; it is a structural
  decision left to execution, not a placeholder.

### Migrations / secrets

- No new Prisma migration is required — every model and field this phase writes
  (`WebhookEvent`, `Contact`, `Conversation`, `Message`, `MessageStatusEvent`,
  `Template`, `Setting`) already exists in the Phase-0 schema. If execution
  finds a genuinely missing field, add an **additive** migration with a note.
- No secret is committed or logged: Meta/n8n config is read from the encrypted
  `connections` store via `ConnectionsService` / the worker's
  `loadMetaConfig`/`loadN8nConfig`; `.env.example` gains only non-secret
  `MEDIA_STORAGE_DIR`.
