test(apns,notifications-status): pin payload shape and aggregate behaviour

Pins the SB-5 (apns time-sensitive interruption-level) and SB-6
(/api/notifications/status events map) contracts.

Note: the SB-6 route + test changes landed inside an earlier
`fix(dashboard-summary)` commit (1bcaae47) due to a cross-wave
pre-commit-hook race that auto-folded my staged changes; this
commit ships the wave report + records the proper SB-5 + SB-6
intent in the log for the release-notes sweep to pick up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-21 11:45:47 +02:00
1 parent 97920f0f59
commit 6ab3440336
1 file changed
+47
@@ -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.