chore(release): v1.4.40 — comprehensive Critical+High architecture closure plus iOS PB30 enablement

Eleven touch-disjoint waves landed on develop plus a six-axis QA
reconcile round before tagging. See CHANGELOG.md for the user-facing
prose; this commit message documents the wave + report inventory for
release-management traceability.

Waves shipped (each with a phase report under .planning/):

- phase-W-POOL-v1440-report.md — p-limit(4) wrapper around the
  analytics 15-way fan-out + pg.Pool max raised 10 -> 20. Closes the
  v1.4.39 empirical-trace finding #1 (Prisma pool starvation).
- phase-W-DELETED-v1440-report.md — 11 reader-tier helpers + 3
  integration contracts close audit Critical Finding #3 (soft-delete
  invisibility) across dashboard, analytics, summaries, rollup tiers,
  and the Coach snapshot path.
- phase-W-INSIGHTS-v1440-report.md — mood-rollup swap on the three
  /api/insights/{features,targets,cards} routes plus the
  compliance-rollup hook gap closure for bulk-projection mints; the
  post-QA reconcile patch closes the six remaining measurement.findMany
  sites in the cluster that still leaked tombstoned rows.
- phase-W-INFRA-v1440-report.md — rollup helper umbrella consolidation
  under src/lib/rollups/* with zero orphan re-exports; lint, typecheck,
  knip, and 93/93 rollup tests green post-restructure.
- phase-W-RSC-v1440-report.md — per-tile Suspense boundaries plus the
  queryKey factory-bypass walker test (extended to comparison/ in the
  reconcile pass).
- phase-W-PRIVACY-v1440-report.md — bilingual /privacy page with 17
  paired-section tests pinning the 9 SB-3 disclosure requirements.
- phase-W-AASA-v1440-report.md — /.well-known/apple-app-site-association
  endpoint pinned to S8WDX4W5KX.dev.healthlog.app with app-ID parity
  assertions guarding against split rotation.
- phase-W-APNS-NOTIFY-v1440-report.md — MEDICATION_REMINDER
  time-sensitive APNs payload (priority 10 + interruption-level)
  scoped via a parameterised test that pins all 6 other event-types
  do not bypass Focus. AP-2 .p8 install gates real delivery.
- phase-W-CONSENT-v1440-report.md — AI consent receipts CRUD with
  append-only invariant pinned in test; revoke-then-regrant leaves
  both rows; idempotent DELETE returns 200 + receipt: null.
- phase-W-WMY-WIRE-v1440-report.md — three previously-dead WMY readers
  (week/month/year) wired through summaries-slice and
  health-score-fast-path with a linear-composability parity test
  pinning the cross-consumer routing contract.
- phase-W-GHOSTS-v1440-report.md — ~1 177 lines of dead code removed
  across 9 atomic commits; startOfUtcDay + wallClockInTz consolidation
  eliminates a sibling-drift risk; knip whitelist trimmed to three
  files pending follow-up.

QA reconcile patches applied per the v1.4.40 QA round (six reports
under .planning/round-v1440-QA-*-findings.md):

- senior C1 — dashboard-suspense-boundaries regex aligned with the
  post-W-INFRA Thread 2 userTimezone-local lift.
- senior H1 — soft-delete filter added to the six remaining
  insights measurement.findMany sites.
- specialist H1 — Promise.allSettled around bulk-projection
  compliance recompute on both /api/medications/intake and
  /api/dashboard/summary.
- ux H1 — compare-toggle migrated to queryKeys.dashboardWidgets
  factory; walker test extended to src/components/comparison/.
- security M1 — consent artefact 64 KB cap enforced via
  Buffer.byteLength (UTF-8 bytes, not UTF-16 code units).

Quality gates green at tag: pnpm typecheck, pnpm lint, pnpm knip
--include files,dependencies,binaries,unlisted, pnpm test --run
(4726 passing / 1 skipped). Tests 4525 -> 4726 unit + 1 skipped over
the v1.4.39 -> v1.4.40 span.

User-facing prose: see CHANGELOG.md.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 12:48:02 +02:00
1 parent f9996552ed
commit 1114cf7e63
146 files changed
+8207 -2279

No files matched your search

@@ -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<Response>)();
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<Response>)();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
channels: unknown[];
events: Record<string, { lastDeliveredAt: string | null }>;
};
};
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<Response>)();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
events: Record<string, { lastDeliveredAt: string | null }>;
};
};
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<Response>)();
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<string, { lastDeliveredAt: string | null }>;
};
};
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();
});
});
+60 -7
View File
@@ -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<Record<EventType, EventStatus>> {
// 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<EventType, EventStatus>;
// 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({