mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge branch 'release/v1.46.0' into feat/v146-ui
This commit is contained in:
commit
551fbbbdec
445 files changed
+164623
-24087
No files matched your search
@@ -312,7 +312,7 @@ describe("workout route geometry never reaches an AI path", () => {
|
||||
/\breadRouteGeometry\s*\(|\bencryptRouteGeometry\s*\(|\bgeometryEncrypted\b|\bgeometry\s*:\s*true\b/;
|
||||
|
||||
const ROUTE_READERS: Record<string, string> = {
|
||||
"app/api/workouts/[id]/route.ts":
|
||||
"lib/workouts/detail.ts":
|
||||
"The workout detail read: serves the track to the person's own map.",
|
||||
"lib/workouts/batch-ingest/write.ts":
|
||||
"The workout batch ingest's write: stores a kept track (opt-in), sealed.",
|
||||
|
||||
@@ -41,7 +41,7 @@ const ADMITTED: Record<string, { jobs: string[]; why: string }> = {
|
||||
jobs: ["daily_briefing"],
|
||||
why: "The daily briefing's generation and its phrasing re-roll; the JSON and grounding repairs do not reason.",
|
||||
},
|
||||
"app/api/insights/generate/route.ts": {
|
||||
"lib/insights/briefing-request/completion.ts": {
|
||||
jobs: ["daily_briefing"],
|
||||
why: "A briefing asked for by hand reasons like the scheduled one; its JSON and grounding repairs do not.",
|
||||
},
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
{
|
||||
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 96,
|
||||
"total": 106730,
|
||||
"count": 91,
|
||||
"total": 101359,
|
||||
"entries": {
|
||||
"app/api/dashboard/summary/route.ts": 1032,
|
||||
"app/api/documents/inbound/route.ts": 811,
|
||||
"app/api/insights/generate/route.ts": 1214,
|
||||
"app/api/medications/[id]/route.ts": 1270,
|
||||
"app/api/medications/intake/bulk/route.ts": 1044,
|
||||
"app/medications/page-client.tsx": 839,
|
||||
"components/charts/mood-chart.tsx": 1166,
|
||||
"components/cycle/log-day-sheet.tsx": 1639,
|
||||
|
||||
@@ -1,35 +1,27 @@
|
||||
{
|
||||
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 373,
|
||||
"total": 124458,
|
||||
"count": 355,
|
||||
"total": 114858,
|
||||
"entries": {
|
||||
"app/achievements/page.tsx :: AchievementsPage": 206,
|
||||
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314,
|
||||
"app/api/admin/notifications/reminder-check/route.ts :: apiHandler(arg)": 208,
|
||||
"app/api/admin/settings/route.ts :: apiHandler(arg) #2": 211,
|
||||
"app/api/analytics/route.ts :: buildAnalyticsResponse": 446,
|
||||
"app/api/auth/login/route.ts :: apiHandler(arg)": 262,
|
||||
"app/api/auth/me/route.ts :: apiHandler(arg)": 386,
|
||||
"app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 499,
|
||||
"app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 294,
|
||||
"app/api/auth/register/route.ts :: apiHandler(arg)": 268,
|
||||
"app/api/coach/about-me/route.ts :: apiHandler(arg) #2": 309,
|
||||
"app/api/cycle/calendar/route.ts :: apiHandler(arg)": 230,
|
||||
"app/api/cycle/day-logs/bulk/route.ts :: postBulk": 213,
|
||||
"app/api/cycle/insights/route.ts :: apiHandler(arg)": 226,
|
||||
"app/api/dashboard/summary/route.ts :: buildDashboardSummary": 606,
|
||||
"app/api/devices/route.ts :: apiHandler(arg)": 231,
|
||||
"app/api/documents/inbound/bulk/route.ts :: apiHandler(arg)": 207,
|
||||
"app/api/documents/inbound/route.ts :: processUpload": 309,
|
||||
"app/api/encounters/[id]/route.ts :: apiHandler(arg) #2": 269,
|
||||
"app/api/environment/route.ts :: apiHandler(arg)": 201,
|
||||
"app/api/export/health-record/route.ts :: apiHandler(arg)": 241,
|
||||
"app/api/import/csv/route.ts :: apiHandler(arg)": 407,
|
||||
"app/api/import/route.ts :: apiHandler(arg)": 364,
|
||||
"app/api/ingest/medication/route.ts :: apiHandler(arg)": 267,
|
||||
"app/api/insights/cards/route.ts :: apiHandler(arg)": 211,
|
||||
"app/api/insights/comprehensive/route.ts :: buildComprehensiveResponse": 505,
|
||||
"app/api/insights/generate/route.ts :: apiHandler(arg) #2": 866,
|
||||
"app/api/insights/generate/route.ts :: apiHandler(arg) > withFeatureCacheScope(arg)": 862,
|
||||
"app/api/integrations/status/route.ts :: apiHandler(arg)": 451,
|
||||
"app/api/labs/[id]/route.ts :: apiHandler(arg) #2": 239,
|
||||
"app/api/labs/ocr/commit/route.ts :: commitOcrRows": 285,
|
||||
@@ -37,28 +29,18 @@
|
||||
"app/api/mcp/oauth/token/route.ts :: POST": 250,
|
||||
"app/api/mcp/oauth/token/route.ts :: POST > withBackgroundEvent(arg)": 248,
|
||||
"app/api/measurement-reminders/[id]/route.ts :: apiHandler(arg) #2": 239,
|
||||
"app/api/measurements/series/route.ts :: apiHandler(arg)": 549,
|
||||
"app/api/medications/[id]/dose-history/route.ts :: apiHandler(arg)": 256,
|
||||
"app/api/medications/[id]/intake/route.ts :: postIntake": 607,
|
||||
"app/api/medications/[id]/inventory/[itemId]/route.ts :: apiHandler(arg)": 204,
|
||||
"app/api/medications/[id]/route.ts :: apiHandler(arg)": 214,
|
||||
"app/api/medications/[id]/route.ts :: apiHandler(arg) #2": 886,
|
||||
"app/api/medications/[id]/route.ts :: apiHandler(arg) > prisma.$transaction(arg)": 293,
|
||||
"app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts :: apiHandler(arg)": 250,
|
||||
"app/api/medications/extract/route.ts :: handleExtract": 201,
|
||||
"app/api/medications/intake/bulk/route.ts :: postBulk": 846,
|
||||
"app/api/medications/intake/route.ts :: apiHandler(arg) #2": 432,
|
||||
"app/api/mental-health/assessments/route.ts :: postAssessment": 249,
|
||||
"app/api/mood-entries/[id]/route.ts :: apiHandler(arg) #2": 296,
|
||||
"app/api/mood-entries/bulk/route.ts :: postBulk": 479,
|
||||
"app/api/mood-entries/route.ts :: postMoodEntry": 324,
|
||||
"app/api/nutrients/batch/route.ts :: postBatch": 304,
|
||||
"app/api/onboarding/complete/route.ts :: completeNeedsFlow": 218,
|
||||
"app/api/record-settings/[family]/route.ts :: apiHandler(arg) #2": 262,
|
||||
"app/api/sync/changes/route.ts :: apiHandler(arg)": 427,
|
||||
"app/api/user/ai-provider/route.ts :: apiHandler(arg) #2": 248,
|
||||
"app/api/withings/callback/route.ts :: apiHandler(arg)": 247,
|
||||
"app/api/workouts/[id]/route.ts :: apiHandler(arg)": 297,
|
||||
"app/auth/login/page.tsx :: LoginPage": 576,
|
||||
"app/auth/register/page.tsx :: RegisterPage": 285,
|
||||
"app/coach/conversations/page.tsx :: CoachConversationsBody": 249,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
|
||||
"count": 2904,
|
||||
"total": 9630,
|
||||
"count": 2898,
|
||||
"total": 9297,
|
||||
"entries": {
|
||||
"__tests__/account-payload-consumer-guard.test.ts": 2,
|
||||
"__tests__/acting-account-boundary-guard.test.ts": 7,
|
||||
@@ -128,7 +128,7 @@
|
||||
"app/api/allergies/route.ts": 1,
|
||||
"app/api/analytics/__tests__/route.test.ts": 27,
|
||||
"app/api/analytics/range/route.ts": 2,
|
||||
"app/api/analytics/route.ts": 39,
|
||||
"app/api/analytics/route.ts": 10,
|
||||
"app/api/anamnesis/facts/__tests__/route.test.ts": 1,
|
||||
"app/api/auth/claim/__tests__/route.test.ts": 1,
|
||||
"app/api/auth/claim/preview/route.ts": 1,
|
||||
@@ -163,7 +163,7 @@
|
||||
"app/api/auth/mfa/verify/route.ts": 1,
|
||||
"app/api/auth/mfa/webauthn/verify/route.ts": 1,
|
||||
"app/api/auth/oidc/callback/__tests__/route.test.ts": 1,
|
||||
"app/api/auth/oidc/callback/route.ts": 7,
|
||||
"app/api/auth/oidc/callback/route.ts": 3,
|
||||
"app/api/auth/oidc/login/route.ts": 2,
|
||||
"app/api/auth/passkey/login-options/route.ts": 1,
|
||||
"app/api/auth/passkey/login-verify/__tests__/native-token.test.ts": 2,
|
||||
@@ -220,7 +220,7 @@
|
||||
"app/api/dashboard/snapshot/__tests__/route.test.ts": 2,
|
||||
"app/api/dashboard/snapshot/route.ts": 1,
|
||||
"app/api/dashboard/summary/__tests__/route.test.ts": 16,
|
||||
"app/api/dashboard/summary/route.ts": 31,
|
||||
"app/api/dashboard/summary/route.ts": 5,
|
||||
"app/api/dashboard/widgets/__tests__/route.test.ts": 4,
|
||||
"app/api/dashboard/widgets/route.ts": 14,
|
||||
"app/api/devices/__tests__/route.test.ts": 1,
|
||||
@@ -243,7 +243,7 @@
|
||||
"app/api/documents/inbound/[id]/summary/route.ts": 2,
|
||||
"app/api/documents/inbound/[id]/thumbnail/route.ts": 1,
|
||||
"app/api/documents/inbound/reindex/route.ts": 1,
|
||||
"app/api/documents/inbound/route.ts": 3,
|
||||
"app/api/documents/inbound/route.ts": 2,
|
||||
"app/api/documents/inbound/usage/route.ts": 1,
|
||||
"app/api/environment/backfill/__tests__/route.test.ts": 1,
|
||||
"app/api/environment/route.ts": 1,
|
||||
@@ -284,7 +284,7 @@
|
||||
"app/api/illness/episodes/route.ts": 1,
|
||||
"app/api/import/__tests__/route.test.ts": 3,
|
||||
"app/api/import/csv/__tests__/route.test.ts": 2,
|
||||
"app/api/import/route.ts": 8,
|
||||
"app/api/import/route.ts": 4,
|
||||
"app/api/ingest/medication/__tests__/route.test.ts": 1,
|
||||
"app/api/ingest/medication/route.ts": 7,
|
||||
"app/api/insights/biomarker-assessment/route.ts": 1,
|
||||
@@ -321,7 +321,6 @@
|
||||
"app/api/insights/coach/nudge-status/route.ts": 1,
|
||||
"app/api/insights/coach/seen/route.ts": 1,
|
||||
"app/api/insights/comprehensive/__tests__/route.test.ts": 9,
|
||||
"app/api/insights/comprehensive/route.ts": 24,
|
||||
"app/api/insights/correlations/__tests__/route.test.ts": 10,
|
||||
"app/api/insights/correlations/route.ts": 9,
|
||||
"app/api/insights/derived/__tests__/route.test.ts": 4,
|
||||
@@ -334,7 +333,6 @@
|
||||
"app/api/insights/ecg/route.ts": 1,
|
||||
"app/api/insights/feedback/route.ts": 2,
|
||||
"app/api/insights/generate/__tests__/route.test.ts": 12,
|
||||
"app/api/insights/generate/route.ts": 57,
|
||||
"app/api/insights/glp1-plateau/route.ts": 2,
|
||||
"app/api/insights/glp1-timeline/route.ts": 4,
|
||||
"app/api/insights/health-status/route.ts": 3,
|
||||
@@ -413,7 +411,7 @@
|
||||
"app/api/measurements/series-batch/__tests__/route.test.ts": 1,
|
||||
"app/api/measurements/series-batch/route.ts": 1,
|
||||
"app/api/measurements/series/__tests__/route.test.ts": 5,
|
||||
"app/api/measurements/series/route.ts": 21,
|
||||
"app/api/measurements/series/route.ts": 6,
|
||||
"app/api/medications/__tests__/route.test.ts": 10,
|
||||
"app/api/medications/[id]/__tests__/route.test.ts": 8,
|
||||
"app/api/medications/[id]/api-endpoint/route.ts": 2,
|
||||
@@ -434,11 +432,9 @@
|
||||
"app/api/medications/[id]/intake/import/__tests__/route.test.ts": 2,
|
||||
"app/api/medications/[id]/intake/import/route.ts": 6,
|
||||
"app/api/medications/[id]/intake/purge/route.ts": 4,
|
||||
"app/api/medications/[id]/intake/route.ts": 30,
|
||||
"app/api/medications/[id]/inventory/[itemId]/route.ts": 8,
|
||||
"app/api/medications/[id]/inventory/route.ts": 4,
|
||||
"app/api/medications/[id]/phase-config/route.ts": 4,
|
||||
"app/api/medications/[id]/route.ts": 58,
|
||||
"app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts": 1,
|
||||
"app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts": 9,
|
||||
"app/api/medications/[id]/schedule-revisions/route.ts": 3,
|
||||
@@ -453,9 +449,8 @@
|
||||
"app/api/medications/intake/__tests__/route.test.ts": 21,
|
||||
"app/api/medications/intake/bulk/__tests__/dose-arrival-scope.test.ts": 1,
|
||||
"app/api/medications/intake/bulk/__tests__/route.test.ts": 10,
|
||||
"app/api/medications/intake/bulk/route.ts": 42,
|
||||
"app/api/medications/intake/bulk/route.ts": 9,
|
||||
"app/api/medications/intake/dose-history-import/route.ts": 2,
|
||||
"app/api/medications/intake/route.ts": 34,
|
||||
"app/api/medications/layout/__tests__/route.test.ts": 3,
|
||||
"app/api/medications/layout/route.ts": 3,
|
||||
"app/api/medications/route.ts": 2,
|
||||
@@ -467,7 +462,7 @@
|
||||
"app/api/mood-entries/bulk-delete/__tests__/route.test.ts": 1,
|
||||
"app/api/mood-entries/bulk-delete/route.ts": 4,
|
||||
"app/api/mood-entries/bulk/__tests__/route.test.ts": 5,
|
||||
"app/api/mood-entries/bulk/route.ts": 16,
|
||||
"app/api/mood-entries/bulk/route.ts": 6,
|
||||
"app/api/mood-entries/restore/__tests__/route.test.ts": 1,
|
||||
"app/api/mood-entries/restore/route.ts": 3,
|
||||
"app/api/mood-entries/route.ts": 22,
|
||||
@@ -533,7 +528,6 @@
|
||||
"app/api/strava/credentials/route.ts": 1,
|
||||
"app/api/strava/disconnect/route.ts": 1,
|
||||
"app/api/strava/status/route.ts": 1,
|
||||
"app/api/sync/changes/route.ts": 5,
|
||||
"app/api/sync/state/route.ts": 2,
|
||||
"app/api/telegram/webhook/__tests__/route.test.ts": 3,
|
||||
"app/api/user/ai-provider/__tests__/legacy-private-host.test.ts": 1,
|
||||
@@ -559,7 +553,7 @@
|
||||
"app/api/withings/webhook/route.ts": 2,
|
||||
"app/api/workouts/__tests__/batch-create.test.ts": 3,
|
||||
"app/api/workouts/__tests__/canonical-dedup.test.ts": 3,
|
||||
"app/api/workouts/[id]/route.ts": 5,
|
||||
"app/api/workouts/[id]/route.ts": 2,
|
||||
"app/api/workouts/batch/route.ts": 3,
|
||||
"app/api/workouts/route.ts": 2,
|
||||
"app/auth/claim/page.tsx": 1,
|
||||
|
||||
@@ -25,7 +25,8 @@ import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const READS = join(process.cwd(), "src/lib/dashboard/summary-reads.ts");
|
||||
const ROUTE = join(process.cwd(), "src/app/api/dashboard/summary/route.ts");
|
||||
/** The summary's builder, moved out of its route; it makes the reads. */
|
||||
const ROUTE = join(process.cwd(), "src/lib/dashboard/summary/build.ts");
|
||||
|
||||
/**
|
||||
* Source with every comment removed, so a guard matches the SQL the
|
||||
|
||||
@@ -69,6 +69,15 @@ const SCOPED_DIRS = [
|
||||
"lib/measurements/batch-ingest/",
|
||||
"lib/measurements/create/",
|
||||
"lib/measurements/list/measurement-list.ts",
|
||||
"lib/medications/read-medication.ts",
|
||||
"lib/medications/update-medication/",
|
||||
"lib/medications/delete-medication.ts",
|
||||
"lib/medications/record-intake/",
|
||||
"lib/medications/list-intake-events.ts",
|
||||
"lib/medications/intake-today.ts",
|
||||
"lib/medications/intake-compliance.ts",
|
||||
"lib/medications/update-intake-status/",
|
||||
"lib/medications/intake-bulk/",
|
||||
];
|
||||
|
||||
/** Types a device writes at sampling rate rather than a few times a day. */
|
||||
@@ -106,7 +115,7 @@ const ALLOWED: Record<string, string> = {
|
||||
"Same 30-day clinical glucose panel as the coach snapshot (per-reading metrics), grouped by meal context.",
|
||||
"lib/analytics/score/reader.ts::{ in: types":
|
||||
"Health-score inputs: steps (drained to one row per day nightly), sleep, waist, weight, blood pressure, fasting glucose only.",
|
||||
'app/api/analytics/route.ts::"BLOOD_GLUCOSE"':
|
||||
'lib/analytics/response/glucose.ts::"BLOOD_GLUCOSE"':
|
||||
"The 30-day per-context glucose summary, the same window and bound as the dashboard and coach glucose panels: at a sensor's fixed rate, below 45 000 rows.",
|
||||
"lib/measurements/batch-ingest/write-measurements.ts::p.row.type as MeasurementType":
|
||||
"Cross-source merge probe: one ±tolerance window around each posted reading's instant, so the rows found are bounded by the batch size.",
|
||||
|
||||
@@ -365,7 +365,7 @@ describe("the intake idempotency key is never read across users", () => {
|
||||
const found = intakeQueriesFilteringOnTheKey();
|
||||
expect(found.length).toBeGreaterThanOrEqual(6);
|
||||
expect(found.map((f) => f.file)).toContain(
|
||||
"app/api/medications/intake/bulk/route.ts",
|
||||
"lib/medications/intake-bulk/entry-write.ts",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -402,14 +402,21 @@ describe("the batch surfaces refuse per entry, never whole-batch", () => {
|
||||
"lib/measurements/batch-ingest/prepare.ts",
|
||||
"lib/measurements/batch-ingest/ingest.ts",
|
||||
],
|
||||
"app/api/mood-entries/bulk/route.ts": [],
|
||||
"app/api/medications/intake/bulk/route.ts": [],
|
||||
"app/api/mood-entries/bulk/route.ts": ["lib/mood/bulk-ingest.ts"],
|
||||
"app/api/medications/intake/bulk/route.ts": [
|
||||
"lib/medications/intake-bulk/entry.ts",
|
||||
"lib/medications/intake-bulk/ingest.ts",
|
||||
],
|
||||
"app/api/workouts/batch/route.ts": [
|
||||
"lib/workouts/batch-ingest/entries.ts",
|
||||
"lib/workouts/batch-ingest/ingest.ts",
|
||||
],
|
||||
"app/api/cycle/day-logs/bulk/route.ts": [],
|
||||
"app/api/import/route.ts": [],
|
||||
"app/api/import/route.ts": [
|
||||
"lib/import/json/measurements.ts",
|
||||
"lib/import/json/mood.ts",
|
||||
"lib/import/json/run.ts",
|
||||
],
|
||||
};
|
||||
const BATCH_ROUTES = Object.keys(BATCH_SURFACES);
|
||||
|
||||
|
||||
@@ -309,9 +309,44 @@ function actionsReachableFrom(
|
||||
out.push(...packageActions(reexported, read));
|
||||
}
|
||||
}
|
||||
for (const body of HANDLER_BODY_MODULES[relative(SRC, file)] ?? []) {
|
||||
out.push(...actionsReachableFrom(join(SRC, body), read));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegated routes whose handler bodies live in service modules under
|
||||
* `src/lib/`, keyed by the route relative to `src/`. A handler body is the
|
||||
* route's own code moved, so it counts at the route's level: its actions and
|
||||
* those of its direct imports (the medication lifecycle a dose write
|
||||
* reconciles through) are reached as they were before the move. Each module
|
||||
* has to be a direct import of its route (leg below), so an entry cannot
|
||||
* outlive the delegation it describes.
|
||||
*/
|
||||
const HANDLER_BODY_MODULES: Record<string, readonly string[]> = {
|
||||
"app/api/medications/route.ts": ["lib/medications/create-medication.ts"],
|
||||
"app/api/medications/[id]/route.ts": [
|
||||
"lib/medications/read-medication.ts",
|
||||
"lib/medications/update-medication/update-medication.ts",
|
||||
"lib/medications/delete-medication.ts",
|
||||
],
|
||||
"app/api/medications/[id]/intake/route.ts": [
|
||||
"lib/medications/record-intake/record-intake.ts",
|
||||
"lib/medications/list-intake-events.ts",
|
||||
],
|
||||
"app/api/medications/[id]/intake/[eventId]/route.ts": [
|
||||
"lib/medications/update-intake-event.ts",
|
||||
"lib/medications/delete-intake-event.ts",
|
||||
],
|
||||
"app/api/medications/intake/route.ts": [
|
||||
"lib/medications/intake-today.ts",
|
||||
"lib/medications/intake-compliance.ts",
|
||||
"lib/medications/update-intake-status/admission.ts",
|
||||
"lib/medications/update-intake-status/update-intake-status.ts",
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* The actions of a re-exported module together with the sibling modules it
|
||||
* imports from its own folder. The re-exported module is a package's public
|
||||
@@ -553,6 +588,21 @@ describe("every delegated action has a sentence of its own", () => {
|
||||
expect(manageOnly).not.toContain(anchor);
|
||||
});
|
||||
|
||||
it("reads each moved handler body where its route imports it from", () => {
|
||||
const entries = Object.entries(HANDLER_BODY_MODULES);
|
||||
expect(entries.length).toBeGreaterThan(0);
|
||||
for (const [route, bodies] of entries) {
|
||||
const file = join(SRC, route);
|
||||
expect(delegatedModules, route).toContain(file);
|
||||
for (const body of bodies) {
|
||||
expect(
|
||||
read(file).imports,
|
||||
`${route} does not import ${body}`,
|
||||
).toContain(join(SRC, body));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("reaches the actions a route files through what it imports", () => {
|
||||
// Both halves of the fix, pinned against the two that were invisible.
|
||||
expect(actions).toContain("medication.intake.import.kickoff.denied");
|
||||
|
||||
@@ -73,7 +73,10 @@ const WRITERS: Record<string, Disposition> = {
|
||||
kind: "upstream",
|
||||
validatedIn: "lib/validations/measurement.ts",
|
||||
},
|
||||
"app/api/import/route.ts": { kind: "validator" },
|
||||
"lib/import/json/measurements.ts": {
|
||||
kind: "upstream",
|
||||
validatedIn: "app/api/import/route.ts",
|
||||
},
|
||||
// The batch ingest's set-based insert writes rows that already passed
|
||||
// `validateMeasurementRange` per entry in its first pass; everything else
|
||||
// goes through the reconciler, which gates itself.
|
||||
@@ -88,7 +91,7 @@ const WRITERS: Record<string, Disposition> = {
|
||||
// insert after `validateMeasurementRange`; day totals through the gated
|
||||
// reconcile seam.
|
||||
"lib/import/health-connect/import.ts": { kind: "validator" },
|
||||
"app/api/import/csv/route.ts": {
|
||||
"lib/import/csv-write.ts": {
|
||||
kind: "upstream",
|
||||
validatedIn: "lib/import/csv-measurements.ts",
|
||||
},
|
||||
|
||||
@@ -52,8 +52,8 @@ const PROVIDER_TAIL = "runs afterProviderWrite";
|
||||
* fails too).
|
||||
*/
|
||||
const WRITE_SITES: Record<string, string> = {
|
||||
"app/api/import/csv/route.ts": TAIL,
|
||||
"app/api/import/route.ts": TAIL,
|
||||
"lib/import/csv-write.ts": TAIL,
|
||||
"lib/import/json/measurements.ts": TAIL_VIA("lib/import/json/run.ts"),
|
||||
"app/api/measurements/[id]/route.ts": TAIL,
|
||||
"app/api/measurements/bulk-delete/route.ts": TAIL,
|
||||
"app/api/measurements/by-external-ids/route.ts": TAIL,
|
||||
|
||||
@@ -30,7 +30,7 @@ import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
import { stripComments, walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
@@ -57,22 +57,24 @@ const INTAKE_READ =
|
||||
*/
|
||||
const INTAKE_MAY_SEE_EVERY_ROW: Record<string, string> = {
|
||||
"app/api/admin/status/route.ts": "instance row counts",
|
||||
"app/api/dashboard/summary/route.ts":
|
||||
"lib/dashboard/summary/build.ts":
|
||||
"engagement streak of days with any logged action, no rate",
|
||||
"app/api/export/medications/route.ts": "export of the recorded history",
|
||||
"app/api/export/route.ts": "export of the recorded history",
|
||||
"lib/export/medications-backup.ts": "backup of the recorded history",
|
||||
"app/api/sync/state/route.ts": "sync watermarks, no rate",
|
||||
"lib/sync/changes/read-rows.ts":
|
||||
"delta feed mirroring the recorded rows to a paired client, no rate",
|
||||
"lib/jobs/measurement-tombstone-cleanup.ts": "tombstone purge",
|
||||
"lib/ai/coach/tools/availability.ts":
|
||||
"whether any intake history exists, and its date range",
|
||||
"app/api/medications/[id]/intake/route.ts":
|
||||
"one medication's recorded history, listed or written",
|
||||
"lib/medications/list-intake-events.ts":
|
||||
"one medication's recorded history, listed",
|
||||
"app/api/medications/[id]/intake/bulk-delete/route.ts":
|
||||
"deletes rows the person chose",
|
||||
"app/api/medications/[id]/route.ts":
|
||||
"one medication's slots; tombstones on the tracking switch",
|
||||
"app/api/medications/intake/bulk/route.ts": "writes recorded doses",
|
||||
"lib/medications/update-medication/write.ts":
|
||||
"one medication's open slots, tombstoned on a schedule replace or the tracking switch",
|
||||
"lib/medications/intake-bulk/ingest.ts": "writes recorded doses",
|
||||
"lib/medications/scheduling/slot-upsert.ts":
|
||||
"binds a recorded dose to its slot",
|
||||
"lib/medications/scheduling/worker-helpers.ts":
|
||||
@@ -113,6 +115,14 @@ const MAY_READ_EVERY_ROW: Record<string, string> = {
|
||||
"units consumed by a recorded dose",
|
||||
"lib/medications/intake-slot-dedup.ts":
|
||||
"folds duplicate rows of doses already recorded",
|
||||
// The medication edit: the schedule count and the primary schedule's id
|
||||
// for the reminder-window bridge, and the replaced rows archived as an era
|
||||
// whose payload is empty for a stretch with tracking off. Nothing due comes
|
||||
// out of either; the edit answers through scheduleWireFields.
|
||||
"lib/medications/update-medication/preflight.ts":
|
||||
"schedule count and the primary schedule's id, derives nothing due",
|
||||
"lib/medications/update-medication/write.ts":
|
||||
"archives the replaced schedule; the era payload follows the tracking switch",
|
||||
// Readers that hand the rows to a builder whose input TYPE requires
|
||||
// `trackIntake` and which gates on it: the per-medication compliance
|
||||
// payload (`buildCompliancePayload`, INTAKE_NOT_TRACKED) and the doctor
|
||||
@@ -141,12 +151,6 @@ function sourceFiles(): string[] {
|
||||
}
|
||||
|
||||
/** Drop line and block comments so a mention in prose never counts. */
|
||||
function stripComments(source: string): string {
|
||||
return source
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/(^|[^:])\/\/.*$/gm, "$1");
|
||||
}
|
||||
|
||||
function code(rel: string): string {
|
||||
return stripComments(readFileSync(join(SRC, rel), "utf8"));
|
||||
}
|
||||
|
||||
@@ -37,10 +37,13 @@ describe("native OIDC error reasons are documented", () => {
|
||||
// The login route answers both legs through `loginError`; the callback's
|
||||
// native-aware helper is `failRedirect`. `errorRedirect` in the callback is
|
||||
// the browser-only branch taken before the state (and so the native flag)
|
||||
// could be read, and never reaches the app.
|
||||
// could be read, and never reaches the app. The account resolution the
|
||||
// callback calls names its refusals through `refuse`, and the callback
|
||||
// hands each one to `failRedirect`.
|
||||
const sent = new Set([
|
||||
...reasons(read("app/api/auth/oidc/login/route.ts"), ["loginError"]),
|
||||
...reasons(read("app/api/auth/oidc/callback/route.ts"), ["failRedirect"]),
|
||||
...reasons(read("lib/auth/oidc-callback/resolve-account.ts"), ["refuse"]),
|
||||
]);
|
||||
|
||||
// The login route's 302 description is one string literal on one line.
|
||||
|
||||
@@ -70,7 +70,9 @@ const SQL_MEAN =
|
||||
const TS_MEAN =
|
||||
/\.reduce\(\s*\(\s*\w+\s*,\s*\w+\s*\)\s*=>\s*\w+\s*\+\s*[\w.]+\s*,\s*0\s*\)\s*\/\s*[\w.]+\.length|\bavgInWindow\s*\(|\bsummarize\s*\(|\bbucketWeekly\s*\(|\bbuildDailyValueRows\s*\(|\bapplyPayloadBudget\s*\(/g;
|
||||
|
||||
const NAMES_PULSE = /\bPULSE\b/;
|
||||
// The stored type, or the series route's `kind === "pulse"` (its readers name
|
||||
// the wire kind, not the type).
|
||||
const NAMES_PULSE = /\bPULSE\b|===\s*"pulse"/;
|
||||
|
||||
/**
|
||||
* A day folded from its hours' means with every hour weighing one: SQL
|
||||
@@ -113,7 +115,7 @@ const SQL_READERS: Record<string, Entry> = {
|
||||
|
||||
/** Files that name pulse and hold an in-memory mean or a mean-making fold. */
|
||||
const TS_READERS: Record<string, Entry> = {
|
||||
"app/api/measurements/series/route.ts": HELPER,
|
||||
"lib/measurements/series/read-raw.ts": HELPER,
|
||||
"lib/doctor-report/collect.ts": HELPER,
|
||||
"lib/doctor-report-pdf/clinical-summary.ts": HELPER,
|
||||
"lib/insights/comprehensive-generate.ts": HELPER,
|
||||
@@ -146,7 +148,7 @@ const TS_READERS: Record<string, Entry> = {
|
||||
"lib/analytics/correlations-fast-path.ts": {
|
||||
exempt: "mood mean; pulse enters as the resting series, never a day mean",
|
||||
},
|
||||
"app/api/dashboard/summary/route.ts": {
|
||||
"lib/dashboard/summary/cards.ts": {
|
||||
exempt:
|
||||
"places a card's seven-day sparkline mean against its band for the trend verdict; each sparkline point is already one rollup day value, so the mean weighs every day once",
|
||||
},
|
||||
@@ -175,7 +177,7 @@ const TS_READERS: Record<string, Entry> = {
|
||||
"app/api/insights/cards/route.ts": {
|
||||
exempt: "pulse is summarised from day means (readDayMeanSeries)",
|
||||
},
|
||||
"app/api/insights/comprehensive/route.ts": {
|
||||
"lib/insights/comprehensive-overview/build.ts": {
|
||||
exempt: "summarises the mood series only",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -362,7 +362,7 @@ const V143_FIELDS: ResolvedFieldEntry[] = [
|
||||
sameResolver: {
|
||||
pattern: VERDICT_RESOLVER,
|
||||
files: [
|
||||
"src/app/api/dashboard/summary/route.ts",
|
||||
"src/lib/dashboard/summary/cards.ts",
|
||||
"src/lib/dashboard/snapshot.ts",
|
||||
],
|
||||
},
|
||||
@@ -410,7 +410,7 @@ const V143_FIELDS: ResolvedFieldEntry[] = [
|
||||
sameResolver: {
|
||||
pattern: VERDICT_RESOLVER,
|
||||
files: [
|
||||
"src/app/api/measurements/series/route.ts",
|
||||
"src/lib/measurements/series/read-series.ts",
|
||||
"src/lib/dashboard/snapshot.ts",
|
||||
],
|
||||
},
|
||||
|
||||
@@ -76,7 +76,7 @@ import type { ShareScope } from "@/lib/sharing/scope";
|
||||
|
||||
import { ADMITTED_MUTATING_HANDLERS } from "../../tests/fixtures/v137/sharing-matrix";
|
||||
import { API_HANDLER, guardModuleOf } from "./helpers/auth-guards";
|
||||
import { readSourceText } from "./helpers/source-files";
|
||||
import { readSourceText, walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
@@ -435,6 +435,17 @@ function callsSymbol(rel: string, symbol: string): boolean {
|
||||
*/
|
||||
const HANDLER_BODY_MODULES: Record<string, readonly string[]> = {
|
||||
"app/api/medications/route.ts": ["lib/medications/create-medication.ts"],
|
||||
"app/api/medications/[id]/route.ts": [
|
||||
"lib/medications/update-medication/update-medication.ts",
|
||||
"lib/medications/delete-medication.ts",
|
||||
],
|
||||
"app/api/medications/[id]/intake/route.ts": [
|
||||
"lib/medications/record-intake/record-intake.ts",
|
||||
],
|
||||
"app/api/medications/intake/route.ts": [
|
||||
"lib/medications/update-intake-status/admission.ts",
|
||||
"lib/medications/update-intake-status/update-intake-status.ts",
|
||||
],
|
||||
"app/api/medications/[id]/intake/[eventId]/route.ts": [
|
||||
"lib/medications/update-intake-event.ts",
|
||||
"lib/medications/delete-intake-event.ts",
|
||||
@@ -3305,15 +3316,30 @@ describe("(g) the MANAGE route set is frozen", () => {
|
||||
// and refreshes no token. Credential policy belongs inside every resolver
|
||||
// and presence helper so a delegate or managed system job cannot select a
|
||||
// personal BYOK key or custom base URL first.
|
||||
// The route hands its body to the overview modules, so the leg reads the
|
||||
// route and every one of them: the probe is called among them, and no
|
||||
// resolver is called by any.
|
||||
const comprehensiveModules = [
|
||||
"app/api/insights/comprehensive/route.ts",
|
||||
...walkSourceFiles(join(SRC, "lib/insights/comprehensive-overview"), {
|
||||
floor: 5,
|
||||
}).map((f) => `lib/insights/comprehensive-overview/${f}`),
|
||||
];
|
||||
expect(read(comprehensiveModules[0])).toContain(
|
||||
'"@/lib/insights/comprehensive-overview/overview"',
|
||||
);
|
||||
expect(
|
||||
callsSymbol(
|
||||
"app/api/insights/comprehensive/route.ts",
|
||||
"probeProviderPresence",
|
||||
),
|
||||
comprehensiveModules.some((m) => callsSymbol(m, "probeProviderPresence")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
callsSymbol("app/api/insights/comprehensive/route.ts", "resolveProvider"),
|
||||
).toBe(false);
|
||||
for (const m of comprehensiveModules) {
|
||||
for (const name of [
|
||||
"resolveProvider",
|
||||
"resolveProviderChain",
|
||||
"probeProviderChain",
|
||||
]) {
|
||||
expect(callsSymbol(m, name), `${m} calls ${name}`).toBe(false);
|
||||
}
|
||||
}
|
||||
for (const name of [
|
||||
"resolveProvider",
|
||||
"resolveProviderChain",
|
||||
|
||||
@@ -90,10 +90,7 @@ describe("one standard deviation", () => {
|
||||
it("the series API and the AI and MCP payload report the same spread", () => {
|
||||
expect(
|
||||
stripComments(
|
||||
readSource(
|
||||
"src/app/api/measurements/series/route.ts",
|
||||
"READING_SPREAD",
|
||||
),
|
||||
readSource("src/lib/measurements/series/kinds.ts", "READING_SPREAD"),
|
||||
),
|
||||
).toMatch(/standardDeviation\(\s*values,\s*READING_SPREAD\s*\)/);
|
||||
const features = stripComments(
|
||||
|
||||
@@ -53,6 +53,8 @@ const ALLOWED: Record<string, string> = {
|
||||
"Withholds medication texts from the recall trail; a deleted fact still withholds, which errs toward saying less.",
|
||||
"coachFact@lib/ai/coach/memory/remember.ts":
|
||||
"Answer-once check on the message that proposed the fact, inside its lock.",
|
||||
"moodEntry@lib/mood/bulk-ingest.ts":
|
||||
"Write path, not analysis: resolves the row that won a raced external-id upsert, which owns the key whether or not it is deleted.",
|
||||
};
|
||||
|
||||
function stripComments(text: string): string {
|
||||
|
||||
@@ -253,6 +253,8 @@ const WIDE_SWEEP_ROOTS = [
|
||||
"lib/jobs/",
|
||||
"lib/targets/",
|
||||
"lib/daily/",
|
||||
// The series route's readers, moved out of `app/` with their unit tokens.
|
||||
"lib/measurements/series/",
|
||||
];
|
||||
|
||||
const UNIT_LITERAL_PATTERNS: readonly RegExp[] = [
|
||||
@@ -359,7 +361,7 @@ const UNIT_LITERAL_ALLOWLIST: Record<
|
||||
lines: 1,
|
||||
reason: "the legacy export endpoint's canonical storage-unit contract",
|
||||
},
|
||||
"app/api/measurements/series/route.ts": {
|
||||
"lib/measurements/series/kinds.ts": {
|
||||
lines: 4,
|
||||
reason:
|
||||
"canonical wire units; glucose is overridden to the reader's unit per request",
|
||||
@@ -512,7 +514,7 @@ const READER_UNIT_PRODUCERS = [
|
||||
"lib/ai/prompts/weight.ts",
|
||||
"lib/insights/features-units.ts",
|
||||
"lib/insights/comprehensive-generate.ts",
|
||||
"app/api/insights/generate/route.ts",
|
||||
"lib/insights/briefing-request/features.ts",
|
||||
"lib/insights/weight-status.ts",
|
||||
"lib/insights/metric-status.ts",
|
||||
"lib/insights/narrative/period-narrative.ts",
|
||||
|
||||
@@ -48,7 +48,8 @@ const PICKER_CALLERS = ["lib/workouts/canonical-read.ts"];
|
||||
* acknowledgement. The value is the file that owns the read's `select`.
|
||||
*/
|
||||
const IDENTITY_OWNER: Record<string, string> = {
|
||||
"app/api/workouts/[id]/route.ts": "app/api/workouts/[id]/route.ts",
|
||||
// The workout detail read behind `GET /api/workouts/{id}`.
|
||||
"lib/workouts/detail.ts": "lib/workouts/detail.ts",
|
||||
"lib/jobs/workout-insight-generate.ts":
|
||||
"lib/jobs/workout-insight-generate.ts",
|
||||
"lib/workouts/list-read.ts": "lib/workouts/list-read.ts",
|
||||
|
||||
@@ -21,7 +21,11 @@ import { join } from "node:path";
|
||||
|
||||
const ROOT = join(__dirname, "../../..");
|
||||
const INSIGHTS_PATH = join(ROOT, "src/app/insights/page-client.tsx");
|
||||
const ANALYTICS_ROUTE_PATH = join(ROOT, "src/app/api/analytics/route.ts");
|
||||
/** The analytics default body, built outside its route. */
|
||||
const ANALYTICS_BODY_PATHS = [
|
||||
join(ROOT, "src/lib/analytics/response/default-body.ts"),
|
||||
join(ROOT, "src/lib/analytics/response/correlations.ts"),
|
||||
];
|
||||
const ADVISOR_HOOK_PATH = join(
|
||||
ROOT,
|
||||
"src/components/insights/use-insights-advisor.ts",
|
||||
@@ -84,7 +88,7 @@ describe("v1.4.20 B3 — /api/analytics surfaces correlations", () => {
|
||||
});
|
||||
|
||||
it("returns a `correlations` block on the success payload", () => {
|
||||
const src = load(ANALYTICS_ROUTE_PATH);
|
||||
const src = ANALYTICS_BODY_PATHS.map(load).join("\n");
|
||||
expect(src).toMatch(/correlations:\s*correlationsWithPatterns,/);
|
||||
expect(src).toMatch(/computeCorrelationHypotheses/);
|
||||
});
|
||||
|
||||
@@ -238,8 +238,9 @@ describe("v1.4.22 W5 reconcile (Code-H2) — BD-Zielbereich tile compareDelta us
|
||||
});
|
||||
|
||||
it("ships priorMonth + priorYear pcts in the analytics envelope", () => {
|
||||
// The default body is built outside the route.
|
||||
const ROUTE_SRC = readFileSync(
|
||||
join(ROOT, "src/app/api/analytics/route.ts"),
|
||||
join(ROOT, "src/lib/analytics/response/default-body.ts"),
|
||||
"utf8",
|
||||
);
|
||||
expect(ROUTE_SRC).toContain("bpInTargetPctPriorMonth");
|
||||
|
||||
@@ -1,46 +1,19 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { analyticsQuerySchema } from "@/lib/validations/analytics";
|
||||
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { summarize, wireSummary, type DataPoint } from "@/lib/analytics/trends";
|
||||
import { readSummariesSlice } from "@/lib/analytics/summaries-slice";
|
||||
import { loadRecoveryEmpty } from "@/lib/insights/recovery-empty-read";
|
||||
import { resolveEffectiveBpTargets } from "@/lib/analytics/effective-range";
|
||||
import { bpTargetsEqual, getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
import {
|
||||
buildSleepStageComposition,
|
||||
type SleepStageComposition,
|
||||
} from "@/lib/analytics/sleep-stage-composition";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { ensureUserRollupsFresh } from "@/lib/rollups/measurement-rollups";
|
||||
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
|
||||
import {
|
||||
computeBpInTargetFastPath,
|
||||
type BpInTargetEnvelope,
|
||||
} from "@/lib/analytics/bp-in-target-fast-path";
|
||||
import { computeAndRecordUserHealthScore } from "@/lib/analytics/score/record";
|
||||
import { isModuleEnabled, resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { gateSummariesByModules } from "@/lib/dashboard/widget-modules";
|
||||
import { resolveRestMode } from "@/lib/illness/rest-mode";
|
||||
import { deriveBpWindow90 } from "@/lib/analytics/window-confidence";
|
||||
import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { type Locale } from "@/lib/i18n/config";
|
||||
import {
|
||||
computeGlucoseClinicalMetrics,
|
||||
GLUCOSE_PANEL_WINDOW_DAYS,
|
||||
type GlucoseClinicalMetrics,
|
||||
} from "@/lib/analytics/glucose-metrics";
|
||||
import { groupByGlucoseContext, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
import {
|
||||
decisionForEvidence,
|
||||
PATTERN_FAMILIES,
|
||||
syncAcceptedPatterns,
|
||||
} from "@/lib/insights/correlation-patterns";
|
||||
buildAnalyticsResponse,
|
||||
type AnalyticsBody,
|
||||
} from "@/lib/analytics/response/default-body";
|
||||
import { enrichLastSeenDaysAgo } from "@/lib/analytics/response/last-seen";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -154,9 +127,7 @@ export const GET = apiHandler(async (request?: Request) => {
|
||||
// cost a little duplication (most users have one locale).
|
||||
const locale = await resolveServerLocale({ userLocale: user.locale ?? null });
|
||||
const cachedBody = await cachedSwr(
|
||||
caches.analytics as ServerCache<
|
||||
Awaited<ReturnType<typeof buildAnalyticsResponse>>
|
||||
>,
|
||||
caches.analytics as ServerCache<AnalyticsBody>,
|
||||
`${user.id}|default|${locale}`,
|
||||
() => buildAnalyticsResponse(user, locale),
|
||||
annotate,
|
||||
@@ -182,580 +153,3 @@ export const GET = apiHandler(async (request?: Request) => {
|
||||
response.headers.set("Cache-Control", NO_STORE_BUT_BFCACHE);
|
||||
return response;
|
||||
});
|
||||
|
||||
type AuthedUser = Awaited<ReturnType<typeof requireAuth>>["user"];
|
||||
|
||||
/**
|
||||
* v1.4.38 — derive `daysAgo` from the cached `lastSeenAt` ISO string
|
||||
* per request. The cached envelope deliberately omits `daysAgo` so
|
||||
* the dashboard tile staleness caption stays correct across day
|
||||
* boundaries even when the body has been sitting in the 60s LRU.
|
||||
*
|
||||
* Returns a shallow-copied envelope with the enriched
|
||||
* `lastSeenByType` map; everything else is passed through unchanged.
|
||||
*/
|
||||
function enrichLastSeenDaysAgo<
|
||||
T extends {
|
||||
lastSeenByType: Record<string, { lastSeenAt: string } | null>;
|
||||
},
|
||||
>(
|
||||
body: T,
|
||||
): Omit<T, "lastSeenByType"> & {
|
||||
lastSeenByType: Record<
|
||||
string,
|
||||
{ lastSeenAt: string; daysAgo: number } | null
|
||||
>;
|
||||
} {
|
||||
const nowMs = Date.now();
|
||||
const enriched: Record<
|
||||
string,
|
||||
{ lastSeenAt: string; daysAgo: number } | null
|
||||
> = {};
|
||||
for (const [type, slot] of Object.entries(body.lastSeenByType)) {
|
||||
if (slot === null) {
|
||||
enriched[type] = null;
|
||||
continue;
|
||||
}
|
||||
const lastMs = new Date(slot.lastSeenAt).getTime();
|
||||
const daysAgo = Math.floor((nowMs - lastMs) / (24 * 60 * 60 * 1000));
|
||||
enriched[type] = { lastSeenAt: slot.lastSeenAt, daysAgo };
|
||||
}
|
||||
return { ...body, lastSeenByType: enriched };
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.34 — the heavy default-slice body, lifted out of the route
|
||||
* handler so `cached()` can wrap it. Returns the raw JSON payload; the
|
||||
* route handler attaches `Cache-Control` headers afterward. The split
|
||||
* matches the v1.4.33 snapshot LRU's `buildCoachSnapshot` →
|
||||
* `buildCoachSnapshotImpl` shape.
|
||||
*/
|
||||
async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
|
||||
// v1.4.37.1 — fire-and-forget the rollup-fresh check.
|
||||
// Awaiting it on the read path can stall the Node event loop for
|
||||
// 30–60 s on a power-user account whose iOS step samples keep the
|
||||
// 90-day DAY window slightly stale, which starves /api/health,
|
||||
// /api/version, and concurrent iOS calls. The downstream coverage
|
||||
// probe falls back to live SQL when a type is uncovered, so
|
||||
// correctness is preserved; the worst the user sees is data from
|
||||
// up to ~60 s ago on the very first request after a fresh
|
||||
// measurement lands, and the next request returns the up-to-date
|
||||
// value once the background refresh completes. `ensureUserRollupsFresh`
|
||||
// wraps its own try/catch, so the void call cannot reject.
|
||||
void ensureUserRollupsFresh(user.id);
|
||||
|
||||
// v1.4.37 — single per-type coverage probe shared by the
|
||||
// bp_in_target / healthScore / correlations branches below. The
|
||||
// probe is one indexed query against `measurement_rollups`; the
|
||||
// three downstream branches each reuse the resulting `coverage`
|
||||
// map to decide between the rollup-fast-path and the live
|
||||
// fallback. Probing once instead of per-branch keeps the fan-out
|
||||
// cost flat across the three branches.
|
||||
const coverage = await probeRollupCoverage(user.id);
|
||||
|
||||
// v1.4.25 — 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;
|
||||
|
||||
// v1.4.49.1 — replace the 15-way per-type `fetchMeasurementSeriesChunked`
|
||||
// fan-out with the slim rollup-tier reader. The previous live walk
|
||||
// against `measurements` (425-day window, ≥ 50 Prisma round-trips on a
|
||||
// 15-type tenant) saturated the `p-limit(4)` slot pool for ~8 s on the
|
||||
// production 467 k-row account — and because Prisma's 20-slot pool was
|
||||
// simultaneously claimed by those slots, the concurrent
|
||||
// `?slice=summaries` request (only ~9 small queries) queued behind it
|
||||
// and observed the same ~8 s wall-clock.
|
||||
//
|
||||
// `computeSummariesSlice` reads `measurement_rollups` DAY buckets
|
||||
// (sub-second) plus one 90-day narrow `$queryRaw` for the windowed
|
||||
// avg / slope / r² columns, and falls back to a split-aggregate live
|
||||
// path for users with incomplete rollup coverage. Response shape is
|
||||
// identical to the previous fan-out output for every field the
|
||||
// dashboard actually consumes:
|
||||
// - `count / latest / min / max / mean` from DAY buckets
|
||||
// - `avg7 / avg30 / slope7 / slope30` from the narrow query
|
||||
// - `avg30LastMonth` from the same narrow query (added in this
|
||||
// release; previously only the live walk produced it)
|
||||
// - `avg30LastYear` populated for any type whose WMY tier carries
|
||||
// the year-ago window
|
||||
// - `lastSeenByType` from the per-type latest read
|
||||
//
|
||||
// `anomalyCount` and `slope90` left this wire in v1.37.19: the slim
|
||||
// path could never compute the former (it always reported 0) and no
|
||||
// client component read the latter — the insights pipeline computes
|
||||
// both from its own `summarize()` / comprehensive-aggregator reads.
|
||||
// The one field the slim path leaves at a default value is
|
||||
// `avg30LastYear` on types with no WMY coverage, which matches the
|
||||
// pre-fix behaviour for tenants whose year-ago window happened to
|
||||
// fall outside the 425-day floor.
|
||||
const slim = await readSummariesSlice(user.id);
|
||||
// Same module gate as the slim slice and the snapshot builder — the
|
||||
// default slice feeds the dashboard too (it is the thick half of the
|
||||
// legacy two-cell path), so it cannot be the one feed that leaks a
|
||||
// disabled module's types.
|
||||
const thickModules = await resolveModuleMap(user.id);
|
||||
const thickGated = gateSummariesByModules(
|
||||
slim.summaries,
|
||||
slim.lastSeenByType,
|
||||
thickModules,
|
||||
);
|
||||
const results = thickGated.summaries;
|
||||
const lastSeenByType = thickGated.lastSeenByType;
|
||||
|
||||
// `computeSummariesSlice` annotates `action: "analytics.get.slim"`
|
||||
// for telemetry on the slim route. Restore the default-slice action
|
||||
// name so the wide-event reflects the route the client actually
|
||||
// called (otherwise every default-slice request would appear in the
|
||||
// logs under the slim action).
|
||||
annotate({ action: { name: "analytics.get" } });
|
||||
|
||||
// v1.4.23 — sleep-stage breakdown for the trailing 30 days. Only
|
||||
// included when the user has stage-tagged rows in window; null
|
||||
// otherwise so the UI can render a plain total without the
|
||||
// breakdown card painting empty.
|
||||
const sleepStages = await computeSleepStageBreakdown(user.id, userTz);
|
||||
|
||||
// BMI calculation
|
||||
let bmi: number | null = null;
|
||||
if (user.heightCm && results.WEIGHT?.latest) {
|
||||
const heightM = user.heightCm / 100;
|
||||
bmi = Math.round((results.WEIGHT.latest / (heightM * heightM)) * 10) / 10;
|
||||
}
|
||||
|
||||
// BP in-target percentage (auto-calculated from date of birth)
|
||||
let bpInTargetPct: number | null = null;
|
||||
let bpInTargetPct7d: number | null = null;
|
||||
let bpInTargetPct30d: number | null = null;
|
||||
let bpInTargetPctAllTime: number | null = null;
|
||||
/**
|
||||
* v1.4.22 — period-aligned prior-window
|
||||
* pcts so the BD-Zielbereich tile's comparison-overlay caption
|
||||
* stops mismatching its math with its label. The tile's
|
||||
* `compareDelta` is `last30Days - priorMonth` (or `… - priorYear`)
|
||||
* matching the user's `comparisonBaseline` selection, never
|
||||
* `last30Days - allTime` (the v1.4.22 A2 shortcut).
|
||||
*/
|
||||
let bpInTargetPctPriorMonth: number | null = null;
|
||||
let bpInTargetPctPriorYear: number | null = null;
|
||||
// v1.17 — 90-day pair count + effective label span for the tile's
|
||||
// thin-data gate. Surfaced so the BD-Zielbereich tile renders "collecting
|
||||
// data" until the window holds enough readings, with a dynamic span label.
|
||||
let bpInTargetCount90: number | null = null;
|
||||
let bpInTargetSpanDays90: number | null = null;
|
||||
// v1.17 — hold the current + prior-week BP envelopes so the shared
|
||||
// Health-Score input builder grades the pillar off the identical shape the
|
||||
// dashboard snapshot uses (one builder, no per-surface assembly drift).
|
||||
// The graded score + prior-week BP figures the score consumes are read off
|
||||
// these envelopes inside `buildHealthScoreBpInputs`.
|
||||
let bpEnvelope: BpInTargetEnvelope | null = null;
|
||||
let bpEnvelopePriorWeek: BpInTargetEnvelope | null = null;
|
||||
let bpEnvelopePriorTwoWeeks: BpInTargetEnvelope | null = null;
|
||||
const bpTargets = resolveEffectiveBpTargets(
|
||||
{
|
||||
dateOfBirth: user.dateOfBirth,
|
||||
gender: user.gender,
|
||||
heightCm: user.heightCm ?? null,
|
||||
},
|
||||
user.thresholdsJson,
|
||||
);
|
||||
const clinicalBpTargets = getBpTargets(user.dateOfBirth);
|
||||
if (bpTargets) {
|
||||
const now = new Date();
|
||||
// v1.4.37 — probe-gated dispatcher replaces the inline chunked
|
||||
// read. When BOTH BP types have DAY-bucket coverage the helper
|
||||
// pairs the per-day MEAN SYS + MEAN DIA from `measurement_rollups`
|
||||
// and counts in-target days against the five reporting windows,
|
||||
// skipping the heavy 365-day chunked walk against `measurements`.
|
||||
// Falls back to the legacy `computeBpInTargetWindows` over a
|
||||
// chunked read when coverage is partial so a brand-new account
|
||||
// (no buckets yet) still sees per-event numbers. The helper emits
|
||||
// a `path: "rollup" | "live"` annotate so prod logs prove which
|
||||
// branch fired. See `bp-in-target-fast-path.ts` for the
|
||||
// documented per-day-mean approximation.
|
||||
//
|
||||
// v1.4.38 — two parallel runs: one anchored at `now` for the
|
||||
// standard windows, one anchored at `now - 7d` so the Health-Score
|
||||
// helper has a real prior-week BP pct to feed into its previous-
|
||||
// window snapshot. The two probes share the coverage map and the
|
||||
// same rollup / live branch decision; the extra call is one extra
|
||||
// pair of `readRollupBuckets` reads on the rollup path and is a
|
||||
// no-op when the user has no BP rows at all.
|
||||
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
const fourteenDaysAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000);
|
||||
const [windows, windowsPriorWeek, windowsPriorTwoWeeks] = await Promise.all(
|
||||
[
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: bpTargets,
|
||||
now,
|
||||
coverage,
|
||||
// v1.4.38 — cross-tz runtime guard. The helper falls back
|
||||
// to the live SQL path when the user is more than 3 hours
|
||||
// from UTC, where the rollup table's UTC-midnight day-key
|
||||
// would slip a calendar day relative to the live aggregator's
|
||||
// window cuts.
|
||||
userTz,
|
||||
}),
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: bpTargets,
|
||||
now: sevenDaysAgo,
|
||||
coverage,
|
||||
userTz,
|
||||
}),
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: bpTargets,
|
||||
now: fourteenDaysAgo,
|
||||
coverage,
|
||||
userTz,
|
||||
}),
|
||||
],
|
||||
);
|
||||
// The pillar is graded against the CLINICAL band even when the user
|
||||
// keeps a personal target (that band is the number's identity; the
|
||||
// personal one is displayed beside it and scores nothing). So when the
|
||||
// two differ we re-run the whole helper against the clinical targets
|
||||
// and the score comes from that second run.
|
||||
//
|
||||
// v1.34.5 — the "why this number" basis rides the same envelope, so it
|
||||
// is the same run, the same representative pair and the same ceilings
|
||||
// by construction. Assign envelopes WHOLE here; picking `graded.score`
|
||||
// off one run and a basis off the other would let the popover explain
|
||||
// a number nobody is looking at, and it would look correct in review.
|
||||
if (bpTargetsEqual(bpTargets, clinicalBpTargets)) {
|
||||
bpEnvelope = windows;
|
||||
bpEnvelopePriorWeek = windowsPriorWeek;
|
||||
bpEnvelopePriorTwoWeeks = windowsPriorTwoWeeks;
|
||||
} else if (clinicalBpTargets) {
|
||||
[bpEnvelope, bpEnvelopePriorWeek, bpEnvelopePriorTwoWeeks] =
|
||||
await Promise.all([
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: clinicalBpTargets,
|
||||
now,
|
||||
coverage,
|
||||
userTz,
|
||||
}),
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: clinicalBpTargets,
|
||||
now: sevenDaysAgo,
|
||||
coverage,
|
||||
userTz,
|
||||
}),
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: clinicalBpTargets,
|
||||
now: fourteenDaysAgo,
|
||||
coverage,
|
||||
userTz,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
// v1.17 — the BD-Zielbereich headline standardises on the
|
||||
// trailing-90-day window (labelled "· 90 T" in the tile). All-time
|
||||
// remains carried below for the BP detail page's long view only.
|
||||
bpInTargetPct = windows.last90Days?.pct ?? null;
|
||||
bpInTargetPct7d = windows.last7Days?.pct ?? null;
|
||||
bpInTargetPct30d = windows.last30Days?.pct ?? null;
|
||||
bpInTargetPctAllTime = windows.allTime?.pct ?? null;
|
||||
bpInTargetPctPriorMonth = windows.priorMonth?.pct ?? null;
|
||||
bpInTargetPctPriorYear = windows.priorYear?.pct ?? null;
|
||||
// v1.17 — count + effective span for the BD-Zielbereich tile's
|
||||
// confidence gate (thin-data → "collecting data"; dynamic span label).
|
||||
// Shared helper keeps this identical to the dashboard snapshot.
|
||||
const bpWindow = deriveBpWindow90(
|
||||
windows.last90Days,
|
||||
windows.last90EarliestAt,
|
||||
now,
|
||||
);
|
||||
bpInTargetCount90 = bpWindow.count;
|
||||
bpInTargetSpanDays90 = bpWindow.spanDays;
|
||||
}
|
||||
|
||||
// Per-context glucose summaries (canonical mg/dL).
|
||||
//
|
||||
// v1.4.29 — bound to the trailing 30 days. The dashboard tile
|
||||
// path only reads the trailing window; pre-bound this query
|
||||
// walked every persisted BG row a multi-year user has written.
|
||||
const glucoseSince = new Date(
|
||||
Date.now() - GLUCOSE_PANEL_WINDOW_DAYS * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const glucoseRows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: "BLOOD_GLUCOSE",
|
||||
measuredAt: { gte: glucoseSince },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { value: true, measuredAt: true, glucoseContext: true },
|
||||
});
|
||||
// #943 — grouped through the shared bucket resolver, so readings with no
|
||||
// meal-time tag land in `UNSPECIFIED` instead of being dropped on the floor.
|
||||
const glucoseByContext: Record<string, ReturnType<typeof summarize>> = {};
|
||||
for (const [bucket, ctxRows] of groupByGlucoseContext(
|
||||
glucoseRows,
|
||||
(r) => r.glucoseContext,
|
||||
)) {
|
||||
glucoseByContext[bucket] = wireSummary(
|
||||
summarize(
|
||||
ctxRows.map((r): DataPoint => ({ date: r.measuredAt, value: r.value })),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// v1.17.0 — server-authoritative glucose clinical panel. The TIR / GMI /
|
||||
// eA1C / CV% headline and the advanced J-index + LBGI/HBGI tier are computed
|
||||
// ONCE here over the declared 30-day window (matching `glucoseSince`) so the
|
||||
// insights panel, the coach snapshot, and the doctor report all render the
|
||||
// same numbers instead of each re-deriving. The learning gate keeps a thin
|
||||
// spot-data window from being asserted as a clinical AGP. Always present in
|
||||
// the payload (even with zero readings) so the client renders the calm
|
||||
// "still learning" state from a populated object rather than a missing key.
|
||||
const glucoseClinical: GlucoseClinicalMetrics = computeGlucoseClinicalMetrics(
|
||||
glucoseRows.map((r) => ({ measuredAt: r.measuredAt, mgdl: r.value })),
|
||||
{ windowDays: GLUCOSE_PANEL_WINDOW_DAYS, now: new Date() },
|
||||
);
|
||||
|
||||
// v1.4.20 — three pre-defined correlation hypotheses.
|
||||
// v1.4.37 — probe-gated helper. The 28-day scan window (down
|
||||
// from 30 to keep the cold critical path tight while still
|
||||
// satisfying the n >= 20 surface gate) reads SYS / PULSE / WEIGHT
|
||||
// per-day means from `measurement_rollups` when the user has full
|
||||
// coverage; mood and medication-intake reads always stay live (no
|
||||
// rollup equivalent). The helper emits `meta.correlations.path` +
|
||||
// `meta.correlations.window_days` so prod logs prove the branch
|
||||
// selection and the truthful window.
|
||||
const correlations = await computeCorrelationHypothesesFastPath({
|
||||
userId: user.id,
|
||||
userTz,
|
||||
now: new Date(),
|
||||
coverage,
|
||||
locale,
|
||||
});
|
||||
const fixedDefinitions = [
|
||||
{
|
||||
key: "bpCompliance" as const,
|
||||
factorKey: "HYPOTHESIS:MEDICATION_COMPLIANCE",
|
||||
outcomeKey: "BLOOD_PRESSURE_SYS",
|
||||
},
|
||||
{
|
||||
key: "moodPulse" as const,
|
||||
factorKey: "HYPOTHESIS:MOOD",
|
||||
outcomeKey: "RESTING_PULSE",
|
||||
},
|
||||
{
|
||||
key: "weightWeekday" as const,
|
||||
factorKey: "HYPOTHESIS:WEEKDAY",
|
||||
outcomeKey: "WEIGHT",
|
||||
},
|
||||
];
|
||||
const acceptedFixed = fixedDefinitions.flatMap((definition) => {
|
||||
const result = correlations[definition.key];
|
||||
return result.status === "ok"
|
||||
? [
|
||||
{
|
||||
factorKey: definition.factorKey,
|
||||
outcomeKey: definition.outcomeKey,
|
||||
lagDays: 0,
|
||||
sampleSize: result.n,
|
||||
effectSize: result.statistic,
|
||||
pValue: result.pValue,
|
||||
qValue: null,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
const fixedDecisions = await syncAcceptedPatterns({
|
||||
userId: user.id,
|
||||
family: PATTERN_FAMILIES.fixed,
|
||||
accepted: acceptedFixed,
|
||||
});
|
||||
const correlationsWithPatterns = { ...correlations };
|
||||
for (const definition of fixedDefinitions) {
|
||||
const result = correlations[definition.key];
|
||||
if (result.status !== "ok") continue;
|
||||
const decision = decisionForEvidence(fixedDecisions, {
|
||||
factorKey: definition.factorKey,
|
||||
outcomeKey: definition.outcomeKey,
|
||||
lagDays: 0,
|
||||
});
|
||||
correlationsWithPatterns[definition.key] = decision
|
||||
? { ...result, ...decision }
|
||||
: result;
|
||||
}
|
||||
|
||||
// v1.34 — the versioned cardiometabolic reference report. The shared reader
|
||||
// admits only evidence-backed pillars with their own windows and coverage
|
||||
// floors, then applies the three-domain breadth rule. Its BP envelopes come
|
||||
// from the same rollup/live fast path the dashboard uses; every other domain
|
||||
// resolves through the canonical reader and fails independently.
|
||||
const [
|
||||
glucoseEnabled,
|
||||
labsEnabled,
|
||||
sleepEnabled,
|
||||
mentalHealthEnabled,
|
||||
scoreSourcePriority,
|
||||
] = await Promise.all([
|
||||
isModuleEnabled(user.id, "glucose"),
|
||||
isModuleEnabled(user.id, "labs"),
|
||||
isModuleEnabled(user.id, "sleep"),
|
||||
isModuleEnabled(user.id, "mentalHealth"),
|
||||
loadUserSourcePriority(user.id),
|
||||
]);
|
||||
const glucoseByContextOut = glucoseEnabled ? glucoseByContext : null;
|
||||
const glucoseClinicalOut = glucoseEnabled ? glucoseClinical : null;
|
||||
const scoredAt = new Date();
|
||||
const healthScore = await computeAndRecordUserHealthScore({
|
||||
userId: user.id,
|
||||
now: scoredAt,
|
||||
profile: {
|
||||
dateOfBirth: user.dateOfBirth,
|
||||
heightCm: user.heightCm ?? null,
|
||||
timezone: userTz,
|
||||
sourcePriorityJson: scoreSourcePriority,
|
||||
thresholdsJson: user.thresholdsJson ?? null,
|
||||
glucoseUnit: resolveGlucoseUnit(user.glucoseUnit),
|
||||
},
|
||||
modules: {
|
||||
glucose: glucoseEnabled,
|
||||
labs: labsEnabled,
|
||||
sleep: sleepEnabled,
|
||||
mentalHealth: mentalHealthEnabled,
|
||||
},
|
||||
healthScoreConfigJson: user.healthScoreConfigJson,
|
||||
bpTargets: clinicalBpTargets,
|
||||
bpEnvelope,
|
||||
bpEnvelopePriorWeek,
|
||||
bpEnvelopePriorTwoWeeks,
|
||||
});
|
||||
|
||||
// v1.18.1 — Rest Mode annotation. When an illness/condition episode is
|
||||
// active we frame the score (not penalise it): the number above is left
|
||||
// exactly as computed and we attach a value-free "you were unwell" context.
|
||||
// Resolved server-side so iOS mirrors the same fact, never recomputing it.
|
||||
// Resolved as-of the scored window (`scoredAt`), not a fresh now.
|
||||
if (healthScore) {
|
||||
const restMode = await resolveRestMode(user.id, scoredAt);
|
||||
healthScore.restMode = restMode.active
|
||||
? {
|
||||
active: true,
|
||||
since: restMode.since,
|
||||
episodeCount: restMode.episodeCount,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
return {
|
||||
summaries: results,
|
||||
bmi,
|
||||
bpInTargetPct,
|
||||
bpInTargetPct7d,
|
||||
bpInTargetPct30d,
|
||||
bpInTargetPctAllTime,
|
||||
bpInTargetPctPriorMonth,
|
||||
bpInTargetPctPriorYear,
|
||||
bpInTargetCount90,
|
||||
bpInTargetSpanDays90,
|
||||
glucoseByContext: glucoseByContextOut,
|
||||
glucoseClinical: glucoseClinicalOut,
|
||||
correlations: correlationsWithPatterns,
|
||||
healthScore,
|
||||
sleepStages,
|
||||
// v1.28.x — the latest night's source-discrepancy annotation, computed
|
||||
// inside the slim slice's per-night post-pass. Additive: the dashboard
|
||||
// sleep tile marks the headline with a discreet "sources disagree"
|
||||
// hint; clients that don't read the field stay unchanged.
|
||||
sleepSourceDiscrepancy: slim.sleepSourceDiscrepancy,
|
||||
// v1.4.34 — per-type freshness map drives the dashboard's
|
||||
// staleness caption on each `<TrendCard>`. Additive: clients that
|
||||
// don't read the field stay unchanged.
|
||||
lastSeenByType,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-stage sleep-minutes breakdown over the trailing 30 days.
|
||||
*
|
||||
* Returns `null` when the user has no stage-tagged sleep rows in
|
||||
* window — the analytics consumer renders the existing
|
||||
* `summaries.SLEEP_DURATION` totals without a stage card in that
|
||||
* case. Returns the sum-per-stage AND the day count covered so the
|
||||
* UI can render an "averaged across N nights" caption truthfully.
|
||||
*
|
||||
* v1.4.25 — also returns a `perNight` array: one entry per
|
||||
* Berlin-tz day inside the window, with minutes-per-stage for the
|
||||
* stacked-bar chart. Days with zero stage-tagged rows are omitted
|
||||
* from `perNight` (the chart treats them as gaps).
|
||||
*
|
||||
* The per-night stages are the MAIN NIGHT's stages. A nap ends on the
|
||||
* same wake day as that morning's night, and the per-wake-day totals
|
||||
* folded it into the night's buckets — an afternoon nap added CORE to
|
||||
* the night and the column read longer and shallower than the night
|
||||
* was. The nap's minutes now ride alongside on `napMinutes` /
|
||||
* `napCount`, both absent on a day without one.
|
||||
*/
|
||||
async function computeSleepStageBreakdown(
|
||||
userId: string,
|
||||
userTz: string,
|
||||
): Promise<SleepStageComposition | null> {
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const WINDOW_DAYS = 30;
|
||||
const since = new Date(Date.now() - WINDOW_DAYS * DAY_MS);
|
||||
// v1.11.5 — read the FULL per-stage row set (not `sleepStage: { not: null }`)
|
||||
// and route it through the canonical session reconstruction so the
|
||||
// stacked-bar agrees with the dashboard tile + chart series. The old path
|
||||
// keyed each stage row by its OWN calendar day (splitting a midnight-
|
||||
// spanning night across two buckets) and never collapsed multi-source
|
||||
// (double-counting a WHOOP + Apple Health night). Routing through the
|
||||
// helper fixes both: it session-clusters, keys by the wake day, and picks
|
||||
// one canonical source per night. The bare-vs-granular de-dup is applied
|
||||
// per-session to the per-stage breakdown too. The `source` is selected so
|
||||
// the helper's source-collapse can run.
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "SLEEP_DURATION",
|
||||
measuredAt: { gte: since },
|
||||
deletedAt: null,
|
||||
},
|
||||
select: {
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
sleepStage: true,
|
||||
source: true,
|
||||
deviceType: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const priorityJson = await loadUserSourcePriority(userId);
|
||||
return buildSleepStageComposition(rows, userTz, priorityJson, WINDOW_DAYS);
|
||||
}
|
||||
|
||||
// v1.4.37 — `computeCorrelationHypotheses` body relocated to
|
||||
// `src/lib/analytics/correlations-fast-path.ts` so the probe-gated
|
||||
// rollup / live dispatcher can be unit-tested independently of the
|
||||
// route. The new helper also tightens the scan window to 28 days and
|
||||
// emits a sentinel on `meta.correlations.window_days` / `degraded`.
|
||||
|
||||
// v1.4.49.1 — the chunked `fetchMeasurementSeriesChunked` helper and
|
||||
// its supporting types / constants relocated entirely. The default
|
||||
// slice now delegates the per-type summaries work to
|
||||
// `computeSummariesSlice` (rollup-tier, sub-second on covered tenants)
|
||||
// so the legacy 15-way live walk against `measurements` no longer has
|
||||
// a caller. The slim slice was already independent of it. Deleting the
|
||||
// dead code here removes the pool-starvation surface that gated the
|
||||
// concurrent slim slice and prevents future regressions from
|
||||
// accidentally reintroducing the fan-out.
|
||||
|
||||
// v1.4.37 — `WEEKDAY_FORMATTER_CACHE`, `getWeekdayFormatter`,
|
||||
// `dateFromDayKey`, `ISO_WEEKDAY`, and `isoWeekdayInTz` relocated
|
||||
// alongside the correlation runner in
|
||||
// `src/lib/analytics/correlations-fast-path.ts`. They were only ever
|
||||
// used by the weight-weekday + day-key helpers inside the old inline
|
||||
// correlation builder.
|
||||
@@ -3,17 +3,11 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
createSession,
|
||||
validateSessionFromCookieValue,
|
||||
SESSION_COOKIE_NAME,
|
||||
} from "@/lib/auth/session";
|
||||
import { recordSignInDevice } from "@/lib/auth/login-alert";
|
||||
import {
|
||||
deriveUniqueUsername,
|
||||
discoverOidcMetadata,
|
||||
exchangeCodeForTokens,
|
||||
fetchUserinfoEmail,
|
||||
@@ -29,15 +23,19 @@ import {
|
||||
OIDC_STATE_COOKIE,
|
||||
OIDC_STATE_COOKIE_PATH,
|
||||
} from "@/lib/auth/oidc-cookie";
|
||||
import { createMfaChallenge } from "@/lib/auth/mfa/challenge";
|
||||
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
import { resolveServerDefaultTimezone } from "@/lib/tz/resolver";
|
||||
import {
|
||||
buildNativeCallbackUrl,
|
||||
mintNativeHandoff,
|
||||
} from "@/lib/auth/oidc-native-handoff";
|
||||
import { buildNativeCallbackUrl } from "@/lib/auth/oidc-native-handoff";
|
||||
import { setPendingLinkCookie } from "@/lib/auth/oidc-pending-link";
|
||||
import {
|
||||
idpErrorCode,
|
||||
readStoredOidcState,
|
||||
} from "@/lib/auth/oidc-callback/state";
|
||||
import { resolveOidcAccount } from "@/lib/auth/oidc-callback/resolve-account";
|
||||
import {
|
||||
mintOidcNativeHandoff,
|
||||
openOidcSession,
|
||||
startOidcSecondFactor,
|
||||
} from "@/lib/auth/oidc-callback/complete-sign-in";
|
||||
import {
|
||||
CLAIM_COOKIE,
|
||||
CLAIM_ERROR_PATH,
|
||||
@@ -74,84 +72,6 @@ function deleteStateCookie(response: NextResponse): void {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Closed set of RFC 6749 §4.1.2.1 / OIDC Core §3.1.2.6 authorization error
|
||||
* codes. The wide-event meta only ever carries a member of this set (or
|
||||
* "other") — never the raw, attacker-influenceable query value.
|
||||
*/
|
||||
const KNOWN_IDP_ERROR_CODES = new Set([
|
||||
"access_denied",
|
||||
"invalid_request",
|
||||
"unauthorized_client",
|
||||
"unsupported_response_type",
|
||||
"invalid_scope",
|
||||
"server_error",
|
||||
"temporarily_unavailable",
|
||||
"interaction_required",
|
||||
"login_required",
|
||||
"account_selection_required",
|
||||
"consent_required",
|
||||
"invalid_request_uri",
|
||||
"invalid_request_object",
|
||||
"request_not_supported",
|
||||
"request_uri_not_supported",
|
||||
"registration_not_supported",
|
||||
]);
|
||||
|
||||
interface StoredOidcState {
|
||||
state: string;
|
||||
nonce: string;
|
||||
codeVerifier: string;
|
||||
next: string;
|
||||
/**
|
||||
* v1.30.x — set only when the login GET carried `client=native`. When true
|
||||
* the callback mints a handoff code (or MFA ticket) and redirects to the
|
||||
* custom scheme instead of minting a session. Both fields live ONLY inside
|
||||
* the AES-256-GCM blob, so they are tamper-authenticated: a network attacker
|
||||
* or the IdP cannot flip a web login into a native one or swap the app
|
||||
* challenge.
|
||||
*/
|
||||
native?: boolean;
|
||||
/** The app's S256 PKCE challenge, bound at login; verified at the exchange. */
|
||||
appCodeChallenge?: string;
|
||||
/**
|
||||
* v1.43 (#959) — set only when the login carried `intent=claim` and a valid
|
||||
* `hl_claim` cookie. The callback then claims the managed profile the
|
||||
* cookie names instead of signing in, and only when the cookie still
|
||||
* carries this exact binding. Web only: never together with `native`.
|
||||
*/
|
||||
intent?: "claim";
|
||||
claimBinding?: string;
|
||||
}
|
||||
|
||||
function isStoredOidcState(value: unknown): value is StoredOidcState {
|
||||
const v = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof value !== "object" ||
|
||||
value === null ||
|
||||
typeof v.state !== "string" ||
|
||||
typeof v.nonce !== "string" ||
|
||||
typeof v.codeVerifier !== "string" ||
|
||||
typeof v.next !== "string"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Optional native fields: when present they must be the right shape, and a
|
||||
// native start MUST carry the challenge the exchange verifies against.
|
||||
if (v.native !== undefined) {
|
||||
if (v.native !== true) return false;
|
||||
if (typeof v.appCodeChallenge !== "string") return false;
|
||||
}
|
||||
if (v.intent !== undefined) {
|
||||
if (v.intent !== "claim") return false;
|
||||
if (typeof v.claimBinding !== "string" || v.claimBinding.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (v.native !== undefined) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* OIDC callback. Must work with NO existing session — this route
|
||||
* authenticates the user in the first place, unlike the per-account
|
||||
@@ -159,6 +79,9 @@ function isStoredOidcState(value: unknown): value is StoredOidcState {
|
||||
* `requireAuth()`. Every failure branch redirects to `/auth/login?error=...`
|
||||
* (never JSON — this is a top-level browser navigation) and deletes the
|
||||
* single-use state cookie, mirroring `src/app/api/withings/callback/route.ts`.
|
||||
*
|
||||
* The state blob, the account resolution and the three endings (second
|
||||
* factor, native handoff, session) live in `src/lib/auth/oidc-callback/`.
|
||||
*/
|
||||
export const GET = apiHandler(async (req: NextRequest) => {
|
||||
annotate({ action: { name: "auth.oidc.callback" } });
|
||||
@@ -169,16 +92,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
// the web login page — the app parses `?error=<reason>` and never lands on a
|
||||
// dead web page. A missing/undecryptable blob leaves `stored` null (isNative
|
||||
// false) and those branches fall back to the web redirect by construction.
|
||||
const rawCookie = req.cookies.get(OIDC_STATE_COOKIE)?.value;
|
||||
let stored: StoredOidcState | null = null;
|
||||
if (rawCookie) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(decrypt(rawCookie));
|
||||
if (isStoredOidcState(parsed)) stored = parsed;
|
||||
} catch {
|
||||
stored = null;
|
||||
}
|
||||
}
|
||||
const stored = readStoredOidcState(req.cookies.get(OIDC_STATE_COOKIE)?.value);
|
||||
const isNative = stored?.native === true;
|
||||
const isClaim = stored?.intent === "claim";
|
||||
|
||||
@@ -229,7 +143,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
annotate({
|
||||
meta: {
|
||||
reason: "idp_denied",
|
||||
idpError: KNOWN_IDP_ERROR_CODES.has(idpError) ? idpError : "other",
|
||||
idpError: idpErrorCode(idpError),
|
||||
},
|
||||
});
|
||||
return failRedirect("oidc_denied");
|
||||
@@ -367,198 +281,32 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
return response;
|
||||
}
|
||||
|
||||
// (1) Durable identity match — the (issuer, sub) pair stamped on a
|
||||
// previous login, link, or provision. Email is deliberately NOT
|
||||
// consulted here: after the stamp it is a display field, so an
|
||||
// IdP-side email change can refresh it below but can never re-point
|
||||
// the login at a different account.
|
||||
let user = await prisma.user.findFirst({
|
||||
where: { oidcIssuer: metadata.issuer, oidcSub: identity.sub },
|
||||
const account = await resolveOidcAccount({
|
||||
issuer: metadata.issuer,
|
||||
sub: identity.sub,
|
||||
email,
|
||||
emailVerified,
|
||||
ip,
|
||||
});
|
||||
|
||||
if (user) {
|
||||
// Refresh the display email only — and only from a claim the IdP
|
||||
// explicitly marks verified. Skipped when another account already
|
||||
// holds the address (`email` is unique); the login itself proceeds
|
||||
// either way, keyed by (issuer, sub).
|
||||
const displayEmail = email ? email.toLowerCase() : null;
|
||||
if (
|
||||
displayEmail &&
|
||||
emailVerified === true &&
|
||||
user.email?.toLowerCase() !== displayEmail
|
||||
) {
|
||||
const emailTaken = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: { equals: displayEmail, mode: "insensitive" },
|
||||
NOT: { id: user.id },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!emailTaken) {
|
||||
user = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { email: displayEmail },
|
||||
});
|
||||
}
|
||||
if (account.kind === "refused") return failRedirect(account.reason);
|
||||
if (account.kind === "linkPending") {
|
||||
if (isNative) {
|
||||
// The native sign-in sheet cannot run the confirmation; the person
|
||||
// signs in on the web once, after which the app's SSO works.
|
||||
return failRedirect("oidc_link_required");
|
||||
}
|
||||
} else {
|
||||
// No stamped identity yet — both remaining paths (link-once,
|
||||
// provision) bind this (issuer, sub) to an account keyed by email,
|
||||
// so the claim must be explicitly `email_verified: true`. An ABSENT
|
||||
// claim is a reject, not a benefit of the doubt: an IdP that does
|
||||
// not assert verification cannot anchor a link that later gates a
|
||||
// health record.
|
||||
if (!email) {
|
||||
annotate({ meta: { reason: "no_email" } });
|
||||
return failRedirect("oidc_no_email");
|
||||
}
|
||||
if (emailVerified !== true) {
|
||||
annotate({ meta: { reason: "email_unverified" } });
|
||||
return failRedirect("oidc_email_unverified");
|
||||
}
|
||||
|
||||
const byEmail = await prisma.user.findFirst({
|
||||
where: { email: { equals: email, mode: "insensitive" } },
|
||||
const response = errorRedirect("oidc_link_required");
|
||||
setPendingLinkCookie(response, {
|
||||
userId: account.userId,
|
||||
issuer: metadata.issuer,
|
||||
sub: identity.sub,
|
||||
});
|
||||
|
||||
if (byEmail) {
|
||||
// (2) Link ONCE. An account already pinned to a different IdP
|
||||
// identity is never silently re-bound — a rebind would let a
|
||||
// second IdP identity that acquires the same email capture the
|
||||
// account. Audited so the operator can see the collision.
|
||||
if (byEmail.oidcIssuer !== null || byEmail.oidcSub !== null) {
|
||||
await auditLog("auth.oidc.link_conflict", {
|
||||
userId: byEmail.id,
|
||||
ipAddress: ip,
|
||||
});
|
||||
annotate({ meta: { reason: "identity_conflict" } });
|
||||
return failRedirect("oidc_identity_conflict");
|
||||
}
|
||||
// (2b) Never link on the email alone. The local address was never
|
||||
// verified — anyone can register with it or type it into a profile —
|
||||
// so matching it proves nothing about who owns the account. Record
|
||||
// the link this login would make and send the browser to sign in to
|
||||
// the account with its own credential; the link is made there
|
||||
// (`completePendingOidcLink`), and only for this account.
|
||||
await auditLog("auth.oidc.link_pending", {
|
||||
userId: byEmail.id,
|
||||
ipAddress: ip,
|
||||
});
|
||||
annotate({ meta: { reason: "link_confirmation_required" } });
|
||||
if (isNative) {
|
||||
// The native sign-in sheet cannot run the confirmation; the person
|
||||
// signs in on the web once, after which the app's SSO works.
|
||||
return failRedirect("oidc_link_required");
|
||||
}
|
||||
const response = errorRedirect("oidc_link_required");
|
||||
setPendingLinkCookie(response, {
|
||||
userId: byEmail.id,
|
||||
issuer: metadata.issuer,
|
||||
sub: identity.sub,
|
||||
});
|
||||
return response;
|
||||
} else {
|
||||
// (3) Provision.
|
||||
const userCount = await prisma.user.count();
|
||||
let registrationEnabled = true;
|
||||
try {
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
});
|
||||
if (settings && !settings.registrationEnabled && userCount > 0) {
|
||||
registrationEnabled = false;
|
||||
}
|
||||
} catch {
|
||||
// Table may not exist yet; allow provisioning (matches register/route.ts).
|
||||
}
|
||||
if (!registrationEnabled) {
|
||||
annotate({ meta: { reason: "registration_disabled" } });
|
||||
return failRedirect("oidc_registration_disabled");
|
||||
}
|
||||
|
||||
const username = await deriveUniqueUsername(email, (candidate) =>
|
||||
prisma.user
|
||||
.findUnique({ where: { username: candidate } })
|
||||
.then((u: unknown) => u !== null),
|
||||
);
|
||||
|
||||
// Same server-default resolution the register route uses — an
|
||||
// SSO-provisioned account has no registration form to carry the
|
||||
// browser-detected timezone.
|
||||
const timezone = await resolveServerDefaultTimezone();
|
||||
|
||||
// v1.28.48 — the first provisioned user becomes ADMIN. Deriving that
|
||||
// role from the early `userCount` (read above, before username
|
||||
// derivation) and then creating without coordination is the same
|
||||
// check-then-act race the password-register route closed in v1.28.42:
|
||||
// two concurrent OIDC first-logins racing the empty-DB window would
|
||||
// both observe `0` and both be minted ADMIN. Serialise the
|
||||
// count+insert behind a transaction-scoped advisory lock (released on
|
||||
// commit/rollback) and re-count *inside* the lock so the second
|
||||
// provision observes the first's committed row and is minted USER.
|
||||
// The lock key STRING matches the register route's — an OIDC
|
||||
// first-login racing a password first-registration is serialised
|
||||
// against the same lock, so only one ADMIN can ever be minted.
|
||||
user = await prisma.$transaction(async (tx) => {
|
||||
// `pg_advisory_xact_lock` returns void, which the client cannot
|
||||
// deserialize as a column — selecting FROM it yields a plain int row.
|
||||
await tx.$queryRaw`
|
||||
SELECT 1 AS locked
|
||||
FROM pg_advisory_xact_lock(hashtextextended('register:first-admin', 0))
|
||||
`;
|
||||
const priorUsers = await tx.user.count();
|
||||
const role = priorUsers === 0 ? "ADMIN" : "USER";
|
||||
return tx.user.create({
|
||||
data: {
|
||||
email: email.toLowerCase(),
|
||||
username,
|
||||
passwordHash: null,
|
||||
role,
|
||||
timezone,
|
||||
oidcIssuer: metadata.issuer,
|
||||
oidcSub: identity.sub,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await auditLog("auth.oidc.provisioned", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
});
|
||||
}
|
||||
return response;
|
||||
}
|
||||
const user = account.user;
|
||||
|
||||
// Native second factor. OIDC is a DELEGATED factor: it proves the IdP
|
||||
// authenticated someone, but it never substitutes for this app's own
|
||||
// second factor. An account with a confirmed TOTP secret or a
|
||||
// registered security key gets the same MFA challenge step password
|
||||
// login gets — no session is minted here; the single-use ticket rides
|
||||
// the handoff cookie to the login page (web) or the custom scheme
|
||||
// (native), which render the same challenge UI and complete at
|
||||
// `/api/auth/mfa/verify`. (The trusted-device skip cannot apply on this
|
||||
// path: its cookie is SameSite=Strict, which the browser withholds on an
|
||||
// IdP-initiated redirect chain.)
|
||||
const hasTotp = Boolean(user.totpConfirmedAt);
|
||||
const webauthnKeyCount = await prisma.webauthnMfaCredential.count({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
const hasWebauthn = webauthnKeyCount > 0;
|
||||
|
||||
if (hasTotp || hasWebauthn) {
|
||||
const challenge = await createMfaChallenge(user.id, "login");
|
||||
const methods: ("totp" | "recovery" | "webauthn")[] = [];
|
||||
if (hasTotp) methods.push("totp", "recovery");
|
||||
if (hasWebauthn) methods.push("webauthn");
|
||||
await auditLog("auth.mfa.challenge", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { source: isNative ? "login.oidc.native" : "login.oidc" },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.mfa.challenge" },
|
||||
meta: { mfa_required: true },
|
||||
});
|
||||
|
||||
const secondFactor = await startOidcSecondFactor(user, ip, isNative);
|
||||
if (secondFactor) {
|
||||
// Native: hand the app the SAME single-use ticket via the custom scheme.
|
||||
// No `OIDC_MFA_COOKIE` (it exists only to ferry the ticket to the web
|
||||
// login page's script) and no session — the app completes at the
|
||||
@@ -569,8 +317,8 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
if (isNative) {
|
||||
const response = NextResponse.redirect(
|
||||
buildNativeCallbackUrl({
|
||||
mfa_ticket: challenge.ticket,
|
||||
methods: methods.join(","),
|
||||
mfa_ticket: secondFactor.ticket,
|
||||
methods: secondFactor.methods.join(","),
|
||||
}),
|
||||
);
|
||||
deleteStateCookie(response);
|
||||
@@ -585,7 +333,10 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
deleteStateCookie(response);
|
||||
response.cookies.set(
|
||||
OIDC_MFA_COOKIE,
|
||||
JSON.stringify({ ticket: challenge.ticket, methods }),
|
||||
JSON.stringify({
|
||||
ticket: secondFactor.ticket,
|
||||
methods: secondFactor.methods,
|
||||
}),
|
||||
{
|
||||
// Not httpOnly by design — see the constant's doc comment.
|
||||
httpOnly: false,
|
||||
@@ -600,26 +351,14 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
|
||||
const ua = req.headers.get("user-agent");
|
||||
|
||||
// Native, no MFA: mint a one-time handoff code and hand it to the app via
|
||||
// the custom scheme. NO session and NO cookie are minted here — the app
|
||||
// exchanges the code at `POST /api/auth/oidc/native/token` for the native
|
||||
// bundle. `userId` is the resolved identity; the app's S256 challenge
|
||||
// (bound at login, tamper-authenticated in the state blob) locks the code
|
||||
// to the app instance that started the flow. The session/device are
|
||||
// recorded at the exchange via `finishLogin`, not here.
|
||||
if (isNative) {
|
||||
const { code: handoffCode } = await mintNativeHandoff({
|
||||
const handoffCode = await mintOidcNativeHandoff({
|
||||
userId: user.id,
|
||||
// Guaranteed present by `isStoredOidcState` when `native === true`.
|
||||
// Guaranteed present by the state reader when `native === true`.
|
||||
appCodeChallenge: stored.appCodeChallenge as string,
|
||||
ipAddress: ip,
|
||||
ip,
|
||||
userAgent: ua,
|
||||
});
|
||||
await auditLog("auth.oidc.native.handoff_minted", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
});
|
||||
annotate({ action: { name: "auth.oidc.native.handoff_minted" } });
|
||||
const response = NextResponse.redirect(
|
||||
buildNativeCallbackUrl({ code: handoffCode }),
|
||||
);
|
||||
@@ -627,28 +366,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
return response;
|
||||
}
|
||||
|
||||
// v1.23 parity with password login — the admin-enforced-MFA hint cookie
|
||||
// sends a single-factor account into forced enrollment after sign-in.
|
||||
await syncMfaEnrollCookie(user.id, {
|
||||
totpConfirmedAt: user.totpConfirmedAt,
|
||||
mfaEnforced: user.mfaEnforced,
|
||||
});
|
||||
|
||||
// The 5th argument stays null: OIDC is a delegated factor, so an SSO
|
||||
// session must never satisfy `requireFreshMfa` step-up (MFA disable,
|
||||
// encryption-key rotation, encrypted export, account deletion) — the
|
||||
// same treatment a trusted-device login gets.
|
||||
await createSession(
|
||||
user.id,
|
||||
user.onboardingCompletedAt === null,
|
||||
ip,
|
||||
ua,
|
||||
null,
|
||||
);
|
||||
|
||||
void recordSignInDevice({ userId: user.id, ip, userAgent: ua });
|
||||
|
||||
await auditLog("auth.oidc.login", { userId: user.id, ipAddress: ip });
|
||||
await openOidcSession(user, ip, ua);
|
||||
|
||||
const response = NextResponse.redirect(oidcAppUrl(stored.next));
|
||||
deleteStateCookie(response);
|
||||
|
||||
@@ -52,284 +52,21 @@
|
||||
* belongs to a disabled module never ships. The decision comes from the same
|
||||
* map the shared snapshot builder uses (`disabledSummaryTypes`); the filter
|
||||
* runs OUTSIDE the cache because a module toggle does not evict it.
|
||||
*
|
||||
* The builder lives in `src/lib/dashboard/summary/`.
|
||||
*/
|
||||
import { loadFreeWeekdaySpans } from "@/lib/profile/life-circumstances";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import {
|
||||
resolveTrendVerdict,
|
||||
trendSentimentOfTrend,
|
||||
type TrendSentiment,
|
||||
type TrendVerdict,
|
||||
type TrendVerdictContext,
|
||||
} from "@/lib/targets/trend-verdict";
|
||||
import { type Locale } from "@/lib/i18n/config";
|
||||
import { resolveJobLocale } from "@/lib/i18n/job-locale";
|
||||
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
import { validTimezoneOr } from "@/lib/tz/format";
|
||||
import { startOfLocalDayKey } from "@/lib/tz/local-day";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today";
|
||||
import { computeStreak } from "@/lib/dashboard/streak";
|
||||
import {
|
||||
readActivityDayKeys,
|
||||
readAllTimeCounts,
|
||||
readLatestEver,
|
||||
readSparkBuckets,
|
||||
} from "@/lib/dashboard/summary-reads";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { gateMetricCardsByModules } from "@/lib/dashboard/widget-modules";
|
||||
import {
|
||||
summarizeSleepNights,
|
||||
reconstructSleepNights,
|
||||
type SleepSourceDiscrepancy,
|
||||
type SleepStageRow,
|
||||
} from "@/lib/analytics/sleep-night";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import type { SleepStage } from "@/generated/prisma/client";
|
||||
import { loadBaselineProfile } from "@/lib/insights/derived/baseline";
|
||||
import { computeBmi } from "@/lib/insights/derived/bmi";
|
||||
import { isDerivedOk } from "@/lib/insights/derived/types";
|
||||
import { buildMoodDailySeries } from "@/lib/analytics/mood-series";
|
||||
import {
|
||||
reconstructNights,
|
||||
sleepNeedMinutes,
|
||||
} from "@/lib/insights/derived/sleep-score";
|
||||
import {
|
||||
computeSleepRhythmFromNights,
|
||||
type SleepRhythmDto,
|
||||
} from "@/lib/insights/derived/sleep-rhythm";
|
||||
|
||||
const SPARK_DAYS = 7;
|
||||
const STREAK_WINDOW_DAYS = 365;
|
||||
|
||||
type MetricKind =
|
||||
| "weight"
|
||||
| "bloodPressure"
|
||||
| "pulse"
|
||||
| "bodyFat"
|
||||
| "glucose"
|
||||
| "sleep"
|
||||
| "steps"
|
||||
| "totalBodyWater"
|
||||
| "boneMass"
|
||||
| "oxygenSaturation"
|
||||
| "mood"
|
||||
| "bmi";
|
||||
|
||||
interface MetricCard {
|
||||
id: string;
|
||||
kind: MetricKind;
|
||||
/**
|
||||
* v1.5.x — i18n key for the metric's display title (e.g.
|
||||
* `dashboard.metric.title.weight`). Clients resolve the key against
|
||||
* their own locale bundle: the PWA via `useTranslations()`, the iOS
|
||||
* client via its bundled `Localizable.xcstrings`. The previous wire
|
||||
* shape carried a German string literal which injected DE into an
|
||||
* English-locale iOS UI.
|
||||
*/
|
||||
titleKey: string;
|
||||
latestValue: number | null;
|
||||
secondaryValue: number | null;
|
||||
/**
|
||||
* v1.5.x — i18n key for the metric's display unit (e.g.
|
||||
* `dashboard.metric.unit.weight`). Same resolution contract as
|
||||
* `titleKey`. SI tokens like `kg`, `mmHg`, `bpm`, `mg/dL` are
|
||||
* identical across every shipped locale; per-kind keys keep the
|
||||
* door open for locale-specific overrides (e.g. Imperial units)
|
||||
* without another wire-shape change.
|
||||
*/
|
||||
unitKey: string;
|
||||
/**
|
||||
* v1.11.4 — explicit unit token for clients that need to know the
|
||||
* value's unit without inferring it from the metric kind. Most tiles
|
||||
* leave this `null` (the `unitKey` i18n key already carries the
|
||||
* display unit). The `sleep` tile sets it to `"h"` because its
|
||||
* `latestValue` is a per-NIGHT total expressed in HOURS (a float),
|
||||
* not the canonical `SLEEP_DURATION` minutes — see the sleep block
|
||||
* below for why the night total replaced the single-stage value.
|
||||
*/
|
||||
unit: string | null;
|
||||
/**
|
||||
* v1.11.4 — per-stage minutes for the headline night, sleep tile only.
|
||||
* `null` for every other kind and for a sleep night with no
|
||||
* stage-tagged rows (a legacy bare-duration night). Additive: a future
|
||||
* sleep detail view can render the breakdown without changing the
|
||||
* headline `latestValue`.
|
||||
*/
|
||||
sleepStages: Partial<Record<SleepStage, number>> | null;
|
||||
/**
|
||||
* v1.28.x — source-discrepancy annotation for the headline night's MAIN
|
||||
* session, sleep tile only; `null` for every other kind and for a night
|
||||
* whose writers agree. Additive (the established wire pattern for the
|
||||
* iOS-locked shape): observational only — `latestValue` stays the
|
||||
* winning writer's total; clients render a discreet "sources disagree"
|
||||
* hint next to the number. Minutes are whole (rounded), matching the
|
||||
* `/api/sleep/night` serialisation.
|
||||
*/
|
||||
sleepSourceDiscrepancy: SleepSourceDiscrepancy | null;
|
||||
trend: "up" | "down" | "flat" | "unknown";
|
||||
sparkline: number[];
|
||||
updatedAt: string | null;
|
||||
/**
|
||||
* v1.4.33 — total readings the user has ever
|
||||
* logged for this metric, irrespective of the 7-day sparkline
|
||||
* window. The dashboard tile keeps showing whenever `allTimeCount > 0`
|
||||
* so a user with valid historical data isn't surprised by a tile
|
||||
* disappearing during a logging gap. Distinct from `sparkline.length`
|
||||
* which only carries the trailing-7-day points.
|
||||
*/
|
||||
allTimeCount: number;
|
||||
/**
|
||||
* v1.4.33 — ISO timestamp of the metric's single
|
||||
* most recent reading. When `allTimeCount > 0` but the latest
|
||||
* reading is older than 7 days, the iOS tile renders a muted
|
||||
* "last reading N days ago" caption so the user understands the
|
||||
* value isn't stale silently. `null` when the metric has no readings
|
||||
* at all.
|
||||
*/
|
||||
lastSeenAt: string | null;
|
||||
/**
|
||||
* v1.43 (ios #116 item 3) — which way a change of this metric counts for
|
||||
* the person, against their own target or a profile default
|
||||
* (`src/lib/targets/trend-verdict.ts`).
|
||||
*/
|
||||
direction?: TrendVerdict | null;
|
||||
/** v1.43 — `direction` applied to this card's `trend`. */
|
||||
trendSentiment?: TrendSentiment;
|
||||
}
|
||||
|
||||
/**
|
||||
* The verdict key behind each card: the measurement type it reads, or the
|
||||
* derived key for the two cards that are not one.
|
||||
*/
|
||||
const VERDICT_KEY: Record<MetricKind, string> = {
|
||||
weight: "WEIGHT",
|
||||
bloodPressure: "BLOOD_PRESSURE_SYS",
|
||||
pulse: "PULSE",
|
||||
bodyFat: "BODY_FAT",
|
||||
glucose: "BLOOD_GLUCOSE",
|
||||
sleep: "SLEEP_DURATION",
|
||||
steps: "ACTIVITY_STEPS",
|
||||
totalBodyWater: "TOTAL_BODY_WATER",
|
||||
boneMass: "BONE_MASS",
|
||||
oxygenSaturation: "OXYGEN_SATURATION",
|
||||
mood: "MOOD",
|
||||
bmi: "BMI",
|
||||
};
|
||||
|
||||
/**
|
||||
* Lay the verdict over each card. The reference placed against the band is
|
||||
* the mean of the card's own seven-day sparkline, else its latest value, the
|
||||
* seven-day average the web tiles place. Sleep cards are in hours and the
|
||||
* verdict reads minutes.
|
||||
*/
|
||||
function withVerdicts(
|
||||
cards: MetricCard[],
|
||||
ctx: Omit<TrendVerdictContext, "referenceValue">,
|
||||
): MetricCard[] {
|
||||
const referenceOf = (card: MetricCard | undefined): number | null => {
|
||||
if (!card) return null;
|
||||
const spark = card.sparkline.filter((v) => Number.isFinite(v));
|
||||
return spark.length > 0
|
||||
? spark.reduce((sum, v) => sum + v, 0) / spark.length
|
||||
: card.latestValue;
|
||||
};
|
||||
// BMI is judged by the weight it is derived from (the target is a weight).
|
||||
const weightReference = referenceOf(cards.find((c) => c.kind === "weight"));
|
||||
return cards.map((card) => {
|
||||
const reference = card.kind === "bmi" ? weightReference : referenceOf(card);
|
||||
const direction = resolveTrendVerdict(VERDICT_KEY[card.kind], {
|
||||
...ctx,
|
||||
referenceValue:
|
||||
reference !== null && card.kind === "sleep"
|
||||
? reference * 60
|
||||
: reference,
|
||||
});
|
||||
return {
|
||||
...card,
|
||||
direction,
|
||||
trendSentiment: trendSentimentOfTrend(card.trend, direction),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.5.x — wire i18n keys, not translated strings.
|
||||
*
|
||||
* The legacy maps emitted German literals like "Gewicht" / "Schritte"
|
||||
* which injected into an English iOS UI once the iOS app flipped its
|
||||
* source language. Clients now resolve these keys against their own
|
||||
* locale bundle (the PWA via `useTranslations()`, iOS via
|
||||
* `Localizable.xcstrings`). The server payload becomes language-neutral
|
||||
* and the response shrinks slightly.
|
||||
*
|
||||
* One key per `MetricKind` — symmetric maps so a new kind has to add
|
||||
* both a title and a unit key (Type-checked: `Record<MetricKind, string>`).
|
||||
*/
|
||||
const METRIC_TITLE_KEYS: Record<MetricKind, string> = {
|
||||
weight: "dashboard.metric.title.weight",
|
||||
bloodPressure: "dashboard.metric.title.bloodPressure",
|
||||
pulse: "dashboard.metric.title.pulse",
|
||||
bodyFat: "dashboard.metric.title.bodyFat",
|
||||
glucose: "dashboard.metric.title.glucose",
|
||||
sleep: "dashboard.metric.title.sleep",
|
||||
steps: "dashboard.metric.title.steps",
|
||||
totalBodyWater: "dashboard.metric.title.totalBodyWater",
|
||||
boneMass: "dashboard.metric.title.boneMass",
|
||||
oxygenSaturation: "dashboard.metric.title.oxygenSaturation",
|
||||
mood: "dashboard.metric.title.mood",
|
||||
bmi: "dashboard.metric.title.bmi",
|
||||
};
|
||||
|
||||
const METRIC_UNIT_KEYS: Record<MetricKind, string> = {
|
||||
weight: "dashboard.metric.unit.weight",
|
||||
bloodPressure: "dashboard.metric.unit.bloodPressure",
|
||||
pulse: "dashboard.metric.unit.pulse",
|
||||
bodyFat: "dashboard.metric.unit.bodyFat",
|
||||
glucose: "dashboard.metric.unit.glucose",
|
||||
sleep: "dashboard.metric.unit.sleep",
|
||||
steps: "dashboard.metric.unit.steps",
|
||||
totalBodyWater: "dashboard.metric.unit.totalBodyWater",
|
||||
boneMass: "dashboard.metric.unit.boneMass",
|
||||
oxygenSaturation: "dashboard.metric.unit.oxygenSaturation",
|
||||
mood: "dashboard.metric.unit.mood",
|
||||
bmi: "dashboard.metric.unit.bmi",
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.11.4 — sleep sparkline = the trailing-7 nights' TIME-ASLEEP in
|
||||
* hours, reconstructed per night from the raw stage rows. The generic
|
||||
* DAY-bucket rollup sparkline can't be used for sleep because it means
|
||||
* across the per-stage rows (e.g. a 60-min DEEP row and a 240-min CORE
|
||||
* row average to 150) rather than summing them into a night total.
|
||||
*/
|
||||
function buildSleepSparkline(
|
||||
rows: SleepStageRow[],
|
||||
tz: string,
|
||||
priorityJson: unknown,
|
||||
): number[] {
|
||||
return reconstructSleepNights(rows, tz, priorityJson)
|
||||
.filter((n) => n.asleepMinutes > 0)
|
||||
.map((n) => Math.round((n.asleepMinutes / 60) * 100) / 100)
|
||||
.slice(-SPARK_DAYS);
|
||||
}
|
||||
|
||||
function trendOf(values: number[]): MetricCard["trend"] {
|
||||
if (values.length < 2) return "unknown";
|
||||
const first = values[0];
|
||||
const last = values[values.length - 1];
|
||||
const delta = last - first;
|
||||
const epsilon = Math.max(1, Math.abs(first) * 0.01);
|
||||
if (Math.abs(delta) < epsilon) return "flat";
|
||||
return delta > 0 ? "up" : "down";
|
||||
}
|
||||
buildDashboardSummary,
|
||||
buildSummaryContext,
|
||||
} from "@/lib/dashboard/summary/build";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
|
||||
@@ -365,7 +102,7 @@ export const GET = apiHandler(async () => {
|
||||
>,
|
||||
`${user.id}|dashboard-summary`,
|
||||
async () =>
|
||||
buildDashboardSummary(user.id, userTz, await buildContext(user)),
|
||||
buildDashboardSummary(user.id, userTz, await buildSummaryContext(user)),
|
||||
annotate,
|
||||
);
|
||||
|
||||
@@ -399,634 +136,3 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
return apiSuccess({ ...body, metrics });
|
||||
});
|
||||
|
||||
interface SummaryBuilderContext {
|
||||
greetingName: string;
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
async function buildContext(user: {
|
||||
displayName: string | null;
|
||||
username: string;
|
||||
locale: string | null;
|
||||
}): Promise<SummaryBuilderContext> {
|
||||
const greetingName = user.displayName ?? user.username;
|
||||
// The greeting salutation and the streak label are the two strings this
|
||||
// route still translates server-side, and the native client is its only
|
||||
// caller — a Bearer request carries no locale cookie and no meaningful
|
||||
// Accept-Language, so the stored column is the only per-user signal.
|
||||
// That is exactly the shape `resolveJobLocale` exists for: stored user
|
||||
// locale, then the operator's configured default, then English. The
|
||||
// hand-rolled coercion this replaces stopped at English, so a German
|
||||
// instance whose user never touched the language picker greeted them in
|
||||
// English while every other string on the same screen was German.
|
||||
const locale: Locale = await resolveJobLocale(user.locale);
|
||||
return { greetingName, locale };
|
||||
}
|
||||
|
||||
async function buildDashboardSummary(
|
||||
userId: string,
|
||||
userTz: string,
|
||||
ctx: SummaryBuilderContext,
|
||||
) {
|
||||
const now = new Date();
|
||||
// REG-11 (v1.4.44): the trailing-7-day pivot used to anchor the
|
||||
// latest-reading + sparkline filters is gone — both sub-queries now
|
||||
// take the most recent N rows per type regardless of age. Only the
|
||||
// streak window still anchors on a calendar slice.
|
||||
const streakWindowStart = new Date(
|
||||
now.getTime() - STREAK_WINDOW_DAYS * 86_400_000,
|
||||
);
|
||||
|
||||
// Derived from canonical enum so a new measurement type is auto-included
|
||||
// (V3 audit: enum drift cousins). Per-kind display blocks below decide
|
||||
// which types render as MetricCards.
|
||||
const measurementTypes = [
|
||||
...measurementTypeEnum.options,
|
||||
] as MeasurementType[];
|
||||
|
||||
// v1.4.38 — per-sub-query wall-clock timing for prod observability.
|
||||
// Captured on the cache-miss path; the cache-hit path skips this whole
|
||||
// builder. Reported under `meta.dashboard.sub_*_ms` so the next
|
||||
// perf-verify can attribute a regression to a specific sub-query
|
||||
// without re-instrumenting the route.
|
||||
const timings: Record<string, number> = {};
|
||||
const time = async <T>(
|
||||
label: string,
|
||||
builder: () => Promise<T>,
|
||||
): Promise<T> => {
|
||||
const t0 = Date.now();
|
||||
const result = await builder();
|
||||
timings[`dashboard.sub_${label}_ms`] = Date.now() - t0;
|
||||
return result;
|
||||
};
|
||||
|
||||
// v1.4.38 — six bounded sub-queries replace the legacy 4
|
||||
// unbounded ones. Row counts are now:
|
||||
// - latestEver: ≤ N metric types (one indexed lookup per type via
|
||||
// LATERAL, REG-11: dropped the 7-day window so a
|
||||
// stale-but-valid historical reading still feeds the tile)
|
||||
// - sparkBuckets: ≤ SPARK_DAYS × N metric types (typically <80,
|
||||
// REG-11: taken via ROW_NUMBER window, no calendar
|
||||
// filter, so a 60-day-old metric still paints a chart)
|
||||
// - allTimeAggregate: ≤ N metric types, summed from the DAY rollups
|
||||
// - medsToday: today-window tally via the shared projector
|
||||
// (≤ daily intake schedule count, unchanged semantics)
|
||||
// - streakActivity: ≤ daily intake count × 365 (unchanged)
|
||||
// - measurementStreakDays: ≤ 365 (was: every raw row in 365d)
|
||||
const [
|
||||
latestEver,
|
||||
sparkBuckets,
|
||||
allTimeAggregate,
|
||||
medsToday,
|
||||
streakActivity,
|
||||
measurementStreakDays,
|
||||
sleepStageRows,
|
||||
] = await Promise.all([
|
||||
// Both reads live in `@/lib/dashboard/summary-reads` — the SQL is a
|
||||
// contract the integration suite exercises directly against Postgres,
|
||||
// and each carries the reason it names the type list at its
|
||||
// definition. Short version: PostgreSQL 16 has no index skip scan, so
|
||||
// "one row per type" has to be driven FROM the type list or it
|
||||
// degrades to a full pass over the account.
|
||||
time("latestEver", () => readLatestEver(prisma, userId, measurementTypes)),
|
||||
time("sparkline", () =>
|
||||
readSparkBuckets(prisma, userId, measurementTypes, SPARK_DAYS),
|
||||
),
|
||||
// Counted from the DAY rollups where they cover the days, not by one
|
||||
// index entry per reading (see `readAllTimeCounts`).
|
||||
time("allTime", () =>
|
||||
readAllTimeCounts(prisma, userId, measurementTypes, now),
|
||||
),
|
||||
// v1.4.39 — the dashboard compliance tile pulled
|
||||
// `MedicationIntakeEvent` rows for the today-window only. Daily
|
||||
// schedules (`daysOfWeek: null`) had no row to read until the
|
||||
// reminder worker entered the RED phase at the end of the dose
|
||||
// window, so the iOS Dashboard tile fell to "Heute nichts geplant"
|
||||
// even when the intake route (post-`expandTodayIntakes` fix) was
|
||||
// already projecting + backfilling correctly. The shared
|
||||
// `buildMedsTodayBlock` runs the same idempotent projection
|
||||
// (`skipDuplicates: true` so a concurrent intake-route hit can't
|
||||
// race a duplicate row in) before its today-window tally, so this
|
||||
// route and the dashboard snapshot count the identical row set.
|
||||
time("medsToday", () => buildMedsTodayBlock(prisma, userId, userTz, now)),
|
||||
time("streakIntakes", () =>
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId,
|
||||
// v1.7.0 sync — exclude tombstoned rows from streak counting.
|
||||
deletedAt: null,
|
||||
scheduledFor: { gte: streakWindowStart },
|
||||
OR: [{ takenAt: { not: null } }, { skipped: true }],
|
||||
},
|
||||
select: { takenAt: true, scheduledFor: true },
|
||||
}),
|
||||
),
|
||||
time("streakDays", () =>
|
||||
readActivityDayKeys(prisma, userId, userTz, streakWindowStart, now),
|
||||
),
|
||||
// v1.11.4 — raw per-stage SLEEP_DURATION rows for the night-total
|
||||
// tile. `SLEEP_DURATION` is stored one row per STAGE per night
|
||||
// (minutes), so the single-most-recent row (the `latestEver` path
|
||||
// above) is just ONE stage, not the night. The sleep tile instead
|
||||
// sums the asleep stages of the latest night via
|
||||
// `summarizeSleepNights`. Bounded to the streak window (≈ a year of
|
||||
// ~5 rows/night) so the read stays small; the headline only needs
|
||||
// the most-recent night but the window also feeds the iOS sparkline
|
||||
// night totals.
|
||||
time("sleepNights", () =>
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "SLEEP_DURATION",
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: streakWindowStart },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
// Writer-level collapse: two HealthKit apps behind one source
|
||||
// (watch stages vs phone in-bed) must not blend into one night.
|
||||
select: {
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
sleepStage: true,
|
||||
source: true,
|
||||
deviceType: true,
|
||||
},
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
// v1.11.4 — the user's sleep source-priority ladder, used to collapse a
|
||||
// dual-source night (e.g. WHOOP + Apple Health) to one canonical source
|
||||
// before the per-night reconstruction sums it. v1.17.0 — the baseline
|
||||
// profile rides along (one read) so the sleep-rhythm block resolves the
|
||||
// age-based sleep need the cumulative-debt deficit uses.
|
||||
const [sleepPriorityJson, sleepProfile, moodSeries] = await Promise.all([
|
||||
loadUserSourcePriority(userId),
|
||||
loadBaselineProfile(prisma, userId),
|
||||
// The mood card reads the SAME canonical daily series the web mood
|
||||
// analytics and the dashboard snapshot read (`buildMoodDailySeries`),
|
||||
// rollup fast-path included — never a second aggregation.
|
||||
time("moodSeries", () => buildMoodDailySeries(userId, prisma)),
|
||||
]);
|
||||
|
||||
// BMI is derived, not measured: it needs the profile height loaded above,
|
||||
// so it cannot join the round before it. `computeBmi` is the one canonical
|
||||
// implementation (the same one the web vitals dashboard renders through
|
||||
// the derived-metric dispatch) — the route resolves the figure and the
|
||||
// client consumes it, rather than every client re-deriving weight / m².
|
||||
const bmiDerived = await time("bmi", () =>
|
||||
computeBmi(userId, sleepProfile, { now }),
|
||||
);
|
||||
|
||||
// Per-type metadata lookup — typed Map so a metric with no readings
|
||||
// at all falls through `metaForType` to the `{ allTimeCount: 0,
|
||||
// lastSeenAt: null }` default.
|
||||
const allTimeByType = new Map<
|
||||
MeasurementType,
|
||||
{ allTimeCount: number; lastSeenAt: Date | null }
|
||||
>();
|
||||
// The newest reading is the one `latestEver` already read per type; its
|
||||
// instant is the metric's last-seen time.
|
||||
const lastSeenByType = new Map(
|
||||
latestEver.map((row) => [row.type, row.measured_at]),
|
||||
);
|
||||
for (const row of allTimeAggregate) {
|
||||
allTimeByType.set(row.type, {
|
||||
allTimeCount: row.count,
|
||||
lastSeenAt: lastSeenByType.get(row.type) ?? null,
|
||||
});
|
||||
}
|
||||
function metaForType(type: MeasurementType): {
|
||||
allTimeCount: number;
|
||||
lastSeenAt: string | null;
|
||||
} {
|
||||
const slot = allTimeByType.get(type);
|
||||
return {
|
||||
allTimeCount: slot?.allTimeCount ?? 0,
|
||||
lastSeenAt: slot?.lastSeenAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// v1.4.38 — activity-day set assembled from the bounded streak
|
||||
// queries. The measurement side arrives as local YYYY-MM-DD keys, the
|
||||
// same keys `userDayKey(measuredAt, userTz)` produces for the intakes.
|
||||
const activityDays = new Set<string>(measurementStreakDays);
|
||||
for (const e of streakActivity) {
|
||||
activityDays.add(userDayKey(e.takenAt ?? e.scheduledFor, userTz));
|
||||
}
|
||||
|
||||
const streak = computeStreak(activityDays, userTz);
|
||||
|
||||
// v1.4.38 — per-type latest (one row per type) + sparkline
|
||||
// (bucket means per day) assembled from the two new SQL aggregates.
|
||||
// REG-11 (v1.4.44): `latestEver` carries the most recent reading
|
||||
// regardless of age — the iOS tile shows the historical value plus a
|
||||
// muted "Letzter Wert vor Xd" caption rather than nothing at all.
|
||||
const latestByType = new Map<MeasurementType, { value: number; at: Date }>();
|
||||
for (const row of latestEver) {
|
||||
latestByType.set(row.type, {
|
||||
value: Number(row.value),
|
||||
at: new Date(row.measured_at),
|
||||
});
|
||||
}
|
||||
// v1.4.39 — cumulative tiles paint the daily SUM; spot tiles
|
||||
// keep the daily MEAN. Reading `sum_value` directly off the rollup
|
||||
// row eliminates the legacy `mean * count` reconstruction; the
|
||||
// fallback covers pre-v1.4.39 NULL rows during the boot-backfill
|
||||
// convergence window.
|
||||
const sparkByType = new Map<MeasurementType, number[]>();
|
||||
for (const row of sparkBuckets) {
|
||||
const list = sparkByType.get(row.type) ?? [];
|
||||
// QA Simplifier (v1.4.39): flatten the cumulative-vs-spot ternary
|
||||
// into a named branch per the CLAUDE.md "no nested ternaries"
|
||||
// rule. Cumulative tiles paint the daily SUM; spot tiles keep the
|
||||
// daily MEAN. The legacy NULL fallback covers pre-v1.4.39 rollup
|
||||
// rows the boot-backfill hasn't refreshed yet.
|
||||
const cumulativePoint =
|
||||
row.sum_value !== null
|
||||
? Number(row.sum_value)
|
||||
: Number(row.mean) * Number(row.count);
|
||||
const point = CUMULATIVE_HK_TYPES.has(row.type)
|
||||
? cumulativePoint
|
||||
: Number(row.mean);
|
||||
list.push(point);
|
||||
sparkByType.set(row.type, list);
|
||||
}
|
||||
|
||||
function latestOf(type: MeasurementType): { value: number; at: Date } | null {
|
||||
return latestByType.get(type) ?? null;
|
||||
}
|
||||
|
||||
function sparkOf(type: MeasurementType): number[] {
|
||||
return sparkByType.get(type) ?? [];
|
||||
}
|
||||
|
||||
// v1.11.4 — collapse the per-stage SLEEP_DURATION rows into per-night
|
||||
// asleep totals. The sleep tile's headline is last night's TIME ASLEEP
|
||||
// (CORE/light + DEEP + REM, excluding IN_BED + AWAKE), emitted in HOURS
|
||||
// with an explicit `unit: "h"`, replacing the single-stage minutes the
|
||||
// `latestEver` read used to surface.
|
||||
const sleepSummary = summarizeSleepNights(
|
||||
sleepStageRows as SleepStageRow[],
|
||||
userTz,
|
||||
sleepPriorityJson,
|
||||
);
|
||||
|
||||
// v1.17.0 — server-authoritative sleep-rhythm DTO (sleep-debt + chronotype)
|
||||
// off the SAME canonical reconstruction the Sleep page + Sleep Score read,
|
||||
// so iOS renders identical values. Reuses the rows already loaded above —
|
||||
// `reconstructNights` adapts them into the asleep + wall-clock-midpoint
|
||||
// shape the foundation modules consume, then `computeSleepRhythmFromNights`
|
||||
// forwards them to `computeSleepDebt` / `computeChronotype` (the one place
|
||||
// the math lives). Both signals carry a calm partial / learning state below
|
||||
// their night thresholds — never an assertion off thin data.
|
||||
const sleepRhythm: SleepRhythmDto = computeSleepRhythmFromNights(
|
||||
reconstructNights(
|
||||
sleepStageRows as SleepStageRow[],
|
||||
userTz,
|
||||
sleepPriorityJson,
|
||||
),
|
||||
sleepNeedMinutes(sleepProfile.ageYears),
|
||||
"COMPUTED",
|
||||
await loadFreeWeekdaySpans(userId),
|
||||
);
|
||||
|
||||
const metrics: MetricCard[] = [];
|
||||
|
||||
// v1.4.33 — every emitted card now carries
|
||||
// `allTimeCount` + `lastSeenAt` so the iOS tile renderer can keep
|
||||
// a metric visible during a logging gap (gate on `allTimeCount > 0`)
|
||||
// and paint a muted "Letzter Wert vor Xd" caption when the most
|
||||
// recent reading is older than the 7-day sparkline window.
|
||||
|
||||
// Weight
|
||||
{
|
||||
const latest = latestOf("WEIGHT");
|
||||
const spark = sparkOf("WEIGHT");
|
||||
const meta = metaForType("WEIGHT");
|
||||
metrics.push({
|
||||
id: "weight",
|
||||
kind: "weight",
|
||||
titleKey: METRIC_TITLE_KEYS.weight,
|
||||
latestValue: latest?.value ?? null,
|
||||
secondaryValue: null,
|
||||
unitKey: METRIC_UNIT_KEYS.weight,
|
||||
unit: null,
|
||||
sleepStages: null,
|
||||
sleepSourceDiscrepancy: null,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt,
|
||||
allTimeCount: meta.allTimeCount,
|
||||
lastSeenAt: meta.lastSeenAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Blood pressure (paired sys/dia) — REG-11 (v1.4.44): gate the emit
|
||||
// on `latestSys || latestDia || bpAllTimeCount > 0` so an account
|
||||
// that has never logged BP doesn't get an empty placeholder tile.
|
||||
// Mirrors the body-fat gate below.
|
||||
{
|
||||
const latestSys = latestOf("BLOOD_PRESSURE_SYS");
|
||||
const latestDia = latestOf("BLOOD_PRESSURE_DIA");
|
||||
const sysSpark = sparkOf("BLOOD_PRESSURE_SYS");
|
||||
const sysMeta = metaForType("BLOOD_PRESSURE_SYS");
|
||||
const diaMeta = metaForType("BLOOD_PRESSURE_DIA");
|
||||
// BP is a paired metric — the tile is "alive" whenever either side
|
||||
// of the pair has history. Sum the count to reflect total readings
|
||||
// and pick the most recent `_max` so the staleness hint follows
|
||||
// whichever side is freshest.
|
||||
const bpAllTimeCount = sysMeta.allTimeCount + diaMeta.allTimeCount;
|
||||
const bpLastSeenAt = ((): string | null => {
|
||||
const sysAt = sysMeta.lastSeenAt;
|
||||
const diaAt = diaMeta.lastSeenAt;
|
||||
if (!sysAt) return diaAt;
|
||||
if (!diaAt) return sysAt;
|
||||
return sysAt >= diaAt ? sysAt : diaAt;
|
||||
})();
|
||||
if (latestSys || latestDia || bpAllTimeCount > 0) {
|
||||
metrics.push({
|
||||
id: "bp",
|
||||
kind: "bloodPressure",
|
||||
titleKey: METRIC_TITLE_KEYS.bloodPressure,
|
||||
latestValue: latestSys?.value ?? null,
|
||||
secondaryValue: latestDia?.value ?? null,
|
||||
unitKey: METRIC_UNIT_KEYS.bloodPressure,
|
||||
unit: null,
|
||||
sleepStages: null,
|
||||
sleepSourceDiscrepancy: null,
|
||||
trend: trendOf(sysSpark),
|
||||
sparkline: sysSpark,
|
||||
updatedAt:
|
||||
latestSys?.at?.toISOString() ??
|
||||
latestDia?.at?.toISOString() ??
|
||||
bpLastSeenAt,
|
||||
allTimeCount: bpAllTimeCount,
|
||||
lastSeenAt: bpLastSeenAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Pulse — REG-11 (v1.4.44): gate the emit on `latest || allTimeCount
|
||||
// > 0` so an account that has never logged pulse doesn't get an empty
|
||||
// placeholder tile. Mirrors the body-fat gate below.
|
||||
{
|
||||
const latest = latestOf("PULSE");
|
||||
const spark = sparkOf("PULSE");
|
||||
const meta = metaForType("PULSE");
|
||||
if (latest || meta.allTimeCount > 0) {
|
||||
metrics.push({
|
||||
id: "pulse",
|
||||
kind: "pulse",
|
||||
titleKey: METRIC_TITLE_KEYS.pulse,
|
||||
latestValue: latest?.value ?? null,
|
||||
secondaryValue: null,
|
||||
unitKey: METRIC_UNIT_KEYS.pulse,
|
||||
unit: null,
|
||||
sleepStages: null,
|
||||
sleepSourceDiscrepancy: null,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt,
|
||||
allTimeCount: meta.allTimeCount,
|
||||
lastSeenAt: meta.lastSeenAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Body fat — v1.4.33 widens the emit gate from `latestOf` (only
|
||||
// fires when the 7-day window has a reading) to `allTimeCount > 0`
|
||||
// so a tile doesn't disappear during a fortnight without a fresh
|
||||
// reading.
|
||||
{
|
||||
const latest = latestOf("BODY_FAT");
|
||||
const spark = sparkOf("BODY_FAT");
|
||||
const meta = metaForType("BODY_FAT");
|
||||
if (latest || meta.allTimeCount > 0) {
|
||||
metrics.push({
|
||||
id: "bodyFat",
|
||||
kind: "bodyFat",
|
||||
titleKey: METRIC_TITLE_KEYS.bodyFat,
|
||||
latestValue: latest?.value ?? null,
|
||||
secondaryValue: null,
|
||||
unitKey: METRIC_UNIT_KEYS.bodyFat,
|
||||
unit: null,
|
||||
sleepStages: null,
|
||||
sleepSourceDiscrepancy: null,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest?.at.toISOString() ?? meta.lastSeenAt,
|
||||
allTimeCount: meta.allTimeCount,
|
||||
lastSeenAt: meta.lastSeenAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Optional cards — emitted whenever the user has *ever* logged the
|
||||
// metric. Up to v1.4.32 the gate was `latestOf(type)` (only fires
|
||||
// when the trailing 7-day window has a reading); v1.4.33 widens to
|
||||
// the all-time count so a glucose tile that hasn't been touched in
|
||||
// 10 days still renders with the historical value visible to the
|
||||
// iOS client (paired with the lastSeenAt caption).
|
||||
for (const [type, kind] of [
|
||||
["BLOOD_GLUCOSE", "glucose"],
|
||||
["SLEEP_DURATION", "sleep"],
|
||||
["ACTIVITY_STEPS", "steps"],
|
||||
["TOTAL_BODY_WATER", "totalBodyWater"],
|
||||
["BONE_MASS", "boneMass"],
|
||||
["OXYGEN_SATURATION", "oxygenSaturation"],
|
||||
] as const) {
|
||||
const latest = latestOf(type);
|
||||
const meta = metaForType(type);
|
||||
if (!latest && meta.allTimeCount === 0) continue;
|
||||
|
||||
// v1.11.4 — sleep is night-aggregated, not single-stage. Emit the
|
||||
// latest night's TIME ASLEEP in HOURS (float) with an explicit
|
||||
// `unit: "h"`, the per-stage breakdown, and a sparkline of the
|
||||
// trailing nights' asleep hours. Every other kind keeps the
|
||||
// single-row latest value + canonical-unit i18n key.
|
||||
if (kind === "sleep") {
|
||||
const night = sleepSummary.latestNight;
|
||||
const toHours = (minutes: number): number =>
|
||||
Math.round((minutes / 60) * 100) / 100;
|
||||
// Sparkline = trailing nights' asleep hours from the night
|
||||
// reconstruction (the DAY-bucket rollup sparkline blends stages
|
||||
// and would be misleading for sleep).
|
||||
const nightSpark = buildSleepSparkline(
|
||||
sleepStageRows as SleepStageRow[],
|
||||
userTz,
|
||||
sleepPriorityJson,
|
||||
);
|
||||
const stageHours: Partial<Record<SleepStage, number>> | null = night
|
||||
? Object.fromEntries(
|
||||
Object.entries(night.stages).map(([s, m]) => [s, toHours(m)]),
|
||||
)
|
||||
: null;
|
||||
metrics.push({
|
||||
id: kind,
|
||||
kind,
|
||||
titleKey: METRIC_TITLE_KEYS[kind],
|
||||
latestValue: night ? toHours(night.asleepMinutes) : null,
|
||||
secondaryValue: null,
|
||||
unitKey: METRIC_UNIT_KEYS[kind],
|
||||
unit: "h",
|
||||
sleepStages:
|
||||
stageHours && Object.keys(stageHours).length > 0 ? stageHours : null,
|
||||
// Additive, observational: the headline night's MAIN session saw
|
||||
// two writer buckets with clearly different asleep totals. Round
|
||||
// to whole minutes, matching the `/api/sleep/night` serialisation.
|
||||
sleepSourceDiscrepancy: night?.sourceDiscrepancy
|
||||
? {
|
||||
deltaMinutes: Math.round(night.sourceDiscrepancy.deltaMinutes),
|
||||
sources: night.sourceDiscrepancy.sources.map((b) => ({
|
||||
source: b.source,
|
||||
deviceType: b.deviceType,
|
||||
asleepMinutes: Math.round(b.asleepMinutes),
|
||||
})),
|
||||
}
|
||||
: null,
|
||||
trend: trendOf(nightSpark),
|
||||
sparkline: nightSpark,
|
||||
updatedAt: night?.measuredAt.toISOString() ?? meta.lastSeenAt,
|
||||
allTimeCount: meta.allTimeCount,
|
||||
lastSeenAt: night?.measuredAt.toISOString() ?? meta.lastSeenAt,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const spark = sparkOf(type);
|
||||
metrics.push({
|
||||
id: kind,
|
||||
kind,
|
||||
titleKey: METRIC_TITLE_KEYS[kind],
|
||||
latestValue: latest?.value ?? null,
|
||||
secondaryValue: null,
|
||||
unitKey: METRIC_UNIT_KEYS[kind],
|
||||
unit: null,
|
||||
sleepStages: null,
|
||||
sleepSourceDiscrepancy: null,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest?.at.toISOString() ?? meta.lastSeenAt,
|
||||
allTimeCount: meta.allTimeCount,
|
||||
lastSeenAt: meta.lastSeenAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Mood — the one metric on this payload that is not a `Measurement`. The
|
||||
// daily series is the canonical `buildMoodDailySeries` output (rollup
|
||||
// fast-path, 1..5 scale, one entry per day with an average score), the
|
||||
// same series the web mood analytics and the dashboard snapshot render.
|
||||
// Emitted only when the account has ever logged a mood, matching the
|
||||
// `allTimeCount === 0` gate every optional card above uses.
|
||||
if (moodSeries.entryCount > 0) {
|
||||
const spark = moodSeries.entries.slice(-SPARK_DAYS).map((e) => e.score);
|
||||
const latestEntry = moodSeries.entries[moodSeries.entries.length - 1];
|
||||
// The series carries the user's LOCAL day keys (`MoodEntry.date`), not
|
||||
// timestamps, so the only honest instant is the first instant of that
|
||||
// day in the user's zone. Writing the key as `T00:00Z` instead put the
|
||||
// instant on the previous calendar day for anyone west of UTC, and a
|
||||
// client formatting it in its own zone showed today's entry as
|
||||
// yesterday's.
|
||||
const latestAt = latestEntry
|
||||
? startOfLocalDayKey(latestEntry.date, userTz).toISOString()
|
||||
: null;
|
||||
metrics.push({
|
||||
id: "mood",
|
||||
kind: "mood",
|
||||
titleKey: METRIC_TITLE_KEYS.mood,
|
||||
latestValue: latestEntry?.score ?? null,
|
||||
secondaryValue: null,
|
||||
unitKey: METRIC_UNIT_KEYS.mood,
|
||||
unit: null,
|
||||
sleepStages: null,
|
||||
sleepSourceDiscrepancy: null,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latestAt,
|
||||
allTimeCount: moodSeries.entryCount,
|
||||
lastSeenAt: latestAt,
|
||||
});
|
||||
}
|
||||
|
||||
// BMI — derived from the latest weight at the profile height by
|
||||
// `computeBmi`, the single implementation the web vitals dashboard also
|
||||
// renders. The card is omitted when the derivation is insufficient
|
||||
// (no height on the profile, or no weight inside its window): the card
|
||||
// shape has no "unknown" state to express honestly, and a card with a
|
||||
// null value would read as a logging gap rather than a missing profile
|
||||
// field. Clients that want to prompt for a height should do so from the
|
||||
// profile surface, not from an empty dashboard tile.
|
||||
if (isDerivedOk(bmiDerived)) {
|
||||
const spark = bmiDerived.value.series.slice(-SPARK_DAYS);
|
||||
metrics.push({
|
||||
id: "bmi",
|
||||
kind: "bmi",
|
||||
titleKey: METRIC_TITLE_KEYS.bmi,
|
||||
latestValue: bmiDerived.value.bmi,
|
||||
secondaryValue: null,
|
||||
unitKey: METRIC_UNIT_KEYS.bmi,
|
||||
unit: null,
|
||||
sleepStages: null,
|
||||
sleepSourceDiscrepancy: null,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
// BMI moves exactly when weight does, so the weight metadata is the
|
||||
// honest recency signal — there is no separate BMI reading.
|
||||
updatedAt: metaForType("WEIGHT").lastSeenAt,
|
||||
allTimeCount: metaForType("WEIGHT").allTimeCount,
|
||||
lastSeenAt: metaForType("WEIGHT").lastSeenAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Shared builder carries the exact tally semantics this tile always
|
||||
// had (scheduled = every today-window row; taken = `takenAt` set and
|
||||
// not skipped). The wire shape of `compliance` is locked for iOS.
|
||||
const scheduledToday = medsToday.scheduledToday;
|
||||
const takenToday = medsToday.takenToday;
|
||||
|
||||
const t = getServerTranslator(ctx.locale).t;
|
||||
|
||||
// v1.43 — the record's profile and own targets, for the card verdicts.
|
||||
const verdictProfile = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
heightCm: true,
|
||||
thresholdsJson: true,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({ meta: timings });
|
||||
|
||||
return {
|
||||
greeting: {
|
||||
salutation: t("dashboard.greetingSalutation", { name: ctx.greetingName }),
|
||||
date: now.toISOString(),
|
||||
},
|
||||
streak: {
|
||||
currentDays: streak.currentDays,
|
||||
longest: streak.longest,
|
||||
label: t("dashboard.streakLabel"),
|
||||
},
|
||||
compliance: {
|
||||
scheduledToday,
|
||||
takenToday,
|
||||
},
|
||||
highlightInsight: null,
|
||||
metrics: withVerdicts(metrics, {
|
||||
profile: {
|
||||
dateOfBirth: verdictProfile?.dateOfBirth ?? null,
|
||||
gender: verdictProfile?.gender ?? null,
|
||||
heightCm: verdictProfile?.heightCm ?? null,
|
||||
},
|
||||
thresholdsJson: verdictProfile?.thresholdsJson ?? null,
|
||||
}),
|
||||
// v1.17.0 — sleep-debt + chronotype, server-authoritative. Same values
|
||||
// the Sleep page's `/api/sleep/rhythm` read returns.
|
||||
sleepRhythm,
|
||||
lastUpdated: now.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -30,11 +30,13 @@
|
||||
*
|
||||
* The document is UNTRUSTED (prompt-injection): the server never acts on an
|
||||
* instruction inside it. Storing it does nothing with its contents.
|
||||
*
|
||||
* The request reading, the stored-row answers and the list read live in
|
||||
* `src/lib/documents/` (`upload-request.ts`, `upload-answer.ts`,
|
||||
* `vault-list.ts`); the store itself is the shared `ingest.ts`.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
apiHandler,
|
||||
isScopedCredential,
|
||||
@@ -50,48 +52,44 @@ import {
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { getAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import { listPage } from "@/lib/http/list-query";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { hashQueryTokens } from "@/lib/documents/content-index";
|
||||
import {
|
||||
ingestDocument,
|
||||
isoDateToUtc,
|
||||
personalUploadBucket,
|
||||
UPLOAD_LIMIT_PER_HOUR,
|
||||
UPLOAD_WINDOW_MS,
|
||||
} from "@/lib/documents/ingest";
|
||||
import {
|
||||
loadConditionLinks,
|
||||
loadDocumentEncounterLinks,
|
||||
} from "@/lib/documents/links";
|
||||
import {
|
||||
serialiseDocument,
|
||||
type SerialisableDocument,
|
||||
} from "@/lib/documents/store";
|
||||
import { DOCUMENTS_WRITE_SCOPE } from "@/lib/documents/scopes";
|
||||
import {
|
||||
checkSourceLookupRateLimit,
|
||||
findSourceKey,
|
||||
type SourceKeyMatch,
|
||||
} from "@/lib/documents/source-key";
|
||||
import {
|
||||
acquireDocumentUploadSlot,
|
||||
resolveDocumentLimits,
|
||||
resolveDocumentUploadLimitPerHour,
|
||||
} from "@/lib/documents/upload-policy";
|
||||
import { storedDocumentBody } from "@/lib/documents/upload-answer";
|
||||
import {
|
||||
aliasLimitResponse,
|
||||
answerSourceKey,
|
||||
duplicateResponse,
|
||||
fileTooLarge,
|
||||
receiptResponse,
|
||||
type UploadContext,
|
||||
} from "@/lib/documents/upload-responses";
|
||||
import {
|
||||
parseQuerySourceKey,
|
||||
parseUploadMetadata,
|
||||
readUploadForm,
|
||||
resolveUploadSourceKey,
|
||||
type QuerySourceKey,
|
||||
} from "@/lib/documents/upload-request";
|
||||
import { listVaultDocuments } from "@/lib/documents/vault-list";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { BodyTooLargeError, readBoundedBody } from "@/lib/labs/ocr-upload";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import {
|
||||
documentCreateSchema,
|
||||
documentListQuerySchema,
|
||||
documentSourceKeySchema,
|
||||
toContentIndexSource,
|
||||
type DocumentSourceSystemValue,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import type { IngestResult } from "@/lib/documents/ingest";
|
||||
import { documentListQuerySchema } from "@/lib/validations/inbound-documents";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -101,201 +99,31 @@ export const dynamic = "force-dynamic";
|
||||
* exact per-file cap is re-enforced on the extracted file bytes below.
|
||||
*/
|
||||
const MULTIPART_OVERHEAD_BYTES = 64 * 1024;
|
||||
const UPLOAD_BODY_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** §3.2 — 413 fileTooLarge with the configured limit in `meta`. */
|
||||
function fileTooLarge(maxFileBytes: number): NextResponse {
|
||||
return apiError("File is too large.", 413, {
|
||||
errorCode: "documents.inbound.fileTooLarge",
|
||||
reason: "fileTooLarge",
|
||||
maxFileBytes,
|
||||
});
|
||||
}
|
||||
|
||||
/** What a single upload asked for, carried into the annotations. */
|
||||
interface UploadContext {
|
||||
userId: string;
|
||||
/** A narrow `documents:write` token, not a session or a `["*"]` token. */
|
||||
scoped: boolean;
|
||||
sourceSystem: DocumentSourceSystemValue | null;
|
||||
aiDeferred: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The receipt a scoped caller gets in place of the stored row: the id and
|
||||
* whether the upload was a duplicate. A write-only credential holder who
|
||||
* knows a file's bytes learns that it is stored, not its title, filename,
|
||||
* links or fact counts.
|
||||
* A source key may ride the query string. It is answered here, before the
|
||||
* bucket is charged and before a byte of the body is read: a nightly re-sync
|
||||
* of an archive HealthLog already holds costs neither. Answers the response
|
||||
* when the key settles the upload, else the key (or null) to carry on with.
|
||||
*/
|
||||
function receiptResponse(
|
||||
id: string | null,
|
||||
status: 200 | 201,
|
||||
flags: { duplicate: boolean; deleted?: boolean },
|
||||
): NextResponse {
|
||||
const data = {
|
||||
id,
|
||||
duplicate: flags.duplicate,
|
||||
...(flags.deleted ? { deleted: true } : {}),
|
||||
};
|
||||
return NextResponse.json(
|
||||
{
|
||||
data,
|
||||
error: null,
|
||||
...(flags.duplicate
|
||||
? {
|
||||
meta: {
|
||||
duplicate: true,
|
||||
...(flags.deleted ? { deleted: true } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.39.2 (#1038) — a re-send of an imported document the person deleted.
|
||||
* 200 and no new row, for every caller: the deletion was a decision, and an
|
||||
* importer re-running over the same source must not undo it. `id` is the
|
||||
* tombstoned row while it exists and null once the purge took it; there is no
|
||||
* row left to serialise either way, so the answer is the receipt.
|
||||
*/
|
||||
function deletedResponse(ctx: UploadContext, id: string | null): NextResponse {
|
||||
annotate({
|
||||
action: { name: "documents.vault.upload" },
|
||||
meta: {
|
||||
documentId: id,
|
||||
duplicate: true,
|
||||
deleted: true,
|
||||
scoped: ctx.scoped,
|
||||
sourceSystem: ctx.sourceSystem,
|
||||
aiDeferred: ctx.aiDeferred,
|
||||
},
|
||||
});
|
||||
return receiptResponse(id, 200, { duplicate: true, deleted: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* The same bytes have already been sent under too many source keys
|
||||
* (`MAX_SOURCE_ALIASES_PER_DOCUMENT`). A conflict with what is stored, not a
|
||||
* rate: nothing is remembered and nothing stored.
|
||||
*/
|
||||
function aliasLimitResponse(): NextResponse {
|
||||
return apiError(
|
||||
"This file is already stored under too many source ids.",
|
||||
409,
|
||||
{ errorCode: "documents.inbound.sourceAliasLimit" },
|
||||
);
|
||||
}
|
||||
|
||||
/** Answer a source key that is already held: duplicate, or deleted. */
|
||||
function answerSourceKey(
|
||||
ctx: UploadContext,
|
||||
match: SourceKeyMatch,
|
||||
): Promise<NextResponse> | NextResponse {
|
||||
return match.state === "live"
|
||||
? duplicateResponse(ctx, match.document)
|
||||
: deletedResponse(ctx, match.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* §3.2 — a duplicate upload is NOT an error: return the existing live row
|
||||
* with `meta.duplicate: true` at the envelope level (the UI toasts "already
|
||||
* stored" and highlights the row). A scoped caller gets the receipt instead.
|
||||
*/
|
||||
async function duplicateResponse(
|
||||
ctx: UploadContext,
|
||||
existing: SerialisableDocument,
|
||||
): Promise<NextResponse> {
|
||||
annotate({
|
||||
action: { name: "documents.vault.upload" },
|
||||
meta: {
|
||||
documentId: existing.id,
|
||||
duplicate: true,
|
||||
scoped: ctx.scoped,
|
||||
sourceSystem: ctx.sourceSystem,
|
||||
aiDeferred: ctx.aiDeferred,
|
||||
},
|
||||
});
|
||||
if (ctx.scoped) {
|
||||
return receiptResponse(existing.id, 200, { duplicate: true });
|
||||
}
|
||||
const userId = ctx.userId;
|
||||
const [links, visitLinks, groups] = await Promise.all([
|
||||
loadConditionLinks(userId, [existing.id]),
|
||||
loadDocumentEncounterLinks(userId, [existing.id]),
|
||||
prisma.extractedFact.groupBy({
|
||||
by: ["status"],
|
||||
where: { userId, documentId: existing.id },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
let factCount = 0;
|
||||
let pendingCount = 0;
|
||||
for (const g of groups) {
|
||||
if (g.status !== "REJECTED") factCount += g._count._all;
|
||||
if (g.status === "PENDING") pendingCount += g._count._all;
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: serialiseDocument(
|
||||
existing,
|
||||
{ factCount, pendingCount },
|
||||
links.get(existing.id) ?? [],
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
visitLinks.get(existing.id) ?? [],
|
||||
),
|
||||
error: null,
|
||||
meta: { duplicate: true },
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
/** Process one admitted upload. The caller owns and releases its memory slot. */
|
||||
async function processUpload(
|
||||
async function answerQuerySourceKey(
|
||||
request: Request,
|
||||
auth: AuthContext,
|
||||
): Promise<Response> {
|
||||
scoped: boolean,
|
||||
): Promise<Response | QuerySourceKey | null> {
|
||||
const { user } = auth;
|
||||
const scoped = isScopedCredential(auth);
|
||||
|
||||
// Opt-in module gate — even a valid Bearer token is refused when the surface
|
||||
// is off (it ships dark; the user turns it on deliberately).
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
// A source key may ride the query string. It is answered here, before the
|
||||
// bucket is charged and before a byte of the body is read: a nightly
|
||||
// re-sync of an archive HealthLog already holds costs neither. Both halves
|
||||
// or neither; half a key is a mistake worth saying so about.
|
||||
const url = new URL(request.url);
|
||||
const querySystem = url.searchParams.get("sourceSystem");
|
||||
const queryId = url.searchParams.get("sourceId");
|
||||
const queryInstance = url.searchParams.get("sourceInstance");
|
||||
let queryKey: {
|
||||
sourceSystem: DocumentSourceSystemValue;
|
||||
sourceId: string;
|
||||
sourceInstance?: string;
|
||||
} | null = null;
|
||||
if (querySystem !== null || queryId !== null) {
|
||||
const parsedKey = documentSourceKeySchema.safeParse({
|
||||
sourceSystem: querySystem ?? undefined,
|
||||
sourceId: queryId ?? undefined,
|
||||
sourceInstance: queryInstance ?? undefined,
|
||||
});
|
||||
if (!parsedKey.success) {
|
||||
return apiValidationError(
|
||||
"Invalid source key",
|
||||
sanitiseZodIssues(parsedKey.error.issues),
|
||||
422,
|
||||
{ errorCode: "documents.inbound.invalidMetadata" },
|
||||
);
|
||||
}
|
||||
queryKey = parsedKey.data;
|
||||
const query = parseQuerySourceKey(new URL(request.url));
|
||||
if (query.kind === "invalid") {
|
||||
return apiValidationError(
|
||||
"Invalid source key",
|
||||
sanitiseZodIssues(query.issues),
|
||||
422,
|
||||
{ errorCode: "documents.inbound.invalidMetadata" },
|
||||
);
|
||||
}
|
||||
const queryKey: QuerySourceKey | null =
|
||||
query.kind === "key" ? query.key : null;
|
||||
if (queryKey) {
|
||||
// Metered like the lookup route, on the same bucket: the check stores
|
||||
// nothing and costs no upload slot, but it is not a free oracle either.
|
||||
const lookupRl = await checkSourceLookupRateLimit(
|
||||
@@ -331,6 +159,71 @@ async function processUpload(
|
||||
}
|
||||
}
|
||||
|
||||
return queryKey;
|
||||
}
|
||||
|
||||
/** The answer to an upload the ingest did not store. */
|
||||
function answerNotStored(
|
||||
ctx: UploadContext,
|
||||
result: Exclude<IngestResult, { kind: "stored" }>,
|
||||
): Response | Promise<Response> {
|
||||
const scoped = ctx.scoped;
|
||||
switch (result.kind) {
|
||||
case "sourceKey":
|
||||
return answerSourceKey(ctx, result.match);
|
||||
case "tooLarge":
|
||||
return fileTooLarge(result.maxFileBytes);
|
||||
case "empty":
|
||||
return apiError("Uploaded file is empty", 422, {
|
||||
errorCode: "documents.inbound.invalidMetadata",
|
||||
});
|
||||
case "unsupportedType":
|
||||
return apiError("This file type is not supported.", 415, {
|
||||
errorCode: "documents.inbound.fileType",
|
||||
reason: "unsupportedType",
|
||||
});
|
||||
case "episodeNotFound":
|
||||
return apiError("Episode not found", 404, {
|
||||
errorCode: "documents.inbound.episodeNotFound",
|
||||
});
|
||||
case "encounterNotFound":
|
||||
return apiError("Visit not found", 404, {
|
||||
errorCode: "documents.inbound.encounterNotFound",
|
||||
});
|
||||
case "aliasLimit":
|
||||
return aliasLimitResponse();
|
||||
case "duplicate":
|
||||
return duplicateResponse(ctx, result.document);
|
||||
case "quotaExceeded":
|
||||
// The figures are the owner's; a write-only token learns only that the
|
||||
// vault is full.
|
||||
return apiError("Storage quota exceeded.", 413, {
|
||||
errorCode: "documents.inbound.quotaExceeded",
|
||||
reason: "quotaExceeded",
|
||||
...(scoped
|
||||
? {}
|
||||
: { quotaBytes: result.quotaBytes, usedBytes: result.usedBytes }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Process one admitted upload. The caller owns and releases its memory slot. */
|
||||
async function processUpload(
|
||||
request: Request,
|
||||
auth: AuthContext,
|
||||
): Promise<Response> {
|
||||
const { user } = auth;
|
||||
const scoped = isScopedCredential(auth);
|
||||
|
||||
// Opt-in module gate — even a valid Bearer token is refused when the surface
|
||||
// is off (it ships dark; the user turns it on deliberately).
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const query = await answerQuerySourceKey(request, auth, scoped);
|
||||
if (query instanceof Response) return query;
|
||||
const queryKey = query;
|
||||
|
||||
// A scoped token draws on its own bucket, keyed on the token (its id rides
|
||||
// `session.id` on the Bearer path), so an import running overnight cannot
|
||||
// use up the allowance the person's own uploads from the web or the phone
|
||||
@@ -367,55 +260,23 @@ async function processUpload(
|
||||
return fileTooLarge(limits.maxFileBytes);
|
||||
}
|
||||
|
||||
let formData: FormData;
|
||||
try {
|
||||
const bytes = await readBoundedBody(request.body, bodyCap, {
|
||||
signal: request.signal,
|
||||
deadline: Date.now() + UPLOAD_BODY_TIMEOUT_MS,
|
||||
const read = await readUploadForm(request, bodyCap);
|
||||
if (read.kind === "tooLarge") return fileTooLarge(limits.maxFileBytes);
|
||||
if (read.kind === "timeout") {
|
||||
return apiError("Upload body read timed out.", 408, {
|
||||
errorCode: "documents.inbound.uploadTimeout",
|
||||
reason: "uploadTimeout",
|
||||
});
|
||||
formData = await new Request(request.url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": request.headers.get("content-type") ?? "" },
|
||||
body: bytes,
|
||||
}).formData();
|
||||
} catch (err) {
|
||||
if (err instanceof BodyTooLargeError) {
|
||||
return fileTooLarge(limits.maxFileBytes);
|
||||
}
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
return apiError("Upload body read timed out.", 408, {
|
||||
errorCode: "documents.inbound.uploadTimeout",
|
||||
reason: "uploadTimeout",
|
||||
});
|
||||
}
|
||||
return apiError("Invalid multipart body", 400);
|
||||
}
|
||||
if (read.kind === "invalid") return apiError("Invalid multipart body", 400);
|
||||
const formData = read.formData;
|
||||
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return apiError("Field 'file' must be a file", 422);
|
||||
}
|
||||
|
||||
// Optional metadata (title / kind / documentDate / episodeIds /
|
||||
// encounterIds). The file is read separately; these are the form fields
|
||||
// beside it. Both id lists may be repeated.
|
||||
const rawEpisodeIds = formData
|
||||
.getAll("episodeIds")
|
||||
.filter((v): v is string => typeof v === "string");
|
||||
const rawEncounterIds = formData
|
||||
.getAll("encounterIds")
|
||||
.filter((v): v is string => typeof v === "string");
|
||||
const parsed = documentCreateSchema.safeParse({
|
||||
title: formData.get("title") ?? undefined,
|
||||
kind: formData.get("kind") ?? undefined,
|
||||
documentDate: formData.get("documentDate") ?? undefined,
|
||||
episodeIds: rawEpisodeIds.length > 0 ? rawEpisodeIds : undefined,
|
||||
encounterIds: rawEncounterIds.length > 0 ? rawEncounterIds : undefined,
|
||||
sourceSystem: formData.get("sourceSystem") ?? undefined,
|
||||
sourceId: formData.get("sourceId") ?? undefined,
|
||||
sourceInstance: formData.get("sourceInstance") ?? undefined,
|
||||
aiRead: formData.get("aiRead") ?? undefined,
|
||||
});
|
||||
const parsed = parseUploadMetadata(formData);
|
||||
if (!parsed.success) {
|
||||
return apiValidationError(
|
||||
"Invalid document metadata",
|
||||
@@ -427,30 +288,15 @@ async function processUpload(
|
||||
);
|
||||
}
|
||||
|
||||
// The key from the query string, or from the form fields; if a caller sends
|
||||
// both they have to agree.
|
||||
const formSystem = parsed.data.sourceSystem ?? null;
|
||||
const formId = formSystem ? (parsed.data.sourceId ?? null) : null;
|
||||
const formInstance = formId ? (parsed.data.sourceInstance ?? null) : null;
|
||||
if (
|
||||
queryKey &&
|
||||
formSystem !== null &&
|
||||
(formSystem !== queryKey.sourceSystem ||
|
||||
formId !== queryKey.sourceId ||
|
||||
(formInstance !== null &&
|
||||
formInstance !== (queryKey.sourceInstance ?? null)))
|
||||
) {
|
||||
const key = resolveUploadSourceKey(queryKey, parsed.data);
|
||||
if (key.kind === "mismatch") {
|
||||
return apiError(
|
||||
"The source key in the address and in the form differ.",
|
||||
422,
|
||||
{ errorCode: "documents.inbound.invalidMetadata" },
|
||||
);
|
||||
}
|
||||
const sourceSystem = queryKey?.sourceSystem ?? formSystem;
|
||||
const sourceId = queryKey?.sourceId ?? formId;
|
||||
const sourceInstance = sourceId
|
||||
? (queryKey?.sourceInstance ?? formInstance ?? null)
|
||||
: null;
|
||||
const { sourceSystem, sourceId, sourceInstance } = key;
|
||||
const ctx: UploadContext = {
|
||||
userId: user.id,
|
||||
scoped,
|
||||
@@ -505,65 +351,11 @@ async function processUpload(
|
||||
documentAi: () => getAiCapability("documentAi"),
|
||||
});
|
||||
|
||||
switch (result.kind) {
|
||||
case "sourceKey":
|
||||
return answerSourceKey(ctx, result.match);
|
||||
case "tooLarge":
|
||||
return fileTooLarge(result.maxFileBytes);
|
||||
case "empty":
|
||||
return apiError("Uploaded file is empty", 422, {
|
||||
errorCode: "documents.inbound.invalidMetadata",
|
||||
});
|
||||
case "unsupportedType":
|
||||
return apiError("This file type is not supported.", 415, {
|
||||
errorCode: "documents.inbound.fileType",
|
||||
reason: "unsupportedType",
|
||||
});
|
||||
case "episodeNotFound":
|
||||
return apiError("Episode not found", 404, {
|
||||
errorCode: "documents.inbound.episodeNotFound",
|
||||
});
|
||||
case "encounterNotFound":
|
||||
return apiError("Visit not found", 404, {
|
||||
errorCode: "documents.inbound.encounterNotFound",
|
||||
});
|
||||
case "aliasLimit":
|
||||
return aliasLimitResponse();
|
||||
case "duplicate":
|
||||
return duplicateResponse(ctx, result.document);
|
||||
case "quotaExceeded":
|
||||
// The figures are the owner's; a write-only token learns only that the
|
||||
// vault is full.
|
||||
return apiError("Storage quota exceeded.", 413, {
|
||||
errorCode: "documents.inbound.quotaExceeded",
|
||||
reason: "quotaExceeded",
|
||||
...(scoped
|
||||
? {}
|
||||
: { quotaBytes: result.quotaBytes, usedBytes: result.usedBytes }),
|
||||
});
|
||||
case "stored":
|
||||
break;
|
||||
}
|
||||
if (result.kind !== "stored") return answerNotStored(ctx, result);
|
||||
|
||||
const document = result.document;
|
||||
if (scoped) return receiptResponse(document.id, 201, { duplicate: false });
|
||||
|
||||
const [links, visitLinks] = await Promise.all([
|
||||
loadConditionLinks(user.id, [document.id]),
|
||||
loadDocumentEncounterLinks(user.id, [document.id]),
|
||||
]);
|
||||
return apiSuccess(
|
||||
serialiseDocument(
|
||||
document,
|
||||
{ factCount: 0, pendingCount: 0 },
|
||||
links.get(document.id) ?? [],
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
visitLinks.get(document.id) ?? [],
|
||||
),
|
||||
201,
|
||||
);
|
||||
return apiSuccess(await storedDocumentBody(user.id, document), 201);
|
||||
}
|
||||
|
||||
/** Authenticate and reserve memory capacity before any request-body read. */
|
||||
@@ -640,172 +432,6 @@ export const GET = apiHandler(async (request: Request) => {
|
||||
},
|
||||
);
|
||||
}
|
||||
const {
|
||||
q,
|
||||
kind,
|
||||
episodeId,
|
||||
encounterId,
|
||||
year,
|
||||
from,
|
||||
to,
|
||||
sort,
|
||||
order,
|
||||
cursor,
|
||||
limit,
|
||||
} = parsed.data;
|
||||
|
||||
const where: Prisma.InboundDocumentWhereInput = {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
};
|
||||
if (kind && kind.length > 0) where.kind = { in: kind };
|
||||
if (episodeId) where.conditionLinks = { some: { episodeId } };
|
||||
// The visit filter reads the SAME table the visit's own sheet writes, from
|
||||
// the document side. Filtering rather than post-filtering: a page of fifty
|
||||
// documents narrowed in Node would page wrongly.
|
||||
if (encounterId) where.encounterLinks = { some: { encounterId } };
|
||||
if (year !== undefined) {
|
||||
where.documentDate = {
|
||||
gte: new Date(Date.UTC(year, 0, 1)),
|
||||
lt: new Date(Date.UTC(year + 1, 0, 1)),
|
||||
};
|
||||
} else if (from || to) {
|
||||
where.documentDate = {
|
||||
...(from ? { gte: isoDateToUtc(from) } : {}),
|
||||
// inclusive end-of-day for the `to` bound
|
||||
...(to ? { lt: new Date(isoDateToUtc(to).getTime() + 86_400_000) } : {}),
|
||||
};
|
||||
}
|
||||
if (q) {
|
||||
// Substring match on the short plaintext fields …
|
||||
const or: Prisma.InboundDocumentWhereInput[] = [
|
||||
{ title: { contains: q, mode: "insensitive" } },
|
||||
{ filename: { contains: q, mode: "insensitive" } },
|
||||
];
|
||||
// … unioned with a WHOLE-WORD content match over the blind token index.
|
||||
// The query is tokenised + HMAC'd the same way the index was built, then
|
||||
// matched with a GIN-accelerated array-overlap (`hasSome` → `&&`). The
|
||||
// list still never selects the encrypted text — only the opaque hashes are
|
||||
// touched, in the related table. Degrades silently to title/filename when
|
||||
// the caller has no indexed documents (no rows overlap).
|
||||
const hashes = hashQueryTokens(q);
|
||||
if (hashes.length > 0) {
|
||||
or.push({ contentIndex: { is: { searchTokens: { hasSome: hashes } } } });
|
||||
}
|
||||
where.OR = or;
|
||||
}
|
||||
|
||||
// Keyset pagination on the sort column + a stable `id` tiebreak; nullable
|
||||
// sort columns sort nulls last so undated documents trail.
|
||||
const primaryOrder:
|
||||
Prisma.InboundDocumentOrderByWithRelationInput | undefined =
|
||||
sort === "documentDate"
|
||||
? { documentDate: { sort: order, nulls: "last" } }
|
||||
: sort === "title"
|
||||
? { title: { sort: order, nulls: "last" } }
|
||||
: { createdAt: order };
|
||||
const orderBy: Prisma.InboundDocumentOrderByWithRelationInput[] = [
|
||||
primaryOrder,
|
||||
{ id: order },
|
||||
];
|
||||
|
||||
// Hardening: the list NEVER fetches the encrypted blob column — a page of
|
||||
// 50 rows would otherwise drag up to 50 × cap ciphertext bytes per request.
|
||||
const { rows: page, cap } = await listPage(limit, (take) =>
|
||||
prisma.inboundDocument.findMany({
|
||||
where,
|
||||
omit: { contentEncrypted: true },
|
||||
orderBy,
|
||||
take,
|
||||
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
|
||||
}),
|
||||
);
|
||||
|
||||
const hasMore = cap.truncated;
|
||||
const nextCursor = hasMore ? (page[page.length - 1]?.id ?? null) : null;
|
||||
|
||||
// Fact tallies for the whole page in ONE grouped count — no per-document
|
||||
// fan-out and no materialising fact-id rows. `factCount` counts every
|
||||
// non-REJECTED fact (a rejected fact is discarded, not part of the
|
||||
// document's tally), `pendingCount` the PENDING subset awaiting review.
|
||||
const factCounts = new Map<
|
||||
string,
|
||||
{ factCount: number; pendingCount: number }
|
||||
>();
|
||||
if (page.length > 0) {
|
||||
const groups = await prisma.extractedFact.groupBy({
|
||||
by: ["documentId", "status"],
|
||||
where: { userId: user.id, documentId: { in: page.map((d) => d.id) } },
|
||||
_count: { _all: true },
|
||||
});
|
||||
for (const g of groups) {
|
||||
const entry = factCounts.get(g.documentId) ?? {
|
||||
factCount: 0,
|
||||
pendingCount: 0,
|
||||
};
|
||||
const n = g._count._all;
|
||||
if (g.status !== "REJECTED") entry.factCount += n;
|
||||
if (g.status === "PENDING") entry.pendingCount += n;
|
||||
factCounts.set(g.documentId, entry);
|
||||
}
|
||||
}
|
||||
|
||||
// Condition/visit links + the content-index and thumbnail probes for the
|
||||
// page, all in ONE Promise.all — four independent grouped queries (no
|
||||
// N+1, never a ciphertext/blob column), and the vault polls this list
|
||||
// every few seconds so two serialized round-trips were pure added
|
||||
// latency (A5-4).
|
||||
const pageIds = page.map((d) => d.id);
|
||||
const [linkMap, visitLinkMap, indexedRows, thumbRows] = await Promise.all([
|
||||
loadConditionLinks(user.id, pageIds),
|
||||
loadDocumentEncounterLinks(user.id, pageIds),
|
||||
// Which of the page's documents have a content index (drives the
|
||||
// searchable status + the provenance the UI reads to tell an AI-read
|
||||
// document from a locally-indexed one).
|
||||
page.length > 0
|
||||
? prisma.documentContentIndex.findMany({
|
||||
where: { userId: user.id, documentId: { in: pageIds } },
|
||||
select: { documentId: true, source: true },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
// Which of the page's documents have a preview thumbnail (gates the
|
||||
// card's <img>). Grouped on the 1:1 side table.
|
||||
page.length > 0
|
||||
? prisma.documentThumbnail.findMany({
|
||||
where: { userId: user.id, documentId: { in: pageIds } },
|
||||
select: { documentId: true },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const indexSources = new Map<string, string>();
|
||||
for (const row of indexedRows) indexSources.set(row.documentId, row.source);
|
||||
const thumbnailIds = new Set<string>();
|
||||
for (const row of thumbRows) thumbnailIds.add(row.documentId);
|
||||
|
||||
annotate({
|
||||
action: { name: "documents.inbound.list" },
|
||||
meta: {
|
||||
count: page.length,
|
||||
sort,
|
||||
order,
|
||||
filtered: Boolean(
|
||||
q || (kind && kind.length > 0) || episodeId || encounterId || year,
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
documents: page.map((doc) =>
|
||||
serialiseDocument(
|
||||
doc,
|
||||
factCounts.get(doc.id) ?? { factCount: 0, pendingCount: 0 },
|
||||
linkMap.get(doc.id) ?? [],
|
||||
indexSources.has(doc.id),
|
||||
toContentIndexSource(indexSources.get(doc.id)),
|
||||
thumbnailIds.has(doc.id),
|
||||
visitLinkMap.get(doc.id) ?? [],
|
||||
),
|
||||
),
|
||||
nextCursor,
|
||||
});
|
||||
return apiSuccess(await listVaultDocuments(user.id, parsed.data));
|
||||
});
|
||||
+13
-428
@@ -1,7 +1,5 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
@@ -10,83 +8,14 @@ import {
|
||||
returnAllZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { csvImportQuerySchema } from "@/lib/validations/record-import";
|
||||
import { encryptNote, readNote } from "@/lib/crypto/note-cipher";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import {
|
||||
parseCsvMeasurements,
|
||||
type CsvRowResult,
|
||||
} from "@/lib/import/csv-measurements";
|
||||
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
|
||||
import type {
|
||||
MeasurementType,
|
||||
GlucoseContext,
|
||||
} from "@/generated/prisma/client";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import type { NormalisedMeasurementRow } from "@/lib/import/csv-measurements";
|
||||
import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit";
|
||||
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
|
||||
|
||||
/** Keys per dedup probe: well under Postgres's bind-parameter cap. */
|
||||
const LOOKUP_CHUNK = 1000;
|
||||
/** Rows per create call / per update batch transaction. */
|
||||
const WRITE_CHUNK = 200;
|
||||
|
||||
function chunked<T>(items: readonly T[], size: number): T[][] {
|
||||
const out: T[][] = [];
|
||||
for (let i = 0; i < items.length; i += size) {
|
||||
out.push(items.slice(i, i + size));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Group rows' probe keys by measurement type, de-duplicated. */
|
||||
function groupByType<K>(
|
||||
rows: ReadonlyArray<{ row: NormalisedMeasurementRow }>,
|
||||
keyOf: (r: { row: NormalisedMeasurementRow }) => K,
|
||||
): Map<MeasurementType, K[]> {
|
||||
const byType = new Map<MeasurementType, Map<string, K>>();
|
||||
for (const r of rows) {
|
||||
const type = r.row.type as MeasurementType;
|
||||
const key = keyOf(r);
|
||||
const bucket = byType.get(type) ?? new Map<string, K>();
|
||||
bucket.set(key instanceof Date ? String(key.getTime()) : String(key), key);
|
||||
byType.set(type, bucket);
|
||||
}
|
||||
return new Map([...byType].map(([type, keys]) => [type, [...keys.values()]]));
|
||||
}
|
||||
import { parseCsvMeasurements } from "@/lib/import/csv-measurements";
|
||||
import { runCsvImport, type CsvOkRow } from "@/lib/import/csv-write";
|
||||
|
||||
/**
|
||||
* True when a stored IMPORT row already holds exactly what this line would
|
||||
* write: same value, unit, instant, glucose context and note, and live. Such
|
||||
* a line is a duplicate, not an update.
|
||||
*/
|
||||
function sameStoredRow(
|
||||
stored: {
|
||||
value: number;
|
||||
unit: string;
|
||||
measuredAt: Date;
|
||||
glucoseContext: GlucoseContext | null;
|
||||
notes: string | null;
|
||||
notesEncrypted: Uint8Array | null;
|
||||
deletedAt: Date | null;
|
||||
},
|
||||
m: NormalisedMeasurementRow,
|
||||
): boolean {
|
||||
return (
|
||||
stored.deletedAt === null &&
|
||||
stored.value === m.value &&
|
||||
stored.unit === m.unit &&
|
||||
stored.measuredAt.getTime() === m.measuredAt.getTime() &&
|
||||
stored.glucoseContext ===
|
||||
((m.glucoseContext as GlucoseContext | undefined) ?? null) &&
|
||||
readNote(stored.notesEncrypted, stored.notes) === (m.notes || null)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV measurement import (v1.17.1).
|
||||
* CSV measurement import.
|
||||
*
|
||||
* The cold-start escape hatch for self-hosters migrating from a spreadsheet
|
||||
* or another tracker. Reuses the `/api/import` write loop + rollup re-fold;
|
||||
@@ -101,7 +30,8 @@ function sameStoredRow(
|
||||
* WITHOUT writing — a trust win for a bulk cold-start import.
|
||||
*
|
||||
* Response: `{ inserted, updated, skipped, total, dryRun, rows: [{line,
|
||||
* status, reason}] }`, mirroring the batch route's per-entry envelope.
|
||||
* status, reason}] }`, mirroring the batch route's per-entry envelope. The
|
||||
* batched writer lives in `src/lib/import/csv-write.ts`.
|
||||
*/
|
||||
|
||||
const MAX_CSV_BYTES = 16 * 1024 * 1024;
|
||||
@@ -150,8 +80,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
const okRows = parsed.rows.filter(
|
||||
(r): r is CsvRowResult & { row: NonNullable<CsvRowResult["row"]> } =>
|
||||
r.status === "ok" && r.row !== undefined,
|
||||
(r): r is CsvOkRow => r.status === "ok" && r.row !== undefined,
|
||||
);
|
||||
if (okRows.length > MAX_ROWS) {
|
||||
return apiError(
|
||||
@@ -160,357 +89,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
const userId = user.id;
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
let skipped = parsed.rows.filter((r) => r.status === "skipped").length;
|
||||
|
||||
const touchedMeasurements: Array<{
|
||||
type: MeasurementType;
|
||||
measuredAt: Date;
|
||||
}> = [];
|
||||
|
||||
// Per-line write outcome for rows that reached the DB. Keyed by source line
|
||||
// so the response envelope can surface `inserted` / `updated` / `duplicate`
|
||||
// distinctly without polluting the parse-time `CsvRowReason`.
|
||||
const writeOutcome = new Map<number, "inserted" | "updated" | "duplicate">();
|
||||
|
||||
if (!dryRun) {
|
||||
// Batched write. The previous shape ran one serial `upsert` (or `create`)
|
||||
// per accepted row — N round-trips for an N-row file, which on a
|
||||
// few-thousand-row import approaches the request timeout. This adopts the
|
||||
// fast shape `/measurements/batch` uses: pre-fetch the existing rows under
|
||||
// the IMPORT source in one query per dedup-key shape, partition into bulk
|
||||
// insert / update sets, then `createMany` + grouped `updateMany`. The
|
||||
// per-line inserted/updated/duplicate envelope is reconstructed from the
|
||||
// partition, so the response contract is unchanged.
|
||||
|
||||
const buildCreateData = (
|
||||
m: NormalisedMeasurementRow,
|
||||
): Prisma.MeasurementCreateManyInput => ({
|
||||
userId,
|
||||
type: m.type as MeasurementType,
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
source: "IMPORT",
|
||||
...(m.externalId ? { externalId: m.externalId } : {}),
|
||||
measuredAt: m.measuredAt,
|
||||
notes: null,
|
||||
notesEncrypted: encryptNote(m.notes ?? null),
|
||||
glucoseContext: (m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
});
|
||||
|
||||
const extRows = okRows.filter((r) => r.row.externalId);
|
||||
const plainRows = okRows.filter((r) => !r.row.externalId);
|
||||
|
||||
// --- externalId rows: idempotent upsert on
|
||||
// (userId, type, source=IMPORT, externalId). One probe per type and chunk
|
||||
// fetches every pre-existing key with the columns an update would write,
|
||||
// so a row whose stored values already match is left alone.
|
||||
const extExisting = new Map<
|
||||
string,
|
||||
{
|
||||
value: number;
|
||||
unit: string;
|
||||
measuredAt: Date;
|
||||
glucoseContext: GlucoseContext | null;
|
||||
notes: string | null;
|
||||
notesEncrypted: Uint8Array | null;
|
||||
deletedAt: Date | null;
|
||||
}
|
||||
>();
|
||||
for (const [type, ids] of groupByType(
|
||||
extRows,
|
||||
(r) => r.row.externalId as string,
|
||||
)) {
|
||||
for (const chunk of chunked(ids, LOOKUP_CHUNK)) {
|
||||
const found = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
source: "IMPORT",
|
||||
type,
|
||||
externalId: { in: chunk },
|
||||
},
|
||||
select: {
|
||||
externalId: true,
|
||||
value: true,
|
||||
unit: true,
|
||||
measuredAt: true,
|
||||
glucoseContext: true,
|
||||
notes: true,
|
||||
notesEncrypted: true,
|
||||
deletedAt: true,
|
||||
},
|
||||
});
|
||||
for (const row of found) {
|
||||
extExisting.set(`${type}::${row.externalId}`, row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Partition in file order, honouring in-file repeats: the first sighting
|
||||
// of a brand-new key inserts, every later sighting (or any sighting of a
|
||||
// key already in the DB) updates in place. The last value for a key wins,
|
||||
// matching the old sequential-upsert overwrite. Insert/update maps are
|
||||
// keyed by (type, externalId) so a repeated key collapses to one write.
|
||||
// A key already in the DB whose stored row equals this line is a
|
||||
// duplicate: no write, reported like the natural-key duplicates below.
|
||||
// That is what makes re-importing the same file cheap — a second upload
|
||||
// of an unchanged 9 000-row export writes nothing.
|
||||
const seenExt = new Set<string>();
|
||||
const extInserts = new Map<string, NormalisedMeasurementRow>();
|
||||
const extUpdates = new Map<string, NormalisedMeasurementRow>();
|
||||
for (const result of extRows) {
|
||||
const m = result.row;
|
||||
const key = `${m.type}::${m.externalId}`;
|
||||
const stored = extExisting.get(key);
|
||||
if (stored && !extUpdates.has(key) && sameStoredRow(stored, m)) {
|
||||
skipped++;
|
||||
writeOutcome.set(result.line, "duplicate");
|
||||
continue;
|
||||
}
|
||||
if (stored) {
|
||||
extUpdates.set(key, m);
|
||||
updated++;
|
||||
writeOutcome.set(result.line, "updated");
|
||||
} else if (seenExt.has(key)) {
|
||||
// New-to-DB key seen again in this file: still a single create with
|
||||
// the latest value, but this line reads as an update just as the
|
||||
// sequential upsert did.
|
||||
extInserts.set(key, m);
|
||||
updated++;
|
||||
writeOutcome.set(result.line, "updated");
|
||||
} else {
|
||||
seenExt.add(key);
|
||||
extInserts.set(key, m);
|
||||
inserted++;
|
||||
writeOutcome.set(result.line, "inserted");
|
||||
}
|
||||
touchedMeasurements.push({
|
||||
type: m.type as MeasurementType,
|
||||
measuredAt: m.measuredAt,
|
||||
});
|
||||
// An update that moves the reading to another instant leaves the day
|
||||
// it came from: that day is re-folded too, or the old day keeps a
|
||||
// reading that no longer exists there.
|
||||
if (stored && stored.measuredAt.getTime() !== m.measuredAt.getTime()) {
|
||||
touchedMeasurements.push({
|
||||
type: m.type as MeasurementType,
|
||||
measuredAt: stored.measuredAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- externalId-less rows: create-only, deduped on the natural unique key
|
||||
// (userId, type, measuredAt, source=IMPORT, sleepStage=null). A collision
|
||||
// — against the DB or an earlier row in this same file — is a duplicate,
|
||||
// never an overwrite (each sample is a canonical reading). Probed per type
|
||||
// and chunk, like the externalId rows.
|
||||
const plainExistingSet = new Set<string>();
|
||||
for (const [type, instants] of groupByType(
|
||||
plainRows,
|
||||
(r) => r.row.measuredAt,
|
||||
)) {
|
||||
for (const chunk of chunked(instants, LOOKUP_CHUNK)) {
|
||||
const found = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
source: "IMPORT",
|
||||
type,
|
||||
measuredAt: { in: chunk },
|
||||
},
|
||||
select: { measuredAt: true },
|
||||
});
|
||||
for (const row of found) {
|
||||
plainExistingSet.add(`${type}::${row.measuredAt.getTime()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const seenPlain = new Set<string>();
|
||||
const plainInserts: NormalisedMeasurementRow[] = [];
|
||||
for (const result of plainRows) {
|
||||
const m = result.row;
|
||||
const key = `${m.type}::${m.measuredAt.getTime()}`;
|
||||
if (plainExistingSet.has(key) || seenPlain.has(key)) {
|
||||
skipped++;
|
||||
writeOutcome.set(result.line, "duplicate");
|
||||
} else {
|
||||
seenPlain.add(key);
|
||||
plainInserts.push(m);
|
||||
inserted++;
|
||||
writeOutcome.set(result.line, "inserted");
|
||||
touchedMeasurements.push({
|
||||
type: m.type as MeasurementType,
|
||||
measuredAt: m.measuredAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk write in bounded chunks, each its own short transaction.
|
||||
//
|
||||
// This used to be ONE interactive transaction around every create chunk
|
||||
// AND one `updateMany` per re-imported row. Re-importing a 9 000-row file
|
||||
// with an externalId column meant 9 000 serial updates inside it, which
|
||||
// outlived Prisma's 5 s interactive-transaction timeout and ended in a
|
||||
// 500. A chunk now commits on its own: creates go through
|
||||
// `createManyAndReturn` (skipDuplicates absorbs a concurrent
|
||||
// double-submit), updates through one batch transaction per chunk. A
|
||||
// failure part-way leaves the earlier chunks written, and that is safe to
|
||||
// retry: a re-upload of the same file skips what landed (unchanged
|
||||
// externalId rows and natural-key duplicates are not written again).
|
||||
const toCreate: Prisma.MeasurementCreateManyInput[] = [
|
||||
...[...extInserts.values()].map(buildCreateData),
|
||||
...plainInserts.map(buildCreateData),
|
||||
];
|
||||
let createdCount = 0;
|
||||
const createdRows: Array<{
|
||||
id: string;
|
||||
type: MeasurementType;
|
||||
measuredAt: Date;
|
||||
}> = [];
|
||||
// The re-fold runs whatever happens to the writes. A chunk that fails
|
||||
// part-way leaves the earlier chunks written, and a retry reads those
|
||||
// rows as duplicates and touches nothing: without the re-fold here their
|
||||
// days would keep the rollups from before the import for good. Re-folding
|
||||
// a (type, day) nothing landed on is harmless.
|
||||
try {
|
||||
for (const chunk of chunked(toCreate, WRITE_CHUNK)) {
|
||||
const rows = await prisma.measurement.createManyAndReturn({
|
||||
data: chunk,
|
||||
skipDuplicates: true,
|
||||
select: { id: true, type: true, measuredAt: true },
|
||||
});
|
||||
createdRows.push(...rows);
|
||||
createdCount += rows.length;
|
||||
}
|
||||
for (const chunk of chunked([...extUpdates.values()], WRITE_CHUNK)) {
|
||||
await prisma.$transaction(
|
||||
chunk.map((m) =>
|
||||
prisma.measurement.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
source: "IMPORT",
|
||||
type: m.type as MeasurementType,
|
||||
externalId: m.externalId as string,
|
||||
},
|
||||
data: {
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
measuredAt: m.measuredAt,
|
||||
notes: null,
|
||||
notesEncrypted: encryptNote(m.notes ?? null),
|
||||
glucoseContext:
|
||||
(m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
// No-op on a live row, a deliberate RESURRECTION on a
|
||||
// tombstoned one — IMPORT rows are re-importable by design, so
|
||||
// a re-imported externalId brings the row back (mirrors the
|
||||
// source-owned sync resurrect rule).
|
||||
deletedAt: null,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void emitInsertedMeasurementArrivals(
|
||||
userId,
|
||||
createdRows,
|
||||
"csv_import",
|
||||
).catch(() => {});
|
||||
void maybeEnqueueMorningRefresh(
|
||||
userId,
|
||||
createdRows
|
||||
.filter((row) => row.type === "SLEEP_DURATION")
|
||||
.map((row) => row.measuredAt),
|
||||
).catch(() => {});
|
||||
|
||||
// Reconcile `inserted` against what `createMany` actually wrote
|
||||
// (mirrors the batch route's raced-duplicate downgrade). Under
|
||||
// `skipDuplicates` a conflicting row is silently absorbed — the key is
|
||||
// present in the table either way, so we cannot identify the SPECIFIC
|
||||
// raced lines; we only need the counters and per-line statuses to sum
|
||||
// to the truth. Downgrade enough `inserted` lines to `duplicate` so
|
||||
// the envelope matches the DB write count.
|
||||
const racedDuplicates = toCreate.length - createdCount;
|
||||
if (racedDuplicates > 0) {
|
||||
let downgraded = 0;
|
||||
for (const [line, outcome] of writeOutcome) {
|
||||
if (downgraded >= racedDuplicates) break;
|
||||
if (outcome === "inserted") {
|
||||
writeOutcome.set(line, "duplicate");
|
||||
inserted--;
|
||||
skipped++;
|
||||
downgraded++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// One bounded rollup re-fold per touched (type, day) — a 10 000-row
|
||||
// CSV pays at most ~N (type, day) recomputes, not 10 000 per-row hooks
|
||||
// — and the status re-warm for the touched types. Best-effort: a
|
||||
// populator hiccup never fails the importer.
|
||||
await afterMeasurementMutation(userId, touchedMeasurements, {
|
||||
source: "manual",
|
||||
label: "import.csv",
|
||||
});
|
||||
}
|
||||
|
||||
await auditLog("import.csv.upload", {
|
||||
userId,
|
||||
return apiSuccess(
|
||||
await runCsvImport({
|
||||
userId: user.id,
|
||||
rows: parsed.rows,
|
||||
okRows,
|
||||
dryRun,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { inserted, updated, skipped },
|
||||
});
|
||||
} else {
|
||||
// Dry-run preview: no write ran, so report the projected outcome —
|
||||
// every valid parse-`ok` row would insert (an externalId row could land
|
||||
// as an update, but that needs a DB read we deliberately skip here).
|
||||
inserted = okRows.length;
|
||||
}
|
||||
|
||||
const summary = {
|
||||
inserted,
|
||||
updated,
|
||||
skipped,
|
||||
total: parsed.rows.length,
|
||||
dryRun,
|
||||
// Per-row status, mirroring the batch route's per-entry envelope. The
|
||||
// normalised payload is stripped — the client only needs line + status.
|
||||
// Parse-time `ok` rows are refined by the write outcome: a row that hit a
|
||||
// unique-constraint duplicate becomes `skipped`/`duplicate`; an upsert
|
||||
// that landed on an existing row becomes `updated`. In `dryRun` mode no
|
||||
// write ran, so every parse-`ok` row stays `inserted` (the projected
|
||||
// outcome).
|
||||
rows: parsed.rows.map((r) => {
|
||||
if (r.status === "skipped") {
|
||||
return {
|
||||
line: r.line,
|
||||
status: "skipped" as const,
|
||||
...(r.reason ? { reason: r.reason } : {}),
|
||||
};
|
||||
}
|
||||
const outcome = writeOutcome.get(r.line);
|
||||
if (outcome === "duplicate") {
|
||||
return {
|
||||
line: r.line,
|
||||
status: "skipped" as const,
|
||||
reason: "duplicate",
|
||||
};
|
||||
}
|
||||
if (outcome === "updated") {
|
||||
return { line: r.line, status: "updated" as const };
|
||||
}
|
||||
return { line: r.line, status: "inserted" as const };
|
||||
}),
|
||||
};
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
import_csv_inserted: inserted,
|
||||
import_csv_updated: updated,
|
||||
import_csv_skipped: skipped,
|
||||
import_csv_dry_run: dryRun,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(summary);
|
||||
);
|
||||
});
|
||||
+14
-389
@@ -1,7 +1,10 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
/**
|
||||
* `POST /api/import` — import user data (JSON format matching the export
|
||||
* structure). The route authenticates, meters and parses the file; the
|
||||
* writers live in `src/lib/import/json/`.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -13,7 +16,6 @@ import {
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import {
|
||||
validateMeasurementRange,
|
||||
@@ -21,27 +23,12 @@ import {
|
||||
glucoseContextEnum,
|
||||
} from "@/lib/validations/measurement";
|
||||
import {
|
||||
ENTRY_INSTANT_CLOCK_SKEW_MS,
|
||||
isPlausibleEntryInstant,
|
||||
validateEntryInstant,
|
||||
} from "@/lib/validations/entry-instant";
|
||||
import {
|
||||
classifyExternalId,
|
||||
unstableExternalIdMeta,
|
||||
type UnstableExternalIdShape,
|
||||
} from "@/lib/validations/external-id";
|
||||
import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups";
|
||||
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit";
|
||||
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
|
||||
import { isP2002, isP2025 } from "@/lib/prisma-errors";
|
||||
import { moodDateKey } from "@/lib/mood/date-key";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { deriveA1 } from "@/lib/mood/level-a";
|
||||
import { zonedWallClockToUtc } from "@/lib/tz/wall-clock";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
import { fallbackMoodDateStart } from "@/lib/import/json/mood";
|
||||
import { runJsonImport } from "@/lib/import/json/run";
|
||||
|
||||
// Derived from canonical enum so round-trip export → import covers every
|
||||
// type. Previous hardcoded subset silently dropped 4 of 11 types
|
||||
@@ -70,7 +57,7 @@ const measurementSchema = z
|
||||
// Stability is enforced per entry in the import loop, not at the
|
||||
// field: a schema-level refusal would fail the whole file on one bad
|
||||
// row.
|
||||
// @external-id-checked-per-entry: src/app/api/import/route.ts
|
||||
// @external-id-checked-per-entry: src/lib/import/json/measurements.ts
|
||||
externalId: z.string().min(1).max(120).optional(),
|
||||
})
|
||||
.refine(
|
||||
@@ -78,16 +65,6 @@ const measurementSchema = z
|
||||
"Wert ausserhalb des plausiblen Bereichs",
|
||||
);
|
||||
|
||||
function fallbackMoodDateStart(date: string): Date | null {
|
||||
const [year, month, day] = date.split("-").map(Number);
|
||||
if (!year || !month || !day) return null;
|
||||
const instant = zonedWallClockToUtc(
|
||||
{ year, month, day, hour: 0, minute: 0 },
|
||||
DEFAULT_TIMEZONE,
|
||||
);
|
||||
return moodDateKey(instant, DEFAULT_TIMEZONE) === date ? instant : null;
|
||||
}
|
||||
|
||||
const moodEntrySchema = z
|
||||
.object({
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
@@ -106,7 +83,7 @@ const moodEntrySchema = z
|
||||
// Stability is enforced per entry in the import loop, not at the
|
||||
// field: a schema-level refusal would fail the whole file on one bad
|
||||
// row.
|
||||
// @external-id-checked-per-entry: src/app/api/import/route.ts
|
||||
// @external-id-checked-per-entry: src/lib/import/json/mood.ts
|
||||
externalId: z.string().min(1).max(120).optional(),
|
||||
})
|
||||
.superRefine((entry, ctx) => {
|
||||
@@ -121,42 +98,11 @@ const moodEntrySchema = z
|
||||
}
|
||||
});
|
||||
|
||||
type MoodEntryInput = z.infer<typeof moodEntrySchema>;
|
||||
|
||||
function fallbackMoodIdentity(entry: MoodEntryInput): string {
|
||||
return JSON.stringify([
|
||||
entry.date,
|
||||
entry.mood,
|
||||
entry.score,
|
||||
entry.tags ?? null,
|
||||
entry.externalId ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
function stableFallbackMoodInstant(
|
||||
entry: MoodEntryInput,
|
||||
occurrence: number,
|
||||
probe: number,
|
||||
): Date {
|
||||
const digest = createHash("sha256")
|
||||
.update(`${fallbackMoodIdentity(entry)}:${occurrence}`)
|
||||
.digest();
|
||||
const initialOffset = digest.readUInt32BE(0) % ENTRY_INSTANT_CLOCK_SKEW_MS;
|
||||
const offset = (initialOffset + probe) % ENTRY_INSTANT_CLOCK_SKEW_MS;
|
||||
const dateStart = fallbackMoodDateStart(entry.date);
|
||||
if (!dateStart) throw new Error("Invalid mood date");
|
||||
return new Date(dateStart.getTime() + offset);
|
||||
}
|
||||
|
||||
const importSchema = z.object({
|
||||
measurements: z.array(measurementSchema).max(10000).optional(),
|
||||
moodEntries: z.array(moodEntrySchema).max(10000).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Import user data (JSON format matching export structure).
|
||||
* Deduplicates via unique constraints — existing records are skipped.
|
||||
*/
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "import.upload" } });
|
||||
@@ -183,334 +129,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
const userId = user.id;
|
||||
const data = parsed.data;
|
||||
const stats = { measurements: 0, moodEntries: 0, skipped: 0 };
|
||||
const unstableShapes: UnstableExternalIdShape[] = [];
|
||||
let writeFailed = false;
|
||||
|
||||
// Import measurements
|
||||
// v1.4.39.1 — track each (type, measuredAt) we wrote so we can re-
|
||||
// fold the persistent rollup tier at the end. Pre-fix, the import
|
||||
// path bypassed the rollup write hook entirely, so a CSV / JSON
|
||||
// restore left the user's `source=rollup` chart paths under-counting
|
||||
// the imported days until the next worker boot re-ran the backfill.
|
||||
const touchedMeasurements: Array<{
|
||||
type: MeasurementType;
|
||||
measuredAt: Date;
|
||||
}> = [];
|
||||
const insertedMeasurements: Array<{
|
||||
id: string;
|
||||
type: MeasurementType;
|
||||
measuredAt: Date;
|
||||
}> = [];
|
||||
if (data.measurements?.length) {
|
||||
for (const m of data.measurements) {
|
||||
// The `(userId, type, source, externalId)` upsert key is only
|
||||
// idempotent while the id is STABLE; an id that rotates between
|
||||
// exports re-imports as a fresh row every time. Skipped per entry,
|
||||
// like every other rejected row in this importer.
|
||||
const unstable =
|
||||
m.externalId === undefined ? null : classifyExternalId(m.externalId);
|
||||
if (unstable) {
|
||||
unstableShapes.push(unstable);
|
||||
stats.skipped++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
// `measuredAt` is already a `Date` (validateEntryInstant transform).
|
||||
const measuredAt = m.measuredAt;
|
||||
if (m.externalId) {
|
||||
const notesEncrypted = encryptNote(m.notes || null);
|
||||
// INSERT ... RETURNING is the only insertion proof. A concurrent
|
||||
// importer can win this key after any existence probe, so attempt
|
||||
// the insert first and update only when the statement returns no row.
|
||||
const created = await prisma.measurement.createManyAndReturn({
|
||||
data: [
|
||||
{
|
||||
userId,
|
||||
type: m.type,
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
source: "IMPORT",
|
||||
externalId: m.externalId,
|
||||
measuredAt,
|
||||
notes: null,
|
||||
notesEncrypted,
|
||||
glucoseContext: m.glucoseContext ?? null,
|
||||
},
|
||||
],
|
||||
skipDuplicates: true,
|
||||
select: { id: true, type: true, measuredAt: true },
|
||||
});
|
||||
const inserted = created[0];
|
||||
if (inserted) {
|
||||
insertedMeasurements.push(inserted);
|
||||
} else {
|
||||
// A pre-existing row or a raced insert owns the key. Preserve
|
||||
// idempotent re-import semantics without classifying this update
|
||||
// as an arrival.
|
||||
try {
|
||||
await prisma.measurement.update({
|
||||
where: {
|
||||
userId_type_source_externalId: {
|
||||
userId,
|
||||
type: m.type,
|
||||
source: "IMPORT",
|
||||
externalId: m.externalId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
measuredAt,
|
||||
notes: null,
|
||||
notesEncrypted,
|
||||
glucoseContext: m.glucoseContext ?? null,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (isP2025(err)) {
|
||||
stats.skipped++;
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const inserted = await prisma.measurement.create({
|
||||
data: {
|
||||
userId,
|
||||
type: m.type,
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
source: "IMPORT",
|
||||
measuredAt,
|
||||
notes: null,
|
||||
notesEncrypted: encryptNote(m.notes || null),
|
||||
glucoseContext: m.glucoseContext ?? null,
|
||||
},
|
||||
select: { id: true, type: true, measuredAt: true },
|
||||
});
|
||||
insertedMeasurements.push(inserted);
|
||||
}
|
||||
touchedMeasurements.push({
|
||||
type: m.type as MeasurementType,
|
||||
measuredAt,
|
||||
});
|
||||
stats.measurements++;
|
||||
} catch (err) {
|
||||
if (isP2002(err)) {
|
||||
stats.skipped++;
|
||||
continue;
|
||||
}
|
||||
writeFailed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void emitInsertedMeasurementArrivals(
|
||||
userId,
|
||||
insertedMeasurements,
|
||||
"json_import",
|
||||
).catch(() => {});
|
||||
void maybeEnqueueMorningRefresh(
|
||||
userId,
|
||||
insertedMeasurements
|
||||
.filter((row) => row.type === "SLEEP_DURATION")
|
||||
.map((row) => row.measuredAt),
|
||||
).catch(() => {});
|
||||
|
||||
// Import mood entries
|
||||
if (!writeFailed && data.moodEntries?.length) {
|
||||
const fallbackOccurrences = new Map<string, number>();
|
||||
const reservedFallbackInstants = new Set<number>();
|
||||
for (const e of data.moodEntries) {
|
||||
// Same stability floor as the measurement loop above — the mood
|
||||
// upsert keys on `(userId, source, externalId)`.
|
||||
const unstableMood =
|
||||
e.externalId === undefined ? null : classifyExternalId(e.externalId);
|
||||
if (unstableMood) {
|
||||
unstableShapes.push(unstableMood);
|
||||
stats.skipped++;
|
||||
continue;
|
||||
}
|
||||
const identity = e.loggedAt ? null : fallbackMoodIdentity(e);
|
||||
const occurrence =
|
||||
identity === null ? 0 : (fallbackOccurrences.get(identity) ?? 0);
|
||||
if (identity !== null) {
|
||||
fallbackOccurrences.set(identity, occurrence + 1);
|
||||
}
|
||||
|
||||
for (let probe = 0; probe < ENTRY_INSTANT_CLOCK_SKEW_MS; probe++) {
|
||||
const loggedAt =
|
||||
e.loggedAt ?? stableFallbackMoodInstant(e, occurrence, probe);
|
||||
if (!e.loggedAt && reservedFallbackInstants.has(loggedAt.getTime())) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (e.externalId) {
|
||||
// v1.12.1 — idempotent re-import keyed on the source-stable id.
|
||||
// A second import of the same export updates the row in place
|
||||
// rather than skipping it as a duplicate, so an upstream edit
|
||||
// (re-scored mood, added tag) is reflected.
|
||||
await prisma.moodEntry.upsert({
|
||||
where: {
|
||||
userId_source_externalId: {
|
||||
userId,
|
||||
source: "IMPORT",
|
||||
externalId: e.externalId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
date: e.date,
|
||||
mood: e.mood,
|
||||
score: e.score,
|
||||
// v1.37 — pleasantness derived from the row's own label, not
|
||||
// from the file's `score`. This is the one writer whose score
|
||||
// comes out of the file rather than out of the label, so the
|
||||
// two can disagree; the column that records a self-assessment
|
||||
// has to agree with the label the user actually picked.
|
||||
moodA1: deriveA1(e.mood),
|
||||
tags: e.tags || null,
|
||||
moodLoggedAt: loggedAt,
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
date: e.date,
|
||||
mood: e.mood,
|
||||
score: e.score,
|
||||
moodA1: deriveA1(e.mood),
|
||||
tags: e.tags || null,
|
||||
source: "IMPORT",
|
||||
externalId: e.externalId,
|
||||
moodLoggedAt: loggedAt,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await prisma.moodEntry.create({
|
||||
data: {
|
||||
userId,
|
||||
date: e.date,
|
||||
mood: e.mood,
|
||||
score: e.score,
|
||||
// See the upsert arm above: derived from the label, because
|
||||
// an imported file's `score` is its own field.
|
||||
moodA1: deriveA1(e.mood),
|
||||
tags: e.tags || null,
|
||||
source: "IMPORT",
|
||||
moodLoggedAt: loggedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!e.loggedAt) {
|
||||
reservedFallbackInstants.add(loggedAt.getTime());
|
||||
}
|
||||
stats.moodEntries++;
|
||||
break;
|
||||
} catch (err) {
|
||||
if (!isP2002(err)) {
|
||||
writeFailed = true;
|
||||
break;
|
||||
}
|
||||
if (e.loggedAt) {
|
||||
stats.skipped++;
|
||||
break;
|
||||
}
|
||||
if (!e.externalId) {
|
||||
try {
|
||||
const existing = await prisma.moodEntry.findUnique({
|
||||
where: {
|
||||
userId_date_moodLoggedAt: {
|
||||
userId,
|
||||
date: e.date,
|
||||
moodLoggedAt: loggedAt,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
source: true,
|
||||
mood: true,
|
||||
score: true,
|
||||
tags: true,
|
||||
externalId: true,
|
||||
},
|
||||
});
|
||||
if (
|
||||
existing?.source === "IMPORT" &&
|
||||
existing.mood === e.mood &&
|
||||
existing.score === e.score &&
|
||||
existing.tags === (e.tags || null) &&
|
||||
(existing.externalId ?? null) === null
|
||||
) {
|
||||
reservedFallbackInstants.add(loggedAt.getTime());
|
||||
stats.skipped++;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
writeFailed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (probe === ENTRY_INSTANT_CLOCK_SKEW_MS - 1) {
|
||||
writeFailed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (writeFailed) break;
|
||||
}
|
||||
}
|
||||
|
||||
// v1.4.39 — one bounded re-fold per import is cheaper than
|
||||
// firing N per-row hooks. The mood rollup tier is keyed on the
|
||||
// user + day; the fold materialises every touched day from the
|
||||
// entries we just wrote. Best-effort: an importer hiccup must not
|
||||
// surface as a 5xx, the rollup is a cache tier.
|
||||
if (stats.moodEntries > 0) {
|
||||
try {
|
||||
await recomputeUserMoodRollups(userId, { granularities: ["DAY"] });
|
||||
} catch (err) {
|
||||
annotate({
|
||||
meta: {
|
||||
mood_rollup_import_failed: true,
|
||||
mood_rollup_import_error:
|
||||
err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh the persistent measurement rollup table for every distinct
|
||||
// (type, day) the import touched (collapsed, so a 10 000-row restore pays
|
||||
// at most ~N recomputes) and re-warm the touched types' status
|
||||
// assessments. Best-effort: a populator hiccup never fails the importer.
|
||||
await afterMeasurementMutation(userId, touchedMeasurements, {
|
||||
// A file the person chose to import: a deliberate act, narrated like a
|
||||
// hand entry rather than held to the sync gap.
|
||||
source: "manual",
|
||||
label: "import",
|
||||
const { stats, writeFailed } = await runJsonImport({
|
||||
userId: user.id,
|
||||
measurements: parsed.data.measurements,
|
||||
moodEntries: parsed.data.moodEntries,
|
||||
ipAddress: getClientIp(request),
|
||||
});
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
import_stats: stats,
|
||||
...(unstableShapes.length > 0
|
||||
? unstableExternalIdMeta("import.json", unstableShapes)
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await auditLog("import.upload", {
|
||||
userId,
|
||||
ipAddress: getClientIp(request),
|
||||
details: stats,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!writeFailed) throw err;
|
||||
annotate({ meta: { import_audit_failed: true } });
|
||||
}
|
||||
|
||||
if (writeFailed) {
|
||||
return apiError("Import write failed", 503, {
|
||||
errorCode: "import.write_failed",
|
||||
|
||||
@@ -28,7 +28,7 @@ import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
|
||||
import { unavailableStatusBody } from "@/lib/insights/status-unavailable";
|
||||
import { generateBiomarkerStatus } from "@/lib/insights/biomarker-status";
|
||||
import { normalizeLocale as resolveMetricStatusLocale } from "@/lib/insights/status-shared";
|
||||
import { normalizeLocale } from "@/lib/insights/status-shared";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -67,7 +67,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
userLocale: user.locale ?? null,
|
||||
override: localeParam,
|
||||
});
|
||||
const locale = resolveMetricStatusLocale(resolved);
|
||||
const locale = normalizeLocale(resolved);
|
||||
|
||||
const result = await generateBiomarkerStatus({
|
||||
biomarkerId: parsed.data.biomarkerId,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { generateBloodPressureStatusForUser } from "@/lib/insights/blood-pressure-status";
|
||||
import { normalizeLocale as resolveBloodPressureStatusLocale } from "@/lib/insights/status-shared";
|
||||
import { normalizeLocale } from "@/lib/insights/status-shared";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
@@ -40,7 +40,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
userLocale: user.locale ?? null,
|
||||
override: localeParam,
|
||||
});
|
||||
const locale = resolveBloodPressureStatusLocale(resolved);
|
||||
const locale = normalizeLocale(resolved);
|
||||
|
||||
// v1.8.3 — read-only: serve the cache, enqueue generation out of band on
|
||||
// a miss. The GET never awaits the provider, so opening /insights/<metric>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { generateBmiStatusForUser } from "@/lib/insights/bmi-status";
|
||||
import { normalizeLocale as resolveBmiStatusLocale } from "@/lib/insights/status-shared";
|
||||
import { normalizeLocale } from "@/lib/insights/status-shared";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
@@ -40,7 +40,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
userLocale: user.locale ?? null,
|
||||
override: localeParam,
|
||||
});
|
||||
const locale = resolveBmiStatusLocale(resolved);
|
||||
const locale = normalizeLocale(resolved);
|
||||
|
||||
// v1.8.3 — read-only: serve the cache, enqueue generation out of band on
|
||||
// a miss. The GET never awaits the provider, so opening /insights/<metric>
|
||||
|
||||
@@ -1,59 +1,20 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { probeProviderPresence } from "@/lib/ai/provider";
|
||||
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import type { DataPoint, DataSummary } from "@/lib/analytics/trends";
|
||||
import { summarize } from "@/lib/analytics/trends";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import { computeBpInTargetPct } from "@/lib/analytics/bp-in-target";
|
||||
import {
|
||||
classifyBMI,
|
||||
classifyBP,
|
||||
generateAlerts,
|
||||
} from "@/lib/analytics/classifications";
|
||||
import {
|
||||
significantPearsonCorrelation,
|
||||
type PairedPoint,
|
||||
} from "@/lib/analytics/correlations";
|
||||
import {
|
||||
buildComplianceMedicationContext,
|
||||
COURSES_COMPLIANCE_SELECT,
|
||||
calculateCompliance,
|
||||
expectsDoses,
|
||||
lastNonSkippedTakenAt,
|
||||
SCHEDULE_COMPLIANCE_SELECT,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import { resolveMedicationCategories } from "@/lib/medication-category";
|
||||
import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse";
|
||||
import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read";
|
||||
import {
|
||||
apiHandler,
|
||||
requireRecordAuth,
|
||||
type AuthContext,
|
||||
} from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
cachedSwrWithMeta,
|
||||
caches,
|
||||
type ServerCache,
|
||||
} from "@/lib/cache/server-cache";
|
||||
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
|
||||
import {
|
||||
ensureUserMoodRollupsFresh,
|
||||
readMoodDayRollups,
|
||||
} from "@/lib/rollups/mood-rollups";
|
||||
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
import { dayKeyAsUtcMidnight, dateOnlyKey } from "@/lib/tz/date-only";
|
||||
import { readComprehensiveOverview } from "@/lib/insights/comprehensive-overview/overview";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/insights/comprehensive — the Insights overview's computed read.
|
||||
* The body is built in `src/lib/insights/comprehensive-overview/`.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
|
||||
// provider anywhere on the path.
|
||||
// MANAGE-level read: computed over the whole record, with no provider
|
||||
// anywhere on the path.
|
||||
const { user } = await requireRecordAuth("manage", "record");
|
||||
// v1.15.20 — shared analytics-read budget (generous; caps runaway loops).
|
||||
// Shared analytics-read budget (generous; caps runaway loops).
|
||||
const rl = await checkAnalyticsReadRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many analytics requests. Please retry later.", 429);
|
||||
@@ -66,37 +27,7 @@ export const GET = apiHandler(async () => {
|
||||
// provider up, still gets the overview. The `ai` block beside the body says
|
||||
// whether the briefing and status notes that decorate the overview can be
|
||||
// shown, resolved per request and never cached with the body.
|
||||
|
||||
// v1.4.35 — read-through the analytics cache keyed on
|
||||
// (userId, "comprehensive"). The /insights page mount routinely
|
||||
// fans out to this endpoint alongside the Coach drawer + the
|
||||
// recommendations grid; the 60s TTL converts every duplicate
|
||||
// mount within the window to a Map lookup. Invalidation is handled
|
||||
// by `invalidateUserMeasurements`, which marks every key under the
|
||||
// `${userId}|` prefix stale (see `lib/cache/invalidate.ts`).
|
||||
//
|
||||
// v1.16.7 — stale-while-revalidate. This is the single heaviest
|
||||
// SQL-side aggregation on the /insights mount; serving the prior
|
||||
// body instantly while ONE background recompute refreshes it keeps
|
||||
// the page interactive across the bucket's 10-minute stale window
|
||||
// (a measurement sync used to bust the entry into a blocking cold
|
||||
// rebuild on the very next mount, all day). The truly-cold first
|
||||
// read of the day still computes inline — there is no prior body to
|
||||
// serve — but it no longer recurs per visit.
|
||||
//
|
||||
// The `revalidating` flag marks a stale-served body honestly so the
|
||||
// client can poll (bounded) until the background rebuild lands —
|
||||
// mirroring the `/api/insights/generate` GET contract. It rides on
|
||||
// the response, never inside the cached body, so a warmed entry
|
||||
// doesn't freeze the flag.
|
||||
const { value: body, outcome } = await cachedSwrWithMeta(
|
||||
caches.analytics as ServerCache<
|
||||
Awaited<ReturnType<typeof buildComprehensiveResponse>>
|
||||
>,
|
||||
`${user.id}|comprehensive`,
|
||||
() => buildComprehensiveResponse(user),
|
||||
annotate,
|
||||
);
|
||||
const { body, revalidating } = await readComprehensiveOverview(user);
|
||||
|
||||
const [briefing, statusText] = await Promise.all([
|
||||
aiCapabilityToServe(user.id, "briefing"),
|
||||
@@ -105,627 +36,7 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
return apiSuccess({
|
||||
...body,
|
||||
revalidating: outcome === "stale",
|
||||
revalidating,
|
||||
ai: { briefing, statusText },
|
||||
});
|
||||
});
|
||||
|
||||
type AuthedUser = AuthContext["user"];
|
||||
|
||||
/**
|
||||
* v1.4.35 — comprehensive insights response body, lifted out of the
|
||||
* route handler so `cached()` can wrap it. Replaces the previous
|
||||
* 100k-row-in-JS aggregation with SQL-side aggregates served by
|
||||
* `buildComprehensiveAggregate`. The medication-compliance block keeps
|
||||
* its bounded Prisma reads (medications + intake events) — the directive
|
||||
* called those out as already on the safe path. The byte-shape of the
|
||||
* returned envelope is unchanged from the legacy route.
|
||||
*/
|
||||
export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
const userId = user.id;
|
||||
const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
||||
// v1.30.3 — the one shared per-user tz for every day-bucketing
|
||||
// pass in this route (resting-pulse proxy, medication-continuity, BP
|
||||
// pairing). Was previously re-derived ad hoc (or hardcoded UTC) at each
|
||||
// call site.
|
||||
const userTz = user.timezone || DEFAULT_TIMEZONE;
|
||||
|
||||
// Fetch user profile (height + DOB drive BMI + BP targets).
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
heightCm: true,
|
||||
dateOfBirth: true,
|
||||
},
|
||||
});
|
||||
|
||||
// ── Aggregate-first reads ─────────────────────────────────
|
||||
// SQL-side rollup replaces the 100k-row findMany on the legacy
|
||||
// route. Returns per-type DataSummary identical to the JS
|
||||
// `summarize()` semantics over the 90-day window, plus the bounded
|
||||
// raw rows the BP target adherence pairing needs, plus daily-mean
|
||||
// buckets for the correlation pairings.
|
||||
const aggregate = await buildComprehensiveAggregate(userId);
|
||||
|
||||
// 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`.
|
||||
//
|
||||
// Since v1.32.12 the mood rollup writer mints `bucketStart` from the
|
||||
// canonical local `MoodEntry.date` label at UTC midnight, so
|
||||
// `.toISOString().slice(0, 10)` recovers the user's LOCAL calendar day —
|
||||
// byte-identical to the live fallback's `entry.date`. Both mood paths
|
||||
// therefore emit local-day keys. The mood × metric pairing partners
|
||||
// below (sys BP, weight, resting pulse) are consequently derived in the
|
||||
// user's tz (v1.32.17) so both join sides share ONE local-day key space.
|
||||
// The measurement-rollup tier itself still buckets UTC (`dailyByType`)
|
||||
// and migrates to local in a documented follow-up release; the two
|
||||
// internally-consistent UTC joins (weight × BP, BP-med continuity) are
|
||||
// left on it deliberately (see their call sites below).
|
||||
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: dateOnlyKey(r.bucketStart),
|
||||
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({
|
||||
// v1.7.0 sync — exclude tombstoned rows.
|
||||
where: { userId, deletedAt: null, 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;
|
||||
}
|
||||
|
||||
const moodSummary =
|
||||
moodDataPoints.length > 0 ? summarize(moodDataPoints) : null;
|
||||
|
||||
const summaries: Record<string, DataSummary> = aggregate.summaries;
|
||||
|
||||
// ── BMI ──────────────────────────────────────────────────
|
||||
let bmi: number | null = null;
|
||||
let bmiClassification = null;
|
||||
if (dbUser?.heightCm && summaries.WEIGHT?.latest) {
|
||||
const heightM = dbUser.heightCm / 100;
|
||||
bmi = Math.round((summaries.WEIGHT.latest / (heightM * heightM)) * 10) / 10;
|
||||
bmiClassification = classifyBMI(bmi);
|
||||
}
|
||||
|
||||
// ── BP classification (30-day average) ────────────────────
|
||||
let bpClassification = null;
|
||||
const bpTargets = getBpTargets(dbUser?.dateOfBirth ?? null);
|
||||
if (
|
||||
summaries.BLOOD_PRESSURE_SYS?.avg30 &&
|
||||
summaries.BLOOD_PRESSURE_DIA?.avg30
|
||||
) {
|
||||
bpClassification = classifyBP(
|
||||
summaries.BLOOD_PRESSURE_SYS.avg30,
|
||||
summaries.BLOOD_PRESSURE_DIA.avg30,
|
||||
);
|
||||
}
|
||||
|
||||
// ── BP target adherence (trailing 90 days) ───────────────
|
||||
// Route through the canonical `computeBpInTargetPct` so this figure
|
||||
// shares ONE definition with the dashboard tile, the insight cards, the
|
||||
// AI snapshot and the targets endpoint (per the bp-in-target.ts
|
||||
// contract — the inline copy that used to live here drifted: it paired
|
||||
// sys+dia on the 5-minute window ONLY, dropping readings whose sys/dia
|
||||
// timestamps drift past 5 min on Withings / Apple-Health imports, which
|
||||
// skewed the share iOS renders on the Home Health Score). The helper
|
||||
// adds the same-local-day pairing fallback (v1.30.3 QA F7 — keyed on
|
||||
// the user's OWN tz, not a hardcoded Berlin day) so those readings
|
||||
// pair, and keeps the ESH-2023 band + 90/50 hypotension floor (both sys
|
||||
// AND dia at or below the age-band ceiling). Window is the aggregator's
|
||||
// 90-day raw pull — a "recent control" figure, not the all-time headline.
|
||||
let bpPctInTarget: number | null = null;
|
||||
if (bpTargets) {
|
||||
bpPctInTarget =
|
||||
computeBpInTargetPct(
|
||||
aggregate.bpRawRows.sys,
|
||||
aggregate.bpRawRows.dia,
|
||||
bpTargets,
|
||||
userTz,
|
||||
)?.pct ?? null;
|
||||
}
|
||||
|
||||
// ── Correlations: weight × Sys BP ─────────────────────────
|
||||
// Daily-key pairing (the legacy `pairByTimestamp` ran with a
|
||||
// 24-hour default tolerance — daily-mean buckets are the
|
||||
// SQL-side equivalent and the directive explicitly accepts the
|
||||
// small semantic shift to bound the raw-row footprint).
|
||||
//
|
||||
// v1.32.17 — this pair stays on the UTC `dailyByType` series: BOTH
|
||||
// sides come from `dailyByType` (weight × sys), so they share one UTC
|
||||
// day-key space and are internally consistent. Re-keying it to local
|
||||
// is R12's read-swap (it migrates with the rollup tier), NOT this
|
||||
// hotfix — doing it here would mean per-request raw re-aggregation
|
||||
// without the local-bucketed rollup behind it.
|
||||
const weightDaily = aggregate.dailyByType.WEIGHT ?? [];
|
||||
const sysDaily = aggregate.dailyByType.BLOOD_PRESSURE_SYS ?? [];
|
||||
const weightBpPairs: PairedPoint[] = joinDailyByDay(weightDaily, sysDaily);
|
||||
const weightBpCorrelation = significantPearsonCorrelation(weightBpPairs);
|
||||
const scatterData = weightBpPairs.map((p) => ({
|
||||
weight: p.a,
|
||||
sysBP: p.b,
|
||||
}));
|
||||
|
||||
// ── Correlations: mood × {sys BP, weight, pulse} ─────────
|
||||
// v1.32.17 — the mood series is LOCAL-day-keyed (since v1.32.12, see the
|
||||
// rollup note above). `pairMoodWithDaily` joins on an EXACT YYYY-MM-DD
|
||||
// key, so the metric partners MUST also be local-day-keyed or every
|
||||
// evening logger west of UTC gets a lag-1 pairing sold as same-day.
|
||||
// Derive the sys / weight partners as exact local-day means from the raw
|
||||
// rows already on the request path (canonical-source deduped in the
|
||||
// aggregator), NOT from the UTC `dailyByType` — a UTC-day MEAN cannot be
|
||||
// relabeled into a local-day mean. `round2` mirrors the aggregator's
|
||||
// `ROUND(AVG, 2)` so correlation thresholds don't drift.
|
||||
const sysDailyLocal = deriveDailyLocalMeans(aggregate.bpRawRows.sys, userTz);
|
||||
const weightDailyLocal = deriveDailyLocalMeans(
|
||||
aggregate.weightRawRows,
|
||||
userTz,
|
||||
);
|
||||
|
||||
const moodBpPairs = pairMoodWithDaily(dailyMoodEntries, sysDailyLocal);
|
||||
const moodBpCorrelation = significantPearsonCorrelation(moodBpPairs);
|
||||
const moodBpScatterData = moodBpPairs.map((p) => ({ mood: p.a, sysBP: p.b }));
|
||||
|
||||
const moodWeightPairs = pairMoodWithDaily(dailyMoodEntries, weightDailyLocal);
|
||||
const moodWeightCorrelation = significantPearsonCorrelation(moodWeightPairs);
|
||||
const moodWeightScatterData = moodWeightPairs.map((p) => ({
|
||||
mood: p.a,
|
||||
weight: p.b,
|
||||
}));
|
||||
|
||||
// v1.2.5 — this correlation is labelled "Resting pulse (bpm)";
|
||||
// score it against the RESTING series, not the raw PULSE day-mean. The
|
||||
// aggregator's `dailyByType.PULSE` is a per-day AVG that folds in workout
|
||||
// HR for Apple-Health users and inflates the "resting" figure. Prefer the
|
||||
// clean RESTING_HEART_RATE rows; only pull RAW PULSE (for the
|
||||
// low-percentile daily proxy) when the user has no resting rows — the
|
||||
// proxy needs the per-day distribution, which the day-mean discards.
|
||||
//
|
||||
// v1.32.17 — key this resting series on `userDayKey(d, userTz)`. Its ONLY
|
||||
// pairing partner is `dailyMoodEntries`, which is LOCAL-day-keyed since
|
||||
// v1.32.12 (the mood rollup writer now mints `bucketStart` from the
|
||||
// canonical local `MoodEntry.date` label, not a UTC truncation). Keeping
|
||||
// UTC here would offset every evening logger west of UTC by one day and
|
||||
// sell a lag-1 correlation as same-day. This is exactly the flip the
|
||||
// v1.30.3 F8 note said becomes correct once the mood side goes local —
|
||||
// it now has. (Unlike the mood side, this series builds from raw rows in
|
||||
// this route, so it re-keys directly with no rollup-tier change.)
|
||||
const restingPulseRows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "RESTING_HEART_RATE",
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: ninetyDaysAgo },
|
||||
},
|
||||
select: { measuredAt: true, value: true },
|
||||
});
|
||||
//
|
||||
// #1023 — the proxy folds in Postgres. It needs one percentile per day, and
|
||||
// reading the raw samples to get it walked 130 000 rows per page view on a
|
||||
// watch that records once a minute, blocking the event loop for over a
|
||||
// second. `readRestingPulseProxy` returns the same series one row per day.
|
||||
const localDayKey = (d: Date) => userDayKey(d, userTz);
|
||||
const restingPulseSeries =
|
||||
restingPulseRows.length > 0
|
||||
? resolveRestingPulseSeries({
|
||||
restingSamples: restingPulseRows,
|
||||
pulseSamples: [],
|
||||
dayKeyOf: localDayKey,
|
||||
}).series
|
||||
: await readRestingPulseProxy({
|
||||
userId,
|
||||
since: ninetyDaysAgo,
|
||||
timeZone: userTz,
|
||||
});
|
||||
const restingByDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const s of restingPulseSeries) {
|
||||
const key = localDayKey(s.measuredAt);
|
||||
const cur = restingByDay.get(key) ?? { sum: 0, count: 0 };
|
||||
cur.sum += s.value;
|
||||
cur.count += 1;
|
||||
restingByDay.set(key, cur);
|
||||
}
|
||||
const restingPulseDaily = Array.from(restingByDay.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([day, stats]) => ({ day, value: stats.sum / stats.count }));
|
||||
const moodPulsePairs = pairMoodWithDaily(dailyMoodEntries, restingPulseDaily);
|
||||
const moodPulseCorrelation = significantPearsonCorrelation(moodPulsePairs);
|
||||
const moodPulseScatterData = moodPulsePairs.map((p) => ({
|
||||
mood: p.a,
|
||||
pulse: p.b,
|
||||
}));
|
||||
|
||||
// ── Medication compliance ────────────────────────────────
|
||||
// Bounded reads — left untouched per the directive.
|
||||
const medicationRows = await prisma.medication.findMany({
|
||||
// v1.16.11 — as-needed (PRN) medications never surface a compliance
|
||||
// rate (no expected doses).
|
||||
where: { userId, active: true, asNeeded: false, ...TRACKED_INTAKE_WHERE },
|
||||
// v1.15.20 — schedules through the shared compliance select so the
|
||||
// configured per-dose windows reach this surface like every other.
|
||||
include: {
|
||||
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
|
||||
// v1.16.3 — archived schedule eras for era-aware compliance.
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
// v1.25 — pause eras so paused days drop out of the denominator.
|
||||
pauseEras: { select: { pausedAt: true, resumedAt: true } },
|
||||
// v1.40 (#1024) — the courses, so a gap between two expects nothing.
|
||||
courses: COURSES_COMPLIANCE_SELECT,
|
||||
},
|
||||
});
|
||||
// A medication with no schedule expects no dose, and `calculateCompliance`
|
||||
// answers 100 % for that case. The `asNeeded: false` filter above catches
|
||||
// only the PRN arm; the empty schedule is reachable on a scheduled
|
||||
// medication and would otherwise report perfect adherence.
|
||||
const medications = medicationRows.filter(expectsDoses);
|
||||
|
||||
const resolvedCategories = await resolveMedicationCategories(
|
||||
medications.map((m) => m.id),
|
||||
);
|
||||
const categoryMap = Object.fromEntries(
|
||||
Object.entries(resolvedCategories).map(([id, r]) => [id, r.category]),
|
||||
);
|
||||
|
||||
const medCompliance = [];
|
||||
const bpMedicationEvents: Array<{
|
||||
scheduledFor: Date;
|
||||
takenAt: Date | null;
|
||||
skipped: boolean;
|
||||
}> = [];
|
||||
const bpMedications = medications.filter(
|
||||
(med) => (categoryMap[med.id] ?? "OTHER") === "BLOOD_PRESSURE",
|
||||
);
|
||||
// Single round-trip for all medications instead of N+1: one query keyed
|
||||
// on `medicationId IN (...)`, then group in memory.
|
||||
const allEvents = medications.length
|
||||
? await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
medicationId: { in: medications.map((m) => m.id) },
|
||||
userId,
|
||||
// v1.7.0 sync — exclude tombstoned rows.
|
||||
deletedAt: null,
|
||||
scheduledFor: { gte: ninetyDaysAgo },
|
||||
},
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
})
|
||||
: [];
|
||||
|
||||
const eventsByMed = new Map<string, typeof allEvents>();
|
||||
for (const ev of allEvents) {
|
||||
const list = eventsByMed.get(ev.medicationId);
|
||||
if (list) list.push(ev);
|
||||
else eventsByMed.set(ev.medicationId, [ev]);
|
||||
}
|
||||
|
||||
for (const med of medications) {
|
||||
const events = eventsByMed.get(med.id) ?? [];
|
||||
const mapped = events.map((e) => ({
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
scheduledFor: e.scheduledFor,
|
||||
}));
|
||||
// v1.7.0 — engine-routed denominator.
|
||||
const medicationContext = buildComplianceMedicationContext(
|
||||
med,
|
||||
lastNonSkippedTakenAt(mapped),
|
||||
user.timezone || DEFAULT_TIMEZONE,
|
||||
);
|
||||
const c7 = calculateCompliance(mapped, med.schedules, 7, med.createdAt, {
|
||||
medicationContext,
|
||||
});
|
||||
const c30 = calculateCompliance(mapped, med.schedules, 30, med.createdAt, {
|
||||
medicationContext,
|
||||
});
|
||||
|
||||
medCompliance.push({
|
||||
id: med.id,
|
||||
name: med.name,
|
||||
dose: med.dose,
|
||||
category: categoryMap[med.id] ?? "OTHER",
|
||||
// v1.40 (#1041) — the label of a custom category; null for a built-in.
|
||||
categoryLabel: resolvedCategories[med.id]?.categoryLabel ?? null,
|
||||
compliance7: c7.rate,
|
||||
compliance30: c30.rate,
|
||||
streak: c7.streak,
|
||||
taken7: c7.taken,
|
||||
skipped7: c7.skipped,
|
||||
missed7: c7.missed,
|
||||
});
|
||||
|
||||
if ((categoryMap[med.id] ?? "OTHER") === "BLOOD_PRESSURE") {
|
||||
bpMedicationEvents.push(
|
||||
...events.map((e) => ({
|
||||
scheduledFor: e.scheduledFor,
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Correlation: BP medication continuity × systolic BP ──
|
||||
// Uses the same daily BP_SYS series the aggregator pre-computed —
|
||||
// no second round-trip. The intake-side aggregation matches the
|
||||
// legacy code: continuity = min(1, taken / expectedPerDay), pairing
|
||||
// every day that had a sys reading.
|
||||
let bpMedicationCorrelation: {
|
||||
r: number;
|
||||
strength: string;
|
||||
n: number;
|
||||
medicationCount: number;
|
||||
} | null = null;
|
||||
const bpMedicationScatterData: Array<{
|
||||
continuityPct: number;
|
||||
sysBP: number;
|
||||
}> = [];
|
||||
|
||||
const expectedBpIntakesPerDay = bpMedications.reduce(
|
||||
(sum, med) => sum + med.schedules.length,
|
||||
0,
|
||||
);
|
||||
|
||||
if (expectedBpIntakesPerDay > 0) {
|
||||
// Daily systolic means (already keyed YYYY-MM-DD) from the SQL pass.
|
||||
// v1.32.17 — `sysDaily` (UTC `date_trunc` from the aggregator's
|
||||
// `dailyByType`) and `takenByDay` below (intake `scheduledFor`
|
||||
// truncated to UTC) both live in the SAME UTC day-key space and are
|
||||
// internally consistent with each other — this join is NOT broken and
|
||||
// stays as is. Only the mood pairings above moved to a separately
|
||||
// derived local-day series (their partner, the mood series, is
|
||||
// local-day-keyed). This correlation migrates to local when the
|
||||
// measurement-rollup tier buckets per-user tz (R12), not in this hotfix
|
||||
// — re-keying it here without the local-bucketed rollup would mean
|
||||
// per-request raw re-aggregation.
|
||||
const sysByDay = new Map<string, number>();
|
||||
for (const row of sysDaily) {
|
||||
sysByDay.set(row.day, row.value);
|
||||
}
|
||||
|
||||
const takenByDay = new Map<string, number>();
|
||||
// Days that carry an intake record of ANY kind. A logged skip is a real
|
||||
// zero and belongs in the series; a day the person never touched is not a
|
||||
// zero, it is nothing, and pairing it against that day's systolic reading
|
||||
// invented a data point they never produced. Absence is not zero, here as
|
||||
// everywhere else in this project, so the unlogged day drops out of both
|
||||
// the scatter and the coefficient rather than dragging continuity down.
|
||||
const loggedDays = new Set<string>();
|
||||
for (const event of bpMedicationEvents) {
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: pairs intake days with the DAY rollup buckets in sysByDay, which are UTC days
|
||||
const dayKey = event.scheduledFor.toISOString().slice(0, 10);
|
||||
loggedDays.add(dayKey);
|
||||
if (event.skipped || !event.takenAt) continue;
|
||||
takenByDay.set(dayKey, (takenByDay.get(dayKey) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const pairs: Array<{ a: number; b: number; date: Date }> = [];
|
||||
for (const [dayKey, avgSys] of sysByDay.entries()) {
|
||||
if (!loggedDays.has(dayKey)) continue;
|
||||
const taken = takenByDay.get(dayKey) ?? 0;
|
||||
const continuity = Math.min(1, taken / expectedBpIntakesPerDay);
|
||||
pairs.push({
|
||||
a: continuity,
|
||||
b: avgSys,
|
||||
date: dayKeyAsUtcMidnight(dayKey),
|
||||
});
|
||||
bpMedicationScatterData.push({
|
||||
continuityPct: Math.round(continuity * 100),
|
||||
sysBP: Math.round(avgSys * 10) / 10,
|
||||
});
|
||||
}
|
||||
|
||||
const corr = significantPearsonCorrelation(pairs);
|
||||
if (corr) {
|
||||
bpMedicationCorrelation = {
|
||||
...corr,
|
||||
medicationCount: bpMedications.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Generate alerts
|
||||
const alerts = generateAlerts({
|
||||
bmi,
|
||||
bpAvgSys: summaries.BLOOD_PRESSURE_SYS?.avg30 ?? null,
|
||||
bpAvgDia: summaries.BLOOD_PRESSURE_DIA?.avg30 ?? null,
|
||||
bpPctInTarget,
|
||||
weightSlope30: summaries.WEIGHT?.slope30?.slope ?? null,
|
||||
pulseAvg30: summaries.PULSE?.avg30 ?? null,
|
||||
pulseAnomalyCount: summaries.PULSE?.anomalyCount ?? 0,
|
||||
medications: medCompliance.map((m) => ({
|
||||
name: m.name,
|
||||
compliance7: m.compliance7,
|
||||
compliance30: m.compliance30,
|
||||
})),
|
||||
});
|
||||
|
||||
// Data span — derived from the earliest 90-day measurement.
|
||||
const dataSpanDays = aggregate.firstMeasurementAt
|
||||
? Math.ceil(
|
||||
(Date.now() - aggregate.firstMeasurementAt.getTime()) /
|
||||
(24 * 60 * 60 * 1000),
|
||||
)
|
||||
: 0;
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.comprehensive" },
|
||||
meta: {
|
||||
totalMeasurements: aggregate.totalMeasurements,
|
||||
moodEntries: moodEntryCount,
|
||||
medications: medications.length,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
summaries,
|
||||
bmi,
|
||||
bmiClassification,
|
||||
bpClassification,
|
||||
bpPctInTarget,
|
||||
bpTargets,
|
||||
weightBpCorrelation,
|
||||
scatterData,
|
||||
bpMedicationCorrelation,
|
||||
bpMedicationScatterData,
|
||||
moodSummary,
|
||||
moodBpCorrelation,
|
||||
moodBpScatterData,
|
||||
moodWeightCorrelation,
|
||||
moodWeightScatterData,
|
||||
moodPulseCorrelation,
|
||||
moodPulseScatterData,
|
||||
medications: medCompliance,
|
||||
alerts,
|
||||
// Presence only: no client is built and no token is refreshed on a read.
|
||||
hasProvider: await probeProviderPresence(userId),
|
||||
dataSpanDays,
|
||||
totalMeasurements: aggregate.totalMeasurements,
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* v1.32.17 — derive exact local-day mean series from raw measurement rows,
|
||||
* keyed by the user's timezone via `userDayKey`. The mood series is
|
||||
* local-day-keyed (since v1.32.12), and `pairMoodWithDaily` joins on an
|
||||
* exact YYYY-MM-DD key, so its metric partners must be local-day-keyed too.
|
||||
* A UTC-day mean (from `dailyByType`) cannot be relabeled into a local-day
|
||||
* mean, so the mean is recomputed here from raw rows already on the request
|
||||
* path. `userDayKey` formats via a memoised `Intl.DateTimeFormat` — no
|
||||
* offset arithmetic — so DST-transition days (23 h / 25 h) bucket correctly
|
||||
* and the result is host-TZ-independent (CI runs UTC). `round2` mirrors the
|
||||
* aggregator's `ROUND(AVG, 2)` so correlation thresholds don't drift.
|
||||
* Returns `{ day, value }[]` sorted ASC by day, the shape
|
||||
* `pairMoodWithDaily` expects.
|
||||
*/
|
||||
function deriveDailyLocalMeans(
|
||||
rows: Array<{ measuredAt: Date; value: number }>,
|
||||
tz: string,
|
||||
): Array<{ day: string; value: number }> {
|
||||
const byDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const r of rows) {
|
||||
const key = userDayKey(r.measuredAt, tz);
|
||||
const cur = byDay.get(key) ?? { sum: 0, count: 0 };
|
||||
cur.sum += r.value;
|
||||
cur.count += 1;
|
||||
byDay.set(key, cur);
|
||||
}
|
||||
return Array.from(byDay.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([day, stats]) => ({
|
||||
day,
|
||||
value: Math.round((stats.sum / stats.count) * 100) / 100,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily-key inner join between two daily-aggregated series. Returns
|
||||
* `PairedPoint` shapes compatible with `significantPearsonCorrelation`.
|
||||
*
|
||||
* Inputs are sorted ASC by `day` (the aggregator's ORDER BY
|
||||
* guarantees that). The walk is O(n + m) merge-join style instead of
|
||||
* Map.lookup-per-element so it scales with the worst-case 90 buckets
|
||||
* x 90 buckets case without surprises.
|
||||
*/
|
||||
function joinDailyByDay(
|
||||
a: Array<{ day: string; value: number }>,
|
||||
b: Array<{ day: string; value: number }>,
|
||||
): PairedPoint[] {
|
||||
const pairs: PairedPoint[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < a.length && j < b.length) {
|
||||
const ai = a[i];
|
||||
const bj = b[j];
|
||||
if (ai.day === bj.day) {
|
||||
pairs.push({
|
||||
a: ai.value,
|
||||
b: bj.value,
|
||||
date: new Date(`${ai.day}T12:00:00.000Z`),
|
||||
});
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if (ai.day < bj.day) {
|
||||
i += 1;
|
||||
} else {
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair the daily mood series (left side) with a daily-aggregated
|
||||
* metric (right side). Matches the legacy `buildMoodMetricPairs`
|
||||
* helper byte-for-byte: same daily YYYY-MM-DD key, same
|
||||
* `T12:00:00.000Z` anchor for the `date` field.
|
||||
*/
|
||||
function pairMoodWithDaily(
|
||||
dailyMood: Array<{ day: string; value: number }>,
|
||||
dailyMetric: Array<{ day: string; value: number }>,
|
||||
): PairedPoint[] {
|
||||
if (dailyMood.length === 0 || dailyMetric.length === 0) return [];
|
||||
const metricByDay = new Map<string, number>();
|
||||
for (const m of dailyMetric) {
|
||||
metricByDay.set(m.day, m.value);
|
||||
}
|
||||
const pairs: PairedPoint[] = [];
|
||||
for (const mood of dailyMood) {
|
||||
const metric = metricByDay.get(mood.day);
|
||||
if (metric !== undefined) {
|
||||
pairs.push({
|
||||
a: mood.value,
|
||||
b: metric,
|
||||
date: new Date(`${mood.day}T12:00:00.000Z`),
|
||||
});
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
+128
-1109
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { generateMedicationComplianceStatusForUser } from "@/lib/insights/medication-compliance-status";
|
||||
import { normalizeLocale as resolveMedicationComplianceStatusLocale } from "@/lib/insights/status-shared";
|
||||
import { normalizeLocale } from "@/lib/insights/status-shared";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
@@ -40,7 +40,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
userLocale: user.locale ?? null,
|
||||
override: localeParam,
|
||||
});
|
||||
const locale = resolveMedicationComplianceStatusLocale(resolved);
|
||||
const locale = normalizeLocale(resolved);
|
||||
|
||||
// v1.8.3 — read-only: serve the cache, enqueue generation out of band on
|
||||
// a miss. The GET never awaits the provider, so opening /insights/<metric>
|
||||
|
||||
@@ -34,7 +34,7 @@ import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
|
||||
import { unavailableStatusBody } from "@/lib/insights/status-unavailable";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { generateMetricStatus } from "@/lib/insights/metric-status";
|
||||
import { normalizeLocale as resolveMetricStatusLocale } from "@/lib/insights/status-shared";
|
||||
import { normalizeLocale } from "@/lib/insights/status-shared";
|
||||
import {
|
||||
METRIC_STATUS_IDS,
|
||||
getMetricStatusMeta,
|
||||
@@ -132,7 +132,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
userLocale: user.locale ?? null,
|
||||
override: localeParam,
|
||||
});
|
||||
const locale = resolveMetricStatusLocale(resolved);
|
||||
const locale = normalizeLocale(resolved);
|
||||
|
||||
const result = await generateMetricStatus({
|
||||
metric,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { generateMoodStatusForUser } from "@/lib/insights/mood-status";
|
||||
import { normalizeLocale as resolveMoodStatusLocale } from "@/lib/insights/status-shared";
|
||||
import { normalizeLocale } from "@/lib/insights/status-shared";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
@@ -47,7 +47,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
userLocale: user.locale ?? null,
|
||||
override: localeParam,
|
||||
});
|
||||
const locale = resolveMoodStatusLocale(resolved);
|
||||
const locale = normalizeLocale(resolved);
|
||||
|
||||
// v1.8.3 — read-only: serve the cache, enqueue generation out of band on
|
||||
// a miss. The GET never awaits the provider, so opening /insights/<metric>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { generatePulseStatusForUser } from "@/lib/insights/pulse-status";
|
||||
import { normalizeLocale as resolvePulseStatusLocale } from "@/lib/insights/status-shared";
|
||||
import { normalizeLocale } from "@/lib/insights/status-shared";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
@@ -40,7 +40,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
userLocale: user.locale ?? null,
|
||||
override: localeParam,
|
||||
});
|
||||
const locale = resolvePulseStatusLocale(resolved);
|
||||
const locale = normalizeLocale(resolved);
|
||||
|
||||
// v1.8.3 — read-only: serve the cache, enqueue generation out of band on
|
||||
// a miss. The GET never awaits the provider, so opening /insights/<metric>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { generateWeightStatusForUser } from "@/lib/insights/weight-status";
|
||||
import { normalizeLocale as resolveWeightStatusLocale } from "@/lib/insights/status-shared";
|
||||
import { normalizeLocale } from "@/lib/insights/status-shared";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
@@ -40,7 +40,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
userLocale: user.locale ?? null,
|
||||
override: localeParam,
|
||||
});
|
||||
const locale = resolveWeightStatusLocale(resolved);
|
||||
const locale = normalizeLocale(resolved);
|
||||
|
||||
// v1.8.3 — read-only: serve the cache, enqueue generation out of band on
|
||||
// a miss. The GET never awaits the provider, so opening /insights/<metric>
|
||||
|
||||
@@ -14,10 +14,8 @@
|
||||
* window, judged by that direction) and `referenceLines` (the person's own
|
||||
* target lines, else the clinical ones, in the unit of the points).
|
||||
*/
|
||||
import { readDenseSeries } from "@/lib/measurements/dense-series-read";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
@@ -27,94 +25,9 @@ import {
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||
import { redactForExcerpt } from "@/lib/observability/redact-payload";
|
||||
import type { MeasurementType, SleepStage } from "@/generated/prisma/client";
|
||||
import { readSleepNights } from "@/lib/measurements/list/sleep-nights";
|
||||
import { VALUE_RANGES } from "@/lib/validations/measurement";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
|
||||
import { readingsMean } from "@/lib/measurements/day-mean";
|
||||
import { seriesRowsFrom } from "@/lib/measurements/series-canonical";
|
||||
import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql";
|
||||
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
|
||||
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
import { resolveTrendVerdict } from "@/lib/targets/trend-verdict";
|
||||
import {
|
||||
computeSeriesChange,
|
||||
seriesDayValues,
|
||||
seriesReferenceValue,
|
||||
} from "@/lib/measurements/series-change";
|
||||
import { resolveSeriesReferenceLines } from "@/lib/measurements/series-reference-lines";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { resolveJobLocale } from "@/lib/i18n/job-locale";
|
||||
import { READING_SPREAD, standardDeviation } from "@/lib/stats/spread";
|
||||
|
||||
/**
|
||||
* v1.28.25 — sample-dense kinds read raw rows only up to this window.
|
||||
* A CGM account stores ~288 BLOOD_GLUCOSE rows/day (105k+ rows/year) and
|
||||
* per-sample PULSE piles up similarly, so the 365 / 3650-day requests
|
||||
* walked a six-figure row set into JS, mapped it twice, and serialized
|
||||
* everything. Mirrors the sleep precedent above, but instead of silently
|
||||
* clamping the window, requests beyond the cap keep their full range and
|
||||
* are day-bucketed in SQL (one point per day, mean + min/max band) — the
|
||||
* chart still shows the whole requested history, just at daily grain.
|
||||
* The short windows users actually browse (7 / 30 / 90 d) stay raw and
|
||||
* byte-identical. Sparse kinds (weight, BP, …) are untouched.
|
||||
*/
|
||||
const DENSE_SERIES_RAW_WINDOW_DAYS = 90;
|
||||
/**
|
||||
* The kinds a device can write at sampling rate. Heart-rate variability and
|
||||
* blood oxygen joined pulse and glucose once watches and rings began writing
|
||||
* them every few minutes overnight: their 3650-day "Alle" range read every
|
||||
* raw row, which is the read this rule exists to stop.
|
||||
*/
|
||||
const DENSE_SERIES_KINDS: ReadonlySet<string> = new Set([
|
||||
"glucose",
|
||||
"pulse",
|
||||
"heartRateVariability",
|
||||
"oxygenSaturation",
|
||||
]);
|
||||
|
||||
/**
|
||||
* #1023 — inside the raw window, a dense-kind series (pulse, glucose, HRV,
|
||||
* blood oxygen) with more rows than this is bucketed per local hour in SQL
|
||||
* instead of sent raw.
|
||||
*
|
||||
* A watch that records heart rate once a minute puts 43 000 rows in the
|
||||
* default 30-day window and 130 000 in 90 days: a 15 MB response the client
|
||||
* could not draw at that resolution anyway, built from as many objects on the
|
||||
* server. A CGM reading every five minutes puts 26 000 glucose rows in 90
|
||||
* days, the same problem at a smaller scale. Hourly buckets carry the hour's
|
||||
* mean (pulse also its low/high band, the shape an hourly-bucket import
|
||||
* already has) and cap the answer at 24 points a day; the statistics stay
|
||||
* computed over every raw reading. The cap sits far above what sparse sources
|
||||
* produce (a cuff, fingerstick glucose, hourly buckets over 90 days, a watch
|
||||
* sampling every five minutes over 30 days), so those series stay raw and
|
||||
* unchanged.
|
||||
*/
|
||||
const DENSE_SERIES_RAW_ROW_CAP = 10_000;
|
||||
|
||||
const kindEnum = z.enum([
|
||||
"weight",
|
||||
"bloodPressure",
|
||||
"pulse",
|
||||
"bodyFat",
|
||||
"glucose",
|
||||
"sleep",
|
||||
"steps",
|
||||
"totalBodyWater",
|
||||
"boneMass",
|
||||
"oxygenSaturation",
|
||||
// v1.5.5 — the iOS app surfaces these as series-capable, but
|
||||
// the route used to reject the trend/detail view request with a
|
||||
// 422. The underlying MeasurementType enum already carries each
|
||||
// value; this list extends the camelCase wire shape so the route
|
||||
// matches the data it can serve.
|
||||
"restingHeartRate",
|
||||
"heartRateVariability",
|
||||
"vo2Max",
|
||||
]);
|
||||
import { kindEnum } from "@/lib/measurements/series/kinds";
|
||||
import { readSeries } from "@/lib/measurements/series/read-series";
|
||||
|
||||
const querySchema = z.object({
|
||||
kind: kindEnum,
|
||||
@@ -127,80 +40,6 @@ const querySchema = z.object({
|
||||
days: z.coerce.number().int().min(1).max(3650).optional().default(30),
|
||||
});
|
||||
|
||||
const KIND_TO_TYPE: Record<z.infer<typeof kindEnum>, MeasurementType> = {
|
||||
weight: "WEIGHT",
|
||||
bloodPressure: "BLOOD_PRESSURE_SYS",
|
||||
pulse: "PULSE",
|
||||
bodyFat: "BODY_FAT",
|
||||
glucose: "BLOOD_GLUCOSE",
|
||||
sleep: "SLEEP_DURATION",
|
||||
steps: "ACTIVITY_STEPS",
|
||||
totalBodyWater: "TOTAL_BODY_WATER",
|
||||
boneMass: "BONE_MASS",
|
||||
oxygenSaturation: "OXYGEN_SATURATION",
|
||||
restingHeartRate: "RESTING_HEART_RATE",
|
||||
heartRateVariability: "HEART_RATE_VARIABILITY",
|
||||
vo2Max: "VO2_MAX",
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.11.4 — explicit per-kind unit token returned at the top level so
|
||||
* the client never has to infer the value's unit. `bloodPressure` reuses
|
||||
* the systolic type's unit (mmHg, same for both lines). `sleep` is
|
||||
* overridden at request time to `"h"` because the route returns per-night
|
||||
* TIME-ASLEEP in hours rather than the canonical per-stage minutes.
|
||||
*
|
||||
* v1.16.16 — `glucose` is the canonical-stored unit (mg/dL) here; it is
|
||||
* overridden at request time to the user's `glucoseUnit` preference and the
|
||||
* point values are converted to match, so this wire DTO reads in the SAME
|
||||
* unit as the CSV + FHIR exports and the blood-glucose detail page (one
|
||||
* number, one engine).
|
||||
*/
|
||||
const SERIES_UNIT: Record<z.infer<typeof kindEnum>, string> = {
|
||||
weight: "kg",
|
||||
bloodPressure: "mmHg",
|
||||
pulse: "bpm",
|
||||
bodyFat: "%",
|
||||
glucose: "mg/dL",
|
||||
sleep: "h",
|
||||
steps: "steps",
|
||||
totalBodyWater: "kg",
|
||||
boneMass: "kg",
|
||||
oxygenSaturation: "%",
|
||||
restingHeartRate: "bpm",
|
||||
heartRateVariability: "ms",
|
||||
vo2Max: "mL/(kg·min)",
|
||||
};
|
||||
|
||||
interface SeriesPoint {
|
||||
id: string;
|
||||
at: string;
|
||||
value: number;
|
||||
secondary: number | null;
|
||||
/**
|
||||
* v1.11.4 — per-stage minutes for a sleep night point. `null` for
|
||||
* every non-sleep kind. Lets a sleep detail view render the night's
|
||||
* stage breakdown without a second fetch; the headline `value` stays
|
||||
* the night's TIME-ASLEEP total.
|
||||
*/
|
||||
sleepStages?: Partial<Record<SleepStage, number>> | null;
|
||||
/**
|
||||
* v1.19.2 (iOS #34 extension) — per-point spread for `kind=pulse`. On an
|
||||
* aggregated hourly heart-rate bucket `value` is the hour's AVERAGE bpm
|
||||
* and these carry the hour's MIN / MAX. `null` for a per-sample PULSE row
|
||||
* (no bucket spread) and absent for every non-pulse kind, so a client can
|
||||
* render a low/high band only where the data supports it.
|
||||
*/
|
||||
valueMin?: number | null;
|
||||
valueMax?: number | null;
|
||||
}
|
||||
|
||||
function stdDev(values: number[]): number {
|
||||
if (values.length < 2) return 0;
|
||||
const sd = standardDeviation(values, READING_SPREAD) ?? 0;
|
||||
return Math.round(sd * 100) / 100;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user, actor } = await requireRecordAuth("read", "measurements");
|
||||
|
||||
@@ -250,503 +89,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
const { kind, days } = parsed.data;
|
||||
const since = new Date(Date.now() - days * 86_400_000);
|
||||
|
||||
// v1.11.4 — explicit unit token so the client never infers the unit
|
||||
// from the kind. Sleep is special-cased below (per-night TIME-ASLEEP
|
||||
// in HOURS); every other kind carries its canonical stored unit.
|
||||
let unit = SERIES_UNIT[kind];
|
||||
|
||||
let points: SeriesPoint[] = [];
|
||||
// v1.16.16 — when glucose stats are computed over RAW mg/dL (parity with the
|
||||
// detail page + FHIR), the branch fills this so the response below skips the
|
||||
// generic over-points path that would double-round mmol/L figures.
|
||||
// v1.28.25 — the dense day-bucket branch also fills it (stats stay
|
||||
// aggregated over the RAW rows in SQL, not over the day-bucket points).
|
||||
let statsOverride: {
|
||||
mean: number;
|
||||
min: number;
|
||||
max: number;
|
||||
stdDev: number;
|
||||
count: number;
|
||||
} | null = null;
|
||||
if (kind === "sleep") {
|
||||
// SLEEP_DURATION is stored one row per STAGE per night (minutes).
|
||||
// Collapse the stage rows into ONE point per night carrying the
|
||||
// night's TIME ASLEEP (CORE + DEEP + REM, excluding IN_BED + AWAKE),
|
||||
// converted to HOURS. The single-stage rows the legacy path returned
|
||||
// made the chart show one fragment per stage instead of a nightly
|
||||
// trend. The per-night reconstruction clusters stages into sessions
|
||||
// (so a midnight-spanning night stays one point) and collapses a
|
||||
// dual-source night to one canonical source via the user's `sleep`
|
||||
// priority ladder.
|
||||
//
|
||||
// Sleep is the one kind read row-per-stage rather than from the rollup
|
||||
// tier, so the nights are named in Postgres and reconstructed a chunk at
|
||||
// a time (`readSleepNights`): "All" reaches as far back as the history
|
||||
// does, and the stage rows in the heap stay a chunk's worth.
|
||||
const [tz, priorityJson] = await Promise.all([
|
||||
resolveUserTimezone(user.id),
|
||||
loadUserSourcePriority(user.id),
|
||||
]);
|
||||
const nights = await readSleepNights(user.id, tz, priorityJson, {
|
||||
from: since,
|
||||
// A window of `days` days holds at most one night more than that.
|
||||
maxNights: days + 1,
|
||||
});
|
||||
unit = "h";
|
||||
points = nights
|
||||
.filter((n) => n.asleepMinutes > 0)
|
||||
.map((n) => {
|
||||
const stageHours = Object.fromEntries(
|
||||
Object.entries(n.stages).map(([s, m]) => [
|
||||
s,
|
||||
Math.round((m / 60) * 100) / 100,
|
||||
]),
|
||||
) as Partial<Record<SleepStage, number>>;
|
||||
return {
|
||||
id: `sleep:${n.night}`,
|
||||
at: n.measuredAt.toISOString(),
|
||||
value: Math.round((n.asleepMinutes / 60) * 100) / 100,
|
||||
secondary: null,
|
||||
sleepStages: Object.keys(stageHours).length > 0 ? stageHours : null,
|
||||
};
|
||||
});
|
||||
} else if (kind === "bloodPressure") {
|
||||
// Exclude non-physiological rows (systolic < 40 / diastolic < 20) from the
|
||||
// series AND the implicit "latest" (the last point). BP is two rows paired
|
||||
// client-side, so a corrupt row that slipped in before the input floor was
|
||||
// enforced — a seed-era systolic-0, iOS #33 — must never surface as the
|
||||
// latest reading. Floors mirror VALUE_RANGES (the input validator's min);
|
||||
// this is a read-side selection guard, not a tightening of the write floor.
|
||||
//
|
||||
// The ladder picks ONE source per day for the reading, not one per type:
|
||||
// the source is chosen from the day's systolic rows that pass the floor,
|
||||
// and both halves are read from it. Two per-type picks diverge whenever a
|
||||
// source holds only half of a reading on a day (a deleted diastolic, a
|
||||
// sub-floor systolic, an orphaned sample), and the pairing below then
|
||||
// joined one source's systolic to another source's diastolic. Picking on
|
||||
// floor-passing systolic rows also keeps a corrupt row from claiming a day
|
||||
// it then contributes nothing to. The day is the UTC day, as in every
|
||||
// other ladder read (`canonicalMeasurementsCte`), and the pick looks at
|
||||
// the whole edge day, not only the slice after `since`.
|
||||
//
|
||||
// `rank` is the closed-enum whitelist splice from `buildSourceRankCase`;
|
||||
// every request value is a positional parameter.
|
||||
const rank = buildSourceRankCase(
|
||||
await loadUserSourcePriority(user.id),
|
||||
'"type"',
|
||||
'"source"',
|
||||
);
|
||||
const bpRows = await prisma.$queryRawUnsafe<
|
||||
Array<{
|
||||
id: string;
|
||||
type: "BLOOD_PRESSURE_SYS" | "BLOOD_PRESSURE_DIA";
|
||||
source: string;
|
||||
value: number;
|
||||
measured_at: Date;
|
||||
}>
|
||||
>(
|
||||
`
|
||||
SELECT m."id", m."type"::text AS type, m."source"::text AS source,
|
||||
m."value", m."measured_at"
|
||||
FROM measurements m
|
||||
JOIN (
|
||||
SELECT DISTINCT ON (date_trunc('day', "measured_at"))
|
||||
date_trunc('day', "measured_at") AS d,
|
||||
"source" AS canon
|
||||
FROM measurements
|
||||
WHERE "user_id" = $1
|
||||
AND "type" = 'BLOOD_PRESSURE_SYS'::"measurement_type"
|
||||
AND "deleted_at" IS NULL
|
||||
AND "value" >= $3
|
||||
AND "measured_at" >= date_trunc('day', $2::timestamptz AT TIME ZONE 'UTC')
|
||||
ORDER BY date_trunc('day', "measured_at"), (${rank}), "source"::text
|
||||
) c
|
||||
ON c.d = date_trunc('day', m."measured_at")
|
||||
AND c.canon = m."source"
|
||||
WHERE m."user_id" = $1
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."measured_at" >= $2
|
||||
AND (
|
||||
(m."type" = 'BLOOD_PRESSURE_SYS'::"measurement_type" AND m."value" >= $3)
|
||||
OR (m."type" = 'BLOOD_PRESSURE_DIA'::"measurement_type" AND m."value" >= $4)
|
||||
)
|
||||
ORDER BY m."measured_at" ASC, m."id" ASC
|
||||
`,
|
||||
user.id,
|
||||
since,
|
||||
VALUE_RANGES.BLOOD_PRESSURE_SYS.min,
|
||||
VALUE_RANGES.BLOOD_PRESSURE_DIA.min,
|
||||
);
|
||||
const sys = bpRows.filter((r) => r.type === "BLOOD_PRESSURE_SYS");
|
||||
// Diastolic rows per source: a pair is only ever made inside one source,
|
||||
// which also holds across midnight, where the two days may have picked
|
||||
// different sources.
|
||||
const diaBySource = new Map<string, typeof bpRows>();
|
||||
for (const r of bpRows) {
|
||||
if (r.type !== "BLOOD_PRESSURE_DIA") continue;
|
||||
const list = diaBySource.get(r.source);
|
||||
if (list) list.push(r);
|
||||
else diaBySource.set(r.source, [r]);
|
||||
}
|
||||
|
||||
// Pair by closest timestamp within ±5 minutes. Both reads arrive
|
||||
// sorted ascending, so the nearest-diastolic index is non-decreasing
|
||||
// across the systolic walk — a two-pointer merge (one pointer per
|
||||
// source) finds each pair in O(sys + dia) instead of the previous full
|
||||
// diastolic rescan per systolic row (O(sys × dia) — quadratic on dense
|
||||
// imports). Ties keep the earlier diastolic row, matching the old
|
||||
// scan's strict-`<` keep.
|
||||
const PAIR_WINDOW_MS = 5 * 60_000;
|
||||
const diaIdx = new Map<string, number>();
|
||||
points = sys.map((s) => {
|
||||
const sysMs = s.measured_at.getTime();
|
||||
const dia = diaBySource.get(s.source) ?? [];
|
||||
let idx = diaIdx.get(s.source) ?? 0;
|
||||
while (
|
||||
idx + 1 < dia.length &&
|
||||
Math.abs(dia[idx + 1].measured_at.getTime() - sysMs) <
|
||||
Math.abs(dia[idx].measured_at.getTime() - sysMs)
|
||||
) {
|
||||
idx += 1;
|
||||
}
|
||||
diaIdx.set(s.source, idx);
|
||||
const best = dia[idx];
|
||||
const paired =
|
||||
best !== undefined &&
|
||||
Math.abs(best.measured_at.getTime() - sysMs) <= PAIR_WINDOW_MS;
|
||||
return {
|
||||
id: s.id,
|
||||
at: s.measured_at.toISOString(),
|
||||
value: s.value,
|
||||
secondary: paired ? best.value : null,
|
||||
};
|
||||
});
|
||||
} else if (
|
||||
(DENSE_SERIES_KINDS.has(kind) && days > DENSE_SERIES_RAW_WINDOW_DAYS) ||
|
||||
(DENSE_SERIES_KINDS.has(kind) &&
|
||||
(await prisma.measurement.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: KIND_TO_TYPE[kind],
|
||||
measuredAt: { gte: since },
|
||||
deletedAt: null,
|
||||
},
|
||||
})) > DENSE_SERIES_RAW_ROW_CAP)
|
||||
) {
|
||||
// Day buckets past the raw window; hour buckets for a dense-kind
|
||||
// stream too dense to send raw inside it.
|
||||
const grain = days > DENSE_SERIES_RAW_WINDOW_DAYS ? "day" : "hour";
|
||||
// v1.28.25 — long-window read of a sample-dense kind (CGM glucose,
|
||||
// per-sample / hourly pulse). Day-bucket in SQL instead of walking
|
||||
// every raw row into JS: one aggregate pass in Postgres returns at
|
||||
// most `days` rows regardless of sample density. Bound parameters
|
||||
// mirror the rollup tier's `date_trunc(... ) GROUP BY` aggregate
|
||||
// (measurement-rollups.ts), and the rows are the ladder-winning source per
|
||||
// day, the same rule the rollup tier and sleep apply.
|
||||
const [userTz, priorityJson] = await Promise.all([
|
||||
resolveUserTimezone(user.id),
|
||||
loadUserSourcePriority(user.id),
|
||||
]);
|
||||
const type = KIND_TO_TYPE[kind];
|
||||
const seriesRows = seriesRowsFrom(priorityJson, type, days);
|
||||
// A day of a type with an activity-dependent sampling rate (pulse) is the
|
||||
// weighted mean of its local hours' means, so a workout hour with hundreds
|
||||
// of times the readings does not outweigh the rest of the day (see
|
||||
// `day-statistic.ts`). The hour grain is already one mean per hour.
|
||||
const { bucketRows, rawAgg } = await readDenseSeries({
|
||||
userId: user.id,
|
||||
type,
|
||||
grain,
|
||||
userTz,
|
||||
since,
|
||||
seriesRows,
|
||||
});
|
||||
const round2 = (v: number) => Math.round(v * 100) / 100;
|
||||
const dayId = (d: Date) =>
|
||||
grain === "day"
|
||||
? `day:${userDayKey(d, userTz)}`
|
||||
: `hour:${d.toISOString()}`;
|
||||
if (kind === "glucose") {
|
||||
// Same unit engine as the raw glucose branch: canonical mg/dL in
|
||||
// the DB, converted ONCE at serialization to the user's preference.
|
||||
const profile = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { glucoseUnit: true },
|
||||
});
|
||||
const glucoseUnit = resolveGlucoseUnit(profile?.glucoseUnit ?? null);
|
||||
unit = glucoseUnit;
|
||||
points = bucketRows.map((r) => ({
|
||||
id: dayId(r.bucket_start),
|
||||
at: r.bucket_start.toISOString(),
|
||||
value: convertGlucose(r.mean, glucoseUnit),
|
||||
secondary: null,
|
||||
}));
|
||||
if (
|
||||
rawAgg !== undefined &&
|
||||
rawAgg.n > 0 &&
|
||||
rawAgg.mean !== null &&
|
||||
rawAgg.min !== null &&
|
||||
rawAgg.max !== null
|
||||
) {
|
||||
// Mirrors the raw branch's v1.16.16 parity: round the mg/dL
|
||||
// aggregate to 2 decimals (summarize()'s convention), then
|
||||
// convert each figure once.
|
||||
statsOverride = {
|
||||
mean: convertGlucose(round2(rawAgg.mean), glucoseUnit),
|
||||
min: convertGlucose(rawAgg.min, glucoseUnit),
|
||||
max: convertGlucose(rawAgg.max, glucoseUnit),
|
||||
stdDev: convertGlucose(round2(rawAgg.sd ?? 0), glucoseUnit),
|
||||
count: rawAgg.n,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// `value` is the bucket's average. For pulse, valueMin/valueMax carry
|
||||
// the bucket's low/high band (folding each hourly import bucket's own
|
||||
// spread via the COALESCE above), same band semantics as the per-hour
|
||||
// shape. The other dense kinds keep the bare point shape their raw rows
|
||||
// have, so a client reads a bucket the way it reads a reading.
|
||||
points = bucketRows.map((r) => ({
|
||||
id: dayId(r.bucket_start),
|
||||
at: r.bucket_start.toISOString(),
|
||||
value: round2(r.mean),
|
||||
secondary: null,
|
||||
...(kind === "pulse"
|
||||
? { valueMin: r.min_value, valueMax: r.max_value }
|
||||
: {}),
|
||||
}));
|
||||
if (
|
||||
rawAgg !== undefined &&
|
||||
rawAgg.n > 0 &&
|
||||
rawAgg.mean !== null &&
|
||||
rawAgg.min !== null &&
|
||||
rawAgg.max !== null
|
||||
) {
|
||||
statsOverride = {
|
||||
mean: round2(rawAgg.mean),
|
||||
min: rawAgg.min,
|
||||
max: rawAgg.max,
|
||||
stdDev: round2(rawAgg.sd ?? 0),
|
||||
count: rawAgg.n,
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const type = KIND_TO_TYPE[kind];
|
||||
// v1.19.2 (iOS #34 extension) — pull the per-bucket spread for the
|
||||
// heart-rate kind so the chart can render a low/high band around the
|
||||
// hourly average. Only PULSE rows ever carry a non-null spread (the
|
||||
// hourly HR bucket); every other kind selects the bare value shape.
|
||||
const includeSpread = kind === "pulse";
|
||||
// The ladder-winning source per day (see `seriesRowsFrom`), so a second
|
||||
// provider neither blends into nor duplicates the series. Bounded as before:
|
||||
// every dense kind reaches this read only inside 90 days and under 10 000
|
||||
// rows (counted first), and the sparse kinds are a few readings a day.
|
||||
const sourcePriority = await loadUserSourcePriority(user.id);
|
||||
const rawRows = await prisma.$queryRawUnsafe<
|
||||
Array<{
|
||||
id: string;
|
||||
value: number;
|
||||
measured_at: Date;
|
||||
value_min: number | null;
|
||||
value_max: number | null;
|
||||
}>
|
||||
>(
|
||||
`
|
||||
SELECT m."id", m."value", m."measured_at", m."value_min", m."value_max"
|
||||
FROM ${seriesRowsFrom(sourcePriority, type, days)}
|
||||
WHERE m."measured_at" >= $2
|
||||
ORDER BY m."measured_at" ASC, m."id" ASC
|
||||
`,
|
||||
user.id,
|
||||
since,
|
||||
);
|
||||
const rows = rawRows.map((r) => ({
|
||||
id: r.id,
|
||||
value: r.value,
|
||||
measuredAt: r.measured_at,
|
||||
...(includeSpread
|
||||
? { valueMin: r.value_min, valueMax: r.value_max }
|
||||
: {}),
|
||||
}));
|
||||
if (kind === "glucose") {
|
||||
// v1.16.16 — glucose is stored canonical mg/dL; convert each point to
|
||||
// the user's display unit AT SERIALIZATION so the wire DTO is unit-
|
||||
// coherent with the CSV + FHIR exports (one number, one engine). The
|
||||
// top-level `unit` token follows. mg/dL-preference users are unchanged
|
||||
// (convertGlucose rounds the integer); mmol/L users see 1-decimal
|
||||
// values (100 → 5.5) and `unit: "mmol/L"`.
|
||||
const profile = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { glucoseUnit: true },
|
||||
});
|
||||
const glucoseUnit = resolveGlucoseUnit(profile?.glucoseUnit ?? null);
|
||||
unit = glucoseUnit;
|
||||
points = rows.map((r) => ({
|
||||
id: r.id,
|
||||
at: r.measuredAt.toISOString(),
|
||||
value: convertGlucose(r.value, glucoseUnit),
|
||||
secondary: null,
|
||||
}));
|
||||
// v1.16.16 — stat parity. The stat strip's mean/min/max/stdDev must
|
||||
// match the detail-page + FHIR convention: aggregate over RAW mg/dL,
|
||||
// then convert each resulting figure ONCE. Deriving the stats from the
|
||||
// already-converted+rounded points double-rounds the mean and stdDev
|
||||
// for mmol/L users, drifting the last decimal off the detail page.
|
||||
// Single-reading sets stay identical (100 → 5.5). Hand back stats here
|
||||
// so the shared block below leaves a populated `statsOverride` alone.
|
||||
const rawDataPoints: DataPoint[] = rows.map((r) => ({
|
||||
date: r.measuredAt,
|
||||
value: r.value,
|
||||
}));
|
||||
if (rawDataPoints.length > 0) {
|
||||
const rawSummary = summarize(rawDataPoints);
|
||||
const rawValues = rawDataPoints.map((p) => p.value);
|
||||
statsOverride = {
|
||||
mean:
|
||||
rawSummary.mean === null
|
||||
? 0
|
||||
: convertGlucose(rawSummary.mean, glucoseUnit),
|
||||
min:
|
||||
rawSummary.min === null
|
||||
? 0
|
||||
: convertGlucose(rawSummary.min, glucoseUnit),
|
||||
max:
|
||||
rawSummary.max === null
|
||||
? 0
|
||||
: convertGlucose(rawSummary.max, glucoseUnit),
|
||||
// Glucose mg/dL↔mmol/L is purely multiplicative (no offset), so a
|
||||
// spread converts with the same factor as a level.
|
||||
stdDev: convertGlucose(stdDev(rawValues), glucoseUnit),
|
||||
count: rawSummary.count,
|
||||
};
|
||||
}
|
||||
} else if (includeSpread) {
|
||||
// v1.19.2 (iOS #34 extension) — pulse points carry the per-bucket
|
||||
// spread. A per-sample PULSE row has null min/max; an hourly HR bucket
|
||||
// carries the hour's range around the average.
|
||||
points = rows.map((r) => {
|
||||
const row = r as typeof r & {
|
||||
valueMin: number | null;
|
||||
valueMax: number | null;
|
||||
};
|
||||
return {
|
||||
id: row.id,
|
||||
at: row.measuredAt.toISOString(),
|
||||
value: row.value,
|
||||
secondary: null,
|
||||
valueMin: row.valueMin ?? null,
|
||||
valueMax: row.valueMax ?? null,
|
||||
};
|
||||
});
|
||||
// An hourly-mean type's strip mean is the mean of its local days'
|
||||
// values, each day the mean of its local hours' means, so a dense
|
||||
// workout hour does not outweigh the rest of the window. Count, min,
|
||||
// max and the spread stay over every reading.
|
||||
if (usesHourlyMeanDay(type) && rows.length > 0) {
|
||||
const userTz = await resolveUserTimezone(user.id);
|
||||
const values = rows.map((r) => r.value);
|
||||
const rawSummary = summarize(
|
||||
rows.map((r) => ({ date: r.measuredAt, value: r.value })),
|
||||
);
|
||||
const mean = readingsMean(type, rows, userTz);
|
||||
statsOverride = {
|
||||
mean: mean === null ? 0 : Math.round(mean * 100) / 100,
|
||||
min: rawSummary.min ?? 0,
|
||||
max: rawSummary.max ?? 0,
|
||||
stdDev: stdDev(values),
|
||||
count: rawSummary.count,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
points = rows.map((r) => ({
|
||||
id: r.id,
|
||||
at: r.measuredAt.toISOString(),
|
||||
value: r.value,
|
||||
secondary: null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const dataPoints: DataPoint[] = points.map((p) => ({
|
||||
date: new Date(p.at),
|
||||
value: p.value,
|
||||
}));
|
||||
const summary = dataPoints.length > 0 ? summarize(dataPoints) : null;
|
||||
const values = dataPoints.map((p) => p.value);
|
||||
|
||||
// v1.43 — the resolved judgements. The profile and targets are the
|
||||
// RECORD's (the chart is theirs); the labels are in the language of the
|
||||
// account reading it.
|
||||
const [record, reader, seriesTz] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
heightCm: true,
|
||||
thresholdsJson: true,
|
||||
glucoseUnit: true,
|
||||
locale: true,
|
||||
},
|
||||
}),
|
||||
actor.id === user.id
|
||||
? null
|
||||
: prisma.user.findUnique({
|
||||
where: { id: actor.id },
|
||||
select: { locale: true },
|
||||
}),
|
||||
resolveUserTimezone(user.id),
|
||||
]);
|
||||
const rangeProfile = {
|
||||
dateOfBirth: record?.dateOfBirth ?? null,
|
||||
gender: record?.gender ?? null,
|
||||
heightCm: record?.heightCm ?? null,
|
||||
};
|
||||
const verdictType = KIND_TO_TYPE[kind];
|
||||
const dayValues = seriesDayValues(verdictType, points, seriesTz);
|
||||
const reference = seriesReferenceValue(dayValues);
|
||||
const direction = resolveTrendVerdict(verdictType, {
|
||||
profile: rangeProfile,
|
||||
thresholdsJson: record?.thresholdsJson ?? null,
|
||||
// The verdict reads stored units: sleep is served in hours here and
|
||||
// stored in minutes. Glucose has no band of its own on this chart.
|
||||
referenceValue:
|
||||
reference !== null && kind === "sleep" ? reference * 60 : reference,
|
||||
});
|
||||
const translator = getServerTranslator(
|
||||
await resolveJobLocale((reader ?? record)?.locale ?? null),
|
||||
return apiSuccess(
|
||||
await readSeries({ userId: user.id, actorId: actor.id, kind, days }),
|
||||
);
|
||||
const referenceLines = resolveSeriesReferenceLines(kind, {
|
||||
profile: rangeProfile,
|
||||
thresholdsJson: record?.thresholdsJson ?? null,
|
||||
glucoseUnit: resolveGlucoseUnit(record?.glucoseUnit ?? null),
|
||||
t: (key) => translator.t(key),
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "measurements.series" },
|
||||
meta: { kind, days, count: points.length },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
kind,
|
||||
unit,
|
||||
points,
|
||||
direction,
|
||||
change: computeSeriesChange(dayValues, direction),
|
||||
referenceLines,
|
||||
stats:
|
||||
statsOverride ??
|
||||
(summary
|
||||
? {
|
||||
mean: summary.mean,
|
||||
min: summary.min,
|
||||
max: summary.max,
|
||||
stdDev: stdDev(values),
|
||||
count: summary.count,
|
||||
}
|
||||
: { mean: 0, min: 0, max: 0, stdDev: 0, count: 0 }),
|
||||
});
|
||||
});
|
||||
@@ -64,7 +64,7 @@ export const GET = apiHandler(
|
||||
const { id } = await params;
|
||||
|
||||
// v1.5.5 — close the §10 invariant 24 gap. The destructive
|
||||
// sweep in af224964 lifted every sibling `[id]/**` handler onto the
|
||||
// sweep in af224964 lifted every sibling `[id]` handler onto the
|
||||
// shared ownership helper; the glp1 GET still hand-rolled the
|
||||
// `findUnique + userId compare` pattern. Routing it through the
|
||||
// same predicate keeps the 404 leak shape identical across every
|
||||
|
||||
@@ -105,7 +105,7 @@ export const POST = apiHandler(
|
||||
|
||||
const { id } = await params;
|
||||
// v1.5.5 — route ownership through the shared helper so the
|
||||
// 404 leak shape stays identical across every `[id]/**` handler.
|
||||
// 404 leak shape stays identical across every `[id]` handler.
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -18,32 +17,13 @@ import {
|
||||
unstableExternalIdMeta,
|
||||
unstableExternalIdShape,
|
||||
} from "@/lib/validations/external-id";
|
||||
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
|
||||
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import {
|
||||
consumeForIntake,
|
||||
restoreForIntake,
|
||||
} from "@/lib/medications/inventory/consumption";
|
||||
import { reconcileOneShotState } from "@/lib/medications/lifecycle";
|
||||
import {
|
||||
assertMedicationOwnership,
|
||||
assertMedicationTakesIntake,
|
||||
} from "@/lib/medications/route-guards";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
|
||||
import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear";
|
||||
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
|
||||
import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake";
|
||||
import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh";
|
||||
import {
|
||||
applyCanonicalSlotWrite,
|
||||
findPinConflict,
|
||||
mayConvergeOntoSuppliedSlot,
|
||||
resolveForcedSlotForWrite,
|
||||
resolveSlotForWriteByBand,
|
||||
resolveSlotInstantForWrite,
|
||||
} from "@/lib/medications/scheduling/slot-upsert";
|
||||
import { recordIntake } from "@/lib/medications/record-intake/record-intake";
|
||||
import { listIntakeEvents } from "@/lib/medications/list-intake-events";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
@@ -53,25 +33,25 @@ export const POST = apiHandler(
|
||||
);
|
||||
|
||||
async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
// v1.36.x — a delegated write, the per-medication form of the same verb.
|
||||
// A delegated write, the per-medication form of the same verb.
|
||||
// `user` is the record the dose belongs to; `actor` is whoever pressed the
|
||||
// button, and is used for nothing but the owner's notification below.
|
||||
// button, and is used for nothing but the owner's notification.
|
||||
const { user, actor, authMethod } = await requireRecordAuth(
|
||||
"write",
|
||||
"medications",
|
||||
);
|
||||
|
||||
// v1.36.1 — the sibling route `POST /api/medications/intake`
|
||||
// refuses a delegate changing a dose the owner already recorded. This route
|
||||
// reaches the same rows through the slot upsert, so without the same rule it
|
||||
// is simply the way round the other one: an explicit skip posted onto a
|
||||
// taken slot flips the outcome and refunds the inventory the take consumed.
|
||||
// The flag rides into `applyCanonicalSlotWrite`, which is where the existing
|
||||
// row is already loaded, so the check adds no query and cannot race.
|
||||
// The sibling route `POST /api/medications/intake` refuses a delegate
|
||||
// changing a dose the owner already recorded. This route reaches the same
|
||||
// rows through the slot upsert, so without the same rule it is simply the
|
||||
// way round the other one: an explicit skip posted onto a taken slot flips
|
||||
// the outcome and refunds the inventory the take consumed. The flag rides
|
||||
// into `applyCanonicalSlotWrite`, which is where the existing row is
|
||||
// already loaded, so the check adds no query and cannot race.
|
||||
const isDelegated = actor.id !== user.id;
|
||||
|
||||
/**
|
||||
* The refusal itself, shared by both upsert call sites so the two cannot
|
||||
* The refusal itself, answered for both upsert call sites so the two cannot
|
||||
* drift apart. Files under the OWNER — `auditLog` stamps the actor from the
|
||||
* request's acting context — so a refused attempt reaches the activity panel
|
||||
* the same way the sibling route's does, under the same action name.
|
||||
@@ -92,7 +72,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
{ errorCode: "sharing.not_permitted" },
|
||||
);
|
||||
}
|
||||
// v1.32.8 (iOS #64) — write provenance derived from the transport, never
|
||||
// Write provenance derived from the transport (iOS #64), never
|
||||
// client-asserted: a Bearer/native call stores `API`, a cookie/browser call
|
||||
// stores `WEB`. The other `IntakeSource` values are producer-owned by OTHER
|
||||
// routes (the Telegram worker mints `REMINDER`, the bulk/import paths mint
|
||||
@@ -103,8 +83,8 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
const intakeSource = authMethod === "bearer" ? "API" : "WEB";
|
||||
|
||||
const { id } = await params;
|
||||
// v1.4.25 — privacy gate hoisted to the shared helper. This is a
|
||||
// dose write, so the guard also refuses a record-only medication with
|
||||
// Privacy gate through the shared helper. This is a dose write, so the
|
||||
// guard also refuses a record-only medication with
|
||||
// `medication.intake.notTracked` (iOS #116, item 15): its history stays,
|
||||
// but a new dose would be stored and then counted nowhere.
|
||||
const guard = await assertMedicationTakesIntake(id, user.id);
|
||||
@@ -120,8 +100,8 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
medicationId: id,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
// v1.4.43 — per-med intake POST hot path; multi-issue 422 +
|
||||
// audit breadcrumb keyed `medications.intake.create.validation-failed`.
|
||||
// Per-med intake POST hot path; multi-issue 422 + audit breadcrumb keyed
|
||||
// `medications.intake.create.validation-failed`.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
const shape = unstableExternalIdShape(body, "idempotencyKey");
|
||||
annotate({
|
||||
@@ -134,12 +114,12 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
// v1.4.49 — strip `message` from the audit-ledger row; the
|
||||
// intake payload carries `idempotencyKey` (opaque caller string).
|
||||
// Strip `message` from the audit-ledger row; the intake payload carries
|
||||
// `idempotencyKey` (opaque caller string).
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
// v1.36.x — through `auditLog()`, the only writer that stamps the actor.
|
||||
// Through `auditLog()`, the only writer that stamps the actor.
|
||||
void auditLog("medications.intake.create.validation-failed", {
|
||||
userId: user.id,
|
||||
details: { issues: auditIssues, medicationId: id },
|
||||
@@ -149,515 +129,28 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const {
|
||||
scheduledFor,
|
||||
takenAt,
|
||||
skipped,
|
||||
idempotencyKey,
|
||||
injectionSite,
|
||||
forceSlotInstant,
|
||||
doseTaken,
|
||||
} = parsed.data;
|
||||
|
||||
// v1.16.4 — the dose override documents a consumed dose, so it only
|
||||
// applies to a taken (non-skipped) write; on a skip it is silently
|
||||
// dropped (the slot consumed nothing).
|
||||
const resolvedDoseTaken = !skipped && doseTaken ? doseTaken : null;
|
||||
|
||||
// v1.8.5 — resolve + server-validate the optional injection site. Load
|
||||
// the medication's delivery form + tracking opt-in + per-medication
|
||||
// allowed sites, plus the user's global exclusion deny-list. A site
|
||||
// outside the effective allowed set is a hard 422; a site on a
|
||||
// non-injection / tracking-off med (or a skip) is silently dropped.
|
||||
let resolvedInjectionSite: InjectionSiteKey | null = null;
|
||||
if (injectionSite !== undefined) {
|
||||
const [med, userRow] = await Promise.all([
|
||||
prisma.medication.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
deliveryForm: true,
|
||||
trackInjectionSites: true,
|
||||
allowedInjectionSites: true,
|
||||
},
|
||||
}),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { globalExcludedInjectionSites: true },
|
||||
}),
|
||||
]);
|
||||
const resolution = resolveInjectionSiteForWrite({
|
||||
submitted: injectionSite,
|
||||
taken: !skipped,
|
||||
deliveryForm: med?.deliveryForm ?? "ORAL",
|
||||
trackInjectionSites: med?.trackInjectionSites ?? false,
|
||||
allowedInjectionSites: (med?.allowedInjectionSites ??
|
||||
[]) as InjectionSiteKey[],
|
||||
globalExcludedInjectionSites: (userRow?.globalExcludedInjectionSites ??
|
||||
[]) as InjectionSiteKey[],
|
||||
});
|
||||
if (resolution.kind === "disallowed") {
|
||||
annotate({
|
||||
action: { name: "medication.intake.injection_site.disallowed" },
|
||||
meta: { medication_id: id, site: resolution.site },
|
||||
});
|
||||
return apiError(
|
||||
"Injection site is not allowed for this medication",
|
||||
422,
|
||||
{
|
||||
errorCode: "medications.intake.injection_site.disallowed",
|
||||
},
|
||||
);
|
||||
}
|
||||
resolvedInjectionSite = resolution.site;
|
||||
}
|
||||
|
||||
const resolvedTakenAt = skipped ? null : (takenAt ?? new Date());
|
||||
const incomingScheduledFor = scheduledFor ?? takenAt ?? new Date();
|
||||
// The per-med route only ever carries an explicit user gesture:
|
||||
// `resolvedTakenAt` is `now()` for any non-skip POST (there is no
|
||||
// "mark pending" write on this route), so a non-skip is an explicit
|
||||
// taken and a `skipped:true` body is an explicit skip. Neither is a
|
||||
// pending projection echo, so the no-downgrade guard never trips here —
|
||||
// it is the bulk/sync route that replays pending echoes. The flags are
|
||||
// threaded through so the shared upsert applies last-write-wins.
|
||||
const isExplicitTaken = !skipped;
|
||||
const isExplicitSkip = skipped === true;
|
||||
|
||||
// v1.15.18 — window-band slot attribution (replaces the wide ±6h
|
||||
// `snapToleranceMs` nearest-snap). The take is bound to a slot by
|
||||
// membership in that slot's configurable dose window — the SAME bands the
|
||||
// read ledger + the compliance % consume, so the three surfaces can never
|
||||
// disagree. A take that lands in no window is ad-hoc (`canonicalSlot` null)
|
||||
// and records as a standalone "taken now" row; PRN meds always insert
|
||||
// standalone. Resolved BEFORE the idempotency/dedup window so a scheduled
|
||||
// dose routes through the slot upsert (which is itself the dedup).
|
||||
//
|
||||
// - SKIP: a skip is logged against a slot deliberately (it carries no
|
||||
// `takenAt` to attribute by), so it keeps the canonical `scheduledFor`
|
||||
// snap that binds it to the slot's pending REMINDER row.
|
||||
// - TAKEN: attribute by `takenAt` band membership. The optional
|
||||
// `forceSlotInstant` pins an off-window take onto a chosen real slot
|
||||
// ("diesem Slot zuordnen?"); a pin that is not a real slot is a 422.
|
||||
let canonicalSlot: Date | null = null;
|
||||
// v1.15.20 — binding provenance for the written row: USER_PIN on the
|
||||
// forced "diesem Slot zuordnen" path, AUTO when band attribution decided.
|
||||
// Skips carry no binding decision (undefined → column untouched/default).
|
||||
let attributionSource: "AUTO" | "USER_PIN" | undefined;
|
||||
if (skipped) {
|
||||
if (scheduledFor === undefined) {
|
||||
// v1.16.9 — a slot-less deliberate skip resolves by band membership
|
||||
// on the skip moment, like a take does. The defaulted-now snap only
|
||||
// reached the tight reminder-grace window, so a skip tapped past it
|
||||
// landed as an orphan ad-hoc row while the slot's pending REMINDER
|
||||
// row stayed open to auto-miss.
|
||||
const attribution = await resolveSlotForWriteByBand({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
userTz: user.timezone,
|
||||
takenAt: incomingScheduledFor,
|
||||
});
|
||||
canonicalSlot = attribution.slotInstant;
|
||||
} else {
|
||||
canonicalSlot = await resolveSlotInstantForWrite({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
userTz: user.timezone,
|
||||
incoming: incomingScheduledFor,
|
||||
instantIsExplicit: true,
|
||||
isTakenWrite: false,
|
||||
});
|
||||
}
|
||||
} else if (forceSlotInstant !== undefined) {
|
||||
canonicalSlot = await resolveForcedSlotForWrite({
|
||||
userId: user.id,
|
||||
const outcome = await recordIntake(
|
||||
{
|
||||
user,
|
||||
actorId: actor.id,
|
||||
medicationId: id,
|
||||
userTz: user.timezone,
|
||||
slotInstant: forceSlotInstant,
|
||||
});
|
||||
if (canonicalSlot === null) {
|
||||
annotate({
|
||||
action: { name: "medication.intake.force_slot.invalid" },
|
||||
meta: { medication_id: id },
|
||||
});
|
||||
return apiError(
|
||||
"forceSlotInstant is not a scheduled slot of this medication",
|
||||
422,
|
||||
{ errorCode: "medications.intake.force_slot.invalid" },
|
||||
);
|
||||
}
|
||||
// v1.16.0 — refuse to pin onto a slot another recorded action already
|
||||
// serves: the explicit-write last-write-wins rule would silently
|
||||
// overwrite that dose record. The ledger UI only offers the pin for
|
||||
// unserved slots, so this only fires for stale clients / raw API calls.
|
||||
if (
|
||||
await findPinConflict({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
canonicalSlot,
|
||||
incomingTakenAt: resolvedTakenAt ?? null,
|
||||
})
|
||||
) {
|
||||
annotate({
|
||||
action: { name: "medication.intake.force_slot.occupied" },
|
||||
meta: { medication_id: id },
|
||||
});
|
||||
return apiError(
|
||||
"forceSlotInstant already carries a recorded dose action",
|
||||
422,
|
||||
{ errorCode: "medications.intake.force_slot.occupied" },
|
||||
);
|
||||
}
|
||||
attributionSource = "USER_PIN";
|
||||
} else {
|
||||
// A non-skip write on this route always carries a `takenAt` (defaulted to
|
||||
// now), so `resolvedTakenAt` is non-null here; the fallback only guards the
|
||||
// type.
|
||||
const attribution = await resolveSlotForWriteByBand({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
userTz: user.timezone,
|
||||
takenAt: resolvedTakenAt ?? incomingScheduledFor,
|
||||
});
|
||||
canonicalSlot = attribution.slotInstant;
|
||||
// A band decision (slot or ad-hoc) is an AUTO binding; it also resets a
|
||||
// stale USER_PIN when this write converges onto a previously-pinned row.
|
||||
attributionSource = "AUTO";
|
||||
|
||||
// The client can NAME the dose it is recording, and when it does, that
|
||||
// answer outranks the band. The Verlauf ledger's "Genommen" posts the
|
||||
// displayed slot's own anchor, and the medication card posts its
|
||||
// display-due slot; both mean "record THIS dose". Band attribution answers
|
||||
// a different question — which slot the take's clock time falls into — so
|
||||
// backfilling a slot from an earlier day matched no band, fell through to
|
||||
// the standalone insert, and left the named slot unserved: the entry
|
||||
// reverted to missed on the next read and the dose the user recorded was
|
||||
// filed on the wrong day.
|
||||
//
|
||||
// Only a REAL slot of this medication is honoured (the same band-anchor
|
||||
// validation the "diesem Slot zuordnen" pin runs), so an arbitrary client
|
||||
// instant still records ad-hoc rather than parking a live row on a
|
||||
// fabricated anchor. And only a slot that does not sit ahead of the take,
|
||||
// by the same forward guard the convergence probe below uses — a
|
||||
// late-morning dose must never consume the evening slot.
|
||||
if (
|
||||
scheduledFor !== undefined &&
|
||||
mayConvergeOntoSuppliedSlot({
|
||||
skipped,
|
||||
takenAt: resolvedTakenAt,
|
||||
suppliedSlot: incomingScheduledFor,
|
||||
})
|
||||
) {
|
||||
const namedSlot = await resolveForcedSlotForWrite({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
userTz: user.timezone,
|
||||
slotInstant: incomingScheduledFor,
|
||||
});
|
||||
// Only when the naming actually decides something the clock did not.
|
||||
// A dose taken inside its own window resolves to the same anchor either
|
||||
// way and stays an AUTO binding, so an ordinary on-time take is
|
||||
// unchanged. When the two disagree the binding rests on the user's
|
||||
// choice, which is what USER_PIN records — and the read ledger binds a
|
||||
// USER_PIN take by its stored anchor rather than by the clock, so a
|
||||
// backfilled dose reads under the slot it was recorded for instead of
|
||||
// falling back to ad-hoc.
|
||||
if (
|
||||
namedSlot !== null &&
|
||||
namedSlot.getTime() !== canonicalSlot?.getTime()
|
||||
) {
|
||||
canonicalSlot = namedSlot;
|
||||
attributionSource = "USER_PIN";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Idempotency check (explicit key or server-side dedup window)
|
||||
if (idempotencyKey) {
|
||||
const existing = await prisma.medicationIntakeEvent.findFirst({
|
||||
where: {
|
||||
idempotencyKey,
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
return apiSuccess(existing);
|
||||
}
|
||||
} else if (!canonicalSlot) {
|
||||
// Unscheduled / PRN only — the slot upsert handles dedup for
|
||||
// scheduled doses by collapsing onto the canonical slot row.
|
||||
// Server-side dedup: prevent double-logging within 60 seconds.
|
||||
const recentDuplicate = await prisma.medicationIntakeEvent.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
skipped,
|
||||
createdAt: { gte: new Date(Date.now() - 60_000) },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
if (recentDuplicate) {
|
||||
return apiSuccess(recentDuplicate);
|
||||
}
|
||||
}
|
||||
|
||||
let event;
|
||||
// v1.8.2 — whether this write moved the slot pending→taken.
|
||||
// Only that transition decrements pen inventory (M2). For the
|
||||
// unscheduled/PRN branch a non-skip write always records a fresh dose,
|
||||
// so it consumes when not skipped.
|
||||
let consumedTransition = !skipped;
|
||||
if (canonicalSlot) {
|
||||
// Scheduled dose — converge onto the one canonical slot row regardless
|
||||
// of `source` (the pending REMINDER row, or any prior row for this
|
||||
// slot) through the shared upsert: H1 deterministic selection, C2
|
||||
// no-downgrade guard, and a C1 race-safe create that re-finds + updates
|
||||
// on a P2002 collision rather than 500-ing or duplicating.
|
||||
const applied = await applyCanonicalSlotWrite({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
canonicalSlot,
|
||||
takenAt: resolvedTakenAt,
|
||||
skipped,
|
||||
isExplicitTaken,
|
||||
isExplicitSkip,
|
||||
idempotencyKey: idempotencyKey ?? null,
|
||||
createSource: intakeSource,
|
||||
refuseOutcomeChange: isDelegated,
|
||||
// v1.8.5 — resolved + validated site (null unless a tracking-on
|
||||
// injection taken write supplied an allowed site).
|
||||
injectionSite: resolvedInjectionSite,
|
||||
attributionSource,
|
||||
// v1.16.4 — per-intake dose override (null unless a taken write
|
||||
// carried one).
|
||||
doseTaken: resolvedDoseTaken,
|
||||
});
|
||||
if (applied.outcomeChangeRefused) return refuseOutcomeChange();
|
||||
event = applied.row;
|
||||
consumedTransition = applied.consumedTransition;
|
||||
// Reset the snooze when a dose is actually recorded (not on a
|
||||
// no-downgrade no-op, which left the prior taken row untouched).
|
||||
if (!skipped && !applied.noDowngradeNoOp) {
|
||||
await prisma.medication.update({
|
||||
where: { id },
|
||||
data: { snoozedUntil: null },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Unscheduled / PRN / off-slot. When the client named an explicit
|
||||
// `scheduledFor`, converge source-agnostically onto any live row that
|
||||
// already sits on that instant (e.g. the pending REMINDER row the
|
||||
// worker minted on a slot the band attribution did not claim) before
|
||||
// inserting. Without the probe the insert lands a second live row for
|
||||
// the same slot that differs only by `source` — the partial unique
|
||||
// index carries `source` and cannot catch it — inflating the
|
||||
// compliance rollup's scheduled count. A defaulted anchor (takenAt /
|
||||
// now) never names a slot, so the probe is skipped on that hot path.
|
||||
//
|
||||
// Dose-safety guard: a TAKEN write must not converge onto a slot whose
|
||||
// anchor is in the future relative to the take. The card advances its
|
||||
// display-due to the evening slot once the morning slot's catch-up
|
||||
// window lapses, so a late-morning "Genommen" posts the evening slot as
|
||||
// `scheduledFor`; band attribution already rejected the take, and the
|
||||
// probe must not re-bind it forward onto the evening pending row (a
|
||||
// late-morning dose silently consuming the 21:00 slot). It records
|
||||
// standalone (ad-hoc) instead.
|
||||
const existingSlotRow =
|
||||
scheduledFor !== undefined &&
|
||||
mayConvergeOntoSuppliedSlot({
|
||||
skipped,
|
||||
takenAt: resolvedTakenAt,
|
||||
suppliedSlot: incomingScheduledFor,
|
||||
})
|
||||
? await prisma.medicationIntakeEvent.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
scheduledFor: incomingScheduledFor,
|
||||
deletedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
: null;
|
||||
if (existingSlotRow) {
|
||||
const applied = await applyCanonicalSlotWrite({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
canonicalSlot: incomingScheduledFor,
|
||||
takenAt: resolvedTakenAt,
|
||||
skipped,
|
||||
isExplicitTaken,
|
||||
isExplicitSkip,
|
||||
idempotencyKey: idempotencyKey ?? null,
|
||||
createSource: intakeSource,
|
||||
refuseOutcomeChange: isDelegated,
|
||||
injectionSite: resolvedInjectionSite,
|
||||
attributionSource,
|
||||
doseTaken: resolvedDoseTaken,
|
||||
});
|
||||
if (applied.outcomeChangeRefused) return refuseOutcomeChange();
|
||||
event = applied.row;
|
||||
consumedTransition = applied.consumedTransition;
|
||||
if (!skipped && !applied.noDowngradeNoOp) {
|
||||
await prisma.medication.update({
|
||||
where: { id },
|
||||
data: { snoozedUntil: null },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Genuinely standalone. Anchor a taken write on the intake instant —
|
||||
// the documented ad-hoc contract (`scheduledFor = takenAt`) — so an
|
||||
// unresolvable client anchor can never park a live row exactly on a
|
||||
// slot instant a pending REMINDER row is minted for later. A skip
|
||||
// without a slot keeps the incoming instant (it has no takenAt).
|
||||
[event] = await prisma.$transaction([
|
||||
prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
scheduledFor: resolvedTakenAt ?? incomingScheduledFor,
|
||||
takenAt: resolvedTakenAt,
|
||||
skipped,
|
||||
source: intakeSource,
|
||||
idempotencyKey: idempotencyKey ?? null,
|
||||
// v1.8.5 — site only on a resolved taken-injection write.
|
||||
...(resolvedInjectionSite !== null && {
|
||||
injectionSite: resolvedInjectionSite,
|
||||
}),
|
||||
// v1.16.4 — dose override only on a taken write carrying one.
|
||||
...(resolvedDoseTaken !== null && {
|
||||
doseTaken: resolvedDoseTaken,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
// Reset snooze when medication is taken
|
||||
...(!skipped
|
||||
? [
|
||||
prisma.medication.update({
|
||||
where: { id },
|
||||
data: { snoozedUntil: null },
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// v1.16.10 — inventory consumption / restore. A taken write consumes
|
||||
// `unitsPerDose` units (the stamp on the event row makes replays and
|
||||
// re-posts exactly-once; `consumedTransition` skips the read for the
|
||||
// no-op echo cases the slot upsert already classified). An explicit
|
||||
// skip can downgrade a previously-taken row (last-write-wins), so it
|
||||
// refunds whatever that row's stamp recorded. Both hooks are
|
||||
// best-effort and never block the intake write.
|
||||
if (!skipped && consumedTransition) {
|
||||
await consumeForIntake({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
eventId: event.id,
|
||||
intakeAt: event.takenAt ?? event.scheduledFor,
|
||||
});
|
||||
} else if (skipped) {
|
||||
await restoreForIntake({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
eventId: event.id,
|
||||
});
|
||||
}
|
||||
|
||||
await auditLog("medication.intake", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
medicationId: id,
|
||||
eventId: event.id,
|
||||
skipped,
|
||||
intakeSource,
|
||||
isDelegated,
|
||||
ipAddress: getClientIp(request),
|
||||
originDeviceToken: request.headers.get("x-device-id"),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.intake",
|
||||
entity_type: "intake_event",
|
||||
entity_id: event.id,
|
||||
},
|
||||
meta: {
|
||||
medication_id: id,
|
||||
skipped,
|
||||
},
|
||||
});
|
||||
|
||||
// v1.4.34 — bust per-user medications + compliance + achievement
|
||||
// caches so the next read reflects the dose event.
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
|
||||
// v1.4.39 — refresh the persistent compliance rollup for the
|
||||
// affected day. The hook is best-effort; failures annotate but never
|
||||
// block the user's POST response.
|
||||
await refreshDoseSlot({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
scheduledFor: event.scheduledFor,
|
||||
tz: user.timezone,
|
||||
});
|
||||
|
||||
// v1.5.0 — one-shot lifecycle reconciliation. A `oneShot` medication
|
||||
// has at most one live intake; the helper re-reads the most recent
|
||||
// non-skipped intake and flips `active` to match. Idempotent on
|
||||
// non-one-shot medications (the underlying updateMany is gated by
|
||||
// `oneShot:true`). The flip runs AFTER the intake row is committed
|
||||
// so a flaky write never deactivates a medication that didn't
|
||||
// actually receive its dose.
|
||||
const reconcileAction = await reconcileOneShotState(prisma, id, user.id);
|
||||
if (reconcileAction !== "noop") {
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
parsed.data,
|
||||
);
|
||||
switch (outcome.kind) {
|
||||
case "refused":
|
||||
return apiError(outcome.message, 422, { errorCode: outcome.errorCode });
|
||||
case "existing":
|
||||
return apiSuccess(outcome.row);
|
||||
case "outcome_change_refused":
|
||||
return refuseOutcomeChange();
|
||||
case "recorded":
|
||||
return apiSuccess(outcome.event, 201);
|
||||
}
|
||||
|
||||
// (#22) — silent cross-device intake sync: wake the user's OTHER iOS
|
||||
// devices so a running Live Activity / widget reconciles this dose.
|
||||
// Coalesced per user, best-effort — never affects the response.
|
||||
queueMedicationIntakeSync({
|
||||
userId: user.id,
|
||||
originDeviceToken: request.headers.get("x-device-id"),
|
||||
});
|
||||
|
||||
// PWA counterpart of the sync wake above: a dose resolved here (taken or
|
||||
// skipped) closes the still-pending dose-due Web Push reminder for the
|
||||
// slot and refreshes the app badge. The other intake routes have carried
|
||||
// this since v1.18.4; this route is the replay target of the iOS offline
|
||||
// queue, so a drained dose must clear the web reminder the same way.
|
||||
// Best-effort, fire-and-forget — the canonical row is already persisted.
|
||||
void (async () => {
|
||||
const badgeCount = await countOutstandingDosesToday(user.id, user.timezone);
|
||||
await dispatchMedicationIntakeWebClear({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
scheduledFor: event.scheduledFor.toISOString(),
|
||||
badgeCount,
|
||||
});
|
||||
})();
|
||||
|
||||
// v1.36.x — "somebody else marked your dose". This route has no snooze arm,
|
||||
// so the state is whichever of the two markings the payload carried. The
|
||||
// helper refuses on self, so a person marking their own dose is unaffected.
|
||||
// Fire and forget, like every neighbour above. The dispatcher awaits each
|
||||
// channel in the cascade, so an unreachable one would have added its whole
|
||||
// timeout to the caregiver's request — the person standing next to the
|
||||
// patient waiting for a dose to register. The helper never throws, and the
|
||||
// owner learning a moment later is the right trade against the tick being
|
||||
// slow. The integration test already polls the ledger rather than relying on
|
||||
// the await, so nothing about the proof weakens.
|
||||
void notifyDelegatedIntake({
|
||||
ownerId: user.id,
|
||||
actorId: actor.id,
|
||||
medicationId: id,
|
||||
status: skipped ? "skipped" : "taken",
|
||||
});
|
||||
|
||||
return apiSuccess(event, 201);
|
||||
}
|
||||
|
||||
export const GET = apiHandler(
|
||||
@@ -665,28 +158,28 @@ export const GET = apiHandler(
|
||||
const { user } = await requireRecordAuth("read", "medications");
|
||||
|
||||
const { id } = await params;
|
||||
// v1.4.25 — privacy gate hoisted to the shared helper.
|
||||
// Privacy gate through the shared helper.
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
const searchParams = Object.fromEntries(request.nextUrl.searchParams);
|
||||
const parsed = listIntakeEventsSchema.safeParse(searchParams);
|
||||
if (!parsed.success) {
|
||||
// v1.4.43 — multi-issue 422 + audit breadcrumb keyed
|
||||
// Multi-issue 422 + audit breadcrumb keyed
|
||||
// `medications.intake.list.validation-failed`.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
annotate({
|
||||
action: { name: "medications.intake.list.validation-failed" },
|
||||
meta: { issue_count: issues.length, medication_id: id },
|
||||
});
|
||||
// v1.4.49 — strip `message` from the audit-ledger row.
|
||||
// Strip `message` from the audit-ledger row.
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
// v1.36.0 — through `auditLog()` rather than a bare `prisma.auditLog
|
||||
// .create`, because that helper is the only thing that stamps
|
||||
// `actorUserId`. Filed under the resolved record either way; without
|
||||
// the stamp a delegate's malformed query would read as the owner's own.
|
||||
// Through `auditLog()` rather than a bare `prisma.auditLog.create`,
|
||||
// because that helper is the only thing that stamps `actorUserId`.
|
||||
// Filed under the resolved record either way; without the stamp a
|
||||
// delegate's malformed query would read as the owner's own.
|
||||
void auditLog("medications.intake.list.validation-failed", {
|
||||
userId: user.id,
|
||||
details: { issues: auditIssues, medicationId: id },
|
||||
@@ -696,72 +189,12 @@ export const GET = apiHandler(
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { limit, offset, sortBy, sortDir, status } = parsed.data;
|
||||
|
||||
// v1.4.37 — translate the optional `status` filter into a Prisma
|
||||
// `where` fragment. Default `status:"all"` keeps the contract
|
||||
// byte-stable for the iOS Swift client and the dashboard tiles that
|
||||
// were on the wire before this knob existed. The detail-page
|
||||
// IntakeHistoryListV2 component opts into `status:"completed"` so
|
||||
// ambiguous "missed / never confirmed" rows
|
||||
// (`takenAt IS NULL AND skipped = false`) stay out of the user-facing
|
||||
// table — they were the source of the v1.4.36 regression where rows
|
||||
// with no takenAt rendered an "Eingenommen" chip.
|
||||
const statusFilter =
|
||||
status === "taken"
|
||||
? { takenAt: { not: null }, skipped: false }
|
||||
: status === "skipped"
|
||||
? { skipped: true }
|
||||
: status === "completed"
|
||||
? {
|
||||
OR: [
|
||||
{ takenAt: { not: null }, skipped: false },
|
||||
{ skipped: true },
|
||||
],
|
||||
}
|
||||
: {};
|
||||
// v1.7.0 sync — exclude tombstoned rows from the per-medication
|
||||
// intake history list + its count.
|
||||
const where = {
|
||||
medicationId: id,
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
...statusFilter,
|
||||
};
|
||||
|
||||
// v1.7.0 — pin NULLS LAST on the `takenAt` sort. Skipped rows
|
||||
// carry `takenAt: null`; under a bare `desc` collation Postgres
|
||||
// emits NULLS FIRST, floating skipped/planned rows to the top of
|
||||
// the history view. Pinning them last keeps the descending order
|
||||
// reading today → yesterday → … with real timestamps first. Other
|
||||
// sort columns are non-null so they keep the simple shape.
|
||||
// The trailing `{ id: sortDir }` is the unique tiebreaker. Scheduled
|
||||
// slots share an instant by construction — a twice-daily medication puts
|
||||
// every morning dose on the same `scheduledFor` — so offset paging over
|
||||
// the primary key alone can repeat a row on one page and drop another.
|
||||
const orderBy =
|
||||
sortBy === "takenAt"
|
||||
? [
|
||||
{ takenAt: { sort: sortDir, nulls: "last" as const } },
|
||||
{ id: sortDir },
|
||||
]
|
||||
: [{ [sortBy]: sortDir }, { id: sortDir }];
|
||||
|
||||
const [events, total] = await Promise.all([
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
take: limit,
|
||||
skip: offset,
|
||||
return apiSuccess(
|
||||
await listIntakeEvents({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
query: parsed.data,
|
||||
}),
|
||||
prisma.medicationIntakeEvent.count({ where }),
|
||||
]);
|
||||
|
||||
annotate({
|
||||
action: { name: "medication.intake.list" },
|
||||
meta: { medication_id: id, total, status },
|
||||
});
|
||||
|
||||
return apiSuccess({ events, meta: { total, limit, offset } });
|
||||
);
|
||||
},
|
||||
);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -45,7 +45,6 @@ import { z } from "zod/v4";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, isScopedCredential, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -56,52 +55,13 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { markSyncCheckpoint } from "@/lib/sync/checkpoint";
|
||||
import {
|
||||
BACKFILL_SYNC_TRIGGER,
|
||||
HEALTHKIT_REQUEST_SYNC_TRIGGERS,
|
||||
MEDICATION_DOSE_ARRIVAL_TYPE,
|
||||
recordHealthKitTypeArrivals,
|
||||
} from "@/lib/integrations/healthkit-type-sync";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import {
|
||||
dayKeyForScheduledFor,
|
||||
refreshDoseDay,
|
||||
} from "@/lib/medications/dose-day-refresh";
|
||||
import {
|
||||
applyCanonicalSlotWrite,
|
||||
findPinConflict,
|
||||
mayConvergeOntoSuppliedSlot,
|
||||
resolveForcedSlotForWrite,
|
||||
resolveSlotForWriteByBand,
|
||||
resolveSlotInstantForWrite,
|
||||
slotHeldByAnotherDose,
|
||||
} from "@/lib/medications/scheduling/slot-upsert";
|
||||
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
|
||||
import {
|
||||
consumeForIntake,
|
||||
restoreForIntake,
|
||||
} from "@/lib/medications/inventory/consumption";
|
||||
import { HEALTHKIT_REQUEST_SYNC_TRIGGERS } from "@/lib/integrations/healthkit-type-sync";
|
||||
import {
|
||||
boundedTakenAtSchema,
|
||||
injectionSiteEnum,
|
||||
type InjectionSiteValue,
|
||||
} from "@/lib/validations/medication";
|
||||
import {
|
||||
classifyExternalId,
|
||||
unstableExternalIdMeta,
|
||||
UNSTABLE_EXTERNAL_ID_REASON,
|
||||
type UnstableExternalIdShape,
|
||||
} from "@/lib/validations/external-id";
|
||||
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
|
||||
import { dispatchMedicationIntakeWebClearBulk } from "@/lib/notifications/web-push-clear";
|
||||
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
|
||||
import {
|
||||
INTAKE_NOT_TRACKED_REASON,
|
||||
isRecordOnly,
|
||||
} from "@/lib/medications/intake-tracking";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
import { ingestBulkIntake } from "@/lib/medications/intake-bulk/ingest";
|
||||
|
||||
const MAX_ENTRIES_PER_BATCH = 500;
|
||||
const BATCH_RATE_LIMIT_MAX = 60;
|
||||
@@ -121,7 +81,7 @@ const bulkEntrySchema = z
|
||||
// equality forever, so it is an identity and carries the same
|
||||
// stability floor. Checked per entry for the same reason as
|
||||
// `externalId` above.
|
||||
// @external-id-checked-per-entry: src/app/api/medications/intake/bulk/route.ts
|
||||
// @external-id-checked-per-entry: src/lib/medications/intake-bulk/entry.ts
|
||||
idempotencyKey: z.string().min(1).max(128).optional(),
|
||||
// v1.8.5 — optional per-entry injection site. Validated against the
|
||||
// medication's effective allowed set; a disallowed site marks the
|
||||
@@ -157,7 +117,7 @@ const bulkEntrySchema = z
|
||||
// Stability is enforced per entry in the loop below, not at the
|
||||
// field: a schema-level refusal would fail the whole batch on one
|
||||
// bad row.
|
||||
// @external-id-checked-per-entry: src/app/api/medications/intake/bulk/route.ts
|
||||
// @external-id-checked-per-entry: src/lib/medications/intake-bulk/entry.ts
|
||||
externalId: z.string().trim().min(1).max(128).optional(),
|
||||
})
|
||||
.refine(
|
||||
@@ -181,19 +141,6 @@ const bulkPayloadSchema = z.object({
|
||||
syncTrigger: z.enum(HEALTHKIT_REQUEST_SYNC_TRIGGERS).optional(),
|
||||
});
|
||||
|
||||
// v1.8.2 — `updated` joins the per-entry status vocabulary: a write that
|
||||
// snaps onto an existing scheduled-slot row (e.g. the pending REMINDER
|
||||
// row) updates it in place rather than inserting. The iOS sync engine
|
||||
// treats `updated` the same as `inserted` for cursor advancement — both
|
||||
// mean "the server accepted this entry and produced a row id".
|
||||
type EntryStatus = "inserted" | "updated" | "duplicate" | "skipped";
|
||||
interface EntryResult {
|
||||
index: number;
|
||||
status: EntryStatus;
|
||||
reason?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk));
|
||||
|
||||
async function postBulk(request: NextRequest): Promise<Response> {
|
||||
@@ -203,8 +150,8 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
const auth = await requireAuth(undefined, { writeBucket: "own" });
|
||||
const { user } = auth;
|
||||
// A narrow, single-purpose credential (a bridge, an ingest token) is no
|
||||
// Apple Health sync, so it never writes the dose arrival record below. The
|
||||
// bare `requireAuth()` refuses such a credential today; the check keeps the
|
||||
// Apple Health sync, so it never writes the dose arrival record. The bare
|
||||
// `requireAuth()` refuses such a credential today; the check keeps the
|
||||
// record honest should this route ever declare a scope.
|
||||
const scoped = isScopedCredential(auth);
|
||||
|
||||
@@ -238,7 +185,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
|
||||
const parsed = bulkPayloadSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
// v1.4.43 — bulk intake ingest; preserve the existing
|
||||
// Bulk intake ingest; preserve the existing
|
||||
// `medications.intake.bulk.invalid` errorCode meta so the iOS
|
||||
// Sync engine's retry classifier still branches on it. Audit
|
||||
// breadcrumb keyed `medications.intake.bulk.validation-failed`.
|
||||
@@ -247,7 +194,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
action: { name: "medications.intake.bulk.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
// v1.4.49 — strip `message` from the audit-ledger row.
|
||||
// Strip `message` from the audit-ledger row.
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
@@ -269,776 +216,16 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
|
||||
const { entries, syncTrigger } = parsed.data;
|
||||
|
||||
// Up-front ownership check — the user can only POST intake events
|
||||
// against medications they own. One read covers every medicationId
|
||||
// in the batch.
|
||||
const medicationIds = Array.from(new Set(entries.map((e) => e.medicationId)));
|
||||
const ownedMedications = await prisma.medication.findMany({
|
||||
where: { id: { in: medicationIds }, userId: user.id },
|
||||
// v1.8.5 — pull the injection-tracking fields so a per-entry
|
||||
// `injectionSite` can be resolved + validated without an extra
|
||||
// per-entry read. v1.28 — `externalSource` rides along so the
|
||||
// Apple-entry mirror gate below needs no extra read either.
|
||||
select: {
|
||||
id: true,
|
||||
deliveryForm: true,
|
||||
trackInjectionSites: true,
|
||||
allowedInjectionSites: true,
|
||||
externalSource: true,
|
||||
trackIntake: true,
|
||||
},
|
||||
});
|
||||
const ownedSet = new Set(ownedMedications.map((m) => m.id));
|
||||
const medById = new Map(ownedMedications.map((m) => [m.id, m]));
|
||||
|
||||
// v1.28 — source-exclusive Apple-mirrored medications: an APPLE_HEALTH
|
||||
// dose event may only attach to a medication mirrored from Apple Health
|
||||
// (`Medication.externalSource === "APPLE_HEALTH"`). Targeting a native
|
||||
// medication is a client contract violation (the iOS sync maps HK dose
|
||||
// events onto the meds it mirrored itself), not a per-entry data
|
||||
// problem, so the whole batch 422s with a stable errorCode the client
|
||||
// can branch on. Unowned medicationIds keep the per-entry
|
||||
// `medication_not_found` skip below.
|
||||
const appleMismatch = entries.some(
|
||||
(e) =>
|
||||
e.source === "APPLE_HEALTH" &&
|
||||
ownedSet.has(e.medicationId) &&
|
||||
medById.get(e.medicationId)?.externalSource !== "APPLE_HEALTH",
|
||||
);
|
||||
if (appleMismatch) {
|
||||
return apiError(
|
||||
"An APPLE_HEALTH entry may only target a medication mirrored from Apple Health",
|
||||
422,
|
||||
{ errorCode: "medications.intake.bulk.apple_health_not_mirrored" },
|
||||
);
|
||||
}
|
||||
|
||||
// v1.28 — idempotent re-sync dedup: one read collects every live row
|
||||
// already carrying one of the batch's external dose-event UUIDs; the
|
||||
// per-entry loop reports those entries as `duplicate` (first-write-wins
|
||||
// per Apple dose) without touching the DB again. Fresh inserts register
|
||||
// into the same map so an intra-batch repeat of a UUID dedups too. The
|
||||
// partial `(user_id, external_id)` unique (migration 0239) is the race
|
||||
// backstop.
|
||||
const batchExternalIds = Array.from(
|
||||
new Set(
|
||||
entries
|
||||
.map((e) => e.externalId)
|
||||
.filter((id): id is string => id !== undefined),
|
||||
),
|
||||
);
|
||||
const rowIdByExternalId = new Map<string, string>();
|
||||
if (batchExternalIds.length > 0) {
|
||||
const existingExternalRows = await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
externalId: { in: batchExternalIds },
|
||||
deletedAt: null,
|
||||
},
|
||||
select: { id: true, externalId: true },
|
||||
});
|
||||
for (const row of existingExternalRows) {
|
||||
if (row.externalId) rowIdByExternalId.set(row.externalId, row.id);
|
||||
}
|
||||
}
|
||||
|
||||
// v1.8.5 — the user's global exclusion deny-list, loaded once for the
|
||||
// whole batch (it is user-scoped, not per-medication). Only read when
|
||||
// at least one entry actually carries an `injectionSite`, so the hot
|
||||
// sync path (no site) pays for zero extra round-trips.
|
||||
const batchHasInjectionSite = entries.some(
|
||||
(e) => e.injectionSite !== undefined,
|
||||
);
|
||||
let globalExcluded: InjectionSiteValue[] = [];
|
||||
if (batchHasInjectionSite) {
|
||||
const userRow = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { globalExcludedInjectionSites: true },
|
||||
});
|
||||
globalExcluded = (userRow?.globalExcludedInjectionSites ??
|
||||
[]) as InjectionSiteValue[];
|
||||
}
|
||||
|
||||
const results: EntryResult[] = [];
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
let duplicates = 0;
|
||||
// External dose records kept as their own unscheduled dose because their
|
||||
// band slot already carried a different one.
|
||||
let extraDosesUnscheduled = 0;
|
||||
const skipped: Array<{ index: number; reason: string }> = [];
|
||||
const unstableShapes: UnstableExternalIdShape[] = [];
|
||||
// Distinct `(medicationId, dayKey)` pairs touched by the batch, so one
|
||||
// dose-day refresh fires per pair after all inserts complete. A per-row
|
||||
// refresh would balloon a 500-entry batch into 500 sequential refreshes.
|
||||
const touchedDays = new Map<
|
||||
string,
|
||||
{ medicationId: string; dayKey: string }
|
||||
>();
|
||||
// v1.17.1 (#22) — distinct `(medicationId, scheduledFor)` slots whose
|
||||
// state actually changed in this batch (a row inserted / updated), so the
|
||||
// silent cross-device sync fires once per affected slot — not once per
|
||||
// row, and not for pending echoes / duplicates that changed nothing.
|
||||
const syncSlots = new Map<
|
||||
string,
|
||||
{ medicationId: string; scheduledFor: string }
|
||||
>();
|
||||
// A landed non-skipped dose quiets an active snooze, exactly like the
|
||||
// single-intake routes (they null `snoozedUntil` on every recorded take).
|
||||
// Collected per medication here, cleared once after the loop.
|
||||
const snoozeClearIds = new Set<string>();
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
|
||||
if (!ownedSet.has(entry.medicationId)) {
|
||||
const reason = "medication_not_found";
|
||||
skipped.push({ index: i, reason });
|
||||
results.push({ index: i, status: "skipped", reason });
|
||||
continue;
|
||||
}
|
||||
|
||||
// A record-only medication takes no new dose (iOS #116, item 15). The
|
||||
// single-dose routes answer 422 `medication.intake.notTracked`; here the
|
||||
// same decision is one entry's outcome, so the rest of the batch lands.
|
||||
if (isRecordOnly(medById.get(entry.medicationId) ?? {})) {
|
||||
const reason = INTAKE_NOT_TRACKED_REASON;
|
||||
skipped.push({ index: i, reason });
|
||||
results.push({ index: i, status: "skipped", reason });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Both dedup keys on this route are only idempotent while the value
|
||||
// is STABLE across client launches: `(userId, externalId)` for the
|
||||
// Apple dose event, and the `@unique` `idempotency_key` column for
|
||||
// the replay probe below. A value that rotates per process (an
|
||||
// object description carrying a memory address) never matches its
|
||||
// own earlier row, so every re-sync logs the dose again. Refused per
|
||||
// entry — one bad row must not stop the rest of the batch landing.
|
||||
const unstable =
|
||||
(entry.externalId === undefined
|
||||
? null
|
||||
: classifyExternalId(entry.externalId)) ??
|
||||
(entry.idempotencyKey === undefined
|
||||
? null
|
||||
: classifyExternalId(entry.idempotencyKey));
|
||||
if (unstable) {
|
||||
unstableShapes.push(unstable);
|
||||
skipped.push({ index: i, reason: UNSTABLE_EXTERNAL_ID_REASON });
|
||||
results.push({
|
||||
index: i,
|
||||
status: "skipped",
|
||||
reason: UNSTABLE_EXTERNAL_ID_REASON,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// v1.28 — Apple dose-event replay: the batch pre-check found a live
|
||||
// row already carrying this external UUID (or an earlier entry in
|
||||
// THIS batch inserted it). First-write-wins per Apple dose — report
|
||||
// `duplicate` with the existing row id so the sync cursor advances.
|
||||
if (entry.externalId !== undefined) {
|
||||
const existingRowId = rowIdByExternalId.get(entry.externalId);
|
||||
if (existingRowId !== undefined) {
|
||||
duplicates += 1;
|
||||
results.push({ index: i, status: "duplicate", id: existingRowId });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const incomingScheduledFor =
|
||||
entry.scheduledFor ?? entry.takenAt ?? new Date();
|
||||
|
||||
// Classify the incoming write. An offline-sync replay echoes a
|
||||
// PENDING projection (no `takenAt`, `skipped:false`) for a slot the
|
||||
// user may already have actioned; that echo must NEVER clear a
|
||||
// recorded `takenAt`. An explicit `takenAt` or `skipped:true` is a
|
||||
// real user action and applies last-write-wins.
|
||||
const isExplicitTaken = !entry.skipped && entry.takenAt !== undefined;
|
||||
const isExplicitSkip = entry.skipped === true;
|
||||
|
||||
// v1.15.20 — taken writes attribute through the window-band engine
|
||||
// (the SAME minter the read ledger + the compliance % consume), so a
|
||||
// bulk-synced take binds to a slot identically to the single-route
|
||||
// write. The optional `forceSlotInstant` pins an off-window take onto
|
||||
// a named real slot. Pending echoes and skips carry no take to
|
||||
// attribute by, so they keep the canonical anchor snap that binds
|
||||
// them to the projector/worker-minted slot row.
|
||||
let canonicalSlot: Date | null;
|
||||
// v1.15.20 — binding provenance: USER_PIN on the forced path, AUTO on
|
||||
// a band decision. Pending echoes / skips carry no decision
|
||||
// (undefined → the upsert leaves an existing USER_PIN untouched).
|
||||
let attributionSource: "AUTO" | "USER_PIN" | undefined;
|
||||
// Set when the band slot is held by a different dose and this take is
|
||||
// recorded unscheduled; it must not converge onto any slot row below.
|
||||
let heldByAnotherDose = false;
|
||||
if (isExplicitTaken && entry.forceSlotInstant !== undefined) {
|
||||
canonicalSlot = await resolveForcedSlotForWrite({
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
userTz: user.timezone,
|
||||
slotInstant: entry.forceSlotInstant,
|
||||
});
|
||||
if (canonicalSlot === null) {
|
||||
// Mirrors the single route's 422 (`medications.intake.force_slot
|
||||
// .invalid`) as a per-entry skip so one bad pin never fails the
|
||||
// batch.
|
||||
const reason = "force_slot_invalid";
|
||||
skipped.push({ index: i, reason });
|
||||
results.push({ index: i, status: "skipped", reason });
|
||||
continue;
|
||||
}
|
||||
// v1.16.0 — refuse to pin onto a slot another recorded action
|
||||
// already serves: the explicit-write last-write-wins rule would
|
||||
// silently overwrite that dose record. Per-entry skip, mirroring
|
||||
// the single route's 422 `medications.intake.force_slot.occupied`.
|
||||
if (
|
||||
await findPinConflict({
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
canonicalSlot,
|
||||
incomingTakenAt: entry.takenAt ?? null,
|
||||
})
|
||||
) {
|
||||
const reason = "force_slot_occupied";
|
||||
skipped.push({ index: i, reason });
|
||||
results.push({ index: i, status: "skipped", reason });
|
||||
continue;
|
||||
}
|
||||
attributionSource = "USER_PIN";
|
||||
} else if (isExplicitTaken) {
|
||||
const attribution = await resolveSlotForWriteByBand({
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
userTz: user.timezone,
|
||||
takenAt: entry.takenAt as Date,
|
||||
});
|
||||
canonicalSlot = attribution.slotInstant;
|
||||
attributionSource = "AUTO";
|
||||
// An external dose record (a HealthKit dose event) whose band slot
|
||||
// already carries a different recorded dose is an extra dose, not a
|
||||
// correction of that one. Converging would overwrite the slot's
|
||||
// take last-write-wins and the extra dose would exist nowhere; it is
|
||||
// recorded as its own unscheduled dose instead.
|
||||
if (
|
||||
canonicalSlot !== null &&
|
||||
entry.externalId !== undefined &&
|
||||
(await slotHeldByAnotherDose({
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
canonicalSlot,
|
||||
takenAt: entry.takenAt as Date,
|
||||
externalId: entry.externalId,
|
||||
scheduledFor: entry.scheduledFor ?? null,
|
||||
userTz: user.timezone,
|
||||
}))
|
||||
) {
|
||||
canonicalSlot = null;
|
||||
heldByAnotherDose = true;
|
||||
extraDosesUnscheduled += 1;
|
||||
}
|
||||
} else if (isExplicitSkip && entry.scheduledFor === undefined) {
|
||||
// v1.16.9 — a slot-less deliberate skip resolves by band
|
||||
// membership on the skip moment, exactly like a take does. The
|
||||
// defaulted-now snap below only reaches the tight reminder-grace
|
||||
// window, so a skip tapped 20 minutes past the dose time inserted
|
||||
// an orphan ad-hoc skip row while the slot's pending REMINDER row
|
||||
// stayed open and later auto-missed — the user's deliberate skip
|
||||
// recorded as a miss.
|
||||
const attribution = await resolveSlotForWriteByBand({
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
userTz: user.timezone,
|
||||
takenAt: incomingScheduledFor,
|
||||
});
|
||||
canonicalSlot = attribution.slotInstant;
|
||||
} else {
|
||||
// v1.8.2 — source-agnostic anchor snap for pending echoes + skips.
|
||||
// iOS posts the reminder actions here (source API); without this
|
||||
// they inserted a SECOND row for a slot already carrying the
|
||||
// projector/worker's pending REMINDER row (the unique key includes
|
||||
// `source`, and the iOS-vs-server `scheduledFor` drifts by a
|
||||
// minute). Snap onto the canonical slot instant and update the
|
||||
// existing row in place.
|
||||
canonicalSlot = await resolveSlotInstantForWrite({
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
userTz: user.timezone,
|
||||
incoming: incomingScheduledFor,
|
||||
// Only a client-supplied `scheduledFor` names a real slot. A
|
||||
// defaulted-now write must not snap across the wide ±halfGap
|
||||
// window onto a far slot (phantom morning dose).
|
||||
instantIsExplicit: entry.scheduledFor !== undefined,
|
||||
// Pending echoes / skips never record a take; a pending sync
|
||||
// echo legitimately maps to a future slot, so the taken
|
||||
// future-slot guard stays off here.
|
||||
isTakenWrite: false,
|
||||
});
|
||||
}
|
||||
|
||||
// The `scheduledFor` instant the row actually lands on — set by the
|
||||
// branch that performs the write so the rollup recompute below keys
|
||||
// the same day the stored row anchors to.
|
||||
let effectiveScheduledFor = canonicalSlot ?? incomingScheduledFor;
|
||||
|
||||
// v1.8.5 — resolve + validate the optional per-entry injection
|
||||
// site. A disallowed site marks the entry skipped without failing
|
||||
// the batch (per-entry contract); a non-injection / tracking-off /
|
||||
// non-taken entry silently drops it.
|
||||
let resolvedInjectionSite: InjectionSiteKey | null = null;
|
||||
if (entry.injectionSite !== undefined) {
|
||||
const med = medById.get(entry.medicationId);
|
||||
const resolution = resolveInjectionSiteForWrite({
|
||||
submitted: entry.injectionSite,
|
||||
taken: isExplicitTaken,
|
||||
deliveryForm: med?.deliveryForm ?? "ORAL",
|
||||
trackInjectionSites: med?.trackInjectionSites ?? false,
|
||||
allowedInjectionSites: (med?.allowedInjectionSites ??
|
||||
[]) as InjectionSiteKey[],
|
||||
globalExcludedInjectionSites: globalExcluded as InjectionSiteKey[],
|
||||
});
|
||||
if (resolution.kind === "disallowed") {
|
||||
const reason = "injection_site_not_allowed";
|
||||
skipped.push({ index: i, reason });
|
||||
results.push({ index: i, status: "skipped", reason });
|
||||
continue;
|
||||
}
|
||||
resolvedInjectionSite = resolution.site;
|
||||
}
|
||||
|
||||
if (canonicalSlot) {
|
||||
// Scheduled dose — converge onto the one canonical slot row through
|
||||
// the shared upsert: H1 deterministic selection, C2 no-downgrade
|
||||
// guard, and a C1 race-safe create that re-finds + updates on a
|
||||
// P2002 collision rather than misclassifying it as a duplicate and
|
||||
// dropping the dose.
|
||||
const applied = await applyCanonicalSlotWrite({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
canonicalSlot,
|
||||
takenAt: entry.takenAt ?? null,
|
||||
skipped: entry.skipped,
|
||||
isExplicitTaken,
|
||||
isExplicitSkip,
|
||||
idempotencyKey: entry.idempotencyKey ?? null,
|
||||
// v1.28 — an Apple dose-event import stamps its provenance on a
|
||||
// fresh row; every other caller keeps today's "API".
|
||||
createSource: entry.source ?? "API",
|
||||
injectionSite: resolvedInjectionSite,
|
||||
attributionSource,
|
||||
// v1.16.4 — dose override only documents a consumed dose.
|
||||
doseTaken: (!entry.skipped && entry.doseTaken) || null,
|
||||
// v1.28 — external dose-event UUID rides onto the landed row.
|
||||
externalId: entry.externalId ?? null,
|
||||
});
|
||||
if (applied.noDowngradeNoOp) {
|
||||
// Pending echo onto an already-actioned slot. Report it as a
|
||||
// duplicate so the iOS cursor advances WITHOUT downgrading the
|
||||
// recorded dose. A pending echo is never a taken write, so it
|
||||
// must never reach the inventory consume below.
|
||||
duplicates += 1;
|
||||
results.push({ index: i, status: "duplicate", id: applied.row.id });
|
||||
} else if (applied.outcome === "updated") {
|
||||
updated += 1;
|
||||
results.push({ index: i, status: "updated", id: applied.row.id });
|
||||
} else {
|
||||
inserted += 1;
|
||||
results.push({ index: i, status: "inserted", id: applied.row.id });
|
||||
}
|
||||
// v1.16.10 — inventory: only the genuine pending→taken
|
||||
// transition consumes (the upsert's `consumedTransition` flag —
|
||||
// the ONLY transition that may move stock). The stamp keeps a
|
||||
// replayed batch exactly-once for post-v1.16.10 rows; the
|
||||
// transition gate additionally protects pre-stamp rows: a full
|
||||
// journal replay re-posting historical taken doses must not
|
||||
// drain today's stock through their NULL stamps. An explicit
|
||||
// skip refunds a previously-taken row's stamp. Pending echoes
|
||||
// carry neither flag and never touch stock.
|
||||
if (applied.consumedTransition && isExplicitTaken) {
|
||||
await consumeForIntake({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
eventId: applied.row.id,
|
||||
intakeAt: entry.takenAt as Date,
|
||||
});
|
||||
} else if (!applied.noDowngradeNoOp && isExplicitSkip) {
|
||||
await restoreForIntake({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
eventId: applied.row.id,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Resolver-null: PRN / off-slot / future-slot-guarded. Before the
|
||||
// standalone insert, two guards keep the slot from forking into a
|
||||
// second live row:
|
||||
//
|
||||
// 1. idempotencyKey replay pre-check — a re-submission returns the
|
||||
// existing row as "duplicate" (previously reached via the P2002
|
||||
// catch; the explicit probe keeps that contract now that a
|
||||
// replay could otherwise converge as "updated" below).
|
||||
// 2. source-agnostic convergence probe — when ANY live row already
|
||||
// sits on the incoming instant (e.g. the pending REMINDER row
|
||||
// the worker pre-minted on a slot the taken-write future guard
|
||||
// refused to snap to), converge onto THAT row through the shared
|
||||
// slot upsert instead of inserting a sibling that differs only
|
||||
// by `source`. The partial unique index carries `source`, so it
|
||||
// cannot catch this duplicate; the probe must.
|
||||
if (entry.idempotencyKey) {
|
||||
// Scoped to the caller. The key is client-supplied, so an
|
||||
// unscoped read would resolve another user's row and hand its
|
||||
// id back here (migration 0273 made the unique per user).
|
||||
const replay = await prisma.medicationIntakeEvent.findUnique({
|
||||
where: {
|
||||
userId_idempotencyKey: {
|
||||
userId: user.id,
|
||||
idempotencyKey: entry.idempotencyKey,
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (replay) {
|
||||
duplicates += 1;
|
||||
results.push({ index: i, status: "duplicate", id: replay.id });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Only an explicit client `scheduledFor` names a slot a row could
|
||||
// already sit on; a defaulted anchor (takenAt / now) never does, so
|
||||
// the probe is skipped on that path.
|
||||
//
|
||||
// Dose-safety guard: a TAKEN entry must not converge onto a slot
|
||||
// whose anchor is in the future relative to the take. The resolver's
|
||||
// forward guard already refused to snap a late take onto a future
|
||||
// slot; the probe must honour the same invariant rather than binding
|
||||
// the take forward onto that slot's pending REMINDER row (a
|
||||
// late-morning dose consuming the evening slot). It records
|
||||
// standalone (ad-hoc) instead.
|
||||
const existingSlotRow =
|
||||
!heldByAnotherDose &&
|
||||
entry.scheduledFor !== undefined &&
|
||||
mayConvergeOntoSuppliedSlot({
|
||||
skipped: entry.skipped,
|
||||
takenAt: entry.takenAt ?? null,
|
||||
suppliedSlot: incomingScheduledFor,
|
||||
})
|
||||
? await prisma.medicationIntakeEvent.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
scheduledFor: incomingScheduledFor,
|
||||
deletedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
: null;
|
||||
if (existingSlotRow) {
|
||||
const applied = await applyCanonicalSlotWrite({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
canonicalSlot: incomingScheduledFor,
|
||||
takenAt: entry.takenAt ?? null,
|
||||
skipped: entry.skipped,
|
||||
isExplicitTaken,
|
||||
isExplicitSkip,
|
||||
idempotencyKey: entry.idempotencyKey ?? null,
|
||||
// v1.28 — Apple provenance on a fresh row (see above).
|
||||
createSource: entry.source ?? "API",
|
||||
injectionSite: resolvedInjectionSite,
|
||||
attributionSource,
|
||||
doseTaken: (!entry.skipped && entry.doseTaken) || null,
|
||||
// v1.28 — external dose-event UUID rides onto the landed row.
|
||||
externalId: entry.externalId ?? null,
|
||||
});
|
||||
if (applied.noDowngradeNoOp) {
|
||||
duplicates += 1;
|
||||
results.push({ index: i, status: "duplicate", id: applied.row.id });
|
||||
} else if (applied.outcome === "updated") {
|
||||
updated += 1;
|
||||
results.push({ index: i, status: "updated", id: applied.row.id });
|
||||
} else {
|
||||
inserted += 1;
|
||||
results.push({ index: i, status: "inserted", id: applied.row.id });
|
||||
}
|
||||
// v1.16.10 — same inventory rule as the canonical-slot branch:
|
||||
// gate on the upsert's pending→taken transition flag.
|
||||
if (applied.consumedTransition && isExplicitTaken) {
|
||||
await consumeForIntake({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
eventId: applied.row.id,
|
||||
intakeAt: entry.takenAt as Date,
|
||||
});
|
||||
} else if (!applied.noDowngradeNoOp && isExplicitSkip) {
|
||||
await restoreForIntake({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
eventId: applied.row.id,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Genuinely standalone. Anchor the row on the intake instant —
|
||||
// the documented ad-hoc contract (`scheduledFor = takenAt`) — so
|
||||
// an unresolvable client anchor (a future slot instant the taken
|
||||
// guard rejected) can never park a live row exactly on a slot the
|
||||
// worker later mints a pending REMINDER row for. A pending echo
|
||||
// (no takenAt) keeps the incoming instant. The idempotencyKey,
|
||||
// when supplied, has a UNIQUE index; a racing re-submission
|
||||
// returns the existing row via P2002 → status: "duplicate".
|
||||
effectiveScheduledFor = entry.takenAt ?? incomingScheduledFor;
|
||||
const row = await prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
scheduledFor: effectiveScheduledFor,
|
||||
takenAt: entry.takenAt ?? null,
|
||||
skipped: entry.skipped,
|
||||
// v1.28 — Apple provenance on a standalone (ad-hoc) row;
|
||||
// every other caller keeps today's "API".
|
||||
source: entry.source ?? "API",
|
||||
idempotencyKey: entry.idempotencyKey ?? null,
|
||||
// v1.28 — external dose-event UUID for the idempotent
|
||||
// re-sync dedup.
|
||||
...(entry.externalId !== undefined && {
|
||||
externalId: entry.externalId,
|
||||
}),
|
||||
// v1.8.5 — site only on a resolved taken-injection entry.
|
||||
...(resolvedInjectionSite !== null && {
|
||||
injectionSite: resolvedInjectionSite,
|
||||
}),
|
||||
// v1.16.4 — dose override only on a taken entry carrying one.
|
||||
...(!entry.skipped &&
|
||||
entry.doseTaken && { doseTaken: entry.doseTaken }),
|
||||
},
|
||||
});
|
||||
inserted += 1;
|
||||
results.push({ index: i, status: "inserted", id: row.id });
|
||||
// v1.16.10 — a fresh standalone taken row consumes inventory
|
||||
// units (a fresh skip / pending row has nothing to consume or
|
||||
// refund).
|
||||
if (isExplicitTaken) {
|
||||
await consumeForIntake({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
eventId: row.id,
|
||||
intakeAt: entry.takenAt as Date,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const dayKey = dayKeyForScheduledFor(
|
||||
effectiveScheduledFor,
|
||||
user.timezone,
|
||||
);
|
||||
touchedDays.set(`${entry.medicationId}|${dayKey}`, {
|
||||
medicationId: entry.medicationId,
|
||||
dayKey,
|
||||
});
|
||||
// v1.17.1 (#22) — collect the slot for the silent cross-device sync
|
||||
// only when the row's state actually changed (inserted / updated). A
|
||||
// pending echo, duplicate, or no-downgrade no-op leaves the visible
|
||||
// dose state untouched, so it must not wake the user's other devices.
|
||||
// v1.28 — register the landed row under its external dose-event
|
||||
// UUID so a repeat of the same UUID later in THIS batch dedups
|
||||
// in-memory instead of tripping the partial unique.
|
||||
const landed = results[results.length - 1];
|
||||
if (entry.externalId !== undefined && landed?.id !== undefined) {
|
||||
rowIdByExternalId.set(entry.externalId, landed.id);
|
||||
}
|
||||
const lastStatus = results[results.length - 1]?.status;
|
||||
if (lastStatus === "inserted" || lastStatus === "updated") {
|
||||
const scheduledForIso = effectiveScheduledFor.toISOString();
|
||||
syncSlots.set(`${entry.medicationId}|${scheduledForIso}`, {
|
||||
medicationId: entry.medicationId,
|
||||
scheduledFor: scheduledForIso,
|
||||
});
|
||||
if (!entry.skipped) snoozeClearIds.add(entry.medicationId);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
// P2002 = unique-constraint violation. Two shapes reach here:
|
||||
// 1. an idempotencyKey collision on the unscheduled/PRN insert →
|
||||
// "duplicate" (the canonical-slot path already absorbs its own
|
||||
// same-slot P2002 race inside `applyCanonicalSlotWrite`);
|
||||
// 2. a same-slot collision on the unscheduled insert. In both
|
||||
// cases we surface the existing row id so the cursor advances.
|
||||
if (
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
"code" in err &&
|
||||
(err as { code: string }).code === "P2002"
|
||||
) {
|
||||
const existing = entry.idempotencyKey
|
||||
? await prisma.medicationIntakeEvent.findUnique({
|
||||
where: {
|
||||
userId_idempotencyKey: {
|
||||
userId: user.id,
|
||||
idempotencyKey: entry.idempotencyKey,
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
: // v1.28 — an Apple entry that raced the partial
|
||||
// `(user_id, external_id)` unique (concurrent batches syncing
|
||||
// the same dose event) resolves to the winning live row.
|
||||
entry.externalId
|
||||
? await prisma.medicationIntakeEvent.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
externalId: entry.externalId,
|
||||
deletedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
: null;
|
||||
duplicates += 1;
|
||||
results.push({
|
||||
index: i,
|
||||
status: "duplicate",
|
||||
id: existing?.id,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const reason =
|
||||
err instanceof Error ? err.message.slice(0, 120) : "create_failed";
|
||||
skipped.push({ index: i, reason });
|
||||
results.push({ index: i, status: "skipped", reason });
|
||||
}
|
||||
}
|
||||
|
||||
await auditLog("medications.intake.bulk.ingest", {
|
||||
userId: user.id,
|
||||
const outcome = await ingestBulkIntake({
|
||||
user,
|
||||
scoped,
|
||||
entries,
|
||||
syncTrigger,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
updated,
|
||||
duplicates,
|
||||
skipped: skipped.length,
|
||||
},
|
||||
originDeviceToken: request.headers.get("x-device-id"),
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "medications.intake.bulk.ingest" },
|
||||
meta: {
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
updated,
|
||||
duplicates,
|
||||
skipped: skipped.length,
|
||||
extra_doses_unscheduled: extraDosesUnscheduled,
|
||||
...(unstableShapes.length > 0
|
||||
? unstableExternalIdMeta("medication.intake.bulk", unstableShapes)
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
// v1.4.34 — bust per-user medications + compliance + achievement
|
||||
// caches when at least one row landed so the next read reflects the
|
||||
// ingested batch. v1.16.8 — hard-evict: this bulk endpoint is the iOS
|
||||
// user's INTERACTIVE intake path (a dose taken / skipped on the phone
|
||||
// syncs through here), so the very next read must show the user their
|
||||
// own action — a marked-stale SWR cell would hand back the pre-dose
|
||||
// payload. The genuinely background writers (the auto-miss cron in
|
||||
// `intake-auto-skip.ts`, slot dedup in `intake-slot-dedup.ts`) keep
|
||||
// the default mark-stale at their own call sites.
|
||||
if (inserted > 0 || updated > 0) {
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
if (outcome.kind === "refused") {
|
||||
return apiError(outcome.message, 422, { errorCode: outcome.errorCode });
|
||||
}
|
||||
|
||||
// Refresh the derived rows of each distinct `(medicationId, dayKey)` the
|
||||
// batch touched. Best-effort: the refresh reports its own failures and
|
||||
// never fails the ingest.
|
||||
for (const { medicationId, dayKey } of touchedDays.values()) {
|
||||
await refreshDoseDay({
|
||||
userId: user.id,
|
||||
medicationId,
|
||||
dayKey,
|
||||
tz: user.timezone,
|
||||
});
|
||||
}
|
||||
|
||||
// v1.17.1 (#22) — silent cross-device intake sync. ONE queued fan-out
|
||||
// for the whole batch regardless of how many slots it touched (the
|
||||
// per-user coalescer folds bursts further), excluding the originating
|
||||
// device (`X-Device-Id` = registered `Device.token`). APNs-only,
|
||||
// best-effort, fire-and-forget: the canonical rows are already
|
||||
// persisted, so a sync-push miss never affects the batch response.
|
||||
// The single-intake routes reset a medication's snooze on every recorded
|
||||
// take; a drained offline batch must quiet the snooze the same way, or a
|
||||
// dose synced in later keeps counting as snoozed although it was taken.
|
||||
if (snoozeClearIds.size > 0) {
|
||||
await prisma.medication.updateMany({
|
||||
where: { id: { in: [...snoozeClearIds] }, userId: user.id },
|
||||
data: { snoozedUntil: null },
|
||||
});
|
||||
}
|
||||
|
||||
if (syncSlots.size > 0) {
|
||||
queueMedicationIntakeSync({
|
||||
userId: user.id,
|
||||
originDeviceToken: request.headers.get("x-device-id"),
|
||||
});
|
||||
|
||||
// v1.18.4 — PWA-only counterpart: close the still-pending dose-due
|
||||
// reminders for every affected slot over Web Push and refresh the app
|
||||
// badge once with the post-batch outstanding-dose count. Best-effort,
|
||||
// fire-and-forget — a clear miss never affects the batch response.
|
||||
const affectedSlots = Array.from(syncSlots.values());
|
||||
void (async () => {
|
||||
const badgeCount = await countOutstandingDosesToday(
|
||||
user.id,
|
||||
user.timezone,
|
||||
);
|
||||
await dispatchMedicationIntakeWebClearBulk({
|
||||
userId: user.id,
|
||||
slots: affectedSlots,
|
||||
badgeCount,
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
// v1.42.4 — Apple Health doses get the per-type arrival record the
|
||||
// measurement batch keeps for its types: when they last arrived, under which
|
||||
// trigger, when a sync last brought a new or changed dose, and when a
|
||||
// background sync last carried them. Entries sent with `source:
|
||||
// "APPLE_HEALTH"` only; a dose taken in the app is not an Apple Health
|
||||
// arrival. Replays answered `duplicate` alone are not one (#1259), the
|
||||
// same rule as the batch route.
|
||||
const doseArrival = { accepted: 0, newSamples: 0 };
|
||||
for (const result of results) {
|
||||
if (entries[result.index]?.source !== "APPLE_HEALTH") continue;
|
||||
if (result.status === "skipped") continue;
|
||||
doseArrival.accepted += 1;
|
||||
if (result.status !== "duplicate") doseArrival.newSamples += 1;
|
||||
}
|
||||
if (doseArrival.accepted > 0 && !scoped) {
|
||||
await recordHealthKitTypeArrivals(prisma, {
|
||||
userId: user.id,
|
||||
counts: new Map([[MEDICATION_DOSE_ARRIVAL_TYPE, doseArrival]]),
|
||||
trigger: syncTrigger ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// The SyncMode checkpoint belongs to the push that moved the data, not to
|
||||
// the `/api/sync/state` handshake that reports it. This endpoint is one of
|
||||
// the three domains that handshake summarises, so an intake drain advances
|
||||
// it. A `backfill` catch-up does not: it is history, not a sync.
|
||||
if (syncTrigger !== BACKFILL_SYNC_TRIGGER) {
|
||||
await markSyncCheckpoint(user.id);
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
updated,
|
||||
duplicates,
|
||||
skipped,
|
||||
entries: results,
|
||||
});
|
||||
return apiSuccess(outcome.body);
|
||||
}
|
||||
@@ -22,44 +22,19 @@ import {
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh";
|
||||
import {
|
||||
buildScheduleAnchoredComplianceBuckets,
|
||||
type ScheduleAnchoredComplianceBucket,
|
||||
} from "@/lib/analytics/schedule-anchored-compliance";
|
||||
import { startOfLocalDayInTz } from "@/lib/tz/local-day";
|
||||
import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes";
|
||||
import {
|
||||
applyCanonicalSlotWrite,
|
||||
resolveForcedSlotForWrite,
|
||||
resolveSlotForWriteByBand,
|
||||
} from "@/lib/medications/scheduling/slot-upsert";
|
||||
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
|
||||
import {
|
||||
consumeForIntake,
|
||||
restoreForIntake,
|
||||
} from "@/lib/medications/inventory/consumption";
|
||||
import {
|
||||
intakeAggregateQuerySchema,
|
||||
intakeStatusUpdateSchema,
|
||||
type InjectionSiteValue,
|
||||
} from "@/lib/validations/medication";
|
||||
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
|
||||
import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear";
|
||||
import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake";
|
||||
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
|
||||
import {
|
||||
TRACKED_INTAKE_EVENT_WHERE,
|
||||
isRecordOnly,
|
||||
} from "@/lib/medications/intake-tracking";
|
||||
import { refuseUntrackedIntake } from "@/lib/medications/route-guards";
|
||||
import { readTodayIntakes } from "@/lib/medications/intake-today";
|
||||
import { readIntakeCompliance } from "@/lib/medications/intake-compliance";
|
||||
import { findIntakeForStatusUpdate } from "@/lib/medications/update-intake-status/admission";
|
||||
import { updateIntakeStatus } from "@/lib/medications/update-intake-status/update-intake-status";
|
||||
|
||||
// The query + body schemas moved to `@/lib/validations/medication` when this
|
||||
// The query + body schemas live in `@/lib/validations/medication` since this
|
||||
// endpoint joined the published contract: the OpenAPI registry generates from
|
||||
// the validation modules, and a schema that only exists inside a handler is
|
||||
// one the spec has to restate — which is a second copy to keep in step.
|
||||
@@ -71,22 +46,22 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
Object.fromEntries(request.nextUrl.searchParams),
|
||||
);
|
||||
if (!parsed.success) {
|
||||
// v1.4.43 — multi-issue 422 + audit breadcrumb keyed
|
||||
// Multi-issue 422 + audit breadcrumb keyed
|
||||
// `medications.intake.list.validation-failed`.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
annotate({
|
||||
action: { name: "medications.intake.list.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
// v1.4.49 — strip `message` from the audit-ledger row; iOS-sent
|
||||
// query strings can flow into Zod issue messages.
|
||||
// Strip `message` from the audit-ledger row; iOS-sent query strings can
|
||||
// flow into Zod issue messages.
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
// v1.36.0 — through `auditLog()` rather than a bare `prisma.auditLog
|
||||
// .create`, because that helper is the only thing that stamps
|
||||
// `actorUserId`. Filed under the resolved record either way; without the
|
||||
// stamp a delegate's malformed query would read as the owner's own.
|
||||
// Through `auditLog()` rather than a bare `prisma.auditLog.create`,
|
||||
// because that helper is the only thing that stamps `actorUserId`. Filed
|
||||
// under the resolved record either way; without the stamp a delegate's
|
||||
// malformed query would read as the owner's own.
|
||||
void auditLog("medications.intake.list.validation-failed", {
|
||||
userId: user.id,
|
||||
details: { issues: auditIssues },
|
||||
@@ -98,117 +73,24 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const { scope, days } = parsed.data;
|
||||
|
||||
// v1.4.25 — anchor "today" and per-day compliance buckets to the
|
||||
// user's display timezone so a 23:30 reading in Pacific/Auckland lands
|
||||
// in today's bucket rather than yesterday's Berlin one.
|
||||
// Anchor "today" and per-day compliance buckets to the user's display
|
||||
// timezone so a 23:30 reading in Pacific/Auckland lands in today's bucket
|
||||
// rather than yesterday's Berlin one.
|
||||
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
|
||||
if (scope === "today") {
|
||||
const todayStart = startOfLocalDayInTz(new Date(), userTz);
|
||||
const todayEnd = new Date(todayStart.getTime() + 86_400_000);
|
||||
|
||||
// v1.4.39 — project pending intake rows for every
|
||||
// active schedule whose window opens today, then idempotently
|
||||
// backfill any missing rows. Pre-fix the endpoint returned `[]`
|
||||
// for daily meds (`schedule.daysOfWeek = null` in the DB) until
|
||||
// the reminder worker entered the RED phase at the end of the
|
||||
// dose window — leaving the iOS Dashboard tile + the "Erfassen"
|
||||
// sheet empty for the whole morning. Shared helper mirrors the
|
||||
// dashboard-summary call site so the two routes converge on the
|
||||
// same row set.
|
||||
const { projected, backfilled } = await projectTodayIntakesAndRecompute({
|
||||
userId: user.id,
|
||||
userTz,
|
||||
todayStart,
|
||||
todayEnd,
|
||||
});
|
||||
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
// v1.7.0 sync — exclude tombstoned rows from the today list.
|
||||
deletedAt: null,
|
||||
scheduledFor: { gte: todayStart, lt: todayEnd },
|
||||
// v1.39.1 (#1033) — the today list is what clients offer doses
|
||||
// from; a medication with intake tracking off is not on it.
|
||||
...TRACKED_INTAKE_EVENT_WHERE,
|
||||
},
|
||||
orderBy: { scheduledFor: "asc" },
|
||||
include: { medication: { select: { id: true, snoozedUntil: true } } },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "medications.intake.today" },
|
||||
meta: {
|
||||
count: events.length,
|
||||
projected,
|
||||
backfilled,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
events.map((e) => ({
|
||||
id: e.id,
|
||||
medicationId: e.medicationId,
|
||||
scheduledAt: e.scheduledFor.toISOString(),
|
||||
takenAt: e.takenAt?.toISOString() ?? null,
|
||||
status: e.skipped
|
||||
? "skipped"
|
||||
: e.takenAt
|
||||
? "taken"
|
||||
: // v1.15.9 — a never-acted dose the auto-miss cron flipped is a
|
||||
// terminal MISS, not a perpetual "pending". (Today's freshly
|
||||
// projected rows are < 24 h old so this rarely fires here, but
|
||||
// a stale row surfacing in the window reads honestly.)
|
||||
e.autoMissed
|
||||
? "missed"
|
||||
: e.medication.snoozedUntil &&
|
||||
e.medication.snoozedUntil > new Date()
|
||||
? "snoozed"
|
||||
: "pending",
|
||||
snoozedUntil: e.medication.snoozedUntil?.toISOString() ?? null,
|
||||
})),
|
||||
);
|
||||
return apiSuccess(await readTodayIntakes({ userId: user.id, userTz }));
|
||||
}
|
||||
|
||||
// v1.4.34 — compliance: per-day scheduled vs taken for the last
|
||||
// N days. Cached at a 15-minute TTL because daily compliance buckets
|
||||
// are slow-moving (intake events trickle in, yesterday's row doesn't
|
||||
// move). Cache key carries the userTz so a user who changes timezone
|
||||
// doesn't read another tz's bucketing.
|
||||
//
|
||||
// v1.15.9 — `scheduled` is now the SCHEDULE-ANCHORED expected-dose count
|
||||
// per day (the canonical recurrence engine), NOT the count of logged
|
||||
// intake rows. The old rollup-backed path set `scheduled = COUNT(*)` of
|
||||
// intake rows, so the dashboard tile's rate (`taken / scheduled`) was ~100%
|
||||
// across every window regardless of real adherence — every logged row was
|
||||
// both numerator and denominator. Anchoring `scheduled` to the schedule
|
||||
// makes the rate genuinely reflect taken-of-expected and lets the 7/30/90
|
||||
// windows diverge with partial adherence.
|
||||
const result = await cached(
|
||||
caches.medicationsIntake as ServerCache<ScheduleAnchoredComplianceBucket[]>,
|
||||
`${user.id}|compliance|${days}|${userTz}`,
|
||||
() => buildScheduleAnchoredComplianceBuckets(user.id, days, userTz),
|
||||
annotate,
|
||||
return apiSuccess(
|
||||
await readIntakeCompliance({ userId: user.id, days, userTz }),
|
||||
);
|
||||
|
||||
annotate({
|
||||
action: { name: "medications.intake.compliance" },
|
||||
meta: { days, count: result.length },
|
||||
});
|
||||
|
||||
return apiSuccess(result);
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// v1.36.x — a delegated write, the canonical slot form. Everything the
|
||||
// handler does downstream addresses the RECORD and stays correct: the
|
||||
// cross-device intake sync wakes the OWNER's iOS devices, the web-clear
|
||||
// closes the OWNER's pending dose reminder and rewrites the OWNER's badge,
|
||||
// and the compliance rollup recomputes the OWNER's day. None of them can
|
||||
// address the delegate, and none of them should — the delegate's feedback is
|
||||
// the response they are already awaiting. What the owner gets instead is the
|
||||
// notification at the end of this handler.
|
||||
// A delegated write, the canonical slot form. Everything the handler does
|
||||
// downstream addresses the RECORD and stays correct; what the owner gets
|
||||
// is the notification at the end of the update.
|
||||
const { user, actor } = await requireRecordAuth("write", "medications");
|
||||
|
||||
const { data: body, error } = await safeJson(request, {
|
||||
@@ -218,18 +100,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const parsed = intakeStatusUpdateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
// v1.4.43 — intake-event update hot path; multi-issue 422 +
|
||||
// audit breadcrumb keyed `medications.intake.update.validation-failed`.
|
||||
// Intake-event update hot path; multi-issue 422 + audit breadcrumb keyed
|
||||
// `medications.intake.update.validation-failed`.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
annotate({
|
||||
action: { name: "medications.intake.update.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
// v1.4.49 — strip `message` from the audit-ledger row.
|
||||
// Strip `message` from the audit-ledger row.
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
// v1.36.x — through `auditLog()`, the only writer that stamps the actor.
|
||||
// Through `auditLog()`, the only writer that stamps the actor.
|
||||
void auditLog("medications.intake.update.validation-failed", {
|
||||
userId: user.id,
|
||||
details: { issues: auditIssues },
|
||||
@@ -239,396 +121,29 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const {
|
||||
intakeId,
|
||||
status,
|
||||
takenAt,
|
||||
snoozedUntil,
|
||||
injectionSite,
|
||||
forceSlotInstant,
|
||||
} = parsed.data;
|
||||
|
||||
// v1.7.0 sync — a tombstoned intake 404s on a status toggle; the
|
||||
// `deletedAt: null` filter refuses to mutate a soft-deleted row.
|
||||
const existing = await prisma.medicationIntakeEvent.findFirst({
|
||||
where: { id: intakeId, deletedAt: null },
|
||||
include: {
|
||||
medication: {
|
||||
select: {
|
||||
deliveryForm: true,
|
||||
trackInjectionSites: true,
|
||||
allowedInjectionSites: true,
|
||||
trackIntake: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
const existing = await findIntakeForStatusUpdate({
|
||||
userId: user.id,
|
||||
intakeId: parsed.data.intakeId,
|
||||
});
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
if (!existing) {
|
||||
return apiError("Intake event not found", 404);
|
||||
}
|
||||
|
||||
// A record-only medication keeps the doses it already has, and an owner may
|
||||
// still correct one of those (that is an edit of history). What it no longer
|
||||
// takes is a NEW dose: resolving or snoozing an event that is still open
|
||||
// would record one, and it would then be counted nowhere (iOS #116, item
|
||||
// 15). Refused by name, the same code the per-medication route answers with.
|
||||
const stillOpen = existing.takenAt === null && !existing.skipped;
|
||||
if (stillOpen && isRecordOnly(existing.medication)) {
|
||||
return refuseUntrackedIntake();
|
||||
}
|
||||
|
||||
// v1.36.1 — this route is admitted for delegates because marking a dose is
|
||||
// the thing a person standing next to the patient needs to do. It is an
|
||||
// UPDATE, though, and two of the transitions it can express are not that.
|
||||
//
|
||||
// Snoozing writes `snoozedUntil` on the OWNER's medication row, and the
|
||||
// reminder cron skips a medication for as long as that stamp is in the
|
||||
// future. The field is unbounded, the owner is deliberately not notified of
|
||||
// a delegated snooze (there is nothing useful to say about a thirty-minute
|
||||
// deferral), and the activity feed renders the whole family as "marked a
|
||||
// dose". So a delegate could switch the owner's medication reminders off for
|
||||
// years, and every surface that exists to make delegation visible would
|
||||
// describe it as marking a dose. In an adherence product that is the worst
|
||||
// thing on this route.
|
||||
//
|
||||
// Flipping an already-resolved event is the other one: taken to skipped and
|
||||
// back rewrites the owner's compliance history and refunds inventory. The
|
||||
// shipped promise, on the consent screen and in the release notes, is that
|
||||
// editing what is already there stays with the owner. Marking a dose that is
|
||||
// still open is a contribution; changing a decision the owner already
|
||||
// recorded is an edit.
|
||||
//
|
||||
// Both refuse for a delegate and stay open to the owner, who is the only
|
||||
// person the snooze and the correction were ever for.
|
||||
if (actor.id !== user.id) {
|
||||
const alreadyResolved = existing.takenAt !== null || existing.skipped;
|
||||
const refusal =
|
||||
status === "snoozed"
|
||||
? "snooze"
|
||||
: alreadyResolved
|
||||
? "resolved_event"
|
||||
: null;
|
||||
if (refusal) {
|
||||
annotate({
|
||||
action: { name: "medications.intake.update.refused" },
|
||||
meta: { reason: refusal },
|
||||
});
|
||||
// Filed under the OWNER. `auditLog` stamps the actor itself from the
|
||||
// request's acting context, which is what puts this on the trail the
|
||||
// owner's activity panel reads — a refused attempt to change their
|
||||
// record is worth as much to them as a successful one.
|
||||
await auditLog("medications.intake.update.refused", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { intakeId, status, reason: refusal },
|
||||
});
|
||||
return apiError(
|
||||
refusal === "snooze"
|
||||
? "Snoozing a reminder is not part of shared access"
|
||||
: "Changing a dose that is already recorded is not part of shared access",
|
||||
403,
|
||||
{ errorCode: "sharing.not_permitted" },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// v1.8.5 — resolve + server-validate the optional injection site for a
|
||||
// "taken" toggle. A site outside the medication's effective allowed
|
||||
// set is a hard 422; non-injection / tracking-off / non-taken drops it.
|
||||
let resolvedInjectionSite: InjectionSiteValue | null = null;
|
||||
if (injectionSite !== undefined) {
|
||||
const userRow = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { globalExcludedInjectionSites: true },
|
||||
});
|
||||
const resolution = resolveInjectionSiteForWrite({
|
||||
submitted: injectionSite,
|
||||
taken: status === "taken",
|
||||
deliveryForm: existing.medication.deliveryForm,
|
||||
trackInjectionSites: existing.medication.trackInjectionSites,
|
||||
allowedInjectionSites: existing.medication
|
||||
.allowedInjectionSites as InjectionSiteValue[],
|
||||
globalExcludedInjectionSites: (userRow?.globalExcludedInjectionSites ??
|
||||
[]) as InjectionSiteValue[],
|
||||
});
|
||||
if (resolution.kind === "disallowed") {
|
||||
annotate({
|
||||
action: { name: "medication.intake.injection_site.disallowed" },
|
||||
meta: { medication_id: existing.medicationId, site: resolution.site },
|
||||
});
|
||||
return apiError(
|
||||
"Injection site is not allowed for this medication",
|
||||
422,
|
||||
{
|
||||
errorCode: "medications.intake.injection_site.disallowed",
|
||||
},
|
||||
);
|
||||
}
|
||||
resolvedInjectionSite = resolution.site;
|
||||
}
|
||||
|
||||
const userTzForHook = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
|
||||
let updated;
|
||||
if (status === "taken") {
|
||||
const resolvedTakenAt = takenAt ?? new Date();
|
||||
|
||||
// v1.15.18 — re-run window-band attribution on a taken toggle so an
|
||||
// edited / off-window take re-binds to the right slot instead of leaving
|
||||
// `scheduledFor` stale (audit HIGH-4). `forceSlotInstant` pins onto a
|
||||
// named real slot (422 if it is not one); otherwise band membership picks
|
||||
// the slot (the take's own time on a miss → ad-hoc). Mirrors the
|
||||
// per-event PUT route.
|
||||
let targetScheduledFor: Date;
|
||||
if (forceSlotInstant !== undefined) {
|
||||
const forced = await resolveForcedSlotForWrite({
|
||||
userId: user.id,
|
||||
medicationId: existing.medicationId,
|
||||
userTz: userTzForHook,
|
||||
slotInstant: forceSlotInstant,
|
||||
});
|
||||
if (forced === null) {
|
||||
annotate({
|
||||
action: { name: "medication.intake.force_slot.invalid" },
|
||||
meta: { medication_id: existing.medicationId, intake_id: intakeId },
|
||||
});
|
||||
return apiError(
|
||||
"forceSlotInstant is not a scheduled slot of this medication",
|
||||
422,
|
||||
{ errorCode: "medications.intake.force_slot.invalid" },
|
||||
);
|
||||
}
|
||||
targetScheduledFor = forced;
|
||||
} else {
|
||||
const attribution = await resolveSlotForWriteByBand({
|
||||
userId: user.id,
|
||||
medicationId: existing.medicationId,
|
||||
userTz: userTzForHook,
|
||||
takenAt: resolvedTakenAt,
|
||||
});
|
||||
targetScheduledFor = attribution.slotInstant ?? resolvedTakenAt;
|
||||
}
|
||||
|
||||
const slotMoved =
|
||||
targetScheduledFor.getTime() !== existing.scheduledFor.getTime();
|
||||
|
||||
// v1.16.10 — transition gate for the inventory consume below. Only
|
||||
// a row moving INTO taken may consume; a re-take of an already-
|
||||
// taken row (e.g. a time correction) leaves its stamp frozen, and a
|
||||
// pre-v1.16.10 taken row (NULL stamp, stock already moved by the
|
||||
// legacy hook at take time) must never retro-consume.
|
||||
const wasTaken = existing.takenAt !== null && !existing.skipped;
|
||||
if (!slotMoved) {
|
||||
[updated] = await prisma.$transaction([
|
||||
prisma.medicationIntakeEvent.update({
|
||||
where: { id: intakeId },
|
||||
// v1.7.0 sync — bump the reconciliation counter on every
|
||||
// server-side mutation so the delta feed echoes a monotonic value.
|
||||
data: {
|
||||
takenAt: resolvedTakenAt,
|
||||
skipped: false,
|
||||
// v1.42.2 — the person confirmed it; no longer automatic.
|
||||
autoRecorded: false,
|
||||
syncVersion: { increment: 1 },
|
||||
// v1.8.5 — persist the resolved site on the taken branch.
|
||||
...(resolvedInjectionSite !== null && {
|
||||
injectionSite: resolvedInjectionSite,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
prisma.medication.update({
|
||||
where: { id: existing.medicationId },
|
||||
data: { snoozedUntil: null },
|
||||
}),
|
||||
]);
|
||||
// v1.16.10 — the toggle recorded a take; consume inventory units
|
||||
// on the genuine pending→taken (or skipped→taken) transition. The
|
||||
// stamp additionally keeps a racing replay exactly-once.
|
||||
if (!wasTaken) {
|
||||
await consumeForIntake({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
medicationId: existing.medicationId,
|
||||
eventId: intakeId,
|
||||
intakeAt: resolvedTakenAt,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// The take re-attributed to a different slot. Tombstone the source row
|
||||
// and route the dose through the shared canonical-slot upsert, which
|
||||
// converges onto any row already at the target slot rather than
|
||||
// bare-updating into an occupied slot (P2002-safe).
|
||||
//
|
||||
// v1.16.10 — refund the source row's consumption stamp BEFORE the
|
||||
// tombstone, then consume on the converged row below: a slot move
|
||||
// is a re-binding of one real dose, so the stock nets exactly one
|
||||
// consumption.
|
||||
await restoreForIntake({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
eventId: intakeId,
|
||||
});
|
||||
await prisma.medicationIntakeEvent.update({
|
||||
where: { id: intakeId },
|
||||
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
|
||||
});
|
||||
const applied = await applyCanonicalSlotWrite({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
medicationId: existing.medicationId,
|
||||
canonicalSlot: targetScheduledFor,
|
||||
takenAt: resolvedTakenAt,
|
||||
skipped: false,
|
||||
isExplicitTaken: true,
|
||||
isExplicitSkip: false,
|
||||
idempotencyKey: null,
|
||||
createSource: "WEB",
|
||||
// v1.16.9 — carry the recorded site / dose override across the
|
||||
// tombstone + re-insert: a slot move is a re-binding, not a new
|
||||
// dose, so the original row's documentation must survive when the
|
||||
// toggle write itself carries none.
|
||||
injectionSite:
|
||||
resolvedInjectionSite ??
|
||||
(existing.injectionSite as InjectionSiteValue | null),
|
||||
doseTaken: existing.doseTaken,
|
||||
});
|
||||
updated = applied.row;
|
||||
await prisma.medication.update({
|
||||
where: { id: existing.medicationId },
|
||||
data: { snoozedUntil: null },
|
||||
});
|
||||
// v1.16.10 — consume on the converged row (the old row's stamp
|
||||
// was refunded above, so the move nets one consumption). A taken
|
||||
// source WITHOUT a stamp is a pre-v1.16.10 row whose stock moved
|
||||
// through the legacy hook: the refund above was a no-op, so
|
||||
// consuming on the target would double-charge — skip it.
|
||||
if (!wasTaken || existing.inventoryConsumption !== null) {
|
||||
await consumeForIntake({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
medicationId: existing.medicationId,
|
||||
eventId: applied.row.id,
|
||||
intakeAt: resolvedTakenAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (status === "skipped") {
|
||||
updated = await prisma.medicationIntakeEvent.update({
|
||||
where: { id: intakeId },
|
||||
// v1.7.0 sync — bump the reconciliation counter on the skip toggle.
|
||||
// v1.42.2 — a skip the person records clears the automatic marker.
|
||||
data: {
|
||||
takenAt: null,
|
||||
skipped: true,
|
||||
autoRecorded: false,
|
||||
syncVersion: { increment: 1 },
|
||||
},
|
||||
});
|
||||
// v1.16.10 — the row stopped being taken; refund whatever its
|
||||
// consumption stamp recorded (no-op for a never-consumed row).
|
||||
await restoreForIntake({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
eventId: intakeId,
|
||||
});
|
||||
} else {
|
||||
// snoozed: snoozedUntil lives on the Medication row.
|
||||
const until = snoozedUntil ?? new Date(Date.now() + 30 * 60_000); // default +30min
|
||||
await prisma.medication.update({
|
||||
where: { id: existing.medicationId },
|
||||
data: { snoozedUntil: until },
|
||||
});
|
||||
updated = await prisma.medicationIntakeEvent.findUnique({
|
||||
where: { id: intakeId },
|
||||
const outcome = await updateIntakeStatus(
|
||||
{
|
||||
user,
|
||||
actorId: actor.id,
|
||||
existing,
|
||||
ipAddress: getClientIp(request),
|
||||
originDeviceToken: request.headers.get("x-device-id"),
|
||||
},
|
||||
parsed.data,
|
||||
);
|
||||
if (outcome.kind === "untracked") return refuseUntrackedIntake();
|
||||
if (outcome.kind === "refused") {
|
||||
return apiError(outcome.message, outcome.status, {
|
||||
errorCode: outcome.errorCode,
|
||||
});
|
||||
}
|
||||
|
||||
await auditLog("medications.intake.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { intakeId, status },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "medications.intake.update" },
|
||||
meta: { intakeId, status },
|
||||
});
|
||||
|
||||
// v1.4.34 — bust the medications + compliance + achievement
|
||||
// caches for this user so the next read reflects the dose change.
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
|
||||
// v1.4.39 — refresh the persistent compliance rollup row for
|
||||
// the affected day so the next read after the cache miss returns
|
||||
// the up-to-date `(scheduled, taken, skipped)` tuple. Best-effort:
|
||||
// a populator failure annotates ops without blocking the response.
|
||||
await refreshDoseSlot({
|
||||
userId: user.id,
|
||||
medicationId: existing.medicationId,
|
||||
scheduledFor: existing.scheduledFor,
|
||||
tz: userTzForHook,
|
||||
});
|
||||
// v1.16.9 — a slot move re-binds the dose onto a DIFFERENT instant,
|
||||
// possibly a different local day. Recompute the target day too, or the
|
||||
// source day self-corrects while the destination keeps its stale tuple.
|
||||
const movedTo = updated?.scheduledFor;
|
||||
if (movedTo && movedTo.getTime() !== existing.scheduledFor.getTime()) {
|
||||
await refreshDoseSlot({
|
||||
userId: user.id,
|
||||
medicationId: existing.medicationId,
|
||||
scheduledFor: movedTo,
|
||||
tz: userTzForHook,
|
||||
});
|
||||
}
|
||||
|
||||
// v1.17.1 (#22) — silent cross-device intake sync. Wake the user's OTHER
|
||||
// iOS devices so a running Live Activity / Home-Screen widget reconciles
|
||||
// the dose state changed here. APNs-only, best-effort, coalesced per user:
|
||||
// the canonical row is already persisted, so a sync-push miss never
|
||||
// affects the response. The originating device (the one that POSTed) is
|
||||
// excluded via its `X-Device-Id` (= registered `Device.token`).
|
||||
queueMedicationIntakeSync({
|
||||
userId: user.id,
|
||||
originDeviceToken: request.headers.get("x-device-id"),
|
||||
});
|
||||
|
||||
// v1.18.4 — PWA-only equivalent of the Live Activity end: when a dose is
|
||||
// resolved (taken / skipped), push a `type:"clear"` to the user's Web Push
|
||||
// subscriptions so the still-pending dose-due reminder for this slot is
|
||||
// closed by the service worker (matched on the stable slot tag) and the app
|
||||
// badge re-reflects the outstanding-dose count. A snooze leaves the dose
|
||||
// outstanding, so it neither clears the reminder nor changes the badge.
|
||||
if (status === "taken" || status === "skipped") {
|
||||
void (async () => {
|
||||
const badgeCount = await countOutstandingDosesToday(
|
||||
user.id,
|
||||
userTzForHook,
|
||||
);
|
||||
await dispatchMedicationIntakeWebClear({
|
||||
userId: user.id,
|
||||
medicationId: existing.medicationId,
|
||||
scheduledFor: (movedTo ?? existing.scheduledFor).toISOString(),
|
||||
badgeCount,
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
// v1.36.x — "somebody else marked your dose". The helper refuses on self and
|
||||
// on a snooze, resolves both names itself, and never throws.
|
||||
//
|
||||
// Fire and forget, like every neighbour above. The dispatcher awaits each
|
||||
// channel in the cascade, so an unreachable one would have added its whole
|
||||
// timeout to the caregiver's request — the person standing next to the
|
||||
// patient waiting for a dose to register. The helper never throws, and the
|
||||
// owner learning a moment later is the right trade against the tick being
|
||||
// slow. The integration test already polls the ledger rather than relying on
|
||||
// the await, so nothing about the proof weakens.
|
||||
void notifyDelegatedIntake({
|
||||
ownerId: user.id,
|
||||
actorId: actor.id,
|
||||
medicationId: existing.medicationId,
|
||||
status,
|
||||
});
|
||||
|
||||
return apiSuccess(updated);
|
||||
return apiSuccess(outcome.row);
|
||||
});
|
||||
@@ -20,6 +20,9 @@
|
||||
* Locked contract — see `.planning/v15-ios-handoff/06-ios-responsibilities.md`
|
||||
* §"Cumulative metrics" sibling section for the SyncMode rationale,
|
||||
* and `08-locked-contracts.md` §2 for the batch envelope shape.
|
||||
*
|
||||
* The route authenticates, meters, caps and parses; the per-entry writes live
|
||||
* in `src/lib/mood/bulk-ingest.ts`.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
@@ -27,7 +30,6 @@ import { z } from "zod/v4";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -38,31 +40,9 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { markSyncCheckpoint } from "@/lib/sync/checkpoint";
|
||||
import {
|
||||
getScoreForMood,
|
||||
moodContextSchema,
|
||||
moodLevelEnum,
|
||||
moodSourceEnum,
|
||||
} from "@/lib/validations/mood";
|
||||
import {
|
||||
classifyExternalId,
|
||||
unstableExternalIdMeta,
|
||||
UNSTABLE_EXTERNAL_ID_REASON,
|
||||
type UnstableExternalIdShape,
|
||||
} from "@/lib/validations/external-id";
|
||||
import { moodDateKey } from "@/lib/mood/date-key";
|
||||
import { moodLevelEnum, moodSourceEnum } from "@/lib/validations/mood";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { levelAForWrite } from "@/lib/mood/level-a";
|
||||
import { persistMoodContext } from "@/lib/mood/context";
|
||||
import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
import {
|
||||
createTagLinks,
|
||||
droppedLinkKeysForWire,
|
||||
NO_DROPPED_LINK_KEYS,
|
||||
} from "@/lib/mood/tag-links";
|
||||
import { ingestMoodBulk } from "@/lib/mood/bulk-ingest";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
const MAX_ENTRIES_PER_BATCH = 500;
|
||||
@@ -110,12 +90,12 @@ const bulkEntrySchema = z.object({
|
||||
* to stay correct.
|
||||
*
|
||||
* `unknown` here and parsed with {@link bulkDimensionsSchema} PER ENTRY in
|
||||
* the loop below, for the same reason `context` is: the doc comment beside
|
||||
* the bulk ingest, for the same reason `context` is: the doc comment beside
|
||||
* this field used to promise that an out-of-range value marks THIS entry
|
||||
* skipped while the field itself sat in the batch schema and 422'd all five
|
||||
* hundred. The words were right and the code was not. A phone draining a
|
||||
* year of local history must not lose the year to one row.
|
||||
* @per-entry-parse: bulkDimensionsSchema, in the loop below
|
||||
* @per-entry-parse: bulkDimensionsSchema, in src/lib/mood/bulk-ingest.ts
|
||||
*/
|
||||
a1: z.unknown().optional(),
|
||||
a2: z.unknown().optional(),
|
||||
@@ -128,12 +108,12 @@ const bulkEntrySchema = z.object({
|
||||
* context surface must not blank one filled in on the web.
|
||||
*
|
||||
* Declared as `unknown` here and parsed with `moodContextSchema` PER ENTRY
|
||||
* in the loop below, deliberately. Validating it in the batch schema would
|
||||
* in the bulk ingest, deliberately. Validating it in the batch schema would
|
||||
* make one entry carrying a retired key fail all five hundred, and this
|
||||
* endpoint's whole contract — stated in its own OpenAPI description — is
|
||||
* that a bad row is `skipped` and the rest of the batch lands. A phone
|
||||
* draining a year of local history must not lose the year to one row.
|
||||
* @per-entry-parse: moodContextSchema, in the loop below
|
||||
* @per-entry-parse: moodContextSchema, in src/lib/mood/bulk-ingest.ts
|
||||
*/
|
||||
context: z.unknown().optional(),
|
||||
moodLoggedAt: isoDateTime().transform((s) => new Date(s)),
|
||||
@@ -146,9 +126,9 @@ const bulkEntrySchema = z.object({
|
||||
* existing `(userId, date, moodLoggedAt)` unique index still
|
||||
* protects against straight-up duplicates.
|
||||
*
|
||||
* Stability is enforced per entry in the loop below, not at the field:
|
||||
* Stability is enforced per entry in the bulk ingest, not at the field:
|
||||
* a schema-level refusal would fail the whole batch on one bad row.
|
||||
* @external-id-checked-per-entry: src/app/api/mood-entries/bulk/route.ts
|
||||
* @external-id-checked-per-entry: src/lib/mood/bulk-ingest.ts
|
||||
*/
|
||||
externalId: z.string().min(1).max(120).optional(),
|
||||
});
|
||||
@@ -174,23 +154,6 @@ const bulkPayloadSchema = z.object({
|
||||
entries: z.array(bulkEntrySchema).min(1).max(MAX_ENTRIES_PER_BATCH),
|
||||
});
|
||||
|
||||
type EntryStatus = "inserted" | "duplicate" | "skipped";
|
||||
interface EntryResult {
|
||||
index: number;
|
||||
status: EntryStatus;
|
||||
reason?: string;
|
||||
id?: string;
|
||||
// v1.12.1 — echo the client-supplied source-stable id back on each
|
||||
// result so iOS can map a server row id onto its local SwiftData row
|
||||
// without re-deriving it. Omitted when the entry sent no externalId.
|
||||
externalId?: string;
|
||||
// Submitted keys the catalog did not resolve (unknown, archived, another
|
||||
// account's custom tag, or the wrong kind). Present only when non-empty,
|
||||
// on an "inserted" or "duplicate" result: the entry landed, these did not.
|
||||
droppedTagKeys?: string[];
|
||||
droppedFactorKeys?: string[];
|
||||
}
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk));
|
||||
|
||||
async function postBulk(request: NextRequest): Promise<Response> {
|
||||
@@ -259,416 +222,14 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
});
|
||||
}
|
||||
|
||||
const { entries } = parsed.data;
|
||||
const tz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
|
||||
const results: EntryResult[] = [];
|
||||
let inserted = 0;
|
||||
let duplicates = 0;
|
||||
let droppedTagKeyCount = 0;
|
||||
let droppedFactorKeyCount = 0;
|
||||
const skipped: Array<{ index: number; reason: string }> = [];
|
||||
const unstableShapes: UnstableExternalIdShape[] = [];
|
||||
|
||||
// Perf — a single indexed existence read replaces the per-entry
|
||||
// `findUnique` probe (up to 500 sequential round trips pre-fix). Every
|
||||
// entry still resolves through exactly the key its own dedup contract
|
||||
// uses (externalId when carried, else the legacy date+moodLoggedAt key),
|
||||
// so lookups below are byte-identical to the per-entry probe — only the
|
||||
// read is now batched. The per-entry `upsert` + tombstone check + tag
|
||||
// links + reverse push all stay unchanged; batching those further would
|
||||
// risk the correctness-sensitive per-row side effects for a write path
|
||||
// that is usually small (a normal sync posts the current + previous
|
||||
// local day, not a full historical backfill).
|
||||
interface ExistingMoodRow {
|
||||
id: string;
|
||||
deletedAt: Date | null;
|
||||
}
|
||||
const orClauses: Array<
|
||||
| { source: string; externalId: string }
|
||||
| { date: string; moodLoggedAt: Date }
|
||||
> = entries.map((entry) =>
|
||||
entry.externalId
|
||||
? { source: entry.source, externalId: entry.externalId }
|
||||
: {
|
||||
date: moodDateKey(entry.moodLoggedAt, tz),
|
||||
moodLoggedAt: entry.moodLoggedAt,
|
||||
},
|
||||
return apiSuccess(
|
||||
await ingestMoodBulk({
|
||||
userId: user.id,
|
||||
tz: user.timezone ?? DEFAULT_TIMEZONE,
|
||||
entries: parsed.data.entries,
|
||||
ipAddress: getClientIp(request),
|
||||
parseDimensions: (dimensions) =>
|
||||
bulkDimensionsSchema.safeParse(dimensions),
|
||||
}),
|
||||
);
|
||||
const existingRows =
|
||||
orClauses.length > 0
|
||||
? await prisma.moodEntry.findMany({
|
||||
where: { userId: user.id, OR: orClauses },
|
||||
select: {
|
||||
id: true,
|
||||
deletedAt: true,
|
||||
source: true,
|
||||
externalId: true,
|
||||
date: true,
|
||||
moodLoggedAt: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
const existingByExternalKey = new Map<string, ExistingMoodRow>();
|
||||
const existingByDateKey = new Map<string, ExistingMoodRow>();
|
||||
for (const row of existingRows) {
|
||||
if (row.externalId !== null) {
|
||||
existingByExternalKey.set(`${row.source}::${row.externalId}`, row);
|
||||
}
|
||||
existingByDateKey.set(
|
||||
`${row.date}::${row.moodLoggedAt.toISOString()}`,
|
||||
row,
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
|
||||
// The `(userId, source, externalId)` dedup key only holds while the
|
||||
// id is STABLE across client launches; a per-process value (an object
|
||||
// description carrying a memory address) mints a fresh entry on every
|
||||
// re-post. Per-entry refusal, never a whole-batch 422.
|
||||
const unstable =
|
||||
entry.externalId === undefined
|
||||
? null
|
||||
: classifyExternalId(entry.externalId);
|
||||
if (unstable) {
|
||||
unstableShapes.push(unstable);
|
||||
skipped.push({ index: i, reason: UNSTABLE_EXTERNAL_ID_REASON });
|
||||
results.push({
|
||||
index: i,
|
||||
status: "skipped",
|
||||
reason: UNSTABLE_EXTERNAL_ID_REASON,
|
||||
externalId: entry.externalId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// The context, parsed for THIS entry. A value outside the vocabulary
|
||||
// skips this row and nothing else.
|
||||
const parsedContext =
|
||||
entry.context === undefined
|
||||
? { success: true as const, data: undefined }
|
||||
: moodContextSchema.nullable().safeParse(entry.context);
|
||||
if (!parsedContext.success) {
|
||||
const reason = "invalid_context";
|
||||
skipped.push({ index: i, reason });
|
||||
results.push({
|
||||
index: i,
|
||||
status: "skipped",
|
||||
reason,
|
||||
...(entry.externalId ? { externalId: entry.externalId } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// The five dimensions, parsed for THIS entry. Out of range skips this row
|
||||
// and nothing else.
|
||||
const parsedDimensions = bulkDimensionsSchema.safeParse({
|
||||
a1: entry.a1,
|
||||
a2: entry.a2,
|
||||
a3: entry.a3,
|
||||
a4: entry.a4,
|
||||
a5: entry.a5,
|
||||
});
|
||||
if (!parsedDimensions.success) {
|
||||
const reason = "invalid_dimensions";
|
||||
skipped.push({ index: i, reason });
|
||||
results.push({
|
||||
index: i,
|
||||
status: "skipped",
|
||||
reason,
|
||||
...(entry.externalId ? { externalId: entry.externalId } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const date = moodDateKey(entry.moodLoggedAt, tz);
|
||||
const score = getScoreForMood(entry.mood);
|
||||
// v1.37 / v1.38 — the same resolution the single-entry path uses:
|
||||
// pleasantness derived from the label unless the entry states its own, and
|
||||
// the other four present only when this entry carried them. An omitted
|
||||
// dimension is left alone rather than written as null, so a row that
|
||||
// already carries hand-set values keeps them across a re-import.
|
||||
const levelA = levelAForWrite(entry.mood, parsedDimensions.data);
|
||||
|
||||
try {
|
||||
// v1.12.1 — when the entry carries a source-stable `externalId`,
|
||||
// dedup on the NULL-distinct `(userId, source, externalId)` key so a
|
||||
// re-post with the same id (an iOS retry after a network hiccup, or a
|
||||
// second adopt-on-pair backfill) updates the existing row in place
|
||||
// instead of minting a duplicate when `moodLoggedAt` re-rounds /
|
||||
// re-zones. Absent → the legacy `(userId, date, moodLoggedAt)` key.
|
||||
// `source` carries a schema default of "MANUAL"; resolve it once so
|
||||
// the probe, upsert key, and create write all agree on the value.
|
||||
const resolvedSource = entry.source;
|
||||
const probeWhere = entry.externalId
|
||||
? {
|
||||
userId_source_externalId: {
|
||||
userId: user.id,
|
||||
source: resolvedSource,
|
||||
externalId: entry.externalId,
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId_date_moodLoggedAt: {
|
||||
userId: user.id,
|
||||
date,
|
||||
moodLoggedAt: entry.moodLoggedAt,
|
||||
},
|
||||
};
|
||||
|
||||
// The response reliably distinguishes "inserted" from "duplicate"
|
||||
// via the batched pre-read above (keyed exactly like `probeWhere`),
|
||||
// so the per-entry probe here is now a Map lookup, not a round trip.
|
||||
const existing =
|
||||
(entry.externalId
|
||||
? existingByExternalKey.get(`${resolvedSource}::${entry.externalId}`)
|
||||
: existingByDateKey.get(
|
||||
`${date}::${entry.moodLoggedAt.toISOString()}`,
|
||||
)) ?? null;
|
||||
|
||||
// Tombstone suppression: a soft-deleted match stays deleted. The
|
||||
// user-facing DELETE route flips `deletedAt` so the `/api/sync/changes`
|
||||
// feed surfaces the deletion to paired clients offline at delete time
|
||||
// (see the MoodEntry schema note) — an offline client's later re-post
|
||||
// of the same entry is stale state, not a recreation, and must not
|
||||
// resurrect the row. True no-op: no value churn, no `updatedAt` bump,
|
||||
// no tag-link writes on the hidden row; report `duplicate` so the
|
||||
// client checkpoints past it exactly like a live-row match.
|
||||
if (existing?.deletedAt) {
|
||||
duplicates += 1;
|
||||
results.push({
|
||||
index: i,
|
||||
status: "duplicate",
|
||||
id: existing.id,
|
||||
...(entry.externalId ? { externalId: entry.externalId } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// The upsert and the tag/factor links run in ONE transaction, matching
|
||||
// the single-entry POST path. Split apart, an out-of-scale
|
||||
// `ratedFactors` value (validated inside `createTagLinks`, i.e. AFTER
|
||||
// the row committed) left a persisted mood entry with no links while the
|
||||
// per-entry catch reported the entry as "skipped" — the client believes
|
||||
// nothing saved and a half-written row is on disk.
|
||||
const written = await prisma.$transaction(async (tx) => {
|
||||
const upserted = await tx.moodEntry.upsert({
|
||||
where: probeWhere,
|
||||
create: {
|
||||
userId: user.id,
|
||||
date,
|
||||
tz,
|
||||
mood: entry.mood,
|
||||
score,
|
||||
...levelA,
|
||||
tags: entry.tags ? JSON.stringify(entry.tags) : null,
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(entry.note ?? null),
|
||||
source: resolvedSource,
|
||||
externalId: entry.externalId ?? null,
|
||||
moodLoggedAt: entry.moodLoggedAt,
|
||||
},
|
||||
update: {
|
||||
// Last-writer-wins on the mood + tags + note triple. The
|
||||
// iOS client only re-posts an existing entry when it has
|
||||
// new data; the server trusts that decision. When the dedup
|
||||
// key is `externalId`, also refresh `date` / `moodLoggedAt`
|
||||
// so a re-zoned re-import lands the corrected wall-clock on
|
||||
// the same row.
|
||||
mood: entry.mood,
|
||||
score,
|
||||
...levelA,
|
||||
tags: entry.tags ? JSON.stringify(entry.tags) : null,
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(entry.note ?? null),
|
||||
...(entry.externalId
|
||||
? { date, moodLoggedAt: entry.moodLoggedAt }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
// v1.12.0 — persist structured-tag links, mirroring the
|
||||
// single-entry `createTagLinks` path. Additive + idempotent:
|
||||
// `createTagLinks` resolves keys against the catalog (dropping
|
||||
// unknown keys) and `skipDuplicates` on the join insert keeps a
|
||||
// re-posted entry from minting duplicate links. Runs for both
|
||||
// fresh and re-posted (upserted) rows so a backfill that adds tag
|
||||
// keys on a second pass still lands them.
|
||||
// v1.12.0 — rated factors ride the same path; an out-of-scale
|
||||
// rating throws `RatedFactorOutOfRangeError`. Inside the
|
||||
// transaction that throw now rolls the mood row back with it, so a
|
||||
// "skipped" result means nothing was written — which is what the
|
||||
// client was already being told.
|
||||
// v1.38 — the day context, inside the same transaction as the row, so
|
||||
// an entry reported as skipped really did write nothing.
|
||||
await persistMoodContext(tx, upserted.id, user.id, parsedContext.data);
|
||||
|
||||
// The keys the catalog did not resolve are reported on this entry's
|
||||
// result: "inserted" alone told a client its tags had landed.
|
||||
let droppedKeys = NO_DROPPED_LINK_KEYS;
|
||||
if (
|
||||
(entry.tagKeys && entry.tagKeys.length > 0) ||
|
||||
(entry.ratedFactors && entry.ratedFactors.length > 0)
|
||||
) {
|
||||
droppedKeys = await createTagLinks(
|
||||
upserted.id,
|
||||
user.id,
|
||||
entry.tagKeys ?? [],
|
||||
tx,
|
||||
entry.ratedFactors ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
return { upserted, droppedKeys };
|
||||
});
|
||||
const result = written.upserted;
|
||||
const droppedKeys = written.droppedKeys;
|
||||
droppedTagKeyCount += droppedKeys.droppedTagKeys.length;
|
||||
droppedFactorKeyCount += droppedKeys.droppedFactorKeys.length;
|
||||
|
||||
if (existing) {
|
||||
duplicates += 1;
|
||||
results.push({
|
||||
index: i,
|
||||
status: "duplicate",
|
||||
id: result.id,
|
||||
...(entry.externalId ? { externalId: entry.externalId } : {}),
|
||||
...droppedLinkKeysForWire(droppedKeys),
|
||||
});
|
||||
} else {
|
||||
inserted += 1;
|
||||
results.push({
|
||||
index: i,
|
||||
status: "inserted",
|
||||
id: result.id,
|
||||
...(entry.externalId ? { externalId: entry.externalId } : {}),
|
||||
...droppedLinkKeysForWire(droppedKeys),
|
||||
});
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
// v1.37.19 — P2002 = a concurrent batch raced the same
|
||||
// NULL-distinct `(userId, source, externalId)` key and won. That is
|
||||
// the plain duplicate the sequential probe would have classified;
|
||||
// reporting it as "skipped" with a raw Prisma message told the
|
||||
// client its row was dropped when it is in fact stored. Resolve the
|
||||
// winning row's id so the caller's cursor advances, exactly like
|
||||
// the intake bulk route.
|
||||
if (
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
"code" in err &&
|
||||
(err as { code: string }).code === "P2002" &&
|
||||
entry.externalId
|
||||
) {
|
||||
const winner = await prisma.moodEntry.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
// Same resolution as the upsert key: the schema defaults
|
||||
// `source` to "MANUAL" at parse time.
|
||||
source: entry.source,
|
||||
externalId: entry.externalId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
duplicates += 1;
|
||||
results.push({
|
||||
index: i,
|
||||
status: "duplicate",
|
||||
...(winner ? { id: winner.id } : {}),
|
||||
externalId: entry.externalId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const reason =
|
||||
err instanceof Error ? err.message.slice(0, 120) : "upsert_failed";
|
||||
skipped.push({ index: i, reason });
|
||||
results.push({
|
||||
index: i,
|
||||
status: "skipped",
|
||||
reason,
|
||||
...(entry.externalId ? { externalId: entry.externalId } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await auditLog("mood.bulk.ingest", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
duplicates,
|
||||
skipped: skipped.length,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "mood.bulk.ingest" },
|
||||
meta: {
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
duplicates,
|
||||
skipped: skipped.length,
|
||||
dropped_tag_keys: droppedTagKeyCount,
|
||||
dropped_factor_keys: droppedFactorKeyCount,
|
||||
...(unstableShapes.length > 0
|
||||
? unstableExternalIdMeta("mood.bulk", unstableShapes)
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
// v1.4.34 — bust per-user mood + achievements + analytics caches
|
||||
// when at least one row landed so the next read picks up the ingested
|
||||
// batch. Skipped / duplicate-only ingests are no-ops.
|
||||
if (inserted > 0) {
|
||||
invalidateUserMood(user.id);
|
||||
}
|
||||
|
||||
// v1.4.39 — refresh the rollup tier for every distinct day
|
||||
// touched by this batch. The bulk endpoint is an iOS one-shot
|
||||
// backfill so the batch can span many days; we collapse to the
|
||||
// unique `(user, dayStart)` set first to bound the recompute count.
|
||||
// Best-effort: rollup failures must not surface as 5xx.
|
||||
if (inserted > 0 || duplicates > 0) {
|
||||
// v1.32.12 — collapse to the unique set of `date` labels touched by
|
||||
// this batch (the same per-row `moodDateKey` the insert used), so
|
||||
// the rollup keys byte-identically to the stored `MoodEntry.date`.
|
||||
const touchedLabels = new Set<string>();
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const status = results[i]?.status;
|
||||
if (status === "inserted" || status === "duplicate") {
|
||||
touchedLabels.add(moodDateKey(entries[i].moodLoggedAt, tz));
|
||||
}
|
||||
}
|
||||
try {
|
||||
await Promise.all(
|
||||
Array.from(touchedLabels).map((label) =>
|
||||
recomputeMoodBucketsForEntry(user.id, label),
|
||||
),
|
||||
);
|
||||
} catch (rollupErr) {
|
||||
annotate({
|
||||
meta: {
|
||||
mood_rollup_write_failed: true,
|
||||
mood_rollup_write_error:
|
||||
rollupErr instanceof Error ? rollupErr.message : String(rollupErr),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// The SyncMode checkpoint belongs to the push that moved the data, not to
|
||||
// the `/api/sync/state` handshake that reports it. This endpoint is one of
|
||||
// the three domains that handshake summarises, so a mood drain advances it.
|
||||
await markSyncCheckpoint(user.id);
|
||||
|
||||
return apiSuccess({
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
duplicates,
|
||||
skipped,
|
||||
entries: results,
|
||||
});
|
||||
}
|
||||
@@ -46,16 +46,11 @@
|
||||
* `apiHandler` + `requireAuth` (cookie OR Bearer; iOS uses Bearer).
|
||||
* Read-only — no idempotency, no write side-effect (unlike the legacy
|
||||
* `/api/sync/state` checkpoint bump). The cursor is owned by the client.
|
||||
* The pull itself lives in `src/lib/sync/changes/`.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
findCompactionTombstones,
|
||||
isFoldedWindowCandidate,
|
||||
loadFoldTimezone,
|
||||
} from "@/lib/measurements/folded-window";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
@@ -63,153 +58,17 @@ import {
|
||||
apiValidationError,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { readNote } from "@/lib/crypto/note-cipher";
|
||||
import { levelAForWire } from "@/lib/mood/level-a";
|
||||
import { contextForWire, type MoodContextWire } from "@/lib/mood/context";
|
||||
import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client";
|
||||
import {
|
||||
decodeCursor,
|
||||
decodeCursorIssuedAt,
|
||||
encodeCursor,
|
||||
SYNC_DOMAINS,
|
||||
type DomainWatermark,
|
||||
type SyncCursor,
|
||||
type SyncDomain,
|
||||
} from "@/lib/sync/cursor";
|
||||
import {
|
||||
toCycleDayLogDTO,
|
||||
toMenstrualCycleDTO,
|
||||
dayLogSymptomInclude,
|
||||
type CycleDayLogDTO,
|
||||
type MenstrualCycleDTO,
|
||||
} from "@/lib/cycle/dto";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import { pullSyncChanges } from "@/lib/sync/changes/pull";
|
||||
|
||||
const DEFAULT_LIMIT = 200;
|
||||
const MAX_LIMIT = 500;
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
const querySchema = z.object({
|
||||
cursor: z.string().min(1).max(2048).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(MAX_LIMIT).optional(),
|
||||
});
|
||||
|
||||
interface MeasurementUpsert {
|
||||
id: string;
|
||||
externalId: string | null;
|
||||
type: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
measuredAt: string;
|
||||
source: string;
|
||||
notes: string | null;
|
||||
syncVersion: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface MeasurementTombstone {
|
||||
id: string;
|
||||
externalId: string | null;
|
||||
syncVersion: number;
|
||||
deletedAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface MoodUpsert {
|
||||
id: string;
|
||||
date: string;
|
||||
mood: string;
|
||||
score: number;
|
||||
/**
|
||||
* v1.38 — the five level-A self-state values, each 0-10 and each nullable.
|
||||
* `a2` is inverse-oriented: a higher number means more stress, and a client
|
||||
* that wants "up is better" flips the sign itself rather than the server
|
||||
* storing a flipped value.
|
||||
*
|
||||
* Server-authoritative: `a1` is derived from the five-point label on every
|
||||
* write, so a client that sends only `mood` still mirrors a complete row.
|
||||
*/
|
||||
a1: number | null;
|
||||
a2: number | null;
|
||||
a3: number | null;
|
||||
a4: number | null;
|
||||
a5: number | null;
|
||||
/**
|
||||
* v1.38 — the day's context, or null when the entry carries none. Null and
|
||||
* an object of nulls are different answers and the feed keeps them apart.
|
||||
*/
|
||||
context: MoodContextWire | null;
|
||||
tags: string | null;
|
||||
note: string | null;
|
||||
moodLoggedAt: string;
|
||||
source: string;
|
||||
syncVersion: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface MoodTombstone {
|
||||
id: string;
|
||||
syncVersion: number;
|
||||
deletedAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface IntakeUpsert {
|
||||
id: string;
|
||||
medicationId: string;
|
||||
scheduledFor: string;
|
||||
takenAt: string | null;
|
||||
skipped: boolean;
|
||||
source: string;
|
||||
/** v1.42.2 — recorded by the server (`autoMarkTaken`), not the person. */
|
||||
autoRecorded: boolean;
|
||||
syncVersion: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface IntakeTombstone {
|
||||
id: string;
|
||||
syncVersion: number;
|
||||
deletedAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// v1.15.0 — cycle domains. cycleDays key tombstones on `externalId` when
|
||||
// present (HealthKit-origin rows are externalId-keyed cross-device, like
|
||||
// measurements) else the server `id`; cycles key on the server `id`
|
||||
// (computed rows carry no externalId).
|
||||
interface CycleDayTombstone {
|
||||
id: string;
|
||||
externalId: string | null;
|
||||
syncVersion: number;
|
||||
deletedAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface CycleTombstone {
|
||||
id: string;
|
||||
syncVersion: number;
|
||||
deletedAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `(updatedAt, id)` keyset predicate for a domain's watermark.
|
||||
* Empty object (full scan) when the client has no watermark for it yet.
|
||||
*/
|
||||
function keysetFilter(wm: DomainWatermark | undefined) {
|
||||
if (!wm) return {};
|
||||
const at = new Date(wm.updatedAtMs);
|
||||
return {
|
||||
OR: [
|
||||
{ updatedAt: { gt: at } },
|
||||
{ AND: [{ updatedAt: at }, { id: { gt: wm.id } }] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
@@ -232,424 +91,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
422,
|
||||
);
|
||||
}
|
||||
const limit = parsed.data.limit ?? DEFAULT_LIMIT;
|
||||
|
||||
const serverNow = new Date();
|
||||
|
||||
// ── module gate: cycle ────────────────────────────────────────────
|
||||
// The `cycleDays` / `cycles` domains carry period days and per-day
|
||||
// symptom rows — the same data every `/api/cycle/*` route refuses with a
|
||||
// 403 when the module is off. Without this the delta feed re-served them
|
||||
// ungated to the very same token.
|
||||
//
|
||||
// OMIT, NOT 403 — deliberately, and unlike the whole-record FHIR export.
|
||||
// This feed multiplexes five domains over one cursor: refusing the whole
|
||||
// request would wedge measurement / mood / intake catch-up for every
|
||||
// account with cycle tracking off, which (the gate delegates to
|
||||
// `isCycleEnabled`) is the default for most accounts. A partial payload
|
||||
// is the only outcome that keeps the client syncing while still honouring
|
||||
// the promise that a disabled module hands out nothing.
|
||||
//
|
||||
// The two cycle watermarks are deliberately NOT advanced while the module
|
||||
// is off (the queries are skipped, so `advanceCursor` sees an empty page
|
||||
// and leaves the prior watermark untouched). Re-enabling therefore resumes
|
||||
// from where the client actually left off instead of silently skipping the
|
||||
// rows that changed while the module was hidden.
|
||||
//
|
||||
// KNOWN GAP, out of scope here: a client that synced cycle rows BEFORE the
|
||||
// module was turned off keeps its stale local mirror. Emitting tombstones
|
||||
// for them would be wrong — a tombstone means deleted, and disabling a
|
||||
// module must leave the rows intact so re-enabling finds a complete
|
||||
// history. Purging a paired client's mirror needs an explicit
|
||||
// "module went dark" signal in the feed contract plus client support for
|
||||
// it; that is an additive wire change and an iOS-client change, not a
|
||||
// server-side gate fix, so it is left for a coordinated contract cycle.
|
||||
const cycleEnabled = await isModuleEnabled(user.id, "cycle");
|
||||
|
||||
// The retention horizon: tombstones older than this may have been
|
||||
// pruned by the cleanup job, so a cursor that predates it can no longer
|
||||
// be trusted to deliver every deletion incrementally.
|
||||
const retentionHorizon = new Date(
|
||||
serverNow.getTime() - TOMBSTONE_RETENTION_DAYS * DAY_MS,
|
||||
return apiSuccess(
|
||||
await pullSyncChanges({
|
||||
userId: user.id,
|
||||
token: parsed.data.cursor,
|
||||
limit: parsed.data.limit ?? DEFAULT_LIMIT,
|
||||
serverNow: new Date(),
|
||||
}),
|
||||
);
|
||||
|
||||
// A garbage / unparseable / old-format cursor decodes to null → fresh
|
||||
// initial sync (every domain scans from the start).
|
||||
const cursor: SyncCursor = parsed.data.cursor
|
||||
? (decodeCursor(parsed.data.cursor) ?? {})
|
||||
: {};
|
||||
|
||||
// A stale cursor (ANY domain watermark older than retention) forces a
|
||||
// clean re-init: a deletion older than retention may already be pruned.
|
||||
const pastRetention = SYNC_DOMAINS.some((domain) => {
|
||||
const wm = cursor[domain];
|
||||
return wm !== undefined && wm.updatedAtMs < retentionHorizon.getTime();
|
||||
});
|
||||
// A cursor issued before the account's record was last replaced (a backup
|
||||
// restore, or the person deleting all their data) cannot be caught up
|
||||
// either: the restore wrote rows back with their original `updatedAt`,
|
||||
// behind the cursor's watermarks, and removed rows without tombstones. The
|
||||
// client re-initialises instead. Compared by the time the cursor was
|
||||
// issued, not by its watermarks, which a restore leaves meaningless; a
|
||||
// cursor from before the issue time was recorded counts as issued before
|
||||
// any reset.
|
||||
let beforeReset = false;
|
||||
if (parsed.data.cursor) {
|
||||
const account = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { syncResetAt: true },
|
||||
});
|
||||
if (account?.syncResetAt) {
|
||||
const issuedAt = decodeCursorIssuedAt(parsed.data.cursor);
|
||||
beforeReset =
|
||||
issuedAt === null || issuedAt < account.syncResetAt.getTime();
|
||||
}
|
||||
}
|
||||
const expired = pastRetention || beforeReset;
|
||||
if (expired) {
|
||||
annotate({
|
||||
action: { name: "sync.changes.pull" },
|
||||
meta: {
|
||||
cursor_expired: true,
|
||||
cursor_expired_reason: beforeReset ? "sync_reset" : "retention",
|
||||
returned: 0,
|
||||
},
|
||||
});
|
||||
return apiSuccess({
|
||||
serverNow: serverNow.toISOString(),
|
||||
cursor: parsed.data.cursor ?? null,
|
||||
hasMore: false,
|
||||
cursorExpired: true,
|
||||
changes: {
|
||||
measurements: { upserts: [], tombstones: [] },
|
||||
mood: { upserts: [], tombstones: [] },
|
||||
intakes: { upserts: [], tombstones: [] },
|
||||
cycleDays: { upserts: [], tombstones: [] },
|
||||
cycles: { upserts: [], tombstones: [] },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Per-domain keyset walks. Both live and soft-deleted rows are in the
|
||||
// same scan — a soft-delete bumps `updatedAt`, so a tombstone is just a
|
||||
// row whose `deletedAt` is non-null. Fetch limit+1 to detect `hasMore`
|
||||
// without a count query.
|
||||
const [measurementRows, moodRows, intakeRows, cycleDayRows, cycleRows] =
|
||||
await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where: { userId: user.id, ...keysetFilter(cursor.measurements) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
select: {
|
||||
id: true,
|
||||
externalId: true,
|
||||
type: true,
|
||||
value: true,
|
||||
unit: true,
|
||||
measuredAt: true,
|
||||
source: true,
|
||||
notes: true,
|
||||
notesEncrypted: true,
|
||||
syncVersion: true,
|
||||
deletedAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.moodEntry.findMany({
|
||||
where: { userId: user.id, ...keysetFilter(cursor.mood) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
select: {
|
||||
id: true,
|
||||
date: true,
|
||||
mood: true,
|
||||
score: true,
|
||||
moodA1: true,
|
||||
stressA2: true,
|
||||
energyA3: true,
|
||||
connectionA4: true,
|
||||
stabilityA5: true,
|
||||
context: true,
|
||||
tags: true,
|
||||
note: true,
|
||||
noteEncrypted: true,
|
||||
moodLoggedAt: true,
|
||||
source: true,
|
||||
syncVersion: true,
|
||||
deletedAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: { userId: user.id, ...keysetFilter(cursor.intakes) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
select: {
|
||||
id: true,
|
||||
medicationId: true,
|
||||
scheduledFor: true,
|
||||
takenAt: true,
|
||||
skipped: true,
|
||||
source: true,
|
||||
autoRecorded: true,
|
||||
syncVersion: true,
|
||||
deletedAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
// Skipped outright when the module is off — not filtered after the
|
||||
// fact — so the disabled rows are never read out of Postgres at all.
|
||||
cycleEnabled
|
||||
? prisma.cycleDayLog.findMany({
|
||||
where: { userId: user.id, ...keysetFilter(cursor.cycleDays) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
include: dayLogSymptomInclude,
|
||||
})
|
||||
: [],
|
||||
cycleEnabled
|
||||
? prisma.menstrualCycle.findMany({
|
||||
where: { userId: user.id, ...keysetFilter(cursor.cycles) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
|
||||
const nextCursor: SyncCursor = { ...cursor };
|
||||
let hasMore = false;
|
||||
|
||||
// ── measurements ──────────────────────────────────────────
|
||||
const mHasMore = measurementRows.length > limit;
|
||||
const mPage = mHasMore ? measurementRows.slice(0, limit) : measurementRows;
|
||||
const measurementUpserts: MeasurementUpsert[] = [];
|
||||
const measurementTombstones: MeasurementTombstone[] = [];
|
||||
// v1.42 — compaction tombstones are not deletions anyone made. The folds
|
||||
// used to soft-delete the raw Apple Health samples they folded into an
|
||||
// hourly or daily `stats:` row, and those rows rode this feed as
|
||||
// tombstones for 75 days. They are left out now, by the same classifier the
|
||||
// backlog purge uses (`folded-window.ts`); the cursor still advances past
|
||||
// them, so the keyset walk is unchanged. A person's own deletions are
|
||||
// reported exactly as before.
|
||||
const compaction = new Set<string>();
|
||||
const tombstonesOnPage = mPage.filter((row) => row.deletedAt !== null);
|
||||
if (tombstonesOnPage.length > 0) {
|
||||
const candidates = tombstonesOnPage.map((row) => ({
|
||||
type: row.type,
|
||||
source: row.source,
|
||||
externalId: row.externalId,
|
||||
measuredAt: row.measuredAt,
|
||||
deletedAt: row.deletedAt,
|
||||
}));
|
||||
if (
|
||||
candidates.some((row) => isFoldedWindowCandidate(row, row.deletedAt!))
|
||||
) {
|
||||
const classA = await findCompactionTombstones(
|
||||
prisma,
|
||||
user.id,
|
||||
await loadFoldTimezone(prisma, user.id),
|
||||
candidates,
|
||||
);
|
||||
for (const index of classA) compaction.add(tombstonesOnPage[index].id);
|
||||
}
|
||||
}
|
||||
for (const row of mPage) {
|
||||
if (compaction.has(row.id)) continue;
|
||||
if (row.deletedAt) {
|
||||
measurementTombstones.push({
|
||||
id: row.id,
|
||||
externalId: row.externalId,
|
||||
syncVersion: row.syncVersion,
|
||||
deletedAt: row.deletedAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
} else {
|
||||
// Every row carries its `source`. RECOVERY_SCORE is written by BOTH the
|
||||
// WHOOP-native sync (`source = WHOOP`) and the COMPUTED proxy job
|
||||
// (`source = COMPUTED`) for the same day; the client must resolve to ONE
|
||||
// canonical recovery series — WHOOP wins when present, COMPUTED is the
|
||||
// fallback — and never chart the proxy and the native value as two
|
||||
// competing series. The feed faithfully mirrors both rows (a delta-sync
|
||||
// mirror cannot drop a row mid-keyset without a tombstone); the
|
||||
// canonical pick is the same WHOOP-over-COMPUTED rule the server's
|
||||
// wellness tile + doctor PDF apply (`resolveCanonicalRecovery`).
|
||||
measurementUpserts.push({
|
||||
id: row.id,
|
||||
externalId: row.externalId,
|
||||
type: row.type,
|
||||
value: row.value,
|
||||
unit: row.unit,
|
||||
measuredAt: row.measuredAt.toISOString(),
|
||||
source: row.source,
|
||||
notes: readNote(row.notesEncrypted, row.notes),
|
||||
syncVersion: row.syncVersion,
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
advanceCursor(nextCursor, "measurements", mPage);
|
||||
hasMore = hasMore || mHasMore;
|
||||
|
||||
// ── mood ──────────────────────────────────────────────────
|
||||
const moodHasMore = moodRows.length > limit;
|
||||
const moodPage = moodHasMore ? moodRows.slice(0, limit) : moodRows;
|
||||
const moodUpserts: MoodUpsert[] = [];
|
||||
const moodTombstones: MoodTombstone[] = [];
|
||||
for (const row of moodPage) {
|
||||
if (row.deletedAt) {
|
||||
moodTombstones.push({
|
||||
id: row.id,
|
||||
syncVersion: row.syncVersion,
|
||||
deletedAt: row.deletedAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
} else {
|
||||
moodUpserts.push({
|
||||
id: row.id,
|
||||
date: row.date,
|
||||
mood: row.mood,
|
||||
score: row.score,
|
||||
// Under the same wire keys the write path accepts, so a client reads
|
||||
// back exactly what it can post.
|
||||
...levelAForWire(row),
|
||||
context: row.context ? contextForWire(row.context) : null,
|
||||
tags: row.tags,
|
||||
note: readNote(row.noteEncrypted, row.note),
|
||||
moodLoggedAt: row.moodLoggedAt.toISOString(),
|
||||
source: row.source,
|
||||
syncVersion: row.syncVersion,
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
advanceCursor(nextCursor, "mood", moodPage);
|
||||
hasMore = hasMore || moodHasMore;
|
||||
|
||||
// ── intakes ───────────────────────────────────────────────
|
||||
const intakeHasMore = intakeRows.length > limit;
|
||||
const intakePage = intakeHasMore ? intakeRows.slice(0, limit) : intakeRows;
|
||||
const intakeUpserts: IntakeUpsert[] = [];
|
||||
const intakeTombstones: IntakeTombstone[] = [];
|
||||
for (const row of intakePage) {
|
||||
if (row.deletedAt) {
|
||||
intakeTombstones.push({
|
||||
id: row.id,
|
||||
syncVersion: row.syncVersion,
|
||||
deletedAt: row.deletedAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
} else {
|
||||
intakeUpserts.push({
|
||||
id: row.id,
|
||||
medicationId: row.medicationId,
|
||||
scheduledFor: row.scheduledFor.toISOString(),
|
||||
takenAt: row.takenAt?.toISOString() ?? null,
|
||||
skipped: row.skipped,
|
||||
source: row.source,
|
||||
autoRecorded: row.autoRecorded,
|
||||
syncVersion: row.syncVersion,
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
advanceCursor(nextCursor, "intakes", intakePage);
|
||||
hasMore = hasMore || intakeHasMore;
|
||||
|
||||
// ── cycleDays ─────────────────────────────────────────────
|
||||
const cycleDayHasMore = cycleDayRows.length > limit;
|
||||
const cycleDayPage = cycleDayHasMore
|
||||
? cycleDayRows.slice(0, limit)
|
||||
: cycleDayRows;
|
||||
const cycleDayUpserts: CycleDayLogDTO[] = [];
|
||||
const cycleDayTombstones: CycleDayTombstone[] = [];
|
||||
for (const row of cycleDayPage) {
|
||||
if (row.deletedAt) {
|
||||
cycleDayTombstones.push({
|
||||
id: row.id,
|
||||
externalId: row.externalId,
|
||||
syncVersion: row.syncVersion,
|
||||
deletedAt: row.deletedAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
} else {
|
||||
cycleDayUpserts.push(toCycleDayLogDTO(row));
|
||||
}
|
||||
}
|
||||
advanceCursor(nextCursor, "cycleDays", cycleDayPage);
|
||||
hasMore = hasMore || cycleDayHasMore;
|
||||
|
||||
// ── cycles ────────────────────────────────────────────────
|
||||
const cycleHasMore = cycleRows.length > limit;
|
||||
const cyclePage = cycleHasMore ? cycleRows.slice(0, limit) : cycleRows;
|
||||
const cycleUpserts: MenstrualCycleDTO[] = [];
|
||||
const cycleTombstones: CycleTombstone[] = [];
|
||||
for (const row of cyclePage) {
|
||||
if (row.deletedAt) {
|
||||
cycleTombstones.push({
|
||||
id: row.id,
|
||||
syncVersion: row.syncVersion,
|
||||
deletedAt: row.deletedAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
} else {
|
||||
cycleUpserts.push(toMenstrualCycleDTO(row));
|
||||
}
|
||||
}
|
||||
advanceCursor(nextCursor, "cycles", cyclePage);
|
||||
hasMore = hasMore || cycleHasMore;
|
||||
|
||||
annotate({
|
||||
action: { name: "sync.changes.pull" },
|
||||
meta: {
|
||||
measurement_upserts: measurementUpserts.length,
|
||||
measurement_tombstones: measurementTombstones.length,
|
||||
mood_upserts: moodUpserts.length,
|
||||
mood_tombstones: moodTombstones.length,
|
||||
intake_upserts: intakeUpserts.length,
|
||||
intake_tombstones: intakeTombstones.length,
|
||||
cycle_day_upserts: cycleDayUpserts.length,
|
||||
cycle_day_tombstones: cycleDayTombstones.length,
|
||||
cycle_upserts: cycleUpserts.length,
|
||||
cycle_tombstones: cycleTombstones.length,
|
||||
cycle_module_enabled: cycleEnabled,
|
||||
has_more: hasMore,
|
||||
cursor_present: Boolean(parsed.data.cursor),
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
serverNow: serverNow.toISOString(),
|
||||
cursor: encodeCursor(nextCursor, serverNow.getTime()),
|
||||
hasMore,
|
||||
cursorExpired: false,
|
||||
changes: {
|
||||
measurements: {
|
||||
upserts: measurementUpserts,
|
||||
tombstones: measurementTombstones,
|
||||
},
|
||||
mood: { upserts: moodUpserts, tombstones: moodTombstones },
|
||||
intakes: { upserts: intakeUpserts, tombstones: intakeTombstones },
|
||||
cycleDays: {
|
||||
upserts: cycleDayUpserts,
|
||||
tombstones: cycleDayTombstones,
|
||||
},
|
||||
cycles: { upserts: cycleUpserts, tombstones: cycleTombstones },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Advance a domain's watermark to the last row of its page. A domain
|
||||
* that returned no rows keeps its prior watermark untouched so a future
|
||||
* pull does not re-scan from the start.
|
||||
*/
|
||||
function advanceCursor(
|
||||
cursor: SyncCursor,
|
||||
domain: SyncDomain,
|
||||
page: Array<{ updatedAt: Date; id: string }>,
|
||||
): void {
|
||||
const last = page[page.length - 1];
|
||||
if (last) {
|
||||
cursor[domain] = { updatedAtMs: last.updatedAt.getTime(), id: last.id };
|
||||
}
|
||||
}
|
||||
@@ -29,76 +29,16 @@
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
|
||||
import {
|
||||
lockPersonalRecordsForUser,
|
||||
workoutPrSlotsForSport,
|
||||
} from "@/lib/personal-records/pr-detection-worker";
|
||||
import {
|
||||
canonicalClusterOf,
|
||||
canonicalWorkoutsInRange,
|
||||
readWorkoutsAroundRange,
|
||||
type CanonicalReadRow,
|
||||
type WorkoutBoundedRead,
|
||||
type WorkoutStartBounds,
|
||||
} from "@/lib/workouts/canonical-read";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
|
||||
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { buildWorkoutHrSeries } from "@/lib/workouts/hr-series";
|
||||
import {
|
||||
computeZones,
|
||||
hrMaxFromAge,
|
||||
parseWhoopZoneDurations,
|
||||
} from "@/lib/workouts/zones";
|
||||
import { computeSplits } from "@/lib/workouts/splits";
|
||||
import { buildSportContext } from "@/lib/workouts/sport-context";
|
||||
import type { RouteCoordinate } from "@/lib/workouts/route-svg";
|
||||
import { readRouteGeometry } from "@/lib/workouts/route-geometry-cipher";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { readWorkoutDetail } from "@/lib/workouts/detail";
|
||||
import { deleteManualWorkout } from "@/lib/workouts/delete-manual-workout";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
/** How many earlier rows one step of the previous-session search reads. */
|
||||
const PREVIOUS_SESSION_BATCH = 20;
|
||||
/** Steps before the search gives up; each reaches past the last one. */
|
||||
const PREVIOUS_SESSION_MAX_STEPS = 5;
|
||||
|
||||
/**
|
||||
* The newest canonical workout of the sport that starts before `before` (the
|
||||
* first start of the current session's cluster). A batch of earlier rows names
|
||||
* the span to resolve; the span is read through the windowed reader so its
|
||||
* twins are resolved the way the list resolves them. A span whose rows are all
|
||||
* hidden by a twin further back moves the search back one batch.
|
||||
*/
|
||||
async function previousCanonicalWorkoutId<T extends CanonicalReadRow>(
|
||||
read: WorkoutBoundedRead<T>,
|
||||
latestStartsBefore: (to: Date, take: number) => Promise<Date[]>,
|
||||
before: Date,
|
||||
priorityJson: unknown,
|
||||
): Promise<string | null> {
|
||||
let to = before;
|
||||
for (let step = 0; step < PREVIOUS_SESSION_MAX_STEPS; step += 1) {
|
||||
const earlier = await latestStartsBefore(to, PREVIOUS_SESSION_BATCH);
|
||||
if (earlier.length === 0) return null;
|
||||
const from = earlier[earlier.length - 1]!;
|
||||
const windowed = await readWorkoutsAroundRange({ from, to: before }, read);
|
||||
const canonical = canonicalWorkoutsInRange(windowed, priorityJson);
|
||||
const newest = canonical[canonical.length - 1];
|
||||
if (newest) return newest.id;
|
||||
to = from;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -119,283 +59,19 @@ export const GET = apiHandler(
|
||||
// not even loaded, `aiInsight` is null, and `ai` says why.
|
||||
const ai = await aiCapabilityToServe(user.id, "workoutInsights");
|
||||
|
||||
const row = await prisma.workout.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
route: {
|
||||
select: {
|
||||
id: true,
|
||||
geometry: true,
|
||||
geometryEncrypted: true,
|
||||
sampleTimestamps: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
samples: {
|
||||
select: {
|
||||
samples: true,
|
||||
sampleCount: true,
|
||||
},
|
||||
},
|
||||
insight: ai.available
|
||||
? {
|
||||
select: { paragraphEncrypted: true, generatedAt: true },
|
||||
}
|
||||
: false,
|
||||
},
|
||||
const body = await readWorkoutDetail({
|
||||
userId: user.id,
|
||||
id,
|
||||
compact,
|
||||
ai,
|
||||
});
|
||||
|
||||
// Cross-user 404 guard. The user can only fetch rows they own; any
|
||||
// other id surfaces as "not found" so the existence channel stays
|
||||
// sealed.
|
||||
if (!row || row.userId !== user.id) {
|
||||
if (!body) {
|
||||
return apiError("Workout not found", 404);
|
||||
}
|
||||
|
||||
// Resolve the canonical winner for the row's cluster. The read goes
|
||||
// through the same windowed reader as the list, so the cluster drawn here
|
||||
// is the one the list draws: a twin that straddles a clock slot boundary,
|
||||
// or one whose cluster begins with an earlier workout, resolves to the row
|
||||
// the list shows.
|
||||
const readSameSport = (bounds: WorkoutStartBounds) =>
|
||||
prisma.workout.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
sportType: row.sportType,
|
||||
startedAt: bounds,
|
||||
},
|
||||
orderBy: [{ startedAt: "asc" }, { id: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
source: true,
|
||||
startedAt: true,
|
||||
sportType: true,
|
||||
// Part of the canonical session identity consulted by the picker —
|
||||
// without it a HealthKit re-send of this very workout stays in the
|
||||
// cluster and the deep link can resolve to the duplicate instead of
|
||||
// the row the list shows.
|
||||
durationSec: true,
|
||||
avgHeartRate: true,
|
||||
maxHeartRate: true,
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
const around = await readWorkoutsAroundRange(
|
||||
{ from: row.startedAt, to: row.startedAt, inclusiveEnd: true },
|
||||
readSameSport,
|
||||
);
|
||||
|
||||
const userRow = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { sourcePriorityJson: true, dateOfBirth: true },
|
||||
});
|
||||
// The local calendar day this session belongs to. Resolved here, in the
|
||||
// user's timezone, so the day linkage stays server-authoritative — a
|
||||
// client that derived it from `startedAt` would fall back to the browser
|
||||
// zone and put a late-evening session on the wrong day whenever the two
|
||||
// disagree.
|
||||
const timezone = await resolveUserTimezone(user.id);
|
||||
const dayKey = userDayKey(row.startedAt, timezone);
|
||||
|
||||
const cluster = canonicalClusterOf(
|
||||
around,
|
||||
row.id,
|
||||
userRow?.sourcePriorityJson ?? null,
|
||||
);
|
||||
// The cluster's canonical rows, the requested row first when it is one of
|
||||
// them and otherwise the nearest in time, so a deep link to a hidden twin
|
||||
// lands on the most similar row.
|
||||
const canonical = cluster?.canonical[0] ?? null;
|
||||
const effectiveAvgHeartRate = canonical?.avgHeartRate ?? row.avgHeartRate;
|
||||
const effectiveMaxHeartRate = canonical?.maxHeartRate ?? row.maxHeartRate;
|
||||
|
||||
// ── Enrichment reads (all over existing tables — no migration) ────
|
||||
|
||||
// Heart-rate curve: stored series first, pulse-window fallback
|
||||
// second, hide third. One server path, one DTO with provenance.
|
||||
const hrSeries = await buildWorkoutHrSeries({
|
||||
userId: user.id,
|
||||
startedAt: row.startedAt,
|
||||
endedAt: row.endedAt,
|
||||
durationSec: row.durationSec,
|
||||
storedSamples: row.samples?.samples ?? null,
|
||||
});
|
||||
annotate({
|
||||
action: { name: "workouts.detail.hr_series" },
|
||||
meta: {
|
||||
source: hrSeries?.source ?? "none",
|
||||
points: hrSeries?.points.length ?? 0,
|
||||
},
|
||||
});
|
||||
|
||||
// Effort zones: WHOOP device durations win; else %HRmax fold from
|
||||
// the series when profile age exists; else null.
|
||||
const ageYears = getAgeFromDateOfBirth(userRow?.dateOfBirth ?? null);
|
||||
const zones = computeZones({
|
||||
hrMax: hrMaxFromAge(ageYears),
|
||||
series: hrSeries?.points ?? [],
|
||||
bucketSec: hrSeries?.bucketSec ?? 0,
|
||||
whoopZoneDurations: parseWhoopZoneDurations(
|
||||
canonical?.metadata ?? row.metadata,
|
||||
),
|
||||
});
|
||||
|
||||
// Per-km splits computed server-side from the geometry + timestamps
|
||||
// so the web client keeps dropping the raw timestamp blob under
|
||||
// `compact=1` while still rendering splits (server-authoritative
|
||||
// parity — iOS gets the same resolved figures).
|
||||
// v1.39.4 — the track is stored sealed; the legacy readable column is
|
||||
// read only for a row the backfill has not reached yet.
|
||||
const routeGeometry = row.route ? readRouteGeometry(row.route) : null;
|
||||
const geometryCoords =
|
||||
routeGeometry &&
|
||||
typeof routeGeometry === "object" &&
|
||||
Array.isArray((routeGeometry as { coordinates?: unknown }).coordinates)
|
||||
? (routeGeometry as { coordinates: RouteCoordinate[] }).coordinates
|
||||
: null;
|
||||
const splits =
|
||||
geometryCoords && Array.isArray(row.route?.sampleTimestamps)
|
||||
? computeSplits(geometryCoords, row.route.sampleTimestamps as string[])
|
||||
: null;
|
||||
|
||||
// Sport context: the user's own last-180-days average for this
|
||||
// sport, cross-source-collapsed so twins don't double-count.
|
||||
const sportContext = await buildSportContext(
|
||||
user.id,
|
||||
row.sportType,
|
||||
userRow?.sourcePriorityJson ?? null,
|
||||
row.id,
|
||||
);
|
||||
|
||||
// The session before this one in the same sport. "How does this compare
|
||||
// to last time?" is the most likely next question after reading a
|
||||
// workout, and today it costs a trip back to the list plus a scan.
|
||||
// Scoped to rows that start before this session's cluster, so a twin of
|
||||
// THIS session can never be offered as the previous one, and resolved
|
||||
// through the same windowed read so the link lands on the row the list
|
||||
// shows.
|
||||
const clusterStart = cluster?.members[0]?.startedAt ?? row.startedAt;
|
||||
const previousWorkoutId = await previousCanonicalWorkoutId(
|
||||
readSameSport,
|
||||
async (to, take) =>
|
||||
(
|
||||
await prisma.workout.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
sportType: row.sportType,
|
||||
startedAt: { lt: to },
|
||||
},
|
||||
orderBy: [{ startedAt: "desc" }, { id: "desc" }],
|
||||
take,
|
||||
select: { startedAt: true },
|
||||
})
|
||||
).map((earlier) => earlier.startedAt),
|
||||
clusterStart,
|
||||
userRow?.sourcePriorityJson ?? null,
|
||||
);
|
||||
|
||||
// A route whose track cannot be read (a sealed value that no longer
|
||||
// opens, or an old row stored as a JSON null) is reported as no route at
|
||||
// all: clients decode `route.geometry` as required, so a route object
|
||||
// with a null geometry would fail the whole workout detail for them.
|
||||
const route =
|
||||
row.route && routeGeometry
|
||||
? {
|
||||
geometry: routeGeometry,
|
||||
// `compact=1` drops the (up to 20k-entry) timestamp array; the
|
||||
// SVG needs geometry, not the per-sample timestamps, and the
|
||||
// splits above are already derived from them server-side.
|
||||
sampleTimestamps: compact
|
||||
? null
|
||||
: (row.route.sampleTimestamps ?? null),
|
||||
}
|
||||
: null;
|
||||
|
||||
// v1.10.0 — route-independent per-workout HR series. Present for
|
||||
// both indoor (no route) and outdoor workouts that shipped a
|
||||
// `samples` array on ingest; null otherwise. `compact=1` keeps the
|
||||
// denormalised count but drops the raw sample blob (the web curve
|
||||
// reads `hrSeries` instead).
|
||||
const samples = row.samples
|
||||
? {
|
||||
sampleCount: row.samples.sampleCount,
|
||||
samples: compact ? null : row.samples.samples,
|
||||
}
|
||||
: null;
|
||||
|
||||
// Decrypt the stored paragraph. Fail-SOFT here and only here: `decrypt` is
|
||||
// fail-closed by design, so a rotated-away key would otherwise turn the
|
||||
// whole workout-detail page into a 500 over a garnish field. An
|
||||
// undecryptable paragraph degrades to no card, exactly like a workout that
|
||||
// never had one.
|
||||
let aiInsight: { paragraph: string; generatedAt: string } | null = null;
|
||||
if (ai.available && row.insight) {
|
||||
try {
|
||||
aiInsight = {
|
||||
paragraph: decryptFromBytes(row.insight.paragraphEncrypted),
|
||||
generatedAt: row.insight.generatedAt.toISOString(),
|
||||
};
|
||||
} catch {
|
||||
annotate({
|
||||
action: { name: "workouts.detail.insight_undecryptable" },
|
||||
meta: { workoutId: row.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
id: row.id,
|
||||
sportType: row.sportType,
|
||||
startedAt: row.startedAt,
|
||||
endedAt: row.endedAt,
|
||||
// `YYYY-MM-DD` in the user's timezone. Additive; it addresses the
|
||||
// day-scoped reads (intraday pulse, that night's sleep, the day's
|
||||
// mood) the detail surface renders around the session.
|
||||
dayKey,
|
||||
durationSec: row.durationSec,
|
||||
distanceM: row.totalDistanceM,
|
||||
activeEnergyKcal: row.totalEnergyKcal,
|
||||
avgHr: effectiveAvgHeartRate,
|
||||
maxHr: effectiveMaxHeartRate,
|
||||
minHr: row.minHeartRate,
|
||||
// The row's own heart rate. `avgHr` / `maxHr` above may be filled in
|
||||
// from a twin by the canonical picker, which is right for display and
|
||||
// wrong for an edit: re-posting them would store the twin's values on
|
||||
// the hand-entered row (#1162).
|
||||
storedAvgHr: row.avgHeartRate,
|
||||
storedMaxHr: row.maxHeartRate,
|
||||
stepCount: row.stepCount,
|
||||
elevationM: row.elevationM,
|
||||
pauseDurationSec: row.pauseDurationSec,
|
||||
source: row.source,
|
||||
externalId: row.externalId,
|
||||
metadata: row.metadata,
|
||||
route,
|
||||
samples,
|
||||
// #67 enrichment — all additive, all over existing tables.
|
||||
hrSeries,
|
||||
zones,
|
||||
splits,
|
||||
sportContext,
|
||||
// The per-workout Activity Insight. A pure READ of a row the
|
||||
// `workout-insight-generate` worker wrote when this workout landed —
|
||||
// this route never generates, never enqueues, and never falls back to
|
||||
// a provider. A workout with no row (every historical one, every
|
||||
// re-synced one, every one on a provider-less install) serves null and
|
||||
// the page's `{aiInsight ? <card/> : null}` renders nothing.
|
||||
aiInsight,
|
||||
// Whether the Activity Insight can be shown, and why not.
|
||||
ai,
|
||||
// v1.4.32 — when the requested id is a non-canonical twin the
|
||||
// caller can redirect to `canonicalId` to land on the cluster
|
||||
// winner. `canonicalId === id` when the requested row already
|
||||
// is the winner.
|
||||
canonicalId: canonical?.id ?? row.id,
|
||||
// The canonical previous session in this sport, or null when this is
|
||||
// the first one on record. Additive; a client that does not read it is
|
||||
// unaffected.
|
||||
previousWorkoutId,
|
||||
});
|
||||
return apiSuccess(body);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -425,89 +101,21 @@ export const DELETE = apiHandler(
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const row = await prisma.workout.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
source: true,
|
||||
externalId: true,
|
||||
startedAt: true,
|
||||
sportType: true,
|
||||
},
|
||||
const outcome = await deleteManualWorkout({
|
||||
userId: user.id,
|
||||
id,
|
||||
ipAddress: getClientIp(request),
|
||||
});
|
||||
if (!row || row.userId !== user.id) {
|
||||
if (outcome.kind === "not_found") {
|
||||
return apiError("Workout not found", 404);
|
||||
}
|
||||
if (row.source !== "MANUAL") {
|
||||
annotate({
|
||||
action: {
|
||||
name: "workout.delete",
|
||||
entity_type: "workout",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: { outcome: "synced_source", source: row.source },
|
||||
});
|
||||
if (outcome.kind === "synced_source") {
|
||||
return apiError(
|
||||
"Only a workout entered by hand can be deleted here",
|
||||
409,
|
||||
{ errorCode: "workout.delete.synced_source" },
|
||||
);
|
||||
}
|
||||
|
||||
const removedRecords = await prisma.$transaction(async (tx) => {
|
||||
// The detector reads the best workout and writes its record under this
|
||||
// same lock, so it either wrote before this delete (and the record goes
|
||||
// below) or reads after it (and never sees the workout).
|
||||
await lockPersonalRecordsForUser(tx, user.id);
|
||||
const records = await tx.personalRecord.deleteMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
source: "MANUAL",
|
||||
...(row.externalId !== null
|
||||
? { metricSlot: { not: null }, externalId: row.externalId }
|
||||
: {
|
||||
// A legacy record carries no external id, so the start time
|
||||
// is all that ties it to this workout. Scope it to the slots
|
||||
// of this workout's sport so a record another sport set at
|
||||
// the same instant stays.
|
||||
metricSlot: { in: workoutPrSlotsForSport(row.sportType) },
|
||||
externalId: null,
|
||||
achievedAt: row.startedAt,
|
||||
}),
|
||||
},
|
||||
});
|
||||
await tx.workout.delete({ where: { id: row.id } });
|
||||
return records.count;
|
||||
});
|
||||
|
||||
await auditLog("workout.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
workoutId: id,
|
||||
sportType: row.sportType,
|
||||
personalRecordsRemoved: removedRecords,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: { name: "workout.delete", entity_type: "workout", entity_id: id },
|
||||
meta: { outcome: "deleted", personalRecordsRemoved: removedRecords },
|
||||
});
|
||||
|
||||
invalidateUserMeasurements(user.id, { evict: true });
|
||||
|
||||
if (removedRecords > 0) {
|
||||
try {
|
||||
await enqueuePrDetection(user.id, { silent: true });
|
||||
} catch (err) {
|
||||
annotate({
|
||||
action: { name: "personal_records.detection_enqueue_failed" },
|
||||
meta: { error: err instanceof Error ? err.message : String(err) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return apiSuccess({ deleted: true });
|
||||
},
|
||||
);
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
const ROOT = join(__dirname, "..", "..", "..", "..");
|
||||
|
||||
const CONSUMERS = [
|
||||
"src/app/api/analytics/route.ts",
|
||||
"src/lib/analytics/response/glucose.ts",
|
||||
"src/lib/dashboard/snapshot.ts",
|
||||
// v1.28.26 — the Coach glucose section moved into its own block module;
|
||||
// the alias derivation lives there and snapshot.ts imports the alias.
|
||||
|
||||
@@ -31,7 +31,7 @@ const CALL_SITES: Array<{ file: string; surface: string }> = [
|
||||
surface: "insight targets",
|
||||
},
|
||||
{
|
||||
file: "src/app/api/insights/comprehensive/route.ts",
|
||||
file: "src/lib/insights/comprehensive-overview/medication-compliance.ts",
|
||||
surface: "comprehensive insight",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* The blood-pressure in-target block of the default analytics body: the
|
||||
* reporting-window percentages against the user's effective targets, and
|
||||
* the envelopes the Health Score grades against the clinical band.
|
||||
*/
|
||||
import type { SessionUser } from "@/lib/auth/session-user";
|
||||
import { resolveEffectiveBpTargets } from "@/lib/analytics/effective-range";
|
||||
import { bpTargetsEqual, getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import {
|
||||
computeBpInTargetFastPath,
|
||||
type BpInTargetEnvelope,
|
||||
} from "@/lib/analytics/bp-in-target-fast-path";
|
||||
import { deriveBpWindow90 } from "@/lib/analytics/window-confidence";
|
||||
import type { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
|
||||
|
||||
type Coverage = Awaited<ReturnType<typeof probeRollupCoverage>>;
|
||||
|
||||
export async function readBpInTargetBlock(
|
||||
user: SessionUser,
|
||||
coverage: Coverage,
|
||||
userTz: string,
|
||||
) {
|
||||
// BP in-target percentage (auto-calculated from date of birth)
|
||||
let bpInTargetPct: number | null = null;
|
||||
let bpInTargetPct7d: number | null = null;
|
||||
let bpInTargetPct30d: number | null = null;
|
||||
let bpInTargetPctAllTime: number | null = null;
|
||||
/**
|
||||
* Period-aligned prior-window
|
||||
* pcts so the BD-Zielbereich tile's comparison-overlay caption
|
||||
* stops mismatching its math with its label. The tile's
|
||||
* `compareDelta` is `last30Days - priorMonth` (or `… - priorYear`)
|
||||
* matching the user's `comparisonBaseline` selection, never
|
||||
* `last30Days - allTime` (the v1.4.22 A2 shortcut).
|
||||
*/
|
||||
let bpInTargetPctPriorMonth: number | null = null;
|
||||
let bpInTargetPctPriorYear: number | null = null;
|
||||
// 90-day pair count + effective label span for the tile's
|
||||
// thin-data gate. Surfaced so the BD-Zielbereich tile renders "collecting
|
||||
// data" until the window holds enough readings, with a dynamic span label.
|
||||
let bpInTargetCount90: number | null = null;
|
||||
let bpInTargetSpanDays90: number | null = null;
|
||||
// Hold the current + prior-week BP envelopes so the shared
|
||||
// Health-Score input builder grades the pillar off the identical shape the
|
||||
// dashboard snapshot uses (one builder, no per-surface assembly drift).
|
||||
// The graded score + prior-week BP figures the score consumes are read off
|
||||
// these envelopes inside `buildHealthScoreBpInputs`.
|
||||
let bpEnvelope: BpInTargetEnvelope | null = null;
|
||||
let bpEnvelopePriorWeek: BpInTargetEnvelope | null = null;
|
||||
let bpEnvelopePriorTwoWeeks: BpInTargetEnvelope | null = null;
|
||||
const bpTargets = resolveEffectiveBpTargets(
|
||||
{
|
||||
dateOfBirth: user.dateOfBirth,
|
||||
gender: user.gender,
|
||||
heightCm: user.heightCm ?? null,
|
||||
},
|
||||
user.thresholdsJson,
|
||||
);
|
||||
const clinicalBpTargets = getBpTargets(user.dateOfBirth);
|
||||
if (bpTargets) {
|
||||
const now = new Date();
|
||||
// Probe-gated dispatcher replaces the inline chunked
|
||||
// read. When BOTH BP types have DAY-bucket coverage the helper
|
||||
// pairs the per-day MEAN SYS + MEAN DIA from `measurement_rollups`
|
||||
// and counts in-target days against the five reporting windows,
|
||||
// skipping the heavy 365-day chunked walk against `measurements`.
|
||||
// Falls back to the legacy `computeBpInTargetWindows` over a
|
||||
// chunked read when coverage is partial so a brand-new account
|
||||
// (no buckets yet) still sees per-event numbers. The helper emits
|
||||
// a `path: "rollup" | "live"` annotate so prod logs prove which
|
||||
// branch fired. See `bp-in-target-fast-path.ts` for the
|
||||
// documented per-day-mean approximation.
|
||||
//
|
||||
// Two parallel runs: one anchored at `now` for the
|
||||
// standard windows, one anchored at `now - 7d` so the Health-Score
|
||||
// helper has a real prior-week BP pct to feed into its previous-
|
||||
// window snapshot. The two probes share the coverage map and the
|
||||
// same rollup / live branch decision; the extra call is one extra
|
||||
// pair of `readRollupBuckets` reads on the rollup path and is a
|
||||
// no-op when the user has no BP rows at all.
|
||||
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
const fourteenDaysAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000);
|
||||
const [windows, windowsPriorWeek, windowsPriorTwoWeeks] = await Promise.all(
|
||||
[
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: bpTargets,
|
||||
now,
|
||||
coverage,
|
||||
// Cross-tz runtime guard. The helper falls back
|
||||
// to the live SQL path when the user is more than 3 hours
|
||||
// from UTC, where the rollup table's UTC-midnight day-key
|
||||
// would slip a calendar day relative to the live aggregator's
|
||||
// window cuts.
|
||||
userTz,
|
||||
}),
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: bpTargets,
|
||||
now: sevenDaysAgo,
|
||||
coverage,
|
||||
userTz,
|
||||
}),
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: bpTargets,
|
||||
now: fourteenDaysAgo,
|
||||
coverage,
|
||||
userTz,
|
||||
}),
|
||||
],
|
||||
);
|
||||
// The pillar is graded against the CLINICAL band even when the user
|
||||
// keeps a personal target (that band is the number's identity; the
|
||||
// personal one is displayed beside it and scores nothing). So when the
|
||||
// two differ we re-run the whole helper against the clinical targets
|
||||
// and the score comes from that second run.
|
||||
//
|
||||
// The "why this number" basis rides the same envelope, so it
|
||||
// is the same run, the same representative pair and the same ceilings
|
||||
// by construction. Assign envelopes WHOLE here; picking `graded.score`
|
||||
// off one run and a basis off the other would let the popover explain
|
||||
// a number nobody is looking at, and it would look correct in review.
|
||||
if (bpTargetsEqual(bpTargets, clinicalBpTargets)) {
|
||||
bpEnvelope = windows;
|
||||
bpEnvelopePriorWeek = windowsPriorWeek;
|
||||
bpEnvelopePriorTwoWeeks = windowsPriorTwoWeeks;
|
||||
} else if (clinicalBpTargets) {
|
||||
[bpEnvelope, bpEnvelopePriorWeek, bpEnvelopePriorTwoWeeks] =
|
||||
await Promise.all([
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: clinicalBpTargets,
|
||||
now,
|
||||
coverage,
|
||||
userTz,
|
||||
}),
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: clinicalBpTargets,
|
||||
now: sevenDaysAgo,
|
||||
coverage,
|
||||
userTz,
|
||||
}),
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: clinicalBpTargets,
|
||||
now: fourteenDaysAgo,
|
||||
coverage,
|
||||
userTz,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
// The BD-Zielbereich headline standardises on the
|
||||
// trailing-90-day window (labelled "· 90 T" in the tile). All-time
|
||||
// remains carried below for the BP detail page's long view only.
|
||||
bpInTargetPct = windows.last90Days?.pct ?? null;
|
||||
bpInTargetPct7d = windows.last7Days?.pct ?? null;
|
||||
bpInTargetPct30d = windows.last30Days?.pct ?? null;
|
||||
bpInTargetPctAllTime = windows.allTime?.pct ?? null;
|
||||
bpInTargetPctPriorMonth = windows.priorMonth?.pct ?? null;
|
||||
bpInTargetPctPriorYear = windows.priorYear?.pct ?? null;
|
||||
// Count + effective span for the BD-Zielbereich tile's
|
||||
// confidence gate (thin-data → "collecting data"; dynamic span label).
|
||||
// Shared helper keeps this identical to the dashboard snapshot.
|
||||
const bpWindow = deriveBpWindow90(
|
||||
windows.last90Days,
|
||||
windows.last90EarliestAt,
|
||||
now,
|
||||
);
|
||||
bpInTargetCount90 = bpWindow.count;
|
||||
bpInTargetSpanDays90 = bpWindow.spanDays;
|
||||
}
|
||||
return {
|
||||
bpInTargetPct,
|
||||
bpInTargetPct7d,
|
||||
bpInTargetPct30d,
|
||||
bpInTargetPctAllTime,
|
||||
bpInTargetPctPriorMonth,
|
||||
bpInTargetPctPriorYear,
|
||||
bpInTargetCount90,
|
||||
bpInTargetSpanDays90,
|
||||
bpEnvelope,
|
||||
bpEnvelopePriorWeek,
|
||||
bpEnvelopePriorTwoWeeks,
|
||||
clinicalBpTargets,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* The correlation block of the default analytics body: the three fixed
|
||||
* hypotheses, with the accepted ones synced to the stored patterns and
|
||||
* their decision attached.
|
||||
*/
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path";
|
||||
import {
|
||||
decisionForEvidence,
|
||||
PATTERN_FAMILIES,
|
||||
syncAcceptedPatterns,
|
||||
} from "@/lib/insights/correlation-patterns";
|
||||
import type { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
|
||||
|
||||
type Coverage = Awaited<ReturnType<typeof probeRollupCoverage>>;
|
||||
|
||||
export async function readCorrelationBlock(args: {
|
||||
userId: string;
|
||||
userTz: string;
|
||||
coverage: Coverage;
|
||||
locale: Locale;
|
||||
}) {
|
||||
const { userId, userTz, coverage, locale } = args;
|
||||
// Three pre-defined correlation hypotheses.
|
||||
// Probe-gated helper. The 28-day scan window (down
|
||||
// from 30 to keep the cold critical path tight while still
|
||||
// satisfying the n >= 20 surface gate) reads SYS / PULSE / WEIGHT
|
||||
// per-day means from `measurement_rollups` when the user has full
|
||||
// coverage; mood and medication-intake reads always stay live (no
|
||||
// rollup equivalent). The helper emits `meta.correlations.path` +
|
||||
// `meta.correlations.window_days` so prod logs prove the branch
|
||||
// selection and the truthful window.
|
||||
const correlations = await computeCorrelationHypothesesFastPath({
|
||||
userId,
|
||||
userTz,
|
||||
now: new Date(),
|
||||
coverage,
|
||||
locale,
|
||||
});
|
||||
const fixedDefinitions = [
|
||||
{
|
||||
key: "bpCompliance" as const,
|
||||
factorKey: "HYPOTHESIS:MEDICATION_COMPLIANCE",
|
||||
outcomeKey: "BLOOD_PRESSURE_SYS",
|
||||
},
|
||||
{
|
||||
key: "moodPulse" as const,
|
||||
factorKey: "HYPOTHESIS:MOOD",
|
||||
outcomeKey: "RESTING_PULSE",
|
||||
},
|
||||
{
|
||||
key: "weightWeekday" as const,
|
||||
factorKey: "HYPOTHESIS:WEEKDAY",
|
||||
outcomeKey: "WEIGHT",
|
||||
},
|
||||
];
|
||||
const acceptedFixed = fixedDefinitions.flatMap((definition) => {
|
||||
const result = correlations[definition.key];
|
||||
return result.status === "ok"
|
||||
? [
|
||||
{
|
||||
factorKey: definition.factorKey,
|
||||
outcomeKey: definition.outcomeKey,
|
||||
lagDays: 0,
|
||||
sampleSize: result.n,
|
||||
effectSize: result.statistic,
|
||||
pValue: result.pValue,
|
||||
qValue: null,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
const fixedDecisions = await syncAcceptedPatterns({
|
||||
userId,
|
||||
family: PATTERN_FAMILIES.fixed,
|
||||
accepted: acceptedFixed,
|
||||
});
|
||||
const correlationsWithPatterns = { ...correlations };
|
||||
for (const definition of fixedDefinitions) {
|
||||
const result = correlations[definition.key];
|
||||
if (result.status !== "ok") continue;
|
||||
const decision = decisionForEvidence(fixedDecisions, {
|
||||
factorKey: definition.factorKey,
|
||||
outcomeKey: definition.outcomeKey,
|
||||
lagDays: 0,
|
||||
});
|
||||
correlationsWithPatterns[definition.key] = decision
|
||||
? { ...result, ...decision }
|
||||
: result;
|
||||
}
|
||||
return correlationsWithPatterns;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* The default `GET /api/analytics` body, built once per (user, locale) and
|
||||
* cached by the route: the module-gated summaries, BMI, the blood-pressure
|
||||
* in-target block, the glucose blocks, the correlations, the Health Score and
|
||||
* the sleep stages. The stages run in the order the route always ran them.
|
||||
*/
|
||||
import type { SessionUser } from "@/lib/auth/session-user";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
import { readSummariesSlice } from "@/lib/analytics/summaries-slice";
|
||||
import { ensureUserRollupsFresh } from "@/lib/rollups/measurement-rollups";
|
||||
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { gateSummariesByModules } from "@/lib/dashboard/widget-modules";
|
||||
|
||||
import { readBpInTargetBlock } from "./bp-in-target";
|
||||
import { readCorrelationBlock } from "./correlations";
|
||||
import { readGlucoseBlocks } from "./glucose";
|
||||
import { readHealthScoreBlock } from "./health-score";
|
||||
import { computeSleepStageBreakdown } from "./sleep-stages";
|
||||
|
||||
export type AnalyticsBody = Awaited<ReturnType<typeof buildAnalyticsResponse>>;
|
||||
|
||||
/**
|
||||
* The heavy default-slice body, outside the route handler so `cachedSwr()`
|
||||
* can wrap it. Returns the raw JSON payload; the route attaches the
|
||||
* `Cache-Control` header afterward.
|
||||
*/
|
||||
export async function buildAnalyticsResponse(
|
||||
user: SessionUser,
|
||||
locale: Locale,
|
||||
) {
|
||||
// Fire-and-forget the rollup-fresh check.
|
||||
// Awaiting it on the read path can stall the Node event loop for
|
||||
// 30–60 s on a power-user account whose iOS step samples keep the
|
||||
// 90-day DAY window slightly stale, which starves /api/health,
|
||||
// /api/version, and concurrent iOS calls. The downstream coverage
|
||||
// probe falls back to live SQL when a type is uncovered, so
|
||||
// correctness is preserved; the worst the user sees is data from
|
||||
// up to ~60 s ago on the very first request after a fresh
|
||||
// measurement lands, and the next request returns the up-to-date
|
||||
// value once the background refresh completes. `ensureUserRollupsFresh`
|
||||
// wraps its own try/catch, so the void call cannot reject.
|
||||
void ensureUserRollupsFresh(user.id);
|
||||
|
||||
// Single per-type coverage probe shared by the
|
||||
// bp_in_target / healthScore / correlations branches below. The
|
||||
// probe is one indexed query against `measurement_rollups`; the
|
||||
// three downstream branches each reuse the resulting `coverage`
|
||||
// map to decide between the rollup-fast-path and the live
|
||||
// fallback. Probing once instead of per-branch keeps the fan-out
|
||||
// cost flat across the three branches.
|
||||
const coverage = await probeRollupCoverage(user.id);
|
||||
|
||||
// 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;
|
||||
|
||||
// Replace the 15-way per-type `fetchMeasurementSeriesChunked`
|
||||
// fan-out with the slim rollup-tier reader. The previous live walk
|
||||
// against `measurements` (425-day window, ≥ 50 Prisma round-trips on a
|
||||
// 15-type tenant) saturated the `p-limit(4)` slot pool for ~8 s on the
|
||||
// production 467 k-row account — and because Prisma's 20-slot pool was
|
||||
// simultaneously claimed by those slots, the concurrent
|
||||
// `?slice=summaries` request (only ~9 small queries) queued behind it
|
||||
// and observed the same ~8 s wall-clock.
|
||||
//
|
||||
// `computeSummariesSlice` reads `measurement_rollups` DAY buckets
|
||||
// (sub-second) plus one 90-day narrow `$queryRaw` for the windowed
|
||||
// avg / slope / r² columns, and falls back to a split-aggregate live
|
||||
// path for users with incomplete rollup coverage. Response shape is
|
||||
// identical to the previous fan-out output for every field the
|
||||
// dashboard actually consumes:
|
||||
// - `count / latest / min / max / mean` from DAY buckets
|
||||
// - `avg7 / avg30 / slope7 / slope30` from the narrow query
|
||||
// - `avg30LastMonth` from the same narrow query (added in this
|
||||
// release; previously only the live walk produced it)
|
||||
// - `avg30LastYear` populated for any type whose WMY tier carries
|
||||
// the year-ago window
|
||||
// - `lastSeenByType` from the per-type latest read
|
||||
//
|
||||
// `anomalyCount` and `slope90` left this wire in v1.37.19: the slim
|
||||
// path could never compute the former (it always reported 0) and no
|
||||
// client component read the latter — the insights pipeline computes
|
||||
// both from its own `summarize()` / comprehensive-aggregator reads.
|
||||
// The one field the slim path leaves at a default value is
|
||||
// `avg30LastYear` on types with no WMY coverage, which matches the
|
||||
// pre-fix behaviour for tenants whose year-ago window happened to
|
||||
// fall outside the 425-day floor.
|
||||
const slim = await readSummariesSlice(user.id);
|
||||
// Same module gate as the slim slice and the snapshot builder — the
|
||||
// default slice feeds the dashboard too (it is the thick half of the
|
||||
// legacy two-cell path), so it cannot be the one feed that leaks a
|
||||
// disabled module's types.
|
||||
const thickModules = await resolveModuleMap(user.id);
|
||||
const thickGated = gateSummariesByModules(
|
||||
slim.summaries,
|
||||
slim.lastSeenByType,
|
||||
thickModules,
|
||||
);
|
||||
const results = thickGated.summaries;
|
||||
const lastSeenByType = thickGated.lastSeenByType;
|
||||
|
||||
// `computeSummariesSlice` annotates `action: "analytics.get.slim"`
|
||||
// for telemetry on the slim route. Restore the default-slice action
|
||||
// name so the wide-event reflects the route the client actually
|
||||
// called (otherwise every default-slice request would appear in the
|
||||
// logs under the slim action).
|
||||
annotate({ action: { name: "analytics.get" } });
|
||||
|
||||
// Sleep-stage breakdown for the trailing 30 days. Only
|
||||
// included when the user has stage-tagged rows in window; null
|
||||
// otherwise so the UI can render a plain total without the
|
||||
// breakdown card painting empty.
|
||||
const sleepStages = await computeSleepStageBreakdown(user.id, userTz);
|
||||
|
||||
// BMI calculation
|
||||
let bmi: number | null = null;
|
||||
if (user.heightCm && results.WEIGHT?.latest) {
|
||||
const heightM = user.heightCm / 100;
|
||||
bmi = Math.round((results.WEIGHT.latest / (heightM * heightM)) * 10) / 10;
|
||||
}
|
||||
|
||||
const bp = await readBpInTargetBlock(user, coverage, userTz);
|
||||
const { glucoseByContext, glucoseClinical } = await readGlucoseBlocks(
|
||||
user.id,
|
||||
);
|
||||
const correlationsWithPatterns = await readCorrelationBlock({
|
||||
userId: user.id,
|
||||
userTz,
|
||||
coverage,
|
||||
locale,
|
||||
});
|
||||
const { glucoseEnabled, healthScore } = await readHealthScoreBlock({
|
||||
user,
|
||||
userTz,
|
||||
clinicalBpTargets: bp.clinicalBpTargets,
|
||||
bpEnvelope: bp.bpEnvelope,
|
||||
bpEnvelopePriorWeek: bp.bpEnvelopePriorWeek,
|
||||
bpEnvelopePriorTwoWeeks: bp.bpEnvelopePriorTwoWeeks,
|
||||
});
|
||||
const glucoseByContextOut = glucoseEnabled ? glucoseByContext : null;
|
||||
const glucoseClinicalOut = glucoseEnabled ? glucoseClinical : null;
|
||||
|
||||
return {
|
||||
summaries: results,
|
||||
bmi,
|
||||
bpInTargetPct: bp.bpInTargetPct,
|
||||
bpInTargetPct7d: bp.bpInTargetPct7d,
|
||||
bpInTargetPct30d: bp.bpInTargetPct30d,
|
||||
bpInTargetPctAllTime: bp.bpInTargetPctAllTime,
|
||||
bpInTargetPctPriorMonth: bp.bpInTargetPctPriorMonth,
|
||||
bpInTargetPctPriorYear: bp.bpInTargetPctPriorYear,
|
||||
bpInTargetCount90: bp.bpInTargetCount90,
|
||||
bpInTargetSpanDays90: bp.bpInTargetSpanDays90,
|
||||
glucoseByContext: glucoseByContextOut,
|
||||
glucoseClinical: glucoseClinicalOut,
|
||||
correlations: correlationsWithPatterns,
|
||||
healthScore,
|
||||
sleepStages,
|
||||
// The latest night's source-discrepancy annotation, computed
|
||||
// inside the slim slice's per-night post-pass. Additive: the dashboard
|
||||
// sleep tile marks the headline with a discreet "sources disagree"
|
||||
// hint; clients that don't read the field stay unchanged.
|
||||
sleepSourceDiscrepancy: slim.sleepSourceDiscrepancy,
|
||||
// Per-type freshness map drives the dashboard's
|
||||
// staleness caption on each `<TrendCard>`. Additive: clients that
|
||||
// don't read the field stay unchanged.
|
||||
lastSeenByType,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* The glucose blocks of the default analytics body: the per-context
|
||||
* summaries and the clinical panel over the same trailing window.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { summarize, wireSummary, type DataPoint } from "@/lib/analytics/trends";
|
||||
import {
|
||||
computeGlucoseClinicalMetrics,
|
||||
GLUCOSE_PANEL_WINDOW_DAYS,
|
||||
type GlucoseClinicalMetrics,
|
||||
} from "@/lib/analytics/glucose-metrics";
|
||||
import { groupByGlucoseContext } from "@/lib/glucose";
|
||||
|
||||
export async function readGlucoseBlocks(userId: string) {
|
||||
// Per-context glucose summaries (canonical mg/dL).
|
||||
//
|
||||
// Bound to the trailing 30 days. The dashboard tile
|
||||
// path only reads the trailing window; pre-bound this query
|
||||
// walked every persisted BG row a multi-year user has written.
|
||||
const glucoseSince = new Date(
|
||||
Date.now() - GLUCOSE_PANEL_WINDOW_DAYS * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const glucoseRows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "BLOOD_GLUCOSE",
|
||||
measuredAt: { gte: glucoseSince },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { value: true, measuredAt: true, glucoseContext: true },
|
||||
});
|
||||
// #943 — grouped through the shared bucket resolver, so readings with no
|
||||
// meal-time tag land in `UNSPECIFIED` instead of being dropped on the floor.
|
||||
const glucoseByContext: Record<string, ReturnType<typeof summarize>> = {};
|
||||
for (const [bucket, ctxRows] of groupByGlucoseContext(
|
||||
glucoseRows,
|
||||
(r) => r.glucoseContext,
|
||||
)) {
|
||||
glucoseByContext[bucket] = wireSummary(
|
||||
summarize(
|
||||
ctxRows.map((r): DataPoint => ({ date: r.measuredAt, value: r.value })),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Server-authoritative glucose clinical panel. The TIR / GMI /
|
||||
// eA1C / CV% headline and the advanced J-index + LBGI/HBGI tier are computed
|
||||
// ONCE here over the declared 30-day window (matching `glucoseSince`) so the
|
||||
// insights panel, the coach snapshot, and the doctor report all render the
|
||||
// same numbers instead of each re-deriving. The learning gate keeps a thin
|
||||
// spot-data window from being asserted as a clinical AGP. Always present in
|
||||
// the payload (even with zero readings) so the client renders the calm
|
||||
// "still learning" state from a populated object rather than a missing key.
|
||||
const glucoseClinical: GlucoseClinicalMetrics = computeGlucoseClinicalMetrics(
|
||||
glucoseRows.map((r) => ({ measuredAt: r.measuredAt, mgdl: r.value })),
|
||||
{ windowDays: GLUCOSE_PANEL_WINDOW_DAYS, now: new Date() },
|
||||
);
|
||||
return { glucoseByContext, glucoseClinical };
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* The Health Score block of the default analytics body: the versioned
|
||||
* cardiometabolic reference report, recorded, with the Rest Mode framing.
|
||||
*/
|
||||
import type { SessionUser } from "@/lib/auth/session-user";
|
||||
import type { BpInTargetEnvelope } from "@/lib/analytics/bp-in-target-fast-path";
|
||||
import type { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import { computeAndRecordUserHealthScore } from "@/lib/analytics/score/record";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { resolveRestMode } from "@/lib/illness/rest-mode";
|
||||
import { resolveGlucoseUnit } from "@/lib/glucose";
|
||||
|
||||
export async function readHealthScoreBlock(args: {
|
||||
user: SessionUser;
|
||||
userTz: string;
|
||||
clinicalBpTargets: ReturnType<typeof getBpTargets>;
|
||||
bpEnvelope: BpInTargetEnvelope | null;
|
||||
bpEnvelopePriorWeek: BpInTargetEnvelope | null;
|
||||
bpEnvelopePriorTwoWeeks: BpInTargetEnvelope | null;
|
||||
}) {
|
||||
const {
|
||||
user,
|
||||
userTz,
|
||||
clinicalBpTargets,
|
||||
bpEnvelope,
|
||||
bpEnvelopePriorWeek,
|
||||
bpEnvelopePriorTwoWeeks,
|
||||
} = args;
|
||||
// The versioned cardiometabolic reference report. The shared reader
|
||||
// admits only evidence-backed pillars with their own windows and coverage
|
||||
// floors, then applies the three-domain breadth rule. Its BP envelopes come
|
||||
// from the same rollup/live fast path the dashboard uses; every other domain
|
||||
// resolves through the canonical reader and fails independently.
|
||||
const [
|
||||
glucoseEnabled,
|
||||
labsEnabled,
|
||||
sleepEnabled,
|
||||
mentalHealthEnabled,
|
||||
scoreSourcePriority,
|
||||
] = await Promise.all([
|
||||
isModuleEnabled(user.id, "glucose"),
|
||||
isModuleEnabled(user.id, "labs"),
|
||||
isModuleEnabled(user.id, "sleep"),
|
||||
isModuleEnabled(user.id, "mentalHealth"),
|
||||
loadUserSourcePriority(user.id),
|
||||
]);
|
||||
const scoredAt = new Date();
|
||||
const healthScore = await computeAndRecordUserHealthScore({
|
||||
userId: user.id,
|
||||
now: scoredAt,
|
||||
profile: {
|
||||
dateOfBirth: user.dateOfBirth,
|
||||
heightCm: user.heightCm ?? null,
|
||||
timezone: userTz,
|
||||
sourcePriorityJson: scoreSourcePriority,
|
||||
thresholdsJson: user.thresholdsJson ?? null,
|
||||
glucoseUnit: resolveGlucoseUnit(user.glucoseUnit),
|
||||
},
|
||||
modules: {
|
||||
glucose: glucoseEnabled,
|
||||
labs: labsEnabled,
|
||||
sleep: sleepEnabled,
|
||||
mentalHealth: mentalHealthEnabled,
|
||||
},
|
||||
healthScoreConfigJson: user.healthScoreConfigJson,
|
||||
bpTargets: clinicalBpTargets,
|
||||
bpEnvelope,
|
||||
bpEnvelopePriorWeek,
|
||||
bpEnvelopePriorTwoWeeks,
|
||||
});
|
||||
|
||||
// Rest Mode annotation. When an illness/condition episode is
|
||||
// active we frame the score (not penalise it): the number above is left
|
||||
// exactly as computed and we attach a value-free "you were unwell" context.
|
||||
// Resolved server-side so iOS mirrors the same fact, never recomputing it.
|
||||
// Resolved as-of the scored window (`scoredAt`), not a fresh now.
|
||||
if (healthScore) {
|
||||
const restMode = await resolveRestMode(user.id, scoredAt);
|
||||
healthScore.restMode = restMode.active
|
||||
? {
|
||||
active: true,
|
||||
since: restMode.since,
|
||||
episodeCount: restMode.episodeCount,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
return { glucoseEnabled, healthScore };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Derive `daysAgo` from the cached `lastSeenAt` ISO string per request. The
|
||||
* cached envelope deliberately omits `daysAgo` so the dashboard tile
|
||||
* staleness caption stays correct across day boundaries even when the body
|
||||
* has been sitting in the 60s LRU.
|
||||
*
|
||||
* Returns a shallow-copied envelope with the enriched `lastSeenByType` map;
|
||||
* everything else is passed through unchanged.
|
||||
*/
|
||||
export function enrichLastSeenDaysAgo<
|
||||
T extends {
|
||||
lastSeenByType: Record<string, { lastSeenAt: string } | null>;
|
||||
},
|
||||
>(
|
||||
body: T,
|
||||
): Omit<T, "lastSeenByType"> & {
|
||||
lastSeenByType: Record<
|
||||
string,
|
||||
{ lastSeenAt: string; daysAgo: number } | null
|
||||
>;
|
||||
} {
|
||||
const nowMs = Date.now();
|
||||
const enriched: Record<
|
||||
string,
|
||||
{ lastSeenAt: string; daysAgo: number } | null
|
||||
> = {};
|
||||
for (const [type, slot] of Object.entries(body.lastSeenByType)) {
|
||||
if (slot === null) {
|
||||
enriched[type] = null;
|
||||
continue;
|
||||
}
|
||||
const lastMs = new Date(slot.lastSeenAt).getTime();
|
||||
const daysAgo = Math.floor((nowMs - lastMs) / (24 * 60 * 60 * 1000));
|
||||
enriched[type] = { lastSeenAt: slot.lastSeenAt, daysAgo };
|
||||
}
|
||||
return { ...body, lastSeenByType: enriched };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* The sleep-stage block of the default analytics body.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
buildSleepStageComposition,
|
||||
type SleepStageComposition,
|
||||
} from "@/lib/analytics/sleep-stage-composition";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
|
||||
/**
|
||||
* Per-stage sleep-minutes breakdown over the trailing 30 days.
|
||||
*
|
||||
* Returns `null` when the user has no stage-tagged sleep rows in
|
||||
* window — the analytics consumer renders the existing
|
||||
* `summaries.SLEEP_DURATION` totals without a stage card in that
|
||||
* case. Returns the sum-per-stage AND the day count covered so the
|
||||
* UI can render an "averaged across N nights" caption truthfully.
|
||||
*
|
||||
* Also returns a `perNight` array: one entry per
|
||||
* Berlin-tz day inside the window, with minutes-per-stage for the
|
||||
* stacked-bar chart. Days with zero stage-tagged rows are omitted
|
||||
* from `perNight` (the chart treats them as gaps).
|
||||
*
|
||||
* The per-night stages are the MAIN NIGHT's stages. A nap ends on the
|
||||
* same wake day as that morning's night, and the per-wake-day totals
|
||||
* folded it into the night's buckets — an afternoon nap added CORE to
|
||||
* the night and the column read longer and shallower than the night
|
||||
* was. The nap's minutes now ride alongside on `napMinutes` /
|
||||
* `napCount`, both absent on a day without one.
|
||||
*/
|
||||
export async function computeSleepStageBreakdown(
|
||||
userId: string,
|
||||
userTz: string,
|
||||
): Promise<SleepStageComposition | null> {
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const WINDOW_DAYS = 30;
|
||||
const since = new Date(Date.now() - WINDOW_DAYS * DAY_MS);
|
||||
// Read the FULL per-stage row set (not `sleepStage: { not: null }`)
|
||||
// and route it through the canonical session reconstruction so the
|
||||
// stacked-bar agrees with the dashboard tile + chart series. The old path
|
||||
// keyed each stage row by its OWN calendar day (splitting a midnight-
|
||||
// spanning night across two buckets) and never collapsed multi-source
|
||||
// (double-counting a WHOOP + Apple Health night). Routing through the
|
||||
// helper fixes both: it session-clusters, keys by the wake day, and picks
|
||||
// one canonical source per night. The bare-vs-granular de-dup is applied
|
||||
// per-session to the per-stage breakdown too. The `source` is selected so
|
||||
// the helper's source-collapse can run.
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "SLEEP_DURATION",
|
||||
measuredAt: { gte: since },
|
||||
deletedAt: null,
|
||||
},
|
||||
select: {
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
sleepStage: true,
|
||||
source: true,
|
||||
deviceType: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const priorityJson = await loadUserSourcePriority(userId);
|
||||
return buildSleepStageComposition(rows, userTz, priorityJson, WINDOW_DAYS);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* The three ways an SSO sign-in ends once its account is known
|
||||
* (`GET /api/auth/oidc/callback`): this app's own second factor, a native
|
||||
* handoff code, or a web session. The callback turns each into its
|
||||
* redirect.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { User } from "@/generated/prisma/client";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { recordSignInDevice } from "@/lib/auth/login-alert";
|
||||
import { createMfaChallenge } from "@/lib/auth/mfa/challenge";
|
||||
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
import { mintNativeHandoff } from "@/lib/auth/oidc-native-handoff";
|
||||
|
||||
export type MfaMethod = "totp" | "recovery" | "webauthn";
|
||||
|
||||
/**
|
||||
* OIDC is a DELEGATED factor: it proves the IdP authenticated someone, but it
|
||||
* never substitutes for this app's own second factor. An account with a
|
||||
* confirmed TOTP secret or a registered security key gets the same MFA
|
||||
* challenge step password login gets: no session is minted, and the
|
||||
* single-use ticket goes to the login page (web) or the custom scheme
|
||||
* (native), which render the same challenge UI and complete at
|
||||
* `/api/auth/mfa/verify`. (The trusted-device skip cannot apply on this
|
||||
* path: its cookie is SameSite=Strict, which the browser withholds on an
|
||||
* IdP-initiated redirect chain.)
|
||||
*
|
||||
* Answers the challenge, or null when the account has no second factor.
|
||||
*/
|
||||
export async function startOidcSecondFactor(
|
||||
user: User,
|
||||
ip: string,
|
||||
native: boolean,
|
||||
): Promise<{ ticket: string; methods: MfaMethod[] } | null> {
|
||||
const hasTotp = Boolean(user.totpConfirmedAt);
|
||||
const webauthnKeyCount = await prisma.webauthnMfaCredential.count({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
const hasWebauthn = webauthnKeyCount > 0;
|
||||
if (!hasTotp && !hasWebauthn) return null;
|
||||
|
||||
const challenge = await createMfaChallenge(user.id, "login");
|
||||
const methods: MfaMethod[] = [];
|
||||
if (hasTotp) methods.push("totp", "recovery");
|
||||
if (hasWebauthn) methods.push("webauthn");
|
||||
await auditLog("auth.mfa.challenge", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { source: native ? "login.oidc.native" : "login.oidc" },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.mfa.challenge" },
|
||||
meta: { mfa_required: true },
|
||||
});
|
||||
return { ticket: challenge.ticket, methods };
|
||||
}
|
||||
|
||||
/**
|
||||
* Native, no MFA: a one-time handoff code for the app. NO session and NO
|
||||
* cookie are minted here: the app exchanges the code at
|
||||
* `POST /api/auth/oidc/native/token` for the native bundle. The app's S256
|
||||
* challenge (bound at login, tamper-authenticated in the state blob) locks
|
||||
* the code to the app instance that started the flow. The session and
|
||||
* device are recorded at the exchange via `finishLogin`, not here.
|
||||
*/
|
||||
export async function mintOidcNativeHandoff(input: {
|
||||
userId: string;
|
||||
appCodeChallenge: string;
|
||||
ip: string;
|
||||
userAgent: string | null;
|
||||
}): Promise<string> {
|
||||
const { code } = await mintNativeHandoff({
|
||||
userId: input.userId,
|
||||
appCodeChallenge: input.appCodeChallenge,
|
||||
ipAddress: input.ip,
|
||||
userAgent: input.userAgent,
|
||||
});
|
||||
await auditLog("auth.oidc.native.handoff_minted", {
|
||||
userId: input.userId,
|
||||
ipAddress: input.ip,
|
||||
});
|
||||
annotate({ action: { name: "auth.oidc.native.handoff_minted" } });
|
||||
return code;
|
||||
}
|
||||
|
||||
/** A web sign-in without a second factor: the session and its ledger row. */
|
||||
export async function openOidcSession(
|
||||
user: User,
|
||||
ip: string,
|
||||
userAgent: string | null,
|
||||
): Promise<void> {
|
||||
// Parity with password login: the admin-enforced-MFA hint cookie sends a
|
||||
// single-factor account into forced enrollment after sign-in.
|
||||
await syncMfaEnrollCookie(user.id, {
|
||||
totpConfirmedAt: user.totpConfirmedAt,
|
||||
mfaEnforced: user.mfaEnforced,
|
||||
});
|
||||
|
||||
// The 5th argument stays null: OIDC is a delegated factor, so an SSO
|
||||
// session must never satisfy `requireFreshMfa` step-up (MFA disable,
|
||||
// encryption-key rotation, encrypted export, account deletion), the same
|
||||
// treatment a trusted-device login gets.
|
||||
await createSession(
|
||||
user.id,
|
||||
user.onboardingCompletedAt === null,
|
||||
ip,
|
||||
userAgent,
|
||||
null,
|
||||
);
|
||||
|
||||
void recordSignInDevice({ userId: user.id, ip, userAgent });
|
||||
|
||||
await auditLog("auth.oidc.login", { userId: user.id, ipAddress: ip });
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Which account an SSO sign-in belongs to (`GET /api/auth/oidc/callback`),
|
||||
* once the provider has authenticated a subject.
|
||||
*
|
||||
* 1. The durable identity match: the (issuer, sub) pair stamped on a
|
||||
* previous login, link or provision.
|
||||
* 2. An account that already holds the verified email: never linked on the
|
||||
* email alone. Pinned to another identity it is a conflict; otherwise
|
||||
* the link waits for the account's own credential.
|
||||
* 3. Nobody: provision, while registration is open.
|
||||
*
|
||||
* A refusal names the reason the callback redirects with.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { User } from "@/generated/prisma/client";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { deriveUniqueUsername } from "@/lib/auth/oidc";
|
||||
import { resolveServerDefaultTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
export type OidcAccountResolution =
|
||||
| { kind: "user"; user: User }
|
||||
| { kind: "refused"; reason: string }
|
||||
| { kind: "linkPending"; userId: string };
|
||||
|
||||
/** A refusal; the reason is the `?error=` the callback redirects with. */
|
||||
function refuse(reason: string): OidcAccountResolution {
|
||||
return { kind: "refused", reason };
|
||||
}
|
||||
|
||||
interface OidcSubject {
|
||||
issuer: string;
|
||||
sub: string;
|
||||
email: string | null;
|
||||
emailVerified: boolean | undefined;
|
||||
ip: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the display email only, and only from a claim the IdP explicitly
|
||||
* marks verified. Skipped when another account already holds the address
|
||||
* (`email` is unique); the login itself proceeds either way, keyed by
|
||||
* (issuer, sub). Email is deliberately NOT consulted for the match: after
|
||||
* the stamp it is a display field, so an IdP-side email change can refresh
|
||||
* it but can never re-point the login at a different account.
|
||||
*/
|
||||
async function refreshDisplayEmail(
|
||||
user: User,
|
||||
subject: OidcSubject,
|
||||
): Promise<User> {
|
||||
const displayEmail = subject.email ? subject.email.toLowerCase() : null;
|
||||
if (
|
||||
!displayEmail ||
|
||||
subject.emailVerified !== true ||
|
||||
user.email?.toLowerCase() === displayEmail
|
||||
) {
|
||||
return user;
|
||||
}
|
||||
const emailTaken = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: { equals: displayEmail, mode: "insensitive" },
|
||||
NOT: { id: user.id },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (emailTaken) return user;
|
||||
return prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { email: displayEmail },
|
||||
});
|
||||
}
|
||||
|
||||
async function registrationOpen(): Promise<boolean> {
|
||||
const userCount = await prisma.user.count();
|
||||
try {
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
});
|
||||
if (settings && !settings.registrationEnabled && userCount > 0) {
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
// Table may not exist yet; allow provisioning (matches register/route.ts).
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first provisioned user becomes ADMIN. Deriving that role from an early
|
||||
* count and then creating without coordination is the check-then-act race
|
||||
* the password-register route closed: two concurrent first logins racing the
|
||||
* empty-DB window would both observe `0` and both be minted ADMIN. The
|
||||
* count+insert is serialised behind a transaction-scoped advisory lock
|
||||
* (released on commit/rollback) and re-counted *inside* the lock, so the
|
||||
* second provision observes the first's committed row and is minted USER.
|
||||
* The lock key STRING matches the register route's: an OIDC first login
|
||||
* racing a password first registration is serialised against the same lock,
|
||||
* so only one ADMIN can ever be minted.
|
||||
*/
|
||||
async function provision(
|
||||
subject: OidcSubject & { email: string },
|
||||
): Promise<User> {
|
||||
const username = await deriveUniqueUsername(subject.email, (candidate) =>
|
||||
prisma.user
|
||||
.findUnique({ where: { username: candidate } })
|
||||
.then((u: unknown) => u !== null),
|
||||
);
|
||||
// Same server-default resolution the register route uses: an
|
||||
// SSO-provisioned account has no registration form to carry the
|
||||
// browser-detected timezone.
|
||||
const timezone = await resolveServerDefaultTimezone();
|
||||
const user = await prisma.$transaction(async (tx) => {
|
||||
// `pg_advisory_xact_lock` returns void, which the client cannot
|
||||
// deserialize as a column; selecting FROM it yields a plain int row.
|
||||
await tx.$queryRaw`
|
||||
SELECT 1 AS locked
|
||||
FROM pg_advisory_xact_lock(hashtextextended('register:first-admin', 0))
|
||||
`;
|
||||
const priorUsers = await tx.user.count();
|
||||
const role = priorUsers === 0 ? "ADMIN" : "USER";
|
||||
return tx.user.create({
|
||||
data: {
|
||||
email: subject.email.toLowerCase(),
|
||||
username,
|
||||
passwordHash: null,
|
||||
role,
|
||||
timezone,
|
||||
oidcIssuer: subject.issuer,
|
||||
oidcSub: subject.sub,
|
||||
},
|
||||
});
|
||||
});
|
||||
await auditLog("auth.oidc.provisioned", {
|
||||
userId: user.id,
|
||||
ipAddress: subject.ip,
|
||||
});
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function resolveOidcAccount(
|
||||
subject: OidcSubject,
|
||||
): Promise<OidcAccountResolution> {
|
||||
// (1) Durable identity match.
|
||||
const stamped = await prisma.user.findFirst({
|
||||
where: { oidcIssuer: subject.issuer, oidcSub: subject.sub },
|
||||
});
|
||||
if (stamped) {
|
||||
return { kind: "user", user: await refreshDisplayEmail(stamped, subject) };
|
||||
}
|
||||
|
||||
// No stamped identity yet: both remaining paths (link-once, provision)
|
||||
// bind this (issuer, sub) to an account keyed by email, so the claim must
|
||||
// be explicitly `email_verified: true`. An ABSENT claim is a reject, not a
|
||||
// benefit of the doubt: an IdP that does not assert verification cannot
|
||||
// anchor a link that later gates a health record.
|
||||
const email = subject.email;
|
||||
if (!email) {
|
||||
annotate({ meta: { reason: "no_email" } });
|
||||
return refuse("oidc_no_email");
|
||||
}
|
||||
if (subject.emailVerified !== true) {
|
||||
annotate({ meta: { reason: "email_unverified" } });
|
||||
return refuse("oidc_email_unverified");
|
||||
}
|
||||
|
||||
const byEmail = await prisma.user.findFirst({
|
||||
where: { email: { equals: email, mode: "insensitive" } },
|
||||
});
|
||||
if (byEmail) {
|
||||
// (2) Link ONCE. An account already pinned to a different IdP identity
|
||||
// is never silently re-bound: a rebind would let a second IdP identity
|
||||
// that acquires the same email capture the account. Audited so the
|
||||
// operator can see the collision.
|
||||
if (byEmail.oidcIssuer !== null || byEmail.oidcSub !== null) {
|
||||
await auditLog("auth.oidc.link_conflict", {
|
||||
userId: byEmail.id,
|
||||
ipAddress: subject.ip,
|
||||
});
|
||||
annotate({ meta: { reason: "identity_conflict" } });
|
||||
return refuse("oidc_identity_conflict");
|
||||
}
|
||||
// (2b) Never link on the email alone. The local address was never
|
||||
// verified (anyone can register with it or type it into a profile), so
|
||||
// matching it proves nothing about who owns the account. Record the link
|
||||
// this login would make; the callback sends the browser to sign in to
|
||||
// the account with its own credential, and the link is made there
|
||||
// (`completePendingOidcLink`), and only for this account.
|
||||
await auditLog("auth.oidc.link_pending", {
|
||||
userId: byEmail.id,
|
||||
ipAddress: subject.ip,
|
||||
});
|
||||
annotate({ meta: { reason: "link_confirmation_required" } });
|
||||
return { kind: "linkPending", userId: byEmail.id };
|
||||
}
|
||||
|
||||
// (3) Provision.
|
||||
if (!(await registrationOpen())) {
|
||||
annotate({ meta: { reason: "registration_disabled" } });
|
||||
return refuse("oidc_registration_disabled");
|
||||
}
|
||||
return { kind: "user", user: await provision({ ...subject, email }) };
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* The single-use state blob of an SSO sign-in, as the callback reads it back
|
||||
* (`GET /api/auth/oidc/callback`), and the provider error codes it may name
|
||||
* in its wide event.
|
||||
*/
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
|
||||
export interface StoredOidcState {
|
||||
state: string;
|
||||
nonce: string;
|
||||
codeVerifier: string;
|
||||
next: string;
|
||||
/**
|
||||
* Set only when the login GET carried `client=native`. When true the
|
||||
* callback mints a handoff code (or MFA ticket) and redirects to the custom
|
||||
* scheme instead of minting a session. Both fields live ONLY inside the
|
||||
* AES-256-GCM blob, so they are tamper-authenticated: a network attacker or
|
||||
* the IdP cannot flip a web login into a native one or swap the app
|
||||
* challenge.
|
||||
*/
|
||||
native?: boolean;
|
||||
/** The app's S256 PKCE challenge, bound at login; verified at the exchange. */
|
||||
appCodeChallenge?: string;
|
||||
/**
|
||||
* Set only when the login carried `intent=claim` and a valid `hl_claim`
|
||||
* cookie (#959). The callback then claims the managed profile the cookie
|
||||
* names instead of signing in, and only when the cookie still carries this
|
||||
* exact binding. Web only: never together with `native`.
|
||||
*/
|
||||
intent?: "claim";
|
||||
claimBinding?: string;
|
||||
}
|
||||
|
||||
function isStoredOidcState(value: unknown): value is StoredOidcState {
|
||||
const v = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof value !== "object" ||
|
||||
value === null ||
|
||||
typeof v.state !== "string" ||
|
||||
typeof v.nonce !== "string" ||
|
||||
typeof v.codeVerifier !== "string" ||
|
||||
typeof v.next !== "string"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Optional native fields: when present they must be the right shape, and a
|
||||
// native start MUST carry the challenge the exchange verifies against.
|
||||
if (v.native !== undefined) {
|
||||
if (v.native !== true) return false;
|
||||
if (typeof v.appCodeChallenge !== "string") return false;
|
||||
}
|
||||
if (v.intent !== undefined) {
|
||||
if (v.intent !== "claim") return false;
|
||||
if (typeof v.claimBinding !== "string" || v.claimBinding.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (v.native !== undefined) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The decrypted blob, or null when it is missing, unreadable or malformed. */
|
||||
export function readStoredOidcState(
|
||||
rawCookie: string | undefined,
|
||||
): StoredOidcState | null {
|
||||
if (!rawCookie) return null;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(decrypt(rawCookie));
|
||||
return isStoredOidcState(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closed set of RFC 6749 §4.1.2.1 / OIDC Core §3.1.2.6 authorization error
|
||||
* codes. The wide-event meta only ever carries a member of this set (or
|
||||
* "other"), never the raw, attacker-influenceable query value.
|
||||
*/
|
||||
const KNOWN_IDP_ERROR_CODES = new Set([
|
||||
"access_denied",
|
||||
"invalid_request",
|
||||
"unauthorized_client",
|
||||
"unsupported_response_type",
|
||||
"invalid_scope",
|
||||
"server_error",
|
||||
"temporarily_unavailable",
|
||||
"interaction_required",
|
||||
"login_required",
|
||||
"account_selection_required",
|
||||
"consent_required",
|
||||
"invalid_request_uri",
|
||||
"invalid_request_object",
|
||||
"request_not_supported",
|
||||
"request_uri_not_supported",
|
||||
"registration_not_supported",
|
||||
]);
|
||||
|
||||
/** The provider's error code as the wide event may carry it. */
|
||||
export function idpErrorCode(raw: string): string {
|
||||
return KNOWN_IDP_ERROR_CODES.has(raw) ? raw : "other";
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* The body of `GET /api/dashboard/summary`, built on a cache miss: greeting,
|
||||
* intake-day streak, today's medication compliance, and the metric cards
|
||||
* with latest value, sparkline, trend and verdict, plus the sleep rhythm.
|
||||
*
|
||||
* Every per-type read is bounded (see `@/lib/dashboard/summary-reads`): one
|
||||
* row per type for the latest reading, at most `SPARK_DAYS` DAY-rollup
|
||||
* buckets per type for the sparkline, the all-time counts from the DAY
|
||||
* rollups, and the set of local day keys with activity in the streak window.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { type Locale } from "@/lib/i18n/config";
|
||||
import { resolveJobLocale } from "@/lib/i18n/job-locale";
|
||||
import { userDayKey } from "@/lib/tz/resolver";
|
||||
import { startOfLocalDayKey } from "@/lib/tz/local-day";
|
||||
import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today";
|
||||
import { computeStreak } from "@/lib/dashboard/streak";
|
||||
import {
|
||||
readActivityDayKeys,
|
||||
readAllTimeCounts,
|
||||
readLatestEver,
|
||||
readSparkBuckets,
|
||||
} from "@/lib/dashboard/summary-reads";
|
||||
import {
|
||||
summarizeSleepNights,
|
||||
type SleepStageRow,
|
||||
} from "@/lib/analytics/sleep-night";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { loadBaselineProfile } from "@/lib/insights/derived/baseline";
|
||||
import { computeBmi } from "@/lib/insights/derived/bmi";
|
||||
import { isDerivedOk } from "@/lib/insights/derived/types";
|
||||
import { buildMoodDailySeries } from "@/lib/analytics/mood-series";
|
||||
import { loadFreeWeekdaySpans } from "@/lib/profile/life-circumstances";
|
||||
import {
|
||||
reconstructNights,
|
||||
sleepNeedMinutes,
|
||||
} from "@/lib/insights/derived/sleep-score";
|
||||
import {
|
||||
computeSleepRhythmFromNights,
|
||||
type SleepRhythmDto,
|
||||
} from "@/lib/insights/derived/sleep-rhythm";
|
||||
|
||||
import { plainCard, SPARK_DAYS, withVerdicts, type MetricCard } from "./cards";
|
||||
import { summaryLookups, type SummaryLookups } from "./lookups";
|
||||
import { measurementCards } from "./measurement-cards";
|
||||
|
||||
const STREAK_WINDOW_DAYS = 365;
|
||||
|
||||
export interface SummaryBuilderContext {
|
||||
greetingName: string;
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
export async function buildSummaryContext(user: {
|
||||
displayName: string | null;
|
||||
username: string;
|
||||
locale: string | null;
|
||||
}): Promise<SummaryBuilderContext> {
|
||||
const greetingName = user.displayName ?? user.username;
|
||||
// The greeting salutation and the streak label are the two strings this
|
||||
// route still translates server-side, and the native client is its only
|
||||
// caller: a Bearer request carries no locale cookie and no meaningful
|
||||
// Accept-Language, so the stored column is the only per-user signal. That
|
||||
// is exactly the shape `resolveJobLocale` exists for: stored user locale,
|
||||
// then the operator's configured default, then English.
|
||||
const locale: Locale = await resolveJobLocale(user.locale);
|
||||
return { greetingName, locale };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mood is the one metric on this payload that is not a `Measurement`. The
|
||||
* daily series is the canonical `buildMoodDailySeries` output (rollup
|
||||
* fast-path, 1..5 scale, one entry per day with an average score), the same
|
||||
* series the web mood analytics and the dashboard snapshot render. Emitted
|
||||
* only when the account has ever logged a mood.
|
||||
*/
|
||||
function moodCard(
|
||||
moodSeries: Awaited<ReturnType<typeof buildMoodDailySeries>>,
|
||||
userTz: string,
|
||||
): MetricCard | null {
|
||||
if (moodSeries.entryCount === 0) return null;
|
||||
const spark = moodSeries.entries.slice(-SPARK_DAYS).map((e) => e.score);
|
||||
const latestEntry = moodSeries.entries[moodSeries.entries.length - 1];
|
||||
// The series carries the user's LOCAL day keys (`MoodEntry.date`), not
|
||||
// timestamps, so the only honest instant is the first instant of that day
|
||||
// in the user's zone. Writing the key as `T00:00Z` instead put the instant
|
||||
// on the previous calendar day for anyone west of UTC.
|
||||
const latestAt = latestEntry
|
||||
? startOfLocalDayKey(latestEntry.date, userTz).toISOString()
|
||||
: null;
|
||||
return plainCard("mood", "mood", {
|
||||
latestValue: latestEntry?.score ?? null,
|
||||
spark,
|
||||
updatedAt: latestAt,
|
||||
allTimeCount: moodSeries.entryCount,
|
||||
lastSeenAt: latestAt,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* BMI, derived from the latest weight at the profile height by
|
||||
* `computeBmi`, the single implementation the web vitals dashboard also
|
||||
* renders. Omitted when the derivation is insufficient (no height on the
|
||||
* profile, or no weight inside its window): the card shape has no "unknown"
|
||||
* state to express honestly, and a card with a null value would read as a
|
||||
* logging gap rather than a missing profile field.
|
||||
*/
|
||||
function bmiCard(
|
||||
bmiDerived: Awaited<ReturnType<typeof computeBmi>>,
|
||||
lookups: SummaryLookups,
|
||||
): MetricCard | null {
|
||||
if (!isDerivedOk(bmiDerived)) return null;
|
||||
const spark = bmiDerived.value.series.slice(-SPARK_DAYS);
|
||||
// BMI moves exactly when weight does, so the weight metadata is the honest
|
||||
// recency signal: there is no separate BMI reading.
|
||||
return plainCard("bmi", "bmi", {
|
||||
latestValue: bmiDerived.value.bmi,
|
||||
spark,
|
||||
updatedAt: lookups.metaForType("WEIGHT").lastSeenAt,
|
||||
allTimeCount: lookups.metaForType("WEIGHT").allTimeCount,
|
||||
lastSeenAt: lookups.metaForType("WEIGHT").lastSeenAt,
|
||||
});
|
||||
}
|
||||
|
||||
type Timed = <T>(label: string, builder: () => Promise<T>) => Promise<T>;
|
||||
|
||||
/** The summary's reads, in their three rounds, each timed under its label. */
|
||||
async function readSummaryInputs(
|
||||
userId: string,
|
||||
userTz: string,
|
||||
now: Date,
|
||||
time: Timed,
|
||||
) {
|
||||
// The latest-reading and sparkline reads take the most recent N rows per
|
||||
// type regardless of age; only the streak window anchors on a calendar
|
||||
// slice.
|
||||
const streakWindowStart = new Date(
|
||||
now.getTime() - STREAK_WINDOW_DAYS * 86_400_000,
|
||||
);
|
||||
// Derived from the canonical enum so a new measurement type is
|
||||
// auto-included. The card builders decide which types render as cards.
|
||||
const measurementTypes = [
|
||||
...measurementTypeEnum.options,
|
||||
] as MeasurementType[];
|
||||
|
||||
const [
|
||||
latestEver,
|
||||
sparkBuckets,
|
||||
allTimeAggregate,
|
||||
medsToday,
|
||||
streakActivity,
|
||||
measurementStreakDays,
|
||||
sleepStageRows,
|
||||
] = await Promise.all([
|
||||
// Both reads live in `@/lib/dashboard/summary-reads`: the SQL is a
|
||||
// contract the integration suite exercises directly against Postgres,
|
||||
// and each carries the reason it names the type list at its definition.
|
||||
// Short version: PostgreSQL 16 has no index skip scan, so "one row per
|
||||
// type" has to be driven FROM the type list or it degrades to a full
|
||||
// pass over the account.
|
||||
time("latestEver", () => readLatestEver(prisma, userId, measurementTypes)),
|
||||
time("sparkline", () =>
|
||||
readSparkBuckets(prisma, userId, measurementTypes, SPARK_DAYS),
|
||||
),
|
||||
// Counted from the DAY rollups where they cover the days, not by one
|
||||
// index entry per reading (see `readAllTimeCounts`).
|
||||
time("allTime", () =>
|
||||
readAllTimeCounts(prisma, userId, measurementTypes, now),
|
||||
),
|
||||
// The shared `buildMedsTodayBlock` runs the same idempotent projection
|
||||
// the intake route runs (`skipDuplicates: true` so a concurrent
|
||||
// intake-route hit can't race a duplicate row in) before its
|
||||
// today-window tally, so this route and the dashboard snapshot count the
|
||||
// identical row set. Daily schedules have no row to read until the
|
||||
// projection writes one.
|
||||
time("medsToday", () => buildMedsTodayBlock(prisma, userId, userTz, now)),
|
||||
time("streakIntakes", () =>
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId,
|
||||
// Tombstoned rows do not count towards a streak.
|
||||
deletedAt: null,
|
||||
scheduledFor: { gte: streakWindowStart },
|
||||
OR: [{ takenAt: { not: null } }, { skipped: true }],
|
||||
},
|
||||
select: { takenAt: true, scheduledFor: true },
|
||||
}),
|
||||
),
|
||||
time("streakDays", () =>
|
||||
readActivityDayKeys(prisma, userId, userTz, streakWindowStart, now),
|
||||
),
|
||||
// Raw per-stage SLEEP_DURATION rows for the night-total tile.
|
||||
// `SLEEP_DURATION` is stored one row per STAGE per night (minutes), so
|
||||
// the single most recent row (the `latestEver` path) is just ONE stage,
|
||||
// not the night. Bounded to the streak window (about a year of ~5
|
||||
// rows/night); the headline only needs the most recent night but the
|
||||
// window also feeds the sparkline night totals.
|
||||
time("sleepNights", () =>
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "SLEEP_DURATION",
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: streakWindowStart },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
// Writer-level collapse: two HealthKit apps behind one source
|
||||
// (watch stages vs phone in-bed) must not blend into one night.
|
||||
select: {
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
sleepStage: true,
|
||||
source: true,
|
||||
deviceType: true,
|
||||
},
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
// The user's sleep source-priority ladder collapses a dual-source night
|
||||
// (e.g. WHOOP + Apple Health) to one canonical source before the per-night
|
||||
// reconstruction sums it. The baseline profile rides along (one read) so
|
||||
// the sleep-rhythm block resolves the age-based sleep need.
|
||||
const [sleepPriorityJson, sleepProfile, moodSeries] = await Promise.all([
|
||||
loadUserSourcePriority(userId),
|
||||
loadBaselineProfile(prisma, userId),
|
||||
// The mood card reads the SAME canonical daily series the web mood
|
||||
// analytics and the dashboard snapshot read, rollup fast-path included:
|
||||
// never a second aggregation.
|
||||
time("moodSeries", () => buildMoodDailySeries(userId, prisma)),
|
||||
]);
|
||||
|
||||
// BMI is derived, not measured: it needs the profile height loaded above,
|
||||
// so it cannot join the round before it.
|
||||
const bmiDerived = await time("bmi", () =>
|
||||
computeBmi(userId, sleepProfile, { now }),
|
||||
);
|
||||
|
||||
return {
|
||||
latestEver,
|
||||
sparkBuckets,
|
||||
allTimeAggregate,
|
||||
medsToday,
|
||||
streakActivity,
|
||||
measurementStreakDays,
|
||||
sleepStageRows,
|
||||
sleepPriorityJson,
|
||||
sleepProfile,
|
||||
moodSeries,
|
||||
bmiDerived,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildDashboardSummary(
|
||||
userId: string,
|
||||
userTz: string,
|
||||
ctx: SummaryBuilderContext,
|
||||
) {
|
||||
const now = new Date();
|
||||
// Per-sub-query wall-clock timing, reported under
|
||||
// `meta.dashboard.sub_*_ms` so a regression can be attributed to a
|
||||
// specific sub-query without re-instrumenting the route. Captured on the
|
||||
// cache-miss path only; a cache hit skips this whole builder.
|
||||
const timings: Record<string, number> = {};
|
||||
const time = async <T>(
|
||||
label: string,
|
||||
builder: () => Promise<T>,
|
||||
): Promise<T> => {
|
||||
const t0 = Date.now();
|
||||
const result = await builder();
|
||||
timings[`dashboard.sub_${label}_ms`] = Date.now() - t0;
|
||||
return result;
|
||||
};
|
||||
|
||||
const {
|
||||
latestEver,
|
||||
sparkBuckets,
|
||||
allTimeAggregate,
|
||||
medsToday,
|
||||
streakActivity,
|
||||
measurementStreakDays,
|
||||
sleepStageRows,
|
||||
sleepPriorityJson,
|
||||
sleepProfile,
|
||||
moodSeries,
|
||||
bmiDerived,
|
||||
} = await readSummaryInputs(userId, userTz, now, time);
|
||||
|
||||
const lookups = summaryLookups({
|
||||
latestEver,
|
||||
sparkBuckets,
|
||||
allTimeAggregate,
|
||||
});
|
||||
|
||||
// The activity-day set from the bounded streak reads. The measurement
|
||||
// side arrives as local YYYY-MM-DD keys, the same keys
|
||||
// `userDayKey(measuredAt, userTz)` produces for the intakes.
|
||||
const activityDays = new Set<string>(measurementStreakDays);
|
||||
for (const e of streakActivity) {
|
||||
activityDays.add(userDayKey(e.takenAt ?? e.scheduledFor, userTz));
|
||||
}
|
||||
const streak = computeStreak(activityDays, userTz);
|
||||
|
||||
const sleepRows = sleepStageRows as SleepStageRow[];
|
||||
const sleepSummary = summarizeSleepNights(
|
||||
sleepRows,
|
||||
userTz,
|
||||
sleepPriorityJson,
|
||||
);
|
||||
|
||||
// Server-authoritative sleep-rhythm DTO (sleep debt + chronotype) off the
|
||||
// SAME canonical reconstruction the Sleep page + Sleep Score read, so iOS
|
||||
// renders identical values. Both signals carry a calm partial / learning
|
||||
// state below their night thresholds, never an assertion off thin data.
|
||||
const sleepRhythm: SleepRhythmDto = computeSleepRhythmFromNights(
|
||||
reconstructNights(sleepRows, userTz, sleepPriorityJson),
|
||||
sleepNeedMinutes(sleepProfile.ageYears),
|
||||
"COMPUTED",
|
||||
await loadFreeWeekdaySpans(userId),
|
||||
);
|
||||
|
||||
const metrics = measurementCards(lookups, {
|
||||
summary: sleepSummary,
|
||||
rows: sleepRows,
|
||||
tz: userTz,
|
||||
priorityJson: sleepPriorityJson,
|
||||
});
|
||||
const mood = moodCard(moodSeries, userTz);
|
||||
if (mood) metrics.push(mood);
|
||||
const bmi = bmiCard(bmiDerived, lookups);
|
||||
if (bmi) metrics.push(bmi);
|
||||
|
||||
const t = getServerTranslator(ctx.locale).t;
|
||||
|
||||
// The record's profile and own targets, for the card verdicts.
|
||||
const verdictProfile = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
heightCm: true,
|
||||
thresholdsJson: true,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({ meta: timings });
|
||||
|
||||
return {
|
||||
greeting: {
|
||||
salutation: t("dashboard.greetingSalutation", { name: ctx.greetingName }),
|
||||
date: now.toISOString(),
|
||||
},
|
||||
streak: {
|
||||
currentDays: streak.currentDays,
|
||||
longest: streak.longest,
|
||||
label: t("dashboard.streakLabel"),
|
||||
},
|
||||
// The shared builder carries the exact tally semantics this tile always
|
||||
// had (scheduled = every today-window row; taken = `takenAt` set and not
|
||||
// skipped). The wire shape of `compliance` is locked for iOS.
|
||||
compliance: {
|
||||
scheduledToday: medsToday.scheduledToday,
|
||||
takenToday: medsToday.takenToday,
|
||||
},
|
||||
highlightInsight: null,
|
||||
metrics: withVerdicts(metrics, {
|
||||
profile: {
|
||||
dateOfBirth: verdictProfile?.dateOfBirth ?? null,
|
||||
gender: verdictProfile?.gender ?? null,
|
||||
heightCm: verdictProfile?.heightCm ?? null,
|
||||
},
|
||||
thresholdsJson: verdictProfile?.thresholdsJson ?? null,
|
||||
}),
|
||||
// Sleep debt + chronotype, server-authoritative. Same values the Sleep
|
||||
// page's `/api/sleep/rhythm` read returns.
|
||||
sleepRhythm,
|
||||
lastUpdated: now.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* The metric cards of `GET /api/dashboard/summary`: their wire shape, the
|
||||
* i18n keys they carry, the trend and verdict laid over them, and the
|
||||
* builders for each card the summary emits.
|
||||
*
|
||||
* The shape is fixed for the iOS client and intentionally normalised:
|
||||
* `kind` is iOS-friendly (camelCase), unlike the canonical Prisma enum
|
||||
* (BLOOD_PRESSURE_SYS etc.).
|
||||
*/
|
||||
import type { MeasurementType, SleepStage } from "@/generated/prisma/client";
|
||||
import {
|
||||
resolveTrendVerdict,
|
||||
trendSentimentOfTrend,
|
||||
type TrendSentiment,
|
||||
type TrendVerdict,
|
||||
type TrendVerdictContext,
|
||||
} from "@/lib/targets/trend-verdict";
|
||||
import {
|
||||
reconstructSleepNights,
|
||||
type SleepSourceDiscrepancy,
|
||||
type SleepStageRow,
|
||||
} from "@/lib/analytics/sleep-night";
|
||||
|
||||
export const SPARK_DAYS = 7;
|
||||
|
||||
export type MetricKind =
|
||||
| "weight"
|
||||
| "bloodPressure"
|
||||
| "pulse"
|
||||
| "bodyFat"
|
||||
| "glucose"
|
||||
| "sleep"
|
||||
| "steps"
|
||||
| "totalBodyWater"
|
||||
| "boneMass"
|
||||
| "oxygenSaturation"
|
||||
| "mood"
|
||||
| "bmi";
|
||||
|
||||
export interface MetricCard {
|
||||
id: string;
|
||||
kind: MetricKind;
|
||||
/**
|
||||
* i18n key for the metric's display title (e.g.
|
||||
* `dashboard.metric.title.weight`). Clients resolve the key against their
|
||||
* own locale bundle: the PWA via `useTranslations()`, the iOS client via
|
||||
* its bundled `Localizable.xcstrings`. The previous wire shape carried a
|
||||
* German string literal which injected DE into an English-locale iOS UI.
|
||||
*/
|
||||
titleKey: string;
|
||||
latestValue: number | null;
|
||||
secondaryValue: number | null;
|
||||
/**
|
||||
* i18n key for the metric's display unit (e.g.
|
||||
* `dashboard.metric.unit.weight`). Same resolution contract as
|
||||
* `titleKey`. SI tokens like `kg`, `mmHg`, `bpm`, `mg/dL` are identical
|
||||
* across every shipped locale; per-kind keys keep the door open for
|
||||
* locale-specific overrides (e.g. Imperial units) without another
|
||||
* wire-shape change.
|
||||
*/
|
||||
unitKey: string;
|
||||
/**
|
||||
* Explicit unit token for clients that need to know the value's unit
|
||||
* without inferring it from the metric kind. Most tiles leave this `null`
|
||||
* (the `unitKey` i18n key already carries the display unit). The `sleep`
|
||||
* tile sets it to `"h"` because its `latestValue` is a per-NIGHT total
|
||||
* expressed in HOURS (a float), not the canonical `SLEEP_DURATION`
|
||||
* minutes.
|
||||
*/
|
||||
unit: string | null;
|
||||
/**
|
||||
* Per-stage hours for the headline night, sleep tile only. `null` for
|
||||
* every other kind and for a sleep night with no stage-tagged rows (a
|
||||
* legacy bare-duration night). Additive: a future sleep detail view can
|
||||
* render the breakdown without changing the headline `latestValue`.
|
||||
*/
|
||||
sleepStages: Partial<Record<SleepStage, number>> | null;
|
||||
/**
|
||||
* Source-discrepancy annotation for the headline night's MAIN session,
|
||||
* sleep tile only; `null` for every other kind and for a night whose
|
||||
* writers agree. Additive (the established wire pattern for the
|
||||
* iOS-locked shape): observational only; `latestValue` stays the winning
|
||||
* writer's total; clients render a discreet "sources disagree" hint next
|
||||
* to the number. Minutes are whole (rounded), matching the
|
||||
* `/api/sleep/night` serialisation.
|
||||
*/
|
||||
sleepSourceDiscrepancy: SleepSourceDiscrepancy | null;
|
||||
trend: "up" | "down" | "flat" | "unknown";
|
||||
sparkline: number[];
|
||||
updatedAt: string | null;
|
||||
/**
|
||||
* Total readings the user has ever logged for this metric, irrespective
|
||||
* of the 7-day sparkline window. The dashboard tile keeps showing
|
||||
* whenever `allTimeCount > 0` so a user with valid historical data isn't
|
||||
* surprised by a tile disappearing during a logging gap. Distinct from
|
||||
* `sparkline.length` which only carries the trailing-7-day points.
|
||||
*/
|
||||
allTimeCount: number;
|
||||
/**
|
||||
* ISO timestamp of the metric's single most recent reading. When
|
||||
* `allTimeCount > 0` but the latest reading is older than 7 days, the iOS
|
||||
* tile renders a muted "last reading N days ago" caption so the user
|
||||
* understands the value isn't stale silently. `null` when the metric has
|
||||
* no readings at all.
|
||||
*/
|
||||
lastSeenAt: string | null;
|
||||
/**
|
||||
* Which way a change of this metric counts for the person, against their
|
||||
* own target or a profile default (`src/lib/targets/trend-verdict.ts`).
|
||||
*/
|
||||
direction?: TrendVerdict | null;
|
||||
/** `direction` applied to this card's `trend`. */
|
||||
trendSentiment?: TrendSentiment;
|
||||
}
|
||||
|
||||
/**
|
||||
* The verdict key behind each card: the measurement type it reads, or the
|
||||
* derived key for the two cards that are not one.
|
||||
*/
|
||||
const VERDICT_KEY: Record<MetricKind, string> = {
|
||||
weight: "WEIGHT",
|
||||
bloodPressure: "BLOOD_PRESSURE_SYS",
|
||||
pulse: "PULSE",
|
||||
bodyFat: "BODY_FAT",
|
||||
glucose: "BLOOD_GLUCOSE",
|
||||
sleep: "SLEEP_DURATION",
|
||||
steps: "ACTIVITY_STEPS",
|
||||
totalBodyWater: "TOTAL_BODY_WATER",
|
||||
boneMass: "BONE_MASS",
|
||||
oxygenSaturation: "OXYGEN_SATURATION",
|
||||
mood: "MOOD",
|
||||
bmi: "BMI",
|
||||
};
|
||||
|
||||
/**
|
||||
* Lay the verdict over each card. The reference placed against the band is
|
||||
* the mean of the card's own seven-day sparkline, else its latest value, the
|
||||
* seven-day average the web tiles place. Sleep cards are in hours and the
|
||||
* verdict reads minutes.
|
||||
*/
|
||||
export function withVerdicts(
|
||||
cards: MetricCard[],
|
||||
ctx: Omit<TrendVerdictContext, "referenceValue">,
|
||||
): MetricCard[] {
|
||||
const referenceOf = (card: MetricCard | undefined): number | null => {
|
||||
if (!card) return null;
|
||||
const spark = card.sparkline.filter((v) => Number.isFinite(v));
|
||||
return spark.length > 0
|
||||
? spark.reduce((sum, v) => sum + v, 0) / spark.length
|
||||
: card.latestValue;
|
||||
};
|
||||
// BMI is judged by the weight it is derived from (the target is a weight).
|
||||
const weightReference = referenceOf(cards.find((c) => c.kind === "weight"));
|
||||
return cards.map((card) => {
|
||||
const reference = card.kind === "bmi" ? weightReference : referenceOf(card);
|
||||
const direction = resolveTrendVerdict(VERDICT_KEY[card.kind], {
|
||||
...ctx,
|
||||
referenceValue:
|
||||
reference !== null && card.kind === "sleep"
|
||||
? reference * 60
|
||||
: reference,
|
||||
});
|
||||
return {
|
||||
...card,
|
||||
direction,
|
||||
trendSentiment: trendSentimentOfTrend(card.trend, direction),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire i18n keys, not translated strings. The legacy maps emitted German
|
||||
* literals like "Gewicht" / "Schritte" which injected into an English iOS UI
|
||||
* once the iOS app flipped its source language. Clients now resolve these
|
||||
* keys against their own locale bundle (the PWA via `useTranslations()`, iOS
|
||||
* via `Localizable.xcstrings`). The server payload becomes language-neutral
|
||||
* and the response shrinks slightly.
|
||||
*
|
||||
* One key per `MetricKind`: symmetric maps so a new kind has to add both a
|
||||
* title and a unit key (Type-checked: `Record<MetricKind, string>`).
|
||||
*/
|
||||
const METRIC_TITLE_KEYS: Record<MetricKind, string> = {
|
||||
weight: "dashboard.metric.title.weight",
|
||||
bloodPressure: "dashboard.metric.title.bloodPressure",
|
||||
pulse: "dashboard.metric.title.pulse",
|
||||
bodyFat: "dashboard.metric.title.bodyFat",
|
||||
glucose: "dashboard.metric.title.glucose",
|
||||
sleep: "dashboard.metric.title.sleep",
|
||||
steps: "dashboard.metric.title.steps",
|
||||
totalBodyWater: "dashboard.metric.title.totalBodyWater",
|
||||
boneMass: "dashboard.metric.title.boneMass",
|
||||
oxygenSaturation: "dashboard.metric.title.oxygenSaturation",
|
||||
mood: "dashboard.metric.title.mood",
|
||||
bmi: "dashboard.metric.title.bmi",
|
||||
};
|
||||
|
||||
const METRIC_UNIT_KEYS: Record<MetricKind, string> = {
|
||||
weight: "dashboard.metric.unit.weight",
|
||||
bloodPressure: "dashboard.metric.unit.bloodPressure",
|
||||
pulse: "dashboard.metric.unit.pulse",
|
||||
bodyFat: "dashboard.metric.unit.bodyFat",
|
||||
glucose: "dashboard.metric.unit.glucose",
|
||||
sleep: "dashboard.metric.unit.sleep",
|
||||
steps: "dashboard.metric.unit.steps",
|
||||
totalBodyWater: "dashboard.metric.unit.totalBodyWater",
|
||||
boneMass: "dashboard.metric.unit.boneMass",
|
||||
oxygenSaturation: "dashboard.metric.unit.oxygenSaturation",
|
||||
mood: "dashboard.metric.unit.mood",
|
||||
bmi: "dashboard.metric.unit.bmi",
|
||||
};
|
||||
|
||||
/**
|
||||
* The sleep sparkline: the trailing nights' TIME-ASLEEP in hours,
|
||||
* reconstructed per night from the raw stage rows. The generic DAY-bucket
|
||||
* rollup sparkline can't be used for sleep because it means across the
|
||||
* per-stage rows (e.g. a 60-min DEEP row and a 240-min CORE row average to
|
||||
* 150) rather than summing them into a night total.
|
||||
*/
|
||||
export function buildSleepSparkline(
|
||||
rows: SleepStageRow[],
|
||||
tz: string,
|
||||
priorityJson: unknown,
|
||||
): number[] {
|
||||
return reconstructSleepNights(rows, tz, priorityJson)
|
||||
.filter((n) => n.asleepMinutes > 0)
|
||||
.map((n) => Math.round((n.asleepMinutes / 60) * 100) / 100)
|
||||
.slice(-SPARK_DAYS);
|
||||
}
|
||||
|
||||
export function trendOf(values: number[]): MetricCard["trend"] {
|
||||
if (values.length < 2) return "unknown";
|
||||
const first = values[0];
|
||||
const last = values[values.length - 1];
|
||||
const delta = last - first;
|
||||
const epsilon = Math.max(1, Math.abs(first) * 0.01);
|
||||
if (Math.abs(delta) < epsilon) return "flat";
|
||||
return delta > 0 ? "up" : "down";
|
||||
}
|
||||
|
||||
/** A card with no sleep extras, from its reading and its spark. */
|
||||
export function plainCard(
|
||||
id: string,
|
||||
kind: MetricKind,
|
||||
card: {
|
||||
latestValue: number | null;
|
||||
secondaryValue?: number | null;
|
||||
spark: number[];
|
||||
updatedAt: string | null;
|
||||
allTimeCount: number;
|
||||
lastSeenAt: string | null;
|
||||
},
|
||||
): MetricCard {
|
||||
return {
|
||||
id,
|
||||
kind,
|
||||
titleKey: METRIC_TITLE_KEYS[kind],
|
||||
latestValue: card.latestValue,
|
||||
secondaryValue: card.secondaryValue ?? null,
|
||||
unitKey: METRIC_UNIT_KEYS[kind],
|
||||
unit: null,
|
||||
sleepStages: null,
|
||||
sleepSourceDiscrepancy: null,
|
||||
trend: trendOf(card.spark),
|
||||
sparkline: card.spark,
|
||||
updatedAt: card.updatedAt,
|
||||
allTimeCount: card.allTimeCount,
|
||||
lastSeenAt: card.lastSeenAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** The sleep card: the latest night's TIME ASLEEP in hours. */
|
||||
export function sleepCard(card: {
|
||||
latestValue: number | null;
|
||||
sleepStages: Partial<Record<SleepStage, number>> | null;
|
||||
sleepSourceDiscrepancy: SleepSourceDiscrepancy | null;
|
||||
spark: number[];
|
||||
updatedAt: string | null;
|
||||
allTimeCount: number;
|
||||
lastSeenAt: string | null;
|
||||
}): MetricCard {
|
||||
return {
|
||||
id: "sleep",
|
||||
kind: "sleep",
|
||||
titleKey: METRIC_TITLE_KEYS.sleep,
|
||||
latestValue: card.latestValue,
|
||||
secondaryValue: null,
|
||||
unitKey: METRIC_UNIT_KEYS.sleep,
|
||||
unit: "h",
|
||||
sleepStages: card.sleepStages,
|
||||
sleepSourceDiscrepancy: card.sleepSourceDiscrepancy,
|
||||
trend: trendOf(card.spark),
|
||||
sparkline: card.spark,
|
||||
updatedAt: card.updatedAt,
|
||||
allTimeCount: card.allTimeCount,
|
||||
lastSeenAt: card.lastSeenAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** The measurement types of the optional cards, in emit order. */
|
||||
export const OPTIONAL_CARD_TYPES = [
|
||||
["BLOOD_GLUCOSE", "glucose"],
|
||||
["SLEEP_DURATION", "sleep"],
|
||||
["ACTIVITY_STEPS", "steps"],
|
||||
["TOTAL_BODY_WATER", "totalBodyWater"],
|
||||
["BONE_MASS", "boneMass"],
|
||||
["OXYGEN_SATURATION", "oxygenSaturation"],
|
||||
] as const satisfies ReadonlyArray<readonly [MeasurementType, MetricKind]>;
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Per-type lookups over the summary's bounded reads: the latest reading,
|
||||
* the sparkline points and the all-time metadata of each measurement type.
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
|
||||
import type {
|
||||
readAllTimeCounts,
|
||||
readLatestEver,
|
||||
readSparkBuckets,
|
||||
} from "@/lib/dashboard/summary-reads";
|
||||
|
||||
export interface SummaryLookups {
|
||||
latestOf(type: MeasurementType): { value: number; at: Date } | null;
|
||||
sparkOf(type: MeasurementType): number[];
|
||||
metaForType(type: MeasurementType): {
|
||||
allTimeCount: number;
|
||||
lastSeenAt: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export function summaryLookups(reads: {
|
||||
latestEver: Awaited<ReturnType<typeof readLatestEver>>;
|
||||
sparkBuckets: Awaited<ReturnType<typeof readSparkBuckets>>;
|
||||
allTimeAggregate: Awaited<ReturnType<typeof readAllTimeCounts>>;
|
||||
}): SummaryLookups {
|
||||
const { latestEver, sparkBuckets, allTimeAggregate } = reads;
|
||||
// Per-type metadata lookup: a typed Map so a metric with no readings at
|
||||
// all falls through `metaForType` to the `{ allTimeCount: 0, lastSeenAt:
|
||||
// null }` default.
|
||||
const allTimeByType = new Map<
|
||||
MeasurementType,
|
||||
{ allTimeCount: number; lastSeenAt: Date | null }
|
||||
>();
|
||||
// The newest reading is the one `latestEver` already read per type; its
|
||||
// instant is the metric's last-seen time.
|
||||
const lastSeenByType = new Map(
|
||||
latestEver.map((row) => [row.type, row.measured_at]),
|
||||
);
|
||||
for (const row of allTimeAggregate) {
|
||||
allTimeByType.set(row.type, {
|
||||
allTimeCount: row.count,
|
||||
lastSeenAt: lastSeenByType.get(row.type) ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Per-type latest (one row per type), carrying the most recent reading
|
||||
// regardless of age: the iOS tile shows the historical value plus a muted
|
||||
// "last reading N days ago" caption rather than nothing at all.
|
||||
const latestByType = new Map<MeasurementType, { value: number; at: Date }>();
|
||||
for (const row of latestEver) {
|
||||
latestByType.set(row.type, {
|
||||
value: Number(row.value),
|
||||
at: new Date(row.measured_at),
|
||||
});
|
||||
}
|
||||
// Cumulative tiles paint the daily SUM; spot tiles keep the daily MEAN.
|
||||
// Reading `sum_value` directly off the rollup row avoids a `mean * count`
|
||||
// reconstruction; the fallback covers rollup rows written before the sum
|
||||
// column existed, during the boot-backfill convergence window.
|
||||
const sparkByType = new Map<MeasurementType, number[]>();
|
||||
for (const row of sparkBuckets) {
|
||||
const list = sparkByType.get(row.type) ?? [];
|
||||
const cumulativePoint =
|
||||
row.sum_value !== null
|
||||
? Number(row.sum_value)
|
||||
: Number(row.mean) * Number(row.count);
|
||||
const point = CUMULATIVE_HK_TYPES.has(row.type)
|
||||
? cumulativePoint
|
||||
: Number(row.mean);
|
||||
list.push(point);
|
||||
sparkByType.set(row.type, list);
|
||||
}
|
||||
|
||||
return {
|
||||
latestOf: (type) => latestByType.get(type) ?? null,
|
||||
sparkOf: (type) => sparkByType.get(type) ?? [],
|
||||
metaForType: (type) => {
|
||||
const slot = allTimeByType.get(type);
|
||||
return {
|
||||
allTimeCount: slot?.allTimeCount ?? 0,
|
||||
lastSeenAt: slot?.lastSeenAt?.toISOString() ?? null,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* The measurement-backed cards of the dashboard summary, in emit order:
|
||||
* weight, blood pressure, pulse, body fat, then the optional cards. Every
|
||||
* card carries `allTimeCount` + `lastSeenAt` so the iOS tile renderer can
|
||||
* keep a metric visible during a logging gap (gate on `allTimeCount > 0`)
|
||||
* and paint a muted "last reading N days ago" caption when the most recent
|
||||
* reading is older than the 7-day sparkline window.
|
||||
*/
|
||||
import type { SleepStage } from "@/generated/prisma/client";
|
||||
import type {
|
||||
summarizeSleepNights,
|
||||
SleepStageRow,
|
||||
} from "@/lib/analytics/sleep-night";
|
||||
|
||||
import {
|
||||
buildSleepSparkline,
|
||||
OPTIONAL_CARD_TYPES,
|
||||
plainCard,
|
||||
sleepCard,
|
||||
type MetricCard,
|
||||
} from "./cards";
|
||||
import type { SummaryLookups } from "./lookups";
|
||||
|
||||
export interface SummarySleepInput {
|
||||
summary: ReturnType<typeof summarizeSleepNights>;
|
||||
rows: SleepStageRow[];
|
||||
tz: string;
|
||||
priorityJson: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blood pressure is a paired metric: the tile is "alive" whenever either
|
||||
* side of the pair has history, and an account that has never logged it
|
||||
* gets no empty placeholder tile. The count sums both sides; the most
|
||||
* recent last-seen instant of the two drives the staleness hint.
|
||||
*/
|
||||
function bloodPressureCard(lookups: SummaryLookups): MetricCard | null {
|
||||
const latestSys = lookups.latestOf("BLOOD_PRESSURE_SYS");
|
||||
const latestDia = lookups.latestOf("BLOOD_PRESSURE_DIA");
|
||||
const sysSpark = lookups.sparkOf("BLOOD_PRESSURE_SYS");
|
||||
const sysMeta = lookups.metaForType("BLOOD_PRESSURE_SYS");
|
||||
const diaMeta = lookups.metaForType("BLOOD_PRESSURE_DIA");
|
||||
const bpAllTimeCount = sysMeta.allTimeCount + diaMeta.allTimeCount;
|
||||
const bpLastSeenAt = ((): string | null => {
|
||||
const sysAt = sysMeta.lastSeenAt;
|
||||
const diaAt = diaMeta.lastSeenAt;
|
||||
if (!sysAt) return diaAt;
|
||||
if (!diaAt) return sysAt;
|
||||
return sysAt >= diaAt ? sysAt : diaAt;
|
||||
})();
|
||||
if (!latestSys && !latestDia && bpAllTimeCount === 0) return null;
|
||||
return plainCard("bp", "bloodPressure", {
|
||||
latestValue: latestSys?.value ?? null,
|
||||
secondaryValue: latestDia?.value ?? null,
|
||||
spark: sysSpark,
|
||||
updatedAt:
|
||||
latestSys?.at?.toISOString() ??
|
||||
latestDia?.at?.toISOString() ??
|
||||
bpLastSeenAt,
|
||||
allTimeCount: bpAllTimeCount,
|
||||
lastSeenAt: bpLastSeenAt,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep is night-aggregated, not single-stage: the latest night's TIME
|
||||
* ASLEEP (CORE/light + DEEP + REM, excluding IN_BED + AWAKE) in HOURS, the
|
||||
* per-stage breakdown, and a sparkline of the trailing nights' asleep hours
|
||||
* from the night reconstruction (the DAY-bucket rollup sparkline blends
|
||||
* stages and would be misleading for sleep).
|
||||
*/
|
||||
function sleepNightCard(
|
||||
sleep: SummarySleepInput,
|
||||
meta: { allTimeCount: number; lastSeenAt: string | null },
|
||||
): MetricCard {
|
||||
const night = sleep.summary.latestNight;
|
||||
const toHours = (minutes: number): number =>
|
||||
Math.round((minutes / 60) * 100) / 100;
|
||||
const nightSpark = buildSleepSparkline(
|
||||
sleep.rows,
|
||||
sleep.tz,
|
||||
sleep.priorityJson,
|
||||
);
|
||||
const stageHours: Partial<Record<SleepStage, number>> | null = night
|
||||
? Object.fromEntries(
|
||||
Object.entries(night.stages).map(([s, m]) => [s, toHours(m)]),
|
||||
)
|
||||
: null;
|
||||
return sleepCard({
|
||||
latestValue: night ? toHours(night.asleepMinutes) : null,
|
||||
sleepStages:
|
||||
stageHours && Object.keys(stageHours).length > 0 ? stageHours : null,
|
||||
// Additive, observational: the headline night's MAIN session saw two
|
||||
// writer buckets with clearly different asleep totals. Rounded to whole
|
||||
// minutes, matching the `/api/sleep/night` serialisation.
|
||||
sleepSourceDiscrepancy: night?.sourceDiscrepancy
|
||||
? {
|
||||
deltaMinutes: Math.round(night.sourceDiscrepancy.deltaMinutes),
|
||||
sources: night.sourceDiscrepancy.sources.map((b) => ({
|
||||
source: b.source,
|
||||
deviceType: b.deviceType,
|
||||
asleepMinutes: Math.round(b.asleepMinutes),
|
||||
})),
|
||||
}
|
||||
: null,
|
||||
spark: nightSpark,
|
||||
updatedAt: night?.measuredAt.toISOString() ?? meta.lastSeenAt,
|
||||
allTimeCount: meta.allTimeCount,
|
||||
lastSeenAt: night?.measuredAt.toISOString() ?? meta.lastSeenAt,
|
||||
});
|
||||
}
|
||||
|
||||
export function measurementCards(
|
||||
lookups: SummaryLookups,
|
||||
sleep: SummarySleepInput,
|
||||
): MetricCard[] {
|
||||
const metrics: MetricCard[] = [];
|
||||
|
||||
// Weight is always emitted.
|
||||
{
|
||||
const latest = lookups.latestOf("WEIGHT");
|
||||
const meta = lookups.metaForType("WEIGHT");
|
||||
metrics.push(
|
||||
plainCard("weight", "weight", {
|
||||
latestValue: latest?.value ?? null,
|
||||
spark: lookups.sparkOf("WEIGHT"),
|
||||
updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt,
|
||||
allTimeCount: meta.allTimeCount,
|
||||
lastSeenAt: meta.lastSeenAt,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const bp = bloodPressureCard(lookups);
|
||||
if (bp) metrics.push(bp);
|
||||
|
||||
// Pulse and body fat: emitted whenever there is a latest reading or any
|
||||
// history, so a tile doesn't disappear during a fortnight without a fresh
|
||||
// reading and an account that never logged one gets no placeholder.
|
||||
for (const [type, id, kind] of [
|
||||
["PULSE", "pulse", "pulse"],
|
||||
["BODY_FAT", "bodyFat", "bodyFat"],
|
||||
] as const) {
|
||||
const latest = lookups.latestOf(type);
|
||||
const meta = lookups.metaForType(type);
|
||||
if (latest || meta.allTimeCount > 0) {
|
||||
metrics.push(
|
||||
plainCard(id, kind, {
|
||||
latestValue: latest?.value ?? null,
|
||||
spark: lookups.sparkOf(type),
|
||||
updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt,
|
||||
allTimeCount: meta.allTimeCount,
|
||||
lastSeenAt: meta.lastSeenAt,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Optional cards: emitted whenever the user has *ever* logged the metric,
|
||||
// so a glucose tile that hasn't been touched in 10 days still renders with
|
||||
// the historical value visible (paired with the lastSeenAt caption).
|
||||
for (const [type, kind] of OPTIONAL_CARD_TYPES) {
|
||||
const latest = lookups.latestOf(type);
|
||||
const meta = lookups.metaForType(type);
|
||||
if (!latest && meta.allTimeCount === 0) continue;
|
||||
if (kind === "sleep") {
|
||||
metrics.push(sleepNightCard(sleep, meta));
|
||||
continue;
|
||||
}
|
||||
metrics.push(
|
||||
plainCard(kind, kind, {
|
||||
latestValue: latest?.value ?? null,
|
||||
spark: lookups.sparkOf(type),
|
||||
updatedAt: latest?.at.toISOString() ?? meta.lastSeenAt,
|
||||
allTimeCount: meta.allTimeCount,
|
||||
lastSeenAt: meta.lastSeenAt,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return metrics;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* The stored row a vault upload answers with (`POST /api/documents/inbound`),
|
||||
* serialised with its condition and visit links: a fresh insert with no facts
|
||||
* yet, or the existing live row a duplicate resolved to, with its fact
|
||||
* tallies.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
loadConditionLinks,
|
||||
loadDocumentEncounterLinks,
|
||||
} from "@/lib/documents/links";
|
||||
import {
|
||||
serialiseDocument,
|
||||
type SerialisableDocument,
|
||||
} from "@/lib/documents/store";
|
||||
|
||||
/** The existing row a duplicate upload resolved to. */
|
||||
export async function duplicateDocumentBody(
|
||||
userId: string,
|
||||
existing: SerialisableDocument,
|
||||
) {
|
||||
const [links, visitLinks, groups] = await Promise.all([
|
||||
loadConditionLinks(userId, [existing.id]),
|
||||
loadDocumentEncounterLinks(userId, [existing.id]),
|
||||
prisma.extractedFact.groupBy({
|
||||
by: ["status"],
|
||||
where: { userId, documentId: existing.id },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
let factCount = 0;
|
||||
let pendingCount = 0;
|
||||
for (const g of groups) {
|
||||
if (g.status !== "REJECTED") factCount += g._count._all;
|
||||
if (g.status === "PENDING") pendingCount += g._count._all;
|
||||
}
|
||||
return serialiseDocument(
|
||||
existing,
|
||||
{ factCount, pendingCount },
|
||||
links.get(existing.id) ?? [],
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
visitLinks.get(existing.id) ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
/** A row the upload just stored. */
|
||||
export async function storedDocumentBody(
|
||||
userId: string,
|
||||
document: SerialisableDocument,
|
||||
) {
|
||||
const [links, visitLinks] = await Promise.all([
|
||||
loadConditionLinks(userId, [document.id]),
|
||||
loadDocumentEncounterLinks(userId, [document.id]),
|
||||
]);
|
||||
return serialiseDocument(
|
||||
document,
|
||||
{ factCount: 0, pendingCount: 0 },
|
||||
links.get(document.id) ?? [],
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
visitLinks.get(document.id) ?? [],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Reading one vault upload (`POST /api/documents/inbound`): the source key
|
||||
* that may ride the query string, the bounded multipart body, the metadata
|
||||
* fields beside the file, and the one source key the upload is filed under.
|
||||
* Each step answers what it found; the route turns a refusal into its
|
||||
* response.
|
||||
*/
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { BodyTooLargeError, readBoundedBody } from "@/lib/labs/ocr-upload";
|
||||
import {
|
||||
documentCreateSchema,
|
||||
documentSourceKeySchema,
|
||||
type DocumentSourceSystemValue,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
const UPLOAD_BODY_TIMEOUT_MS = 30_000;
|
||||
|
||||
export interface QuerySourceKey {
|
||||
sourceSystem: DocumentSourceSystemValue;
|
||||
sourceId: string;
|
||||
sourceInstance?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The source key in the query string. Both halves or neither; half a key is
|
||||
* a mistake worth saying so about.
|
||||
*/
|
||||
export function parseQuerySourceKey(
|
||||
url: URL,
|
||||
):
|
||||
| { kind: "none" }
|
||||
| { kind: "invalid"; issues: z.core.$ZodIssue[] }
|
||||
| { kind: "key"; key: QuerySourceKey } {
|
||||
const querySystem = url.searchParams.get("sourceSystem");
|
||||
const queryId = url.searchParams.get("sourceId");
|
||||
const queryInstance = url.searchParams.get("sourceInstance");
|
||||
if (querySystem === null && queryId === null) return { kind: "none" };
|
||||
const parsedKey = documentSourceKeySchema.safeParse({
|
||||
sourceSystem: querySystem ?? undefined,
|
||||
sourceId: queryId ?? undefined,
|
||||
sourceInstance: queryInstance ?? undefined,
|
||||
});
|
||||
if (!parsedKey.success) {
|
||||
return { kind: "invalid", issues: parsedKey.error.issues };
|
||||
}
|
||||
return { kind: "key", key: parsedKey.data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the multipart body, aborting at `bodyCap` bytes or after the upload
|
||||
* deadline, and parse it as form data.
|
||||
*/
|
||||
export async function readUploadForm(
|
||||
request: Request,
|
||||
bodyCap: number,
|
||||
): Promise<
|
||||
| { kind: "form"; formData: FormData }
|
||||
| { kind: "tooLarge" }
|
||||
| { kind: "timeout" }
|
||||
| { kind: "invalid" }
|
||||
> {
|
||||
try {
|
||||
const bytes = await readBoundedBody(request.body, bodyCap, {
|
||||
signal: request.signal,
|
||||
deadline: Date.now() + UPLOAD_BODY_TIMEOUT_MS,
|
||||
});
|
||||
const formData = await new Request(request.url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": request.headers.get("content-type") ?? "" },
|
||||
body: bytes,
|
||||
}).formData();
|
||||
return { kind: "form", formData };
|
||||
} catch (err) {
|
||||
if (err instanceof BodyTooLargeError) return { kind: "tooLarge" };
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
return { kind: "timeout" };
|
||||
}
|
||||
return { kind: "invalid" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The optional metadata (title / kind / documentDate / episodeIds /
|
||||
* encounterIds / source key / aiRead): the form fields beside the file. Both
|
||||
* id lists may be repeated.
|
||||
*/
|
||||
export function parseUploadMetadata(formData: FormData) {
|
||||
const rawEpisodeIds = formData
|
||||
.getAll("episodeIds")
|
||||
.filter((v): v is string => typeof v === "string");
|
||||
const rawEncounterIds = formData
|
||||
.getAll("encounterIds")
|
||||
.filter((v): v is string => typeof v === "string");
|
||||
return documentCreateSchema.safeParse({
|
||||
title: formData.get("title") ?? undefined,
|
||||
kind: formData.get("kind") ?? undefined,
|
||||
documentDate: formData.get("documentDate") ?? undefined,
|
||||
episodeIds: rawEpisodeIds.length > 0 ? rawEpisodeIds : undefined,
|
||||
encounterIds: rawEncounterIds.length > 0 ? rawEncounterIds : undefined,
|
||||
sourceSystem: formData.get("sourceSystem") ?? undefined,
|
||||
sourceId: formData.get("sourceId") ?? undefined,
|
||||
sourceInstance: formData.get("sourceInstance") ?? undefined,
|
||||
aiRead: formData.get("aiRead") ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The key from the query string, or from the form fields; if a caller sends
|
||||
* both they have to agree.
|
||||
*/
|
||||
export function resolveUploadSourceKey(
|
||||
queryKey: QuerySourceKey | null,
|
||||
metadata: z.infer<typeof documentCreateSchema>,
|
||||
):
|
||||
| { kind: "mismatch" }
|
||||
| {
|
||||
kind: "key";
|
||||
sourceSystem: DocumentSourceSystemValue | null;
|
||||
sourceId: string | null;
|
||||
sourceInstance: string | null;
|
||||
} {
|
||||
const formSystem = metadata.sourceSystem ?? null;
|
||||
const formId = formSystem ? (metadata.sourceId ?? null) : null;
|
||||
const formInstance = formId ? (metadata.sourceInstance ?? null) : null;
|
||||
if (
|
||||
queryKey &&
|
||||
formSystem !== null &&
|
||||
(formSystem !== queryKey.sourceSystem ||
|
||||
formId !== queryKey.sourceId ||
|
||||
(formInstance !== null &&
|
||||
formInstance !== (queryKey.sourceInstance ?? null)))
|
||||
) {
|
||||
return { kind: "mismatch" };
|
||||
}
|
||||
const sourceSystem = queryKey?.sourceSystem ?? formSystem;
|
||||
const sourceId = queryKey?.sourceId ?? formId;
|
||||
const sourceInstance = sourceId
|
||||
? (queryKey?.sourceInstance ?? formInstance ?? null)
|
||||
: null;
|
||||
return { kind: "key", sourceSystem, sourceId, sourceInstance };
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* The answers a vault upload (`POST /api/documents/inbound`) gives besides a
|
||||
* freshly stored row: the receipt a narrow token gets, a duplicate, a
|
||||
* re-send of a deleted import, the alias limit, and the size refusal.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import type { SerialisableDocument } from "@/lib/documents/store";
|
||||
import type { SourceKeyMatch } from "@/lib/documents/source-key";
|
||||
import { duplicateDocumentBody } from "@/lib/documents/upload-answer";
|
||||
import type { DocumentSourceSystemValue } from "@/lib/validations/inbound-documents";
|
||||
|
||||
/** §3.2 — 413 fileTooLarge with the configured limit in `meta`. */
|
||||
export function fileTooLarge(maxFileBytes: number): NextResponse {
|
||||
return apiError("File is too large.", 413, {
|
||||
errorCode: "documents.inbound.fileTooLarge",
|
||||
reason: "fileTooLarge",
|
||||
maxFileBytes,
|
||||
});
|
||||
}
|
||||
|
||||
/** What a single upload asked for, carried into the annotations. */
|
||||
export interface UploadContext {
|
||||
userId: string;
|
||||
/** A narrow `documents:write` token, not a session or a `["*"]` token. */
|
||||
scoped: boolean;
|
||||
sourceSystem: DocumentSourceSystemValue | null;
|
||||
aiDeferred: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The receipt a scoped caller gets in place of the stored row: the id and
|
||||
* whether the upload was a duplicate. A write-only credential holder who
|
||||
* knows a file's bytes learns that it is stored, not its title, filename,
|
||||
* links or fact counts.
|
||||
*/
|
||||
export function receiptResponse(
|
||||
id: string | null,
|
||||
status: 200 | 201,
|
||||
flags: { duplicate: boolean; deleted?: boolean },
|
||||
): NextResponse {
|
||||
const data = {
|
||||
id,
|
||||
duplicate: flags.duplicate,
|
||||
...(flags.deleted ? { deleted: true } : {}),
|
||||
};
|
||||
return NextResponse.json(
|
||||
{
|
||||
data,
|
||||
error: null,
|
||||
...(flags.duplicate
|
||||
? {
|
||||
meta: {
|
||||
duplicate: true,
|
||||
...(flags.deleted ? { deleted: true } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A re-send of an imported document the person deleted.
|
||||
* 200 and no new row, for every caller: the deletion was a decision, and an
|
||||
* importer re-running over the same source must not undo it. `id` is the
|
||||
* tombstoned row while it exists and null once the purge took it; there is no
|
||||
* row left to serialise either way, so the answer is the receipt.
|
||||
*/
|
||||
export function deletedResponse(
|
||||
ctx: UploadContext,
|
||||
id: string | null,
|
||||
): NextResponse {
|
||||
annotate({
|
||||
action: { name: "documents.vault.upload" },
|
||||
meta: {
|
||||
documentId: id,
|
||||
duplicate: true,
|
||||
deleted: true,
|
||||
scoped: ctx.scoped,
|
||||
sourceSystem: ctx.sourceSystem,
|
||||
aiDeferred: ctx.aiDeferred,
|
||||
},
|
||||
});
|
||||
return receiptResponse(id, 200, { duplicate: true, deleted: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* The same bytes have already been sent under too many source keys
|
||||
* (`MAX_SOURCE_ALIASES_PER_DOCUMENT`). A conflict with what is stored, not a
|
||||
* rate: nothing is remembered and nothing stored.
|
||||
*/
|
||||
export function aliasLimitResponse(): NextResponse {
|
||||
return apiError(
|
||||
"This file is already stored under too many source ids.",
|
||||
409,
|
||||
{ errorCode: "documents.inbound.sourceAliasLimit" },
|
||||
);
|
||||
}
|
||||
|
||||
/** Answer a source key that is already held: duplicate, or deleted. */
|
||||
export function answerSourceKey(
|
||||
ctx: UploadContext,
|
||||
match: SourceKeyMatch,
|
||||
): Promise<NextResponse> | NextResponse {
|
||||
return match.state === "live"
|
||||
? duplicateResponse(ctx, match.document)
|
||||
: deletedResponse(ctx, match.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* §3.2 — a duplicate upload is NOT an error: return the existing live row
|
||||
* with `meta.duplicate: true` at the envelope level (the UI toasts "already
|
||||
* stored" and highlights the row). A scoped caller gets the receipt instead.
|
||||
*/
|
||||
export async function duplicateResponse(
|
||||
ctx: UploadContext,
|
||||
existing: SerialisableDocument,
|
||||
): Promise<NextResponse> {
|
||||
annotate({
|
||||
action: { name: "documents.vault.upload" },
|
||||
meta: {
|
||||
documentId: existing.id,
|
||||
duplicate: true,
|
||||
scoped: ctx.scoped,
|
||||
sourceSystem: ctx.sourceSystem,
|
||||
aiDeferred: ctx.aiDeferred,
|
||||
},
|
||||
});
|
||||
if (ctx.scoped) {
|
||||
return receiptResponse(existing.id, 200, { duplicate: true });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: await duplicateDocumentBody(ctx.userId, existing),
|
||||
error: null,
|
||||
meta: { duplicate: true },
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* The vault list read behind `GET /api/documents/inbound`: title/filename
|
||||
* search unioned with a whole-word content match, kind / episode / visit /
|
||||
* year / date-range filters, sort, and keyset pagination. It NEVER selects
|
||||
* the encrypted blob column.
|
||||
*/
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { listPage } from "@/lib/http/list-query";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { hashQueryTokens } from "@/lib/documents/content-index";
|
||||
import { isoDateToUtc } from "@/lib/documents/ingest";
|
||||
import {
|
||||
loadConditionLinks,
|
||||
loadDocumentEncounterLinks,
|
||||
} from "@/lib/documents/links";
|
||||
import { serialiseDocument } from "@/lib/documents/store";
|
||||
import {
|
||||
toContentIndexSource,
|
||||
type documentListQuerySchema,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
type VaultListQuery = z.infer<typeof documentListQuerySchema>;
|
||||
|
||||
function listWhere(
|
||||
userId: string,
|
||||
query: VaultListQuery,
|
||||
): Prisma.InboundDocumentWhereInput {
|
||||
const { q, kind, episodeId, encounterId, year, from, to } = query;
|
||||
const where: Prisma.InboundDocumentWhereInput = {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
};
|
||||
if (kind && kind.length > 0) where.kind = { in: kind };
|
||||
if (episodeId) where.conditionLinks = { some: { episodeId } };
|
||||
// The visit filter reads the SAME table the visit's own sheet writes, from
|
||||
// the document side. Filtering rather than post-filtering: a page of fifty
|
||||
// documents narrowed in Node would page wrongly.
|
||||
if (encounterId) where.encounterLinks = { some: { encounterId } };
|
||||
if (year !== undefined) {
|
||||
where.documentDate = {
|
||||
gte: new Date(Date.UTC(year, 0, 1)),
|
||||
lt: new Date(Date.UTC(year + 1, 0, 1)),
|
||||
};
|
||||
} else if (from || to) {
|
||||
where.documentDate = {
|
||||
...(from ? { gte: isoDateToUtc(from) } : {}),
|
||||
// inclusive end-of-day for the `to` bound
|
||||
...(to ? { lt: new Date(isoDateToUtc(to).getTime() + 86_400_000) } : {}),
|
||||
};
|
||||
}
|
||||
if (q) {
|
||||
// Substring match on the short plaintext fields …
|
||||
const or: Prisma.InboundDocumentWhereInput[] = [
|
||||
{ title: { contains: q, mode: "insensitive" } },
|
||||
{ filename: { contains: q, mode: "insensitive" } },
|
||||
];
|
||||
// … unioned with a WHOLE-WORD content match over the blind token index.
|
||||
// The query is tokenised + HMAC'd the same way the index was built, then
|
||||
// matched with a GIN-accelerated array-overlap (`hasSome` → `&&`). The
|
||||
// list still never selects the encrypted text: only the opaque hashes
|
||||
// are touched, in the related table. Degrades silently to title/filename
|
||||
// when the caller has no indexed documents (no rows overlap).
|
||||
const hashes = hashQueryTokens(q);
|
||||
if (hashes.length > 0) {
|
||||
or.push({ contentIndex: { is: { searchTokens: { hasSome: hashes } } } });
|
||||
}
|
||||
where.OR = or;
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sort column of a page; nullable sort columns sort nulls last so
|
||||
* undated documents trail.
|
||||
*/
|
||||
function primaryOrder(
|
||||
query: VaultListQuery,
|
||||
): Prisma.InboundDocumentOrderByWithRelationInput {
|
||||
const { sort, order } = query;
|
||||
return sort === "documentDate"
|
||||
? { documentDate: { sort: order, nulls: "last" } }
|
||||
: sort === "title"
|
||||
? { title: { sort: order, nulls: "last" } }
|
||||
: { createdAt: order };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fact tallies for the whole page in ONE grouped count: no per-document
|
||||
* fan-out and no materialising fact-id rows. `factCount` counts every
|
||||
* non-REJECTED fact (a rejected fact is discarded, not part of the
|
||||
* document's tally), `pendingCount` the PENDING subset awaiting review.
|
||||
*/
|
||||
async function pageFactCounts(
|
||||
userId: string,
|
||||
pageIds: string[],
|
||||
): Promise<Map<string, { factCount: number; pendingCount: number }>> {
|
||||
const factCounts = new Map<
|
||||
string,
|
||||
{ factCount: number; pendingCount: number }
|
||||
>();
|
||||
if (pageIds.length === 0) return factCounts;
|
||||
const groups = await prisma.extractedFact.groupBy({
|
||||
by: ["documentId", "status"],
|
||||
where: { userId, documentId: { in: pageIds } },
|
||||
_count: { _all: true },
|
||||
});
|
||||
for (const g of groups) {
|
||||
const entry = factCounts.get(g.documentId) ?? {
|
||||
factCount: 0,
|
||||
pendingCount: 0,
|
||||
};
|
||||
const n = g._count._all;
|
||||
if (g.status !== "REJECTED") entry.factCount += n;
|
||||
if (g.status === "PENDING") entry.pendingCount += n;
|
||||
factCounts.set(g.documentId, entry);
|
||||
}
|
||||
return factCounts;
|
||||
}
|
||||
|
||||
export async function listVaultDocuments(
|
||||
userId: string,
|
||||
query: VaultListQuery,
|
||||
) {
|
||||
const { q, kind, episodeId, encounterId, year, sort, order, cursor } = query;
|
||||
const where = listWhere(userId, query);
|
||||
// Keyset pagination on the sort column + a stable `id` tiebreak.
|
||||
const orderBy: Prisma.InboundDocumentOrderByWithRelationInput[] = [
|
||||
primaryOrder(query),
|
||||
{ id: order },
|
||||
];
|
||||
|
||||
// Hardening: the list NEVER fetches the encrypted blob column; a page of
|
||||
// 50 rows would otherwise drag up to 50 × cap ciphertext bytes per request.
|
||||
const { rows: page, cap } = await listPage(query.limit, (take) =>
|
||||
prisma.inboundDocument.findMany({
|
||||
where,
|
||||
omit: { contentEncrypted: true },
|
||||
orderBy,
|
||||
take,
|
||||
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
|
||||
}),
|
||||
);
|
||||
|
||||
const hasMore = cap.truncated;
|
||||
const nextCursor = hasMore ? (page[page.length - 1]?.id ?? null) : null;
|
||||
|
||||
const pageIds = page.map((d) => d.id);
|
||||
const factCounts = await pageFactCounts(userId, pageIds);
|
||||
|
||||
// Condition/visit links + the content-index and thumbnail probes for the
|
||||
// page, all in ONE Promise.all: four independent grouped queries (no N+1,
|
||||
// never a ciphertext/blob column), and the vault polls this list every few
|
||||
// seconds so two serialized round-trips were pure added latency.
|
||||
const [linkMap, visitLinkMap, indexedRows, thumbRows] = await Promise.all([
|
||||
loadConditionLinks(userId, pageIds),
|
||||
loadDocumentEncounterLinks(userId, pageIds),
|
||||
// Which of the page's documents have a content index (drives the
|
||||
// searchable status + the provenance the UI reads to tell an AI-read
|
||||
// document from a locally-indexed one).
|
||||
page.length > 0
|
||||
? prisma.documentContentIndex.findMany({
|
||||
where: { userId, documentId: { in: pageIds } },
|
||||
select: { documentId: true, source: true },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
// Which of the page's documents have a preview thumbnail (gates the
|
||||
// card's <img>). Grouped on the 1:1 side table.
|
||||
page.length > 0
|
||||
? prisma.documentThumbnail.findMany({
|
||||
where: { userId, documentId: { in: pageIds } },
|
||||
select: { documentId: true },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const indexSources = new Map<string, string>();
|
||||
for (const row of indexedRows) indexSources.set(row.documentId, row.source);
|
||||
const thumbnailIds = new Set<string>();
|
||||
for (const row of thumbRows) thumbnailIds.add(row.documentId);
|
||||
|
||||
annotate({
|
||||
action: { name: "documents.inbound.list" },
|
||||
meta: {
|
||||
count: page.length,
|
||||
sort,
|
||||
order,
|
||||
filtered: Boolean(
|
||||
q || (kind && kind.length > 0) || episodeId || encounterId || year,
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
documents: page.map((doc) =>
|
||||
serialiseDocument(
|
||||
doc,
|
||||
factCounts.get(doc.id) ?? { factCount: 0, pendingCount: 0 },
|
||||
linkMap.get(doc.id) ?? [],
|
||||
indexSources.has(doc.id),
|
||||
toContentIndexSource(indexSources.get(doc.id)),
|
||||
thumbnailIds.has(doc.id),
|
||||
visitLinkMap.get(doc.id) ?? [],
|
||||
),
|
||||
),
|
||||
nextCursor,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
/**
|
||||
* The batched writer behind `POST /api/import/csv`.
|
||||
*
|
||||
* The route reads, parses and bounds the file; this module writes its
|
||||
* accepted rows. It pre-fetches the existing rows under the IMPORT source in
|
||||
* one query per dedup-key shape (per type and chunk), partitions the file
|
||||
* into insert and update sets, then writes them with `createManyAndReturn`
|
||||
* and grouped `updateMany` in bounded chunks. The per-line
|
||||
* inserted / updated / duplicate envelope is reconstructed from the
|
||||
* partition. Serial per-row upserts once approached the request timeout on a
|
||||
* few-thousand-row file; this is the shape `/measurements/batch` uses.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { encryptNote, readNote } from "@/lib/crypto/note-cipher";
|
||||
import type { CsvRowResult } from "@/lib/import/csv-measurements";
|
||||
import type { NormalisedMeasurementRow } from "@/lib/import/csv-measurements";
|
||||
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
|
||||
import type {
|
||||
GlucoseContext,
|
||||
MeasurementType,
|
||||
Prisma,
|
||||
} from "@/generated/prisma/client";
|
||||
import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit";
|
||||
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
|
||||
|
||||
/** Keys per dedup probe: well under Postgres's bind-parameter cap. */
|
||||
const LOOKUP_CHUNK = 1000;
|
||||
/** Rows per create call / per update batch transaction. */
|
||||
const WRITE_CHUNK = 200;
|
||||
|
||||
/** A parse-`ok` line with its normalised row. */
|
||||
export type CsvOkRow = CsvRowResult & {
|
||||
row: NonNullable<CsvRowResult["row"]>;
|
||||
};
|
||||
|
||||
type WriteOutcome = "inserted" | "updated" | "duplicate";
|
||||
|
||||
interface StoredImportRow {
|
||||
value: number;
|
||||
unit: string;
|
||||
measuredAt: Date;
|
||||
glucoseContext: GlucoseContext | null;
|
||||
notes: string | null;
|
||||
notesEncrypted: Uint8Array | null;
|
||||
deletedAt: Date | null;
|
||||
}
|
||||
|
||||
/** The counts and per-line outcomes of one import, filled as it plans. */
|
||||
interface CsvTally {
|
||||
inserted: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
/**
|
||||
* Per-line write outcome for rows that reached the DB. Keyed by source
|
||||
* line so the response envelope can surface `inserted` / `updated` /
|
||||
* `duplicate` distinctly without polluting the parse-time `CsvRowReason`.
|
||||
*/
|
||||
outcome: Map<number, WriteOutcome>;
|
||||
touched: Array<{ type: MeasurementType; measuredAt: Date }>;
|
||||
}
|
||||
|
||||
function chunked<T>(items: readonly T[], size: number): T[][] {
|
||||
const out: T[][] = [];
|
||||
for (let i = 0; i < items.length; i += size) {
|
||||
out.push(items.slice(i, i + size));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Group rows' probe keys by measurement type, de-duplicated. */
|
||||
function groupByType<K>(
|
||||
rows: ReadonlyArray<{ row: NormalisedMeasurementRow }>,
|
||||
keyOf: (r: { row: NormalisedMeasurementRow }) => K,
|
||||
): Map<MeasurementType, K[]> {
|
||||
const byType = new Map<MeasurementType, Map<string, K>>();
|
||||
for (const r of rows) {
|
||||
const type = r.row.type as MeasurementType;
|
||||
const key = keyOf(r);
|
||||
const bucket = byType.get(type) ?? new Map<string, K>();
|
||||
bucket.set(key instanceof Date ? String(key.getTime()) : String(key), key);
|
||||
byType.set(type, bucket);
|
||||
}
|
||||
return new Map([...byType].map(([type, keys]) => [type, [...keys.values()]]));
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a stored IMPORT row already holds exactly what this line would
|
||||
* write: same value, unit, instant, glucose context and note, and live. Such
|
||||
* a line is a duplicate, not an update.
|
||||
*/
|
||||
function sameStoredRow(
|
||||
stored: StoredImportRow,
|
||||
m: NormalisedMeasurementRow,
|
||||
): boolean {
|
||||
return (
|
||||
stored.deletedAt === null &&
|
||||
stored.value === m.value &&
|
||||
stored.unit === m.unit &&
|
||||
stored.measuredAt.getTime() === m.measuredAt.getTime() &&
|
||||
stored.glucoseContext ===
|
||||
((m.glucoseContext as GlucoseContext | undefined) ?? null) &&
|
||||
readNote(stored.notesEncrypted, stored.notes) === (m.notes || null)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows with an external id: idempotent upsert on
|
||||
* `(userId, type, source=IMPORT, externalId)`. One probe per type and chunk
|
||||
* fetches every pre-existing key with the columns an update would write, so
|
||||
* a row whose stored values already match is left alone.
|
||||
*
|
||||
* Partitioned in file order, honouring in-file repeats: the first sighting
|
||||
* of a brand-new key inserts, every later sighting (or any sighting of a key
|
||||
* already in the DB) updates in place. The last value for a key wins,
|
||||
* matching the old sequential-upsert overwrite. Insert / update maps are
|
||||
* keyed by (type, externalId) so a repeated key collapses to one write. A
|
||||
* key already in the DB whose stored row equals this line is a duplicate: no
|
||||
* write, reported like the natural-key duplicates. That is what makes
|
||||
* re-importing the same file cheap: a second upload of an unchanged
|
||||
* 9 000-row export writes nothing.
|
||||
*/
|
||||
async function planExternalIdRows(
|
||||
userId: string,
|
||||
extRows: CsvOkRow[],
|
||||
tally: CsvTally,
|
||||
): Promise<{
|
||||
inserts: Map<string, NormalisedMeasurementRow>;
|
||||
updates: Map<string, NormalisedMeasurementRow>;
|
||||
}> {
|
||||
const existing = new Map<string, StoredImportRow>();
|
||||
for (const [type, ids] of groupByType(
|
||||
extRows,
|
||||
(r) => r.row.externalId as string,
|
||||
)) {
|
||||
for (const chunk of chunked(ids, LOOKUP_CHUNK)) {
|
||||
const found = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
source: "IMPORT",
|
||||
type,
|
||||
externalId: { in: chunk },
|
||||
},
|
||||
select: {
|
||||
externalId: true,
|
||||
value: true,
|
||||
unit: true,
|
||||
measuredAt: true,
|
||||
glucoseContext: true,
|
||||
notes: true,
|
||||
notesEncrypted: true,
|
||||
deletedAt: true,
|
||||
},
|
||||
});
|
||||
for (const row of found) {
|
||||
existing.set(`${type}::${row.externalId}`, row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const inserts = new Map<string, NormalisedMeasurementRow>();
|
||||
const updates = new Map<string, NormalisedMeasurementRow>();
|
||||
for (const result of extRows) {
|
||||
const m = result.row;
|
||||
const key = `${m.type}::${m.externalId}`;
|
||||
const stored = existing.get(key);
|
||||
if (stored && !updates.has(key) && sameStoredRow(stored, m)) {
|
||||
tally.skipped++;
|
||||
tally.outcome.set(result.line, "duplicate");
|
||||
continue;
|
||||
}
|
||||
if (stored) {
|
||||
updates.set(key, m);
|
||||
tally.updated++;
|
||||
tally.outcome.set(result.line, "updated");
|
||||
} else if (seen.has(key)) {
|
||||
// New-to-DB key seen again in this file: still a single create with
|
||||
// the latest value, but this line reads as an update just as the
|
||||
// sequential upsert did.
|
||||
inserts.set(key, m);
|
||||
tally.updated++;
|
||||
tally.outcome.set(result.line, "updated");
|
||||
} else {
|
||||
seen.add(key);
|
||||
inserts.set(key, m);
|
||||
tally.inserted++;
|
||||
tally.outcome.set(result.line, "inserted");
|
||||
}
|
||||
tally.touched.push({
|
||||
type: m.type as MeasurementType,
|
||||
measuredAt: m.measuredAt,
|
||||
});
|
||||
// An update that moves the reading to another instant leaves the day it
|
||||
// came from: that day is re-folded too, or the old day keeps a reading
|
||||
// that no longer exists there.
|
||||
if (stored && stored.measuredAt.getTime() !== m.measuredAt.getTime()) {
|
||||
tally.touched.push({
|
||||
type: m.type as MeasurementType,
|
||||
measuredAt: stored.measuredAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { inserts, updates };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows without an external id: create-only, deduped on the natural unique
|
||||
* key (userId, type, measuredAt, source=IMPORT, sleepStage=null). A
|
||||
* collision, against the DB or an earlier row in this same file, is a
|
||||
* duplicate, never an overwrite (each sample is a canonical reading).
|
||||
* Probed per type and chunk, like the external-id rows.
|
||||
*/
|
||||
async function planPlainRows(
|
||||
userId: string,
|
||||
plainRows: CsvOkRow[],
|
||||
tally: CsvTally,
|
||||
): Promise<NormalisedMeasurementRow[]> {
|
||||
const existing = new Set<string>();
|
||||
for (const [type, instants] of groupByType(
|
||||
plainRows,
|
||||
(r) => r.row.measuredAt,
|
||||
)) {
|
||||
for (const chunk of chunked(instants, LOOKUP_CHUNK)) {
|
||||
const found = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
source: "IMPORT",
|
||||
type,
|
||||
measuredAt: { in: chunk },
|
||||
},
|
||||
select: { measuredAt: true },
|
||||
});
|
||||
for (const row of found) {
|
||||
existing.add(`${type}::${row.measuredAt.getTime()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const inserts: NormalisedMeasurementRow[] = [];
|
||||
for (const result of plainRows) {
|
||||
const m = result.row;
|
||||
const key = `${m.type}::${m.measuredAt.getTime()}`;
|
||||
if (existing.has(key) || seen.has(key)) {
|
||||
tally.skipped++;
|
||||
tally.outcome.set(result.line, "duplicate");
|
||||
} else {
|
||||
seen.add(key);
|
||||
inserts.push(m);
|
||||
tally.inserted++;
|
||||
tally.outcome.set(result.line, "inserted");
|
||||
tally.touched.push({
|
||||
type: m.type as MeasurementType,
|
||||
measuredAt: m.measuredAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
return inserts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Under `skipDuplicates` a conflicting row is silently absorbed: the key is
|
||||
* present in the table either way, so the SPECIFIC raced lines cannot be
|
||||
* identified; only the counters and per-line statuses have to sum to the
|
||||
* truth. Downgrade enough `inserted` lines to `duplicate` so the envelope
|
||||
* matches the DB write count (the batch route's raced-duplicate downgrade).
|
||||
*/
|
||||
function downgradeRacedInserts(tally: CsvTally, raced: number): void {
|
||||
if (raced <= 0) return;
|
||||
let downgraded = 0;
|
||||
for (const [line, outcome] of tally.outcome) {
|
||||
if (downgraded >= raced) break;
|
||||
if (outcome === "inserted") {
|
||||
tally.outcome.set(line, "duplicate");
|
||||
tally.inserted--;
|
||||
tally.skipped++;
|
||||
downgraded++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk write in bounded chunks, each its own short transaction.
|
||||
*
|
||||
* This used to be ONE interactive transaction around every create chunk AND
|
||||
* one `updateMany` per re-imported row. Re-importing a 9 000-row file with an
|
||||
* externalId column meant 9 000 serial updates inside it, which outlived
|
||||
* Prisma's 5 s interactive-transaction timeout and ended in a 500. A chunk
|
||||
* now commits on its own: creates go through `createManyAndReturn`
|
||||
* (skipDuplicates absorbs a concurrent double-submit), updates through one
|
||||
* batch transaction per chunk. A failure part-way leaves the earlier chunks
|
||||
* written, and that is safe to retry: a re-upload of the same file skips
|
||||
* what landed (unchanged externalId rows and natural-key duplicates are not
|
||||
* written again).
|
||||
*/
|
||||
async function writeRows(
|
||||
userId: string,
|
||||
toCreate: Prisma.MeasurementCreateManyInput[],
|
||||
updates: NormalisedMeasurementRow[],
|
||||
tally: CsvTally,
|
||||
): Promise<void> {
|
||||
let createdCount = 0;
|
||||
const createdRows: Array<{
|
||||
id: string;
|
||||
type: MeasurementType;
|
||||
measuredAt: Date;
|
||||
}> = [];
|
||||
for (const chunk of chunked(toCreate, WRITE_CHUNK)) {
|
||||
const rows = await prisma.measurement.createManyAndReturn({
|
||||
data: chunk,
|
||||
skipDuplicates: true,
|
||||
select: { id: true, type: true, measuredAt: true },
|
||||
});
|
||||
createdRows.push(...rows);
|
||||
createdCount += rows.length;
|
||||
}
|
||||
for (const chunk of chunked(updates, WRITE_CHUNK)) {
|
||||
await prisma.$transaction(
|
||||
chunk.map((m) =>
|
||||
prisma.measurement.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
source: "IMPORT",
|
||||
type: m.type as MeasurementType,
|
||||
externalId: m.externalId as string,
|
||||
},
|
||||
data: {
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
measuredAt: m.measuredAt,
|
||||
notes: null,
|
||||
notesEncrypted: encryptNote(m.notes ?? null),
|
||||
glucoseContext:
|
||||
(m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
// No-op on a live row, a deliberate RESURRECTION on a tombstoned
|
||||
// one: IMPORT rows are re-importable by design, so a re-imported
|
||||
// externalId brings the row back (mirrors the source-owned sync
|
||||
// resurrect rule).
|
||||
deletedAt: null,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void emitInsertedMeasurementArrivals(userId, createdRows, "csv_import").catch(
|
||||
() => {},
|
||||
);
|
||||
void maybeEnqueueMorningRefresh(
|
||||
userId,
|
||||
createdRows
|
||||
.filter((row) => row.type === "SLEEP_DURATION")
|
||||
.map((row) => row.measuredAt),
|
||||
).catch(() => {});
|
||||
|
||||
downgradeRacedInserts(tally, toCreate.length - createdCount);
|
||||
}
|
||||
|
||||
async function importRows(
|
||||
userId: string,
|
||||
okRows: CsvOkRow[],
|
||||
tally: CsvTally,
|
||||
ipAddress: string | null,
|
||||
): Promise<void> {
|
||||
const buildCreateData = (
|
||||
m: NormalisedMeasurementRow,
|
||||
): Prisma.MeasurementCreateManyInput => ({
|
||||
userId,
|
||||
type: m.type as MeasurementType,
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
source: "IMPORT",
|
||||
...(m.externalId ? { externalId: m.externalId } : {}),
|
||||
measuredAt: m.measuredAt,
|
||||
notes: null,
|
||||
notesEncrypted: encryptNote(m.notes ?? null),
|
||||
glucoseContext: (m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
});
|
||||
|
||||
const ext = await planExternalIdRows(
|
||||
userId,
|
||||
okRows.filter((r) => r.row.externalId),
|
||||
tally,
|
||||
);
|
||||
const plainInserts = await planPlainRows(
|
||||
userId,
|
||||
okRows.filter((r) => !r.row.externalId),
|
||||
tally,
|
||||
);
|
||||
|
||||
const toCreate: Prisma.MeasurementCreateManyInput[] = [
|
||||
...[...ext.inserts.values()].map(buildCreateData),
|
||||
...plainInserts.map(buildCreateData),
|
||||
];
|
||||
// The re-fold runs whatever happens to the writes. A chunk that fails
|
||||
// part-way leaves the earlier chunks written, and a retry reads those rows
|
||||
// as duplicates and touches nothing: without the re-fold here their days
|
||||
// would keep the rollups from before the import for good. Re-folding a
|
||||
// (type, day) nothing landed on is harmless.
|
||||
try {
|
||||
await writeRows(userId, toCreate, [...ext.updates.values()], tally);
|
||||
} finally {
|
||||
// One bounded rollup re-fold per touched (type, day): a 10 000-row CSV
|
||||
// pays at most ~N (type, day) recomputes, not 10 000 per-row hooks, and
|
||||
// the status re-warm for the touched types. Best-effort: a populator
|
||||
// hiccup never fails the importer.
|
||||
await afterMeasurementMutation(userId, tally.touched, {
|
||||
source: "manual",
|
||||
label: "import.csv",
|
||||
});
|
||||
}
|
||||
|
||||
await auditLog("import.csv.upload", {
|
||||
userId,
|
||||
ipAddress,
|
||||
details: {
|
||||
inserted: tally.inserted,
|
||||
updated: tally.updated,
|
||||
skipped: tally.skipped,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Write (or, with `dryRun`, only project) one parsed CSV file and answer the
|
||||
* route's envelope: `{ inserted, updated, skipped, total, dryRun, rows }`.
|
||||
*/
|
||||
export async function runCsvImport(input: {
|
||||
userId: string;
|
||||
rows: CsvRowResult[];
|
||||
okRows: CsvOkRow[];
|
||||
dryRun: boolean;
|
||||
ipAddress: string | null;
|
||||
}) {
|
||||
const { rows, okRows, dryRun } = input;
|
||||
const tally: CsvTally = {
|
||||
inserted: 0,
|
||||
updated: 0,
|
||||
skipped: rows.filter((r) => r.status === "skipped").length,
|
||||
outcome: new Map(),
|
||||
touched: [],
|
||||
};
|
||||
|
||||
if (!dryRun) {
|
||||
await importRows(input.userId, okRows, tally, input.ipAddress);
|
||||
} else {
|
||||
// Dry-run preview: no write ran, so report the projected outcome: every
|
||||
// valid parse-`ok` row would insert (an externalId row could land as an
|
||||
// update, but that needs a DB read deliberately skipped here).
|
||||
tally.inserted = okRows.length;
|
||||
}
|
||||
|
||||
const summary = {
|
||||
inserted: tally.inserted,
|
||||
updated: tally.updated,
|
||||
skipped: tally.skipped,
|
||||
total: rows.length,
|
||||
dryRun,
|
||||
// Per-row status, mirroring the batch route's per-entry envelope. The
|
||||
// normalised payload is stripped: the client only needs line + status.
|
||||
// Parse-time `ok` rows are refined by the write outcome: a row that hit
|
||||
// a unique-constraint duplicate becomes `skipped`/`duplicate`; an upsert
|
||||
// that landed on an existing row becomes `updated`. In `dryRun` mode no
|
||||
// write ran, so every parse-`ok` row stays `inserted` (the projected
|
||||
// outcome).
|
||||
rows: rows.map((r) => {
|
||||
if (r.status === "skipped") {
|
||||
return {
|
||||
line: r.line,
|
||||
status: "skipped" as const,
|
||||
...(r.reason ? { reason: r.reason } : {}),
|
||||
};
|
||||
}
|
||||
const outcome = tally.outcome.get(r.line);
|
||||
if (outcome === "duplicate") {
|
||||
return {
|
||||
line: r.line,
|
||||
status: "skipped" as const,
|
||||
reason: "duplicate",
|
||||
};
|
||||
}
|
||||
if (outcome === "updated") {
|
||||
return { line: r.line, status: "updated" as const };
|
||||
}
|
||||
return { line: r.line, status: "inserted" as const };
|
||||
}),
|
||||
};
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
import_csv_inserted: tally.inserted,
|
||||
import_csv_updated: tally.updated,
|
||||
import_csv_skipped: tally.skipped,
|
||||
import_csv_dry_run: dryRun,
|
||||
},
|
||||
});
|
||||
|
||||
return summary;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* The measurement rows of a JSON import (`POST /api/import`).
|
||||
*
|
||||
* The route's schema has already bounded each row (plausible range, entry
|
||||
* instant). With an external id a row is inserted or, when the key is taken,
|
||||
* updated in place, so a re-import of the same export is idempotent; without
|
||||
* one it is a first-write-wins create with a NULL external id, which does
|
||||
* duplicate on re-import.
|
||||
*/
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import type {
|
||||
glucoseContextEnum,
|
||||
measurementTypeEnum,
|
||||
} from "@/lib/validations/measurement";
|
||||
import { classifyExternalId } from "@/lib/validations/external-id";
|
||||
import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import { isP2002, isP2025 } from "@/lib/prisma-errors";
|
||||
|
||||
import type { JsonImportTally } from "./tally";
|
||||
|
||||
export interface JsonImportMeasurement {
|
||||
type: z.infer<typeof measurementTypeEnum>;
|
||||
value: number;
|
||||
unit: string;
|
||||
measuredAt: Date;
|
||||
glucoseContext?: z.infer<typeof glucoseContextEnum>;
|
||||
source?: string;
|
||||
notes?: string;
|
||||
externalId?: string;
|
||||
}
|
||||
|
||||
export interface ImportedMeasurements {
|
||||
/**
|
||||
* Each `(type, measuredAt)` written, so the persistent rollup tier is
|
||||
* re-folded at the end. The import path once bypassed the rollup write
|
||||
* hook entirely, so a restore left the `source=rollup` chart paths
|
||||
* under-counting the imported days until the next worker boot re-ran the
|
||||
* backfill.
|
||||
*/
|
||||
touched: Array<{ type: MeasurementType; measuredAt: Date }>;
|
||||
/** The rows this import inserted (not the ones it updated in place). */
|
||||
inserted: Array<{ id: string; type: MeasurementType; measuredAt: Date }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write `rows` for `userId`, counting into `tally`. Stops at the first
|
||||
* write that fails for any reason other than a duplicate, and marks the
|
||||
* tally failed.
|
||||
*/
|
||||
export async function importJsonMeasurements(
|
||||
userId: string,
|
||||
rows: JsonImportMeasurement[],
|
||||
tally: JsonImportTally,
|
||||
): Promise<ImportedMeasurements> {
|
||||
const touched: ImportedMeasurements["touched"] = [];
|
||||
const inserted: ImportedMeasurements["inserted"] = [];
|
||||
for (const m of rows) {
|
||||
// The `(userId, type, source, externalId)` upsert key is only
|
||||
// idempotent while the id is STABLE; an id that rotates between
|
||||
// exports re-imports as a fresh row every time. Skipped per entry,
|
||||
// like every other rejected row in this importer.
|
||||
const unstable =
|
||||
m.externalId === undefined ? null : classifyExternalId(m.externalId);
|
||||
if (unstable) {
|
||||
tally.unstableShapes.push(unstable);
|
||||
tally.stats.skipped++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
// `measuredAt` is already a `Date` (validateEntryInstant transform).
|
||||
const measuredAt = m.measuredAt;
|
||||
if (m.externalId) {
|
||||
const notesEncrypted = encryptNote(m.notes || null);
|
||||
// INSERT ... RETURNING is the only insertion proof. A concurrent
|
||||
// importer can win this key after any existence probe, so attempt
|
||||
// the insert first and update only when the statement returns no row.
|
||||
const created = await prisma.measurement.createManyAndReturn({
|
||||
data: [
|
||||
{
|
||||
userId,
|
||||
type: m.type,
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
source: "IMPORT",
|
||||
externalId: m.externalId,
|
||||
measuredAt,
|
||||
notes: null,
|
||||
notesEncrypted,
|
||||
glucoseContext: m.glucoseContext ?? null,
|
||||
},
|
||||
],
|
||||
skipDuplicates: true,
|
||||
select: { id: true, type: true, measuredAt: true },
|
||||
});
|
||||
const row = created[0];
|
||||
if (row) {
|
||||
inserted.push(row);
|
||||
} else {
|
||||
// A pre-existing row or a raced insert owns the key. Preserve
|
||||
// idempotent re-import semantics without classifying this update
|
||||
// as an arrival.
|
||||
try {
|
||||
await prisma.measurement.update({
|
||||
where: {
|
||||
userId_type_source_externalId: {
|
||||
userId,
|
||||
type: m.type,
|
||||
source: "IMPORT",
|
||||
externalId: m.externalId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
measuredAt,
|
||||
notes: null,
|
||||
notesEncrypted,
|
||||
glucoseContext: m.glucoseContext ?? null,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (isP2025(err)) {
|
||||
tally.stats.skipped++;
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const row = await prisma.measurement.create({
|
||||
data: {
|
||||
userId,
|
||||
type: m.type,
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
source: "IMPORT",
|
||||
measuredAt,
|
||||
notes: null,
|
||||
notesEncrypted: encryptNote(m.notes || null),
|
||||
glucoseContext: m.glucoseContext ?? null,
|
||||
},
|
||||
select: { id: true, type: true, measuredAt: true },
|
||||
});
|
||||
inserted.push(row);
|
||||
}
|
||||
touched.push({ type: m.type as MeasurementType, measuredAt });
|
||||
tally.stats.measurements++;
|
||||
} catch (err) {
|
||||
if (isP2002(err)) {
|
||||
tally.stats.skipped++;
|
||||
continue;
|
||||
}
|
||||
tally.writeFailed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { touched, inserted };
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* The mood rows of a JSON import (`POST /api/import`).
|
||||
*
|
||||
* A row with an external id upserts on `(userId, source, externalId)`. A row
|
||||
* without a logged instant gets a stable fallback instant inside its day,
|
||||
* derived from the row's content and its occurrence in the file, so the same
|
||||
* file lands on the same instants every time and twin rows land on distinct
|
||||
* ones.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ENTRY_INSTANT_CLOCK_SKEW_MS } from "@/lib/validations/entry-instant";
|
||||
import { classifyExternalId } from "@/lib/validations/external-id";
|
||||
import { isP2002 } from "@/lib/prisma-errors";
|
||||
import { moodDateKey } from "@/lib/mood/date-key";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { deriveA1 } from "@/lib/mood/level-a";
|
||||
import { zonedWallClockToUtc } from "@/lib/tz/wall-clock";
|
||||
|
||||
import type { JsonImportTally } from "./tally";
|
||||
|
||||
export interface JsonImportMoodEntry {
|
||||
date: string;
|
||||
mood: "SUPER_GUT" | "GUT" | "OKAY" | "SCHLECHT" | "LAUSIG";
|
||||
score: number;
|
||||
tags?: string;
|
||||
loggedAt?: Date;
|
||||
externalId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The start of a `YYYY-MM-DD` day in the default zone, or null when the
|
||||
* string does not name a real day. The route's schema uses it to bound a row
|
||||
* without a logged instant.
|
||||
*/
|
||||
export function fallbackMoodDateStart(date: string): Date | null {
|
||||
const [year, month, day] = date.split("-").map(Number);
|
||||
if (!year || !month || !day) return null;
|
||||
const instant = zonedWallClockToUtc(
|
||||
{ year, month, day, hour: 0, minute: 0 },
|
||||
DEFAULT_TIMEZONE,
|
||||
);
|
||||
return moodDateKey(instant, DEFAULT_TIMEZONE) === date ? instant : null;
|
||||
}
|
||||
|
||||
function fallbackMoodIdentity(entry: JsonImportMoodEntry): string {
|
||||
return JSON.stringify([
|
||||
entry.date,
|
||||
entry.mood,
|
||||
entry.score,
|
||||
entry.tags ?? null,
|
||||
entry.externalId ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
function stableFallbackMoodInstant(
|
||||
entry: JsonImportMoodEntry,
|
||||
occurrence: number,
|
||||
probe: number,
|
||||
): Date {
|
||||
const digest = createHash("sha256")
|
||||
.update(`${fallbackMoodIdentity(entry)}:${occurrence}`)
|
||||
.digest();
|
||||
const initialOffset = digest.readUInt32BE(0) % ENTRY_INSTANT_CLOCK_SKEW_MS;
|
||||
const offset = (initialOffset + probe) % ENTRY_INSTANT_CLOCK_SKEW_MS;
|
||||
const dateStart = fallbackMoodDateStart(entry.date);
|
||||
if (!dateStart) throw new Error("Invalid mood date");
|
||||
return new Date(dateStart.getTime() + offset);
|
||||
}
|
||||
|
||||
/** The fallback instants this import has handed out, and how often each row content occurred. */
|
||||
interface FallbackState {
|
||||
occurrences: Map<string, number>;
|
||||
reserved: Set<number>;
|
||||
}
|
||||
|
||||
async function writeMoodEntry(
|
||||
userId: string,
|
||||
e: JsonImportMoodEntry,
|
||||
loggedAt: Date,
|
||||
): Promise<void> {
|
||||
if (e.externalId) {
|
||||
// Idempotent re-import keyed on the source-stable id. A second import
|
||||
// of the same export updates the row in place rather than skipping it as
|
||||
// a duplicate, so an upstream edit (re-scored mood, added tag) is
|
||||
// reflected.
|
||||
await prisma.moodEntry.upsert({
|
||||
where: {
|
||||
userId_source_externalId: {
|
||||
userId,
|
||||
source: "IMPORT",
|
||||
externalId: e.externalId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
date: e.date,
|
||||
mood: e.mood,
|
||||
score: e.score,
|
||||
// Pleasantness derived from the row's own label, not from the file's
|
||||
// `score`. This is the one writer whose score comes out of the file
|
||||
// rather than out of the label, so the two can disagree; the column
|
||||
// that records a self-assessment has to agree with the label the
|
||||
// user actually picked.
|
||||
moodA1: deriveA1(e.mood),
|
||||
tags: e.tags || null,
|
||||
moodLoggedAt: loggedAt,
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
date: e.date,
|
||||
mood: e.mood,
|
||||
score: e.score,
|
||||
moodA1: deriveA1(e.mood),
|
||||
tags: e.tags || null,
|
||||
source: "IMPORT",
|
||||
externalId: e.externalId,
|
||||
moodLoggedAt: loggedAt,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await prisma.moodEntry.create({
|
||||
data: {
|
||||
userId,
|
||||
date: e.date,
|
||||
mood: e.mood,
|
||||
score: e.score,
|
||||
// See the upsert arm above: derived from the label, because an
|
||||
// imported file's `score` is its own field.
|
||||
moodA1: deriveA1(e.mood),
|
||||
tags: e.tags || null,
|
||||
source: "IMPORT",
|
||||
moodLoggedAt: loggedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the row already stored at `loggedAt` is this very fallback row
|
||||
* from an earlier import of the same file. Throws when the probe fails.
|
||||
*/
|
||||
async function sameImportedRow(
|
||||
userId: string,
|
||||
e: JsonImportMoodEntry,
|
||||
loggedAt: Date,
|
||||
): Promise<boolean> {
|
||||
const existing = await prisma.moodEntry.findUnique({
|
||||
where: {
|
||||
userId_date_moodLoggedAt: {
|
||||
userId,
|
||||
date: e.date,
|
||||
moodLoggedAt: loggedAt,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
source: true,
|
||||
mood: true,
|
||||
score: true,
|
||||
tags: true,
|
||||
externalId: true,
|
||||
},
|
||||
});
|
||||
return (
|
||||
existing?.source === "IMPORT" &&
|
||||
existing.mood === e.mood &&
|
||||
existing.score === e.score &&
|
||||
existing.tags === (e.tags || null) &&
|
||||
(existing.externalId ?? null) === null
|
||||
);
|
||||
}
|
||||
|
||||
async function importMoodEntry(
|
||||
userId: string,
|
||||
e: JsonImportMoodEntry,
|
||||
fallback: FallbackState,
|
||||
tally: JsonImportTally,
|
||||
): Promise<void> {
|
||||
// Same stability floor as the measurement rows: the mood upsert keys on
|
||||
// `(userId, source, externalId)`.
|
||||
const unstableMood =
|
||||
e.externalId === undefined ? null : classifyExternalId(e.externalId);
|
||||
if (unstableMood) {
|
||||
tally.unstableShapes.push(unstableMood);
|
||||
tally.stats.skipped++;
|
||||
return;
|
||||
}
|
||||
const identity = e.loggedAt ? null : fallbackMoodIdentity(e);
|
||||
const occurrence =
|
||||
identity === null ? 0 : (fallback.occurrences.get(identity) ?? 0);
|
||||
if (identity !== null) {
|
||||
fallback.occurrences.set(identity, occurrence + 1);
|
||||
}
|
||||
|
||||
for (let probe = 0; probe < ENTRY_INSTANT_CLOCK_SKEW_MS; probe++) {
|
||||
const loggedAt =
|
||||
e.loggedAt ?? stableFallbackMoodInstant(e, occurrence, probe);
|
||||
if (!e.loggedAt && fallback.reserved.has(loggedAt.getTime())) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await writeMoodEntry(userId, e, loggedAt);
|
||||
if (!e.loggedAt) {
|
||||
fallback.reserved.add(loggedAt.getTime());
|
||||
}
|
||||
tally.stats.moodEntries++;
|
||||
return;
|
||||
} catch (err) {
|
||||
if (!isP2002(err)) {
|
||||
tally.writeFailed = true;
|
||||
return;
|
||||
}
|
||||
if (e.loggedAt) {
|
||||
tally.stats.skipped++;
|
||||
return;
|
||||
}
|
||||
if (!e.externalId) {
|
||||
try {
|
||||
if (await sameImportedRow(userId, e, loggedAt)) {
|
||||
fallback.reserved.add(loggedAt.getTime());
|
||||
tally.stats.skipped++;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
tally.writeFailed = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (probe === ENTRY_INSTANT_CLOCK_SKEW_MS - 1) {
|
||||
tally.writeFailed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write `rows` for `userId`, counting into `tally`. Stops at the first row
|
||||
* whose write failed for a reason other than a duplicate.
|
||||
*/
|
||||
export async function importJsonMoodEntries(
|
||||
userId: string,
|
||||
rows: JsonImportMoodEntry[],
|
||||
tally: JsonImportTally,
|
||||
): Promise<void> {
|
||||
const fallback: FallbackState = {
|
||||
occurrences: new Map(),
|
||||
reserved: new Set(),
|
||||
};
|
||||
for (const e of rows) {
|
||||
await importMoodEntry(userId, e, fallback, tally);
|
||||
if (tally.writeFailed) break;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* One JSON import (`POST /api/import`): the measurement rows, then the mood
|
||||
* rows, then the rollup and status tails, the wide event and the audit row.
|
||||
* Deduplicates via unique constraints: existing records are skipped or, with
|
||||
* an external id, updated in place.
|
||||
*/
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { unstableExternalIdMeta } from "@/lib/validations/external-id";
|
||||
import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups";
|
||||
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
|
||||
import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit";
|
||||
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
|
||||
|
||||
import {
|
||||
importJsonMeasurements,
|
||||
type JsonImportMeasurement,
|
||||
} from "./measurements";
|
||||
import { importJsonMoodEntries, type JsonImportMoodEntry } from "./mood";
|
||||
import { newJsonImportTally, type JsonImportTally } from "./tally";
|
||||
|
||||
export async function runJsonImport(input: {
|
||||
userId: string;
|
||||
measurements: JsonImportMeasurement[] | undefined;
|
||||
moodEntries: JsonImportMoodEntry[] | undefined;
|
||||
ipAddress: string | null;
|
||||
}): Promise<Pick<JsonImportTally, "stats" | "writeFailed">> {
|
||||
const { userId } = input;
|
||||
const tally = newJsonImportTally();
|
||||
|
||||
const written = input.measurements?.length
|
||||
? await importJsonMeasurements(userId, input.measurements, tally)
|
||||
: { touched: [], inserted: [] };
|
||||
|
||||
void emitInsertedMeasurementArrivals(
|
||||
userId,
|
||||
written.inserted,
|
||||
"json_import",
|
||||
).catch(() => {});
|
||||
void maybeEnqueueMorningRefresh(
|
||||
userId,
|
||||
written.inserted
|
||||
.filter((row) => row.type === "SLEEP_DURATION")
|
||||
.map((row) => row.measuredAt),
|
||||
).catch(() => {});
|
||||
|
||||
if (!tally.writeFailed && input.moodEntries?.length) {
|
||||
await importJsonMoodEntries(userId, input.moodEntries, tally);
|
||||
}
|
||||
|
||||
// One bounded re-fold per import is cheaper than firing N per-row hooks.
|
||||
// The mood rollup tier is keyed on the user + day; the fold materialises
|
||||
// every touched day from the entries just written. Best-effort: an
|
||||
// importer hiccup must not surface as a 5xx, the rollup is a cache tier.
|
||||
if (tally.stats.moodEntries > 0) {
|
||||
try {
|
||||
await recomputeUserMoodRollups(userId, { granularities: ["DAY"] });
|
||||
} catch (err) {
|
||||
annotate({
|
||||
meta: {
|
||||
mood_rollup_import_failed: true,
|
||||
mood_rollup_import_error:
|
||||
err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh the persistent measurement rollup table for every distinct
|
||||
// (type, day) the import touched (collapsed, so a 10 000-row restore pays
|
||||
// at most ~N recomputes) and re-warm the touched types' status
|
||||
// assessments. Best-effort: a populator hiccup never fails the importer.
|
||||
await afterMeasurementMutation(userId, written.touched, {
|
||||
// A file the person chose to import: a deliberate act, narrated like a
|
||||
// hand entry rather than held to the sync gap.
|
||||
source: "manual",
|
||||
label: "import",
|
||||
});
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
import_stats: tally.stats,
|
||||
...(tally.unstableShapes.length > 0
|
||||
? unstableExternalIdMeta("import.json", tally.unstableShapes)
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await auditLog("import.upload", {
|
||||
userId,
|
||||
ipAddress: input.ipAddress,
|
||||
details: tally.stats,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!tally.writeFailed) throw err;
|
||||
annotate({ meta: { import_audit_failed: true } });
|
||||
}
|
||||
|
||||
return { stats: tally.stats, writeFailed: tally.writeFailed };
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { UnstableExternalIdShape } from "@/lib/validations/external-id";
|
||||
|
||||
/** The running counts of one JSON import, shared by its row writers. */
|
||||
export interface JsonImportTally {
|
||||
stats: { measurements: number; moodEntries: number; skipped: number };
|
||||
unstableShapes: UnstableExternalIdShape[];
|
||||
/** A write failed for a reason other than a duplicate; the import stops. */
|
||||
writeFailed: boolean;
|
||||
}
|
||||
|
||||
export function newJsonImportTally(): JsonImportTally {
|
||||
return {
|
||||
stats: { measurements: 0, moodEntries: 0, skipped: 0 },
|
||||
unstableShapes: [],
|
||||
writeFailed: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* The generating POST's cache short-circuit, and the cached payload it
|
||||
* degrades to when the hour's generation quota is spent.
|
||||
*
|
||||
* A fresh cache WITHOUT a briefing must not satisfy a briefing-expecting
|
||||
* caller: after a failed nightly warm the cached payload can sit inside the
|
||||
* 24 h window all day, and an unconditional short-circuit served it to every
|
||||
* POST (three regenerate attempts, three `cached: true` responses, zero
|
||||
* generations). The short-circuit holds only when the cached payload carries
|
||||
* today's briefing OR the account has no usable provider (regenerating would
|
||||
* be futile — serve what exists). When the briefingless fall-through is
|
||||
* rate-limited, the cached payload is served after all, so a POST-as-read
|
||||
* client degrades to the old behaviour instead of a 429.
|
||||
*/
|
||||
import { isLegacyInsightPayload } from "@/lib/ai/legacy-payload";
|
||||
import {
|
||||
generatedOnLocalToday,
|
||||
insightsPayloadForToday,
|
||||
} from "@/lib/daily/briefing-today-read";
|
||||
import { readBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at";
|
||||
import { cachedPayloadCarriesBriefing } from "@/lib/insights/briefing-payload";
|
||||
import { hasUsableStatusProvider } from "@/lib/insights/status-provider";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
import type { GenerationContext } from "./context";
|
||||
|
||||
/** A fresh cached payload kept aside for the quota fallback. */
|
||||
export interface CachedFallback {
|
||||
cached: unknown;
|
||||
legacyPayload: boolean;
|
||||
cachedAt: Date;
|
||||
}
|
||||
|
||||
export type CacheDecision =
|
||||
| { kind: "serve"; data: Record<string, unknown> }
|
||||
| { kind: "generate"; fallback: CachedFallback | null };
|
||||
|
||||
export async function briefingFromCache(
|
||||
ctx: GenerationContext,
|
||||
forceRefresh: boolean,
|
||||
): Promise<CacheDecision> {
|
||||
const { dbUser, cachedText, userId, locale } = ctx;
|
||||
// A cache tagged with another language is not this reader's cache and
|
||||
// never short-circuits; the generation writes the reader's own.
|
||||
const cachedLocaleMatches =
|
||||
dbUser?.insightsCachedLocale == null ||
|
||||
dbUser.insightsCachedLocale === locale;
|
||||
if (
|
||||
!forceRefresh &&
|
||||
dbUser?.insightsCachedAt &&
|
||||
cachedText &&
|
||||
cachedLocaleMatches &&
|
||||
Date.now() - dbUser.insightsCachedAt.getTime() < 24 * 60 * 60 * 1000
|
||||
) {
|
||||
try {
|
||||
const cached = JSON.parse(cachedText);
|
||||
// Legacy payloads (pre-rationale) are flagged so the UI can show a
|
||||
// "regenerate for new explainability features" CTA. No automatic
|
||||
// regeneration: that would burn rate-limit tokens on a cache hit
|
||||
// silently. User-initiated only.
|
||||
const legacyPayload = isLegacyInsightPayload(cached);
|
||||
// A briefing generated on an earlier day is not today's read: like a
|
||||
// briefingless cache, it does not short-circuit a provider-backed
|
||||
// account, and degrades to the cached payload on a 429.
|
||||
const carriesBriefing =
|
||||
cachedPayloadCarriesBriefing(cachedText) &&
|
||||
generatedOnLocalToday(
|
||||
readBriefingGeneratedAt(cachedText),
|
||||
ctx.timezone,
|
||||
new Date(),
|
||||
);
|
||||
if (carriesBriefing || !(await hasUsableStatusProvider(userId))) {
|
||||
annotate({
|
||||
action: { name: "insights.generate" },
|
||||
meta: {
|
||||
cached: true,
|
||||
legacyPayload,
|
||||
briefingless: !carriesBriefing,
|
||||
},
|
||||
});
|
||||
return {
|
||||
kind: "serve",
|
||||
data: {
|
||||
insights: await insightsPayloadForToday(cached, {
|
||||
userId,
|
||||
timezone: ctx.timezone,
|
||||
language: locale,
|
||||
}),
|
||||
cached: true,
|
||||
cachedAt: dbUser.insightsCachedAt,
|
||||
legacyPayload,
|
||||
},
|
||||
};
|
||||
}
|
||||
annotate({
|
||||
action: { name: "insights.generate.briefingless_cache_bypassed" },
|
||||
meta: { locale },
|
||||
});
|
||||
return {
|
||||
kind: "generate",
|
||||
fallback: {
|
||||
cached,
|
||||
legacyPayload,
|
||||
cachedAt: dbUser.insightsCachedAt,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
// Invalid cache, regenerate
|
||||
}
|
||||
}
|
||||
return { kind: "generate", fallback: null };
|
||||
}
|
||||
|
||||
/** The cached payload, served when the hour's quota refused a generation. */
|
||||
export async function serveCachedFallback(
|
||||
ctx: GenerationContext,
|
||||
fallback: CachedFallback,
|
||||
): Promise<Record<string, unknown>> {
|
||||
annotate({
|
||||
action: { name: "insights.generate" },
|
||||
meta: { cached: true, rate_limited_fallback: true },
|
||||
});
|
||||
return {
|
||||
insights: await insightsPayloadForToday(fallback.cached, {
|
||||
userId: ctx.userId,
|
||||
timezone: ctx.timezone,
|
||||
language: ctx.locale,
|
||||
}),
|
||||
cached: true,
|
||||
cachedAt: fallback.cachedAt,
|
||||
legacyPayload: fallback.legacyPayload,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* The on-demand briefing's commit: the outbound safety screen, the two scope
|
||||
* guards, the cache write, and everything that follows a written briefing.
|
||||
*
|
||||
* Contract with the client (`AdvisorSettleState` in
|
||||
* `src/components/insights/use-insights-advisor.ts`, refs #786): on success
|
||||
* this writes the fresh briefing to the user cache (`insightsCachedAt`
|
||||
* advances) — even when the client has long since aborted its 45 s request —
|
||||
* and a failed inline generation leaves a failure marker the read reports as
|
||||
* `generationFailed`. The client settles a timed-out regenerate on exactly
|
||||
* those two signals, so renaming or short-circuiting either write breaks
|
||||
* that convergence.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { getSelfContextTextForUser } from "@/lib/ai/coach/about-me";
|
||||
import { screenInsightPayloadProse } from "@/lib/ai/safety/insight-payload-screen";
|
||||
import { invalidateUserInsights } from "@/lib/cache/invalidate";
|
||||
import { insightsPayloadForToday } from "@/lib/daily/briefing-today-read";
|
||||
import { recordBriefingFailure } from "@/lib/insights/briefing-failure-marker";
|
||||
import { withBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at";
|
||||
import { applyBriefingShortForm } from "@/lib/insights/briefing-short-form";
|
||||
import { insightsCacheWrite } from "@/lib/insights/cached-text-cipher";
|
||||
import { enqueueStatusRefillForUser } from "@/lib/insights/comprehensive-generate";
|
||||
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
|
||||
import { normalizeLocale } from "@/lib/insights/status-shared";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
import type { BriefingCall } from "./completion";
|
||||
import type { BriefingRefusal } from "./context";
|
||||
import type { PreparedFeatures } from "./features";
|
||||
import type { GroundedReply } from "./grounding";
|
||||
|
||||
export type CommitOutcome =
|
||||
| { ok: true; data: Record<string, unknown> }
|
||||
| { ok: false; refusal: BriefingRefusal };
|
||||
|
||||
/** Write the briefing, guarded on the scope the prompt was built from. */
|
||||
async function writeBriefing(
|
||||
call: BriefingCall,
|
||||
prepared: PreparedFeatures,
|
||||
stored: object,
|
||||
): Promise<boolean> {
|
||||
const { ctx, prompt } = call;
|
||||
const { scope, locale } = ctx;
|
||||
const committed = await prisma.user.updateMany({
|
||||
where: {
|
||||
id: ctx.userId,
|
||||
insightsPrivacyMode: scope.mode,
|
||||
insightsExcludeMetrics: { equals: scope.excludeMetrics },
|
||||
locale: scope.locale,
|
||||
dateOfBirth: scope.dateOfBirth,
|
||||
gender: scope.gender,
|
||||
heightCm: scope.heightCm,
|
||||
displayName: scope.displayName,
|
||||
},
|
||||
data: {
|
||||
insightsCachedAt: new Date(),
|
||||
...insightsCacheWrite(JSON.stringify(stored)),
|
||||
insightsCachedLocale: locale,
|
||||
insightsSnapshotHash: hashInsightSnapshot({
|
||||
features: prepared.compactFeatures,
|
||||
aboutMe: prompt.aboutMe ?? null,
|
||||
comparisonBaseline: prompt.comparisonSnapshot?.baseline ?? "none",
|
||||
generationLocale: locale,
|
||||
...(prompt.briefingForm === "short"
|
||||
? { briefingForm: prompt.briefingForm }
|
||||
: {}),
|
||||
}),
|
||||
},
|
||||
});
|
||||
return committed.count > 0;
|
||||
}
|
||||
|
||||
/** What follows a written briefing: the marker, the cards, the audit row. */
|
||||
async function afterWrite(
|
||||
call: BriefingCall,
|
||||
reply: GroundedReply,
|
||||
ipAddress: string | null,
|
||||
fallbackHopCount: number,
|
||||
): Promise<void> {
|
||||
const { ctx } = call;
|
||||
const { userId, locale } = ctx;
|
||||
const { result, workingProviderType } = reply;
|
||||
// The grounding omission persists (marker AFTER the cache write so it is
|
||||
// newer than `insightsCachedAt`). The transient `briefingOmittedReason` on
|
||||
// the response only reached the caller that forced the regenerate; every
|
||||
// later read showed a generic "no briefing yet". The dated marker lets the
|
||||
// read surface the same honest "briefing withheld" state until the next
|
||||
// successful generation.
|
||||
if (reply.briefingOmittedReason === "ungrounded") {
|
||||
void recordBriefingFailure({
|
||||
userId,
|
||||
reason: "briefing-ungrounded",
|
||||
locale,
|
||||
});
|
||||
}
|
||||
|
||||
// No blanket per-status eviction (nuking ~45 cache rows per manual
|
||||
// regenerate deleted every hash baseline and caused a card-regeneration
|
||||
// storm). The manual regenerate takes the cards along through the
|
||||
// hash-gated queue: every scope the user has data for is enqueued, the
|
||||
// worker forces each generator past its same-day cache read, and the
|
||||
// content-hash gate regenerates exactly the cards whose data changed while
|
||||
// refreshing the unchanged ones for free. This is the user's escape hatch
|
||||
// for a card they can SEE is stale — without it the cards had no refresh
|
||||
// path until the next nightly warm.
|
||||
const refillScopes = await enqueueStatusRefillForUser(
|
||||
userId,
|
||||
normalizeLocale(locale),
|
||||
);
|
||||
annotate({
|
||||
action: { name: "insights.generate.cards_refill" },
|
||||
meta: { scopes: refillScopes },
|
||||
});
|
||||
|
||||
// The dashboard snapshot embeds the pre-generated daily briefing
|
||||
// read-only; drop it so the next snapshot carries the fresh briefing
|
||||
// instead of the stale one.
|
||||
invalidateUserInsights(userId);
|
||||
|
||||
await auditLog("insights.generate", {
|
||||
userId,
|
||||
ipAddress,
|
||||
details: {
|
||||
privacyMode: ctx.dbUser?.insightsPrivacyMode,
|
||||
tokensUsed: result.tokensUsed,
|
||||
providerType: result.providerType,
|
||||
chainProviderType: workingProviderType,
|
||||
fallbackHopCount,
|
||||
model: result.model,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.generate" },
|
||||
meta: {
|
||||
cached: false,
|
||||
providerType: result.providerType,
|
||||
chainProviderType: workingProviderType,
|
||||
fallbackHopCount,
|
||||
model: result.model,
|
||||
tokensUsed: result.tokensUsed,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function commitBriefing(
|
||||
call: BriefingCall,
|
||||
prepared: PreparedFeatures,
|
||||
reply: GroundedReply,
|
||||
options: { ipAddress: string | null; fallbackHopCount: number },
|
||||
): Promise<CommitOutcome> {
|
||||
const { ctx, prompt } = call;
|
||||
const { userId, locale } = ctx;
|
||||
let { insights } = reply;
|
||||
|
||||
// Outbound safety screen over the whole prose surface, before the
|
||||
// persist. This path is user-initiated but writes the SAME cached payload
|
||||
// the background generator writes, so a violation must not be persisted
|
||||
// here either. The user is waiting, so unlike the background path they get
|
||||
// an explicit error rather than silence — and because nothing was written,
|
||||
// the previous cached insight is still what the app renders.
|
||||
const screened = screenInsightPayloadProse(insights, locale);
|
||||
if (screened) {
|
||||
annotate({
|
||||
action: { name: "insights.generate.outbound_blocked" },
|
||||
meta: { locale, reason: screened },
|
||||
});
|
||||
return { ok: false, refusal: "outbound-screened" };
|
||||
}
|
||||
|
||||
const currentSelfContext = await getSelfContextTextForUser(userId, locale);
|
||||
if (currentSelfContext !== prompt.aboutMe) {
|
||||
return { ok: false, refusal: "profile-scope-changed" };
|
||||
}
|
||||
if (prompt.briefingForm === "short") {
|
||||
insights = applyBriefingShortForm(insights);
|
||||
}
|
||||
const stored = withBriefingGeneratedAt(insights, new Date());
|
||||
if (!(await writeBriefing(call, prepared, stored))) {
|
||||
return { ok: false, refusal: "scope-changed" };
|
||||
}
|
||||
|
||||
await afterWrite(call, reply, options.ipAddress, options.fallbackHopCount);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
insights: await insightsPayloadForToday(stored, {
|
||||
userId,
|
||||
timezone: ctx.timezone,
|
||||
language: locale,
|
||||
}),
|
||||
cached: false,
|
||||
legacyPayload: false,
|
||||
// Additive: non-null when the grounding gate stripped the briefing
|
||||
// from THIS generation, so the card can explain the hole (#470).
|
||||
briefingOmittedReason: reply.briefingOmittedReason,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* The on-demand briefing's provider calls, and what a failed call means for
|
||||
* the person waiting on it.
|
||||
*/
|
||||
import { resolveInsightsMaxTokens } from "@/lib/ai/ai-budgets";
|
||||
import { providerFailureKind } from "@/lib/ai/failure-kind";
|
||||
import { buildSystemPromptWithReferences } from "@/lib/ai/prompts/insight-generator";
|
||||
import {
|
||||
AllProvidersFailedError,
|
||||
type ProviderChainResolved,
|
||||
type RunRawWithFallbackResult,
|
||||
} from "@/lib/ai/provider-runner";
|
||||
import {
|
||||
BriefingBudgetExceededError,
|
||||
runBriefingCompletion,
|
||||
} from "@/lib/insights/briefing-provider";
|
||||
import { recordBriefingFailure } from "@/lib/insights/briefing-failure-marker";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
import type { BriefingRefusal, GenerationContext } from "./context";
|
||||
import type { BriefingPrompt } from "./prompt";
|
||||
|
||||
export interface BriefingCall {
|
||||
ctx: GenerationContext;
|
||||
chain: ProviderChainResolved[];
|
||||
prompt: BriefingPrompt;
|
||||
}
|
||||
|
||||
/** One briefing completion with the route's fixed parameters. */
|
||||
export function runBriefingCall(
|
||||
call: BriefingCall,
|
||||
options: {
|
||||
userPrompt: string;
|
||||
stage: "generate" | "grounding-retry";
|
||||
reasoning: boolean;
|
||||
},
|
||||
): Promise<RunRawWithFallbackResult> {
|
||||
const { ctx, chain, prompt } = call;
|
||||
return runBriefingCompletion({
|
||||
userId: ctx.userId,
|
||||
chain,
|
||||
// The strict prompt carries GROUND RULE 8 — emit a top-level
|
||||
// `dailyBriefing` block when the snapshot has analysable signal — plus
|
||||
// the trendAnnotations and storyboardAnnotations rules. The legacy
|
||||
// `getInsightsSystemPrompt` returned the old `{changed, stable, drivers,
|
||||
// …}` shape and never asked the model for dailyBriefing, so the hero
|
||||
// strip's "Re-run analysis" button kept producing a payload with no
|
||||
// briefing block. The reply tolerates the strict shape via
|
||||
// `insightResultSchema.safeParse`'s soft fallback to the parsed object.
|
||||
systemPrompt: buildSystemPromptWithReferences(
|
||||
ctx.locale,
|
||||
prompt.referenceMetrics,
|
||||
),
|
||||
userPrompt: options.userPrompt,
|
||||
temperature: 0.3,
|
||||
// Env-tunable output ceiling (INSIGHTS_MAX_TOKENS, default 2500, #470).
|
||||
// A fixed 1500 truncated verbose models' briefing JSON mid-string →
|
||||
// generic invalid-JSON 422.
|
||||
maxTokens: resolveInsightsMaxTokens(),
|
||||
// A wide upstream budget, honouring the per-user response-timeout
|
||||
// setting, so the reasoning-heavy briefing generation is not aborted at
|
||||
// the client's 60 s default on large accounts.
|
||||
timeoutMs: ctx.effectiveTimeoutMs,
|
||||
stage: options.stage,
|
||||
// A briefing asked for by hand reasons exactly like the scheduled one;
|
||||
// the retries repair a reply and do not.
|
||||
...(options.reasoning ? { reasoningJob: "daily_briefing" as const } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export type FirstCompletion =
|
||||
| {
|
||||
ok: true;
|
||||
result: RunRawWithFallbackResult["result"];
|
||||
workingProviderType: string;
|
||||
fallbackHopCount: number;
|
||||
}
|
||||
| { ok: false; refusal: BriefingRefusal };
|
||||
|
||||
/** The refusal for a chain on which every provider failed. */
|
||||
function chainRefusal(e: AllProvidersFailedError): BriefingRefusal {
|
||||
annotate({
|
||||
meta: {
|
||||
insights_chain_outcome: "all-failed",
|
||||
insights_chain_attempts: e.attempts.length,
|
||||
insights_chain_first_provider: e.attempts[0]?.providerType ?? null,
|
||||
insights_chain_first_status: e.attempts[0]?.httpStatus ?? null,
|
||||
},
|
||||
});
|
||||
// The most specific user-facing message available. Auth-class failures
|
||||
// across the entire chain mean every provider's credential is bad — the
|
||||
// "check your API key" wording tells the user where to act. 429 across the
|
||||
// chain is rate-limit; 5xx is upstream brown-out; transport-only
|
||||
// (ECONNRESET, no httpStatus) maps to the generic connection hint so users
|
||||
// on flaky networks don't see "rate-limited".
|
||||
if (e.attempts.every((a) => a.httpStatus === 401 || a.httpStatus === 403)) {
|
||||
return "provider-rejected";
|
||||
}
|
||||
if (e.attempts.every((a) => a.httpStatus === 429)) {
|
||||
return "provider-rate-limited";
|
||||
}
|
||||
if (e.attempts.some((a) => a.httpStatus !== null && a.httpStatus >= 500)) {
|
||||
return "provider-unavailable";
|
||||
}
|
||||
if (e.attempts.every((a) => a.httpStatus === null || a.httpStatus <= 0)) {
|
||||
return "provider-connection";
|
||||
}
|
||||
return "provider-unavailable";
|
||||
}
|
||||
|
||||
/**
|
||||
* The refusal for a single provider error that did not cascade (e.g. a
|
||||
* custom 4xx from a self-hosted local model). Cloudflare rewrites 5xx to its
|
||||
* own HTML error page, which breaks `await res.json()` on the dashboard, so
|
||||
* the statuses map onto the ai/test categorisation.
|
||||
*/
|
||||
function providerRefusal(e: unknown): BriefingRefusal {
|
||||
const err = e as Error & { httpStatus?: number; bodyExcerpt?: string };
|
||||
annotate({
|
||||
meta: {
|
||||
insights_provider_error: err.message.slice(0, 500),
|
||||
insights_provider_status: err.httpStatus ?? null,
|
||||
insights_provider_body_excerpt: err.bodyExcerpt?.slice(0, 500) ?? null,
|
||||
},
|
||||
});
|
||||
const status = err.httpStatus ?? 0;
|
||||
if (status === 401 || status === 403) return "provider-rejected";
|
||||
if (status === 429) return "provider-rate-limited";
|
||||
if (status >= 500) return "provider-unavailable";
|
||||
return "provider-connection";
|
||||
}
|
||||
|
||||
export async function runFirstCompletion(
|
||||
call: BriefingCall,
|
||||
): Promise<FirstCompletion> {
|
||||
try {
|
||||
const fallback = await runBriefingCall(call, {
|
||||
userPrompt: call.prompt.userPrompt,
|
||||
stage: "generate",
|
||||
reasoning: true,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
result: fallback.result,
|
||||
workingProviderType: fallback.workingProvider.providerType,
|
||||
fallbackHopCount: fallback.fallbackHops.length,
|
||||
};
|
||||
} catch (e) {
|
||||
// The day's token ceiling refused the reservation — no provider was
|
||||
// contacted, so this is NOT a provider failure and must not record a
|
||||
// briefing-failure marker (which would make the read path claim the
|
||||
// refresh broke). The hourly rate limit bounds request frequency; this
|
||||
// bounds cost.
|
||||
if (e instanceof BriefingBudgetExceededError) {
|
||||
return { ok: false, refusal: "budget-exceeded" };
|
||||
}
|
||||
// A dated failure marker (no cache row is written on this path, so the
|
||||
// last good briefing stays intact). The read path pairs the preserved
|
||||
// text with a discreet "couldn't refresh" hint, or shows a retry empty
|
||||
// state when there is no last good text. The upstream status rides
|
||||
// along so the hint can tell an auth / rate-limit failure apart from a
|
||||
// plain timeout: for an all-providers-failed error the first hop's
|
||||
// status is the most representative; a single provider error exposes its
|
||||
// own `httpStatus`.
|
||||
const failureHttpStatus =
|
||||
e instanceof AllProvidersFailedError
|
||||
? (e.attempts[0]?.httpStatus ?? null)
|
||||
: typeof (e as { httpStatus?: number }).httpStatus === "number"
|
||||
? (e as { httpStatus?: number }).httpStatus
|
||||
: null;
|
||||
void recordBriefingFailure({
|
||||
userId: call.ctx.userId,
|
||||
reason:
|
||||
e instanceof AllProvidersFailedError
|
||||
? "all-providers-failed"
|
||||
: "provider-error",
|
||||
locale: call.ctx.locale,
|
||||
httpStatus: failureHttpStatus,
|
||||
failureKind:
|
||||
e instanceof AllProvidersFailedError
|
||||
? (e.attempts[0]?.failureKind ?? null)
|
||||
: providerFailureKind(e).failure,
|
||||
});
|
||||
return {
|
||||
ok: false,
|
||||
refusal:
|
||||
e instanceof AllProvidersFailedError
|
||||
? chainRefusal(e)
|
||||
: providerRefusal(e),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* What an on-demand briefing generation reads before it decides anything:
|
||||
* the account row, the cached payload, the scope columns the commit guard
|
||||
* matches on, the response timeout, the reader's language and zone.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { resolveEffectiveTimeoutMs } from "@/lib/ai/effective-timeout";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import {
|
||||
INSIGHTS_CACHE_SELECT,
|
||||
openInsightsCachedText,
|
||||
} from "@/lib/insights/cached-text-cipher";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
export const GENERATION_USER_SELECT = {
|
||||
insightsPrivacyMode: true,
|
||||
insightsCachedAt: true,
|
||||
...INSIGHTS_CACHE_SELECT,
|
||||
insightsCachedLocale: true,
|
||||
timezone: true,
|
||||
// The per-user opt-out list mirroring the Coach settings. Filtered off
|
||||
// `features` before serialisation so the model never sees the excluded
|
||||
// blocks.
|
||||
insightsExcludeMetrics: true,
|
||||
locale: true,
|
||||
// The profile for the derived-signal briefing detector.
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
heightCm: true,
|
||||
// The first name for the sparse, hash-gated briefing opener.
|
||||
displayName: true,
|
||||
// The per-user response timeout (seconds), threaded onto the generation
|
||||
// calls so a slow self-hosted backend honours the setting.
|
||||
aiResponseTimeoutSeconds: true,
|
||||
// The reader's units; the feature set is converted into them before it is
|
||||
// serialised, hashed and checked for grounding.
|
||||
unitPreference: true,
|
||||
glucoseUnit: true,
|
||||
} as const;
|
||||
|
||||
export type GenerationUserRow = Prisma.UserGetPayload<{
|
||||
select: typeof GENERATION_USER_SELECT;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* The columns the commit guard matches on, captured at read time.
|
||||
*
|
||||
* Every User write bumps `updatedAt`, including `resolveProviderChain()`'s
|
||||
* OAuth token-refresh update later in the same request, so keying the
|
||||
* optimistic-concurrency check on it produced a false 409 whenever a
|
||||
* provider credential happened to refresh mid-generation. These are exactly
|
||||
* the selected columns that feed the prompt; requiring an exact match at
|
||||
* commit still catches a genuine privacy-mode / exclude-list / locale /
|
||||
* profile change without colliding on unrelated column writes.
|
||||
*/
|
||||
export interface GenerationScope {
|
||||
mode: GenerationUserRow["insightsPrivacyMode"];
|
||||
excludeMetrics: GenerationUserRow["insightsExcludeMetrics"];
|
||||
locale: GenerationUserRow["locale"];
|
||||
dateOfBirth: GenerationUserRow["dateOfBirth"];
|
||||
gender: GenerationUserRow["gender"];
|
||||
heightCm: GenerationUserRow["heightCm"];
|
||||
displayName: GenerationUserRow["displayName"];
|
||||
}
|
||||
|
||||
export interface GenerationContext {
|
||||
userId: string;
|
||||
dbUser: GenerationUserRow | null;
|
||||
cachedText: string | null;
|
||||
scope: GenerationScope;
|
||||
effectiveTimeoutMs: number;
|
||||
locale: Locale;
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a generation ended without a briefing. The route owns the status and
|
||||
* the wording for each.
|
||||
*/
|
||||
export type BriefingRefusal =
|
||||
| "payload-too-large"
|
||||
| "budget-exceeded"
|
||||
| "provider-rejected"
|
||||
| "provider-rate-limited"
|
||||
| "provider-unavailable"
|
||||
| "provider-connection"
|
||||
| "response-truncated"
|
||||
| "response-invalid-json"
|
||||
| "outbound-screened"
|
||||
| "profile-scope-changed"
|
||||
| "scope-changed";
|
||||
|
||||
export async function loadGenerationContext(args: {
|
||||
request: NextRequest;
|
||||
userId: string;
|
||||
userLocale: string | null;
|
||||
}): Promise<GenerationContext> {
|
||||
const { request, userId } = args;
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: GENERATION_USER_SELECT,
|
||||
});
|
||||
const cachedText = openInsightsCachedText(dbUser);
|
||||
const scope: GenerationScope = {
|
||||
mode: dbUser?.insightsPrivacyMode ?? "aggregated",
|
||||
excludeMetrics: dbUser?.insightsExcludeMetrics ?? [],
|
||||
locale: dbUser?.locale ?? null,
|
||||
dateOfBirth: dbUser?.dateOfBirth ?? null,
|
||||
gender: dbUser?.gender ?? null,
|
||||
heightCm: dbUser?.heightCm ?? null,
|
||||
displayName: dbUser?.displayName ?? null,
|
||||
};
|
||||
|
||||
const effectiveTimeoutMs = resolveEffectiveTimeoutMs(
|
||||
dbUser?.aiResponseTimeoutSeconds,
|
||||
AI_BUDGETS.comprehensive.timeoutMs,
|
||||
);
|
||||
|
||||
const locale = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: dbUser?.locale ?? args.userLocale ?? null,
|
||||
});
|
||||
return {
|
||||
userId,
|
||||
dbUser,
|
||||
cachedText,
|
||||
scope,
|
||||
effectiveTimeoutMs,
|
||||
locale,
|
||||
timezone: dbUser?.timezone ?? DEFAULT_TIMEZONE,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* The feature set an on-demand briefing is written from: the bounded read,
|
||||
* its downgrade ladder for an oversized payload, the user's exclusions, the
|
||||
* reader's units, and the compact wire form.
|
||||
*/
|
||||
import { compactSections } from "@/lib/ai/prompts/compact-sections";
|
||||
import { applyInsightsExcludeFilter } from "@/lib/insights/exclude-filter";
|
||||
import { getCachedFeatures } from "@/lib/insights/feature-cache";
|
||||
import {
|
||||
BRIEFING_FEATURE_WINDOW_DAYS,
|
||||
extractFeatures,
|
||||
FeaturesPayloadTooLargeError,
|
||||
} from "@/lib/insights/features";
|
||||
import { featuresInReaderUnits } from "@/lib/insights/features-units";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveUnitPreferences } from "@/lib/measurements/display-transform";
|
||||
|
||||
import type { GenerationContext } from "./context";
|
||||
|
||||
export type BriefingFeatures = Awaited<ReturnType<typeof extractFeatures>>;
|
||||
|
||||
/**
|
||||
* The blocks dropped when even the aggregated shape crosses the ceiling
|
||||
* (very large medication history + multi-year context), before one last try.
|
||||
*/
|
||||
const MAX_DOWNGRADE_TOKENS: ReadonlyArray<string> = [
|
||||
"anthropometrics",
|
||||
"medications",
|
||||
"compliance",
|
||||
"sleep",
|
||||
"steps",
|
||||
"hrv",
|
||||
"resting_hr",
|
||||
];
|
||||
|
||||
interface Downgrade {
|
||||
payloadDowngraded: boolean;
|
||||
payloadHardDowngraded: boolean;
|
||||
payloadOversizeBytes: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The bounded feature read, computed once per request through the scope the
|
||||
* route opened. `extractFeatures` enforces a 5 MB ceiling on the serialised
|
||||
* payload and throws `FeaturesPayloadTooLargeError` past it; the raw
|
||||
* (bucketed) shape downgrades to the aggregated one rather than 500-ing, so
|
||||
* the user still gets an insight, and the annotation surfaces the
|
||||
* regression to ops. Null when every rung is still too large.
|
||||
*/
|
||||
async function readWithDowngrade(
|
||||
userId: string,
|
||||
includeRaw: boolean,
|
||||
downgrade: Downgrade,
|
||||
): Promise<BriefingFeatures | null> {
|
||||
// The bulk read is bounded to a recent window (all-time extremes are
|
||||
// sourced separately, see features.ts); the ladder is the safety net for
|
||||
// the rare oversize payload.
|
||||
const featureWindow = { sinceDays: BRIEFING_FEATURE_WINDOW_DAYS };
|
||||
const read = (raw: boolean) =>
|
||||
getCachedFeatures({
|
||||
userId,
|
||||
includeRaw: raw,
|
||||
sinceDays: featureWindow.sinceDays,
|
||||
compute: () => extractFeatures(userId, raw, featureWindow),
|
||||
});
|
||||
try {
|
||||
return await read(includeRaw);
|
||||
} catch (err) {
|
||||
if (!(err instanceof FeaturesPayloadTooLargeError)) throw err;
|
||||
downgrade.payloadDowngraded = true;
|
||||
downgrade.payloadOversizeBytes = err.sizeBytes;
|
||||
}
|
||||
try {
|
||||
return await read(false);
|
||||
} catch (retryErr) {
|
||||
if (!(retryErr instanceof FeaturesPayloadTooLargeError)) throw retryErr;
|
||||
// The aggregated shape ALSO crossed the ceiling. Drop the optional
|
||||
// context blocks via the exclude filter and try once more.
|
||||
downgrade.payloadHardDowngraded = true;
|
||||
downgrade.payloadOversizeBytes = retryErr.sizeBytes;
|
||||
}
|
||||
try {
|
||||
return applyInsightsExcludeFilter(await read(false), MAX_DOWNGRADE_TOKENS);
|
||||
} catch (finalErr) {
|
||||
// Even the last read blew up. Annotate and refuse rather than let the
|
||||
// uncaught throw 500 out.
|
||||
annotate({
|
||||
meta: {
|
||||
insights_payload_too_large: true,
|
||||
insights_payload_oversize_bytes:
|
||||
finalErr instanceof FeaturesPayloadTooLargeError
|
||||
? finalErr.sizeBytes
|
||||
: downgrade.payloadOversizeBytes,
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface PreparedFeatures {
|
||||
features: BriefingFeatures;
|
||||
compactFeatures: Record<string, unknown>;
|
||||
featuresJson: string;
|
||||
units: ReturnType<typeof resolveUnitPreferences>;
|
||||
}
|
||||
|
||||
export async function prepareBriefingFeatures(
|
||||
ctx: GenerationContext,
|
||||
): Promise<PreparedFeatures | null> {
|
||||
const { dbUser, userId } = ctx;
|
||||
const includeRaw = dbUser?.insightsPrivacyMode === "raw";
|
||||
const downgrade: Downgrade = {
|
||||
payloadDowngraded: false,
|
||||
payloadHardDowngraded: false,
|
||||
payloadOversizeBytes: null,
|
||||
};
|
||||
let features = await readWithDowngrade(userId, includeRaw, downgrade);
|
||||
if (features === null) return null;
|
||||
// The user's exclude-metrics opt-out list, applied BEFORE serialisation so
|
||||
// the model never sees the dropped blocks.
|
||||
const excludeList = dbUser?.insightsExcludeMetrics ?? [];
|
||||
features = applyInsightsExcludeFilter(features, excludeList);
|
||||
// The reader's units, after all canonical arithmetic: the prompt, the
|
||||
// stored hash and the grounding check read the same converted figures.
|
||||
const units = resolveUnitPreferences({
|
||||
unitPreference: dbUser?.unitPreference,
|
||||
glucoseUnit: dbUser?.glucoseUnit,
|
||||
});
|
||||
features = featuresInReaderUnits(features, units);
|
||||
// Zero-row blocks are dropped so the prompt never carries labelled-empty
|
||||
// sections (`"sleep": []`, `"medications": []`, etc.), which made the
|
||||
// model narrate "there are no medications in your data" when the user
|
||||
// explicitly excluded the block.
|
||||
const compactFeatures = compactSections(
|
||||
features as unknown as Record<string, unknown>,
|
||||
);
|
||||
// Compact on the wire: indentation is about 40% of a pretty-printed
|
||||
// payload, and the model reads the structure without it (#1217).
|
||||
const featuresJson = JSON.stringify(compactFeatures);
|
||||
if (downgrade.payloadDowngraded) {
|
||||
annotate({
|
||||
meta: {
|
||||
insights_features_downgraded: true,
|
||||
insights_features_oversize_bytes: downgrade.payloadOversizeBytes,
|
||||
insights_features_hard_downgraded: downgrade.payloadHardDowngraded,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { features, compactFeatures, featuresJson, units };
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* An on-demand briefing generation, from the resolved provider chain to the
|
||||
* committed payload. The route owns authentication, the capability gate,
|
||||
* the body, the hourly quota and the status of every refusal.
|
||||
*/
|
||||
import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
|
||||
import { assertConsentForChain } from "@/lib/ai/consent-guard";
|
||||
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
|
||||
import { recordBriefingFailure } from "@/lib/insights/briefing-failure-marker";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
import { commitBriefing } from "./commit";
|
||||
import { runFirstCompletion, type BriefingCall } from "./completion";
|
||||
import type { BriefingRefusal, GenerationContext } from "./context";
|
||||
import { prepareBriefingFeatures } from "./features";
|
||||
import { groundBriefing, parseBriefingReply } from "./grounding";
|
||||
import { buildBriefingPrompt } from "./prompt";
|
||||
|
||||
/**
|
||||
* The user's full provider chain, so a hard failure on the primary (401,
|
||||
* 5xx, network) cascades to the next configured fallback rather than
|
||||
* surfacing to the dashboard. An empty chain falls back to the
|
||||
* single-provider resolution when the legacy `aiProvider` field still points
|
||||
* at something usable, so accounts that never customised the chain keep
|
||||
* working. Null when there is no provider at all.
|
||||
*
|
||||
* The consent gate runs on the resolved chain before anything leaves for a
|
||||
* model. Required for the operator's global key (`admin-openai`); BYOK /
|
||||
* local / ChatGPT-OAuth chains stay ungated. It throws ConsentRequiredError,
|
||||
* which `apiHandler` answers with 403 + `consent.ai.required`.
|
||||
*/
|
||||
export async function resolveConsentedBriefingChain(
|
||||
userId: string,
|
||||
): Promise<ProviderChainResolved[] | null> {
|
||||
const chain = await resolveProviderChain(userId);
|
||||
if (chain.length === 0) {
|
||||
const legacy = await resolveProvider(userId);
|
||||
if (legacy.type === "none") return null;
|
||||
chain.push({ providerType: "admin-openai", instance: legacy });
|
||||
}
|
||||
await assertConsentForChain({ userId, chain, surface: "insights" });
|
||||
return chain;
|
||||
}
|
||||
|
||||
export type GenerateOutcome =
|
||||
| { ok: true; data: Record<string, unknown> }
|
||||
| { ok: false; refusal: BriefingRefusal };
|
||||
|
||||
export async function generateBriefing(args: {
|
||||
ctx: GenerationContext;
|
||||
chain: ProviderChainResolved[];
|
||||
ipAddress: string | null;
|
||||
}): Promise<GenerateOutcome> {
|
||||
const { ctx, chain } = args;
|
||||
const prepared = await prepareBriefingFeatures(ctx);
|
||||
if (prepared === null) return { ok: false, refusal: "payload-too-large" };
|
||||
|
||||
const prompt = await buildBriefingPrompt(ctx, prepared);
|
||||
const call: BriefingCall = { ctx, chain, prompt };
|
||||
|
||||
const first = await runFirstCompletion(call);
|
||||
if (!first.ok) return first;
|
||||
|
||||
let insights;
|
||||
try {
|
||||
insights = parseBriefingReply(first.result.content);
|
||||
} catch {
|
||||
// A 502 here triggers Cloudflare's HTML error rewrite, which breaks
|
||||
// `await res.json()` on the client; the refusal stays a JSON 422 so the
|
||||
// React Query mutation can read the body and surface a readable message.
|
||||
void recordBriefingFailure({
|
||||
userId: ctx.userId,
|
||||
reason: "invalid-json",
|
||||
locale: ctx.locale,
|
||||
});
|
||||
// Truncation-aware (#470): `finishReason === "length"` means the model
|
||||
// hit the output-token ceiling and the JSON was cut off mid-string, which
|
||||
// is a budget problem, not a model-quality one.
|
||||
if (first.result.finishReason === "length") {
|
||||
annotate({ meta: { insights_response_truncated: true } });
|
||||
return { ok: false, refusal: "response-truncated" };
|
||||
}
|
||||
return { ok: false, refusal: "response-invalid-json" };
|
||||
}
|
||||
|
||||
const reply = await groundBriefing(call, prepared, {
|
||||
insights,
|
||||
result: first.result,
|
||||
workingProviderType: first.workingProviderType,
|
||||
});
|
||||
|
||||
return commitBriefing(call, prepared, reply, {
|
||||
ipAddress: args.ipAddress,
|
||||
fallbackHopCount: first.fallbackHopCount,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* The reply's parse and the daily-briefing number-grounding gate.
|
||||
*
|
||||
* The schema only checks the briefing's SHAPE; the gate asserts every
|
||||
* number the briefing prose restates traces to a `features.signalsOfDay`
|
||||
* figure, mirroring the recommendations citation cross-check. On a miss: one
|
||||
* corrective retry, then the briefing block is dropped rather than shipping
|
||||
* a fabricated number.
|
||||
*/
|
||||
import {
|
||||
buildBriefingGroundingCorrection,
|
||||
findUngroundedBriefingNumbers,
|
||||
readBriefingBlock,
|
||||
} from "@/lib/ai/briefing-grounding";
|
||||
import type { RunRawWithFallbackResult } from "@/lib/ai/provider-runner";
|
||||
import { insightResultSchema, type InsightResult } from "@/lib/ai/types";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
import { runBriefingCall, type BriefingCall } from "./completion";
|
||||
import type { PreparedFeatures } from "./features";
|
||||
|
||||
export type BriefingInsights = InsightResult | Record<string, unknown>;
|
||||
|
||||
/** The parsed reply: the strict shape when it validates, the raw object if not. */
|
||||
export function parseBriefingReply(content: string): BriefingInsights {
|
||||
const parsed = JSON.parse(content);
|
||||
// Try the strict schema first, fall back to the raw parsed object.
|
||||
const validated = insightResultSchema.safeParse(parsed);
|
||||
return validated.success ? validated.data : parsed;
|
||||
}
|
||||
|
||||
export interface GroundedReply {
|
||||
insights: BriefingInsights;
|
||||
result: RunRawWithFallbackResult["result"];
|
||||
workingProviderType: string;
|
||||
/**
|
||||
* Non-null when the gate stripped the briefing from THIS generation and no
|
||||
* previous one could stand in, so the card can explain the hole instead
|
||||
* of reading "no briefing yet" (#470).
|
||||
*/
|
||||
briefingOmittedReason: "ungrounded" | null;
|
||||
}
|
||||
|
||||
/** The previous cached briefing, when the payload parses and carries one. */
|
||||
function previousBriefing(cachedText: string | null): unknown {
|
||||
if (!cachedText) return null;
|
||||
try {
|
||||
const prev = JSON.parse(cachedText) as Record<string, unknown>;
|
||||
return prev.dailyBriefing ?? null;
|
||||
} catch {
|
||||
// Unparseable previous payload — nothing safe to stand in.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function groundBriefing(
|
||||
call: BriefingCall,
|
||||
prepared: PreparedFeatures,
|
||||
reply: Omit<GroundedReply, "briefingOmittedReason">,
|
||||
): Promise<GroundedReply> {
|
||||
const { features } = prepared;
|
||||
const { comparisonSnapshot } = call.prompt;
|
||||
let { insights, result, workingProviderType } = reply;
|
||||
let briefingOmittedReason: "ungrounded" | null = null;
|
||||
const signals = features.signalsOfDay ?? null;
|
||||
let ungrounded = findUngroundedBriefingNumbers(
|
||||
readBriefingBlock(insights),
|
||||
signals,
|
||||
features,
|
||||
comparisonSnapshot,
|
||||
);
|
||||
if (ungrounded.length > 0) {
|
||||
annotate({
|
||||
action: { name: "insights.generate.briefing_grounding_retry" },
|
||||
meta: { ungroundedCount: ungrounded.length },
|
||||
});
|
||||
try {
|
||||
const retry = await runBriefingCall(call, {
|
||||
userPrompt: `${call.prompt.userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`,
|
||||
stage: "grounding-retry",
|
||||
reasoning: false,
|
||||
});
|
||||
const retryInsights = parseBriefingReply(retry.result.content);
|
||||
ungrounded = findUngroundedBriefingNumbers(
|
||||
readBriefingBlock(retryInsights),
|
||||
signals,
|
||||
features,
|
||||
comparisonSnapshot,
|
||||
);
|
||||
if (ungrounded.length === 0) {
|
||||
insights = retryInsights;
|
||||
result = retry.result;
|
||||
workingProviderType = retry.workingProvider.providerType;
|
||||
}
|
||||
} catch {
|
||||
// Retry failed to produce parseable JSON, or the day's ceiling refused
|
||||
// the correction pass — either way fall through to the strip. An
|
||||
// ungrounded figure is never persisted.
|
||||
}
|
||||
}
|
||||
// Still ungrounded after the retry: the freshly generated briefing is
|
||||
// discarded rather than shipped — the anti-fabrication guarantee is
|
||||
// absolute here too. But rather than leave a hole, the PREVIOUS cached
|
||||
// briefing stands in its place: it passed this identical gate when it was
|
||||
// written, so it carries no fabricated figure, and a day-old grounded
|
||||
// briefing beats a vanished one. This mirrors the background generator,
|
||||
// which is the path that writes the same cached payload; the two must not
|
||||
// disagree on disposal.
|
||||
//
|
||||
// `briefingOmittedReason` is set ONLY when no previous briefing exists to
|
||||
// stand in, so the card's honest "a figure couldn't be verified" state
|
||||
// still fires exactly when the user really has no briefing.
|
||||
if (ungrounded.length > 0 && insights && typeof insights === "object") {
|
||||
const fallbackBriefing = previousBriefing(call.ctx.cachedText);
|
||||
(insights as Record<string, unknown>).dailyBriefing = fallbackBriefing;
|
||||
briefingOmittedReason = fallbackBriefing == null ? "ungrounded" : null;
|
||||
annotate({
|
||||
action: { name: "insights.generate.briefing_grounding_stripped" },
|
||||
meta: {
|
||||
ungroundedCount: ungrounded.length,
|
||||
briefing_fallback:
|
||||
fallbackBriefing != null ? "previous-cached" : "stripped",
|
||||
},
|
||||
});
|
||||
}
|
||||
return { insights, result, workingProviderType, briefingOmittedReason };
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* The on-demand briefing's prompt: the feature snapshot, and every
|
||||
* SYSTEM CONTEXT block that rides on it.
|
||||
*/
|
||||
import { metricsFromPresentSections } from "@/lib/ai/medical-references";
|
||||
import {
|
||||
buildAboutMeInsightBlock,
|
||||
getSelfContextTextForUser,
|
||||
} from "@/lib/ai/coach/about-me";
|
||||
import { buildBriefingPersonalisationBlock } from "@/lib/ai/prompts/insight-generator";
|
||||
import { buildUserPrompt } from "@/lib/ai/prompts/insight-system-prompt";
|
||||
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
|
||||
import {
|
||||
briefingFormForRecord,
|
||||
buildBriefingShortFormBlock,
|
||||
} from "@/lib/insights/briefing-short-form";
|
||||
import { buildComparisonSnapshotForUser } from "@/lib/insights/comprehensive-generate";
|
||||
import {
|
||||
buildDerivedBriefingPrompt,
|
||||
detectDerivedBriefingSignals,
|
||||
} from "@/lib/insights/derived-briefing";
|
||||
import {
|
||||
buildGlp1PlateauPrompt,
|
||||
detectGlp1Plateau,
|
||||
} from "@/lib/insights/glp1-plateau";
|
||||
import {
|
||||
buildBriefingIllnessCycleContext,
|
||||
buildBriefingIllnessCyclePrompt,
|
||||
} from "@/lib/insights/illness-cycle-briefing";
|
||||
import { toProfileSex } from "@/lib/profile/sex";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
import type { GenerationContext } from "./context";
|
||||
import type { PreparedFeatures } from "./features";
|
||||
|
||||
export type BriefingPrompt = Awaited<ReturnType<typeof buildBriefingPrompt>>;
|
||||
|
||||
export async function buildBriefingPrompt(
|
||||
ctx: GenerationContext,
|
||||
prepared: PreparedFeatures,
|
||||
) {
|
||||
const { userId, dbUser, locale } = ctx;
|
||||
const { features, featuresJson, units } = prepared;
|
||||
// The persisted comparison toggle: when active, a compact prior-period
|
||||
// snapshot built from the `features` summaries that already include
|
||||
// `avg30LastMonth` / `avg30LastYear`. The narrative is default-on whenever
|
||||
// the toggle is on — the pulldown is the single affordance.
|
||||
const comparisonSnapshot = await buildComparisonSnapshotForUser(userId);
|
||||
// GLP-1 plateau detector: a SYSTEM CONTEXT block when the active GLP-1
|
||||
// medication has been on a stable dose for ≥21 days with the weight delta
|
||||
// within ±0.5 kg, asking for an observational glp1_plateau keyFinding (no
|
||||
// dose recommendation per GROUND RULE 13). Null — the common case — costs
|
||||
// no tokens.
|
||||
const plateauContext = await detectGlp1Plateau(userId);
|
||||
let userPrompt = buildUserPrompt(
|
||||
featuresJson,
|
||||
dbUser?.insightsPrivacyMode ?? "aggregated",
|
||||
locale,
|
||||
comparisonSnapshot ?? undefined,
|
||||
);
|
||||
if (plateauContext) {
|
||||
userPrompt += buildGlp1PlateauPrompt(plateauContext, locale, units.system);
|
||||
}
|
||||
// The user-authored "about me" self-description (Settings → AI), as a
|
||||
// delimited, user-provided SYSTEM CONTEXT block. Null (no text /
|
||||
// undecryptable) costs nothing.
|
||||
const aboutMe = await getSelfContextTextForUser(userId, locale);
|
||||
if (aboutMe) {
|
||||
userPrompt += buildAboutMeInsightBlock(aboutMe, locale);
|
||||
}
|
||||
// A notable derived wellness signal (readiness / recovery shift,
|
||||
// confidence-gated), read from the one derived contract; null (the common
|
||||
// case) costs nothing.
|
||||
const derivedSex = toProfileSex(dbUser?.gender);
|
||||
const derivedBriefing = await detectDerivedBriefingSignals(userId, {
|
||||
ageYears: getAgeFromDateOfBirth(dbUser?.dateOfBirth ?? null),
|
||||
sex: derivedSex,
|
||||
heightCm: dbUser?.heightCm ?? null,
|
||||
});
|
||||
if (derivedBriefing) {
|
||||
userPrompt += buildDerivedBriefingPrompt(derivedBriefing, locale);
|
||||
}
|
||||
|
||||
// Illness-episode + cycle state, so a mid-illness or mid-cycle user gets
|
||||
// context-aware prose. The same server-authoritative builders the Coach
|
||||
// assembles; both are module-gated and short-circuit to null (zero token
|
||||
// cost) for users without them.
|
||||
const illnessCycleCtx = await buildBriefingIllnessCycleContext(
|
||||
userId,
|
||||
dbUser?.gender ?? null,
|
||||
await resolveUserTimezone(userId),
|
||||
);
|
||||
if (illnessCycleCtx) {
|
||||
userPrompt += buildBriefingIllnessCyclePrompt(illnessCycleCtx, locale);
|
||||
}
|
||||
|
||||
// Opener-archetype rotation + sparse first-name personalization
|
||||
// (deterministic per user+day; omitted for unnamed accounts).
|
||||
userPrompt += buildBriefingPersonalisationBlock(
|
||||
userId,
|
||||
dbUser?.displayName ?? null,
|
||||
locale,
|
||||
await resolveUserTimezone(userId),
|
||||
);
|
||||
// The short form on the `frugal` usage level; the full contract on every
|
||||
// other level (#1217).
|
||||
const briefingForm = await briefingFormForRecord(userId);
|
||||
userPrompt += buildBriefingShortFormBlock(locale, briefingForm);
|
||||
|
||||
// The curated SOURCES block for the metric sections this briefing
|
||||
// actually carries, so a normative claim can cite a real `referenceId` the
|
||||
// schema + UI footnote already support. The plain prompt stays unchanged
|
||||
// when no applicable metric section is present.
|
||||
const referenceMetrics = metricsFromPresentSections({
|
||||
bloodPressure: features.bloodPressure != null,
|
||||
weight: features.weight != null,
|
||||
pulse: features.pulse != null,
|
||||
mood: features.mood != null,
|
||||
medication: (features.medications?.length ?? 0) > 0,
|
||||
});
|
||||
|
||||
return {
|
||||
userPrompt,
|
||||
comparisonSnapshot,
|
||||
aboutMe,
|
||||
briefingForm,
|
||||
referenceMetrics,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* The read-only advisor briefing: the cached payload, served immediately and
|
||||
* never generated here.
|
||||
*
|
||||
* The advisor consumers (hero strip, daily-briefing card, trends row) used
|
||||
* to mount against the generating POST, which blocked the page-load path on
|
||||
* the full provider chain. This read serves `User.insightsCachedTextEncrypted`
|
||||
* as it is and, when the cache is stale or missing AND a provider is
|
||||
* configured, enqueues an out-of-band warm (the same `insight-pregenerate`
|
||||
* queue the nightly cron and the "prepare assessments" button use). The next
|
||||
* read reflects the fresh briefing.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
generatedOnLocalToday,
|
||||
insightsPayloadForToday,
|
||||
} from "@/lib/daily/briefing-today-read";
|
||||
import { isLegacyInsightPayload } from "@/lib/ai/legacy-payload";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { readBriefingFailure } from "@/lib/insights/briefing-failure-marker";
|
||||
import { readBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at";
|
||||
import {
|
||||
INSIGHTS_CACHE_SELECT,
|
||||
openInsightsCachedText,
|
||||
} from "@/lib/insights/cached-text-cipher";
|
||||
import { normalizeLocale } from "@/lib/insights/status-shared";
|
||||
import { hasUsableStatusProvider } from "@/lib/insights/status-provider";
|
||||
import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
/**
|
||||
* Briefing freshness window for the read. A cached briefing read this
|
||||
* recently is fresh and triggers no warm; older / missing enqueues an
|
||||
* out-of-band regeneration. Mirrors the 24 h cache-hit window the POST path
|
||||
* honours so the two stay consistent.
|
||||
*/
|
||||
const BRIEFING_FRESH_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* The honesty signals beside the payload: whether a provider exists, and
|
||||
* what the last generation attempt left behind.
|
||||
*/
|
||||
async function readGenerationSignals(userId: string, cachedAt: Date | null) {
|
||||
// Provider availability, resolved once. The read never blocks on or
|
||||
// generates through the provider, but it must REPORT whether one exists:
|
||||
// a provider-less account's cached briefing freezes at the last successful
|
||||
// generation (the pregenerate cron can't refill it), and the UI presented
|
||||
// that stale text as current. `hasProvider: false` lets the insights
|
||||
// surfaces pair the honest "Stand: vor X Tagen" age with a discreet
|
||||
// connect-an-AI-provider hint instead of implying the read is live.
|
||||
const hasProvider = await hasUsableStatusProvider(userId);
|
||||
|
||||
// Whether the most recent generation attempt failed (a marker newer than
|
||||
// the last successful generation). The briefing keeps its last good text
|
||||
// on failure, so this is the only honest signal that a shown briefing is
|
||||
// held rather than freshly refreshed — and, when there is no last good
|
||||
// text, that the empty state should read "couldn't generate" with a retry
|
||||
// rather than the generic "no briefing yet".
|
||||
const briefingFailure = await readBriefingFailure({
|
||||
userId,
|
||||
since: cachedAt,
|
||||
});
|
||||
// A `briefing-ungrounded` marker is NOT a failure: the generation
|
||||
// succeeded but the grounding gate withheld the briefing (marker written
|
||||
// after the cache write, so it survives the `since` filter). It is the
|
||||
// persistent omission signal — the card's "briefing withheld" state used
|
||||
// to exist only on the transient POST response, so a nightly strip read as
|
||||
// a silent generic empty card.
|
||||
const briefingOmittedReason: "ungrounded" | null =
|
||||
briefingFailure?.reason === "briefing-ungrounded" ? "ungrounded" : null;
|
||||
const generationFailed =
|
||||
briefingFailure !== null && briefingOmittedReason === null;
|
||||
// The failure class lets the empty state point its hint at the right
|
||||
// lever (raise the response timeout vs re-check the provider).
|
||||
const generationFailureClass = generationFailed
|
||||
? (briefingFailure?.failureClass ?? null)
|
||||
: null;
|
||||
return {
|
||||
hasProvider,
|
||||
generationFailed,
|
||||
generationFailureClass,
|
||||
briefingOmittedReason,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readAdvisorBriefing(args: {
|
||||
request: NextRequest;
|
||||
userId: string;
|
||||
userLocale: string | null;
|
||||
}) {
|
||||
const { request, userId } = args;
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
insightsCachedAt: true,
|
||||
...INSIGHTS_CACHE_SELECT,
|
||||
insightsCachedLocale: true,
|
||||
locale: true,
|
||||
timezone: true,
|
||||
},
|
||||
});
|
||||
const cachedText = openInsightsCachedText(dbUser);
|
||||
|
||||
// The locale the caller is actually reading (cookie / Accept-Language
|
||||
// fall-back when `User.locale` is unset), validated against the shipped
|
||||
// list — the same convention the nightly's `normalizeLocale` and the status
|
||||
// routes follow. Collapsing a NULL `User.locale` to German filled the wrong
|
||||
// cache family for a client reading the app in English.
|
||||
const readerLocale = normalizeLocale(
|
||||
await resolveServerLocale({
|
||||
request,
|
||||
userLocale: dbUser?.locale ?? args.userLocale ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
// The cache is one slot per user. Text written in another language is
|
||||
// never the reader's briefing, however fresh: treat the slot as empty for
|
||||
// prose, and re-warm in the reader's language below. An untagged row
|
||||
// predates the tag and is served as before.
|
||||
const languageMismatch =
|
||||
dbUser?.insightsCachedLocale != null &&
|
||||
dbUser.insightsCachedLocale !== readerLocale;
|
||||
|
||||
const cachedAt = dbUser?.insightsCachedAt ?? null;
|
||||
const timezone = dbUser?.timezone ?? DEFAULT_TIMEZONE;
|
||||
// Fresh means written today on the reader's calendar, by the moment the
|
||||
// text was generated: `insightsCachedAt` also moves when an unchanged
|
||||
// warm only re-stamps yesterday's text.
|
||||
const generatedToday = generatedOnLocalToday(
|
||||
readBriefingGeneratedAt(cachedText),
|
||||
timezone,
|
||||
new Date(),
|
||||
);
|
||||
const isFresh =
|
||||
!languageMismatch &&
|
||||
cachedAt !== null &&
|
||||
generatedToday &&
|
||||
Date.now() - cachedAt.getTime() < BRIEFING_FRESH_MS;
|
||||
|
||||
const signals = await readGenerationSignals(userId, cachedAt);
|
||||
const { hasProvider } = signals;
|
||||
|
||||
// Never block on the provider. Warm out of band only when the cached
|
||||
// briefing is stale / missing AND a provider is configured (a
|
||||
// provider-less account costs one cheap chain-resolve and shows the
|
||||
// empty / connect-AI state instead of a wasted enqueue).
|
||||
let revalidating = false;
|
||||
if (!isFresh && hasProvider) {
|
||||
void enqueueForceWarm({ userId, locale: readerLocale });
|
||||
revalidating = true;
|
||||
}
|
||||
|
||||
if (cachedText && !languageMismatch) {
|
||||
try {
|
||||
const cached = JSON.parse(cachedText);
|
||||
const legacyPayload = isLegacyInsightPayload(cached);
|
||||
annotate({
|
||||
action: { name: "insights.generate.read" },
|
||||
meta: { cached: true, legacyPayload, revalidating, hasProvider },
|
||||
});
|
||||
return {
|
||||
// The briefing as every surface serves it: today's text only, its
|
||||
// signals measured today, its deltas at display precision.
|
||||
insights: await insightsPayloadForToday(cached, {
|
||||
userId,
|
||||
timezone,
|
||||
language: readerLocale,
|
||||
}),
|
||||
cached: true,
|
||||
cachedAt,
|
||||
legacyPayload,
|
||||
// Honest stale-serve marker: true while the out-of-band warm is in
|
||||
// flight, so the client can poll (bounded) until the fresh briefing
|
||||
// lands instead of sitting on the stale payload for the rest of the
|
||||
// session.
|
||||
revalidating,
|
||||
// False when no AI provider is configured anywhere: the served
|
||||
// briefing can never refresh, so the UI pairs its age with a
|
||||
// connect-provider affordance.
|
||||
hasProvider,
|
||||
// True when the last generation attempt failed (held text).
|
||||
generationFailed: signals.generationFailed,
|
||||
// Coarse failure class for the empty-state hint (null when the last
|
||||
// attempt succeeded).
|
||||
generationFailureClass: signals.generationFailureClass,
|
||||
// Persistent grounding-omission signal (see above), so a
|
||||
// nightly-stripped briefing renders "withheld", not "no briefing
|
||||
// yet".
|
||||
briefingOmittedReason: signals.briefingOmittedReason,
|
||||
};
|
||||
} catch {
|
||||
// Invalid cache row — fall through to the empty payload below. The
|
||||
// warm enqueue above (when a provider exists) repairs it for the next
|
||||
// read.
|
||||
}
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.generate.read" },
|
||||
meta: {
|
||||
cached: false,
|
||||
revalidating,
|
||||
hasProvider,
|
||||
languageMismatch,
|
||||
readerLocale,
|
||||
},
|
||||
});
|
||||
return {
|
||||
insights: null,
|
||||
cached: false,
|
||||
legacyPayload: false,
|
||||
revalidating,
|
||||
hasProvider,
|
||||
// No last-good text AND the last attempt failed: the UI shows a
|
||||
// "couldn't generate" empty state with a retry instead of the generic
|
||||
// one.
|
||||
generationFailed: signals.generationFailed,
|
||||
generationFailureClass: signals.generationFailureClass,
|
||||
briefingOmittedReason: signals.briefingOmittedReason,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* The Insights overview's response body, computed from the record.
|
||||
*
|
||||
* SQL-side aggregates served by `buildComprehensiveAggregate` replace the
|
||||
* 100k-row-in-JS aggregation the route once ran; the medication-compliance
|
||||
* block keeps its bounded Prisma reads (medications + intake events). No
|
||||
* provider is called anywhere on the path: `hasProvider` is the presence
|
||||
* probe only.
|
||||
*
|
||||
* Every day-bucketing pass (resting-pulse proxy, medication continuity, BP
|
||||
* pairing) shares the one per-user tz resolved here.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { probeProviderPresence } from "@/lib/ai/provider";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { summarize } from "@/lib/analytics/trends";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import { computeBpInTargetPct } from "@/lib/analytics/bp-in-target";
|
||||
import {
|
||||
classifyBMI,
|
||||
classifyBP,
|
||||
generateAlerts,
|
||||
} from "@/lib/analytics/classifications";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
import { overviewCorrelations } from "./correlations";
|
||||
import {
|
||||
bpMedicationContinuity,
|
||||
readMedicationCompliance,
|
||||
} from "./medication-compliance";
|
||||
import { readOverviewMoodSeries } from "./mood-series";
|
||||
import { readRestingPulseDaily } from "./resting-pulse-daily";
|
||||
|
||||
/** Who the overview is computed for: the record, with its zone. */
|
||||
export interface OverviewRecord {
|
||||
id: string;
|
||||
timezone?: string | null;
|
||||
}
|
||||
|
||||
export type ComprehensiveOverviewBody = Awaited<
|
||||
ReturnType<typeof buildComprehensiveResponse>
|
||||
>;
|
||||
|
||||
/** BMI and the 30-day BP classification against the record's profile. */
|
||||
function classifyProfile(
|
||||
summaries: Record<string, DataSummary>,
|
||||
dbUser: { heightCm: number | null; dateOfBirth: Date | null } | null,
|
||||
) {
|
||||
let bmi: number | null = null;
|
||||
let bmiClassification = null;
|
||||
if (dbUser?.heightCm && summaries.WEIGHT?.latest) {
|
||||
const heightM = dbUser.heightCm / 100;
|
||||
bmi = Math.round((summaries.WEIGHT.latest / (heightM * heightM)) * 10) / 10;
|
||||
bmiClassification = classifyBMI(bmi);
|
||||
}
|
||||
|
||||
let bpClassification = null;
|
||||
const bpTargets = getBpTargets(dbUser?.dateOfBirth ?? null);
|
||||
if (
|
||||
summaries.BLOOD_PRESSURE_SYS?.avg30 &&
|
||||
summaries.BLOOD_PRESSURE_DIA?.avg30
|
||||
) {
|
||||
bpClassification = classifyBP(
|
||||
summaries.BLOOD_PRESSURE_SYS.avg30,
|
||||
summaries.BLOOD_PRESSURE_DIA.avg30,
|
||||
);
|
||||
}
|
||||
return { bmi, bmiClassification, bpClassification, bpTargets };
|
||||
}
|
||||
|
||||
export async function buildComprehensiveResponse(user: OverviewRecord) {
|
||||
const userId = user.id;
|
||||
const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
||||
const userTz = user.timezone || DEFAULT_TIMEZONE;
|
||||
|
||||
// Height + DOB drive BMI + BP targets.
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
heightCm: true,
|
||||
dateOfBirth: true,
|
||||
},
|
||||
});
|
||||
|
||||
// SQL-side rollup: per-type DataSummary identical to the JS `summarize()`
|
||||
// semantics over the 90-day window, plus the bounded raw rows the BP target
|
||||
// adherence pairing needs, plus daily-mean buckets for the correlations.
|
||||
const aggregate = await buildComprehensiveAggregate(userId);
|
||||
|
||||
const { dailyMoodEntries, moodDataPoints, moodEntryCount } =
|
||||
await readOverviewMoodSeries(userId, ninetyDaysAgo);
|
||||
const moodSummary =
|
||||
moodDataPoints.length > 0 ? summarize(moodDataPoints) : null;
|
||||
|
||||
const summaries: Record<string, DataSummary> = aggregate.summaries;
|
||||
const { bmi, bmiClassification, bpClassification, bpTargets } =
|
||||
classifyProfile(summaries, dbUser);
|
||||
|
||||
// BP target adherence over the trailing 90 days, through the canonical
|
||||
// `computeBpInTargetPct` so this figure shares ONE definition with the
|
||||
// dashboard tile, the insight cards, the AI snapshot and the targets
|
||||
// endpoint. The helper pairs sys+dia on the 5-minute window with the
|
||||
// same-local-day fallback (keyed on the user's OWN tz) and keeps the
|
||||
// ESH-2023 band + 90/50 hypotension floor. Window is the aggregator's
|
||||
// 90-day raw pull — a "recent control" figure, not the all-time headline.
|
||||
let bpPctInTarget: number | null = null;
|
||||
if (bpTargets) {
|
||||
bpPctInTarget =
|
||||
computeBpInTargetPct(
|
||||
aggregate.bpRawRows.sys,
|
||||
aggregate.bpRawRows.dia,
|
||||
bpTargets,
|
||||
userTz,
|
||||
)?.pct ?? null;
|
||||
}
|
||||
|
||||
const restingPulseDaily = await readRestingPulseDaily(
|
||||
userId,
|
||||
ninetyDaysAgo,
|
||||
userTz,
|
||||
);
|
||||
const sysDaily = aggregate.dailyByType.BLOOD_PRESSURE_SYS ?? [];
|
||||
const correlations = overviewCorrelations({
|
||||
aggregate,
|
||||
dailyMoodEntries,
|
||||
restingPulseDaily,
|
||||
userTz,
|
||||
});
|
||||
|
||||
const { medications, medCompliance, bpMedications, bpMedicationEvents } =
|
||||
await readMedicationCompliance({
|
||||
userId,
|
||||
since: ninetyDaysAgo,
|
||||
timezone: user.timezone || DEFAULT_TIMEZONE,
|
||||
});
|
||||
const { bpMedicationCorrelation, bpMedicationScatterData } =
|
||||
bpMedicationContinuity({ bpMedications, bpMedicationEvents, sysDaily });
|
||||
|
||||
const alerts = generateAlerts({
|
||||
bmi,
|
||||
bpAvgSys: summaries.BLOOD_PRESSURE_SYS?.avg30 ?? null,
|
||||
bpAvgDia: summaries.BLOOD_PRESSURE_DIA?.avg30 ?? null,
|
||||
bpPctInTarget,
|
||||
weightSlope30: summaries.WEIGHT?.slope30?.slope ?? null,
|
||||
pulseAvg30: summaries.PULSE?.avg30 ?? null,
|
||||
pulseAnomalyCount: summaries.PULSE?.anomalyCount ?? 0,
|
||||
medications: medCompliance.map((m) => ({
|
||||
name: m.name,
|
||||
compliance7: m.compliance7,
|
||||
compliance30: m.compliance30,
|
||||
})),
|
||||
});
|
||||
|
||||
// Data span — derived from the earliest 90-day measurement.
|
||||
const dataSpanDays = aggregate.firstMeasurementAt
|
||||
? Math.ceil(
|
||||
(Date.now() - aggregate.firstMeasurementAt.getTime()) /
|
||||
(24 * 60 * 60 * 1000),
|
||||
)
|
||||
: 0;
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.comprehensive" },
|
||||
meta: {
|
||||
totalMeasurements: aggregate.totalMeasurements,
|
||||
moodEntries: moodEntryCount,
|
||||
medications: medications.length,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
summaries,
|
||||
bmi,
|
||||
bmiClassification,
|
||||
bpClassification,
|
||||
bpPctInTarget,
|
||||
bpTargets,
|
||||
weightBpCorrelation: correlations.weightBpCorrelation,
|
||||
scatterData: correlations.scatterData,
|
||||
bpMedicationCorrelation,
|
||||
bpMedicationScatterData,
|
||||
moodSummary,
|
||||
moodBpCorrelation: correlations.moodBpCorrelation,
|
||||
moodBpScatterData: correlations.moodBpScatterData,
|
||||
moodWeightCorrelation: correlations.moodWeightCorrelation,
|
||||
moodWeightScatterData: correlations.moodWeightScatterData,
|
||||
moodPulseCorrelation: correlations.moodPulseCorrelation,
|
||||
moodPulseScatterData: correlations.moodPulseScatterData,
|
||||
medications: medCompliance,
|
||||
alerts,
|
||||
// Presence only: no client is built and no token is refreshed on a read.
|
||||
hasProvider: await probeProviderPresence(userId),
|
||||
dataSpanDays,
|
||||
totalMeasurements: aggregate.totalMeasurements,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* The overview's correlations: weight × systolic, and mood against
|
||||
* systolic, weight and resting pulse. Pure: every series is already read.
|
||||
*/
|
||||
import {
|
||||
significantPearsonCorrelation,
|
||||
type PairedPoint,
|
||||
} from "@/lib/analytics/correlations";
|
||||
import type { ComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
|
||||
|
||||
import {
|
||||
deriveDailyLocalMeans,
|
||||
joinDailyByDay,
|
||||
pairMoodWithDaily,
|
||||
type DailyValue,
|
||||
} from "./daily-series";
|
||||
|
||||
export function overviewCorrelations(args: {
|
||||
aggregate: ComprehensiveAggregate;
|
||||
dailyMoodEntries: DailyValue[];
|
||||
restingPulseDaily: DailyValue[];
|
||||
userTz: string;
|
||||
}) {
|
||||
const { aggregate, dailyMoodEntries, restingPulseDaily, userTz } = args;
|
||||
|
||||
// Weight × Sys BP, daily-key pairing. Both sides come from the UTC
|
||||
// `dailyByType` series, so they share one UTC day-key space and are
|
||||
// internally consistent; re-keying it to local migrates with the rollup
|
||||
// tier.
|
||||
const weightDaily = aggregate.dailyByType.WEIGHT ?? [];
|
||||
const sysDaily = aggregate.dailyByType.BLOOD_PRESSURE_SYS ?? [];
|
||||
const weightBpPairs: PairedPoint[] = joinDailyByDay(weightDaily, sysDaily);
|
||||
const weightBpCorrelation = significantPearsonCorrelation(weightBpPairs);
|
||||
const scatterData = weightBpPairs.map((p) => ({
|
||||
weight: p.a,
|
||||
sysBP: p.b,
|
||||
}));
|
||||
|
||||
// Mood × {sys BP, weight, pulse}. The mood series is LOCAL-day-keyed and
|
||||
// `pairMoodWithDaily` joins on an EXACT YYYY-MM-DD key, so the partners
|
||||
// are exact local-day means from the raw rows already on the request path
|
||||
// (canonical-source deduped in the aggregator), NOT the UTC `dailyByType` —
|
||||
// a UTC-day MEAN cannot be relabeled into a local-day mean.
|
||||
const sysDailyLocal = deriveDailyLocalMeans(aggregate.bpRawRows.sys, userTz);
|
||||
const weightDailyLocal = deriveDailyLocalMeans(
|
||||
aggregate.weightRawRows,
|
||||
userTz,
|
||||
);
|
||||
|
||||
const moodBpPairs = pairMoodWithDaily(dailyMoodEntries, sysDailyLocal);
|
||||
const moodBpCorrelation = significantPearsonCorrelation(moodBpPairs);
|
||||
const moodBpScatterData = moodBpPairs.map((p) => ({ mood: p.a, sysBP: p.b }));
|
||||
|
||||
const moodWeightPairs = pairMoodWithDaily(dailyMoodEntries, weightDailyLocal);
|
||||
const moodWeightCorrelation = significantPearsonCorrelation(moodWeightPairs);
|
||||
const moodWeightScatterData = moodWeightPairs.map((p) => ({
|
||||
mood: p.a,
|
||||
weight: p.b,
|
||||
}));
|
||||
|
||||
const moodPulsePairs = pairMoodWithDaily(dailyMoodEntries, restingPulseDaily);
|
||||
const moodPulseCorrelation = significantPearsonCorrelation(moodPulsePairs);
|
||||
const moodPulseScatterData = moodPulsePairs.map((p) => ({
|
||||
mood: p.a,
|
||||
pulse: p.b,
|
||||
}));
|
||||
|
||||
return {
|
||||
weightBpCorrelation,
|
||||
scatterData,
|
||||
moodBpCorrelation,
|
||||
moodBpScatterData,
|
||||
moodWeightCorrelation,
|
||||
moodWeightScatterData,
|
||||
moodPulseCorrelation,
|
||||
moodPulseScatterData,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Day-keyed series helpers for the Insights overview's correlations: local
|
||||
* day means from raw rows, and the two joins that pair one daily series with
|
||||
* another.
|
||||
*/
|
||||
import type { PairedPoint } from "@/lib/analytics/correlations";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
export interface DailyValue {
|
||||
day: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive exact local-day mean series from raw measurement rows, keyed by the
|
||||
* user's timezone via `userDayKey`. The mood series is local-day-keyed, and
|
||||
* `pairMoodWithDaily` joins on an exact YYYY-MM-DD key, so its metric
|
||||
* partners must be local-day-keyed too. A UTC-day mean (from `dailyByType`)
|
||||
* cannot be relabeled into a local-day mean, so the mean is recomputed here
|
||||
* from raw rows already on the request path. `userDayKey` formats via a
|
||||
* memoised `Intl.DateTimeFormat` — no offset arithmetic — so DST-transition
|
||||
* days (23 h / 25 h) bucket correctly and the result is host-TZ-independent
|
||||
* (CI runs UTC). `round2` mirrors the aggregator's `ROUND(AVG, 2)` so
|
||||
* correlation thresholds don't drift. Returns `{ day, value }[]` sorted ASC
|
||||
* by day, the shape `pairMoodWithDaily` expects.
|
||||
*/
|
||||
export function deriveDailyLocalMeans(
|
||||
rows: Array<{ measuredAt: Date; value: number }>,
|
||||
tz: string,
|
||||
): DailyValue[] {
|
||||
const byDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const r of rows) {
|
||||
const key = userDayKey(r.measuredAt, tz);
|
||||
const cur = byDay.get(key) ?? { sum: 0, count: 0 };
|
||||
cur.sum += r.value;
|
||||
cur.count += 1;
|
||||
byDay.set(key, cur);
|
||||
}
|
||||
return Array.from(byDay.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([day, stats]) => ({
|
||||
day,
|
||||
value: Math.round((stats.sum / stats.count) * 100) / 100,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily-key inner join between two daily-aggregated series. Returns
|
||||
* `PairedPoint` shapes compatible with `significantPearsonCorrelation`.
|
||||
*
|
||||
* Inputs are sorted ASC by `day` (the aggregator's ORDER BY
|
||||
* guarantees that). The walk is O(n + m) merge-join style instead of
|
||||
* Map.lookup-per-element so it scales with the worst-case 90 buckets
|
||||
* x 90 buckets case without surprises.
|
||||
*/
|
||||
export function joinDailyByDay(
|
||||
a: DailyValue[],
|
||||
b: DailyValue[],
|
||||
): PairedPoint[] {
|
||||
const pairs: PairedPoint[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < a.length && j < b.length) {
|
||||
const ai = a[i];
|
||||
const bj = b[j];
|
||||
if (ai.day === bj.day) {
|
||||
pairs.push({
|
||||
a: ai.value,
|
||||
b: bj.value,
|
||||
date: new Date(`${ai.day}T12:00:00.000Z`),
|
||||
});
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if (ai.day < bj.day) {
|
||||
i += 1;
|
||||
} else {
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair the daily mood series (left side) with a daily-aggregated
|
||||
* metric (right side). Matches the legacy `buildMoodMetricPairs`
|
||||
* helper byte-for-byte: same daily YYYY-MM-DD key, same
|
||||
* `T12:00:00.000Z` anchor for the `date` field.
|
||||
*/
|
||||
export function pairMoodWithDaily(
|
||||
dailyMood: DailyValue[],
|
||||
dailyMetric: DailyValue[],
|
||||
): PairedPoint[] {
|
||||
if (dailyMood.length === 0 || dailyMetric.length === 0) return [];
|
||||
const metricByDay = new Map<string, number>();
|
||||
for (const m of dailyMetric) {
|
||||
metricByDay.set(m.day, m.value);
|
||||
}
|
||||
const pairs: PairedPoint[] = [];
|
||||
for (const mood of dailyMood) {
|
||||
const metric = metricByDay.get(mood.day);
|
||||
if (metric !== undefined) {
|
||||
pairs.push({
|
||||
a: mood.value,
|
||||
b: metric,
|
||||
date: new Date(`${mood.day}T12:00:00.000Z`),
|
||||
});
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* The overview's medication block: per-medication compliance over 7 and 30
|
||||
* days, and the blood-pressure medication continuity × systolic correlation
|
||||
* that pairs the BP medications' intake days with the daily systolic means.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
buildComplianceMedicationContext,
|
||||
COURSES_COMPLIANCE_SELECT,
|
||||
calculateCompliance,
|
||||
expectsDoses,
|
||||
lastNonSkippedTakenAt,
|
||||
SCHEDULE_COMPLIANCE_SELECT,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import { significantPearsonCorrelation } from "@/lib/analytics/correlations";
|
||||
import { resolveMedicationCategories } from "@/lib/medication-category";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
|
||||
|
||||
import type { DailyValue } from "./daily-series";
|
||||
|
||||
interface BpIntakeEvent {
|
||||
scheduledFor: Date;
|
||||
takenAt: Date | null;
|
||||
skipped: boolean;
|
||||
}
|
||||
|
||||
/** Read the tracked scheduled medications with what compliance needs. */
|
||||
async function readScheduledMedications(userId: string) {
|
||||
const medicationRows = await prisma.medication.findMany({
|
||||
// As-needed (PRN) medications never surface a compliance rate (no
|
||||
// expected doses).
|
||||
where: { userId, active: true, asNeeded: false, ...TRACKED_INTAKE_WHERE },
|
||||
include: {
|
||||
// Schedules through the shared compliance select so the configured
|
||||
// per-dose windows reach this surface like every other.
|
||||
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
|
||||
// Archived schedule eras for era-aware compliance.
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
// Pause eras so paused days drop out of the denominator.
|
||||
pauseEras: { select: { pausedAt: true, resumedAt: true } },
|
||||
// The courses, so a gap between two expects nothing (#1024).
|
||||
courses: COURSES_COMPLIANCE_SELECT,
|
||||
},
|
||||
});
|
||||
// A medication with no schedule expects no dose, and `calculateCompliance`
|
||||
// answers 100 % for that case. The `asNeeded: false` filter above catches
|
||||
// only the PRN arm; the empty schedule is reachable on a scheduled
|
||||
// medication and would otherwise report perfect adherence.
|
||||
return medicationRows.filter(expectsDoses);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-medication compliance, plus the intake events of the blood-pressure
|
||||
* medications for the continuity correlation.
|
||||
*/
|
||||
export async function readMedicationCompliance(args: {
|
||||
userId: string;
|
||||
since: Date;
|
||||
timezone: string;
|
||||
}) {
|
||||
const { userId, since, timezone } = args;
|
||||
const medications = await readScheduledMedications(userId);
|
||||
|
||||
const resolvedCategories = await resolveMedicationCategories(
|
||||
medications.map((m) => m.id),
|
||||
);
|
||||
const categoryMap = Object.fromEntries(
|
||||
Object.entries(resolvedCategories).map(([id, r]) => [id, r.category]),
|
||||
);
|
||||
|
||||
const medCompliance = [];
|
||||
const bpMedicationEvents: BpIntakeEvent[] = [];
|
||||
const bpMedications = medications.filter(
|
||||
(med) => (categoryMap[med.id] ?? "OTHER") === "BLOOD_PRESSURE",
|
||||
);
|
||||
// Single round-trip for all medications instead of N+1: one query keyed
|
||||
// on `medicationId IN (...)`, then group in memory.
|
||||
const allEvents = medications.length
|
||||
? await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
medicationId: { in: medications.map((m) => m.id) },
|
||||
userId,
|
||||
// Tombstoned rows are excluded.
|
||||
deletedAt: null,
|
||||
scheduledFor: { gte: since },
|
||||
},
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
})
|
||||
: [];
|
||||
|
||||
const eventsByMed = new Map<string, typeof allEvents>();
|
||||
for (const ev of allEvents) {
|
||||
const list = eventsByMed.get(ev.medicationId);
|
||||
if (list) list.push(ev);
|
||||
else eventsByMed.set(ev.medicationId, [ev]);
|
||||
}
|
||||
|
||||
for (const med of medications) {
|
||||
const events = eventsByMed.get(med.id) ?? [];
|
||||
const mapped = events.map((e) => ({
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
scheduledFor: e.scheduledFor,
|
||||
}));
|
||||
// Engine-routed denominator.
|
||||
const medicationContext = buildComplianceMedicationContext(
|
||||
med,
|
||||
lastNonSkippedTakenAt(mapped),
|
||||
timezone,
|
||||
);
|
||||
const c7 = calculateCompliance(mapped, med.schedules, 7, med.createdAt, {
|
||||
medicationContext,
|
||||
});
|
||||
const c30 = calculateCompliance(mapped, med.schedules, 30, med.createdAt, {
|
||||
medicationContext,
|
||||
});
|
||||
|
||||
medCompliance.push({
|
||||
id: med.id,
|
||||
name: med.name,
|
||||
dose: med.dose,
|
||||
category: categoryMap[med.id] ?? "OTHER",
|
||||
// The label of a custom category; null for a built-in (#1041).
|
||||
categoryLabel: resolvedCategories[med.id]?.categoryLabel ?? null,
|
||||
compliance7: c7.rate,
|
||||
compliance30: c30.rate,
|
||||
streak: c7.streak,
|
||||
taken7: c7.taken,
|
||||
skipped7: c7.skipped,
|
||||
missed7: c7.missed,
|
||||
});
|
||||
|
||||
if ((categoryMap[med.id] ?? "OTHER") === "BLOOD_PRESSURE") {
|
||||
bpMedicationEvents.push(
|
||||
...events.map((e) => ({
|
||||
scheduledFor: e.scheduledFor,
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { medications, medCompliance, bpMedications, bpMedicationEvents };
|
||||
}
|
||||
|
||||
/**
|
||||
* Correlation: BP medication continuity × systolic BP.
|
||||
*
|
||||
* Uses the same daily BP_SYS series the aggregator pre-computed — no second
|
||||
* round-trip. The intake-side aggregation matches the legacy code:
|
||||
* continuity = min(1, taken / expectedPerDay), pairing every day that had a
|
||||
* sys reading.
|
||||
*/
|
||||
export function bpMedicationContinuity(args: {
|
||||
bpMedications: Array<{ schedules: unknown[] }>;
|
||||
bpMedicationEvents: BpIntakeEvent[];
|
||||
sysDaily: DailyValue[];
|
||||
}) {
|
||||
const { bpMedications, bpMedicationEvents, sysDaily } = args;
|
||||
let bpMedicationCorrelation: {
|
||||
r: number;
|
||||
strength: string;
|
||||
n: number;
|
||||
medicationCount: number;
|
||||
} | null = null;
|
||||
const bpMedicationScatterData: Array<{
|
||||
continuityPct: number;
|
||||
sysBP: number;
|
||||
}> = [];
|
||||
|
||||
const expectedBpIntakesPerDay = bpMedications.reduce(
|
||||
(sum, med) => sum + med.schedules.length,
|
||||
0,
|
||||
);
|
||||
|
||||
if (expectedBpIntakesPerDay > 0) {
|
||||
// Daily systolic means (already keyed YYYY-MM-DD) from the SQL pass.
|
||||
// `sysDaily` (UTC `date_trunc` from the aggregator's `dailyByType`) and
|
||||
// `takenByDay` below (intake `scheduledFor` truncated to UTC) both live
|
||||
// in the SAME UTC day-key space and are internally consistent with each
|
||||
// other, so this join stays UTC. Only the mood pairings moved to a
|
||||
// separately derived local-day series (their partner, the mood series,
|
||||
// is local-day-keyed). This correlation migrates to local when the
|
||||
// measurement-rollup tier buckets per-user tz; re-keying it here without
|
||||
// the local-bucketed rollup would mean per-request raw re-aggregation.
|
||||
const sysByDay = new Map<string, number>();
|
||||
for (const row of sysDaily) {
|
||||
sysByDay.set(row.day, row.value);
|
||||
}
|
||||
|
||||
const takenByDay = new Map<string, number>();
|
||||
// Days that carry an intake record of ANY kind. A logged skip is a real
|
||||
// zero and belongs in the series; a day the person never touched is not a
|
||||
// zero, it is nothing, and pairing it against that day's systolic reading
|
||||
// invented a data point they never produced. Absence is not zero, here as
|
||||
// everywhere else in this project, so the unlogged day drops out of both
|
||||
// the scatter and the coefficient rather than dragging continuity down.
|
||||
const loggedDays = new Set<string>();
|
||||
for (const event of bpMedicationEvents) {
|
||||
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: pairs intake days with the DAY rollup buckets in sysByDay, which are UTC days
|
||||
const dayKey = event.scheduledFor.toISOString().slice(0, 10);
|
||||
loggedDays.add(dayKey);
|
||||
if (event.skipped || !event.takenAt) continue;
|
||||
takenByDay.set(dayKey, (takenByDay.get(dayKey) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const pairs: Array<{ a: number; b: number; date: Date }> = [];
|
||||
for (const [dayKey, avgSys] of sysByDay.entries()) {
|
||||
if (!loggedDays.has(dayKey)) continue;
|
||||
const taken = takenByDay.get(dayKey) ?? 0;
|
||||
const continuity = Math.min(1, taken / expectedBpIntakesPerDay);
|
||||
pairs.push({
|
||||
a: continuity,
|
||||
b: avgSys,
|
||||
date: dayKeyAsUtcMidnight(dayKey),
|
||||
});
|
||||
bpMedicationScatterData.push({
|
||||
continuityPct: Math.round(continuity * 100),
|
||||
sysBP: Math.round(avgSys * 10) / 10,
|
||||
});
|
||||
}
|
||||
|
||||
const corr = significantPearsonCorrelation(pairs);
|
||||
if (corr) {
|
||||
bpMedicationCorrelation = {
|
||||
...corr,
|
||||
medicationCount: bpMedications.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { bpMedicationCorrelation, bpMedicationScatterData };
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* The overview's 90-day mood series, read from the mood-rollup DAY tier.
|
||||
*
|
||||
* The two consumers downstream — `dailyMoodEntries` for the mood × metric
|
||||
* correlations and the mood summary for the dashboard mood block — read at a
|
||||
* per-day resolution, which is precisely the shape the rollup writer emits,
|
||||
* so the tier replaces an unbounded `prisma.moodEntry.findMany`.
|
||||
*
|
||||
* 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 mood rollup writer mints `bucketStart` from the canonical local
|
||||
* `MoodEntry.date` label at UTC midnight, so `.toISOString().slice(0, 10)`
|
||||
* recovers the user's LOCAL calendar day — byte-identical to the live
|
||||
* fallback's `entry.date`. Both mood paths therefore emit local-day keys, and
|
||||
* the mood × metric pairing partners (sys BP, weight, resting pulse) are
|
||||
* derived in the user's tz so both join sides share ONE local-day key space.
|
||||
* The measurement-rollup tier itself still buckets UTC (`dailyByType`); the
|
||||
* two internally-consistent UTC joins (weight × BP, BP-med continuity) are
|
||||
* left on it deliberately (see their call sites in `build.ts`).
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { DataPoint } from "@/lib/analytics/trends";
|
||||
import {
|
||||
ensureUserMoodRollupsFresh,
|
||||
readMoodDayRollups,
|
||||
} from "@/lib/rollups/mood-rollups";
|
||||
import { dateOnlyKey } from "@/lib/tz/date-only";
|
||||
|
||||
import type { DailyValue } from "./daily-series";
|
||||
|
||||
export interface OverviewMoodSeries {
|
||||
dailyMoodEntries: DailyValue[];
|
||||
moodDataPoints: DataPoint[];
|
||||
moodEntryCount: number;
|
||||
}
|
||||
|
||||
export async function readOverviewMoodSeries(
|
||||
userId: string,
|
||||
since: Date,
|
||||
): Promise<OverviewMoodSeries> {
|
||||
void ensureUserMoodRollupsFresh(userId);
|
||||
const moodRollupDayRows = await readMoodDayRollups(userId, since);
|
||||
if (moodRollupDayRows.length > 0) {
|
||||
return {
|
||||
dailyMoodEntries: moodRollupDayRows.map((r) => ({
|
||||
day: dateOnlyKey(r.bucketStart),
|
||||
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),
|
||||
};
|
||||
}
|
||||
// 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({
|
||||
// Tombstoned rows are excluded.
|
||||
where: { userId, deletedAt: null, moodLoggedAt: { gte: since } },
|
||||
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);
|
||||
}
|
||||
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,
|
||||
}));
|
||||
return {
|
||||
dailyMoodEntries,
|
||||
// Per-day means feed `summarize()` so the live-fallback path shares the
|
||||
// rollup-tier semantic (one DataPoint per day, the daily mean), the same
|
||||
// reading mood/analytics uses.
|
||||
moodDataPoints: dailyMoodEntries.map((e) => ({
|
||||
date: new Date(`${e.day}T12:00:00.000Z`),
|
||||
value: e.value,
|
||||
})),
|
||||
moodEntryCount: moodEntries.length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* The Insights overview read: the computed body through the analytics
|
||||
* cache. The route resolves the per-request AI state beside it, which is
|
||||
* never cached with the body.
|
||||
*/
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
cachedSwrWithMeta,
|
||||
caches,
|
||||
type ServerCache,
|
||||
} from "@/lib/cache/server-cache";
|
||||
|
||||
import {
|
||||
buildComprehensiveResponse,
|
||||
type ComprehensiveOverviewBody,
|
||||
type OverviewRecord,
|
||||
} from "./build";
|
||||
|
||||
export async function readComprehensiveOverview(
|
||||
user: OverviewRecord,
|
||||
): Promise<{ body: ComprehensiveOverviewBody; revalidating: boolean }> {
|
||||
// Read-through the analytics cache keyed on (userId, "comprehensive"). The
|
||||
// /insights page mount routinely fans out to this endpoint alongside the
|
||||
// Coach drawer + the recommendations grid; the 60s TTL converts every
|
||||
// duplicate mount within the window to a Map lookup. Invalidation is
|
||||
// handled by `invalidateUserMeasurements`, which marks every key under the
|
||||
// `${userId}|` prefix stale (see `lib/cache/invalidate.ts`).
|
||||
//
|
||||
// Stale-while-revalidate: this is the single heaviest SQL-side aggregation
|
||||
// on the /insights mount; serving the prior body instantly while ONE
|
||||
// background recompute refreshes it keeps the page interactive across the
|
||||
// bucket's 10-minute stale window (a measurement sync used to bust the
|
||||
// entry into a blocking cold rebuild on the very next mount, all day). The
|
||||
// truly-cold first read of the day still computes inline — there is no
|
||||
// prior body to serve — but it no longer recurs per visit.
|
||||
//
|
||||
// The `revalidating` flag marks a stale-served body honestly so the client
|
||||
// can poll (bounded) until the background rebuild lands — mirroring the
|
||||
// `/api/insights/generate` GET contract. It rides on the response, never
|
||||
// inside the cached body, so a warmed entry doesn't freeze the flag.
|
||||
const { value: body, outcome } = await cachedSwrWithMeta(
|
||||
caches.analytics as ServerCache<ComprehensiveOverviewBody>,
|
||||
`${user.id}|comprehensive`,
|
||||
() => buildComprehensiveResponse(user),
|
||||
annotate,
|
||||
);
|
||||
|
||||
return { body, revalidating: outcome === "stale" };
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* The overview's daily resting-pulse series, the mood × pulse correlation's
|
||||
* partner.
|
||||
*
|
||||
* The correlation is labelled "Resting pulse (bpm)", so it is scored against
|
||||
* the RESTING series, not the raw PULSE day-mean: the aggregator's
|
||||
* `dailyByType.PULSE` is a per-day AVG that folds in workout HR for
|
||||
* Apple-Health users and inflates the "resting" figure. The clean
|
||||
* RESTING_HEART_RATE rows win; only without them does the low-percentile
|
||||
* daily proxy over raw pulse stand in. The proxy folds in Postgres (#1023):
|
||||
* it needs one percentile per day, and reading the raw samples to get it
|
||||
* walked 130 000 rows per page view on a watch that records once a minute.
|
||||
*
|
||||
* Keyed on `userDayKey(d, userTz)`: its only pairing partner is the mood
|
||||
* series, which is local-day-keyed, and a UTC key here would offset every
|
||||
* evening logger west of UTC by one day and sell a lag-1 correlation as
|
||||
* same-day. Unlike the mood side, this series builds from raw rows, so it
|
||||
* re-keys directly with no rollup-tier change.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse";
|
||||
import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
import type { DailyValue } from "./daily-series";
|
||||
|
||||
export async function readRestingPulseDaily(
|
||||
userId: string,
|
||||
since: Date,
|
||||
userTz: string,
|
||||
): Promise<DailyValue[]> {
|
||||
const restingPulseRows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "RESTING_HEART_RATE",
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: since },
|
||||
},
|
||||
select: { measuredAt: true, value: true },
|
||||
});
|
||||
const localDayKey = (d: Date) => userDayKey(d, userTz);
|
||||
const restingPulseSeries =
|
||||
restingPulseRows.length > 0
|
||||
? resolveRestingPulseSeries({
|
||||
restingSamples: restingPulseRows,
|
||||
pulseSamples: [],
|
||||
dayKeyOf: localDayKey,
|
||||
}).series
|
||||
: await readRestingPulseProxy({
|
||||
userId,
|
||||
since,
|
||||
timeZone: userTz,
|
||||
});
|
||||
const restingByDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const s of restingPulseSeries) {
|
||||
const key = localDayKey(s.measuredAt);
|
||||
const cur = restingByDay.get(key) ?? { sum: 0, count: 0 };
|
||||
cur.sum += s.value;
|
||||
cur.count += 1;
|
||||
restingByDay.set(key, cur);
|
||||
}
|
||||
return Array.from(restingByDay.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([day, stats]) => ({ day, value: stats.sum / stats.count }));
|
||||
}
|
||||
@@ -28,7 +28,38 @@ import {
|
||||
import { dayGroupedListResponse } from "@/lib/measurements/list/day-grouped";
|
||||
import { sleepListResponse } from "@/lib/measurements/list/sleep-list";
|
||||
import { resolveStoredTimezone, resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import type { MeasurementType, Prisma } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* The columns a measurement row answers with (`MeasurementResource`), plus
|
||||
* the sealed note `shapeMeasurementNotes` decrypts onto `notes` and drops.
|
||||
* Named column by column so a column added to the model reaches the wire
|
||||
* only when someone adds it here.
|
||||
*/
|
||||
const MEASUREMENT_WIRE_SELECT = {
|
||||
id: true,
|
||||
userId: true,
|
||||
type: true,
|
||||
value: true,
|
||||
valueMin: true,
|
||||
valueMax: true,
|
||||
unit: true,
|
||||
source: true,
|
||||
measuredAt: true,
|
||||
notes: true,
|
||||
notesEncrypted: true,
|
||||
externalId: true,
|
||||
externalSourceVersion: true,
|
||||
aggregationProvenance: true,
|
||||
glucoseContext: true,
|
||||
sleepStage: true,
|
||||
rhythmClassification: true,
|
||||
deviceType: true,
|
||||
syncVersion: true,
|
||||
deletedAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
} as const satisfies Prisma.MeasurementSelect;
|
||||
|
||||
type ListQuery = z.output<typeof listMeasurementsSchema>;
|
||||
type ListUser = { id: string; timezone: string | null };
|
||||
@@ -162,13 +193,16 @@ async function drillDownResponse(
|
||||
// returning 422 when a caller asks for more). `limit` is read straight
|
||||
// through because the validator has already clamped it.
|
||||
take: limit,
|
||||
select: MEASUREMENT_WIRE_SELECT,
|
||||
});
|
||||
annotate({
|
||||
action: { name: "measurement.list" },
|
||||
meta: { total: samples.length, type, dayKey, mode: "drill-down" },
|
||||
});
|
||||
return apiSuccess({
|
||||
measurements: samples,
|
||||
// The same row shape as the plain page: the note decrypted onto `notes`,
|
||||
// the ciphertext never on the wire.
|
||||
measurements: samples.map(shapeMeasurementNotes),
|
||||
meta: { total: samples.length, limit, offset: 0, dayKey },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* The vocabulary of `GET /api/measurements/series`: the camelCase kinds the
|
||||
* route serves, the stored type and wire unit behind each, the point shape,
|
||||
* and the thresholds that decide when a sample-dense kind is bucketed.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { MeasurementType, SleepStage } from "@/generated/prisma/client";
|
||||
import { READING_SPREAD, standardDeviation } from "@/lib/stats/spread";
|
||||
|
||||
/**
|
||||
* Sample-dense kinds read raw rows only up to this window.
|
||||
* A CGM account stores ~288 BLOOD_GLUCOSE rows/day (105k+ rows/year) and
|
||||
* per-sample PULSE piles up similarly, so the 365 / 3650-day requests
|
||||
* walked a six-figure row set into JS, mapped it twice, and serialized
|
||||
* everything. Mirrors the sleep precedent above, but instead of silently
|
||||
* clamping the window, requests beyond the cap keep their full range and
|
||||
* are day-bucketed in SQL (one point per day, mean + min/max band) — the
|
||||
* chart still shows the whole requested history, just at daily grain.
|
||||
* The short windows users actually browse (7 / 30 / 90 d) stay raw and
|
||||
* byte-identical. Sparse kinds (weight, BP, …) are untouched.
|
||||
*/
|
||||
export const DENSE_SERIES_RAW_WINDOW_DAYS = 90;
|
||||
/**
|
||||
* The kinds a device can write at sampling rate. Heart-rate variability and
|
||||
* blood oxygen joined pulse and glucose once watches and rings began writing
|
||||
* them every few minutes overnight: their 3650-day "Alle" range read every
|
||||
* raw row, which is the read this rule exists to stop.
|
||||
*/
|
||||
export const DENSE_SERIES_KINDS: ReadonlySet<string> = new Set([
|
||||
"glucose",
|
||||
"pulse",
|
||||
"heartRateVariability",
|
||||
"oxygenSaturation",
|
||||
]);
|
||||
|
||||
/**
|
||||
* #1023 — inside the raw window, a dense-kind series (pulse, glucose, HRV,
|
||||
* blood oxygen) with more rows than this is bucketed per local hour in SQL
|
||||
* instead of sent raw.
|
||||
*
|
||||
* A watch that records heart rate once a minute puts 43 000 rows in the
|
||||
* default 30-day window and 130 000 in 90 days: a 15 MB response the client
|
||||
* could not draw at that resolution anyway, built from as many objects on the
|
||||
* server. A CGM reading every five minutes puts 26 000 glucose rows in 90
|
||||
* days, the same problem at a smaller scale. Hourly buckets carry the hour's
|
||||
* mean (pulse also its low/high band, the shape an hourly-bucket import
|
||||
* already has) and cap the answer at 24 points a day; the statistics stay
|
||||
* computed over every raw reading. The cap sits far above what sparse sources
|
||||
* produce (a cuff, fingerstick glucose, hourly buckets over 90 days, a watch
|
||||
* sampling every five minutes over 30 days), so those series stay raw and
|
||||
* unchanged.
|
||||
*/
|
||||
export const DENSE_SERIES_RAW_ROW_CAP = 10_000;
|
||||
|
||||
export const kindEnum = z.enum([
|
||||
"weight",
|
||||
"bloodPressure",
|
||||
"pulse",
|
||||
"bodyFat",
|
||||
"glucose",
|
||||
"sleep",
|
||||
"steps",
|
||||
"totalBodyWater",
|
||||
"boneMass",
|
||||
"oxygenSaturation",
|
||||
// The iOS app surfaces these as series-capable, but
|
||||
// the route used to reject the trend/detail view request with a
|
||||
// 422. The underlying MeasurementType enum already carries each
|
||||
// value; this list extends the camelCase wire shape so the route
|
||||
// matches the data it can serve.
|
||||
"restingHeartRate",
|
||||
"heartRateVariability",
|
||||
"vo2Max",
|
||||
]);
|
||||
|
||||
export type SeriesKind = z.infer<typeof kindEnum>;
|
||||
|
||||
export const KIND_TO_TYPE: Record<SeriesKind, MeasurementType> = {
|
||||
weight: "WEIGHT",
|
||||
bloodPressure: "BLOOD_PRESSURE_SYS",
|
||||
pulse: "PULSE",
|
||||
bodyFat: "BODY_FAT",
|
||||
glucose: "BLOOD_GLUCOSE",
|
||||
sleep: "SLEEP_DURATION",
|
||||
steps: "ACTIVITY_STEPS",
|
||||
totalBodyWater: "TOTAL_BODY_WATER",
|
||||
boneMass: "BONE_MASS",
|
||||
oxygenSaturation: "OXYGEN_SATURATION",
|
||||
restingHeartRate: "RESTING_HEART_RATE",
|
||||
heartRateVariability: "HEART_RATE_VARIABILITY",
|
||||
vo2Max: "VO2_MAX",
|
||||
};
|
||||
|
||||
/**
|
||||
* Explicit per-kind unit token returned at the top level so
|
||||
* the client never has to infer the value's unit. `bloodPressure` reuses
|
||||
* the systolic type's unit (mmHg, same for both lines). `sleep` is
|
||||
* overridden at request time to `"h"` because the route returns per-night
|
||||
* TIME-ASLEEP in hours rather than the canonical per-stage minutes.
|
||||
*
|
||||
* `glucose` is the canonical-stored unit (mg/dL) here; it is
|
||||
* overridden at request time to the user's `glucoseUnit` preference and the
|
||||
* point values are converted to match, so this wire DTO reads in the SAME
|
||||
* unit as the CSV + FHIR exports and the blood-glucose detail page (one
|
||||
* number, one engine).
|
||||
*/
|
||||
export const SERIES_UNIT: Record<SeriesKind, string> = {
|
||||
weight: "kg",
|
||||
bloodPressure: "mmHg",
|
||||
pulse: "bpm",
|
||||
bodyFat: "%",
|
||||
glucose: "mg/dL",
|
||||
sleep: "h",
|
||||
steps: "steps",
|
||||
totalBodyWater: "kg",
|
||||
boneMass: "kg",
|
||||
oxygenSaturation: "%",
|
||||
restingHeartRate: "bpm",
|
||||
heartRateVariability: "ms",
|
||||
vo2Max: "mL/(kg·min)",
|
||||
};
|
||||
|
||||
export interface SeriesPoint {
|
||||
id: string;
|
||||
at: string;
|
||||
value: number;
|
||||
secondary: number | null;
|
||||
/**
|
||||
* Per-stage minutes for a sleep night point. `null` for
|
||||
* every non-sleep kind. Lets a sleep detail view render the night's
|
||||
* stage breakdown without a second fetch; the headline `value` stays
|
||||
* the night's TIME-ASLEEP total.
|
||||
*/
|
||||
sleepStages?: Partial<Record<SleepStage, number>> | null;
|
||||
/**
|
||||
* (iOS #34 extension) — per-point spread for `kind=pulse`. On an
|
||||
* aggregated hourly heart-rate bucket `value` is the hour's AVERAGE bpm
|
||||
* and these carry the hour's MIN / MAX. `null` for a per-sample PULSE row
|
||||
* (no bucket spread) and absent for every non-pulse kind, so a client can
|
||||
* render a low/high band only where the data supports it.
|
||||
*/
|
||||
valueMin?: number | null;
|
||||
valueMax?: number | null;
|
||||
}
|
||||
|
||||
export function stdDev(values: number[]): number {
|
||||
if (values.length < 2) return 0;
|
||||
const sd = standardDeviation(values, READING_SPREAD) ?? 0;
|
||||
return Math.round(sd * 100) / 100;
|
||||
}
|
||||
|
||||
/** The stat strip, when a reader computes it from the raw rows itself. */
|
||||
export interface SeriesStats {
|
||||
mean: number;
|
||||
min: number;
|
||||
max: number;
|
||||
stdDev: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** What one reader hands back: the points, and the unit and stats it fixed. */
|
||||
export interface SeriesRead {
|
||||
points: SeriesPoint[];
|
||||
unit?: string;
|
||||
statsOverride?: SeriesStats | null;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* The blood-pressure series: systolic points paired with the diastolic of
|
||||
* the same source within five minutes, from the ladder's one source per day.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { VALUE_RANGES } from "@/lib/validations/measurement";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql";
|
||||
|
||||
import type { SeriesRead } from "./kinds";
|
||||
|
||||
export async function readBloodPressureSeries(
|
||||
userId: string,
|
||||
since: Date,
|
||||
): Promise<SeriesRead> {
|
||||
// Exclude non-physiological rows (systolic < 40 / diastolic < 20) from the
|
||||
// series AND the implicit "latest" (the last point). BP is two rows paired
|
||||
// client-side, so a corrupt row that slipped in before the input floor was
|
||||
// enforced — a seed-era systolic-0, iOS #33 — must never surface as the
|
||||
// latest reading. Floors mirror VALUE_RANGES (the input validator's min);
|
||||
// this is a read-side selection guard, not a tightening of the write floor.
|
||||
//
|
||||
// The ladder picks ONE source per day for the reading, not one per type:
|
||||
// the source is chosen from the day's systolic rows that pass the floor,
|
||||
// and both halves are read from it. Two per-type picks diverge whenever a
|
||||
// source holds only half of a reading on a day (a deleted diastolic, a
|
||||
// sub-floor systolic, an orphaned sample), and the pairing below then
|
||||
// joined one source's systolic to another source's diastolic. Picking on
|
||||
// floor-passing systolic rows also keeps a corrupt row from claiming a day
|
||||
// it then contributes nothing to. The day is the UTC day, as in every
|
||||
// other ladder read (`canonicalMeasurementsCte`), and the pick looks at
|
||||
// the whole edge day, not only the slice after `since`.
|
||||
//
|
||||
// `rank` is the closed-enum whitelist splice from `buildSourceRankCase`;
|
||||
// every request value is a positional parameter.
|
||||
const rank = buildSourceRankCase(
|
||||
await loadUserSourcePriority(userId),
|
||||
'"type"',
|
||||
'"source"',
|
||||
);
|
||||
const bpRows = await prisma.$queryRawUnsafe<
|
||||
Array<{
|
||||
id: string;
|
||||
type: "BLOOD_PRESSURE_SYS" | "BLOOD_PRESSURE_DIA";
|
||||
source: string;
|
||||
value: number;
|
||||
measured_at: Date;
|
||||
}>
|
||||
>(
|
||||
`
|
||||
SELECT m."id", m."type"::text AS type, m."source"::text AS source,
|
||||
m."value", m."measured_at"
|
||||
FROM measurements m
|
||||
JOIN (
|
||||
SELECT DISTINCT ON (date_trunc('day', "measured_at"))
|
||||
date_trunc('day', "measured_at") AS d,
|
||||
"source" AS canon
|
||||
FROM measurements
|
||||
WHERE "user_id" = $1
|
||||
AND "type" = 'BLOOD_PRESSURE_SYS'::"measurement_type"
|
||||
AND "deleted_at" IS NULL
|
||||
AND "value" >= $3
|
||||
AND "measured_at" >= date_trunc('day', $2::timestamptz AT TIME ZONE 'UTC')
|
||||
ORDER BY date_trunc('day', "measured_at"), (${rank}), "source"::text
|
||||
) c
|
||||
ON c.d = date_trunc('day', m."measured_at")
|
||||
AND c.canon = m."source"
|
||||
WHERE m."user_id" = $1
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."measured_at" >= $2
|
||||
AND (
|
||||
(m."type" = 'BLOOD_PRESSURE_SYS'::"measurement_type" AND m."value" >= $3)
|
||||
OR (m."type" = 'BLOOD_PRESSURE_DIA'::"measurement_type" AND m."value" >= $4)
|
||||
)
|
||||
ORDER BY m."measured_at" ASC, m."id" ASC
|
||||
`,
|
||||
userId,
|
||||
since,
|
||||
VALUE_RANGES.BLOOD_PRESSURE_SYS.min,
|
||||
VALUE_RANGES.BLOOD_PRESSURE_DIA.min,
|
||||
);
|
||||
const sys = bpRows.filter((r) => r.type === "BLOOD_PRESSURE_SYS");
|
||||
// Diastolic rows per source: a pair is only ever made inside one source,
|
||||
// which also holds across midnight, where the two days may have picked
|
||||
// different sources.
|
||||
const diaBySource = new Map<string, typeof bpRows>();
|
||||
for (const r of bpRows) {
|
||||
if (r.type !== "BLOOD_PRESSURE_DIA") continue;
|
||||
const list = diaBySource.get(r.source);
|
||||
if (list) list.push(r);
|
||||
else diaBySource.set(r.source, [r]);
|
||||
}
|
||||
|
||||
// Pair by closest timestamp within ±5 minutes. Both reads arrive
|
||||
// sorted ascending, so the nearest-diastolic index is non-decreasing
|
||||
// across the systolic walk — a two-pointer merge (one pointer per
|
||||
// source) finds each pair in O(sys + dia) instead of the previous full
|
||||
// diastolic rescan per systolic row (O(sys × dia) — quadratic on dense
|
||||
// imports). Ties keep the earlier diastolic row, matching the old
|
||||
// scan's strict-`<` keep.
|
||||
const PAIR_WINDOW_MS = 5 * 60_000;
|
||||
const diaIdx = new Map<string, number>();
|
||||
const points = sys.map((s) => {
|
||||
const sysMs = s.measured_at.getTime();
|
||||
const dia = diaBySource.get(s.source) ?? [];
|
||||
let idx = diaIdx.get(s.source) ?? 0;
|
||||
while (
|
||||
idx + 1 < dia.length &&
|
||||
Math.abs(dia[idx + 1].measured_at.getTime() - sysMs) <
|
||||
Math.abs(dia[idx].measured_at.getTime() - sysMs)
|
||||
) {
|
||||
idx += 1;
|
||||
}
|
||||
diaIdx.set(s.source, idx);
|
||||
const best = dia[idx];
|
||||
const paired =
|
||||
best !== undefined &&
|
||||
Math.abs(best.measured_at.getTime() - sysMs) <= PAIR_WINDOW_MS;
|
||||
return {
|
||||
id: s.id,
|
||||
at: s.measured_at.toISOString(),
|
||||
value: s.value,
|
||||
secondary: paired ? best.value : null,
|
||||
};
|
||||
});
|
||||
return { points };
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* The bucketed series of a sample-dense kind: day buckets past the raw
|
||||
* window, hour buckets for a stream too dense to send raw inside it, with
|
||||
* the stat strip aggregated over every raw reading in SQL.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { readDenseSeries } from "@/lib/measurements/dense-series-read";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { seriesRowsFrom } from "@/lib/measurements/series-canonical";
|
||||
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
|
||||
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
|
||||
import {
|
||||
DENSE_SERIES_KINDS,
|
||||
DENSE_SERIES_RAW_ROW_CAP,
|
||||
DENSE_SERIES_RAW_WINDOW_DAYS,
|
||||
KIND_TO_TYPE,
|
||||
type SeriesKind,
|
||||
type SeriesPoint,
|
||||
type SeriesRead,
|
||||
type SeriesStats,
|
||||
} from "./kinds";
|
||||
|
||||
/** Whether this request takes the bucketed read. */
|
||||
export async function takesBucketedRead(
|
||||
userId: string,
|
||||
kind: SeriesKind,
|
||||
days: number,
|
||||
since: Date,
|
||||
): Promise<boolean> {
|
||||
return (
|
||||
(DENSE_SERIES_KINDS.has(kind) && days > DENSE_SERIES_RAW_WINDOW_DAYS) ||
|
||||
(DENSE_SERIES_KINDS.has(kind) &&
|
||||
(await prisma.measurement.count({
|
||||
where: {
|
||||
userId,
|
||||
type: KIND_TO_TYPE[kind],
|
||||
measuredAt: { gte: since },
|
||||
deletedAt: null,
|
||||
},
|
||||
})) > DENSE_SERIES_RAW_ROW_CAP)
|
||||
);
|
||||
}
|
||||
|
||||
export async function readBucketedSeries(
|
||||
userId: string,
|
||||
kind: SeriesKind,
|
||||
days: number,
|
||||
since: Date,
|
||||
): Promise<SeriesRead> {
|
||||
let points: SeriesPoint[] = [];
|
||||
let unit: string | undefined;
|
||||
let statsOverride: SeriesStats | null = null;
|
||||
// Day buckets past the raw window; hour buckets for a dense-kind
|
||||
// stream too dense to send raw inside it.
|
||||
const grain = days > DENSE_SERIES_RAW_WINDOW_DAYS ? "day" : "hour";
|
||||
// Long-window read of a sample-dense kind (CGM glucose,
|
||||
// per-sample / hourly pulse). Day-bucket in SQL instead of walking
|
||||
// every raw row into JS: one aggregate pass in Postgres returns at
|
||||
// most `days` rows regardless of sample density. Bound parameters
|
||||
// mirror the rollup tier's `date_trunc(... ) GROUP BY` aggregate
|
||||
// (measurement-rollups.ts), and the rows are the ladder-winning source per
|
||||
// day, the same rule the rollup tier and sleep apply.
|
||||
const [userTz, priorityJson] = await Promise.all([
|
||||
resolveUserTimezone(userId),
|
||||
loadUserSourcePriority(userId),
|
||||
]);
|
||||
const type = KIND_TO_TYPE[kind];
|
||||
const seriesRows = seriesRowsFrom(priorityJson, type, days);
|
||||
// A day of a type with an activity-dependent sampling rate (pulse) is the
|
||||
// weighted mean of its local hours' means, so a workout hour with hundreds
|
||||
// of times the readings does not outweigh the rest of the day (see
|
||||
// `day-statistic.ts`). The hour grain is already one mean per hour.
|
||||
const { bucketRows, rawAgg } = await readDenseSeries({
|
||||
userId: userId,
|
||||
type,
|
||||
grain,
|
||||
userTz,
|
||||
since,
|
||||
seriesRows,
|
||||
});
|
||||
const round2 = (v: number) => Math.round(v * 100) / 100;
|
||||
const dayId = (d: Date) =>
|
||||
grain === "day"
|
||||
? `day:${userDayKey(d, userTz)}`
|
||||
: `hour:${d.toISOString()}`;
|
||||
if (kind === "glucose") {
|
||||
// Same unit engine as the raw glucose branch: canonical mg/dL in
|
||||
// the DB, converted ONCE at serialization to the user's preference.
|
||||
const profile = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { glucoseUnit: true },
|
||||
});
|
||||
const glucoseUnit = resolveGlucoseUnit(profile?.glucoseUnit ?? null);
|
||||
unit = glucoseUnit;
|
||||
points = bucketRows.map((r) => ({
|
||||
id: dayId(r.bucket_start),
|
||||
at: r.bucket_start.toISOString(),
|
||||
value: convertGlucose(r.mean, glucoseUnit),
|
||||
secondary: null,
|
||||
}));
|
||||
if (
|
||||
rawAgg !== undefined &&
|
||||
rawAgg.n > 0 &&
|
||||
rawAgg.mean !== null &&
|
||||
rawAgg.min !== null &&
|
||||
rawAgg.max !== null
|
||||
) {
|
||||
// Mirrors the raw branch's v1.16.16 parity: round the mg/dL
|
||||
// aggregate to 2 decimals (summarize()'s convention), then
|
||||
// convert each figure once.
|
||||
statsOverride = {
|
||||
mean: convertGlucose(round2(rawAgg.mean), glucoseUnit),
|
||||
min: convertGlucose(rawAgg.min, glucoseUnit),
|
||||
max: convertGlucose(rawAgg.max, glucoseUnit),
|
||||
stdDev: convertGlucose(round2(rawAgg.sd ?? 0), glucoseUnit),
|
||||
count: rawAgg.n,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// `value` is the bucket's average. For pulse, valueMin/valueMax carry
|
||||
// the bucket's low/high band (folding each hourly import bucket's own
|
||||
// spread via the COALESCE above), same band semantics as the per-hour
|
||||
// shape. The other dense kinds keep the bare point shape their raw rows
|
||||
// have, so a client reads a bucket the way it reads a reading.
|
||||
points = bucketRows.map((r) => ({
|
||||
id: dayId(r.bucket_start),
|
||||
at: r.bucket_start.toISOString(),
|
||||
value: round2(r.mean),
|
||||
secondary: null,
|
||||
...(kind === "pulse"
|
||||
? { valueMin: r.min_value, valueMax: r.max_value }
|
||||
: {}),
|
||||
}));
|
||||
if (
|
||||
rawAgg !== undefined &&
|
||||
rawAgg.n > 0 &&
|
||||
rawAgg.mean !== null &&
|
||||
rawAgg.min !== null &&
|
||||
rawAgg.max !== null
|
||||
) {
|
||||
statsOverride = {
|
||||
mean: round2(rawAgg.mean),
|
||||
min: rawAgg.min,
|
||||
max: rawAgg.max,
|
||||
stdDev: round2(rawAgg.sd ?? 0),
|
||||
count: rawAgg.n,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { points, unit, statsOverride };
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* The raw series: the ladder-winning source per day, row by row, with the
|
||||
* glucose unit and the hourly-mean stat strip applied where they belong.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
|
||||
import { readingsMean } from "@/lib/measurements/day-mean";
|
||||
import { seriesRowsFrom } from "@/lib/measurements/series-canonical";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
|
||||
import {
|
||||
KIND_TO_TYPE,
|
||||
stdDev,
|
||||
type SeriesKind,
|
||||
type SeriesPoint,
|
||||
type SeriesRead,
|
||||
type SeriesStats,
|
||||
} from "./kinds";
|
||||
|
||||
export async function readRawSeries(
|
||||
userId: string,
|
||||
kind: SeriesKind,
|
||||
days: number,
|
||||
since: Date,
|
||||
): Promise<SeriesRead> {
|
||||
let points: SeriesPoint[] = [];
|
||||
let unit: string | undefined;
|
||||
let statsOverride: SeriesStats | null = null;
|
||||
const type = KIND_TO_TYPE[kind];
|
||||
// (iOS #34 extension) — pull the per-bucket spread for the
|
||||
// heart-rate kind so the chart can render a low/high band around the
|
||||
// hourly average. Only PULSE rows ever carry a non-null spread (the
|
||||
// hourly HR bucket); every other kind selects the bare value shape.
|
||||
const includeSpread = kind === "pulse";
|
||||
// The ladder-winning source per day (see `seriesRowsFrom`), so a second
|
||||
// provider neither blends into nor duplicates the series. Bounded as before:
|
||||
// every dense kind reaches this read only inside 90 days and under 10 000
|
||||
// rows (counted first), and the sparse kinds are a few readings a day.
|
||||
const sourcePriority = await loadUserSourcePriority(userId);
|
||||
const rawRows = await prisma.$queryRawUnsafe<
|
||||
Array<{
|
||||
id: string;
|
||||
value: number;
|
||||
measured_at: Date;
|
||||
value_min: number | null;
|
||||
value_max: number | null;
|
||||
}>
|
||||
>(
|
||||
`
|
||||
SELECT m."id", m."value", m."measured_at", m."value_min", m."value_max"
|
||||
FROM ${seriesRowsFrom(sourcePriority, type, days)}
|
||||
WHERE m."measured_at" >= $2
|
||||
ORDER BY m."measured_at" ASC, m."id" ASC
|
||||
`,
|
||||
userId,
|
||||
since,
|
||||
);
|
||||
const rows = rawRows.map((r) => ({
|
||||
id: r.id,
|
||||
value: r.value,
|
||||
measuredAt: r.measured_at,
|
||||
...(includeSpread ? { valueMin: r.value_min, valueMax: r.value_max } : {}),
|
||||
}));
|
||||
if (kind === "glucose") {
|
||||
// Glucose is stored canonical mg/dL; convert each point to
|
||||
// the user's display unit AT SERIALIZATION so the wire DTO is unit-
|
||||
// coherent with the CSV + FHIR exports (one number, one engine). The
|
||||
// top-level `unit` token follows. mg/dL-preference users are unchanged
|
||||
// (convertGlucose rounds the integer); mmol/L users see 1-decimal
|
||||
// values (100 → 5.5) and `unit: "mmol/L"`.
|
||||
const profile = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { glucoseUnit: true },
|
||||
});
|
||||
const glucoseUnit = resolveGlucoseUnit(profile?.glucoseUnit ?? null);
|
||||
unit = glucoseUnit;
|
||||
points = rows.map((r) => ({
|
||||
id: r.id,
|
||||
at: r.measuredAt.toISOString(),
|
||||
value: convertGlucose(r.value, glucoseUnit),
|
||||
secondary: null,
|
||||
}));
|
||||
// Stat parity. The stat strip's mean/min/max/stdDev must
|
||||
// match the detail-page + FHIR convention: aggregate over RAW mg/dL,
|
||||
// then convert each resulting figure ONCE. Deriving the stats from the
|
||||
// already-converted+rounded points double-rounds the mean and stdDev
|
||||
// for mmol/L users, drifting the last decimal off the detail page.
|
||||
// Single-reading sets stay identical (100 → 5.5). Hand back stats here
|
||||
// so the shared block below leaves a populated `statsOverride` alone.
|
||||
const rawDataPoints: DataPoint[] = rows.map((r) => ({
|
||||
date: r.measuredAt,
|
||||
value: r.value,
|
||||
}));
|
||||
if (rawDataPoints.length > 0) {
|
||||
const rawSummary = summarize(rawDataPoints);
|
||||
const rawValues = rawDataPoints.map((p) => p.value);
|
||||
statsOverride = {
|
||||
mean:
|
||||
rawSummary.mean === null
|
||||
? 0
|
||||
: convertGlucose(rawSummary.mean, glucoseUnit),
|
||||
min:
|
||||
rawSummary.min === null
|
||||
? 0
|
||||
: convertGlucose(rawSummary.min, glucoseUnit),
|
||||
max:
|
||||
rawSummary.max === null
|
||||
? 0
|
||||
: convertGlucose(rawSummary.max, glucoseUnit),
|
||||
// Glucose mg/dL↔mmol/L is purely multiplicative (no offset), so a
|
||||
// spread converts with the same factor as a level.
|
||||
stdDev: convertGlucose(stdDev(rawValues), glucoseUnit),
|
||||
count: rawSummary.count,
|
||||
};
|
||||
}
|
||||
} else if (includeSpread) {
|
||||
// (iOS #34 extension) — pulse points carry the per-bucket
|
||||
// spread. A per-sample PULSE row has null min/max; an hourly HR bucket
|
||||
// carries the hour's range around the average.
|
||||
points = rows.map((r) => {
|
||||
const row = r as typeof r & {
|
||||
valueMin: number | null;
|
||||
valueMax: number | null;
|
||||
};
|
||||
return {
|
||||
id: row.id,
|
||||
at: row.measuredAt.toISOString(),
|
||||
value: row.value,
|
||||
secondary: null,
|
||||
valueMin: row.valueMin ?? null,
|
||||
valueMax: row.valueMax ?? null,
|
||||
};
|
||||
});
|
||||
// An hourly-mean type's strip mean is the mean of its local days'
|
||||
// values, each day the mean of its local hours' means, so a dense
|
||||
// workout hour does not outweigh the rest of the window. Count, min,
|
||||
// max and the spread stay over every reading.
|
||||
if (usesHourlyMeanDay(type) && rows.length > 0) {
|
||||
const userTz = await resolveUserTimezone(userId);
|
||||
const values = rows.map((r) => r.value);
|
||||
const rawSummary = summarize(
|
||||
rows.map((r) => ({ date: r.measuredAt, value: r.value })),
|
||||
);
|
||||
const mean = readingsMean(type, rows, userTz);
|
||||
statsOverride = {
|
||||
mean: mean === null ? 0 : Math.round(mean * 100) / 100,
|
||||
min: rawSummary.min ?? 0,
|
||||
max: rawSummary.max ?? 0,
|
||||
stdDev: stdDev(values),
|
||||
count: rawSummary.count,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
points = rows.map((r) => ({
|
||||
id: r.id,
|
||||
at: r.measuredAt.toISOString(),
|
||||
value: r.value,
|
||||
secondary: null,
|
||||
}));
|
||||
}
|
||||
return { points, unit, statsOverride };
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* `GET /api/measurements/series` after its query has been validated: pick the
|
||||
* reader the kind and window call for, then add the judgements a client would
|
||||
* otherwise make itself — the change direction, the change over the window,
|
||||
* the reference lines — and the stat strip.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { resolveGlucoseUnit } from "@/lib/glucose";
|
||||
import { resolveTrendVerdict } from "@/lib/targets/trend-verdict";
|
||||
import {
|
||||
computeSeriesChange,
|
||||
seriesDayValues,
|
||||
seriesReferenceValue,
|
||||
} from "@/lib/measurements/series-change";
|
||||
import { resolveSeriesReferenceLines } from "@/lib/measurements/series-reference-lines";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { resolveJobLocale } from "@/lib/i18n/job-locale";
|
||||
|
||||
import {
|
||||
KIND_TO_TYPE,
|
||||
SERIES_UNIT,
|
||||
stdDev,
|
||||
type SeriesKind,
|
||||
type SeriesRead,
|
||||
} from "./kinds";
|
||||
import { readBloodPressureSeries } from "./read-blood-pressure";
|
||||
import { readBucketedSeries, takesBucketedRead } from "./read-bucketed";
|
||||
import { readRawSeries } from "./read-raw";
|
||||
import { readSleepSeries } from "./read-sleep";
|
||||
|
||||
/** The reader this kind and window call for, in the route's old order. */
|
||||
async function readPoints(
|
||||
userId: string,
|
||||
kind: SeriesKind,
|
||||
days: number,
|
||||
since: Date,
|
||||
): Promise<SeriesRead> {
|
||||
if (kind === "sleep") return readSleepSeries(userId, since, days);
|
||||
if (kind === "bloodPressure") return readBloodPressureSeries(userId, since);
|
||||
if (await takesBucketedRead(userId, kind, days, since)) {
|
||||
return readBucketedSeries(userId, kind, days, since);
|
||||
}
|
||||
return readRawSeries(userId, kind, days, since);
|
||||
}
|
||||
|
||||
export async function readSeries(args: {
|
||||
/** The record read (the owner under a delegation). */
|
||||
userId: string;
|
||||
/** Who is reading: the labels are in their language. */
|
||||
actorId: string;
|
||||
kind: SeriesKind;
|
||||
days: number;
|
||||
}) {
|
||||
const { userId, actorId, kind, days } = args;
|
||||
const since = new Date(Date.now() - days * 86_400_000);
|
||||
|
||||
const read = await readPoints(userId, kind, days, since);
|
||||
// An explicit unit token so the client never infers the unit from the
|
||||
// kind. Sleep is served in hours and glucose in the user's unit; every
|
||||
// other kind carries its canonical stored unit.
|
||||
const unit = read.unit ?? SERIES_UNIT[kind];
|
||||
const points = read.points;
|
||||
const statsOverride = read.statsOverride ?? null;
|
||||
|
||||
const dataPoints: DataPoint[] = points.map((p) => ({
|
||||
date: new Date(p.at),
|
||||
value: p.value,
|
||||
}));
|
||||
const summary = dataPoints.length > 0 ? summarize(dataPoints) : null;
|
||||
const values = dataPoints.map((p) => p.value);
|
||||
|
||||
// The resolved judgements. The profile and targets are the
|
||||
// RECORD's (the chart is theirs); the labels are in the language of the
|
||||
// account reading it.
|
||||
const [record, reader, seriesTz] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
heightCm: true,
|
||||
thresholdsJson: true,
|
||||
glucoseUnit: true,
|
||||
locale: true,
|
||||
},
|
||||
}),
|
||||
actorId === userId
|
||||
? null
|
||||
: prisma.user.findUnique({
|
||||
where: { id: actorId },
|
||||
select: { locale: true },
|
||||
}),
|
||||
resolveUserTimezone(userId),
|
||||
]);
|
||||
const rangeProfile = {
|
||||
dateOfBirth: record?.dateOfBirth ?? null,
|
||||
gender: record?.gender ?? null,
|
||||
heightCm: record?.heightCm ?? null,
|
||||
};
|
||||
const verdictType = KIND_TO_TYPE[kind];
|
||||
const dayValues = seriesDayValues(verdictType, points, seriesTz);
|
||||
const reference = seriesReferenceValue(dayValues);
|
||||
const direction = resolveTrendVerdict(verdictType, {
|
||||
profile: rangeProfile,
|
||||
thresholdsJson: record?.thresholdsJson ?? null,
|
||||
// The verdict reads stored units: sleep is served in hours here and
|
||||
// stored in minutes. Glucose has no band of its own on this chart.
|
||||
referenceValue:
|
||||
reference !== null && kind === "sleep" ? reference * 60 : reference,
|
||||
});
|
||||
const translator = getServerTranslator(
|
||||
await resolveJobLocale((reader ?? record)?.locale ?? null),
|
||||
);
|
||||
const referenceLines = resolveSeriesReferenceLines(kind, {
|
||||
profile: rangeProfile,
|
||||
thresholdsJson: record?.thresholdsJson ?? null,
|
||||
glucoseUnit: resolveGlucoseUnit(record?.glucoseUnit ?? null),
|
||||
t: (key) => translator.t(key),
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "measurements.series" },
|
||||
meta: { kind, days, count: points.length },
|
||||
});
|
||||
|
||||
return {
|
||||
kind,
|
||||
unit,
|
||||
points,
|
||||
direction,
|
||||
change: computeSeriesChange(dayValues, direction),
|
||||
referenceLines,
|
||||
stats:
|
||||
statsOverride ??
|
||||
(summary
|
||||
? {
|
||||
mean: summary.mean,
|
||||
min: summary.min,
|
||||
max: summary.max,
|
||||
stdDev: stdDev(values),
|
||||
count: summary.count,
|
||||
}
|
||||
: { mean: 0, min: 0, max: 0, stdDev: 0, count: 0 }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* The sleep series: one point per night carrying its time asleep in hours.
|
||||
*/
|
||||
import { readSleepNights } from "@/lib/measurements/list/sleep-nights";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import type { SleepStage } from "@/generated/prisma/client";
|
||||
|
||||
import type { SeriesRead } from "./kinds";
|
||||
|
||||
export async function readSleepSeries(
|
||||
userId: string,
|
||||
since: Date,
|
||||
days: number,
|
||||
): Promise<SeriesRead> {
|
||||
// SLEEP_DURATION is stored one row per STAGE per night (minutes).
|
||||
// Collapse the stage rows into ONE point per night carrying the
|
||||
// night's TIME ASLEEP (CORE + DEEP + REM, excluding IN_BED + AWAKE),
|
||||
// converted to HOURS. The single-stage rows the legacy path returned
|
||||
// made the chart show one fragment per stage instead of a nightly
|
||||
// trend. The per-night reconstruction clusters stages into sessions
|
||||
// (so a midnight-spanning night stays one point) and collapses a
|
||||
// dual-source night to one canonical source via the user's `sleep`
|
||||
// priority ladder.
|
||||
//
|
||||
// Sleep is the one kind read row-per-stage rather than from the rollup
|
||||
// tier, so the nights are named in Postgres and reconstructed a chunk at
|
||||
// a time (`readSleepNights`): "All" reaches as far back as the history
|
||||
// does, and the stage rows in the heap stay a chunk's worth.
|
||||
const [tz, priorityJson] = await Promise.all([
|
||||
resolveUserTimezone(userId),
|
||||
loadUserSourcePriority(userId),
|
||||
]);
|
||||
const nights = await readSleepNights(userId, tz, priorityJson, {
|
||||
from: since,
|
||||
// A window of `days` days holds at most one night more than that.
|
||||
maxNights: days + 1,
|
||||
});
|
||||
const points = nights
|
||||
.filter((n) => n.asleepMinutes > 0)
|
||||
.map((n) => {
|
||||
const stageHours = Object.fromEntries(
|
||||
Object.entries(n.stages).map(([s, m]) => [
|
||||
s,
|
||||
Math.round((m / 60) * 100) / 100,
|
||||
]),
|
||||
) as Partial<Record<SleepStage, number>>;
|
||||
return {
|
||||
id: `sleep:${n.night}`,
|
||||
at: n.measuredAt.toISOString(),
|
||||
value: Math.round((n.asleepMinutes / 60) * 100) / 100,
|
||||
secondary: null,
|
||||
sleepStages: Object.keys(stageHours).length > 0 ? stageHours : null,
|
||||
};
|
||||
});
|
||||
return { points, unit: "h" };
|
||||
}
|
||||
@@ -83,7 +83,7 @@ describe("sendsMedicationReminders", () => {
|
||||
}
|
||||
for (const dto of [
|
||||
"lib/medications/list-read.ts",
|
||||
"app/api/medications/[id]/route.ts",
|
||||
"lib/medications/read-medication.ts",
|
||||
]) {
|
||||
expect(src(dto), dto).toMatch(
|
||||
/remindersActive:\s*sendsMedicationReminders\(/,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Delete one medication: `DELETE /api/medications/{id}`.
|
||||
*
|
||||
* The route authenticates (the caller's own account) and runs the ownership
|
||||
* guard; the token revocation, the delete, its audit row and its tails run
|
||||
* here, in the order they always ran.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
enqueueSupplementNutrientRecompute,
|
||||
medicationHasComposition,
|
||||
} from "@/lib/medications/ingredients/recompute-job";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { deleteMedicationCategory } from "@/lib/medication-category";
|
||||
|
||||
/**
|
||||
* The name the audit row carries, read after the ownership guard passed.
|
||||
* Null for the route's 404.
|
||||
*/
|
||||
export async function findMedicationForDelete(
|
||||
id: string,
|
||||
): Promise<{ name: string } | null> {
|
||||
return prisma.medication.findUnique({
|
||||
where: { id },
|
||||
select: { name: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteMedication(input: {
|
||||
userId: string;
|
||||
id: string;
|
||||
existing: { name: string };
|
||||
ipAddress: string | null;
|
||||
}): Promise<void> {
|
||||
const { userId, id, existing } = input;
|
||||
|
||||
// Revoke API tokens scoped to this medication
|
||||
const medicationScope = `medication:${id}:ingest`;
|
||||
await prisma.apiToken.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
revoked: false,
|
||||
permissions: { has: medicationScope },
|
||||
},
|
||||
data: { revoked: true },
|
||||
});
|
||||
|
||||
// Whether its doses counted toward the micronutrients, read
|
||||
// before the delete cascades the composition away.
|
||||
const hadComposition = await medicationHasComposition(userId, id);
|
||||
|
||||
await deleteMedicationCategory(id);
|
||||
await prisma.medication.delete({ where: { id } });
|
||||
|
||||
await auditLog("medication.delete", {
|
||||
userId,
|
||||
ipAddress: input.ipAddress,
|
||||
details: { medicationId: id, name: existing.name },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.delete",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
});
|
||||
|
||||
// Bust per-user medications + compliance + achievement
|
||||
// caches so the next read reflects the deletion.
|
||||
invalidateUserMedications(userId, { evict: true });
|
||||
|
||||
// Its doses are gone with it, so the days they counted on are recomputed.
|
||||
if (hadComposition) await enqueueSupplementNutrientRecompute(userId);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* What a bulk intake batch accumulates while its entries are walked: the
|
||||
* per-entry results and counters the response carries, and the distinct
|
||||
* days, slots and medications the tails after the loop act on.
|
||||
*/
|
||||
import type { UnstableExternalIdShape } from "@/lib/validations/external-id";
|
||||
import type { InjectionSiteValue } from "@/lib/validations/medication";
|
||||
|
||||
/**
|
||||
* One entry of `POST /api/medications/intake/bulk`, as the route's schema
|
||||
* parses it.
|
||||
*/
|
||||
export interface BulkIntakeEntry {
|
||||
medicationId: string;
|
||||
scheduledFor?: Date;
|
||||
takenAt?: Date;
|
||||
skipped: boolean;
|
||||
idempotencyKey?: string;
|
||||
injectionSite?: InjectionSiteValue;
|
||||
forceSlotInstant?: Date;
|
||||
doseTaken?: string;
|
||||
source?: "APPLE_HEALTH";
|
||||
externalId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `updated` joins the per-entry status vocabulary: a write that snaps onto
|
||||
* an existing scheduled-slot row (e.g. the pending REMINDER row) updates it
|
||||
* in place rather than inserting. The iOS sync engine treats `updated` the
|
||||
* same as `inserted` for cursor advancement — both mean "the server accepted
|
||||
* this entry and produced a row id".
|
||||
*/
|
||||
type EntryStatus = "inserted" | "updated" | "duplicate" | "skipped";
|
||||
export interface EntryResult {
|
||||
index: number;
|
||||
status: EntryStatus;
|
||||
reason?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface BatchState {
|
||||
results: EntryResult[];
|
||||
inserted: number;
|
||||
updated: number;
|
||||
duplicates: number;
|
||||
/**
|
||||
* External dose records kept as their own unscheduled dose because their
|
||||
* band slot already carried a different one.
|
||||
*/
|
||||
extraDosesUnscheduled: number;
|
||||
skipped: Array<{ index: number; reason: string }>;
|
||||
unstableShapes: UnstableExternalIdShape[];
|
||||
/**
|
||||
* Distinct `(medicationId, dayKey)` pairs touched by the batch, so one
|
||||
* dose-day refresh fires per pair after all inserts complete. A per-row
|
||||
* refresh would balloon a 500-entry batch into 500 sequential refreshes.
|
||||
*/
|
||||
touchedDays: Map<string, { medicationId: string; dayKey: string }>;
|
||||
/**
|
||||
* Distinct `(medicationId, scheduledFor)` slots whose state actually
|
||||
* changed in this batch (#22: a row inserted / updated), so the silent
|
||||
* cross-device sync fires once per affected slot — not once per row, and
|
||||
* not for pending echoes / duplicates that changed nothing.
|
||||
*/
|
||||
syncSlots: Map<string, { medicationId: string; scheduledFor: string }>;
|
||||
/**
|
||||
* A landed non-skipped dose quiets an active snooze, exactly like the
|
||||
* single-intake routes (they null `snoozedUntil` on every recorded take).
|
||||
* Collected per medication here, cleared once after the loop.
|
||||
*/
|
||||
snoozeClearIds: Set<string>;
|
||||
/**
|
||||
* Live rows already carrying one of the batch's external dose-event UUIDs,
|
||||
* and the rows this batch landed under one, so an intra-batch repeat of a
|
||||
* UUID dedups too.
|
||||
*/
|
||||
rowIdByExternalId: Map<string, string>;
|
||||
}
|
||||
|
||||
export function createBatchState(
|
||||
rowIdByExternalId: Map<string, string>,
|
||||
): BatchState {
|
||||
return {
|
||||
results: [],
|
||||
inserted: 0,
|
||||
updated: 0,
|
||||
duplicates: 0,
|
||||
extraDosesUnscheduled: 0,
|
||||
skipped: [],
|
||||
unstableShapes: [],
|
||||
touchedDays: new Map(),
|
||||
syncSlots: new Map(),
|
||||
snoozeClearIds: new Set<string>(),
|
||||
rowIdByExternalId,
|
||||
};
|
||||
}
|
||||
|
||||
/** Record an entry the batch skipped, with its reason. */
|
||||
export function skipEntry(state: BatchState, index: number, reason: string) {
|
||||
state.skipped.push({ index, reason });
|
||||
state.results.push({ index, status: "skipped", reason });
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Which slot one bulk intake entry binds to.
|
||||
*
|
||||
* Taken writes attribute through the window-band engine (the SAME minter the
|
||||
* read ledger + the compliance % consume), so a bulk-synced take binds to a
|
||||
* slot identically to the single-route write. The optional
|
||||
* `forceSlotInstant` pins an off-window take onto a named real slot. Pending
|
||||
* echoes and skips carry no take to attribute by, so they keep the canonical
|
||||
* anchor snap that binds them to the projector/worker-minted slot row.
|
||||
*/
|
||||
import type { SessionUser } from "@/lib/auth/session-user";
|
||||
import {
|
||||
findPinConflict,
|
||||
resolveForcedSlotForWrite,
|
||||
resolveSlotForWriteByBand,
|
||||
resolveSlotInstantForWrite,
|
||||
slotHeldByAnotherDose,
|
||||
} from "@/lib/medications/scheduling/slot-upsert";
|
||||
import type { BulkIntakeEntry } from "./batch-state";
|
||||
|
||||
export interface EntryAttribution {
|
||||
canonicalSlot: Date | null;
|
||||
/**
|
||||
* Binding provenance: USER_PIN on the forced path, AUTO on a band
|
||||
* decision. Pending echoes / skips carry no decision (undefined → the
|
||||
* upsert leaves an existing USER_PIN untouched).
|
||||
*/
|
||||
attributionSource: "AUTO" | "USER_PIN" | undefined;
|
||||
/**
|
||||
* Set when the band slot is held by a different dose and this take is
|
||||
* recorded unscheduled; it must not converge onto any slot row.
|
||||
*/
|
||||
heldByAnotherDose: boolean;
|
||||
}
|
||||
|
||||
interface EntryContext {
|
||||
user: Pick<SessionUser, "id" | "timezone">;
|
||||
entry: BulkIntakeEntry;
|
||||
incomingScheduledFor: Date;
|
||||
}
|
||||
|
||||
async function attributePin(
|
||||
ctx: EntryContext,
|
||||
forceSlotInstant: Date,
|
||||
): Promise<EntryAttribution | { reason: string }> {
|
||||
const { user, entry } = ctx;
|
||||
const canonicalSlot = await resolveForcedSlotForWrite({
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
userTz: user.timezone,
|
||||
slotInstant: forceSlotInstant,
|
||||
});
|
||||
if (canonicalSlot === null) {
|
||||
// Mirrors the single route's 422 (`medications.intake.force_slot
|
||||
// .invalid`) as a per-entry skip so one bad pin never fails the
|
||||
// batch.
|
||||
return { reason: "force_slot_invalid" };
|
||||
}
|
||||
// Refuse to pin onto a slot another recorded action
|
||||
// already serves: the explicit-write last-write-wins rule would
|
||||
// silently overwrite that dose record. Per-entry skip, mirroring
|
||||
// the single route's 422 `medications.intake.force_slot.occupied`.
|
||||
if (
|
||||
await findPinConflict({
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
canonicalSlot,
|
||||
incomingTakenAt: entry.takenAt ?? null,
|
||||
})
|
||||
) {
|
||||
return { reason: "force_slot_occupied" };
|
||||
}
|
||||
return {
|
||||
canonicalSlot,
|
||||
attributionSource: "USER_PIN",
|
||||
heldByAnotherDose: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function attributeTake(ctx: EntryContext): Promise<EntryAttribution> {
|
||||
const { user, entry } = ctx;
|
||||
const attribution = await resolveSlotForWriteByBand({
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
userTz: user.timezone,
|
||||
takenAt: entry.takenAt as Date,
|
||||
});
|
||||
let canonicalSlot = attribution.slotInstant;
|
||||
let heldByAnotherDose = false;
|
||||
// An external dose record (a HealthKit dose event) whose band slot
|
||||
// already carries a different recorded dose is an extra dose, not a
|
||||
// correction of that one. Converging would overwrite the slot's
|
||||
// take last-write-wins and the extra dose would exist nowhere; it is
|
||||
// recorded as its own unscheduled dose instead.
|
||||
if (
|
||||
canonicalSlot !== null &&
|
||||
entry.externalId !== undefined &&
|
||||
(await slotHeldByAnotherDose({
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
canonicalSlot,
|
||||
takenAt: entry.takenAt as Date,
|
||||
externalId: entry.externalId,
|
||||
scheduledFor: entry.scheduledFor ?? null,
|
||||
userTz: user.timezone,
|
||||
}))
|
||||
) {
|
||||
canonicalSlot = null;
|
||||
heldByAnotherDose = true;
|
||||
}
|
||||
return { canonicalSlot, attributionSource: "AUTO", heldByAnotherDose };
|
||||
}
|
||||
|
||||
/**
|
||||
* The slot of one entry, or the per-entry skip reason of a bad pin. The
|
||||
* write flags say what the entry is: an offline-sync replay echoes a PENDING
|
||||
* projection (no `takenAt`, `skipped:false`) for a slot the user may already
|
||||
* have actioned; an explicit `takenAt` or `skipped:true` is a real user
|
||||
* action and applies last-write-wins.
|
||||
*/
|
||||
export async function attributeEntry(
|
||||
ctx: EntryContext,
|
||||
flags: { isExplicitTaken: boolean; isExplicitSkip: boolean },
|
||||
): Promise<EntryAttribution | { reason: string }> {
|
||||
const { user, entry, incomingScheduledFor } = ctx;
|
||||
if (flags.isExplicitTaken && entry.forceSlotInstant !== undefined) {
|
||||
return attributePin(ctx, entry.forceSlotInstant);
|
||||
}
|
||||
if (flags.isExplicitTaken) {
|
||||
return attributeTake(ctx);
|
||||
}
|
||||
if (flags.isExplicitSkip && entry.scheduledFor === undefined) {
|
||||
// A slot-less deliberate skip resolves by band
|
||||
// membership on the skip moment, exactly like a take does. The
|
||||
// defaulted-now snap below only reaches the tight reminder-grace
|
||||
// window, so a skip tapped 20 minutes past the dose time inserted
|
||||
// an orphan ad-hoc skip row while the slot's pending REMINDER row
|
||||
// stayed open and later auto-missed — the user's deliberate skip
|
||||
// recorded as a miss.
|
||||
const attribution = await resolveSlotForWriteByBand({
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
userTz: user.timezone,
|
||||
takenAt: incomingScheduledFor,
|
||||
});
|
||||
return {
|
||||
canonicalSlot: attribution.slotInstant,
|
||||
attributionSource: undefined,
|
||||
heldByAnotherDose: false,
|
||||
};
|
||||
}
|
||||
// Source-agnostic anchor snap for pending echoes + skips.
|
||||
// iOS posts the reminder actions here (source API); without this
|
||||
// they inserted a SECOND row for a slot already carrying the
|
||||
// projector/worker's pending REMINDER row (the unique key includes
|
||||
// `source`, and the iOS-vs-server `scheduledFor` drifts by a
|
||||
// minute). Snap onto the canonical slot instant and update the
|
||||
// existing row in place.
|
||||
const canonicalSlot = await resolveSlotInstantForWrite({
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
userTz: user.timezone,
|
||||
incoming: incomingScheduledFor,
|
||||
// Only a client-supplied `scheduledFor` names a real slot. A
|
||||
// defaulted-now write must not snap across the wide ±halfGap
|
||||
// window onto a far slot (phantom morning dose).
|
||||
instantIsExplicit: entry.scheduledFor !== undefined,
|
||||
// Pending echoes / skips never record a take; a pending sync
|
||||
// echo legitimately maps to a future slot, so the taken
|
||||
// future-slot guard stays off here.
|
||||
isTakenWrite: false,
|
||||
});
|
||||
return {
|
||||
canonicalSlot,
|
||||
attributionSource: undefined,
|
||||
heldByAnotherDose: false,
|
||||
};
|
||||
}
|
||||
Loaded 100 of 445 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user