mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
commit
c509afeb2e
256 files changed
+13048
-4642
No files matched your search
+393
-193
File diff suppressed because it is too large.
Load diff
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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.%';
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,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,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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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()}`,
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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
@@ -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
Reference in new issue
Block a user