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

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

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

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

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

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

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

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

No files matched your search

@@ -76,11 +76,11 @@ vi.mock("@/lib/logging/context", async () => {
// The three fast-paths and the rollup helpers are exercised by their
// own tests; here we stub them so the route's per-type read is the
// only Prisma traffic the test observes.
vi.mock("@/lib/measurements/rollups", () => ({
vi.mock("@/lib/rollups/measurement-rollups", () => ({
ensureUserRollupsFresh: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/measurements/rollup-coverage", () => ({
vi.mock("@/lib/rollups/measurement-coverage", () => ({
probeRollupCoverage: vi.fn().mockResolvedValue(new Map()),
}));
@@ -150,10 +150,10 @@ beforeEach(async () => {
// module scope only sets the initial implementation, and the slim
// slice test must observe a resolved value not `undefined`.
const { ensureUserRollupsFresh } = await import(
"@/lib/measurements/rollups"
"@/lib/rollups/measurement-rollups"
);
const { probeRollupCoverage } = await import(
"@/lib/measurements/rollup-coverage"
"@/lib/rollups/measurement-coverage"
);
const { computeBpInTargetFastPath } = await import(
"@/lib/analytics/bp-in-target-fast-path"
@@ -0,0 +1,116 @@
/**
* v1.4.40 W-INSIGHTS SB-7 — pin the four discovery branches of
* `GET /api/auth/registration-status`.
*
* The route is read-only; the iOS sign-in flow + the web onboarding
* dispatcher both depend on its envelope shape (`registrationEnabled`
* boolean) staying byte-stable. A regression that flipped the
* fail-closed branch to fail-open would silently allow registration on
* a self-hosted deployment whose admin set `registrationEnabled: false`
* and an upstream DB blip lost the row.
*
* Four branches walked:
*
* 1. **Singleton row exists with `registrationEnabled: true`** →
* envelope reports `true`.
* 2. **Singleton row exists with `registrationEnabled: false`** →
* envelope reports `false`. (Admin-disabled tenant — invite-only.)
* 3. **Singleton row missing (`findUnique` returns `null`)** →
* envelope reports the schema default `true` (open registration —
* the no-config baseline for a fresh self-hosted instance).
* 4. **`findUnique` throws** (DB outage / connection drop) → envelope
* fails closed with `false`. Critical: a DB blip on a tenant
* that has disabled registration must NOT unlock the sign-up
* flow.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: {
findUnique: 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: () => {},
})),
}));
import { GET } from "@/app/api/auth/registration-status/route";
import { prisma } from "@/lib/db";
const callGet = GET as unknown as (...args: never[]) => Promise<Response>;
interface RegistrationStatusBody {
data: { registrationEnabled: boolean };
}
beforeEach(() => {
vi.resetAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("GET /api/auth/registration-status", () => {
it("reports `true` when the singleton row carries registrationEnabled: true", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
id: "singleton",
registrationEnabled: true,
} as never);
const res = await callGet();
expect(res.status).toBe(200);
const body = (await res.json()) as RegistrationStatusBody;
expect(body.data.registrationEnabled).toBe(true);
});
it("reports `false` when the singleton row carries registrationEnabled: false (admin-disabled tenant)", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
id: "singleton",
registrationEnabled: false,
} as never);
const res = await callGet();
expect(res.status).toBe(200);
const body = (await res.json()) as RegistrationStatusBody;
expect(body.data.registrationEnabled).toBe(false);
});
it("falls back to the schema default `true` when the singleton row is missing (fresh self-hosted instance)", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
const res = await callGet();
expect(res.status).toBe(200);
const body = (await res.json()) as RegistrationStatusBody;
expect(body.data.registrationEnabled).toBe(true);
});
it("fails closed with `false` when the DB lookup throws (defense-in-depth against silent unlock)", async () => {
vi.mocked(prisma.appSettings.findUnique).mockRejectedValue(
new Error("connection terminated"),
);
const res = await callGet();
expect(res.status).toBe(200);
const body = (await res.json()) as RegistrationStatusBody;
expect(body.data.registrationEnabled).toBe(false);
});
});
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
/**
* SB-4 (v1.4.40) — Apple App Site Association (AASA) handler contract.
*
* iOS Universal Links and Web Credentials both fetch
* `/.well-known/apple-app-site-association` without credentials and feed
* the body to Apple's swcd / aasa-validator. Both pieces of Apple
* tooling are strict about three things:
*
* 1. The exact JSON shape — `applinks.details[].appID` (the
* `<TeamID>.<BundleID>` prefix) plus `applinks.details[].paths`
* (the URL-prefix allow-list), and `webcredentials.apps` (the same
* App ID prefix) for passkey origin-sharing.
* 2. The `Content-Type` header — must be `application/json` *without*
* a `charset` parameter. `application/json; charset=utf-8` makes
* Apple's CDN proxy refuse to mirror the file, which silently
* breaks Universal Links on every device.
* 3. The response answers 200 to an unauthenticated GET (covered by
* `proxy-well-known-public.test.ts` at the proxy layer).
*
* This file pins (1) and (2). Any drift means iOS PB30 (App-Store
* submission) regresses without a CI signal.
*/
import { GET } from "@/app/.well-known/apple-app-site-association/route";
const APP_ID = "S8WDX4W5KX.dev.healthlog.app";
describe("/.well-known/apple-app-site-association (SB-4)", () => {
it("answers 200", async () => {
const res = GET();
expect(res.status).toBe(200);
});
it("uses Content-Type: application/json with no charset", async () => {
const res = GET();
const contentType = res.headers.get("content-type");
// Apple's aasa-validator + swcd reject any `charset=…` parameter
// on the AASA file. The handler MUST emit the bare media type.
expect(contentType).toBe("application/json");
});
it("sets a Cache-Control directive that allows mirroring", async () => {
const res = GET();
const cacheControl = res.headers.get("cache-control");
expect(cacheControl).toBeTruthy();
// Public + max-age so Apple's CDN (one-hour TTL) can mirror the
// file without falling back to private/no-store behaviour.
expect(cacheControl).toMatch(/public/);
expect(cacheControl).toMatch(/max-age=\d+/);
});
it("emits the SB-4 payload shape exactly", async () => {
const res = GET();
const body = await res.json();
expect(body).toEqual({
applinks: {
details: [
{
appID: APP_ID,
paths: ["*"],
},
],
},
webcredentials: {
apps: [APP_ID],
},
});
});
it("uses the same App ID prefix for applinks and webcredentials", async () => {
// Apple's passkey origin-pairing requires the iOS bundle and the
// website origin to advertise the same App ID. Splitting these two
// entries (e.g. a stale Team ID on one side) breaks passkey login
// without a redirect / status-code signal — the only symptom is a
// silent fall-back to password login on iOS.
const res = GET();
const body = (await res.json()) as {
applinks: { details: Array<{ appID: string }> };
webcredentials: { apps: string[] };
};
expect(body.applinks.details[0].appID).toBe(body.webcredentials.apps[0]);
});
});
@@ -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";
+1 -1
View File
@@ -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;
+68 -1
View File
@@ -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);
});
});
+43 -8
View File
@@ -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");
});
});
+143
View File
@@ -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 });
});
+84
View File
@@ -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(),
};
}
+33 -1
View File
@@ -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
`,
),
]);
+5 -5
View File
@@ -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();
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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";
/**
+1
View File
@@ -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).
+70 -32
View File
@@ -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,
},
});
+1 -1
View File
@@ -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);
});
});
+130 -35
View File
@@ -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";
+1 -1
View File
@@ -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) },
+1 -1
View File
@@ -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;
+10 -1
View File
@@ -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
+8 -1
View File
@@ -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";
+1 -1
View File
@@ -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,
});
});
+1 -1
View File
@@ -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;
+34 -1
View File
@@ -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/);
});
});
-130
View File
@@ -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);
}
});
+1 -1
View File
@@ -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 }> };
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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();
});
});
+60 -7
View File
@@ -4,8 +4,8 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError } from "@/lib/api-response";
import { CHANNEL_TYPE_LABELS } from "@/lib/notifications/types";
import type { ChannelType } from "@/lib/notifications/types";
import { CHANNEL_TYPE_LABELS, EVENT_TYPES } from "@/lib/notifications/types";
import type { ChannelType, EventType } from "@/lib/notifications/types";
import { reEnableChannel } from "@/lib/notifications/channel-state";
export const dynamic = "force-dynamic";
@@ -45,21 +45,74 @@ function deriveState(channel: {
return "active";
}
/**
* Per-event-type "last delivered" map. SB-6 v1.4.40 — drives the iOS
* NotificationsScreen "last-delivered X h ago" line so the user can tell
* at a glance whether the channel is actually firing for the category
* they care about.
*
* Source-of-truth strategy: aggregate from whichever per-event ledger
* exists today.
* - `MOOD_REMINDER` → `MoodReminderDispatch.dispatchedAt` (the
* idempotency ledger written by `mood-reminder.ts`).
* - everything else → `null` for now. As future categories grow their
* own ledger (e.g. a `MedicationReminderDispatch` table planned for a
* later phase), wire them in below — the iOS contract is stable
* around "null means no data yet", so adding sources is additive.
*
* Every known category is always present in the map (with `null` when
* no data exists) so the iOS layer can render an empty-state row
* without conditional plumbing.
*/
type EventStatus = { lastDeliveredAt: string | null };
async function loadEventStatuses(
userId: string,
): Promise<Record<EventType, EventStatus>> {
// Initialise the map with every known event-type → null so the
// response shape is stable regardless of which ledgers have rows.
const out = Object.fromEntries(
EVENT_TYPES.map((ev) => [ev, { lastDeliveredAt: null as string | null }]),
) as Record<EventType, EventStatus>;
// MOOD_REMINDER — read the latest row out of the idempotency ledger.
const lastMood = await prisma.moodReminderDispatch.findFirst({
where: { userId },
orderBy: { dispatchedAt: "desc" },
select: { dispatchedAt: true },
});
if (lastMood) {
out.MOOD_REMINDER = {
lastDeliveredAt: lastMood.dispatchedAt.toISOString(),
};
}
return out;
}
/**
* GET /api/notifications/status
*
* Returns the per-channel reliability state — what the Settings →
* Notifications page paints into "Active / Auto-disabled / Sending paused"
* badges + last-send / last-failure timestamps.
*
* SB-6 v1.4.40 — additionally returns a per-event-type `events` map so
* the iOS NotificationsScreen can render "last delivered X h ago" per
* category. `events[category].lastDeliveredAt` is null when the user
* has never received that event-type (or no ledger source exists yet).
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "notifications.status.get" } });
const channels = await prisma.notificationChannel.findMany({
where: { userId: user.id },
orderBy: { type: "asc" },
});
const [channels, events] = await Promise.all([
prisma.notificationChannel.findMany({
where: { userId: user.id },
orderBy: { type: "asc" },
}),
loadEventStatuses(user.id),
]);
const data: ChannelStatus[] = channels.map((ch) => ({
id: ch.id,
@@ -75,7 +128,7 @@ export const GET = apiHandler(async () => {
nextRetryAt: ch.nextRetryAt?.toISOString() ?? null,
}));
return apiSuccess({ channels: data });
return apiSuccess({ channels: data, events });
});
const reEnableSchema = z.object({
+1 -1
View File
@@ -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;
+4 -3
View File
@@ -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;
+2 -1
View File
@@ -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"));
+2 -1
View File
@@ -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
View File
@@ -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>
))}
+123 -26
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+8 -3
View File
@@ -2,6 +2,7 @@
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import {
ResponsiveContainer,
ComposedChart,
@@ -541,8 +542,12 @@ export function HealthChart({
}, [rangePoints, effectiveCompareBaseline]);
const { data, isLoading } = useQuery({
queryKey: [
"chart-data",
// v1.4.40 W-RSC — route the chart-data key through `queryKeys.chartData`
// so the `["chart-data"]` prefix lands in `measurementDependentKeys`
// and a fresh measurement evicts every per-chart daily cache in one
// pass (audit C2). The factory-packed tuple is byte-identical with
// the pre-v1.4.40 inline literal so the cache layout stays stable.
queryKey: queryKeys.chartData(
types.join(","),
valueMode,
bmiDivisor ?? "no-bmi",
@@ -555,7 +560,7 @@ export function HealthChart({
// re-running the unbounded walk.
fetchWindow.from,
fetchWindow.to,
],
),
// v1.4.28 FB-D2 — cache the bounded window for a minute so tab
// navigation between insights sub-pages does not re-fire every
// chart's fetch. `gcTime` keeps the inactive cache for five
@@ -34,6 +34,7 @@
*/
import { useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { queryKeys } from "@/lib/query-keys";
import {
ResponsiveContainer,
ComposedChart,
@@ -226,8 +227,12 @@ export function MedicationComplianceChart({
// room of the weight / BMI charts.
const viewportWidth = useViewportWidth();
// v1.4.40 W-RSC — factory-routed key so the prefix
// `["dashboard-medication-compliance"]` lands in
// `medicationDependentKeys` and an intake POST refreshes the chart
// immediately rather than waiting for `staleTime` (audit L4).
const { data, isLoading } = useQuery({
queryKey: ["medication-compliance-chart", days],
queryKey: queryKeys.dashboardMedicationCompliance(days),
queryFn: async (): Promise<DailyCompliancePoint[]> => {
const res = await fetch(
`/api/medications/intake?scope=compliance&days=${days}`,
+18 -1
View File
@@ -2,6 +2,7 @@
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import {
ResponsiveContainer,
ComposedChart,
@@ -312,8 +313,22 @@ export function MoodChart({
const displayTitle = title ?? t("charts.mood");
// v1.4.40 W-RSC — align the chart's queryKey with the dashboard's
// `useQuery({ queryKey: queryKeys.moodAnalytics() })` call against the
// same `/api/mood/analytics` endpoint. Pre-fix the chart fetched its
// own copy under `["mood-chart-data"]` (audit C1) — TanStack treated
// the two keys as separate cache slots and the cold-mount dashboard
// fired the request twice (once at `app/page.tsx:291`, once here on
// the chart's first render). The dedup eliminates a ~22 KB round-trip
// and frees one pg pool slot per cold mount.
//
// `staleTime: 60_000` matches the dashboard's `DASHBOARD_QUERY_OPTS`
// so a route swing back to the dashboard within a minute keeps the
// cache fresh; the chart's own subscription becomes the cache writer
// for any insights / mood sub-page that mounts the chart without the
// dashboard parent having fetched first.
const { data, isLoading } = useQuery({
queryKey: ["mood-chart-data"],
queryKey: queryKeys.moodAnalytics(),
queryFn: async (): Promise<MoodAnalyticsData> => {
const res = await fetch("/api/mood/analytics");
if (!res.ok) throw new Error("Failed to fetch mood analytics");
@@ -321,6 +336,8 @@ export function MoodChart({
return json.data as MoodAnalyticsData;
},
enabled: isAuthenticated,
staleTime: 60_000,
refetchOnWindowFocus: false,
});
const chartData = useMemo((): ChartDataPoint[] | undefined => {
+11 -9
View File
@@ -3,6 +3,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { queryKeys } from "@/lib/query-keys";
import {
COMPARISON_BASELINES,
resolveDashboardLayout,
@@ -26,7 +27,7 @@ export function CompareToggle({ className }: { className?: string }) {
const queryClient = useQueryClient();
const { data: layoutData } = useQuery({
queryKey: ["user", "dashboardWidgets"],
queryKey: queryKeys.dashboardWidgets(),
queryFn: async () => {
const res = await fetch("/api/dashboard/widgets");
if (!res.ok) throw new Error("failed");
@@ -52,15 +53,14 @@ export function CompareToggle({ className }: { className?: string }) {
},
onMutate: async (next) => {
await queryClient.cancelQueries({
queryKey: ["user", "dashboardWidgets"],
queryKey: queryKeys.dashboardWidgets(),
});
const prev = queryClient.getQueryData<DashboardLayout>([
"user",
"dashboardWidgets",
]);
const prev = queryClient.getQueryData<DashboardLayout>(
queryKeys.dashboardWidgets(),
);
if (prev) {
queryClient.setQueryData<DashboardLayout>(
["user", "dashboardWidgets"],
queryKeys.dashboardWidgets(),
{ ...prev, comparisonBaseline: next },
);
}
@@ -68,11 +68,13 @@ export function CompareToggle({ className }: { className?: string }) {
},
onError: (_err, _next, ctx) => {
if (ctx?.prev) {
queryClient.setQueryData(["user", "dashboardWidgets"], ctx.prev);
queryClient.setQueryData(queryKeys.dashboardWidgets(), ctx.prev);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["user", "dashboardWidgets"] });
queryClient.invalidateQueries({
queryKey: queryKeys.dashboardWidgets(),
});
},
});
@@ -58,7 +58,7 @@ import {
* shaped the same as `MoodForm`).
*
* On success: invalidate `medicationDependentKeys` (medications +
* analytics + insights + intake-summary + achievements) PLUS the
* analytics + insights + achievements) PLUS the
* inline per-medication compliance chart key so the detail page tile
* refreshes if it happens to be mounted elsewhere in the app.
*
@@ -9,6 +9,7 @@ import { useWorkouts } from "@/hooks/use-workouts";
import { InsightsTabStrip } from "@/components/insights/insights-tab-strip";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import { queryKeys } from "@/lib/query-keys";
import type { InsightInputs } from "@/lib/insights/metric-availability";
/**
@@ -66,7 +67,7 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
// event-driven gating branches. Sub-pages read the same key so the
// payload lands once per route.
const comprehensiveQuery = useQuery({
queryKey: ["insights", "comprehensive"],
queryKey: queryKeys.insightsComprehensive(),
queryFn: async () => {
const res = await fetch("/api/insights/comprehensive");
if (!res.ok) throw new Error("Failed");
+8 -3
View File
@@ -2,6 +2,7 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { queryKeys } from "@/lib/query-keys";
export interface AuthUser {
id: string;
@@ -33,7 +34,11 @@ async function fetchMe(): Promise<AuthUser> {
export function useAuth() {
const query = useQuery({
queryKey: ["auth", "me"],
// v1.4.40 W-RSC — factory-routed to `queryKeys.authMe()`. Pre-fix
// the literal `["auth", "me"]` was the canonical example in
// audit-H1 of factory drift. The prefix `["auth"]` still matches
// `queryKeys.auth()` invalidations.
queryKey: queryKeys.authMe(),
queryFn: fetchMe,
retry: false,
staleTime: 5 * 60 * 1000,
@@ -57,8 +62,8 @@ export function useLogout() {
await fetch("/api/auth/logout", { method: "POST" });
},
onSuccess: () => {
queryClient.setQueryData(["auth", "me"], null);
queryClient.invalidateQueries({ queryKey: ["auth"] });
queryClient.setQueryData(queryKeys.authMe(), null);
queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
router.push("/auth/login");
},
});
+64
View File
@@ -0,0 +1,64 @@
/**
* v1.4.40 W-POOL — pool-config contract for `src/lib/db.ts`.
*
* The v1.4.39 empirical cold-mount trace
* (`.planning/round-v1439-empirical-trace.md` § B2) traced the
* Wave-B/C dashboard-query stall to the Prisma `pg.Pool` falling
* back to the library default ceiling of 10 connections. W-POOL
* pinned the ceiling to 20 via `getPoolMax()`; this test prevents a
* future refactor from silently dropping the override and re-introducing
* the saturation regression on a power-user cold mount.
*
* The test only exercises the `getPoolMax()` env-resolver. Constructing
* a real `PrismaClient` requires a live `DATABASE_URL`, which would
* make this an integration test instead of a unit test — the helper
* isolation is intentional.
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { getPoolMax } from "../db";
describe("getPoolMax", () => {
const originalEnv = process.env.DATABASE_POOL_MAX;
beforeEach(() => {
delete process.env.DATABASE_POOL_MAX;
});
afterEach(() => {
if (originalEnv === undefined) {
delete process.env.DATABASE_POOL_MAX;
} else {
process.env.DATABASE_POOL_MAX = originalEnv;
}
});
it("defaults to 20 when DATABASE_POOL_MAX is unset", () => {
expect(getPoolMax()).toBe(20);
});
it("honours DATABASE_POOL_MAX when it parses to a positive int", () => {
process.env.DATABASE_POOL_MAX = "30";
expect(getPoolMax()).toBe(30);
});
it("falls back to 20 on a malformed DATABASE_POOL_MAX", () => {
process.env.DATABASE_POOL_MAX = "not-a-number";
expect(getPoolMax()).toBe(20);
});
it("falls back to 20 on a non-positive DATABASE_POOL_MAX", () => {
process.env.DATABASE_POOL_MAX = "0";
expect(getPoolMax()).toBe(20);
process.env.DATABASE_POOL_MAX = "-5";
expect(getPoolMax()).toBe(20);
});
it("never returns the library default of 10 on the happy path", () => {
// The whole point of the W-POOL change. If anybody removes the
// `max:` config from `createPrismaClient` and reverts to the
// library default, this assertion stops the regression before the
// next saturated cold mount.
expect(getPoolMax()).toBeGreaterThanOrEqual(20);
});
});
-13
View File
@@ -1,7 +1,6 @@
import { describe, it, expect } from "vitest";
import {
mgdlToMmol,
mmolToMgdl,
convertGlucose,
resolveGlucoseUnit,
thresholdMetricForContext,
@@ -14,18 +13,6 @@ describe("glucose conversion", () => {
expect(mgdlToMmol(70)).toBeCloseTo(3.9, 1); // hypoglycemia threshold
});
it("converts mmol/L to mg/dL rounded to integer", () => {
expect(mmolToMgdl(5.5)).toBe(99);
expect(mmolToMgdl(7.0)).toBe(126);
});
it("roundtrips within 1 mg/dL", () => {
for (const mgdl of [70, 99, 100, 126, 140, 200, 300]) {
const back = mmolToMgdl(mgdlToMmol(mgdl));
expect(Math.abs(back - mgdl)).toBeLessThanOrEqual(1);
}
});
it("convertGlucose dispatches to the right unit", () => {
expect(convertGlucose(100, "mg/dL")).toBe(100);
expect(convertGlucose(100, "mmol/L")).toBe(5.5);
+161
View File
@@ -1,3 +1,5 @@
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, it, expect, vi } from "vitest";
import {
queryKeys,
@@ -34,6 +36,51 @@ describe("queryKeys factory", () => {
expect(queryKeys.analytics()).toEqual(["analytics"]);
expect(queryKeys.analytics(undefined)).toEqual(["analytics"]);
});
// v1.4.40 W-RSC — the four new factory entries from this wave each
// need a byte-stable contract so the audit-cited drift cases stay
// pinned. A future rename of any of these literals would land a
// matching test failure rather than silently breaking cache layout.
it("exposes the authMe shape so useAuth doesn't drift back to a bare literal", () => {
expect(queryKeys.authMe()).toEqual(["auth", "me"]);
});
it("exposes the userThresholds shape for Settings + targets", () => {
expect(queryKeys.userThresholds()).toEqual(["user", "thresholds"]);
});
it("packs chartData params into a stable tuple prefixed by chart-data", () => {
const key = queryKeys.chartData(
"WEIGHT",
"raw",
"no-bmi",
"Europe/Berlin",
"2026-04-01T00:00:00.000Z",
"2026-05-01T00:00:00.000Z",
);
expect(key[0]).toBe("chart-data");
expect(key.length).toBe(7);
// Numeric bmiDivisor is allowed (BMI charts pass `heightCm * heightCm`).
const bmiKey = queryKeys.chartData(
"WEIGHT",
"bmi",
30625,
"Europe/Berlin",
"2026-04-01T00:00:00.000Z",
"2026-05-01T00:00:00.000Z",
);
expect(bmiKey[3]).toBe(30625);
});
it("prefixes the dashboard medication-compliance key under one root", () => {
expect(queryKeys.dashboardMedicationCompliance(7)).toEqual([
"dashboard-medication-compliance",
7,
]);
expect(queryKeys.dashboardMedicationCompliance(30)[0]).toBe(
"dashboard-medication-compliance",
);
});
});
describe("dependent-key bundles", () => {
@@ -48,6 +95,16 @@ describe("dependent-key bundles", () => {
);
});
// v1.4.40 W-RSC — chart-data prefix now lives in the bundle so a
// fresh measurement evicts every per-chart daily-aggregate cache.
// Pre-fix the chart row stayed 60 s stale after a measurement save
// (audit-C2). Pin the bundle membership so a refactor of the chart
// queryKey factory can't silently drop the dependency.
it("measurementDependentKeys bundles the chart-data prefix (v1.4.40)", () => {
const keyStrings = measurementDependentKeys.map((k) => JSON.stringify(k));
expect(keyStrings).toContain(JSON.stringify(["chart-data"]));
});
it("moodDependentKeys bundle covers mood + analytics + targets", () => {
const keyStrings = moodDependentKeys.map((k) => JSON.stringify(k));
expect(keyStrings).toContain(JSON.stringify(["mood-entries"]));
@@ -63,6 +120,110 @@ describe("dependent-key bundles", () => {
JSON.stringify(["gamification", "achievements"]),
);
});
// v1.4.40 W-RSC — dashboard-medication-compliance prefix now lives in
// the bundle so an intake POST refreshes the dashboard chart in
// lockstep with the per-medication compliance-chart-inline tile
// (audit-L4).
it("medicationDependentKeys bundles the dashboard-medication-compliance prefix (v1.4.40)", () => {
const keyStrings = medicationDependentKeys.map((k) => JSON.stringify(k));
expect(keyStrings).toContain(JSON.stringify(["dashboard-medication-compliance"]));
});
});
/**
* v1.4.40 W-RSC — factory-bypass guard.
*
* Walks every `.ts` / `.tsx` file in `src/components/charts` and
* `src/app/page.tsx` (the dashboard + chart files we own in this wave)
* and fails if a literal `queryKey: [...]` slipped past the factory.
* Acts as a lint-style enforcement check in the absence of a custom
* ESLint rule — keeps the CI gate cheap and the failure message
* pointed at the exact file the contributor needs to fix.
*
* The audit (`.planning/round-v1439-arch-qa-frontend.md` §H1) found
* 154 bare queryKey sites at v1.4.39.3. We intentionally scope this
* guard tight to the dashboard + chart files this wave touched so a
* future wave can extend the directory list as it migrates the
* remaining sites — opt-in expansion beats a giant red CI on
* landing.
*/
describe("queryKey factory enforcement", () => {
function listFiles(dir: string): string[] {
const entries = readdirSync(dir);
const out: string[] = [];
for (const name of entries) {
const full = join(dir, name);
const st = statSync(full);
if (st.isDirectory()) {
if (name === "__tests__" || name === "node_modules") continue;
out.push(...listFiles(full));
} else if (name.endsWith(".ts") || name.endsWith(".tsx")) {
out.push(full);
}
}
return out;
}
const repoRoot = join(__dirname, "..", "..", "..");
// Files this wave migrated to the factory. Extend this list as
// future waves migrate their own surface; the audit-H1 long tail
// (admin/, settings/, medications/, integrations) stays out of the
// guard's scope until a follow-up wave routes those keys.
const guardedRoots = [
join(repoRoot, "src", "components", "charts"),
join(repoRoot, "src", "components", "comparison"),
join(repoRoot, "src", "app", "page.tsx"),
join(repoRoot, "src", "hooks", "use-auth.ts"),
];
function collect(): string[] {
const all: string[] = [];
for (const root of guardedRoots) {
const st = statSync(root);
if (st.isDirectory()) {
all.push(...listFiles(root));
} else {
all.push(root);
}
}
return all;
}
it("no guarded file declares a bare-literal `queryKey:` (factory enforcement)", () => {
const offenders: Array<{ file: string; line: number; snippet: string }> = [];
const files = collect();
for (const file of files) {
const text = readFileSync(file, "utf8");
const lines = text.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Match `queryKey:` followed by a `[` on the same line. We
// explicitly allow `queryKey: queryKeys.<entry>(…)` patterns
// and any indirect identifier the call site passes through
// (a `key` local, a `listKey` constant, etc.) so the guard
// only flags the literal-array shape that drove audit-H1.
const match = line.match(/queryKey\s*:\s*\[/);
if (!match) continue;
// Strip trailing comments + JSX comments before deciding.
const trimmed = line.replace(/\/\/.*$/, "").trim();
if (!/queryKey\s*:\s*\[/.test(trimmed)) continue;
offenders.push({
file: file.slice(repoRoot.length + 1),
line: i + 1,
snippet: trimmed,
});
}
}
expect(
offenders,
`Factory bypass — every \`queryKey:\` in guarded files must go through \`queryKeys.<entry>()\`. ` +
`Offenders:\n${offenders
.map((o) => ` ${o.file}:${o.line} ${o.snippet}`)
.join("\n")}`,
).toEqual([]);
});
});
describe("invalidateKeys", () => {
+1
View File
@@ -497,6 +497,7 @@ async function buildCoachSnapshotImpl(
userId,
type: { in: wantedTypes as never[] },
measuredAt: { gte: cutoff },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: { type: true, value: true, measuredAt: true },
@@ -1,31 +0,0 @@
import { describe, it, expect } from "vitest";
import { berlinDayKey } from "../berlin-day";
/**
* v1.4.22 W5 reconcile (Code-MED-3) — pin that the helper buckets a
* 23:30-Berlin reading on Tuesday into the Tuesday key, regardless of
* UTC offset (CET / CEST, DST).
*/
describe("berlinDayKey", () => {
it("buckets a 23:30-Berlin reading on Tuesday into Tuesday's key (CEST)", () => {
// 2025-10-25T21:30:00Z = 2025-10-25T23:30 CEST (Saturday before DST end)
expect(berlinDayKey(new Date("2025-10-25T21:30:00Z"))).toBe("2025-10-25");
});
it("buckets a 23:30-Berlin reading after DST end into the right Berlin day", () => {
// 2025-10-26 is the DST end day in Europe/Berlin (CET takes over).
// 2025-10-26T22:30:00Z = 2025-10-26T23:30 CET — still Sunday in Berlin.
expect(berlinDayKey(new Date("2025-10-26T22:30:00Z"))).toBe("2025-10-26");
});
it("crosses the UTC-midnight boundary in Berlin's favour", () => {
// 2026-05-09T22:30:00Z falls on 2026-05-10T00:30 CEST — Berlin says
// Sunday, UTC says Saturday. The helper must follow Berlin.
expect(berlinDayKey(new Date("2026-05-09T22:30:00Z"))).toBe("2026-05-10");
});
it("returns YYYY-MM-DD format (sortable, padded)", () => {
expect(berlinDayKey(new Date("2026-01-09T12:00:00Z"))).toBe("2026-01-09");
expect(berlinDayKey(new Date("2026-12-09T12:00:00Z"))).toBe("2026-12-09");
});
});
@@ -19,7 +19,7 @@ vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/measurements/rollup-coverage", () => ({
vi.mock("@/lib/rollups/measurement-coverage", () => ({
isFullyCovered: vi.fn(),
probeRollupCoverage: vi.fn(),
}));
@@ -29,7 +29,7 @@ import { annotate } from "@/lib/logging/context";
import {
isFullyCovered,
probeRollupCoverage,
} from "@/lib/measurements/rollup-coverage";
} from "@/lib/rollups/measurement-coverage";
import { computeBpInTargetFastPath } from "../bp-in-target-fast-path";
const MEASUREMENT_FIND_MANY = prisma.measurement.findMany as unknown as ReturnType<typeof vi.fn>;
@@ -22,7 +22,7 @@ vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/measurements/rollup-coverage", () => ({
vi.mock("@/lib/rollups/measurement-coverage", () => ({
isFullyCovered: vi.fn(),
probeRollupCoverage: vi.fn(),
}));
@@ -32,7 +32,7 @@ import { annotate } from "@/lib/logging/context";
import {
isFullyCovered,
probeRollupCoverage,
} from "@/lib/measurements/rollup-coverage";
} from "@/lib/rollups/measurement-coverage";
import {
CORRELATION_WINDOW_DAYS,
computeCorrelationHypothesesFastPath,
@@ -1,10 +1,6 @@
import { describe, it, expect } from "vitest";
import type { DataPoint } from "../trends";
import {
pairByTimestamp,
pearsonCorrelation,
weeklyAverages,
} from "../correlations";
import { pairByTimestamp, pearsonCorrelation } from "../correlations";
function makePoints(values: number[], startDaysAgo = 30): DataPoint[] {
const now = Date.now();
@@ -107,57 +103,3 @@ describe("pearsonCorrelation", () => {
});
});
describe("weeklyAverages", () => {
it("returns empty for empty input", () => {
expect(weeklyAverages([])).toEqual([]);
});
it("groups data points by ISO week", () => {
const data = makePoints(
[70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83],
14,
);
const result = weeklyAverages(data);
// 14 days = 2-3 weeks
expect(result.length).toBeGreaterThanOrEqual(2);
expect(result.length).toBeLessThanOrEqual(3);
});
it("averages values within same week", () => {
const now = new Date();
// Find the Monday of current week
const day = now.getDay();
const monday = new Date(now);
monday.setDate(now.getDate() - day + (day === 0 ? -6 : 1));
monday.setHours(12, 0, 0, 0);
const tuesday = new Date(monday);
tuesday.setDate(monday.getDate() + 1);
const data: DataPoint[] = [
{ date: monday, value: 70 },
{ date: tuesday, value: 80 },
];
const result = weeklyAverages(data);
expect(result).toHaveLength(1);
expect(result[0].value).toBe(75);
});
// v3 audit caught a TZ bug: a Sunday-evening Berlin reading (e.g. 22:00
// local = 21:00 UTC in summer / 21:00 UTC in winter via DST) bucketed
// into next week on UTC servers because `Date.getDay()` was system-local.
// The Berlin-TZ-aware implementation puts the same point into THIS week.
it("buckets Sunday-evening Berlin into the same ISO week as the preceding Monday", () => {
// 2024-08-04 is a Sunday; 22:00 Berlin = 20:00 UTC (CEST). The Monday
// of the same Berlin week is 2024-07-29.
const sundayEveningBerlin = new Date("2024-08-04T20:00:00.000Z");
const mondayMidday = new Date("2024-07-29T11:00:00.000Z");
const result = weeklyAverages([
{ date: mondayMidday, value: 70 },
{ date: sundayEveningBerlin, value: 90 },
]);
expect(result).toHaveLength(1);
expect(result[0].value).toBe(80); // (70 + 90) / 2
expect(result[0].date.toISOString()).toBe("2024-07-29T00:00:00.000Z");
});
});
@@ -22,7 +22,7 @@ vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/measurements/rollup-coverage", () => ({
vi.mock("@/lib/rollups/measurement-coverage", () => ({
isFullyCovered: vi.fn(),
probeRollupCoverage: vi.fn(),
}));
@@ -32,7 +32,7 @@ import { annotate } from "@/lib/logging/context";
import {
isFullyCovered,
probeRollupCoverage,
} from "@/lib/measurements/rollup-coverage";
} from "@/lib/rollups/measurement-coverage";
import { computeUserHealthScoreFastPath } from "../health-score-fast-path";
const MEASUREMENT_FIND_MANY = prisma.measurement.findMany as unknown as ReturnType<typeof vi.fn>;
@@ -102,6 +102,11 @@ describe("computeUserHealthScoreFastPath", () => {
computedAt: now,
},
]);
// v1.4.40 W-WMY-WIRE — the parallel long-window read fires
// alongside the DAY-bucket read; mock its `findMany` to return
// a coverage miss so the annotate carries `weightLongWindow:
// null` and the score shape is unchanged.
ROLLUP_FIND_MANY.mockResolvedValueOnce([]);
// Weight source-attribution read (narrow 2-column).
MEASUREMENT_FIND_MANY
.mockResolvedValueOnce([
@@ -147,6 +152,170 @@ describe("computeUserHealthScoreFastPath", () => {
});
});
/**
* v1.4.40 W-WMY-WIRE — pin the long-window weight wiring through
* `readBestGranularityRollups`. The score shape is unchanged; the
* weightLongWindow annotate makes the rollup-tier consumption
* verifiable in production wide-events.
*/
describe("long-window weight wiring (W-WMY-WIRE)", () => {
it("surfaces a MONTH-granularity weightLongWindow mean on the rollup branch", async () => {
const coverage = new Map<string, boolean>([["WEIGHT", true]]);
FULLY_COVERED.mockReturnValue(true);
PROBE.mockResolvedValue(coverage);
const now = new Date("2026-05-17T12:00:00.000Z");
// DAY-bucket read (trailing 37 days) — single bucket, irrelevant
// for the long-window assertion.
ROLLUP_FIND_MANY.mockResolvedValueOnce([
{
bucketStart: new Date("2026-05-10T00:00:00.000Z"),
count: 1,
mean: 82.5,
minValue: 82.5,
maxValue: 82.5,
sd: null,
slope: null,
r2: null,
computedAt: now,
},
]);
// readBestGranularityRollups(WEIGHT, 365) — first call is the
// MONTH probe (floor 181 d). Two MONTH buckets, count-weighted
// mean = (12*82 + 18*80) / 30 = 80.8.
ROLLUP_FIND_MANY.mockResolvedValueOnce([
{
bucketStart: new Date("2025-08-01T00:00:00.000Z"),
count: 12,
mean: 82,
minValue: 80,
maxValue: 84,
sd: 1,
slope: 0,
r2: 0,
sumValue: null,
},
{
bucketStart: new Date("2025-09-01T00:00:00.000Z"),
count: 18,
mean: 80,
minValue: 78,
maxValue: 83,
sd: 1,
slope: 0,
r2: 0,
sumValue: null,
},
]);
// Source attribution + BP-SYS attribution reads.
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([
{
measuredAt: new Date("2026-05-10T08:00:00.000Z"),
source: "WITHINGS",
},
]).mockResolvedValueOnce([]);
await computeUserHealthScoreFastPath({
userId: "user-longwindow",
bpInTargetPct: 85,
heightCm: 178,
now,
coverage,
});
const calls = ANNOTATE.mock.calls.map((c) => c[0]);
const pathCall = calls.find(
(c) => c?.meta?.healthScore?.path === "rollup",
);
expect(pathCall).toBeDefined();
const longWindow = pathCall?.meta?.healthScore?.weightLongWindow;
expect(longWindow).not.toBeNull();
expect(longWindow?.granularity).toBe("MONTH");
expect(longWindow?.buckets).toBe(2);
// Σ(count*mean) / Σcount = (12*82 + 18*80) / 30 = 80.8 → rounded.
expect(longWindow?.mean).toBeCloseTo(80.8, 5);
});
it("emits weightLongWindow:null when the WMY tier carries no buckets", async () => {
const coverage = new Map<string, boolean>([["WEIGHT", true]]);
FULLY_COVERED.mockReturnValue(true);
PROBE.mockResolvedValue(coverage);
const now = new Date("2026-05-17T12:00:00.000Z");
// DAY-bucket read.
ROLLUP_FIND_MANY.mockResolvedValueOnce([
{
bucketStart: new Date("2026-05-10T00:00:00.000Z"),
count: 1,
mean: 82.5,
minValue: 82.5,
maxValue: 82.5,
sd: null,
slope: null,
r2: null,
computedAt: now,
},
]);
// Long-window read — every granularity returns empty (router
// walks MONTH → WEEK → DAY; 365 d skips YEAR floor).
ROLLUP_FIND_MANY.mockResolvedValue([]);
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([
{
measuredAt: new Date("2026-05-10T08:00:00.000Z"),
source: "WITHINGS",
},
]).mockResolvedValueOnce([]);
await computeUserHealthScoreFastPath({
userId: "user-no-longwindow",
bpInTargetPct: 85,
heightCm: 178,
now,
coverage,
});
const calls = ANNOTATE.mock.calls.map((c) => c[0]);
const pathCall = calls.find(
(c) => c?.meta?.healthScore?.path === "rollup",
);
expect(pathCall?.meta?.healthScore?.weightLongWindow).toBeNull();
});
it("omits the long-window read on the live fallback branch", async () => {
const coverage = new Map<string, boolean>([["WEIGHT", false]]);
FULLY_COVERED.mockReturnValue(false);
PROBE.mockResolvedValue(coverage);
const now = new Date("2026-05-17T12:00:00.000Z");
// Weight live read + BP source attribution.
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([
{
measuredAt: new Date("2026-05-10T08:00:00.000Z"),
value: 82.5,
source: "MANUAL",
},
]).mockResolvedValueOnce([]);
await computeUserHealthScoreFastPath({
userId: "user-live-nowmy",
bpInTargetPct: 80,
heightCm: 178,
now,
coverage,
});
// The live branch never issues the long-window probe — guards
// against accidentally double-reading on cold mounts where the
// raw weight findMany is already doing the work.
expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled();
const calls = ANNOTATE.mock.calls.map((c) => c[0]);
const pathCall = calls.find(
(c) => c?.meta?.healthScore?.path === "live",
);
expect(pathCall?.meta?.healthScore?.weightLongWindow).toBeNull();
});
});
describe("live fallback — WEIGHT not covered", () => {
it("reads raw weight rows and pins path:live", async () => {
const coverage = new Map<string, boolean>([
@@ -291,7 +291,164 @@ describe("computeSummariesSlice", () => {
// probe + narrow aggregate + latests + rollup GROUP BY
// (v1.4.37.2 — the prior `findMany` is gone). No heavy aggregate.
expect(RAW).toHaveBeenCalledTimes(4);
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(0);
// v1.4.40 W-WMY-WIRE — the year-ago baseline probe runs
// `readBestGranularityRollups(userId, type, 395)` per
// type-with-data via `prisma.measurementRollup.findMany`. The
// 395-day window skips the YEAR floor (731 d) and walks MONTH
// → WEEK → DAY on full coverage miss; the default mock returns
// `[]` so all three reachable tiers probe.
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(3);
});
});
/**
* v1.4.40 W-WMY-WIRE — pin the wiring of the WMY readers into the
* slim slice. The pre-v1.4.40 shape hardcoded `avg30LastYear` to
* `null`; we now populate it from `readBestGranularityRollups` when
* the YEAR / MONTH / WEEK / DAY tier carries buckets that overlap
* the `[now-395d, now-365d)` slice.
*/
describe("year-over-year wiring (avg30LastYear)", () => {
it("populates avg30LastYear from MONTH buckets that overlap the year-ago slice", async () => {
// Coverage probe shows WEIGHT covered → rollup happy path.
RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }])
.mockResolvedValueOnce([
{
type: "WEIGHT",
avg7: 82,
avg30: 82.5,
slope7: 0,
r2_7: 0,
slope30: 0,
r2_30: 0,
slope90: 0,
r2_90: 0,
},
])
.mockResolvedValueOnce([
{ type: "WEIGHT", value: 82.7, measured_at: new Date() },
])
.mockResolvedValueOnce([
{ type: "WEIGHT", count: 5, min: 82, max: 84, mean: 83 },
]);
// Year-ago slice MONTH bucket — `bucketStart` placed 380 days
// ago so it falls inside `[now-395d, now-365d)`. Single bucket
// (count=10, mean=85) → weighted mean = 85.
const yearAgoBucketStart = new Date(
Date.now() - 380 * 24 * 60 * 60 * 1000,
);
ROLLUP_FIND_MANY.mockResolvedValueOnce([
{
bucketStart: yearAgoBucketStart,
count: 10,
mean: 85,
sd: 1,
slope: 0,
r2: 0,
sumValue: null,
minValue: 83,
maxValue: 87,
},
]);
const result = await computeSummariesSlice("user-yoy");
expect(result.summaries.WEIGHT.avg30LastYear).toBe(85);
// Router asked MONTH first (395 > 181, 395 < 731 → skip YEAR).
expect(ROLLUP_FIND_MANY.mock.calls[0][0].where.granularity).toBe("MONTH");
});
it("leaves avg30LastYear null when no bucket overlaps the year-ago slice", async () => {
RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }])
.mockResolvedValueOnce([
{
type: "WEIGHT",
avg7: 82,
avg30: 82.5,
slope7: 0,
r2_7: 0,
slope30: 0,
r2_30: 0,
slope90: 0,
r2_90: 0,
},
])
.mockResolvedValueOnce([
{ type: "WEIGHT", value: 82.7, measured_at: new Date() },
])
.mockResolvedValueOnce([
{ type: "WEIGHT", count: 5, min: 82, max: 84, mean: 83 },
]);
// MONTH bucket placed 30 days ago — inside the YEAR / MONTH /
// WEEK 395-day window the router asks for, but outside the
// `[now-395d, now-365d)` slice. The helper returns null.
const recentBucket = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
ROLLUP_FIND_MANY.mockResolvedValueOnce([
{
bucketStart: recentBucket,
count: 10,
mean: 82,
sd: 1,
slope: 0,
r2: 0,
sumValue: null,
minValue: 80,
maxValue: 84,
},
]);
const result = await computeSummariesSlice("user-recent");
expect(result.summaries.WEIGHT.avg30LastYear).toBeNull();
});
it("leaves avg30LastYear null when every granularity misses (no coverage)", async () => {
RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }])
.mockResolvedValueOnce([
{
type: "WEIGHT",
avg7: 82,
avg30: 82.5,
slope7: 0,
r2_7: 0,
slope30: 0,
r2_30: 0,
slope90: 0,
r2_90: 0,
},
])
.mockResolvedValueOnce([
{ type: "WEIGHT", value: 82.7, measured_at: new Date() },
])
.mockResolvedValueOnce([
{ type: "WEIGHT", count: 5, min: 82, max: 84, mean: 83 },
]);
// Default mock returns `[]` for every findMany call → router
// walks YEAR → MONTH → WEEK → DAY and gives up.
ROLLUP_FIND_MANY.mockResolvedValue([]);
const result = await computeSummariesSlice("user-empty-yoy");
expect(result.summaries.WEIGHT.avg30LastYear).toBeNull();
// 395d window skips YEAR (floor 731 d) → MONTH → WEEK → DAY,
// three reachable tiers all probed on full coverage miss.
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(3);
});
it("only probes types that actually have data in the current window", async () => {
// Coverage probe + rollup GROUP BY both report zero rows → no
// types-with-data, so the year-ago probe must NOT fan out.
RAW.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
await computeSummariesSlice("user-no-data");
expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled();
});
});
});
@@ -1,6 +1,5 @@
import { describe, it, expect } from "vitest";
import {
movingAverage,
trendSlope,
detectAnomalies,
summarize,
@@ -15,27 +14,6 @@ function makePoints(values: number[], startDaysAgo = 30): DataPoint[] {
}));
}
describe("movingAverage", () => {
it("returns empty for empty input", () => {
expect(movingAverage([], 7)).toEqual([]);
});
it("calculates moving average over window", () => {
const data = makePoints([70, 71, 72, 73, 74, 75, 76], 7);
const result = movingAverage(data, 3);
expect(result).toHaveLength(7);
// Last point should be avg of last 3 days
expect(result[result.length - 1].value).toBeCloseTo(75, 0);
});
it("handles single data point", () => {
const data = makePoints([100], 1);
const result = movingAverage(data, 7);
expect(result).toHaveLength(1);
expect(result[0].value).toBe(100);
});
});
describe("trendSlope", () => {
it("returns null for insufficient data", () => {
expect(trendSlope([], 7)).toBeNull();
-35
View File
@@ -1,35 +0,0 @@
/**
* v1.4.22 W5 reconcile (Code-MED-1) — shared Berlin-day-key helper.
*
* The CLAUDE.md timezone convention is "Europe/Berlin for display,
* UTC in database". The dashboard analytics route used a private
* `berlinDayKey()` (defined in `src/app/api/analytics/route.ts:330`)
* to bucket measurements by Berlin calendar day. The targets route's
* `sparklinePoints()` was bucketing by UTC `YYYY-MM-DD` — a 23:30
* Berlin reading on Tuesday landed in Wednesday's bucket, drifting
* the sparkline 1 day on cross-DST boundaries.
*
* Lift the helper into `src/lib/analytics/` so every display surface
* shares the same bucket contract.
*/
const BERLIN_DATE_PARTS = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
});
/**
* Format a `Date` as a `YYYY-MM-DD` string anchored to Europe/Berlin
* calendar day. Used as a stable key for daily aggregation so a
* 23:30-Berlin reading on Tuesday lands in Tuesday's bucket
* regardless of UTC offset (CET / CEST, DST).
*/
export function berlinDayKey(d: Date): string {
const parts = BERLIN_DATE_PARTS.formatToParts(d);
const y = parts.find((p) => p.type === "year")?.value ?? "1970";
const m = parts.find((p) => p.type === "month")?.value ?? "01";
const day = parts.find((p) => p.type === "day")?.value ?? "01";
return `${y}-${m}-${day}`;
}
+3 -3
View File
@@ -52,11 +52,11 @@
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { readRollupBuckets } from "@/lib/measurements/rollups";
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
import {
probeRollupCoverage,
type RollupCoverageMap,
} from "@/lib/measurements/rollup-coverage";
} from "@/lib/rollups/measurement-coverage";
import { isNearUtc } from "@/lib/tz/resolver";
import {
computeBpInTargetWindows,
@@ -432,7 +432,7 @@ async function fetchSeriesChunked(
let cursorId: string | undefined;
for (let page = 0; page < 1000; page++) {
const chunk = await prisma.measurement.findMany({
where: { userId, type, measuredAt: { gte: since } },
where: { userId, type, measuredAt: { gte: since }, deletedAt: null },
orderBy: [{ measuredAt: "asc" }, { id: "asc" }],
select: { id: true, measuredAt: true, value: true },
take: CHUNK,
+3 -3
View File
@@ -45,11 +45,11 @@
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { readRollupBuckets } from "@/lib/measurements/rollups";
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
import {
probeRollupCoverage,
type RollupCoverageMap,
} from "@/lib/measurements/rollup-coverage";
} from "@/lib/rollups/measurement-coverage";
import {
correlateBpCompliance,
correlateMoodPulse,
@@ -342,7 +342,7 @@ async function fetchSeriesChunked(
let cursorId: string | undefined;
for (let page = 0; page < 1000; page++) {
const chunk = await prisma.measurement.findMany({
where: { userId, type, measuredAt: { gte: since } },
where: { userId, type, measuredAt: { gte: since }, deletedAt: null },
orderBy: [{ measuredAt: "asc" }, { id: "asc" }],
select: { id: true, measuredAt: true, value: true },
take: CHUNK,
-66
View File
@@ -105,69 +105,3 @@ export function pearsonCorrelation(
return { r, strength, n };
}
const BERLIN_DATE_PARTS = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
weekday: "short",
});
/**
* Aggregate data into weekly averages, ISO-Monday-based, in Europe/Berlin.
*
* v3 audit caught a TZ bug here: the previous implementation used
* `Date.getDay()` / `Date.getDate()` (system local) so on a UTC server, a
* Sunday-evening Berlin reading bucketed into the next week. We now derive
* year/month/day/weekday in Berlin via Intl.DateTimeFormat and rebuild the
* Monday key from there. The returned `date` for each bucket is the Monday
* UTC midnight — sortable, comparable across DST.
*/
export function weeklyAverages(data: DataPoint[]): DataPoint[] {
if (data.length === 0) return [];
const sorted = [...data].sort((a, b) => a.date.getTime() - b.date.getTime());
const weeks = new Map<string, { sum: number; count: number; date: Date }>();
const WEEKDAY_INDEX: Record<string, number> = {
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
Sun: 7,
};
for (const point of sorted) {
const parts = BERLIN_DATE_PARTS.formatToParts(point.date);
const yearStr = parts.find((p) => p.type === "year")?.value ?? "1970";
const monthStr = parts.find((p) => p.type === "month")?.value ?? "01";
const dayStr = parts.find((p) => p.type === "day")?.value ?? "01";
const weekdayStr = parts.find((p) => p.type === "weekday")?.value ?? "Mon";
const isoWeekday = WEEKDAY_INDEX[weekdayStr] ?? 1;
// Days to subtract to land on Monday of the same ISO week in Berlin.
const offsetDays = isoWeekday - 1;
const dayUtc = new Date(`${yearStr}-${monthStr}-${dayStr}T00:00:00.000Z`);
const monday = new Date(dayUtc.getTime() - offsetDays * 86_400_000);
const key = monday.toISOString().slice(0, 10);
const existing = weeks.get(key);
if (existing) {
existing.sum += point.value;
existing.count++;
} else {
weeks.set(key, { sum: point.value, count: 1, date: monday });
}
}
return Array.from(weeks.values())
.sort((a, b) => a.date.getTime() - b.date.getTime())
.map((w) => ({
date: w.date,
value: Math.round((w.sum / w.count) * 100) / 100,
}));
}
+67 -9
View File
@@ -40,11 +40,12 @@
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { readRollupBuckets } from "@/lib/measurements/rollups";
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
import {
probeRollupCoverage,
type RollupCoverageMap,
} from "@/lib/measurements/rollup-coverage";
} from "@/lib/rollups/measurement-coverage";
import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy";
import { calculateCompliance } from "./compliance";
import {
computeHealthScore,
@@ -159,19 +160,38 @@ export async function computeUserHealthScoreFastPath(
let weightSeriesPrev30d: Array<{ date: string; kg: number }>;
let weightSourcesIn30d: ReadonlyArray<ContributingSource>;
let latestWeightAsOf: string | null;
// v1.4.40 W-WMY-WIRE — long-window weight baseline served from the
// WEEK / MONTH / YEAR rollup tier. Used for the diagnostic annotate
// so operators can verify the WMY readers actually serve production
// traffic instead of sitting as dead write amplification (see
// `.planning/round-v1438-perf-analysis.md` §2 + §5 P6). The score
// shape stays unchanged; the long-window mean is annotate-only so
// the v1.5 Coach drawer can opt into it later without breaking the
// current contract.
let weightLongWindowGranularity: string | null = null;
let weightLongWindowMean: number | null = null;
let weightLongWindowBucketCount = 0;
if (weightCovered) {
// Rollup-fast-path — per-day MEAN weight from `measurement_rollups`.
// Linear regression on per-day means produces a slope equivalent
// to per-event regression for a series with consistent sampling
// cadence (one weigh-in per day is the canonical pattern).
const dayBuckets = await readRollupBuckets(
userId,
"WEIGHT",
"DAY",
prevSince30d,
now,
);
//
// v1.4.40 W-WMY-WIRE — the long-window weight read runs in
// parallel with the canonical DAY-bucket read so the wall-clock
// cost stays flat. `readBestGranularityRollups(..., 365)` routes
// the trailing-year window through MONTH (floor 181 d) by default
// — typically 12 monthly buckets vs the ~365 DAY rows the live
// path would scan. count / mean are linearly composable across
// MONTH buckets so the derived mean is mathematically equivalent
// to the per-row average over the same year-long window.
// Coverage-fallback in the helper means a tenant with WEEK or DAY
// coverage but no MONTH buckets still resolves to a usable mean.
const [dayBuckets, longWindow] = await Promise.all([
readRollupBuckets(userId, "WEIGHT", "DAY", prevSince30d, now),
readBestGranularityRollups(userId, "WEIGHT", 365),
]);
weightSeriesLast30d = dayBuckets
.filter((b) => b.bucketStart >= since30d)
.map((b) => ({
@@ -184,6 +204,19 @@ export async function computeUserHealthScoreFastPath(
date: b.bucketStart.toISOString(),
kg: b.mean,
}));
if (longWindow && longWindow.rows.length > 0) {
weightLongWindowGranularity = longWindow.granularity;
weightLongWindowBucketCount = longWindow.rows.length;
let totalCount = 0;
let weighted = 0;
for (const row of longWindow.rows) {
totalCount += row.count;
weighted += row.count * row.mean;
}
if (totalCount > 0) {
weightLongWindowMean = weighted / totalCount;
}
}
// Source attribution still needs the raw rows — only the raw table
// carries the `source` enum. Pull the narrow 30-day window with a
// 2-column projection so the round-trip stays minimal.
@@ -192,6 +225,7 @@ export async function computeUserHealthScoreFastPath(
userId,
type: "WEIGHT",
measuredAt: { gte: since30d, lte: now },
deletedAt: null,
},
select: { measuredAt: true, source: true },
orderBy: { measuredAt: "asc" },
@@ -206,6 +240,7 @@ export async function computeUserHealthScoreFastPath(
userId,
type: "WEIGHT",
measuredAt: { gte: prevSince30d, lte: now },
deletedAt: null,
},
select: { value: true, measuredAt: true, source: true },
orderBy: { measuredAt: "asc" },
@@ -235,6 +270,7 @@ export async function computeUserHealthScoreFastPath(
userId,
type: "BLOOD_PRESSURE_SYS",
measuredAt: { gte: since30d, lte: now },
deletedAt: null,
},
select: { measuredAt: true, source: true },
orderBy: { measuredAt: "asc" },
@@ -335,6 +371,14 @@ export async function computeUserHealthScoreFastPath(
score: null,
reason: "no_components_available",
path: weightCovered ? "rollup" : "live",
weightLongWindow:
weightLongWindowMean !== null
? {
mean: Math.round(weightLongWindowMean * 100) / 100,
granularity: weightLongWindowGranularity,
buckets: weightLongWindowBucketCount,
}
: null,
},
},
});
@@ -403,6 +447,20 @@ export async function computeUserHealthScoreFastPath(
band: result.band,
delta: result.delta,
path: weightCovered ? "rollup" : "live",
// v1.4.40 W-WMY-WIRE — the long-window weight baseline drawn
// from the WEEK / MONTH / YEAR rollup tier. `null` when the
// user lacks long-tail coverage or when the live fallback
// branch ran (no probe issued). Surfaces the granularity the
// router landed on so operators can see "rollup tier served
// the year-long mean from MONTH buckets" in the wide-event.
weightLongWindow:
weightLongWindowMean !== null
? {
mean: Math.round(weightLongWindowMean * 100) / 100,
granularity: weightLongWindowGranularity,
buckets: weightLongWindowBucketCount,
}
: null,
},
},
});
+143 -3
View File
@@ -59,16 +59,18 @@ import { prisma } from "@/lib/db";
import type { DataSummary } from "@/lib/analytics/trends";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import { annotate } from "@/lib/logging/context";
import { ensureUserRollupsFresh } from "@/lib/measurements/rollups";
import { ensureUserRollupsFresh } from "@/lib/rollups/measurement-rollups";
import {
isFullyCovered,
probeRollupCoverage,
} from "@/lib/measurements/rollup-coverage";
} from "@/lib/rollups/measurement-coverage";
import {
aggregateWmyBuckets,
readBestGranularityRollups,
type RollupBucketRow,
} from "@/lib/measurements/rollup-read-wmy";
} from "@/lib/rollups/measurement-read-wmy";
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Heavy aggregate row — used on the cold-mount fallback path where the
@@ -301,6 +303,7 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
)::double precision AS r2_90
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."deleted_at" IS NULL
GROUP BY m."type"
`,
prisma.$queryRaw<LatestRow[]>`
@@ -310,6 +313,7 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
m."measured_at" AS measured_at
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."deleted_at" IS NULL
ORDER BY m."type", m."measured_at" DESC
`,
// v1.4.37.2 hotfix — the v1.4.35 implementation read EVERY DAY
@@ -388,10 +392,12 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
let totalRows = 0;
let typeCount = 0;
const typesWithData: string[] = [];
for (const row of dayBuckets) {
if (!row.count || row.count <= 0) continue;
typeCount += 1;
totalRows += row.count;
typesWithData.push(row.type);
const narrow = narrowByType.get(row.type);
const latest = latestByType.get(row.type) ?? null;
summaries[row.type] = {
@@ -411,6 +417,21 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
};
}
// v1.4.40 W-WMY-WIRE — populate `avg30LastYear` per type from the
// WMY rollup tier. Only types with data in the current window are
// probed so we don't pay the per-type round-trip on the long tail
// of unlogged measurement enums. Types without YEAR/MONTH coverage
// surface as `null` (existing behaviour) — additive only.
const avg30LastYearMap = await computeAvg30LastYearMap(userId, typesWithData);
let yearOverYearTypeCount = 0;
for (const [type, value] of avg30LastYearMap.entries()) {
if (value === null) continue;
const summary = summaries[type];
if (!summary) continue;
summary.avg30LastYear = round2(value);
yearOverYearTypeCount += 1;
}
annotate({
action: { name: "analytics.get.slim" },
meta: {
@@ -419,6 +440,7 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
row_count: totalRows,
type_count: typeCount,
path: "rollup",
year_over_year_types: yearOverYearTypeCount,
},
},
},
@@ -487,6 +509,7 @@ async function computeFromLiveAggregate(
)::double precision AS r2_90
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."deleted_at" IS NULL
GROUP BY m."type"
`,
prisma.$queryRaw<LatestRow[]>`
@@ -496,6 +519,7 @@ async function computeFromLiveAggregate(
m."measured_at" AS measured_at
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."deleted_at" IS NULL
ORDER BY m."type", m."measured_at" DESC
`,
]);
@@ -531,9 +555,11 @@ async function computeFromLiveAggregate(
}
let totalRows = 0;
const typesWithData: string[] = [];
for (const row of aggregates) {
const count = Number(row.count);
totalRows += count;
typesWithData.push(row.type);
const latest = latestByType.get(row.type) ?? null;
summaries[row.type] = {
count,
@@ -552,6 +578,23 @@ async function computeFromLiveAggregate(
};
}
// v1.4.40 W-WMY-WIRE — even on the live-aggregate fallback (DAY
// coverage incomplete), the YEAR / MONTH rollup tier may still
// carry the year-ago baseline because the boot-time backfill mints
// all granularities once per user. Probing here means a partial-
// coverage cold mount still surfaces `avg30LastYear` whenever the
// long-tail tier is populated, instead of waiting for the next
// refresh cycle.
const avg30LastYearMap = await computeAvg30LastYearMap(userId, typesWithData);
let yearOverYearTypeCount = 0;
for (const [type, value] of avg30LastYearMap.entries()) {
if (value === null) continue;
const summary = summaries[type];
if (!summary) continue;
summary.avg30LastYear = round2(value);
yearOverYearTypeCount += 1;
}
annotate({
action: { name: "analytics.get.slim" },
meta: {
@@ -560,6 +603,7 @@ async function computeFromLiveAggregate(
row_count: totalRows,
type_count: aggregates.length,
path: "live",
year_over_year_types: yearOverYearTypeCount,
},
},
},
@@ -660,3 +704,99 @@ export async function computeLongWindowSummary(
sum: aggregate.sum,
};
}
/**
* v1.4.40 W-WMY-WIRE — year-ago 30-day baseline per type, served from
* the WEEK / MONTH / YEAR rollup tier.
*
* Why this helper exists
* ----------------------
* `DataSummary.avg30LastYear` is the mean over `[now-395d, now-365d)` —
* the 30-day window starting 365 days ago. The dashboard tile-strip's
* delta callout uses it to narrate "current avg vs same time last
* year". Up to v1.4.39 the slim slice (and the comprehensive
* aggregator's rollup branch) hardcoded the field to `null` because
* the 90-day windowed `$queryRaw` cannot reach back a year, and the
* live `measurements` table walk for a 395-day per-type window is
* exactly the cold-path cost we're trying to avoid.
*
* The WMY rollup tier already carries the per-bucket stats this
* window needs. `readBestGranularityRollups(userId, type, 395)`
* routes the 395-day window through YEAR (if covered) → MONTH → WEEK
* → DAY and returns the buckets whose `bucketStart` falls inside the
* window. We then keep only the buckets that overlap the
* `[now-395d, now-365d)` slice and compose `count / mean` linearly to
* produce the year-ago baseline.
*
* Compositional contract
* ----------------------
* `count / mean` are linearly composable across any granularity, so
* the year-ago mean derived from MONTH or YEAR buckets is
* mathematically equivalent to the per-row average over the same
* window. The bucket-overlap filter is conservative: we include only
* buckets whose `bucketStart` is strictly inside the slice. On a
* YEAR-granularity routing this collapses to at most one bucket and
* approximates the 30-day mean by the surrounding yearly average —
* acceptable for a UI hint that explicitly narrates "last year".
*
* Coverage-miss policy
* --------------------
* Returns `null` when `readBestGranularityRollups` returns null (no
* coverage at any granularity) OR when no buckets overlap the
* year-ago slice. The caller leaves the dashboard field as `null`,
* which the UI already handles as "no comparison available".
*/
async function computeAvg30LastYearForType(
userId: string,
type: MeasurementType,
): Promise<number | null> {
const resolved = await readBestGranularityRollups(userId, type, 395);
if (!resolved) return null;
const now = Date.now();
const sliceStart = now - 395 * DAY_MS;
const sliceEnd = now - 365 * DAY_MS;
const overlapping = resolved.rows.filter((row) => {
const t = row.bucketStart.getTime();
return t >= sliceStart && t < sliceEnd;
});
if (overlapping.length === 0) return null;
let totalCount = 0;
let weighted = 0;
for (const row of overlapping) {
totalCount += row.count;
weighted += row.count * row.mean;
}
if (totalCount === 0) return null;
return weighted / totalCount;
}
/**
* v1.4.40 W-WMY-WIRE — fan out `computeAvg30LastYearForType` across
* the types the caller actually surfaces. Runs the per-type WMY reads
* in parallel so the long-tail of types doesn't serialise into a
* per-type round-trip stack.
*
* Returns a `type → mean | null` map; types with no coverage at any
* granularity (or no buckets in the year-ago slice) map to `null` so
* the caller can blanket-assign without per-type null-checking.
*/
async function computeAvg30LastYearMap(
userId: string,
types: ReadonlyArray<string>,
): Promise<Map<string, number | null>> {
const out = new Map<string, number | null>();
if (types.length === 0) return out;
const results = await Promise.all(
types.map(async (type) => {
const value = await computeAvg30LastYearForType(
userId,
type as MeasurementType,
);
return [type, value] as const;
}),
);
for (const [type, value] of results) {
out.set(type, value);
}
return out;
}
-27
View File
@@ -8,33 +8,6 @@ export interface DataPoint {
value: number;
}
// ── Moving Average ───────────────────────────────────────
export function movingAverage(
data: DataPoint[],
windowDays: number,
): DataPoint[] {
if (data.length === 0) return [];
const sorted = [...data].sort((a, b) => a.date.getTime() - b.date.getTime());
const result: DataPoint[] = [];
const windowMs = windowDays * 24 * 60 * 60 * 1000;
for (const point of sorted) {
const windowStart = point.date.getTime() - windowMs;
const windowPoints = sorted.filter(
(p) =>
p.date.getTime() > windowStart &&
p.date.getTime() <= point.date.getTime(),
);
const avg =
windowPoints.reduce((sum, p) => sum + p.value, 0) / windowPoints.length;
result.push({ date: point.date, value: Math.round(avg * 100) / 100 });
}
return result;
}
// ── Trend Slope ──────────────────────────────────────────
export interface TrendSlope {
+123
View File
@@ -0,0 +1,123 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
consentReceipt: {
create: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
},
},
}));
import { prisma } from "@/lib/db";
import {
createReceipt,
latestActiveReceipt,
latestActiveReceiptsByKind,
revokeLatest,
} from "../receipts";
beforeEach(() => {
vi.resetAllMocks();
});
function row(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: "rcpt-1",
userId: "user-1",
kind: "ai_full",
artefact: "PDF…",
signedAt: new Date("2026-05-18T10:00:00.000Z"),
revokedAt: null,
createdAt: new Date("2026-05-18T10:00:01.000Z"),
...overrides,
};
}
describe("createReceipt", () => {
it("inserts a row scoped to (userId, kind) with the artefact + signedAt", async () => {
vi.mocked(prisma.consentReceipt.create).mockResolvedValue(row() as never);
const signedAt = new Date("2026-05-18T10:00:00.000Z");
const result = await createReceipt("user-1", "ai_full", "PDF…", signedAt);
expect(prisma.consentReceipt.create).toHaveBeenCalledWith({
data: {
userId: "user-1",
kind: "ai_full",
artefact: "PDF…",
signedAt,
},
});
expect(result.id).toBe("rcpt-1");
});
});
describe("latestActiveReceipt", () => {
it("filters by user + kind + revokedAt:null and orders by createdAt desc", async () => {
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(row() as never);
const result = await latestActiveReceipt("user-1", "ai_full");
expect(prisma.consentReceipt.findFirst).toHaveBeenCalledWith({
where: { userId: "user-1", kind: "ai_full", revokedAt: null },
orderBy: { createdAt: "desc" },
});
expect(result?.id).toBe("rcpt-1");
});
it("returns null when no active receipt exists", async () => {
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
const result = await latestActiveReceipt("user-1", "ai_full");
expect(result).toBeNull();
});
});
describe("latestActiveReceiptsByKind", () => {
it("returns the freshest row per kind (first-wins over createdAt desc)", async () => {
vi.mocked(prisma.consentReceipt.findMany).mockResolvedValue([
row({ id: "fresh-coach", kind: "ai_coach" }),
row({ id: "fresh-full", kind: "ai_full" }),
// Older `ai_full` row — must be ignored because we already saw
// the freshest one above (the mock list is pre-sorted desc).
row({ id: "older-full", kind: "ai_full" }),
] as never);
const result = await latestActiveReceiptsByKind("user-1");
expect(result.ai_full?.id).toBe("fresh-full");
expect(result.ai_coach?.id).toBe("fresh-coach");
expect(result.ai_insights_only).toBeUndefined();
});
});
describe("revokeLatest", () => {
it("flips revokedAt on the latest active row and returns the updated row", async () => {
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(row() as never);
vi.mocked(prisma.consentReceipt.update).mockResolvedValue(
row({ revokedAt: new Date("2026-05-18T11:00:00.000Z") }) as never,
);
const now = new Date("2026-05-18T11:00:00.000Z");
const result = await revokeLatest("user-1", "ai_full", now);
expect(prisma.consentReceipt.update).toHaveBeenCalledWith({
where: { id: "rcpt-1" },
data: { revokedAt: now },
});
expect(result?.revokedAt).toEqual(now);
});
it("returns null without writing when no active receipt exists", async () => {
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
const result = await revokeLatest("user-1", "ai_full");
expect(result).toBeNull();
expect(prisma.consentReceipt.update).not.toHaveBeenCalled();
});
});
+105
View File
@@ -0,0 +1,105 @@
/**
* v1.4.40 SB-10 — AI consent receipts CRUD helper.
*
* Append-only audit trail. Every grant + revoke mints a fresh row;
* no updates, no deletes outside of cascade-on-user-delete. The
* `revokeLatest` helper writes the current row's `revokedAt` and
* returns it — that single field flip is the only mutation the
* helper performs. A subsequent re-grant inserts a new row, so the
* historical chain stays intact for legal review.
*
* Source of truth for "is AI active right now?" is
* `latestActiveReceipt(userId, kind)` returning non-null. Reader
* routes never inspect older rows.
*/
import { prisma } from "@/lib/db";
import type { ConsentReceiptModel } from "@/generated/prisma/models/ConsentReceipt";
import type { ConsentKind } from "@/lib/validations/consent";
export type ConsentReceipt = ConsentReceiptModel;
/**
* Insert a fresh consent receipt for (userId, kind). Always appends;
* never collides with an earlier row. The caller has already
* Zod-validated `artefact` (≤ 64 KB) and `signedAt` (ISO-8601).
*/
export async function createReceipt(
userId: string,
kind: ConsentKind,
artefact: string,
signedAt: Date,
): Promise<ConsentReceipt> {
return prisma.consentReceipt.create({
data: {
userId,
kind,
artefact,
signedAt,
},
});
}
/**
* Return the most recent non-revoked receipt for (userId, kind), or
* `null` if none exists. Ordering is by `createdAt DESC` — the same
* index direction declared on the schema, so the planner walks the
* compound index forward without a sort.
*
* "Non-revoked" = `revokedAt IS NULL`. A revoked row never resurrects;
* a new grant must mint a new row.
*/
export async function latestActiveReceipt(
userId: string,
kind: ConsentKind,
): Promise<ConsentReceipt | null> {
return prisma.consentReceipt.findFirst({
where: { userId, kind, revokedAt: null },
orderBy: { createdAt: "desc" },
});
}
/**
* Return the latest non-revoked receipt across every kind for the
* user, keyed by kind. Used by `GET /api/consent/ai/latest` when the
* caller omits the `?kind` query param.
*/
export async function latestActiveReceiptsByKind(
userId: string,
): Promise<Partial<Record<ConsentKind, ConsentReceipt>>> {
const rows = await prisma.consentReceipt.findMany({
where: { userId, revokedAt: null },
orderBy: { createdAt: "desc" },
});
// First-wins reduce over `createdAt DESC`. `findMany` returns every
// historical grant for the user; we keep the freshest per kind.
const out: Partial<Record<ConsentKind, ConsentReceipt>> = {};
for (const r of rows) {
const k = r.kind as ConsentKind;
if (!(k in out)) out[k] = r;
}
return out;
}
/**
* Mark the latest active receipt for (userId, kind) as revoked.
* Returns the updated row, or `null` if no active receipt exists.
*
* Implementation note: a two-step "read latest → update by id" is
* race-tolerant for the single-user revoke flow (iOS toggle is a
* per-device action; no concurrent revoke from a second device is
* plausible inside the same millisecond). If two devices race we end
* up writing `revokedAt` to the same row twice with identical
* timestamps — semantically idempotent.
*/
export async function revokeLatest(
userId: string,
kind: ConsentKind,
now: Date = new Date(),
): Promise<ConsentReceipt | null> {
const latest = await latestActiveReceipt(userId, kind);
if (!latest) return null;
return prisma.consentReceipt.update({
where: { id: latest.id },
data: { revokedAt: now },
});
}
+31
View File
@@ -5,9 +5,40 @@ const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
/**
* v1.4.40 W-POOL — Prisma `pg.Pool` ceiling raised from the library
* default of 10 → 20.
*
* The v1.4.39 empirical cold-mount trace
* (`.planning/round-v1439-empirical-trace.md` § B2) showed thick
* `/api/analytics` holding ≥ 8 of the 10 default pool slots for
* 6.5 s on Marc's 347k-row tenant, starving every other Wave-B and
* Wave-C useQuery fan-out for the duration. Coolify's Postgres
* container runs the stock `max_connections` 100 ceiling, so a
* 20-slot Node-side pool sits well under the server's hard cap with
* plenty of headroom for concurrent power-users (≤ 5 simultaneous
* tenants × 20 = 100). Complements the W-POOL `p-limit(4)` cap on
* the analytics fan-out itself — the bounded concurrency keeps any
* single analytics call to ≤ 4 slots, so the remaining 16 stay
* available for every other dashboard query.
*
* Env-overridable via `DATABASE_POOL_MAX` so an operator running
* with a smaller Postgres `max_connections` can dial down without a
* code change.
*/
export function getPoolMax(): number {
const raw = process.env.DATABASE_POOL_MAX;
if (raw) {
const parsed = Number.parseInt(raw, 10);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
return 20;
}
function createPrismaClient() {
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL!,
max: getPoolMax(),
});
return new PrismaClient({ adapter });
}
-4
View File
@@ -15,10 +15,6 @@ export function mgdlToMmol(mgdl: number): number {
return Math.round((mgdl / MGDL_PER_MMOL) * 10) / 10;
}
export function mmolToMgdl(mmol: number): number {
return Math.round(mmol * MGDL_PER_MMOL);
}
export function convertGlucose(value: number, to: GlucoseUnit): number {
return to === "mmol/L" ? mgdlToMmol(value) : Math.round(value);
}
@@ -1,69 +0,0 @@
/**
* v1.4.27 B6 — `format.dateShort` / `timeShort` / `dateTime` ordering.
*
* Each shipped locale carries a `format.*` namespace that documents
* its native date-pattern ordering as i18n strings. The actual
* formatting still routes through `Intl.*` via
* `src/lib/format-locale.ts`; the format keys exist so downstream
* surfaces (PDF export, email templates, CSV docstrings) can render
* the date hint without a JS context.
*
* This smoke test pins the ordering per locale so a copy-paste
* regression that flips DD/MM to MM/DD on the French bundle would
* fail fast.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const MESSAGES = join(__dirname, "../../../../messages");
function loadFormat(locale: string): {
dateShort: string;
timeShort: string;
dateTime: string;
} {
const bundle = JSON.parse(
readFileSync(join(MESSAGES, `${locale}.json`), "utf8"),
);
const fmt = bundle.format as
| { dateShort?: string; timeShort?: string; dateTime?: string }
| undefined;
if (!fmt || !fmt.dateShort || !fmt.timeShort || !fmt.dateTime) {
throw new Error(`Locale ${locale} is missing the format namespace`);
}
return fmt as { dateShort: string; timeShort: string; dateTime: string };
}
describe("locale-native format ordering", () => {
it("DE renders day-month-year with dot separators", () => {
const fmt = loadFormat("de");
expect(fmt.dateShort).toBe("{day}.{month}.{year}");
expect(fmt.dateTime).toBe("{day}.{month}.{year} {hour}:{minute}");
expect(fmt.timeShort).toBe("{hour}:{minute}");
});
it("EN renders month-day-year with slash separators", () => {
const fmt = loadFormat("en");
expect(fmt.dateShort).toBe("{month}/{day}/{year}");
expect(fmt.dateTime).toBe("{month}/{day}/{year} {hour}:{minute}");
expect(fmt.timeShort).toBe("{hour}:{minute}");
});
it.each([["fr"], ["es"], ["it"]])(
"%s renders day-month-year with slash separators (native ordering)",
(locale) => {
const fmt = loadFormat(locale);
expect(fmt.dateShort).toBe("{day}/{month}/{year}");
expect(fmt.dateTime).toBe("{day}/{month}/{year} {hour}:{minute}");
expect(fmt.timeShort).toBe("{hour}:{minute}");
},
);
it("PL renders day-month-year with dot separators (native ordering)", () => {
const fmt = loadFormat("pl");
expect(fmt.dateShort).toBe("{day}.{month}.{year}");
expect(fmt.dateTime).toBe("{day}.{month}.{year} {hour}:{minute}");
expect(fmt.timeShort).toBe("{hour}:{minute}");
});
});
+28 -2
View File
@@ -5,12 +5,28 @@ vi.mock("@/lib/db", () => ({
user: { findUnique: vi.fn() },
measurement: { findMany: vi.fn() },
measurementRollup: { 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() },
$queryRawUnsafe: vi.fn(),
},
}));
// Stub the mood-rollup warm-up so test runs don't fire the real
// recompute aggregate. The warm-up is fire-and-forget on the route.
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/medication-category", () => ({
getMedicationCategories: vi.fn(async () => ({})),
}));
@@ -27,7 +43,14 @@ const prismaMock = prisma as unknown as {
user: { findUnique: ReturnType<typeof vi.fn> };
measurement: { findMany: ReturnType<typeof vi.fn> };
measurementRollup: { findMany: ReturnType<typeof vi.fn> };
moodEntry: { findMany: ReturnType<typeof vi.fn> };
moodEntry: {
findMany: ReturnType<typeof vi.fn>;
findFirst: ReturnType<typeof vi.fn>;
};
moodEntryRollup: {
findMany: ReturnType<typeof vi.fn>;
findFirst: ReturnType<typeof vi.fn>;
};
medication: { findMany: ReturnType<typeof vi.fn> };
medicationIntakeEvent: { findMany: ReturnType<typeof vi.fn> };
};
@@ -58,6 +81,9 @@ beforeEach(() => {
prismaMock.measurement.findMany.mockResolvedValue([]);
prismaMock.measurementRollup.findMany.mockResolvedValue([]);
prismaMock.moodEntry.findMany.mockResolvedValue([]);
prismaMock.moodEntry.findFirst.mockResolvedValue(null);
prismaMock.moodEntryRollup.findMany.mockResolvedValue([]);
prismaMock.moodEntryRollup.findFirst.mockResolvedValue(null);
prismaMock.medication.findMany.mockResolvedValue([]);
prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]);
});
+12 -3
View File
@@ -100,12 +100,12 @@
import { prisma } from "@/lib/db";
import type { DataSummary } from "@/lib/analytics/trends";
import { annotate } from "@/lib/logging/context";
import { ensureUserRollupsFresh } from "@/lib/measurements/rollups";
import { aggregateBuckets } from "@/lib/measurements/rollup-read";
import { ensureUserRollupsFresh } from "@/lib/rollups/measurement-rollups";
import { aggregateBuckets } from "@/lib/rollups/measurement-read";
import {
isFullyCovered,
probeRollupCoverage,
} from "@/lib/measurements/rollup-coverage";
} from "@/lib/rollups/measurement-coverage";
/**
* Heavy aggregate row — count/min/max/mean alongside the non-composable
@@ -310,6 +310,7 @@ async function fetchBpRawRows(
userId,
type: { in: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"] },
measuredAt: { gte: ninetyDaysAgo },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: { type: true, measuredAt: true, value: true },
@@ -348,6 +349,7 @@ async function buildFromRollups(
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."measured_at" >= ${ninetyDaysAgo}
AND m."deleted_at" IS NULL
GROUP BY m."type"
)
SELECT
@@ -414,6 +416,7 @@ async function buildFromRollups(
JOIN window_stats ws ON ws."type" = m."type"
WHERE m."user_id" = ${userId}
AND m."measured_at" >= ${ninetyDaysAgo}
AND m."deleted_at" IS NULL
GROUP BY m."type", ws.stddev_value
`),
timeSubquery(timings, "latests", () => prisma.$queryRaw<LatestRow[]>`
@@ -424,6 +427,7 @@ async function buildFromRollups(
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."measured_at" >= ${ninetyDaysAgo}
AND m."deleted_at" IS NULL
ORDER BY m."type", m."measured_at" DESC
`),
// v1.4.35 — DAY rollup buckets for the trailing 90 days, every
@@ -450,6 +454,7 @@ async function buildFromRollups(
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."measured_at" >= ${ninetyDaysAgo}
AND m."deleted_at" IS NULL
`),
]);
@@ -536,6 +541,7 @@ async function buildFromLiveAggregate(
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."measured_at" >= ${ninetyDaysAgo}
AND m."deleted_at" IS NULL
GROUP BY m."type"
)
SELECT
@@ -602,6 +608,7 @@ async function buildFromLiveAggregate(
JOIN window_stats ws ON ws."type" = m."type"
WHERE m."user_id" = ${userId}
AND m."measured_at" >= ${ninetyDaysAgo}
AND m."deleted_at" IS NULL
GROUP BY m."type", ws.stddev_value
`),
timeSubquery(timings, "latests", () => prisma.$queryRaw<LatestRow[]>`
@@ -612,6 +619,7 @@ async function buildFromLiveAggregate(
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."measured_at" >= ${ninetyDaysAgo}
AND m."deleted_at" IS NULL
ORDER BY m."type", m."measured_at" DESC
`),
// Buckets may exist for some types even when the COUNT probe came
@@ -672,6 +680,7 @@ async function buildFromLiveAggregate(
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."measured_at" >= ${ninetyDaysAgo}
AND m."deleted_at" IS NULL
`,
);
firstMeasurementAt = firstRows[0]?.first_at
+111 -27
View File
@@ -15,7 +15,11 @@ import {
type CorrelationResult,
} from "@/lib/analytics/correlations";
import { getMedicationCategories } from "@/lib/medication-category";
import { readRollupBuckets } from "@/lib/measurements/rollups";
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
import {
ensureUserMoodRollupsFresh,
readMoodDayRollups,
} from "@/lib/rollups/mood-rollups";
import type {
MeasurementType,
RollupGranularity,
@@ -373,8 +377,8 @@ export async function extractFeatures(
: null;
const measurements = await prisma.measurement.findMany({
where: sinceCutoff
? { userId, measuredAt: { gte: sinceCutoff } }
: { userId },
? { userId, measuredAt: { gte: sinceCutoff }, deletedAt: null }
: { userId, deletedAt: null },
orderBy: { measuredAt: "asc" },
});
@@ -583,24 +587,104 @@ export async function extractFeatures(
}
// Mood
const moodEntries = await prisma.moodEntry.findMany({
where: { userId },
orderBy: { moodLoggedAt: "asc" },
});
//
// v1.4.40 — swap the unbounded `prisma.moodEntry.findMany` for the
// persistent mood-rollup DAY tier (audit Critical Finding #2). The
// feature block downstream consumes:
// - `avg7` / `avg30` over recent rollup means
// - `trend30` from first-half vs second-half of last 30 days
// - `latest` = newest individual entry score (one bounded row)
// - `totalEntries` = sum of rollup `count` across all DAY rows
// - `oldest` / `newest` = first and last rollup `bucketStart`
// - `moodPoints` for cross-metric correlations (one per-day mean
// DataPoint — same resolution as the legacy raw-score series for
// single-entry-per-day power users, slightly different on
// multi-entry days, which is the rollup-tier semantic the
// v1.4.39 `/api/mood/analytics` already shipped)
//
// Coverage-fallback: when raw entries exist but the rollup tier is
// still empty (legacy account before boot-time backfill caught up),
// we fall back to a bounded 1-year raw walk and fire the warm-up so
// the next request lands on the rollup tier. Same posture as
// `/api/mood/analytics`.
void ensureUserMoodRollupsFresh(userId);
// Five-year window mirrors the mood-rollup writer default; covers
// every realistic user history span without an unbounded scan.
const moodSince = new Date(Date.now() - 5 * 365 * 24 * 60 * 60 * 1000);
const moodRollupDayRows = await readMoodDayRollups(userId, moodSince);
if (moodEntries.length > 0) {
type MoodDayPoint = {
measuredAt: Date;
value: number;
count: number;
};
let moodDailyPoints: MoodDayPoint[] = [];
let moodLatestScore: number | null = null;
let moodTotalEntries = 0;
if (moodRollupDayRows.length > 0) {
moodDailyPoints = moodRollupDayRows.map((r) => ({
measuredAt: r.bucketStart,
value: r.mean,
count: r.count,
}));
moodTotalEntries = moodRollupDayRows.reduce((s, r) => s + r.count, 0);
// Latest score = newest individual entry (one bounded row).
const latestEntry = await prisma.moodEntry.findFirst({
where: { userId },
orderBy: { moodLoggedAt: "desc" },
select: { score: true },
});
moodLatestScore = latestEntry?.score ?? null;
} else {
// Coverage-fallback. Bounded 1-year walk (instead of the legacy
// unbounded `findMany`) so even a cache miss is capped.
const oneYearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000);
const moodEntriesRaw = await prisma.moodEntry.findMany({
where: { userId, moodLoggedAt: { gte: oneYearAgo } },
orderBy: { moodLoggedAt: "asc" },
select: { score: true, moodLoggedAt: true, date: true },
});
if (moodEntriesRaw.length > 0) {
// Bucket per `date` (TZ-anchored YYYY-MM-DD label, same key the
// rollup tier would emit for Berlin-anchored single-entry days)
// so the fallback shape matches the rollup-tier shape.
const byDay = new Map<string, { sum: number; count: number; ts: Date }>();
for (const e of moodEntriesRaw) {
const k = e.date;
const cur = byDay.get(k) ?? { sum: 0, count: 0, ts: e.moodLoggedAt };
cur.sum += e.score;
cur.count += 1;
// Pin the bucket's measuredAt to the latest entry's timestamp
// — close enough for the trailing-window filters below.
if (e.moodLoggedAt > cur.ts) cur.ts = e.moodLoggedAt;
byDay.set(k, cur);
}
moodDailyPoints = Array.from(byDay.values())
.map((b) => ({
measuredAt: b.ts,
value: b.sum / b.count,
count: b.count,
}))
.sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime());
moodTotalEntries = moodEntriesRaw.length;
moodLatestScore = moodEntriesRaw[moodEntriesRaw.length - 1].score;
}
}
if (moodDailyPoints.length > 0) {
const moodNow = Date.now();
const last7 = moodEntries.filter(
(e) => moodNow - e.moodLoggedAt.getTime() < 7 * 24 * 60 * 60 * 1000,
const last7 = moodDailyPoints.filter(
(e) => moodNow - e.measuredAt.getTime() < 7 * 24 * 60 * 60 * 1000,
);
const last30 = moodEntries.filter(
(e) => moodNow - e.moodLoggedAt.getTime() < 30 * 24 * 60 * 60 * 1000,
const last30 = moodDailyPoints.filter(
(e) => moodNow - e.measuredAt.getTime() < 30 * 24 * 60 * 60 * 1000,
);
const avg = (entries: typeof moodEntries) =>
entries.length > 0
const avg = (points: MoodDayPoint[]) =>
points.length > 0
? Math.round(
(entries.reduce((s, e) => s + e.score, 0) / entries.length) * 100,
(points.reduce((s, e) => s + e.value, 0) / points.length) * 100,
) / 100
: null;
@@ -608,10 +692,10 @@ export async function extractFeatures(
let trend30: "improving" | "declining" | "stable" | null = null;
if (last30.length >= 4) {
const firstHalf = last30.filter(
(e) => moodNow - e.moodLoggedAt.getTime() >= 15 * 24 * 60 * 60 * 1000,
(e) => moodNow - e.measuredAt.getTime() >= 15 * 24 * 60 * 60 * 1000,
);
const secondHalf = last30.filter(
(e) => moodNow - e.moodLoggedAt.getTime() < 15 * 24 * 60 * 60 * 1000,
(e) => moodNow - e.measuredAt.getTime() < 15 * 24 * 60 * 60 * 1000,
);
if (firstHalf.length >= 2 && secondHalf.length >= 2) {
const avgFirst = avg(firstHalf)!;
@@ -623,25 +707,25 @@ export async function extractFeatures(
}
}
const oldest = moodEntries[0].moodLoggedAt;
const newest = moodEntries[moodEntries.length - 1].moodLoggedAt;
const oldest = moodDailyPoints[0].measuredAt;
const newest = moodDailyPoints[moodDailyPoints.length - 1].measuredAt;
const spanDays = Math.round(
(newest.getTime() - oldest.getTime()) / (24 * 60 * 60 * 1000),
);
const avgDaysBetween =
moodEntries.length > 1
? Math.round((spanDays / (moodEntries.length - 1)) * 10) / 10
moodTotalEntries > 1
? Math.round((spanDays / (moodTotalEntries - 1)) * 10) / 10
: null;
features.mood = {
scale: "1=LAUSIG, 2=SCHLECHT, 3=OKAY, 4=GUT, 5=SUPER_GUT",
avg7: avg(last7),
avg30: avg(last30),
latest: moodEntries[moodEntries.length - 1].score,
latest: moodLatestScore,
trend30,
totalEntries: moodEntries.length,
totalEntries: moodTotalEntries,
coverage: {
count: moodEntries.length,
count: moodTotalEntries,
spanDays,
avgDaysBetween,
oldestDaysAgo: Math.round(
@@ -659,9 +743,9 @@ export async function extractFeatures(
const sysPoints = toDataPoints(sysData);
const diaPoints = toDataPoints(diaData);
const pulsePoints = toDataPoints(pulseData);
const moodPoints: DataPoint[] = moodEntries.map((e) => ({
date: e.moodLoggedAt,
value: e.score,
const moodPoints: DataPoint[] = moodDailyPoints.map((e) => ({
date: e.measuredAt,
value: e.value,
}));
const computeCorr = (a: DataPoint[], b: DataPoint[]) =>
+1
View File
@@ -74,6 +74,7 @@ export async function detectGlp1Plateau(
userId,
type: "WEIGHT",
measuredAt: { gte: windowStart },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true },
+1
View File
@@ -158,6 +158,7 @@ export async function generatePulseStatusForUser(
where: {
userId,
type: "PULSE",
deletedAt: null,
},
orderBy: { measuredAt: "desc" },
take: 365,
+1 -1
View File
@@ -36,7 +36,7 @@ import {
type ImportJobProgress,
type ImportJobResult,
} from "@/lib/measurements/import-apple-health-export";
import { recomputeUserRollups } from "@/lib/measurements/rollups";
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
/** Queue name for the Apple Health export ingest. */
export const APPLE_HEALTH_IMPORT_QUEUE = "apple-health-import";
+3 -47
View File
@@ -66,7 +66,7 @@ import {
recomputeUserRollups,
type RollupFullBackfillPayload,
type RollupRecomputePayload,
} from "@/lib/measurements/rollups";
} from "@/lib/rollups/measurement-rollups";
import {
MOOD_ROLLUP_FULL_BACKFILL_QUEUE,
MOOD_ROLLUP_FULL_BACKFILL_CONCURRENCY,
@@ -76,7 +76,7 @@ import {
recomputeUserMoodRollups,
type MoodRollupFullBackfillPayload,
type MoodRollupRecomputePayload,
} from "@/lib/mood/rollups";
} from "@/lib/rollups/mood-rollups";
import {
MEDICATION_COMPLIANCE_BACKFILL_QUEUE,
MEDICATION_COMPLIANCE_BACKFILL_CONCURRENCY,
@@ -84,7 +84,7 @@ import {
recomputeUserMedicationCompliance,
enqueueBootTimeMedicationComplianceBackfill,
type MedicationComplianceBackfillPayload,
} from "@/lib/medications/compliance-rollups";
} from "@/lib/rollups/medication-compliance-rollups";
import {
drainPerSampleCumulative,
DRAIN_CUMULATIVE_CUTOFF_HOURS,
@@ -161,7 +161,6 @@ const BMI_STATUS_CRON = "20 2 * * *"; // daily at 02:20
const MEDICATION_COMPLIANCE_STATUS_QUEUE =
"insights-medication-compliance-status";
const MEDICATION_COMPLIANCE_STATUS_CRON = "25 2 * * *"; // daily at 02:25
const TELEGRAM_CLEANUP_QUEUE = "telegram-message-cleanup";
const MOODLOG_SYNC_QUEUE = "moodlog-sync";
const MOODLOG_SYNC_CRON = "30 * * * *"; // every hour at :30
const DATA_BACKUP_QUEUE = "data-backup";
@@ -278,12 +277,6 @@ interface MedicationComplianceStatusPayload {
triggeredAt: string;
}
interface TelegramCleanupPayload {
userId: string;
chatId: string;
messageId: number;
}
interface MoodLogSyncPayload {
triggeredAt: string;
}
@@ -1091,37 +1084,6 @@ async function handleMedicationComplianceStatusGenerate(
);
}
/**
* Delete a Telegram message after a 24h delay.
* Scheduled by the Telegram sender when a notification is sent.
*/
async function handleTelegramCleanup(jobs: Job<TelegramCleanupPayload>[]) {
await withBackgroundEvent("job.telegram_cleanup", async (evt) => {
const prisma = getWorkerPrisma();
let deleted = 0;
for (const job of jobs) {
try {
const { userId, chatId, messageId } = job.data;
const user = await prisma.user.findFirst({
where: { id: userId, telegramBotToken: { not: null } },
select: { telegramBotToken: true },
});
if (user?.telegramBotToken) {
const botToken = decrypt(user.telegramBotToken);
await deleteMessage(botToken, chatId, messageId);
deleted++;
}
} catch (err) {
evt.addWarning(`Failed to delete message: ${err}`);
}
}
evt.setBackground({
task_name: "job.telegram_cleanup",
result: { deleted, total: jobs.length },
});
});
}
/**
* Fallback polling for moodLog data.
* Syncs mood entries for all users with moodLog enabled.
@@ -1669,7 +1631,6 @@ export async function startReminderWorker() {
PULSE_STATUS_QUEUE,
BMI_STATUS_QUEUE,
MEDICATION_COMPLIANCE_STATUS_QUEUE,
TELEGRAM_CLEANUP_QUEUE,
MOODLOG_SYNC_QUEUE,
DATA_BACKUP_QUEUE,
RATE_LIMIT_CLEANUP_QUEUE,
@@ -1829,11 +1790,6 @@ export async function startReminderWorker() {
{ localConcurrency: 1 },
handleMedicationComplianceStatusGenerate,
);
await boss.work<TelegramCleanupPayload>(
TELEGRAM_CLEANUP_QUEUE,
{ localConcurrency: 1 },
handleTelegramCleanup,
);
await boss.work<MoodLogSyncPayload>(
MOODLOG_SYNC_QUEUE,
{ localConcurrency: 1 },
+4 -63
View File
@@ -28,6 +28,7 @@
*/
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { wallClockInTz } from "@/lib/tz/wall-clock";
export interface ScheduleLike {
/** "HH:mm" 24h, user-tz reference (per existing schema). */
@@ -78,69 +79,9 @@ const WEEK_MS = 7 * DAY_MS;
*/
const PAIR_RADIUS_MS = 12 * 60 * 60 * 1000;
interface WallClockParts {
year: number;
month: number; // 1-12
day: number;
hour: number;
minute: number;
second: number;
weekday: number; // 0 = Sunday
}
const WEEKDAY_MAP: Record<string, number> = {
Sun: 0,
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
};
/**
* Compute the wall-clock parts of `date` interpreted in the user's
* timezone. When `tz` is omitted, falls back to the system-local
* representation so the legacy single-tz callers keep their existing
* shape (the v1.4.25 W19e callers were system-local by construction).
*/
function wallClockInTz(date: Date, tz: string | undefined): WallClockParts {
if (!tz) {
return {
year: date.getFullYear(),
month: date.getMonth() + 1,
day: date.getDate(),
hour: date.getHours(),
minute: date.getMinutes(),
second: date.getSeconds(),
weekday: date.getDay(),
};
}
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
weekday: "short",
}).formatToParts(date);
const get = (type: Intl.DateTimeFormatPartTypes): string =>
parts.find((p) => p.type === type)?.value ?? "0";
let hour = Number(get("hour"));
if (hour === 24) hour = 0;
return {
year: Number(get("year")),
month: Number(get("month")),
day: Number(get("day")),
hour,
minute: Number(get("minute")),
second: Number(get("second")),
weekday: WEEKDAY_MAP[get("weekday")] ?? 0,
};
}
// WallClockParts + wallClockInTz live in `@/lib/tz/wall-clock` as the
// canonical helper — see the file-level comment there for the v1.4.40
// consolidation rationale.
/**
* Compute the UTC offset (in minutes) of `tz` at the given instant.
+1 -1
View File
@@ -7,7 +7,7 @@ import {
recordSyncFailure,
recordSyncSuccess,
} from "@/lib/integrations/status";
import { recomputeUserMoodRollups } from "@/lib/mood/rollups";
import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups";
/**
* Sync mood entries from a user's moodLog instance.
@@ -41,6 +41,8 @@ vi.mock("@parse/node-apn", () => {
public payload: unknown;
public category: string | undefined;
public mutableContent: boolean | undefined;
public interruptionLevel: string | undefined;
public priority: number | undefined;
constructor() {
notificationCtorMock();
}
@@ -466,6 +468,72 @@ describe("sendViaApns — dispatcher fan-out", () => {
expect(note.payload.scheduledAt).toBe("2026-05-17T20:00:00.000Z");
});
it("MEDICATION_REMINDER sets interruption-level=time-sensitive + priority=10", async () => {
// SB-5 v1.4.40 — medication reminders are the one category the user
// explicitly opts into Focus-bypass for. The aps payload must carry
// BOTH `interruption-level: time-sensitive` AND `apns-priority: 10`
// per Apple's push best-practices doc; otherwise APNs may downgrade
// delivery and the Focus-bypass intent is silently lost.
vi.mocked(prisma.device.findMany).mockResolvedValueOnce([
{ id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" },
] as never);
sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] });
await sendViaApns("u-1", {
title: "Take Mounjaro",
message: "10mg, weekly dose",
eventType: "MEDICATION_REMINDER",
});
const note = sendMock.mock.calls[0][0];
expect(note.interruptionLevel).toBe("time-sensitive");
expect(note.priority).toBe(10);
});
it.each([
"MOOD_REMINDER",
"MEASUREMENT_ANOMALY",
"COMPLIANCE_LOW",
"WITHINGS_SYNC_FAILED",
"SYSTEM_ALERT",
"PERSONAL_RECORD",
])("eventType %s does NOT set interruption-level (Focus respected)", async (eventType) => {
// Every non-MEDICATION_REMINDER event-type must omit the
// interruption-level so the system default (`active`) lets Focus
// modes — Sleep, Do-Not-Disturb, Personal — silence the alert as
// the user expects.
vi.mocked(prisma.device.findMany).mockResolvedValueOnce([
{ id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" },
] as never);
sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] });
await sendViaApns("u-1", {
title: "t",
message: "m",
eventType,
});
const note = sendMock.mock.calls[0][0];
expect(note.interruptionLevel).toBeUndefined();
// priority too — only opt in for time-sensitive deliveries.
expect(note.priority).toBeUndefined();
});
it("forwards explicit interruptionLevel on sendApnsPush", async () => {
// Lower-level entry should round-trip the explicit flag — caller
// controls the level when bypassing `sendViaApns`.
setEnv(VALID_ENV);
sendMock.mockResolvedValueOnce({ sent: [{ device: "abc" }], failed: [] });
await sendApnsPush({
deviceToken: "abc",
environment: "sandbox",
payload: {
alert: { title: "t", body: "b" },
interruptionLevel: "time-sensitive",
priority: 10,
},
});
const note = sendMock.mock.calls[0][0];
expect(note.interruptionLevel).toBe("time-sensitive");
expect(note.priority).toBe(10);
});
it("forwards explicit category override on sendApnsPush", async () => {
// Lower-level entry — directly setting `payload.category` must
// propagate to `note.category` so callers that bypass `sendViaApns`
+58
View File
@@ -59,6 +59,26 @@ export interface ApnsPayload {
* roundtrip.
*/
mutableContent?: boolean;
/**
* APNs `aps.interruption-level` (iOS 15+). Controls whether the alert
* bypasses Focus modes:
* - `passive` — silent, no banner; lives in Notification Center.
* - `active` — default; banner + sound; honours Focus.
* - `time-sensitive` — breaks through Focus modes (per-app opt-in by
* the user on iOS); used for things the user explicitly asked to be
* interrupted for. **Restricted to MEDICATION_REMINDER** in the
* dispatcher; other categories must NOT bypass Focus.
* - `critical` — entitlement-gated, ignores DND completely; not
* used by HealthLog.
* Omit for the iOS default (`active`).
*/
interruptionLevel?: "passive" | "active" | "time-sensitive" | "critical";
/**
* APNs `apns-priority` header. `10` = immediate delivery (required for
* time-sensitive interruption-level per Apple's push best-practices),
* `5` = power-conserving. Omit to let node-apn default to `10`.
*/
priority?: 5 | 10;
}
export interface ApnsSendInput {
@@ -269,6 +289,30 @@ export async function sendApnsPush(
// unlocks the iOS Notification Service Extension hook.
note.mutableContent = true;
}
if (input.payload.interruptionLevel) {
// node-apn's `interruptionLevel` setter writes through to
// `aps.interruption-level`. iOS 15+ reads it to decide whether the
// alert breaks through Focus modes — `time-sensitive` is the level
// that does so, gated on a per-app toggle the user grants on
// install. Older iOS versions ignore the key, so this is safe to
// send unconditionally.
//
// The cast mirrors the `category` setter above — node-apn 8.1's
// d.ts omits the public setter (the runtime setter on
// `apsProperties.js` writes the value through to
// `aps.interruption-level` unchanged), so we assign via a typed
// cast rather than poking `note.aps` directly. Forward-compatible
// with future d.ts fixes.
(note as unknown as { interruptionLevel: string }).interruptionLevel =
input.payload.interruptionLevel;
}
if (typeof input.payload.priority === "number") {
// node-apn writes this through to the `apns-priority` HTTP header.
// Apple's push best-practices doc requires priority 10 alongside
// `time-sensitive` interruption-level — otherwise APNs may downgrade
// delivery and the Focus-bypass intent is lost.
note.priority = input.payload.priority;
}
if (input.collapseId) {
note.collapseId = input.collapseId;
}
@@ -386,6 +430,14 @@ export async function sendViaApns(
const env =
device.apnsEnvironment === "production" ? "production" : "sandbox";
// SB-5 v1.4.40 — only MEDICATION_REMINDER opts in to Focus-bypass.
// The user explicitly asked the server to nudge them about a dose;
// a missed dose is the textbook "time-sensitive" use case Apple
// sanctions. Every other event-type (mood nudges, anomaly alerts,
// PRs, system messages) must stay on the default `active` level so
// Focus modes — including Sleep — silence them as the user expects.
const isTimeSensitive = payload.eventType === "MEDICATION_REMINDER";
const result = await sendApnsPush({
deviceToken: device.apnsToken,
environment: env,
@@ -407,6 +459,12 @@ export async function sendViaApns(
// no-op on today's binary but unblocks NSE work without a
// server-side change.
mutableContent: true,
...(isTimeSensitive
? {
interruptionLevel: "time-sensitive" as const,
priority: 10 as const,
}
: {}),
},
collapseId: payload.eventType,
});
+77 -2
View File
@@ -11,7 +11,24 @@ import type { QueryClient, QueryKey } from "@tanstack/react-query";
export const queryKeys = {
auth: () => ["auth"] as const,
/**
* v1.4.40 W-RSC — the `useAuth` hook's `["auth", "me"]` shape was a
* recurring source of factory drift (audit H1 — "`useAuth` uses
* `["auth", "me"]` but `queryKeys.auth()` returns `["auth"]`"). Both
* shapes share the `["auth"]` prefix so existing prefix-invalidations
* still match, but the centralised name makes the call site
* obviously factory-routed.
*/
authMe: () => ["auth", "me"] as const,
authRegistrationStatus: () => ["auth", "registration-status"] as const,
/**
* v1.4.40 W-RSC — Settings → AI surfaces and the targets editor
* subscribe to the user-thresholds API. Centralise the key so a
* future rename (e.g. `["user","limits"]`) doesn't drift across the
* three call sites (settings/thresholds-editor-section,
* settings/ai-section, targets/target-edit-sheet).
*/
userThresholds: () => ["user", "thresholds"] as const,
measurements: () => ["measurements"] as const,
moodEntries: () => ["mood-entries"] as const,
@@ -56,9 +73,20 @@ export const queryKeys = {
medicationDetail: (id: string) => ["medications", id] as const,
medicationComplianceChart: (medicationId: string) =>
["compliance-chart-inline", medicationId] as const,
/**
* v1.4.40 W-RSC — the dashboard-level compliance chart (aggregate
* across every scheduled medication) was a bare `["medication-
* compliance-chart", days]` key; route it through the factory so
* `medicationDependentKeys` invalidates it on intake-mutation just
* like the per-medication compliance-chart-inline tile. `days` is the
* range (7 / 30 / 90); kept as the only param so the prefix
* `["dashboard-medication-compliance"]` invalidates every range at
* once.
*/
dashboardMedicationCompliance: (days: number) =>
["dashboard-medication-compliance", days] as const,
medicationPhaseConfig: (medicationId: string) =>
["phase-config", medicationId] as const,
medicationIntakeSummary: () => ["medications", "intake-summary"] as const,
gamificationAchievements: () => ["gamification", "achievements"] as const,
@@ -107,11 +135,51 @@ export const queryKeys = {
* immediately.
*/
sourcePriority: () => ["auth", "source-priority"] as const,
/**
* v1.4.40 W-RSC — per-chart daily-aggregate fetch from the dashboard
* + insights chart row. Pre-fix the key was bare `["chart-data", …]`
* across the codebase, which excluded it from
* `measurementDependentKeys` and left chart caches stale for up to
* 60 s after a measurement save (audit-C2). Routing through the
* factory pulls every variant under a single
* `["chart-data"]` invalidation prefix so a mutation refreshes the
* tile strip + the chart row in lockstep.
*
* The shape carries the heavy parameter list because the chart query
* is bounded by metric set, value mode, BMI divisor, timezone, and
* fetch window; the factory packs those into a single tuple to keep
* the cache layout byte-identical with the pre-v1.4.40 layout.
*/
chartData: (
types: string,
valueMode: string,
bmiDivisor: string | number,
timezone: string,
fromIso: string,
toIso: string,
) =>
[
"chart-data",
types,
valueMode,
bmiDivisor,
timezone,
fromIso,
toIso,
] as const,
};
/**
* Keys that should be invalidated when a measurement is created, updated or
* deleted. Kept here so dashboards, insights, and targets always stay in sync.
*
* v1.4.40 W-RSC — `["chart-data"]` prefix now lives in the bundle so a
* fresh measurement evicts every per-chart daily-aggregate cache. The
* prefix matches every key returned from `queryKeys.chartData(…)` via
* TanStack's hierarchical-prefix semantics — adding a measurement now
* refreshes the tile strip *and* the chart row in lockstep instead of
* leaving the chart row 60 s stale (audit C2).
*/
export const measurementDependentKeys = [
queryKeys.measurements(),
@@ -119,6 +187,7 @@ export const measurementDependentKeys = [
queryKeys.insightsRoot(),
queryKeys.insightsTargets(),
queryKeys.gamificationAchievements(),
["chart-data"] as const,
];
/**
@@ -135,14 +204,20 @@ export const moodDependentKeys = [
/**
* Keys invalidated when medications change (CRUD or intake).
*
* v1.4.40 W-RSC — the dashboard's aggregate compliance chart now
* rides the factory under `dashboardMedicationCompliance`. The prefix
* `["dashboard-medication-compliance"]` lands in the bundle so an
* intake POST refreshes the chart immediately rather than waiting for
* `staleTime` (audit L4).
*/
export const medicationDependentKeys = [
queryKeys.medications(),
queryKeys.analytics(),
queryKeys.insightsRoot(),
queryKeys.insightsTargets(),
queryKeys.medicationIntakeSummary(),
queryKeys.gamificationAchievements(),
["dashboard-medication-compliance"] as const,
];
/**
@@ -24,9 +24,8 @@ import { prisma } from "@/lib/db";
import {
isCumulativeType,
readCumulativeDaySums,
readCumulativeDaySumsBatch,
resolveBucketSum,
} from "../rollup-read-cumulative";
} from "../measurement-read-cumulative";
beforeEach(() => {
mocks.findMany.mockReset();
@@ -112,64 +111,6 @@ describe("readCumulativeDaySums", () => {
});
});
describe("readCumulativeDaySumsBatch", () => {
it("groups by type and seeds empty arrays for missing types", async () => {
const since = new Date("2026-04-15T00:00:00.000Z");
mocks.findMany.mockResolvedValueOnce([
{
type: "ACTIVITY_STEPS",
bucketStart: new Date("2026-04-15T00:00:00.000Z"),
sumValue: 8120,
count: 4,
mean: 2030,
},
{
type: "ACTIVITY_STEPS",
bucketStart: new Date("2026-04-16T00:00:00.000Z"),
sumValue: 12480,
count: 5,
mean: 2496,
},
{
type: "FLIGHTS_CLIMBED",
bucketStart: new Date("2026-04-15T00:00:00.000Z"),
sumValue: 22,
count: 3,
mean: 7.33,
},
]);
const out = await readCumulativeDaySumsBatch(
"user-1",
["ACTIVITY_STEPS", "FLIGHTS_CLIMBED", "TIME_IN_DAYLIGHT"],
since,
);
expect(out.get("ACTIVITY_STEPS")).toHaveLength(2);
expect(out.get("FLIGHTS_CLIMBED")).toHaveLength(1);
// Type with zero rollup rows still surfaces as an empty array so
// the caller can avoid a `.get(...) ?? []` fallback per branch.
expect(out.get("TIME_IN_DAYLIGHT")).toEqual([]);
// Single findMany over the IN-list — replaces the per-type loop
// that drove the A2 cold path on Marc's tenant.
expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(1);
const args = mocks.findMany.mock.calls[0][0];
expect(args.where.type).toEqual({
in: ["ACTIVITY_STEPS", "FLIGHTS_CLIMBED", "TIME_IN_DAYLIGHT"],
});
});
it("returns an empty map without querying when types is empty", async () => {
const out = await readCumulativeDaySumsBatch(
"user-1",
[],
new Date("2026-04-15T00:00:00.000Z"),
);
expect(out.size).toBe(0);
expect(prisma.measurementRollup.findMany).not.toHaveBeenCalled();
});
});
describe("resolveBucketSum", () => {
it("returns sumValue when populated", () => {
expect(
@@ -41,7 +41,7 @@ import {
readWeekRollups,
readYearRollups,
type RollupBucketRow,
} from "../rollup-read-wmy";
} from "../measurement-read-wmy";
const { findMany } = mocks;
@@ -290,3 +290,73 @@ describe("aggregateWmyBuckets", () => {
expect(aggregateWmyBuckets(rows).sum).toBe(100);
});
});
/**
* v1.4.40 W-WMY-WIRE — granularity-routing parity across the v1.4.40
* consumers (summaries-slice + health-score-fast-path). Both consumers
* now call `readBestGranularityRollups` for their long-window probes;
* this pins:
* - 90 d → DAY (every coarser floor too high — DAY is canonical
* for the trailing quarter regardless of consumer),
* - 365 d → MONTH (clears MONTH floor 181 d, short of YEAR floor
* 731 d) — the granularity health-score-fast-path's weight long-
* window read and summaries-slice's year-ago baseline both
* consume,
* - 1095 d → YEAR (clears the 731-day YEAR floor) — the v1.5
* multi-year trend card target.
*
* The parity assertion ensures the routing contract is a single
* source of truth: any future floor adjustment forces both consumers
* to re-pin in lock-step rather than silently diverging.
*/
describe("readBestGranularityRollups — cross-consumer routing parity", () => {
it("pins the 90 / 365 / 1095 day routing targets the consumers depend on", async () => {
// Three sequential probes with non-empty results so the router
// stops at the first matching tier per window.
findMany
.mockResolvedValueOnce([bucket("2026-02-15T00:00:00.000Z")]) // 90d → DAY
.mockResolvedValueOnce([bucket("2025-08-01T00:00:00.000Z")]) // 365d → MONTH
.mockResolvedValueOnce([bucket("2024-01-01T00:00:00.000Z")]); // 1095d → YEAR
const ninety = await readBestGranularityRollups("user", "WEIGHT", 90);
const yearLong = await readBestGranularityRollups("user", "WEIGHT", 365);
const threeYear = await readBestGranularityRollups("user", "WEIGHT", 1095);
expect(ninety?.granularity).toBe("DAY");
expect(yearLong?.granularity).toBe("MONTH");
expect(threeYear?.granularity).toBe("YEAR");
// Each consumer-relevant window only fires one round-trip on the
// happy path; the per-tier walk is reserved for coverage-miss
// scenarios already pinned above.
expect(findMany).toHaveBeenCalledTimes(3);
expect(findMany.mock.calls[0][0].where.granularity).toBe("DAY");
expect(findMany.mock.calls[1][0].where.granularity).toBe("MONTH");
expect(findMany.mock.calls[2][0].where.granularity).toBe("YEAR");
});
it("aggregates MONTH buckets to a byte-identical mean compared to the underlying DAY buckets", async () => {
// The compositional contract `count / mean` are linearly
// composable across granularities — pin it by simulating "MONTH
// routing for a 365-day window" vs "DAY routing for the same
// window" against the same underlying counts/means and asserting
// the count-weighted mean agrees.
//
// MONTH bucket (consolidated): count=30, mean=80
// DAY buckets (per-day refresh): count=10/mean=82 + count=20/mean=79
// ⇒ DAY-derived mean = (10*82 + 20*79) / 30 = 80.0
// Both must agree numerically — the routing helper's choice is a
// performance optimisation, not a math change.
const monthBuckets: RollupBucketRow[] = [
bucket("2025-08-01T00:00:00.000Z", { count: 30, mean: 80 }),
];
const dayBuckets: RollupBucketRow[] = [
bucket("2025-08-05T00:00:00.000Z", { count: 10, mean: 82 }),
bucket("2025-08-20T00:00:00.000Z", { count: 20, mean: 79 }),
];
const monthAgg = aggregateWmyBuckets(monthBuckets);
const dayAgg = aggregateWmyBuckets(dayBuckets);
expect(monthAgg.count).toBe(dayAgg.count);
expect(monthAgg.mean).toBeCloseTo(dayAgg.mean ?? Number.NaN, 5);
});
});
@@ -8,7 +8,7 @@
*/
import { describe, expect, it } from "vitest";
import { aggregateBuckets, type DailyMeanRow } from "../rollup-read";
import { aggregateBuckets, type DailyMeanRow } from "../measurement-read";
const dayA: DailyMeanRow = {
day: new Date("2026-05-08T00:00:00.000Z"),
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff. Show more