mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user