---
phase: 05-web-push-notifications
plan: 02
subsystem: api/push-dispatcher
tags: [web-push, vapid, push-dispatcher, tdd, red-green]
dependency_graph:
  requires: [05-01]
  provides: [dispatchPush helper, buildPushBody helper, 410/404 prune logic]
  affects: [apps/api/src/lib/pushDispatcher.ts]
tech_stack:
  added: []
  patterns: [default-import-cjs (web-push Pitfall 7), dual-format push payload (iOS 18.4+ declarative + legacy), 410/404 DB prune pattern]
key_files:
  created:
    - apps/api/src/lib/pushDispatcher.ts
  modified: []
decisions:
  - "dispatchPush uses sub.id (not a separate dbRowId argument) — test calls with 2 args; signature matches test"
  - "buildPushBody emits both web_push:8030+notification{} (iOS 18.4+) and top-level title/body/tag/data (iOS 16.4–18.3 + Android)"
  - "setVapidDetails is NOT called at module scope — deferred to index.ts startup (Plan 05-04)"
  - "dispatchPush never throws — resolves after logging transient errors; safe for fan-out loops"
metrics:
  duration: 5
  completed_date: "2026-06-10"
  tasks_completed: 1
  files_changed: 1
---

# Phase 05 Plan 02: pushDispatcher — VAPID send + 410/404 prune — Summary

TDD GREEN: `pushDispatcher.ts` implemented with dual-format iOS payload, VAPID send via web-push, and DB prune on 410/404.

## Tasks Executed

### Task 1: Implement pushDispatcher.ts (GREEN)

**Status:** Completed.

The RED test scaffold was already committed in Plan 05-01 (commit ef558b6). This plan turns it GREEN.

Created `apps/api/src/lib/pushDispatcher.ts` with:

**`buildPushBody(notification)`** — builds the dual-format JSON payload string:
- `web_push: 8030` + `notification: { title, body, navigate }` — iOS 18.4+ declarative web push format
- Top-level `title`, `body`, `tag`, `data: { url: navigate }` — legacy format for iOS 16.4–18.3 and Android

**`dispatchPush(sub, notification)`** — VAPID-signed push send + prune:
- Constructs `webPushSub = { endpoint, keys: { p256dh, auth } }` from subscription row
- Calls `webpush.sendNotification(webPushSub, body, { TTL: 300, urgency: 'normal' })`
- On thrown error with `statusCode === 410` or `statusCode === 404`: deletes the row via `db.delete(pushSubscriptions).where(eq(pushSubscriptions.id, sub.id))`
- On transient errors (5xx, 429, network): logs `[pushDispatcher] sendNotification failed: <statusCode> <message>` then resolves
- On success: no action

Uses default import `import webpush from 'web-push'` (CommonJS — Pitfall 7 from RESEARCH.md).

**TDD Gate Compliance:**
- RED: `test(05-01): add Wave-0 RED scaffolds + VAPID fixture + setup truncation` — ef558b6 (Plan 05-01)
- GREEN: `feat(05-02): implement pushDispatcher — VAPID send + 410/404 prune` — e4170b3

Commit: `e4170b3`

## Verification

```
pnpm --filter @familysync/api exec vitest run tests/lib/pushDispatcher.test.ts

 Test Files  1 passed (1)
      Tests  4 passed (4)
```

`pnpm --filter @familysync/api typecheck` — passes (no errors).

## Deviations from Plan

### Plan specifies `dispatchPush(subscription, notification, dbRowId)` — test uses 2-arg form

The plan text describes a 3-argument signature `dispatchPush(sub, notification, dbRowId)`. The existing RED scaffold test (committed in Plan 05-01) calls `dispatchPush(FAKE_SUB, { title, body })` with 2 arguments — the subscription object already carries the `id` field. The test is canonical; the implementation uses `sub.id` directly and exposes a 2-argument signature. No test file changes were needed.

## Known Stubs

None.

## Threat Flags

No new threat surface introduced. `pushDispatcher.ts` is a pure utility module — no new network endpoints, no auth paths, no file access. T-05-03 (VAPID signing via web-push only), T-05-04 (per-sub catch), and T-05-05 (no key/payload logging) are all mitigated.

## Self-Check

**Files created/verified:**
- [x] apps/api/src/lib/pushDispatcher.ts — exists

**Commits verified:**
- ef558b6: test(05-01): add Wave-0 RED scaffolds + VAPID fixture + setup truncation (RED gate — from Plan 05-01)
- e4170b3: feat(05-02): implement pushDispatcher — VAPID send + 410/404 prune (GREEN gate)

## Self-Check: PASSED
