Merge release/v1.39.0 into feat/ai-routes-insights-coach

Brings in the jobs, stored-text and consent package and the extraction
routes. Conflicts resolved:

- The digest keeps its capability-driven reaction line and check-in,
  now through the shared `aiCapabilityForRecord`; the local copy of it
  is removed.
- The route inventories classify the extraction routes (documents,
  labs OCR, medication text, fenced chat and attachments) as actions;
  a capability call counts as the gate for the module inventory.
- With every caller moved, `requireAssistantSurface` and
  `AssistantDisabledError` are deleted. The `assistant.disabled.*`
  codes stay on the capability refusal, and the guard now asserts the
  retired gate is gone.
- `aiCapabilityToServe` answers whether stored model text may be shown
  from the record's own state, apart from who is reading.

Refs #975
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 10:16:53 +02:00
commit c509afeb2e
256 files changed
+13048 -4642

No files matched your search

+393 -193
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -760,6 +760,7 @@
"typeWaistToHeight": "Taille-Größe-Verhältnis",
"unitScore": "Pkt.",
"unitRatio": "Verhältnis",
"unitRating": "Stufe",
"typeWho5Score": "WHO-5-Wert",
"typeSciScore": "SCI-Wert"
},
@@ -8256,6 +8257,7 @@
"saving": "Speichern…",
"cancel": "Abbrechen",
"saveError": "Konnte nicht gespeichert werden. Versuch es erneut.",
"emptyHint": "Wähle eine Stärke oder ein Symptom, um diesen Tag zu speichern, oder nutze oben den Knopf für den Periodenbeginn.",
"intermenstrualBleeding": "Schmierblutung zwischen Perioden",
"pregnancyTest": "Schwangerschaftstest",
"progesteroneTest": "Progesterontest",
+2
View File
@@ -760,6 +760,7 @@
"typeWaistToHeight": "Waist-to-height ratio",
"unitScore": "pts",
"unitRatio": "ratio",
"unitRating": "rating",
"typeWho5Score": "WHO-5 score",
"typeSciScore": "SCI score"
},
@@ -8266,6 +8267,7 @@
"saving": "Saving…",
"cancel": "Cancel",
"saveError": "Could not save. Try again.",
"emptyHint": "Pick a flow or a symptom to save this day, or use the period start button above.",
"temperatureSection": "Temperature & ovulation",
"intimacySection": "Intimacy & contraception",
"testsSection": "Tests",
+2
View File
@@ -760,6 +760,7 @@
"typeWaistToHeight": "Índice cintura-altura",
"unitScore": "pts",
"unitRatio": "ratio",
"unitRating": "nivel",
"typeWho5Score": "Puntuación WHO-5",
"typeSciScore": "Puntuación SCI"
},
@@ -8256,6 +8257,7 @@
"saving": "Guardando…",
"cancel": "Cancelar",
"saveError": "No se pudo guardar. Inténtalo de nuevo.",
"emptyHint": "Elige un flujo o un síntoma para guardar este día, o usa arriba el botón de inicio del periodo.",
"intermenstrualBleeding": "Manchado entre periodos",
"pregnancyTest": "Prueba de embarazo",
"progesteroneTest": "Prueba de progesterona",
+4 -2
View File
@@ -619,7 +619,7 @@
"typeBpSys": "Sys",
"typeBpDia": "Dia",
"typePulse": "Pouls",
"typeBodyFat": "Masse grasse",
"typeBodyFat": "Graisse corporelle",
"typeSleep": "Sommeil",
"typeSteps": "Pas",
"typeBloodGlucose": "Glycémie",
@@ -633,7 +633,7 @@
"typeWalkingRunningDistance": "Distance à pied et en course",
"typeVo2Max": "VO₂ max",
"typeBodyTemperature": "Température corporelle",
"typeFatFreeMass": "Masse maigre",
"typeFatFreeMass": "Masse non grasse",
"typeFatMass": "Masse grasse",
"typeMuscleMass": "Masse musculaire",
"typeSkinTemperature": "Température cutanée",
@@ -760,6 +760,7 @@
"typeWaistToHeight": "Rapport taille-taille",
"unitScore": "pts",
"unitRatio": "ratio",
"unitRating": "niveau",
"typeWho5Score": "Score WHO-5",
"typeSciScore": "Score SCI"
},
@@ -8256,6 +8257,7 @@
"saving": "Enregistrement…",
"cancel": "Annuler",
"saveError": "Enregistrement impossible. Réessaie.",
"emptyHint": "Choisis un flux ou un symptôme pour enregistrer cette journée, ou utilise le bouton de début des règles ci-dessus.",
"intermenstrualBleeding": "Saignements entre les règles",
"pregnancyTest": "Test de grossesse",
"progesteroneTest": "Test de progestérone",
+4 -2
View File
@@ -619,7 +619,7 @@
"typeBpSys": "Sis",
"typeBpDia": "Dia",
"typePulse": "Polso",
"typeBodyFat": "Massa grassa",
"typeBodyFat": "Grasso corporeo",
"typeSleep": "Sonno",
"typeSteps": "Passi",
"typeBloodGlucose": "Glicemia",
@@ -633,7 +633,7 @@
"typeWalkingRunningDistance": "Distanza a piedi e di corsa",
"typeVo2Max": "VO₂ max",
"typeBodyTemperature": "Temperatura corporea",
"typeFatFreeMass": "Massa magra",
"typeFatFreeMass": "Massa libera da grasso",
"typeFatMass": "Massa grassa",
"typeMuscleMass": "Massa muscolare",
"typeSkinTemperature": "Temperatura cutanea",
@@ -760,6 +760,7 @@
"typeWaistToHeight": "Rapporto vita-altezza",
"unitScore": "pti",
"unitRatio": "rapporto",
"unitRating": "livello",
"typeWho5Score": "Punteggio WHO-5",
"typeSciScore": "Punteggio SCI"
},
@@ -8256,6 +8257,7 @@
"saving": "Salvataggio…",
"cancel": "Annulla",
"saveError": "Impossibile salvare. Riprova.",
"emptyHint": "Scegli un flusso o un sintomo per salvare questo giorno, oppure usa in alto il pulsante di inizio mestruazioni.",
"intermenstrualBleeding": "Spotting tra i cicli",
"pregnancyTest": "Test di gravidanza",
"progesteroneTest": "Test del progesterone",
+2
View File
@@ -760,6 +760,7 @@
"typeWaistToHeight": "허리-키 비율",
"unitScore": "점",
"unitRatio": "비율",
"unitRating": "단계",
"typeWho5Score": "WHO-5 점수",
"typeSciScore": "SCI 점수"
},
@@ -8266,6 +8267,7 @@
"saving": "저장 중…",
"cancel": "취소",
"saveError": "저장하지 못했어요. 다시 시도해 주세요.",
"emptyHint": "이 날을 저장하려면 생리량이나 증상을 고르거나, 위의 생리 시작 버튼을 눌러 주세요.",
"temperatureSection": "체온 및 배란",
"intimacySection": "성관계 및 피임",
"testsSection": "검사",
+3 -1
View File
@@ -647,7 +647,7 @@
"typeAudioExposureEvent": "Zdarzenie głośnego słuchania",
"typeRespiratoryRate": "Częstość oddechów",
"typeBodyMassIndex": "Wskaźnik masy ciała",
"typeLeanBodyMass": "Masa beztłuszczowa",
"typeLeanBodyMass": "Szczupła masa ciała",
"typeWalkingHeartRateAverage": "Tętno przy chodzeniu (śr.)",
"typeWalkingAsymmetry": "Asymetria chodu",
"typeWalkingDoubleSupport": "Podwójna podpora chodu",
@@ -760,6 +760,7 @@
"typeWaistToHeight": "Stosunek talii do wzrostu",
"unitScore": "pkt",
"unitRatio": "stosunek",
"unitRating": "poziom",
"typeWho5Score": "Wynik WHO-5",
"typeSciScore": "Wynik SCI"
},
@@ -8256,6 +8257,7 @@
"saving": "Zapisywanie…",
"cancel": "Anuluj",
"saveError": "Nie udało się zapisać. Spróbuj ponownie.",
"emptyHint": "Wybierz krwawienie lub objaw, aby zapisać ten dzień, albo użyj przycisku początku miesiączki powyżej.",
"intermenstrualBleeding": "Plamienie między okresami",
"pregnancyTest": "Test ciążowy",
"progesteroneTest": "Test progesteronu",
@@ -0,0 +1,44 @@
-- Status notes move out of the audit log.
--
-- The per-metric status notes used to be appended to `audit_logs` as plaintext
-- JSON, one row per metric per day, next to the security trail. They now live
-- in their own table, encrypted at rest, one row per (user, metric, locale),
-- upserted in place.
--
-- 1. The new table.
-- CreateTable
CREATE TABLE "insight_status_caches" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"metric" TEXT NOT NULL,
"locale" TEXT NOT NULL,
"text_encrypted" BYTEA,
"items_encrypted" BYTEA,
"input_hash" TEXT,
"snapshot_hash" TEXT,
"date_key" TEXT NOT NULL,
"generated_at" TIMESTAMP(3),
"retry_at" TIMESTAMP(3),
"negative_reason" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "insight_status_caches_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "insight_status_caches_user_id_metric_locale_key" ON "insight_status_caches"("user_id", "metric", "locale");
-- AddForeignKey
ALTER TABLE "insight_status_caches" ADD CONSTRAINT "insight_status_caches_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- 2. The old rows go. DESTRUCTIVE by design: the notes are a regenerable
-- cache, nothing reads them from `audit_logs` any more, and leaving them
-- would keep plaintext health text in a table documented as holding none.
-- Nothing is copied across, because the old rows are plaintext and a
-- migration cannot encrypt; the first nightly run after the upgrade writes
-- every note again. Take a database backup before applying, as for 0339.
-- Matches exactly the cache keys `insights.<scope>-status.<locale>` (the
-- seven specialised scopes and the `metric:` / `derived-score:` /
-- `biomarker:` generic ones); no security-trail action has this shape.
DELETE FROM "audit_logs" WHERE "action" LIKE 'insights.%-status.%';
+52
View File
@@ -860,6 +860,9 @@ model User {
// v1.11.0 W3 — one durable period-narrative row per (period, locale).
// Delete/regenerate-clean; the generated prose is AES-256-GCM at rest.
insightNarratives InsightNarrative[]
// v1.39.0 — the per-metric status notes, one encrypted row per
// (metric, locale). A regenerable cache; it used to live in `audit_logs`.
insightStatusCaches InsightStatusCache[]
// v1.31.0 — the data-arrival spine's durable per-day reaction marker. One
// row per (kind, local day); the unique index IS the once-per-kind-per-day
// claim every reaction surface reads. Retained 14 days.
@@ -6722,6 +6725,55 @@ model InsightNarrative {
@@map("insight_narratives")
}
// v1.39.0 — the per-metric status notes ("Status notes"): what a model wrote
// about one metric for one person in one language, and the fingerprints that
// let the next run skip the model when nothing changed.
//
// One row per (user, metric, locale), upserted in place. Until v1.39.0 these
// notes were appended to `audit_logs` as plaintext JSON, one row per metric per
// day, mixed with the security trail; migration 0344 dropped those rows, so the
// first night after the upgrade regenerates every note once.
//
// A regenerable cache, not a record: excluded from backups, deleted by "Delete
// All Data", purged when the person withdraws AI consent, and never served
// while the `statusText` capability is unavailable.
model InsightStatusCache {
id String @id @default(cuid())
userId String @map("user_id")
/// The status scope: `weight`, `blood-pressure`, `general`, …, or a prefixed
/// generic scope (`metric:<ID>`, `derived-score:<ID>`, `biomarker:<id>`).
metric String
/// The UI locale the note was written in.
locale String
/// AES-256-GCM ciphertext of the note. Null while the row only carries a
/// negative-cache window (a generation that stalled before any note existed).
textEncrypted Bytes? @map("text_encrypted")
/// AES-256-GCM ciphertext of per-item notes as JSON, for a card that carries
/// them (medication compliance: one line per medication). Null otherwise.
itemsEncrypted Bytes? @map("items_encrypted")
/// Cheap fingerprint of the salient inputs (count + newest reading).
inputHash String? @map("input_hash")
/// Fingerprint of the full data snapshot the note was written from.
snapshotHash String? @map("snapshot_hash")
/// The person's local YYYY-MM-DD the note counts as current for.
dateKey String @map("date_key")
/// When the note was written, or last re-dated by an unchanged-data run.
generatedAt DateTime? @map("generated_at")
/// Negative cache: while in the future, a read does not enqueue another
/// generation. Cleared when a note is written.
retryAt DateTime? @map("retry_at")
/// Why the last generation served the deterministic line instead of a note:
/// `timeout` | `error` | `screened`. Set together with `retryAt`.
negativeReason String? @map("negative_reason")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, metric, locale])
@@map("insight_status_caches")
}
// ─── Data-arrival spine (v1.31.0) ─────────────────────────────
//
// One row per (user, arrival kind, local day). The row is written by the
+19
View File
@@ -454,6 +454,25 @@ async function main() {
),
);
// ───── v1.39.0 InsightStatusCache."textEncrypted" / "itemsEncrypted" ─────
// The per-metric status notes. Both nullable (a row may carry only a
// negative-cache window; only one card stores per-item lines), and
// `rotateBytesColumn` skips a NULL payload.
results.push(
await rotateBytesColumn(
"InsightStatusCache",
"textEncrypted",
prisma.insightStatusCache,
),
);
results.push(
await rotateBytesColumn(
"InsightStatusCache",
"itemsEncrypted",
prisma.insightStatusCache,
),
);
// ───── v1.31.0 ArrivalReaction."lineEncrypted" (Bytes, nullable) ─────
// The data-arrival spine's generated reaction line. Nullable by design — a
// provider-less install writes markers with no line at all — and
+30 -24
View File
@@ -211,6 +211,7 @@ async function seed() {
await client.query("DELETE FROM coach_conversations");
await client.query("DELETE FROM consent_receipts");
await client.query("DELETE FROM insight_narratives");
await client.query("DELETE FROM insight_status_caches");
await client.query("DELETE FROM illness_symptom_links");
await client.query("DELETE FROM illness_day_logs");
await client.query("DELETE FROM illness_episodes");
@@ -1826,11 +1827,10 @@ async function seed() {
console.log("Creating baked AI insight texts...");
const todayKey = berlinDayKey();
// A real-looking provider/model pair so the read path treats the row as a
// genuine assessment (NOT a `model: "timeout-stub"`, which the cache-read
// rejects). The text is what renders; provider/model are provenance only.
// A real-looking provider tag for the rows that record one (the cached
// briefing, the narratives). The text is what renders; the tag is
// provenance only.
const bakedProvider = "anthropic";
const bakedModel = "claude-3-5-sonnet";
// ── Comprehensive insight + daily briefing ──
// Stored on users.insights_cached_text (JSON). The GET read parses it and
@@ -2037,13 +2037,12 @@ async function seed() {
);
// ── Per-metric status cards ──
// Each card is an audit_logs row keyed `insights.<scope>-status.<locale>`
// whose details JSON is { dateKey, locale, text, providerType, model,
// tokensUsed, snapshotHash }. The read serves a row only when its dateKey
// equals today's Berlin key and the model is not the timeout-stub
// sentinel. We stamp today's key + a real model name so every card reads
// as a current assessment. `text` is the rendered field. 2-4 sentences
// each, grounded in the seeded data, in the base-system advisor voice.
// Each card is an insight_status_caches row keyed (user, metric, locale),
// the note encrypted with the app's codec. The read serves a note only
// when its date_key equals today's key, so every card is stamped with
// today's key and reads as a current assessment. The medication card also
// carries its (empty) per-medication list. 2-4 sentences each, grounded in
// the seeded data, in the base-system advisor voice.
const statusCards: Array<{ scope: string; text: string }> = [
{
scope: "blood-pressure",
@@ -2108,23 +2107,30 @@ async function seed() {
];
for (const card of statusCards) {
const action = `insights.${card.scope}-status.en`;
await client.query(
`INSERT INTO audit_logs (id, user_id, action, details, created_at)
VALUES ($1, $2, $3, $4, NOW())`,
`INSERT INTO insight_status_caches
(id, user_id, metric, locale, text_encrypted, items_encrypted,
snapshot_hash, date_key, generated_at, created_at, updated_at)
VALUES ($1, $2, $3, 'en', $4, $5, $6, $7, NOW(), NOW(), NOW())
ON CONFLICT (user_id, metric, locale) DO UPDATE SET
text_encrypted = EXCLUDED.text_encrypted,
items_encrypted = EXCLUDED.items_encrypted,
snapshot_hash = EXCLUDED.snapshot_hash,
date_key = EXCLUDED.date_key,
generated_at = EXCLUDED.generated_at,
retry_at = NULL,
negative_reason = NULL,
updated_at = NOW()`,
[
cuid(),
userId,
action,
JSON.stringify({
dateKey: todayKey,
locale: "en",
text: card.text,
providerType: bakedProvider,
model: bakedModel,
tokensUsed: null,
snapshotHash: `demo-baked-${card.scope}`,
}),
card.scope,
Buffer.from(encryptToBytes(card.text)),
card.scope === "medication-compliance"
? Buffer.from(encryptToBytes("[]"))
: null,
`demo-baked-${card.scope}`,
todayKey,
],
);
}
@@ -64,14 +64,14 @@ const RESOLUTION_HELPERS = [
"resolveProviderForTest",
] as const;
/** The two sanctioned consent APIs. Either satisfies the guard. */
/** The sanctioned consent APIs in the consent guard. Any satisfies the guard. */
const CONSENT_HELPERS = [
// Chain-shaped: throw-form and predicate-form.
"assertConsentForChain",
"chainRequiresServerManagedConsent",
"hasActiveConsentForSurface",
// Document-class, pick-shaped.
"assertDocumentEgressConsent",
// Document-class, pick-shaped (the receipt itself is read by the capability
// egress re-check below).
"isExternalDocumentEgress",
] as const;
@@ -108,9 +108,23 @@ function resolvesAProvider(source: string): boolean {
return RESOLUTION_HELPERS.some((h) => imported.includes(h));
}
/**
* The capability re-check at the wire. It answers the consent question for the
* providers a caller is about to send to, from the receipt kinds the capability
* table declares, so it is a consent helper in its own right.
*/
const CAPABILITY_EGRESS_HELPERS = [
"assertAiEgress",
"aiEgressRefusal",
] as const;
function importsAConsentHelper(source: string): boolean {
const imported = importedNamesFrom(source, "@/lib/ai/consent-guard");
return CONSENT_HELPERS.some((h) => imported.includes(h));
const egress = importedNamesFrom(source, "@/lib/ai/capabilities/egress");
return (
CONSENT_HELPERS.some((h) => imported.includes(h)) ||
CAPABILITY_EGRESS_HELPERS.some((h) => egress.includes(h))
);
}
/**
@@ -125,8 +139,6 @@ function importsAConsentHelper(source: string): boolean {
*/
const ALLOWLIST: Record<string, string> = {
// --- Capability / config probes: resolve a chain, never call it. ---
"lib/labs/ocr-capability.ts":
"Resolves the chain to REPORT which entry could do vision/text OCR (mode, reason, pdfSupported). Makes no completion call. Every consumer that then egresses a document is gated at its own call site with assertDocumentEgressConsent.",
"app/api/insights/provider-chain/route.ts":
"Lists the user's configured chain and which entry is active, for the settings UI. Constructs clients but never calls generateCompletion. No health data in the request or the response.",
@@ -0,0 +1,199 @@
/**
* Structural guard on the places a model is actually called.
*
* A capability is only as good as the last check before the wire. A job
* enqueued before an operator flipped a switch, or before the person withdrew
* consent or turned AI analysis off, must still stop there. So every file that
* hands a prompt to a provider (`.generateCompletion(`, the chain runners) is
* named here with the reason its call is admitted, and the ones that ARE a
* chokepoint must re-check a capability in the same file.
*
* Mirrors `ai-consent-enforcement-guard.test.ts` in shape and in its limits: it
* proves a reference exists, not that it sits on every branch or before the
* call. The integration suite with a provider spy
* (`tests/integration/ai-optional-jobs.test.ts`) is what proves behaviour.
* A future egress helper under a new name would slip the matcher; a reviewer
* adding one should add it here.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { runStatusCompletion } from "@/lib/insights/status-provider";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
/** A provider call: a client's completion (plain or streamed), or a chain runner. */
const EGRESS_CALL =
/\.generateCompletion(?:Stream)?\s*\(|\brunRawCompletionWithFallback\s*\(|\brunWithFallback\s*\(/;
/**
* A capability read in the same file: one of the gates, or the wire re-check
* (`aiEgressRefusal` / `assertAiEgress`), which also answers the consent a
* picked provider needs.
*/
const CAPABILITY_CHECK =
/\b(?:aiCapabilityForJob|aiCapabilityForRecord|requireAiCapability|getAiCapability|aiEgressRefusal|assertAiEgress)\s*\(/;
/** Comments name the helpers too; only code counts. */
function stripComments(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/(^|[^:])\/\/.*$/gm, "$1");
}
function sourceFiles(): string[] {
return walkSourceFiles(SRC, { floor: 3000 })
.filter((p) => !p.startsWith("generated/"))
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
.sort();
}
function code(rel: string): string {
return stripComments(readFileSync(join(SRC, rel), "utf8"));
}
interface EgressSite {
/** True when this file is a chokepoint and must re-check a capability. */
recheck: boolean;
reason: string;
}
/**
* Every file that calls a provider, and why the call is admitted.
*/
const EGRESS_SITES: Record<string, EgressSite> = {
// ── Chokepoints: re-check at the wire ──────────────────────────────────
"lib/insights/status-provider.ts": {
recheck: true,
reason:
"runStatusCompletion: status notes, narratives, workout paragraphs, derived and biomarker assessments, Coach memory. Takes the capability as a required argument and re-checks it before the chain is resolved.",
},
"lib/jobs/reaction-line.ts": {
recheck: true,
reason:
"Reaction lines call the first chain entry directly; the worker resolves `reactionLines` before the digest and re-checks the chain at the wire (`aiEgressRefusal`).",
},
"lib/jobs/coach-nudge-ai.ts": {
recheck: true,
reason:
"AI-composed nudges call the first chain entry directly; the composer re-checks `coach` before the chain.",
},
// ── Admitted by the one caller that reaches them ───────────────────────
"lib/insights/briefing-provider.ts": {
recheck: false,
reason:
"Reached only through generateComprehensiveInsight, which re-checks `briefing` before the chain is resolved.",
},
"lib/ai/coach/tools/loop.ts": {
recheck: false,
reason:
"The Coach tool loop runs inside the chat stream, which the chat route gates on `coach`.",
},
"lib/ai/coach/self-context-questions.ts": {
recheck: false,
reason:
"About-me follow-up questions; reached from the about-me route, which gates `aboutMeQuestions`.",
},
"lib/documents/assist.ts": {
recheck: false,
reason:
"Document suggest/chat helpers; reached from the document routes and jobs, which resolve `documentAi` before a provider is picked.",
},
"lib/documents/describe.ts": {
recheck: false,
reason:
"Document summary; reached from the summary route and the summary job, which resolve `documentAi` first.",
},
"lib/documents/extract.ts": {
recheck: false,
reason:
"Document extraction/indexing; reached from the document routes and the index jobs, which resolve `documentAi` first.",
},
"lib/labs/ocr-extract.ts": {
recheck: false,
reason:
"Lab report scan; reached from the labs OCR route, which gates `labsOcr`.",
},
"app/api/medications/extract/route.ts": {
recheck: false,
reason:
"Medication text extraction; the route is the chokepoint for `medicationExtract`.",
},
"app/api/ai/test/route.ts": {
recheck: false,
reason:
"The connection test a person runs on a provider they are configuring; it sends a fixed probe prompt, no health data.",
},
// ── The machinery itself ────────────────────────────────────────────────
"lib/ai/provider-runner.ts": {
recheck: false,
reason: "The chain runners; every caller above is what admits a run.",
},
"lib/ai/openai-client.ts": {
recheck: false,
reason: "A wire client delegating to its own completion method.",
},
"lib/ai/local-client.ts": {
recheck: false,
reason: "A wire client delegating to its own completion method.",
},
"lib/ai/coach/eval/judge.ts": {
recheck: false,
reason: "The offline evaluation harness; never on a request or job path.",
},
};
describe("AI egress call sites", () => {
const found = sourceFiles().filter((rel) => EGRESS_CALL.test(code(rel)));
it("finds provider calls at all (an empty matcher is a failure, not a pass)", () => {
expect(found.length).toBeGreaterThan(5);
});
it("every provider call site is named, with a reason", () => {
const unlisted = found.filter((rel) => !(rel in EGRESS_SITES));
expect(
unlisted,
"A new file calls a provider. Add it to EGRESS_SITES with the reason its call is admitted, and re-check a capability in it if it is a chokepoint.",
).toEqual([]);
});
it("the list carries no file that no longer calls a provider", () => {
const stale = Object.keys(EGRESS_SITES).filter(
(rel) => !found.includes(rel),
);
expect(stale).toEqual([]);
});
it("every chokepoint re-checks a capability in the same file", () => {
const missing = Object.entries(EGRESS_SITES)
.filter(([, site]) => site.recheck)
.filter(([rel]) => !CAPABILITY_CHECK.test(code(rel)))
.map(([rel]) => rel);
expect(missing).toEqual([]);
});
});
describe("the status chokepoint's capability argument", () => {
it("is required by the type (the @ts-expect-error below fails typecheck if it is not)", () => {
type Args = Parameters<typeof runStatusCompletion>[0];
// @ts-expect-error — `capability` is required: a caller that omits it does not compile.
const withoutCapability: Args = {
userId: "u",
cacheAction: "insights.weight-status.en",
systemPrompt: "s",
userPrompt: "p",
};
const withCapability: Args = {
...withoutCapability,
capability: "statusText",
};
expect(withCapability.capability).toBe("statusText");
});
});
@@ -0,0 +1,175 @@
/**
* Structural guard: background and insight readers must not materialise a
* dense measurement stream.
*
* #1023: a single account syncing heart rate from a watch took the worker
* down within half an hour. The status-card read was bounded in time
* (`measuredAt >= now − 91 days`) but not in rows, and a stream sampled every
* minute or faster turns 91 days into six or seven figures of objects. The
* read also ran into the statement timeout. The cure was to fold per day in
* SQL (`readDayAggregates`, `readLiveBuckets`), so the reader's size tracks
* the number of days, not the sampling rate.
*
* What this guard pins, in the directories that run on the worker or feed an
* insight: every `measurement.findMany` without a `take` reads a type that is
* named as a literal and is not one a device streams. A read whose type is a
* variable could be handed a dense type by the next caller, so it must be
* listed below with the reason it is safe. The list is frozen both ways: a new
* unlisted read fails, and an entry whose read is gone fails too.
*
* It is a tripwire, not a proof: the matcher reads source text, and a read
* written some other way (a raw SELECT of rows, a helper in another
* directory) passes it. The floor below fails the guard if the matcher stops
* finding reads at all.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
import {
CUMULATIVE_HK_TYPES,
HIGH_FREQUENCY_MEAN_TYPES,
} from "@/lib/measurements/apple-health-mapping";
const SRC = join(process.cwd(), "src");
const SCOPED_DIRS = [
"lib/insights/",
"lib/jobs/",
"lib/rollups/",
"lib/ai/",
"lib/analytics/",
"lib/dashboard/",
"lib/daily/",
];
/** Types a device writes at sampling rate rather than a few times a day. */
const DENSE_TYPES: ReadonlySet<string> = new Set([
"PULSE",
"BLOOD_GLUCOSE",
"HEART_RATE_VARIABILITY",
"OXYGEN_SATURATION",
...CUMULATIVE_HK_TYPES,
...HIGH_FREQUENCY_MEAN_TYPES,
]);
/**
* Untaken reads whose type is not a sparse literal, keyed
* `<file>::<type expression>`, with why each stays bounded.
*/
const ALLOWED: Record<string, string> = {
"lib/insights/comprehensive-generate.ts::(shorthand)":
'Only reached for SLEEP_DURATION (guarded by `type === "SLEEP_DURATION"`); a few stage rows per night.',
"lib/insights/derived/vascular-age.ts::VASCULAR_AGE_TYPE":
"A constant for a type written at most a few times a day.",
"lib/insights/derived/fitness-age.ts::VO2_MAX_TYPE":
"A constant for VO2 max, written at most daily.",
"lib/insights/derived/six-minute-walk.ts::SIX_MINUTE_WALK_TYPE":
"A constant for six-minute-walk distance, written at most daily.",
"lib/insights/derived/wellness-scores.ts::measurementType":
"Daily computed / provider scores (recovery, strain, readiness), one row per day.",
"lib/jobs/reaction-line.ts::{ in: [...types]":
"Exact-instant match (`measuredAt: row.occurredAt`), not a window.",
"lib/jobs/step-consolidation-repair.ts::STEP_TYPE":
"Repair job over one user's step rows for a bounded day range; runs once per repair, not per status refresh.",
'lib/ai/coach/snapshot.ts::"BLOOD_GLUCOSE" as never':
"Clinical CGM metrics (time in range, SD / CV, J-index, LBGI / HBGI, reading span) are functions of the individual readings, which a per-day aggregate cannot reproduce. 30 days at a sensor's fixed rate (at most one a minute) stays below 45 000 rows.",
'lib/dashboard/snapshot.ts::"BLOOD_GLUCOSE"':
"Same 30-day clinical glucose panel as the coach snapshot (per-reading metrics), grouped by meal context.",
"lib/analytics/score/reader.ts::{ in: types":
"Health-score inputs: steps (drained to one row per day nightly), sleep, waist, weight, blood pressure, fasting glucose only.",
};
interface UntakenRead {
file: string;
line: number;
typeExpr: string;
}
function scopedFiles(): string[] {
return walkSourceFiles(SRC, { floor: 3000 })
.filter((p) => SCOPED_DIRS.some((d) => p.startsWith(d)))
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
.sort();
}
function untakenReads(): { all: number; untaken: UntakenRead[] } {
let all = 0;
const untaken: UntakenRead[] = [];
for (const file of scopedFiles()) {
const src = readFileSync(join(SRC, file), "utf8");
// Whitespace-tolerant: a call split across lines still matches.
const re = /\.measurement\s*\.\s*findMany\s*\(/g;
for (let m = re.exec(src); m; m = re.exec(src)) {
all += 1;
let i = m.index + m[0].length;
let depth = 1;
while (depth > 0 && i < src.length) {
if (src[i] === "(") depth += 1;
else if (src[i] === ")") depth -= 1;
i += 1;
}
const call = src.slice(m.index, i);
if (/\btake\b/.test(call)) continue;
const typed = /\btype\s*:\s*([^,\n}]+)/.exec(call);
const typeExpr = typed
? typed[1].trim()
: /\btype\s*,/.test(call)
? "(shorthand)"
: "(none)";
untaken.push({
file,
line: src.slice(0, m.index).split("\n").length,
typeExpr,
});
}
}
return { all, untaken };
}
function sparseLiteral(typeExpr: string): boolean {
const lit = /^"([A-Z_0-9]+)"/.exec(typeExpr);
return lit !== null && !DENSE_TYPES.has(lit[1]);
}
describe("dense measurement reads stay bounded (#1023)", () => {
const { all, untaken } = untakenReads();
it("finds the reads it is meant to police", () => {
// Guard against a matcher that silently matches nothing.
expect(all).toBeGreaterThanOrEqual(40);
expect(untaken.length).toBeGreaterThanOrEqual(15);
});
it("every untaken read names a sparse literal type or is listed with its reason", () => {
const offenders = untaken
.filter((r) => !sparseLiteral(r.typeExpr))
.filter((r) => !(`${r.file}::${r.typeExpr}` in ALLOWED))
.map((r) => `${r.file}:${r.line} type ${r.typeExpr}`);
expect(offenders).toEqual([]);
});
it("every listed exception still exists", () => {
const present = new Set(untaken.map((r) => `${r.file}::${r.typeExpr}`));
expect(Object.keys(ALLOWED).filter((k) => !present.has(k))).toEqual([]);
});
it("the readers fixed for #1023 fold in SQL rather than reading rows", () => {
for (const file of [
"lib/insights/graded-series.ts",
"lib/rollups/tiered-context.ts",
"lib/jobs/coach-plan-review.ts",
"lib/measurements/daily-series-read.ts",
]) {
const src = readFileSync(join(SRC, file), "utf8");
expect(src, file).not.toMatch(/\.measurement\s*\.\s*findMany\s*\(/);
}
const baseline = readFileSync(
join(SRC, "lib/insights/derived/baseline.ts"),
"utf8",
);
expect(baseline).not.toMatch(/\.measurement\s*\.\s*findMany\s*\(/);
});
});
@@ -0,0 +1,67 @@
/**
* A `@/lib/documents/provider-order` mock for the vault route suites.
*
* The suites steer the document pick through `resolveDocumentVisionProvider` /
* `resolveDocumentTextProvider` (a `{ chain, pick }` per case). The routes call
* the `require*` forms, which throw instead of returning an empty pick; these
* are built on the two mocked resolvers so a case that returns no pick sees
* the route answer the vault's own `documents.inbound.providerUnsupported`
* 422, and a case that returns a `withheld` refusal sees it thrown.
*
* Use inside a factory, where only a dynamic import is allowed:
*
* vi.mock("@/lib/documents/provider-order", async () =>
* (await import("@/__tests__/helpers/provider-order-mock")).providerOrderMock(),
* );
*/
import { vi } from "vitest";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
interface MockPick {
pick: unknown;
withheld?: AiUnavailableError | null;
}
async function required(resolved: Promise<MockPick> | MockPick) {
const result = await resolved;
if (result?.withheld) throw result.withheld;
if (!result?.pick) {
throw new AiUnavailableError("documentAi", "no_provider", null, {
errorCode: "documents.inbound.providerUnsupported",
status: 422,
});
}
return result.pick;
}
export function providerOrderMock() {
const resolveDocumentVisionProvider = vi.fn();
const resolveDocumentTextProvider = vi.fn();
return {
resolveDocumentVisionProvider,
resolveDocumentTextProvider,
requireDocumentVisionProvider: vi.fn((userId: string) =>
required(resolveDocumentVisionProvider(userId)),
),
requireDocumentTextProvider: vi.fn((userId: string) =>
required(resolveDocumentTextProvider(userId)),
),
};
}
/** The capability gate, open. Pair with `vi.mock("@/lib/ai/capabilities/gate", …)`. */
export function openCapabilityGateMock() {
return {
requireAiCapability: vi.fn(async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
})),
getAiCapability: vi.fn(async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
})),
};
}
+1 -1
View File
@@ -74,7 +74,7 @@ const DYNAMIC_ALLOWLIST_PREFIXES = [
*/
const EXEMPT_TEMPLATES: Record<string, string> = {
"insights.${scope}-status.${locale}":
"auditLog action name for the per-metric status cache (statusCacheAction), not an i18n key",
"the per-metric status note name (statusCacheAction), not an i18n key",
"insights.${scope}.${locale}":
"auditLog action name for the previous-insight memory read (getPreviousInsightContext), not an i18n key",
"apiErrors.${errorCode}":
@@ -45,13 +45,18 @@ function code(path: string): string {
}
/** Whitespace-tolerant: a call split across lines still matches. */
const GATE_CALL = /\b(requireAiCapability|getAiCapability)\s*\(\s*"(\w+)"/g;
const ANY_GATE = /\b(?:requireAiCapability|getAiCapability)\s*\(/;
const GATE_CALL =
/\b(?:(requireAiCapability|getAiCapability)\s*\(\s*"(\w+)"|(aiCapabilityToServe)\s*\([^,()]*,\s*"(\w+)")/g;
const ANY_GATE =
/\b(?:requireAiCapability|getAiCapability|aiCapabilityToServe)\s*\(/;
const REQUIRE_GATE = /\brequireAiCapability\s*\(/;
const RETIRED_GATE = /\brequireAssistantSurface\s*\(/;
function gateCalls(text: string): Array<{ fn: string; key: string }> {
return [...text.matchAll(GATE_CALL)].map((m) => ({ fn: m[1], key: m[2] }));
return [...text.matchAll(GATE_CALL)].map((m) => ({
fn: m[1] ?? m[3],
key: m[2] ?? m[4],
}));
}
function routeFiles(): string[] {
@@ -81,6 +86,9 @@ describe("AI capability route inventory", () => {
{ fn: "requireAiCapability", key: "coach" },
]);
expect(ANY_GATE.test("getAiCapability (\n")).toBe(true);
expect(
gateCalls('aiCapabilityToServe(\n user.id,\n "statusText",\n )'),
).toEqual([{ fn: "aiCapabilityToServe", key: "statusText" }]);
});
it("finds the routes it is meant to judge", () => {
+71 -12
View File
@@ -11,9 +11,11 @@
*
* - `action` routes call `requireAiCapability(key)` and refuse with the
* capability envelope;
* - `mixed` reads call `getAiCapability(key)`, answer 200, null the model
* text and say why in `ai`. They never refuse for an AI reason, so they
* never call `requireAiCapability`.
* - `mixed` reads call `getAiCapability(key)` (the viewer's view, for work
* they would start) or `aiCapabilityToServe(recordId, key)` (the record's
* own view, for stored model text they would show), answer 200, null the
* model text and say why in `ai`. They never refuse for an AI reason, so
* they never call `requireAiCapability`.
*
* Read by two guards: `ai-capability-route-inventory.test.ts` (the whole API
* tree) and `insights/__tests__/coach-route-gate-inventory.test.ts` (every
@@ -29,6 +31,67 @@ export interface AiRouteEntry {
}
export const AI_ROUTES: Readonly<Record<string, AiRouteEntry>> = {
// Reading documents, lab scans and medication text.
"src/app/api/documents/inbound/[id]/chat/route.ts": {
kind: "action",
capabilities: ["documentAi"],
why: "POST asks a model about one document; the history GET asks nothing.",
},
"src/app/api/documents/inbound/[id]/extract/route.ts": {
kind: "action",
capabilities: ["documentAi"],
why: "Reads a document's text with a model.",
},
"src/app/api/documents/inbound/[id]/index/route.ts": {
kind: "action",
capabilities: ["documentAi"],
why: "Indexes a document's content through a provider.",
},
"src/app/api/documents/inbound/[id]/suggest/route.ts": {
kind: "action",
capabilities: ["documentAi"],
why: "Suggests a title and category from the document with a model.",
},
"src/app/api/documents/inbound/[id]/summary/route.ts": {
kind: "action",
capabilities: ["documentAi"],
why: "Summarises a document with a model.",
},
"src/app/api/documents/inbound/reindex/route.ts": {
kind: "action",
capabilities: ["documentAi"],
why: "Re-indexes the vault through a provider.",
},
"src/app/api/documents/inbound/route.ts": {
kind: "mixed",
capabilities: ["documentAi"],
why: "The upload is data and always accepted; only the AI work queued after it follows the capability.",
},
"src/app/api/auth/me/documents-auto-ai-read/route.ts": {
kind: "mixed",
capabilities: ["documentAi"],
why: "The preference is stored whatever AI says; the catch-up it queues follows the capability.",
},
"src/app/api/insights/chat/fenced/route.ts": {
kind: "action",
capabilities: ["coach", "documentAi"],
why: "Document chat inside a Coach conversation.",
},
"src/app/api/insights/chat/[id]/attachments/route.ts": {
kind: "action",
capabilities: ["coach", "documentAi"],
why: "Attaching a document to a Coach conversation for the model to read.",
},
"src/app/api/labs/ocr/extract/route.ts": {
kind: "action",
capabilities: ["labsOcr"],
why: "Reads a lab report image with a model.",
},
"src/app/api/medications/extract/route.ts": {
kind: "action",
capabilities: ["medicationExtract"],
why: "Turns typed medication text into a schedule with a model.",
},
"src/app/api/insights/chat/route.ts": {
kind: "action",
capabilities: ["coach"],
@@ -189,23 +252,19 @@ export const DATA_ROUTES: Readonly<Record<string, string>> = {
"src/app/api/coach/reminders/[id]/route.ts": "One stored Coach reminder.",
"src/app/api/coach/reminder-suggestions/route.ts":
"Deterministic reminder suggestions.",
"src/app/api/insights/chat/[id]/attachments/[documentId]/route.ts":
"Detaching a document: removal of one's own data.",
"src/app/api/coach/suggested-actions/route.ts":
"Confirms a proposed action from a closed allowlist; no model call.",
};
/**
* Routes in the AI trees whose AI gating another change owns. Each still has
* Routes in the AI trees whose AI gating another change still owns. Each has
* to be classified; the entry names who moves it. An entry that starts
* calling a capability gate fails the guard until it moves to `AI_ROUTES`.
* Empty: every route in these trees is classified.
*/
export const PENDING_ROUTES: Readonly<Record<string, string>> = {
"src/app/api/insights/chat/fenced/route.ts":
"Document chat inside a conversation: moves to `coach` and `documentAi` with the document-extraction routes.",
"src/app/api/insights/chat/[id]/attachments/route.ts":
"Attaching a document to a conversation: moves to `coach` and `documentAi` with the document-extraction routes.",
"src/app/api/insights/chat/[id]/attachments/[documentId]/route.ts":
"Detaching a document: removal of one's own data, stays ungated once the document routes move.",
};
export const PENDING_ROUTES: Readonly<Record<string, string>> = {};
/** The trees in which every route has to be classified above. */
export const AI_ROUTE_TREES = [
@@ -42,9 +42,6 @@ import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
* module is the AI analysis opt-out and gates only AI parts;
* the data routes that happen to live under
* `/api/insights` are EXEMPT below.
* - coach → `requireAssistantSurface("coach")`, the retired gate,
* still recognised for the one route outside these trees
* that has not moved to its capability yet.
*
* 3. EXEMPT — an explicit, COMMENTED allowlist of routes that serve a
* toggleable domain but are deliberately NOT gated, each with the
@@ -253,10 +250,9 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
// affordance), not the row store. The extract route is read-only vision
// assistance, the commit route writes the user's own confirmed lab rows,
// and the capability probe is an infra availability check carrying no
// module data. All three are owner-scoped and AI-gated (consent / budget /
// rate); the module toggle hiding the surface does not need to wedge them.
// module data. The extract route is not listed: it names the `labsOcr` AI
// capability, which carries the labs module, so it counts as gated.
"src/app/api/labs/ocr/capability/route.ts",
"src/app/api/labs/ocr/extract/route.ts",
"src/app/api/labs/ocr/commit/route.ts",
// ── DATA LAYER (medications) ──────────────────────────────────────
// v1.18.1 (D3) — medications graduated from CORE to a toggleable module,
@@ -277,9 +273,9 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
// per-medication importer below: it writes intake rows and reads its own job.
"src/app/api/medications/intake/dose-history-import/route.ts",
"src/app/api/medications/intake/dose-history-import/[jobId]/status/route.ts",
// NB: `medications/extract` is NOT exempt — it gates on
// `requireAssistantSurface("coach")` (the NL-extraction is an assistant
// surface), so the inventory already counts it as a delegated gate.
// `medications/extract` is not listed: it is model work under the
// `medicationExtract` AI capability (no owning module; medications is a
// core domain for extraction), and a capability call counts as the gate.
"src/app/api/medications/[id]/route.ts",
"src/app/api/medications/[id]/api-endpoint/route.ts",
"src/app/api/medications/[id]/cadence/route.ts",
@@ -421,10 +417,10 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
const MODULE_GATE_NEEDLE = "requireModuleEnabled(";
const CYCLE_GATE_NEEDLE = "requireCycleEnabled(";
const COACH_GATE_NEEDLE = 'requireAssistantSurface("coach")';
// An AI capability resolves its owning modules and the AI opt-out, so a route
// that names one is gated on them.
const AI_CAPABILITY_GATE = /\b(?:requireAiCapability|getAiCapability)\s*\(/;
const AI_CAPABILITY_GATE =
/\b(?:requireAiCapability|getAiCapability|aiCapabilityToServe)\s*\(/;
// v1.18.1 — the illness journal's thin gate wrapper. `requireIllnessEnabled`
// delegates to `requireModuleEnabled(userId, "illness")` and re-stamps the
// illness-specific errorCode, exactly mirroring how `cycle` delegates to
@@ -679,7 +675,6 @@ describe("module API route gate inventory", () => {
if (fileHasCall(text, MODULE_GATE_NEEDLE)) continue;
if (fileHasCall(text, CYCLE_GATE_NEEDLE)) continue;
if (fileHasCall(text, COACH_GATE_NEEDLE)) continue;
if (fileHasCall(text, ILLNESS_GATE_NEEDLE)) continue;
if (fileHasCapabilityGate(text)) continue;
@@ -801,7 +796,6 @@ describe("module API route gate inventory", () => {
if (
fileHasCall(text, MODULE_GATE_NEEDLE) ||
fileHasCall(text, CYCLE_GATE_NEEDLE) ||
fileHasCall(text, COACH_GATE_NEEDLE) ||
fileHasCall(text, ILLNESS_GATE_NEEDLE) ||
fileHasCapabilityGate(text)
) {
@@ -43,8 +43,19 @@ vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/feature-flags", () => ({
loadAssistantSwitches: vi.fn(async () => ({
enabled: true,
coach: true,
briefing: true,
insightStatus: true,
documentAi: true,
})),
}));
import { POST } from "../route";
import { resolveProviderForTest } from "@/lib/ai/provider";
import { loadAssistantSwitches } from "@/lib/feature-flags";
beforeEach(() => {
vi.resetAllMocks();
@@ -367,6 +378,8 @@ describe("POST /api/ai/test — daily ceiling + ledger", () => {
expect(response.status).toBe(429);
expect(generateCompletion).not.toHaveBeenCalled();
const body = (await response.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("ai.budget.exceeded");
});
it("records a successful probe on the ledger", async () => {
@@ -385,3 +398,48 @@ describe("POST /api/ai/test — daily ceiling + ledger", () => {
);
});
});
describe("POST /api/ai/test — the operator's master switch and typed refusals", () => {
it("refuses with the master's code when AI is off on the server, before any provider resolves", async () => {
vi.mocked(loadAssistantSwitches).mockResolvedValueOnce({
enabled: false,
coach: false,
briefing: false,
insightStatus: false,
documentAi: false,
});
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(403);
const body = (await response.json()) as { meta: Record<string, unknown> };
expect(body.meta).toEqual({
errorCode: "assistant.disabled.enabled",
reason: "operator_disabled",
});
expect(resolveProviderForTest).not.toHaveBeenCalled();
});
it("fails closed, without a 5xx, when the switches cannot be read", async () => {
vi.mocked(loadAssistantSwitches).mockResolvedValueOnce(null);
const response = await POST(emptyRequest() as never);
expect(response.status).toBeLessThan(500);
const body = (await response.json()) as { meta: Record<string, unknown> };
expect(body.meta).toEqual({
errorCode: "ai.unavailable",
reason: "check_failed",
});
expect(resolveProviderForTest).not.toHaveBeenCalled();
});
it("names a missing provider", async () => {
vi.mocked(resolveProviderForTest).mockResolvedValue({
type: "none",
} as never);
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(422);
const body = (await response.json()) as { meta: Record<string, unknown> };
expect(body.meta).toEqual({
errorCode: "ai.provider.none",
reason: "no_provider",
});
});
});
+45 -2
View File
@@ -23,6 +23,23 @@ import {
resolveDailyCap,
} from "@/lib/ai/coach/budget";
import { aiTestOverrideSchema } from "@/lib/validations/ai-provider";
import {
AI_PROVIDER_NONE_ERROR_CODE,
AI_UNAVAILABLE_ERROR_CODE,
} from "@/lib/ai/capabilities/types";
import { loadAssistantSwitches } from "@/lib/feature-flags";
/**
* The master switch's refusal code. The connection test sends a fixed prompt
* and no health data, so it belongs to no capability; it answers to the
* operator's master switch alone, under the `assistant.disabled.<switch>`
* template with the master's own name (`enabled`) as the last segment.
*/
const MASTER_SWITCH = "enabled";
const AI_TEST_MASTER_DISABLED_CODE = `assistant.disabled.${MASTER_SWITCH}`;
/** The daily AI budget refusal's code. */
const AI_TEST_BUDGET_CODE = "ai.budget.exceeded";
/**
* Output ceiling of the probe below. Mirrors the `maxTokens` on the completion
@@ -50,6 +67,27 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "ai.test" } });
// With the operator's master switch off no AI call leaves this server, and
// the connection test is an AI call. A switch set that could not be read
// fails closed, like every capability does.
// (403, not the usual 503 for `ai.unavailable`: this route never answers
// 5xx, because a proxy rewrites a 5xx into an HTML page the settings form
// cannot parse. See the catch below.)
const switches = await loadAssistantSwitches();
if (switches === null) {
return apiError("AI is unavailable right now", 403, {
errorCode: AI_UNAVAILABLE_ERROR_CODE,
reason: "check_failed",
});
}
if (!switches.enabled) {
annotate({ action: { name: "ai.test.operator_disabled" } });
return apiError("AI is turned off on this server", 403, {
errorCode: AI_TEST_MASTER_DISABLED_CODE,
reason: "operator_disabled",
});
}
const rl = await checkRateLimit(`ai-test:${user.id}`, 5, 60_000);
if (!rl.allowed) return apiError("Too many test requests", 429);
@@ -96,7 +134,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
if (provider.type === "none") {
return apiError("No AI provider configured", 422);
return apiError("No AI provider configured", 422, {
errorCode: AI_PROVIDER_NONE_ERROR_CODE,
reason: "no_provider",
});
}
// Meter the probe on the same daily ledger every other AI surface writes to,
@@ -123,7 +164,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
if (!reservation.allowed) {
annotate({ action: { name: "ai.test.budget_exceeded" } });
return apiError("Daily AI budget exhausted", 429);
return apiError("Daily AI budget exhausted", 429, {
errorCode: AI_TEST_BUDGET_CODE,
});
}
try {
@@ -11,7 +11,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
*/
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn(), update: vi.fn() } },
prisma: {
user: { findUnique: vi.fn(), update: vi.fn() },
consentReceipt: { findFirst: vi.fn(), create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
@@ -21,8 +24,8 @@ vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/consent/web-grant", () => ({
ensureWebAiConsentReceipt: vi.fn().mockResolvedValue(undefined),
vi.mock("@/lib/ai/capabilities/gate", () => ({
getAiCapability: vi.fn(),
}));
vi.mock("@/lib/jobs/document-summary-catchup", () => ({
enqueueSummaryCatchUp: vi.fn().mockResolvedValue({ enqueued: true }),
@@ -47,7 +50,7 @@ vi.mock("next/headers", () => ({
import { PATCH } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { enqueueSummaryCatchUp } from "@/lib/jobs/document-summary-catchup";
const SESSION_OK = {
@@ -74,6 +77,13 @@ beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
vi.mocked(prisma.consentReceipt.create).mockResolvedValue({} as never);
vi.mocked(getAiCapability).mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
});
});
describe("PATCH /api/auth/me/documents-auto-ai-read — catch-up scheduling", () => {
@@ -103,23 +113,75 @@ describe("PATCH /api/auth/me/documents-auto-ai-read — catch-up scheduling", ()
await PATCH(mkPatch(false));
expect(enqueueSummaryCatchUp).not.toHaveBeenCalled();
expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
});
it("still mints the consent receipt on the flip that schedules the pass", async () => {
// The catch-up rides the same act of consent, never around it. The mint
// is marked `affirmative` because switching the toggle on IS the consent
// act: unlike the settings-mount heal, it may lift an earlier
// withdrawal, which is the user deciding again rather than the page
// deciding for them.
it("mints the extraction receipt on the flip that schedules the pass", async () => {
// The catch-up rides the same act of consent, never around it. The toggle
// grants document reading only: `ai_extraction`, not the master `ai_full`
// that would also open the Coach and the AI analysis.
withPrevious(false);
await PATCH(mkPatch(true));
expect(ensureWebAiConsentReceipt).toHaveBeenCalledWith(
"user-1",
"affirmative",
expect(prisma.consentReceipt.create).toHaveBeenCalledTimes(1);
const data = vi.mocked(prisma.consentReceipt.create).mock.calls[0]![0]
.data as { userId: string; kind: string };
expect(data.userId).toBe("user-1");
expect(data.kind).toBe("ai_extraction");
// The capability is read after the mint, so the fresh receipt counts.
const mintOrder = vi.mocked(prisma.consentReceipt.create).mock
.invocationCallOrder[0]!;
const readOrder = vi.mocked(getAiCapability).mock.invocationCallOrder[0]!;
expect(mintOrder).toBeLessThan(readOrder);
});
it("does not mint a second receipt when one that covers document reads is active", async () => {
withPrevious(false);
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue({
id: "r1",
} as never);
await PATCH(mkPatch(true));
expect(prisma.consentReceipt.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
userId: "user-1",
revokedAt: null,
kind: { in: ["ai_extraction", "ai_full"] },
}),
}),
);
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
});
it("treats a concurrent mint as success", async () => {
withPrevious(false);
vi.mocked(prisma.consentReceipt.create).mockRejectedValue({
code: "P2002",
});
const res = await PATCH(mkPatch(true));
expect(res.status).toBe(200);
});
it("saves the preference but schedules no catch-up while document reading is closed", async () => {
// The operator turned document reading off: every summary the pass would
// enqueue could only be refused, so it is not enqueued at all.
withPrevious(false);
vi.mocked(getAiCapability).mockResolvedValue({
available: false,
reason: "operator_disabled",
onDeviceAllowed: false,
});
const res = await PATCH(mkPatch(true));
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalled();
expect(enqueueSummaryCatchUp).not.toHaveBeenCalled();
});
it("persists the flag field-by-field", async () => {
@@ -10,12 +10,17 @@
* through the user's configured external provider with no per-document tap.
*
* Flipping it ON is itself the standing consent act, so the write also mints an
* append-only `ai_full` consent receipt (`ensureWebAiConsentReceipt`) — the
* durable audit record that sits alongside the runtime short-circuit the
* document consent gate reads. An OFF→ON flip additionally schedules a bounded
* catch-up over the documents already stored (`enqueueSummaryCatchUp`), because
* the summary job is enqueued at upload time and would otherwise never revisit
* a vault filled before the opt-in. Mirrors `auth/me/labs-local-ocr`: 60/min rate
* append-only `ai_extraction` consent receipt — the durable record the document
* capability reads. It covers reading documents, lab reports and medication
* text and nothing else: the Coach and the AI analysis keep asking for their
* own consent. (Before this release the toggle minted `ai_full`, which also
* opened those; receipts minted that way stay valid, because nothing tells
* them apart from a deliberate master grant.) An OFF→ON flip additionally
* schedules a bounded catch-up over the documents already stored
* (`enqueueSummaryCatchUp`), because the summary job is enqueued at upload time
* and would otherwise never revisit a vault filled before the opt-in; it is
* skipped when the `documentAi` capability is closed for the record, since
* every summary it would enqueue could only be refused. Mirrors `auth/me/labs-local-ocr`: 60/min rate
* limit, Zod `safeParse` → 422 via `returnAllZodIssues`, audit-log row,
* field-by-field write (no mass assignment). Idempotent — always returns the
* resolved next state so the client can hard-set the optimistic update.
@@ -30,8 +35,11 @@ import {
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { AI_CAPABILITIES } from "@/lib/ai/capabilities/types";
import { AI_EXTRACTION_CONSENT_KIND } from "@/lib/consent/receipts";
import { prisma } from "@/lib/db";
import { isP2002 } from "@/lib/prisma-errors";
import { enqueueSummaryCatchUp } from "@/lib/jobs/document-summary-catchup";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { documentsAutoAiReadPatchSchema } from "@/lib/validations/user-prefs";
@@ -45,6 +53,55 @@ type DocumentsAutoAiReadResponse = {
documentsAutoAiRead: boolean;
};
/**
* The receipt kind the toggle mints: the extraction grant, narrower than the
* master `ai_full`. The capability table lists it (with `ai_full`) as what
* satisfies the document consent rule.
*/
const AUTO_READ_CONSENT_KIND = AI_EXTRACTION_CONSENT_KIND;
/**
* Mint the extraction receipt unless one that satisfies document reads is
* already active. The toggle is an affirmative act, so an earlier revocation
* does not block it, exactly as a first grant would not be blocked. The
* partial unique index on active receipts is the backstop for two concurrent
* flips; losing that race means the other flip already minted, which is the
* outcome wanted. Returns whether a receipt was written.
*/
async function ensureExtractionReceipt(
userId: string,
now: Date = new Date(),
): Promise<boolean> {
const active = await prisma.consentReceipt.findFirst({
where: {
userId,
revokedAt: null,
kind: { in: [...AI_CAPABILITIES.documentAi.consent.kinds] },
},
select: { id: true },
});
if (active) return false;
try {
await prisma.consentReceipt.create({
data: {
userId,
kind: AUTO_READ_CONSENT_KIND,
artefact: JSON.stringify({
source: "web",
kind: AUTO_READ_CONSENT_KIND,
grantedAt: now.toISOString(),
note: "Documents read automatically with AI, turned on in the app.",
}),
signedAt: now,
},
});
return true;
} catch (err) {
if (isP2002(err)) return false;
throw err;
}
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "auth.me.documentsAutoAiRead.get" } });
@@ -103,11 +160,9 @@ export const PATCH = apiHandler(async (req: Request) => {
});
// Turning the toggle ON is the standing consent act — mint an append-only
// `ai_full` receipt (idempotent) so the durable audit trail records it, in
// addition to the runtime short-circuit the document consent gate reads.
if (next) {
await ensureWebAiConsentReceipt(user.id, "affirmative");
}
// extraction receipt (idempotent) so the durable audit trail records it and
// the document capability can read it.
const consentMinted = next ? await ensureExtractionReceipt(user.id) : false;
// A genuine OFF→ON flip schedules a catch-up over the documents already in
// the vault. Without it the switch only ever applied to FUTURE uploads: the
@@ -117,7 +172,16 @@ export const PATCH = apiHandler(async (req: Request) => {
// budget gate still runs per document inside the summary job itself.
const wasEnabled = previous?.documentsAutoAiRead ?? false;
if (next && !wasEnabled) {
void enqueueSummaryCatchUp(user.id);
// Read after the mint, so a receipt this request just wrote counts.
const documentAi = await getAiCapability("documentAi");
if (documentAi.available) {
void enqueueSummaryCatchUp(user.id);
} else {
annotate({
action: { name: "auth.me.documentsAutoAiRead.catchUpSkipped" },
meta: { reason: documentAi.reason },
});
}
}
await auditLog("user.documentsAutoAiRead.update", {
@@ -126,6 +190,7 @@ export const PATCH = apiHandler(async (req: Request) => {
details: {
previous: previous?.documentsAutoAiRead ?? false,
next,
consentMinted,
},
});
@@ -43,7 +43,7 @@ vi.mock("@/lib/rate-limit", () => ({
}));
// Operator master flag = on by default; coach delegation keys off it.
// Partial mock — api-handler imports `AssistantDisabledError` from here.
// Partial mock: the rest of the module stays real.
vi.mock("@/lib/feature-flags", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/feature-flags")>();
return {
+10 -1
View File
@@ -49,6 +49,10 @@ import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
import { buildAvatarUrl } from "@/lib/avatar";
import { decrypt } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import {
isValidTimezone,
resolveServerDefaultTimezone,
} from "@/lib/tz/resolver";
import { isCycleEnabled } from "@/lib/cycle/gate";
import {
resolveModuleMap,
@@ -272,7 +276,12 @@ export const GET = apiHandler(async () => {
heightCm: user.heightCm,
dateOfBirth: user.dateOfBirth,
gender: user.gender,
timezone: user.timezone,
// The zone the server cuts this account's days in: the stored zone, or
// the instance default when the stored one is unusable. Clients bucket
// by this value, so it must be the resolved one, not the raw column.
timezone: isValidTimezone(user.timezone)
? user.timezone
: await resolveServerDefaultTimezone(),
onboardingCompletedAt: user.onboardingCompletedAt,
onboardingTourCompleted: user.onboardingTourCompleted,
// v1.39 (C1) — the needs-based setup flow, published additively:
@@ -1,24 +1,30 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// v1.16.16 — `createReceipt` + `revokeLatest` run inside a transaction; the
// mock runs the callback against the same proxy.
// v1.16.16 — `createReceipt` and the withdrawal run inside a transaction;
// the mock runs the callback against the same proxy. The withdrawal also
// purges the regenerable AI text, so the proxy carries those delegates too.
type TxFn = (tx: unknown) => unknown;
vi.mock("@/lib/db", () => {
const consentReceipt = {
create: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
};
return {
prisma: {
consentReceipt,
$transaction: vi.fn((fn: TxFn) => fn({ consentReceipt })),
const prisma = {
consentReceipt: {
create: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
count: vi.fn(),
},
insightStatusCache: { deleteMany: vi.fn() },
user: { updateMany: vi.fn() },
insightNarrative: { deleteMany: vi.fn() },
arrivalReaction: { updateMany: vi.fn() },
workoutInsight: { deleteMany: vi.fn() },
$transaction: vi.fn(),
};
prisma.$transaction.mockImplementation((fn: TxFn) => fn(prisma));
return { prisma };
});
vi.mock("@/lib/rate-limit", () => ({
@@ -78,9 +84,21 @@ beforeEach(() => {
vi.mocked(auditLog).mockResolvedValue(undefined);
// Re-arm rate-limit + transaction pass-throughs cleared by reset.
vi.mocked(checkConsentRateLimit).mockResolvedValue(RL_OK);
$transaction.mockImplementation((fn: TxFn) =>
fn({ consentReceipt: prisma.consentReceipt }),
);
$transaction.mockImplementation((fn: TxFn) => fn(prisma));
// Default: another receipt still covers the analysis, so no purge runs.
// The purge cases below set this to 0.
vi.mocked(prisma.consentReceipt.count).mockResolvedValue(1 as never);
for (const deleteMany of [
prisma.insightStatusCache.deleteMany,
prisma.insightNarrative.deleteMany,
prisma.workoutInsight.deleteMany,
]) {
vi.mocked(deleteMany).mockResolvedValue({ count: 0 } as never);
}
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
vi.mocked(prisma.arrivalReaction.updateMany).mockResolvedValue({
count: 0,
} as never);
});
describe("GET /api/consent/ai/latest", () => {
@@ -278,14 +296,15 @@ describe("DELETE /api/consent/ai/latest", () => {
const createdAt = new Date("2026-05-18T10:00:01.000Z");
const revokedAt = new Date("2026-05-18T11:00:00.000Z");
// v1.16.16 — `revokeLatest` runs per kind in `consentKindEnum.options`
// order: ai_full → ai_insights_only → ai_coach. The `updateMany` count
// gates the follow-up re-read. ai_full + ai_coach revoke a row;
// ai_insights_only has none (count 0, no re-read).
// The withdrawal runs per kind in `consentKindEnum.options` order:
// ai_full → ai_insights_only → ai_coach → ai_extraction. The
// `updateMany` count gates the follow-up re-read. ai_full + ai_coach
// revoke a row; the others have none (count 0, no re-read).
vi.mocked(prisma.consentReceipt.updateMany)
.mockResolvedValueOnce({ count: 1 } as never) // ai_full
.mockResolvedValueOnce({ count: 0 } as never) // ai_insights_only
.mockResolvedValueOnce({ count: 1 } as never); // ai_coach
.mockResolvedValueOnce({ count: 1 } as never) // ai_coach
.mockResolvedValueOnce({ count: 0 } as never); // ai_extraction
vi.mocked(prisma.consentReceipt.findFirst)
.mockResolvedValueOnce({
id: "rcpt-full",
@@ -323,6 +342,80 @@ describe("DELETE /api/consent/ai/latest", () => {
});
});
describe("DELETE /api/consent/ai/latest — purge of regenerable AI text", () => {
const revokedRow = {
id: "rcpt-1",
userId: "user-1",
kind: "ai_insights_only",
artefact: "PDF",
signedAt: new Date("2026-05-18T10:00:00.000Z"),
revokedAt: new Date("2026-05-18T11:00:00.000Z"),
createdAt: new Date("2026-05-18T10:00:01.000Z"),
};
it("deletes the regenerable text and writes a counts-only purge audit row when nothing covers the analysis any more", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
count: 1,
} as never);
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(
revokedRow as never,
);
vi.mocked(prisma.consentReceipt.count).mockResolvedValue(0 as never);
vi.mocked(prisma.insightStatusCache.deleteMany).mockResolvedValue({
count: 4,
} as never);
const res = await DELETE(
new NextRequest(
"http://localhost/api/consent/ai/latest?kind=ai_insights_only",
{ method: "DELETE" },
),
);
expect(res.status).toBe(200);
expect(prisma.insightStatusCache.deleteMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
});
expect(prisma.workoutInsight.deleteMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
});
expect(auditLog).toHaveBeenCalledWith("consent.ai.purge", {
userId: "user-1",
details: {
statusNotes: 4,
briefing: 0,
narratives: 0,
reactionLines: 0,
workoutParagraphs: 0,
},
});
});
it("keeps the text while another receipt still covers the analysis", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
count: 1,
} as never);
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(
revokedRow as never,
);
vi.mocked(prisma.consentReceipt.count).mockResolvedValue(1 as never);
const res = await DELETE(
new NextRequest(
"http://localhost/api/consent/ai/latest?kind=ai_insights_only",
{ method: "DELETE" },
),
);
expect(res.status).toBe(200);
expect(prisma.insightStatusCache.deleteMany).not.toHaveBeenCalled();
expect(auditLog).not.toHaveBeenCalledWith(
"consent.ai.purge",
expect.anything(),
);
});
});
describe("append-only invariant", () => {
/**
* v1.4.40 SB-10 — the receipts table is the legal audit trail.
+43 -38
View File
@@ -13,6 +13,11 @@
* DELETE /api/consent/ai/latest
* Master toggle — revokes the latest active receipt across every
* consent kind. Returns the list of revoked rows.
*
* Either DELETE also deletes the regenerable model-written text (status
* notes, the cached briefing, model-written narratives, reaction lines,
* workout paragraphs) when it leaves no receipt covering the analysis, in the
* same transaction as the revoke. Coach memory and document summaries stay.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
@@ -28,8 +33,8 @@ import {
import {
latestActiveReceipt,
latestActiveReceiptsByKind,
revokeLatest,
} from "@/lib/consent/receipts";
import { withdrawConsent } from "@/lib/consent/withdrawal";
import { serialiseReceipt } from "../route";
export const GET = apiHandler(async (request: NextRequest) => {
@@ -81,6 +86,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
? serialiseReceipt(byKind.ai_insights_only)
: null,
ai_coach: byKind.ai_coach ? serialiseReceipt(byKind.ai_coach) : null,
ai_extraction: byKind.ai_extraction
? serialiseReceipt(byKind.ai_extraction)
: null,
};
annotate({
@@ -119,53 +127,50 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
const { kind } = parsed.data;
if (kind) {
const revoked = await revokeLatest(user.id, kind);
if (!revoked) {
// Idempotent: "no active receipt to revoke" is success-shaped.
// The iOS toggle hammers this on every flip; returning 404
// would surface as a spurious error in the client.
annotate({
action: { name: "consent.ai.revoke" },
meta: { kind, revoked: false },
});
return apiSuccess({ kind, receipt: null });
}
// One kind, or every kind for the master "turn AI off" toggle. The revoke
// and the purge of the regenerable text the consent covered commit
// together (`withdrawConsent`).
const kinds = kind ? [kind] : consentKindEnum.options;
const { revoked, purged } = await withdrawConsent(user.id, kinds);
for (const { kind: k, receipt } of revoked) {
auditLog("consent.ai.revoke", {
userId: user.id,
details: { kind, receiptId: revoked.id },
details: { kind: k, receiptId: receipt.id },
}).catch(() => {});
annotate({
action: { name: "consent.ai.revoke" },
meta: { kind, receiptId: revoked.id },
});
return apiSuccess({ kind, receipt: serialiseReceipt(revoked) });
}
// No kind specified — master "AI deaktivieren" toggle. Revoke the
// latest active row for every kind that currently has one.
const revokedRows: Array<{
kind: ConsentKind;
receipt: ReturnType<typeof serialiseReceipt>;
}> = [];
for (const k of consentKindEnum.options) {
const r = await revokeLatest(user.id, k);
if (r) {
revokedRows.push({ kind: k, receipt: serialiseReceipt(r) });
auditLog("consent.ai.revoke", {
userId: user.id,
details: { kind: k, receiptId: r.id },
}).catch(() => {});
}
if (purged) {
// Counts only, never content.
auditLog("consent.ai.purge", {
userId: user.id,
details: { ...purged },
}).catch(() => {});
}
annotate({
action: { name: "consent.ai.revoke" },
meta: {
kinds_revoked: revokedRows.map((r) => r.kind),
count: revokedRows.length,
kinds_revoked: revoked.map((r) => r.kind),
count: revoked.length,
purged: purged !== null,
},
});
return apiSuccess({ revoked: revokedRows });
if (kind) {
// Idempotent: "no active receipt to revoke" is success-shaped. The iOS
// toggle hammers this on every flip; returning 404 would surface as a
// spurious error in the client.
const receipt = revoked[0]?.receipt ?? null;
return apiSuccess({
kind,
receipt: receipt ? serialiseReceipt(receipt) : null,
});
}
return apiSuccess({
revoked: revoked.map((r) => ({
kind: r.kind,
receipt: serialiseReceipt(r.receipt),
})),
});
});
+8
View File
@@ -180,6 +180,14 @@ async function postPeriod(request: NextRequest): Promise<Response> {
...(txResult.moved.priorCycleId
? { priorCycleId: txResult.moved.priorCycleId }
: {}),
// A later start folded into this one (the same period, recorded in
// the other order): name the row that stopped existing and its date.
...(txResult.moved.absorbedCycleId
? {
absorbedCycleId: txResult.moved.absorbedCycleId,
absorbedStartDate: txResult.moved.absorbedStartDate,
}
: {}),
...overwriteDetails({
before: {
priorEndDate: txResult.moved.priorEndDateBefore,
@@ -44,8 +44,8 @@ vi.mock("@/lib/dashboard/snapshot", async (importOriginal) => ({
}));
// The per-read briefing capability; its own behaviour is pinned in
// `snapshot-read-briefing-capability.test.ts`.
vi.mock("@/lib/ai/capabilities/record", () => ({
aiCapabilityForRecord: async () => ({
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityToServe: async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
@@ -34,17 +34,13 @@ vi.mock("@/lib/documents/content-index", () => ({
vi.mock("@/lib/documents/provider-order", () => ({
resolveDocumentTextProvider: vi.fn(),
}));
vi.mock("@/lib/ai/consent-guard", () => ({
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
ConsentRequiredError: class ConsentRequiredError extends Error {
errorCode = "consent.ai.required" as const;
surface: string;
constructor(surface: string) {
super("consent required");
this.surface = surface;
this.name = "ConsentRequiredError";
}
},
vi.mock("@/lib/ai/capabilities/gate", async () =>
(
await import("@/__tests__/helpers/provider-order-mock")
).openCapabilityGateMock(),
);
vi.mock("@/lib/ai/capabilities/egress", () => ({
assertAiEgress: vi.fn(),
}));
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(() => "2026-07-07"),
@@ -94,10 +90,9 @@ import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { loadDocumentChatText } from "@/lib/documents/content-index";
import { resolveDocumentTextProvider } from "@/lib/documents/provider-order";
import {
assertDocumentEgressConsent,
ConsentRequiredError,
} from "@/lib/ai/consent-guard";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { assertAiEgress } from "@/lib/ai/capabilities/egress";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
import { reserveBudget } from "@/lib/ai/coach/budget";
import { runStreamingRawCompletionWithFallback } from "@/lib/ai/provider-runner";
import {
@@ -162,7 +157,7 @@ beforeEach(() => {
allowed: true,
reserved: 600,
} as never);
vi.mocked(assertDocumentEgressConsent).mockResolvedValue(undefined);
vi.mocked(assertAiEgress).mockResolvedValue(undefined);
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
id: "doc-1",
@@ -413,9 +408,44 @@ describe("POST — numeric grounding", () => {
});
describe("POST — consent / budget gating", () => {
it("403s when document egress consent is missing", async () => {
vi.mocked(assertDocumentEgressConsent).mockRejectedValue(
new ConsentRequiredError("insights"),
it("403s when the pick needs a consent receipt the record does not hold", async () => {
vi.mocked(resolveDocumentTextProvider).mockResolvedValue({
chain: [],
pick: null,
withheld: new AiUnavailableError("documentAi", "consent_required"),
} as never);
const res = await POST(
req("doc-1", { message: "hi" }) as never,
ctx("doc-1") as never,
);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.meta).toEqual({
errorCode: "consent.ai.required",
capability: "documentAi",
reason: "consent_required",
});
expect(runStreamingRawCompletionWithFallback).not.toHaveBeenCalled();
expect(appendMessage).not.toHaveBeenCalled();
});
it("answers under documentAi alone: the vault's chat asks nothing of the Coach", async () => {
await frames(
await POST(
req("doc-1", { message: "hi" }) as never,
ctx("doc-1") as never,
),
);
expect(requireAiCapability).toHaveBeenCalledTimes(1);
expect(requireAiCapability).toHaveBeenCalledWith("documentAi", {
pickDecides: true,
});
expect(assertAiEgress).not.toHaveBeenCalled();
});
it("refuses before loading anything when document reading is off", async () => {
vi.mocked(requireAiCapability).mockRejectedValueOnce(
new AiUnavailableError("documentAi", "operator_disabled"),
);
const res = await POST(
req("doc-1", { message: "hi" }) as never,
@@ -423,7 +453,8 @@ describe("POST — consent / budget gating", () => {
);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.meta?.errorCode ?? body.errorCode).toBe("consent.ai.required");
expect(body.meta.errorCode).toBe("assistant.disabled.documentAi");
expect(loadDocumentChatText).not.toHaveBeenCalled();
expect(runStreamingRawCompletionWithFallback).not.toHaveBeenCalled();
});
@@ -23,6 +23,7 @@
*/
import { type NextRequest } from "next/server";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import {
apiError,
@@ -76,6 +77,11 @@ export const POST = apiHandler(
const gate = await requireModuleEnabled(userId, "inboundDocuments");
if (!gate.enabled) return gate.response;
// A turn sends the document's text to a model. The provider and the
// consent receipt are answered by the pick, for the provider actually used.
// Reading the history (GET) is the person's own data and stays open.
await requireAiCapability("documentAi", { pickDecides: true });
const { id } = await params;
const document = await loadOwnedDocument(userId, id);
if (!document) {
@@ -213,6 +219,7 @@ export const POST = apiHandler(
contractLocale,
locale,
signal: request.signal,
alsoRequires: [],
});
},
);
@@ -56,14 +56,14 @@ vi.mock("@/lib/labs/ocr-upload", () => ({
detectOcrMimeType: vi.fn(() => "image/png"),
}));
vi.mock("@/lib/documents/provider-order", () => ({
resolveDocumentVisionProvider: vi.fn(),
resolveDocumentTextProvider: vi.fn(),
}));
vi.mock("@/lib/ai/consent-guard", () => ({
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
ConsentRequiredError: class ConsentRequiredError extends Error {},
}));
vi.mock("@/lib/documents/provider-order", async () =>
(await import("@/__tests__/helpers/provider-order-mock")).providerOrderMock(),
);
vi.mock("@/lib/ai/capabilities/gate", async () =>
(
await import("@/__tests__/helpers/provider-order-mock")
).openCapabilityGateMock(),
);
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(() => "2026-06-27"),
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }),
@@ -117,6 +117,8 @@ import {
resolveDocumentTextProvider,
resolveDocumentVisionProvider,
} from "@/lib/documents/provider-order";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
const tx = (
prisma as unknown as {
@@ -323,3 +325,39 @@ describe("POST /api/documents/inbound/[id]/extract — stored mode", () => {
expect(runInboundExtraction).not.toHaveBeenCalled();
});
});
describe("POST /api/documents/inbound/[id]/extract — the documentAi capability", () => {
it("refuses with the operator's code before the document is loaded", async () => {
vi.mocked(requireAiCapability).mockRejectedValueOnce(
new AiUnavailableError("documentAi", "operator_disabled"),
);
const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.meta).toEqual({
errorCode: "assistant.disabled.documentAi",
capability: "documentAi",
reason: "operator_disabled",
});
expect(prisma.inboundDocument.findFirst).not.toHaveBeenCalled();
expect(resolveDocumentVisionProvider).not.toHaveBeenCalled();
expect(runInboundExtraction).not.toHaveBeenCalled();
});
it("stops at the wire when the picked provider needs a receipt, before any slot is charged", async () => {
vi.mocked(resolveDocumentTextProvider).mockResolvedValue({
chain: [],
pick: null,
withheld: new AiUnavailableError("documentAi", "consent_required"),
} as never);
vi.mocked(loadDocumentChatText).mockResolvedValue({
text: "Glucose 95 mg/dL",
} as never);
const res = await POST(storedReq("doc-1") as never, ctx("doc-1") as never);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.meta.errorCode).toBe("consent.ai.required");
expect(checkRateLimit).not.toHaveBeenCalled();
expect(runInboundExtraction).not.toHaveBeenCalled();
});
});
@@ -4,8 +4,9 @@
* Extraction is no longer part of upload: a document is stored first
* (provider-free), and THIS route is the explicit, user-triggered enhancement.
* It carries the entire ingest gauntlet that upload used to run inline —
* resolve document provider (local-first, codex last) → assertDocumentEgressConsent
* → rate-limit → reserveBudget → runInboundExtraction → reconcileSpend → stage
* requireAiCapability("documentAi") → resolve document provider (local-first,
* codex last) and re-check the capability for that pick, consent receipt
* included → rate-limit → reserveBudget → runInboundExtraction → reconcileSpend → stage
* facts — but now against a row that already exists. Absent a provider this 422s
* the ENHANCEMENT only; the stored document is untouched and remains filed.
*
@@ -36,7 +37,7 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import {
buildDateKey,
reconcileSpend,
@@ -58,8 +59,8 @@ import {
serialiseDocumentDetail,
} from "@/lib/documents/store";
import {
resolveDocumentTextProvider,
resolveDocumentVisionProvider,
requireDocumentTextProvider,
requireDocumentVisionProvider,
} from "@/lib/documents/provider-order";
import {
checkDocumentAiRateLimit,
@@ -139,6 +140,10 @@ export const POST = apiHandler(
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
if (!gate.enabled) return gate.response;
// Reading a document is model work. The provider and the consent receipt
// are answered by the pick below, for the provider actually used.
await requireAiCapability("documentAi", { pickDecides: true });
const { id } = await params;
const document = await prisma.inboundDocument.findFirst({
where: { id, userId: user.id, deletedAt: null },
@@ -220,18 +225,7 @@ async function handleTextExtract(
});
}
const { pick } = await resolveDocumentTextProvider(userId);
if (!pick) {
return apiError("No AI provider is configured", 422, {
errorCode: "documents.inbound.providerUnsupported",
});
}
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
const pick = await requireDocumentTextProvider(userId);
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
@@ -337,18 +331,7 @@ async function handleStoredExtract(
});
}
const { pick } = await resolveDocumentTextProvider(userId);
if (!pick) {
return apiError("No AI provider is configured", 422, {
errorCode: "documents.inbound.providerUnsupported",
});
}
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
const pick = await requireDocumentTextProvider(userId);
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
@@ -423,18 +406,7 @@ async function handleVisionExtract(
userId: string,
document: LoadedDocument,
): Promise<Response> {
const { pick } = await resolveDocumentVisionProvider(userId);
if (!pick) {
return apiError("No vision-capable AI provider is configured", 422, {
errorCode: "documents.inbound.providerUnsupported",
});
}
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
const pick = await requireDocumentVisionProvider(userId);
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
@@ -35,18 +35,19 @@ vi.mock("@/lib/documents/describe", () => ({
vi.mock("@/lib/documents/content-index", () => ({
upsertContentIndex: vi.fn().mockResolvedValue({ tokenCount: 7 }),
}));
vi.mock("@/lib/documents/provider-order", () => ({
resolveDocumentVisionProvider: vi.fn(),
}));
vi.mock("@/lib/documents/provider-order", async () =>
(await import("@/__tests__/helpers/provider-order-mock")).providerOrderMock(),
);
vi.mock("@/lib/ai/capabilities/gate", async () =>
(
await import("@/__tests__/helpers/provider-order-mock")
).openCapabilityGateMock(),
);
vi.mock("@/lib/documents/auto-stage-labs", () => ({
maybeAutoStageLabFacts: vi
.fn()
.mockResolvedValue({ staged: false, reason: "not-lab" }),
}));
vi.mock("@/lib/ai/consent-guard", () => ({
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
ConsentRequiredError: class ConsentRequiredError extends Error {},
}));
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(() => "2026-07-07"),
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }),
@@ -32,7 +32,7 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import {
buildDateKey,
reconcileSpend,
@@ -57,7 +57,7 @@ import {
DocumentDescribeError,
transcribeDocument,
} from "@/lib/documents/describe";
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
import { requireDocumentVisionProvider } from "@/lib/documents/provider-order";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
@@ -183,17 +183,12 @@ async function handleVisionIndex(
userId: string,
document: LoadedDocument,
): Promise<Response> {
const { pick } = await resolveDocumentVisionProvider(userId);
if (!pick) {
return apiError("No vision-capable AI provider is configured", 422, {
errorCode: "documents.inbound.providerUnsupported",
});
}
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
// Transcribing the stored original is model work; indexing text the browser
// already read (the text mode above) is not, and stays open with AI off so
// search keeps working. The provider and the consent receipt are answered
// by the pick, for the provider actually used.
await requireAiCapability("documentAi", { pickDecides: true });
const pick = await requireDocumentVisionProvider(userId);
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
@@ -27,14 +27,14 @@ vi.mock("@/lib/documents/assist", () => ({
runDocumentAssist: vi.fn(),
DocumentAssistError: class DocumentAssistError extends Error {},
}));
vi.mock("@/lib/documents/provider-order", () => ({
resolveDocumentVisionProvider: vi.fn(),
resolveDocumentTextProvider: vi.fn(),
}));
vi.mock("@/lib/ai/consent-guard", () => ({
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
ConsentRequiredError: class ConsentRequiredError extends Error {},
}));
vi.mock("@/lib/documents/provider-order", async () =>
(await import("@/__tests__/helpers/provider-order-mock")).providerOrderMock(),
);
vi.mock("@/lib/ai/capabilities/gate", async () =>
(
await import("@/__tests__/helpers/provider-order-mock")
).openCapabilityGateMock(),
);
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(() => "2026-07-07"),
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }),
@@ -24,7 +24,7 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import {
buildDateKey,
reconcileSpend,
@@ -48,8 +48,8 @@ import {
type LoadedDocument,
} from "@/lib/documents/ai-route-support";
import {
resolveDocumentTextProvider,
resolveDocumentVisionProvider,
requireDocumentTextProvider,
requireDocumentVisionProvider,
} from "@/lib/documents/provider-order";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
@@ -67,6 +67,10 @@ export const POST = apiHandler(
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
if (!gate.enabled) return gate.response;
// Reading a document is model work. The provider and the consent receipt
// are answered by the pick below, for the provider actually used.
await requireAiCapability("documentAi", { pickDecides: true });
const { id } = await params;
const document = await loadOwnedDocument(user.id, id);
if (!document) {
@@ -125,18 +129,7 @@ async function handleTextSuggest(
});
}
const { pick } = await resolveDocumentTextProvider(userId);
if (!pick) {
return apiError("No AI provider is configured", 422, {
errorCode: "documents.inbound.providerUnsupported",
});
}
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
const pick = await requireDocumentTextProvider(userId);
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
@@ -218,18 +211,7 @@ async function handleVisionSuggest(
userId: string,
document: LoadedDocument,
): Promise<Response> {
const { pick } = await resolveDocumentVisionProvider(userId);
if (!pick) {
return apiError("No vision-capable AI provider is configured", 422, {
errorCode: "documents.inbound.providerUnsupported",
});
}
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
const pick = await requireDocumentVisionProvider(userId);
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
@@ -40,14 +40,14 @@ vi.mock("@/lib/documents/describe", () => ({
documentSummaryBlockedCopy: () => "The summary was withheld.",
DocumentDescribeError: class DocumentDescribeError extends Error {},
}));
vi.mock("@/lib/documents/provider-order", () => ({
resolveDocumentVisionProvider: vi.fn(),
resolveDocumentTextProvider: vi.fn(),
}));
vi.mock("@/lib/ai/consent-guard", () => ({
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
ConsentRequiredError: class ConsentRequiredError extends Error {},
}));
vi.mock("@/lib/documents/provider-order", async () =>
(await import("@/__tests__/helpers/provider-order-mock")).providerOrderMock(),
);
vi.mock("@/lib/ai/capabilities/gate", async () =>
(
await import("@/__tests__/helpers/provider-order-mock")
).openCapabilityGateMock(),
);
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(() => "2026-07-07"),
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }),
@@ -31,7 +31,7 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import {
buildDateKey,
reconcileSpend,
@@ -59,8 +59,8 @@ import {
import { encryptDocumentSummary } from "@/lib/documents/store";
import type { OutboundReason } from "@/lib/ai/safety/outbound-screen";
import {
resolveDocumentTextProvider,
resolveDocumentVisionProvider,
requireDocumentTextProvider,
requireDocumentVisionProvider,
} from "@/lib/documents/provider-order";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
@@ -203,6 +203,10 @@ export const POST = apiHandler(
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
if (!gate.enabled) return gate.response;
// Reading a document is model work. The provider and the consent receipt
// are answered by the pick below, for the provider actually used.
await requireAiCapability("documentAi", { pickDecides: true });
const { id } = await params;
const document = await loadOwnedDocument(user.id, id);
if (!document) {
@@ -321,19 +325,9 @@ async function handleTextSummary(
return finishSummary(request, userId, document.id, "text", mode, result);
}
const { pick } = await resolveDocumentTextProvider(userId);
if (!pick) {
await refundDocumentAiSlot(userId);
return apiError("No AI provider is configured", 422, {
errorCode: "documents.inbound.providerUnsupported",
});
}
let pick: Awaited<ReturnType<typeof requireDocumentTextProvider>>;
try {
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
pick = await requireDocumentTextProvider(userId);
} catch (err) {
// Refused before any dispatch — the slot goes back with the refusal.
await refundDocumentAiSlot(userId);
@@ -394,17 +388,7 @@ async function handleVisionSummary(
locale: Locale,
persist: SummaryPersistenceTarget | null,
): Promise<Response> {
const { pick } = await resolveDocumentVisionProvider(userId);
if (!pick) {
return apiError("No vision-capable AI provider is configured", 422, {
errorCode: "documents.inbound.providerUnsupported",
});
}
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
const pick = await requireDocumentVisionProvider(userId);
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
@@ -92,6 +92,14 @@ vi.mock("@/lib/jobs/document-index", () => ({
enqueueDocumentIndex: vi.fn().mockResolvedValue({ enqueued: true }),
}));
vi.mock("@/lib/jobs/document-summary", () => ({
enqueueDocumentSummary: vi.fn().mockResolvedValue({ enqueued: true }),
}));
vi.mock("@/lib/ai/capabilities/gate", async () =>
(
await import("@/__tests__/helpers/provider-order-mock")
).openCapabilityGateMock(),
);
vi.mock("@/lib/jobs/document-thumbnail", () => ({
enqueueDocumentThumbnail: vi.fn().mockResolvedValue({ enqueued: true }),
}));
@@ -129,6 +137,8 @@ import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { enqueueDocumentIndex } from "@/lib/jobs/document-index";
import { enqueueDocumentSummary } from "@/lib/jobs/document-summary";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import {
acquireDocumentUploadSlot,
DOCUMENT_UPLOAD_GLOBAL_CONCURRENCY,
@@ -547,3 +557,27 @@ describe("GET /api/documents/inbound (list)", () => {
expect(res.status).toBe(422);
});
});
describe("POST /api/documents/inbound — AI work on upload follows documentAi", () => {
it("enqueues the background summary when document reading is open", async () => {
const res = await post(mkUpload());
expect(res.status).toBe(201);
expect(getAiCapability).toHaveBeenCalledWith("documentAi");
expect(enqueueDocumentSummary).toHaveBeenCalledWith("user-1", "doc-1");
});
it("stores the document and indexes it, but enqueues no summary, with document reading closed", async () => {
vi.mocked(getAiCapability).mockResolvedValueOnce({
available: false,
reason: "operator_disabled",
onDeviceAllowed: false,
});
const res = await post(mkUpload());
// The upload is data: accepted whatever AI says.
expect(res.status).toBe(201);
expect(txCreate).toHaveBeenCalled();
// The index job still runs its provider-free text-layer path.
expect(enqueueDocumentIndex).toHaveBeenCalledWith("user-1", "doc-1");
expect(enqueueDocumentSummary).not.toHaveBeenCalled();
});
});
@@ -35,6 +35,7 @@ export const GET = apiHandler(async () => {
reason: capability.reason,
pdfSupported: capability.pdfSupported,
egress: capability.egress,
aiReason: capability.ai.reason,
},
});
@@ -20,11 +20,10 @@ vi.mock("@/lib/jobs/document-content-index-backfill", () => ({
enqueueContentIndexBackfill: vi.fn(),
}));
vi.mock("@/lib/documents/provider-order", () => ({
resolveDocumentVisionProvider: vi.fn(),
requireDocumentVisionProvider: vi.fn(),
}));
vi.mock("@/lib/ai/consent-guard", () => ({
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
ConsentRequiredError: class ConsentRequiredError extends Error {},
vi.mock("@/lib/ai/capabilities/gate", () => ({
requireAiCapability: vi.fn(),
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
@@ -54,7 +53,9 @@ import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { enqueueContentIndexBackfill } from "@/lib/jobs/document-content-index-backfill";
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
import { requireDocumentVisionProvider } from "@/lib/documents/provider-order";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
const SESSION_OK = {
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -78,7 +79,14 @@ const req = () =>
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never);
vi.mocked(requireAiCapability).mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
});
vi.mocked(requireDocumentVisionProvider).mockResolvedValue(
PICK.pick as never,
);
vi.mocked(enqueueContentIndexBackfill).mockResolvedValue({ enqueued: true });
// 7 live documents, 4 already indexed → 3 remain.
vi.mocked(prisma.inboundDocument.count).mockResolvedValue(7 as never);
@@ -114,3 +122,36 @@ describe("POST /api/documents/inbound/reindex", () => {
});
});
});
describe("POST /api/documents/inbound/reindex — the documentAi capability", () => {
it("answers the capability envelope and enqueues nothing when document reading is off", async () => {
vi.mocked(requireAiCapability).mockRejectedValue(
new AiUnavailableError("documentAi", "operator_disabled"),
);
const res = await POST(req() as never);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.meta).toEqual({
errorCode: "assistant.disabled.documentAi",
capability: "documentAi",
reason: "operator_disabled",
});
expect(requireAiCapability).toHaveBeenCalledWith("documentAi", {
pickDecides: true,
});
expect(enqueueContentIndexBackfill).not.toHaveBeenCalled();
});
it("keeps the vault's own code when no provider can read documents", async () => {
vi.mocked(requireDocumentVisionProvider).mockRejectedValue(
new AiUnavailableError("documentAi", "no_provider", null, {
errorCode: "documents.inbound.providerUnsupported",
}),
);
const res = await POST(req() as never);
expect(res.status).toBe(422);
const body = await res.json();
expect(body.meta.errorCode).toBe("documents.inbound.providerUnsupported");
expect(enqueueContentIndexBackfill).not.toHaveBeenCalled();
});
});
+10 -15
View File
@@ -3,17 +3,17 @@
*
* Enqueues a per-user job that indexes the caller's not-yet-indexed documents
* (one provider transcription each, bounded + resumable). Gated on the module,
* a configured vision provider, and the EXISTING AI consent (the worker
* re-checks all three). The work runs off-request on pg-boss; this route only
* the `documentAi` capability, a configured vision provider, and a document
* consent receipt for that provider (the worker re-checks all of it). The work runs off-request on pg-boss; this route only
* enqueues and returns immediately so the vault stays responsive.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { enqueueContentIndexBackfill } from "@/lib/jobs/document-content-index-backfill";
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
import { requireDocumentVisionProvider } from "@/lib/documents/provider-order";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
@@ -30,6 +30,10 @@ export const POST = apiHandler(async (request) => {
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
if (!gate.enabled) return gate.response;
// The backfill transcribes each document through a model. The provider and
// the consent receipt are answered by the pick below.
await requireAiCapability("documentAi", { pickDecides: true });
const rl = await checkRateLimit(
`documents-reindex:${user.id}`,
REINDEX_LIMIT_PER_HOUR,
@@ -47,17 +51,8 @@ export const POST = apiHandler(async (request) => {
// Fail fast when the precondition is not met so the UI gets immediate
// feedback rather than a silently no-op'd job.
const { pick } = await resolveDocumentVisionProvider(user.id);
if (!pick) {
return apiError("No vision-capable AI provider is configured", 422, {
errorCode: "documents.inbound.providerUnsupported",
});
}
await assertDocumentEgressConsent({
userId: user.id,
providerType: pick.providerType,
surface: "insights",
});
// The worker re-checks all of it per run.
await requireDocumentVisionProvider(user.id);
const { enqueued: jobCreated } = await enqueueContentIndexBackfill(user.id);
+14 -2
View File
@@ -40,6 +40,7 @@ import {
getClientIp,
sanitiseZodIssues,
} from "@/lib/api-response";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { hashQueryTokens } from "@/lib/documents/content-index";
@@ -446,10 +447,21 @@ async function processUpload(
// Summarise the freshly stored document in the background — but ONLY when the
// `documentsAutoAiRead` opt-in is ON (the job re-checks it and the egress
// consent, and no-ops otherwise). The persisted summary shows on the detail
// consent, and no-ops otherwise) and the `documentAi` capability is open for
// this record. The upload itself never depends on AI: the document is stored
// above whatever the capability says, and the index job still runs its
// provider-free text-layer path. The persisted summary shows on the detail
// view. Same fire-and-forget contract: the upload never blocks on or fails
// because of it. Only fresh inserts reach here (a duplicate returns early).
void enqueueDocumentSummary(user.id, document.id);
const documentAi = await getAiCapability("documentAi");
if (documentAi.available) {
void enqueueDocumentSummary(user.id, document.id);
} else {
annotate({
action: { name: "documents.summary.enqueueSkipped" },
meta: { documentId: document.id, reason: documentAi.reason },
});
}
const [links, visitLinks] = await Promise.all([
loadConditionLinks(user.id, [document.id]),
+5 -3
View File
@@ -14,7 +14,7 @@ import {
DOCUMENT_ACCEPTED_EXTENSIONS,
resolveDocumentLimits,
} from "@/lib/documents/upload-policy";
import { resolveOcrCapability } from "@/lib/labs/ocr-capability";
import { resolveDocumentAiCapability } from "@/lib/documents/provider-order";
import { listDistinctTargets } from "@/lib/links";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
@@ -58,8 +58,10 @@ export const GET = apiHandler(async () => {
}),
// Whether an AI action can run for this caller (assist + indexing share
// the same provider precondition). Honest availability so the UI never
// offers what the endpoint would 422.
resolveOcrCapability(user.id),
// offers what the endpoint would refuse: the document provider order and
// the `documentAi` capability for this record, the same answer the
// capability probe gives.
resolveDocumentAiCapability(user.id),
// Content-index coverage: how many live documents are indexed …
// Scope the count to LIVE documents: a soft-deleted document keeps its
// index row (cascade fires on hard purge only), so an unscoped count
@@ -59,7 +59,8 @@ function routeFiles(tree: string): string[] {
const ROUTES = AI_ROUTE_TREES.flatMap(routeFiles).sort();
const ANY_GATE = /\b(?:requireAiCapability|getAiCapability)\s*\(/;
const ANY_GATE =
/\b(?:requireAiCapability|getAiCapability|aiCapabilityToServe)\s*\(/;
function code(path: string): string {
return readFileSync(resolve(repoRoot, path), "utf8")
@@ -1,6 +1,14 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// Day-mean reads fold per-day aggregates in SQL; the fake folds the mocked
// `measurement.findMany` rows with the same rules.
vi.mock("@/lib/measurements/day-aggregates", async () => ({
readDayAggregates: (
await import("@/lib/measurements/__tests__/fake-day-aggregates")
).fakeReadDayAggregates,
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
@@ -2,14 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import type * as ApiHandlerModule from "@/lib/api-handler";
const { requireAuth, requireAssistantSurface, annotate, auditLog } = vi.hoisted(
() => ({
requireAuth: vi.fn(),
requireAssistantSurface: vi.fn(),
annotate: vi.fn(),
auditLog: vi.fn(),
}),
);
const { requireAuth, annotate, auditLog } = vi.hoisted(() => ({
requireAuth: vi.fn(),
annotate: vi.fn(),
auditLog: vi.fn(),
}));
vi.mock("@/lib/api-handler", async (importOriginal) => {
const actual = await importOriginal<typeof ApiHandlerModule>();
@@ -20,7 +17,6 @@ vi.mock("@/lib/api-handler", async (importOriginal) => {
requireAuth,
};
});
vi.mock("@/lib/feature-flags", () => ({ requireAssistantSurface }));
vi.mock("@/lib/logging/context", () => ({ annotate }));
vi.mock("@/lib/auth/audit", () => ({ auditLog }));
vi.mock("@/lib/ai/coach/persistence", () => ({
@@ -65,7 +61,6 @@ async function body(response: Response) {
beforeEach(() => {
vi.clearAllMocks();
requireAuth.mockResolvedValue({ user: { id: USER_ID, locale: "en" } });
requireAssistantSurface.mockResolvedValue(undefined);
auditLog.mockResolvedValue(undefined);
});
@@ -77,7 +72,6 @@ describe("PATCH /api/insights/chat/[id]", () => {
await expect(PATCH(request({ title: "Renamed" }), context())).rejects.toBe(
unauthenticated,
);
expect(requireAssistantSurface).not.toHaveBeenCalled();
expect(renameConversation).not.toHaveBeenCalled();
});
@@ -8,6 +8,7 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
MODULE_DISABLED_ERROR_CODE: "module.disabled",
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
@@ -28,6 +29,11 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/ai/capabilities/gate", async () =>
(
await import("@/__tests__/helpers/provider-order-mock")
).openCapabilityGateMock(),
);
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
@@ -43,6 +49,8 @@ vi.mock("next/headers", () => ({
import { POST } from "../route";
import { DELETE } from "../[documentId]/route";
import { getSession } from "@/lib/auth/session";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
import {
attachDocument,
detachDocument,
@@ -211,3 +219,50 @@ describe("DELETE detach", () => {
expect(body.data.fenced).toBe(true);
});
});
describe("attachments — the coach and documentAi capabilities", () => {
it("refuses an attach when the Coach is off for the record", async () => {
vi.mocked(requireAiCapability).mockRejectedValueOnce(
new AiUnavailableError("coach", "user_disabled", "coach"),
);
const res = await POST(
postReq({ documentId: "doc-a" }) as never,
postCtx() as never,
);
expect(res.status).toBe(403);
expect(await code(res)).toBe("module.disabled");
expect(attachDocument).not.toHaveBeenCalled();
});
it("checks both capabilities on attach", async () => {
vi.mocked(fetchConversationAttachmentState).mockResolvedValue(null);
await POST(postReq({ documentId: "doc-a" }) as never, postCtx() as never);
expect(requireAiCapability).toHaveBeenCalledWith("coach", {
pickDecides: true,
});
expect(requireAiCapability).toHaveBeenCalledWith("documentAi", {
pickDecides: true,
});
});
it("lets a detach through with every capability closed: it removes the person's own data", async () => {
vi.mocked(requireAiCapability).mockRejectedValue(
new AiUnavailableError("coach", "operator_disabled"),
);
vi.mocked(fetchConversationAttachmentState).mockResolvedValue({
id: "conv-1",
documentScoped: true,
messageCount: 0,
attachmentIds: ["doc-a"],
});
const res = await DELETE(
new NextRequest(
new URL("http://localhost/api/insights/chat/conv-1/attachments/doc-a"),
{ method: "DELETE" },
) as never,
delCtx() as never,
);
expect(res.status).not.toBe(403);
expect(requireAiCapability).not.toHaveBeenCalled();
});
});
@@ -9,6 +9,7 @@
*/
import { type NextRequest } from "next/server";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
@@ -48,6 +49,12 @@ export const POST = apiHandler(
const gate = await requireModuleEnabled(userId, "inboundDocuments");
if (!gate.enabled) return gate.response;
// Attaching prepares a fenced turn, which is Coach work over documents.
// Nothing is sent here, so the provider and the consent receipt wait for
// the turn. Detaching (DELETE) removes the person's own data and stays open.
await requireAiCapability("coach", { pickDecides: true });
await requireAiCapability("documentAi", { pickDecides: true });
const { id: conversationId } = await params;
const rl = await checkRateLimit(
@@ -10,6 +10,11 @@ import { NextRequest } from "next/server";
*/
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/ai/capabilities/gate", async () =>
(
await import("@/__tests__/helpers/provider-order-mock")
).openCapabilityGateMock(),
);
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
}));
@@ -54,6 +59,8 @@ vi.mock("next/headers", () => ({
import { POST } from "../route";
import { getSession } from "@/lib/auth/session";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
import {
createConversation,
fetchConversationWithMessages,
@@ -260,3 +267,50 @@ describe("fenced route — refusal", () => {
expect(streamFencedReply).not.toHaveBeenCalled();
});
});
describe("fenced route — the coach and documentAi capabilities", () => {
it("answers under both, leaving provider and consent to the pick", async () => {
await POST(req({ message: "hi", attachmentIds: ["doc-a"] }) as never);
expect(requireAiCapability).toHaveBeenCalledWith("coach", {
pickDecides: true,
});
expect(requireAiCapability).toHaveBeenCalledWith("documentAi", {
pickDecides: true,
});
// The turn re-checks the Coach for the picked provider too.
expect(streamFencedReply).toHaveBeenCalledWith(
expect.objectContaining({ alsoRequires: ["coach"] }),
);
});
it("refuses with the Coach's code before any conversation is created", async () => {
vi.mocked(requireAiCapability).mockRejectedValueOnce(
new AiUnavailableError("coach", "operator_disabled"),
);
const res = await POST(
req({ message: "hi", attachmentIds: ["doc-a"] }) as never,
);
expect(res.status).toBe(403);
expect(await errorCode(res)).toBe("assistant.disabled.coach");
expect(createConversation).not.toHaveBeenCalled();
expect(streamFencedReply).not.toHaveBeenCalled();
});
it("refuses when document reading is off even with the Coach on", async () => {
vi.mocked(requireAiCapability)
.mockResolvedValueOnce({
available: true,
reason: null,
onDeviceAllowed: true,
})
.mockRejectedValueOnce(
new AiUnavailableError("documentAi", "operator_disabled"),
);
const res = await POST(
req({ message: "hi", attachmentIds: ["doc-a"] }) as never,
);
expect(res.status).toBe(403);
expect(await errorCode(res)).toBe("assistant.disabled.documentAi");
expect(streamFencedReply).not.toHaveBeenCalled();
});
});
+10 -1
View File
@@ -7,13 +7,15 @@
* route via `@/lib/documents/fenced-chat` so the two cannot drift.
*
* THE FENCE (see `fenced-chat.ts` for the full posture): NO tools, NO health
* snapshot; every document fenced as untrusted DATA; per-document egress consent;
* snapshot; every document fenced as untrusted DATA; the `coach` and
* `documentAi` capabilities, re-checked for the picked provider;
* numeric grounding over the LIVE attachment union only. A plain tool
* conversation 404s here (`documentScoped: true` in the fetch), so a fenced turn
* can never be appended into a tool thread's history.
*/
import { type NextRequest } from "next/server";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import {
apiError,
@@ -56,6 +58,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
const gate = await requireModuleEnabled(userId, "inboundDocuments");
if (!gate.enabled) return gate.response;
// A fenced turn is a Coach turn that reads documents: both capabilities must
// be open. The provider and the consent receipt are answered by the pick,
// for the provider actually used (a missing provider stays an SSE frame).
await requireAiCapability("coach", { pickDecides: true });
await requireAiCapability("documentAi", { pickDecides: true });
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
@@ -224,5 +232,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
contractLocale,
locale,
signal: request.signal,
alsoRequires: ["coach"],
});
});
@@ -33,9 +33,6 @@ vi.mock("@/lib/modules/gate", async (importOriginal) => ({
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
resolveModuleMap: vi.fn().mockResolvedValue({}),
}));
vi.mock("@/lib/feature-flags", () => ({
requireAssistantSurface: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkAnalyticsReadRateLimit: vi.fn(),
}));
+4 -1
View File
@@ -4,7 +4,9 @@
* Cheap probe (no provider call) the Labs UI uses to decide whether to show
* the "Scan a report" affordance. Returns `{ available, reason, pdfSupported }`
* for the calling user's configured AI provider. The surface stays dark for
* users with no provider configured and no local-OCR opt-in.
* users with no provider configured and no local-OCR opt-in, and whenever the
* `labsOcr` capability is closed (operator switch, labs module, sharing grant);
* `ai` says which.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
@@ -25,6 +27,7 @@ export const GET = apiHandler(async () => {
mode: capability.mode,
reason: capability.reason,
pdfSupported: capability.pdfSupported,
aiReason: capability.ai.reason,
},
});
@@ -27,15 +27,14 @@ vi.mock("@/lib/db", () => ({
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/labs/ocr-capability", () => ({
resolveTextProvider: vi.fn(),
resolveVisionProvider: vi.fn(),
requireLabsOcrProvider: vi.fn(),
}));
vi.mock("@/lib/ai/capabilities/gate", () => ({
requireAiCapability: vi.fn(),
}));
vi.mock("@/lib/documents/rasterize-pdf", () => ({
rasterizePdf: vi.fn(),
}));
vi.mock("@/lib/ai/consent-guard", () => ({
assertConsentForChain: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
refundRateLimit: vi.fn().mockResolvedValue(undefined),
@@ -60,10 +59,9 @@ import { POST } from "../route";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { requireAuth } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
import {
resolveTextProvider,
resolveVisionProvider,
} from "@/lib/labs/ocr-capability";
import { requireLabsOcrProvider } from "@/lib/labs/ocr-capability";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
import { rasterizePdf } from "@/lib/documents/rasterize-pdf";
import { reserveBudget, reconcileSpend } from "@/lib/ai/coach/budget";
import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
@@ -94,12 +92,14 @@ beforeEach(() => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
labsLocalOcrEnabled: true,
} as never);
vi.mocked(resolveTextProvider).mockResolvedValue({
chain: [{ providerType: "openai", instance: {} as never }],
pick: {
entry: { providerType: "openai", instance: {} as never },
providerType: "openai",
},
vi.mocked(requireAiCapability).mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
});
vi.mocked(requireLabsOcrProvider).mockResolvedValue({
entry: { providerType: "openai", instance: {} as never },
providerType: "openai",
} as never);
vi.mocked(reserveBudget).mockResolvedValue({
allowed: true,
@@ -187,14 +187,10 @@ describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => {
beforeEach(() => {
// A non-Anthropic vision provider (codex): no native PDF block, so the
// route must rasterize.
vi.mocked(resolveVisionProvider).mockResolvedValue({
chain: [{ providerType: "codex", instance: {} as never }],
localOcrEnabled: false,
pick: {
entry: { providerType: "codex", instance: {} as never },
providerType: "codex",
pdfSupported: false,
},
vi.mocked(requireLabsOcrProvider).mockResolvedValue({
entry: { providerType: "codex", instance: {} as never },
providerType: "codex",
pdfSupported: false,
} as never);
vi.mocked(reserveBudget).mockResolvedValue({
allowed: true,
@@ -334,3 +330,55 @@ describe("POST /api/labs/ocr/extract — the hourly scan bucket", () => {
expect(refundRateLimit).not.toHaveBeenCalled();
});
});
describe("POST /api/labs/ocr/extract — the labsOcr capability", () => {
it("refuses before anything is read when the operator turned document reading off", async () => {
vi.mocked(requireAiCapability).mockRejectedValue(
new AiUnavailableError("labsOcr", "operator_disabled"),
);
const error = await POST(textReq()).catch((e) => e);
expect(error).toBeInstanceOf(AiUnavailableError);
expect(error.meta.errorCode).toBe("assistant.disabled.documentAi");
expect(requireAiCapability).toHaveBeenCalledWith("labsOcr", {
pickDecides: true,
});
expect(requireLabsOcrProvider).not.toHaveBeenCalled();
expect(checkRateLimit).not.toHaveBeenCalled();
expect(runOcrExtraction).not.toHaveBeenCalled();
});
it("stops at the wire when the pick is refused, before any slot or budget is spent", async () => {
vi.mocked(requireLabsOcrProvider).mockRejectedValue(
new AiUnavailableError("labsOcr", "consent_required"),
);
const error = await POST(textReq()).catch((e) => e);
expect(error.meta).toEqual({
errorCode: "consent.ai.required",
capability: "labsOcr",
reason: "consent_required",
});
expect(requireLabsOcrProvider).toHaveBeenCalledWith("user-1", "text");
expect(checkRateLimit).not.toHaveBeenCalled();
expect(reserveBudget).not.toHaveBeenCalled();
expect(runOcrExtraction).not.toHaveBeenCalled();
});
it("asks for the vision pick on a multipart upload", async () => {
vi.mocked(requireLabsOcrProvider).mockRejectedValue(
new AiUnavailableError("labsOcr", "no_provider", null, {
errorCode: "labs.ocr.providerUnsupported",
}),
);
const form = new FormData();
form.append("file", new File([new Uint8Array([1])], "a.png"));
const error = await POST(
new Request("http://localhost/api/labs/ocr/extract", {
method: "POST",
body: form,
}),
).catch((e) => e);
expect(requireLabsOcrProvider).toHaveBeenCalledWith("user-1", "vision");
expect(error.status).toBe(422);
expect(error.meta.errorCode).toBe("labs.ocr.providerUnsupported");
});
});
+18 -35
View File
@@ -18,7 +18,10 @@
* `labsLocalOcrEnabled` preference.
*
* Guards mirror the Coach's discipline in both modes:
* requireAuth → resolve provider → assertConsentForChain → rate-limit
* requireAuth → requireAiCapability("labsOcr") → resolve provider and
* re-check the capability for that pick (`requireLabsOcrProvider`: the
* operator's switch, the labs module, and a document consent receipt for a
* pick that leaves the machine) → rate-limit
* (`LABS_OCR_LIMIT_PER_HOUR`, default 6/h) → reserveBudget → run extraction
* → reconcile budget. A slot is charged early so a 429 stays cheap, and it is
* handed back when the scan fails before the provider is called.
@@ -38,7 +41,7 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { assertConsentForChain } from "@/lib/ai/consent-guard";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import {
buildDateKey,
reconcileSpend,
@@ -48,10 +51,7 @@ import {
} from "@/lib/ai/coach/budget";
import { prisma } from "@/lib/db";
import { rasterizePdf } from "@/lib/documents/rasterize-pdf";
import {
resolveTextProvider,
resolveVisionProvider,
} from "@/lib/labs/ocr-capability";
import { requireLabsOcrProvider } from "@/lib/labs/ocr-capability";
import { OcrExtractError, runOcrExtraction } from "@/lib/labs/ocr-extract";
import {
BodyTooLargeError,
@@ -94,6 +94,11 @@ function providerFailureMeta(error: unknown, mode?: "text") {
export const POST = apiHandler(async (request: Request) => {
const { user } = await requireAuth();
// Reading a lab report is model work in both modes: vision sends the image,
// text mode sends what the browser read off it. The provider and the consent
// receipt are answered by the pick below, for the provider actually used.
await requireAiCapability("labsOcr", { pickDecides: true });
// Dispatch on the body shape: a JSON body is the local-OCR text mode; a
// multipart body is the native-vision image/PDF mode.
const contentType = request.headers.get("content-type") ?? "";
@@ -124,21 +129,10 @@ async function handleTextExtract(
});
}
// Any configured provider can structure the text.
const { chain, pick } = await resolveTextProvider(userId);
if (!pick) {
annotate({
action: { name: "labs.ocr.providerUnsupported" },
meta: { chainLength: chain.length, mode: "text" },
});
return apiError("No AI provider is configured", 422, {
errorCode: "labs.ocr.providerUnsupported",
});
}
// Consent gate — only the server-managed key trips this; BYOK / local / codex
// egress is the user's own act (the toggle is the consent for local OCR).
await assertConsentForChain({ userId, chain, surface: "insights" });
// Any configured provider can structure the text. The pick is re-checked
// against `labsOcr` for exactly that provider; one that leaves the machine
// needs a document consent receipt.
const pick = await requireLabsOcrProvider(userId, "text");
const rl = await checkLabsOcrRateLimit(userId);
if (!rl.allowed) {
@@ -244,20 +238,9 @@ async function handleVisionExtract(
request: Request,
userId: string,
): Promise<Response> {
// 1. Resolve a vision-capable provider. 422 when none is configured.
const { chain, pick } = await resolveVisionProvider(userId);
if (!pick) {
annotate({
action: { name: "labs.ocr.providerUnsupported" },
meta: { chainLength: chain.length },
});
return apiError("No vision-capable AI provider is configured", 422, {
errorCode: "labs.ocr.providerUnsupported",
});
}
// 2. Consent gate — server-managed egress of health data requires a receipt.
await assertConsentForChain({ userId, chain, surface: "insights" });
// 1-2. Resolve a vision-capable provider (422 when none is configured) and
// re-check `labsOcr` for it, consent receipt included.
const pick = await requireLabsOcrProvider(userId, "vision");
// 3. Rate-limit (vision calls are costly).
const rl = await checkLabsOcrRateLimit(userId);
@@ -32,9 +32,6 @@ vi.mock("@/lib/db", () => ({
},
}));
// v1.4.39.2 — the rollup branch's coverage-mismatch fallback may call
// `recomputeUserRollups` inline. Mock it so tests can assert whether
// the inline fold fired without exercising the real populator.
vi.mock("@/lib/rollups/measurement-rollups", async () => {
const actual = await vi.importActual<
typeof import("@/lib/rollups/measurement-rollups")
@@ -66,7 +63,7 @@ vi.mock("next/headers", () => ({
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
import { invalidateUserTimezone } from "@/lib/tz/resolver";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -221,318 +218,50 @@ describe("GET /api/measurements — all-time semantics (SD-H1)", () => {
expect(months.size).toBe(24);
});
it("source=rollup + aggregate=daily reads measurement_rollups without firing the heavy date_trunc query", async () => {
// v1.4.36 W1 — Insights trends row routes the three daily chart
// fetches through the persistent DAY buckets via `source=rollup`.
// The heavy `date_trunc` $queryRaw must NOT fire on the happy
// path where the rollup has rows for the requested window.
const buckets = Array.from({ length: 30 }, (_, i) => ({
type: "WEIGHT",
bucketStart: new Date(Date.UTC(2026, 3, 16 + i, 0, 0, 0)),
mean: 81 + i * 0.05,
count: 5,
// v1.11.1 — single source per day → collapse is identity.
source: "APPLE_HEALTH",
}));
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
buckets as never,
);
it("source=rollup + aggregate=daily folds live in the user's zone, never the UTC-day rollup (#1026)", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "Asia/Kolkata",
sourcePriorityJson: null,
} as never);
// The resolver caches per user; drop what earlier cases left behind.
invalidateUserTimezone("user-1");
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{
type: "ACTIVITY_STEPS",
bucket_start: new Date("2026-05-09T18:30:00.000Z"),
avg: 8123,
cnt: 96,
min_value: 0,
max_value: 400,
},
] as never);
const res = await GET(
getRequest(
"type=WEIGHT&from=2026-04-15T00:00:00Z&to=2026-05-15T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
"type=ACTIVITY_STEPS&from=2026-04-15T00:00:00Z&to=2026-05-15T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
),
);
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: {
measurements: Array<{ type: string; value: number; count: number }>;
meta: { aggregate?: string };
measurements: Array<{
measuredAt: string;
value: number;
minValue?: number;
}>;
};
};
expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(1);
expect(prisma.$queryRaw).not.toHaveBeenCalled();
expect(json.data.measurements.length).toBe(30);
// First bucket reflects the rollup mean — round-trip-stable.
expect(json.data.measurements[0].type).toBe("WEIGHT");
expect(json.data.measurements[0].value).toBeCloseTo(81, 5);
expect(json.data.measurements[0].count).toBe(5);
expect(json.data.meta.aggregate).toBe("daily");
});
it("source=rollup cumulative path reads sum_value directly (v1.4.39 W-SUM)", async () => {
// ACTIVITY_STEPS rollup row with sum_value populated. The route
// must consume sumValue directly — NOT reconstruct from mean *
// count. Use a sum that is NOT mean × count so the test
// distinguishes the two paths.
const buckets = Array.from({ length: 3 }, (_, i) => ({
type: "ACTIVITY_STEPS",
bucketStart: new Date(Date.UTC(2026, 4, 1 + i, 0, 0, 0)),
mean: 2000, // would yield 10000 if multiplied by count=5
count: 5,
sumValue: 11000 + i * 250, // distinct from mean × count
source: "APPLE_HEALTH",
}));
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
buckets as never,
);
const res = await GET(
getRequest(
"type=ACTIVITY_STEPS&from=2026-05-01T00:00:00Z&to=2026-05-05T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
),
);
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: { measurements: Array<{ value: number }> };
};
expect(prisma.$queryRaw).not.toHaveBeenCalled();
expect(json.data.measurements.map((m) => m.value)).toEqual([
11000, 11250, 11500,
]);
});
it("source=rollup cumulative path falls back to mean*count when sum_value is NULL (v1.4.39 W-SUM)", async () => {
// Pre-v1.4.39 row — boot-backfill hasn't converged yet. The
// route falls back to mean × count so the chart never paints
// a hole during the convergence window.
const buckets = [
{
type: "ACTIVITY_STEPS",
bucketStart: new Date(Date.UTC(2026, 4, 1)),
mean: 2000,
count: 5,
sumValue: null,
source: "APPLE_HEALTH",
},
];
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
buckets as never,
);
const res = await GET(
getRequest(
"type=ACTIVITY_STEPS&from=2026-05-01T00:00:00Z&to=2026-05-05T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
),
);
const json = (await res.json()) as {
data: { measurements: Array<{ value: number }> };
};
expect(json.data.measurements[0].value).toBe(10000);
});
it("falls back to live date_trunc when source=rollup returns zero buckets", async () => {
// Empty rollup ⇒ heavy aggregate runs so brand-new accounts still
// see a correct chart on their first render.
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
const liveBuckets = Array.from({ length: 7 }, (_, i) => ({
type: "WEIGHT",
bucket_start: new Date(Date.UTC(2026, 4, 8 + i, 0, 0, 0)),
avg: 81 + i * 0.05,
cnt: 2,
}));
vi.mocked(prisma.$queryRaw).mockResolvedValue(liveBuckets as never);
const res = await GET(
getRequest(
"type=WEIGHT&from=2026-05-08T00:00:00Z&to=2026-05-15T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
),
);
expect(res.status).toBe(200);
expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(1);
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
const json = (await res.json()) as {
data: { measurements: Array<unknown> };
};
expect(json.data.measurements.length).toBe(7);
});
it("source=rollup folds the partition inline and re-reads when the rollup is under-converged (v1.4.39.2)", async () => {
// The maintainer's pre-fix production trace: 30-day BP_SYS window returned
// `total: 2` from the rollup branch even though `measurements`
// carried ~30 distinct days. The chart tripped its `< 3 daily
// points` empty-state because the route short-circuited on
// `rollupRows.length > 0` and never reconciled with the live
// table. The coverage-mismatch fallback runs an inline
// `recomputeUserRollups` for the (user, type, DAY, window)
// partition and re-reads so the request returns the converged
// rows.
const sparseRollup = [
{
type: "BLOOD_PRESSURE_SYS",
bucketStart: new Date(Date.UTC(2026, 4, 1, 0, 0, 0)),
mean: 128,
count: 1,
sumValue: null,
source: "APPLE_HEALTH",
},
{
type: "BLOOD_PRESSURE_SYS",
bucketStart: new Date(Date.UTC(2026, 4, 10, 0, 0, 0)),
mean: 132,
count: 1,
sumValue: null,
source: "APPLE_HEALTH",
},
];
const convergedRollup = Array.from({ length: 28 }, (_, i) => ({
type: "BLOOD_PRESSURE_SYS",
bucketStart: new Date(Date.UTC(2026, 4, 1 + i, 0, 0, 0)),
mean: 128 + (i % 5),
count: 2,
sumValue: null,
source: "APPLE_HEALTH",
}));
// First read returns the sparse rows; second (post-fold) read
// returns the converged rows.
vi.mocked(prisma.measurementRollup.findMany)
.mockResolvedValueOnce(sparseRollup as never)
.mockResolvedValueOnce(convergedRollup as never);
// Coverage probe — live `measurements` has 28 distinct days.
vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([
{ days: BigInt(28) },
] as never);
const res = await GET(
getRequest(
"type=BLOOD_PRESSURE_SYS&from=2026-04-30T00:00:00Z&to=2026-05-30T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
),
);
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: { measurements: Array<unknown> };
};
// The inline fold fired exactly once with the (type, DAY, window)
// bound so the partition write stays small.
expect(recomputeUserRollups).toHaveBeenCalledTimes(1);
expect(recomputeUserRollups).toHaveBeenCalledWith(
"user-1",
expect.objectContaining({
types: ["BLOOD_PRESSURE_SYS"],
granularities: ["DAY"],
}),
);
// The route re-read the rollup after the fold so the response
// carries the converged 28 buckets — well above the chart's
// `< 3 daily points` empty-state threshold.
expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(2);
expect(json.data.measurements.length).toBe(28);
});
it("source=rollup skips the coverage probe on a covered window (v1.4.39.2 hot-path)", async () => {
// Happy case: 30-day window, 28 rollup rows present. The route
// must short-circuit on the first read and skip the probe so the
// covered-tenant hot path stays a single indexed read.
const buckets = Array.from({ length: 28 }, (_, i) => ({
type: "WEIGHT",
bucketStart: new Date(Date.UTC(2026, 3, 16 + i, 0, 0, 0)),
mean: 81 + i * 0.05,
count: 2,
sumValue: null,
source: "APPLE_HEALTH",
}));
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
buckets as never,
);
const res = await GET(
getRequest(
"type=WEIGHT&from=2026-04-15T00:00:00Z&to=2026-05-15T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
),
);
expect(res.status).toBe(200);
expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(1);
expect(recomputeUserRollups).not.toHaveBeenCalled();
expect(prisma.$queryRaw).not.toHaveBeenCalled();
});
it("source=rollup skips the coverage fallback on short windows (v1.4.39.2)", async () => {
// A 5-day window with 2 rollup rows is plausibly genuine sparsity
// for a user who measures BP every other day. The coverage
// fallback gates on `windowDays >= 7` so the probe + inline fold
// never fire on these short windows.
const sparseRollup = [
{
type: "BLOOD_PRESSURE_SYS",
bucketStart: new Date(Date.UTC(2026, 4, 1, 0, 0, 0)),
mean: 128,
count: 1,
sumValue: null,
source: "APPLE_HEALTH",
},
{
type: "BLOOD_PRESSURE_SYS",
bucketStart: new Date(Date.UTC(2026, 4, 3, 0, 0, 0)),
mean: 132,
count: 1,
sumValue: null,
source: "APPLE_HEALTH",
},
];
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
sparseRollup as never,
);
const res = await GET(
getRequest(
"type=BLOOD_PRESSURE_SYS&from=2026-05-01T00:00:00Z&to=2026-05-06T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
),
);
expect(res.status).toBe(200);
expect(recomputeUserRollups).not.toHaveBeenCalled();
expect(prisma.$queryRaw).not.toHaveBeenCalled();
const json = (await res.json()) as {
data: { measurements: Array<unknown> };
};
expect(json.data.measurements.length).toBe(2);
});
it("source=rollup keeps the sparse rollup when live `measurements` agrees (v1.4.39.2)", async () => {
// Genuine sparsity: the user measured BP twice in 30 days. The
// probe finds the same 2 distinct days in `measurements` so the
// route returns the 2 rollup rows without firing the inline
// fold. The chart's `< 3 daily points` empty-state is the right
// surface for this case — the data really is sparse.
const sparseRollup = [
{
type: "BLOOD_PRESSURE_SYS",
bucketStart: new Date(Date.UTC(2026, 4, 1, 0, 0, 0)),
mean: 128,
count: 1,
sumValue: null,
source: "APPLE_HEALTH",
},
{
type: "BLOOD_PRESSURE_SYS",
bucketStart: new Date(Date.UTC(2026, 4, 10, 0, 0, 0)),
mean: 132,
count: 1,
sumValue: null,
source: "APPLE_HEALTH",
},
];
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
sparseRollup as never,
);
// Probe agrees — 2 distinct days in `measurements` too.
vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([
{ days: BigInt(2) },
] as never);
const res = await GET(
getRequest(
"type=BLOOD_PRESSURE_SYS&from=2026-04-30T00:00:00Z&to=2026-05-30T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
),
);
expect(res.status).toBe(200);
// Probe fired (suspicious-low rollup) but the inline fold did
// not (live and rollup agree).
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
expect(recomputeUserRollups).not.toHaveBeenCalled();
const json = (await res.json()) as {
data: { measurements: Array<unknown> };
};
expect(json.data.measurements.length).toBe(2);
expect(prisma.measurementRollup.findMany).not.toHaveBeenCalled();
// The zone travels as a bound parameter of the fold.
const call = vi.mocked(prisma.$queryRaw).mock.calls[0] as unknown[];
expect(call.slice(1)).toContain("Asia/Kolkata");
// Local midnight of 10 May in Kolkata; a SUM carries no spread band.
expect(json.data.measurements[0]).toMatchObject({
measuredAt: "2026-05-09T18:30:00.000Z",
value: 8123,
});
expect(json.data.measurements[0].minValue).toBeUndefined();
});
it("returns a weekly series when the all-time window is under two years", async () => {
+27 -115
View File
@@ -45,20 +45,21 @@ import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
import { runSafetyFloorCheck } from "@/lib/illness/safety-floor-check";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { readDailySeries } from "@/lib/measurements/daily-series-read";
import {
readDailySeries,
readLiveBuckets,
} from "@/lib/measurements/daily-series-read";
import {
reconstructSleepSessions,
pickMainNightAndNaps,
} from "@/lib/analytics/sleep-night";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql";
import { NextRequest } from "next/server";
import type {
MeasurementType,
MeasurementSource,
GlucoseContext,
} from "@/generated/prisma/client";
import { Prisma } from "@/generated/prisma/client";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "measurements");
@@ -393,36 +394,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
// The route's response is bounded by the `BUCKET_CAP` ceiling per
// grain (monthly: 24, weekly: 105, daily: 365) so a multi-decade
// account never paints an unbounded series.
// v1.4.36 W1 — rollup-table read for daily aggregates.
//
// The Insights trends row fires three parallel
// `GET /api/measurements?type=…&aggregate=daily&limit=5000` requests
// (BP_SYS / BP_DIA / WEIGHT). The legacy `date_trunc('day', …)`
// path scanned the measurements table each time — a full year window
// on the maintainer's account hit 3 × ~3 s on the v1.4.35 HAR. Reading from
// the persistent `measurement_rollups` DAY buckets drops each call
// to a small indexed read against the ~5 k-row rollup table.
//
// Gating:
// - `source=rollup` is an explicit client opt-in (`@/lib/validations/measurement`
// constrains the enum to `["rollup"]`).
// - We require `aggregate=daily` because the rollup populator only
// keeps DAY buckets on the synchronous write hook; the WEEK /
// MONTH paths still depend on the async pg-boss recomputes which
// may lag behind a recent write.
// - `type` is mandatory so the response shape mirrors the per-type
// ask the chart code makes.
// - When the rollup window returns zero rows we fall through to
// the live `date_trunc` path so a brand-new account whose
// populator hasn't caught up still sees a correct chart on its
// first render.
// `source=rollup` + `aggregate=daily` — the chart's series read. Windows up
// to the DAY cap fold live into the user's local days (#1026); wider
// windows step up to the WEEK / MONTH / YEAR rollup tier. `type` is
// mandatory so the response mirrors the per-type ask the chart makes.
if (source === "rollup" && aggregate === "daily" && type && from && to) {
// v1.18.6 — the rollup-daily read (per-source collapse + coverage
// probe + inline fold + live-SQL fallback) moved to the shared
// `readDailySeries` reader so the batched series endpoint
// (`/api/measurements/series-batch`) reads through the SAME path
// instead of duplicating this SQL. Behaviour is unchanged: the rows
// returned here are byte-identical with the pre-extraction branch.
// Shared with the batched series endpoint, so both read the same rows.
const cap = Math.min(limit, BUCKET_CAP.daily);
const priorityJson = await loadUserSourcePriority(user.id);
const measurements = await readDailySeries({
@@ -444,94 +421,29 @@ export const GET = apiHandler(async (request: NextRequest) => {
},
});
}
// `readDailySeries` already falls back to the live `date_trunc`
// aggregate on a rollup-coverage miss, so an empty result here means
// the window genuinely holds no rows. Fall through to the shared
// live aggregate branch below to keep the response shape identical
// for the truly-empty case.
// An empty result means the window genuinely holds no rows. Fall
// through to the shared live aggregate branch below to keep the
// response shape identical for the truly-empty case.
}
if (aggregate && aggregate !== "raw" && from && to) {
const grain: AggregateGrain = aggregate;
const grain: Exclude<AggregateGrain, "raw"> = aggregate;
const cap = Math.min(limit, BUCKET_CAP[grain]);
// Postgres `date_trunc` requires the unit argument to be a SQL
// literal — it cannot be supplied as a prepared-statement parameter.
// The previous `${truncUnit}` (bound) interpolation 500'd in
// production for every grain; the grain string is restricted to
// the `AggregateGrain` enum upstream via the Zod schema, so
// injecting the mapped unit via `Prisma.raw` is safe. The mapping
// also fixes the "weekly"/"monthly" passthrough — Postgres expects
// the singular forms `week`/`month`.
const TRUNC_UNIT: Record<Exclude<AggregateGrain, "raw">, string> = {
daily: "day",
weekly: "week",
monthly: "month",
};
const truncUnit = TRUNC_UNIT[grain];
const truncUnitLiteral = Prisma.raw(`'${truncUnit}'`);
// Cumulative HK types (steps, active energy, flights, distance,
// daylight) are partial-day increments — averaging across the day
// understates the daily total by the per-bucket sample count.
// SUM for those; AVG for spot metrics (BP, weight, pulse, BG,
// body fat, mood, sleep). See R-A finding 2.
const useSum =
type != null && CUMULATIVE_HK_TYPES.has(type as MeasurementType);
const aggregator = useSum
? Prisma.raw(`SUM(m."value")::double precision`)
: Prisma.raw(`AVG(m."value")::double precision`);
// v1.11.1 — collapse overlapping sources to the ladder-canonical reading
// per (type, bucket) before aggregating, so this cold/uncovered fallback
// agrees with the warm rollup path. `canon` picks the winning source for
// each (type, bucket) via the per-user rank; the join keeps only its rows.
// Buckets are cut at the user's own calendar boundaries (#1026), the
// same day the raw list and the chart's short range use. Cumulative
// types SUM per bucket, every other type averages; overlapping sources
// collapse to the ladder-canonical one first.
const priorityJson = await loadUserSourcePriority(user.id);
const rankRaw = Prisma.raw(
buildSourceRankCase(priorityJson, 'm."type"', 'm."source"'),
);
const typeFilter = type
? Prisma.sql`AND m."type" = ${type}::measurement_type`
: Prisma.empty;
const buckets = await prisma.$queryRaw<
Array<{ type: string; bucket_start: Date; avg: number; cnt: number }>
>`
WITH canon AS (
SELECT DISTINCT ON (m."type", date_trunc(${truncUnitLiteral}, m."measured_at"))
m."type" AS t,
date_trunc(${truncUnitLiteral}, m."measured_at") AS d,
m."source" AS canon
FROM measurements m
WHERE m."user_id" = ${user.id}
AND m."measured_at" >= ${from}
AND m."measured_at" <= ${to}
AND m."deleted_at" IS NULL
${typeFilter}
ORDER BY m."type", date_trunc(${truncUnitLiteral}, m."measured_at"), (${rankRaw}), m."source"
)
SELECT
m."type"::text AS type,
date_trunc(${truncUnitLiteral}, m."measured_at") AS bucket_start,
${aggregator} AS avg,
COUNT(*)::int AS cnt
FROM measurements m
JOIN canon c
ON c.t = m."type"
AND c.d = date_trunc(${truncUnitLiteral}, m."measured_at")
AND c.canon = m."source"
WHERE m."user_id" = ${user.id}
AND m."measured_at" >= ${from}
AND m."measured_at" <= ${to}
AND m."deleted_at" IS NULL
${typeFilter}
GROUP BY m."type", bucket_start
ORDER BY bucket_start ASC
LIMIT ${cap}
`;
const measurements = buckets.map((b) => ({
type: b.type,
value: Number(b.avg),
measuredAt: b.bucket_start.toISOString(),
count: Number(b.cnt),
}));
const measurements = await readLiveBuckets({
userId: user.id,
type: (type as MeasurementType | undefined) ?? null,
from,
to,
cap,
priorityJson,
grain,
timeZone: await resolveUserTimezone(user.id),
});
annotate({
action: { name: "measurement.list" },
meta: { total: measurements.length, type, aggregate: grain },
@@ -264,3 +264,165 @@ describe("GET /api/medications/[id]/dose-history", () => {
expect(res.status).toBe(422);
});
});
/**
* Issue #1028 — a dose logged for earlier today on a medication created later
* the same day vanished from the trailing-window ledger while the full
* history (which has no window) showed it. The window floor was clamped to
* the medication's creation instant and the same clamped floor filtered the
* intakes, so any dose recorded with a time before the moment the medication
* was added fell out of "Last 90 days". The creation floor exists to keep
* the ledger from minting phantom slots before the medication existed; it
* says nothing about which recorded doses belong in the window.
*
* Pinned in a positive and a negative UTC offset so a zone-dependent floor
* cannot hide behind one of them.
*/
describe("GET /api/medications/[id]/dose-history — intakes recorded before creation (#1028)", () => {
for (const tz of ["Asia/Kolkata", "America/Los_Angeles"]) {
it(`keeps a same-day dose logged before the medication was added (${tz})`, async () => {
const today = new Date("2026-09-22T12:00:00Z");
const now = localHmAsUtc(today, tz, 16, 30);
const createdAt = localHmAsUtc(today, tz, 16, 20);
const takenAt = localHmAsUtc(today, tz, 14, 0);
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(now);
try {
vi.mocked(getSession).mockResolvedValue({
...SESSION_OK,
user: { ...SESSION_OK.user, timezone: tz },
} as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt,
scheduleRevisions: [],
schedules: [
{
id: "sched-1",
windowStart: "08:00",
windowEnd: "08:00",
daysOfWeek: null,
timesOfDay: ["08:00"],
reminderGraceMinutes: null,
rrule: null,
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
},
],
} as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
{
id: "evt-earlier-today",
scheduledFor: takenAt,
takenAt,
skipped: false,
autoMissed: false,
attributionSource: "AUTO",
doseTaken: null,
source: "WEB",
},
] as never);
// Exactly what the Verlauf tab sends: the trailing 90 days to now.
const from = new Date(now.getTime() - 90 * 86_400_000).toISOString();
const to = now.toISOString();
const res = await GET(
getReq(
`?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`,
),
ROUTE_PARAMS,
);
expect(res.status).toBe(200);
const json = await res.json();
const ids = (
json.data.rows as Array<{ intake: { id: string | null } | null }>
).map((r) => r.intake?.id);
expect(ids).toContain("evt-earlier-today");
// The response reports the window the recorded doses were read
// over: the requested start, not the medication's creation.
expect(json.data.from).toBe(from);
} finally {
vi.useRealTimers();
}
});
}
});
describe("GET /api/medications/[id]/dose-history — early take (#1028 class)", () => {
it("keeps a dose taken inside its window before the slot's own time", async () => {
const tz = "Asia/Kolkata";
const today = new Date("2026-09-22T12:00:00Z");
const now = localHmAsUtc(today, tz, 19, 30);
const slot = localHmAsUtc(today, tz, 20, 0);
const takenAt = localHmAsUtc(today, tz, 19, 15);
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(now);
try {
vi.mocked(getSession).mockResolvedValue({
...SESSION_OK,
user: { ...SESSION_OK.user, timezone: tz },
} as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date("2026-01-01T00:00:00Z"),
scheduleRevisions: [],
schedules: [
{
id: "sched-1",
windowStart: "20:00",
windowEnd: "20:00",
daysOfWeek: null,
timesOfDay: ["20:00"],
reminderGraceMinutes: null,
rrule: null,
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
},
],
} as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
{
id: "evt-early",
scheduledFor: slot,
takenAt,
skipped: false,
autoMissed: false,
attributionSource: "AUTO",
doseTaken: null,
source: "WEB",
},
] as never);
const from = new Date(now.getTime() - 90 * 86_400_000).toISOString();
const res = await GET(
getReq(`?from=${encodeURIComponent(from)}`),
ROUTE_PARAMS,
);
const json = await res.json();
const rows = json.data.rows as Array<{
status: string;
intake: { id: string | null } | null;
}>;
// `to` reports how far recorded doses were read: up to the early
// dose's slot, past the request's own now.
expect(json.data.to).toBe(slot.toISOString());
const early = rows.find((r) => r.intake?.id === "evt-early");
expect(early?.status).toBe("taken_on_time");
// The read horizon stretches only to the recorded anchor: no later
// slot is minted as upcoming.
expect(rows.filter((r) => r.status === "upcoming")).toHaveLength(0);
} finally {
vi.useRealTimers();
}
});
});
@@ -142,12 +142,18 @@ export const GET = apiHandler(
const requestedFrom =
parsed.data.from ?? new Date(to.getTime() - 90 * DAY_MS);
const spanFloor = new Date(to.getTime() - MAX_WINDOW_DAYS * DAY_MS);
// The window the caller asked for, capped only by the span limit. This is
// the floor for recorded doses: a dose logged with a time before the
// medication was added (created at 16:20, "taken at 14:00") is a real
// record and belongs in the window, exactly as the unwindowed full
// history shows it (#1028).
const intakeFrom = new Date(
Math.max(requestedFrom.getTime(), spanFloor.getTime()),
);
// The floor for minted slots additionally stops at the medication's
// creation, so pre-existence days never mint phantom missed slots.
const from = new Date(
Math.max(
requestedFrom.getTime(),
medication.createdAt.getTime(),
spanFloor.getTime(),
),
Math.max(intakeFrom.getTime(), medication.createdAt.getTime()),
);
const events = await prisma.medicationIntakeEvent.findMany({
@@ -182,6 +188,23 @@ export const GET = apiHandler(
const lastIntakeAt = lastNonSkippedTakenAt(mapped);
// A dose may be recorded before its slot's own time: the dose window opens
// up to two hours early, and a skip can be logged ahead. Such a row is
// stored on the slot's anchor, which still lies after `to`, so a window
// that ends at `to` dropped a dose the user had just recorded until the
// slot's clock time passed (#1028). Extend the read horizon to the latest
// anchor of a recorded row (taken or skipped) within a day of `to`, and no
// further: pending reminder rows record nothing and never stretch it.
const horizon = new Date(
mapped.reduce((latest, e) => {
const t = e.scheduledFor.getTime();
const recorded = e.skipped || (e.takenAt !== null && e.takenAt <= to);
return recorded && t > latest && t <= to.getTime() + DAY_MS
? t
: latest;
}, to.getTime()),
);
const bandMedication: BandMinterMedication = {
id: medication.id,
startsOn: medication.startsOn,
@@ -223,7 +246,7 @@ export const GET = apiHandler(
revisions: medication.scheduleRevisions ?? [],
ctx,
userTz,
range: { from, to },
range: { from, to: horizon },
now,
intakeInstants,
});
@@ -241,7 +264,7 @@ export const GET = apiHandler(
// partition, so filter on the stored anchor (a take snapped to an
// out-of-window slot is excluded, matching the compliance read).
const historyIntakes: HistoryIntake[] = mapped
.filter((e) => e.scheduledFor >= from && e.scheduledFor <= to)
.filter((e) => e.scheduledFor >= intakeFrom && e.scheduledFor <= horizon)
.map((e) => ({
id: e.id,
scheduledFor: e.scheduledFor,
@@ -290,9 +313,13 @@ export const GET = apiHandler(
meta: { row_count: serialized.length, family },
});
// The window the recorded doses were read over (#1028): from the
// requested start (slots additionally start at the medication's
// creation) to the read horizon, which reaches past `to` only for a dose
// recorded ahead of its slot.
return apiSuccess({
from: from.toISOString(),
to: to.toISOString(),
from: intakeFrom.toISOString(),
to: horizon.toISOString(),
family,
hasExpectedSlots: bands.length > 0,
rows: serialized,
@@ -22,9 +22,11 @@ vi.mock("@/lib/api-handler", async () => {
};
});
vi.mock("@/lib/feature-flags", () => ({
requireAssistantSurface: vi.fn(async () => undefined),
AssistantDisabledError: class extends Error {},
vi.mock("@/lib/ai/capabilities/gate", () => ({
requireAiCapability: vi.fn(),
}));
vi.mock("@/lib/ai/capabilities/egress", () => ({
assertAiEgress: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
@@ -53,14 +55,6 @@ vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain: vi.fn(async () => []),
}));
// v1.12.1 — free-text medication extraction egresses PHI to the operator's
// server-managed key, so it now passes through the consent gate. Mock it to a
// no-op here; the gate's own fail-closed behaviour is covered by
// `consent-guard.test.ts`.
vi.mock("@/lib/ai/consent-guard", () => ({
assertConsentForChain: vi.fn(async () => undefined),
}));
vi.mock("@/lib/ai/provider-runner", async () => {
const actual = await vi.importActual<
typeof import("@/lib/ai/provider-runner")
@@ -93,7 +87,9 @@ import {
runRawCompletionWithFallback,
AllProvidersFailedError,
} from "@/lib/ai/provider-runner";
import { assertConsentForChain } from "@/lib/ai/consent-guard";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { assertAiEgress } from "@/lib/ai/capabilities/egress";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/medications/extract", {
@@ -110,6 +106,12 @@ const AUTH_OK = {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireAiCapability).mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
});
vi.mocked(assertAiEgress).mockResolvedValue(undefined);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
@@ -208,10 +210,15 @@ describe("POST /api/medications/extract — happy path", () => {
doseUnit: "mg",
cadenceKind: "everyNWeeks",
});
// The PHI free-text egress is gated on an active consent receipt for the
// coach surface before the provider chain runs.
expect(assertConsentForChain).toHaveBeenCalledWith(
expect.objectContaining({ surface: "coach" }),
// The PHI free-text egress is re-checked against the capability for the
// whole chain the runner may cascade through, before it runs.
expect(assertAiEgress).toHaveBeenCalledWith(
"medicationExtract",
"user-1",
expect.any(Array),
);
expect(vi.mocked(assertAiEgress).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(runRawCompletionWithFallback).mock.invocationCallOrder[0]!,
);
});
@@ -300,6 +307,8 @@ describe("POST /api/medications/extract — daily budget", () => {
expect(res.status).toBe(429);
const body = await res.json();
expect(body.error).toMatch(/budget/i);
// The code a client branches on, the same string the body always carried.
expect(body.meta).toEqual({ errorCode: "coach.budget.exceeded" });
// The refusal happens BEFORE any upstream spend.
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
});
@@ -374,8 +383,19 @@ describe("POST /api/medications/extract — validation + provider errors", () =>
type: "none",
} as never);
const res = await POST(postReq({ text: "Mounjaro 5mg weekly" }) as never);
expect(res.status).toBe(503);
// The status stays 503 (a shipped client may branch on it) and gains the
// code that says what it means.
const error = await POST(
postReq({ text: "Mounjaro 5mg weekly" }) as never,
).catch((e) => e);
expect(error).toBeInstanceOf(AiUnavailableError);
expect(error.status).toBe(503);
expect(error.meta).toEqual({
errorCode: "ai.provider.none",
capability: "medicationExtract",
reason: "no_provider",
});
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
});
it("returns 502 when the provider returns unparseable JSON", async () => {
@@ -395,3 +415,39 @@ describe("POST /api/medications/extract — validation + provider errors", () =>
expect(res.status).toBe(502);
});
});
describe("POST /api/medications/extract — the medicationExtract capability", () => {
it("answers under medicationExtract, keeping 503 for no provider", async () => {
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
await POST(postReq({ text: "5mg weekly" }) as never).catch(() => null);
expect(requireAiCapability).toHaveBeenCalledWith("medicationExtract", {
noProvider: { errorCode: "ai.provider.none", status: 503 },
});
});
it("refuses before the body is read when the operator turned it off", async () => {
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
vi.mocked(requireAiCapability).mockRejectedValueOnce(
new AiUnavailableError("medicationExtract", "operator_disabled"),
);
const error = await POST(postReq({ text: "5mg weekly" }) as never).catch(
(e) => e,
);
expect(error.meta.errorCode).toBe("assistant.disabled.documentAi");
expect(checkRateLimit).not.toHaveBeenCalled();
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
});
it("stops at the wire without reserving budget or calling a provider", async () => {
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
vi.mocked(assertAiEgress).mockRejectedValueOnce(
new AiUnavailableError("medicationExtract", "consent_required"),
);
const error = await POST(postReq({ text: "5mg weekly" }) as never).catch(
(e) => e,
);
expect(error.meta.errorCode).toBe("consent.ai.required");
expect(reserveBudget).not.toHaveBeenCalled();
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
});
});
+53 -14
View File
@@ -24,9 +24,16 @@
* Behaviour:
*
* 1. requireAuth() — cookie OR Bearer.
* 2. requireAssistantSurface("coach") — operator can disable.
* 2. requireAiCapability("medicationExtract") — the operator's "Reading
* documents" switch (and the master), provider-work authority for the
* record, a configured provider (503 `ai.provider.none` otherwise; the
* status predates the envelope and stays until no shipped client reads
* it), and a document-class consent receipt: free-text medication input
* is health data, and any provider that leaves the machine needs one.
* 3. checkRateLimit(...) — 10 requests / 5 min / user.
* 4. resolveProviderChain() — fall back through providers.
* 4. resolveProviderChain() — fall back through providers,
* then `assertAiEgress` re-checks the capability for exactly that chain
* immediately before anything is sent.
* 5. reserveBudget(resolveDailyCap(chain)) — atomic daily token ceiling,
* capped against the COST OWNER: a self-hoster on their own key or a
* local model is measured against the user-plan ceiling, not the
@@ -56,7 +63,13 @@ import {
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { assertAiEgress } from "@/lib/ai/capabilities/egress";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import {
AiUnavailableError,
type NoProviderRefusal,
} from "@/lib/ai/capabilities/refusal";
import { AI_PROVIDER_NONE_ERROR_CODE } from "@/lib/ai/capabilities/types";
import {
AllProvidersFailedError,
@@ -64,7 +77,6 @@ import {
} from "@/lib/ai/provider-runner";
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
import { singleUserTurn } from "@/lib/ai/types";
import { assertConsentForChain } from "@/lib/ai/consent-guard";
import {
buildDateKey,
@@ -80,6 +92,19 @@ import {
type MedicationExtractionResult,
} from "@/lib/ai/coach/medication-extract-prompt";
/**
* How the extractor refuses with no provider. 503 predates the capability
* envelope; it stays until no shipped client is known to branch on it, and the
* code says what it means either way.
*/
const MEDICATION_EXTRACT_NO_PROVIDER: NoProviderRefusal = {
errorCode: AI_PROVIDER_NONE_ERROR_CODE,
status: 503,
};
/** The budget refusal's code; the same string the body always carried. */
const MEDICATION_EXTRACT_BUDGET_CODE = "coach.budget.exceeded";
/** Hard cap on the free-text payload; mirrors `coachChatRequestSchema.message`. */
const MAX_TEXT_LENGTH = 2000;
@@ -128,10 +153,12 @@ function parseModelJson(content: string): unknown | null {
async function handleExtract(request: NextRequest): Promise<Response> {
const auth = await requireAuth();
// Same operator-disable gate the Coach SSE route honours — when
// "coach" is off, the overlay also disappears so the wizard simply
// falls back to the manual path.
await requireAssistantSurface("coach");
// Medication text extraction is its own capability under the operator's
// "Reading documents" switch. When it is closed the wizard keeps its
// manual path; nothing here is data the wizard needs.
await requireAiCapability("medicationExtract", {
noProvider: MEDICATION_EXTRACT_NO_PROVIDER,
});
const userId = auth.user.id;
@@ -178,15 +205,25 @@ async function handleExtract(request: NextRequest): Promise<Response> {
const legacy = await resolveProvider(userId);
if (legacy.type === "none") {
annotate({ action: { name: "medications.extract.no-provider" } });
return apiError("No AI provider configured", 503);
throw new AiUnavailableError(
"medicationExtract",
"no_provider",
null,
MEDICATION_EXTRACT_NO_PROVIDER,
);
}
chain.push({ providerType: "admin-openai", instance: legacy });
}
// Free-text medication input is PHI. If the resolved chain could egress
// via the operator's server-managed key, an active consent receipt is
// required first — same gate as the Coach (which shares this chain).
await assertConsentForChain({ userId, chain, surface: "coach" });
// The wire. The runner may cascade through every entry, so each one is
// named: any entry that leaves the machine needs a document-class consent
// receipt, and a switch turned off since the gate above answered stops the
// call here.
await assertAiEgress(
"medicationExtract",
userId,
chain.map((entry) => entry.providerType),
);
const today = todayOverride ?? buildDateKey();
const { systemPrompt, userPrompt } = buildMedicationExtractionPrompt({
@@ -219,7 +256,9 @@ async function handleExtract(request: NextRequest): Promise<Response> {
action: { name: "medications.extract.budget-exceeded" },
meta: { totalAfter: reservation.totalAfter },
});
return apiError("coach.budget.exceeded", 429);
return apiError(MEDICATION_EXTRACT_BUDGET_CODE, 429, {
errorCode: MEDICATION_EXTRACT_BUDGET_CODE,
});
}
let completion;
@@ -93,9 +93,10 @@ describe("<HealthChart> — bounded range fetch", () => {
expect(measurementsUrl!).toMatch(/aggregate=daily/);
});
it("omits aggregate=daily on windows of 7 days or fewer", async () => {
// v1.4.29 C3 — short windows keep raw fetching so the user can
// see hour-by-hour detail on the 7-day view.
it("asks for daily rows on the 7-day window too", async () => {
// The chart folds every range into days. The 7-day window used to read
// raw rows oldest-first under the 5000-row cap, which on a per-minute
// stream cut off the newest half of the week.
const { I18nProvider } = await import("@/lib/i18n/context");
const { HealthChart } = await import("../health-chart");
@@ -116,7 +117,7 @@ describe("<HealthChart> — bounded range fetch", () => {
u.includes("/api/measurements"),
);
expect(measurementsUrl).toBeDefined();
expect(measurementsUrl!).not.toMatch(/aggregate=daily/);
expect(measurementsUrl!).toMatch(/aggregate=daily/);
});
it("re-keys the query when the range window changes", async () => {
@@ -0,0 +1,53 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* A chart mount that passes no `userTimezone` must still cut days in the
* signed-in user's zone. Several mounts (the medications page, the coach
* chat, the recovery section) pass none, and the component used to fall
* back to a fixed Europe/Berlin, so a user in Asia/Kolkata saw those charts
* bucketed and labelled in Berlin days while every other chart used their
* own. The zone now comes from the account, which the server resolves.
*/
let lastKey: unknown[] | undefined;
vi.mock("@tanstack/react-query", () => ({
keepPreviousData: (previous: unknown) => previous,
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
lastKey = queryKey;
return { data: [], isLoading: false };
},
useQueryClient: () => ({
cancelQueries: () => Promise.resolve(),
getQueryData: () => undefined,
setQueryData: () => undefined,
invalidateQueries: () => Promise.resolve(),
}),
useMutation: () => ({ mutate: () => undefined, isPending: false }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
isAuthenticated: true,
user: { id: "u1", timezone: "Asia/Kolkata" },
isLoading: false,
}),
}));
describe("<HealthChart> timezone", () => {
it("uses the account's zone when the mount passes none", async () => {
const { I18nProvider } = await import("@/lib/i18n/context");
const { HealthChart } = await import("../health-chart");
renderToStaticMarkup(
<I18nProvider initialLocale="en">
<HealthChart types={["ACTIVITY_STEPS"]} title="Steps" unit="" />
</I18nProvider>,
);
expect(lastKey).toBeDefined();
expect(lastKey).toContain("Asia/Kolkata");
expect(lastKey).not.toContain("Europe/Berlin");
});
});
@@ -0,0 +1,27 @@
/**
* Query string for one type's chart series (`GET /api/measurements`).
*
* Every range asks for one row per local calendar day. The 7-day range used
* to ask for raw rows oldest-first under a 5 000-row limit; a per-minute
* heart-rate stream holds 10 080 rows in seven days, so the answer stopped
* half-way through the week and the newest days were silently missing. The
* chart folds rows into days either way, so the daily answer draws the same
* points while its size depends only on the number of days.
*
* Pure so the request the chart makes can be tested against the real route.
*/
export function chartSeriesParams(
type: string,
window: { from: string; to: string },
): URLSearchParams {
const params = new URLSearchParams();
params.set("type", type);
params.set("sortBy", "measuredAt");
params.set("sortDir", "asc");
params.set("from", window.from);
params.set("to", window.to);
params.set("limit", "5000");
params.set("aggregate", "daily");
params.set("source", "rollup");
return params;
}
+27 -55
View File
@@ -1,5 +1,7 @@
"use client";
import { chartSeriesParams } from "@/components/charts/chart-series-request";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
@@ -213,10 +215,11 @@ interface HealthChartProps {
/**
* v1.4.25 W7b — per-user display timezone. When passed (mount sites
* thread `useAuth().user?.timezone`), x-axis tick labels and the
* per-day bucket keys both render in the user's zone instead of the
* legacy Europe/Berlin pin. Defaults to "Europe/Berlin" so older
* callers that haven't yet adopted the prop keep their previous
* behaviour bit-for-bit.
* per-day bucket keys both render in the user's zone. When a mount
* passes none, the chart uses the signed-in account's zone (which
* `/api/auth/me` reports already resolved against the server default),
* and only before that has loaded the shared `DEFAULT_TIMEZONE` the
* server itself falls back to.
*/
userTimezone?: string;
/**
@@ -327,8 +330,8 @@ interface MeasurementApiRow {
value: number;
// v1.8.5 — per-day min / max emitted by the rollup daily-aggregate
// path (non-cumulative metrics only). Drive the Apple-Health-style
// range band shaded around the mean line. Absent on the raw-row path
// (windows ≤ 7 days) and on cumulative metrics.
// range band shaded around the mean line. Not drawn on the 7-day range
// and absent on cumulative metrics.
// v1.18.6 — `null` accepted alongside `undefined` so the batched
// series endpoint's row shape (which carries `number | null`) feeds
// `preloadedSeries` directly. The bucketing loop already guards each
@@ -340,8 +343,7 @@ interface MeasurementApiRow {
// the underlying raw-row count per bucket. Used downstream to gate
// the "more days needed" empty-state copy on the actual measurement
// count rather than the number of distinct calendar days. Optional
// because the short-window raw-row path does not populate it (each
// row already represents one measurement, count = 1).
// because the per-night sleep series does not carry it.
count?: number;
}
@@ -614,7 +616,7 @@ export function HealthChart({
chartKey,
annotations,
verticalMarkers,
userTimezone = "Europe/Berlin",
userTimezone: userTimezoneProp,
valueScale = 1,
valueOffset = 0,
onVisibleStats,
@@ -625,6 +627,9 @@ export function HealthChart({
showDataTable = false,
}: HealthChartProps) {
const { isAuthenticated, user } = useAuth();
// A mount without the prop used to pin Europe/Berlin, so those charts
// cut days in Berlin for every user whatever their profile said.
const userTimezone = userTimezoneProp || user?.timezone || DEFAULT_TIMEZONE;
const { t, locale } = useTranslations();
const fmt = useFormatters();
// Issue #490 — x-axis tick + tooltip date labels. Every timestamp this
@@ -764,12 +769,11 @@ export function HealthChart({
};
}, [rangePoints, effectiveCompareBaseline]);
// v1.18.6 — honour the dashboard's batched slice ONLY for the
// daily-aggregate window (`windowDays > 7`, the rollup path) and ONLY
// when EVERY non-sleep type the chart renders is present in the slice.
// SLEEP_DURATION is never batched (per-night `/series`), so a chart
// that includes it always self-fetches. A range-tab change to a ≤7-day
// window (raw rows) drops batched coverage and self-fetches.
// v1.18.6 — honour the dashboard's batched slice ONLY for windows over
// 7 days and ONLY when EVERY non-sleep type the chart renders is present
// in the slice. SLEEP_DURATION is never batched (per-night `/series`),
// so a chart that includes it always self-fetches. The 7-day tab
// self-fetches its own window.
//
// v1.19.0 — also require that the requested window FITS within the
// batch's actual coverage. The dashboard batches only a ~30-day slice;
@@ -916,41 +920,7 @@ export function HealthChart({
await fetchSleepNights();
return;
}
const typeParams = new URLSearchParams();
typeParams.set("type", type);
typeParams.set("sortBy", "measuredAt");
typeParams.set("sortDir", "asc");
typeParams.set("from", fetchWindow.from);
typeParams.set("to", fetchWindow.to);
typeParams.set("limit", "5000");
// v1.4.29 C3 — windows over 7 days ask the server to bucket
// daily. Caps the chart's per-type payload at ~365 rows
// instead of ~5 000 for high-density types (pulse), and drops
// Recharts paint cost ~50× on continuous-monitoring accounts.
// Short windows keep raw fetching so hour-by-hour detail
// stays visible.
//
// v1.4.36 W1 — also route the daily aggregate through the
// persistent rollup buckets via `source=rollup`. The route
// reads from `measurement_rollups` instead of running a live
// `date_trunc` scan over the raw measurements table; the
// three parallel daily fetches on the Insights trends row
// (BP_SYS / BP_DIA / WEIGHT) drop from ~3 s each to a small
// indexed read against the ~5 k-row rollup table. The server
// falls back to live SQL when the rollup is empty for the
// requested window so brand-new accounts still see a chart
// on their first render.
//
// v1.19.2 — the `aggregate=daily` ask still holds, but for windows
// wider than the DAY bucket cap the server steps the rollup tier
// up to WEEK / MONTH and returns whole-history coverage at that
// tier. Each returned row is one coarse bucket; the daily fold +
// `bucketTimeSeries` below downsample it to the visible range, so
// the request contract is unchanged.
if (fetchWindow.windowDays > 7) {
typeParams.set("aggregate", "daily");
typeParams.set("source", "rollup");
}
const typeParams = chartSeriesParams(type, fetchWindow);
// v1.18.6 — when the dashboard handed this chart a batched slice
// for the type, fold those rows instead of a per-type fetch. The
@@ -1011,8 +981,11 @@ export function HealthChart({
// same scale to the spread so the band tracks the line. The
// BMI view drops the range tuple in its `select` callback (it
// has never rendered the band), so the raw cache always
// carries the spread.
// carries the spread. The 7-day range never drew the band (it
// used to read raw rows, which carry no spread) and still does
// not, so its picture is unchanged now that it reads days too.
if (
fetchWindow.windowDays > 7 &&
typeof measurement.minValue === "number" &&
typeof measurement.maxValue === "number"
) {
@@ -1044,10 +1017,9 @@ export function HealthChart({
for (const [type, stats] of Object.entries(bucket.values)) {
// v1.4.29.1 — cumulative HealthKit types (steps, active energy,
// distance, flights, daylight) must reduce with sum, not the
// per-sample average. The server already does this when the
// chart fetches with `aggregate=daily` (windows over 7 days);
// the 7-day path still pulls raw rows and aggregates here,
// so the same distinction must hold client-side.
// per-sample average. The server already returns one row per
// day; a day that arrives as several rows (the coarser tiers
// of the "All" range) must still add up rather than average.
const isCumulative = CUMULATIVE_HK_TYPES.has(
type as MeasurementType,
);
@@ -20,6 +20,8 @@ function day(date: string, overrides: Partial<CalendarDay> = {}): CalendarDay {
isPredictedOvulation: false,
isPeriodLogged: false,
isCycleStart: false,
cycleDay: null,
periodEndable: false,
flow: null,
hasSymptoms: false,
confidence: 1,
@@ -20,6 +20,8 @@ function dayBase(date: string): CalendarDay {
isPredictedOvulation: false,
isPeriodLogged: false,
isCycleStart: false,
cycleDay: null,
periodEndable: false,
flow: null,
hasSymptoms: false,
confidence: 1,
@@ -0,0 +1,137 @@
/**
* The log sheet speaks about the date it opened on, not about today (#1004).
*
* It used to take its header count and its "period ended" button from the
* verdict, which is the server's answer about TODAY. A January date opened
* in September therefore read "Day 2", and a back-dated period could not be
* closed unless today happened to be a bleeding day. The fixture below makes
* the two sources disagree on purpose: the verdict says day 2 of a period,
* the grid day for the open date says day 20 with no period end possible.
* The sheet must follow the grid.
*/
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { CalendarDay } from "../types";
vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({
canManage: true,
canWriteDomain: () => true,
canManageDomain: () => true,
}),
}));
const TODAY = "2026-07-01";
function gridDay(date: string, over: Partial<CalendarDay> = {}): CalendarDay {
return {
date,
phase: null,
isPredictedPeriod: false,
isFertileWindow: false,
isPredictedOvulation: false,
isPeriodLogged: false,
isCycleStart: false,
cycleDay: null,
periodEndable: false,
flow: null,
hasSymptoms: false,
confidence: 0,
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: null,
...over,
};
}
const calendarQuery = {
data: {
profile: {
goal: "GENERAL_HEALTH",
rawChartMode: false,
predictionEnabled: true,
cyclesObserved: 4,
},
prediction: null,
verdict: {
state: "IN_CYCLE",
dayOfCycle: 2,
cycleLength: 28,
phase: "MENSTRUAL",
spans: [],
cycleStartDate: "2026-06-30",
overdueDays: null,
daysUntilNext: null,
fertileWindow: { start: null, end: null, active: false },
},
stillLearning: false,
days: [gridDay(TODAY, { cycleDay: 20, phase: "LUTEAL" })],
meta: { generatedAt: "2026-07-01T00:00:00Z" },
},
isError: false,
isLoading: false,
refetch: vi.fn(),
};
const idleQuery = {
data: undefined,
isError: false,
isLoading: false,
refetch: vi.fn(),
};
const mutationStub = { mutate: vi.fn(), isPending: false, isError: false };
vi.mock("../use-cycle", () => ({
localYmd: () => TODAY,
useCycleCalendar: () => calendarQuery,
useCycleHistory: () => idleQuery,
useCycleInsights: () => idleQuery,
useCycleProfile: () => idleQuery,
useCycleDayLog: () => idleQuery,
useCustomSymptoms: () => idleQuery,
useLogDay: () => mutationStub,
usePatchDayLog: () => mutationStub,
useStartPeriod: () => mutationStub,
useEndPeriod: () => mutationStub,
useDeleteDayLog: () => mutationStub,
useCreateCustomSymptom: () => mutationStub,
useDeleteCustomSymptom: () => mutationStub,
CUSTOM_SYMPTOM_LIMIT_ERROR_CODE: "cycle.symptom.custom.limit",
CustomSymptomError: class extends Error {},
}));
const sheetProps: Record<string, unknown>[] = [];
vi.mock("../log-day-sheet", async (importActual) => {
const actual = await importActual<typeof import("../log-day-sheet")>();
return {
...actual,
LogDaySheet: (props: Record<string, unknown>) => {
sheetProps.push(props);
return null;
},
};
});
import { I18nProvider } from "@/lib/i18n/context";
import { CycleView } from "../cycle-view";
describe("<CycleView> — the log sheet describes the date it opened on", () => {
it("feeds the sheet the grid day's count and period-end answer, not today's verdict", () => {
sheetProps.length = 0;
renderToStaticMarkup(
<I18nProvider initialLocale="en">
<CycleView />
</I18nProvider>,
);
const props = sheetProps.at(-1);
expect(props).toBeDefined();
expect(props?.date).toBe(TODAY);
expect(props?.dayOfCycle).toBe(20);
expect(props?.phase).toBe("LUTEAL");
expect(props?.periodEndable).toBe(false);
});
});
@@ -8,8 +8,12 @@ import {
intimacyCount,
testsCount,
noteCount,
isBlankDayLog,
dayLogSaveState,
sheetDayContext,
type DayLogFormState,
} from "../log-day-sheet";
import type { CalendarDay } from "../types";
function blank(): DayLogFormState {
return {
@@ -204,3 +208,116 @@ describe("section summary-badge counts (v1.17.0)", () => {
expect(noteCount({ ...blank(), note: "tired today" })).toBe(1);
});
});
describe("saving a day (#1004)", () => {
it("treats a form with nothing picked as blank", () => {
expect(isBlankDayLog(blank())).toBe(true);
// A disturbed-flag or protection toggle alone records nothing: both only
// mean something next to the value they qualify.
expect(isBlankDayLog({ ...blank(), bbtDisturbed: true })).toBe(true);
expect(isBlankDayLog({ ...blank(), protectedSex: true })).toBe(true);
expect(isBlankDayLog({ ...blank(), note: " " })).toBe(true);
});
it("treats any recorded sign as content", () => {
expect(isBlankDayLog({ ...blank(), flow: "LIGHT" })).toBe(false);
expect(isBlankDayLog({ ...blank(), intermenstrual: true })).toBe(false);
expect(isBlankDayLog({ ...blank(), bbt: "36.5" })).toBe(false);
expect(isBlankDayLog({ ...blank(), mucus: "DRY" })).toBe(false);
expect(isBlankDayLog({ ...blank(), intercourse: true })).toBe(false);
expect(isBlankDayLog({ ...blank(), note: "headache" })).toBe(false);
expect(
isBlankDayLog({ ...blank(), symptoms: new Map([["cramps", null]]) }),
).toBe(false);
});
it("does not save an empty new entry, so no empty row lands unseen", () => {
const state = dayLogSaveState({
hydrated: true,
rowId: null,
form: blank(),
});
expect(state.canSave).toBe(false);
expect(state.blankNewEntry).toBe(true);
});
it("still saves an emptied existing entry, because clearing is an edit", () => {
const state = dayLogSaveState({
hydrated: true,
rowId: "row-1",
form: blank(),
});
expect(state.canSave).toBe(true);
expect(state.rowId).toBe("row-1");
});
it("neither saves nor names a row before the date's own entry has loaded", () => {
// The form still holds the previously opened date. A Save here used to
// PATCH that other date's row.
const state = dayLogSaveState({
hydrated: false,
rowId: "row-of-another-date",
form: { ...blank(), flow: "HEAVY" },
});
expect(state.canSave).toBe(false);
expect(state.rowId).toBeNull();
});
});
function gridDay(date: string, over: Partial<CalendarDay> = {}): CalendarDay {
return {
date,
phase: null,
isPredictedPeriod: false,
isFertileWindow: false,
isPredictedOvulation: false,
isPeriodLogged: false,
isCycleStart: false,
cycleDay: null,
periodEndable: false,
flow: null,
hasSymptoms: false,
confidence: 0,
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: null,
...over,
};
}
describe("sheetDayContext (#1004)", () => {
it("describes the selected date from its own grid day", () => {
const ctx = sheetDayContext("2026-01-27", [
gridDay("2026-01-26", { isCycleStart: true, cycleDay: 1 }),
gridDay("2026-01-27", {
cycleDay: 2,
periodEndable: true,
phase: "MENSTRUAL",
}),
]);
expect(ctx).toEqual({
startsCycle: false,
periodEndable: true,
phase: "MENSTRUAL",
dayOfCycle: 2,
});
});
it("makes no claim about a date the grid does not hold", () => {
expect(sheetDayContext("2025-12-01", [gridDay("2026-01-26")])).toEqual({
startsCycle: false,
periodEndable: false,
phase: null,
dayOfCycle: null,
});
});
it("takes the later of two overlapping grid reads, as the calendar does", () => {
const ctx = sheetDayContext("2026-04-01", [
gridDay("2026-04-01", { cycleDay: null }),
gridDay("2026-04-01", { cycleDay: 4, periodEndable: true }),
]);
expect(ctx.dayOfCycle).toBe(4);
expect(ctx.periodEndable).toBe(true);
});
});
@@ -0,0 +1,75 @@
/**
* Selecting a symptom must not move anything on the sheet (#1001 follow-up).
*
* The intensity selector used to appear inline beside a symptom chip in a
* wrapping row, so a selection widened the chip and pushed the chips after it
* along, sometimes out from under the finger reaching for the intensity. A
* symptom is now one row with its intensity beside it, and the intensity's
* space is laid out whether or not it is shown. What the test pins is exactly
* that: the selected and unselected row render the same boxes, the intensity
* group is present in both, and only its visibility and the colours change.
*/
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { Droplet } from "lucide-react";
vi.mock("../use-cycle", () => ({
useDeleteCustomSymptom: () => ({ mutateAsync: vi.fn(), isPending: false }),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { SymptomRow } from "../log-day-sheet";
function render(active: boolean, severity: number | null = null): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<SymptomRow
symptomKey="cramps"
icon={Droplet}
label="Cramps"
active={active}
severity={severity}
onToggle={() => {}}
onSeverity={() => {}}
/>
</I18nProvider>,
);
}
/** The markup with every class and state attribute removed: the box tree. */
function skeleton(html: string): string {
return html
.replace(/ class="[^"]*"/g, "")
.replace(/ (aria-[a-z]+|data-active|disabled|tabindex)(="[^"]*")?/g, "");
}
function severityGroup(html: string): string {
const m = /<div[^>]*data-slot="cycle-symptom-severity"[^>]*>/.exec(html);
if (!m) throw new Error("no severity group rendered");
return m[0];
}
describe("<SymptomRow>", () => {
it("lays out the same boxes whether or not the symptom is selected", () => {
expect(skeleton(render(true, 2))).toBe(skeleton(render(false)));
});
it("reserves the intensity's space while hidden, and reveals it in place", () => {
const idle = severityGroup(render(false));
const picked = severityGroup(render(true));
expect(idle).toContain("invisible");
expect(idle).toContain('aria-hidden="true"');
expect(picked).not.toContain("invisible");
expect(picked).not.toContain("aria-hidden");
});
it("keeps the intensity optional and out of reach until the symptom is picked", () => {
const idle = render(false);
// Four levels exist in both states, none of them focusable while hidden.
expect(idle.match(/aria-label="Severity \d of 4"/g)).toHaveLength(4);
expect(idle.match(/tabindex="-1"/g)).toHaveLength(4);
const picked = render(true);
expect(picked).not.toContain('tabindex="-1"');
expect(picked.match(/aria-pressed="true"/g)).toHaveLength(1);
});
});
+5 -8
View File
@@ -18,7 +18,7 @@ import { cn } from "@/lib/utils";
import { CycleRing } from "./cycle-ring";
import { CycleCalendar } from "./cycle-calendar";
import { CycleHistoryChart } from "./cycle-history-chart";
import { LogDaySheet } from "./log-day-sheet";
import { LogDaySheet, sheetDayContext } from "./log-day-sheet";
import { PhaseEducationCard } from "./phase-education-card";
import { PredictionsPanel } from "./predictions-panel";
import { CyclePhaseHeadline, CyclePhaseCrosstab } from "./cycle-phase-crosstab";
@@ -469,13 +469,10 @@ export function CycleView() {
onOpenChange={setSheetOpen}
date={selectedDate}
today={today}
startsCycle={
calendarDays.find((d) => d.date === selectedDate)?.isCycleStart ??
false
}
activePeriod={verdict?.phase === "MENSTRUAL"}
phase={verdict?.phase ?? null}
dayOfCycle={verdict?.dayOfCycle ?? null}
// Everything the sheet says about the day is about THAT day: its own
// cycle day, its own phase, whether a period end can land on it. The
// verdict answers for today and only ever labelled today correctly.
{...sheetDayContext(selectedDate, calendarDays)}
goal={goal}
predictionEnabled={calProfile?.predictionEnabled ?? false}
rawChartMode={calProfile?.rawChartMode ?? false}
+203 -71
View File
@@ -51,7 +51,7 @@ import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-share
import { FieldInfo } from "./field-info";
import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog";
import { FLOW_HUE, PHASE_HUE } from "./phase-tokens";
import type { CyclePhase, CycleGoal } from "./types";
import type { CalendarDay, CyclePhase, CycleGoal } from "./types";
import {
useCreateCustomSymptom,
useCustomSymptoms,
@@ -88,9 +88,9 @@ import type {
* detailed form posts a single day-log (flow, symptoms, BBT, OPK, mucus,
* intercourse + protection, free-text note). The write is optimistic-ish
* (the mutation invalidates the cycle keys on success so the calendar +
* wheel repaint); the symptom chips reuse the mood-tag chip styling.
* wheel repaint); symptoms list one per line with their intensity beside them.
*
* a11y: every chip is a real `<button>` with `aria-pressed`; the sheet
* a11y: every chip and symptom toggle is a real `<button>` with `aria-pressed`; the sheet
* footer sticky-pins Save/Cancel.
*/
@@ -311,6 +311,89 @@ export function noteCount(s: DayLogFormState): number {
return s.note.trim() ? 1 : 0;
}
/**
* Whether the form holds nothing a new day-log would record.
*
* Saving such a form used to write an empty row: the request succeeded, the
* sheet closed, and the calendar had nothing to draw, so a person who picked
* a past date and pressed Save to mark it saw the save "do nothing". A new
* entry now needs at least one thing to store. An existing row keeps Save
* enabled, because emptying it is a real edit.
*/
export function isBlankDayLog(s: DayLogFormState): boolean {
return (
s.flow === null &&
!s.intermenstrual &&
resolveBbt(s.bbt) === null &&
s.opk === null &&
s.mucus === null &&
s.cervixPosition === null &&
s.cervixFirmness === null &&
s.cervixOpening === null &&
!s.intercourse &&
s.pregnancyTest === null &&
s.progesteroneTest === null &&
s.contraceptive === null &&
s.note.trim() === "" &&
s.symptoms.size === 0
);
}
/**
* What the sheet may do with the form right now.
*
* `hydrated` is false between opening a date and its stored row arriving.
* In that window the form still holds the PREVIOUS date's values and row id,
* so a quick Save used to PATCH the other date's row and Delete would have
* removed it. Nothing writes until the form belongs to the date on screen.
*/
export function dayLogSaveState({
hydrated,
rowId,
form,
}: {
hydrated: boolean;
rowId: string | null;
form: DayLogFormState;
}): { rowId: string | null; canSave: boolean; blankNewEntry: boolean } {
if (!hydrated) return { rowId: null, canSave: false, blankNewEntry: false };
const blankNewEntry = rowId === null && isBlankDayLog(form);
return { rowId, canSave: !blankNewEntry, blankNewEntry };
}
/** The sheet's per-date context, read off the calendar grid for that date. */
export interface SheetDayContext {
startsCycle: boolean;
periodEndable: boolean;
phase: CyclePhase | null;
dayOfCycle: number | null;
}
/**
* Resolve what the sheet says about `date` from the server's grid day for it.
* The grid may hold the same date twice where the anchored read and the
* month read overlap; the later entry wins, as it does in the calendar.
* A date the grid does not hold gets no claims at all.
*/
export function sheetDayContext(
date: string,
days: readonly CalendarDay[],
): SheetDayContext {
let day: CalendarDay | undefined;
for (let i = days.length - 1; i >= 0; i--) {
if (days[i].date === date) {
day = days[i];
break;
}
}
return {
startsCycle: day?.isCycleStart ?? false,
periodEndable: day?.periodEndable ?? false,
phase: day?.phase ?? null,
dayOfCycle: day?.cycleDay ?? null,
};
}
/** The MIN_CYCLES gate the phase-education card uses — mirrored here so the
* sheet's phase-context header makes the same honesty claim (no phase label
* until prediction is on, not raw-chart, and at least three cycles seen). */
@@ -329,17 +412,21 @@ export interface LogDaySheetProps {
*/
startsCycle?: boolean;
/**
* Whether a period is currently open (today is in the MENSTRUAL phase) — gates
* the one-tap "end period" affordance so it never shows when no period is in
* progress (QA M2).
* Whether a one-tap period end can land on `date` (the server's per-date
* `periodEndable`). It used to follow TODAY's phase, so the button was
* missing on every back-dated period and appeared on unrelated dates
* whenever today happened to be a bleeding day.
*/
activePeriod?: boolean;
periodEndable?: boolean;
/**
* The active cycle phase from the calendar read (or null). Drives the
* The phase of `date` from the calendar read (or null). Drives the
* hue-tinted phase-context header so the sheet visually belongs to the ring.
*/
phase?: CyclePhase | null;
/** Day-of-cycle for the header ("Day 14 · Ovulatory"); null when no cycle. */
/**
* The cycle day of `date` for the header ("Day 14 · Ovulatory"); null when
* the date sits in no logged cycle. Never today's count.
*/
dayOfCycle?: number | null;
/**
* The user's cycle goal — fertility goals (TTC / avoid-pregnancy) auto-open
@@ -501,7 +588,7 @@ export function LogDaySheet({
date,
today,
startsCycle = false,
activePeriod = false,
periodEndable = false,
phase = null,
dayOfCycle = null,
goal,
@@ -611,6 +698,7 @@ export function LogDaySheet({
} else if (!open && lastFormKey !== null) {
setLastFormKey(null);
}
const hydrated = open && lastFormKey === formKey;
function toggleSymptom(key: string) {
setSymptoms((prev) => {
@@ -651,16 +739,20 @@ export function LogDaySheet({
symptoms,
};
const saveState = dayLogSaveState({ hydrated, rowId, form: formState });
const currentRowId = saveState.rowId;
async function handleSave() {
if (!saveState.canSave) return;
// v1.16.4 — catch so a rejected save doesn't escape as an unhandled
// rejection; the inline `logDay.isError || patchDay.isError` strip in the
// footer carries the visible failure signal and the sheet stays open.
try {
if (rowId) {
if (currentRowId) {
// Editing an existing row → PATCH with explicit nulls so a deselected
// chip actually CLEARS (the POST merge can only add/keep — QA W-2).
await patchDay.mutateAsync({
id: rowId,
id: currentRowId,
patch: buildDayLogPatch(formState),
});
} else {
@@ -695,9 +787,9 @@ export function LogDaySheet({
}
async function handleDelete() {
if (!rowId) return;
if (!currentRowId) return;
try {
await deleteDay.mutateAsync(rowId);
await deleteDay.mutateAsync(currentRowId);
onOpenChange(false);
} catch {
/* toast shown by useDeleteDayLog onError */
@@ -733,7 +825,7 @@ export function LogDaySheet({
contentWidth="lg"
footer={
<>
{rowId ? (
{currentRowId ? (
<ConfirmButton
slot="cycle-day-delete"
variant="outline"
@@ -760,7 +852,11 @@ export function LogDaySheet({
>
{t("cycle.sheet.cancel")}
</Button>
<Button onClick={handleSave} disabled={busy}>
<Button
onClick={handleSave}
disabled={busy || !saveState.canSave}
data-slot="cycle-day-save"
>
{saving ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
@@ -782,7 +878,8 @@ export function LogDaySheet({
{/* One-tap period boundaries — available on any selected date so a
forgotten day-1 can be corrected retroactively (M3). "End period"
shows only while a period is actually open (M2). The labels drop the
shows only where it can land: inside the first days of the logged
cycle the selected date belongs to. The labels drop the
word "today" on a back-dated day: they always wrote to the selected
date, and saying otherwise is how a correction reads as a mistake. */}
<div className="flex flex-wrap gap-2">
@@ -802,7 +899,7 @@ export function LogDaySheet({
? t("cycle.startedPeriod")
: t("cycle.startedPeriodOnDate")}
</Button>
{activePeriod ? (
{periodEndable ? (
<Button
variant="outline"
className="flex-1 justify-start gap-2"
@@ -867,9 +964,9 @@ export function LogDaySheet({
<cat.icon className="h-3.5 w-3.5" aria-hidden="true" />
{t(cat.labelKey)}
</div>
<div className="flex flex-wrap gap-2">
<div className="space-y-1.5">
{cat.symptoms.map((s) => (
<SymptomChip
<SymptomRow
key={s.key}
symptomKey={s.key}
icon={s.icon}
@@ -891,9 +988,9 @@ export function LogDaySheet({
<Tag className="h-3.5 w-3.5" aria-hidden="true" />
{t("cycle.symptomCategory.custom")}
</div>
<div className="flex flex-wrap gap-2">
<div className="space-y-1.5">
{(customSymptoms.data?.symptoms ?? []).map((s) => (
<SymptomChip
<SymptomRow
key={s.key}
symptomKey={s.key}
icon={customIcon(s.icon)}
@@ -1197,6 +1294,15 @@ export function LogDaySheet({
/>
</SheetSection>
{saveState.blankNewEntry ? (
<p
className="text-muted-foreground text-xs"
data-slot="cycle-day-empty-hint"
>
{t("cycle.sheet.emptyHint")}
</p>
) : null}
{logDay.isError || patchDay.isError ? (
<p className="text-destructive text-sm" role="alert">
{t("cycle.sheet.saveError")}
@@ -1228,12 +1334,19 @@ function Field({
}
/**
* One symptom chip + its inline 1-4 severity selector (shared by the seeded
* catalogue chips and the custom chips so both render identically). A custom
* chip additionally carries a tiny remove (×) affordance that soft-hides the
* symptom from the catalogue.
* One symptom per line: the toggle on the left, its optional 1-4 intensity
* on the right (shared by the seeded catalogue and the custom symptoms so both
* render identically). A custom row additionally carries a remove control that
* soft-hides the symptom from the catalogue.
*
* The intensity used to appear inline next to a chip in a wrapping row, so
* selecting a symptom widened its chip and pushed every chip after it along,
* often right out from under the finger reaching for the intensity. The row
* now reserves the intensity's space whether or not it is shown: selecting a
* symptom changes colours and reveals the intensity in place, and nothing on
* the sheet moves.
*/
function SymptomChip({
export function SymptomRow({
symptomKey,
icon: Icon,
label,
@@ -1269,26 +1382,41 @@ function SymptomChip({
}
return (
<div className="flex items-center gap-1">
<span className="relative inline-flex items-center">
<Chip active={active} onClick={() => onToggle(symptomKey)}>
<span className="flex items-center gap-1.5">
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
{label}
</span>
</Chip>
{custom ? (
<div
className="flex items-center gap-2"
data-slot="cycle-symptom-row"
data-symptom={symptomKey}
data-active={active ? "true" : "false"}
>
<button
type="button"
aria-pressed={active}
onClick={() => onToggle(symptomKey)}
className={cn(
"focus-visible:ring-ring/50 flex min-h-11 min-w-0 flex-1 items-center gap-2 rounded-md border px-3 py-1.5 text-left text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none sm:min-h-9",
active
? "border-primary bg-primary/10 text-primary"
: "border-border text-foreground hover:bg-accent",
)}
>
<Icon className="size-3.5 shrink-0" aria-hidden="true" />
{/* Wraps rather than truncates: the intensity column leaves a narrow
label on a phone, and a symptom name cut to "Breast tendern…" is
not a name. A wrapped row is taller in both states, so it still
never moves on select. */}
<span className="min-w-0 leading-snug break-words">{label}</span>
</button>
{custom ? (
<>
<button
type="button"
onClick={() => setConfirmRemove(true)}
disabled={deleteCustom.isPending}
aria-label={t("cycle.symptom.custom.remove", { label })}
className="border-border bg-background text-muted-foreground hover:text-destructive hover:border-destructive focus-visible:ring-ring/50 relative -ml-1.5 grid size-4 shrink-0 place-items-center rounded-full border transition-colors before:absolute before:-inset-2 before:content-[''] focus-visible:ring-2 focus-visible:outline-none"
className="text-muted-foreground hover:text-destructive hover:bg-accent focus-visible:ring-ring/50 grid size-11 shrink-0 place-items-center rounded-md transition-colors focus-visible:ring-2 focus-visible:outline-none sm:size-9"
>
<X className="size-2.5" aria-hidden="true" />
<X className="size-4" aria-hidden="true" />
</button>
) : null}
{custom ? (
<ConfirmDialog
slot="cycle-custom-symptom-remove"
open={confirmRemove}
@@ -1299,41 +1427,45 @@ function SymptomChip({
pending={deleteCustom.isPending}
onConfirm={handleRemove}
/>
) : null}
</span>
{active ? (
<div
className="flex gap-0.5"
role="group"
aria-label={t("cycle.sheet.severity")}
>
{SEVERITY_LEVELS.map((lvl) => (
<button
key={lvl}
type="button"
aria-pressed={severity === lvl}
aria-label={t("cycle.sheet.severityLevel", { level: lvl })}
onClick={() => onSeverity(symptomKey, lvl)}
className={cn(
"focus-visible:ring-ring/50 grid size-11 place-items-center rounded-full border text-xs tabular-nums transition-colors focus-visible:ring-2 focus-visible:outline-none sm:size-8",
severity === lvl
? "border-primary bg-primary/15 text-primary font-semibold"
: "border-border text-muted-foreground hover:bg-accent",
)}
>
{lvl}
</button>
))}
</div>
</>
) : null}
{/* Always laid out, only shown while the symptom is selected: the
reserved width is what keeps the row still when it appears. */}
<div
className={cn("flex shrink-0 gap-1", !active && "invisible")}
role="group"
aria-label={t("cycle.sheet.severity")}
aria-hidden={active ? undefined : true}
data-slot="cycle-symptom-severity"
>
{SEVERITY_LEVELS.map((lvl) => (
<button
key={lvl}
type="button"
aria-pressed={severity === lvl}
aria-label={t("cycle.sheet.severityLevel", { level: lvl })}
onClick={() => onSeverity(symptomKey, lvl)}
disabled={!active}
tabIndex={active ? undefined : -1}
className={cn(
"focus-visible:ring-ring/50 grid size-11 place-items-center rounded-full border text-xs tabular-nums transition-colors focus-visible:ring-2 focus-visible:outline-none sm:size-9",
severity === lvl
? "border-primary bg-primary/15 text-primary font-semibold"
: "border-border text-muted-foreground hover:bg-accent",
)}
>
{lvl}
</button>
))}
</div>
</div>
);
}
/**
* The dashed ghost-chip in the symptom grid that opens a compact label + icon
* popover to mint a new custom symptom. Matches the chip grid (same rounded-
* full pill, same size) so it reads as part of the grid, not a foreign button.
* The dashed ghost row at the end of the custom symptoms that opens a compact
* label + icon popover to mint a new custom symptom. Same height and shape as
* a symptom row so it reads as part of the list, not a foreign button.
*/
function AddSymptomChip({ onCreated }: { onCreated: (key: string) => void }) {
const { t } = useTranslations();
@@ -1380,9 +1512,9 @@ function AddSymptomChip({ onCreated }: { onCreated: (key: string) => void }) {
<PopoverTrigger asChild>
<button
type="button"
className="border-border text-muted-foreground hover:border-primary hover:text-primary focus-visible:ring-ring/50 inline-flex items-center gap-1.5 rounded-full border border-dashed px-3 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none"
className="border-border text-muted-foreground hover:border-primary hover:text-primary focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-2 rounded-md border border-dashed px-3 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none sm:min-h-9"
>
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
<Plus className="size-3.5" aria-hidden="true" />
{t("cycle.symptom.custom.add")}
</button>
</PopoverTrigger>
+7
View File
@@ -31,6 +31,13 @@ export interface CalendarDay {
isPeriodLogged: boolean;
/** Whether a logged cycle opens on this day. */
isCycleStart: boolean;
/**
* The 1-based day of the logged cycle this date belongs to, resolved by the
* server for THIS date (not today). Null when the record cannot say.
*/
cycleDay: number | null;
/** Whether the one-tap period end can land on this date. */
periodEndable: boolean;
flow: string | null;
hasSymptoms: boolean;
confidence: number;
@@ -0,0 +1,44 @@
/**
* "Save & add another" keeps the report and the marker, clears the reading
* (#1029).
*
* It used to clear the biomarker too. The picker then fell out of its
* controlled state and went on showing the marker it last held while the
* form's own value was empty, so the second save answered "Pick a biomarker
* first" under a visibly picked marker. Keeping the marker removes the reset
* that caused the disagreement; the picker itself is now always controlled,
* so an empty pick shows the placeholder instead of a stale marker.
*/
import { describe, it, expect } from "vitest";
import { nextEntryAfterKeepOpen, type LabEntryDraft } from "../lab-form";
const saved: LabEntryDraft = {
biomarkerId: "bm-vitamin-d",
value: "42",
valueText: "",
takenAt: "2026-09-01T08:30",
note: "fasting",
sourceRange: "30 - 100",
visitId: "visit-1",
};
describe("nextEntryAfterKeepOpen", () => {
it("keeps the picked biomarker so the next save does not ask for it again", () => {
expect(nextEntryAfterKeepOpen(saved).biomarkerId).toBe("bm-vitamin-d");
});
it("keeps what the report shares: its draw date and its visit", () => {
const next = nextEntryAfterKeepOpen(saved);
expect(next.takenAt).toBe("2026-09-01T08:30");
expect(next.visitId).toBe("visit-1");
});
it("clears the reading itself and its printed window", () => {
const next = nextEntryAfterKeepOpen({ ...saved, valueText: "negative" });
expect(next.value).toBe("");
expect(next.valueText).toBe("");
expect(next.note).toBe("");
expect(next.sourceRange).toBe("");
});
});
+64 -30
View File
@@ -1,7 +1,7 @@
"use client";
import { useActiveRecordName } from "@/hooks/use-record-capabilities";
import { useId, useMemo, useRef, useState } from "react";
import { useId, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
@@ -57,6 +57,40 @@ function parseDecimal(raw: string): number | null {
return Number.isFinite(n) ? n : null;
}
/** The per-reading fields "Save & add another" decides to keep or clear. */
export interface LabEntryDraft {
biomarkerId: string;
value: string;
valueText: string;
takenAt: string;
note: string;
sourceRange: string;
visitId: string | null;
}
/**
* The form after "Save & add another": the reading is cleared, everything that
* describes the report it came from stays. The draw date and the visit are
* shared by every value on one report, and the biomarker stays because a run
* of entries is as often one marker over several dates as several markers on
* one date; re-picking it each time is what the button exists to avoid.
*
* Clearing the biomarker here used to drop the picker out of its controlled
* state. It kept showing the marker it last held while the form value was
* empty, so the next save refused with "Pick a biomarker first" under a
* visibly picked marker.
*/
export function nextEntryAfterKeepOpen(draft: LabEntryDraft): LabEntryDraft {
return {
...draft,
value: "",
valueText: "",
note: "",
// The next reading has its own printed window.
sourceRange: "",
};
}
interface LabFormProps {
/** When set, the biomarker is pre-selected and locked (add-from-detail). */
lockedBiomarkerId?: string;
@@ -157,7 +191,6 @@ export function LabForm({
const [defineFooterEl, setDefineFooterEl] = useState<HTMLDivElement | null>(
null,
);
const biomarkerTriggerRef = useRef<HTMLButtonElement | null>(null);
const allMarkers = catalog?.biomarkers ?? [];
const selected = allMarkers.find((m) => m.id === biomarkerId);
@@ -256,30 +289,31 @@ export function LabForm({
: undefined,
);
if (keepOpen) {
// A real lab report shares one blood-draw date across every
// analyte — clear the reading but deliberately KEEP `takenAt` so
// the next row doesn't need it re-set away from "now" again.
// The biomarker resets too (unless locked to one detail page)
// since the whole point is entering the NEXT analyte.
if (!lockedBiomarkerId) setBiomarkerId("");
setValue("");
setValueText("");
setNote("");
// The next analyte off the same report has its own printed window.
setSourceRange("");
const next = nextEntryAfterKeepOpen({
biomarkerId,
value,
valueText,
takenAt,
note,
sourceRange,
visitId,
});
setBiomarkerId(next.biomarkerId);
setValue(next.value);
setValueText(next.valueText);
setTakenAt(next.takenAt);
setNote(next.note);
setSourceRange(next.sourceRange);
setVisitId(next.visitId);
setError(null);
onSavedKeepOpen?.(created);
// Return focus to wherever the next entry starts so the flow
// stays keyboard/screen-reader friendly across repeated saves.
if (!lockedBiomarkerId) {
biomarkerTriggerRef.current?.focus();
} else {
document
.getElementById(
resultType === "numeric" ? "lab-value" : "lab-valueText",
)
?.focus();
}
// The next entry starts at the value, so focus goes there and keeps
// the flow keyboard/screen-reader friendly across repeated saves.
document
.getElementById(
resultType === "numeric" ? "lab-value" : "lab-valueText",
)
?.focus();
} else {
onSuccess?.(created);
}
@@ -355,16 +389,16 @@ export function LabForm({
<form id={formId} onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="lab-biomarker">{t("labs.form.biomarker")}</Label>
{/* Always controlled: `""` shows the placeholder. Passing
`undefined` for an empty pick switched the picker to its own
internal state, which kept displaying the last marker while the
form held none. */}
<Select
value={biomarkerId || undefined}
value={biomarkerId}
onValueChange={handleSelect}
disabled={!!lockedBiomarkerId || catalogLoading}
>
<SelectTrigger
id="lab-biomarker"
ref={biomarkerTriggerRef}
className="w-full"
>
<SelectTrigger id="lab-biomarker" className="w-full">
<SelectValue placeholder={t("labs.form.biomarkerPlaceholder")} />
</SelectTrigger>
<SelectContent>
@@ -0,0 +1,112 @@
/**
* The manual-entry form offers every body-composition reading the server
* accepts from a person (#1013).
*
* The form keeps a hand-written type list, and the server's list of
* measurement types grew past it: visceral fat, fat mass, fat-free mass and
* lean body mass all arrived from scales and Apple Health, with units and
* plausibility ranges, while a person reading the same number off a scale's
* display had no row to type it into. Nothing noticed, because nothing
* compared the two.
*
* The comparison is derived from the server's own grouping
* (`MEASUREMENT_CATEGORIES`, the "body" family), so a type added to that
* family later fails here until it is either given a form row or named below
* with the reason a person would not type it.
*/
import { describe, it, expect } from "vitest";
import { MEASUREMENT_CATEGORIES } from "@/lib/measurements/categories";
import { VALUE_RANGES, getUnitForType } from "@/lib/validations/measurement";
import { hasDisplayTransform } from "@/lib/measurements/display-transform";
import { parseDecimalEntry } from "@/lib/measurements/entry-units";
import {
MEASUREMENT_FORM_TYPE_VALUES,
MEASUREMENT_TYPES,
} from "@/components/measurements/measurement-form";
import en from "../../../../messages/en.json";
/**
* Body-composition types deliberately absent from the manual form, each with
* the reason. Keep this list short and honest: "a scale reports it" is a
* reason to be in the form, not out of it.
*/
const NOT_HAND_ENTERED: Readonly<Record<string, string>> = {
BODY_MASS_INDEX:
"Derived from weight and height. A typed copy would be a second number that can disagree with the weight it came from.",
};
const BODY_COMPOSITION = [...MEASUREMENT_CATEGORIES.entries()]
.filter(([, category]) => category === "body")
.map(([type]) => type as string);
function translate(key: string): unknown {
return key
.split(".")
.reduce<unknown>(
(node, part) =>
node && typeof node === "object"
? (node as Record<string, unknown>)[part]
: undefined,
en,
);
}
describe("manual measurement form — body composition", () => {
it("reads a non-empty body family off the server's categories", () => {
// A guard over an empty set passes for the wrong reason.
expect(BODY_COMPOSITION.length).toBeGreaterThan(5);
expect(BODY_COMPOSITION).toContain("VISCERAL_FAT");
});
it.each(BODY_COMPOSITION)(
"%s is offered in the form or excluded with a reason",
(type) => {
const inForm = MEASUREMENT_FORM_TYPE_VALUES.includes(type);
const excluded = type in NOT_HAND_ENTERED;
expect(
inForm !== excluded,
`${type}: add a form row, or name it in NOT_HAND_ENTERED with a reason (and never both)`,
).toBe(true);
},
);
it("excludes only real body-composition types, each for a stated reason", () => {
for (const [type, reason] of Object.entries(NOT_HAND_ENTERED)) {
expect(BODY_COMPOSITION, `${type} is not a body type`).toContain(type);
expect(reason.trim().length).toBeGreaterThan(20);
}
});
const formBodyRows = MEASUREMENT_TYPES.filter((row) =>
BODY_COMPOSITION.includes(row.value),
);
it.each(formBodyRows.map((row) => [row.value, row] as const))(
"%s asks in the unit the server stores, with a label in every place it needs one",
(type, row) => {
const canonical = getUnitForType(type);
expect(canonical).not.toBe("unknown");
if ("unit" in row && !hasDisplayTransform(type)) {
expect(row.unit).toBe(canonical);
}
if ("unitKey" in row) {
expect(typeof translate(row.unitKey)).toBe("string");
}
expect(typeof translate(row.labelKey)).toBe("string");
},
);
it.each(formBodyRows.map((row) => [row.value, row] as const))(
"%s suggests a placeholder the server would accept",
(type, row) => {
const range = VALUE_RANGES[type];
expect(range, `${type} has no plausibility range`).toBeDefined();
const placeholder =
"placeholder" in row ? parseDecimalEntry(row.placeholder) : null;
expect(placeholder).not.toBeNull();
expect(placeholder!).toBeGreaterThanOrEqual(range.min);
expect(placeholder!).toBeLessThanOrEqual(range.max);
},
);
});
@@ -47,7 +47,7 @@ import {
const MAX_COMMENT_LENGTH = MEASUREMENT_NOTES_MAX_LENGTH;
const MEASUREMENT_TYPES = [
export const MEASUREMENT_TYPES = [
{
value: "BLOOD_PRESSURE",
labelKey: "measurements.typeBloodPressure",
@@ -104,6 +104,46 @@ const MEASUREMENT_TYPES = [
placeholder: "3.2",
placeholderImperial: "7",
},
// The rest of what a body-composition scale reports. The server has
// accepted all of these since the Withings and Apple Health work; only this
// list lagged, so a reading typed off the scale's display had no row. The
// guard in `measurement-form-body-composition.test.ts` keeps the two lists
// from drifting again.
{
value: "FAT_MASS",
labelKey: "measurements.typeFatMass",
unit: "kg",
placeholder: "18",
placeholderImperial: "40",
},
{
value: "FAT_FREE_MASS",
labelKey: "measurements.typeFatFreeMass",
unit: "kg",
placeholder: "58",
placeholderImperial: "128",
},
{
value: "LEAN_BODY_MASS",
labelKey: "measurements.typeLeanBodyMass",
unit: "kg",
placeholder: "55",
placeholderImperial: "121",
},
{
value: "MUSCLE_MASS",
labelKey: "measurements.typeMuscleMass",
unit: "kg",
placeholder: "52",
placeholderImperial: "115",
},
{
// A scale's own rating (Withings prints 1-12), not a percent.
value: "VISCERAL_FAT",
labelKey: "measurements.typeVisceralFat",
unitKey: "measurements.unitRating",
placeholder: "7",
},
{
value: "OXYGEN_SATURATION",
labelKey: "measurements.typeOxygenSaturation",
@@ -0,0 +1,105 @@
/**
* Issue #1028 — the Verlauf ledger's trailing window must be resolved when
* the request fires, not when the tab mounts.
*
* The ledger used to pin `from` and `to` in state at mount and reuse them for
* every fetch. A refetch after adding a dose therefore asked for rows up to
* the moment the tab opened, and a dose recorded afterwards stayed out of
* "Last 90 days" until a reload, while the unwindowed full history showed it.
*
* The test captures the query function the ledger hands to TanStack Query and
* runs it twice at two different clock readings: both requests must reach the
* present, which means no pinned `to` and a `from` that moves with the clock.
*/
import { describe, expect, it, vi, afterEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const captured: { queryFn?: () => Promise<unknown>; queryKey?: unknown } = {};
vi.mock("@tanstack/react-query", async (orig) => {
const actual = await orig<typeof import("@tanstack/react-query")>();
return {
...actual,
useQuery: (opts: {
queryFn: () => Promise<unknown>;
queryKey: unknown;
}) => {
captured.queryFn = opts.queryFn;
captured.queryKey = opts.queryKey;
return { data: undefined, isLoading: true, isError: false };
},
useQueryClient: () => ({}),
};
});
vi.mock("@/lib/api/api-fetch", () => ({
apiGet: vi.fn().mockResolvedValue({ rows: [] }),
apiPost: vi.fn(),
apiPut: vi.fn(),
apiDelete: vi.fn(),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", username: "t", role: "USER", timezone: "Asia/Kolkata" },
isAuthenticated: true,
isLoading: false,
}),
}));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({ canWriteDomain: () => true }),
useActiveRecordName: () => null,
}));
import { I18nProvider } from "@/lib/i18n/context";
import { DoseHistoryLedger } from "@/components/medications/dose-history-ledger";
import { apiGet } from "@/lib/api/api-fetch";
afterEach(() => {
vi.useRealTimers();
});
describe("DoseHistoryLedger request window (#1028)", () => {
it("resolves the window at fetch time so a later refetch reaches the present", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
const mountedAt = new Date("2026-09-22T10:00:00Z");
vi.setSystemTime(mountedAt);
renderToStaticMarkup(
<I18nProvider initialLocale="en">
<DoseHistoryLedger
medicationId="med-1"
medicationName="Test"
schedules={[]}
/>
</I18nProvider>,
);
expect(captured.queryFn).toBeTypeOf("function");
await captured.queryFn!();
// An hour later the user adds a dose; the invalidation refetches.
const later = new Date(mountedAt.getTime() + 3_600_000);
vi.setSystemTime(later);
await captured.queryFn!();
const urls = vi.mocked(apiGet).mock.calls.map((c) => String(c[0]));
expect(urls).toHaveLength(2);
for (const url of urls) {
const params = new URL(url, "http://x").searchParams;
// No pinned upper bound: the server's `to` is its own now.
expect(params.get("to")).toBeNull();
}
const secondFrom = new URL(urls[1], "http://x").searchParams.get("from");
expect(secondFrom).toBe(
new Date(later.getTime() - 90 * 86_400_000).toISOString(),
);
// The cache key carries no instant, so it stays stable across renders.
expect(captured.queryKey).toEqual([
"medications",
"med-1",
"dose-history",
90,
]);
});
});
@@ -200,27 +200,18 @@ export function DoseHistoryLedger({
slotAt: string;
} | null>(null);
// Stable window captured once at mount (a lazy state initializer keeps the
// impure `Date.now()` out of render) so the query key + the `from`/`to`
// request stay byte-equal across renders rather than re-keying every paint.
const [{ fromIso, toIso }] = useState(() => {
const now = Date.now();
return {
toIso: new Date(now).toISOString(),
fromIso: new Date(now - windowDays * DAY_MS).toISOString(),
};
});
const queryKey = queryKeys.medicationDoseHistory(
medicationId,
fromIso,
toIso,
);
// The window is resolved when the request fires, not when the ledger
// mounts. A window pinned at mount made every refetch (including the one
// after adding an entry) ask for rows up to the moment the tab opened, so a
// dose recorded afterwards stayed invisible until a reload (#1028). Only
// `from` travels: the server's `to` defaults to its own now.
const queryKey = queryKeys.medicationDoseHistory(medicationId, windowDays);
const { data, isLoading, isError } = useQuery<LedgerPayload>({
queryKey,
queryFn: async () => {
const search = new URLSearchParams({ from: fromIso, to: toIso });
const from = new Date(Date.now() - windowDays * DAY_MS).toISOString();
const search = new URLSearchParams({ from });
return apiGet<LedgerPayload>(
`/api/medications/${medicationId}/dose-history?${search.toString()}`,
);
+15 -104
View File
@@ -3,25 +3,19 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/consent/receipts", () => ({
latestActiveReceipt: vi.fn(),
}));
vi.mock("@/lib/documents/document-settings", () => ({
documentAutoReadEnabled: vi.fn().mockResolvedValue(false),
}));
import {
ConsentRequiredError,
assertConsentForChain,
assertDocumentEgressConsent,
chainRequiresServerManagedConsent,
hasActiveConsentForSurface,
isExternalDocumentEgress,
} from "../consent-guard";
import { latestActiveReceipt } from "@/lib/consent/receipts";
import { documentAutoReadEnabled } from "@/lib/documents/document-settings";
import type { ProviderChainResolved } from "../provider-runner";
import type { ConsentKind } from "@/lib/validations/consent";
const mockedLatest = vi.mocked(latestActiveReceipt);
const mockedAutoRead = vi.mocked(documentAutoReadEnabled);
/** Minimal chain entry — the gate only inspects `providerType`. */
function entry(providerType: string): ProviderChainResolved {
@@ -203,103 +197,20 @@ describe("isExternalDocumentEgress", () => {
});
});
describe("assertDocumentEgressConsent", () => {
beforeEach(() => {
mockedLatest.mockReset();
mockedAutoRead.mockReset();
// Default: the auto-read toggle is OFF (the shipped privacy posture).
mockedAutoRead.mockResolvedValue(false);
});
it("never reads consent and never throws for a LOCAL document pick", async () => {
grant([]);
await expect(
assertDocumentEgressConsent({
userId: "u1",
providerType: "local",
surface: "insights",
}),
).resolves.toBeUndefined();
expect(mockedLatest).not.toHaveBeenCalled();
// A local pick short-circuits before the toggle is even consulted.
expect(mockedAutoRead).not.toHaveBeenCalled();
});
// The live gap the governance fix closes: codex was ungated for documents.
it("REQUIRES a receipt to send a document to codex (the closed gap)", async () => {
grant([]);
await expect(
assertDocumentEgressConsent({
userId: "u1",
providerType: "codex",
surface: "insights",
}),
).rejects.toBeInstanceOf(ConsentRequiredError);
});
it("requires a receipt for BYOK document egress too (openai / anthropic)", async () => {
grant([]);
for (const p of ["openai", "anthropic", "admin-openai"]) {
await expect(
assertDocumentEgressConsent({
userId: "u1",
providerType: p,
surface: "insights",
}),
).rejects.toBeInstanceOf(ConsentRequiredError);
}
});
it("proceeds for codex WITH an active document-class receipt", async () => {
grant(["ai_insights_only"]);
await expect(
assertDocumentEgressConsent({
userId: "u1",
providerType: "codex",
surface: "insights",
}),
).resolves.toBeUndefined();
});
// Gate A: the documentsAutoAiRead opt-in short-circuits an external pick.
it("proceeds for an external pick when documentsAutoAiRead is ON, without a receipt", async () => {
grant([]);
mockedAutoRead.mockResolvedValue(true);
for (const p of ["codex", "openai", "anthropic", "admin-openai"]) {
await expect(
assertDocumentEgressConsent({
userId: "u1",
providerType: p,
surface: "insights",
}),
).resolves.toBeUndefined();
}
// The toggle short-circuits BEFORE the receipt read.
expect(mockedLatest).not.toHaveBeenCalled();
});
it("STILL requires a receipt for an external pick when documentsAutoAiRead is OFF", async () => {
grant([]);
mockedAutoRead.mockResolvedValue(false);
await expect(
assertDocumentEgressConsent({
userId: "u1",
providerType: "codex",
surface: "insights",
}),
).rejects.toBeInstanceOf(ConsentRequiredError);
});
it("does NOT consult the toggle for a LOCAL pick even when it would be ON", async () => {
grant([]);
mockedAutoRead.mockResolvedValue(true);
await expect(
assertDocumentEgressConsent({
userId: "u1",
providerType: "local",
surface: "insights",
}),
).resolves.toBeUndefined();
expect(mockedAutoRead).not.toHaveBeenCalled();
// The document-class receipt (`ai_extraction` or `ai_full`) is read by the
// capability egress re-check, not here: see
// `src/lib/ai/capabilities/__tests__/egress.test.ts` and
// `tests/integration/consent-withdrawal-purge.test.ts`. What this module still
// owes the document surfaces is the local/external split above, and that the
// extraction kind satisfies neither chain surface.
describe("an extraction receipt does not satisfy the chain surfaces", () => {
it("satisfies neither the Coach nor the analysis", async () => {
grant(["ai_extraction"]);
await expect(hasActiveConsentForSurface("u1", "coach")).resolves.toBe(
false,
);
await expect(hasActiveConsentForSurface("u1", "insights")).resolves.toBe(
false,
);
});
});
@@ -0,0 +1,254 @@
/**
* The capability re-check at the wire, and the gate option that leaves the
* provider questions to the route's own pick.
*
* The loader is replaced so each case hands the gate exactly the inputs it
* needs; the receipt read is replaced so the exact-pick consent answer can be
* steered independently of the presence-based one.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
let auth: Record<string, unknown> | undefined;
vi.mock("@/lib/logging/context", () => ({
getEvent: () => ({ getAuth: () => auth, addWarning: vi.fn() }),
}));
vi.mock("@/lib/sharing/grants", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/sharing/grants")>()),
findActiveGrant: vi.fn(),
}));
vi.mock("../load", () => ({ loadAiCapabilityInputs: vi.fn() }));
vi.mock("@/lib/db", () => ({
prisma: { consentReceipt: { findFirst: vi.fn() } },
}));
import { prisma } from "@/lib/db";
import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
import type { ModuleAccessState } from "@/lib/sharing/module-disclosure";
import { aiEgressRefusal, assertAiEgress } from "../egress";
import { requireAiCapability } from "../gate";
import { loadAiCapabilityInputs } from "../load";
import { AiUnavailableError } from "../refusal";
import type { AiCapabilityInputs } from "../resolve";
function inputs(
overrides: Partial<AiCapabilityInputs> = {},
): AiCapabilityInputs {
const moduleAccess = {} as Record<ModuleKey, ModuleAccessState>;
for (const key of MODULE_KEYS) moduleAccess[key] = "enabled";
return {
switches: {
enabled: true,
coach: true,
briefing: true,
insightStatus: true,
documentAi: true,
},
moduleAccess,
providerWorkAdmitted: true,
provider: {
entries: [{ providerType: "local", vision: true }],
localOcrEnabled: false,
managedBy: "local",
},
activeConsentKinds: new Set(),
recordKind: "self",
...overrides,
};
}
const LOAD = vi.mocked(loadAiCapabilityInputs);
const RECEIPT = vi.mocked(prisma.consentReceipt.findFirst);
beforeEach(() => {
LOAD.mockReset();
RECEIPT.mockReset();
RECEIPT.mockResolvedValue(null);
auth = { user_id: "user-1" };
});
describe("requireAiCapability with pickDecides", () => {
it("leaves no_provider to the route's pick", async () => {
LOAD.mockResolvedValue(
inputs({
provider: { entries: [], localOcrEnabled: false, managedBy: null },
}),
);
await expect(
requireAiCapability("documentAi", { pickDecides: true }),
).resolves.toMatchObject({ available: true });
});
it("leaves consent_required to the route's pick", async () => {
LOAD.mockResolvedValue(
inputs({
provider: {
entries: [{ providerType: "openai", vision: true }],
localOcrEnabled: false,
managedBy: "user",
},
}),
);
await expect(
requireAiCapability("documentAi", { pickDecides: true }),
).resolves.toMatchObject({ available: true });
});
it("still refuses a layer the pick cannot answer", async () => {
LOAD.mockResolvedValue(
inputs({ switches: { ...inputs().switches, documentAi: false } }),
);
const error = await requireAiCapability("documentAi", {
pickDecides: true,
}).catch((e) => e);
expect(error).toBeInstanceOf(AiUnavailableError);
expect(error.meta.errorCode).toBe("assistant.disabled.documentAi");
});
});
describe("aiEgressRefusal inside a request", () => {
it("refuses when the operator switch is off, whatever the pick", async () => {
LOAD.mockResolvedValue(
inputs({ switches: { ...inputs().switches, documentAi: false } }),
);
const refusal = await aiEgressRefusal("documentAi", "user-1", ["local"]);
expect(refusal).toBeInstanceOf(AiUnavailableError);
expect(refusal?.meta).toEqual({
errorCode: "assistant.disabled.documentAi",
capability: "documentAi",
reason: "operator_disabled",
});
});
it("refuses the master switch through every extraction capability", async () => {
LOAD.mockResolvedValue(
inputs({ switches: { ...inputs().switches, enabled: false } }),
);
for (const key of ["documentAi", "labsOcr", "medicationExtract"] as const) {
const refusal = await aiEgressRefusal(key, "user-1", ["local"]);
expect(refusal?.reason, key).toBe("operator_disabled");
}
});
it("asks for a receipt when the actual pick leaves the machine", async () => {
// Presence says a local model would read it; the route picked an external
// provider anyway (text mode reads the chain head). The pick decides.
LOAD.mockResolvedValue(inputs());
const refusal = await aiEgressRefusal("documentAi", "user-1", ["openai"]);
expect(refusal?.meta).toEqual({
errorCode: "consent.ai.required",
capability: "documentAi",
reason: "consent_required",
});
expect(RECEIPT).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
userId: "user-1",
revokedAt: null,
kind: { in: ["ai_extraction", "ai_full"] },
}),
}),
);
});
it("admits an external pick with an extraction receipt", async () => {
LOAD.mockResolvedValue(inputs());
RECEIPT.mockResolvedValue({ id: "r1" } as never);
await expect(
aiEgressRefusal("documentAi", "user-1", ["openai"]),
).resolves.toBeNull();
});
it("needs no receipt for a local pick, even when presence says one is needed", async () => {
LOAD.mockResolvedValue(
inputs({
provider: {
entries: [{ providerType: "openai", vision: true }],
localOcrEnabled: false,
managedBy: "user",
},
}),
);
await expect(
aiEgressRefusal("documentAi", "user-1", ["local"]),
).resolves.toBeNull();
expect(RECEIPT).not.toHaveBeenCalled();
});
it("checks every entry of a cascading chain under the document rule", async () => {
LOAD.mockResolvedValue(inputs());
const refusal = await aiEgressRefusal("medicationExtract", "user-1", [
"local",
"admin-openai",
]);
expect(refusal?.reason).toBe("consent_required");
await expect(
aiEgressRefusal("medicationExtract", "user-1", ["local"]),
).resolves.toBeNull();
});
it("uses the self-snapshot rule for the Coach: a person's own key needs nothing", async () => {
LOAD.mockResolvedValue(inputs());
await expect(
aiEgressRefusal("coach", "user-1", ["openai"]),
).resolves.toBeNull();
const refusal = await aiEgressRefusal("coach", "user-1", ["admin-openai"]);
expect(refusal?.reason).toBe("consent_required");
expect(RECEIPT).toHaveBeenLastCalledWith(
expect.objectContaining({
where: expect.objectContaining({
kind: { in: ["ai_coach", "ai_full"] },
}),
}),
);
});
it("fails closed when the inputs cannot be loaded", async () => {
LOAD.mockResolvedValue(null);
const refusal = await aiEgressRefusal("labsOcr", "user-1", ["local"]);
expect(refusal?.reason).toBe("check_failed");
expect(refusal?.status).toBe(503);
});
});
describe("aiEgressRefusal outside a request (a job)", () => {
beforeEach(() => {
auth = undefined;
});
it("resolves for the record the job names", async () => {
LOAD.mockResolvedValue(
inputs({ switches: { ...inputs().switches, documentAi: false } }),
);
const refusal = await aiEgressRefusal("documentAi", "user-9", ["local"]);
expect(refusal?.reason).toBe("operator_disabled");
expect(LOAD).toHaveBeenCalledWith(
expect.objectContaining({ recordId: "user-9" }),
);
});
it("admits the wire when every layer is open", async () => {
LOAD.mockResolvedValue(inputs());
await expect(
aiEgressRefusal("documentAi", "user-9", ["local"]),
).resolves.toBeNull();
});
});
describe("assertAiEgress", () => {
it("throws the refusal", async () => {
LOAD.mockResolvedValue(
inputs({ switches: { ...inputs().switches, documentAi: false } }),
);
await expect(
assertAiEgress("medicationExtract", "user-1", ["local"]),
).rejects.toBeInstanceOf(AiUnavailableError);
});
it("returns quietly when the wire is open", async () => {
LOAD.mockResolvedValue(inputs());
await expect(
assertAiEgress("labsOcr", "user-1", ["local"]),
).resolves.toBeUndefined();
});
});
+96
View File
@@ -0,0 +1,96 @@
/**
* The capability re-check at the wire.
*
* `requireAiCapability` answers at the top of a route, before anything is
* picked. That is the refusal a client sees, but it is not the last word: a
* route may pick its provider in its own order, and a job enqueued before the
* operator turned a switch off runs long after any route answered. So every
* place that hands input to a model (the document provider pick, the labs
* scan, the medication extractor, the fenced document chat) asks again here,
* with the provider it is about to send to, immediately before it sends.
*
* Two questions, both about the actual wire:
*
* 1. Is the capability still open for this record? Inside a request this is
* the request's own resolution (memoised per request, so the answer
* matches the one the route gave); outside a request it is the job's
* resolution for the record it names.
* 2. Does sending to exactly these providers need a consent receipt, and is
* one active? Presence cannot answer this for a route that reads the
* chain in its own order, so the pick answers it here, from the same
* rule and the same receipt kinds the capability table declares.
*
* `no_provider` is never answered here: a caller that has a pick has a
* provider, and one without a pick refuses with its own typed code.
*/
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import { aiCapabilityForJob, requireAiCapability } from "./gate";
import { AiUnavailableError } from "./refusal";
import { consentNeededForProviders } from "./resolve";
import {
AI_CAPABILITIES,
PICK_DECIDED_REASONS,
type AiCapabilityKey,
} from "./types";
/** Whether the record holds an active receipt of one of these kinds. */
async function hasActiveReceipt(
recordId: string,
kinds: readonly string[],
): Promise<boolean> {
const row = await prisma.consentReceipt.findFirst({
where: { userId: recordId, revokedAt: null, kind: { in: [...kinds] } },
select: { id: true },
});
return row !== null;
}
/**
* Why input may not leave for these providers right now, or `null` when it
* may. Never throws for a refusal; a database error propagates, which is the
* closed direction for a caller about to send.
*
* @param recordId The record the input belongs to. Inside a request the
* request's own record is used, which is the record the route acts on.
* @param providerTypes The providers the input is about to reach: the one
* picked provider for a single-pick read, the whole chain for a cascade.
*/
export async function aiEgressRefusal(
key: AiCapabilityKey,
recordId: string,
providerTypes: readonly string[],
): Promise<AiUnavailableError | null> {
if (getEvent()?.getAuth()?.user_id) {
try {
await requireAiCapability(key, { pickDecides: true });
} catch (error) {
if (error instanceof AiUnavailableError) return error;
throw error;
}
} else {
const state = await aiCapabilityForJob(recordId, key);
if (state.reason !== null && !PICK_DECIDED_REASONS.has(state.reason)) {
return new AiUnavailableError(key, state.reason);
}
}
if (
consentNeededForProviders(key, providerTypes) &&
!(await hasActiveReceipt(recordId, AI_CAPABILITIES[key].consent.kinds))
) {
return new AiUnavailableError(key, "consent_required");
}
return null;
}
/** {@link aiEgressRefusal}, thrown. For routes, where `apiHandler` renders it. */
export async function assertAiEgress(
key: AiCapabilityKey,
recordId: string,
providerTypes: readonly string[],
): Promise<void> {
const refusal = await aiEgressRefusal(key, recordId, providerTypes);
if (refusal) throw refusal;
}
+76 -2
View File
@@ -40,7 +40,11 @@ import { providerWorkAuthorityForRecord } from "@/lib/sharing/provider-work-auth
import { loadAiCapabilityInputs, type AiCapabilityScope } from "./load";
import { explainAiCapability, resolveAiCapability } from "./resolve";
import { AiUnavailableError, type NoProviderRefusal } from "./refusal";
import type { AiCapabilityKey, AiCapabilityState } from "./types";
import {
PICK_DECIDED_REASONS,
type AiCapabilityKey,
type AiCapabilityState,
} from "./types";
export interface AiGateOptions {
/**
@@ -50,6 +54,15 @@ export interface AiGateOptions {
recordId?: string;
/** The route's own `no_provider` refusal, where a client already reads it. */
noProvider?: NoProviderRefusal;
/**
* Leave `no_provider` and `consent_required` to the provider the route
* actually picks. The resolver answers both from presence, which is right
* for the published payload but can differ from a route that reads the
* chain in its own order (a text-mode document read takes the chain head,
* not the first vision entry). A route that sets this must run the pick
* through `assertAiEgress` / `aiEgressRefusal`, which answers both exactly.
*/
pickDecides?: boolean;
}
/**
@@ -118,7 +131,10 @@ export async function requireAiCapability(
} catch {
finding = { reason: "check_failed", module: null };
}
if (finding.reason !== null) {
if (
finding.reason !== null &&
!(options.pickDecides && PICK_DECIDED_REASONS.has(finding.reason))
) {
throw new AiUnavailableError(
key,
finding.reason,
@@ -150,3 +166,61 @@ export async function aiCapabilityForJob(
return resolveAiCapability(key, null);
}
}
/**
* One capability for one record, from wherever the caller runs. Inside an
* authenticated request it is the request's view (masked to the active grant,
* memoised with every other capability read of the request); outside one it
* is the worker's view under the worker's own authority.
*
* For code that serves stored model text on both paths: a status note read on
* a page visit and the same read inside the nightly batch must answer the same
* question without the caller knowing which path it is on.
*/
export async function aiCapabilityForRecord(
recordId: string,
key: AiCapabilityKey,
): Promise<AiCapabilityState> {
return getEvent()?.getAuth()?.user_id
? getAiCapability(key, { recordId })
: aiCapabilityForJob(recordId, key);
}
/**
* Whether text a model already wrote for a record may be SHOWN, answered from
* the record's own state, whoever is reading.
*
* Serving stored text and starting model work are different questions. A
* delegate inside somebody else's record may not start model work there
* (`not_permitted_for_record`: no generation, no warm, no chat on the owner's
* key), but reads the owner's stored briefing, narrative, status notes and
* workout notes exactly when the owner would. So this resolves the record's
* operator switches, its modules and AI opt-outs, its provider and its
* consent, under the record's own authority (`system`, as a job would, which
* also answers a managed profile correctly), unmasked by the viewer's grant.
*
* Use it only to decide whether stored model text is served. Anything that
* generates, warms or enqueues asks `getAiCapability` / `requireAiCapability`
* (the viewer's view) instead. Never throws.
*/
export async function aiCapabilityToServe(
recordId: string,
key: AiCapabilityKey,
): Promise<AiCapabilityState> {
try {
const inputs = await loadAiCapabilityInputs({
recordId,
authority: {
origin: "system",
recordUserId: recordId,
actorUserId: null,
grantId: null,
},
sections: null,
recordKind: "self",
});
return resolveAiCapability(key, inputs);
} catch {
return resolveAiCapability(key, null);
}
}
-26
View File
@@ -1,26 +0,0 @@
/**
* One capability for one record, from whichever context is asking.
*
* Some readers run both inside an API request and outside one: the dashboard
* snapshot and the daily digest are read by their routes, by the server
* components that prefetch them, and by the morning push. Inside a request
* the route gate answers, and it honours the request's grant (a delegate
* inside somebody else's record gets that record's masked answer). Outside a
* request there is no caller to mask for, so the worker path answers with
* the record's own authority, exactly as a job would.
*
* Never throws; an input that cannot be loaded reads `check_failed`.
*/
import { getEvent } from "@/lib/logging/context";
import { aiCapabilityForJob, getAiCapability } from "./gate";
import type { AiCapabilityKey, AiCapabilityState } from "./types";
export function aiCapabilityForRecord(
recordId: string,
key: AiCapabilityKey,
): Promise<AiCapabilityState> {
return getEvent()?.getAuth()?.user_id
? getAiCapability(key, { recordId })
: aiCapabilityForJob(recordId, key);
}
+23
View File
@@ -223,6 +223,29 @@ function consentNeeded(
);
}
/**
* Whether sending to exactly these providers needs a receipt under the
* capability's consent rule. The egress re-check asks it about the providers a
* route actually picked, so the answer at the wire comes from the same rule the
* published capability does. Pass the one picked provider for a single-pick
* read, or the whole chain for a cascade.
*/
export function consentNeededForProviders(
key: AiCapabilityKey,
providerTypes: readonly string[],
): boolean {
return consentNeeded(AI_CAPABILITIES[key], {
// Every entry named here is one the route will send to, so each counts as
// able to read the input.
entries: providerTypes.map((providerType) => ({
providerType,
vision: true,
})),
localOcrEnabled: false,
managedBy: null,
});
}
function consentReason(
def: AiCapabilityDefinition,
provider: ProviderPresence,
+9
View File
@@ -84,6 +84,15 @@ export interface AiCapabilityState {
export const ON_DEVICE_ALLOWED_REASONS: ReadonlySet<AiUnavailableReason> =
new Set<AiUnavailableReason>(["no_provider", "consent_required"]);
/**
* The reasons a route's own provider pick answers exactly: whether there is a
* provider, and whether sending to it needs a receipt. The resolver answers
* both from presence, which is right for the published payload; a route that
* reads the chain in its own order asks again about the provider it picked.
*/
export const PICK_DECIDED_REASONS: ReadonlySet<AiUnavailableReason> =
new Set<AiUnavailableReason>(["no_provider", "consent_required"]);
/**
* The operator's assistant switches below the master. Each maps to a cost or
* egress profile an operator can reason about: the Coach, the daily briefing
@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForJob: vi.fn(),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("../conversation-summary", () => ({
refreshConversationSummary: vi.fn(async () => ({ status: "refreshed" })),
}));
vi.mock("../facts", () => ({
extractAndStoreFacts: vi.fn(async () => ({ status: "stored", count: 1 })),
}));
vi.mock("../plans", () => ({
extractAndStorePlanProposals: vi.fn(async () => ({
status: "stored",
count: 0,
})),
}));
import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate";
import { annotate } from "@/lib/logging/context";
import { runCoachMemoryRefresh } from "../coach-memory-refresh-worker";
import { refreshConversationSummary } from "../conversation-summary";
import { extractAndStoreFacts } from "../facts";
import { extractAndStorePlanProposals } from "../plans";
const payload = { conversationId: "c1", userId: "u1", locale: "en" as const };
beforeEach(() => {
vi.clearAllMocks();
});
describe("runCoachMemoryRefresh", () => {
it("runs all three steps when the Coach is available", async () => {
vi.mocked(aiCapabilityForJob).mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
});
await runCoachMemoryRefresh(payload);
expect(aiCapabilityForJob).toHaveBeenCalledWith("u1", "coach");
expect(refreshConversationSummary).toHaveBeenCalled();
expect(extractAndStoreFacts).toHaveBeenCalled();
expect(extractAndStorePlanProposals).toHaveBeenCalled();
});
it.each(["operator_disabled", "user_disabled", "consent_required"] as const)(
"reads no transcript and runs no step when the Coach is unavailable (%s)",
async (reason) => {
vi.mocked(aiCapabilityForJob).mockResolvedValue({
available: false,
reason,
onDeviceAllowed: false,
});
await runCoachMemoryRefresh(payload);
expect(refreshConversationSummary).not.toHaveBeenCalled();
expect(extractAndStoreFacts).not.toHaveBeenCalled();
expect(extractAndStorePlanProposals).not.toHaveBeenCalled();
expect(annotate).toHaveBeenCalledWith({
action: { name: "coach.memory.refresh.skipped" },
meta: { reason },
});
},
);
});
@@ -10,6 +10,14 @@ vi.mock("@/lib/db", () => ({
},
}));
// The crosstab reads per-source day aggregates; the fake groups the mocked
// `measurement.findMany` rows with the same rules.
vi.mock("@/lib/measurements/day-aggregates", async () => ({
readSourceDayAggregates: (
await import("@/lib/measurements/__tests__/fake-day-aggregates")
).fakeReadSourceDayAggregates,
}));
import { buildCycleSnapshotBlock } from "../cycle-snapshot";
import { prisma } from "@/lib/db";
@@ -15,6 +15,7 @@
* chokepoint did no budget work at all, so every generator behind it, not just
* these two, spent unmetered.
*/
import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate";
import { annotate } from "@/lib/logging/context";
import type { CoachMemoryRefreshPayload } from "./coach-memory-shared";
@@ -31,6 +32,19 @@ export async function runCoachMemoryRefresh(
// fallback body for every locale that has no reviewed one.
const locale = payload.locale ?? "en";
// The `coach` capability before any transcript is read. The chat route that
// enqueued this admitted the Coach, but the operator, the person's Coach
// switch or a consent can change while the job waits. Stored memory stays
// as it is; only new model work stops. The chokepoint re-checks per step.
const capability = await aiCapabilityForJob(userId, "coach");
if (!capability.available) {
annotate({
action: { name: "coach.memory.refresh.skipped" },
meta: { reason: capability.reason },
});
return;
}
let summaryStatus = "error";
try {
const result = await refreshConversationSummary(conversationId, userId, {
+1 -1
View File
@@ -193,7 +193,7 @@ export async function refreshConversationSummary(
const completion: StatusProviderResult = await runCompletion({
userId,
cacheAction: "coach.summary",
consentSurface: "coach",
capability: "coach",
systemPrompt: locale === "de" ? SYSTEM_PROMPT_DE : SYSTEM_PROMPT_EN,
userPrompt: buildSummaryUserPrompt(priorSummary, foldedTurns, locale),
temperature: 0.3,
+14 -17
View File
@@ -40,6 +40,7 @@ import {
} from "@/lib/cycle/phase-crosstab";
import type { CrossMetricMeasurement } from "@/lib/insights/mood-aggregates";
import { addDays } from "@/lib/cycle/day-math";
import { readSourceDayAggregates } from "@/lib/measurements/day-aggregates";
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
/** Trailing window the phase contrast walks (days). Mirrors the read route. */
@@ -195,21 +196,16 @@ export async function buildCycleSnapshotBlock(
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
}),
prisma.measurement.findMany({
where: {
userId,
deletedAt: null,
type: { in: PHASE_CROSSTAB_METRIC_TYPES },
measuredAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) },
},
orderBy: { measuredAt: "asc" },
select: {
type: true,
value: true,
measuredAt: true,
source: true,
deviceType: true,
},
// A year of steps, heart-rate variability and CGM glucose is far too
// many readings to hold as objects (#1023). The crosstab only needs
// each day's sum and count per source and device, which is what the
// per-source picker decides on, so the fold runs in SQL. Days are the
// Berlin days `metricDayMap` keys on.
readSourceDayAggregates({
userId,
types: PHASE_CROSSTAB_METRIC_TYPES,
since: new Date(Date.parse(`${from}T00:00:00Z`)),
timeZone: "Europe/Berlin",
}),
prisma.user.findUnique({
where: { id: userId },
@@ -285,8 +281,9 @@ export async function buildCycleSnapshotBlock(
);
const measurements: CrossMetricMeasurement[] = measurementRows.map((m) => ({
type: m.type,
value: m.value,
measuredAt: m.measuredAt,
value: m.sum,
count: m.n,
measuredAt: m.firstAt,
source: m.source,
deviceType: m.deviceType,
}));
+1 -1
View File
@@ -310,7 +310,7 @@ export async function extractAndStoreFacts(
const result = await runCompletion({
userId,
cacheAction: "coach.facts",
consentSurface: "coach",
capability: "coach",
systemPrompt: extractionSystemPrompt(opts?.locale),
userPrompt: buildUserPrompt(turns, activeTexts),
temperature: 0.2,
+1 -1
View File
@@ -335,7 +335,7 @@ export async function extractAndStorePlanProposals(
const result = await runCompletion({
userId,
cacheAction: "coach.plans",
consentSurface: "coach",
capability: "coach",
systemPrompt: extractionSystemPrompt(opts?.locale),
userPrompt: buildUserPrompt(turns, existingSignatures),
temperature: 0.2,
+10 -35
View File
@@ -28,13 +28,14 @@
* mapped kind OR the superset `ai_full` grant satisfies the gate.
*/
import { latestActiveReceipt } from "@/lib/consent/receipts";
import { documentAutoReadEnabled } from "@/lib/documents/document-settings";
import type { ConsentKind } from "@/lib/validations/consent";
import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
/**
* The two AI surfaces that egress PHI. Each maps to the consent kind the iOS
* client collects for it; `ai_full` (the master grant) satisfies either.
* The two AI surfaces that egress PHI through a chain. Each maps to the consent
* kind the iOS client collects for it; `ai_full` (the master grant) satisfies
* either. Reading documents has its own kind (`ai_extraction`), checked by the
* capability egress re-check rather than here.
*/
export type ConsentSurface = "coach" | "insights";
@@ -51,7 +52,7 @@ const SERVER_MANAGED_PROVIDER_TYPES: ReadonlySet<string> = new Set([
/**
* Error thrown when an external-LLM egress on a server-managed key is
* attempted without an active consent receipt. Mirrors `AssistantDisabledError`
* attempted without an active consent receipt. Mirrors the AI capability refusal
* so the api-handler renders the same 403 envelope shape the iOS client
* already branches on:
*
@@ -141,7 +142,11 @@ export async function assertConsentForChain(args: {
* AI routes call the single resolved provider directly (no runner cascade), so
* the exact egress is the picked provider. A `local` pick never leaves the
* machine and stays ungated; EVERY external pick (codex, BYOK openai/anthropic,
* the operator's admin key) requires an active document-class consent receipt.
* the operator's admin key) requires an active extraction receipt
* (`ai_extraction` or `ai_full`). The check itself runs through the capability
* egress re-check (`aiEgressRefusal` in `@/lib/ai/capabilities/egress`), which
* reads the receipt kinds from the capability table; the auto-read toggle is a
* trigger, never the consent, so a revoked receipt wins over a toggle left on.
*/
const LOCAL_ONLY_PROVIDER_TYPES: ReadonlySet<string> = new Set(["local"]);
@@ -153,33 +158,3 @@ const LOCAL_ONLY_PROVIDER_TYPES: ReadonlySet<string> = new Set(["local"]);
export function isExternalDocumentEgress(providerType: string): boolean {
return !LOCAL_ONLY_PROVIDER_TYPES.has(providerType);
}
/**
* Enforce the document-class consent precondition for the provider that will
* actually receive the document. No-op for a `local` pick (nothing leaves the
* machine); for any external pick, throw `ConsentRequiredError` unless an active
* receipt of the surface's mapped kind (or `ai_full`) is on file.
*
* ONE relaxation: the per-user `documentsAutoAiRead` opt-in. Flipping it ON is
* itself the standing consent act (the toggle write also mints an `ai_full`
* receipt for the audit trail), so the gate short-circuits an external pick when
* it is ON — that is what removes the per-document friction ("upload and the AI
* just reads it, no 80 switches"). When it is OFF the branch is inert and the
* gate behaves exactly as shipped: no external egress without an explicit
* receipt. The short-circuit is checked BEFORE the receipt read and ONLY for an
* external pick — a `local` pick is already ungated above, so the toggle can
* never widen egress that was not already external.
*
* Call this AFTER the document provider is picked and BEFORE the first
* `generateCompletion` on it.
*/
export async function assertDocumentEgressConsent(args: {
userId: string;
providerType: string;
surface: ConsentSurface;
}): Promise<void> {
if (!isExternalDocumentEgress(args.providerType)) return;
if (await documentAutoReadEnabled(args.userId)) return;
if (await hasActiveConsentForSurface(args.userId, args.surface)) return;
throw new ConsentRequiredError(args.surface);
}
+2 -20
View File
@@ -26,7 +26,6 @@ import {
selectorNamesAnAccount,
type ActingCarrier,
} from "./auth/acting-carrier";
import { AssistantDisabledError } from "./feature-flags";
import { AiUnavailableError } from "./ai/capabilities/refusal";
import { ConsentRequiredError } from "./ai/consent-guard";
import { SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE } from "./mcp/oauth/config";
@@ -246,27 +245,10 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
{ data: null, error: error.message, meta: error.meta },
{ status: error.status },
);
} else if (error instanceof AssistantDisabledError) {
// v1.4.31 — operator has disabled the assistant surface.
// The 403 + `errorCode: "assistant.disabled.<surface>"`
// envelope is locked per
// `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5.
// Older iOS clients that don't know the errorCode surface
// this as a generic 403; v1.4.31+ clients can branch on the
// errorCode to render an inline operator-disabled notice.
evt.setError(error);
response = NextResponse.json(
{
data: null,
error: error.message,
meta: { errorCode: error.errorCode },
},
{ status: 403 },
);
} else if (error instanceof ConsentRequiredError) {
// v1.12.1 — server-side consent gate before external-LLM PHI
// egress on the operator's server-managed key. Mirrors the
// AssistantDisabledError envelope (403 + meta.errorCode) so the
// egress on the operator's server-managed key. 403 +
// meta.errorCode, like the capability refusal above, so the
// iOS client renders an inline "grant consent" notice instead of
// a generic failure.
evt.setError(error);
+1 -39
View File
@@ -1,7 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// `createReceipt` + `revokeLatest` run their supersede/revoke logic inside
// a transaction. Run the callback against the same mock proxy. The Prisma
// `createReceipt` runs its supersede logic inside a transaction. Run the callback against the same mock proxy. The Prisma
// `$transaction` signature is generic; the mock takes a loose callback.
type TxFn = (tx: unknown) => unknown;
@@ -26,7 +25,6 @@ import {
createReceipt,
latestActiveReceipt,
latestActiveReceiptsByKind,
revokeLatest,
} from "../receipts";
const $transaction = vi.mocked(prisma.$transaction) as unknown as {
@@ -196,39 +194,3 @@ describe("latestActiveReceiptsByKind", () => {
expect(result.ai_insights_only).toBeUndefined();
});
});
describe("revokeLatest", () => {
it("atomically revokes the active row via updateMany and returns it", async () => {
const now = new Date("2026-05-18T11:00:00.000Z");
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
count: 1,
} as never);
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(
row({ revokedAt: now }) as never,
);
const result = await revokeLatest("user-1", "ai_full", now);
expect(prisma.consentReceipt.updateMany).toHaveBeenCalledWith({
where: { userId: "user-1", kind: "ai_full", revokedAt: null },
data: { revokedAt: now },
});
// Re-reads the just-revoked row so the audit log keeps the receipt id.
expect(prisma.consentReceipt.findFirst).toHaveBeenCalledWith({
where: { userId: "user-1", kind: "ai_full", revokedAt: now },
orderBy: { createdAt: "desc" },
});
expect(result?.revokedAt).toEqual(now);
});
it("returns null without re-reading when no active receipt exists", async () => {
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
count: 0,
} as never);
const result = await revokeLatest("user-1", "ai_full");
expect(result).toBeNull();
expect(prisma.consentReceipt.findFirst).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,119 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// The revoke and the purge run inside one transaction; the mock runs the
// callback against the same proxy. Atomicity against a real database is
// pinned by `tests/integration/consent-withdrawal-purge.test.ts`.
type TxFn = (tx: unknown) => unknown;
vi.mock("@/lib/db", () => {
const consentReceipt = {
updateMany: vi.fn(),
findFirst: vi.fn(),
count: vi.fn(),
};
return {
prisma: {
consentReceipt,
$transaction: vi.fn((fn: TxFn) => fn({ consentReceipt })),
},
};
});
import { prisma } from "@/lib/db";
import { withdrawConsent, type AiTextPurgeCounts } from "../withdrawal";
const COUNTS: AiTextPurgeCounts = {
statusNotes: 3,
briefing: 1,
narratives: 2,
reactionLines: 1,
workoutParagraphs: 1,
};
const now = new Date("2026-09-20T08:00:00.000Z");
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.$transaction).mockImplementation(((fn: TxFn) =>
fn({ consentReceipt: prisma.consentReceipt })) as never);
});
/** Receipts active before the call, by kind. */
function active(kinds: string[]) {
const live = new Set(kinds);
vi.mocked(prisma.consentReceipt.updateMany).mockImplementation((async (args: {
where: { kind: string };
}) => {
const had = live.delete(args.where.kind);
return { count: had ? 1 : 0 };
}) as never);
vi.mocked(prisma.consentReceipt.findFirst).mockImplementation((async (args: {
where: { kind: string };
}) => ({ id: `r-${args.where.kind}`, kind: args.where.kind })) as never);
vi.mocked(prisma.consentReceipt.count).mockImplementation(
(async (args: { where: { kind: { in: string[] } } }) =>
args.where.kind.in.filter((k) => live.has(k)).length) as never,
);
}
describe("withdrawConsent", () => {
it("revokes each active kind conditionally and re-reads it for the audit trail", async () => {
active(["ai_coach"]);
const purge = vi.fn();
const result = await withdrawConsent("u1", ["ai_coach"], now, purge);
expect(prisma.consentReceipt.updateMany).toHaveBeenCalledWith({
where: { userId: "u1", kind: "ai_coach", revokedAt: null },
data: { revokedAt: now },
});
expect(result.revoked.map((r) => r.kind)).toEqual(["ai_coach"]);
});
it("purges when the revoke leaves no receipt covering the analysis", async () => {
active(["ai_full"]);
const purge = vi.fn(async () => COUNTS);
const result = await withdrawConsent("u1", ["ai_full"], now, purge);
expect(purge).toHaveBeenCalledTimes(1);
expect(result.purged).toEqual(COUNTS);
});
it("keeps the text while another receipt still covers the analysis", async () => {
active(["ai_full", "ai_insights_only"]);
const purge = vi.fn(async () => COUNTS);
const result = await withdrawConsent("u1", ["ai_full"], now, purge);
expect(purge).not.toHaveBeenCalled();
expect(result.purged).toBeNull();
});
it("never purges for a revoke that did not touch the analysis (Coach or extraction only)", async () => {
active(["ai_coach", "ai_extraction"]);
const purge = vi.fn(async () => COUNTS);
const result = await withdrawConsent(
"u1",
["ai_coach", "ai_extraction"],
now,
purge,
);
expect(purge).not.toHaveBeenCalled();
expect(result.purged).toBeNull();
expect(result.revoked).toHaveLength(2);
});
it("does nothing, and purges nothing, when no receipt was active", async () => {
active([]);
const purge = vi.fn(async () => COUNTS);
const result = await withdrawConsent("u1", ["ai_full"], now, purge);
expect(result).toEqual({ revoked: [], purged: null });
expect(prisma.consentReceipt.findFirst).not.toHaveBeenCalled();
expect(purge).not.toHaveBeenCalled();
});
it("propagates a purge failure out of the transaction (the revoke rolls back with it)", async () => {
active(["ai_insights_only"]);
const purge = vi.fn(async () => {
throw new Error("purge failed");
});
await expect(
withdrawConsent("u1", ["ai_insights_only"], now, purge),
).rejects.toThrow("purge failed");
});
});
+23 -37
View File
@@ -2,10 +2,10 @@
* v1.4.40 SB-10 — AI consent receipts CRUD helper.
*
* Append-only audit trail. Every grant + revoke mints a fresh row;
* no updates, no deletes outside of cascade-on-user-delete. The
* `revokeLatest` helper writes the current row's `revokedAt` and
* returns it — that single field flip is the only mutation the
* helper performs. A subsequent re-grant inserts a new row, so the
* no updates, no deletes outside of cascade-on-user-delete. A
* revocation (`withdrawConsent` in `./withdrawal`) writes the current
* row's `revokedAt` — that single field flip is the only mutation a
* receipt ever sees. A subsequent re-grant inserts a new row, so the
* historical chain stays intact for legal review.
*
* Source of truth for "is AI active right now?" is
@@ -19,6 +19,25 @@ import { isP2002 } from "@/lib/prisma-errors";
export type ConsentReceipt = ConsentReceiptModel;
/**
* The consent kind for reading documents, lab report scans and typed
* medication text with an external model. Minted by switching the document
* auto-read on; required (or the master `ai_full`) by every extraction path.
* It does not satisfy the Coach or the analysis.
*/
export const AI_EXTRACTION_CONSENT_KIND = "ai_extraction" satisfies ConsentKind;
/**
* The kinds whose active receipt authorises model-written analysis over the
* person's own readings (briefing, status notes, narratives, workout
* paragraphs, reaction lines). When none of these is left active, the
* regenerable text those surfaces stored is purged.
*/
export const INSIGHTS_CONSENT_KINDS: readonly ConsentKind[] = [
"ai_insights_only",
"ai_full",
];
/**
* Insert a fresh consent receipt for (userId, kind). The caller has
* already Zod-validated `artefact` (≤ 64 KB) and `signedAt` (ISO-8601).
@@ -119,36 +138,3 @@ export async function latestActiveReceiptsByKind(
}
return out;
}
/**
* Mark the active receipt for (userId, kind) as revoked. Returns the
* updated row, or `null` if no active receipt exists.
*
* v1.16.16 — the partial unique index guarantees at most one active row
* per (user, kind), so a single atomic `updateMany` over the active
* predicate revokes "the" active receipt without a separate read. Two
* concurrent revokes converge: the first flips `revoked_at`, the second's
* predicate no longer matches and writes nothing. The follow-up read
* returns the row the caller (audit log) needs; it is `null` only when no
* active receipt existed at all.
*/
export async function revokeLatest(
userId: string,
kind: ConsentKind,
now: Date = new Date(),
): Promise<ConsentReceipt | null> {
return prisma.$transaction(async (tx) => {
const updated = await tx.consentReceipt.updateMany({
where: { userId, kind, revokedAt: null },
data: { revokedAt: now },
});
if (updated.count === 0) return null;
// Re-read the just-revoked row (revoked at exactly `now`) so the audit
// trail keeps the receipt id. The active predicate guaranteed a single
// row, so this resolves it unambiguously.
return tx.consentReceipt.findFirst({
where: { userId, kind, revokedAt: now },
orderBy: { createdAt: "desc" },
});
});
}
Loaded 100 of 256 files, more files were not shown because too many files have changed in this diff. Show more