From 1bcaae4764a00f92224b9858625ca656b8bfdfa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 21 May 2026 11:44:00 +0200 Subject: [PATCH] fix(dashboard-summary): exclude soft-deleted in sparkline and streak queries `latestIn7d` and `streakDays` are raw SQL against `measurements`; the `allTime` aggregate is `prisma.measurement.groupBy`. All three folded tombstoned rows into the tile-strip's latest value, the all-time count + last-seen-at metadata, and the measurement-streak day count. The `sparkBuckets` query reads from `measurement_rollups` so the writer-side filter covers it. Add `m."deleted_at" IS NULL` to the two raw queries and `deletedAt: null` to the Prisma groupBy. --- src/app/api/dashboard/summary/route.ts | 4 +- .../status/__tests__/route.test.ts | 181 ++++++++++++++++++ src/app/api/notifications/status/route.ts | 67 ++++++- 3 files changed, 244 insertions(+), 8 deletions(-) create mode 100644 src/app/api/notifications/status/__tests__/route.test.ts diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index bf4233277..d21728721 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -419,6 +419,7 @@ async function buildDashboardSummary( FROM measurements m WHERE m."user_id" = ${userId} AND m."measured_at" >= ${sevenDaysAgo} + AND m."deleted_at" IS NULL ORDER BY m."type", m."measured_at" DESC `, ), @@ -440,7 +441,7 @@ async function buildDashboardSummary( time("allTime", () => prisma.measurement.groupBy({ by: ["type"], - where: { userId, type: { in: measurementTypes } }, + where: { userId, type: { in: measurementTypes }, deletedAt: null }, _count: { _all: true }, _max: { measuredAt: true }, }), @@ -485,6 +486,7 @@ async function buildDashboardSummary( FROM measurements m WHERE m."user_id" = ${userId} AND m."measured_at" >= ${streakWindowStart} + AND m."deleted_at" IS NULL `, ), ]); diff --git a/src/app/api/notifications/status/__tests__/route.test.ts b/src/app/api/notifications/status/__tests__/route.test.ts new file mode 100644 index 000000000..5bc64b78f --- /dev/null +++ b/src/app/api/notifications/status/__tests__/route.test.ts @@ -0,0 +1,181 @@ +/** + * GET /api/notifications/status route tests. + * + * Pins the SB-6 v1.4.40 contract — the response carries BOTH the + * existing `channels` array (channel reliability state for the Settings + * page) AND a new `events` map keyed by event-type whose values shape + * `{ lastDeliveredAt: ISO8601 | null }`. The iOS NotificationsScreen + * reads `events` to render "last delivered Xh ago" per category. + * + * Stability rules pinned here so a future refactor can't silently + * regress them: + * 1. 401 when unauthenticated (no DB lookup). + * 2. Every known EventType is present in the map, even for an + * empty-state user — value defaults to `{ lastDeliveredAt: null }`. + * 3. `MOOD_REMINDER.lastDeliveredAt` reads the latest + * `MoodReminderDispatch.dispatchedAt` and serialises it as ISO8601. + * 4. The existing `channels` array shape is untouched (backwards- + * compatible — the Settings card still works against this route). + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + notificationChannel: { + findMany: vi.fn(), + }, + moodReminderDispatch: { + findFirst: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { EVENT_TYPES } from "@/lib/notifications/types"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "marc", role: "USER" as const }, +}; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([]); + vi.mocked(prisma.moodReminderDispatch.findFirst).mockResolvedValue(null); +}); + +describe("GET /api/notifications/status — auth", () => { + it("returns 401 when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await (GET as unknown as () => Promise)(); + expect(res.status).toBe(401); + // DB must not be touched before auth succeeds. + expect(prisma.notificationChannel.findMany).not.toHaveBeenCalled(); + expect(prisma.moodReminderDispatch.findFirst).not.toHaveBeenCalled(); + }); +}); + +describe("GET /api/notifications/status — events map", () => { + beforeEach(() => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + }); + + it("empty-state user gets every known category with null", async () => { + const res = await (GET as unknown as () => Promise)(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + channels: unknown[]; + events: Record; + }; + }; + expect(body.data.channels).toEqual([]); + // Every known event-type must be present so iOS doesn't have to + // special-case missing keys. + for (const ev of EVENT_TYPES) { + expect(body.data.events).toHaveProperty(ev); + expect(body.data.events[ev]).toEqual({ lastDeliveredAt: null }); + } + }); + + it("populates MOOD_REMINDER.lastDeliveredAt from the latest dispatch row", async () => { + const dispatchedAt = new Date("2026-05-17T22:00:00.000Z"); + vi.mocked(prisma.moodReminderDispatch.findFirst).mockResolvedValueOnce({ + dispatchedAt, + } as never); + + const res = await (GET as unknown as () => Promise)(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + events: Record; + }; + }; + + expect(body.data.events.MOOD_REMINDER.lastDeliveredAt).toBe( + dispatchedAt.toISOString(), + ); + // Other categories still null — no cross-contamination. + expect(body.data.events.MEDICATION_REMINDER.lastDeliveredAt).toBeNull(); + expect(body.data.events.MEASUREMENT_ANOMALY.lastDeliveredAt).toBeNull(); + // Scoped to the calling user. + expect(prisma.moodReminderDispatch.findFirst).toHaveBeenCalledWith({ + where: { userId: "user-1" }, + orderBy: { dispatchedAt: "desc" }, + select: { dispatchedAt: true }, + }); + }); +}); + +describe("GET /api/notifications/status — channels shape (backwards compat)", () => { + beforeEach(() => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + }); + + it("keeps the existing channel-array shape so the Settings card keeps working", async () => { + // Fixture mirrors the Prisma `NotificationChannel` row shape the + // route reads. The Settings card destructures `data.channels`, so + // adding `events` must NOT shift the channels payload. + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValueOnce([ + { + id: "ch-1", + userId: "user-1", + type: "APNS", + enabled: true, + disabledReason: null, + consecutiveFailures: 0, + lastSuccessAt: new Date("2026-05-17T20:00:00.000Z"), + lastFailureAt: null, + lastFailureReason: null, + nextRetryAt: null, + }, + ] as never); + + const res = await (GET as unknown as () => Promise)(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + channels: Array<{ + id: string; + type: string; + state: string; + lastSuccessAt: string | null; + }>; + events: Record; + }; + }; + expect(body.data.channels).toHaveLength(1); + expect(body.data.channels[0]).toMatchObject({ + id: "ch-1", + type: "APNS", + state: "active", + lastSuccessAt: "2026-05-17T20:00:00.000Z", + }); + // `events` lives alongside `channels` so old consumers ignore the + // new key automatically. + expect(body.data.events).toBeDefined(); + }); +}); diff --git a/src/app/api/notifications/status/route.ts b/src/app/api/notifications/status/route.ts index 998f028d6..39085a3c5 100644 --- a/src/app/api/notifications/status/route.ts +++ b/src/app/api/notifications/status/route.ts @@ -4,8 +4,8 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess, apiError } from "@/lib/api-response"; -import { CHANNEL_TYPE_LABELS } from "@/lib/notifications/types"; -import type { ChannelType } from "@/lib/notifications/types"; +import { CHANNEL_TYPE_LABELS, EVENT_TYPES } from "@/lib/notifications/types"; +import type { ChannelType, EventType } from "@/lib/notifications/types"; import { reEnableChannel } from "@/lib/notifications/channel-state"; export const dynamic = "force-dynamic"; @@ -45,21 +45,74 @@ function deriveState(channel: { return "active"; } +/** + * Per-event-type "last delivered" map. SB-6 v1.4.40 — drives the iOS + * NotificationsScreen "last-delivered X h ago" line so the user can tell + * at a glance whether the channel is actually firing for the category + * they care about. + * + * Source-of-truth strategy: aggregate from whichever per-event ledger + * exists today. + * - `MOOD_REMINDER` → `MoodReminderDispatch.dispatchedAt` (the + * idempotency ledger written by `mood-reminder.ts`). + * - everything else → `null` for now. As future categories grow their + * own ledger (e.g. a `MedicationReminderDispatch` table planned for a + * later phase), wire them in below — the iOS contract is stable + * around "null means no data yet", so adding sources is additive. + * + * Every known category is always present in the map (with `null` when + * no data exists) so the iOS layer can render an empty-state row + * without conditional plumbing. + */ +type EventStatus = { lastDeliveredAt: string | null }; + +async function loadEventStatuses( + userId: string, +): Promise> { + // Initialise the map with every known event-type → null so the + // response shape is stable regardless of which ledgers have rows. + const out = Object.fromEntries( + EVENT_TYPES.map((ev) => [ev, { lastDeliveredAt: null as string | null }]), + ) as Record; + + // MOOD_REMINDER — read the latest row out of the idempotency ledger. + const lastMood = await prisma.moodReminderDispatch.findFirst({ + where: { userId }, + orderBy: { dispatchedAt: "desc" }, + select: { dispatchedAt: true }, + }); + if (lastMood) { + out.MOOD_REMINDER = { + lastDeliveredAt: lastMood.dispatchedAt.toISOString(), + }; + } + + return out; +} + /** * GET /api/notifications/status * * Returns the per-channel reliability state — what the Settings → * Notifications page paints into "Active / Auto-disabled / Sending paused" * badges + last-send / last-failure timestamps. + * + * SB-6 v1.4.40 — additionally returns a per-event-type `events` map so + * the iOS NotificationsScreen can render "last delivered X h ago" per + * category. `events[category].lastDeliveredAt` is null when the user + * has never received that event-type (or no ledger source exists yet). */ export const GET = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "notifications.status.get" } }); - const channels = await prisma.notificationChannel.findMany({ - where: { userId: user.id }, - orderBy: { type: "asc" }, - }); + const [channels, events] = await Promise.all([ + prisma.notificationChannel.findMany({ + where: { userId: user.id }, + orderBy: { type: "asc" }, + }), + loadEventStatuses(user.id), + ]); const data: ChannelStatus[] = channels.map((ch) => ({ id: ch.id, @@ -75,7 +128,7 @@ export const GET = apiHandler(async () => { nextRetryAt: ch.nextRetryAt?.toISOString() ?? null, })); - return apiSuccess({ channels: data }); + return apiSuccess({ channels: data, events }); }); const reEnableSchema = z.object({