---
phase: 05-web-push-notifications
plan: 01
subsystem: api/push-foundation
tags: [web-push, vapid, schema, migration, test-scaffolds, red-tests]
dependency_graph:
  requires: [04-shared-lists-live-sync]
  provides: [push_subscriptions table, calendar_events.title column, Wave-0 RED test scaffolds, VAPID env wiring]
  affects: [apps/api/src/db/schema.ts, apps/api/src/db/migrations/, apps/api/test/setup.ts, docker-compose.yml]
tech_stack:
  added: [web-push@3.6.7, "@types/web-push@3.6.4", workbox-core@7.4.1, workbox-precaching@7.4.1, workbox-routing@7.4.1]
  patterns: [drizzle-kit generate+migrate (never push), mysqlTable FK+unique+index pattern, RED test scaffold pattern]
key_files:
  created:
    - apps/api/src/db/migrations/0003_same_xavin.sql
    - apps/api/src/db/migrations/meta/0003_snapshot.json
    - apps/api/tests/fixtures/vapid.ts
    - apps/api/tests/lib/pushDispatcher.test.ts
    - apps/api/tests/lib/pushCoalescer.test.ts
    - apps/api/tests/broker/reminderScheduler.test.ts
    - apps/api/tests/lib/eventChangeDispatcher.test.ts
    - apps/api/tests/routes/push.test.ts
    - .env.example
  modified:
    - apps/api/src/db/schema.ts
    - apps/api/test/setup.ts
    - apps/api/package.json
    - apps/pwa/package.json
    - pnpm-lock.yaml
    - docker-compose.yml
decisions:
  - "VAPID config is env-injected at runtime (docker-compose.yml environment block); no key baked into image"
  - "pushSubscriptions endpoint column uses text (not varchar) — push endpoints can exceed 512 chars"
  - "calendarEvents.title is nullable varchar(500); pre-existing rows stay NULL until Phase 5 sync update"
  - "Test VAPID keypair inlined in tests/fixtures/vapid.ts for offline-safe unit tests"
metrics:
  duration: 20
  completed_date: "2026-06-10"
  tasks_completed: 4
  files_changed: 15
---

# Phase 05 Plan 01: Wave-0 Foundation Summary

Web Push Wave-0 foundation: push dependencies installed, VAPID keypair env-injected, push_subscriptions table + calendar_events.title migrated, five RED test scaffolds committed.

## Tasks Executed

### Task 1: Package legitimacy gate + install push dependencies
**Status:** Done by orchestrator before this agent spawned.

Installed packages verified in package.json:
- `apps/api`: web-push@^3.6.7 (prod), @types/web-push@^3.6.4 (dev)
- `apps/pwa`: workbox-core@^7.4.1, workbox-precaching@^7.4.1, workbox-routing@^7.4.1 (dev)

Commit: `80bbdc1` — `chore(05-01): install web-push and workbox push dependencies`

### Task 2: Generate VAPID keypair + record in env
**Status:** Done by orchestrator before this agent spawned.

VAPID keypair generated and stored in gitignored `.env` (VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT). Real keys never committed.

(No dedicated commit — keys in .env only; .env.example documenting placeholders committed in Task 3.)

### Task 3: Schema — push_subscriptions table + calendar_events.title, generate+migrate
**Status:** Completed.

Added `pushSubscriptions` mysqlTable to `apps/api/src/db/schema.ts`:
- `user_id` INT NOT NULL FK → users.id ON DELETE CASCADE
- `endpoint` TEXT NOT NULL (globally unique — `uniq_push_endpoint`)
- `p256dh` TEXT NOT NULL
- `auth` VARCHAR(256) NOT NULL
- `created_at`, `updated_at` TIMESTAMP
- Index `idx_push_subscriptions_user_id` on userId

Added `title` VARCHAR(500) (nullable) to `calendarEvents` after `rawVevent`. Populated from VEVENT SUMMARY by sync.ts in Plan 05-07; required for readable reminder/change copy (D-02/NOTIF-01).

Migration generated via `db:generate` and applied via `db:migrate` (NOT `db:push` — anti-pattern per drizzle-mariadb-push-unsafe memory). Migration file: `0003_same_xavin.sql`.

VAPID container-transposability: added VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT to `docker-compose.yml` api `environment:` block using `${VAR}` syntax (no default — must be set). Created root `.env.example` documenting all environment variables including VAPID vars with placeholders and generation instructions.

Commit: `73fcdaf` — `feat(05-01): add push_subscriptions table + calendar_events.title column; VAPID env wiring`

### Task 4: Wave-0 RED test scaffolds + VAPID fixture + setup truncation
**Status:** Completed.

Created `tests/fixtures/vapid.ts` — exports `TEST_VAPID` const with a statically inlined P-256 keypair (generated once; no runtime network call; offline-safe).

Created five RED test scaffolds (all fail on `Cannot find module` — correct RED state):

1. **tests/lib/pushDispatcher.test.ts** — 4 tests: 410/404 prune DELETE, 201 no-delete, 5xx no-delete
2. **tests/lib/pushCoalescer.test.ts** — 3 tests: burst collapses to 1 dispatch with count=N; excludeUserId passed; separate lists are independent
3. **tests/broker/reminderScheduler.test.ts** — 3 tests: all-day excluded (D-07); non-shared excluded (D-05); (uid,minuteBucket) dedup
4. **tests/lib/eventChangeDispatcher.test.ts** — 4 tests: create fires; title-change fires; description-only silent (D-04); actor excluded (D-03)
5. **tests/routes/push.test.ts** — POST 201/401; DELETE removes rows; GET /api/push/vapid-public-key returns `{ publicKey }`

Updated `test/setup.ts`:
- Added `pushSubscriptions` to import from schema
- Added `await db.delete(pushSubscriptions)` in afterEach (before lists delete; no FK to lists)

Commit: `ef558b6` — `test(05-01): add Wave-0 RED scaffolds + VAPID fixture + setup truncation`

## Deviations from Plan

### Auto-added: VAPID container-transposability (orchestrator requirement)

The orchestrator folded in a requirement not in the original plan: VAPID env vars must be env-injected in docker-compose.yml, not baked into the image.

- **Fix:** Added three `${VAPID_*}` entries to `docker-compose.yml` api `environment:` block (no default fallback — unset = container won't start, which is correct: no VAPID = no push).
- **Also created:** Root `.env.example` (the plan listed it in `files_modified` but it didn't exist yet) documenting all environment variables for the project including VAPID.
- **Files modified:** docker-compose.yml, .env.example (created)

### Package dependencies committed separately (Rule 3 — blocking issue)

Tasks 1/2 package installs were done by the orchestrator but not yet committed (uncommitted changes in `apps/api/package.json`, `apps/pwa/package.json`, `pnpm-lock.yaml`). These were staged and committed as a separate chore commit (`80bbdc1`) before the schema commit, to keep dependency changes isolated from schema changes.

## Known Stubs

None. This plan lays only schema and test scaffolds — no UI rendering or data-flow stubs.

## Threat Flags

No new threat surface introduced. VAPID private key is in gitignored `.env` only; `.env.example` contains placeholders only (T-05-01 mitigated). Migration used generate+migrate workflow (T-05-02 mitigated). Package installs were pre-approved by human checkpoint Task 1 (T-05-SC mitigated).

## Self-Check

**Files created/verified:**

- [x] apps/api/src/db/migrations/0003_same_xavin.sql — exists
- [x] apps/api/tests/fixtures/vapid.ts — exists
- [x] apps/api/tests/lib/pushDispatcher.test.ts — exists
- [x] apps/api/tests/lib/pushCoalescer.test.ts — exists
- [x] apps/api/tests/broker/reminderScheduler.test.ts — exists
- [x] apps/api/tests/lib/eventChangeDispatcher.test.ts — exists
- [x] apps/api/tests/routes/push.test.ts — exists
- [x] .env.example — exists

**Commits verified:**
- 80bbdc1: chore(05-01): install web-push and workbox push dependencies
- 73fcdaf: feat(05-01): add push_subscriptions table + calendar_events.title column; VAPID env wiring
- ef558b6: test(05-01): add Wave-0 RED scaffolds + VAPID fixture + setup truncation

**Typecheck:** passes (`pnpm --filter @familysync/api typecheck` — no errors)
**RED tests:** all 5 scaffold files fail on `Cannot find module` (correct; implementations in Plans 05-02..05-06)

## Self-Check: PASSED
