# Phase 3 — Templates 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 Templates module — authoring WhatsApp message templates
(header/body/footer/buttons/variables), validating them against Meta's rules,
submitting them to Meta for approval, syncing approval/quality status, and
listing/editing/deleting them — on top of the Phase 1 Meta Gateway.

**Architecture:** One new NestJS module in `apps/api` — `TemplatesModule`
(`TemplatesService` for CRUD + immutability + delete-usage rules,
`TemplateSubmitService` for Meta submission + sync, a `TemplatesController`
exposing `/api/templates`). Two **pure, exhaustively-tested** helpers go in
`packages/shared`: `validateTemplate` (Meta-rule validation — FR-04.5) and
`buildMetaTemplatePayload` (local `components` → Meta `message_templates`
request body — FR-04.6). A `TemplateComponents` Zod schema in `packages/shared`
is the single source of truth for the `components`/`variables` shapes, imported
by both the API DTOs and the validator. The React app gains real Templates
screens (list, editor with live preview + inline validation, detail) behind the
existing `/templates` nav placeholder. Phase 1's webhook handler already updates
`Template` rows on `message_template_status_update` (FR-04.7) — this module only
reads that result and offers a manual `sync`.

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

**References:** `../specs/04-templates.md` (authoritative for FR-04.*/AC-04.*),
`../docs/integrations.md` §1.1–1.2 (Meta API config + template payload shape),
`../docs/design.md`, `../packages/db/prisma/schema.prisma` (authoritative
`Template` model / `TemplateStatus` / `TemplateCategory` enums and field names).

**Conventions:**
- Every task ends in a commit (`feat:` / `fix:` / `test:` / `chore:`).
- Tests use Vitest. Run from the repo root with `pnpm test` or per-package with
  `pnpm --filter <pkg> test <pattern>`.
- **All external HTTP is mocked in tests** — never a live call to Meta. Prisma
  is mocked with plain `vi.fn()` delegates (see the pattern in
  `apps/api/src/contacts/contacts.service.test.ts`). The `MetaClient` is mocked
  with `vi.fn()` methods; `MetaConfigService.getClient()` is mocked to return
  it.
- No secret value is ever committed or logged. There are no new secrets this
  phase — the Meta access token comes from the encrypted `connections` store
  via the existing `MetaConfigService` (`apps/api/src/meta/meta-config.service.ts`).
- **Docker is broken on this machine.** Verification relies on `pnpm lint`,
  `pnpm typecheck`, `pnpm test`, `pnpm build` and unit tests against mocks. Any
  check needing a live Postgres or live Meta API is marked **"live
  verification deferred"** — it is not a blocker for the phase.
- **Migrations are deferred.** Task 1 inspects the schema; the `Template` model
  already has every field this spec needs (see "Schema check" below) — if a
  field turns out missing, the change is an **additive, nullable** column, made
  in `schema.prisma`, with the SQL generated via `prisma migrate diff` and
  committed as a numbered migration folder, applied later when a DB is
  available. Tests use mocked Prisma and never need the column to physically
  exist.
- Web verification is `pnpm --filter @whatapp/web typecheck` + `build` + the
  component tests in this plan. Live click-through in a browser is **"live
  verification deferred"** (no live API).
- The `MetaClient` already has `submitTemplate()` and `listTemplates()`
  (`packages/shared/src/meta/meta-client.ts`). Task 5 adds `deleteTemplate()`.

---

## Schema check

The existing `Template` model (`packages/db/prisma/schema.prisma`) already
carries every field this phase writes:

| Spec need | `Template` field |
|---|---|
| FR-04.1 name/language/category | `name`, `language`, `category` (`TemplateCategory`) |
| FR-04.1 components | `components` (`Json`) |
| FR-04.2 variables (label + sample) | `variables` (`Json`, default `[]`) |
| FR-04.3 status / immutability | `status` (`TemplateStatus`, default `DRAFT`) |
| FR-04.6 Meta id / submitted time | `metaTemplateId`, `submittedAt` |
| FR-04.7 webhook-set fields | `status`, `approvedAt`, `rejectionReason`, `qualityScore` |
| FR-04.1 author | `createdBy` |
| FR-04.4 uniqueness | `@@unique([name, language])` |

`TemplateStatus` = `DRAFT | PENDING | APPROVED | REJECTED | PAUSED | DISABLED`.
`TemplateCategory` = `MARKETING | UTILITY | AUTHENTICATION`.
**No migration is required this phase.** Task 1 verifies this against the live
schema file before any code is written; if (and only if) a gap is found, it
adds an additive nullable column + deferred migration per the Convention above.

---

## File structure

New files this phase creates:

**Shared (`packages/shared/src/templates`)**
- `types.ts` — the `TemplateComponents` / `TemplateVariable` Zod schemas and
  inferred types; the canonical shape of `Template.components` and
  `Template.variables`.
- `validate.ts` — `validateTemplate(components, variables)` pure function +
  `TemplateValidationResult` type (FR-04.5).
- `validate.test.ts` — exhaustive validator tests.
- `meta-payload.ts` — `buildMetaTemplatePayload(template)` pure function →
  Meta `message_templates` request body (FR-04.6).
- `meta-payload.test.ts` — payload-builder tests.
- modify `packages/shared/src/index.ts` — barrel exports for the three files.
- modify `packages/shared/src/meta/meta-client.ts` — add `deleteTemplate()`.

**API — Templates (`apps/api/src/templates`)**
- `dto.ts` — Zod schemas for every templates body/query.
- `templates.service.ts` + `.test.ts` — list/get/create/patch/delete +
  immutability + delete-usage check.
- `template-submit.service.ts` + `.test.ts` — validate/submit/sync against a
  mocked `MetaClient`.
- `templates.controller.ts` + `.test.ts` — `/api/templates` routes.
- `templates.module.ts`.

**API — wiring**
- modify `apps/api/src/app.module.ts` — register `TemplatesModule`.

**Web (`apps/web/src`)**
- `lib/templates-api.ts` — typed fetch functions for the templates API.
- `components/templates/StatusBadge.tsx` + `.test.tsx` — status/quality badge.
- `components/templates/TemplatePreview.tsx` + `.test.tsx` — WhatsApp-style
  rendered preview with sample values.
- `components/templates/TemplateEditorForm.tsx` — the authoring form.
- `routes/Templates.tsx` + `.test.tsx` — templates list screen.
- `routes/TemplateEditor.tsx` + `.test.tsx` — create/edit screen.
- `routes/TemplateDetail.tsx` + `.test.tsx` — detail screen.
- modify `apps/web/src/App.tsx` — replace the `/templates` placeholder with real
  routes (`/templates`, `/templates/new`, `/templates/:id`,
  `/templates/:id/edit`).

---

## Task 1: Verify the Template schema covers the spec

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

No code is written in this task — it locks in the "no migration needed"
assumption from the plan header before any dependent task runs.

- [ ] **Step 1: Read `model Template` in `packages/db/prisma/schema.prisma`**

Confirm every field in the "Schema check" table above is present with the
stated name and type, and that `@@unique([name, language])` exists.

- [ ] **Step 2: Decide**

- If every field is present (expected) — write nothing. Record in the commit
  message that the schema already covers Phase 3.
- If a field is genuinely missing — add it to `model Template` as an
  **additive, nullable** column, run `pnpm --filter @whatapp/db db:generate`,
  generate the SQL with `pnpm exec prisma migrate diff --from-migrations
  ./prisma/migrations --to-schema-datamodel ./prisma/schema.prisma --script`
  (from `packages/db`), and save it under
  `packages/db/prisma/migrations/<YYYYMMDDHHMMSS>_template_phase3/migration.sql`.
  Run `pnpm exec prisma validate` (from `packages/db`) → expect "valid". Mark
  applying it **"live verification deferred"**.

- [ ] **Step 3: Commit**

```bash
git add -A
git commit -m "chore: verify Template schema covers Phase 3 (no migration needed)"
```

(If Step 2 produced a migration, the message is instead
`feat: add additive Template column for Phase 3 (migration deferred)`.)

---

## Task 2: Shared template component schema & types

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

The canonical Zod schema for `Template.components` and `Template.variables`.
Both the API DTOs (Task 6) and the validator (Task 3) import from here, so the
shapes can never drift. No test of its own — it is exercised by Tasks 3 and 6.

- [ ] **Step 1: Implement `packages/shared/src/templates/types.ts`:**

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

/**
 * The canonical shape of a `Template`'s `components` and `variables` JSON
 * columns (FR-04.1, FR-04.2). The Templates API DTOs and the `validateTemplate`
 * pure function both import these schemas so the two can never drift.
 */

/** A header component: text (may carry one variable) or a media placeholder. */
export const templateHeaderSchema = z.discriminatedUnion("format", [
  z.object({ format: z.literal("TEXT"), text: z.string().min(1).max(60) }),
  z.object({ format: z.literal("IMAGE") }),
  z.object({ format: z.literal("VIDEO") }),
  z.object({ format: z.literal("DOCUMENT") }),
]);

/** The required body component. Positional variables live in `text`. */
export const templateBodySchema = z.object({
  text: z.string().min(1).max(1024),
});

/** An optional footer — plain text, no variables (Meta rule). */
export const templateFooterSchema = z.object({
  text: z.string().min(1).max(60),
});

/** A quick-reply button — just a label. */
export const quickReplyButtonSchema = z.object({
  type: z.literal("QUICK_REPLY"),
  text: z.string().min(1).max(25),
});

/** A call-to-action button — opens a URL or dials a phone number. */
export const ctaButtonSchema = z.discriminatedUnion("type", [
  z.object({
    type: z.literal("URL"),
    text: z.string().min(1).max(25),
    url: z.string().url().max(2000),
  }),
  z.object({
    type: z.literal("PHONE_NUMBER"),
    text: z.string().min(1).max(25),
    phoneNumber: z.string().min(1).max(20),
  }),
]);

export const templateButtonSchema = z.union([
  quickReplyButtonSchema,
  ctaButtonSchema,
]);

/** The full `components` object stored on `Template.components`. */
export const templateComponentsSchema = z.object({
  header: templateHeaderSchema.optional(),
  body: templateBodySchema,
  footer: templateFooterSchema.optional(),
  buttons: z.array(templateButtonSchema).max(10).optional(),
});

/**
 * One entry in `Template.variables` (FR-04.2): the positional index, a
 * human label, and a sample value Meta requires for review.
 */
export const templateVariableSchema = z.object({
  /** 1-based position matching `{{1}}`, `{{2}}`, … in header/body text. */
  index: z.number().int().positive(),
  /** A human-friendly label shown in the editor (e.g. "Contact name"). */
  label: z.string().min(1).max(80),
  /** The example value submitted to Meta and used in the preview. */
  sample: z.string().min(1).max(200),
});

export const templateVariablesSchema = z.array(templateVariableSchema);

export type TemplateHeader = z.infer<typeof templateHeaderSchema>;
export type TemplateBody = z.infer<typeof templateBodySchema>;
export type TemplateFooter = z.infer<typeof templateFooterSchema>;
export type TemplateButton = z.infer<typeof templateButtonSchema>;
export type TemplateComponents = z.infer<typeof templateComponentsSchema>;
export type TemplateVariable = z.infer<typeof templateVariableSchema>;
```

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

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

```typescript
export * from "./templates/types.js";
```

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

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

- [ ] **Step 4: Commit**

```bash
git add packages/shared/src/templates/types.ts packages/shared/src/index.ts
git commit -m "feat: add shared template component Zod schema and types"
```

---

## Task 3: `validateTemplate` pure validator

**Files:**
- Create: `packages/shared/src/templates/validate.ts`
- Test: `packages/shared/src/templates/validate.test.ts`
- Modify: `packages/shared/src/index.ts`

The heart of FR-04.5. A **pure function** — no DB, no network — so it is
exhaustively unit-tested. Covers AC-04.2. The submit service (Task 7) calls it
before posting to Meta; the controller (Task 8) exposes it at
`POST /api/templates/:id/validate`.

- [ ] **Step 1: Write the failing test** — `packages/shared/src/templates/validate.test.ts`:

```typescript
import { describe, it, expect } from "vitest";
import { validateTemplate } from "./validate.js";
import type { TemplateComponents, TemplateVariable } from "./types.js";

/** A minimal valid template: body only, no variables, no buttons. */
function base(): TemplateComponents {
  return { body: { text: "Hello from Silver Oak Properties." } };
}

describe("validateTemplate", () => {
  it("a body-only template with no variables is valid", () => {
    const result = validateTemplate(base(), []);
    expect(result.valid).toBe(true);
    expect(result.problems).toEqual([]);
  });

  it("reports a missing body", () => {
    // Body text is empty — Meta requires a non-empty body.
    const result = validateTemplate(
      { body: { text: "" } },
      [],
    );
    expect(result.valid).toBe(false);
    expect(result.problems.join(" ")).toMatch(/body/i);
  });

  it("accepts contiguous {{1}}..{{N}} numbering with samples (FR-04.5)", () => {
    const components: TemplateComponents = {
      body: { text: "Hi {{1}}, your viewing of {{2}} is confirmed." },
    };
    const variables: TemplateVariable[] = [
      { index: 1, label: "Name", sample: "Ahmed" },
      { index: 2, label: "Property", sample: "Marina Villa" },
    ];
    expect(validateTemplate(components, variables).valid).toBe(true);
  });

  it("rejects non-contiguous numbering — {{1}},{{3}} (AC-04.2)", () => {
    const components: TemplateComponents = {
      body: { text: "Hi {{1}}, see {{3}}." },
    };
    const variables: TemplateVariable[] = [
      { index: 1, label: "Name", sample: "Ahmed" },
      { index: 3, label: "Property", sample: "Villa" },
    ];
    const result = validateTemplate(components, variables);
    expect(result.valid).toBe(false);
    expect(result.problems.join(" ")).toMatch(/contiguous|{{2}}/i);
  });

  it("rejects numbering that does not start at {{1}}", () => {
    const components: TemplateComponents = {
      body: { text: "See {{2}}." },
    };
    const variables: TemplateVariable[] = [
      { index: 2, label: "Property", sample: "Villa" },
    ];
    const result = validateTemplate(components, variables);
    expect(result.valid).toBe(false);
    expect(result.problems.join(" ")).toMatch(/{{1}}|start/i);
  });

  it("rejects a variable used in text with no sample value (AC-04.2)", () => {
    const components: TemplateComponents = {
      body: { text: "Hi {{1}}." },
    };
    // variables array is empty — {{1}} has no sample.
    const result = validateTemplate(components, []);
    expect(result.valid).toBe(false);
    expect(result.problems.join(" ")).toMatch(/sample|{{1}}/i);
  });

  it("rejects a variable with a blank sample value", () => {
    const components: TemplateComponents = {
      body: { text: "Hi {{1}}." },
    };
    const result = validateTemplate(components, [
      { index: 1, label: "Name", sample: "   " },
    ]);
    expect(result.valid).toBe(false);
    expect(result.problems.join(" ")).toMatch(/sample/i);
  });

  it("rejects a declared variable that appears nowhere in the text", () => {
    const components: TemplateComponents = {
      body: { text: "Hello there." },
    };
    const result = validateTemplate(components, [
      { index: 1, label: "Unused", sample: "x" },
    ]);
    expect(result.valid).toBe(false);
    expect(result.problems.join(" ")).toMatch(/unused|not used|{{1}}/i);
  });

  it("counts variables across header text and body text", () => {
    const components: TemplateComponents = {
      header: { format: "TEXT", text: "Update for {{1}}" },
      body: { text: "Your booking {{2}} is ready." },
    };
    const variables: TemplateVariable[] = [
      { index: 1, label: "Name", sample: "Ahmed" },
      { index: 2, label: "Ref", sample: "BK-19" },
    ];
    expect(validateTemplate(components, variables).valid).toBe(true);
  });

  it("rejects more than one variable in the header (Meta rule)", () => {
    const components: TemplateComponents = {
      header: { format: "TEXT", text: "{{1}} and {{2}}" },
      body: { text: "Body {{3}}." },
    };
    const variables: TemplateVariable[] = [
      { index: 1, label: "a", sample: "A" },
      { index: 2, label: "b", sample: "B" },
      { index: 3, label: "c", sample: "C" },
    ];
    const result = validateTemplate(components, variables);
    expect(result.valid).toBe(false);
    expect(result.problems.join(" ")).toMatch(/header/i);
  });

  it("rejects body text longer than 1024 characters", () => {
    const components: TemplateComponents = {
      body: { text: "x".repeat(1025) },
    };
    const result = validateTemplate(components, []);
    expect(result.valid).toBe(false);
    expect(result.problems.join(" ")).toMatch(/body.*1024|length/i);
  });

  it("rejects header text longer than 60 characters", () => {
    const components: TemplateComponents = {
      header: { format: "TEXT", text: "x".repeat(61) },
      body: { text: "ok" },
    };
    const result = validateTemplate(components, []);
    expect(result.valid).toBe(false);
    expect(result.problems.join(" ")).toMatch(/header.*60|length/i);
  });

  it("rejects footer text longer than 60 characters", () => {
    const components: TemplateComponents = {
      body: { text: "ok" },
      footer: { text: "x".repeat(61) },
    };
    const result = validateTemplate(components, []);
    expect(result.valid).toBe(false);
    expect(result.problems.join(" ")).toMatch(/footer.*60|length/i);
  });

  it("accepts up to 10 buttons", () => {
    const components: TemplateComponents = {
      body: { text: "ok" },
      buttons: Array.from({ length: 10 }, (_, i) => ({
        type: "QUICK_REPLY" as const,
        text: `B${i}`,
      })),
    };
    expect(validateTemplate(components, []).valid).toBe(true);
  });

  it("rejects more than 10 buttons (Meta limit)", () => {
    const components: TemplateComponents = {
      body: { text: "ok" },
      buttons: Array.from({ length: 11 }, (_, i) => ({
        type: "QUICK_REPLY" as const,
        text: `B${i}`,
      })),
    };
    const result = validateTemplate(components, []);
    expect(result.valid).toBe(false);
    expect(result.problems.join(" ")).toMatch(/button.*10|too many/i);
  });

  it("rejects more than one URL button (Meta limit)", () => {
    const components: TemplateComponents = {
      body: { text: "ok" },
      buttons: [
        { type: "URL", text: "Site", url: "https://a.example" },
        { type: "URL", text: "Blog", url: "https://b.example" },
      ],
    };
    const result = validateTemplate(components, []);
    expect(result.valid).toBe(false);
    expect(result.problems.join(" ")).toMatch(/url button/i);
  });

  it("rejects more than one phone-number button (Meta limit)", () => {
    const components: TemplateComponents = {
      body: { text: "ok" },
      buttons: [
        { type: "PHONE_NUMBER", text: "Call", phoneNumber: "97140000000" },
        { type: "PHONE_NUMBER", text: "Call2", phoneNumber: "97140000001" },
      ],
    };
    const result = validateTemplate(components, []);
    expect(result.valid).toBe(false);
    expect(result.problems.join(" ")).toMatch(/phone.*button/i);
  });

  it("rejects a button label longer than 25 characters", () => {
    const components: TemplateComponents = {
      body: { text: "ok" },
      buttons: [{ type: "QUICK_REPLY", text: "x".repeat(26) }],
    };
    const result = validateTemplate(components, []);
    expect(result.valid).toBe(false);
    expect(result.problems.join(" ")).toMatch(/button.*25|label/i);
  });

  it("reports every problem at once, not just the first", () => {
    const components: TemplateComponents = {
      body: { text: "Hi {{1}} {{3}}." },
      footer: { text: "x".repeat(61) },
    };
    const result = validateTemplate(components, []);
    expect(result.valid).toBe(false);
    expect(result.problems.length).toBeGreaterThanOrEqual(2);
  });
});
```

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

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

- [ ] **Step 3: Implement `packages/shared/src/templates/validate.ts`:**

```typescript
import type { TemplateComponents, TemplateVariable } from "./types.js";

/** The result of validating a template against Meta's pre-submission rules. */
export interface TemplateValidationResult {
  valid: boolean;
  /** Human-readable problems; empty when `valid` is true. */
  problems: string[];
}

/** Meta text-length limits. */
const HEADER_MAX = 60;
const BODY_MAX = 1024;
const FOOTER_MAX = 60;
const BUTTON_TEXT_MAX = 25;
const MAX_BUTTONS = 10;

/** Extracts the sorted unique {{N}} indices referenced in a string. */
function referencedIndices(text: string): number[] {
  const found = new Set<number>();
  for (const m of text.matchAll(/\{\{\s*(\d+)\s*\}\}/g)) {
    const raw = m[1];
    if (raw) found.add(Number(raw));
  }
  return [...found].sort((a, b) => a - b);
}

/**
 * Validates a template against Meta's pre-submission rules (FR-04.5): a body is
 * present; positional variables are numbered contiguously from `{{1}}`; every
 * referenced variable has a sample value and every declared variable is used;
 * the header carries at most one variable; text lengths and button
 * counts/types are within Meta's limits. Pure and deterministic — collects
 * *all* problems so the editor can show them at once.
 */
export function validateTemplate(
  components: TemplateComponents,
  variables: TemplateVariable[],
): TemplateValidationResult {
  const problems: string[] = [];

  // --- Body present ---
  const bodyText = components.body?.text ?? "";
  if (bodyText.trim().length === 0) {
    problems.push("The template body is required and cannot be empty.");
  }
  if (bodyText.length > BODY_MAX) {
    problems.push(`The body text exceeds the ${BODY_MAX}-character limit.`);
  }

  // --- Header / footer lengths ---
  const headerText =
    components.header && components.header.format === "TEXT"
      ? components.header.text
      : "";
  if (headerText.length > HEADER_MAX) {
    problems.push(`The header text exceeds the ${HEADER_MAX}-character limit.`);
  }
  if (components.footer && components.footer.text.length > FOOTER_MAX) {
    problems.push(`The footer text exceeds the ${FOOTER_MAX}-character limit.`);
  }

  // --- Variable numbering ---
  const headerIndices = referencedIndices(headerText);
  if (headerIndices.length > 1) {
    problems.push("The header may contain at most one variable.");
  }
  const usedIndices = referencedIndices(`${headerText} ${bodyText}`);
  // Contiguous from 1: the set of used indices must be exactly {1..N}.
  for (let i = 0; i < usedIndices.length; i++) {
    const expected = i + 1;
    if (usedIndices[i] !== expected) {
      problems.push(
        `Variable numbering must be contiguous from {{1}} — {{${expected}}} is missing or out of order.`,
      );
      break;
    }
  }

  // --- Every used variable has a sample; every declared variable is used ---
  const byIndex = new Map(variables.map((v) => [v.index, v]));
  for (const idx of usedIndices) {
    const v = byIndex.get(idx);
    if (!v) {
      problems.push(`Variable {{${idx}}} has no sample value.`);
    } else if (v.sample.trim().length === 0) {
      problems.push(`Variable {{${idx}}} has a blank sample value.`);
    }
  }
  for (const v of variables) {
    if (!usedIndices.includes(v.index)) {
      problems.push(
        `Variable {{${v.index}}} ("${v.label}") is declared but not used in any text.`,
      );
    }
  }

  // --- Buttons ---
  const buttons = components.buttons ?? [];
  if (buttons.length > MAX_BUTTONS) {
    problems.push(`A template may have at most ${MAX_BUTTONS} buttons.`);
  }
  let urlButtons = 0;
  let phoneButtons = 0;
  for (const b of buttons) {
    if (b.text.length > BUTTON_TEXT_MAX) {
      problems.push(
        `Button label "${b.text}" exceeds the ${BUTTON_TEXT_MAX}-character limit.`,
      );
    }
    if (b.type === "URL") urlButtons++;
    if (b.type === "PHONE_NUMBER") phoneButtons++;
  }
  if (urlButtons > 1) {
    problems.push("A template may have at most one URL button.");
  }
  if (phoneButtons > 1) {
    problems.push("A template may have at most one phone-number button.");
  }

  return { valid: problems.length === 0, problems };
}
```

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

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

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

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

```typescript
export * from "./templates/validate.js";
```

- [ ] **Step 6: Commit**

```bash
git add packages/shared/src/templates/validate.ts packages/shared/src/templates/validate.test.ts packages/shared/src/index.ts
git commit -m "feat: add pure template validation against Meta rules"
```

---

## Task 4: `buildMetaTemplatePayload` pure payload builder

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

The local `components` → Meta `message_templates` request body translation
(FR-04.6). A **pure function** so it is fully unit-tested; the submit service
(Task 7) passes its output straight to `MetaClient.submitTemplate()`.

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

```typescript
import { describe, it, expect } from "vitest";
import { buildMetaTemplatePayload } from "./meta-payload.js";
import type { TemplateComponents, TemplateVariable } from "./types.js";

describe("buildMetaTemplatePayload", () => {
  it("builds a body-only payload with name, language and category", () => {
    const components: TemplateComponents = {
      body: { text: "Hello from Silver Oak." },
    };
    const payload = buildMetaTemplatePayload({
      name: "welcome",
      language: "en",
      category: "MARKETING",
      components,
      variables: [],
    });
    expect(payload.name).toBe("welcome");
    expect(payload.language).toBe("en");
    expect(payload.category).toBe("MARKETING");
    expect(payload.components).toEqual([
      { type: "BODY", text: "Hello from Silver Oak." },
    ]);
  });

  it("attaches body example values in {{N}} order (FR-04.6)", () => {
    const components: TemplateComponents = {
      body: { text: "Hi {{1}}, viewing {{2}} confirmed." },
    };
    const variables: TemplateVariable[] = [
      { index: 2, label: "Property", sample: "Marina Villa" },
      { index: 1, label: "Name", sample: "Ahmed" },
    ];
    const payload = buildMetaTemplatePayload({
      name: "viewing_confirmed",
      language: "en",
      category: "UTILITY",
      components,
      variables,
    });
    const body = payload.components.find((c) => c.type === "BODY");
    // body_text is [[ value-for-{{1}}, value-for-{{2}} ]] regardless of the
    // order the variables array was given in.
    expect(body?.example).toEqual({ body_text: [["Ahmed", "Marina Villa"]] });
  });

  it("emits a TEXT header with a header example when it has a variable", () => {
    const components: TemplateComponents = {
      header: { format: "TEXT", text: "Update for {{1}}" },
      body: { text: "Body {{2}}." },
    };
    const variables: TemplateVariable[] = [
      { index: 1, label: "Name", sample: "Ahmed" },
      { index: 2, label: "Ref", sample: "BK-19" },
    ];
    const payload = buildMetaTemplatePayload({
      name: "x",
      language: "en",
      category: "UTILITY",
      components,
      variables,
    });
    const header = payload.components.find((c) => c.type === "HEADER");
    expect(header).toMatchObject({ type: "HEADER", format: "TEXT" });
    expect(header?.example).toEqual({ header_text: ["Ahmed"] });
  });

  it("emits a media header with format and no example", () => {
    const components: TemplateComponents = {
      header: { format: "IMAGE" },
      body: { text: "ok" },
    };
    const payload = buildMetaTemplatePayload({
      name: "x",
      language: "en",
      category: "MARKETING",
      components,
      variables: [],
    });
    const header = payload.components.find((c) => c.type === "HEADER");
    expect(header).toEqual({ type: "HEADER", format: "IMAGE" });
  });

  it("emits a FOOTER component when a footer is present", () => {
    const components: TemplateComponents = {
      body: { text: "ok" },
      footer: { text: "Silver Oak Properties" },
    };
    const payload = buildMetaTemplatePayload({
      name: "x",
      language: "en",
      category: "MARKETING",
      components,
      variables: [],
    });
    expect(payload.components).toContainEqual({
      type: "FOOTER",
      text: "Silver Oak Properties",
    });
  });

  it("emits a BUTTONS component mapping each button to Meta's shape", () => {
    const components: TemplateComponents = {
      body: { text: "ok" },
      buttons: [
        { type: "QUICK_REPLY", text: "Yes" },
        { type: "URL", text: "Visit", url: "https://silveroak.ae" },
        { type: "PHONE_NUMBER", text: "Call", phoneNumber: "97140000000" },
      ],
    };
    const payload = buildMetaTemplatePayload({
      name: "x",
      language: "en",
      category: "MARKETING",
      components,
      variables: [],
    });
    const buttons = payload.components.find((c) => c.type === "BUTTONS");
    expect(buttons?.buttons).toEqual([
      { type: "QUICK_REPLY", text: "Yes" },
      { type: "URL", text: "Visit", url: "https://silveroak.ae" },
      { type: "PHONE_NUMBER", text: "Call", phone_number: "97140000000" },
    ]);
  });

  it("omits header/footer/buttons when absent", () => {
    const payload = buildMetaTemplatePayload({
      name: "x",
      language: "en",
      category: "MARKETING",
      components: { body: { text: "ok" } },
      variables: [],
    });
    expect(payload.components.map((c) => c.type)).toEqual(["BODY"]);
  });
});
```

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

Run: `pnpm --filter @whatapp/shared test meta-payload`
Expected: FAIL — `./meta-payload.js` module not found.

- [ ] **Step 3: Implement `packages/shared/src/templates/meta-payload.ts`:**

```typescript
import type { TemplateComponents, TemplateVariable } from "./types.js";

/** A single component in a Meta `message_templates` request body. */
export interface MetaTemplateComponent {
  type: "HEADER" | "BODY" | "FOOTER" | "BUTTONS";
  format?: "TEXT" | "IMAGE" | "VIDEO" | "DOCUMENT";
  text?: string;
  example?: { header_text?: string[]; body_text?: string[][] };
  buttons?: Array<Record<string, string>>;
}

/** The full Meta `POST /{wabaId}/message_templates` request body. */
export interface MetaTemplatePayload {
  name: string;
  language: string;
  category: "MARKETING" | "UTILITY" | "AUTHENTICATION";
  components: MetaTemplateComponent[];
}

/** The local template shape this builder consumes. */
export interface LocalTemplateInput {
  name: string;
  language: string;
  category: "MARKETING" | "UTILITY" | "AUTHENTICATION";
  components: TemplateComponents;
  variables: TemplateVariable[];
}

/** Sample values ordered by {{N}} index, contiguous from {{1}}. */
function samplesInOrder(variables: TemplateVariable[]): string[] {
  return [...variables]
    .sort((a, b) => a.index - b.index)
    .map((v) => v.sample);
}

/** Sorted unique {{N}} indices referenced in a string. */
function referencedIndices(text: string): number[] {
  const found = new Set<number>();
  for (const m of text.matchAll(/\{\{\s*(\d+)\s*\}\}/g)) {
    const raw = m[1];
    if (raw) found.add(Number(raw));
  }
  return [...found].sort((a, b) => a - b);
}

/**
 * Translates a local template into the Meta `message_templates` request body
 * (FR-04.6). Pure — the submit service passes the result straight to
 * `MetaClient.submitTemplate()`. Assumes the input already passed
 * `validateTemplate`; numbering is contiguous so body samples are emitted in
 * `{{1}}..{{N}}` order.
 */
export function buildMetaTemplatePayload(
  input: LocalTemplateInput,
): MetaTemplatePayload {
  const { components, variables } = input;
  const out: MetaTemplateComponent[] = [];

  // --- Header ---
  if (components.header) {
    if (components.header.format === "TEXT") {
      const headerVarIndices = referencedIndices(components.header.text);
      const header: MetaTemplateComponent = {
        type: "HEADER",
        format: "TEXT",
        text: components.header.text,
      };
      if (headerVarIndices.length > 0) {
        const first = headerVarIndices[0]!;
        const v = variables.find((x) => x.index === first);
        header.example = { header_text: [v ? v.sample : ""] };
      }
      out.push(header);
    } else {
      out.push({ type: "HEADER", format: components.header.format });
    }
  }

  // --- Body ---
  const body: MetaTemplateComponent = {
    type: "BODY",
    text: components.body.text,
  };
  const bodyVarIndices = referencedIndices(components.body.text);
  if (bodyVarIndices.length > 0) {
    const bodySamples = bodyVarIndices.map((idx) => {
      const v = variables.find((x) => x.index === idx);
      return v ? v.sample : "";
    });
    body.example = { body_text: [bodySamples] };
  }
  out.push(body);

  // --- Footer ---
  if (components.footer) {
    out.push({ type: "FOOTER", text: components.footer.text });
  }

  // --- Buttons ---
  if (components.buttons && components.buttons.length > 0) {
    out.push({
      type: "BUTTONS",
      buttons: components.buttons.map((b) => {
        if (b.type === "URL") {
          return { type: "URL", text: b.text, url: b.url };
        }
        if (b.type === "PHONE_NUMBER") {
          return { type: "PHONE_NUMBER", text: b.text, phone_number: b.phoneNumber };
        }
        return { type: "QUICK_REPLY", text: b.text };
      }),
    });
  }

  return {
    name: input.name,
    language: input.language,
    category: input.category,
    components: out,
  };
}

// `samplesInOrder` is exported for potential reuse by the campaign builder.
export { samplesInOrder };
```

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

Run: `pnpm --filter @whatapp/shared test meta-payload`
Expected: PASS (7 tests).

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

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

```typescript
export * from "./templates/meta-payload.js";
```

- [ ] **Step 6: Commit**

```bash
git add packages/shared/src/templates/meta-payload.ts packages/shared/src/templates/meta-payload.test.ts packages/shared/src/index.ts
git commit -m "feat: add Meta template payload builder"
```

---

## Task 5: `MetaClient.deleteTemplate`

**Files:**
- Modify: `packages/shared/src/meta/meta-client.ts`

FR-04.9 needs `DELETE /{wabaId}/message_templates?name=...`. The `MetaClient`
has `submitTemplate` and `listTemplates` but no delete. Add `deleteTemplate`,
following the same style (global `fetch`, throws on a non-OK response).
Network calls are not unit-tested here — they are an integration point,
consistent with the existing `submitTemplate`/`listTemplates` (see the class
doc comment).

- [ ] **Step 1: Add the method to `MetaClient`** — in
  `packages/shared/src/meta/meta-client.ts`, inside the `// Templates` section
  after `listTemplates()`, add:

```typescript
  /**
   * Deletes a message template at Meta by name.
   * `DELETE /{wabaId}/message_templates?name=...`
   * Returns the parsed response (`{ success: true }` on success).
   */
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  async deleteTemplate(name: string): Promise<Record<string, any>> {
    const url = `${this.baseUrl}/${this.config.wabaId}/message_templates?name=${encodeURIComponent(name)}`;
    const response = await fetch(url, {
      method: "DELETE",
      headers: { Authorization: `Bearer ${this.config.accessToken}` },
    });
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const json: any = await response.json();
    if (!response.ok) {
      throw new Error(
        `Meta deleteTemplate failed: ${json?.error?.message ?? response.statusText}`,
      );
    }
    return json as Record<string, unknown>;
  }
```

- [ ] **Step 2: Update the class doc comment** — in the class JSDoc, extend the
  network-methods list to include `deleteTemplate`:

```typescript
 * Network-facing methods (sendMessage, getMediaUrl, downloadMedia,
 * submitTemplate, listTemplates, deleteTemplate) use the global fetch and are
 * exercised as integration points — they are not unit-tested here.
```

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

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

- [ ] **Step 4: Commit**

```bash
git add packages/shared/src/meta/meta-client.ts
git commit -m "feat: add MetaClient.deleteTemplate"
```

---

## Task 6: Templates DTOs

**Files:**
- Create: `apps/api/src/templates/dto.ts`

Zod schemas for every templates body/query (FR-04.1, .3, .4, .10). No tests of
their own — they are exercised by the controller tests in Task 8.

- [ ] **Step 1: Implement `apps/api/src/templates/dto.ts`:**

```typescript
import { z } from "zod";
import { templateComponentsSchema, templateVariablesSchema } from "@whatapp/shared";

/**
 * Lowercase snake_case, the format Meta requires for template names (FR-04.4).
 * The UI also enforces this; the DTO is the server-side guard.
 */
const templateNameSchema = z
  .string()
  .min(1)
  .max(512)
  .regex(/^[a-z0-9_]+$/, "name must be lowercase snake_case (a-z, 0-9, _)");

/** Query params for `GET /api/templates` (FR-04.10). */
export const listTemplatesSchema = z.object({
  status: z
    .enum(["DRAFT", "PENDING", "APPROVED", "REJECTED", "PAUSED", "DISABLED"])
    .optional(),
  category: z.enum(["MARKETING", "UTILITY", "AUTHENTICATION"]).optional(),
});
export type ListTemplatesDto = z.infer<typeof listTemplatesSchema>;

/** Body of `POST /api/templates` — create a DRAFT (FR-04.1, FR-04.2). */
export const createTemplateSchema = z.object({
  name: templateNameSchema,
  language: z.string().min(2).max(15),
  category: z.enum(["MARKETING", "UTILITY", "AUTHENTICATION"]),
  components: templateComponentsSchema,
  variables: templateVariablesSchema.default([]),
});
export type CreateTemplateDto = z.infer<typeof createTemplateSchema>;

/**
 * Body of `PATCH /api/templates/:id` — edit a DRAFT/REJECTED template
 * (FR-04.3). `name`/`language`/`category` may be re-set while editing; the
 * service enforces the immutability rule by status.
 */
export const patchTemplateSchema = z
  .object({
    name: templateNameSchema.optional(),
    language: z.string().min(2).max(15).optional(),
    category: z.enum(["MARKETING", "UTILITY", "AUTHENTICATION"]).optional(),
    components: templateComponentsSchema.optional(),
    variables: templateVariablesSchema.optional(),
  })
  .strict();
export type PatchTemplateDto = z.infer<typeof patchTemplateSchema>;
```

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

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

- [ ] **Step 3: Commit**

```bash
git add apps/api/src/templates/dto.ts
git commit -m "feat: add Templates module Zod DTOs"
```

---

## Task 7: TemplatesService — list, get, create, patch, delete

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

Covers FR-04.1 (create DRAFT), FR-04.3 (immutability: only DRAFT/REJECTED
editable), FR-04.4 (unique name+language → 409), FR-04.9 (delete; 409 when a
non-draft campaign references it), FR-04.10 (list + filters + usage count),
FR-04.11 (detail + rendered preview). AC-04.1, AC-04.4, AC-04.7. Delete also
calls Meta — that part is in `TemplateSubmitService` (Task 8 wires the
controller's `DELETE` through `TemplatesService.delete`, which is given the
`MetaClient` via the submit service); to keep this service pure of HTTP, the
**Meta delete call is delegated to a `metaDeleteFn` callback** passed in by the
controller, so this service stays unit-testable with a `vi.fn()`.

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

```typescript
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
  ConflictException,
  NotFoundException,
  BadRequestException,
} from "@nestjs/common";
import { TemplatesService } from "./templates.service";

function makePrisma() {
  return {
    template: {
      findMany: vi.fn(),
      findUnique: vi.fn(),
      findFirst: vi.fn(),
      create: vi.fn(),
      update: vi.fn(),
      delete: vi.fn(),
    },
    campaign: { count: vi.fn().mockResolvedValue(0) },
  };
}

const sampleComponents = {
  body: { text: "Hi {{1}}, welcome." },
};
const sampleVariables = [{ index: 1, label: "Name", sample: "Ahmed" }];

describe("TemplatesService", () => {
  let prisma: ReturnType<typeof makePrisma>;
  let service: TemplatesService;

  beforeEach(() => {
    prisma = makePrisma();
    service = new TemplatesService(prisma as never);
  });

  describe("list (FR-04.10)", () => {
    it("filters by status and category", async () => {
      prisma.template.findMany.mockResolvedValue([]);
      await service.list({ status: "APPROVED", category: "MARKETING" });
      const where = prisma.template.findMany.mock.calls[0]?.[0].where;
      expect(where).toEqual({ status: "APPROVED", category: "MARKETING" });
    });

    it("attaches a usage count (campaigns referencing the template)", async () => {
      prisma.template.findMany.mockResolvedValue([{ id: "t1" }]);
      prisma.campaign.count.mockResolvedValue(3);
      const out = await service.list({});
      expect(out[0]).toMatchObject({ id: "t1", usageCount: 3 });
      expect(prisma.campaign.count.mock.calls[0]?.[0]).toEqual({
        where: { templateId: "t1" },
      });
    });
  });

  describe("get (FR-04.11)", () => {
    it("returns the template with a rendered preview and usage count", async () => {
      prisma.template.findUnique.mockResolvedValue({
        id: "t1",
        name: "welcome",
        components: sampleComponents,
        variables: sampleVariables,
      });
      prisma.campaign.count.mockResolvedValue(2);
      const out = await service.get("t1");
      expect(out.id).toBe("t1");
      expect(out.usageCount).toBe(2);
      // {{1}} replaced by the sample value "Ahmed".
      expect(out.preview.body).toBe("Hi Ahmed, welcome.");
    });

    it("throws NotFound for an unknown template", async () => {
      prisma.template.findUnique.mockResolvedValue(null);
      await expect(service.get("missing")).rejects.toBeInstanceOf(
        NotFoundException,
      );
    });
  });

  describe("create (FR-04.1, FR-04.4, AC-04.1)", () => {
    it("creates a DRAFT template with components and variables", async () => {
      prisma.template.findFirst.mockResolvedValue(null);
      prisma.template.create.mockResolvedValue({ id: "t1" });
      await service.create(
        {
          name: "welcome",
          language: "en",
          category: "MARKETING",
          components: sampleComponents,
          variables: sampleVariables,
        },
        "user-1",
      );
      const data = prisma.template.create.mock.calls[0]?.[0].data;
      expect(data.status).toBe("DRAFT");
      expect(data.name).toBe("welcome");
      expect(data.createdBy).toBe("user-1");
      expect(data.variables).toEqual(sampleVariables);
    });

    it("throws Conflict on a duplicate name+language (FR-04.4)", async () => {
      prisma.template.findFirst.mockResolvedValue({ id: "existing" });
      await expect(
        service.create(
          {
            name: "welcome",
            language: "en",
            category: "MARKETING",
            components: sampleComponents,
            variables: sampleVariables,
          },
          "user-1",
        ),
      ).rejects.toBeInstanceOf(ConflictException);
      expect(prisma.template.create).not.toHaveBeenCalled();
    });
  });

  describe("patch immutability (FR-04.3, AC-04.4)", () => {
    it("edits a DRAFT template", async () => {
      prisma.template.findUnique.mockResolvedValue({
        id: "t1",
        status: "DRAFT",
        name: "welcome",
        language: "en",
      });
      prisma.template.findFirst.mockResolvedValue(null);
      prisma.template.update.mockResolvedValue({ id: "t1" });
      await service.patch("t1", { components: sampleComponents });
      expect(prisma.template.update).toHaveBeenCalledOnce();
    });

    it("edits a REJECTED template", async () => {
      prisma.template.findUnique.mockResolvedValue({
        id: "t1",
        status: "REJECTED",
        name: "welcome",
        language: "en",
      });
      prisma.template.findFirst.mockResolvedValue(null);
      prisma.template.update.mockResolvedValue({ id: "t1" });
      await service.patch("t1", { components: sampleComponents });
      expect(prisma.template.update).toHaveBeenCalledOnce();
    });

    it("rejects editing an APPROVED template (AC-04.4)", async () => {
      prisma.template.findUnique.mockResolvedValue({
        id: "t1",
        status: "APPROVED",
      });
      await expect(
        service.patch("t1", { components: sampleComponents }),
      ).rejects.toBeInstanceOf(BadRequestException);
      expect(prisma.template.update).not.toHaveBeenCalled();
    });

    it("rejects editing a PENDING template", async () => {
      prisma.template.findUnique.mockResolvedValue({
        id: "t1",
        status: "PENDING",
      });
      await expect(
        service.patch("t1", { components: sampleComponents }),
      ).rejects.toBeInstanceOf(BadRequestException);
    });

    it("throws Conflict if a rename collides with another name+language", async () => {
      prisma.template.findUnique.mockResolvedValue({
        id: "t1",
        status: "DRAFT",
        name: "welcome",
        language: "en",
      });
      prisma.template.findFirst.mockResolvedValue({ id: "other" });
      await expect(
        service.patch("t1", { name: "taken" }),
      ).rejects.toBeInstanceOf(ConflictException);
    });

    it("throws NotFound for an unknown template", async () => {
      prisma.template.findUnique.mockResolvedValue(null);
      await expect(
        service.patch("missing", { components: sampleComponents }),
      ).rejects.toBeInstanceOf(NotFoundException);
    });
  });

  describe("delete (FR-04.9, AC-04.7)", () => {
    it("deletes at Meta then locally when not in use", async () => {
      prisma.template.findUnique.mockResolvedValue({
        id: "t1",
        name: "welcome",
      });
      prisma.campaign.count.mockResolvedValue(0);
      prisma.template.delete.mockResolvedValue({ id: "t1" });
      const metaDeleteFn = vi.fn().mockResolvedValue(undefined);
      await service.delete("t1", metaDeleteFn);
      expect(metaDeleteFn).toHaveBeenCalledWith("welcome");
      expect(prisma.template.delete).toHaveBeenCalledWith({
        where: { id: "t1" },
      });
    });

    it("returns 409 when a non-draft campaign references it (AC-04.7)", async () => {
      prisma.template.findUnique.mockResolvedValue({
        id: "t1",
        name: "welcome",
      });
      prisma.campaign.count.mockResolvedValue(1);
      const metaDeleteFn = vi.fn();
      await expect(
        service.delete("t1", metaDeleteFn),
      ).rejects.toBeInstanceOf(ConflictException);
      // The usage check counts only non-draft campaigns.
      expect(prisma.campaign.count.mock.calls[0]?.[0].where).toEqual({
        templateId: "t1",
        status: { not: "draft" },
      });
      expect(metaDeleteFn).not.toHaveBeenCalled();
      expect(prisma.template.delete).not.toHaveBeenCalled();
    });

    it("throws NotFound for an unknown template", async () => {
      prisma.template.findUnique.mockResolvedValue(null);
      await expect(
        service.delete("missing", vi.fn()),
      ).rejects.toBeInstanceOf(NotFoundException);
    });
  });
});
```

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

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

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

```typescript
import {
  Injectable,
  ConflictException,
  NotFoundException,
  BadRequestException,
} from "@nestjs/common";
import type {
  TemplateComponents,
  TemplateVariable,
} from "@whatapp/shared";
import { PrismaService } from "../prisma/prisma.service";
import type { CreateTemplateDto, ListTemplatesDto, PatchTemplateDto } from "./dto";

/** Renders a template's text with its sample values for the preview. */
function renderPreview(
  components: TemplateComponents,
  variables: TemplateVariable[],
): { header: string | null; body: string; footer: string | null } {
  const sub = (text: string): string =>
    text.replace(/\{\{\s*(\d+)\s*\}\}/g, (_m, n: string) => {
      const v = variables.find((x) => x.index === Number(n));
      return v ? v.sample : `{{${n}}}`;
    });
  const header =
    components.header && components.header.format === "TEXT"
      ? sub(components.header.text)
      : null;
  return {
    header,
    body: sub(components.body.text),
    footer: components.footer ? components.footer.text : null,
  };
}

/** Statuses whose content may still be edited (FR-04.3). */
const EDITABLE_STATUSES = new Set(["DRAFT", "REJECTED"]);

/**
 * The Templates directory service (FR-04.1, .3, .4, .9, .10, .11). Owns
 * list/detail/create/edit/delete and the immutability + delete-usage rules.
 * Submission and sync live in `TemplateSubmitService`. All external input is
 * already Zod-validated by the controller.
 */
@Injectable()
export class TemplatesService {
  constructor(private readonly prisma: PrismaService) {}

  /** `GET /api/templates` — filtered list with usage counts (FR-04.10). */
  async list(q: ListTemplatesDto): Promise<Array<Record<string, unknown>>> {
    const where: Record<string, unknown> = {};
    if (q.status) where["status"] = q.status;
    if (q.category) where["category"] = q.category;
    const rows = await this.prisma.template.findMany({
      where,
      orderBy: { updatedAt: "desc" },
    });
    return Promise.all(
      rows.map(async (t) => ({
        ...t,
        usageCount: await this.prisma.campaign.count({
          where: { templateId: t.id },
        }),
      })),
    );
  }

  /** `GET /api/templates/:id` — detail + rendered preview (FR-04.11). */
  async get(id: string): Promise<Record<string, unknown>> {
    const t = await this.prisma.template.findUnique({ where: { id } });
    if (!t) throw new NotFoundException(`Template ${id} not found`);
    const usageCount = await this.prisma.campaign.count({
      where: { templateId: id },
    });
    return {
      ...t,
      usageCount,
      preview: renderPreview(
        t.components as TemplateComponents,
        (t.variables as TemplateVariable[]) ?? [],
      ),
    };
  }

  /** `POST /api/templates` — create a DRAFT (FR-04.1, FR-04.4). */
  async create(
    dto: CreateTemplateDto,
    createdBy: string | undefined,
  ): Promise<Record<string, unknown>> {
    const dup = await this.prisma.template.findFirst({
      where: { name: dto.name, language: dto.language },
    });
    if (dup) {
      throw new ConflictException(
        `A template named "${dto.name}" already exists for language "${dto.language}"`,
      );
    }
    return this.prisma.template.create({
      data: {
        name: dto.name,
        language: dto.language,
        category: dto.category,
        components: dto.components as object,
        variables: dto.variables as object,
        status: "DRAFT",
        createdBy: createdBy ?? null,
      },
    });
  }

  /** `PATCH /api/templates/:id` — edit a DRAFT/REJECTED template (FR-04.3). */
  async patch(
    id: string,
    dto: PatchTemplateDto,
  ): Promise<Record<string, unknown>> {
    const t = await this.prisma.template.findUnique({ where: { id } });
    if (!t) throw new NotFoundException(`Template ${id} not found`);
    if (!EDITABLE_STATUSES.has(t.status)) {
      throw new BadRequestException(
        `Template ${id} is ${t.status} and cannot be edited — create a new template instead`,
      );
    }
    // A rename must not collide with another template's name+language.
    const newName = dto.name ?? t.name;
    const newLanguage = dto.language ?? t.language;
    if (newName !== t.name || newLanguage !== t.language) {
      const dup = await this.prisma.template.findFirst({
        where: { name: newName, language: newLanguage, id: { not: id } },
      });
      if (dup) {
        throw new ConflictException(
          `A template named "${newName}" already exists for language "${newLanguage}"`,
        );
      }
    }
    const data: Record<string, unknown> = {};
    if (dto.name !== undefined) data["name"] = dto.name;
    if (dto.language !== undefined) data["language"] = dto.language;
    if (dto.category !== undefined) data["category"] = dto.category;
    if (dto.components !== undefined) data["components"] = dto.components as object;
    if (dto.variables !== undefined) data["variables"] = dto.variables as object;
    return this.prisma.template.update({ where: { id }, data });
  }

  /**
   * `DELETE /api/templates/:id` — delete at Meta then locally (FR-04.9). A
   * template referenced by a **non-draft** campaign cannot be deleted (409,
   * AC-04.7). `metaDeleteFn` is supplied by the controller so this service
   * needs no HTTP dependency and stays unit-testable.
   */
  async delete(
    id: string,
    metaDeleteFn: (name: string) => Promise<unknown>,
  ): Promise<{ deleted: true }> {
    const t = await this.prisma.template.findUnique({ where: { id } });
    if (!t) throw new NotFoundException(`Template ${id} not found`);
    const inUse = await this.prisma.campaign.count({
      where: { templateId: id, status: { not: "draft" } },
    });
    if (inUse > 0) {
      throw new ConflictException(
        `Template ${id} is used by ${inUse} non-draft campaign(s) and cannot be deleted`,
      );
    }
    // Delete at Meta first; only remove locally if Meta accepted it.
    await metaDeleteFn(t.name);
    await this.prisma.template.delete({ where: { id } });
    return { deleted: true };
  }
}
```

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

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

- [ ] **Step 5: Commit**

```bash
git add apps/api/src/templates/templates.service.ts apps/api/src/templates/templates.service.test.ts
git commit -m "feat: add TemplatesService (list, detail, create, edit, delete)"
```

---

## Task 8: TemplateSubmitService — validate, submit, sync

**Files:**
- Create: `apps/api/src/templates/template-submit.service.ts`
- Test: `apps/api/src/templates/template-submit.service.test.ts`

Covers FR-04.5 (validate endpoint backing), FR-04.6 (submit to Meta — store
`metaTemplateId`, `status=PENDING`, `submittedAt`), FR-04.8 (sync reconciles
local rows from `MetaClient.listTemplates()`). AC-04.3, AC-04.6. The
`MetaClient` is mocked; `MetaConfigService.getClient()` is mocked to return it
— **no live Meta calls**.

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

```typescript
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NotFoundException, BadRequestException } from "@nestjs/common";
import { TemplateSubmitService } from "./template-submit.service";

const validComponents = { body: { text: "Hi {{1}}." } };
const validVariables = [{ index: 1, label: "Name", sample: "Ahmed" }];
const invalidComponents = { body: { text: "Hi {{1}} {{3}}." } };

function makePrisma() {
  return {
    template: {
      findUnique: vi.fn(),
      findFirst: vi.fn(),
      update: vi.fn(),
    },
  };
}

function makeMetaClient() {
  return {
    submitTemplate: vi.fn(),
    listTemplates: vi.fn(),
    deleteTemplate: vi.fn(),
  };
}

describe("TemplateSubmitService", () => {
  let prisma: ReturnType<typeof makePrisma>;
  let meta: ReturnType<typeof makeMetaClient>;
  let metaConfig: { getClient: ReturnType<typeof vi.fn> };
  let service: TemplateSubmitService;

  beforeEach(() => {
    prisma = makePrisma();
    meta = makeMetaClient();
    metaConfig = { getClient: vi.fn().mockResolvedValue(meta) };
    service = new TemplateSubmitService(
      prisma as never,
      metaConfig as never,
    );
  });

  describe("validate (FR-04.5)", () => {
    it("returns valid for a well-formed template", async () => {
      prisma.template.findUnique.mockResolvedValue({
        id: "t1",
        components: validComponents,
        variables: validVariables,
      });
      const result = await service.validate("t1");
      expect(result).toEqual({ valid: true, problems: [] });
    });

    it("returns problems for an invalid template", async () => {
      prisma.template.findUnique.mockResolvedValue({
        id: "t1",
        components: invalidComponents,
        variables: [],
      });
      const result = await service.validate("t1");
      expect(result.valid).toBe(false);
      expect(result.problems.length).toBeGreaterThan(0);
    });

    it("throws NotFound for an unknown template", async () => {
      prisma.template.findUnique.mockResolvedValue(null);
      await expect(service.validate("missing")).rejects.toBeInstanceOf(
        NotFoundException,
      );
    });
  });

  describe("submit (FR-04.6, AC-04.3)", () => {
    it("stores metaTemplateId, status=PENDING and submittedAt on success", async () => {
      prisma.template.findUnique.mockResolvedValue({
        id: "t1",
        name: "welcome",
        language: "en",
        category: "MARKETING",
        status: "DRAFT",
        components: validComponents,
        variables: validVariables,
      });
      meta.submitTemplate.mockResolvedValue({ id: "meta-123" });
      prisma.template.update.mockResolvedValue({ id: "t1" });
      await service.submit("t1");
      const data = prisma.template.update.mock.calls[0]?.[0].data;
      expect(data.metaTemplateId).toBe("meta-123");
      expect(data.status).toBe("PENDING");
      expect(data.submittedAt).toBeInstanceOf(Date);
    });

    it("passes a correct Meta payload to submitTemplate", async () => {
      prisma.template.findUnique.mockResolvedValue({
        id: "t1",
        name: "welcome",
        language: "en",
        category: "MARKETING",
        status: "DRAFT",
        components: validComponents,
        variables: validVariables,
      });
      meta.submitTemplate.mockResolvedValue({ id: "meta-123" });
      prisma.template.update.mockResolvedValue({ id: "t1" });
      await service.submit("t1");
      const payload = meta.submitTemplate.mock.calls[0]?.[0];
      expect(payload.name).toBe("welcome");
      expect(payload.language).toBe("en");
      expect(payload.category).toBe("MARKETING");
      expect(payload.components).toContainEqual(
        expect.objectContaining({ type: "BODY" }),
      );
    });

    it("refuses to submit an invalid template and does not call Meta", async () => {
      prisma.template.findUnique.mockResolvedValue({
        id: "t1",
        name: "bad",
        language: "en",
        category: "MARKETING",
        status: "DRAFT",
        components: invalidComponents,
        variables: [],
      });
      await expect(service.submit("t1")).rejects.toBeInstanceOf(
        BadRequestException,
      );
      expect(meta.submitTemplate).not.toHaveBeenCalled();
    });

    it("refuses to submit a non-DRAFT/REJECTED template", async () => {
      prisma.template.findUnique.mockResolvedValue({
        id: "t1",
        status: "APPROVED",
        components: validComponents,
        variables: validVariables,
      });
      await expect(service.submit("t1")).rejects.toBeInstanceOf(
        BadRequestException,
      );
      expect(meta.submitTemplate).not.toHaveBeenCalled();
    });

    it("throws NotFound for an unknown template", async () => {
      prisma.template.findUnique.mockResolvedValue(null);
      await expect(service.submit("missing")).rejects.toBeInstanceOf(
        NotFoundException,
      );
    });
  });

  describe("sync (FR-04.8, AC-04.6)", () => {
    it("reconciles a local template whose webhook was missed", async () => {
      meta.listTemplates.mockResolvedValue([
        {
          id: "meta-123",
          name: "welcome",
          language: "en",
          status: "APPROVED",
          quality_score: { score: "GREEN" },
        },
      ]);
      prisma.template.findFirst.mockResolvedValue({
        id: "t1",
        name: "welcome",
        language: "en",
        status: "PENDING",
        metaTemplateId: null,
      });
      prisma.template.update.mockResolvedValue({ id: "t1" });
      const result = await service.sync();
      const data = prisma.template.update.mock.calls[0]?.[0].data;
      expect(data.status).toBe("APPROVED");
      expect(data.metaTemplateId).toBe("meta-123");
      expect(data.qualityScore).toBe("GREEN");
      expect(result.reconciled).toBe(1);
    });

    it("does not update a template already in sync", async () => {
      meta.listTemplates.mockResolvedValue([
        { id: "meta-123", name: "welcome", language: "en", status: "APPROVED" },
      ]);
      prisma.template.findFirst.mockResolvedValue({
        id: "t1",
        name: "welcome",
        language: "en",
        status: "APPROVED",
        metaTemplateId: "meta-123",
        qualityScore: null,
      });
      const result = await service.sync();
      expect(prisma.template.update).not.toHaveBeenCalled();
      expect(result.reconciled).toBe(0);
    });

    it("skips a Meta template with no matching local row", async () => {
      meta.listTemplates.mockResolvedValue([
        { id: "meta-x", name: "unknown", language: "en", status: "APPROVED" },
      ]);
      prisma.template.findFirst.mockResolvedValue(null);
      const result = await service.sync();
      expect(prisma.template.update).not.toHaveBeenCalled();
      expect(result.reconciled).toBe(0);
    });
  });
});
```

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

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

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

```typescript
import {
  Injectable,
  NotFoundException,
  BadRequestException,
  Logger,
} from "@nestjs/common";
import {
  validateTemplate,
  buildMetaTemplatePayload,
  type TemplateComponents,
  type TemplateVariable,
  type TemplateValidationResult,
} from "@whatapp/shared";
import { PrismaService } from "../prisma/prisma.service";
import { MetaConfigService } from "../meta/meta-config.service";

/** Maps a Meta template `status` string to a local TemplateStatus. */
const META_STATUS_MAP: Record<string, string> = {
  APPROVED: "APPROVED",
  REJECTED: "REJECTED",
  PENDING: "PENDING",
  PAUSED: "PAUSED",
  DISABLED: "DISABLED",
  FLAGGED: "PAUSED",
};

/** Statuses from which a template may be (re-)submitted. */
const SUBMITTABLE_STATUSES = new Set(["DRAFT", "REJECTED"]);

/**
 * Handles template validation, Meta submission and status sync (FR-04.5, .6,
 * .8). The webhook handler (Spec 01) is the primary path for status updates;
 * `sync` is the manual + scheduled fallback when a webhook is missed.
 */
@Injectable()
export class TemplateSubmitService {
  private readonly logger = new Logger(TemplateSubmitService.name);

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

  /** `POST /api/templates/:id/validate` — pre-submission check (FR-04.5). */
  async validate(id: string): Promise<TemplateValidationResult> {
    const t = await this.prisma.template.findUnique({ where: { id } });
    if (!t) throw new NotFoundException(`Template ${id} not found`);
    return validateTemplate(
      t.components as TemplateComponents,
      (t.variables as TemplateVariable[]) ?? [],
    );
  }

  /** `POST /api/templates/:id/submit` — validate then submit to Meta (FR-04.6). */
  async submit(id: string): Promise<Record<string, unknown>> {
    const t = await this.prisma.template.findUnique({ where: { id } });
    if (!t) throw new NotFoundException(`Template ${id} not found`);
    if (!SUBMITTABLE_STATUSES.has(t.status)) {
      throw new BadRequestException(
        `Template ${id} is ${t.status} and cannot be submitted`,
      );
    }
    const components = t.components as TemplateComponents;
    const variables = (t.variables as TemplateVariable[]) ?? [];
    const validation = validateTemplate(components, variables);
    if (!validation.valid) {
      throw new BadRequestException({
        message: "Template failed validation",
        problems: validation.problems,
      });
    }
    const payload = buildMetaTemplatePayload({
      name: t.name,
      language: t.language,
      category: t.category as "MARKETING" | "UTILITY" | "AUTHENTICATION",
      components,
      variables,
    });
    const client = await this.metaConfig.getClient();
    const result = await client.submitTemplate(payload);
    return this.prisma.template.update({
      where: { id },
      data: {
        metaTemplateId: (result["id"] as string | undefined) ?? null,
        status: "PENDING",
        submittedAt: new Date(),
        rejectionReason: null,
      },
    });
  }

  /**
   * `POST /api/templates/sync` — reconcile local rows from Meta (FR-04.8). For
   * each Meta template, find the local row by name+language and update its
   * `status`, `qualityScore` and `metaTemplateId` when they differ.
   */
  async sync(): Promise<{ reconciled: number }> {
    const client = await this.metaConfig.getClient();
    const remote = await client.listTemplates();
    let reconciled = 0;
    for (const r of remote) {
      const name = r["name"] as string | undefined;
      const language = r["language"] as string | undefined;
      if (!name || !language) continue;
      const local = await this.prisma.template.findFirst({
        where: { name, language },
      });
      if (!local) continue;

      const remoteStatus =
        META_STATUS_MAP[String(r["status"] ?? "").toUpperCase()];
      const remoteQuality = this.extractQuality(r["quality_score"]);
      const remoteMetaId = (r["id"] as string | undefined) ?? null;

      const data: Record<string, unknown> = {};
      if (remoteStatus && remoteStatus !== local.status) {
        data["status"] = remoteStatus;
      }
      if (remoteQuality && remoteQuality !== local.qualityScore) {
        data["qualityScore"] = remoteQuality;
      }
      if (remoteMetaId && remoteMetaId !== local.metaTemplateId) {
        data["metaTemplateId"] = remoteMetaId;
      }
      if (Object.keys(data).length > 0) {
        await this.prisma.template.update({ where: { id: local.id }, data });
        reconciled++;
      }
    }
    this.logger.log(`Template sync reconciled ${reconciled} template(s)`);
    return { reconciled };
  }

  /** Meta returns quality as a string or `{ score }` object — normalise it. */
  private extractQuality(raw: unknown): string | null {
    if (!raw) return null;
    if (typeof raw === "string") return raw;
    if (typeof raw === "object" && "score" in raw) {
      return String((raw as { score: unknown }).score);
    }
    return null;
  }
}
```

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

Run: `pnpm --filter @whatapp/api test template-submit.service`
Expected: PASS (all tests).

- [ ] **Step 5: Commit**

```bash
git add apps/api/src/templates/template-submit.service.ts apps/api/src/templates/template-submit.service.test.ts
git commit -m "feat: add TemplateSubmitService (validate, submit, sync)"
```

---

## Task 9: TemplatesController & module wiring

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

Wires the eight routes in the spec's API surface. Reads are open to any
authenticated role; writes require `marketing`/`admin` (matching the Contacts
controller pattern). The controller obtains the `MetaClient` for delete and
passes `client.deleteTemplate.bind(client)` as the `metaDeleteFn` callback to
`TemplatesService.delete`.

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

```typescript
import { describe, it, expect, vi, beforeEach } from "vitest";
import { TemplatesController } from "./templates.controller";

function makeTemplatesService() {
  return {
    list: vi.fn().mockResolvedValue([]),
    get: vi.fn().mockResolvedValue({ id: "t1" }),
    create: vi.fn().mockResolvedValue({ id: "t1" }),
    patch: vi.fn().mockResolvedValue({ id: "t1" }),
    delete: vi.fn().mockResolvedValue({ deleted: true }),
  };
}

function makeSubmitService() {
  return {
    validate: vi.fn().mockResolvedValue({ valid: true, problems: [] }),
    submit: vi.fn().mockResolvedValue({ id: "t1", status: "PENDING" }),
    sync: vi.fn().mockResolvedValue({ reconciled: 0 }),
  };
}

function makeMetaConfig() {
  const client = { deleteTemplate: vi.fn().mockResolvedValue(undefined) };
  return {
    client,
    getClient: vi.fn().mockResolvedValue(client),
  };
}

describe("TemplatesController", () => {
  let templates: ReturnType<typeof makeTemplatesService>;
  let submit: ReturnType<typeof makeSubmitService>;
  let metaConfig: ReturnType<typeof makeMetaConfig>;
  let controller: TemplatesController;

  beforeEach(() => {
    templates = makeTemplatesService();
    submit = makeSubmitService();
    metaConfig = makeMetaConfig();
    controller = new TemplatesController(
      templates as never,
      submit as never,
      metaConfig as never,
    );
  });

  it("GET /templates parses query filters (FR-04.10)", async () => {
    await controller.list({ status: "APPROVED", category: "MARKETING" });
    expect(templates.list).toHaveBeenCalledWith({
      status: "APPROVED",
      category: "MARKETING",
    });
  });

  it("GET /templates rejects an invalid status filter", async () => {
    await expect(controller.list({ status: "BOGUS" })).rejects.toThrow();
  });

  it("GET /templates/:id returns the detail (FR-04.11)", async () => {
    const out = await controller.get("t1");
    expect(out).toEqual({ id: "t1" });
    expect(templates.get).toHaveBeenCalledWith("t1");
  });

  it("POST /templates passes the user id and a validated body (FR-04.1)", async () => {
    await controller.create(
      {
        name: "welcome",
        language: "en",
        category: "MARKETING",
        components: { body: { text: "Hi {{1}}." } },
        variables: [{ index: 1, label: "Name", sample: "Ahmed" }],
      },
      { user: { id: "user-1" } },
    );
    expect(templates.create).toHaveBeenCalledWith(
      expect.objectContaining({ name: "welcome" }),
      "user-1",
    );
  });

  it("POST /templates rejects a non-snake_case name (FR-04.4)", async () => {
    await expect(
      controller.create(
        {
          name: "Welcome Template",
          language: "en",
          category: "MARKETING",
          components: { body: { text: "ok" } },
          variables: [],
        },
        { user: { id: "user-1" } },
      ),
    ).rejects.toThrow();
  });

  it("PATCH /templates/:id parses the body (FR-04.3)", async () => {
    await controller.patch("t1", {
      components: { body: { text: "Updated." } },
    });
    expect(templates.patch).toHaveBeenCalledWith("t1", {
      components: { body: { text: "Updated." } },
    });
  });

  it("POST /templates/:id/validate returns the validation result (FR-04.5)", async () => {
    const out = await controller.validate("t1");
    expect(out).toEqual({ valid: true, problems: [] });
    expect(submit.validate).toHaveBeenCalledWith("t1");
  });

  it("POST /templates/:id/submit submits to Meta (FR-04.6)", async () => {
    const out = await controller.submit("t1");
    expect(out).toMatchObject({ status: "PENDING" });
    expect(submit.submit).toHaveBeenCalledWith("t1");
  });

  it("POST /templates/sync reconciles from Meta (FR-04.8)", async () => {
    const out = await controller.sync();
    expect(out).toEqual({ reconciled: 0 });
    expect(submit.sync).toHaveBeenCalledOnce();
  });

  it("DELETE /templates/:id passes a Meta delete callback (FR-04.9)", async () => {
    await controller.remove("t1");
    expect(metaConfig.getClient).toHaveBeenCalledOnce();
    // delete is called with the id and a callable Meta-delete function.
    expect(templates.delete).toHaveBeenCalledWith("t1", expect.any(Function));
    // invoking the passed callback hits the Meta client.
    const cb = templates.delete.mock.calls[0]?.[1] as (n: string) => unknown;
    await cb("welcome");
    expect(metaConfig.client.deleteTemplate).toHaveBeenCalledWith("welcome");
  });
});
```

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

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

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

```typescript
import {
  Controller,
  Get,
  Post,
  Patch,
  Delete,
  Body,
  Param,
  Query,
  Req,
  HttpCode,
  HttpStatus,
} from "@nestjs/common";
import { Roles } from "../auth/roles.decorator";
import { MetaConfigService } from "../meta/meta-config.service";
import { TemplatesService } from "./templates.service";
import { TemplateSubmitService } from "./template-submit.service";
import {
  listTemplatesSchema,
  createTemplateSchema,
  patchTemplateSchema,
} from "./dto";

/** A request carrying the JWT payload set by the global JwtAuthGuard. */
interface AuthedRequest {
  user?: { id?: string };
}

/**
 * Templates HTTP surface (FR-04.1, .3, .5, .6, .8, .9, .10, .11). Reads are
 * open to any authenticated role; writes require `marketing` or `admin`.
 */
@Controller("templates")
export class TemplatesController {
  constructor(
    private readonly templates: TemplatesService,
    private readonly submitService: TemplateSubmitService,
    private readonly metaConfig: MetaConfigService,
  ) {}

  /** `GET /api/templates` (FR-04.10). */
  @Get()
  async list(@Query() query: unknown): Promise<unknown> {
    return this.templates.list(listTemplatesSchema.parse(query));
  }

  /** `POST /api/templates/sync` (FR-04.8). Declared before `:id` routes. */
  @Post("sync")
  @Roles("admin", "marketing")
  @HttpCode(HttpStatus.OK)
  async sync(): Promise<unknown> {
    return this.submitService.sync();
  }

  /** `GET /api/templates/:id` (FR-04.11). */
  @Get(":id")
  async get(@Param("id") id: string): Promise<unknown> {
    return this.templates.get(id);
  }

  /** `POST /api/templates` (FR-04.1). */
  @Post()
  @Roles("admin", "marketing")
  async create(
    @Body() body: unknown,
    @Req() req: AuthedRequest,
  ): Promise<unknown> {
    return this.templates.create(
      createTemplateSchema.parse(body),
      req.user?.id,
    );
  }

  /** `PATCH /api/templates/:id` (FR-04.3). */
  @Patch(":id")
  @Roles("admin", "marketing")
  async patch(
    @Param("id") id: string,
    @Body() body: unknown,
  ): Promise<unknown> {
    return this.templates.patch(id, patchTemplateSchema.parse(body));
  }

  /** `POST /api/templates/:id/validate` (FR-04.5). */
  @Post(":id/validate")
  @Roles("admin", "marketing")
  @HttpCode(HttpStatus.OK)
  async validate(@Param("id") id: string): Promise<unknown> {
    return this.submitService.validate(id);
  }

  /** `POST /api/templates/:id/submit` (FR-04.6). */
  @Post(":id/submit")
  @Roles("admin", "marketing")
  @HttpCode(HttpStatus.OK)
  async submit(@Param("id") id: string): Promise<unknown> {
    return this.submitService.submit(id);
  }

  /** `DELETE /api/templates/:id` (FR-04.9). */
  @Delete(":id")
  @Roles("admin", "marketing")
  async remove(@Param("id") id: string): Promise<unknown> {
    const client = await this.metaConfig.getClient();
    return this.templates.delete(id, (name) => client.deleteTemplate(name));
  }
}
```

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

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

- [ ] **Step 5: Implement `apps/api/src/templates/templates.module.ts`:**

```typescript
import { Module } from "@nestjs/common";
import { MetaModule } from "../meta/meta.module";
import { TemplatesService } from "./templates.service";
import { TemplateSubmitService } from "./template-submit.service";
import { TemplatesController } from "./templates.controller";

/**
 * The Templates module: authoring, validation, Meta submission, status sync,
 * and deletion (Spec 04). `MetaModule` provides `MetaConfigService`.
 * `PrismaModule` is global (registered in AppModule) — no import needed.
 * `TemplatesService` is exported so Phase 4 (Campaigns) can read templates.
 */
@Module({
  imports: [MetaModule],
  controllers: [TemplatesController],
  providers: [TemplatesService, TemplateSubmitService],
  exports: [TemplatesService],
})
export class TemplatesModule {}
```

> If `MetaModule` does not export `MetaConfigService`, add it to that module's
> `exports` array (`apps/api/src/meta/meta.module.ts`) — check before relying
> on the import. The webhook/messages modules already consume
> `MetaConfigService`, so it is almost certainly exported; verify and, if not,
> add it and note the change in this task's commit.

- [ ] **Step 6: Register the module** — in `apps/api/src/app.module.ts`, import
  `TemplatesModule` and add it to the `imports` array after `InboxModule`:

```typescript
import { TemplatesModule } from "./templates/templates.module";
```

```typescript
  imports: [
    AppConfigModule,
    PrismaModule,
    AuthModule,
    ConnectionsModule,
    MetaModule,
    QualityModule,
    WebhooksModule,
    MessagesModule,
    ContactsModule,
    InboxModule,
    TemplatesModule,
  ],
```

- [ ] **Step 7: Verify the API builds and all tests pass**

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

- [ ] **Step 8: Commit**

```bash
git add apps/api/src/templates/templates.controller.ts apps/api/src/templates/templates.controller.test.ts apps/api/src/templates/templates.module.ts apps/api/src/app.module.ts apps/api/src/meta/meta.module.ts
git commit -m "feat: add Templates controller, module, and app wiring"
```

---

## Task 10: Web — templates API client

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

Typed fetch functions for the templates API, following the `req` pattern in
`apps/web/src/lib/contacts-api.ts`. No test of its own — exercised by the
screen tests in Tasks 12-14.

- [ ] **Step 1: Implement `apps/web/src/lib/templates-api.ts`:**

```typescript
/**
 * Typed fetch functions for the Templates API. Follows the `req` pattern in
 * `contacts-api.ts` (a thin local wrapper over `fetch` reusing `ApiError`).
 */
import { ApiError, getToken } from "./api.js";

async function req<T>(path: string, options: RequestInit = {}): Promise<T> {
  const token = getToken();
  const headers: Record<string, string> = {
    "Content-Type": "application/json",
    ...(options.headers as Record<string, string> | undefined),
  };
  if (token) headers["Authorization"] = `Bearer ${token}`;
  const res = await fetch(path, { ...options, credentials: "include", headers });
  if (!res.ok) {
    let message = `HTTP ${res.status}`;
    try {
      const body = (await res.json()) as { message?: string };
      if (typeof body.message === "string") message = body.message;
    } catch {
      /* keep status message */
    }
    throw new ApiError(res.status, message);
  }
  if (res.status === 204) return undefined as T;
  return res.json() as Promise<T>;
}

// --- Types (mirror the API DTOs / shared schemas) ---

export type TemplateStatus =
  | "DRAFT"
  | "PENDING"
  | "APPROVED"
  | "REJECTED"
  | "PAUSED"
  | "DISABLED";

export type TemplateCategory = "MARKETING" | "UTILITY" | "AUTHENTICATION";

export interface TemplateVariable {
  index: number;
  label: string;
  sample: string;
}

export interface TemplateButton {
  type: "QUICK_REPLY" | "URL" | "PHONE_NUMBER";
  text: string;
  url?: string;
  phoneNumber?: string;
}

export interface TemplateComponents {
  header?:
    | { format: "TEXT"; text: string }
    | { format: "IMAGE" | "VIDEO" | "DOCUMENT" };
  body: { text: string };
  footer?: { text: string };
  buttons?: TemplateButton[];
}

export interface TemplateRow {
  id: string;
  name: string;
  language: string;
  category: TemplateCategory;
  status: TemplateStatus;
  qualityScore: string | null;
  usageCount: number;
  updatedAt: string;
}

export interface TemplateDetail extends TemplateRow {
  components: TemplateComponents;
  variables: TemplateVariable[];
  metaTemplateId: string | null;
  rejectionReason: string | null;
  submittedAt: string | null;
  approvedAt: string | null;
  createdAt: string;
  preview: { header: string | null; body: string; footer: string | null };
}

export interface ValidationResult {
  valid: boolean;
  problems: string[];
}

export interface TemplateInput {
  name: string;
  language: string;
  category: TemplateCategory;
  components: TemplateComponents;
  variables: TemplateVariable[];
}

// --- Calls ---

function qs(params: Record<string, unknown>): string {
  const sp = new URLSearchParams();
  for (const [k, v] of Object.entries(params)) {
    if (v !== undefined && v !== null && v !== "") sp.set(k, String(v));
  }
  const s = sp.toString();
  return s ? `?${s}` : "";
}

export function listTemplates(
  params: { status?: string; category?: string } = {},
): Promise<TemplateRow[]> {
  return req<TemplateRow[]>(`/api/templates${qs(params)}`);
}

export function getTemplate(id: string): Promise<TemplateDetail> {
  return req<TemplateDetail>(`/api/templates/${id}`);
}

export function createTemplate(body: TemplateInput): Promise<TemplateDetail> {
  return req<TemplateDetail>("/api/templates", {
    method: "POST",
    body: JSON.stringify(body),
  });
}

export function patchTemplate(
  id: string,
  body: Partial<TemplateInput>,
): Promise<TemplateDetail> {
  return req<TemplateDetail>(`/api/templates/${id}`, {
    method: "PATCH",
    body: JSON.stringify(body),
  });
}

export function validateTemplate(id: string): Promise<ValidationResult> {
  return req<ValidationResult>(`/api/templates/${id}/validate`, {
    method: "POST",
  });
}

export function submitTemplate(id: string): Promise<TemplateDetail> {
  return req<TemplateDetail>(`/api/templates/${id}/submit`, {
    method: "POST",
  });
}

export function syncTemplates(): Promise<{ reconciled: number }> {
  return req<{ reconciled: number }>("/api/templates/sync", {
    method: "POST",
  });
}

export function deleteTemplate(id: string): Promise<{ deleted: true }> {
  return req<{ deleted: true }>(`/api/templates/${id}`, {
    method: "DELETE",
  });
}
```

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

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

- [ ] **Step 3: Commit**

```bash
git add apps/web/src/lib/templates-api.ts
git commit -m "feat: add web templates API client"
```

---

## Task 11: Web — status badge & template preview components

**Files:**
- Create: `apps/web/src/components/templates/StatusBadge.tsx`
- Create: `apps/web/src/components/templates/TemplatePreview.tsx`
- Test: `apps/web/src/components/templates/StatusBadge.test.tsx`
- Test: `apps/web/src/components/templates/TemplatePreview.test.tsx`

Two presentational pieces reused across the screens. `StatusBadge` shows a
template status (and optional quality). `TemplatePreview` renders a
WhatsApp-style bubble with `{{N}}` substituted by sample values — used by the
editor's live-preview pane (FR-04 UI) and the detail screen (FR-04.11).

- [ ] **Step 1: Write the failing test** — `apps/web/src/components/templates/StatusBadge.test.tsx`:

```typescript
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import StatusBadge from "./StatusBadge.js";

describe("StatusBadge", () => {
  it("renders the status text", () => {
    render(<StatusBadge status="APPROVED" />);
    expect(screen.getByText("APPROVED")).toBeInTheDocument();
  });

  it("renders the quality score when provided", () => {
    render(<StatusBadge status="APPROVED" quality="GREEN" />);
    expect(screen.getByText(/GREEN/i)).toBeInTheDocument();
  });

  it("omits quality when not provided", () => {
    render(<StatusBadge status="DRAFT" />);
    expect(screen.queryByText(/GREEN|YELLOW|RED/i)).not.toBeInTheDocument();
  });
});
```

- [ ] **Step 2: Write the failing test** — `apps/web/src/components/templates/TemplatePreview.test.tsx`:

```typescript
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import TemplatePreview from "./TemplatePreview.js";
import type { TemplateComponents, TemplateVariable } from "../../lib/templates-api.js";

const components: TemplateComponents = {
  header: { format: "TEXT", text: "Hello {{1}}" },
  body: { text: "Your viewing of {{2}} is confirmed." },
  footer: { text: "Silver Oak Properties" },
  buttons: [{ type: "QUICK_REPLY", text: "Thanks" }],
};
const variables: TemplateVariable[] = [
  { index: 1, label: "Name", sample: "Ahmed" },
  { index: 2, label: "Property", sample: "Marina Villa" },
];

describe("TemplatePreview", () => {
  it("substitutes {{N}} with sample values in header and body", () => {
    render(<TemplatePreview components={components} variables={variables} />);
    expect(screen.getByText("Hello Ahmed")).toBeInTheDocument();
    expect(
      screen.getByText("Your viewing of Marina Villa is confirmed."),
    ).toBeInTheDocument();
  });

  it("renders the footer and buttons", () => {
    render(<TemplatePreview components={components} variables={variables} />);
    expect(screen.getByText("Silver Oak Properties")).toBeInTheDocument();
    expect(screen.getByText("Thanks")).toBeInTheDocument();
  });

  it("leaves a {{N}} placeholder when no sample exists", () => {
    render(
      <TemplatePreview
        components={{ body: { text: "Hi {{1}}." } }}
        variables={[]}
      />,
    );
    expect(screen.getByText("Hi {{1}}.")).toBeInTheDocument();
  });
});
```

- [ ] **Step 3: Run the tests, verify they fail**

Run: `pnpm --filter @whatapp/web test templates/StatusBadge templates/TemplatePreview`
Expected: FAIL — modules not found.

- [ ] **Step 4: Implement `apps/web/src/components/templates/StatusBadge.tsx`:**

```typescript
import React from "react";
import type { TemplateStatus } from "../../lib/templates-api.js";

const STATUS_COLOR: Record<TemplateStatus, string> = {
  DRAFT: "#6b7280",
  PENDING: "#d97706",
  APPROVED: "#16a34a",
  REJECTED: "#dc2626",
  PAUSED: "#d97706",
  DISABLED: "#dc2626",
};

/** A coloured pill for a template's status, with an optional quality score. */
export default function StatusBadge({
  status,
  quality,
}: {
  status: TemplateStatus;
  quality?: string | null;
}) {
  return (
    <span style={{ display: "inline-flex", gap: 6, alignItems: "center" }}>
      <span
        style={{
          background: STATUS_COLOR[status],
          color: "white",
          borderRadius: 4,
          padding: "2px 8px",
          fontSize: 12,
          fontWeight: 600,
        }}
      >
        {status}
      </span>
      {quality ? (
        <span style={{ fontSize: 12, color: "#6b7280" }}>
          Quality: {quality}
        </span>
      ) : null}
    </span>
  );
}
```

- [ ] **Step 5: Implement `apps/web/src/components/templates/TemplatePreview.tsx`:**

```typescript
import React from "react";
import type {
  TemplateComponents,
  TemplateVariable,
} from "../../lib/templates-api.js";

/** Replaces `{{N}}` with the matching sample value, or leaves the token. */
function render(text: string, variables: TemplateVariable[]): string {
  return text.replace(/\{\{\s*(\d+)\s*\}\}/g, (_m, n: string) => {
    const v = variables.find((x) => x.index === Number(n));
    return v ? v.sample : `{{${n}}}`;
  });
}

/**
 * A WhatsApp-style preview of a template with sample values filled in. Used by
 * the editor's live-preview pane and the template detail screen (FR-04.11).
 */
export default function TemplatePreview({
  components,
  variables,
}: {
  components: TemplateComponents;
  variables: TemplateVariable[];
}) {
  const header =
    components.header && components.header.format === "TEXT"
      ? render(components.header.text, variables)
      : null;
  const mediaHeader =
    components.header && components.header.format !== "TEXT"
      ? components.header.format
      : null;
  return (
    <div
      style={{
        background: "#e5ddd5",
        padding: 16,
        borderRadius: 8,
        maxWidth: 360,
      }}
    >
      <div
        style={{
          background: "white",
          borderRadius: 8,
          padding: 10,
          boxShadow: "0 1px 1px rgba(0,0,0,0.1)",
        }}
      >
        {mediaHeader ? (
          <div
            style={{
              background: "#d1d5db",
              borderRadius: 4,
              padding: "24px 0",
              textAlign: "center",
              color: "#6b7280",
              fontSize: 12,
              marginBottom: 6,
            }}
          >
            [{mediaHeader}]
          </div>
        ) : null}
        {header ? (
          <div style={{ fontWeight: 700, marginBottom: 4 }}>{header}</div>
        ) : null}
        <div style={{ whiteSpace: "pre-wrap" }}>
          {render(components.body.text, variables)}
        </div>
        {components.footer ? (
          <div style={{ color: "#6b7280", fontSize: 12, marginTop: 6 }}>
            {components.footer.text}
          </div>
        ) : null}
      </div>
      {components.buttons && components.buttons.length > 0 ? (
        <div style={{ marginTop: 6, display: "grid", gap: 4 }}>
          {components.buttons.map((b, i) => (
            <div
              key={i}
              style={{
                background: "white",
                borderRadius: 8,
                padding: 8,
                textAlign: "center",
                color: "#1d9bf0",
                fontSize: 14,
              }}
            >
              {b.text}
            </div>
          ))}
        </div>
      ) : null}
    </div>
  );
}
```

- [ ] **Step 6: Run the tests, verify they pass**

Run: `pnpm --filter @whatapp/web test templates/StatusBadge templates/TemplatePreview`
Expected: PASS (6 tests).

- [ ] **Step 7: Commit**

```bash
git add apps/web/src/components/templates/StatusBadge.tsx apps/web/src/components/templates/StatusBadge.test.tsx apps/web/src/components/templates/TemplatePreview.tsx apps/web/src/components/templates/TemplatePreview.test.tsx
git commit -m "feat: add web template status badge and preview components"
```

---

## Task 12: Web — Templates list screen

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

The templates list (FR-04.10): a table with status/quality badges, category,
language, usage count; status/category filters; a "Sync from Meta" button
(FR-04.8); a "New template" link.

- [ ] **Step 1: Write the failing test** — `apps/web/src/routes/Templates.test.tsx`:

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

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

function renderScreen() {
  const qc = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });
  return render(
    <QueryClientProvider client={qc}>
      <MemoryRouter>
        <Templates />
      </MemoryRouter>
    </QueryClientProvider>,
  );
}

describe("Templates list screen", () => {
  beforeEach(() => {
    vi.mocked(templatesApi.listTemplates).mockResolvedValue([
      {
        id: "t1",
        name: "welcome",
        language: "en",
        category: "MARKETING",
        status: "APPROVED",
        qualityScore: "GREEN",
        usageCount: 2,
        updatedAt: "2026-05-20T00:00:00Z",
      },
    ]);
    vi.mocked(templatesApi.syncTemplates).mockResolvedValue({ reconciled: 0 });
  });

  it("lists templates with status and usage (FR-04.10)", async () => {
    renderScreen();
    await waitFor(() =>
      expect(screen.getByText("welcome")).toBeInTheDocument(),
    );
    expect(screen.getByText("APPROVED")).toBeInTheDocument();
    expect(screen.getByText(/2/)).toBeInTheDocument();
  });

  it("shows status and category filter controls (FR-04.10)", async () => {
    renderScreen();
    await waitFor(() => screen.getByText("welcome"));
    expect(screen.getByLabelText(/status/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/category/i)).toBeInTheDocument();
  });

  it("renders a sync button (FR-04.8)", async () => {
    renderScreen();
    await waitFor(() => screen.getByText("welcome"));
    expect(
      screen.getByRole("button", { name: /sync/i }),
    ).toBeInTheDocument();
  });
});
```

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

Run: `pnpm --filter @whatapp/web test routes/Templates`
Expected: FAIL — `./Templates.js` module not found.

- [ ] **Step 3: Implement `apps/web/src/routes/Templates.tsx`:**

```typescript
import React, { useState } from "react";
import { Link } from "react-router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
  listTemplates,
  syncTemplates,
  type TemplateRow,
} from "../lib/templates-api.js";
import StatusBadge from "../components/templates/StatusBadge.js";

const STATUSES = ["DRAFT", "PENDING", "APPROVED", "REJECTED", "PAUSED", "DISABLED"];
const CATEGORIES = ["MARKETING", "UTILITY", "AUTHENTICATION"];

/** Templates list screen (FR-04.10): table, filters, sync, new-template link. */
export default function Templates() {
  const [status, setStatus] = useState("");
  const [category, setCategory] = useState("");
  const qc = useQueryClient();

  const { data: templates = [], isLoading } = useQuery({
    queryKey: ["templates", status, category],
    queryFn: () =>
      listTemplates({
        status: status || undefined,
        category: category || undefined,
      }),
  });

  const sync = useMutation({
    mutationFn: syncTemplates,
    onSuccess: () => qc.invalidateQueries({ queryKey: ["templates"] }),
  });

  return (
    <div>
      <div
        style={{
          display: "flex",
          justifyContent: "space-between",
          alignItems: "center",
        }}
      >
        <h2 style={{ marginTop: 0 }}>Templates</h2>
        <div style={{ display: "flex", gap: 8 }}>
          <button
            onClick={() => sync.mutate()}
            disabled={sync.isPending}
          >
            {sync.isPending ? "Syncing…" : "Sync from Meta"}
          </button>
          <Link to="/templates/new">
            <button>New template</button>
          </Link>
        </div>
      </div>

      <div style={{ display: "flex", gap: 12, margin: "12px 0" }}>
        <label>
          Status{" "}
          <select value={status} onChange={(e) => setStatus(e.target.value)}>
            <option value="">All</option>
            {STATUSES.map((s) => (
              <option key={s} value={s}>
                {s}
              </option>
            ))}
          </select>
        </label>
        <label>
          Category{" "}
          <select
            value={category}
            onChange={(e) => setCategory(e.target.value)}
          >
            <option value="">All</option>
            {CATEGORIES.map((c) => (
              <option key={c} value={c}>
                {c}
              </option>
            ))}
          </select>
        </label>
      </div>

      {sync.isSuccess ? (
        <p style={{ color: "#16a34a" }}>
          Synced — {sync.data.reconciled} template(s) reconciled.
        </p>
      ) : null}

      {isLoading ? (
        <p>Loading…</p>
      ) : templates.length === 0 ? (
        <p style={{ color: "#6b7280" }}>No templates yet.</p>
      ) : (
        <table style={{ width: "100%", borderCollapse: "collapse" }}>
          <thead>
            <tr style={{ textAlign: "left", borderBottom: "1px solid #e5e7eb" }}>
              <th>Name</th>
              <th>Language</th>
              <th>Category</th>
              <th>Status</th>
              <th>Used by</th>
            </tr>
          </thead>
          <tbody>
            {templates.map((t: TemplateRow) => (
              <tr
                key={t.id}
                style={{ borderBottom: "1px solid #f3f4f6" }}
              >
                <td>
                  <Link to={`/templates/${t.id}`}>{t.name}</Link>
                </td>
                <td>{t.language}</td>
                <td>{t.category}</td>
                <td>
                  <StatusBadge status={t.status} quality={t.qualityScore} />
                </td>
                <td>{t.usageCount} campaign(s)</td>
              </tr>
            ))}
          </tbody>
        </table>
      )}
    </div>
  );
}
```

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

Run: `pnpm --filter @whatapp/web test routes/Templates`
Expected: PASS (3 tests).

- [ ] **Step 5: Wire the route** — in `apps/web/src/App.tsx`, replace the
  `/templates` placeholder line:

```typescript
        <Route path="/templates" element={<Placeholder name="Templates" />} />
```

with the real route (and add the import at the top):

```typescript
import Templates from "./routes/Templates.js";
```

```typescript
        <Route path="/templates" element={<Templates />} />
```

(The `/templates/new`, `/templates/:id`, `/templates/:id/edit` routes are added
in Tasks 13 and 14 — leave them for now.)

- [ ] **Step 6: Verify the web app typechecks**

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

- [ ] **Step 7: Commit**

```bash
git add apps/web/src/routes/Templates.tsx apps/web/src/routes/Templates.test.tsx apps/web/src/App.tsx
git commit -m "feat: add web templates list screen"
```

---

## Task 13: Web — Template editor screen

**Files:**
- Create: `apps/web/src/components/templates/TemplateEditorForm.tsx`
- Create: `apps/web/src/routes/TemplateEditor.tsx`
- Test: `apps/web/src/routes/TemplateEditor.test.tsx`
- Modify: `apps/web/src/App.tsx`

The authoring screen (FR-04.1, .2, .3, .5 UI): header/body/footer/buttons
fields, variable rows with labels and sample values, a live preview pane
(`TemplatePreview`), a Validate button surfacing inline problems, and a Submit
button. Used for both create (`/templates/new`) and edit (`/templates/:id/edit`).

- [ ] **Step 1: Write the failing test** — `apps/web/src/routes/TemplateEditor.test.tsx`:

```typescript
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Routes, Route } from "react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import TemplateEditor from "./TemplateEditor.js";
import * as templatesApi from "../lib/templates-api.js";

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

function renderNew() {
  const qc = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });
  return render(
    <QueryClientProvider client={qc}>
      <MemoryRouter initialEntries={["/templates/new"]}>
        <Routes>
          <Route path="/templates/new" element={<TemplateEditor />} />
          <Route path="/templates/:id" element={<div>detail</div>} />
        </Routes>
      </MemoryRouter>
    </QueryClientProvider>,
  );
}

describe("TemplateEditor screen", () => {
  beforeEach(() => {
    vi.mocked(templatesApi.createTemplate).mockResolvedValue({
      id: "t1",
    } as never);
    vi.mocked(templatesApi.validateTemplate).mockResolvedValue({
      valid: false,
      problems: ["Variable {{1}} has no sample value."],
    });
  });

  it("renders the authoring form with a body field (FR-04.1)", () => {
    renderNew();
    expect(screen.getByLabelText(/body/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
  });

  it("renders a live preview pane (FR-04 UI)", async () => {
    renderNew();
    const body = screen.getByLabelText(/body/i);
    await userEvent.type(body, "Hello world");
    await waitFor(() =>
      expect(screen.getByText("Hello world")).toBeInTheDocument(),
    );
  });

  it("shows inline validation problems when Validate is clicked (FR-04.5)", async () => {
    renderNew();
    await userEvent.type(screen.getByLabelText(/name/i), "welcome");
    await userEvent.type(screen.getByLabelText(/body/i), "Hi {{1}}.");
    await userEvent.click(screen.getByRole("button", { name: /validate/i }));
    await waitFor(() =>
      expect(
        screen.getByText(/has no sample value/i),
      ).toBeInTheDocument(),
    );
  });
});
```

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

Run: `pnpm --filter @whatapp/web test routes/TemplateEditor`
Expected: FAIL — `./TemplateEditor.js` module not found.

> Note: `userEvent` comes from `@testing-library/user-event`. If it is not
> already a dev dependency of `apps/web` (it was not used in Phase 2), add it:
> in `apps/web/package.json` `devDependencies` add
> `"@testing-library/user-event": "^14.5.2"`, then run `pnpm install` from the
> repo root before Step 3.

- [ ] **Step 3: Implement `apps/web/src/components/templates/TemplateEditorForm.tsx`:**

```typescript
import React from "react";
import type {
  TemplateComponents,
  TemplateVariable,
  TemplateCategory,
} from "../../lib/templates-api.js";

/** The full editable state of a template draft. */
export interface EditorState {
  name: string;
  language: string;
  category: TemplateCategory;
  components: TemplateComponents;
  variables: TemplateVariable[];
}

/** A blank draft for the create flow. */
export function emptyDraft(): EditorState {
  return {
    name: "",
    language: "en",
    category: "MARKETING",
    components: { body: { text: "" } },
    variables: [],
  };
}

/**
 * The presentational authoring form (FR-04.1–.2). Controlled — the parent
 * route owns the `EditorState` so the live preview and validation can read it.
 */
export default function TemplateEditorForm({
  state,
  onChange,
  disabled,
}: {
  state: EditorState;
  onChange: (next: EditorState) => void;
  disabled?: boolean;
}) {
  const set = (patch: Partial<EditorState>) =>
    onChange({ ...state, ...patch });
  const setBody = (text: string) =>
    set({ components: { ...state.components, body: { text } } });
  const setHeader = (text: string) =>
    set({
      components: {
        ...state.components,
        header: text ? { format: "TEXT", text } : undefined,
      },
    });
  const setFooter = (text: string) =>
    set({
      components: {
        ...state.components,
        footer: text ? { text } : undefined,
      },
    });

  const headerText =
    state.components.header && state.components.header.format === "TEXT"
      ? state.components.header.text
      : "";

  const setVariable = (i: number, patch: Partial<TemplateVariable>) => {
    const variables = state.variables.map((v, idx) =>
      idx === i ? { ...v, ...patch } : v,
    );
    set({ variables });
  };
  const addVariable = () =>
    set({
      variables: [
        ...state.variables,
        { index: state.variables.length + 1, label: "", sample: "" },
      ],
    });
  const removeVariable = (i: number) =>
    set({
      variables: state.variables
        .filter((_, idx) => idx !== i)
        .map((v, idx) => ({ ...v, index: idx + 1 })),
    });

  return (
    <div style={{ display: "grid", gap: 12 }}>
      <label>
        Name (lowercase snake_case)
        <input
          aria-label="name"
          value={state.name}
          disabled={disabled}
          onChange={(e) => set({ name: e.target.value })}
        />
      </label>
      <label>
        Language
        <input
          aria-label="language"
          value={state.language}
          disabled={disabled}
          onChange={(e) => set({ language: e.target.value })}
        />
      </label>
      <label>
        Category
        <select
          aria-label="category"
          value={state.category}
          disabled={disabled}
          onChange={(e) =>
            set({ category: e.target.value as TemplateCategory })
          }
        >
          <option value="MARKETING">MARKETING</option>
          <option value="UTILITY">UTILITY</option>
          <option value="AUTHENTICATION">AUTHENTICATION</option>
        </select>
      </label>
      <label>
        Header (optional, text)
        <input
          aria-label="header"
          value={headerText}
          disabled={disabled}
          onChange={(e) => setHeader(e.target.value)}
        />
      </label>
      <label>
        Body
        <textarea
          aria-label="body"
          rows={4}
          value={state.components.body.text}
          disabled={disabled}
          onChange={(e) => setBody(e.target.value)}
        />
      </label>
      <label>
        Footer (optional)
        <input
          aria-label="footer"
          value={state.components.footer?.text ?? ""}
          disabled={disabled}
          onChange={(e) => setFooter(e.target.value)}
        />
      </label>

      <fieldset>
        <legend>Variables</legend>
        {state.variables.map((v, i) => (
          <div key={i} style={{ display: "flex", gap: 8, marginBottom: 4 }}>
            <span>{`{{${v.index}}}`}</span>
            <input
              aria-label={`variable-${v.index}-label`}
              placeholder="Label"
              value={v.label}
              disabled={disabled}
              onChange={(e) => setVariable(i, { label: e.target.value })}
            />
            <input
              aria-label={`variable-${v.index}-sample`}
              placeholder="Sample value"
              value={v.sample}
              disabled={disabled}
              onChange={(e) => setVariable(i, { sample: e.target.value })}
            />
            <button
              type="button"
              disabled={disabled}
              onClick={() => removeVariable(i)}
            >
              Remove
            </button>
          </div>
        ))}
        <button type="button" disabled={disabled} onClick={addVariable}>
          Add variable
        </button>
      </fieldset>
    </div>
  );
}
```

- [ ] **Step 4: Implement `apps/web/src/routes/TemplateEditor.tsx`:**

```typescript
import React, { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
  createTemplate,
  patchTemplate,
  getTemplate,
  validateTemplate,
  submitTemplate,
  type ValidationResult,
} from "../lib/templates-api.js";
import TemplateEditorForm, {
  emptyDraft,
  type EditorState,
} from "../components/templates/TemplateEditorForm.js";
import TemplatePreview from "../components/templates/TemplatePreview.js";

/**
 * Template create/edit screen (FR-04.1, .2, .3, .5 UI). Holds the draft state,
 * shows a live preview, validates on demand, saves, and submits. On `:id` it
 * loads the existing template; an APPROVED/PENDING template is read-only
 * (FR-04.3) — the form is disabled.
 */
export default function TemplateEditor() {
  const { id } = useParams();
  const navigate = useNavigate();
  const [state, setState] = useState<EditorState>(emptyDraft());
  const [validation, setValidation] = useState<ValidationResult | null>(null);
  const [savedId, setSavedId] = useState<string | null>(id ?? null);

  const existing = useQuery({
    queryKey: ["template", id],
    queryFn: () => getTemplate(id as string),
    enabled: Boolean(id),
  });

  useEffect(() => {
    if (existing.data) {
      setState({
        name: existing.data.name,
        language: existing.data.language,
        category: existing.data.category,
        components: existing.data.components,
        variables: existing.data.variables,
      });
    }
  }, [existing.data]);

  const readOnly =
    existing.data?.status === "APPROVED" ||
    existing.data?.status === "PENDING";

  const save = useMutation({
    mutationFn: () =>
      savedId
        ? patchTemplate(savedId, state)
        : createTemplate(state),
    onSuccess: (t) => setSavedId(t.id),
  });

  const validate = useMutation({
    mutationFn: async () => {
      // Must be saved before the server-side validate endpoint can run.
      let templateId = savedId;
      if (!templateId) {
        const created = await createTemplate(state);
        templateId = created.id;
        setSavedId(templateId);
      } else {
        await patchTemplate(templateId, state);
      }
      return validateTemplate(templateId);
    },
    onSuccess: (result) => setValidation(result),
  });

  const submit = useMutation({
    mutationFn: async () => {
      if (!savedId) throw new Error("Save the template first");
      return submitTemplate(savedId);
    },
    onSuccess: (t) => navigate(`/templates/${t.id}`),
  });

  return (
    <div>
      <h2 style={{ marginTop: 0 }}>
        {id ? "Edit template" : "New template"}
      </h2>
      {readOnly ? (
        <p style={{ color: "#d97706" }}>
          This template is {existing.data?.status} and cannot be edited. Create
          a new template to change its content.
        </p>
      ) : null}
      <div style={{ display: "flex", gap: 24 }}>
        <div style={{ flex: 1 }}>
          <TemplateEditorForm
            state={state}
            onChange={setState}
            disabled={readOnly}
          />
          <div style={{ display: "flex", gap: 8, marginTop: 12 }}>
            <button
              onClick={() => save.mutate()}
              disabled={readOnly || save.isPending}
            >
              {save.isPending ? "Saving…" : "Save draft"}
            </button>
            <button
              onClick={() => validate.mutate()}
              disabled={readOnly || validate.isPending}
            >
              {validate.isPending ? "Validating…" : "Validate"}
            </button>
            <button
              onClick={() => submit.mutate()}
              disabled={
                readOnly ||
                submit.isPending ||
                !validation ||
                !validation.valid
              }
            >
              {submit.isPending ? "Submitting…" : "Submit to Meta"}
            </button>
          </div>
          {validation ? (
            validation.valid ? (
              <p style={{ color: "#16a34a" }}>
                Valid — ready to submit.
              </p>
            ) : (
              <ul style={{ color: "#dc2626" }}>
                {validation.problems.map((p, i) => (
                  <li key={i}>{p}</li>
                ))}
              </ul>
            )
          ) : null}
          {submit.isError ? (
            <p style={{ color: "#dc2626" }}>
              {(submit.error as Error).message}
            </p>
          ) : null}
        </div>
        <div style={{ flex: 1 }}>
          <h3>Preview</h3>
          <TemplatePreview
            components={state.components}
            variables={state.variables}
          />
        </div>
      </div>
    </div>
  );
}
```

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

Run: `pnpm --filter @whatapp/web test routes/TemplateEditor`
Expected: PASS (3 tests).

- [ ] **Step 6: Wire the routes** — in `apps/web/src/App.tsx`, add the import
  and two routes (the `/templates` route already exists from Task 12):

```typescript
import TemplateEditor from "./routes/TemplateEditor.js";
```

Add inside the `<AppShell />` route block, after the `/templates` route:

```typescript
        <Route path="/templates/new" element={<TemplateEditor />} />
        <Route path="/templates/:id/edit" element={<TemplateEditor />} />
```

- [ ] **Step 7: Verify the web app typechecks**

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

- [ ] **Step 8: Commit**

```bash
git add apps/web/src/components/templates/TemplateEditorForm.tsx apps/web/src/routes/TemplateEditor.tsx apps/web/src/routes/TemplateEditor.test.tsx apps/web/src/App.tsx apps/web/package.json pnpm-lock.yaml
git commit -m "feat: add web template editor with live preview and validation"
```

---

## Task 14: Web — Template detail screen

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

The detail screen (FR-04.11, and reflecting FR-04.7): the rendered preview,
status/quality, the rejection reason when present, the campaigns-using-it count,
and Edit / Delete actions (Delete surfaces the 409 message from FR-04.9).

- [ ] **Step 1: Write the failing test** — `apps/web/src/routes/TemplateDetail.test.tsx`:

```typescript
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Routes, Route } from "react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import TemplateDetail from "./TemplateDetail.js";
import * as templatesApi from "../lib/templates-api.js";

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

function renderDetail() {
  const qc = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });
  return render(
    <QueryClientProvider client={qc}>
      <MemoryRouter initialEntries={["/templates/t1"]}>
        <Routes>
          <Route path="/templates/:id" element={<TemplateDetail />} />
          <Route path="/templates" element={<div>list</div>} />
        </Routes>
      </MemoryRouter>
    </QueryClientProvider>,
  );
}

const detail = {
  id: "t1",
  name: "welcome",
  language: "en",
  category: "MARKETING" as const,
  status: "REJECTED" as const,
  qualityScore: null,
  usageCount: 0,
  components: { body: { text: "Hi {{1}}." } },
  variables: [{ index: 1, label: "Name", sample: "Ahmed" }],
  metaTemplateId: "meta-1",
  rejectionReason: "Body contains promotional content in a UTILITY template.",
  submittedAt: "2026-05-20T00:00:00Z",
  approvedAt: null,
  createdAt: "2026-05-19T00:00:00Z",
  updatedAt: "2026-05-20T00:00:00Z",
  preview: { header: null, body: "Hi Ahmed.", footer: null },
};

describe("TemplateDetail screen", () => {
  beforeEach(() => {
    vi.mocked(templatesApi.getTemplate).mockResolvedValue(detail);
  });

  it("shows the rendered preview with sample values (FR-04.11)", async () => {
    renderDetail();
    await waitFor(() =>
      expect(screen.getByText("Hi Ahmed.")).toBeInTheDocument(),
    );
  });

  it("shows the status and rejection reason (FR-04.7)", async () => {
    renderDetail();
    await waitFor(() => screen.getByText("Hi Ahmed."));
    expect(screen.getByText("REJECTED")).toBeInTheDocument();
    expect(
      screen.getByText(/promotional content/i),
    ).toBeInTheDocument();
  });

  it("shows the campaigns-using-it count (FR-04.10)", async () => {
    renderDetail();
    await waitFor(() => screen.getByText("Hi Ahmed."));
    expect(screen.getByText(/0 campaign/i)).toBeInTheDocument();
  });
});
```

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

Run: `pnpm --filter @whatapp/web test routes/TemplateDetail`
Expected: FAIL — `./TemplateDetail.js` module not found.

- [ ] **Step 3: Implement `apps/web/src/routes/TemplateDetail.tsx`:**

```typescript
import React from "react";
import { Link, useNavigate, useParams } from "react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getTemplate, deleteTemplate } from "../lib/templates-api.js";
import StatusBadge from "../components/templates/StatusBadge.js";
import TemplatePreview from "../components/templates/TemplatePreview.js";

/**
 * Template detail screen (FR-04.11): rendered preview, status/quality, the
 * rejection reason when present (FR-04.7), the campaigns-using count
 * (FR-04.10), and Edit/Delete actions. Delete surfaces the 409 message when
 * the template is in use (FR-04.9).
 */
export default function TemplateDetail() {
  const { id } = useParams();
  const navigate = useNavigate();
  const qc = useQueryClient();

  const { data: template, isLoading } = useQuery({
    queryKey: ["template", id],
    queryFn: () => getTemplate(id as string),
    enabled: Boolean(id),
  });

  const remove = useMutation({
    mutationFn: () => deleteTemplate(id as string),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ["templates"] });
      navigate("/templates");
    },
  });

  if (isLoading || !template) return <p>Loading…</p>;

  const editable =
    template.status === "DRAFT" || template.status === "REJECTED";

  return (
    <div>
      <div
        style={{
          display: "flex",
          justifyContent: "space-between",
          alignItems: "center",
        }}
      >
        <h2 style={{ marginTop: 0 }}>{template.name}</h2>
        <div style={{ display: "flex", gap: 8 }}>
          {editable ? (
            <Link to={`/templates/${template.id}/edit`}>
              <button>Edit</button>
            </Link>
          ) : null}
          <button
            onClick={() => remove.mutate()}
            disabled={remove.isPending}
          >
            {remove.isPending ? "Deleting…" : "Delete"}
          </button>
        </div>
      </div>

      <p>
        <StatusBadge
          status={template.status}
          quality={template.qualityScore}
        />{" "}
        · {template.category} · {template.language}
      </p>

      {template.rejectionReason ? (
        <p style={{ color: "#dc2626" }}>
          Rejection reason: {template.rejectionReason}
        </p>
      ) : null}

      {remove.isError ? (
        <p style={{ color: "#dc2626" }}>
          {(remove.error as Error).message}
        </p>
      ) : null}

      <p style={{ color: "#6b7280" }}>
        Used by {template.usageCount} campaign(s).
      </p>

      <h3>Preview</h3>
      <TemplatePreview
        components={template.components}
        variables={template.variables}
      />
    </div>
  );
}
```

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

Run: `pnpm --filter @whatapp/web test routes/TemplateDetail`
Expected: PASS (3 tests).

- [ ] **Step 5: Wire the route** — in `apps/web/src/App.tsx`, add the import and
  the `/templates/:id` route (after `/templates/:id/edit`):

```typescript
import TemplateDetail from "./routes/TemplateDetail.js";
```

```typescript
        <Route path="/templates/:id" element={<TemplateDetail />} />
```

- [ ] **Step 6: Verify the web app typechecks and all web tests pass**

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

- [ ] **Step 7: Commit**

```bash
git add apps/web/src/routes/TemplateDetail.tsx apps/web/src/routes/TemplateDetail.test.tsx apps/web/src/App.tsx
git commit -m "feat: add web template detail screen"
```

---

## Task 15: Full-phase verification & ROADMAP tick

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

- [ ] **Step 1: Run the full verification suite from the repo root**

Run, in order, and confirm each passes:
- `pnpm lint` → passes
- `pnpm typecheck` → passes
- `pnpm test` → all tests pass (shared validate/meta-payload, API
  templates.service / template-submit.service / templates.controller, web
  StatusBadge / TemplatePreview / Templates / TemplateEditor / TemplateDetail)
- `pnpm build` → all packages build

If any step fails, fix it and re-run before continuing. Do not tick the phase
on assumption.

- [ ] **Step 2: Confirm the deferred items**

- Migrations: Task 1 confirmed no migration is needed (or produced an additive
  one). Applying any migration needs a live Postgres — **live verification
  deferred**.
- Live Meta submit/sync/delete (`submitTemplate` / `listTemplates` /
  `deleteTemplate`): exercised only against mocks in tests — **live
  verification deferred** (no live Meta credentials / Docker broken). These are
  not blockers.

- [ ] **Step 3: Tick Phase 3 in `plans/ROADMAP.md`**

Change:

```markdown
- [ ] Phase 3 — Templates
```

to:

```markdown
- [x] Phase 3 — Templates
```

- [ ] **Step 4: Commit**

```bash
git add plans/ROADMAP.md
git commit -m "chore: complete Phase 3 — Templates"
```

---

## Self-review checklist

Every functional requirement and acceptance criterion in `specs/04-templates.md`
maps to a covering task:

| Requirement | Covered by |
|---|---|
| **FR-04.1** create DRAFT with name/language/category/components | Task 2 (component schema), Task 6 (DTO), Task 7 (`create`), Task 9 (`POST /templates`), Task 13 (editor form) |
| **FR-04.2** positional variables + label/sample in `variables` | Task 2 (`templateVariableSchema`), Task 7 (`create` persists `variables`), Task 13 (variable rows) |
| **FR-04.3** edit only DRAFT/REJECTED; APPROVED/PENDING immutable | Task 7 (`patch` `EDITABLE_STATUSES` guard), Task 8 (`submit` `SUBMITTABLE_STATUSES`), Task 13 (`readOnly` form), Task 14 (`editable` Edit button) |
| **FR-04.4** name lowercase snake_case, unique per language | Task 6 (`templateNameSchema` regex), Task 7 (`create`/`patch` duplicate → 409) |
| **FR-04.5** `POST /:id/validate` — Meta-rule checks | Task 3 (`validateTemplate` pure fn, exhaustive tests), Task 8 (`validate`), Task 9 (route), Task 13 (Validate button + inline problems) |
| **FR-04.6** `POST /:id/submit` — validate, POST to Meta, store id/PENDING/submittedAt | Task 4 (`buildMetaTemplatePayload`), Task 8 (`submit`), Task 9 (route), Task 13 (Submit button) |
| **FR-04.7** webhook updates status/approvedAt/rejectionReason/qualityScore; UI reflects them | Existing Phase 1 webhook handler (not rebuilt — confirmed in `envelope-processor.ts`); Task 14 (detail shows status/rejection reason) |
| **FR-04.8** `POST /templates/sync` — reconcile from `listTemplates()` | Task 8 (`sync`), Task 9 (route, declared before `:id`), Task 12 (Sync button) |
| **FR-04.9** `DELETE /:id` — delete at Meta then locally; 409 if in non-draft campaign | Task 5 (`MetaClient.deleteTemplate`), Task 7 (`delete` usage check + `metaDeleteFn`), Task 9 (route wires the callback), Task 14 (Delete button + 409 message) |
| **FR-04.10** `GET /templates` — list with status/category/quality/usage; filterable | Task 7 (`list` + `usageCount`), Task 9 (route + query parse), Task 12 (table + filters) |
| **FR-04.11** `GET /:id` — full template + rendered preview | Task 7 (`get` + `renderPreview`), Task 9 (route), Task 11 (`TemplatePreview`), Task 14 (detail screen) |
| **AC-04.1** draft created with header/body/footer/buttons + variables | Task 7 (`create` test "creates a DRAFT…"), Task 13 |
| **AC-04.2** validation rejects non-contiguous numbering + missing samples | Task 3 (tests "rejects non-contiguous…", "rejects a variable…with no sample") |
| **AC-04.3** submitting a valid template stores `metaTemplateId`, sets PENDING | Task 8 (`submit` test "stores metaTemplateId, status=PENDING…") |
| **AC-04.4** an APPROVED template cannot be edited | Task 7 (`patch` test "rejects editing an APPROVED template") |
| **AC-04.5** a status webhook flips PENDING→APPROVED/REJECTED; UI reflects it | Existing Phase 1 webhook handler (`handleTemplateStatus`); Task 14 (detail reflects `status`/`rejectionReason`). Live webhook delivery — **deferred**. |
| **AC-04.6** `sync` reconciles a template whose webhook was missed | Task 8 (`sync` test "reconciles a local template whose webhook was missed") |
| **AC-04.7** deleting a template used by a non-draft campaign returns 409 | Task 7 (`delete` test "returns 409 when a non-draft campaign references it") |

**Placeholder scan:** no "TBD"/"implement later"/"add validation" steps — every
code step shows complete code; every command step shows the exact command and
expected output.

**Type consistency:** `TemplateComponents`/`TemplateVariable` are defined once
in Task 2 (`packages/shared/src/templates/types.ts`) and imported by Tasks 3,
4, 6, 7, 8; the web mirror types live in Task 10 and are imported by Tasks
11-14. `validateTemplate` / `TemplateValidationResult` (Task 3),
`buildMetaTemplatePayload` / `LocalTemplateInput` (Task 4),
`MetaClient.deleteTemplate` (Task 5), `metaDeleteFn` callback signature
`(name: string) => Promise<unknown>` (Tasks 7 + 9), and the
`{ valid, problems }` validation shape (Tasks 3, 8, 9, 10, 13) are consistent
across every task that references them.

**Note on FR-04.5 / AC-04.2 double coverage:** the *core* validation logic is
the pure `validateTemplate` in `packages/shared` (Task 3, exhaustively tested).
The API endpoint (Task 8/9) and the editor UI (Task 13) are thin layers over
it — they do not re-implement validation.
