# Phase 6 — Quality & Analytics 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 Quality & Analytics module — persist WABA quality state and
its history; expose messaging, failure, and campaign analytics over date
ranges; raise de-duplicated in-app + email alerts when quality drops or the
messaging limit decreases; and surface all of it on three new UI screens
(Dashboard, Quality, Analytics).

**Architecture:**

- **Additive Prisma migration** adds two tables (`quality_snapshots`,
  `notifications`), the `email` value to the `ConnProvider` enum, and a few
  analytics-friendly composite indexes on `messages`. Docker is broken, so the
  migration SQL is generated with `prisma migrate diff` and committed; live
  `migrate deploy` is deferred to cutover.
- **Pure helpers** in `packages/shared/src/analytics/` and
  `packages/shared/src/alerts/`. Five framework-free functions are unit tested
  exhaustively before any NestJS code is written:
  - `bucketByDay(timestamps, range)` — zero-filled `{ date, count }[]`.
  - `buildMessageMetrics(counts, timeSeries)` — adds `deliveryRate` /
    `readRate` and wraps the response shape.
  - `buildFailureBreakdown(rows)` — maps `{ errorCode, errorTitle, count }[]`
    → `{ reason, code, count }[]` via the existing `mapMetaError`.
  - `buildCampaignMetrics(recipientCounts, campaign)` — campaign performance
    shape with rates and duration.
  - `shouldAlert(prev, next, lastAlertedAt, cooldownMs, now)` — pure decision
    for the alerter (FR-07.10 dedup).
- **`QualityService` extended** in `apps/api/src/quality/` — also writes a
  `QualitySnapshot` row on each change, exposes `getCurrent`,
  `getHistory(range)`, `getPerTemplate`. Hooks the alerter on every change.
- **`AnalyticsModule`** in `apps/api/src/analytics/`:
  - `AnalyticsService` — Prisma `groupBy`/`count` aggregates, delegates shape
    to the pure helpers.
  - `AnalyticsController` — `GET /api/analytics/messages|failures|campaigns|
    campaigns/:id`, Zod-validated query params, role-guarded.
- **`QualityController`** in `apps/api/src/quality/` — `GET /api/quality`,
  `GET /api/quality/history`, `GET /api/quality/templates`.
- **`AlertsModule`** in `apps/api/src/alerts/`:
  - `AlertsService.enqueueIfNeeded(prev, next)` — uses `shouldAlert` against
    the `Notification` table's last unread + cooldown setting; enqueues an
    `alerts` BullMQ job with the rendered subject/body and recipients.
  - `NotificationsController` — `GET /api/notifications`,
    `POST /api/notifications/:id/read` (Dashboard "recent alerts" panel feed).
  - `InternalAlertsController` — `POST /internal/alerts/deliver`
    (worker callback). Persists the `Notification` row and sends the email via
    `EmailService`.
- **`EmailService`** in `apps/api/src/email/` — `nodemailer` transport built
  from `ConnectionsService.getDecrypted("email")`. Mocked in tests.
- **Alerts worker** — new `apps/worker/src/processors/alerts.processor.ts`,
  wired into `apps/worker/src/main.ts` for `QUEUE_ALERTS`. Thin HTTP call to
  `POST /internal/alerts/deliver` via a new method on `InternalApiClient`.
- **Web** — replaces the placeholder `Dashboard.tsx`; adds `Quality.tsx` and
  `Analytics.tsx` routes; adds `apps/web/src/lib/analytics-api.ts` and
  `quality-api.ts`. Charts are CSS/SVG only (no chart library — keeps the
  bundle small and avoids new deps).

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

**References:** `../specs/07-quality-analytics.md` (authoritative for
FR-07.*/AC-07.*), `../docs/integrations.md` §1.5 (messaging tiers), §1.6
(Meta error codes — already mapped by `packages/shared/src/inbox/error-map.ts`
which we reuse), `../packages/db/prisma/schema.prisma` (authoritative existing
`Message`, `Campaign`, `CampaignRecipient`, `Template`, `Setting` model/field
names), `../apps/api/src/quality/quality.service.ts` (existing service we
extend), `../apps/worker/src/queues.ts` (existing `QUEUE_ALERTS = "alerts"`
constant).

**Conventions:**

- Each task ends in a commit (`feat:`, `fix:`, `test:`, `chore:`, `docs:`).
- Tests first. Backend tests mock Prisma with `vi.fn()` delegates — never a
  live DB.
- No live SMTP, no live `migrate deploy` in this phase; both are explicitly
  deferred to cutover.
- No hardcoded secrets — SMTP comes from the encrypted `connections.email`
  row; alert recipients from the `alerts.recipients` `Setting` row.

---

## File Structure

**New files (created in tasks below):**

```
packages/db/prisma/schema.prisma                          # MODIFY: add models / enum / indexes
packages/db/prisma/migrations/20260523_quality_analytics/migration.sql

packages/shared/src/analytics/buckets.ts                  # bucketByDay
packages/shared/src/analytics/buckets.test.ts
packages/shared/src/analytics/message-metrics.ts          # buildMessageMetrics
packages/shared/src/analytics/message-metrics.test.ts
packages/shared/src/analytics/failure-breakdown.ts        # buildFailureBreakdown
packages/shared/src/analytics/failure-breakdown.test.ts
packages/shared/src/analytics/campaign-metrics.ts         # buildCampaignMetrics
packages/shared/src/analytics/campaign-metrics.test.ts
packages/shared/src/alerts/should-alert.ts                # shouldAlert
packages/shared/src/alerts/should-alert.test.ts
packages/shared/src/index.ts                              # MODIFY: re-export

apps/api/src/quality/quality.service.ts                   # MODIFY: snapshot + alerts hook
apps/api/src/quality/quality.service.test.ts              # MODIFY: extend tests
apps/api/src/quality/quality.controller.ts                # NEW
apps/api/src/quality/quality.controller.test.ts           # NEW
apps/api/src/quality/dto.ts                               # NEW (zod schemas)
apps/api/src/quality/quality.module.ts                    # MODIFY: add controller + AlertsModule import

apps/api/src/analytics/analytics.module.ts                # NEW
apps/api/src/analytics/analytics.service.ts               # NEW
apps/api/src/analytics/analytics.service.test.ts          # NEW
apps/api/src/analytics/analytics.controller.ts            # NEW
apps/api/src/analytics/analytics.controller.test.ts       # NEW
apps/api/src/analytics/dto.ts                             # NEW (zod query schemas)

apps/api/src/alerts/alerts.module.ts                      # NEW
apps/api/src/alerts/alerts.service.ts                     # NEW
apps/api/src/alerts/alerts.service.test.ts                # NEW
apps/api/src/alerts/notifications.controller.ts           # NEW
apps/api/src/alerts/notifications.controller.test.ts      # NEW
apps/api/src/alerts/internal-alerts.controller.ts         # NEW
apps/api/src/alerts/internal-alerts.controller.test.ts    # NEW
apps/api/src/alerts/dto.ts                                # NEW

apps/api/src/email/email.module.ts                        # NEW
apps/api/src/email/email.service.ts                       # NEW
apps/api/src/email/email.service.test.ts                  # NEW

apps/api/src/app.module.ts                                # MODIFY: register new modules
apps/api/package.json                                      # MODIFY: add nodemailer

apps/worker/src/processors/alerts.processor.ts            # NEW
apps/worker/src/processors/alerts.processor.test.ts       # NEW
apps/worker/src/lib/internal-api.ts                       # MODIFY: add deliverAlert()
apps/worker/src/lib/internal-api.test.ts                  # MODIFY
apps/worker/src/main.ts                                   # MODIFY: wire alerts worker

apps/web/src/lib/quality-api.ts                           # NEW
apps/web/src/lib/quality-api.test.ts                      # NEW
apps/web/src/lib/analytics-api.ts                         # NEW
apps/web/src/lib/analytics-api.test.ts                    # NEW
apps/web/src/lib/notifications-api.ts                     # NEW
apps/web/src/components/charts/BarChart.tsx               # NEW
apps/web/src/components/charts/BarChart.test.tsx          # NEW
apps/web/src/components/charts/LineChart.tsx              # NEW
apps/web/src/components/charts/LineChart.test.tsx         # NEW
apps/web/src/components/charts/QualityBadge.tsx           # NEW
apps/web/src/routes/Dashboard.tsx                         # REPLACE placeholder
apps/web/src/routes/Dashboard.test.tsx                    # NEW
apps/web/src/routes/Quality.tsx                           # NEW
apps/web/src/routes/Quality.test.tsx                      # NEW
apps/web/src/routes/Analytics.tsx                         # NEW
apps/web/src/routes/Analytics.test.tsx                    # NEW
apps/web/src/components/AppShell.tsx                      # MODIFY: add Quality nav item
apps/web/src/App.tsx                                      # MODIFY: register /quality and real /analytics
```

---

## Tasks

### Task 1: Schema additions (additive migration)

**Files:**
- Modify: `packages/db/prisma/schema.prisma`
- Create: `packages/db/prisma/migrations/20260523_quality_analytics/migration.sql`

- [ ] **Step 1: Add to `schema.prisma`**

Add `email` to the `ConnProvider` enum (additive, no breaking change):

```prisma
enum ConnProvider {
  meta
  n8n
  leadrat
  anthropic
  gmail
  email
}
```

Add two new models at the bottom of the file (before the final newline):

```prisma
// Timestamped WABA quality state history (Spec 07, FR-07.1).
// Each row is one observed change; the latest row = current state.
model QualitySnapshot {
  id                 BigInt   @id @default(autoincrement())
  qualityRating      String?  @map("quality_rating")
  messagingLimitTier String?  @map("messaging_limit_tier")
  phoneNumberStatus  String?  @map("phone_number_status")
  displayPhoneNumber String?  @map("display_phone_number")
  event              String?
  recordedAt         DateTime @default(now()) @map("recorded_at")

  @@index([recordedAt])
  @@map("quality_snapshots")
}

// In-app notifications (alerts and other system messages). FR-07.9.
model Notification {
  id        String    @id @default(uuid())
  kind      String    // e.g. "quality.drop" | "limit.decrease"
  severity  String    @default("warning") // "info" | "warning" | "critical"
  title     String
  body      String
  dedupKey  String?   @map("dedup_key")
  data      Json      @default("{}")
  readAt    DateTime? @map("read_at")
  createdAt DateTime  @default(now()) @map("created_at")

  @@index([readAt, createdAt])
  @@index([dedupKey])
  @@map("notifications")
}
```

Add three composite indexes on `Message` for efficient analytics aggregates
(FR-07.6 — no full scans). Edit the existing `Message` model's index block:

```prisma
  @@index([contactId, createdAt])
  @@index([campaignId])
  @@index([status])
  @@index([createdAt, direction])
  @@index([createdAt, status])
  @@index([campaignId, status])
```

- [ ] **Step 2: Generate the migration SQL with `prisma migrate diff`**

Run from the repo root:

```
pnpm --filter @whatapp/db exec prisma migrate diff \
  --from-schema-datamodel ../../node_modules/.prisma/last-known-schema.prisma \
  --to-schema-datamodel prisma/schema.prisma \
  --script > prisma/migrations/20260523_quality_analytics/migration.sql
```

If the `from-schema` reference is unavailable (clean checkout), fall back to:

```
pnpm --filter @whatapp/db exec prisma migrate diff \
  --from-empty --to-schema-datamodel prisma/schema.prisma \
  --script
```

…and hand-craft the diff against the existing migration history; the final
file must contain ONLY the deltas (new enum value, two new tables, three new
indexes). Expected contents shape:

```sql
ALTER TYPE "ConnProvider" ADD VALUE 'email';

CREATE TABLE "quality_snapshots" (
  "id" BIGSERIAL PRIMARY KEY,
  "quality_rating" TEXT,
  "messaging_limit_tier" TEXT,
  "phone_number_status" TEXT,
  "display_phone_number" TEXT,
  "event" TEXT,
  "recorded_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX "quality_snapshots_recorded_at_idx" ON "quality_snapshots" ("recorded_at");

CREATE TABLE "notifications" (
  "id" TEXT PRIMARY KEY,
  "kind" TEXT NOT NULL,
  "severity" TEXT NOT NULL DEFAULT 'warning',
  "title" TEXT NOT NULL,
  "body" TEXT NOT NULL,
  "dedup_key" TEXT,
  "data" JSONB NOT NULL DEFAULT '{}',
  "read_at" TIMESTAMP(3),
  "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX "notifications_read_at_created_at_idx" ON "notifications" ("read_at", "created_at");
CREATE INDEX "notifications_dedup_key_idx" ON "notifications" ("dedup_key");

CREATE INDEX "messages_created_at_direction_idx" ON "messages" ("created_at", "direction");
CREATE INDEX "messages_created_at_status_idx" ON "messages" ("created_at", "status");
CREATE INDEX "messages_campaign_id_status_idx" ON "messages" ("campaign_id", "status");
```

- [ ] **Step 3: Validate the schema**

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

Run: `pnpm --filter @whatapp/db exec prisma generate`
Expected: success — the generated client has `qualitySnapshot` and
`notification` delegates.

- [ ] **Step 4: Run typecheck across the workspace**

Run: `pnpm typecheck`
Expected: PASS (nothing references the new models yet, so it must still pass).

- [ ] **Step 5: Commit**

```
git add packages/db/prisma/schema.prisma packages/db/prisma/migrations/20260523_quality_analytics/migration.sql
git commit -m "feat(db): add quality_snapshots, notifications, analytics indexes"
```

> NOTE: Live `migrate deploy` is deferred to cutover (Docker is broken). The
> migration SQL is committed and ready.

---

### Task 2: Pure helper — `bucketByDay`

**Files:**
- Create: `packages/shared/src/analytics/buckets.ts`
- Create: `packages/shared/src/analytics/buckets.test.ts`

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

```ts
// packages/shared/src/analytics/buckets.test.ts
import { describe, it, expect } from "vitest";
import { bucketByDay } from "./buckets";

describe("bucketByDay", () => {
  const range = { from: new Date("2026-05-20T00:00:00Z"), to: new Date("2026-05-22T23:59:59Z") };

  it("returns zero-filled buckets for every day in the range when input is empty", () => {
    expect(bucketByDay([], range)).toEqual([
      { date: "2026-05-20", count: 0 },
      { date: "2026-05-21", count: 0 },
      { date: "2026-05-22", count: 0 },
    ]);
  });

  it("counts timestamps into UTC day buckets", () => {
    const ts = [
      new Date("2026-05-20T01:00:00Z"),
      new Date("2026-05-20T23:00:00Z"),
      new Date("2026-05-22T12:00:00Z"),
    ];
    expect(bucketByDay(ts, range)).toEqual([
      { date: "2026-05-20", count: 2 },
      { date: "2026-05-21", count: 0 },
      { date: "2026-05-22", count: 1 },
    ]);
  });

  it("ignores timestamps outside the range", () => {
    const ts = [new Date("2026-05-19T12:00:00Z"), new Date("2026-05-23T00:00:00Z")];
    expect(bucketByDay(ts, range)).toEqual([
      { date: "2026-05-20", count: 0 },
      { date: "2026-05-21", count: 0 },
      { date: "2026-05-22", count: 0 },
    ]);
  });

  it("returns a single bucket when from === to (same day)", () => {
    const sameDay = { from: new Date("2026-05-20T00:00:00Z"), to: new Date("2026-05-20T23:59:59Z") };
    expect(bucketByDay([new Date("2026-05-20T05:00:00Z")], sameDay)).toEqual([
      { date: "2026-05-20", count: 1 },
    ]);
  });
});
```

- [ ] **Step 2: Run and watch it fail**

Run: `pnpm --filter @whatapp/shared test buckets`
Expected: FAIL (module not found).

- [ ] **Step 3: Implement**

```ts
// packages/shared/src/analytics/buckets.ts
export interface DateRange {
  from: Date;
  to: Date;
}
export interface DayBucket {
  date: string; // YYYY-MM-DD (UTC)
  count: number;
}

/** UTC YYYY-MM-DD of a Date. */
function utcDayKey(d: Date): string {
  return d.toISOString().slice(0, 10);
}

/**
 * Counts each timestamp into a UTC-day bucket, zero-filling every day in
 * [from, to] inclusive. Timestamps outside the range are ignored.
 */
export function bucketByDay(timestamps: Date[], range: DateRange): DayBucket[] {
  const buckets = new Map<string, number>();
  const fromKey = utcDayKey(range.from);
  const toKey = utcDayKey(range.to);

  // Seed zero-filled buckets.
  const cursor = new Date(`${fromKey}T00:00:00Z`);
  const end = new Date(`${toKey}T00:00:00Z`);
  while (cursor <= end) {
    buckets.set(utcDayKey(cursor), 0);
    cursor.setUTCDate(cursor.getUTCDate() + 1);
  }

  for (const ts of timestamps) {
    const key = utcDayKey(ts);
    if (buckets.has(key)) {
      buckets.set(key, (buckets.get(key) ?? 0) + 1);
    }
  }

  return Array.from(buckets.entries())
    .sort(([a], [b]) => a.localeCompare(b))
    .map(([date, count]) => ({ date, count }));
}
```

- [ ] **Step 4: Run and watch it pass**

Run: `pnpm --filter @whatapp/shared test buckets`
Expected: PASS (4 tests).

- [ ] **Step 5: Re-export from the package index**

Edit `packages/shared/src/index.ts`, add at the end:

```ts
export * from "./analytics/buckets.js";
```

- [ ] **Step 6: Commit**

```
git add packages/shared/src/analytics/buckets.ts packages/shared/src/analytics/buckets.test.ts packages/shared/src/index.ts
git commit -m "feat(shared): add bucketByDay analytics helper"
```

---

### Task 3: Pure helper — `buildMessageMetrics`

**Files:**
- Create: `packages/shared/src/analytics/message-metrics.ts`
- Create: `packages/shared/src/analytics/message-metrics.test.ts`

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

```ts
import { describe, it, expect } from "vitest";
import { buildMessageMetrics } from "./message-metrics";

describe("buildMessageMetrics", () => {
  it("computes delivery and read rates", () => {
    const out = buildMessageMetrics(
      { sent: 100, delivered: 90, read: 60, failed: 10 },
      [{ date: "2026-05-22", count: 100 }],
    );
    expect(out.totals).toEqual({ sent: 100, delivered: 90, read: 60, failed: 10 });
    expect(out.deliveryRate).toBeCloseTo(0.9);
    expect(out.readRate).toBeCloseTo(0.6);
    expect(out.timeSeries).toEqual([{ date: "2026-05-22", count: 100 }]);
  });

  it("returns 0 rates when sent is 0 (no division by zero)", () => {
    const out = buildMessageMetrics(
      { sent: 0, delivered: 0, read: 0, failed: 0 },
      [],
    );
    expect(out.deliveryRate).toBe(0);
    expect(out.readRate).toBe(0);
  });

  it("computes readRate against delivered, not sent, when configured", () => {
    // Implementation note: readRate = read / sent for simplicity (spec
    // FR-07.4 just says "read rate", and AiSensy showed read/sent).
    const out = buildMessageMetrics(
      { sent: 200, delivered: 200, read: 100, failed: 0 },
      [],
    );
    expect(out.readRate).toBeCloseTo(0.5);
  });
});
```

- [ ] **Step 2: Run and watch it fail**

Run: `pnpm --filter @whatapp/shared test message-metrics`
Expected: FAIL.

- [ ] **Step 3: Implement**

```ts
// packages/shared/src/analytics/message-metrics.ts
import type { DayBucket } from "./buckets.js";

export interface MessageCounts {
  sent: number;
  delivered: number;
  read: number;
  failed: number;
}

export interface MessageMetricsResponse {
  totals: MessageCounts;
  deliveryRate: number; // 0..1
  readRate: number;     // 0..1
  timeSeries: DayBucket[];
}

function rate(numerator: number, denominator: number): number {
  return denominator > 0 ? numerator / denominator : 0;
}

/**
 * Builds the analytics response shape from raw counts + a day-bucketed
 * time series. Rates are 0 when sent === 0 (no division by zero).
 */
export function buildMessageMetrics(
  counts: MessageCounts,
  timeSeries: DayBucket[],
): MessageMetricsResponse {
  return {
    totals: counts,
    deliveryRate: rate(counts.delivered, counts.sent),
    readRate: rate(counts.read, counts.sent),
    timeSeries,
  };
}
```

- [ ] **Step 4: Run and watch it pass**

Run: `pnpm --filter @whatapp/shared test message-metrics`
Expected: PASS (3 tests).

- [ ] **Step 5: Re-export and commit**

Add to `packages/shared/src/index.ts`:
```ts
export * from "./analytics/message-metrics.js";
```

```
git add packages/shared/src/analytics/message-metrics.ts packages/shared/src/analytics/message-metrics.test.ts packages/shared/src/index.ts
git commit -m "feat(shared): add buildMessageMetrics helper"
```

---

### Task 4: Pure helper — `buildFailureBreakdown`

**Files:**
- Create: `packages/shared/src/analytics/failure-breakdown.ts`
- Create: `packages/shared/src/analytics/failure-breakdown.test.ts`

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

```ts
import { describe, it, expect } from "vitest";
import { buildFailureBreakdown } from "./failure-breakdown";

describe("buildFailureBreakdown", () => {
  it("maps each (code, title) row to a human reason via mapMetaError", () => {
    const rows = [
      { errorCode: "131047", errorTitle: null, count: 5 },
      { errorCode: "130429", errorTitle: null, count: 3 },
    ];
    const out = buildFailureBreakdown(rows);
    expect(out).toEqual([
      {
        code: "131047",
        reason: expect.stringContaining("Re-engagement"),
        count: 5,
      },
      {
        code: "130429",
        reason: expect.stringContaining("Rate limit"),
        count: 3,
      },
    ]);
  });

  it("merges rows with the same human reason but different titles", () => {
    // Two unknown 132xxx codes both map to the same generic template reason.
    const rows = [
      { errorCode: "132001", errorTitle: "X", count: 2 },
      { errorCode: "132099", errorTitle: "Y", count: 4 },
    ];
    const out = buildFailureBreakdown(rows);
    expect(out).toHaveLength(1);
    expect(out[0]!.count).toBe(6);
    expect(out[0]!.reason).toContain("Template");
  });

  it("returns empty for empty input", () => {
    expect(buildFailureBreakdown([])).toEqual([]);
  });

  it("sorts by count descending", () => {
    const rows = [
      { errorCode: "131047", errorTitle: null, count: 1 },
      { errorCode: "130429", errorTitle: null, count: 10 },
    ];
    const out = buildFailureBreakdown(rows);
    expect(out[0]!.code).toBe("130429");
    expect(out[1]!.code).toBe("131047");
  });

  it("handles null error code (counts as 'no detail')", () => {
    const rows = [{ errorCode: null, errorTitle: null, count: 2 }];
    const out = buildFailureBreakdown(rows);
    expect(out).toHaveLength(1);
    expect(out[0]!.reason).toContain("no error detail");
  });
});
```

- [ ] **Step 2: Run and watch it fail**

Run: `pnpm --filter @whatapp/shared test failure-breakdown`
Expected: FAIL.

- [ ] **Step 3: Implement**

```ts
// packages/shared/src/analytics/failure-breakdown.ts
import { mapMetaError } from "../inbox/error-map.js";

export interface FailureRow {
  errorCode: string | null;
  errorTitle: string | null;
  count: number;
}

export interface FailureBucket {
  /** First code that mapped to this reason (for display/debug). */
  code: string | null;
  reason: string;
  count: number;
}

/**
 * Groups raw failure rows by the human-readable reason produced by
 * `mapMetaError`. The result is sorted by count descending.
 */
export function buildFailureBreakdown(rows: FailureRow[]): FailureBucket[] {
  const byReason = new Map<string, FailureBucket>();
  for (const r of rows) {
    const reason = mapMetaError(r.errorCode, r.errorTitle);
    const existing = byReason.get(reason);
    if (existing) {
      existing.count += r.count;
    } else {
      byReason.set(reason, { code: r.errorCode, reason, count: r.count });
    }
  }
  return Array.from(byReason.values()).sort((a, b) => b.count - a.count);
}
```

- [ ] **Step 4: Run and watch it pass**

Run: `pnpm --filter @whatapp/shared test failure-breakdown`
Expected: PASS (5 tests).

- [ ] **Step 5: Re-export and commit**

Add `export * from "./analytics/failure-breakdown.js";` to
`packages/shared/src/index.ts`.

```
git add packages/shared/src/analytics/failure-breakdown.ts packages/shared/src/analytics/failure-breakdown.test.ts packages/shared/src/index.ts
git commit -m "feat(shared): add buildFailureBreakdown helper"
```

---

### Task 5: Pure helper — `buildCampaignMetrics`

**Files:**
- Create: `packages/shared/src/analytics/campaign-metrics.ts`
- Create: `packages/shared/src/analytics/campaign-metrics.test.ts`

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

```ts
import { describe, it, expect } from "vitest";
import { buildCampaignMetrics } from "./campaign-metrics";

describe("buildCampaignMetrics", () => {
  const campaign = {
    id: "c1",
    name: "May promo",
    status: "completed",
    startedAt: new Date("2026-05-20T10:00:00Z"),
    completedAt: new Date("2026-05-20T10:05:00Z"),
  };

  it("computes counts and rates from recipient-status counts", () => {
    const out = buildCampaignMetrics(
      { pending: 0, sent: 100, delivered: 90, read: 50, failed: 10, skipped: 0 },
      campaign,
    );
    expect(out.id).toBe("c1");
    expect(out.audienceSize).toBe(200); // 100+90+50+10+0+0 NO — sum
    // audienceSize = sum of every recipient status
    expect(out.counts).toEqual({ sent: 100, delivered: 90, read: 50, failed: 10, skipped: 0 });
    expect(out.deliveryRate).toBeCloseTo(0.9);
    expect(out.readRate).toBeCloseTo(0.5);
    expect(out.durationSeconds).toBe(300);
  });

  it("returns null durationSeconds when startedAt or completedAt is missing", () => {
    const out = buildCampaignMetrics(
      { pending: 5, sent: 0, delivered: 0, read: 0, failed: 0, skipped: 0 },
      { ...campaign, startedAt: null, completedAt: null },
    );
    expect(out.durationSeconds).toBeNull();
  });

  it("rates are 0 when sent is 0", () => {
    const out = buildCampaignMetrics(
      { pending: 0, sent: 0, delivered: 0, read: 0, failed: 0, skipped: 5 },
      campaign,
    );
    expect(out.deliveryRate).toBe(0);
    expect(out.readRate).toBe(0);
  });
});
```

> The audienceSize test note: a recipient ends in exactly one terminal state
> (sent/delivered/read are not stacked — a "read" recipient is counted in
> `read` only, not in `sent`+`delivered`+`read`). The audience size is the
> raw recipient count = sum of every status bucket. The first test's expected
> value MUST be re-derived in the implementation step — update the test if the
> arithmetic disagrees; the rule is "sum all status counts."

- [ ] **Step 2: Run and watch it fail**

Run: `pnpm --filter @whatapp/shared test campaign-metrics`
Expected: FAIL.

- [ ] **Step 3: Implement**

```ts
// packages/shared/src/analytics/campaign-metrics.ts
export interface RecipientCounts {
  pending: number;
  sent: number;
  delivered: number;
  read: number;
  failed: number;
  skipped: number;
}

export interface CampaignSummary {
  id: string;
  name: string;
  status: string;
  startedAt: Date | null;
  completedAt: Date | null;
}

export interface CampaignMetricsResponse {
  id: string;
  name: string;
  status: string;
  audienceSize: number;
  counts: { sent: number; delivered: number; read: number; failed: number; skipped: number };
  deliveryRate: number;
  readRate: number;
  durationSeconds: number | null;
}

function rate(n: number, d: number): number {
  return d > 0 ? n / d : 0;
}

/**
 * Shapes one campaign's per-status recipient counts into the analytics
 * response. `audienceSize` is the total recipient count (a recipient sits in
 * exactly one status bucket).
 */
export function buildCampaignMetrics(
  rc: RecipientCounts,
  c: CampaignSummary,
): CampaignMetricsResponse {
  const audienceSize =
    rc.pending + rc.sent + rc.delivered + rc.read + rc.failed + rc.skipped;
  // Effective "sent successfully" includes anyone past the queue.
  const effectiveSent = rc.sent + rc.delivered + rc.read;
  const effectiveDelivered = rc.delivered + rc.read;
  const effectiveRead = rc.read;
  const duration =
    c.startedAt && c.completedAt
      ? Math.round((c.completedAt.getTime() - c.startedAt.getTime()) / 1000)
      : null;
  return {
    id: c.id,
    name: c.name,
    status: c.status,
    audienceSize,
    counts: {
      sent: effectiveSent,
      delivered: effectiveDelivered,
      read: effectiveRead,
      failed: rc.failed,
      skipped: rc.skipped,
    },
    deliveryRate: rate(effectiveDelivered, effectiveSent),
    readRate: rate(effectiveRead, effectiveSent),
    durationSeconds: duration,
  };
}
```

- [ ] **Step 4: Re-derive expected values, then run**

The first test's expected values must agree with the implementation:
- Input: `{ pending:0, sent:100, delivered:90, read:50, failed:10, skipped:0 }`
- audienceSize = 250
- effectiveSent = 100+90+50 = 240
- effectiveDelivered = 90+50 = 140
- effectiveRead = 50
- deliveryRate = 140/240 ≈ 0.583
- readRate = 50/240 ≈ 0.208

Update the first test to assert those numbers:

```ts
expect(out.audienceSize).toBe(250);
expect(out.counts).toEqual({ sent: 240, delivered: 140, read: 50, failed: 10, skipped: 0 });
expect(out.deliveryRate).toBeCloseTo(140 / 240);
expect(out.readRate).toBeCloseTo(50 / 240);
```

Run: `pnpm --filter @whatapp/shared test campaign-metrics`
Expected: PASS (3 tests).

- [ ] **Step 5: Re-export and commit**

Add `export * from "./analytics/campaign-metrics.js";` to `index.ts`.

```
git add packages/shared/src/analytics/campaign-metrics.ts packages/shared/src/analytics/campaign-metrics.test.ts packages/shared/src/index.ts
git commit -m "feat(shared): add buildCampaignMetrics helper"
```

---

### Task 6: Pure helper — `shouldAlert` (dedup with cool-down)

**Files:**
- Create: `packages/shared/src/alerts/should-alert.ts`
- Create: `packages/shared/src/alerts/should-alert.test.ts`

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

```ts
import { describe, it, expect } from "vitest";
import { shouldAlert, type QualityState } from "./should-alert";

const RATINGS = ["GREEN", "YELLOW", "RED"] as const;
const COOLDOWN_MS = 60 * 60 * 1000; // 1h
const NOW = new Date("2026-05-22T12:00:00Z");

function state(rating: string | null, tier: string | null = "TIER_10K"): QualityState {
  return { qualityRating: rating, messagingLimitTier: tier };
}

describe("shouldAlert", () => {
  it("alerts on rating drop GREEN→YELLOW", () => {
    const r = shouldAlert(state("GREEN"), state("YELLOW"), null, COOLDOWN_MS, NOW);
    expect(r.alert).toBe(true);
    expect(r.kind).toBe("quality.drop");
  });

  it("alerts on rating drop YELLOW→RED", () => {
    const r = shouldAlert(state("YELLOW"), state("RED"), null, COOLDOWN_MS, NOW);
    expect(r.alert).toBe(true);
  });

  it("does NOT alert on rating improvement RED→GREEN", () => {
    const r = shouldAlert(state("RED"), state("GREEN"), null, COOLDOWN_MS, NOW);
    expect(r.alert).toBe(false);
  });

  it("does NOT alert when rating is unchanged", () => {
    const r = shouldAlert(state("YELLOW"), state("YELLOW"), null, COOLDOWN_MS, NOW);
    expect(r.alert).toBe(false);
  });

  it("alerts on messaging-limit decrease TIER_10K → TIER_1K", () => {
    const r = shouldAlert(
      state("GREEN", "TIER_10K"),
      state("GREEN", "TIER_1K"),
      null, COOLDOWN_MS, NOW,
    );
    expect(r.alert).toBe(true);
    expect(r.kind).toBe("limit.decrease");
  });

  it("does NOT alert on messaging-limit increase TIER_1K → TIER_10K", () => {
    const r = shouldAlert(
      state("GREEN", "TIER_1K"),
      state("GREEN", "TIER_10K"),
      null, COOLDOWN_MS, NOW,
    );
    expect(r.alert).toBe(false);
  });

  it("suppresses an alert inside the cool-down window", () => {
    const within = new Date(NOW.getTime() - 30 * 60 * 1000); // 30 min ago
    const r = shouldAlert(state("GREEN"), state("YELLOW"), within, COOLDOWN_MS, NOW);
    expect(r.alert).toBe(false);
    expect(r.reason).toMatch(/cool[- ]?down/i);
  });

  it("alerts again after the cool-down has passed", () => {
    const old = new Date(NOW.getTime() - 2 * COOLDOWN_MS);
    const r = shouldAlert(state("GREEN"), state("YELLOW"), old, COOLDOWN_MS, NOW);
    expect(r.alert).toBe(true);
  });

  it("alerts on transition from null → RED (initial bad state)", () => {
    const r = shouldAlert(state(null), state("RED"), null, COOLDOWN_MS, NOW);
    expect(r.alert).toBe(true);
  });
});
```

- [ ] **Step 2: Run and watch it fail**

Run: `pnpm --filter @whatapp/shared test should-alert`
Expected: FAIL.

- [ ] **Step 3: Implement**

```ts
// packages/shared/src/alerts/should-alert.ts
export interface QualityState {
  qualityRating: string | null;
  messagingLimitTier: string | null;
}

export type AlertKind = "quality.drop" | "limit.decrease";

export interface ShouldAlertResult {
  alert: boolean;
  kind?: AlertKind;
  reason?: string;
  /** Dedup key derived from the transition — same transition = same key. */
  dedupKey?: string;
}

const RATING_RANK: Record<string, number> = { GREEN: 3, YELLOW: 2, RED: 1 };
const TIER_RANK: Record<string, number> = {
  TIER_250: 1,
  TIER_1K: 2,
  TIER_10K: 3,
  TIER_100K: 4,
  TIER_UNLIMITED: 5,
};

function ratingRank(r: string | null): number {
  if (!r) return 4; // unknown treated as best, so any known→null is not an alert
  return RATING_RANK[r] ?? 4;
}
function tierRank(t: string | null): number {
  if (!t) return 0;
  return TIER_RANK[t] ?? 0;
}

/**
 * Decides whether the transition (prev → next) is alert-worthy and whether
 * the cool-down has expired. Pure; no I/O.
 *
 * Conditions:
 *   - quality rating drops to YELLOW or RED, OR
 *   - messaging limit tier decreases.
 * Suppressed if `lastAlertedAt` is within `cooldownMs` of `now`.
 */
export function shouldAlert(
  prev: QualityState,
  next: QualityState,
  lastAlertedAt: Date | null,
  cooldownMs: number,
  now: Date,
): ShouldAlertResult {
  let kind: AlertKind | null = null;
  let dedupKey = "";

  // Quality drop: next rating is YELLOW or RED AND strictly worse than prev.
  if (
    next.qualityRating &&
    (next.qualityRating === "YELLOW" || next.qualityRating === "RED") &&
    ratingRank(next.qualityRating) < ratingRank(prev.qualityRating)
  ) {
    kind = "quality.drop";
    dedupKey = `quality:${prev.qualityRating ?? "null"}->${next.qualityRating}`;
  } else if (tierRank(next.messagingLimitTier) < tierRank(prev.messagingLimitTier)) {
    kind = "limit.decrease";
    dedupKey = `limit:${prev.messagingLimitTier ?? "null"}->${next.messagingLimitTier ?? "null"}`;
  }

  if (!kind) return { alert: false };

  if (lastAlertedAt && now.getTime() - lastAlertedAt.getTime() < cooldownMs) {
    return { alert: false, kind, reason: "cool-down active", dedupKey };
  }
  return { alert: true, kind, dedupKey };
}
```

- [ ] **Step 4: Run and watch it pass**

Run: `pnpm --filter @whatapp/shared test should-alert`
Expected: PASS (9 tests).

- [ ] **Step 5: Re-export and commit**

Add `export * from "./alerts/should-alert.js";` to `packages/shared/src/index.ts`.

```
git add packages/shared/src/alerts/should-alert.ts packages/shared/src/alerts/should-alert.test.ts packages/shared/src/index.ts
git commit -m "feat(shared): add shouldAlert dedup helper"
```

---

### Task 7: Extend `QualityService` — snapshot writes + getters

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

- [ ] **Step 1: Extend the test file**

Add to the existing `describe("QualityService", …)` block in
`quality.service.test.ts`:

```ts
// Replace the beforeEach to include the new delegates:
beforeEach(() => {
  prisma = {
    setting: { upsert: vi.fn().mockResolvedValue({}), findUnique: vi.fn() },
    qualitySnapshot: {
      create: vi.fn().mockResolvedValue({}),
      findFirst: vi.fn().mockResolvedValue(null),
      findMany: vi.fn().mockResolvedValue([]),
    },
    template: {
      findMany: vi.fn().mockResolvedValue([
        { id: "t1", name: "wel", language: "en", qualityScore: "GREEN", status: "APPROVED" },
      ]),
    },
  };
  alerts = { onQualityChange: vi.fn().mockResolvedValue(undefined) };
  service = new QualityService(prisma as never, alerts as never);
});

it("also writes a QualitySnapshot row on each update (FR-07.1)", async () => {
  await service.recordQualityUpdate({
    display_phone_number: "+9715550000",
    event: "FLAGGED",
    current_limit: "TIER_1K",
    messaging_limit_tier: "TIER_1K",
    quality_score: { score: "YELLOW" },
  });
  const arg = prisma.qualitySnapshot.create.mock.calls[0]?.[0];
  expect(arg.data.qualityRating).toBe("YELLOW");
  expect(arg.data.messagingLimitTier).toBe("TIER_1K");
  expect(arg.data.displayPhoneNumber).toBe("+9715550000");
  expect(arg.data.event).toBe("FLAGGED");
});

it("invokes AlertsService.onQualityChange with prev and next states", async () => {
  prisma.qualitySnapshot.findFirst.mockResolvedValueOnce({
    qualityRating: "GREEN",
    messagingLimitTier: "TIER_10K",
  });
  await service.recordQualityUpdate({
    quality_score: { score: "RED" },
    messaging_limit_tier: "TIER_10K",
  });
  expect(alerts.onQualityChange).toHaveBeenCalledWith(
    { qualityRating: "GREEN", messagingLimitTier: "TIER_10K" },
    expect.objectContaining({ qualityRating: "RED", messagingLimitTier: "TIER_10K" }),
  );
});

it("getCurrent returns the latest snapshot (or null)", async () => {
  prisma.qualitySnapshot.findFirst.mockResolvedValueOnce({
    qualityRating: "GREEN",
    messagingLimitTier: "TIER_10K",
    recordedAt: new Date("2026-05-22"),
  });
  const cur = await service.getCurrent();
  expect(cur?.qualityRating).toBe("GREEN");
});

it("getHistory returns rows in the date range", async () => {
  await service.getHistory({ from: new Date("2026-05-20"), to: new Date("2026-05-22") });
  const arg = prisma.qualitySnapshot.findMany.mock.calls[0]?.[0];
  expect(arg.where.recordedAt).toEqual({ gte: expect.any(Date), lte: expect.any(Date) });
  expect(arg.orderBy).toEqual({ recordedAt: "asc" });
});

it("getPerTemplate returns templates with qualityScore", async () => {
  const out = await service.getPerTemplate();
  expect(out).toHaveLength(1);
  expect(out[0]!.qualityScore).toBe("GREEN");
});
```

- [ ] **Step 2: Run the tests and watch them fail**

Run: `pnpm --filter @whatapp/api test quality.service`
Expected: FAIL (methods not defined; constructor arity mismatch).

- [ ] **Step 3: Implement the service changes**

Edit `apps/api/src/quality/quality.service.ts`:

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

export const META_QUALITY_SETTING_KEY = "meta.quality";

export interface QualityStateView {
  qualityRating: string | null;
  messagingLimitTier: string | null;
  phoneNumberStatus: string | null;
  displayPhoneNumber: string | null;
  event: string | null;
  recordedAt: Date;
}

@Injectable()
export class QualityService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly alerts: AlertsService,
  ) {}

  async recordQualityUpdate(value: MetaQualityUpdate): Promise<void> {
    const rating =
      typeof value.quality_score === "string"
        ? value.quality_score
        : value.quality_score?.score;
    const tier = value.messaging_limit_tier ?? value.current_limit ?? null;

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

    // Load the previous snapshot BEFORE writing the new one (for alerting).
    const prev = await this.prisma.qualitySnapshot.findFirst({
      orderBy: { recordedAt: "desc" },
    });

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

    // New: append a history row.
    await this.prisma.qualitySnapshot.create({
      data: {
        qualityRating: rating ?? null,
        messagingLimitTier: tier,
        displayPhoneNumber: value.display_phone_number ?? null,
        event: value.event ?? null,
      },
    });

    // Hook the alerter.
    await this.alerts.onQualityChange(
      {
        qualityRating: prev?.qualityRating ?? null,
        messagingLimitTier: prev?.messagingLimitTier ?? null,
      },
      {
        qualityRating: rating ?? null,
        messagingLimitTier: tier,
      },
    );
  }

  async getCurrent(): Promise<QualityStateView | null> {
    const row = await this.prisma.qualitySnapshot.findFirst({
      orderBy: { recordedAt: "desc" },
    });
    if (!row) return null;
    return {
      qualityRating: row.qualityRating,
      messagingLimitTier: row.messagingLimitTier,
      phoneNumberStatus: row.phoneNumberStatus,
      displayPhoneNumber: row.displayPhoneNumber,
      event: row.event,
      recordedAt: row.recordedAt,
    };
  }

  async getHistory(range: { from: Date; to: Date }): Promise<QualityStateView[]> {
    const rows = await this.prisma.qualitySnapshot.findMany({
      where: { recordedAt: { gte: range.from, lte: range.to } },
      orderBy: { recordedAt: "asc" },
    });
    return rows.map((r) => ({
      qualityRating: r.qualityRating,
      messagingLimitTier: r.messagingLimitTier,
      phoneNumberStatus: r.phoneNumberStatus,
      displayPhoneNumber: r.displayPhoneNumber,
      event: r.event,
      recordedAt: r.recordedAt,
    }));
  }

  async getPerTemplate(): Promise<
    Array<{ id: string; name: string; language: string; qualityScore: string | null; status: string }>
  > {
    const rows = await this.prisma.template.findMany({
      select: { id: true, name: true, language: true, qualityScore: true, status: true },
      orderBy: [{ qualityScore: "asc" }, { name: "asc" }],
    });
    return rows;
  }
}
```

> Note: `AlertsService` is created in Task 9. To keep TDD strict, this task
> introduces a forward import. Use a TYPE-ONLY import + an interface stub if
> the AlertsService doesn't yet exist:
>
> ```ts
> // Temporary stub interface — replaced when the real service lands in Task 9.
> export interface AlertsService {
>   onQualityChange(prev: any, next: any): Promise<void>;
> }
> ```
>
> Remove the stub when Task 9 lands. The service constructor argument typing
> stays the same.

- [ ] **Step 4: Run the tests and watch them pass**

Run: `pnpm --filter @whatapp/api test quality.service`
Expected: PASS (all old + 5 new tests).

- [ ] **Step 5: Commit**

```
git add apps/api/src/quality/quality.service.ts apps/api/src/quality/quality.service.test.ts
git commit -m "feat(api): extend QualityService with snapshot history + alerts hook"
```

---

### Task 8: `EmailService` (nodemailer wrapper)

**Files:**
- Modify: `apps/api/package.json` (add `nodemailer` dependency)
- Create: `apps/api/src/email/email.module.ts`
- Create: `apps/api/src/email/email.service.ts`
- Create: `apps/api/src/email/email.service.test.ts`

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

Edit `apps/api/package.json` `dependencies`:

```json
"nodemailer": "^6.9.14",
```

and `devDependencies`:

```json
"@types/nodemailer": "^6.4.15",
```

Then `pnpm install` from the repo root.

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

```ts
// apps/api/src/email/email.service.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { EmailService } from "./email.service";

describe("EmailService", () => {
  let connections: { getDecrypted: ReturnType<typeof vi.fn> };
  let transporter: { sendMail: ReturnType<typeof vi.fn>; verify: ReturnType<typeof vi.fn> };
  let createTransport: ReturnType<typeof vi.fn>;
  let service: EmailService;

  beforeEach(() => {
    connections = {
      getDecrypted: vi.fn().mockResolvedValue({
        id: "c1",
        provider: "email",
        label: "primary",
        settings: { host: "smtp.example.com", port: 587, from: "noreply@silveroak.ae" },
        secrets: { user: "u", pass: "p" },
        isActive: true,
      }),
    };
    transporter = {
      sendMail: vi.fn().mockResolvedValue({ messageId: "m1" }),
      verify: vi.fn().mockResolvedValue(true),
    };
    createTransport = vi.fn().mockReturnValue(transporter);
    service = new EmailService(connections as never, createTransport as never);
  });

  it("sends mail using the SMTP connection", async () => {
    await service.send({ to: ["it@silveroak.ae"], subject: "Hi", text: "Hello" });
    expect(createTransport).toHaveBeenCalledWith(expect.objectContaining({
      host: "smtp.example.com", port: 587, auth: { user: "u", pass: "p" },
    }));
    expect(transporter.sendMail).toHaveBeenCalledWith(expect.objectContaining({
      from: "noreply@silveroak.ae",
      to: "it@silveroak.ae",
      subject: "Hi",
      text: "Hello",
    }));
  });

  it("throws a clear error when no email connection is configured", async () => {
    connections.getDecrypted.mockResolvedValueOnce(null);
    await expect(
      service.send({ to: ["x@y"], subject: "s", text: "t" }),
    ).rejects.toThrow(/email connection not configured/i);
  });

  it("joins multiple recipients with commas", async () => {
    await service.send({ to: ["a@x", "b@y"], subject: "s", text: "t" });
    expect(transporter.sendMail).toHaveBeenCalledWith(
      expect.objectContaining({ to: "a@x, b@y" }),
    );
  });
});
```

- [ ] **Step 3: Run and watch it fail**

Run: `pnpm --filter @whatapp/api test email.service`
Expected: FAIL (module not found).

- [ ] **Step 4: Implement**

```ts
// apps/api/src/email/email.service.ts
import { Injectable } from "@nestjs/common";
import nodemailer, { type Transporter } from "nodemailer";
import { ConnectionsService } from "../connections/connections.service";

export interface SendEmailInput {
  to: string[];
  subject: string;
  text: string;
  html?: string;
}

export type CreateTransport = (opts: nodemailer.TransportOptions) => Transporter;

@Injectable()
export class EmailService {
  constructor(
    private readonly connections: ConnectionsService,
    /** Injected for tests; defaults to nodemailer.createTransport. */
    private readonly createTransport: CreateTransport = nodemailer.createTransport.bind(nodemailer),
  ) {}

  async send(input: SendEmailInput): Promise<void> {
    const conn = await this.connections.getDecrypted("email" as never);
    if (!conn) {
      throw new Error("Email connection not configured (provider=email)");
    }
    const settings = conn.settings as { host: string; port: number; from: string; secure?: boolean };
    const transporter = this.createTransport({
      host: settings.host,
      port: settings.port,
      secure: settings.secure ?? false,
      auth: { user: conn.secrets["user"], pass: conn.secrets["pass"] },
    });
    await transporter.sendMail({
      from: settings.from,
      to: input.to.join(", "),
      subject: input.subject,
      text: input.text,
      ...(input.html ? { html: input.html } : {}),
    });
  }
}
```

```ts
// apps/api/src/email/email.module.ts
import { Module } from "@nestjs/common";
import { ConnectionsModule } from "../connections/connections.module";
import { EmailService } from "./email.service";

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

- [ ] **Step 5: Run and watch it pass**

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

- [ ] **Step 6: Commit**

```
git add apps/api/package.json apps/api/src/email/email.module.ts apps/api/src/email/email.service.ts apps/api/src/email/email.service.test.ts pnpm-lock.yaml
git commit -m "feat(api): add EmailService SMTP wrapper"
```

---

### Task 9: `AlertsService` + enqueue logic

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

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

```ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { AlertsService } from "./alerts.service";

describe("AlertsService", () => {
  let prisma: any;
  let queue: { add: ReturnType<typeof vi.fn> };
  let service: AlertsService;

  beforeEach(() => {
    prisma = {
      notification: {
        findFirst: vi.fn().mockResolvedValue(null),
        create: vi.fn().mockResolvedValue({ id: "n1" }),
        findMany: vi.fn().mockResolvedValue([]),
        update: vi.fn().mockResolvedValue({}),
      },
      setting: {
        findUnique: vi.fn().mockResolvedValue({
          key: "alerts.recipients",
          value: { recipients: ["it@silveroak.ae"], cooldownMs: 3_600_000 },
        }),
      },
    };
    queue = { add: vi.fn().mockResolvedValue({ id: "j1" }) };
    service = new AlertsService(prisma, queue as never);
  });

  it("creates a notification and enqueues a delivery job on quality drop", async () => {
    await service.onQualityChange(
      { qualityRating: "GREEN", messagingLimitTier: "TIER_10K" },
      { qualityRating: "RED", messagingLimitTier: "TIER_10K" },
    );
    const created = prisma.notification.create.mock.calls[0][0].data;
    expect(created.kind).toBe("quality.drop");
    expect(created.severity).toBe("critical");
    expect(created.dedupKey).toBe("quality:GREEN->RED");
    expect(queue.add).toHaveBeenCalledWith(
      "deliver",
      expect.objectContaining({
        notificationId: "n1",
        recipients: ["it@silveroak.ae"],
      }),
    );
  });

  it("skips when shouldAlert says cool-down is active", async () => {
    prisma.notification.findFirst.mockResolvedValueOnce({
      id: "old",
      createdAt: new Date(),       // just now
      dedupKey: "quality:GREEN->RED",
    });
    await service.onQualityChange(
      { qualityRating: "GREEN", messagingLimitTier: "TIER_10K" },
      { qualityRating: "RED", messagingLimitTier: "TIER_10K" },
    );
    expect(prisma.notification.create).not.toHaveBeenCalled();
    expect(queue.add).not.toHaveBeenCalled();
  });

  it("does not enqueue when there are no recipients", async () => {
    prisma.setting.findUnique.mockResolvedValueOnce({
      key: "alerts.recipients",
      value: { recipients: [], cooldownMs: 3_600_000 },
    });
    await service.onQualityChange(
      { qualityRating: "GREEN", messagingLimitTier: "TIER_10K" },
      { qualityRating: "RED", messagingLimitTier: "TIER_10K" },
    );
    // Notification is still created (in-app feed), but no email is queued.
    expect(prisma.notification.create).toHaveBeenCalledTimes(1);
    expect(queue.add).not.toHaveBeenCalled();
  });

  it("emits a 'limit.decrease' kind for a tier drop", async () => {
    await service.onQualityChange(
      { qualityRating: "GREEN", messagingLimitTier: "TIER_10K" },
      { qualityRating: "GREEN", messagingLimitTier: "TIER_1K" },
    );
    expect(prisma.notification.create.mock.calls[0][0].data.kind).toBe("limit.decrease");
  });

  it("listUnread returns recent notifications oldest-first", async () => {
    prisma.notification.findMany.mockResolvedValueOnce([{ id: "n2" }]);
    const r = await service.listUnread(10);
    expect(r).toEqual([{ id: "n2" }]);
    const arg = prisma.notification.findMany.mock.calls[0][0];
    expect(arg.where.readAt).toBeNull();
    expect(arg.take).toBe(10);
  });

  it("markRead sets readAt on the notification", async () => {
    await service.markRead("n1");
    const arg = prisma.notification.update.mock.calls[0][0];
    expect(arg.where).toEqual({ id: "n1" });
    expect(arg.data.readAt).toBeInstanceOf(Date);
  });
});
```

- [ ] **Step 2: Run and watch it fail**

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

- [ ] **Step 3: Implement**

```ts
// apps/api/src/alerts/alerts.service.ts
import { Inject, Injectable } from "@nestjs/common";
import type { Queue } from "bullmq";
import { shouldAlert, type QualityState } from "@whatapp/shared";
import { PrismaService } from "../prisma/prisma.service";

export const ALERTS_QUEUE = "ALERTS_QUEUE";
export const ALERTS_RECIPIENTS_SETTING = "alerts.recipients";
const DEFAULT_COOLDOWN_MS = 60 * 60 * 1000; // 1h

interface AlertRecipientsSetting {
  recipients: string[];
  cooldownMs?: number;
}

@Injectable()
export class AlertsService {
  constructor(
    private readonly prisma: PrismaService,
    @Inject(ALERTS_QUEUE) private readonly queue: Pick<Queue, "add">,
  ) {}

  /** Called by QualityService after each quality update. */
  async onQualityChange(prev: QualityState, next: QualityState): Promise<void> {
    const cfg = await this.prisma.setting.findUnique({
      where: { key: ALERTS_RECIPIENTS_SETTING },
    });
    const cfgValue = (cfg?.value as AlertRecipientsSetting | undefined) ?? { recipients: [] };
    const cooldown = cfgValue.cooldownMs ?? DEFAULT_COOLDOWN_MS;
    const decision = shouldAlert(prev, next, null, cooldown, new Date());

    if (!decision.alert && !decision.kind) return; // no transition

    // Cool-down check against the most-recent notification with the same key.
    const last = decision.dedupKey
      ? await this.prisma.notification.findFirst({
          where: { dedupKey: decision.dedupKey },
          orderBy: { createdAt: "desc" },
        })
      : null;
    const final = shouldAlert(prev, next, last?.createdAt ?? null, cooldown, new Date());
    if (!final.alert) return;

    const { title, body, severity } = renderAlert(decision.kind!, prev, next);

    const notification = await this.prisma.notification.create({
      data: {
        kind: decision.kind!,
        severity,
        title,
        body,
        dedupKey: decision.dedupKey,
        data: { prev, next },
      },
    });

    if (cfgValue.recipients.length > 0) {
      await this.queue.add("deliver", {
        notificationId: notification.id,
        recipients: cfgValue.recipients,
        subject: title,
        body,
      });
    }
  }

  async listUnread(limit = 20) {
    return this.prisma.notification.findMany({
      where: { readAt: null },
      orderBy: { createdAt: "desc" },
      take: limit,
    });
  }

  async markRead(id: string): Promise<void> {
    await this.prisma.notification.update({
      where: { id },
      data: { readAt: new Date() },
    });
  }
}

function renderAlert(
  kind: "quality.drop" | "limit.decrease",
  prev: QualityState,
  next: QualityState,
): { title: string; body: string; severity: "warning" | "critical" } {
  if (kind === "quality.drop") {
    const severity: "warning" | "critical" = next.qualityRating === "RED" ? "critical" : "warning";
    return {
      title: `WhatsApp quality dropped to ${next.qualityRating}`,
      body: `The WABA quality rating changed from ${prev.qualityRating ?? "unknown"} to ${next.qualityRating}. Review template and messaging quality immediately.`,
      severity,
    };
  }
  return {
    title: `WhatsApp messaging limit decreased to ${next.messagingLimitTier}`,
    body: `The messaging-limit tier changed from ${prev.messagingLimitTier ?? "unknown"} to ${next.messagingLimitTier ?? "unknown"}. Campaign throughput will be reduced.`,
    severity: "warning",
  };
}
```

```ts
// apps/api/src/alerts/alerts.module.ts
import { Module } from "@nestjs/common";
import { Queue } from "bullmq";
import IORedis from "ioredis";
import { PrismaModule } from "../prisma/prisma.module";
import { AlertsService, ALERTS_QUEUE } from "./alerts.service";
import { NotificationsController } from "./notifications.controller";
import { InternalAlertsController } from "./internal-alerts.controller";
import { EmailModule } from "../email/email.module";

@Module({
  imports: [PrismaModule, EmailModule],
  providers: [
    AlertsService,
    {
      provide: ALERTS_QUEUE,
      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("alerts", { connection });
      },
    },
  ],
  controllers: [NotificationsController, InternalAlertsController],
  exports: [AlertsService],
})
export class AlertsModule {}
```

> NotificationsController and InternalAlertsController land in Task 10/11 —
> create empty placeholder files now so the module compiles. Each exports an
> empty `@Controller()` class.

- [ ] **Step 4: Run and watch it pass**

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

- [ ] **Step 5: Update QualityService to import the real AlertsService**

Delete the stub `AlertsService` interface from `quality.service.ts`; import
from `../alerts/alerts.service`. Update `QualityModule` to import
`AlertsModule`:

```ts
// apps/api/src/quality/quality.module.ts
import { Module } from "@nestjs/common";
import { AlertsModule } from "../alerts/alerts.module";
import { QualityService } from "./quality.service";

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

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

- [ ] **Step 6: Commit**

```
git add apps/api/src/alerts apps/api/src/quality/quality.module.ts apps/api/src/quality/quality.service.ts
git commit -m "feat(api): add AlertsService + AlertsModule with quality-change hook"
```

---

### Task 10: `NotificationsController` (`/api/notifications`)

**Files:**
- Create: `apps/api/src/alerts/notifications.controller.ts`
- Create: `apps/api/src/alerts/notifications.controller.test.ts`

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

```ts
import { describe, it, expect, vi } from "vitest";
import { NotificationsController } from "./notifications.controller";

describe("NotificationsController", () => {
  it("GET /api/notifications returns the unread list", async () => {
    const alerts = { listUnread: vi.fn().mockResolvedValue([{ id: "n1" }]), markRead: vi.fn() };
    const ctrl = new NotificationsController(alerts as never);
    expect(await ctrl.list()).toEqual([{ id: "n1" }]);
    expect(alerts.listUnread).toHaveBeenCalled();
  });

  it("POST /api/notifications/:id/read marks the notification read", async () => {
    const alerts = { listUnread: vi.fn(), markRead: vi.fn().mockResolvedValue(undefined) };
    const ctrl = new NotificationsController(alerts as never);
    await ctrl.markRead("n1");
    expect(alerts.markRead).toHaveBeenCalledWith("n1");
  });
});
```

- [ ] **Step 2: Run and watch it fail**

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

- [ ] **Step 3: Implement**

```ts
// apps/api/src/alerts/notifications.controller.ts
import { Controller, Get, Param, Post } from "@nestjs/common";
import { Roles } from "../auth/roles.decorator";
import { AlertsService } from "./alerts.service";

@Controller("api/notifications")
export class NotificationsController {
  constructor(private readonly alerts: AlertsService) {}

  @Get()
  @Roles("viewer", "marketing", "admin")
  list() {
    return this.alerts.listUnread();
  }

  @Post(":id/read")
  @Roles("viewer", "marketing", "admin")
  async markRead(@Param("id") id: string): Promise<{ ok: true }> {
    await this.alerts.markRead(id);
    return { ok: true };
  }
}
```

- [ ] **Step 4: Run and watch it pass + commit**

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

```
git add apps/api/src/alerts/notifications.controller.ts apps/api/src/alerts/notifications.controller.test.ts
git commit -m "feat(api): notifications list + mark-read endpoints"
```

---

### Task 11: `InternalAlertsController` (`POST /internal/alerts/deliver`)

**Files:**
- Create: `apps/api/src/alerts/internal-alerts.controller.ts`
- Create: `apps/api/src/alerts/internal-alerts.controller.test.ts`
- Create: `apps/api/src/alerts/dto.ts`

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

```ts
import { describe, it, expect, vi } from "vitest";
import { InternalAlertsController } from "./internal-alerts.controller";

describe("InternalAlertsController", () => {
  it("sends email to all recipients and returns { delivered: true }", async () => {
    const email = { send: vi.fn().mockResolvedValue(undefined) };
    const ctrl = new InternalAlertsController(email as never);
    const out = await ctrl.deliver({
      notificationId: "n1",
      recipients: ["a@x", "b@y"],
      subject: "Hi",
      body: "Hello",
    });
    expect(out).toEqual({ delivered: true });
    expect(email.send).toHaveBeenCalledWith({
      to: ["a@x", "b@y"],
      subject: "Hi",
      text: "Hello",
    });
  });

  it("rejects invalid bodies via Zod", async () => {
    const ctrl = new InternalAlertsController({ send: vi.fn() } as never);
    await expect(ctrl.deliver({} as never)).rejects.toThrow();
  });
});
```

- [ ] **Step 2: Run and watch it fail**

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

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

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

export const DeliverAlertDto = z.object({
  notificationId: z.string().min(1),
  recipients: z.array(z.string().email()).min(1),
  subject: z.string().min(1),
  body: z.string().min(1),
});
export type DeliverAlertInput = z.infer<typeof DeliverAlertDto>;
```

```ts
// apps/api/src/alerts/internal-alerts.controller.ts
import { Body, Controller, Post, UseGuards } from "@nestjs/common";
import { Public } from "../auth/public.decorator";
import { CallbackAuthGuard } from "../auth/callback-auth.guard";
import { EmailService } from "../email/email.service";
import { DeliverAlertDto, type DeliverAlertInput } from "./dto";

@Controller("internal/alerts")
export class InternalAlertsController {
  constructor(private readonly email: EmailService) {}

  @Post("deliver")
  @Public()
  @UseGuards(CallbackAuthGuard)
  async deliver(@Body() body: unknown): Promise<{ delivered: true }> {
    const parsed: DeliverAlertInput = DeliverAlertDto.parse(body);
    await this.email.send({
      to: parsed.recipients,
      subject: parsed.subject,
      text: parsed.body,
    });
    return { delivered: true };
  }
}
```

- [ ] **Step 4: Run and commit**

Run: `pnpm --filter @whatapp/api test internal-alerts.controller`
Expected: PASS (2 tests).

```
git add apps/api/src/alerts/internal-alerts.controller.ts apps/api/src/alerts/internal-alerts.controller.test.ts apps/api/src/alerts/dto.ts
git commit -m "feat(api): /internal/alerts/deliver endpoint for the worker"
```

---

### Task 12: `QualityController` (`/api/quality`, `/history`, `/templates`)

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

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

```ts
import { describe, it, expect, vi } from "vitest";
import { QualityController } from "./quality.controller";

describe("QualityController", () => {
  const baseService = () => ({
    getCurrent: vi.fn().mockResolvedValue({ qualityRating: "GREEN", messagingLimitTier: "TIER_10K", recordedAt: new Date() }),
    getHistory: vi.fn().mockResolvedValue([{ qualityRating: "GREEN", recordedAt: new Date() }]),
    getPerTemplate: vi.fn().mockResolvedValue([{ id: "t1", name: "wel", language: "en", qualityScore: "GREEN" }]),
  });

  it("GET /api/quality returns the current state (or default when none)", async () => {
    const service = baseService();
    const ctrl = new QualityController(service as never);
    const out = await ctrl.current();
    expect(out.qualityRating).toBe("GREEN");
  });

  it("GET /api/quality returns a null-state response when no snapshot exists", async () => {
    const service = baseService();
    service.getCurrent.mockResolvedValueOnce(null);
    const ctrl = new QualityController(service as never);
    const out = await ctrl.current();
    expect(out.qualityRating).toBeNull();
    expect(out.messagingLimitTier).toBeNull();
  });

  it("GET /api/quality/history parses the date range with Zod", async () => {
    const service = baseService();
    const ctrl = new QualityController(service as never);
    const rows = await ctrl.history({ from: "2026-05-20", to: "2026-05-22" });
    expect(rows).toHaveLength(1);
    expect(service.getHistory).toHaveBeenCalledWith({
      from: new Date("2026-05-20T00:00:00.000Z"),
      to: new Date("2026-05-22T23:59:59.999Z"),
    });
  });

  it("GET /api/quality/history rejects an invalid range (Zod)", async () => {
    const ctrl = new QualityController(baseService() as never);
    await expect(ctrl.history({ from: "nope" } as never)).rejects.toThrow();
  });

  it("GET /api/quality/templates returns each template", async () => {
    const ctrl = new QualityController(baseService() as never);
    const r = await ctrl.templates();
    expect(r).toHaveLength(1);
    expect(r[0]!.qualityScore).toBe("GREEN");
  });
});
```

- [ ] **Step 2: Run and watch it fail**

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

- [ ] **Step 3: Implement**

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

export const DateRangeQuery = z.object({
  from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "from must be YYYY-MM-DD"),
  to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "to must be YYYY-MM-DD"),
});
export type DateRangeQueryInput = z.infer<typeof DateRangeQuery>;

export function parseRange(q: DateRangeQueryInput): { from: Date; to: Date } {
  return {
    from: new Date(`${q.from}T00:00:00.000Z`),
    to: new Date(`${q.to}T23:59:59.999Z`),
  };
}
```

```ts
// apps/api/src/quality/quality.controller.ts
import { Controller, Get, Query } from "@nestjs/common";
import { Roles } from "../auth/roles.decorator";
import { QualityService } from "./quality.service";
import { DateRangeQuery, parseRange } from "./dto";

@Controller("api/quality")
export class QualityController {
  constructor(private readonly quality: QualityService) {}

  @Get()
  @Roles("viewer", "marketing", "admin")
  async current() {
    const row = await this.quality.getCurrent();
    return row ?? {
      qualityRating: null,
      messagingLimitTier: null,
      phoneNumberStatus: null,
      displayPhoneNumber: null,
      event: null,
      recordedAt: null,
    };
  }

  @Get("history")
  @Roles("viewer", "marketing", "admin")
  async history(@Query() query: unknown) {
    const parsed = DateRangeQuery.parse(query);
    return this.quality.getHistory(parseRange(parsed));
  }

  @Get("templates")
  @Roles("viewer", "marketing", "admin")
  async templates() {
    return this.quality.getPerTemplate();
  }
}
```

Update `quality.module.ts` to register the controller:

```ts
import { Module } from "@nestjs/common";
import { AlertsModule } from "../alerts/alerts.module";
import { QualityService } from "./quality.service";
import { QualityController } from "./quality.controller";

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

- [ ] **Step 4: Run and commit**

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

```
git add apps/api/src/quality
git commit -m "feat(api): /api/quality endpoints (current, history, templates)"
```

---

### Task 13: `AnalyticsService` — messages aggregate

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

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

```ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { AnalyticsService } from "./analytics.service";

describe("AnalyticsService.messages", () => {
  let prisma: any;
  let svc: AnalyticsService;

  beforeEach(() => {
    prisma = {
      message: {
        count: vi.fn(),
        findMany: vi.fn(),
        groupBy: vi.fn(),
      },
      campaignRecipient: { groupBy: vi.fn() },
      campaign: { findUnique: vi.fn(), findMany: vi.fn() },
    };
    svc = new AnalyticsService(prisma);
  });

  it("returns totals, rates, and a daily time-series", async () => {
    // Sent = outbound count.
    prisma.message.count
      .mockResolvedValueOnce(100) // sent (outbound)
      .mockResolvedValueOnce(90)  // delivered
      .mockResolvedValueOnce(60)  // read
      .mockResolvedValueOnce(10); // failed

    prisma.message.findMany.mockResolvedValueOnce([
      { createdAt: new Date("2026-05-20T10:00:00Z") },
      { createdAt: new Date("2026-05-20T11:00:00Z") },
      { createdAt: new Date("2026-05-22T01:00:00Z") },
    ]);

    const out = await svc.messages({
      from: new Date("2026-05-20T00:00:00Z"),
      to:   new Date("2026-05-22T23:59:59Z"),
    });

    expect(out.totals).toEqual({ sent: 100, delivered: 90, read: 60, failed: 10 });
    expect(out.deliveryRate).toBeCloseTo(0.9);
    expect(out.readRate).toBeCloseTo(0.6);
    expect(out.timeSeries).toEqual([
      { date: "2026-05-20", count: 2 },
      { date: "2026-05-21", count: 0 },
      { date: "2026-05-22", count: 1 },
    ]);
  });

  it("applies template and campaign filters to every count", async () => {
    prisma.message.count.mockResolvedValue(0);
    prisma.message.findMany.mockResolvedValue([]);
    await svc.messages({
      from: new Date("2026-05-20T00:00:00Z"),
      to: new Date("2026-05-20T23:59:59Z"),
      templateId: "t1",
      campaignId: "c1",
    });
    const firstCall = prisma.message.count.mock.calls[0][0];
    expect(firstCall.where.templateId).toBe("t1");
    expect(firstCall.where.campaignId).toBe("c1");
  });
});
```

- [ ] **Step 2: Run and watch it fail**

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

- [ ] **Step 3: Implement**

```ts
// apps/api/src/analytics/analytics.service.ts
import { Injectable } from "@nestjs/common";
import {
  bucketByDay,
  buildMessageMetrics,
  buildFailureBreakdown,
  buildCampaignMetrics,
  type MessageMetricsResponse,
  type FailureBucket,
  type CampaignMetricsResponse,
  type RecipientCounts,
} from "@whatapp/shared";
import { PrismaService } from "../prisma/prisma.service";

export interface MessagesQuery {
  from: Date;
  to: Date;
  templateId?: string;
  campaignId?: string;
}

@Injectable()
export class AnalyticsService {
  constructor(private readonly prisma: PrismaService) {}

  async messages(q: MessagesQuery): Promise<MessageMetricsResponse> {
    const baseWhere: Record<string, unknown> = {
      createdAt: { gte: q.from, lte: q.to },
      direction: "outbound",
    };
    if (q.templateId) baseWhere["templateId"] = q.templateId;
    if (q.campaignId) baseWhere["campaignId"] = q.campaignId;

    const [sent, delivered, read, failed] = await Promise.all([
      this.prisma.message.count({ where: baseWhere }),
      this.prisma.message.count({ where: { ...baseWhere, status: "delivered" } }),
      this.prisma.message.count({ where: { ...baseWhere, status: "read" } }),
      this.prisma.message.count({ where: { ...baseWhere, status: "failed" } }),
    ]);

    const rows = await this.prisma.message.findMany({
      where: baseWhere,
      select: { createdAt: true },
    });
    const series = bucketByDay(
      rows.map((r) => r.createdAt),
      { from: q.from, to: q.to },
    );

    return buildMessageMetrics({ sent, delivered, read, failed }, series);
  }

  async failures(q: { from: Date; to: Date; campaignId?: string }): Promise<FailureBucket[]> {
    const where: Record<string, unknown> = {
      createdAt: { gte: q.from, lte: q.to },
      direction: "outbound",
      status: "failed",
    };
    if (q.campaignId) where["campaignId"] = q.campaignId;

    const grouped = await this.prisma.message.groupBy({
      by: ["errorCode", "errorTitle"],
      where,
      _count: { _all: true },
    });

    return buildFailureBreakdown(
      grouped.map((g: any) => ({
        errorCode: g.errorCode,
        errorTitle: g.errorTitle,
        count: g._count._all,
      })),
    );
  }

  async campaigns(): Promise<CampaignMetricsResponse[]> {
    const list = await this.prisma.campaign.findMany({
      orderBy: { createdAt: "desc" },
      take: 100,
    });
    return Promise.all(list.map((c: any) => this.shapeCampaign(c)));
  }

  async campaign(id: string): Promise<
    (CampaignMetricsResponse & { failures: FailureBucket[] }) | null
  > {
    const c = await this.prisma.campaign.findUnique({ where: { id } });
    if (!c) return null;
    const metrics = await this.shapeCampaign(c);
    const failures = await this.failures({
      from: c.startedAt ?? c.createdAt,
      to: c.completedAt ?? new Date(),
      campaignId: id,
    });
    return { ...metrics, failures };
  }

  private async shapeCampaign(c: any): Promise<CampaignMetricsResponse> {
    const grouped = await this.prisma.campaignRecipient.groupBy({
      by: ["status"],
      where: { campaignId: c.id },
      _count: { _all: true },
    });
    const counts: RecipientCounts = {
      pending: 0, sent: 0, delivered: 0, read: 0, failed: 0, skipped: 0,
    };
    for (const row of grouped) {
      (counts as any)[row.status] = row._count._all;
    }
    return buildCampaignMetrics(counts, {
      id: c.id,
      name: c.name,
      status: c.status,
      startedAt: c.startedAt ?? null,
      completedAt: c.completedAt ?? null,
    });
  }
}
```

```ts
// apps/api/src/analytics/analytics.module.ts
import { Module } from "@nestjs/common";
import { AnalyticsService } from "./analytics.service";
import { AnalyticsController } from "./analytics.controller";

@Module({
  providers: [AnalyticsService],
  controllers: [AnalyticsController],
  exports: [AnalyticsService],
})
export class AnalyticsModule {}
```

> `AnalyticsController` lands in Task 14 — leave a placeholder empty file
> exporting `class AnalyticsController {}` so the module compiles.

- [ ] **Step 4: Run and commit**

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

```
git add apps/api/src/analytics
git commit -m "feat(api): AnalyticsService — messages aggregate via Prisma + pure helpers"
```

---

### Task 14: `AnalyticsService` — failures, campaigns aggregate tests

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

- [ ] **Step 1: Add failing tests for `failures`, `campaigns`, `campaign(id)`**

```ts
describe("AnalyticsService.failures", () => {
  it("groups by errorCode/errorTitle then runs through buildFailureBreakdown", async () => {
    const prisma = {
      message: {
        groupBy: vi.fn().mockResolvedValue([
          { errorCode: "131047", errorTitle: null, _count: { _all: 5 } },
          { errorCode: "130429", errorTitle: null, _count: { _all: 3 } },
        ]),
      },
    } as any;
    const svc = new AnalyticsService(prisma);
    const out = await svc.failures({ from: new Date(0), to: new Date() });
    expect(out).toHaveLength(2);
    expect(out[0]!.count).toBe(5);
  });
});

describe("AnalyticsService.campaigns", () => {
  it("returns one metrics row per campaign with recipient counts", async () => {
    const prisma = {
      campaign: {
        findMany: vi.fn().mockResolvedValue([
          { id: "c1", name: "May", status: "completed", startedAt: new Date("2026-05-20T10:00:00Z"), completedAt: new Date("2026-05-20T10:05:00Z") },
        ]),
        findUnique: vi.fn(),
      },
      campaignRecipient: {
        groupBy: vi.fn().mockResolvedValue([
          { status: "sent", _count: { _all: 100 } },
          { status: "delivered", _count: { _all: 90 } },
          { status: "read", _count: { _all: 50 } },
          { status: "failed", _count: { _all: 10 } },
        ]),
      },
      message: { groupBy: vi.fn() },
    } as any;
    const svc = new AnalyticsService(prisma);
    const out = await svc.campaigns();
    expect(out).toHaveLength(1);
    expect(out[0]!.audienceSize).toBe(250);
    expect(out[0]!.durationSeconds).toBe(300);
  });
});

describe("AnalyticsService.campaign", () => {
  it("returns metrics + failure breakdown, or null when campaign missing", async () => {
    const prisma = {
      campaign: { findUnique: vi.fn().mockResolvedValue(null), findMany: vi.fn() },
      campaignRecipient: { groupBy: vi.fn() },
      message: { groupBy: vi.fn() },
    } as any;
    const svc = new AnalyticsService(prisma);
    expect(await svc.campaign("missing")).toBeNull();
  });
});
```

- [ ] **Step 2: Run and ensure they pass against the Task-13 implementation**

Run: `pnpm --filter @whatapp/api test analytics.service`
Expected: PASS (5 tests total).

- [ ] **Step 3: Commit**

```
git add apps/api/src/analytics/analytics.service.test.ts
git commit -m "test(api): cover analytics failures and campaigns aggregates"
```

---

### Task 15: `AnalyticsController` (`/api/analytics/*`)

**Files:**
- Create/Replace: `apps/api/src/analytics/analytics.controller.ts`
- Create: `apps/api/src/analytics/analytics.controller.test.ts`
- Create: `apps/api/src/analytics/dto.ts`

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

```ts
import { describe, it, expect, vi } from "vitest";
import { AnalyticsController } from "./analytics.controller";

describe("AnalyticsController", () => {
  const svc = () => ({
    messages: vi.fn().mockResolvedValue({ totals: {}, timeSeries: [] }),
    failures: vi.fn().mockResolvedValue([]),
    campaigns: vi.fn().mockResolvedValue([]),
    campaign: vi.fn().mockResolvedValue({ id: "c1" }),
  });

  it("GET /api/analytics/messages parses query + delegates", async () => {
    const s = svc();
    const ctrl = new AnalyticsController(s as never);
    await ctrl.messages({ from: "2026-05-20", to: "2026-05-22", campaignId: "c1" });
    expect(s.messages).toHaveBeenCalledWith({
      from: new Date("2026-05-20T00:00:00.000Z"),
      to:   new Date("2026-05-22T23:59:59.999Z"),
      campaignId: "c1",
      templateId: undefined,
    });
  });

  it("rejects invalid date ranges", async () => {
    const ctrl = new AnalyticsController(svc() as never);
    await expect(ctrl.messages({ from: "nope" } as never)).rejects.toThrow();
  });

  it("GET /api/analytics/failures delegates with the parsed range", async () => {
    const s = svc();
    const ctrl = new AnalyticsController(s as never);
    await ctrl.failures({ from: "2026-05-20", to: "2026-05-22" });
    expect(s.failures).toHaveBeenCalled();
  });

  it("GET /api/analytics/campaigns/:id returns 404-equivalent null", async () => {
    const s = svc();
    s.campaign.mockResolvedValueOnce(null);
    const ctrl = new AnalyticsController(s as never);
    await expect(ctrl.campaign("missing")).rejects.toThrow(/not found/i);
  });
});
```

- [ ] **Step 2: Run and watch it fail**

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

- [ ] **Step 3: Implement**

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

const dateYmd = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);

export const MessagesQueryDto = z.object({
  from: dateYmd,
  to: dateYmd,
  templateId: z.string().min(1).optional(),
  campaignId: z.string().min(1).optional(),
});
export type MessagesQueryInput = z.infer<typeof MessagesQueryDto>;

export const FailuresQueryDto = z.object({
  from: dateYmd,
  to: dateYmd,
  campaignId: z.string().min(1).optional(),
});
export type FailuresQueryInput = z.infer<typeof FailuresQueryDto>;

export function parseRange(q: { from: string; to: string }): { from: Date; to: Date } {
  return {
    from: new Date(`${q.from}T00:00:00.000Z`),
    to: new Date(`${q.to}T23:59:59.999Z`),
  };
}
```

```ts
// apps/api/src/analytics/analytics.controller.ts
import { Controller, Get, NotFoundException, Param, Query } from "@nestjs/common";
import { Roles } from "../auth/roles.decorator";
import { AnalyticsService } from "./analytics.service";
import {
  MessagesQueryDto,
  FailuresQueryDto,
  parseRange,
} from "./dto";

@Controller("api/analytics")
export class AnalyticsController {
  constructor(private readonly svc: AnalyticsService) {}

  @Get("messages")
  @Roles("viewer", "marketing", "admin")
  messages(@Query() q: unknown) {
    const parsed = MessagesQueryDto.parse(q);
    return this.svc.messages({
      ...parseRange(parsed),
      templateId: parsed.templateId,
      campaignId: parsed.campaignId,
    });
  }

  @Get("failures")
  @Roles("viewer", "marketing", "admin")
  failures(@Query() q: unknown) {
    const parsed = FailuresQueryDto.parse(q);
    return this.svc.failures({
      ...parseRange(parsed),
      campaignId: parsed.campaignId,
    });
  }

  @Get("campaigns")
  @Roles("viewer", "marketing", "admin")
  campaigns() {
    return this.svc.campaigns();
  }

  @Get("campaigns/:id")
  @Roles("viewer", "marketing", "admin")
  async campaign(@Param("id") id: string) {
    const out = await this.svc.campaign(id);
    if (!out) throw new NotFoundException("Campaign not found");
    return out;
  }
}
```

- [ ] **Step 4: Run + register module in `app.module.ts`**

Add `AnalyticsModule` to the imports of `apps/api/src/app.module.ts`.

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

- [ ] **Step 5: Commit**

```
git add apps/api/src/analytics apps/api/src/app.module.ts
git commit -m "feat(api): /api/analytics endpoints (messages, failures, campaigns)"
```

---

### Task 16: Worker — `alerts` queue processor

**Files:**
- Create: `apps/worker/src/processors/alerts.processor.ts`
- Create: `apps/worker/src/processors/alerts.processor.test.ts`
- Modify: `apps/worker/src/lib/internal-api.ts` (add `deliverAlert`)
- Modify: `apps/worker/src/lib/internal-api.test.ts`
- Modify: `apps/worker/src/main.ts` (wire the worker)

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

```ts
// apps/worker/src/processors/alerts.processor.test.ts
import { describe, it, expect, vi } from "vitest";
import { processAlertsJob } from "./alerts.processor";

describe("processAlertsJob", () => {
  it("delegates to the internal /alerts/deliver endpoint", async () => {
    const deliverAlert = vi.fn().mockResolvedValue({ delivered: true });
    const log = vi.fn();
    await processAlertsJob(
      { notificationId: "n1", recipients: ["a@x"], subject: "s", body: "b" },
      { deliverAlert, log },
    );
    expect(deliverAlert).toHaveBeenCalledWith({
      notificationId: "n1", recipients: ["a@x"], subject: "s", body: "b",
    });
  });

  it("re-throws on internal failure so BullMQ retries", async () => {
    const deliverAlert = vi.fn().mockRejectedValue(new Error("smtp down"));
    await expect(
      processAlertsJob(
        { notificationId: "n1", recipients: ["a@x"], subject: "s", body: "b" },
        { deliverAlert, log: () => {} },
      ),
    ).rejects.toThrow("smtp down");
  });
});
```

- [ ] **Step 2: Run and watch it fail**

Run: `pnpm --filter @whatapp/worker test alerts.processor`
Expected: FAIL.

- [ ] **Step 3: Implement processor + extend `internal-api.ts`**

```ts
// apps/worker/src/processors/alerts.processor.ts
export interface AlertsJobData {
  notificationId: string;
  recipients: string[];
  subject: string;
  body: string;
}
export interface AlertsJobDeps {
  deliverAlert: (data: AlertsJobData) => Promise<{ delivered: boolean }>;
  log: (msg: string) => void;
}

export async function processAlertsJob(
  data: AlertsJobData,
  deps: AlertsJobDeps,
): Promise<void> {
  await deps.deliverAlert(data);
  deps.log(`alert delivered: notificationId=${data.notificationId} recipients=${data.recipients.length}`);
}
```

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

```ts
// in InternalApiClient interface
deliverAlert: (data: {
  notificationId: string; recipients: string[]; subject: string; body: string;
}) => Promise<{ delivered: boolean }>;
```

```ts
// at the bottom of createInternalApiClient
async deliverAlert(data) {
  const body = (await postJson(`/internal/alerts/deliver`, data)) as { delivered: boolean };
  return body;
},
```

Add a matching test in `internal-api.test.ts` (asserts the URL and headers).

- [ ] **Step 4: Wire the worker in `main.ts`**

In `apps/worker/src/main.ts`, replace the stub `else` branch for the `alerts`
queue with a real worker. Locate the `for (const queueName of ALL_QUEUE_NAMES)`
loop and add a handler:

```ts
if (queueName === "alerts") {
  return processAlertsJob(job.data as AlertsJobData, {
    deliverAlert: (d) => internalApi.deliverAlert(d),
    log: (m) => console.log(`[alerts] ${m}`),
  });
}
```

Import `processAlertsJob` and `AlertsJobData` at the top.

- [ ] **Step 5: Run and commit**

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

Run: `pnpm typecheck`
Expected: PASS.

```
git add apps/worker/src/processors/alerts.processor.ts apps/worker/src/processors/alerts.processor.test.ts apps/worker/src/lib/internal-api.ts apps/worker/src/lib/internal-api.test.ts apps/worker/src/main.ts
git commit -m "feat(worker): alerts queue processor + deliverAlert internal call"
```

---

### Task 17: Web — analytics + quality + notifications API clients

**Files:**
- Create: `apps/web/src/lib/quality-api.ts`
- Create: `apps/web/src/lib/quality-api.test.ts`
- Create: `apps/web/src/lib/analytics-api.ts`
- Create: `apps/web/src/lib/analytics-api.test.ts`
- Create: `apps/web/src/lib/notifications-api.ts`

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

```ts
// apps/web/src/lib/quality-api.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { getCurrentQuality, getQualityHistory, getTemplateQuality } from "./quality-api";

beforeEach(() => {
  globalThis.fetch = vi.fn().mockResolvedValue({
    ok: true,
    status: 200,
    json: async () => ({ qualityRating: "GREEN" }),
  }) as unknown as typeof fetch;
});

describe("quality-api", () => {
  it("GET /api/quality", async () => {
    const r = await getCurrentQuality();
    expect(r.qualityRating).toBe("GREEN");
    expect((fetch as any).mock.calls[0][0]).toBe("/api/quality");
  });

  it("GET /api/quality/history?from=...&to=...", async () => {
    await getQualityHistory("2026-05-20", "2026-05-22");
    expect((fetch as any).mock.calls[0][0]).toBe(
      "/api/quality/history?from=2026-05-20&to=2026-05-22",
    );
  });

  it("GET /api/quality/templates", async () => {
    await getTemplateQuality();
    expect((fetch as any).mock.calls[0][0]).toBe("/api/quality/templates");
  });
});
```

```ts
// apps/web/src/lib/analytics-api.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { getMessageMetrics, getFailureBreakdown, getCampaignMetrics, getCampaignDetail } from "./analytics-api";

beforeEach(() => {
  globalThis.fetch = vi.fn().mockResolvedValue({
    ok: true,
    status: 200,
    json: async () => ({}),
  }) as unknown as typeof fetch;
});

describe("analytics-api", () => {
  it("builds the messages query string", async () => {
    await getMessageMetrics({ from: "2026-05-20", to: "2026-05-22", templateId: "t1" });
    expect((fetch as any).mock.calls[0][0]).toBe(
      "/api/analytics/messages?from=2026-05-20&to=2026-05-22&templateId=t1",
    );
  });
  it("failures", async () => {
    await getFailureBreakdown({ from: "2026-05-20", to: "2026-05-22" });
    expect((fetch as any).mock.calls[0][0]).toContain("/api/analytics/failures");
  });
  it("campaigns list", async () => {
    await getCampaignMetrics();
    expect((fetch as any).mock.calls[0][0]).toBe("/api/analytics/campaigns");
  });
  it("campaign detail", async () => {
    await getCampaignDetail("c1");
    expect((fetch as any).mock.calls[0][0]).toBe("/api/analytics/campaigns/c1");
  });
});
```

- [ ] **Step 2: Run and watch them fail**

Run: `pnpm --filter @whatapp/web test`
Expected: FAIL.

- [ ] **Step 3: Implement**

```ts
// apps/web/src/lib/quality-api.ts
import { request } from "./api"; // tiny extract — or duplicate the fetcher pattern

export interface QualityState {
  qualityRating: string | null;
  messagingLimitTier: string | null;
  phoneNumberStatus: string | null;
  displayPhoneNumber: string | null;
  event: string | null;
  recordedAt: string | null;
}
export interface QualityHistoryEntry {
  qualityRating: string | null;
  messagingLimitTier: string | null;
  recordedAt: string;
}
export interface TemplateQuality {
  id: string;
  name: string;
  language: string;
  qualityScore: string | null;
  status: string;
}

export const getCurrentQuality = () =>
  request<QualityState>("/api/quality");
export const getQualityHistory = (from: string, to: string) =>
  request<QualityHistoryEntry[]>(`/api/quality/history?from=${from}&to=${to}`);
export const getTemplateQuality = () =>
  request<TemplateQuality[]>("/api/quality/templates");
```

> If `request` is not currently exported from `api.ts`, add `export` to it.

```ts
// apps/web/src/lib/analytics-api.ts
import { request } from "./api";

export interface MessageMetrics {
  totals: { sent: number; delivered: number; read: number; failed: number };
  deliveryRate: number;
  readRate: number;
  timeSeries: { date: string; count: number }[];
}
export interface FailureBucket { code: string | null; reason: string; count: number; }
export interface CampaignMetrics {
  id: string; name: string; status: string;
  audienceSize: number;
  counts: { sent: number; delivered: number; read: number; failed: number; skipped: number };
  deliveryRate: number;
  readRate: number;
  durationSeconds: number | null;
}

function qs(params: Record<string, string | undefined>): string {
  const entries = Object.entries(params).filter(([, v]) => v !== undefined) as [string, string][];
  return entries.length === 0 ? "" : `?${entries.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&")}`;
}

export const getMessageMetrics = (p: { from: string; to: string; templateId?: string; campaignId?: string }) =>
  request<MessageMetrics>(`/api/analytics/messages${qs(p)}`);
export const getFailureBreakdown = (p: { from: string; to: string; campaignId?: string }) =>
  request<FailureBucket[]>(`/api/analytics/failures${qs(p)}`);
export const getCampaignMetrics = () =>
  request<CampaignMetrics[]>("/api/analytics/campaigns");
export const getCampaignDetail = (id: string) =>
  request<CampaignMetrics & { failures: FailureBucket[] }>(`/api/analytics/campaigns/${id}`);
```

```ts
// apps/web/src/lib/notifications-api.ts
import { request } from "./api";

export interface Notification {
  id: string;
  kind: string;
  severity: "info" | "warning" | "critical";
  title: string;
  body: string;
  createdAt: string;
  readAt: string | null;
}

export const listNotifications = () => request<Notification[]>("/api/notifications");
export const markNotificationRead = (id: string) =>
  request<{ ok: true }>(`/api/notifications/${id}/read`, { method: "POST" });
```

- [ ] **Step 4: Run and commit**

Run: `pnpm --filter @whatapp/web test`
Expected: PASS (7 new tests).

```
git add apps/web/src/lib/quality-api.ts apps/web/src/lib/quality-api.test.ts apps/web/src/lib/analytics-api.ts apps/web/src/lib/analytics-api.test.ts apps/web/src/lib/notifications-api.ts apps/web/src/lib/api.ts
git commit -m "feat(web): quality, analytics, and notifications API clients"
```

---

### Task 18: Web — `BarChart`, `LineChart`, `QualityBadge` components

**Files:**
- Create: `apps/web/src/components/charts/BarChart.tsx`
- Create: `apps/web/src/components/charts/BarChart.test.tsx`
- Create: `apps/web/src/components/charts/LineChart.tsx`
- Create: `apps/web/src/components/charts/LineChart.test.tsx`
- Create: `apps/web/src/components/charts/QualityBadge.tsx`

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

```tsx
// BarChart.test.tsx
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import BarChart from "./BarChart";

describe("BarChart", () => {
  it("renders one bar per data point", () => {
    const { container } = render(
      <BarChart data={[{ label: "Mon", value: 10 }, { label: "Tue", value: 5 }]} />,
    );
    expect(container.querySelectorAll("[data-bar]")).toHaveLength(2);
  });
  it("renders zero bars when data is empty", () => {
    const { container } = render(<BarChart data={[]} />);
    expect(container.querySelectorAll("[data-bar]")).toHaveLength(0);
  });
});
```

```tsx
// LineChart.test.tsx
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import LineChart from "./LineChart";

describe("LineChart", () => {
  it("renders an SVG with one polyline", () => {
    const { container } = render(
      <LineChart data={[{ x: "2026-05-20", y: 1 }, { x: "2026-05-21", y: 4 }]} />,
    );
    expect(container.querySelectorAll("polyline")).toHaveLength(1);
  });
});
```

- [ ] **Step 2: Run and watch them fail**

Run: `pnpm --filter @whatapp/web test charts`
Expected: FAIL.

- [ ] **Step 3: Implement**

```tsx
// BarChart.tsx — pure CSS bars, no library.
import React from "react";

export interface BarChartDatum { label: string; value: number; }
export default function BarChart({ data }: { data: BarChartDatum[] }) {
  const max = Math.max(1, ...data.map((d) => d.value));
  return (
    <div style={{ display: "flex", alignItems: "flex-end", gap: 4, height: 120 }}>
      {data.map((d) => (
        <div key={d.label} data-bar style={{ flex: 1, textAlign: "center" }}>
          <div
            style={{
              height: `${(d.value / max) * 100}%`,
              background: "#25D366",
              borderRadius: 2,
              minHeight: 1,
            }}
            title={`${d.label}: ${d.value}`}
          />
          <div style={{ fontSize: 10, color: "#6b7280" }}>{d.label}</div>
        </div>
      ))}
    </div>
  );
}
```

```tsx
// LineChart.tsx — minimal SVG polyline.
import React from "react";

export interface LineChartDatum { x: string; y: number; }
export default function LineChart({ data, height = 120 }: { data: LineChartDatum[]; height?: number }) {
  if (data.length === 0) return <svg width="100%" height={height} />;
  const width = 400;
  const max = Math.max(1, ...data.map((d) => d.y));
  const stepX = width / Math.max(1, data.length - 1);
  const points = data.map((d, i) => `${i * stepX},${height - (d.y / max) * height}`).join(" ");
  return (
    <svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`}>
      <polyline fill="none" stroke="#25D366" strokeWidth={2} points={points} />
    </svg>
  );
}
```

```tsx
// QualityBadge.tsx
import React from "react";
export default function QualityBadge({ rating }: { rating: string | null }) {
  const color = rating === "GREEN" ? "#16a34a" : rating === "YELLOW" ? "#ca8a04" : rating === "RED" ? "#dc2626" : "#6b7280";
  return (
    <span style={{
      display: "inline-block", padding: ".2rem .55rem",
      borderRadius: 999, background: color, color: "#fff",
      fontWeight: 600, fontSize: ".75rem", textTransform: "uppercase" as const,
    }}>{rating ?? "unknown"}</span>
  );
}
```

- [ ] **Step 4: Run and commit**

Run: `pnpm --filter @whatapp/web test charts`
Expected: PASS.

```
git add apps/web/src/components/charts
git commit -m "feat(web): minimal CSS/SVG chart components for analytics"
```

---

### Task 19: Web — Dashboard page (headline cards + recent alerts)

**Files:**
- Replace: `apps/web/src/routes/Dashboard.tsx`
- Create: `apps/web/src/routes/Dashboard.test.tsx`

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

```tsx
// Dashboard.test.tsx
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import Dashboard from "./Dashboard";

vi.mock("../lib/quality-api", () => ({
  getCurrentQuality: vi.fn().mockResolvedValue({
    qualityRating: "GREEN",
    messagingLimitTier: "TIER_10K",
    recordedAt: "2026-05-22T00:00:00Z",
  }),
}));
vi.mock("../lib/analytics-api", () => ({
  getMessageMetrics: vi.fn().mockResolvedValue({
    totals: { sent: 50, delivered: 45, read: 30, failed: 5 },
    deliveryRate: 0.9,
    readRate: 0.6,
    timeSeries: [{ date: "2026-05-22", count: 50 }],
  }),
}));
vi.mock("../lib/notifications-api", () => ({
  listNotifications: vi.fn().mockResolvedValue([
    { id: "n1", kind: "quality.drop", severity: "critical", title: "Drop", body: "b", createdAt: "x", readAt: null },
  ]),
}));
vi.mock("../contexts/AuthContext", () => ({ useAuth: () => ({ user: { name: "Ouchistle" } }) }));

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

describe("Dashboard", () => {
  it("renders headline cards: quality, tier, messages today, delivery rate, read rate", async () => {
    render(wrap(<Dashboard />));
    await waitFor(() => expect(screen.getByText(/quality/i)).toBeTruthy());
    expect(screen.getByText(/GREEN/)).toBeTruthy();
    expect(screen.getByText(/TIER_10K/)).toBeTruthy();
    expect(screen.getByText(/90%/)).toBeTruthy(); // delivery rate
    expect(screen.getByText(/60%/)).toBeTruthy(); // read rate
  });
  it("renders the recent-alerts panel with the unread notification", async () => {
    render(wrap(<Dashboard />));
    await waitFor(() => expect(screen.getByText(/Drop/)).toBeTruthy());
  });
});
```

- [ ] **Step 2: Run and watch it fail**

Run: `pnpm --filter @whatapp/web test Dashboard`
Expected: FAIL.

- [ ] **Step 3: Implement**

```tsx
// Dashboard.tsx
import React from "react";
import { useQuery } from "@tanstack/react-query";
import { getCurrentQuality } from "../lib/quality-api";
import { getMessageMetrics } from "../lib/analytics-api";
import { listNotifications } from "../lib/notifications-api";
import QualityBadge from "../components/charts/QualityBadge";

function todayYmd(): string { return new Date().toISOString().slice(0, 10); }
function pct(x: number): string { return `${Math.round(x * 100)}%`; }

export default function Dashboard() {
  const quality = useQuery({ queryKey: ["quality.current"], queryFn: getCurrentQuality });
  const today = todayYmd();
  const metrics = useQuery({
    queryKey: ["analytics.today", today],
    queryFn: () => getMessageMetrics({ from: today, to: today }),
  });
  const notifs = useQuery({ queryKey: ["notifications"], queryFn: listNotifications });

  return (
    <div>
      <h2 style={{ marginTop: 0 }}>Dashboard</h2>

      <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: 16 }}>
        <Card title="Quality">
          <QualityBadge rating={quality.data?.qualityRating ?? null} />
        </Card>
        <Card title="Messaging tier">{quality.data?.messagingLimitTier ?? "—"}</Card>
        <Card title="Messages today">{metrics.data?.totals.sent ?? 0}</Card>
        <Card title="Delivery rate">{metrics.data ? pct(metrics.data.deliveryRate) : "—"}</Card>
        <Card title="Read rate">{metrics.data ? pct(metrics.data.readRate) : "—"}</Card>
      </div>

      <section style={{ marginTop: 24 }}>
        <h3>Recent alerts</h3>
        {notifs.data && notifs.data.length > 0 ? (
          <ul>
            {notifs.data.map((n) => (
              <li key={n.id}><strong>{n.title}</strong> — {n.body}</li>
            ))}
          </ul>
        ) : (
          <p style={{ color: "#6b7280" }}>No unread alerts.</p>
        )}
      </section>
    </div>
  );
}

function Card({ title, children }: { title: string; children: React.ReactNode }) {
  return (
    <div style={{ background: "#fff", border: "1px solid #e5e7eb", borderRadius: 8, padding: "1rem" }}>
      <div style={{ fontSize: ".75rem", color: "#6b7280", textTransform: "uppercase" as const }}>{title}</div>
      <div style={{ fontSize: "1.25rem", fontWeight: 600, marginTop: 4 }}>{children}</div>
    </div>
  );
}
```

- [ ] **Step 4: Run and commit**

Run: `pnpm --filter @whatapp/web test Dashboard`
Expected: PASS.

```
git add apps/web/src/routes/Dashboard.tsx apps/web/src/routes/Dashboard.test.tsx
git commit -m "feat(web): real Dashboard with quality cards and recent-alerts panel"
```

---

### Task 20: Web — Quality page

**Files:**
- Create: `apps/web/src/routes/Quality.tsx`
- Create: `apps/web/src/routes/Quality.test.tsx`
- Modify: `apps/web/src/App.tsx` (register `/quality`)
- Modify: `apps/web/src/components/AppShell.tsx` (add nav item)

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

```tsx
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import Quality from "./Quality";

vi.mock("../lib/quality-api", () => ({
  getCurrentQuality: vi.fn().mockResolvedValue({ qualityRating: "YELLOW", messagingLimitTier: "TIER_1K", recordedAt: "2026-05-22T00:00:00Z" }),
  getQualityHistory: vi.fn().mockResolvedValue([
    { qualityRating: "GREEN", messagingLimitTier: "TIER_10K", recordedAt: "2026-05-20" },
    { qualityRating: "YELLOW", messagingLimitTier: "TIER_1K", recordedAt: "2026-05-22" },
  ]),
  getTemplateQuality: vi.fn().mockResolvedValue([
    { id: "t1", name: "wel", language: "en", qualityScore: "GREEN", status: "APPROVED" },
    { id: "t2", name: "promo", language: "en", qualityScore: "RED", status: "APPROVED" },
  ]),
}));

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

describe("Quality page", () => {
  it("renders the current rating, history rows, and template rows", async () => {
    render(wrap(<Quality />));
    await waitFor(() => expect(screen.getByText("YELLOW")).toBeTruthy());
    expect(screen.getByText("promo")).toBeTruthy();
    expect(screen.getByText(/RED/)).toBeTruthy();
  });
});
```

- [ ] **Step 2: Run and watch it fail**

Run: `pnpm --filter @whatapp/web test Quality`
Expected: FAIL.

- [ ] **Step 3: Implement**

```tsx
// Quality.tsx
import React, { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { getCurrentQuality, getQualityHistory, getTemplateQuality } from "../lib/quality-api";
import QualityBadge from "../components/charts/QualityBadge";
import LineChart from "../components/charts/LineChart";

function defaultRange(): { from: string; to: string } {
  const to = new Date();
  const from = new Date();
  from.setUTCDate(from.getUTCDate() - 30);
  return { from: from.toISOString().slice(0, 10), to: to.toISOString().slice(0, 10) };
}

const TIER_RANK: Record<string, number> = { TIER_250: 1, TIER_1K: 2, TIER_10K: 3, TIER_100K: 4, TIER_UNLIMITED: 5 };

export default function Quality() {
  const [range] = useState(defaultRange());
  const cur = useQuery({ queryKey: ["quality.current"], queryFn: getCurrentQuality });
  const hist = useQuery({
    queryKey: ["quality.history", range.from, range.to],
    queryFn: () => getQualityHistory(range.from, range.to),
  });
  const tpl = useQuery({ queryKey: ["quality.templates"], queryFn: getTemplateQuality });

  const trend =
    (hist.data ?? []).map((h) => ({
      x: h.recordedAt.slice(0, 10),
      y: TIER_RANK[h.messagingLimitTier ?? ""] ?? 0,
    }));

  return (
    <div>
      <h2 style={{ marginTop: 0 }}>Quality</h2>

      <section style={{ display: "flex", gap: 24, alignItems: "center", marginBottom: 16 }}>
        <div>Current rating: <QualityBadge rating={cur.data?.qualityRating ?? null} /></div>
        <div>Messaging tier: <strong>{cur.data?.messagingLimitTier ?? "—"}</strong></div>
      </section>

      <section style={{ marginBottom: 24 }}>
        <h3>Messaging-limit trend (last 30 days)</h3>
        <LineChart data={trend} />
      </section>

      <section>
        <h3>Per-template quality</h3>
        <table style={{ width: "100%", borderCollapse: "collapse" }}>
          <thead>
            <tr><th align="left">Name</th><th align="left">Language</th><th align="left">Status</th><th align="left">Quality</th></tr>
          </thead>
          <tbody>
            {(tpl.data ?? []).map((t) => (
              <tr key={t.id}>
                <td>{t.name}</td>
                <td>{t.language}</td>
                <td>{t.status}</td>
                <td><QualityBadge rating={t.qualityScore} /></td>
              </tr>
            ))}
          </tbody>
        </table>
      </section>
    </div>
  );
}
```

Update `apps/web/src/App.tsx`: import `Quality` and add
`<Route path="/quality" element={<Quality />} />` next to the analytics route.

Update `apps/web/src/components/AppShell.tsx` NAV_ITEMS — add a `Quality` link
between `Analytics` and `Settings`:

```ts
{ label: "Quality", to: "/quality", minRole: "viewer" },
```

- [ ] **Step 4: Run and commit**

Run: `pnpm --filter @whatapp/web test Quality`
Expected: PASS.

```
git add apps/web/src/routes/Quality.tsx apps/web/src/routes/Quality.test.tsx apps/web/src/App.tsx apps/web/src/components/AppShell.tsx
git commit -m "feat(web): Quality page with current state, trend chart, per-template table"
```

---

### Task 21: Web — Analytics page

**Files:**
- Create: `apps/web/src/routes/Analytics.tsx`
- Create: `apps/web/src/routes/Analytics.test.tsx`
- Modify: `apps/web/src/App.tsx` (replace the `Placeholder` for `/analytics`)

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

```tsx
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router";
import Analytics from "./Analytics";

vi.mock("../lib/analytics-api", () => ({
  getMessageMetrics: vi.fn().mockResolvedValue({
    totals: { sent: 100, delivered: 90, read: 60, failed: 10 },
    deliveryRate: 0.9, readRate: 0.6,
    timeSeries: [{ date: "2026-05-20", count: 50 }, { date: "2026-05-21", count: 50 }],
  }),
  getFailureBreakdown: vi.fn().mockResolvedValue([
    { code: "131047", reason: "Re-engagement", count: 5 },
  ]),
  getCampaignMetrics: vi.fn().mockResolvedValue([
    { id: "c1", name: "May promo", status: "completed", audienceSize: 200, counts: { sent: 100, delivered: 90, read: 50, failed: 10, skipped: 0 }, deliveryRate: 0.9, readRate: 0.5, durationSeconds: 300 },
  ]),
}));

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

describe("Analytics page", () => {
  it("renders headline counters and the volume chart", async () => {
    render(wrap(<Analytics />));
    await waitFor(() => expect(screen.getByText("100")).toBeTruthy()); // sent
    expect(screen.getByText(/90%/)).toBeTruthy(); // delivery
    expect(screen.getByText(/60%/)).toBeTruthy(); // read
  });

  it("renders the failure breakdown row", async () => {
    render(wrap(<Analytics />));
    await waitFor(() => expect(screen.getByText(/Re-engagement/)).toBeTruthy());
  });

  it("renders the campaign performance row linking to campaign detail", async () => {
    render(wrap(<Analytics />));
    await waitFor(() => expect(screen.getByText("May promo")).toBeTruthy());
    const link = screen.getByText("May promo").closest("a");
    expect(link?.getAttribute("href")).toBe("/campaigns/c1");
  });
});
```

- [ ] **Step 2: Run and watch it fail**

Run: `pnpm --filter @whatapp/web test Analytics`
Expected: FAIL.

- [ ] **Step 3: Implement**

```tsx
// Analytics.tsx
import React, { useState } from "react";
import { Link } from "react-router";
import { useQuery } from "@tanstack/react-query";
import { getMessageMetrics, getFailureBreakdown, getCampaignMetrics } from "../lib/analytics-api";
import BarChart from "../components/charts/BarChart";

function lastNDays(n: number): { from: string; to: string } {
  const to = new Date();
  const from = new Date();
  from.setUTCDate(from.getUTCDate() - (n - 1));
  return { from: from.toISOString().slice(0, 10), to: to.toISOString().slice(0, 10) };
}
function pct(x: number): string { return `${Math.round(x * 100)}%`; }

export default function Analytics() {
  const [range, setRange] = useState(lastNDays(7));
  const m = useQuery({ queryKey: ["a.messages", range], queryFn: () => getMessageMetrics(range) });
  const f = useQuery({ queryKey: ["a.failures", range], queryFn: () => getFailureBreakdown(range) });
  const c = useQuery({ queryKey: ["a.campaigns"], queryFn: getCampaignMetrics });

  return (
    <div>
      <h2 style={{ marginTop: 0 }}>Analytics</h2>

      <div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
        <label>From <input type="date" value={range.from} onChange={(e) => setRange((r) => ({ ...r, from: e.target.value }))} /></label>
        <label>To <input type="date" value={range.to} onChange={(e) => setRange((r) => ({ ...r, to: e.target.value }))} /></label>
      </div>

      {m.data && (
        <>
          <div style={{ display: "flex", gap: 24, marginBottom: 16 }}>
            <div><strong>{m.data.totals.sent}</strong> sent</div>
            <div><strong>{m.data.totals.delivered}</strong> delivered</div>
            <div><strong>{m.data.totals.read}</strong> read</div>
            <div><strong>{m.data.totals.failed}</strong> failed</div>
            <div>delivery <strong>{pct(m.data.deliveryRate)}</strong></div>
            <div>read <strong>{pct(m.data.readRate)}</strong></div>
          </div>
          <h3>Volume</h3>
          <BarChart data={m.data.timeSeries.map((t) => ({ label: t.date.slice(5), value: t.count }))} />
        </>
      )}

      <h3 style={{ marginTop: 24 }}>Failure breakdown</h3>
      <table style={{ width: "100%", borderCollapse: "collapse" }}>
        <thead><tr><th align="left">Reason</th><th align="left">Code</th><th align="right">Count</th></tr></thead>
        <tbody>
          {(f.data ?? []).map((b, i) => (
            <tr key={i}><td>{b.reason}</td><td>{b.code ?? "—"}</td><td align="right">{b.count}</td></tr>
          ))}
        </tbody>
      </table>

      <h3 style={{ marginTop: 24 }}>Campaign performance</h3>
      <table style={{ width: "100%", borderCollapse: "collapse" }}>
        <thead><tr><th align="left">Name</th><th align="left">Status</th><th align="right">Audience</th><th align="right">Delivery</th><th align="right">Read</th></tr></thead>
        <tbody>
          {(c.data ?? []).map((cm) => (
            <tr key={cm.id}>
              <td><Link to={`/campaigns/${cm.id}`}>{cm.name}</Link></td>
              <td>{cm.status}</td>
              <td align="right">{cm.audienceSize}</td>
              <td align="right">{pct(cm.deliveryRate)}</td>
              <td align="right">{pct(cm.readRate)}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}
```

Edit `apps/web/src/App.tsx`: replace
`<Route path="/analytics" element={<Placeholder name="Analytics" />} />`
with `<Route path="/analytics" element={<Analytics />} />` and add the import.

- [ ] **Step 4: Run and commit**

Run: `pnpm --filter @whatapp/web test Analytics`
Expected: PASS.

```
git add apps/web/src/routes/Analytics.tsx apps/web/src/routes/Analytics.test.tsx apps/web/src/App.tsx
git commit -m "feat(web): Analytics page (date range, volume, failures, campaigns)"
```

---

### Task 22: Workspace verification (lint, typecheck, test, build)

**Files:** none modified.

- [ ] **Step 1: Run the full pipeline**

```
pnpm lint
pnpm typecheck
pnpm test
pnpm build
```

Expected: all four PASS. Fix any compile-only issues found (e.g., unused
imports, missing types) inline with a small follow-up commit for each fix.

- [ ] **Step 2: Tick Phase 6 in ROADMAP.md**

Edit `plans/ROADMAP.md`, change `[ ] Phase 6` to `[x] Phase 6`.

- [ ] **Step 3: Commit**

```
git add plans/ROADMAP.md
git commit -m "docs: mark Phase 6 complete in roadmap"
```

---

## Deferred (live verification, not blockers)

- **`prisma migrate deploy`** of the Task-1 migration — runs at cutover when
  the dev/prod database is back online (Docker currently broken). The SQL is
  committed and reviewed; `prisma validate` and `prisma generate` are run in
  Task 1 to prove it parses.
- **Live SMTP send** — the `EmailService` is fully unit-tested with mocked
  nodemailer; a real end-to-end send is verified during cutover when an SMTP
  `connections.email` row exists.
- **Real quality-drop simulation in production** — AC-07.6's "raised an
  in-app notification and an email" is proven in unit tests (Tasks 9 + 11 +
  16); the live demonstration is a cutover checklist item.

---

## Self-review (spec coverage)

| Req     | Covered by task(s)                                  |
|---------|-----------------------------------------------------|
| FR-07.1 | T1 (schema), T7 (snapshot writes)                   |
| FR-07.2 | T7 (`getCurrent`/`getHistory`), T12 (controller)    |
| FR-07.3 | T7 (`getPerTemplate`), T12 (controller)             |
| FR-07.4 | T2–T3 (helpers), T13 (service), T15 (controller)    |
| FR-07.5 | T4 (`buildFailureBreakdown` reuses `mapMetaError`), T13/T14 (service), T15 (controller) |
| FR-07.6 | T1 (composite indexes), T13 (groupBy + count)       |
| FR-07.7 | T5 (`buildCampaignMetrics`), T13/T14, T15           |
| FR-07.8 | T13/T14 (`campaign(id)` with failures), T15         |
| FR-07.9 | T8 (Email), T9 (AlertsService), T10/T11 (controllers), T16 (worker), T20 (UI) |
| FR-07.10| T6 (`shouldAlert` cool-down), T9 (`AlertsService` dedup) |
| AC-07.1 | T19 (Dashboard) + T20 (Quality)                     |
| AC-07.2 | T20 (Quality history chart) + T12 (history endpoint)|
| AC-07.3 | T21 (Analytics page) + T13/T15 (service/controller) |
| AC-07.4 | T21 (failure-breakdown table) + T4/T13/T15          |
| AC-07.5 | T21 (campaigns table) + T5/T13/T14/T15              |
| AC-07.6 | T6 (cool-down) + T9 (enqueue + dedup) + T11 (deliver) + T16 (worker) — verified end-to-end across mocked tests |

Every FR-07.* and AC-07.* maps to at least one task; nothing left unmapped.

---

## Execution Handoff

Plan complete and saved to `plans/phase-6-quality-analytics.md`. Two execution
options:

1. **Subagent-Driven (recommended)** — fresh subagent per task, review between
   tasks, fast iteration. Use `superpowers:subagent-driven-development`.
2. **Inline Execution** — execute tasks in this session using
   `superpowers:executing-plans`, batch execution with checkpoints.

Which approach?
