Merge branch 'release/v1.46.0' into feat/v146-ui

This commit is contained in:
Marc-André Bombeck committed 2026-10-23 16:44:26 +02:00
commit 551fbbbdec
445 files changed
+164623 -24087

No files matched your search

@@ -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",
},
{
+188
View File
@@ -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;
}
+173
View File
@@ -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,
};
}
+60
View File
@@ -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 };
}
+37
View File
@@ -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 }) };
}
+102
View File
@@ -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";
}
+383
View File
@@ -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(),
};
}
+307
View File
@@ -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]>;
+86
View File
@@ -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;
}
+66
View File
@@ -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) ?? [],
);
}
+142
View File
@@ -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 };
}
+144
View File
@@ -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 },
);
}
+209
View File
@@ -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,
};
}
+500
View File
@@ -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;
}
+161
View File
@@ -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 };
}
+253
View File
@@ -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;
}
}
+101
View File
@@ -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 };
}
+17
View File
@@ -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,
};
}
+133
View File
@@ -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,
};
}
+202
View File
@@ -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 };
}
+129
View File
@@ -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,
};
}
+224
View File
@@ -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 }));
}
+36 -2
View File
@@ -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 },
});
}
+167
View File
@@ -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 };
}
+165
View File
@@ -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 };
}
+148
View File
@@ -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 }),
};
}
+57
View File
@@ -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\(/,
+77
View File
@@ -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,
};
}
@@ -0,0 +1,251 @@
/**
* The row one bulk intake entry lands on: the canonical slot write, the
* idempotency replay, the convergence probe onto a row already on the named
* instant, or the standalone insert, with the inventory hook each one owes.
*/
import { prisma } from "@/lib/db";
import type { SessionUser } from "@/lib/auth/session-user";
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
import {
applyCanonicalSlotWrite,
mayConvergeOntoSuppliedSlot,
} from "@/lib/medications/scheduling/slot-upsert";
import {
consumeForIntake,
restoreForIntake,
} from "@/lib/medications/inventory/consumption";
import type { BatchState, BulkIntakeEntry } from "./batch-state";
import type { EntryAttribution } from "./entry-attribution";
export interface EntryWrite {
user: Pick<SessionUser, "id" | "timezone">;
entry: BulkIntakeEntry;
index: number;
incomingScheduledFor: Date;
isExplicitTaken: boolean;
isExplicitSkip: boolean;
attribution: EntryAttribution;
resolvedInjectionSite: InjectionSiteKey | null;
}
/**
* Converge onto the one 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.
*/
async function writeOntoSlot(
write: EntryWrite,
state: BatchState,
canonicalSlot: Date,
): Promise<void> {
const { user, entry, index: i, isExplicitTaken, isExplicitSkip } = write;
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,
// An Apple dose-event import stamps its provenance on a
// fresh row; every other caller keeps today's "API".
createSource: entry.source ?? "API",
injectionSite: write.resolvedInjectionSite,
attributionSource: write.attribution.attributionSource,
// Dose override only documents a consumed dose.
doseTaken: (!entry.skipped && entry.doseTaken) || null,
// 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.
state.duplicates += 1;
state.results.push({ index: i, status: "duplicate", id: applied.row.id });
} else if (applied.outcome === "updated") {
state.updated += 1;
state.results.push({ index: i, status: "updated", id: applied.row.id });
} else {
state.inserted += 1;
state.results.push({ index: i, status: "inserted", id: applied.row.id });
}
// 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 stamped 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,
});
}
}
/**
* The 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"). 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).
*/
async function replayedRowId(write: EntryWrite): Promise<string | null> {
const { user, entry } = write;
if (!entry.idempotencyKey) return null;
const replay = await prisma.medicationIntakeEvent.findUnique({
where: {
userId_idempotencyKey: {
userId: user.id,
idempotencyKey: entry.idempotencyKey,
},
},
select: { id: true },
});
return replay?.id ?? null;
}
/**
* 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".
*/
async function insertStandalone(
write: EntryWrite,
state: BatchState,
): Promise<Date> {
const { user, entry, index: i, resolvedInjectionSite } = write;
const effectiveScheduledFor = entry.takenAt ?? write.incomingScheduledFor;
const row = await prisma.medicationIntakeEvent.create({
data: {
userId: user.id,
medicationId: entry.medicationId,
scheduledFor: effectiveScheduledFor,
takenAt: entry.takenAt ?? null,
skipped: entry.skipped,
// Apple provenance on a standalone (ad-hoc) row;
// every other caller keeps today's "API".
source: entry.source ?? "API",
idempotencyKey: entry.idempotencyKey ?? null,
// External dose-event UUID for the idempotent
// re-sync dedup.
...(entry.externalId !== undefined && {
externalId: entry.externalId,
}),
// Site only on a resolved taken-injection entry.
...(resolvedInjectionSite !== null && {
injectionSite: resolvedInjectionSite,
}),
// Dose override only on a taken entry carrying one.
...(!entry.skipped && entry.doseTaken && { doseTaken: entry.doseTaken }),
},
});
state.inserted += 1;
state.results.push({ index: i, status: "inserted", id: row.id });
// A fresh standalone taken row consumes inventory
// units (a fresh skip / pending row has nothing to consume or
// refund).
if (write.isExplicitTaken) {
await consumeForIntake({
client: prisma,
userId: user.id,
medicationId: entry.medicationId,
eventId: row.id,
intakeAt: entry.takenAt as Date,
});
}
return effectiveScheduledFor;
}
/**
* Write the entry. The answer is the `scheduledFor` instant the row actually
* lands on, so the dose-day refresh keys the same day the stored row anchors
* to, or null when an idempotency replay answered the entry and nothing
* after the write applies to it.
*/
export async function writeEntry(
write: EntryWrite,
state: BatchState,
): Promise<Date | null> {
const { user, entry, index: i, incomingScheduledFor } = write;
const { canonicalSlot, heldByAnotherDose } = write.attribution;
if (canonicalSlot) {
// Scheduled dose — converge onto the one canonical slot row.
await writeOntoSlot(write, state, canonicalSlot);
return canonicalSlot;
}
// 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 (see `replayedRowId`);
// 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.
const replayId = await replayedRowId(write);
if (replayId !== null) {
state.duplicates += 1;
state.results.push({ index: i, status: "duplicate", id: replayId });
return null;
}
// 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) {
await writeOntoSlot(write, state, incomingScheduledFor);
return incomingScheduledFor;
}
return insertStandalone(write, state);
}
+303
View File
@@ -0,0 +1,303 @@
/**
* One entry of a bulk intake batch, start to end: the per-entry refusals,
* the slot attribution, the injection site, the write, and what the batch
* remembers of a row that landed. One bad entry never fails the batch; every
* refusal is that entry's own outcome.
*/
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import type { SessionUser } from "@/lib/auth/session-user";
import { dayKeyForScheduledFor } from "@/lib/medications/dose-day-refresh";
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
import {
INTAKE_NOT_TRACKED_REASON,
isRecordOnly,
} from "@/lib/medications/intake-tracking";
import {
classifyExternalId,
UNSTABLE_EXTERNAL_ID_REASON,
} from "@/lib/validations/external-id";
import type { InjectionSiteValue } from "@/lib/validations/medication";
import {
skipEntry,
type BatchState,
type BulkIntakeEntry,
} from "./batch-state";
import { attributeEntry } from "./entry-attribution";
import { writeEntry } from "./entry-write";
/**
* What the batch reads once about the medications it names: the
* injection-tracking fields, so a per-entry `injectionSite` can be resolved +
* validated without an extra per-entry read, and `externalSource`, so the
* Apple-entry mirror gate needs no extra read either.
*/
export const OWNED_MEDICATION_SELECT = {
id: true,
deliveryForm: true,
trackInjectionSites: true,
allowedInjectionSites: true,
externalSource: true,
trackIntake: true,
} satisfies Prisma.MedicationSelect;
export type OwnedMedication = Prisma.MedicationGetPayload<{
select: typeof OWNED_MEDICATION_SELECT;
}>;
export interface BatchContext {
user: Pick<SessionUser, "id" | "timezone">;
ownedSet: Set<string>;
medById: Map<string, OwnedMedication>;
globalExcluded: InjectionSiteValue[];
}
/**
* The refusals decided before any slot is read. True when the entry is
* answered (skipped, or a duplicate of a row the batch already knows).
*/
function screenEntry(
ctx: BatchContext,
state: BatchState,
entry: BulkIntakeEntry,
i: number,
): boolean {
if (!ctx.ownedSet.has(entry.medicationId)) {
skipEntry(state, i, "medication_not_found");
return true;
}
// 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(ctx.medById.get(entry.medicationId) ?? {})) {
skipEntry(state, i, INTAKE_NOT_TRACKED_REASON);
return true;
}
// 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. 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) {
state.unstableShapes.push(unstable);
skipEntry(state, i, UNSTABLE_EXTERNAL_ID_REASON);
return true;
}
// 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 = state.rowIdByExternalId.get(entry.externalId);
if (existingRowId !== undefined) {
state.duplicates += 1;
state.results.push({ index: i, status: "duplicate", id: existingRowId });
return true;
}
}
return false;
}
/**
* 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.
*/
function resolveEntrySite(
ctx: BatchContext,
entry: BulkIntakeEntry,
isExplicitTaken: boolean,
): { site: InjectionSiteKey | null } | { reason: string } {
let resolvedInjectionSite: InjectionSiteKey | null = null;
if (entry.injectionSite !== undefined) {
const med = ctx.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: ctx.globalExcluded as InjectionSiteKey[],
});
if (resolution.kind === "disallowed") {
return { reason: "injection_site_not_allowed" };
}
resolvedInjectionSite = resolution.site;
}
return { site: resolvedInjectionSite };
}
/** What the batch remembers of a row that landed. */
function recordLanded(
ctx: BatchContext,
state: BatchState,
entry: BulkIntakeEntry,
effectiveScheduledFor: Date,
): void {
const dayKey = dayKeyForScheduledFor(
effectiveScheduledFor,
ctx.user.timezone,
);
state.touchedDays.set(`${entry.medicationId}|${dayKey}`, {
medicationId: entry.medicationId,
dayKey,
});
// Collect the slot for the silent cross-device sync (#22)
// 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.
// 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 = state.results[state.results.length - 1];
if (entry.externalId !== undefined && landed?.id !== undefined) {
state.rowIdByExternalId.set(entry.externalId, landed.id);
}
const lastStatus = state.results[state.results.length - 1]?.status;
if (lastStatus === "inserted" || lastStatus === "updated") {
const scheduledForIso = effectiveScheduledFor.toISOString();
state.syncSlots.set(`${entry.medicationId}|${scheduledForIso}`, {
medicationId: entry.medicationId,
scheduledFor: scheduledForIso,
});
if (!entry.skipped) state.snoozeClearIds.add(entry.medicationId);
}
}
async function writeScreenedEntry(
ctx: BatchContext,
state: BatchState,
entry: BulkIntakeEntry,
i: number,
): Promise<void> {
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;
const attribution = await attributeEntry(
{ user: ctx.user, entry, incomingScheduledFor },
{ isExplicitTaken, isExplicitSkip },
);
if ("reason" in attribution) {
skipEntry(state, i, attribution.reason);
return;
}
if (attribution.heldByAnotherDose) state.extraDosesUnscheduled += 1;
const site = resolveEntrySite(ctx, entry, isExplicitTaken);
if ("reason" in site) {
skipEntry(state, i, site.reason);
return;
}
const effectiveScheduledFor = await writeEntry(
{
user: ctx.user,
entry,
index: i,
incomingScheduledFor,
isExplicitTaken,
isExplicitSkip,
attribution,
resolvedInjectionSite: site.site,
},
state,
);
if (effectiveScheduledFor === null) return;
recordLanded(ctx, state, entry, effectiveScheduledFor);
}
/**
* 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.
*/
async function answerFailedEntry(
ctx: BatchContext,
state: BatchState,
entry: BulkIntakeEntry,
i: number,
err: unknown,
): Promise<void> {
const { user } = ctx;
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 },
})
: // 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;
state.duplicates += 1;
state.results.push({
index: i,
status: "duplicate",
id: existing?.id,
});
return;
}
const reason =
err instanceof Error ? err.message.slice(0, 120) : "create_failed";
skipEntry(state, i, reason);
}
/** Ingest one entry into the batch state. */
export async function ingestEntry(
ctx: BatchContext,
state: BatchState,
entry: BulkIntakeEntry,
i: number,
): Promise<void> {
if (screenEntry(ctx, state, entry, i)) return;
try {
await writeScreenedEntry(ctx, state, entry, i);
} catch (err: unknown) {
await answerFailedEntry(ctx, state, entry, i, err);
}
}
+321
View File
@@ -0,0 +1,321 @@
/**
* Ingest one bulk intake batch: `POST /api/medications/intake/bulk`.
*
* The route authenticates, meters its own bulk bucket, reads the body and
* answers the size and schema refusals; the ownership read, the Apple mirror
* gate, the batch pre-reads, the per-entry loop and every tail after it run
* here, in the order they always ran. The outcome is the batch refusal the
* route answers with `apiError`, or the always-200 batch envelope.
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import type { SessionUser } from "@/lib/auth/session-user";
import { markSyncCheckpoint } from "@/lib/sync/checkpoint";
import {
BACKFILL_SYNC_TRIGGER,
MEDICATION_DOSE_ARRIVAL_TYPE,
recordHealthKitTypeArrivals,
type HealthKitRequestSyncTrigger,
} from "@/lib/integrations/healthkit-type-sync";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { refreshDoseDay } from "@/lib/medications/dose-day-refresh";
import { unstableExternalIdMeta } from "@/lib/validations/external-id";
import type { InjectionSiteValue } from "@/lib/validations/medication";
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
import { dispatchMedicationIntakeWebClearBulk } from "@/lib/notifications/web-push-clear";
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
import {
createBatchState,
type BatchState,
type BulkIntakeEntry,
} from "./batch-state";
import {
ingestEntry,
OWNED_MEDICATION_SELECT,
type BatchContext,
} from "./entry";
interface BatchInput {
user: Pick<SessionUser, "id" | "timezone">;
/**
* A narrow, single-purpose credential (a bridge, an ingest token) is no
* Apple Health sync, so it never writes the dose arrival record.
*/
scoped: boolean;
entries: BulkIntakeEntry[];
syncTrigger: HealthKitRequestSyncTrigger | undefined;
ipAddress: string | null;
originDeviceToken: string | null;
}
export type BulkIntakeOutcome =
| { kind: "refused"; message: string; errorCode: string }
| { kind: "ingested"; body: Record<string, unknown> };
/**
* 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.
*/
async function loadKnownExternalIds(
userId: string,
entries: BulkIntakeEntry[],
): Promise<Map<string, string>> {
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,
externalId: { in: batchExternalIds },
deletedAt: null,
},
select: { id: true, externalId: true },
});
for (const row of existingExternalRows) {
if (row.externalId) rowIdByExternalId.set(row.externalId, row.id);
}
}
return rowIdByExternalId;
}
/**
* 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.
*/
async function loadGlobalExcluded(
userId: string,
entries: BulkIntakeEntry[],
): Promise<InjectionSiteValue[]> {
const batchHasInjectionSite = entries.some(
(e) => e.injectionSite !== undefined,
);
let globalExcluded: InjectionSiteValue[] = [];
if (batchHasInjectionSite) {
const userRow = await prisma.user.findUnique({
where: { id: userId },
select: { globalExcludedInjectionSites: true },
});
globalExcluded = (userRow?.globalExcludedInjectionSites ??
[]) as InjectionSiteValue[];
}
return globalExcluded;
}
/** The audit row and the event of the batch. */
async function recordBatch(input: BatchInput, state: BatchState) {
const { entries } = input;
const { inserted, updated, duplicates, skipped, unstableShapes } = state;
await auditLog("medications.intake.bulk.ingest", {
userId: input.user.id,
ipAddress: input.ipAddress,
details: {
processed: entries.length,
inserted,
updated,
duplicates,
skipped: skipped.length,
},
});
annotate({
action: { name: "medications.intake.bulk.ingest" },
meta: {
processed: entries.length,
inserted,
updated,
duplicates,
skipped: skipped.length,
extra_doses_unscheduled: state.extraDosesUnscheduled,
...(unstableShapes.length > 0
? unstableExternalIdMeta("medication.intake.bulk", unstableShapes)
: {}),
},
});
}
/**
* The caches, the dose days, the snooze, and the cross-device and web-push
* tails of the rows the batch landed.
*/
async function settleLandedRows(input: BatchInput, state: BatchState) {
const { user } = input;
// Bust per-user medications + compliance + achievement caches when at
// least one row landed so the next read reflects the ingested batch.
// 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 (state.inserted > 0 || state.updated > 0) {
invalidateUserMedications(user.id, { evict: true });
}
// 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 state.touchedDays.values()) {
await refreshDoseDay({
userId: user.id,
medicationId,
dayKey,
tz: user.timezone,
});
}
// Silent cross-device intake sync (#22). 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 (state.snoozeClearIds.size > 0) {
await prisma.medication.updateMany({
where: { id: { in: [...state.snoozeClearIds] }, userId: user.id },
data: { snoozedUntil: null },
});
}
if (state.syncSlots.size > 0) {
queueMedicationIntakeSync({
userId: user.id,
originDeviceToken: input.originDeviceToken,
});
// 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(state.syncSlots.values());
void (async () => {
const badgeCount = await countOutstandingDosesToday(
user.id,
user.timezone,
);
await dispatchMedicationIntakeWebClearBulk({
userId: user.id,
slots: affectedSlots,
badgeCount,
});
})();
}
}
/**
* 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. Then the
* SyncMode checkpoint, which 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.
*/
async function recordSyncArrival(input: BatchInput, state: BatchState) {
const { user, entries, syncTrigger } = input;
const doseArrival = { accepted: 0, newSamples: 0 };
for (const result of state.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 && !input.scoped) {
await recordHealthKitTypeArrivals(prisma, {
userId: user.id,
counts: new Map([[MEDICATION_DOSE_ARRIVAL_TYPE, doseArrival]]),
trigger: syncTrigger ?? null,
});
}
if (syncTrigger !== BACKFILL_SYNC_TRIGGER) {
await markSyncCheckpoint(user.id);
}
}
export async function ingestBulkIntake(
input: BatchInput,
): Promise<BulkIntakeOutcome> {
const { user, entries } = input;
// 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 },
select: OWNED_MEDICATION_SELECT,
});
const ownedSet = new Set(ownedMedications.map((m) => m.id));
const medById = new Map(ownedMedications.map((m) => [m.id, m]));
// 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.
const appleMismatch = entries.some(
(e) =>
e.source === "APPLE_HEALTH" &&
ownedSet.has(e.medicationId) &&
medById.get(e.medicationId)?.externalSource !== "APPLE_HEALTH",
);
if (appleMismatch) {
return {
kind: "refused",
message:
"An APPLE_HEALTH entry may only target a medication mirrored from Apple Health",
errorCode: "medications.intake.bulk.apple_health_not_mirrored",
};
}
const state = createBatchState(await loadKnownExternalIds(user.id, entries));
const ctx: BatchContext = {
user,
ownedSet,
medById,
globalExcluded: await loadGlobalExcluded(user.id, entries),
};
for (let i = 0; i < entries.length; i++) {
await ingestEntry(ctx, state, entries[i], i);
}
await recordBatch(input, state);
await settleLandedRows(input, state);
await recordSyncArrival(input, state);
return {
kind: "ingested",
body: {
processed: entries.length,
inserted: state.inserted,
updated: state.updated,
duplicates: state.duplicates,
skipped: state.skipped,
entries: state.results,
},
};
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Per-day scheduled vs taken for the last N days:
* `GET /api/medications/intake?scope=compliance`.
*
* Cached at a 15-minute TTL because daily compliance buckets are slow-moving
* (intake events trickle in, yesterday's row doesn't move). The cache key
* carries the userTz so a user who changes timezone doesn't read another
* tz's bucketing.
*
* `scheduled` is 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.
*/
import { annotate } from "@/lib/logging/context";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import {
buildScheduleAnchoredComplianceBuckets,
type ScheduleAnchoredComplianceBucket,
} from "@/lib/analytics/schedule-anchored-compliance";
export async function readIntakeCompliance(input: {
userId: string;
days: number;
userTz: string;
}): Promise<ScheduleAnchoredComplianceBucket[]> {
const { userId, days, userTz } = input;
const result = await cached(
caches.medicationsIntake as ServerCache<ScheduleAnchoredComplianceBucket[]>,
`${userId}|compliance|${days}|${userTz}`,
() => buildScheduleAnchoredComplianceBuckets(userId, days, userTz),
annotate,
);
annotate({
action: { name: "medications.intake.compliance" },
meta: { days, count: result.length },
});
return result;
}
+79
View File
@@ -0,0 +1,79 @@
/**
* Today's intake events across the record's medications:
* `GET /api/medications/intake?scope=today`.
*
* The route authenticates (READ over the `medications` section) and parses
* the query; the projection, the read and the status mapping run here.
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { startOfLocalDayInTz } from "@/lib/tz/local-day";
import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes";
import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking";
export async function readTodayIntakes(input: {
userId: string;
userTz: string;
}) {
const { userId, userTz } = input;
const todayStart = startOfLocalDayInTz(new Date(), userTz);
const todayEnd = new Date(todayStart.getTime() + 86_400_000);
// Project pending intake rows for every active schedule whose window opens
// today, then idempotently backfill any missing rows. Without it 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. The shared helper mirrors the dashboard-summary
// call site so the two routes converge on the same row set.
const { projected, backfilled } = await projectTodayIntakesAndRecompute({
userId,
userTz,
todayStart,
todayEnd,
});
const events = await prisma.medicationIntakeEvent.findMany({
where: {
userId,
// Exclude tombstoned rows from the today list.
deletedAt: null,
scheduledFor: { gte: todayStart, lt: todayEnd },
// The today list is what clients offer doses from (#1033); 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 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"
: // 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,
}));
}
+89
View File
@@ -0,0 +1,89 @@
/**
* One medication's intake history: `GET /api/medications/{id}/intake`.
*
* The route authenticates (READ over the `medications` section), runs the
* ownership guard and parses the query; the filter, the order, the page and
* the count are built here.
*/
import type { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import type { listIntakeEventsSchema } from "@/lib/validations/medication";
export type IntakeEventListQuery = z.output<typeof listIntakeEventsSchema>;
export async function listIntakeEvents(input: {
userId: string;
medicationId: string;
query: IntakeEventListQuery;
}) {
const { userId, medicationId: id } = input;
const { limit, offset, sortBy, sortDir, status } = input.query;
// 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 },
],
}
: {};
// Exclude tombstoned rows from the per-medication
// intake history list + its count.
const where = {
medicationId: id,
userId,
deletedAt: null,
...statusFilter,
};
// 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,
}),
prisma.medicationIntakeEvent.count({ where }),
]);
annotate({
action: { name: "medication.intake.list" },
meta: { medication_id: id, total, status },
});
return { events, meta: { total, limit, offset } };
}
+277
View File
@@ -0,0 +1,277 @@
/**
* Read one medication: `GET /api/medications/{id}`.
*
* The route authenticates (READ over the `medications` section) and answers
* the 404; the fetch, the category enrichment, the server-computed next due
* instant, the stock and runway, the composition, the courses and the related
* records are resolved here, in the order they always were. `null` means the
* medication is not the record's.
*/
import type { Medication, MedicationSchedule } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { sendsMedicationReminders } from "@/lib/medications/auto-mark-taken";
import { toIngredientDtos } from "@/lib/medications/ingredients/composition";
import { annotate } from "@/lib/logging/context";
import type { SessionUser } from "@/lib/auth/session-user";
import { resolveMedicationCategories } from "@/lib/medication-category";
import { resolveCourseFields } from "@/lib/medications/courses";
import { loadAutoTakeRollingAnchors } from "@/lib/medications/auto-take";
import {
computeDisplayDue,
OVERDUE_LOOKBACK_MS,
toResolvedSlotMark,
} from "@/lib/medications/scheduling/next-due";
import {
effectiveUnitsPerDose,
estimateUnitsRunwayDays,
} from "@/lib/medications/supply-runway";
import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-dto";
import {
dueSchedules,
isRecordOnly,
scheduleWireFields,
} from "@/lib/medications/intake-tracking";
import { floorWhole } from "@/lib/medications/units-per-dose";
import { resolveIntakeActionability } from "@/lib/medications/intake-actionable";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { buildRelated } from "@/lib/links/related";
type ReadUser = Pick<SessionUser, "id" | "timezone" | "locale">;
type MedicationWithSchedules = Medication & {
schedules: MedicationSchedule[];
};
/**
* The server-computed next due instant. Rolling cadences re-anchor on the
* latest non-skipped intake, so fetch it once (no-op for calendar cadences
* but cheap + keeps the value correct for rolling injections).
*/
async function loadDisplayDue(
user: ReadUser,
id: string,
medication: MedicationWithSchedules,
) {
const lastIntake = dueSchedules(medication).some(
(s) => s.rollingIntervalDays !== null,
)
? await prisma.medicationIntakeEvent.findFirst({
// A tombstoned intake no longer anchors the rolling-interval
// next-due computation.
where: {
userId: user.id,
medicationId: id,
deletedAt: null,
takenAt: { not: null },
},
orderBy: { takenAt: "desc" },
select: { takenAt: true },
})
: null;
// Slots the user has already acted on near now, so the next-due search
// skips them and advances to the next genuinely-open slot. Bound the read
// to [now-1d, now+2d] — the lookahead only needs the slots adjacent to now.
const now = new Date();
// Reach back as far as the widest band tail so a long-resolved past slot
// can never resurface as "overdue".
const resolvedFrom = new Date(now.getTime() - OVERDUE_LOOKBACK_MS);
const resolvedTo = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000);
const resolvedRows = await prisma.medicationIntakeEvent.findMany({
where: {
userId: user.id,
medicationId: id,
deletedAt: null,
scheduledFor: { gte: resolvedFrom, lte: resolvedTo },
OR: [{ takenAt: { not: null } }, { skipped: true }, { autoMissed: true }],
},
// `takenAt` rides along so an ad-hoc row (`scheduledFor === takenAt`)
// cannot ±6h-resolve a different slot.
select: { scheduledFor: true, takenAt: true },
});
// Current-era floor: the open-overdue search mints from the LIVE schedule
// rows, so it must not reach past the newest revision boundary into a
// previous era's cadence.
const latestRevision = await prisma.medicationScheduleRevision.findFirst({
// Superseded rows are audit records — a correction may have
// shortened the era, so the boundary reads only active rows.
where: { medicationId: id, supersededByRevisionId: null },
orderBy: { validUntil: "desc" },
select: { validUntil: true },
});
// Intake tracking off (#1033): nothing is due from the stored rows, and no
// runway derives from them.
const liveSchedules = dueSchedules(medication);
const userTz = user.timezone || DEFAULT_TIMEZONE;
// Under auto-take a rolling cadence stands where the job will record next
// (see `auto-take.ts`).
const engineLastIntakeAt = await loadAutoTakeRollingAnchors(prisma, {
userId: user.id,
medicationIds: medication.autoMarkTaken ? [id] : [],
now,
userTz,
lastIntakeAt: () => lastIntake?.takenAt ?? null,
});
const display = computeDisplayDue({
medication: {
id: medication.id,
startsOn: medication.startsOn,
endsOn: medication.endsOn,
oneShot: medication.oneShot,
createdAt: medication.createdAt,
},
schedules: liveSchedules,
now,
userTz,
lastIntakeAt: engineLastIntakeAt(id),
resolvedSlots: resolvedRows.map(toResolvedSlotMark),
eraStart: latestRevision?.validUntil ?? null,
});
return { display, userTz, now };
}
/**
* Stock + runway on the detail wire, mirroring the list payload's semantics:
* NULL stock = inventory tracking off, 0 = the supply ran out; runwayDays via
* the slot-aware burn rate. Published resolved so no client re-derives the
* inheritance / cadence math.
*/
async function loadStock(
user: ReadUser,
id: string,
medication: MedicationWithSchedules,
) {
const [usableStock, anyItemCount] = await Promise.all([
prisma.medicationInventoryItem.aggregate({
where: {
userId: user.id,
medicationId: id,
state: { in: ["ACTIVE", "IN_USE"] },
unitsRemaining: { gt: 0 },
},
_sum: { unitsRemaining: true },
}),
prisma.medicationInventoryItem.count({
where: { userId: user.id, medicationId: id },
}),
]);
const schedulesDto = serializeScheduleUnitsPerDose(
medication.schedules,
medication.unitsPerDose,
);
const stockUnitsRemaining =
anyItemCount > 0 ? Number(usableStock._sum.unitsRemaining ?? 0) : null;
const stockDosesRemaining =
stockUnitsRemaining === null
? null
: floorWhole(
stockUnitsRemaining /
effectiveUnitsPerDose(
schedulesDto,
Number(medication.unitsPerDose),
),
);
const runwayDays =
stockUnitsRemaining === null
? null
: estimateUnitsRunwayDays(
stockUnitsRemaining,
isRecordOnly(medication) ? [] : schedulesDto,
Number(medication.unitsPerDose),
);
return { schedulesDto, stockUnitsRemaining, stockDosesRemaining, runwayDays };
}
/** The detail answer of one medication, or null for the route's 404. */
export async function readMedication(input: {
user: ReadUser;
grantId: string | null;
id: string;
}): Promise<Record<string, unknown> | null> {
const { user, grantId, id } = input;
const medication = await prisma.medication.findUnique({
where: { id },
include: { schedules: true },
});
if (!medication || medication.userId !== user.id) {
return null;
}
let category = "OTHER";
let categoryLabel: string | null = null;
try {
const categories = await resolveMedicationCategories([id]);
category = categories[id]?.category ?? "OTHER";
categoryLabel = categories[id]?.categoryLabel ?? null;
} catch {
// Category enrichment is optional
}
const { display, userTz, now } = await loadDisplayDue(user, id, medication);
annotate({
action: {
name: "medication.get",
entity_type: "medication",
entity_id: id,
},
});
const { schedulesDto, stockUnitsRemaining, stockDosesRemaining, runwayDays } =
await loadStock(user, id, medication);
// The composition per unit, labelled in the account language.
const ingredientRows = await prisma.medicationIngredient.findMany({
where: { medicationId: id, userId: user.id },
select: {
nutrient: true,
amountPerUnit: true,
unit: true,
enteredAmount: true,
enteredUnit: true,
},
});
// The courses, resolved on the server (#1024).
const courseFields = (
await resolveCourseFields(
[medication],
now,
user.timezone || DEFAULT_TIMEZONE,
)
).get(id);
return {
...medication,
unitsPerDose: Number(medication.unitsPerDose),
// `schedules: []` plus `recordedSchedules` when intake tracking is off
// (#1033, see `scheduleWireFields`).
...scheduleWireFields(medication.trackIntake, schedulesDto),
category,
categoryLabel,
...courseFields,
nextDueAt: display ? display.at.toISOString() : null,
// The server's own reminder decision, published resolved.
remindersActive: sendsMedicationReminders(medication, {
now,
timeZone: userTz,
}),
nextDueOverdue: display?.overdue ?? false,
// See `resolveIntakeActionability` (#1040).
...resolveIntakeActionability(
medication,
now,
user.timezone || DEFAULT_TIMEZONE,
),
stockUnitsRemaining,
stockDosesRemaining,
runwayDays,
ingredients: toIngredientDtos(ingredientRows, user.locale ?? null),
// The records this medication relates to (`src/lib/links/`).
related: await buildRelated(prisma, {
userId: user.id,
grantId,
kind: "medication",
id,
}),
};
}
@@ -0,0 +1,223 @@
/**
* Which slot a dose posted to `POST /api/medications/{id}/intake` binds to,
* and the binding provenance it records.
*
* Window-band slot attribution (replacing 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.
*/
import { annotate } from "@/lib/logging/context";
import type { SessionUser } from "@/lib/auth/session-user";
import {
findPinConflict,
mayConvergeOntoSuppliedSlot,
resolveForcedSlotForWrite,
resolveSlotForWriteByBand,
resolveSlotInstantForWrite,
} from "@/lib/medications/scheduling/slot-upsert";
/** A coded 422 refusal the route answers with `apiError`. */
export interface IntakeWriteRefusal {
kind: "refused";
message: string;
errorCode: string;
}
export type AttributionSource = "AUTO" | "USER_PIN";
export interface IntakeAttribution {
kind: "attributed";
canonicalSlot: Date | null;
/**
* USER_PIN on the forced "diesem Slot zuordnen" path, AUTO when band
* attribution decided. Skips carry no binding decision (undefined → column
* untouched/default).
*/
attributionSource: AttributionSource | undefined;
}
interface AttributionInput {
user: Pick<SessionUser, "id" | "timezone">;
medicationId: string;
skipped: boolean;
scheduledFor: Date | undefined;
forceSlotInstant: Date | undefined;
resolvedTakenAt: Date | null;
incomingScheduledFor: Date;
}
async function attributeSkip(input: AttributionInput): Promise<Date | null> {
const { user, medicationId: id, scheduledFor, incomingScheduledFor } = input;
if (scheduledFor === undefined) {
// 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,
});
return attribution.slotInstant;
}
return resolveSlotInstantForWrite({
userId: user.id,
medicationId: id,
userTz: user.timezone,
incoming: incomingScheduledFor,
instantIsExplicit: true,
isTakenWrite: false,
});
}
async function attributePin(
input: AttributionInput,
forceSlotInstant: Date,
): Promise<IntakeAttribution | IntakeWriteRefusal> {
const { user, medicationId: id, resolvedTakenAt } = input;
const canonicalSlot = await resolveForcedSlotForWrite({
userId: user.id,
medicationId: id,
userTz: user.timezone,
slotInstant: forceSlotInstant,
});
if (canonicalSlot === null) {
annotate({
action: { name: "medication.intake.force_slot.invalid" },
meta: { medication_id: id },
});
return {
kind: "refused",
message: "forceSlotInstant is not a scheduled slot of this medication",
errorCode: "medications.intake.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. 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 {
kind: "refused",
message: "forceSlotInstant already carries a recorded dose action",
errorCode: "medications.intake.force_slot.occupied",
};
}
return { kind: "attributed", canonicalSlot, attributionSource: "USER_PIN" };
}
async function attributeTake(
input: AttributionInput,
): Promise<IntakeAttribution> {
const {
user,
medicationId: id,
skipped,
scheduledFor,
resolvedTakenAt,
incomingScheduledFor,
} = input;
// 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,
});
let 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.
let attributionSource: 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 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";
}
}
return { kind: "attributed", canonicalSlot, attributionSource };
}
/** The slot the dose binds to, or the coded refusal of a bad pin. */
export async function resolveIntakeAttribution(
input: AttributionInput,
): Promise<IntakeAttribution | IntakeWriteRefusal> {
if (input.skipped) {
return {
kind: "attributed",
canonicalSlot: await attributeSkip(input),
attributionSource: undefined,
};
}
if (input.forceSlotInstant !== undefined) {
return attributePin(input, input.forceSlotInstant);
}
return attributeTake(input);
}
@@ -0,0 +1,310 @@
/**
* Record one dose of one medication: `POST /api/medications/{id}/intake`.
*
* The route authenticates (WRITE over the `medications` section), runs the
* intake guard, parses the body and answers the schema refusal and the
* delegated outcome-change refusal; the injection-site check, the slot
* attribution, the replay and dedup answers, the write and every tail after
* it run here, in the order they always ran.
*/
import type { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import type { SessionUser } from "@/lib/auth/session-user";
import type { intakeSchema } from "@/lib/validations/medication";
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
import {
consumeForIntake,
restoreForIntake,
} from "@/lib/medications/inventory/consumption";
import { reconcileOneShotState } from "@/lib/medications/lifecycle";
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 {
resolveIntakeAttribution,
type IntakeWriteRefusal,
} from "./attribution";
import {
findReplayedIntake,
writeIntake,
type IntakeWriteResult,
} from "./write";
export type IntakeInput = z.output<typeof intakeSchema>;
type WrittenIntake = Extract<IntakeWriteResult, { kind: "written" }>;
export type RecordIntakeOutcome =
| IntakeWriteRefusal
| Exclude<IntakeWriteResult, WrittenIntake>
| { kind: "recorded"; event: WrittenIntake["event"] };
interface RecordIntakeContext {
user: Pick<SessionUser, "id" | "timezone">;
actorId: string;
medicationId: string;
intakeSource: "API" | "WEB";
isDelegated: boolean;
ipAddress: string | null;
originDeviceToken: string | null;
}
/**
* 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.
*/
async function resolveInjectionSite(
ctx: RecordIntakeContext,
data: IntakeInput,
): Promise<
{ kind: "site"; site: InjectionSiteKey | null } | IntakeWriteRefusal
> {
const { user, medicationId: id } = ctx;
const { injectionSite, skipped } = data;
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 {
kind: "refused",
message: "Injection site is not allowed for this medication",
errorCode: "medications.intake.injection_site.disallowed",
};
}
resolvedInjectionSite = resolution.site;
}
return { kind: "site", site: resolvedInjectionSite };
}
/**
* 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.
*/
async function settleInventory(
ctx: RecordIntakeContext,
skipped: boolean,
written: WrittenIntake,
): Promise<void> {
const { user, medicationId: id } = ctx;
const { event, consumedTransition } = written;
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,
});
}
}
/** The audit row, the event, the caches, the rollup and the lifecycle. */
async function recordAfterWrite(
ctx: RecordIntakeContext,
skipped: boolean,
event: WrittenIntake["event"],
): Promise<void> {
const { user, medicationId: id } = ctx;
await auditLog("medication.intake", {
userId: user.id,
ipAddress: ctx.ipAddress,
details: {
medicationId: id,
eventId: event.id,
skipped,
},
});
annotate({
action: {
name: "medication.intake",
entity_type: "intake_event",
entity_id: event.id,
},
meta: {
medication_id: id,
skipped,
},
});
// Bust per-user medications + compliance + achievement
// caches so the next read reflects the dose event.
invalidateUserMedications(user.id, { evict: true });
// 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,
});
// 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 });
}
}
/** The tails that never affect the response. */
function startTails(
ctx: RecordIntakeContext,
skipped: boolean,
event: WrittenIntake["event"],
): void {
const { user, medicationId: id } = ctx;
// (#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: ctx.originDeviceToken,
});
// 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,
});
})();
// "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: ctx.actorId,
medicationId: id,
status: skipped ? "skipped" : "taken",
});
}
/** Record the dose the route parsed. */
export async function recordIntake(
ctx: RecordIntakeContext,
data: IntakeInput,
): Promise<RecordIntakeOutcome> {
const { user, medicationId: id } = ctx;
const { scheduledFor, takenAt, skipped, idempotencyKey, doseTaken } = data;
// 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;
const site = await resolveInjectionSite(ctx, data);
if (site.kind === "refused") return site;
const resolvedTakenAt = skipped ? null : (takenAt ?? new Date());
const incomingScheduledFor = scheduledFor ?? takenAt ?? new Date();
const attribution = await resolveIntakeAttribution({
user,
medicationId: id,
skipped,
scheduledFor,
forceSlotInstant: data.forceSlotInstant,
resolvedTakenAt,
incomingScheduledFor,
});
if (attribution.kind === "refused") return attribution;
const write = {
userId: user.id,
medicationId: id,
canonicalSlot: attribution.canonicalSlot,
attributionSource: attribution.attributionSource,
scheduledFor,
skipped,
idempotencyKey,
resolvedTakenAt,
incomingScheduledFor,
intakeSource: ctx.intakeSource,
isDelegated: ctx.isDelegated,
resolvedInjectionSite: site.site,
resolvedDoseTaken,
};
const replayed = await findReplayedIntake(write);
if (replayed) return { kind: "existing", row: replayed };
const written = await writeIntake(write);
if (written.kind !== "written") return written;
await settleInventory(ctx, skipped, written);
await recordAfterWrite(ctx, skipped, written.event);
startTails(ctx, skipped, written.event);
return { kind: "recorded", event: written.event };
}
+238
View File
@@ -0,0 +1,238 @@
/**
* The row a dose posted to `POST /api/medications/{id}/intake` lands on: the
* replay and dedup answers, the canonical slot write, the convergence probe
* onto a row already on the named instant, or the standalone insert.
*/
import type { MedicationIntakeEvent } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
import {
applyCanonicalSlotWrite,
mayConvergeOntoSuppliedSlot,
type ApplyCanonicalSlotWriteResult,
} from "@/lib/medications/scheduling/slot-upsert";
import type { AttributionSource } from "./attribution";
type IntakeRow = MedicationIntakeEvent | ApplyCanonicalSlotWriteResult["row"];
export interface IntakeWrite {
userId: string;
medicationId: string;
canonicalSlot: Date | null;
attributionSource: AttributionSource | undefined;
scheduledFor: Date | undefined;
skipped: boolean;
idempotencyKey: string | undefined;
resolvedTakenAt: Date | null;
incomingScheduledFor: Date;
intakeSource: "API" | "WEB";
isDelegated: boolean;
resolvedInjectionSite: InjectionSiteKey | null;
resolvedDoseTaken: string | null;
}
export type IntakeWriteResult =
| { kind: "existing"; row: MedicationIntakeEvent }
| { kind: "outcome_change_refused" }
| { kind: "written"; event: IntakeRow; consumedTransition: boolean };
/**
* Idempotency check (explicit key or server-side dedup window). The row a
* replay or a double-log answers with, or null when the dose is new.
*/
export async function findReplayedIntake(
write: IntakeWrite,
): Promise<MedicationIntakeEvent | null> {
const { userId, medicationId: id, idempotencyKey, skipped } = write;
if (idempotencyKey) {
const existing = await prisma.medicationIntakeEvent.findFirst({
where: {
idempotencyKey,
userId,
medicationId: id,
},
});
if (existing) {
return existing;
}
} else if (!write.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,
medicationId: id,
skipped,
createdAt: { gte: new Date(Date.now() - 60_000) },
},
orderBy: { createdAt: "desc" },
});
if (recentDuplicate) {
return recentDuplicate;
}
}
return null;
}
/**
* Converge onto the one 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 500-ing or duplicating.
*/
async function writeOntoSlot(
write: IntakeWrite,
canonicalSlot: Date,
): Promise<IntakeWriteResult> {
const { userId, medicationId: id, skipped } = write;
// 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;
const applied = await applyCanonicalSlotWrite({
client: prisma,
userId,
medicationId: id,
canonicalSlot,
takenAt: write.resolvedTakenAt,
skipped,
isExplicitTaken,
isExplicitSkip,
idempotencyKey: write.idempotencyKey ?? null,
createSource: write.intakeSource,
refuseOutcomeChange: write.isDelegated,
// Resolved + validated site (null unless a tracking-on
// injection taken write supplied an allowed site).
injectionSite: write.resolvedInjectionSite,
attributionSource: write.attributionSource,
// Per-intake dose override (null unless a taken write
// carried one).
doseTaken: write.resolvedDoseTaken,
});
if (applied.outcomeChangeRefused) return { kind: "outcome_change_refused" };
// 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 },
});
}
return {
kind: "written",
event: applied.row,
consumedTransition: applied.consumedTransition,
};
}
/**
* 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).
*/
async function insertStandalone(write: IntakeWrite): Promise<IntakeRow> {
const {
userId,
medicationId: id,
skipped,
resolvedTakenAt,
resolvedInjectionSite,
resolvedDoseTaken,
} = write;
const [event] = await prisma.$transaction([
prisma.medicationIntakeEvent.create({
data: {
userId,
medicationId: id,
scheduledFor: resolvedTakenAt ?? write.incomingScheduledFor,
takenAt: resolvedTakenAt,
skipped,
source: write.intakeSource,
idempotencyKey: write.idempotencyKey ?? null,
// Site only on a resolved taken-injection write.
...(resolvedInjectionSite !== null && {
injectionSite: resolvedInjectionSite,
}),
// 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 },
}),
]
: []),
]);
return event;
}
/** Write the dose; the caller already answered a replay. */
export async function writeIntake(
write: IntakeWrite,
): Promise<IntakeWriteResult> {
const { userId, medicationId: id, skipped, scheduledFor } = write;
if (write.canonicalSlot) {
// Scheduled dose — converge onto the one canonical slot row regardless
// of `source` (the pending REMINDER row, or any prior row for this
// slot).
return writeOntoSlot(write, write.canonicalSlot);
}
// 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: write.resolvedTakenAt,
suppliedSlot: write.incomingScheduledFor,
})
? await prisma.medicationIntakeEvent.findFirst({
where: {
userId,
medicationId: id,
scheduledFor: write.incomingScheduledFor,
deletedAt: null,
},
select: { id: true },
})
: null;
if (existingSlotRow) {
return writeOntoSlot(write, write.incomingScheduledFor);
}
// For the unscheduled/PRN branch a non-skip write always records a fresh
// dose, so it consumes when not skipped.
return {
kind: "written",
event: await insertStandalone(write),
consumedTransition: !skipped,
};
}
@@ -0,0 +1,188 @@
/**
* Whether a status update to `POST /api/medications/intake` may touch the
* event it names: the live event under the record, the record-only rule, the
* delegated refusals, and the injection site it carries.
*/
import type { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
import {
type InjectionSiteValue,
type intakeStatusUpdateSchema,
} from "@/lib/validations/medication";
import { isRecordOnly } from "@/lib/medications/intake-tracking";
export type IntakeStatusUpdate = z.output<typeof intakeStatusUpdateSchema>;
/** A refusal the route answers with `apiError(message, status, meta)`. */
export interface IntakeStatusRefusal {
kind: "refused";
message: string;
status: number;
errorCode: string;
}
/**
* The live event a status update may change, with what the site check needs
* of its medication, or null for the route's 404. A tombstoned intake 404s on
* a status toggle; the `deletedAt: null` filter refuses to mutate a
* soft-deleted row.
*/
export async function findIntakeForStatusUpdate(input: {
userId: string;
intakeId: string;
}) {
const existing = await prisma.medicationIntakeEvent.findFirst({
where: { id: input.intakeId, deletedAt: null },
include: {
medication: {
select: {
deliveryForm: true,
trackInjectionSites: true,
allowedInjectionSites: true,
trackIntake: true,
},
},
},
});
if (!existing || existing.userId !== input.userId) {
return null;
}
return existing;
}
export type StatusUpdateEvent = NonNullable<
Awaited<ReturnType<typeof findIntakeForStatusUpdate>>
>;
/**
* 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.
*/
export function refusesUntrackedIntake(existing: StatusUpdateEvent): boolean {
const stillOpen = existing.takenAt === null && !existing.skipped;
return stillOpen && isRecordOnly(existing.medication);
}
/**
* 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.
*/
export async function refuseDelegatedChange(input: {
userId: string;
actorId: string;
existing: StatusUpdateEvent;
intakeId: string;
status: IntakeStatusUpdate["status"];
ipAddress: string | null;
}): Promise<IntakeStatusRefusal | null> {
const { userId, existing, intakeId, status } = input;
if (input.actorId !== userId) {
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,
ipAddress: input.ipAddress,
details: { intakeId, status, reason: refusal },
});
return {
kind: "refused",
message:
refusal === "snooze"
? "Snoozing a reminder is not part of shared access"
: "Changing a dose that is already recorded is not part of shared access",
status: 403,
errorCode: "sharing.not_permitted",
};
}
}
return null;
}
/**
* 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.
*/
export async function resolveStatusInjectionSite(input: {
userId: string;
existing: StatusUpdateEvent;
injectionSite: IntakeStatusUpdate["injectionSite"];
status: IntakeStatusUpdate["status"];
}): Promise<
{ kind: "site"; site: InjectionSiteValue | null } | IntakeStatusRefusal
> {
const { existing, injectionSite, status } = input;
let resolvedInjectionSite: InjectionSiteValue | null = null;
if (injectionSite !== undefined) {
const userRow = await prisma.user.findUnique({
where: { id: input.userId },
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 {
kind: "refused",
message: "Injection site is not allowed for this medication",
status: 422,
errorCode: "medications.intake.injection_site.disallowed",
};
}
resolvedInjectionSite = resolution.site;
}
return { kind: "site", site: resolvedInjectionSite };
}
@@ -0,0 +1,198 @@
/**
* A "taken" status update: re-run window-band attribution 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.
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import {
applyCanonicalSlotWrite,
resolveForcedSlotForWrite,
resolveSlotForWriteByBand,
} from "@/lib/medications/scheduling/slot-upsert";
import {
consumeForIntake,
restoreForIntake,
} from "@/lib/medications/inventory/consumption";
import type { InjectionSiteValue } from "@/lib/validations/medication";
import type { IntakeStatusRefusal, StatusUpdateEvent } from "./admission";
interface TakeInput {
userId: string;
intakeId: string;
existing: StatusUpdateEvent;
takenAt: Date | undefined;
forceSlotInstant: Date | undefined;
resolvedInjectionSite: InjectionSiteValue | null;
userTz: string;
}
async function targetSlotOf(
input: TakeInput,
resolvedTakenAt: Date,
): Promise<Date | IntakeStatusRefusal> {
const { userId, existing, intakeId, forceSlotInstant, userTz } = input;
if (forceSlotInstant !== undefined) {
const forced = await resolveForcedSlotForWrite({
userId,
medicationId: existing.medicationId,
userTz,
slotInstant: forceSlotInstant,
});
if (forced === null) {
annotate({
action: { name: "medication.intake.force_slot.invalid" },
meta: { medication_id: existing.medicationId, intake_id: intakeId },
});
return {
kind: "refused",
message: "forceSlotInstant is not a scheduled slot of this medication",
status: 422,
errorCode: "medications.intake.force_slot.invalid",
};
}
return forced;
}
const attribution = await resolveSlotForWriteByBand({
userId,
medicationId: existing.medicationId,
userTz,
takenAt: resolvedTakenAt,
});
return attribution.slotInstant ?? resolvedTakenAt;
}
async function takeInPlace(
input: TakeInput,
resolvedTakenAt: Date,
wasTaken: boolean,
) {
const { userId, intakeId, existing, resolvedInjectionSite } = input;
const [updated] = await prisma.$transaction([
prisma.medicationIntakeEvent.update({
where: { id: intakeId },
// Bump the reconciliation counter on every
// server-side mutation so the delta feed echoes a monotonic value.
data: {
takenAt: resolvedTakenAt,
skipped: false,
// The person confirmed it; no longer automatic.
autoRecorded: false,
syncVersion: { increment: 1 },
// Persist the resolved site on the taken branch.
...(resolvedInjectionSite !== null && {
injectionSite: resolvedInjectionSite,
}),
},
}),
prisma.medication.update({
where: { id: existing.medicationId },
data: { snoozedUntil: null },
}),
]);
// 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,
medicationId: existing.medicationId,
eventId: intakeId,
intakeAt: resolvedTakenAt,
});
}
return updated;
}
/**
* 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).
*
* 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.
*/
async function moveTake(
input: TakeInput,
resolvedTakenAt: Date,
targetScheduledFor: Date,
wasTaken: boolean,
) {
const { userId, intakeId, existing, resolvedInjectionSite } = input;
await restoreForIntake({
client: prisma,
userId,
eventId: intakeId,
});
await prisma.medicationIntakeEvent.update({
where: { id: intakeId },
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
});
const applied = await applyCanonicalSlotWrite({
client: prisma,
userId,
medicationId: existing.medicationId,
canonicalSlot: targetScheduledFor,
takenAt: resolvedTakenAt,
skipped: false,
isExplicitTaken: true,
isExplicitSkip: false,
idempotencyKey: null,
createSource: "WEB",
// 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,
});
await prisma.medication.update({
where: { id: existing.medicationId },
data: { snoozedUntil: null },
});
// 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,
medicationId: existing.medicationId,
eventId: applied.row.id,
intakeAt: resolvedTakenAt,
});
}
return applied.row;
}
/** Record the take, in place or on the slot it now binds to. */
export async function applyTakenStatus(input: TakeInput) {
const resolvedTakenAt = input.takenAt ?? new Date();
const target = await targetSlotOf(input, resolvedTakenAt);
if (!(target instanceof Date)) return target;
const targetScheduledFor = target;
const slotMoved =
targetScheduledFor.getTime() !== input.existing.scheduledFor.getTime();
// Transition gate for the inventory consume. 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 = input.existing.takenAt !== null && !input.existing.skipped;
const updated = !slotMoved
? await takeInPlace(input, resolvedTakenAt, wasTaken)
: await moveTake(input, resolvedTakenAt, targetScheduledFor, wasTaken);
return { kind: "updated" as const, row: updated };
}
@@ -0,0 +1,250 @@
/**
* Mark, skip or snooze one intake event: `POST /api/medications/intake`.
*
* The route authenticates (WRITE over the `medications` section), parses the
* body and answers the schema refusal and the 404; the record-only rule, the
* delegated refusals, the injection site, the status write and every tail
* after it run here, in the order they always ran. Everything downstream
* addresses the RECORD: 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. The owner is told who marked the dose at the end.
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import type { SessionUser } from "@/lib/auth/session-user";
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh";
import { restoreForIntake } from "@/lib/medications/inventory/consumption";
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 {
refuseDelegatedChange,
refusesUntrackedIntake,
resolveStatusInjectionSite,
type IntakeStatusRefusal,
type IntakeStatusUpdate,
type StatusUpdateEvent,
} from "./admission";
import { applyTakenStatus } from "./take";
interface StatusContext {
user: Pick<SessionUser, "id" | "timezone">;
actorId: string;
existing: StatusUpdateEvent;
ipAddress: string | null;
originDeviceToken: string | null;
}
type UpdatedRow = Extract<
Awaited<ReturnType<typeof applyTakenStatus>>,
{ kind: "updated" }
>["row"];
export type IntakeStatusOutcome =
| IntakeStatusRefusal
| { kind: "untracked" }
| { kind: "updated"; row: UpdatedRow | null };
/** The skip and the snooze; the take has its own module. */
async function applySkipOrSnooze(ctx: StatusContext, data: IntakeStatusUpdate) {
const { user, existing } = ctx;
const { intakeId, status, snoozedUntil } = data;
if (status === "skipped") {
const updated = await prisma.medicationIntakeEvent.update({
where: { id: intakeId },
// Bump the reconciliation counter on the skip toggle.
// A skip the person records clears the automatic marker.
data: {
takenAt: null,
skipped: true,
autoRecorded: false,
syncVersion: { increment: 1 },
},
});
// 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,
});
return updated;
}
// 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 },
});
return prisma.medicationIntakeEvent.findUnique({
where: { id: intakeId },
});
}
/**
* The audit row, the event, the caches and the rollup rows of the day the
* event sat on and, after a slot move, of the day it moved to.
*/
async function recordAfterUpdate(
ctx: StatusContext,
data: IntakeStatusUpdate,
updated: UpdatedRow | null,
userTzForHook: string,
): Promise<Date | undefined> {
const { user, existing } = ctx;
const { intakeId, status } = data;
await auditLog("medications.intake.update", {
userId: user.id,
ipAddress: ctx.ipAddress,
details: { intakeId, status },
});
annotate({
action: { name: "medications.intake.update" },
meta: { intakeId, status },
});
// Bust the medications + compliance + achievement
// caches for this user so the next read reflects the dose change.
invalidateUserMedications(user.id, { evict: true });
// 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,
});
// 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,
});
}
return movedTo;
}
/** The tails that never affect the response. */
function startTails(
ctx: StatusContext,
status: IntakeStatusUpdate["status"],
movedTo: Date | undefined,
userTzForHook: string,
): void {
const { user, existing } = ctx;
// (#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: ctx.originDeviceToken,
});
// 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,
});
})();
}
// "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: ctx.actorId,
medicationId: existing.medicationId,
status,
});
}
/** Apply the status update to the event the route found. */
export async function updateIntakeStatus(
ctx: StatusContext,
data: IntakeStatusUpdate,
): Promise<IntakeStatusOutcome> {
const { user, existing } = ctx;
const { intakeId, status } = data;
if (refusesUntrackedIntake(existing)) return { kind: "untracked" };
const refused = await refuseDelegatedChange({
userId: user.id,
actorId: ctx.actorId,
existing,
intakeId,
status,
ipAddress: ctx.ipAddress,
});
if (refused) return refused;
const site = await resolveStatusInjectionSite({
userId: user.id,
existing,
injectionSite: data.injectionSite,
status,
});
if (site.kind === "refused") return site;
const userTzForHook = user.timezone ?? DEFAULT_TIMEZONE;
let updated: UpdatedRow | null;
if (status === "taken") {
const taken = await applyTakenStatus({
userId: user.id,
intakeId,
existing,
takenAt: data.takenAt,
forceSlotInstant: data.forceSlotInstant,
resolvedInjectionSite: site.site,
userTz: userTzForHook,
});
if (taken.kind === "refused") return taken;
updated = taken.row;
} else {
updated = await applySkipOrSnooze(ctx, data);
}
const movedTo = await recordAfterUpdate(ctx, data, updated, userTzForHook);
startTails(ctx, status, movedTo, userTzForHook);
return { kind: "updated", row: updated };
}
@@ -0,0 +1,273 @@
/**
* What a medication edit checks before anything is written: the stored
* pre-image, the record-only rule, the category, the as-needed and one-shot
* shapes, the primary-schedule grace bridge and the course window. Each
* refusal leaves the medication as it was.
*/
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import {
checkCurrentWindow,
courseRefusalResponse,
} from "@/lib/medications/courses";
import type { MedicationEdit } from "./schedule-normalise";
/** A refusal the route answers with `apiError(message, status, meta)`. */
export interface MedicationEditRefusal {
ok: false;
message: string;
status: number;
meta?: { errorCode: string };
}
const EDIT_SELECT = {
active: true,
createdAt: true,
// `asNeeded` + the schedule count feed the as-needed invariants: a flip
// to as-needed must end schedule-less, a flip back to scheduled must end
// with at least one schedule.
asNeeded: true,
// The tracking transition (#1033) needs the before.
trackIntake: true,
// `autoMarkTakenSince` is stamped only on an off→on flip.
autoMarkTaken: true,
// The pre-image the audit row carries (C4).
name: true,
dose: true,
endsOn: true,
// The course check (#1024) needs the stored window.
startsOn: true,
oneShot: true,
_count: { select: { schedules: true } },
} satisfies Prisma.MedicationSelect;
export type EditableMedication = Prisma.MedicationGetPayload<{
select: typeof EDIT_SELECT;
}>;
/**
* The narrow pre-image the edit reads after the route's ownership guard
* passed: `active` for the pausedAt derivation, and the rest for the
* invariants and the audit row. Null for the route's 404.
*/
export async function findMedicationForEdit(
id: string,
): Promise<EditableMedication | null> {
return prisma.medication.findUnique({
where: { id },
select: EDIT_SELECT,
});
}
/**
* ── A record-only schedule is only replaced knowingly (#1033) ──
*
* A medication with intake tracking off is served with `schedules: []`
* and its stored schedule in `recordedSchedules`, so a client that
* predates the field arms no reminder from it. The price is that such a
* client cannot SEE the stored schedule. The shipped iPhone app (1.0.3 /
* 1.0.4) opens a medication without schedule rows in its editor as
* "daily at 08:00"; a rename or dose edit sends no `schedules` (its
* editor diffs against that same prefill), but any touch of a schedule
* field sends a full replacement built from the 08:00 default plus
* `asNeeded` and `oneShot`, and never `trackIntake`. Applied, that would
* overwrite the schedule the person kept on record with one they never
* chose (or flip it to as-needed and delete the rows).
*
* Rule: on a medication whose intake tracking is off, a write that does
* not name `trackIntake` leaves the schedule shape alone. `schedules`,
* `asNeeded` and `oneShot` are dropped from it; every other field (name,
* dose, dates, `active`, `notificationsEnabled`, inventory, ...) applies
* as sent. A client that knows about the switch names it on every
* schedule save (the web editor and wizard do), and those writes apply.
*/
export function applyRecordOnlyRule(
existing: EditableMedication,
data: MedicationEdit,
id: string,
): MedicationEdit {
const keepRecordedSchedule =
existing.trackIntake === false &&
data.trackIntake === undefined &&
(data.schedules !== undefined ||
data.asNeeded !== undefined ||
data.oneShot !== undefined);
const input = keepRecordedSchedule
? {
...data,
schedules: undefined,
asNeeded: undefined,
oneShot: undefined,
}
: data;
if (keepRecordedSchedule) {
annotate({
action: {
name: "medication.update.record_schedule_kept",
entity_type: "medication",
entity_id: id,
},
meta: {
dropped_schedules: data.schedules !== undefined,
dropped_as_needed: data.asNeeded !== undefined,
dropped_one_shot: data.oneShot !== undefined,
},
});
}
return input;
}
/**
* ── As-needed invariants (#316) ──
*
* An as-needed medication ends schedule-less, always. The Zod layer
* already rejects a populated `schedules` array alongside
* `asNeeded: true`; the route covers the cross-row cases the schema
* cannot see:
* - flipping an existing scheduled medication to as-needed
* requires `schedules: []` in the same request (keeping the old
* rows alongside the flag is a 422, per the feature contract);
* - flipping as-needed OFF requires the medication to end with at
* least one schedule (the wizard always sends the full array).
*/
export function refuseAsNeededShape(
existing: EditableMedication,
asNeeded: boolean | undefined,
schedules: MedicationEdit["schedules"],
): MedicationEditRefusal | null {
const effectiveAsNeeded = asNeeded ?? existing.asNeeded;
if (effectiveAsNeeded) {
const endsScheduleless = schedules
? schedules.length === 0
: existing._count.schedules === 0;
if (!endsScheduleless) {
return {
ok: false,
message:
"An as-needed medication cannot carry schedules (send schedules: [] to clear them)",
status: 422,
};
}
} else if (
asNeeded === false &&
existing.asNeeded &&
(!schedules || schedules.length === 0)
) {
return {
ok: false,
message: "A scheduled medication requires at least one schedule",
status: 422,
};
}
return null;
}
export interface GracePatch {
scheduleId: string;
reminderGraceMinutes: number | null;
}
/**
* ── Primary-schedule grace bridge ──
*
* The detail-page settings section saves the reminder-window in
* one PUT carrying only `{ reminderGraceMinutes }` at the top
* level (no full `schedules` array). Map it onto the medication's
* primary schedule before the Prisma update so the persisted
* shape stays per-schedule and the engine reads it from the same
* column as the wizard write-path. A full `schedules` array on
* the same request takes precedence — the wizard already declares
* the grace per row and we never want to overwrite that intent.
*/
export async function findPrimaryScheduleGracePatch(
id: string,
topLevelGraceMinutes: number | null | undefined,
schedules: MedicationEdit["schedules"],
): Promise<GracePatch | null> {
let primaryScheduleGracePatch: GracePatch | null = null;
if (topLevelGraceMinutes !== undefined && !schedules) {
const primary = await prisma.medicationSchedule.findFirst({
where: { medicationId: id },
orderBy: { windowStart: "asc" },
select: { id: true },
});
if (primary) {
primaryScheduleGracePatch = {
scheduleId: primary.id,
reminderGraceMinutes: topLevelGraceMinutes,
};
}
}
return primaryScheduleGracePatch;
}
/**
* ── Route invariants for the scheduling primitives ──
*
* Mirrors the create: oneShot ⇒ at-most-one schedule + no
* recurrence on it; endsOn normalised to startsOn for one-shot;
* recurring default = FREQ=DAILY when nothing else is set;
* timesOfDay dual-write from windowStart when absent.
*
* The PUT replaces all schedules wholesale, so the invariants run
* against the incoming `schedules` array (if provided) the same
* way they do on create.
*/
export function refuseOneShotShape(
oneShot: boolean | undefined,
schedules: MedicationEdit["schedules"],
): MedicationEditRefusal | null {
if (oneShot === true && schedules) {
if (schedules.length > 1) {
return {
ok: false,
message: "A one-shot medication can have at most one schedule",
status: 422,
};
}
const s = schedules[0];
if (s && (s.rrule !== undefined || s.rollingIntervalDays !== undefined)) {
return {
ok: false,
message:
"A one-shot medication cannot have a recurrence (rrule or rollingIntervalDays)",
status: 422,
};
}
}
return null;
}
/**
* The window a PUT sets is the latest course (#1024). Check it against the
* other courses BEFORE the row changes, so a refused window leaves the
* medication as it was.
*/
export async function refuseCourseWindow(args: {
userId: string;
medicationId: string;
timeZone: string;
nextWindow: { startsOn: Date | null; endsOn: Date | null };
oneShot: boolean;
createdAt: Date;
}): Promise<MedicationEditRefusal | null> {
const refusal = await checkCurrentWindow({
userId: args.userId,
medicationId: args.medicationId,
timeZone: args.timeZone,
...args.nextWindow,
oneShot: args.oneShot,
createdAt: args.createdAt,
});
if (refusal) {
const r = courseRefusalResponse(refusal);
return {
ok: false,
message: r.message,
status: r.status,
meta: { errorCode: r.errorCode },
};
}
return null;
}
@@ -0,0 +1,157 @@
/**
* The incoming schedules of a medication edit, normalised once.
*
* The same values feed the nested create payload AND the revision
* compare/snapshot, so the material-change gate sees exactly what will be
* persisted. The invariants mirror the create: recurring default
* `FREQ=DAILY` when nothing else is set, `timesOfDay` dual-written from
* `windowStart` when absent, and the window pulled to the dose times when a
* time falls outside it.
*/
import type { z } from "zod/v4";
import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
import { toRevisionPayloadEntry } from "@/lib/medications/scheduling/schedule-eras";
import { hhmmToMinutesOrNull } from "@/lib/medications/scheduling/hhmm";
import type { updateMedicationSchema } from "@/lib/validations/medication";
export type MedicationEdit = z.output<typeof updateMedicationSchema>;
type ScheduleInput = NonNullable<MedicationEdit["schedules"]>[number];
/** Parse "HH:mm" into minutes-of-day; NaN-safe (malformed → 0). */
function hhmmToMinutes(value: string): number {
return hhmmToMinutesOrNull(value) ?? 0;
}
/**
* Reconcile a schedule's `windowStart` / `windowEnd` with its effective
* `timesOfDay`. The schedule editor can change only the dose times while the
* client echoes the previous window back; a time outside the window then
* breaks every consumer that assumes `timesOfDay ⊆ [windowStart, windowEnd]`
* (the window-status pill, the reminder phases, the legacy compliance
* walker). When ANY time falls outside the window — including the overnight
* `windowEnd < windowStart` shape, checked with wrap-around membership —
* the window is pulled to the min/max of the times so the persisted row is
* self-consistent. A window that already covers every time is left
* byte-identical (explicitly wider windows are a feature).
*/
function reconcileScheduleWindow(
windowStart: string,
windowEnd: string,
timesOfDay: string[],
): { windowStart: string; windowEnd: string } {
if (timesOfDay.length === 0) return { windowStart, windowEnd };
const startMins = hhmmToMinutes(windowStart);
const endMins = hhmmToMinutes(windowEnd);
const overnight = endMins < startMins;
const inWindow = (t: number): boolean =>
overnight ? t >= startMins || t <= endMins : t >= startMins && t <= endMins;
const sorted = [...timesOfDay].sort(
(a, b) => hhmmToMinutes(a) - hhmmToMinutes(b),
);
const allInside = sorted.every((t) => inWindow(hhmmToMinutes(t)));
if (allInside) return { windowStart, windowEnd };
return { windowStart: sorted[0], windowEnd: sorted[sorted.length - 1] };
}
function normaliseSchedule(s: ScheduleInput, oneShot: boolean | undefined) {
// Invariant 2 — default to FREQ=DAILY when nothing else is set.
// PRN carries no cadence, so never default it.
const hasLegacyDays = (s.daysOfWeek?.length ?? 0) > 0;
const isPrn = s.scheduleType === "PRN";
const defaultedRrule =
oneShot !== true &&
!isPrn &&
s.rrule === undefined &&
s.rollingIntervalDays === undefined &&
!hasLegacyDays
? "FREQ=DAILY"
: s.rrule;
// Invariant 3 — dual-write timesOfDay from windowStart.
const effectiveTimesOfDay =
s.timesOfDay && s.timesOfDay.length > 0 ? s.timesOfDay : [s.windowStart];
// Invariant 4 — window / times consistency. See
// `reconcileScheduleWindow`: a times-only edit that echoes the
// stale window back gets the window pulled to the min/max of
// the new times.
const window = reconcileScheduleWindow(
s.windowStart,
s.windowEnd,
effectiveTimesOfDay,
);
const serializedDaysOfWeek = serializeScheduleRecurrence({
daysOfWeek: s.daysOfWeek ?? [],
intervalWeeks: s.intervalWeeks ?? 1,
});
return {
createData: {
windowStart: window.windowStart,
windowEnd: window.windowEnd,
label: s.label ?? null,
dose: s.dose ?? null,
// #219 — per-schedule inventory units, field-by-field. A schedule
// replace re-creates the rows, so absent leaves the column NULL
// and the consume hook inherits the medication-level value.
...(s.unitsPerDose !== undefined && {
unitsPerDose: s.unitsPerDose,
}),
daysOfWeek: serializedDaysOfWeek,
// First-class times-of-day.
timesOfDay: effectiveTimesOfDay,
...(s.reminderGraceMinutes !== undefined && {
reminderGraceMinutes: s.reminderGraceMinutes,
}),
...(defaultedRrule !== undefined && { rrule: defaultedRrule }),
...(s.rollingIntervalDays !== undefined && {
rollingIntervalDays: s.rollingIntervalDays,
}),
// Schedule type + cyclic weeks, field-by-field.
...(s.scheduleType !== undefined && {
scheduleType: s.scheduleType,
}),
...(s.cyclicOnWeeks !== undefined && {
cyclicOnWeeks: s.cyclicOnWeeks,
}),
...(s.cyclicOffWeeks !== undefined && {
cyclicOffWeeks: s.cyclicOffWeeks,
}),
// Per-dose configurable on-time windows. A `schedules`
// replace re-creates the rows, so the column is re-written each
// time; absent leaves it NULL (default ±1h derivation).
...(s.doseWindows !== undefined && {
doseWindows: s.doseWindows,
}),
},
// Mirror of the row the create above will persist (defaults applied),
// shaped for the cadence compare.
snapshot: toRevisionPayloadEntry({
timesOfDay: effectiveTimesOfDay,
windowStart: window.windowStart,
windowEnd: window.windowEnd,
daysOfWeek: serializedDaysOfWeek,
rrule: defaultedRrule ?? null,
rollingIntervalDays: s.rollingIntervalDays ?? null,
scheduleType: s.scheduleType ?? "SCHEDULED",
cyclicOnWeeks: s.cyclicOnWeeks ?? null,
cyclicOffWeeks: s.cyclicOffWeeks ?? null,
doseWindows: s.doseWindows ?? null,
label: s.label ?? null,
dose: s.dose ?? null,
reminderGraceMinutes: s.reminderGraceMinutes ?? null,
}),
};
}
export type NormalisedSchedule = ReturnType<typeof normaliseSchedule>;
/** The incoming schedules, normalised; undefined when the edit sends none. */
export function normaliseSchedules(
schedules: MedicationEdit["schedules"],
oneShot: boolean | undefined,
): NormalisedSchedule[] | undefined {
return schedules?.map((s) => normaliseSchedule(s, oneShot));
}
@@ -0,0 +1,387 @@
/**
* Edit one medication: `PUT /api/medications/{id}`.
*
* The route authenticates (MANAGE over the `medications` section), runs the
* ownership guard, reads the pre-image through `findMedicationForEdit` and
* parses the body; the record-only rule, the invariants, the transaction and
* everything after it run here, in the order they always ran. The outcome is
* a refusal the route answers with `apiError`, or the edited medication.
*/
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 type { SessionUser } from "@/lib/auth/session-user";
import { overwriteDetails } from "@/lib/sharing/audit-details";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import {
isAssignableCategory,
resolveMedicationCategories,
setMedicationCategory,
} from "@/lib/medication-category";
import {
CourseWriteError,
courseRefusalResponse,
resolveCourseFields,
} from "@/lib/medications/courses";
import { refreshDoseDay } from "@/lib/medications/dose-day-refresh";
import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-dto";
import { scheduleWireFields } from "@/lib/medications/intake-tracking";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import {
applyRecordOnlyRule,
findPrimaryScheduleGracePatch,
refuseAsNeededShape,
refuseCourseWindow,
refuseOneShotShape,
type EditableMedication,
type GracePatch,
type MedicationEditRefusal,
} from "./preflight";
import { normaliseSchedules, type MedicationEdit } from "./schedule-normalise";
import {
createEraTrail,
writeMedicationEdit,
type EraTrail,
type MedicationEditWrite,
} from "./write";
type EditUser = Pick<SessionUser, "id" | "timezone">;
type EditedMedication = Awaited<ReturnType<typeof writeMedicationEdit>>;
export type MedicationEditOutcome =
MedicationEditRefusal | { ok: true; body: Record<string, unknown> };
/**
* Everything the edit checks and derives before the transaction, in the
* order the route always ran it. A refusal leaves the medication as it was.
*/
async function planMedicationEdit(input: {
user: EditUser;
id: string;
existing: EditableMedication;
data: MedicationEdit;
}): Promise<
| MedicationEditRefusal
| {
ok: true;
write: MedicationEditWrite;
primaryScheduleGracePatch: GracePatch | null;
}
> {
const { user, id, existing } = input;
const edit = applyRecordOnlyRule(existing, input.data, id);
const {
category,
active,
schedules,
startsOn,
endsOn,
oneShot,
asNeeded,
trackIntake,
reminderGraceMinutes: topLevelGraceMinutes,
} = edit;
// A custom category must be one of this record's own (#1041).
if (
category !== undefined &&
!(await isAssignableCategory(user.id, category))
) {
return {
ok: false,
message: "Unknown medication category",
status: 422,
meta: { errorCode: "medications.category.unknown" },
};
}
// Intake tracking transition (#1033). Off keeps the schedule as a record;
// the live era from here on expects nothing.
const wasTracked = existing.trackIntake !== false;
const willTrack = trackIntake ?? wasTracked;
const trackingChanged = wasTracked !== willTrack;
const asNeededRefusal = refuseAsNeededShape(existing, asNeeded, schedules);
if (asNeededRefusal) return asNeededRefusal;
const primaryScheduleGracePatch = await findPrimaryScheduleGracePatch(
id,
topLevelGraceMinutes,
schedules,
);
const oneShotRefusal = refuseOneShotShape(oneShot, schedules);
if (oneShotRefusal) return oneShotRefusal;
// Normalise endsOn for one-shot. `oneShot === true` + `startsOn`
// means the dose is the start date; endsOn auto-matches.
const normalisedEndsOn = oneShot === true && startsOn ? startsOn : endsOn;
const touchesWindow =
startsOn !== undefined ||
normalisedEndsOn !== undefined ||
oneShot !== undefined;
const nextWindow = {
startsOn: startsOn !== undefined ? startsOn : existing.startsOn,
endsOn: normalisedEndsOn !== undefined ? normalisedEndsOn : existing.endsOn,
};
const courseTz = user.timezone || DEFAULT_TIMEZONE;
if (touchesWindow) {
const refusal = await refuseCourseWindow({
userId: user.id,
medicationId: id,
timeZone: courseTz,
nextWindow,
oneShot: oneShot ?? existing.oneShot,
createdAt: existing.createdAt,
});
if (refusal) return refusal;
}
const pausedAtPatch =
active === undefined
? {}
: active
? { pausedAt: null as Date | null }
: existing.active
? { pausedAt: new Date() }
: {};
return {
ok: true,
primaryScheduleGracePatch,
write: {
user,
id,
existing,
input: edit,
normalisedSchedules: normaliseSchedules(schedules, oneShot),
wasTracked,
willTrack,
trackingChanged,
pausedAtPatch,
touchesWindow,
nextWindow,
courseTz,
trail: createEraTrail(),
},
};
}
/**
* Refresh each dose day the tombstoned slots sat on, now that the tombstones
* are committed, then apply the primary-schedule grace bridge. Both run
* after the transaction so a failure here doesn't roll the edit back.
*/
async function settleAfterCommit(
write: MedicationEditWrite,
medication: EditedMedication,
primaryScheduleGracePatch: GracePatch | null,
): Promise<EditedMedication> {
const { user, id, trail } = write;
// Best-effort like every dose write-hook: a failure is reported, never
// fails the edit.
for (const dayKey of trail.staleDoseDays) {
await refreshDoseDay({
userId: user.id,
medicationId: id,
dayKey,
tz: user.timezone || DEFAULT_TIMEZONE,
});
}
// Re-read the row so the response shape carries the converged grace value.
if (primaryScheduleGracePatch) {
await prisma.medicationSchedule.update({
where: { id: primaryScheduleGracePatch.scheduleId },
data: {
reminderGraceMinutes: primaryScheduleGracePatch.reminderGraceMinutes,
},
});
const refreshed = await prisma.medication.findUnique({
where: { id },
include: { schedules: true },
});
if (refreshed) {
return refreshed;
}
}
return medication;
}
/**
* An iPhone app build that predates custom categories (#1041) decodes an
* unknown category as OTHER and sends it back on every edit, which would
* overwrite the person's own category with OTHER. A native client (Bearer)
* sending OTHER over a custom category keeps the custom one; moving a custom
* category back to OTHER stays possible on the web.
*/
async function writeCategory(
id: string,
category: string | undefined,
authMethod: string,
) {
let categoryToWrite = category;
if (category === "OTHER" && authMethod === "bearer") {
const current = (await resolveMedicationCategories([id]))[id];
if (current?.category.startsWith("custom:")) {
categoryToWrite = undefined;
annotate({
action: { name: "medication.update.custom_category_kept" },
meta: { entity_id: id },
});
}
}
if (categoryToWrite !== undefined)
await setMedicationCategory(id, categoryToWrite);
return (await resolveMedicationCategories([id]))[id];
}
/** The audit row: C4 on the medication row itself, C7 on the schedule. */
async function auditMedicationEdit(
write: MedicationEditWrite,
medication: EditedMedication,
ipAddress: string | null,
) {
const { user, id, existing, trail } = write;
const {
replacedCadence,
replacedScheduleCount,
tombstonedSlotCount,
tombstonedFrom,
tombstonedTo,
}: EraTrail = trail;
await auditLog("medication.update", {
userId: user.id,
ipAddress,
// C4 on what the medication row itself lost, and C7 on the schedule:
// the cadence that was replaced, and how many pending slots went with
// it over which days.
details: {
medicationId: id,
...overwriteDetails({
before: {
name: existing.name,
dose: existing.dose,
active: existing.active,
asNeeded: existing.asNeeded,
trackIntake: existing.trackIntake,
// Switching automatic recording on or off is a change
// to how doses are recorded, so the row names it.
autoMarkTaken: existing.autoMarkTaken,
endsOn: existing.endsOn,
},
after: {
name: medication.name,
dose: medication.dose,
active: medication.active,
asNeeded: medication.asNeeded,
trackIntake: medication.trackIntake,
autoMarkTaken: medication.autoMarkTaken,
endsOn: medication.endsOn,
},
}),
...(replacedScheduleCount !== null
? {
replacedCadence,
replacedScheduleCount,
tombstonedSlotCount,
tombstonedFrom: tombstonedFrom?.toISOString() ?? null,
tombstonedTo: tombstonedTo?.toISOString() ?? null,
}
: {}),
},
});
}
/** Edit one medication the route found and authorised. */
export async function updateMedication(input: {
user: EditUser;
authMethod: string;
id: string;
existing: EditableMedication;
data: MedicationEdit;
ipAddress: string | null;
}): Promise<MedicationEditOutcome> {
const { user, id } = input;
const plan = await planMedicationEdit(input);
if (!plan.ok) return plan;
const { write } = plan;
let medication: EditedMedication;
try {
medication = await writeMedicationEdit(write);
} catch (err) {
// A concurrent course write landed between the check above and the
// course write. The transaction rolled back: nothing was written.
if (err instanceof CourseWriteError) {
const r = courseRefusalResponse(err.refusal);
return {
ok: false,
message: r.message,
status: r.status,
meta: { errorCode: r.errorCode },
};
}
throw err;
}
medication = await settleAfterCommit(
write,
medication,
plan.primaryScheduleGracePatch,
);
const resolvedCategory = await writeCategory(
id,
write.input.category,
input.authMethod,
);
const normalizedCategory = resolvedCategory?.category ?? "OTHER";
// A changed units-per-dose (on the medication or on a slot) changes what
// every taken dose added, so a medication with a composition has its doses
// recomputed. Cheap and coalesced when nothing moved.
if (await medicationHasComposition(user.id, id)) {
await enqueueSupplementNutrientRecompute(user.id);
}
await auditMedicationEdit(write, medication, input.ipAddress);
annotate({
action: {
name: "medication.update",
entity_type: "medication",
entity_id: id,
},
});
// Bust per-user medications + compliance + achievement caches so the next
// read reflects the schedule change.
invalidateUserMedications(user.id, { evict: true });
return {
ok: true,
body: {
...medication,
unitsPerDose: Number(medication.unitsPerDose),
...scheduleWireFields(
medication.trackIntake,
serializeScheduleUnitsPerDose(
medication.schedules,
medication.unitsPerDose,
),
),
category: normalizedCategory,
categoryLabel: resolvedCategory?.categoryLabel ?? null,
// The courses, as every medication read carries them (#1024).
...(
await resolveCourseFields([medication], new Date(), write.courseTz)
).get(id),
},
};
}
@@ -0,0 +1,455 @@
/**
* The write of a medication edit: everything the PUT changes on the
* medication, its schedule and its course commits as one transaction.
*
* The course write can still be refused after the preflight check (another
* request wrote a course in between); with the row update committed first,
* the client got the refusal while the name, the schedule and `oneShot` were
* already changed. Inside one transaction a refusal rolls all of it back. The
* dose-day refresh and the grace bridge stay outside: both read or write what
* this commit settles, and neither may undo it.
*/
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import type { SessionUser } from "@/lib/auth/session-user";
import { setCurrentWindow } from "@/lib/medications/courses";
import {
schedulesMateriallyDiffer,
toRevisionPayloadEntry,
} from "@/lib/medications/scheduling/schedule-eras";
import { moveAutoTakeFloorToNow } from "@/lib/medications/auto-take";
import { dayKeyForScheduledFor } from "@/lib/medications/dose-day-refresh";
import { getUserTodayBounds } from "@/lib/tz/local-day";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import type { EditableMedication } from "./preflight";
import type { MedicationEdit, NormalisedSchedule } from "./schedule-normalise";
/**
* C7. What a schedule replacement destroys, captured for the audit row: the
* cadence that was there and the pending slots that were tombstoned with it.
* A feed line saying "changed a medication" cannot answer "back to what?",
* and unlike a row a schedule keeps ACTING after the manager's grant ends.
* `staleDoseDays` holds the dose days the tombstoned slots sat on, refreshed
* once the transaction has committed the tombstones.
*/
export interface EraTrail {
replacedCadence: string | null;
replacedScheduleCount: number | null;
tombstonedSlotCount: number | null;
tombstonedFrom: Date | null;
tombstonedTo: Date | null;
staleDoseDays: Set<string>;
}
export function createEraTrail(): EraTrail {
return {
replacedCadence: null,
replacedScheduleCount: null,
tombstonedSlotCount: null,
tombstonedFrom: null,
tombstonedTo: null,
staleDoseDays: new Set<string>(),
};
}
export interface MedicationEditWrite {
user: Pick<SessionUser, "id" | "timezone">;
id: string;
existing: EditableMedication;
input: MedicationEdit;
normalisedSchedules: NormalisedSchedule[] | undefined;
wasTracked: boolean;
willTrack: boolean;
trackingChanged: boolean;
pausedAtPatch: { pausedAt?: Date | null };
touchesWindow: boolean;
nextWindow: { startsOn: Date | null; endsOn: Date | null };
courseTz: string;
trail: EraTrail;
}
type RevisionSourceRow = Parameters<typeof toRevisionPayloadEntry>[0];
/**
* Tombstone today's and future open pending rows of this medication. Runs on
* a schedule replace (the old anchors are stale) and when intake tracking is
* switched off (nothing is due any more).
*/
async function tombstoneOpenSlots(
db: Prisma.TransactionClient,
edit: MedicationEditWrite,
): Promise<void> {
const { user, id, trail } = edit;
// A schedule replace invalidates the open slot anchors the projector /
// reminder worker minted for the OLD times: a pending 08:00 row for a
// medication that now doses at 20:00 would linger as a phantom slot,
// auto-miss later, and depress compliance. Tombstone today's and
// future open pending rows (never an actioned row — `takenAt`,
// `skipped`, and `autoMissed` are user-visible history) and bump
// `syncVersion` so delta-sync clients drop them. The projector and
// the reminder worker re-mint the anchors for the new times on their
// next pass — the route does not pre-create them.
const userTz = user.timezone || DEFAULT_TIMEZONE;
const { start: todayStart } = getUserTodayBounds(new Date(), userTz);
const tombstoneWhere = {
userId: user.id,
medicationId: id,
deletedAt: null,
takenAt: null,
skipped: false,
autoMissed: false,
scheduledFor: { gte: todayStart },
} as const;
// Capture the affected slots BEFORE the tombstone so the compliance
// rollups for those days can be recomputed after the commit —
// `updateMany` does not return the touched rows. Without the
// recompute a tombstoned pending kept counting as `scheduled` in
// the rollup row until the next event-driven write for that day,
// depressing the rate the cards read in the meantime.
const tombstoned = await db.medicationIntakeEvent.findMany({
where: tombstoneWhere,
select: { scheduledFor: true },
});
// The count and the day range of the anchors that went.
trail.tombstonedSlotCount = tombstoned.length;
for (const row of tombstoned) {
if (
trail.tombstonedFrom === null ||
row.scheduledFor < trail.tombstonedFrom
) {
trail.tombstonedFrom = row.scheduledFor;
}
if (trail.tombstonedTo === null || row.scheduledFor > trail.tombstonedTo) {
trail.tombstonedTo = row.scheduledFor;
}
}
await db.medicationIntakeEvent.updateMany({
where: tombstoneWhere,
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
});
// Deduped via the user-tz day key.
for (const row of tombstoned) {
trail.staleDoseDays.add(dayKeyForScheduledFor(row.scheduledFor, userTz));
}
}
function revisionEntryOf(row: RevisionSourceRow) {
return toRevisionPayloadEntry({
timesOfDay: row.timesOfDay,
windowStart: row.windowStart,
windowEnd: row.windowEnd,
daysOfWeek: row.daysOfWeek,
rrule: row.rrule,
rollingIntervalDays: row.rollingIntervalDays,
scheduleType: row.scheduleType,
cyclicOnWeeks: row.cyclicOnWeeks,
cyclicOffWeeks: row.cyclicOffWeeks,
doseWindows: row.doseWindows,
label: row.label,
dose: row.dose,
reminderGraceMinutes: row.reminderGraceMinutes,
});
}
/** One archived era covering `[previous validUntil | createdAt, now)`. */
async function archiveEra(
tx: Prisma.TransactionClient,
edit: MedicationEditWrite,
payload: unknown[],
meta: Record<string, unknown>,
): Promise<void> {
const { id, existing } = edit;
const lastRevision = await tx.medicationScheduleRevision.findFirst({
// Chain from the ACTIVE boundary: a superseded row is an
// audit record whose `validUntil` may sit past its correction.
where: { medicationId: id, supersededByRevisionId: null },
orderBy: { validUntil: "desc" },
select: { validUntil: true },
});
await tx.medicationScheduleRevision.create({
data: {
medicationId: id,
validFrom: lastRevision?.validUntil ?? existing.createdAt,
validUntil: new Date(),
payload: payload as unknown as Prisma.InputJsonValue,
},
});
annotate({
action: {
name: "medication.schedule.revision_archived",
entity_type: "medication",
entity_id: id,
},
meta,
});
}
/** The cadence a schedule replace destroys, as one comparable line. */
function cadenceOf(previousRows: RevisionSourceRow[]): string | null {
return previousRows.length > 0
? previousRows
.map((row) =>
[
row.timesOfDay,
row.rrule ?? "",
row.rollingIntervalDays !== null
? `every ${row.rollingIntervalDays}d`
: "",
row.daysOfWeek ?? "",
]
.filter((part) => part !== "")
.join(" "),
)
.join(" | ")
: null;
}
/**
* A schedule replace. Effective dating: before the wholesale replace wipes
* the old rows, archive them as ONE revision covering `[previous validUntil |
* medication.createdAt, now)` — but only when a cadence-relevant field
* actually changes. A no-op edit (the Zeitplan tab echoing the same rows
* back) must not mint a phantom era.
*/
async function replaceSchedules(
tx: Prisma.TransactionClient,
edit: MedicationEditWrite,
normalisedSchedules: NormalisedSchedule[],
): Promise<void> {
const { id, existing, input, wasTracked, trackingChanged, trail } = edit;
const previousRows = await tx.medicationSchedule.findMany({
where: { medicationId: id },
});
const previousEntries = previousRows.map(revisionEntryOf);
trail.replacedScheduleCount = previousRows.length;
trail.replacedCadence = cadenceOf(previousRows);
// A stretch with intake tracking off expected nothing (#1033), so it
// archives with an empty payload whatever the rows said; and a tracking
// transition is itself an era boundary even when the rows stay the same.
const archivedEntries = wasTracked ? previousEntries : [];
if (
previousRows.length > 0 &&
(trackingChanged ||
!wasTracked ||
schedulesMateriallyDiffer(
previousEntries,
normalisedSchedules.map((n) => n.snapshot),
))
) {
await archiveEra(tx, edit, archivedEntries, {
schedule_revision_rows: archivedEntries.length,
});
} else if (
previousRows.length === 0 &&
existing.asNeeded &&
input.asNeeded === false &&
normalisedSchedules.length > 0
) {
// Flipping as-needed OFF. The medication carried ZERO
// schedule rows while as-needed, so the wholesale-replace archive
// above never fires — and without a revision the live era would
// start at the PREVIOUS revision's `validUntil` (or `createdAt`),
// retro-painting the schedule-less as-needed stretch with the NEW
// schedule's expected slots: every PRN day would read as missed.
// Archive an EMPTY era covering the as-needed stretch instead; an
// empty payload expands to zero schedules, so era-aware compliance
// expects nothing there — exactly the as-needed contract.
await archiveEra(tx, edit, [], { schedule_revision_rows: 0 });
}
await tombstoneOpenSlots(tx, edit);
await tx.medicationSchedule.deleteMany({
where: { medicationId: id },
});
}
/**
* Intake tracking switched without a schedule replace (#1033). Close the
* current era at this instant: switching OFF archives the schedule that was
* in force (the tracked stretch keeps its expected doses); switching back ON
* archives the untracked stretch with an empty payload, so compliance never
* counts it as missed and the live schedule, the projector and the reminder
* worker all resume from now.
*/
async function archiveTrackingSwitch(
tx: Prisma.TransactionClient,
edit: MedicationEditWrite,
): Promise<void> {
const { id, wasTracked, willTrack } = edit;
const liveRows = await tx.medicationSchedule.findMany({
where: { medicationId: id },
});
if (liveRows.length > 0) {
const archivedEntries = wasTracked ? liveRows.map(revisionEntryOf) : [];
await archiveEra(tx, edit, archivedEntries, {
schedule_revision_rows: archivedEntries.length,
track_intake: willTrack,
});
}
}
/** The medication row's update, field-by-field from the parsed body. */
function medicationUpdateData(edit: MedicationEditWrite) {
const { existing, input, normalisedSchedules } = edit;
const {
name,
dose,
treatmentClass,
dosesPerUnit,
unitsPerDose,
reorderLeadDays,
deliveryForm,
trackInjectionSites,
allowedInjectionSites,
active,
notificationsEnabled,
liveActivityEnabled,
criticalAlarmEnabled,
atcCode,
rxNormCode,
oneShot,
asNeeded,
trackIntake,
autoMarkTaken,
} = input;
return {
...(name !== undefined && { name }),
...(dose !== undefined && { dose }),
...(treatmentClass !== undefined && { treatmentClass }),
...(dosesPerUnit !== undefined && { dosesPerUnit }),
// Units consumed per dose. Field-by-field; the stamp on
// already-taken intake events freezes their recorded consumption.
...(unitsPerDose !== undefined && { unitsPerDose }),
// Reorder lead override. Field-by-field; `null` is a
// valid explicit value (clears the override → user-level default).
...(reorderLeadDays !== undefined && { reorderLeadDays }),
...(deliveryForm !== undefined && { deliveryForm }),
// Injection-site tracking opt-in + per-medication allowed
// sites. Field-by-field; `false` / `[]` are valid explicit values
// (deactivate tracking / clear the per-med restriction).
...(trackInjectionSites !== undefined && { trackInjectionSites }),
...(allowedInjectionSites !== undefined && { allowedInjectionSites }),
...(active !== undefined && { active }),
...(notificationsEnabled !== undefined && { notificationsEnabled }),
// iOS reminder flags, field-by-field.
...(liveActivityEnabled !== undefined && { liveActivityEnabled }),
...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }),
// Optional drug-classification codes (ATC / RxNorm).
// Field-by-field; `null` is a valid explicit value (clears the code).
...(atcCode !== undefined && { atcCode }),
...(rxNormCode !== undefined && { rxNormCode }),
// Scheduling primitives — pass-through when supplied.
// `startsOn` / `endsOn` are not written here (#1024): the
// course writer below writes them together with the course they
// project, in one transaction, so the row and its courses cannot
// disagree if one of the two writes fails.
...(oneShot !== undefined && { oneShot }),
// As-needed flag, field-by-field (the invariants above
// already guaranteed the medication ends schedule-consistent).
...(asNeeded !== undefined && { asNeeded }),
// Intake tracking, field-by-field (#1033).
...(trackIntake !== undefined && { trackIntake }),
// Auto-mark-taken, field-by-field. Only an off→on flip
// stamps `autoMarkTakenSince`, so re-saving the form with the
// option already on never moves the floor; switching it off
// clears the stamp.
...(autoMarkTaken !== undefined &&
autoMarkTaken !== existing.autoMarkTaken && {
autoMarkTaken,
autoMarkTakenSince: autoMarkTaken ? new Date() : null,
}),
...(normalisedSchedules && {
schedules: {
create: normalisedSchedules.map((n) => n.createData),
},
}),
};
}
/**
* Durable pause intervals. The `pausedAt` column is a single live marker that
* resume clears, so the paused window is irrecoverable once resumed and every
* paused day's slot collapses to "missed" in the compliance denominator.
* Mirror the live transition onto an additive era record the rate paths read:
* a pause opens an era (resumedAt null), a resume closes the latest open era.
* `userId` comes from the authenticated session; the data object is built
* field-by-field.
*/
async function recordPauseTransition(
tx: Prisma.TransactionClient,
edit: MedicationEditWrite,
): Promise<void> {
const { user, id, existing } = edit;
const { active } = edit.input;
if (active === false && existing.active) {
await tx.medicationPauseEra.create({
data: {
medicationId: id,
userId: user.id,
pausedAt: new Date(),
resumedAt: null,
},
});
} else if (active === true && !existing.active) {
await tx.medicationPauseEra.updateMany({
where: { medicationId: id, userId: user.id, resumedAt: null },
data: { resumedAt: new Date() },
});
// Made active again: the auto-take pass starts from
// now, never filling the inactive days as taken.
await moveAutoTakeFloorToNow(tx, id);
}
}
/**
* The transaction. Throws `CourseWriteError` when a concurrent course write
* landed between the preflight check and the course write; nothing is then
* written.
*/
export async function writeMedicationEdit(edit: MedicationEditWrite) {
const { user, id, input, normalisedSchedules, trackingChanged, willTrack } =
edit;
const { schedules } = input;
return prisma.$transaction(async (tx) => {
// If schedules provided, replace all
if (schedules && normalisedSchedules) {
await replaceSchedules(tx, edit, normalisedSchedules);
} else if (trackingChanged) {
await archiveTrackingSwitch(tx, edit);
}
// Switching intake tracking off leaves nothing due (#1033):
// the open placeholders of today and later go the way a schedule
// replace sends them (a replace in the same request already did it).
if (trackingChanged && !willTrack && !schedules) {
await tombstoneOpenSlots(tx, edit);
}
const baseUpdateData = medicationUpdateData(edit);
const updated = await tx.medication.update({
where: { id },
data: { ...baseUpdateData, ...edit.pausedAtPatch },
include: { schedules: true },
});
await recordPauseTransition(tx, edit);
// Write the window onto the latest course and
// project it back, then serve the projected row (#1024).
if (edit.touchesWindow) {
const projected = await setCurrentWindow(
{
userId: user.id,
medicationId: id,
timeZone: edit.courseTz,
...edit.nextWindow,
},
tx,
);
return { ...updated, ...projected };
}
return updated;
});
}
+571
View File
@@ -0,0 +1,571 @@
/**
* The bulk mood ingest behind `POST /api/mood-entries/bulk`.
*
* The route authenticates, meters, caps and parses the batch; this module
* writes it. Per-entry UPSERT keyed by `externalId` when the entry carries
* one, else by the legacy `(userId, date, moodLoggedAt)` key, so a re-run is
* idempotent. A bad entry is `skipped` and the rest of the batch lands: an
* unstable external id, a context outside the vocabulary, dimensions out of
* range, or a rated factor outside its own scale each refuse that one row.
*/
import type { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { markSyncCheckpoint } from "@/lib/sync/checkpoint";
import {
getScoreForMood,
moodContextSchema,
type moodLevelEnum,
type 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 { levelAForWrite, type LevelAInput } 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,
type DroppedLinkKeys,
type RatedFactorInput,
} from "@/lib/mood/tag-links";
/** One entry as the route's batch schema parsed it. */
export interface BulkMoodEntry {
mood: z.infer<typeof moodLevelEnum>;
tags?: string[];
tagKeys?: string[];
ratedFactors?: RatedFactorInput[];
note?: string;
a1?: unknown;
a2?: unknown;
a3?: unknown;
a4?: unknown;
a5?: unknown;
context?: unknown;
moodLoggedAt: Date;
source: z.infer<typeof moodSourceEnum>;
externalId?: string;
}
/**
* The route's per-entry dimension parse. It stays beside the batch schema
* so the bound is pinned against the single-entry one
* (`bulk-dimension-bounds.test.ts`).
*/
export type ParseBulkDimensions = (
dimensions: Pick<BulkMoodEntry, "a1" | "a2" | "a3" | "a4" | "a5">,
) => { success: true; data: LevelAInput } | { success: false };
type EntryStatus = "inserted" | "duplicate" | "skipped";
interface EntryResult {
index: number;
status: EntryStatus;
reason?: string;
id?: string;
// 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 interface BulkMoodIngestResult {
processed: number;
inserted: number;
duplicates: number;
skipped: Array<{ index: number; reason: string }>;
entries: EntryResult[];
}
interface ExistingMoodRow {
id: string;
deletedAt: Date | null;
}
interface ExistingRows {
byExternalKey: Map<string, ExistingMoodRow>;
byDateKey: Map<string, ExistingMoodRow>;
}
/** The running totals of one batch, filled entry by entry. */
interface Tally {
results: EntryResult[];
skipped: Array<{ index: number; reason: string }>;
inserted: number;
duplicates: number;
droppedTagKeyCount: number;
droppedFactorKeyCount: number;
unstableShapes: UnstableExternalIdShape[];
}
function echoExternalId(entry: BulkMoodEntry): { externalId?: string } {
return entry.externalId ? { externalId: entry.externalId } : {};
}
function skip(
tally: Tally,
index: number,
entry: BulkMoodEntry,
reason: string,
): void {
tally.skipped.push({ index, reason });
tally.results.push({
index,
status: "skipped",
reason,
...echoExternalId(entry),
});
}
/**
* Perf — a single indexed existence read replaces the per-entry `findUnique`
* probe (up to 500 sequential round trips before). Every entry still resolves
* through exactly the key its own dedup contract uses (externalId when
* carried, else the legacy date+moodLoggedAt key), so the lookups are
* byte-identical to the per-entry probe; only the read is batched. The
* per-entry `upsert` + tombstone check + tag links stay per entry; 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).
*/
async function readExistingRows(
userId: string,
tz: string,
entries: BulkMoodEntry[],
): Promise<ExistingRows> {
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,
},
);
const existingRows =
orClauses.length > 0
? await prisma.moodEntry.findMany({
where: { userId, OR: orClauses },
select: {
id: true,
deletedAt: true,
source: true,
externalId: true,
date: true,
moodLoggedAt: true,
},
})
: [];
const byExternalKey = new Map<string, ExistingMoodRow>();
const byDateKey = new Map<string, ExistingMoodRow>();
for (const row of existingRows) {
if (row.externalId !== null) {
byExternalKey.set(`${row.source}::${row.externalId}`, row);
}
byDateKey.set(`${row.date}::${row.moodLoggedAt.toISOString()}`, row);
}
return { byExternalKey, byDateKey };
}
/**
* 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.
*/
async function writeEntry(args: {
userId: string;
tz: string;
entry: BulkMoodEntry;
date: string;
levelA: ReturnType<typeof levelAForWrite>;
context: z.infer<typeof moodContextSchema> | null | undefined;
}): Promise<{ id: string; droppedKeys: DroppedLinkKeys }> {
const { userId, tz, entry, date, levelA } = args;
const score = getScoreForMood(entry.mood);
// 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,
source: resolvedSource,
externalId: entry.externalId,
},
}
: {
userId_date_moodLoggedAt: {
userId,
date,
moodLoggedAt: entry.moodLoggedAt,
},
};
return prisma.$transaction(async (tx) => {
const upserted = await tx.moodEntry.upsert({
where: probeWhere,
create: {
userId,
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 } : {}),
},
});
// 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. Rated factors ride the same path; an
// out-of-scale rating throws `RatedFactorOutOfRangeError`, which inside
// the transaction rolls the mood row back with it, so a "skipped" result
// means nothing was written. The day context sits inside the same
// transaction as the row for the same reason.
await persistMoodContext(tx, upserted.id, userId, args.context);
// 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,
userId,
entry.tagKeys ?? [],
tx,
entry.ratedFactors ?? [],
);
}
return { id: upserted.id, droppedKeys };
});
}
/**
* 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.
*/
function isRacedDuplicate(err: unknown): boolean {
return (
typeof err === "object" &&
err !== null &&
"code" in err &&
(err as { code: string }).code === "P2002"
);
}
async function ingestEntry(args: {
userId: string;
tz: string;
entry: BulkMoodEntry;
index: number;
existing: ExistingRows;
parseDimensions: ParseBulkDimensions;
tally: Tally;
}): Promise<void> {
const { userId, tz, entry, index: i, tally } = args;
// 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) {
tally.unstableShapes.push(unstable);
tally.skipped.push({ index: i, reason: UNSTABLE_EXTERNAL_ID_REASON });
tally.results.push({
index: i,
status: "skipped",
reason: UNSTABLE_EXTERNAL_ID_REASON,
externalId: entry.externalId,
});
return;
}
// 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) {
skip(tally, i, entry, "invalid_context");
return;
}
// The five dimensions, parsed for THIS entry. Out of range skips this row
// and nothing else.
const parsedDimensions = args.parseDimensions({
a1: entry.a1,
a2: entry.a2,
a3: entry.a3,
a4: entry.a4,
a5: entry.a5,
});
if (!parsedDimensions.success) {
skip(tally, i, entry, "invalid_dimensions");
return;
}
const date = moodDateKey(entry.moodLoggedAt, tz);
// 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 {
// The response distinguishes "inserted" from "duplicate" via the batched
// pre-read (keyed exactly like the upsert key), so the per-entry probe
// here is a Map lookup, not a round trip.
const existing =
(entry.externalId
? args.existing.byExternalKey.get(
`${entry.source}::${entry.externalId}`,
)
: args.existing.byDateKey.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) {
tally.duplicates += 1;
tally.results.push({
index: i,
status: "duplicate",
id: existing.id,
...echoExternalId(entry),
});
return;
}
const written = await writeEntry({
userId,
tz,
entry,
date,
levelA,
context: parsedContext.data,
});
tally.droppedTagKeyCount += written.droppedKeys.droppedTagKeys.length;
tally.droppedFactorKeyCount += written.droppedKeys.droppedFactorKeys.length;
if (existing) tally.duplicates += 1;
else tally.inserted += 1;
tally.results.push({
index: i,
status: existing ? "duplicate" : "inserted",
id: written.id,
...echoExternalId(entry),
...droppedLinkKeysForWire(written.droppedKeys),
});
} catch (err: unknown) {
if (isRacedDuplicate(err) && entry.externalId) {
const winner = await prisma.moodEntry.findFirst({
where: {
userId,
// Same resolution as the upsert key: the schema defaults `source`
// to "MANUAL" at parse time.
source: entry.source,
externalId: entry.externalId,
},
select: { id: true },
});
tally.duplicates += 1;
tally.results.push({
index: i,
status: "duplicate",
...(winner ? { id: winner.id } : {}),
externalId: entry.externalId,
});
return;
}
const reason =
err instanceof Error ? err.message.slice(0, 120) : "upsert_failed";
skip(tally, i, entry, reason);
}
}
/**
* Refresh the rollup tier for every distinct day the batch touched. The bulk
* endpoint is an iOS one-shot backfill, so the batch can span many days; the
* set of `date` labels (the same per-row `moodDateKey` the insert used, so
* the rollup keys byte-identically to the stored `MoodEntry.date`) bounds the
* recompute count. Best-effort: a rollup failure must not surface as a 5xx.
*/
async function refreshTouchedRollups(
userId: string,
tz: string,
entries: BulkMoodEntry[],
results: EntryResult[],
): Promise<void> {
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(userId, label),
),
);
} catch (rollupErr) {
annotate({
meta: {
mood_rollup_write_failed: true,
mood_rollup_write_error:
rollupErr instanceof Error ? rollupErr.message : String(rollupErr),
},
});
}
}
/** Write one parsed batch for `userId` and answer its per-entry envelope. */
export async function ingestMoodBulk(input: {
userId: string;
tz: string;
entries: BulkMoodEntry[];
ipAddress: string | null;
parseDimensions: ParseBulkDimensions;
}): Promise<BulkMoodIngestResult> {
const { userId, tz, entries } = input;
const tally: Tally = {
results: [],
skipped: [],
inserted: 0,
duplicates: 0,
droppedTagKeyCount: 0,
droppedFactorKeyCount: 0,
unstableShapes: [],
};
const existing = await readExistingRows(userId, tz, entries);
for (let i = 0; i < entries.length; i++) {
await ingestEntry({
userId,
tz,
entry: entries[i],
index: i,
existing,
parseDimensions: input.parseDimensions,
tally,
});
}
await auditLog("mood.bulk.ingest", {
userId,
ipAddress: input.ipAddress,
details: {
processed: entries.length,
inserted: tally.inserted,
duplicates: tally.duplicates,
skipped: tally.skipped.length,
},
});
annotate({
action: { name: "mood.bulk.ingest" },
meta: {
processed: entries.length,
inserted: tally.inserted,
duplicates: tally.duplicates,
skipped: tally.skipped.length,
dropped_tag_keys: tally.droppedTagKeyCount,
dropped_factor_keys: tally.droppedFactorKeyCount,
...(tally.unstableShapes.length > 0
? unstableExternalIdMeta("mood.bulk", tally.unstableShapes)
: {}),
},
});
// Bust the 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 (tally.inserted > 0) {
invalidateUserMood(userId);
}
if (tally.inserted > 0 || tally.duplicates > 0) {
await refreshTouchedRollups(userId, tz, entries, tally.results);
}
// 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(userId);
return {
processed: entries.length,
inserted: tally.inserted,
duplicates: tally.duplicates,
skipped: tally.skipped,
entries: tally.results,
};
}
+2 -2
View File
@@ -43,10 +43,10 @@ import {
import { isoDateTime } from "@/lib/validations/datetime";
// ── Cycle tracking (v1.15.0) ─────────────────────────────────────────
// The `/api/cycle/*` capture / calendar / history / settings surface +
// The `/api/cycle` capture / calendar / history / settings surface +
// the cycle-prefs PATCH. Request bodies come from the Zod validation
// module so the spec stays single-source; response DTOs are declared
// here mirroring `src/lib/cycle/dto.ts`. Every `/api/cycle/*` route also
// here mirroring `src/lib/cycle/dto.ts`. Every `/api/cycle` route also
// 403s `{ errorCode:"cycle.disabled" }` when the feature gate is off.
const predictionMethodEnumOpenapi = z
+30 -5
View File
@@ -379,11 +379,31 @@ const documentUploadReceipt = z
});
// Shared by the POST 200 (duplicate) and 201 (created) responses — the
// envelope id must be registered exactly once.
const documentUploadEnvelope = dataEnvelope(
z.union([inboundDocument, documentUploadReceipt]),
"DocumentUploadEnvelope",
);
// envelope id must be registered exactly once. The duplicate answer flags
// itself at the envelope level, beside the request id.
const documentUploadEnvelope = z
.object({
data: z.union([inboundDocument, documentUploadReceipt]),
error: z.null(),
meta: z
.object({
requestId: z.string().optional(),
duplicate: z
.literal(true)
.optional()
.describe(
"Present on a 200: the bytes or the source key were already stored, and `data` is the existing row (or the receipt).",
),
deleted: z
.literal(true)
.optional()
.describe(
"Present with `duplicate` when the source key belongs to a document the owner deleted. Nothing was stored.",
),
})
.optional(),
})
.meta({ id: "DocumentUploadEnvelope" });
/**
* A `DOCUMENT_EXTRACT` run's result. Smaller than the synchronous extract
@@ -672,6 +692,11 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
'Unsupported or unidentifiable type (`meta.reason = "unsupportedType"`). Executables, HTML/SVG, and generic archives are always refused.',
content: { "application/json": { schema: errorEnvelope } },
},
"404": {
description:
"An `episodeIds` or `encounterIds` id is not a live episode or visit of the caller (`documents.inbound.episodeNotFound` / `documents.inbound.encounterNotFound`). Nothing was stored.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
+9
View File
@@ -276,6 +276,15 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"Every provider in the chain failed for a reason that is not a rate limit — auth, timeout or an upstream 5xx. A dated failure marker is written, so the read GET reports the briefing as held rather than fresh. Also: the `briefing` capability could not be resolved (`meta.errorCode` = `ai.unavailable`, `meta.reason` = `check_failed`); nothing was generated.",
content: { "application/json": { schema: errorEnvelope } },
},
"413": {
description: "The request body is over 16 KB. Nothing was generated.",
content: { "application/json": { schema: errorEnvelope } },
},
"502": {
description:
"The generated briefing did not pass the outbound safety screen and was discarded (`meta.errorCode` = `insights.generate.outboundScreened`). Regenerating may succeed.",
content: { "application/json": { schema: errorEnvelope } },
},
"401": stdResponses["401"],
},
},
+1 -1
View File
@@ -176,7 +176,7 @@ export function listEnvelope<T extends z.ZodType>(payload: T, id: string) {
// annotated clone is exported from here under the name the use sites already
// spell and the base import is aliased. A module that wants the published
// form imports it from `./shared`; importing the raw schema from
// `@/lib/validations/*` instead is what inlined these anonymously.
// a `@/lib/validations` module instead is what inlined these anonymously.
export const measurementTypeEnum = measurementTypeEnumBase.meta({
id: "MeasurementType",
+15 -1
View File
@@ -185,7 +185,21 @@ const workoutListResponse = z
const workoutRouteGeometry = z
.object({
geometry: z.unknown(),
sampleTimestamps: z.array(z.string()).nullable(),
sampleTimestamps: z
.array(
z.union([
isoDateTime(),
z.object({
t: isoDateTime(),
speedMs: z.number().optional(),
hr: z.number().int().optional(),
}),
]),
)
.nullable()
.describe(
"One entry per geometry point, as the track was stored: a bare instant, or (from the workout batch and the Apple Health export) an object with its instant and, where recorded, speed (m/s) and heart rate (bpm).",
),
})
.meta({ id: "WorkoutRouteGeometry" });
@@ -244,12 +244,68 @@ describe("the matcher sees what it looks for", () => {
});
});
/**
* Delegable write routes whose handler bodies live in service modules under
* `src/lib/`, keyed by the route relative to `src/`. The route keeps the
* resolver call, so membership is read off the route; the awaited audit
* actions are read off the route AND these modules, because that is where the
* rows are written now. Each module has to be imported by 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]/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",
],
};
function handlerAuditActions(file: string): string[] {
const rel = relative(join(process.cwd(), "src"), file);
return (HANDLER_BODY_MODULES[rel] ?? []).flatMap((body) => {
const path = join(process.cwd(), "src", body);
return moduleFacts(readFileSync(path, "utf8"), path).awaitedAuditActions;
});
}
describe("the owner's activity view names what a delegate did", () => {
const routes = routeFiles(API);
const writeModules = routes
.map((file) => ({ file, ...moduleFacts(readFileSync(file, "utf8"), file) }))
.map((file) => {
const facts = moduleFacts(readFileSync(file, "utf8"), file);
return {
file,
writesToRecord: facts.writesToRecord,
awaitedAuditActions: [
...facts.awaitedAuditActions,
...handlerAuditActions(file),
],
};
})
.filter((m) => m.writesToRecord);
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, modules] of entries) {
const source = readFileSync(join(process.cwd(), "src", route), "utf8");
expect(moduleFacts(source, route).writesToRecord, route).toBe(true);
for (const body of modules) {
const specifier = `@/${body.replace(/\.ts$/, "")}`;
expect(source, `${route} does not import ${specifier}`).toContain(
`"${specifier}"`,
);
expect(
handlerAuditActions(join(process.cwd(), "src", route)).length,
`${body} awaits no audit row`,
).toBeGreaterThan(0);
}
}
});
it("reads a plausible number of route modules", () => {
// A walk that finds nothing agrees with a map that covers nothing.
expect(routes.length).toBeGreaterThan(300);
+229
View File
@@ -0,0 +1,229 @@
/**
* Turn each domain's rows into its wire block: tombstones for the
* soft-deleted rows, upserts for the live ones.
*/
import { prisma } from "@/lib/db";
import {
findCompactionTombstones,
isFoldedWindowCandidate,
loadFoldTimezone,
} from "@/lib/measurements/folded-window";
import { readNote } from "@/lib/crypto/note-cipher";
import { levelAForWire } from "@/lib/mood/level-a";
import { contextForWire } from "@/lib/mood/context";
import {
toCycleDayLogDTO,
toMenstrualCycleDTO,
type CycleDayLogDTO,
type MenstrualCycleDTO,
} from "@/lib/cycle/dto";
import type {
CycleDayRow,
CycleRow,
IntakeRow,
MeasurementRow,
MoodRow,
} from "./read-rows";
import type {
CycleDayTombstone,
DomainBlock,
IdTombstone,
IntakeUpsert,
MeasurementTombstone,
MeasurementUpsert,
MoodUpsert,
} from "./wire";
/** A domain's page: the rows inside the limit, and whether more follow. */
export function pageOf<R>(
rows: R[],
limit: number,
): { page: R[]; hasMore: boolean } {
const hasMore = rows.length > limit;
return { page: hasMore ? rows.slice(0, limit) : rows, hasMore };
}
function idTombstone(row: {
id: string;
syncVersion: number;
deletedAt: Date;
updatedAt: Date;
}): IdTombstone {
return {
id: row.id,
syncVersion: row.syncVersion,
deletedAt: row.deletedAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
/**
* 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, 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.
*/
async function compactionIds(
userId: string,
page: MeasurementRow[],
): Promise<Set<string>> {
const compaction = new Set<string>();
const tombstonesOnPage = page.filter((row) => row.deletedAt !== null);
if (tombstonesOnPage.length === 0) return compaction;
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,
userId,
await loadFoldTimezone(prisma, userId),
candidates,
);
for (const index of classA) compaction.add(tombstonesOnPage[index].id);
}
return compaction;
}
export async function measurementBlock(
userId: string,
page: MeasurementRow[],
): Promise<DomainBlock<MeasurementUpsert, MeasurementTombstone>> {
const compaction = await compactionIds(userId, page);
const upserts: MeasurementUpsert[] = [];
const tombstones: MeasurementTombstone[] = [];
for (const row of page) {
if (compaction.has(row.id)) continue;
if (row.deletedAt) {
tombstones.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`).
upserts.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(),
});
}
}
return { upserts, tombstones };
}
export function moodBlock(
page: MoodRow[],
): DomainBlock<MoodUpsert, IdTombstone> {
const upserts: MoodUpsert[] = [];
const tombstones: IdTombstone[] = [];
for (const row of page) {
if (row.deletedAt) {
tombstones.push(idTombstone({ ...row, deletedAt: row.deletedAt }));
} else {
upserts.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(),
});
}
}
return { upserts, tombstones };
}
export function intakeBlock(
page: IntakeRow[],
): DomainBlock<IntakeUpsert, IdTombstone> {
const upserts: IntakeUpsert[] = [];
const tombstones: IdTombstone[] = [];
for (const row of page) {
if (row.deletedAt) {
tombstones.push(idTombstone({ ...row, deletedAt: row.deletedAt }));
} else {
upserts.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(),
});
}
}
return { upserts, tombstones };
}
export function cycleDayBlock(
page: CycleDayRow[],
): DomainBlock<CycleDayLogDTO, CycleDayTombstone> {
const upserts: CycleDayLogDTO[] = [];
const tombstones: CycleDayTombstone[] = [];
for (const row of page) {
if (row.deletedAt) {
tombstones.push({
id: row.id,
externalId: row.externalId,
syncVersion: row.syncVersion,
deletedAt: row.deletedAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
});
} else {
upserts.push(toCycleDayLogDTO(row));
}
}
return { upserts, tombstones };
}
export function cycleBlock(
page: CycleRow[],
): DomainBlock<MenstrualCycleDTO, IdTombstone> {
const upserts: MenstrualCycleDTO[] = [];
const tombstones: IdTombstone[] = [];
for (const row of page) {
if (row.deletedAt) {
tombstones.push(idTombstone({ ...row, deletedAt: row.deletedAt }));
} else {
upserts.push(toMenstrualCycleDTO(row));
}
}
return { upserts, tombstones };
}
+218
View File
@@ -0,0 +1,218 @@
/**
* One pull of the multi-domain delta feed behind `GET /api/sync/changes`.
*
* The route authenticates, meters and parses the query; this module decides
* whether the cursor can still be caught up, reads one page of every domain
* past its watermark, and answers the blocks and the next cursor. The feed
* contract is described on the route.
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client";
import {
decodeCursor,
decodeCursorIssuedAt,
encodeCursor,
SYNC_DOMAINS,
type SyncCursor,
type SyncDomain,
} from "@/lib/sync/cursor";
import { isModuleEnabled } from "@/lib/modules/gate";
import { readDomainRows } from "./read-rows";
import {
cycleBlock,
cycleDayBlock,
intakeBlock,
measurementBlock,
moodBlock,
pageOf,
} from "./domain-pages";
import type { SyncChangesBody } from "./wire";
const DAY_MS = 86_400_000;
/**
* 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 };
}
}
/**
* Whether the cursor can no longer be caught up incrementally, and why.
*
* Past retention: ANY domain watermark older than the tombstone-retention
* horizon. Tombstones older than that may have been pruned by the cleanup
* job, so an incremental delta could silently miss a deletion.
*
* Before a reset: 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. 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.
*/
async function expiry(
userId: string,
token: string | undefined,
cursor: SyncCursor,
serverNow: Date,
): Promise<"retention" | "sync_reset" | null> {
const retentionHorizon = new Date(
serverNow.getTime() - TOMBSTONE_RETENTION_DAYS * DAY_MS,
);
const pastRetention = SYNC_DOMAINS.some((domain) => {
const wm = cursor[domain];
return wm !== undefined && wm.updatedAtMs < retentionHorizon.getTime();
});
let beforeReset = false;
if (token) {
const account = await prisma.user.findUnique({
where: { id: userId },
select: { syncResetAt: true },
});
if (account?.syncResetAt) {
const issuedAt = decodeCursorIssuedAt(token);
beforeReset =
issuedAt === null || issuedAt < account.syncResetAt.getTime();
}
}
if (!pastRetention && !beforeReset) return null;
return beforeReset ? "sync_reset" : "retention";
}
function emptyChanges(): SyncChangesBody["changes"] {
return {
measurements: { upserts: [], tombstones: [] },
mood: { upserts: [], tombstones: [] },
intakes: { upserts: [], tombstones: [] },
cycleDays: { upserts: [], tombstones: [] },
cycles: { upserts: [], tombstones: [] },
};
}
export async function pullSyncChanges(input: {
userId: string;
/** The opaque cursor the client echoed, if any. */
token: string | undefined;
limit: number;
serverNow: Date;
}): Promise<SyncChangesBody> {
const { userId, token, limit, serverNow } = input;
// 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(userId, "cycle");
// A garbage / unparseable / old-format cursor decodes to null → fresh
// initial sync (every domain scans from the start).
const cursor: SyncCursor = token ? (decodeCursor(token) ?? {}) : {};
const expired = await expiry(userId, token, cursor, serverNow);
if (expired) {
annotate({
action: { name: "sync.changes.pull" },
meta: {
cursor_expired: true,
cursor_expired_reason: expired,
returned: 0,
},
});
return {
serverNow: serverNow.toISOString(),
cursor: token ?? null,
hasMore: false,
cursorExpired: true,
changes: emptyChanges(),
};
}
const rows = await readDomainRows(userId, cursor, limit, cycleEnabled);
const nextCursor: SyncCursor = { ...cursor };
const m = pageOf(rows.measurements, limit);
const measurements = await measurementBlock(userId, m.page);
advanceCursor(nextCursor, "measurements", m.page);
const md = pageOf(rows.mood, limit);
const mood = moodBlock(md.page);
advanceCursor(nextCursor, "mood", md.page);
const ik = pageOf(rows.intakes, limit);
const intakes = intakeBlock(ik.page);
advanceCursor(nextCursor, "intakes", ik.page);
const cd = pageOf(rows.cycleDays, limit);
const cycleDays = cycleDayBlock(cd.page);
advanceCursor(nextCursor, "cycleDays", cd.page);
const cy = pageOf(rows.cycles, limit);
const cycles = cycleBlock(cy.page);
advanceCursor(nextCursor, "cycles", cy.page);
const hasMore =
m.hasMore || md.hasMore || ik.hasMore || cd.hasMore || cy.hasMore;
annotate({
action: { name: "sync.changes.pull" },
meta: {
measurement_upserts: measurements.upserts.length,
measurement_tombstones: measurements.tombstones.length,
mood_upserts: mood.upserts.length,
mood_tombstones: mood.tombstones.length,
intake_upserts: intakes.upserts.length,
intake_tombstones: intakes.tombstones.length,
cycle_day_upserts: cycleDays.upserts.length,
cycle_day_tombstones: cycleDays.tombstones.length,
cycle_upserts: cycles.upserts.length,
cycle_tombstones: cycles.tombstones.length,
cycle_module_enabled: cycleEnabled,
has_more: hasMore,
cursor_present: Boolean(token),
},
});
return {
serverNow: serverNow.toISOString(),
cursor: encodeCursor(nextCursor, serverNow.getTime()),
hasMore,
cursorExpired: false,
changes: { measurements, mood, intakes, cycleDays, cycles },
};
}
+147
View File
@@ -0,0 +1,147 @@
/**
* The per-domain keyset walks of the delta feed.
*
* Live and soft-deleted rows come out of the same scan: a soft-delete bumps
* `updatedAt`, so a tombstone is just a row whose `deletedAt` is non-null.
* Each walk fetches `limit + 1` rows to detect `hasMore` without a count
* query.
*/
import { prisma } from "@/lib/db";
import type { Prisma } from "@/generated/prisma/client";
import type { DomainWatermark, SyncCursor } from "@/lib/sync/cursor";
import { dayLogSymptomInclude } from "@/lib/cycle/dto";
const MEASUREMENT_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,
} satisfies Prisma.MeasurementSelect;
const MOOD_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,
} satisfies Prisma.MoodEntrySelect;
const INTAKE_SELECT = {
id: true,
medicationId: true,
scheduledFor: true,
takenAt: true,
skipped: true,
source: true,
autoRecorded: true,
syncVersion: true,
deletedAt: true,
updatedAt: true,
} satisfies Prisma.MedicationIntakeEventSelect;
export type MeasurementRow = Prisma.MeasurementGetPayload<{
select: typeof MEASUREMENT_SELECT;
}>;
export type MoodRow = Prisma.MoodEntryGetPayload<{
select: typeof MOOD_SELECT;
}>;
export type IntakeRow = Prisma.MedicationIntakeEventGetPayload<{
select: typeof INTAKE_SELECT;
}>;
export type CycleDayRow = Prisma.CycleDayLogGetPayload<{
include: typeof dayLogSymptomInclude;
}>;
export type CycleRow = Prisma.MenstrualCycleGetPayload<object>;
/**
* 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 interface DomainRows {
measurements: MeasurementRow[];
mood: MoodRow[];
intakes: IntakeRow[];
cycleDays: CycleDayRow[];
cycles: CycleRow[];
}
/**
* Read one page plus one row of every domain past its watermark. The cycle
* walks are skipped outright when the module is off, not filtered after the
* fact, so the disabled rows are never read out of Postgres at all.
*/
export async function readDomainRows(
userId: string,
cursor: SyncCursor,
limit: number,
cycleEnabled: boolean,
): Promise<DomainRows> {
const [measurements, mood, intakes, cycleDays, cycles] = await Promise.all([
prisma.measurement.findMany({
where: { userId, ...keysetFilter(cursor.measurements) },
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
take: limit + 1,
select: MEASUREMENT_SELECT,
}),
prisma.moodEntry.findMany({
where: { userId, ...keysetFilter(cursor.mood) },
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
take: limit + 1,
select: MOOD_SELECT,
}),
prisma.medicationIntakeEvent.findMany({
where: { userId, ...keysetFilter(cursor.intakes) },
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
take: limit + 1,
select: INTAKE_SELECT,
}),
cycleEnabled
? prisma.cycleDayLog.findMany({
where: { userId, ...keysetFilter(cursor.cycleDays) },
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
take: limit + 1,
include: dayLogSymptomInclude,
})
: [],
cycleEnabled
? prisma.menstrualCycle.findMany({
where: { userId, ...keysetFilter(cursor.cycles) },
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
take: limit + 1,
})
: [],
]);
return { measurements, mood, intakes, cycleDays, cycles };
}
+114
View File
@@ -0,0 +1,114 @@
/**
* The wire rows of `GET /api/sync/changes`, one upsert and one tombstone
* shape per domain. Tombstone identity follows the iOS §7.3 table:
* measurements key on `externalId` (the cross-device key); mood and intakes
* key on the server `id` (the client mirrors those by server id); cycle days
* key 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).
*/
import type { MoodContextWire } from "@/lib/mood/context";
import type { CycleDayLogDTO, MenstrualCycleDTO } from "@/lib/cycle/dto";
export interface MeasurementUpsert {
id: string;
externalId: string | null;
type: string;
value: number;
unit: string;
measuredAt: string;
source: string;
notes: string | null;
syncVersion: number;
updatedAt: string;
}
export interface MeasurementTombstone {
id: string;
externalId: string | null;
syncVersion: number;
deletedAt: string;
updatedAt: string;
}
export interface MoodUpsert {
id: string;
date: string;
mood: string;
score: number;
/**
* 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;
/**
* 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;
}
/** The tombstone of a domain keyed on the server id alone. */
export interface IdTombstone {
id: string;
syncVersion: number;
deletedAt: string;
updatedAt: string;
}
export interface IntakeUpsert {
id: string;
medicationId: string;
scheduledFor: string;
takenAt: string | null;
skipped: boolean;
source: string;
/** Recorded by the server (`autoMarkTaken`), not the person. */
autoRecorded: boolean;
syncVersion: number;
updatedAt: string;
}
export interface CycleDayTombstone {
id: string;
externalId: string | null;
syncVersion: number;
deletedAt: string;
updatedAt: string;
}
export interface DomainBlock<U, T> {
upserts: U[];
tombstones: T[];
}
export interface SyncChanges {
measurements: DomainBlock<MeasurementUpsert, MeasurementTombstone>;
mood: DomainBlock<MoodUpsert, IdTombstone>;
intakes: DomainBlock<IntakeUpsert, IdTombstone>;
cycleDays: DomainBlock<CycleDayLogDTO, CycleDayTombstone>;
cycles: DomainBlock<MenstrualCycleDTO, IdTombstone>;
}
export interface SyncChangesBody {
serverNow: string;
cursor: string | null;
hasMore: boolean;
cursorExpired: boolean;
changes: SyncChanges;
}
+115
View File
@@ -0,0 +1,115 @@
/**
* `DELETE /api/workouts/{id}`: remove a workout that was entered by hand.
*
* - Ownership: another user's row reads as missing, like the detail read.
* - `MANUAL` only. Every other source is a sync, and the next sync would
* write the row back, so it is refused instead of pretending.
*
* The workout table carries no tombstone, so the row is removed outright;
* route, samples and insight rows cascade with it. The insight's generation
* claim stays behind with a null workout id: it is the day's cap ledger, and
* a delete must not hand the slot back. A personal record the workout set
* would otherwise outlive it (the detector only ever raises a stored best),
* so the records keyed on this workout go in the same transaction and a
* silent detection pass re-derives the honest best.
*/
import { prisma } from "@/lib/db";
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";
export type DeleteManualWorkoutOutcome =
{ kind: "not_found" } | { kind: "synced_source" } | { kind: "deleted" };
export async function deleteManualWorkout(args: {
userId: string;
id: string;
ipAddress: string | null;
}): Promise<DeleteManualWorkoutOutcome> {
const { userId, id } = args;
const row = await prisma.workout.findUnique({
where: { id },
select: {
id: true,
userId: true,
source: true,
externalId: true,
startedAt: true,
sportType: true,
},
});
if (!row || row.userId !== userId) {
return { kind: "not_found" };
}
if (row.source !== "MANUAL") {
annotate({
action: {
name: "workout.delete",
entity_type: "workout",
entity_id: id,
},
meta: { outcome: "synced_source", source: row.source },
});
return { kind: "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, userId);
const records = await tx.personalRecord.deleteMany({
where: {
userId: userId,
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: userId,
ipAddress: args.ipAddress,
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(userId, { evict: true });
if (removedRecords > 0) {
try {
await enqueuePrDetection(userId, { silent: true });
} catch (err) {
annotate({
action: { name: "personal_records.detection_enqueue_failed" },
meta: { error: err instanceof Error ? err.message : String(err) },
});
}
}
return { kind: "deleted" };
}
+407
View File
@@ -0,0 +1,407 @@
/**
* The single-workout detail behind `GET /api/workouts/{id}`: the stored row,
* the canonical winner of its duplicate cluster, the enrichment reads (heart
* rate curve, effort zones, splits, sport context, the previous session) and
* the stored Activity Insight while it may be served. The route owns
* authentication, the module gate and the AI capability.
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import {
canonicalClusterOf,
canonicalWorkoutsInRange,
readWorkoutsAroundRange,
type CanonicalReadRow,
type WorkoutBoundedRead,
type WorkoutStartBounds,
} from "@/lib/workouts/canonical-read";
import type { 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";
type AiState = Awaited<ReturnType<typeof aiCapabilityToServe>>;
/** 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;
}
/** The stored workout with its track, series and (when served) insight. */
async function loadWorkout(id: string, ai: AiState) {
return 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,
},
});
}
type StoredWorkout = NonNullable<Awaited<ReturnType<typeof loadWorkout>>>;
/**
* The detail body for a workout the caller owns, or null when the id names
* no workout of theirs (another account's row reads as missing, so the
* existence channel stays sealed).
*/
export async function readWorkoutDetail(args: {
userId: string;
id: string;
compact: boolean;
ai: AiState;
}) {
const { userId, id, compact, ai } = args;
const row = await loadWorkout(id, ai);
if (!row || row.userId !== userId) return null;
const identity = await resolveIdentity(userId, row);
const enrichment = await readEnrichment(userId, row, identity);
return shapeDetail(row, identity, enrichment, { compact, ai });
}
/** The canonical winner of the row's cluster, and the local day. */
async function resolveIdentity(userId: string, row: StoredWorkout) {
// 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,
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: userId },
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(userId);
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;
return {
readSameSport,
userRow,
dayKey,
cluster,
canonical,
effectiveAvgHeartRate,
effectiveMaxHeartRate,
};
}
async function readEnrichment(
userId: string,
row: StoredWorkout,
identity: Awaited<ReturnType<typeof resolveIdentity>>,
) {
const { readSameSport, userRow, cluster, canonical } = identity;
// ── 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,
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).
// 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(
userId,
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,
sportType: row.sportType,
startedAt: { lt: to },
},
orderBy: [{ startedAt: "desc" }, { id: "desc" }],
take,
select: { startedAt: true },
})
).map((earlier) => earlier.startedAt),
clusterStart,
userRow?.sourcePriorityJson ?? null,
);
return {
hrSeries,
zones,
routeGeometry,
splits,
sportContext,
previousWorkoutId,
};
}
function shapeDetail(
row: StoredWorkout,
identity: Awaited<ReturnType<typeof resolveIdentity>>,
enrichment: Awaited<ReturnType<typeof readEnrichment>>,
options: { compact: boolean; ai: AiState },
) {
const { compact, ai } = options;
const { dayKey, canonical, effectiveAvgHeartRate, effectiveMaxHeartRate } =
identity;
const {
hrSeries,
zones,
routeGeometry,
splits,
sportContext,
previousWorkoutId,
} = enrichment;
// 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;
// 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 {
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,
// 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,
};
}