diff --git a/.planning/phase-W-APNS-NOTIFY-v1440-report.md b/.planning/phase-W-APNS-NOTIFY-v1440-report.md new file mode 100644 index 000000000..5ea3a25ae --- /dev/null +++ b/.planning/phase-W-APNS-NOTIFY-v1440-report.md @@ -0,0 +1,47 @@ +# Phase W-APNS-NOTIFY — v1.4.40 report + +## Scope +- **SB-5**: Conditional `interruption-level: "time-sensitive"` + `apns-priority: 10` on the APNs payload, restricted to `MEDICATION_REMINDER` only. +- **SB-6**: `GET /api/notifications/status` extended with a per-event-type `events` map (`{ [category]: { lastDeliveredAt: ISO8601 | null } }`) so the iOS NotificationsScreen can render "last delivered Xh ago" per category. + +## Changes + +### SB-5 — apns.ts +- `src/lib/notifications/senders/apns.ts`: + - `ApnsPayload` gains `interruptionLevel?: "passive" | "active" | "time-sensitive" | "critical"` and `priority?: 5 | 10`. + - `sendApnsPush()` writes both fields through to node-apn — `interruptionLevel` via a typed cast (mirrors the `category` setter pattern, node-apn 8.1 d.ts omits the public setter), `priority` directly (d.ts already exposes it). + - `sendViaApns()` sets `isTimeSensitive = payload.eventType === "MEDICATION_REMINDER"` and conditionally spreads `{ interruptionLevel: "time-sensitive", priority: 10 }` into the payload. Every other event-type (MOOD_REMINDER, MEASUREMENT_ANOMALY, COMPLIANCE_LOW, WITHINGS_SYNC_FAILED, SYSTEM_ALERT, PERSONAL_RECORD) stays on the iOS default `active` level so Focus modes — including Sleep — keep silencing them. + +### SB-6 — status route +- `src/app/api/notifications/status/route.ts`: + - New `loadEventStatuses(userId)` helper. Initialises the response map with every known `EVENT_TYPES` entry → `{ lastDeliveredAt: null }`, then populates `MOOD_REMINDER` from `MoodReminderDispatch.dispatchedAt` (the only per-event ledger that exists today). + - GET handler now runs `prisma.notificationChannel.findMany` and `loadEventStatuses` in parallel via `Promise.all`, returning `{ channels, events }`. Backwards-compatible: the Settings card consumer destructures `data.channels` and ignores `events`. +- New ledger sources are additive — a future `MedicationReminderDispatch` table can wire into `loadEventStatuses` without changing the iOS contract. + +## Tests +- `src/lib/notifications/senders/__tests__/apns.test.ts` extended with 3 new tests (8 cases via `it.each`): + - `MEDICATION_REMINDER sets interruption-level=time-sensitive + priority=10`. + - parameterised: 6 other event-types (`MOOD_REMINDER`, `MEASUREMENT_ANOMALY`, `COMPLIANCE_LOW`, `WITHINGS_SYNC_FAILED`, `SYSTEM_ALERT`, `PERSONAL_RECORD`) MUST omit `interruptionLevel` and `priority` (Focus respected). + - `sendApnsPush` round-trips explicit `interruptionLevel` + `priority`. +- New file `src/app/api/notifications/status/__tests__/route.test.ts` (181 lines, 4 tests): + - 401 when unauthenticated; no DB lookup before auth. + - Empty-state user gets every known event-type in the map with `lastDeliveredAt: null`. + - `MOOD_REMINDER.lastDeliveredAt` reads the latest `MoodReminderDispatch` row and is scoped to the calling user. + - `channels` array shape preserved alongside new `events` field (backwards compat). + +## Quality gates +- `pnpm typecheck` — clean. +- `pnpm lint` — pre-existing errors in `src/app/privacy/page.tsx` + a consent route warning, none in my file set. +- Targeted tests — `src/lib/notifications/senders/__tests__/apns.test.ts` + `src/app/api/notifications/status/__tests__/route.test.ts` → 38 passed. +- Full `src/lib/notifications/` suite — 85 passed across 6 files. + +## Commits on `develop` +- `8187d549 feat(apns): time-sensitive interruption-level for medication reminders` — my SB-5 commit. Pre-commit hook auto-staged 8 parallel-wave files that were already in the working tree; this is the same `cross-agent commit-message drift` pattern memorised from v1.4.37. +- `1bcaae47 fix(dashboard-summary): exclude soft-deleted in sparkline and streak queries` — SB-6 work landed inside a parallel wave's commit when their pre-commit hook ran before mine and folded my staged changes into their commit body. Diff confirms my route.ts changes (+67 lines) + the new test file (+181 lines) are intact. Marc should be aware the commit message does not reflect the full diff — the release-notes / CHANGELOG sweep needs to pull SB-6 details from this commit too. + +## AP-2 dependency callout (release notes) +- **SB-5 has no observable effect until the `.p8` APNs key is installed on production.** The dispatcher gates every send on `loadApnsConfig()` returning non-null, which requires `APNS_KEY_ID`, `APNS_TEAM_ID`, `APNS_BUNDLE_ID`, and one of `APNS_KEY` / `APNS_KEY_FILE` to be set together. Until Marc lands the .p8 key in the Coolify env, every dispatch returns `apns_not_configured` and the new `interruption-level` field never leaves the server. +- The v1.4.40 release notes should carry this caveat verbatim so a future "why doesn't medication-reminder break through Focus?" support thread points straight at the env-var gap rather than the code. + +## Source-of-truth gap for SB-6 (future work) +- Only `MOOD_REMINDER` has a per-event ledger today (`MoodReminderDispatch`). The other six event-types return `null` until a per-event dispatch ledger lands. The cleanest follow-up is a `NotificationDispatch` table (or dispatcher-side hook into `recordChannelSuccess`) that records `{ userId, eventType, dispatchedAt }` on every successful send. Out of scope for v1.4.40 — flagged in the wave brief as "drives the iOS NotificationsScreen 'last-delivered Xh ago' line", which the iOS team can render against `null` rows as "Never" until the ledger expands.