mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +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
@@ -6,14 +6,15 @@ import { NextResponse } from "next/server";
|
||||
* Apple fetches `/.well-known/apple-app-site-association` over HTTPS
|
||||
* without credentials, with no extension and with a strict
|
||||
* `application/json` Content-Type expectation. iOS uses the body to:
|
||||
* - Wire Universal Links so HealthLog URLs open straight in the iOS
|
||||
* app instead of Safari (SB-4, v1.4.40). The `["*"]` matcher accepts
|
||||
* every path on the host — the iOS side decides which routes it
|
||||
* actually handles via its scene delegate.
|
||||
* - Wire Web Credentials so the website origin and the iOS bundle
|
||||
* share passkey ceremonies (HealthLog passkeys live at
|
||||
* `/api/auth/passkey/*`, so the origins must match the same App ID).
|
||||
* - Wire Universal Links — out of scope for the first cut, hence the
|
||||
* empty `applinks.details` array. Future entries here pick up
|
||||
* deep-linking once the iOS side opts in.
|
||||
*
|
||||
* The `webcredentials.apps` entry is the App ID prefix
|
||||
* The `appID` / `webcredentials.apps` entry is the App ID prefix
|
||||
* (`<TeamID>.<BundleID>`) for the HealthLog iOS app. Apple bundles the
|
||||
* file on its CDN proxy
|
||||
* (`https://app-site-association.cdn-apple.com/a/v1/<host>`) and serves
|
||||
@@ -23,13 +24,19 @@ import { NextResponse } from "next/server";
|
||||
* Both `healthlog.bombeck.io` (maintainer prod) and `demo.healthlog.dev`
|
||||
* (demo) share this handler — the response is host-independent.
|
||||
*/
|
||||
const AASA_APP_ID = "S8WDX4W5KX.dev.healthlog.app";
|
||||
|
||||
const AASA = {
|
||||
applinks: {
|
||||
apps: [],
|
||||
details: [],
|
||||
details: [
|
||||
{
|
||||
appID: AASA_APP_ID,
|
||||
paths: ["*"],
|
||||
},
|
||||
],
|
||||
},
|
||||
webcredentials: {
|
||||
apps: ["S8WDX4W5KX.dev.healthlog.app"],
|
||||
apps: [AASA_APP_ID],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* v1.4.40 W-RSC — dashboard Suspense boundaries.
|
||||
*
|
||||
* Audit-H2 flagged "no `<Suspense>` boundaries anywhere in the app".
|
||||
* The v1.4.40 W-RSC wave wraps every dashboard tile cell and every
|
||||
* chart cell in a per-cell `<Suspense>` boundary so the composition
|
||||
* reads as a grid of independently-suspending islands rather than a
|
||||
* flat client tree with shared loading semantics.
|
||||
*
|
||||
* Today the boundary is a structural no-op for the dynamic-loaded
|
||||
* charts because their loading skeleton lives inside the
|
||||
* `next/dynamic({ loading: <ChartSkeleton/> })` contract. The benefit
|
||||
* is future-proofing: any descendant that later suspends (an
|
||||
* `useSuspenseQuery` migration, an RSC hoist of a static legend) gets
|
||||
* its own fallback without re-architecting the row.
|
||||
*
|
||||
* The test pins the structural presence by greppig the page source —
|
||||
* a future refactor that drops the boundary lands a failing test
|
||||
* rather than silently regressing the streaming-composition contract.
|
||||
*/
|
||||
const ROOT = join(__dirname, "../../..");
|
||||
const PAGE_PATH = join(ROOT, "src/app/page.tsx");
|
||||
|
||||
function load(path: string): string {
|
||||
return readFileSync(path, "utf8");
|
||||
}
|
||||
|
||||
describe("v1.4.40 — dashboard per-cell Suspense boundaries", () => {
|
||||
it("imports Suspense from React", () => {
|
||||
const src = load(PAGE_PATH);
|
||||
expect(src).toMatch(/from\s+"react"/);
|
||||
expect(src).toMatch(/\bSuspense\b/);
|
||||
});
|
||||
|
||||
it("wraps the chart-row cell in a `<Suspense fallback={<ChartSkeleton />}>` boundary", () => {
|
||||
const src = load(PAGE_PATH);
|
||||
// `<Suspense fallback={<ChartSkeleton />}>{entry.node}</Suspense>`
|
||||
// exact contract: the chart row's per-cell fallback uses the same
|
||||
// ChartSkeleton primitive the dynamic-loaded charts already paint
|
||||
// during JS chunk resolution.
|
||||
expect(src).toMatch(
|
||||
/<Suspense\s+fallback=\{<ChartSkeleton\s*\/>\}>\s*\{entry\.node\}\s*<\/Suspense>/,
|
||||
);
|
||||
});
|
||||
|
||||
it("wraps each tile-strip cell in a `<Suspense>` boundary", () => {
|
||||
const src = load(PAGE_PATH);
|
||||
// `<Suspense fallback={null}>{entry.node}</Suspense>` — the tile
|
||||
// body is synchronous today so a `null` fallback never paints,
|
||||
// but the boundary primes the row for a future RSC hoist of any
|
||||
// tile slot. Pinning the literal keeps the structural contract
|
||||
// visible in CI.
|
||||
expect(src).toMatch(
|
||||
/<Suspense\s+fallback=\{null\}>\s*\{entry\.node\}\s*<\/Suspense>/,
|
||||
);
|
||||
});
|
||||
|
||||
it("hoists DASHBOARD_QUERY_OPTS to module scope (audit-M2)", () => {
|
||||
const src = load(PAGE_PATH);
|
||||
// Module-scope declaration — search outside the component body.
|
||||
// Pre-fix the const was declared inside `DashboardPage` so every
|
||||
// render created a fresh `{}` reference.
|
||||
const moduleSliceEnd = src.indexOf("export default function DashboardPage");
|
||||
expect(moduleSliceEnd).toBeGreaterThan(0);
|
||||
const moduleSlice = src.slice(0, moduleSliceEnd);
|
||||
expect(moduleSlice).toMatch(/const\s+DASHBOARD_QUERY_OPTS\s*=\s*\{/);
|
||||
});
|
||||
|
||||
it("memoises the hour-of-day derivation against user.timezone (audit-H4)", () => {
|
||||
const src = load(PAGE_PATH);
|
||||
// The greeting hour only changes when the user's timezone changes
|
||||
// — a `useMemo` keyed on the lifted `userTimezone` local keeps the
|
||||
// `Intl.DateTimeFormat` instantiation off the per-render hot path.
|
||||
// Post-W-INFRA Thread 2: `user?.timezone` is lifted to a `userTimezone`
|
||||
// local one line above the `useMemo` so the dep array stays stable.
|
||||
expect(src).toMatch(
|
||||
/const\s+hour\s*=\s*useMemo\([\s\S]*?\[\s*userTimezone\s*\][\s\S]*?\);/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -43,12 +43,12 @@ import {
|
||||
summarizeBackup,
|
||||
type BackupSummary,
|
||||
} from "@/lib/validations/backup";
|
||||
import { recomputeUserMoodRollups } from "@/lib/mood/rollups";
|
||||
import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups";
|
||||
import {
|
||||
recomputeUserMedicationCompliance,
|
||||
MEDICATION_COMPLIANCE_BACKFILL_DAYS,
|
||||
} from "@/lib/medications/compliance-rollups";
|
||||
import { recomputeUserRollups } from "@/lib/measurements/rollups";
|
||||
} from "@/lib/rollups/medication-compliance-rollups";
|
||||
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
enqueueBootTimeRollupBackfill,
|
||||
recomputeUserRollups,
|
||||
} from "@/lib/measurements/rollups";
|
||||
} from "@/lib/rollups/measurement-rollups";
|
||||
|
||||
interface RecomputeBody {
|
||||
userId?: string;
|
||||
|
||||
@@ -73,7 +73,7 @@ vi.mock("next/headers", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { ANALYTICS_TYPE_FETCH_CONCURRENCY, GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
@@ -376,4 +376,71 @@ describe("GET /api/analytics", () => {
|
||||
expect(body.data.lastSeenByType.WEIGHT?.daysAgo).toBeLessThanOrEqual(6);
|
||||
expect(body.data.lastSeenByType.PULSE).toBeNull();
|
||||
});
|
||||
|
||||
// v1.4.40 W-POOL — pin the bounded-concurrency cap on the per-type
|
||||
// fan-out inside `buildAnalyticsResponse`. The v1.4.39 empirical
|
||||
// trace (`.planning/round-v1439-empirical-trace.md` §B1) showed the
|
||||
// raw `Promise.all` over `fetchMeasurementSeriesChunked` holding
|
||||
// ≥8 of the default-10 pg.Pool slots for 6.5 s on a power-user cold
|
||||
// mount. The p-limit(4) wrapper keeps the same total work but caps
|
||||
// inflight Prisma round-trips for this branch at 4.
|
||||
//
|
||||
// The assertion instruments the `prisma.measurement.findMany` mock
|
||||
// to track peak inflight count + total batches. With N≥15 types and
|
||||
// a 20 ms per-call delay, an unbounded `Promise.all` would peak at
|
||||
// ~15 and finish in ~20 ms; the bounded version peaks at exactly 4
|
||||
// and finishes in ~80 ms (4 batches × 20 ms ceiling).
|
||||
it("caps per-type Prisma fan-out at ANALYTICS_TYPE_FETCH_CONCURRENCY", async () => {
|
||||
// Default-slice path requires no coverage so the route falls into
|
||||
// the live per-type loop where the fan-out lives.
|
||||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
|
||||
|
||||
let inflight = 0;
|
||||
let peak = 0;
|
||||
let calls = 0;
|
||||
|
||||
vi.mocked(prisma.measurement.findMany).mockImplementation(
|
||||
(async () => {
|
||||
// Only count the per-type chunked fan-out calls (not the
|
||||
// narrower glucose / sleep-stage `findMany` reads inside the
|
||||
// same route; those run sequentially AFTER the fan-out
|
||||
// completes so they never overlap with peak inflight).
|
||||
calls += 1;
|
||||
inflight += 1;
|
||||
peak = Math.max(peak, inflight);
|
||||
// 20 ms is large enough to make the bounded vs unbounded
|
||||
// distinction observable but small enough to keep the suite
|
||||
// under a second.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
inflight -= 1;
|
||||
// Return an empty page so the chunked reader exits after one
|
||||
// round-trip per type.
|
||||
return [] as never;
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const t0 = Date.now();
|
||||
const res = await (
|
||||
GET as unknown as (...args: never[]) => Promise<Response>
|
||||
)();
|
||||
const elapsed = Date.now() - t0;
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// The cap itself — peak inflight Prisma reads against
|
||||
// `measurements` never crosses the configured ceiling.
|
||||
expect(ANALYTICS_TYPE_FETCH_CONCURRENCY).toBe(4);
|
||||
expect(peak).toBeLessThanOrEqual(ANALYTICS_TYPE_FETCH_CONCURRENCY);
|
||||
|
||||
// Sanity: more than one type was actually walked, otherwise the
|
||||
// bound is unfalsifiable.
|
||||
expect(calls).toBeGreaterThanOrEqual(8);
|
||||
|
||||
// Wall-clock proves the lanes are saturated rather than serialised
|
||||
// by accident. 4 lanes × 20 ms × ⌈calls/4⌉ ≈ 60-100 ms; an
|
||||
// unbounded fan-out would finish in ~25-40 ms; a fully serial
|
||||
// walk would take calls × 20 ms ≈ 300+ ms. The window catches
|
||||
// both regressions.
|
||||
expect(elapsed).toBeGreaterThanOrEqual(40);
|
||||
expect(elapsed).toBeLessThan(calls * 20);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import pLimit from "p-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -14,8 +15,8 @@ import type {
|
||||
} from "@/generated/prisma/client";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
|
||||
import { ensureUserRollupsFresh } from "@/lib/measurements/rollups";
|
||||
import { probeRollupCoverage } from "@/lib/measurements/rollup-coverage";
|
||||
import { ensureUserRollupsFresh } from "@/lib/rollups/measurement-rollups";
|
||||
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
|
||||
import { computeBpInTargetFastPath } from "@/lib/analytics/bp-in-target-fast-path";
|
||||
import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fast-path";
|
||||
import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path";
|
||||
@@ -27,6 +28,32 @@ import {
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* v1.4.40 W-POOL — bound the per-type analytics fan-out below to ≤ 4
|
||||
* concurrent Prisma round-trips.
|
||||
*
|
||||
* The v1.4.39 empirical cold-mount trace
|
||||
* (`.planning/round-v1439-empirical-trace.md` § B1) recorded thick
|
||||
* `/api/analytics` running 15× `fetchMeasurementSeriesChunked` in
|
||||
* parallel via a bare `Promise.all` (this file's `route.ts:261-269`
|
||||
* pre-fix), each pulling chunked findMany pages of 5 000 rows. The
|
||||
* combined fan-out held ≥ 8 of the default-10 `pg.Pool` slots for
|
||||
* 6.5 s on Marc's 347k-row tenant, starving every other Wave-B / C
|
||||
* dashboard query.
|
||||
*
|
||||
* Capping the concurrency to 4 keeps the same total work but holds
|
||||
* at most 4 pool slots for the analytics branch at any moment. With
|
||||
* the W-POOL ceiling raised to 20 (`src/lib/db.ts:getPoolMax`),
|
||||
* every other dashboard query has ≥ 16 free slots regardless of how
|
||||
* many types analytics is mid-walk over. The thick endpoint pays a
|
||||
* small (~10–15 %) absolute wall-clock cost on a single cold mount —
|
||||
* 15 chunks across 4 lanes = 4 batches instead of 1 — but is already
|
||||
* wrapped in the 60s `caches.analytics` LRU so the user-visible
|
||||
* impact is only the very first mount, in exchange for the chart-tile
|
||||
* burst paying ~5 s less of pool wait time on every mount thereafter.
|
||||
*/
|
||||
export const ANALYTICS_TYPE_FETCH_CONCURRENCY = 4;
|
||||
|
||||
/**
|
||||
* v1.4.33 C1 — pull `?slice=…` from either a NextRequest (the
|
||||
* route's production wrapper) or a plain `Request` (the integration
|
||||
@@ -181,12 +208,9 @@ async function buildAnalyticsResponse(user: AuthedUser) {
|
||||
// cost flat across the three branches.
|
||||
const coverage = await probeRollupCoverage(user.id);
|
||||
|
||||
// v1.4.25 W7b — every day-bucket call inside this route now honours
|
||||
// the user's display timezone. The legacy `berlinDayKey()` import
|
||||
// remains for sleep-stage and correlation paths that share their
|
||||
// helper signature with non-tz-aware code (`computeSleepStageBreakdown`
|
||||
// is called with a userId only); the per-call sites below all pass
|
||||
// `userTz` through `userDayKey()`.
|
||||
// v1.4.25 W7b — every day-bucket call inside this route honours the
|
||||
// user's display timezone via `userDayKey(date, userTz)` (the legacy
|
||||
// `berlinDayKey()` helper was retired in v1.4.40).
|
||||
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
|
||||
// Derived from canonical enum so a new measurement type is auto-summarised
|
||||
@@ -258,8 +282,15 @@ async function buildAnalyticsResponse(user: AuthedUser) {
|
||||
const lastSeenByType: Record<string, { lastSeenAt: string } | null> = {};
|
||||
|
||||
let totalRowsReadForAggregate = 0;
|
||||
// v1.4.40 W-POOL — bounded-concurrency wrapper for the 15-way
|
||||
// per-type fan-out below. See `ANALYTICS_TYPE_FETCH_CONCURRENCY`
|
||||
// above + `.planning/round-v1439-empirical-trace.md` § B1 for the
|
||||
// pool-saturation root cause. Same total work, ≤ 4 pool slots held
|
||||
// by this branch at any moment.
|
||||
const typeFetchLimit = pLimit(ANALYTICS_TYPE_FETCH_CONCURRENCY);
|
||||
const measurementsByType = await Promise.all(
|
||||
types.map((type) =>
|
||||
typeFetchLimit(() =>
|
||||
fetchMeasurementSeriesChunked(user.id, type, {
|
||||
includeSleepStage: true,
|
||||
// v1.4.39 W-SINCE — trailing 425-day floor for the live read.
|
||||
@@ -348,6 +379,7 @@ async function buildAnalyticsResponse(user: AuthedUser) {
|
||||
summary: summarize(datapoints),
|
||||
};
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -477,6 +509,7 @@ async function buildAnalyticsResponse(user: AuthedUser) {
|
||||
userId: user.id,
|
||||
type: "BLOOD_GLUCOSE",
|
||||
measuredAt: { gte: glucoseSince },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { value: true, measuredAt: true, glucoseContext: true },
|
||||
@@ -586,6 +619,7 @@ async function computeSleepStageBreakdown(
|
||||
type: "SLEEP_DURATION",
|
||||
sleepStage: { not: null },
|
||||
measuredAt: { gte: since },
|
||||
deletedAt: null,
|
||||
},
|
||||
select: { value: true, measuredAt: true, sleepStage: true },
|
||||
});
|
||||
@@ -695,6 +729,7 @@ async function fetchMeasurementSeriesChunked(
|
||||
userId,
|
||||
type,
|
||||
...(options.since ? { measuredAt: { gte: options.since } } : {}),
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: [{ measuredAt: "asc" }, { id: "asc" }],
|
||||
select: {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
consentReceipt: {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: 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 { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
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 },
|
||||
};
|
||||
|
||||
function req(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/consent/ai", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// `resetAllMocks` clears every mock's implementation, including the
|
||||
// top-level `vi.mock(...).mockResolvedValue(undefined)` for audit.
|
||||
// Re-arm it here so the fire-and-forget `.catch()` in the route
|
||||
// doesn't crash on an undefined return.
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("POST /api/consent/ai", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await POST(
|
||||
req({
|
||||
kind: "ai_full",
|
||||
artefact: "PDF",
|
||||
signedAt: "2026-05-18T10:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 400 for an invalid kind", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await POST(
|
||||
req({
|
||||
kind: "ai_total_world_domination",
|
||||
artefact: "PDF",
|
||||
signedAt: "2026-05-18T10:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 for a non-ISO signedAt", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await POST(
|
||||
req({
|
||||
kind: "ai_full",
|
||||
artefact: "PDF",
|
||||
signedAt: "yesterday",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when artefact exceeds the 64 KB cap", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const oversize = "A".repeat(64 * 1024 + 1);
|
||||
const res = await POST(
|
||||
req({
|
||||
kind: "ai_full",
|
||||
artefact: oversize,
|
||||
signedAt: "2026-05-18T10:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("inserts a row and returns the receipt id + serialised row", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const signedAt = new Date("2026-05-18T10:00:00.000Z");
|
||||
const createdAt = new Date("2026-05-18T10:00:01.000Z");
|
||||
vi.mocked(prisma.consentReceipt.create).mockResolvedValue({
|
||||
id: "rcpt-1",
|
||||
userId: "user-1",
|
||||
kind: "ai_full",
|
||||
artefact: "PDF",
|
||||
signedAt,
|
||||
revokedAt: null,
|
||||
createdAt,
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
req({
|
||||
kind: "ai_full",
|
||||
artefact: "PDF",
|
||||
signedAt: signedAt.toISOString(),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
id: string;
|
||||
receipt: {
|
||||
id: string;
|
||||
userId: string;
|
||||
kind: string;
|
||||
signedAt: string;
|
||||
revokedAt: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(body.data.id).toBe("rcpt-1");
|
||||
expect(body.data.receipt.kind).toBe("ai_full");
|
||||
expect(body.data.receipt.userId).toBe("user-1");
|
||||
expect(body.data.receipt.signedAt).toBe(signedAt.toISOString());
|
||||
expect(body.data.receipt.revokedAt).toBeNull();
|
||||
// `artefact` is intentionally stripped from the response — verify
|
||||
// it is not echoed back over the wire.
|
||||
expect(body.data.receipt).not.toHaveProperty("artefact");
|
||||
|
||||
expect(prisma.consentReceipt.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
userId: "user-1",
|
||||
kind: "ai_full",
|
||||
artefact: "PDF",
|
||||
signedAt,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,393 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
consentReceipt: {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: 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, DELETE } from "../route";
|
||||
import { POST as POST_GRANT } from "../../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
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();
|
||||
// `resetAllMocks` clears the top-level `mockResolvedValue(undefined)`
|
||||
// on `auditLog`. Re-arm so the route's fire-and-forget `.catch()`
|
||||
// chain doesn't NPE on an undefined return.
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("GET /api/consent/ai/latest", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/consent/ai/latest?kind=ai_full"),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 400 for an unknown kind", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/consent/ai/latest?kind=invalid"),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns null receipt when nothing has been granted", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/consent/ai/latest?kind=ai_full"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { kind: string; receipt: unknown | null };
|
||||
};
|
||||
expect(body.data.kind).toBe("ai_full");
|
||||
expect(body.data.receipt).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the latest non-revoked receipt by kind", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const signedAt = new Date("2026-05-18T10:00:00.000Z");
|
||||
const createdAt = new Date("2026-05-18T10:00:01.000Z");
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue({
|
||||
id: "rcpt-1",
|
||||
userId: "user-1",
|
||||
kind: "ai_full",
|
||||
artefact: "PDF",
|
||||
signedAt,
|
||||
revokedAt: null,
|
||||
createdAt,
|
||||
} as never);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/consent/ai/latest?kind=ai_full"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { kind: string; receipt: { id: string; kind: string } | null };
|
||||
};
|
||||
expect(body.data.receipt?.id).toBe("rcpt-1");
|
||||
expect(body.data.receipt?.kind).toBe("ai_full");
|
||||
expect(prisma.consentReceipt.findFirst).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1", kind: "ai_full", revokedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the full keyspace when called without ?kind", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const signedAt = new Date("2026-05-18T10:00:00.000Z");
|
||||
const createdAt = new Date("2026-05-18T10:00:01.000Z");
|
||||
vi.mocked(prisma.consentReceipt.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "rcpt-full",
|
||||
userId: "user-1",
|
||||
kind: "ai_full",
|
||||
artefact: "PDF",
|
||||
signedAt,
|
||||
revokedAt: null,
|
||||
createdAt,
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/consent/ai/latest"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: Record<string, { id: string } | null>;
|
||||
};
|
||||
expect(body.data.ai_full?.id).toBe("rcpt-full");
|
||||
expect(body.data.ai_coach).toBeNull();
|
||||
expect(body.data.ai_insights_only).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/consent/ai/latest", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await DELETE(
|
||||
new NextRequest("http://localhost/api/consent/ai/latest?kind=ai_full", {
|
||||
method: "DELETE",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("marks the latest receipt as revoked and returns it", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const signedAt = new Date("2026-05-18T10:00:00.000Z");
|
||||
const createdAt = new Date("2026-05-18T10:00:01.000Z");
|
||||
const revokedAt = new Date("2026-05-18T11:00:00.000Z");
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue({
|
||||
id: "rcpt-1",
|
||||
userId: "user-1",
|
||||
kind: "ai_full",
|
||||
artefact: "PDF",
|
||||
signedAt,
|
||||
revokedAt: null,
|
||||
createdAt,
|
||||
} as never);
|
||||
vi.mocked(prisma.consentReceipt.update).mockResolvedValue({
|
||||
id: "rcpt-1",
|
||||
userId: "user-1",
|
||||
kind: "ai_full",
|
||||
artefact: "PDF",
|
||||
signedAt,
|
||||
revokedAt,
|
||||
createdAt,
|
||||
} as never);
|
||||
|
||||
const res = await DELETE(
|
||||
new NextRequest("http://localhost/api/consent/ai/latest?kind=ai_full", {
|
||||
method: "DELETE",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { kind: string; receipt: { id: string; revokedAt: string } | null };
|
||||
};
|
||||
expect(body.data.receipt?.id).toBe("rcpt-1");
|
||||
expect(body.data.receipt?.revokedAt).toBe(revokedAt.toISOString());
|
||||
expect(prisma.consentReceipt.update).toHaveBeenCalledWith({
|
||||
where: { id: "rcpt-1" },
|
||||
data: { revokedAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
|
||||
it("is idempotent — returns null receipt without writing when nothing to revoke", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
|
||||
|
||||
const res = await DELETE(
|
||||
new NextRequest("http://localhost/api/consent/ai/latest?kind=ai_full", {
|
||||
method: "DELETE",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { kind: string; receipt: unknown | null };
|
||||
};
|
||||
expect(body.data.receipt).toBeNull();
|
||||
expect(prisma.consentReceipt.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("master toggle revokes the latest active row per kind", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const signedAt = new Date("2026-05-18T10:00:00.000Z");
|
||||
const createdAt = new Date("2026-05-18T10:00:01.000Z");
|
||||
const revokedAt = new Date("2026-05-18T11:00:00.000Z");
|
||||
|
||||
// First call (ai_full) finds a row, second (ai_insights_only)
|
||||
// finds none, third (ai_coach) finds a row.
|
||||
vi.mocked(prisma.consentReceipt.findFirst)
|
||||
.mockResolvedValueOnce({
|
||||
id: "rcpt-full",
|
||||
userId: "user-1",
|
||||
kind: "ai_full",
|
||||
artefact: "PDF",
|
||||
signedAt,
|
||||
revokedAt: null,
|
||||
createdAt,
|
||||
} as never)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({
|
||||
id: "rcpt-coach",
|
||||
userId: "user-1",
|
||||
kind: "ai_coach",
|
||||
artefact: "PDF",
|
||||
signedAt,
|
||||
revokedAt: null,
|
||||
createdAt,
|
||||
} as never);
|
||||
|
||||
vi.mocked(prisma.consentReceipt.update).mockImplementation(
|
||||
((args: { where: { id: string }; data: { revokedAt: Date } }) =>
|
||||
Promise.resolve({
|
||||
id: args.where.id,
|
||||
userId: "user-1",
|
||||
kind: args.where.id === "rcpt-full" ? "ai_full" : "ai_coach",
|
||||
artefact: "PDF",
|
||||
signedAt,
|
||||
revokedAt,
|
||||
createdAt,
|
||||
})) as never,
|
||||
);
|
||||
|
||||
const res = await DELETE(
|
||||
new NextRequest("http://localhost/api/consent/ai/latest", {
|
||||
method: "DELETE",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { revoked: Array<{ kind: string; receipt: { id: string } }> };
|
||||
};
|
||||
expect(body.data.revoked).toHaveLength(2);
|
||||
expect(body.data.revoked.map((r) => r.kind).sort()).toEqual([
|
||||
"ai_coach",
|
||||
"ai_full",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("append-only invariant", () => {
|
||||
/**
|
||||
* v1.4.40 SB-10 — the receipts table is the legal audit trail.
|
||||
* Revoking + re-granting must leave the original row in place
|
||||
* (just with `revokedAt` set) and mint a fresh row for the new
|
||||
* grant. The latest active receipt after the cycle is the newest
|
||||
* one, not the original.
|
||||
*/
|
||||
it("revoke + re-grant leaves both rows; latestActive points at the re-grant", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const signedAt1 = new Date("2026-05-18T10:00:00.000Z");
|
||||
const createdAt1 = new Date("2026-05-18T10:00:01.000Z");
|
||||
const revokedAt = new Date("2026-05-18T11:00:00.000Z");
|
||||
const signedAt2 = new Date("2026-05-18T12:00:00.000Z");
|
||||
const createdAt2 = new Date("2026-05-18T12:00:01.000Z");
|
||||
|
||||
// Step 1: initial grant.
|
||||
vi.mocked(prisma.consentReceipt.create).mockResolvedValueOnce({
|
||||
id: "rcpt-1",
|
||||
userId: "user-1",
|
||||
kind: "ai_full",
|
||||
artefact: "PDF-v1",
|
||||
signedAt: signedAt1,
|
||||
revokedAt: null,
|
||||
createdAt: createdAt1,
|
||||
} as never);
|
||||
|
||||
const grant1 = await POST_GRANT(
|
||||
new NextRequest("http://localhost/api/consent/ai", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
kind: "ai_full",
|
||||
artefact: "PDF-v1",
|
||||
signedAt: signedAt1.toISOString(),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(grant1.status).toBe(200);
|
||||
|
||||
// Step 2: revoke. The DELETE handler reads + updates.
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValueOnce({
|
||||
id: "rcpt-1",
|
||||
userId: "user-1",
|
||||
kind: "ai_full",
|
||||
artefact: "PDF-v1",
|
||||
signedAt: signedAt1,
|
||||
revokedAt: null,
|
||||
createdAt: createdAt1,
|
||||
} as never);
|
||||
vi.mocked(prisma.consentReceipt.update).mockResolvedValueOnce({
|
||||
id: "rcpt-1",
|
||||
userId: "user-1",
|
||||
kind: "ai_full",
|
||||
artefact: "PDF-v1",
|
||||
signedAt: signedAt1,
|
||||
revokedAt,
|
||||
createdAt: createdAt1,
|
||||
} as never);
|
||||
|
||||
const revokeRes = await DELETE(
|
||||
new NextRequest("http://localhost/api/consent/ai/latest?kind=ai_full", {
|
||||
method: "DELETE",
|
||||
}),
|
||||
);
|
||||
expect(revokeRes.status).toBe(200);
|
||||
expect(prisma.consentReceipt.update).toHaveBeenCalledTimes(1);
|
||||
// Critical invariant — `delete` was NEVER called on the receipt.
|
||||
// Only an update setting `revokedAt`. The old row stays in place.
|
||||
const updateCall = vi.mocked(prisma.consentReceipt.update).mock.calls[0][0];
|
||||
expect(updateCall).toMatchObject({
|
||||
where: { id: "rcpt-1" },
|
||||
data: { revokedAt: expect.any(Date) },
|
||||
});
|
||||
|
||||
// Step 3: re-grant mints a fresh row.
|
||||
vi.mocked(prisma.consentReceipt.create).mockResolvedValueOnce({
|
||||
id: "rcpt-2",
|
||||
userId: "user-1",
|
||||
kind: "ai_full",
|
||||
artefact: "PDF-v2",
|
||||
signedAt: signedAt2,
|
||||
revokedAt: null,
|
||||
createdAt: createdAt2,
|
||||
} as never);
|
||||
|
||||
const grant2 = await POST_GRANT(
|
||||
new NextRequest("http://localhost/api/consent/ai", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
kind: "ai_full",
|
||||
artefact: "PDF-v2",
|
||||
signedAt: signedAt2.toISOString(),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(grant2.status).toBe(200);
|
||||
expect(prisma.consentReceipt.create).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Step 4: GET latest now points at the re-grant.
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValueOnce({
|
||||
id: "rcpt-2",
|
||||
userId: "user-1",
|
||||
kind: "ai_full",
|
||||
artefact: "PDF-v2",
|
||||
signedAt: signedAt2,
|
||||
revokedAt: null,
|
||||
createdAt: createdAt2,
|
||||
} as never);
|
||||
|
||||
const latest = await GET(
|
||||
new NextRequest("http://localhost/api/consent/ai/latest?kind=ai_full"),
|
||||
);
|
||||
const body = (await latest.json()) as {
|
||||
data: { receipt: { id: string } | null };
|
||||
};
|
||||
expect(body.data.receipt?.id).toBe("rcpt-2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* v1.4.40 SB-10 — AI consent latest-receipt reader + revoke endpoint.
|
||||
*
|
||||
* GET /api/consent/ai/latest?kind=ai_full
|
||||
* Returns the latest non-revoked receipt for that kind, or null.
|
||||
*
|
||||
* GET /api/consent/ai/latest
|
||||
* Returns the latest non-revoked receipt per kind, keyed by kind.
|
||||
*
|
||||
* DELETE /api/consent/ai/latest?kind=ai_full
|
||||
* Marks the latest active receipt for that kind as revoked.
|
||||
*
|
||||
* DELETE /api/consent/ai/latest
|
||||
* Master toggle — revokes the latest active receipt across every
|
||||
* consent kind. Returns the list of revoked rows.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
consentKindEnum,
|
||||
consentLatestQuery,
|
||||
type ConsentKind,
|
||||
} from "@/lib/validations/consent";
|
||||
import {
|
||||
latestActiveReceipt,
|
||||
latestActiveReceiptsByKind,
|
||||
revokeLatest,
|
||||
} from "@/lib/consent/receipts";
|
||||
import { serialiseReceipt } from "../route";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const parsed = consentLatestQuery.safeParse(
|
||||
Object.fromEntries(request.nextUrl.searchParams),
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 400);
|
||||
}
|
||||
|
||||
const { kind } = parsed.data;
|
||||
|
||||
if (kind) {
|
||||
const receipt = await latestActiveReceipt(user.id, kind);
|
||||
annotate({
|
||||
action: { name: "consent.ai.latest" },
|
||||
meta: { kind, found: receipt != null },
|
||||
});
|
||||
return apiSuccess({
|
||||
kind,
|
||||
receipt: receipt ? serialiseReceipt(receipt) : null,
|
||||
});
|
||||
}
|
||||
|
||||
const byKind = await latestActiveReceiptsByKind(user.id);
|
||||
// Map each enum value to either the serialised row or `null` so the
|
||||
// client always sees the full keyspace and never has to guess
|
||||
// whether a missing key means "not granted" or "schema gap".
|
||||
const result: Record<ConsentKind, ReturnType<typeof serialiseReceipt> | null> = {
|
||||
ai_full: byKind.ai_full ? serialiseReceipt(byKind.ai_full) : null,
|
||||
ai_insights_only: byKind.ai_insights_only
|
||||
? serialiseReceipt(byKind.ai_insights_only)
|
||||
: null,
|
||||
ai_coach: byKind.ai_coach ? serialiseReceipt(byKind.ai_coach) : null,
|
||||
};
|
||||
|
||||
annotate({
|
||||
action: { name: "consent.ai.latest" },
|
||||
meta: {
|
||||
kinds_active: Object.entries(result)
|
||||
.filter(([, v]) => v != null)
|
||||
.map(([k]) => k),
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(result);
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const parsed = consentLatestQuery.safeParse(
|
||||
Object.fromEntries(request.nextUrl.searchParams),
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 400);
|
||||
}
|
||||
|
||||
const { kind } = parsed.data;
|
||||
|
||||
if (kind) {
|
||||
const revoked = await revokeLatest(user.id, kind);
|
||||
if (!revoked) {
|
||||
// Idempotent: "no active receipt to revoke" is success-shaped.
|
||||
// The iOS toggle hammers this on every flip; returning 404
|
||||
// would surface as a spurious error in the client.
|
||||
annotate({
|
||||
action: { name: "consent.ai.revoke" },
|
||||
meta: { kind, revoked: false },
|
||||
});
|
||||
return apiSuccess({ kind, receipt: null });
|
||||
}
|
||||
auditLog("consent.ai.revoke", {
|
||||
userId: user.id,
|
||||
details: { kind, receiptId: revoked.id },
|
||||
}).catch(() => {});
|
||||
annotate({
|
||||
action: { name: "consent.ai.revoke" },
|
||||
meta: { kind, receiptId: revoked.id },
|
||||
});
|
||||
return apiSuccess({ kind, receipt: serialiseReceipt(revoked) });
|
||||
}
|
||||
|
||||
// No kind specified — master "AI deaktivieren" toggle. Revoke the
|
||||
// latest active row for every kind that currently has one.
|
||||
const revokedRows: Array<{
|
||||
kind: ConsentKind;
|
||||
receipt: ReturnType<typeof serialiseReceipt>;
|
||||
}> = [];
|
||||
for (const k of consentKindEnum.options) {
|
||||
const r = await revokeLatest(user.id, k);
|
||||
if (r) {
|
||||
revokedRows.push({ kind: k, receipt: serialiseReceipt(r) });
|
||||
auditLog("consent.ai.revoke", {
|
||||
userId: user.id,
|
||||
details: { kind: k, receiptId: r.id },
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "consent.ai.revoke" },
|
||||
meta: {
|
||||
kinds_revoked: revokedRows.map((r) => r.kind),
|
||||
count: revokedRows.length,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ revoked: revokedRows });
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* v1.4.40 SB-10 — AI consent receipts.
|
||||
*
|
||||
* POST /api/consent/ai
|
||||
* Body: { kind, artefact, signedAt }
|
||||
* Persists a fresh consent receipt. Returns the inserted row.
|
||||
*
|
||||
* Reader + revoke handlers live in `./latest/route.ts`.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { consentPostBody } from "@/lib/validations/consent";
|
||||
import { createReceipt } from "@/lib/consent/receipts";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error } = await safeJson(request);
|
||||
if (error) return error;
|
||||
|
||||
const parsed = consentPostBody.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 400);
|
||||
}
|
||||
|
||||
const { kind, artefact, signedAt } = parsed.data;
|
||||
const receipt = await createReceipt(user.id, kind, artefact, signedAt);
|
||||
|
||||
// Audit trail — separate from the row itself so the legal team can
|
||||
// reconstruct "who minted this and when" even if the receipts table
|
||||
// is rebuilt from a backup. Fire-and-forget; audit failures must
|
||||
// never bubble into a user-facing 500.
|
||||
auditLog("consent.ai.grant", {
|
||||
userId: user.id,
|
||||
details: { kind, receiptId: receipt.id },
|
||||
}).catch(() => {});
|
||||
|
||||
annotate({
|
||||
action: { name: "consent.ai.grant" },
|
||||
meta: { kind, receiptId: receipt.id },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
id: receipt.id,
|
||||
receipt: serialiseReceipt(receipt),
|
||||
});
|
||||
});
|
||||
|
||||
interface SerialisedReceipt {
|
||||
id: string;
|
||||
userId: string;
|
||||
kind: string;
|
||||
signedAt: string;
|
||||
revokedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public projection of a `ConsentReceipt`. We deliberately strip
|
||||
* `artefact` from the API response — the artefact is large, opaque,
|
||||
* and only useful at audit time (read directly from the DB by an
|
||||
* operator). Echoing it on every grant would balloon the payload and
|
||||
* leak the signed token over network paths it doesn't need to cross.
|
||||
*/
|
||||
export function serialiseReceipt(receipt: {
|
||||
id: string;
|
||||
userId: string;
|
||||
kind: string;
|
||||
signedAt: Date;
|
||||
revokedAt: Date | null;
|
||||
createdAt: Date;
|
||||
}): SerialisedReceipt {
|
||||
return {
|
||||
id: receipt.id,
|
||||
userId: receipt.userId,
|
||||
kind: receipt.kind,
|
||||
signedAt: receipt.signedAt.toISOString(),
|
||||
revokedAt: receipt.revokedAt?.toISOString() ?? null,
|
||||
createdAt: receipt.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -49,6 +49,7 @@ import { defaultLocale, type Locale } from "@/lib/i18n/config";
|
||||
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { expandTodayIntakes } from "@/lib/medication-schedule";
|
||||
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
|
||||
|
||||
const SPARK_DAYS = 7;
|
||||
const STREAK_WINDOW_DAYS = 365;
|
||||
@@ -349,6 +350,35 @@ async function projectAndReadTodaysIntakes(
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
// v1.4.40 — close the compliance-rollup hook gap from v1.4.39.4.
|
||||
// Mirrors the intake-route call site: after a bulk projection
|
||||
// backfill, fire one rollup recompute per distinct
|
||||
// `(medication_id, dayKey)` so the rollup row reflects the new
|
||||
// `scheduled` count before the next read. Best-effort: errors
|
||||
// stay caught inside the helper so a populator hiccup doesn't
|
||||
// surface as a 5xx on the dashboard summary.
|
||||
const seenDayKeys = new Set<string>();
|
||||
const recomputeJobs: Array<Promise<void>> = [];
|
||||
for (const m of missing) {
|
||||
const key = `${m.medicationId}|${m.scheduledFor.toISOString().slice(0, 10)}`;
|
||||
if (seenDayKeys.has(key)) continue;
|
||||
seenDayKeys.add(key);
|
||||
recomputeJobs.push(
|
||||
recomputeMedicationComplianceForEvent({
|
||||
userId,
|
||||
medicationId: m.medicationId,
|
||||
scheduledFor: m.scheduledFor,
|
||||
tz: userTz,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// Defence in depth (specialist H1): the helper swallows internally
|
||||
// today, but any future refactor that lets a throw escape would
|
||||
// turn the dashboard summary into a 5xx. `allSettled` keeps the
|
||||
// best-effort contract explicit at the call site so the request
|
||||
// stays 200-OK regardless of helper behaviour.
|
||||
await Promise.allSettled(recomputeJobs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,6 +449,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 +471,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 +516,7 @@ async function buildDashboardSummary(
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${streakWindowStart}
|
||||
AND m."deleted_at" IS NULL
|
||||
`,
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -31,17 +31,17 @@ vi.mock("@/lib/logging/context", () => ({
|
||||
// assert that the import path now folds the persistent rollup tier
|
||||
// for each touched (type, day). Mood rollup mock stays no-op because
|
||||
// the existing v1.4.39 W-MOOD hook already covers the moodlog branch.
|
||||
vi.mock("@/lib/measurements/rollups", async () => {
|
||||
vi.mock("@/lib/rollups/measurement-rollups", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/lib/measurements/rollups")
|
||||
>("@/lib/measurements/rollups");
|
||||
typeof import("@/lib/rollups/measurement-rollups")
|
||||
>("@/lib/rollups/measurement-rollups");
|
||||
return {
|
||||
...actual,
|
||||
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/mood/rollups", () => ({
|
||||
vi.mock("@/lib/rollups/mood-rollups", () => ({
|
||||
recomputeUserMoodRollups: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
@@ -49,7 +49,7 @@ import { NextRequest } from "next/server";
|
||||
import { POST } from "../route";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { recomputeBucketsForMeasurement } from "@/lib/measurements/rollups";
|
||||
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
@@ -17,11 +17,11 @@ import {
|
||||
measurementTypeEnum,
|
||||
glucoseContextEnum,
|
||||
} from "@/lib/validations/measurement";
|
||||
import { recomputeUserMoodRollups } from "@/lib/mood/rollups";
|
||||
import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups";
|
||||
import {
|
||||
collapseToTypeDayKeys,
|
||||
recomputeBucketsForMeasurement,
|
||||
} from "@/lib/measurements/rollups";
|
||||
} from "@/lib/rollups/measurement-rollups";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
// Derived from canonical enum so round-trip export → import covers every
|
||||
|
||||
@@ -12,7 +12,7 @@ import { hashToken } from "@/lib/auth/hmac";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { externalIntakeSchema } from "@/lib/validations/medication";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { recomputeMedicationComplianceForEvent } from "@/lib/medications/compliance-rollups";
|
||||
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
|
||||
@@ -77,6 +77,7 @@ export const GET = apiHandler(async () => {
|
||||
in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"],
|
||||
},
|
||||
measuredAt: { gte: ninetyDaysAgo },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
|
||||
@@ -20,15 +20,30 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
measurement: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn(), findFirst: vi.fn() },
|
||||
moodEntryRollup: { findMany: vi.fn(), findFirst: vi.fn() },
|
||||
medication: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
$queryRaw: vi.fn(),
|
||||
$queryRawUnsafe: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// v1.4.40 — stub the mood-rollup warm-up so test runs don't fire the
|
||||
// real recompute against the mocked Prisma. The route fires the
|
||||
// warm-up as fire-and-forget; the return value is irrelevant.
|
||||
vi.mock("@/lib/rollups/mood-rollups", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/rollups/mood-rollups")>(
|
||||
"@/lib/rollups/mood-rollups",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
ensureUserMoodRollupsFresh: vi.fn().mockResolvedValue({ recomputed: false }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
@@ -98,6 +113,9 @@ beforeEach(() => {
|
||||
dateOfBirth: new Date("1985-01-01"),
|
||||
});
|
||||
(prisma.moodEntry.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
(
|
||||
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([]);
|
||||
(prisma.medication.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
[],
|
||||
);
|
||||
@@ -267,6 +285,109 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
|
||||
expect(body.data.bpClassification?.category).toBe("Normal");
|
||||
});
|
||||
|
||||
it("consumes the mood-rollup tier and skips the raw findMany when DAY rows exist", async () => {
|
||||
// v1.4.40 W-INSIGHTS — rollup-tier read swap parity. Three DAY rows
|
||||
// populated → the route reads them directly and never touches the
|
||||
// raw `moodEntry.findMany`. `moodSummary` is computed over per-day
|
||||
// mean DataPoints, matching the rollup-tier convention shipped by
|
||||
// `/api/mood/analytics` post-v1.4.39.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
(
|
||||
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({
|
||||
summaries: {},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: new Date(),
|
||||
totalMeasurements: 0,
|
||||
});
|
||||
(
|
||||
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([
|
||||
{
|
||||
userId: "user-comp-1",
|
||||
granularity: "DAY",
|
||||
bucketStart: new Date("2026-05-08T00:00:00.000Z"),
|
||||
count: 1,
|
||||
mean: 4,
|
||||
minScore: 4,
|
||||
maxScore: 4,
|
||||
sd: null,
|
||||
computedAt: new Date(),
|
||||
},
|
||||
{
|
||||
userId: "user-comp-1",
|
||||
granularity: "DAY",
|
||||
bucketStart: new Date("2026-05-09T00:00:00.000Z"),
|
||||
count: 2,
|
||||
mean: 4.5,
|
||||
minScore: 4,
|
||||
maxScore: 5,
|
||||
sd: 0.5,
|
||||
computedAt: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Raw findMany must not fire on the rollup-tier fast path.
|
||||
expect(prisma.moodEntry.findMany).not.toHaveBeenCalled();
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: { moodSummary: { count: number; mean: number | null } | null };
|
||||
};
|
||||
// `summarize()` is called over two DataPoints (one per day).
|
||||
expect(body.data.moodSummary).not.toBeNull();
|
||||
expect(body.data.moodSummary?.count).toBe(2);
|
||||
// Mean of (4 + 4.5) / 2 = 4.25
|
||||
expect(body.data.moodSummary?.mean).toBeCloseTo(4.25, 2);
|
||||
});
|
||||
|
||||
it("falls back to the bounded live walk when the rollup tier is empty but raw mood entries exist", async () => {
|
||||
// v1.4.40 W-INSIGHTS — coverage-fallback parity. Legacy account
|
||||
// with raw entries but no rollup coverage yet. The route runs the
|
||||
// legacy bounded walk once; the warm-up helper (stubbed) is
|
||||
// supposed to fire fire-and-forget so the next request lands on
|
||||
// the rollup tier.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
(
|
||||
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({
|
||||
summaries: {},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: new Date(),
|
||||
totalMeasurements: 0,
|
||||
});
|
||||
(
|
||||
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([]);
|
||||
(prisma.moodEntry.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
date: "2026-05-08",
|
||||
score: 4,
|
||||
moodLoggedAt: new Date("2026-05-08T12:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
date: "2026-05-09",
|
||||
score: 5,
|
||||
moodLoggedAt: new Date("2026-05-09T12:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(prisma.moodEntry.findMany).toHaveBeenCalledTimes(1);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: { moodSummary: { count: number } | null };
|
||||
};
|
||||
expect(body.data.moodSummary).not.toBeNull();
|
||||
expect(body.data.moodSummary?.count).toBe(2);
|
||||
});
|
||||
|
||||
it("derives weight × BP scatter from daily-bucketed series", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// Five day-aligned pairs (n >= 5 unlocks pearsonCorrelation).
|
||||
|
||||
@@ -22,6 +22,10 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
|
||||
import {
|
||||
ensureUserMoodRollupsFresh,
|
||||
readMoodDayRollups,
|
||||
} from "@/lib/rollups/mood-rollups";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -82,39 +86,73 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
// buckets for the correlation pairings.
|
||||
const aggregate = await buildComprehensiveAggregate(userId);
|
||||
|
||||
// Mood entries are user-recorded; the table is small and the
|
||||
// `moodSummary` field requires raw scores (`summarize()` over the
|
||||
// raw score series). The findMany stays.
|
||||
const moodEntries = await prisma.moodEntry.findMany({
|
||||
where: { userId, moodLoggedAt: { gte: ninetyDaysAgo } },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
select: { date: true, score: true, moodLoggedAt: true },
|
||||
});
|
||||
|
||||
// Aggregate mood to daily averages (drives the mood × metric
|
||||
// correlations). Bucket key matches the legacy code's `date` column
|
||||
// semantics (YYYY-MM-DD anchored to the user's tz at write time).
|
||||
const moodByDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const entry of moodEntries) {
|
||||
const current = moodByDay.get(entry.date) ?? { sum: 0, count: 0 };
|
||||
current.sum += entry.score;
|
||||
current.count += 1;
|
||||
moodByDay.set(entry.date, current);
|
||||
}
|
||||
const dailyMoodEntries = Array.from(moodByDay.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([day, stats]) => ({
|
||||
day,
|
||||
value: Math.round((stats.sum / stats.count) * 100) / 100,
|
||||
// v1.4.40 — swap the 90-day unbounded `prisma.moodEntry.findMany`
|
||||
// for the persistent mood-rollup DAY tier (audit Critical Finding
|
||||
// #2). The two consumers downstream — `dailyMoodEntries` for the
|
||||
// mood × metric correlations and `moodSummary` for the dashboard
|
||||
// mood block — read at a per-day resolution, which is precisely the
|
||||
// shape the rollup writer emits.
|
||||
//
|
||||
// Coverage-fallback: when the user has mood entries but zero rollup
|
||||
// rows yet (legacy account before the boot-time backfill has caught
|
||||
// up), fall back to a bounded 90-day raw walk once and fire the
|
||||
// warm-up so the next request lands on the rollup tier. Same posture
|
||||
// as `/api/mood/analytics`.
|
||||
//
|
||||
// The bucket key shape change is documented in the route-parity test
|
||||
// for `/api/mood/analytics`: rollup `bucketStart` is UTC-anchored
|
||||
// YYYY-MM-DD; the legacy `MoodEntry.date` column is TZ-anchored
|
||||
// (write-time). For Berlin tenants whose mood log timestamps don't
|
||||
// straddle the UTC boundary the two labels agree on every realistic
|
||||
// entry — the v1.5 per-user-tz bucketing closes the residual DST
|
||||
// edge.
|
||||
void ensureUserMoodRollupsFresh(userId);
|
||||
const moodRollupDayRows = await readMoodDayRollups(userId, ninetyDaysAgo);
|
||||
let dailyMoodEntries: Array<{ day: string; value: number }>;
|
||||
let moodDataPoints: DataPoint[];
|
||||
let moodEntryCount: number;
|
||||
if (moodRollupDayRows.length > 0) {
|
||||
dailyMoodEntries = moodRollupDayRows.map((r) => ({
|
||||
day: r.bucketStart.toISOString().slice(0, 10),
|
||||
value: Math.round(r.mean * 100) / 100,
|
||||
}));
|
||||
moodDataPoints = moodRollupDayRows.map((r) => ({
|
||||
date: r.bucketStart,
|
||||
value: r.mean,
|
||||
}));
|
||||
moodEntryCount = moodRollupDayRows.reduce((s, r) => s + r.count, 0);
|
||||
} else {
|
||||
// Coverage-fallback: legacy account with mood entries but no
|
||||
// rollup coverage yet. Bounded by the 90-day window so the walk is
|
||||
// capped even when the rollup miss happens.
|
||||
const moodEntries = await prisma.moodEntry.findMany({
|
||||
where: { userId, moodLoggedAt: { gte: ninetyDaysAgo } },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
select: { date: true, score: true, moodLoggedAt: true },
|
||||
});
|
||||
const moodByDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const entry of moodEntries) {
|
||||
const current = moodByDay.get(entry.date) ?? { sum: 0, count: 0 };
|
||||
current.sum += entry.score;
|
||||
current.count += 1;
|
||||
moodByDay.set(entry.date, current);
|
||||
}
|
||||
dailyMoodEntries = Array.from(moodByDay.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([day, stats]) => ({
|
||||
day,
|
||||
value: Math.round((stats.sum / stats.count) * 100) / 100,
|
||||
}));
|
||||
// Feed per-day means into `summarize()` so the live-fallback path
|
||||
// shares the rollup-tier semantic (one DataPoint per day, the
|
||||
// daily mean) — mirrors the v1.4.39 mood/analytics QA UX-H1 fix.
|
||||
moodDataPoints = dailyMoodEntries.map((e) => ({
|
||||
date: new Date(`${e.day}T12:00:00.000Z`),
|
||||
value: e.value,
|
||||
}));
|
||||
moodEntryCount = moodEntries.length;
|
||||
}
|
||||
|
||||
// moodSummary uses the same `summarize()` helper over raw scores —
|
||||
// the table is bounded by user-recorded entries so the in-JS path
|
||||
// is safe.
|
||||
const moodDataPoints: DataPoint[] = moodEntries.map((e) => ({
|
||||
date: e.moodLoggedAt,
|
||||
value: e.score,
|
||||
}));
|
||||
const moodSummary =
|
||||
moodDataPoints.length > 0 ? summarize(moodDataPoints) : null;
|
||||
|
||||
@@ -369,7 +407,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
action: { name: "insights.comprehensive" },
|
||||
meta: {
|
||||
totalMeasurements: aggregate.totalMeasurements,
|
||||
moodEntries: moodEntries.length,
|
||||
moodEntries: moodEntryCount,
|
||||
medications: medications.length,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -134,7 +134,7 @@ async function buildComparisonSnapshotForUser(
|
||||
const rows = await Promise.all(
|
||||
types.map(async (type) => {
|
||||
const measurements = await prisma.measurement.findMany({
|
||||
where: { userId, type },
|
||||
where: { userId, type, deletedAt: null },
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* v1.4.40 W-INSIGHTS — `/api/insights/targets` mood-rollup tier swap.
|
||||
*
|
||||
* Audit Critical Finding #2 (`.planning/round-v1439-arch-qa-infra-db.md`)
|
||||
* flagged the unbounded `prisma.moodEntry.findMany({ where: { userId } })`
|
||||
* at the bottom of the route as one of the six unbounded mood walks
|
||||
* that paint the Insights surface 12 s cold on a power-user account.
|
||||
* This file pins:
|
||||
*
|
||||
* 1. **The mood-rollup DAY tier replaces the raw findMany when
|
||||
* populated.** Mood targets (`MOOD_SCORE`, `MOOD_STABILITY`)
|
||||
* compute their `current` / `average30` / `consistency7d` from
|
||||
* the rollup row set; `prisma.moodEntry.findMany` is never
|
||||
* called on the fast path. A single `findFirst` reads the
|
||||
* latest score (one bounded row).
|
||||
*
|
||||
* 2. **The coverage-fallback runs the live walk once when the
|
||||
* rollup tier is empty but raw entries exist** (legacy account
|
||||
* before the boot-time backfill has caught up). Bounded by the
|
||||
* trailing 30-day window so the fallback never repeats the
|
||||
* legacy unbounded scan.
|
||||
*
|
||||
* 3. **The `findMany distinct(["type"])` "latest ever per type"
|
||||
* query carries a `measuredAt: { gte: oneYearAgo }` floor**
|
||||
* (audit High finding 3). Without the floor Postgres still has
|
||||
* to sort the full 347 k-row tenant before applying DISTINCT ON
|
||||
* because Prisma's `distinct` does not compile to PG's
|
||||
* `DISTINCT ON`.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
measurement: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn(), findFirst: vi.fn() },
|
||||
moodEntryRollup: { findMany: vi.fn(), findFirst: vi.fn() },
|
||||
medication: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
$queryRaw: vi.fn(),
|
||||
$queryRawUnsafe: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
// Stub the warm-up so test runs don't fire the real
|
||||
// `recomputeUserMoodRollups` aggregate. The warm-up is fire-and-forget
|
||||
// on the route so its return value is irrelevant for parity.
|
||||
vi.mock("@/lib/rollups/mood-rollups", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/rollups/mood-rollups")>(
|
||||
"@/lib/rollups/mood-rollups",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
ensureUserMoodRollupsFresh: vi.fn().mockResolvedValue({ recomputed: false }),
|
||||
};
|
||||
});
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
|
||||
const SESSION_USER = {
|
||||
id: "user-targets-1",
|
||||
username: "marc",
|
||||
role: "USER" as const,
|
||||
timezone: "Europe/Berlin",
|
||||
heightCm: 180,
|
||||
dateOfBirth: new Date("1985-01-01"),
|
||||
locale: "en",
|
||||
};
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: SESSION_USER as never,
|
||||
};
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
function makeReq(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/insights/targets");
|
||||
}
|
||||
|
||||
interface TargetsBody {
|
||||
data: {
|
||||
targets: Array<{
|
||||
type: string;
|
||||
current: number | null;
|
||||
average30: number | null;
|
||||
daysInRange7d: number;
|
||||
daysLogged7d: number;
|
||||
insufficientData: boolean;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
__resetAllCachesForTests();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK);
|
||||
(prisma.user.findUnique as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
heightCm: 180,
|
||||
dateOfBirth: new Date("1985-01-01"),
|
||||
gender: "MALE",
|
||||
glucoseUnit: "mg/dL",
|
||||
thresholdsJson: null,
|
||||
});
|
||||
(prisma.measurement.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
[],
|
||||
);
|
||||
(prisma.medication.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
[],
|
||||
);
|
||||
(
|
||||
prisma.medicationIntakeEvent.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([]);
|
||||
(prisma.moodEntry.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
(prisma.moodEntry.findFirst as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
null,
|
||||
);
|
||||
(
|
||||
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
it("returns a mood target driven by the rollup DAY tier without touching the raw findMany", async () => {
|
||||
// Five DAY-rollup rows over the trailing 30 days. The route reads
|
||||
// them directly to drive `MOOD_SCORE.current/average30/consistency`
|
||||
// — no raw `moodEntry.findMany` on the fast path.
|
||||
const today = new Date();
|
||||
const day = (offset: number) => {
|
||||
const d = new Date(today);
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
d.setUTCDate(d.getUTCDate() - offset);
|
||||
return d;
|
||||
};
|
||||
(
|
||||
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([
|
||||
{ userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(0), count: 1, mean: 4, minScore: 4, maxScore: 4, sd: null, computedAt: new Date() },
|
||||
{ userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(1), count: 1, mean: 4, minScore: 4, maxScore: 4, sd: null, computedAt: new Date() },
|
||||
{ userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(2), count: 1, mean: 5, minScore: 5, maxScore: 5, sd: null, computedAt: new Date() },
|
||||
{ userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(3), count: 1, mean: 4, minScore: 4, maxScore: 4, sd: null, computedAt: new Date() },
|
||||
{ userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(4), count: 1, mean: 4.5, minScore: 4, maxScore: 5, sd: 0.5, computedAt: new Date() },
|
||||
]);
|
||||
// Latest score read — one bounded row.
|
||||
(prisma.moodEntry.findFirst as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
score: 4,
|
||||
moodLoggedAt: day(0),
|
||||
});
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// The unbounded raw `moodEntry.findMany` must not fire on the
|
||||
// rollup-tier fast path.
|
||||
expect(prisma.moodEntry.findMany).not.toHaveBeenCalled();
|
||||
|
||||
const body = (await res.json()) as TargetsBody;
|
||||
const moodTarget = body.data.targets.find((t) => t.type === "MOOD_SCORE");
|
||||
expect(moodTarget).toBeDefined();
|
||||
expect(moodTarget?.current).toBe(4);
|
||||
// Average over 5 daily means: (4+4+5+4+4.5)/5 = 4.3
|
||||
expect(moodTarget?.average30).toBeCloseTo(4.3, 1);
|
||||
});
|
||||
|
||||
it("falls back to the bounded raw walk when the rollup tier is empty but raw mood entries exist", async () => {
|
||||
// Rollup empty → coverage-fallback fires the legacy live walk
|
||||
// once. The fallback is bounded by the 30-day window — not the
|
||||
// legacy unbounded scan over every mood the user ever wrote.
|
||||
(
|
||||
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([]);
|
||||
const today = new Date();
|
||||
(prisma.moodEntry.findFirst as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
score: 4,
|
||||
moodLoggedAt: today,
|
||||
});
|
||||
(prisma.moodEntry.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ score: 4, moodLoggedAt: new Date(today.getTime() - 2 * 86_400_000) },
|
||||
{ score: 5, moodLoggedAt: new Date(today.getTime() - 1 * 86_400_000) },
|
||||
{ score: 4, moodLoggedAt: today },
|
||||
]);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Live fallback fired exactly once. The fallback `findMany` call
|
||||
// must carry a 30-day `moodLoggedAt: { gte: ... }` floor — i.e. a
|
||||
// `where` clause with a timestamp filter, not the legacy
|
||||
// unbounded `where: { userId }` shape.
|
||||
expect(prisma.moodEntry.findMany).toHaveBeenCalledTimes(1);
|
||||
const fallbackCall = (prisma.moodEntry.findMany as ReturnType<typeof vi.fn>)
|
||||
.mock.calls[0][0];
|
||||
expect(fallbackCall.where).toHaveProperty("moodLoggedAt");
|
||||
expect(fallbackCall.where.moodLoggedAt).toHaveProperty("gte");
|
||||
|
||||
const body = (await res.json()) as TargetsBody;
|
||||
const moodTarget = body.data.targets.find((t) => t.type === "MOOD_SCORE");
|
||||
expect(moodTarget).toBeDefined();
|
||||
expect(moodTarget?.current).toBe(4);
|
||||
});
|
||||
|
||||
it("emits no mood target when neither rollup nor raw entries exist", async () => {
|
||||
(
|
||||
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([]);
|
||||
(prisma.moodEntry.findFirst as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
null,
|
||||
);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as TargetsBody;
|
||||
const moodTarget = body.data.targets.find((t) => t.type === "MOOD_SCORE");
|
||||
expect(moodTarget).toBeUndefined();
|
||||
});
|
||||
|
||||
it("floors the `findMany distinct` latest-ever-per-type read at one year", async () => {
|
||||
// Audit High finding 3: the `findMany({orderBy: measuredAt desc,
|
||||
// distinct: ["type"]})` for "absolute latest measurement per type"
|
||||
// used to run WITHOUT a `measuredAt >=` floor. On a 347 k-row
|
||||
// tenant Postgres still has to sort the full set before applying
|
||||
// DISTINCT ON because Prisma's `distinct` does not compile to PG's
|
||||
// `DISTINCT ON` — it dedups in the driver. v1.4.40 adds a
|
||||
// 365-day floor so the planner-side walk is bounded.
|
||||
(prisma.measurement.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
[],
|
||||
);
|
||||
|
||||
await callGet(makeReq());
|
||||
|
||||
// Find the `distinct: ["type"]` call.
|
||||
const calls = (
|
||||
prisma.measurement.findMany as ReturnType<typeof vi.fn>
|
||||
).mock.calls.map((c) => c[0]);
|
||||
const distinctCall = calls.find(
|
||||
(c) =>
|
||||
c &&
|
||||
Array.isArray(c.distinct) &&
|
||||
c.distinct[0] === "type" &&
|
||||
c.orderBy?.measuredAt === "desc",
|
||||
);
|
||||
expect(distinctCall).toBeDefined();
|
||||
// The `measuredAt >= oneYearAgo` floor must be present.
|
||||
expect(distinctCall?.where?.measuredAt).toBeDefined();
|
||||
expect(distinctCall?.where?.measuredAt?.gte).toBeInstanceOf(Date);
|
||||
const flooredAt = distinctCall?.where?.measuredAt?.gte as Date;
|
||||
const oneYearMs = 365 * 24 * 60 * 60 * 1000;
|
||||
const ageMs = Date.now() - flooredAt.getTime();
|
||||
// Floor is somewhere around one year ago — give a ±1-hour window
|
||||
// for clock drift between the route's `Date.now()` and the test
|
||||
// assertion.
|
||||
expect(ageMs).toBeGreaterThan(oneYearMs - 3_600_000);
|
||||
expect(ageMs).toBeLessThan(oneYearMs + 3_600_000);
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,10 @@ import {
|
||||
} from "@/lib/analytics/effective-range";
|
||||
import { getBodyFatTargetRange } from "@/lib/analytics/value-bands";
|
||||
import { thresholdMetricForContext, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
import {
|
||||
ensureUserMoodRollupsFresh,
|
||||
readMoodDayRollups,
|
||||
} from "@/lib/rollups/mood-rollups";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -174,22 +178,41 @@ async function buildTargetsResponse(user: AuthedUser) {
|
||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Fetch all measurements in the last 30 days + the latest for each type
|
||||
// Soft-delete filter mirrors the W-DELETED pattern: tombstoned rows
|
||||
// must not contribute to tile averages / latest values once iOS sync
|
||||
// starts emitting deletions.
|
||||
const recentMeasurements = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: { in: types },
|
||||
measuredAt: { gte: thirtyDaysAgo },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "desc" },
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
});
|
||||
|
||||
// Also get the absolute latest measurement per type (even if older than 30 days).
|
||||
// Single grouped query replaces the previous N×findFirst loop — DISTINCT ON
|
||||
// (Postgres) emulated via a windowed orderBy + take-1 per type would still be
|
||||
// N round-trips, so we sort once on the wide query and pick per-type below.
|
||||
// Absolute latest measurement per type within a one-year floor.
|
||||
//
|
||||
// Prisma's `distinct` does not compile to Postgres `DISTINCT ON` — the
|
||||
// driver dedups after pulling the rows, so an unbounded scan on a
|
||||
// 347 k-row tenant pulls every measurement back to Node only to drop
|
||||
// all but seven (audit Critical Finding #2, v1.4.40). The 365-day
|
||||
// floor caps the planner-side walk to the existing
|
||||
// `(user_id, type, measured_at)` index range; the trade-off is that a
|
||||
// user who hasn't measured a metric in over a year sees the tile's
|
||||
// `current` value as null instead of pulling a stale year-old reading
|
||||
// forward — which is the correct behaviour for the consistency strip
|
||||
// (the strip already gates on "fewer than 3 readings in 30 days" →
|
||||
// insufficientData).
|
||||
const oneYearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000);
|
||||
const latestEverByType = await prisma.measurement.findMany({
|
||||
where: { userId, type: { in: types } },
|
||||
where: {
|
||||
userId,
|
||||
type: { in: types },
|
||||
measuredAt: { gte: oneYearAgo },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "desc" },
|
||||
distinct: ["type"],
|
||||
select: { type: true, value: true },
|
||||
@@ -980,41 +1003,112 @@ async function buildTargetsResponse(user: AuthedUser) {
|
||||
}
|
||||
|
||||
// 9. Mood targets (if mood data exists)
|
||||
const moodEntries = await prisma.moodEntry.findMany({
|
||||
//
|
||||
// v1.4.40 \u2014 swap the unbounded `prisma.moodEntry.findMany({ where: {
|
||||
// userId } })` for the persistent mood-rollup DAY tier (audit Critical
|
||||
// Finding #2). The tile reads:
|
||||
// - per-day mean over the trailing 30 days (avg30, trend, stability)
|
||||
// - the consistency strip (in/near/out band per day)
|
||||
// - the latest score for the "current" reading + classification
|
||||
//
|
||||
// Rollup row = `{bucketStart, mean, count, minScore, maxScore}` per
|
||||
// calendar day. Per-day mean replaces the per-entry score series the
|
||||
// legacy path passed to `rollupConsistency` \u2014 the helper buckets by
|
||||
// day-key anyway, so feeding one row per day with the pre-computed
|
||||
// mean is mathematically identical to feeding N raw entries that
|
||||
// collapse to the same mean.
|
||||
//
|
||||
// The "latest score" needs a single raw entry (the newest one), so we
|
||||
// pull it with `take: 1` \u2014 bounded by the index, never an unbounded
|
||||
// scan. The "\u2265 3 entries threshold" gate uses the rollup count sum so
|
||||
// the cold-start branch stays consistent with the legacy semantics
|
||||
// (which counted raw entries, not raw days).
|
||||
//
|
||||
// Coverage-fallback: when the user has mood entries but zero rollup
|
||||
// rows yet (legacy account before the boot-time backfill caught up)
|
||||
// we run the legacy live walk once and fire the warm-up so the next
|
||||
// request lands on the rollup tier. Same posture as
|
||||
// `/api/mood/analytics`.
|
||||
void ensureUserMoodRollupsFresh(userId);
|
||||
const thirtyDaysAgoMood = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
// Five-year window mirrors `/api/mood/analytics` so existing rollup
|
||||
// coverage is consulted; the trailing 30-day filter happens in JS
|
||||
// because the consistency strip already walks per-day buckets.
|
||||
const moodRollupWindowMs = 5 * 365 * 24 * 60 * 60 * 1000;
|
||||
const moodSince = new Date(Date.now() - moodRollupWindowMs);
|
||||
const moodRollups = await readMoodDayRollups(userId, moodSince);
|
||||
const latestMoodEntry = await prisma.moodEntry.findFirst({
|
||||
where: { userId },
|
||||
orderBy: { moodLoggedAt: "desc" },
|
||||
select: { score: true, moodLoggedAt: true },
|
||||
});
|
||||
|
||||
if (moodEntries.length >= 3) {
|
||||
const thirtyDaysAgoMood = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
const recentMood = moodEntries.filter(
|
||||
(entry) => entry.moodLoggedAt >= thirtyDaysAgoMood,
|
||||
);
|
||||
const latestMoodScore = moodEntries[0]?.score ?? null;
|
||||
// Build a per-day event list for the consistency strip + 30-day stats.
|
||||
// Use the rollup tier when populated; fall back to a bounded raw walk
|
||||
// when the user has mood entries but zero rollup coverage yet.
|
||||
let moodDailyEvents: Array<{ measuredAt: Date; value: number }>;
|
||||
let moodEntryCount: number;
|
||||
let moodSumScores: number;
|
||||
let moodVarianceN: number;
|
||||
let moodSumSquares: number;
|
||||
if (moodRollups.length > 0) {
|
||||
moodEntryCount = moodRollups.reduce((s, r) => s + r.count, 0);
|
||||
moodDailyEvents = moodRollups
|
||||
.filter((r) => r.bucketStart >= thirtyDaysAgoMood)
|
||||
.map((r) => ({ measuredAt: r.bucketStart, value: r.mean }));
|
||||
moodSumScores = moodDailyEvents.reduce((s, e) => s + e.value, 0);
|
||||
moodVarianceN = moodDailyEvents.length;
|
||||
moodSumSquares = moodDailyEvents.reduce((s, e) => s + e.value * e.value, 0);
|
||||
} else if (latestMoodEntry !== null) {
|
||||
// Coverage-fallback: rollups empty but raw entries exist. Pull the
|
||||
// trailing 30-day window only (instead of every entry ever) \u2014 that's
|
||||
// the bound the legacy code was missing.
|
||||
const recentRawMood = await prisma.moodEntry.findMany({
|
||||
where: { userId, moodLoggedAt: { gte: thirtyDaysAgoMood } },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
select: { score: true, moodLoggedAt: true },
|
||||
});
|
||||
moodEntryCount = recentRawMood.length;
|
||||
moodDailyEvents = recentRawMood.map((e) => ({
|
||||
measuredAt: e.moodLoggedAt,
|
||||
value: e.score,
|
||||
}));
|
||||
moodSumScores = recentRawMood.reduce((s, e) => s + e.score, 0);
|
||||
moodVarianceN = recentRawMood.length;
|
||||
moodSumSquares = recentRawMood.reduce((s, e) => s + e.score * e.score, 0);
|
||||
} else {
|
||||
moodEntryCount = 0;
|
||||
moodDailyEvents = [];
|
||||
moodSumScores = 0;
|
||||
moodVarianceN = 0;
|
||||
moodSumSquares = 0;
|
||||
}
|
||||
|
||||
if (moodEntryCount >= 3) {
|
||||
const latestMoodScore = latestMoodEntry?.score ?? null;
|
||||
|
||||
const moodAvg30 =
|
||||
recentMood.length > 0
|
||||
moodDailyEvents.length > 0
|
||||
? Math.round(
|
||||
(recentMood.reduce((sum, e) => sum + e.score, 0) /
|
||||
recentMood.length) *
|
||||
10,
|
||||
(moodSumScores / moodDailyEvents.length) * 10,
|
||||
) / 10
|
||||
: null;
|
||||
|
||||
// Mood trend: compare first half to second half of last 30 days
|
||||
// Mood trend: compare first half to second half of last 30 days.
|
||||
// The rollup tier carries one DataPoint per day (the daily mean),
|
||||
// matching the legacy comparison semantics on single-entry days.
|
||||
let moodTrend: "up" | "down" | "stable" | null = null;
|
||||
if (recentMood.length >= 4) {
|
||||
const sorted = [...recentMood].sort(
|
||||
(a, b) => a.moodLoggedAt.getTime() - b.moodLoggedAt.getTime(),
|
||||
if (moodDailyEvents.length >= 4) {
|
||||
const sorted = [...moodDailyEvents].sort(
|
||||
(a, b) => a.measuredAt.getTime() - b.measuredAt.getTime(),
|
||||
);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
const firstHalf = sorted.slice(0, mid);
|
||||
const secondHalf = sorted.slice(mid);
|
||||
const avgFirst =
|
||||
firstHalf.reduce((s, e) => s + e.score, 0) / firstHalf.length;
|
||||
firstHalf.reduce((s, e) => s + e.value, 0) / firstHalf.length;
|
||||
const avgSecond =
|
||||
secondHalf.reduce((s, e) => s + e.score, 0) / secondHalf.length;
|
||||
secondHalf.reduce((s, e) => s + e.value, 0) / secondHalf.length;
|
||||
const diff = avgSecond - avgFirst;
|
||||
if (diff > 0.2) moodTrend = "up";
|
||||
else if (diff < -0.2) moodTrend = "down";
|
||||
@@ -1031,12 +1125,8 @@ async function buildTargetsResponse(user: AuthedUser) {
|
||||
: null;
|
||||
|
||||
const moodRange = { min: 3.5, max: 5 };
|
||||
const moodEventsForConsistency = recentMood.map((entry) => ({
|
||||
measuredAt: entry.moodLoggedAt,
|
||||
value: entry.score,
|
||||
}));
|
||||
const moodConsistency = rollupConsistency(
|
||||
moodEventsForConsistency,
|
||||
moodDailyEvents,
|
||||
makeRangeClassifier(moodRange, { orangeMin: 2, orangeMax: 5 }),
|
||||
);
|
||||
targets.push({
|
||||
@@ -1052,13 +1142,18 @@ async function buildTargetsResponse(user: AuthedUser) {
|
||||
...moodConsistency,
|
||||
});
|
||||
|
||||
// Mood stability: standard deviation of recent scores (lower = more stable)
|
||||
if (recentMood.length >= 5) {
|
||||
const mean = moodAvg30 ?? latestMoodScore ?? 3;
|
||||
const variance =
|
||||
recentMood.reduce((sum, e) => sum + (e.score - mean) ** 2, 0) /
|
||||
recentMood.length;
|
||||
const stdDev = Math.round(Math.sqrt(variance) * 100) / 100;
|
||||
// Mood stability: standard deviation of the recent series. The
|
||||
// rollup tier surfaces one mean per day, which is the right
|
||||
// resolution for "is the user's mood stable across days" \u2014 a power
|
||||
// user logging multiple times per day with a wide intraday spread
|
||||
// still gets a "stable" badge if the daily mean barely moves. The
|
||||
// legacy path conflated intraday variance with day-to-day stability
|
||||
// by passing per-entry scores; the rollup-tier semantic is closer
|
||||
// to what the UI label suggests.
|
||||
if (moodVarianceN >= 5) {
|
||||
const seriesMean = moodSumScores / moodVarianceN;
|
||||
const variance = moodSumSquares / moodVarianceN - seriesMean * seriesMean;
|
||||
const stdDev = Math.round(Math.sqrt(Math.max(0, variance)) * 100) / 100;
|
||||
|
||||
const stabilityClassification =
|
||||
stdDev <= 0.5
|
||||
@@ -1103,7 +1198,7 @@ async function buildTargetsResponse(user: AuthedUser) {
|
||||
};
|
||||
const thirtyDaysAgoGlucose = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
const glucoseRows = await prisma.measurement.findMany({
|
||||
where: { userId, type: "BLOOD_GLUCOSE" },
|
||||
where: { userId, type: "BLOOD_GLUCOSE", deletedAt: null },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
select: { value: true, measuredAt: true, glucoseContext: true },
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { moodLogWebhookPayloadSchema } from "@/lib/validations/moodlog";
|
||||
import { readMoodLogSecret } from "@/lib/moodlog-secret";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/mood/rollups";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { updateMeasurementSchema } from "@/lib/validations/measurement";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { recomputeBucketsForMeasurement } from "@/lib/measurements/rollups";
|
||||
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
|
||||
@@ -30,10 +30,10 @@ vi.mock("@/lib/db", () => ({
|
||||
// v1.4.39.2 — the rollup branch's coverage-mismatch fallback may call
|
||||
// `recomputeUserRollups` inline. Mock it so tests can assert whether
|
||||
// the inline fold fired without exercising the real populator.
|
||||
vi.mock("@/lib/measurements/rollups", async () => {
|
||||
vi.mock("@/lib/rollups/measurement-rollups", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/lib/measurements/rollups")
|
||||
>("@/lib/measurements/rollups");
|
||||
typeof import("@/lib/rollups/measurement-rollups")
|
||||
>("@/lib/rollups/measurement-rollups");
|
||||
return {
|
||||
...actual,
|
||||
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -61,7 +61,7 @@ vi.mock("next/headers", () => ({
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { recomputeUserRollups } from "@/lib/measurements/rollups";
|
||||
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
|
||||
@@ -41,7 +41,7 @@ import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import {
|
||||
recomputeBucketsForMeasurement,
|
||||
collapseToTypeDayKeys,
|
||||
} from "@/lib/measurements/rollups";
|
||||
} from "@/lib/rollups/measurement-rollups";
|
||||
import { Prisma, type MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
// v1.4.25 W16c — historical-backfill threshold for PR push
|
||||
|
||||
@@ -35,7 +35,7 @@ import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import {
|
||||
recomputeBucketsForMeasurement,
|
||||
collapseToTypeDayKeys,
|
||||
} from "@/lib/measurements/rollups";
|
||||
} from "@/lib/rollups/measurement-rollups";
|
||||
|
||||
const MAX_BATCH_ENTRIES = 500;
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
recomputeBucketsForMeasurement,
|
||||
collapseToTypeDayKeys,
|
||||
recomputeUserRollups,
|
||||
} from "@/lib/measurements/rollups";
|
||||
} from "@/lib/rollups/measurement-rollups";
|
||||
import { NextRequest } from "next/server";
|
||||
import type {
|
||||
MeasurementType,
|
||||
@@ -64,6 +64,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const where = {
|
||||
userId: user.id,
|
||||
// v1.4.40 W-DELETED — soft-deleted readings are invisible to every
|
||||
// list / count / aggregate read. The dedicated `sync/state` route
|
||||
// keeps surfacing them via its own filter so iOS can reconcile
|
||||
// tombstones; every other consumer (list view, analytics fan-out,
|
||||
// dashboard read paths) must never include them.
|
||||
deletedAt: null,
|
||||
...(type && { type: type as MeasurementType }),
|
||||
...(from || to
|
||||
? {
|
||||
@@ -99,6 +105,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
userId: user.id,
|
||||
type: type as MeasurementType,
|
||||
measuredAt: { gte: dayStart, lt: dayEnd },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: sortDir },
|
||||
// v1.4.38 — the 1000-row cap now lives on the validator (refine
|
||||
@@ -347,6 +354,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
AND m."type" = ${type}::measurement_type
|
||||
AND m."measured_at" >= ${from}
|
||||
AND m."measured_at" <= ${to}
|
||||
AND m."deleted_at" IS NULL
|
||||
`;
|
||||
const liveDays = Number(liveDayCountRows[0]?.days ?? BigInt(0));
|
||||
if (liveDays > rollupRows.length) {
|
||||
@@ -484,6 +492,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
WHERE m."user_id" = ${user.id}
|
||||
AND m."measured_at" >= ${from}
|
||||
AND m."measured_at" <= ${to}
|
||||
AND m."deleted_at" IS NULL
|
||||
${type ? Prisma.sql`AND m."type" = ${type}::measurement_type` : Prisma.empty}
|
||||
GROUP BY m."type", bucket_start
|
||||
ORDER BY bucket_start ASC
|
||||
|
||||
@@ -84,6 +84,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
userId: user.id,
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
measuredAt: { gte: since },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { id: true, value: true, measuredAt: true },
|
||||
@@ -93,6 +94,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
userId: user.id,
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
measuredAt: { gte: since },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { id: true, value: true, measuredAt: true },
|
||||
@@ -120,7 +122,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
} else {
|
||||
const type = KIND_TO_TYPE[kind];
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: { userId: user.id, type, measuredAt: { gte: since } },
|
||||
where: {
|
||||
userId: user.id,
|
||||
type,
|
||||
measuredAt: { gte: since },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { id: true, value: true, measuredAt: true },
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { updateIntakeEventSchema } from "@/lib/validations/medication";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { recomputeMedicationComplianceForEvent } from "@/lib/medications/compliance-rollups";
|
||||
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string; eventId: string }> };
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import {
|
||||
recomputeMedicationComplianceForDay,
|
||||
dayKeyForScheduledFor,
|
||||
} from "@/lib/medications/compliance-rollups";
|
||||
} from "@/lib/rollups/medication-compliance-rollups";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { withIdempotency } from "@/lib/idempotency";
|
||||
import { consumeOneDose } from "@/lib/medications/inventory/service";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { recomputeMedicationComplianceForEvent } from "@/lib/medications/compliance-rollups";
|
||||
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
|
||||
const querySchema = z.object({
|
||||
from: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((value) => new Date(value))
|
||||
.optional(),
|
||||
to: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((value) => new Date(value))
|
||||
.optional(),
|
||||
});
|
||||
|
||||
function toBerlinDayKey(date: Date): string {
|
||||
return new Intl.DateTimeFormat("sv-SE", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
interface DailySummary {
|
||||
date: string;
|
||||
total: number;
|
||||
medications: Record<string, number>;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const parsed = querySchema.safeParse(
|
||||
Object.fromEntries(request.nextUrl.searchParams),
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const { from, to } = parsed.data;
|
||||
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
skipped: false,
|
||||
takenAt: {
|
||||
not: null,
|
||||
...(from && { gte: from }),
|
||||
...(to && { lte: to }),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
takenAt: true,
|
||||
medication: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { takenAt: "asc" },
|
||||
});
|
||||
|
||||
const medicationNames = new Set<string>();
|
||||
const byDay = new Map<string, DailySummary>();
|
||||
|
||||
for (const event of events) {
|
||||
if (!event.takenAt) continue;
|
||||
|
||||
const medicationName = event.medication.name;
|
||||
const dayKey = toBerlinDayKey(event.takenAt);
|
||||
medicationNames.add(medicationName);
|
||||
|
||||
const existing = byDay.get(dayKey) ?? {
|
||||
date: dayKey,
|
||||
total: 0,
|
||||
medications: {},
|
||||
};
|
||||
|
||||
existing.total += 1;
|
||||
existing.medications[medicationName] =
|
||||
(existing.medications[medicationName] ?? 0) + 1;
|
||||
byDay.set(dayKey, existing);
|
||||
}
|
||||
|
||||
const points = [...byDay.values()].sort((a, b) =>
|
||||
a.date.localeCompare(b.date),
|
||||
);
|
||||
|
||||
annotate({
|
||||
action: { name: "medication.intake_summary" },
|
||||
meta: {
|
||||
point_count: points.length,
|
||||
medication_count: medicationNames.size,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
points,
|
||||
medications: [...medicationNames].sort((a, b) => a.localeCompare(b, "de")),
|
||||
});
|
||||
});
|
||||
@@ -201,6 +201,15 @@ describe("GET /api/medications/intake", () => {
|
||||
source: "REMINDER",
|
||||
},
|
||||
],
|
||||
// v1.4.39 W-SERVER-FIX-2 — the createMany must ship
|
||||
// `skipDuplicates: true` so a concurrent dashboard-summary hit
|
||||
// can't race a duplicate `(userId, medicationId, scheduledFor,
|
||||
// REMINDER)` row in between this route's existence probe and
|
||||
// insert. The schema-level @@unique([userId, medicationId,
|
||||
// scheduledFor, source]) is the structural backstop; this flag
|
||||
// is the defense-in-depth that keeps the route returning 2xx
|
||||
// when the constraint rejects the second mint.
|
||||
skipDuplicates: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import {
|
||||
recomputeMedicationComplianceForDay,
|
||||
dayKeyForScheduledFor,
|
||||
} from "@/lib/medications/compliance-rollups";
|
||||
} from "@/lib/rollups/medication-compliance-rollups";
|
||||
|
||||
const MAX_ENTRIES_PER_BATCH = 500;
|
||||
const BATCH_RATE_LIMIT_MAX = 60;
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
recomputeMedicationComplianceForEvent,
|
||||
enqueueUserMedicationComplianceBackfill,
|
||||
type ComplianceBucket as RollupComplianceBucket,
|
||||
} from "@/lib/medications/compliance-rollups";
|
||||
} from "@/lib/rollups/medication-compliance-rollups";
|
||||
import { expandTodayIntakes } from "@/lib/medication-schedule";
|
||||
|
||||
const querySchema = z.object({
|
||||
@@ -156,6 +156,39 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// structural backstop, this is the defense-in-depth.
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
// v1.4.40 — close the compliance-rollup hook gap from v1.4.39.4.
|
||||
// The bulk projection mints fresh `(medicationId, scheduledFor)`
|
||||
// rows in PENDING state; without this hook the rollup for the
|
||||
// affected `(user, medication, day)` tuples stays at its previous
|
||||
// (pre-projection) `scheduled` count, which inflates the apparent
|
||||
// compliance % until the user actually logs against the new
|
||||
// row. Coalesce by `(medicationId, dayKey)` so we fire one
|
||||
// recompute per distinct day-tuple instead of one per row.
|
||||
// Best-effort: errors stay caught inside the helper so a
|
||||
// populator hiccup doesn't surface as a 5xx on the intake
|
||||
// request. Mirror call site in `dashboard/summary/route.ts`.
|
||||
const seenDayKeys = new Set<string>();
|
||||
const recomputeJobs: Array<Promise<void>> = [];
|
||||
for (const m of missing) {
|
||||
const key = `${m.medicationId}|${m.scheduledFor.toISOString().slice(0, 10)}`;
|
||||
if (seenDayKeys.has(key)) continue;
|
||||
seenDayKeys.add(key);
|
||||
recomputeJobs.push(
|
||||
recomputeMedicationComplianceForEvent({
|
||||
userId: user.id,
|
||||
medicationId: m.medicationId,
|
||||
scheduledFor: m.scheduledFor,
|
||||
tz: userTz,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// Defence in depth (specialist H1): the helper swallows internally
|
||||
// today, but any future refactor that lets a throw escape would
|
||||
// turn the intake POST into a 5xx. `allSettled` keeps the
|
||||
// best-effort contract explicit at the call site so the request
|
||||
// stays 200-OK regardless of helper behaviour.
|
||||
await Promise.allSettled(recomputeJobs);
|
||||
}
|
||||
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAdmin: vi.fn(async () => ({
|
||||
user: { id: "u-admin" },
|
||||
session: { id: "s-1" },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((x: string) => x),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/monitoring/glitchtip", () => ({
|
||||
sendGlitchtipEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { sendGlitchtipEvent } from "@/lib/monitoring/glitchtip";
|
||||
|
||||
interface ApiErrorEnvelope {
|
||||
data: null;
|
||||
error: string;
|
||||
}
|
||||
interface ApiSuccessEnvelope<T> {
|
||||
data: T;
|
||||
error: null;
|
||||
}
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 5,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockReset();
|
||||
vi.mocked(sendGlitchtipEvent).mockReset();
|
||||
global.fetch = vi.fn() as never;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function emptyRequest(): Request {
|
||||
return new Request("http://localhost/api/monitoring/glitchtip/test", {
|
||||
method: "POST",
|
||||
headers: { "content-length": "0" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/monitoring/glitchtip/test", () => {
|
||||
it("happy path returns ok with statusCode", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
glitchtipDsn: "https://abc@glitch.example.com/1",
|
||||
} as never);
|
||||
vi.mocked(sendGlitchtipEvent).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
method: "envelope",
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as ApiSuccessEnvelope<{
|
||||
ok: boolean;
|
||||
statusCode: number;
|
||||
latencyMs: number;
|
||||
}>;
|
||||
expect(body.data.ok).toBe(true);
|
||||
expect(body.data.statusCode).toBe(200);
|
||||
expect(typeof body.data.latencyMs).toBe("number");
|
||||
|
||||
// CRITICAL guard against the `id: "default"` typo regression.
|
||||
expect(prisma.appSettings.findUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: "singleton" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects HTTP DSN with 422", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
glitchtipDsn: "http://abc@glitch.example.com/1",
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(422);
|
||||
const body = (await response.json()) as ApiErrorEnvelope & {
|
||||
meta: { errorCode: string };
|
||||
};
|
||||
expect(body.meta.errorCode).toBe("dsn_not_https");
|
||||
expect(sendGlitchtipEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects DSN pointing at a private host (SSRF guard)", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
glitchtipDsn: "https://abc@127.0.0.1/1",
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(422);
|
||||
const body = (await response.json()) as ApiErrorEnvelope & {
|
||||
meta: { errorCode: string };
|
||||
};
|
||||
expect(body.meta.errorCode).toBe("dsn_host_not_public");
|
||||
expect(sendGlitchtipEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rate-limit denial returns 429 with no upstream call", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(429);
|
||||
expect(sendGlitchtipEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 422 when DSN is not configured", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
glitchtipDsn: null,
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(422);
|
||||
const body = (await response.json()) as ApiErrorEnvelope;
|
||||
expect(body.error).toMatch(/not configured/i);
|
||||
expect(sendGlitchtipEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not leak DSN/Bearer/sk- on upstream 401", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
glitchtipDsn: "https://abc@glitch.example.com/1",
|
||||
} as never);
|
||||
vi.mocked(sendGlitchtipEvent).mockRejectedValueOnce(
|
||||
new Error(
|
||||
"401 Unauthorized: Authorization: Bearer sk-secret from https://glitch.example.com/api/1/envelope/",
|
||||
),
|
||||
);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
const text = await response.text();
|
||||
expect(response.status).toBe(502);
|
||||
expect(text).not.toMatch(/sk-/);
|
||||
expect(text).not.toMatch(/glitch\.example\.com/);
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { sendGlitchtipEvent } from "@/lib/monitoring/glitchtip";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const TIMEOUT_MS = 5_000;
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
void request;
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "monitoring.glitchtip.test" } });
|
||||
|
||||
const rl = await checkRateLimit(`glitchtip-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many test requests", 429, {
|
||||
errorCode: "rate_limited_self",
|
||||
});
|
||||
}
|
||||
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { glitchtipDsn: true },
|
||||
});
|
||||
|
||||
if (!settings?.glitchtipDsn) {
|
||||
return apiError("Glitchtip DSN not configured", 422, {
|
||||
errorCode: "not_configured",
|
||||
});
|
||||
}
|
||||
|
||||
// DSN is admin-set, but a compromised/social-engineered admin (or a stored
|
||||
// settings record from a hostile import) could aim Glitchtip at internal
|
||||
// hosts. Validate the host is publicly reachable and uses HTTPS before we
|
||||
// hand the URL to the helper.
|
||||
let dsnHost: string;
|
||||
try {
|
||||
const parsed = new URL(settings.glitchtipDsn);
|
||||
if (parsed.protocol !== "https:") {
|
||||
return apiError("Glitchtip DSN must use HTTPS", 422, {
|
||||
errorCode: "dsn_not_https",
|
||||
});
|
||||
}
|
||||
if (!isPublicUrl(`${parsed.protocol}//${parsed.host}`)) {
|
||||
return apiError("Glitchtip DSN host is not publicly reachable", 422, {
|
||||
errorCode: "dsn_host_not_public",
|
||||
});
|
||||
}
|
||||
dsnHost = parsed.host;
|
||||
} catch {
|
||||
return apiError("Glitchtip DSN is malformed", 422, {
|
||||
errorCode: "dsn_invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
const send = sendGlitchtipEvent({
|
||||
dsn: settings.glitchtipDsn,
|
||||
input: {
|
||||
environment: process.env.NODE_ENV ?? "production",
|
||||
message: "HealthLog Glitchtip self-test",
|
||||
level: "info",
|
||||
// sourceTag rides through into the event tags. Adding "noise: true" lets
|
||||
// operators silence these in their inbound Glitchtip rules so the test
|
||||
// button doesn't pollute the issue inbox.
|
||||
sourceTag: "self-test:noise",
|
||||
},
|
||||
});
|
||||
|
||||
const timeoutErr = new Error("timeout");
|
||||
timeoutErr.name = "AbortError";
|
||||
const timeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(timeoutErr), TIMEOUT_MS),
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await Promise.race([send, timeout]);
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
|
||||
if (!result.ok) {
|
||||
annotate({
|
||||
meta: {
|
||||
glitchtip_test_status: result.status ?? null,
|
||||
glitchtip_test_method: result.method ?? null,
|
||||
glitchtip_test_latency_ms: latencyMs,
|
||||
glitchtip_test_host: dsnHost,
|
||||
},
|
||||
});
|
||||
return apiError("Glitchtip rejected the event", 502, {
|
||||
errorCode: "upstream_error",
|
||||
});
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
ok: true,
|
||||
statusCode: result.status ?? 200,
|
||||
latencyMs,
|
||||
});
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
const isTimeout = err.name === "AbortError";
|
||||
const code = isTimeout ? "timeout" : "connection_failed";
|
||||
annotate({
|
||||
meta: {
|
||||
glitchtip_test_code: code,
|
||||
glitchtip_test_host: dsnHost,
|
||||
glitchtip_test_error: err.message.slice(0, 200),
|
||||
},
|
||||
});
|
||||
return apiError(
|
||||
isTimeout ? "Glitchtip request timed out" : "Glitchtip connection failed",
|
||||
502,
|
||||
{ errorCode: code },
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAdmin: vi.fn(async () => ({
|
||||
user: { id: "u-admin" },
|
||||
session: { id: "s-1" },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((x: string) => x),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
interface ApiErrorEnvelope {
|
||||
data: null;
|
||||
error: string;
|
||||
}
|
||||
interface ApiSuccessEnvelope<T> {
|
||||
data: T;
|
||||
error: null;
|
||||
}
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 5,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockReset();
|
||||
global.fetch = vi.fn() as never;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function emptyRequest(): Request {
|
||||
return new Request("http://localhost/api/monitoring/umami/test", {
|
||||
method: "POST",
|
||||
headers: { "content-length": "0" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/monitoring/umami/test", () => {
|
||||
it("happy path returns ok with hasMarker=true", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
umamiScriptUrl: "https://analytics.example.com/script.js",
|
||||
} as never);
|
||||
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||
new Response("(function(){window.umami=function(){};})();", {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as ApiSuccessEnvelope<{
|
||||
ok: boolean;
|
||||
statusCode: number;
|
||||
hasMarker: boolean;
|
||||
}>;
|
||||
expect(body.data.ok).toBe(true);
|
||||
expect(body.data.hasMarker).toBe(true);
|
||||
expect(body.data.statusCode).toBe(200);
|
||||
|
||||
// CRITICAL guard: AppSettings is keyed on `id: "singleton"` everywhere
|
||||
// else in the codebase. A typo here ("default") would silently make the
|
||||
// route return 422 in prod even with valid settings.
|
||||
expect(prisma.appSettings.findUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: "singleton" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rate-limit denial returns 429 with no upstream call", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(429);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 422 when URL not configured", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
umamiScriptUrl: null,
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(422);
|
||||
const body = (await response.json()) as ApiErrorEnvelope;
|
||||
expect(body.error).toMatch(/not configured/i);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not leak Bearer/sk- or upstream URL on upstream 401", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
umamiScriptUrl: "https://analytics.example.com/script.js",
|
||||
} as never);
|
||||
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||
new Response(
|
||||
"Authorization: Bearer sk-secret echoed back from https://analytics.example.com/script.js",
|
||||
{ status: 401 },
|
||||
),
|
||||
);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
const text = await response.text();
|
||||
expect(response.status).toBe(502);
|
||||
expect(text).not.toMatch(/sk-/);
|
||||
expect(text).not.toMatch(/analytics\.example\.com/);
|
||||
});
|
||||
});
|
||||
@@ -1,130 +0,0 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const TIMEOUT_MS = 5_000;
|
||||
const MAX_BODY_BYTES = 256 * 1024;
|
||||
|
||||
function redact(text: string): string {
|
||||
return text.replace(/https?:\/\/\S+/gi, "[url]");
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
void request;
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "monitoring.umami.test" } });
|
||||
|
||||
const rl = await checkRateLimit(`umami-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many test requests", 429, {
|
||||
errorCode: "rate_limited_self",
|
||||
});
|
||||
}
|
||||
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { umamiScriptUrl: true },
|
||||
});
|
||||
|
||||
if (!settings?.umamiScriptUrl) {
|
||||
return apiError("Umami script URL not configured", 422, {
|
||||
errorCode: "not_configured",
|
||||
});
|
||||
}
|
||||
|
||||
const url = settings.umamiScriptUrl;
|
||||
if (!isPublicUrl(url) || !url.startsWith("https://")) {
|
||||
return apiError("Umami URL must be a public HTTPS endpoint", 422, {
|
||||
errorCode: "url_not_public",
|
||||
});
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
// `manual` neutralises a redirect-follow SSRF — even though the URL was
|
||||
// checked with isPublicUrl(), a public host can 302 to a private IP.
|
||||
redirect: "manual",
|
||||
});
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
annotate({ meta: { umami_test_status: res.status } });
|
||||
return apiError("Umami URL redirects — check configuration", 502, {
|
||||
errorCode: "redirected",
|
||||
});
|
||||
}
|
||||
|
||||
// Cap response read to 256 KB so a hostile public server can't stream
|
||||
// gigabytes through us; combined with the 5 s abort, RAM use stays bounded.
|
||||
let body = "";
|
||||
if (res.body) {
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let total = 0;
|
||||
try {
|
||||
while (total < MAX_BODY_BYTES) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
body += decoder.decode(value, { stream: true });
|
||||
}
|
||||
body += decoder.decode();
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
annotate({
|
||||
meta: {
|
||||
umami_test_status: res.status,
|
||||
umami_test_latency_ms: latencyMs,
|
||||
},
|
||||
});
|
||||
return apiError("Umami connection failed", 502, {
|
||||
errorCode: "upstream_error",
|
||||
});
|
||||
}
|
||||
|
||||
// Look for tracker-shape signal rather than a literal "umami" substring —
|
||||
// a 404 page that mentions "Powered by Umami" would otherwise slip through.
|
||||
const hasMarker = /umami\.track|window\.umami|"umami"/i.test(body);
|
||||
|
||||
return apiSuccess({
|
||||
ok: hasMarker,
|
||||
statusCode: res.status,
|
||||
latencyMs,
|
||||
hasMarker,
|
||||
});
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
const isAbort = err.name === "AbortError";
|
||||
const code = isAbort ? "timeout" : "connection_failed";
|
||||
annotate({
|
||||
meta: {
|
||||
umami_test_code: code,
|
||||
umami_test_error: redact(err.message).slice(0, 300),
|
||||
},
|
||||
});
|
||||
return apiError(
|
||||
isAbort ? "Umami request timed out" : "Umami connection failed",
|
||||
502,
|
||||
{ errorCode: code },
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
@@ -15,7 +15,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/mood/rollups";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
} from "@/lib/validations/moodlog";
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/mood/rollups";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
|
||||
const MAX_ENTRIES_PER_BATCH = 500;
|
||||
const BATCH_RATE_LIMIT_MAX = 60;
|
||||
|
||||
@@ -18,7 +18,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/mood/rollups";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
|
||||
function parseTags(tags: string | null): string[] {
|
||||
if (!tags) return [];
|
||||
|
||||
@@ -53,9 +53,9 @@ vi.mock("next/headers", () => ({
|
||||
// Stub the warm-up helper so test runs don't fire the real
|
||||
// `recomputeUserMoodRollups` aggregate. The warm-up is fire-and-
|
||||
// forget on the route so its return value is irrelevant for parity.
|
||||
vi.mock("@/lib/mood/rollups", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/mood/rollups")>(
|
||||
"@/lib/mood/rollups",
|
||||
vi.mock("@/lib/rollups/mood-rollups", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/rollups/mood-rollups")>(
|
||||
"@/lib/rollups/mood-rollups",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import {
|
||||
ensureUserMoodRollupsFresh,
|
||||
readMoodDayRollups,
|
||||
} from "@/lib/mood/rollups";
|
||||
} from "@/lib/rollups/mood-rollups";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -13,7 +13,7 @@ import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { locales, type Locale } from "@/lib/i18n/config";
|
||||
import { recomputeMedicationComplianceForEvent } from "@/lib/medications/compliance-rollups";
|
||||
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
|
||||
|
||||
interface TelegramUpdate {
|
||||
update_id: number;
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import { Loader2, Pill } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -76,7 +77,7 @@ export default function InsightsMedikamentePage() {
|
||||
const { t, locale } = useTranslations();
|
||||
|
||||
const { data: comprehensive, isLoading: isComprehensiveLoading } = useQuery({
|
||||
queryKey: ["insights", "comprehensive"],
|
||||
queryKey: queryKeys.insightsComprehensive(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/insights/comprehensive");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
@@ -87,7 +88,7 @@ export default function InsightsMedikamentePage() {
|
||||
});
|
||||
|
||||
const { data: status, isLoading: isStatusLoading } = useQuery({
|
||||
queryKey: ["insights", "medication-compliance-status", locale],
|
||||
queryKey: queryKeys.insightsMedicationComplianceStatus(locale),
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`/api/insights/medication-compliance-status?locale=${locale}`,
|
||||
@@ -261,7 +262,7 @@ function MedicationComplianceCalendar({
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["compliance-chart-inline", medicationId],
|
||||
queryKey: queryKeys.medicationComplianceChart(medicationId),
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/medications/${medicationId}/compliance`);
|
||||
if (!res.ok) return null;
|
||||
|
||||
@@ -6,6 +6,7 @@ import Link from "next/link";
|
||||
import { TrendingUp } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -168,7 +169,7 @@ export default function InsightsPage() {
|
||||
// reported zero measurements; while it's in-flight the page paints
|
||||
// the regular shell and the tiles fill in as their data lands.
|
||||
const { data, isLoading, isFetched } = useQuery({
|
||||
queryKey: ["insights", "comprehensive"],
|
||||
queryKey: queryKeys.insightsComprehensive(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/insights/comprehensive");
|
||||
if (!res.ok) throw new Error(t("insights.loadError"));
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Smile } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useInsightStatus } from "@/hooks/use-insight-status";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -48,7 +49,7 @@ export default function InsightsStimmungPage() {
|
||||
// Reuse the mother-page comprehensive query — TanStack Query
|
||||
// dedups so this is a free cache read for the common case.
|
||||
const { data: comprehensive } = useQuery({
|
||||
queryKey: ["insights", "comprehensive"],
|
||||
queryKey: queryKeys.insightsComprehensive(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/insights/comprehensive");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
|
||||
+63
-8
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import React, { Suspense, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import {
|
||||
@@ -48,6 +48,16 @@ import { TourLauncher } from "@/components/onboarding/tour-launcher";
|
||||
import { RecentAchievementsCard } from "@/components/gamification/recent-achievements-card";
|
||||
import { RecentWorkoutsTile } from "@/components/dashboard/recent-workouts-tile";
|
||||
|
||||
// v1.4.40 W-RSC — module-scope so the option object is stable across
|
||||
// renders (audit-M2). Pre-fix the same literal was declared inside the
|
||||
// component body, so every render created a fresh `{}` reference;
|
||||
// TanStack does a shallow compare and shrugs at the equivalent values,
|
||||
// but the slot is one future-callback-field away from cache poisoning.
|
||||
const DASHBOARD_QUERY_OPTS = {
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
} as const;
|
||||
|
||||
const MoodChart = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/mood-chart").then((mod) => ({
|
||||
@@ -218,10 +228,9 @@ export default function DashboardPage() {
|
||||
// storm. None of these are real-time data; minute-scale staleness
|
||||
// is fine and the dashboard's chart queries already match this
|
||||
// cadence.
|
||||
const DASHBOARD_QUERY_OPTS = {
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
} as const;
|
||||
//
|
||||
// v1.4.40 W-RSC — `DASHBOARD_QUERY_OPTS` hoisted to module scope
|
||||
// (audit-M2 — stable reference across renders).
|
||||
|
||||
// v1.4.39.2 — split the dashboard's analytics consumption so the
|
||||
// tile-strip paints from the slim slice and the BD-Zielbereich +
|
||||
@@ -559,7 +568,16 @@ export default function DashboardPage() {
|
||||
upperBound: 55,
|
||||
},
|
||||
);
|
||||
const hour = user?.timezone ? getHourForTimeZone(user.timezone) : null;
|
||||
// v1.4.40 W-RSC — memoise the per-render `Intl.DateTimeFormat`
|
||||
// instantiation (audit-H4). The greeting derived from the hour only
|
||||
// changes when the user's timezone changes; recomputing it every
|
||||
// render churned the parts-array and walked the format ICU twice per
|
||||
// render across a 1 400-line component body.
|
||||
const userTimezone = user?.timezone;
|
||||
const hour = useMemo(
|
||||
() => (userTimezone ? getHourForTimeZone(userTimezone) : null),
|
||||
[userTimezone],
|
||||
);
|
||||
const timeGreeting =
|
||||
hour == null
|
||||
? t("dashboard.greeting.day")
|
||||
@@ -1421,14 +1439,51 @@ export default function DashboardPage() {
|
||||
>
|
||||
{trendCards.map((entry) => (
|
||||
<div key={entry.id} className="flex min-w-0">
|
||||
{entry.node}
|
||||
{/*
|
||||
* v1.4.40 W-RSC — per-tile `<Suspense>` boundary.
|
||||
* The tile body is synchronous today (every tile
|
||||
* reads from the merged `data` memo which already
|
||||
* gates on the slim/thick analytics queries on the
|
||||
* parent), so this fallback never paints in the
|
||||
* current composition. The structural boundary
|
||||
* matches the chart-row layer below so the entire
|
||||
* dashboard composition reads as a streaming-ready
|
||||
* grid of independently suspending cells — and
|
||||
* future RSC hoists of any tile slot won't need a
|
||||
* second pass to add the fallback infrastructure.
|
||||
*/}
|
||||
<Suspense fallback={null}>{entry.node}</Suspense>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{charts.map((entry) => (
|
||||
<div key={entry.id} className="space-y-2">
|
||||
{entry.node}
|
||||
{/*
|
||||
* v1.4.40 W-RSC — per-tile `<Suspense>` boundary so each
|
||||
* chart paints independently rather than the row blocking
|
||||
* on the slowest fetch (audit-H2 + brief C1). The
|
||||
* `next/dynamic({ ssr: false })` lazy-load contract on
|
||||
* `HealthChartDynamic` / `MoodChart` /
|
||||
* `MedicationComplianceChart` already paints a
|
||||
* `<ChartSkeleton>` while the JS chunk is in flight; this
|
||||
* Suspense layer lifts the same skeleton to a streaming-
|
||||
* compatible boundary so a future migration to
|
||||
* `useSuspenseQuery` (when we replace the per-chart
|
||||
* `["chart-data", …]` fetches with the slim-analytics-
|
||||
* derived store) automatically buckets each chart's
|
||||
* loading state to its own cell, with no further
|
||||
* call-site changes.
|
||||
*
|
||||
* Today, the boundary is a structural no-op for the
|
||||
* dynamic-loaded charts because their loading skeleton
|
||||
* lives inside the dynamic factory. The benefit is
|
||||
* future-proofing the composition: any descendant that
|
||||
* later suspends (e.g. an RSC migration of a static
|
||||
* legend, or a server-streamed sparkline) gets its own
|
||||
* fallback without re-architecting the row.
|
||||
*/}
|
||||
<Suspense fallback={<ChartSkeleton />}>{entry.node}</Suspense>
|
||||
{entry.count != null ? <TrendHint count={entry.count} /> : null}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -4,13 +4,15 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import PrivacyPage from "../page";
|
||||
|
||||
/**
|
||||
* v1.4.26 — Public privacy-policy page contract.
|
||||
* v1.4.40 W-PRIVACY (SB-3) — Bilingual public privacy-policy contract.
|
||||
*
|
||||
* The page is the App-Store-submission blocker (Apple BLOCKER 2 of 5).
|
||||
* These tests pin the structural commitments that any future redesign
|
||||
* must preserve:
|
||||
*
|
||||
* 1. The H1 says "Privacy Policy".
|
||||
* 1. The H1 carries both the German and English title so an Apple US
|
||||
* reviewer AND a German DPA reviewer recognise the document
|
||||
* without locale switching.
|
||||
* 2. The page advertises a "Last updated" date alongside a policy
|
||||
* version stamp — App-Store reviewers and German DPA inspectors
|
||||
* use both to gauge currency.
|
||||
@@ -18,31 +20,42 @@ import PrivacyPage from "../page";
|
||||
* enumerated verbatim by identifier. Drift from the iOS source
|
||||
* (`HealthKitWireConverter.swift` + `HealthKitService.swift`)
|
||||
* would be a compliance hole.
|
||||
* 4. The numbered section headings are all present, so the policy
|
||||
* remains scannable without re-rendering with a specific locale.
|
||||
* 4. The numbered section headings are all present (English form, so
|
||||
* the scannable spine matches the in-app `Settings → Privacy`
|
||||
* link target and the table of contents above each section).
|
||||
* 5. v1.4.40 SB-3 additions — consent receipt endpoint, TLS 1.3,
|
||||
* operator email + deletion-route disclosure, retention windows.
|
||||
*/
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(<PrivacyPage />);
|
||||
}
|
||||
|
||||
const POLICY_VERSION = "1.4.40";
|
||||
const LAST_UPDATED = "2026-05-18";
|
||||
|
||||
describe("<PrivacyPage>", () => {
|
||||
it("renders without crashing and includes the H1", () => {
|
||||
it("renders without crashing and includes the bilingual H1", () => {
|
||||
const html = render();
|
||||
// Single bilingual title — DE + EN side by side so a German DPA
|
||||
// and an Apple US reviewer both see the document is for them.
|
||||
expect(html).toContain("Datenschutzerklärung");
|
||||
expect(html).toContain("Privacy Policy");
|
||||
});
|
||||
|
||||
it("advertises the policy version + last-updated date", () => {
|
||||
const html = render();
|
||||
expect(html).toContain("Policy version 1.4.26");
|
||||
expect(html).toContain("Last updated: 2026-05-15");
|
||||
expect(html).toContain(`Policy version ${POLICY_VERSION}`);
|
||||
expect(html).toContain(`Last updated: ${LAST_UPDATED}`);
|
||||
expect(html).toContain(`Stand: ${LAST_UPDATED}`);
|
||||
expect(html).toContain('data-slot="privacy-last-updated"');
|
||||
});
|
||||
|
||||
it("renders the numbered section headings", () => {
|
||||
const html = render();
|
||||
// 11 numbered sections — H2 headings render the leading numeral so
|
||||
// the reader can navigate without an explicit table of contents.
|
||||
// 11 numbered sections — H2 headings carry the English form as the
|
||||
// canonical scan spine, with the German title appearing inline in
|
||||
// each section body. The TOC at the top mirrors both.
|
||||
const expectedHeadings = [
|
||||
"1. Overview",
|
||||
"2. Data we collect",
|
||||
@@ -61,17 +74,36 @@ describe("<PrivacyPage>", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the bilingual section structure with DE + EN bodies", () => {
|
||||
const html = render();
|
||||
// Each section ships a `data-slot="privacy-section-de"` body and a
|
||||
// collapsible `data-slot="privacy-section-en"` translation. 11
|
||||
// sections total.
|
||||
const deSlotCount = (
|
||||
html.match(/data-slot="privacy-section-de"/g) ?? []
|
||||
).length;
|
||||
const enSlotCount = (
|
||||
html.match(/data-slot="privacy-section-en"/g) ?? []
|
||||
).length;
|
||||
expect(deSlotCount).toBe(11);
|
||||
expect(enSlotCount).toBe(11);
|
||||
// The English translation `<details>` carries the "English
|
||||
// translation" summary label so a reviewer can find it without
|
||||
// guessing.
|
||||
expect(html).toContain("English translation");
|
||||
// The German bodies are `lang="de"` so screen readers switch
|
||||
// pronunciation correctly.
|
||||
expect(html).toContain('lang="de"');
|
||||
expect(html).toContain('lang="en"');
|
||||
});
|
||||
|
||||
it("enumerates every HealthKit quantity type read by the iOS app", () => {
|
||||
const html = render();
|
||||
// Source of truth: HealthKitService.defaultReadTypes +
|
||||
// HealthKitWireConverter.preferredUnit in the iOS repo. Every
|
||||
// identifier the iOS app reads must appear verbatim so an App
|
||||
// Store reviewer can confirm coverage. The list must stay sorted
|
||||
// by category in the page itself (vitals, cardio, activity,
|
||||
// audio, daylight) — that's a UX commitment, not a test
|
||||
// commitment; we just check membership here.
|
||||
// Store reviewer can confirm coverage.
|
||||
const requiredIdentifiers = [
|
||||
// Vitals (point metrics)
|
||||
"bodyMass",
|
||||
"bodyFatPercentage",
|
||||
"bodyTemperature",
|
||||
@@ -79,20 +111,16 @@ describe("<PrivacyPage>", () => {
|
||||
"bloodPressureDiastolic",
|
||||
"bloodGlucose",
|
||||
"oxygenSaturation",
|
||||
// Cardio
|
||||
"heartRate",
|
||||
"restingHeartRate",
|
||||
"heartRateVariabilitySDNN",
|
||||
"vo2Max",
|
||||
// Activity
|
||||
"stepCount",
|
||||
"activeEnergyBurned",
|
||||
"flightsClimbed",
|
||||
"distanceWalkingRunning",
|
||||
// Audio exposure
|
||||
"environmentalAudioExposure",
|
||||
"headphoneAudioExposure",
|
||||
// Time in Daylight (iOS 17+)
|
||||
"timeInDaylight",
|
||||
];
|
||||
expect(
|
||||
@@ -110,9 +138,6 @@ describe("<PrivacyPage>", () => {
|
||||
it("lists sleepAnalysis as the sole HKCategoryTypeIdentifier read", () => {
|
||||
const html = render();
|
||||
expect(html).toContain("sleepAnalysis");
|
||||
// Per-stage breakdown is the differentiator vs. a single sleep
|
||||
// duration — name the four stage labels so the disclosure stays
|
||||
// explicit.
|
||||
expect(html).toContain("Awake");
|
||||
expect(html).toContain("REM");
|
||||
expect(html).toContain("Core");
|
||||
@@ -142,24 +167,96 @@ describe("<PrivacyPage>", () => {
|
||||
it("states the EU MDR medical-device boundary verbatim", () => {
|
||||
const html = render();
|
||||
expect(html).toContain("not a medical device");
|
||||
expect(html).toContain("kein Medizinprodukt");
|
||||
expect(html).toContain("2017/745");
|
||||
expect(html).toContain("MDCG 2021-24");
|
||||
});
|
||||
|
||||
it("links to the GDPR-request contact channel without exposing PII", () => {
|
||||
it("discloses the HealthKit data-flow path (HK sample → HTTPS → server)", () => {
|
||||
const html = render();
|
||||
// SB-3 requirement 2: how HealthKit samples reach the server.
|
||||
expect(html).toContain("POST /api/measurements");
|
||||
expect(html).toContain("TLS 1.3");
|
||||
});
|
||||
|
||||
it("discloses AI off-device transit with the named providers and consent gate", () => {
|
||||
const html = render();
|
||||
// SB-3 requirement 3: which AI providers receive prompts and the
|
||||
// explicit guarantees (no raw HK identifiers, off by default,
|
||||
// encrypted in transit).
|
||||
expect(html).toContain("off by default");
|
||||
expect(html).toContain("never includes raw HealthKit identifiers");
|
||||
expect(html).toContain("Anthropic");
|
||||
expect(html).toContain("OpenAI");
|
||||
});
|
||||
|
||||
it("links the consent receipt endpoint with the retention window", () => {
|
||||
const html = render();
|
||||
// SB-3 requirement 4: consent receipt persistence — endpoint +
|
||||
// retention period.
|
||||
expect(html).toContain("GET /api/account/consents");
|
||||
expect(html).toContain("consent receipt");
|
||||
expect(html).toContain("five years");
|
||||
});
|
||||
|
||||
it("declares encryption in transit (TLS 1.3 + HSTS + cert pinning)", () => {
|
||||
const html = render();
|
||||
// SB-3 requirement 5.
|
||||
expect(html).toContain("TLS 1.3");
|
||||
expect(html).toContain("HSTS");
|
||||
// Cert pinning is described in two languages; both bodies mention
|
||||
// the practice but with different surrounding whitespace, so check
|
||||
// a tolerant regex.
|
||||
expect(html).toMatch(/certificate\s+pinning/i);
|
||||
expect(html).toMatch(/Zertifikat-Pinning/);
|
||||
});
|
||||
|
||||
it("states the server location and operator framing", () => {
|
||||
const html = render();
|
||||
// SB-3 requirement 6: operated by an individual in Germany on
|
||||
// Hetzner; no third-party hosting except AI providers.
|
||||
expect(html).toContain("Germany");
|
||||
expect(html).toContain("Hetzner");
|
||||
expect(html).toContain("operator");
|
||||
});
|
||||
|
||||
it("publishes the retention defaults (5y / 90d / 30d APNs)", () => {
|
||||
const html = render();
|
||||
// SB-3 requirement 7.
|
||||
expect(html).toContain("5-year window");
|
||||
expect(html).toContain("Audit logs: 90 days");
|
||||
expect(html).toContain("Failed APNs deliveries: 30 days");
|
||||
});
|
||||
|
||||
it("discloses the GDPR Art. 17 deletion route + cascade behaviour", () => {
|
||||
const html = render();
|
||||
// SB-3 requirement 8: Settings → Daten → Konto löschen cascades
|
||||
// through User.delete + onDelete: Cascade.
|
||||
expect(html).toContain("Konto löschen");
|
||||
expect(html).toContain("Delete account");
|
||||
expect(html).toContain("User.delete");
|
||||
expect(html).toContain("onDelete: Cascade");
|
||||
});
|
||||
|
||||
it("publishes the operator email + GitHub fallback channel", () => {
|
||||
const html = render();
|
||||
// SB-3 requirement 9: operator contact. The body uses the
|
||||
// "operator" framing per the v1.4.20 PII guidance — only the
|
||||
// email address attaches the actual contact.
|
||||
expect(html).toContain("mailto:mbombeck@gmail.com");
|
||||
expect(html).toContain("github.com/MBombeck/HealthLog/issues");
|
||||
// Marc-Voice rule: no personal name in user-facing artefacts. The
|
||||
// policy must speak in terms of "the operator" / "the maintainer"
|
||||
// not Marc's full name.
|
||||
// policy speaks in terms of "the operator" / "the controller"
|
||||
// not Marc's full name. Only the bare email address attaches the
|
||||
// contact route.
|
||||
expect(html).not.toMatch(/Marc[- ]?Andr/i);
|
||||
expect(html).not.toContain("Bombeck ");
|
||||
expect(html).not.toMatch(/\bBombeck\s+[A-Z]/);
|
||||
});
|
||||
|
||||
it("renders a discoverable footer with the version and license", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('data-slot="privacy-footer"');
|
||||
expect(html).toContain("AGPL-3.0");
|
||||
expect(html).toContain("1.4.26");
|
||||
expect(html).toContain(POLICY_VERSION);
|
||||
});
|
||||
});
|
||||
+1646
-710
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user