diff --git a/CHANGELOG.md b/CHANGELOG.md index f31026925..7bfbce1ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.37.15] — 2026-08-13 + +### Fixed + +- Re-running the assessment no longer reports a failure while the server is still writing the result. When a refresh takes longer than the browser is willing to wait, the page now keeps watching quietly until the new assessment lands, then swaps it in and confirms. Only a refresh that provably failed says so. Before, a slow refresh showed an error toast and the page held on to the old assessment until the next day. +- Document previews show again. The security policy the app sends to the browser did not cover the way previews load since the sharing fence, so PDF previews were blocked outright and image thumbnails quietly fell back to their icons. The policy now admits exactly the app's own decrypted documents and nothing else. +- A document uploaded while automatic AI reading is switched off no longer claims a summary is being generated forever. The upload stopped queueing summary work it is not allowed to do, a stale claim heals itself on the next pass, and documents already stuck in that state are repaired by the update. The manual summary action is offered instead, as it should have been. +- When reading a document for search fails, the document now says why, in plain words: a scan with no readable text, a PDF whose pages would not render (scanned fax compression formats are a known cause), a provider error, or a provider that cannot take PDF files. Before, a failed read looked identical to one that never happened. An AI answer with no text in it also no longer counts as a successful read. +- The "index all documents" confirmation shows the real number of documents queued instead of the word "true". + ## [1.37.14] — 2026-08-13 ### Fixed diff --git a/bundle-budget.json b/bundle-budget.json index e4906cdd3..c2415210d 100644 --- a/bundle-budget.json +++ b/bundle-budget.json @@ -1,11 +1,11 @@ { - "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling.", + "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling.", "routesKbGz": { "/page": 460, "/insights/page": 445, "/measurements/page": 445, "/insights/mood/page": 460 }, - "totalClientKbGz": 3428, + "totalClientKbGz": 3452, "maxRechartsChunks": 1 } diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 8562757f0..229ac3174 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.37.14 + version: 1.37.15 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -12792,7 +12792,8 @@ paths: immediately; the work runs off-request on the queue. responses: "200": - description: Whether a backfill job was enqueued. + description: How many live documents the enqueued backfill still has to index (0 when everything is indexed or no job + was created). content: application/json: schema: @@ -37856,6 +37857,22 @@ components: - local-pdf - local-ocr - type: "null" + lastIndexAttemptAt: + anyOf: + - type: string + - type: "null" + lastIndexOutcome: + anyOf: + - type: string + enum: + - local-empty + - local-unsupported + - decrypt-error + - raster-failed + - provider-error + - pdf-needs-anthropic + - empty-transcription + - type: "null" hasThumbnail: type: boolean createdAt: @@ -37881,6 +37898,8 @@ components: - servingClass - hasContentIndex - contentIndexSource + - lastIndexAttemptAt + - lastIndexOutcome - hasThumbnail - createdAt - updatedAt @@ -37892,7 +37911,9 @@ components: download-only (`attachment`); render/download by it, never by MIME guess. `hasContentIndex` is true when the document has a content-search index (auto-indexed on upload); `contentIndexSource` says how that index was produced — `vision` means an AI provider read the original, the other values are provider-free local - extractions, and it is null when `hasContentIndex` is false. `hasThumbnail` is true when a preview thumbnail has + extractions, and it is null when `hasContentIndex` is false. `lastIndexAttemptAt` is when the most recent index + attempt finished (successful or not; null when none ran); `lastIndexOutcome` says why that attempt produced no + index, and is null when the attempt succeeded or none ran. `hasThumbnail` is true when a preview thumbnail has been rendered in the background (fetch it from `/api/documents/inbound/{id}/thumbnail`); false means no preview yet or an unsupported type — show a placeholder. Treat an unknown `kind` value as OTHER when decoding. DocumentConditionLink: @@ -38204,6 +38225,22 @@ components: - local-pdf - local-ocr - type: "null" + lastIndexAttemptAt: + anyOf: + - type: string + - type: "null" + lastIndexOutcome: + anyOf: + - type: string + enum: + - local-empty + - local-unsupported + - decrypt-error + - raster-failed + - provider-error + - pdf-needs-anthropic + - empty-transcription + - type: "null" hasThumbnail: type: boolean createdAt: @@ -38249,6 +38286,8 @@ components: - servingClass - hasContentIndex - contentIndexSource + - lastIndexAttemptAt + - lastIndexOutcome - hasThumbnail - createdAt - updatedAt @@ -38680,7 +38719,9 @@ components: type: object properties: enqueued: - type: boolean + type: integer + minimum: 0 + maximum: 9007199254740991 required: - enqueued additionalProperties: false diff --git a/messages/de.json b/messages/de.json index 2d115aed0..70cf23d03 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4172,6 +4172,8 @@ "regenerateRateLimited": "Aktualisierungslimit erreicht — bitte später erneut versuchen.", "regenerateUnavailable": "Keine neue Analyse erhalten — bitte später erneut versuchen.", "regenerateError": "Aktualisierung läuft noch — bitte gleich erneut versuchen.", + "regenerateSettling": "Die Einschätzung wird noch erstellt", + "regenerateSettleFailed": "Die Aktualisierung konnte nicht abgeschlossen werden. Bitte später erneut versuchen.", "deviceScore": { "learning": "Deine Basislinie wird noch gelernt – ein paar weitere Nächte vervollständigen den Verlauf.", "average": "30-Tage-Durchschnitt {value}", @@ -9504,6 +9506,17 @@ "generate": "Zusammenfassung erstellen", "generating": "Wird erstellt…" }, + "index": { + "outcome": { + "local-empty": "Es wurde kein lesbarer Text gefunden. Das sieht nach einem Scan aus; ein KI-Vision-Anbieter kann ihn lesen.", + "local-unsupported": "Dieser Dateityp kann für die Suche nicht gelesen werden.", + "decrypt-error": "Die gespeicherte Datei konnte zum Lesen nicht geöffnet werden.", + "raster-failed": "Die PDF-Seiten konnten zum Lesen nicht gerendert werden. Gescannte Fax-Kompressionsformate sind eine bekannte Ursache.", + "provider-error": "Der KI-Anbieter konnte dieses Dokument beim letzten Versuch nicht lesen.", + "pdf-needs-anthropic": "Der eingerichtete KI-Anbieter kann keine PDF-Dateien lesen. Ein Claude-Anbieter oder lokale OCR kann es.", + "empty-transcription": "Der KI-Anbieter hat für dieses Dokument keinen Text geliefert." + } + }, "loadError": "Dieses Dokument konnte nicht geladen werden.", "saveError": "Die Änderung konnte nicht gespeichert werden.", "deleteError": "Das Dokument konnte nicht gelöscht werden.", diff --git a/messages/en.json b/messages/en.json index 15f1df75d..ef0b410b2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4172,6 +4172,8 @@ "regenerateRateLimited": "Refresh limit reached — please try again later.", "regenerateUnavailable": "No fresh analysis arrived — please try again later.", "regenerateError": "Still refreshing — please try again in a moment.", + "regenerateSettling": "Your assessment is still being prepared", + "regenerateSettleFailed": "The refresh could not be completed. Please try again later.", "deviceScore": { "learning": "Still learning your baseline — a few more nights will fill in the trend.", "average": "30-day average {value}", @@ -9504,6 +9506,17 @@ "generate": "Generate summary", "generating": "Generating…" }, + "index": { + "outcome": { + "local-empty": "No readable text was found. This looks like a scan; an AI vision provider can read it.", + "local-unsupported": "This file type can't be read for search.", + "decrypt-error": "The stored file couldn't be opened for reading.", + "raster-failed": "The PDF pages couldn't be rendered for reading. Scanned fax compression formats are a known cause.", + "provider-error": "The AI provider couldn't read this document on the last try.", + "pdf-needs-anthropic": "The configured AI provider can't read PDF files. A Claude provider or local OCR can.", + "empty-transcription": "The AI provider returned no text for this document." + } + }, "loadError": "Couldn't load this document.", "saveError": "Couldn't save the change.", "deleteError": "Couldn't delete the document.", diff --git a/messages/es.json b/messages/es.json index 13c66febd..82a37da51 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4172,6 +4172,8 @@ "regenerateRateLimited": "Límite de actualizaciones alcanzado: inténtalo de nuevo más tarde.", "regenerateUnavailable": "No llegó un análisis nuevo: inténtalo de nuevo más tarde.", "regenerateError": "Aún se está actualizando: inténtalo de nuevo en un momento.", + "regenerateSettling": "La valoración todavía se está preparando", + "regenerateSettleFailed": "No se pudo completar la actualización. Inténtalo de nuevo más tarde.", "deviceScore": { "learning": "Aún estamos aprendiendo tu línea base: unas noches más completarán la tendencia.", "average": "Media de 30 días {value}", @@ -9504,6 +9506,17 @@ "generate": "Generar resumen", "generating": "Generando…" }, + "index": { + "outcome": { + "local-empty": "No se encontró texto legible. Parece un documento escaneado; un proveedor de visión de IA puede leerlo.", + "local-unsupported": "Este tipo de archivo no se puede leer para la búsqueda.", + "decrypt-error": "El archivo guardado no se pudo abrir para su lectura.", + "raster-failed": "Las páginas del PDF no se pudieron procesar para su lectura. Los formatos de compresión de fax escaneados son una causa conocida.", + "provider-error": "El proveedor de IA no pudo leer este documento en el último intento.", + "pdf-needs-anthropic": "El proveedor de IA configurado no puede leer archivos PDF. Un proveedor Claude o el OCR local sí pueden.", + "empty-transcription": "El proveedor de IA no devolvió texto para este documento." + } + }, "loadError": "No se pudo cargar este documento.", "saveError": "No se pudo guardar el cambio.", "deleteError": "No se pudo eliminar el documento.", diff --git a/messages/fr.json b/messages/fr.json index dd414bccc..5eeb7c25e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4172,6 +4172,8 @@ "regenerateRateLimited": "Limite d'actualisation atteinte — réessayez plus tard.", "regenerateUnavailable": "Aucune nouvelle analyse reçue — réessayez plus tard.", "regenerateError": "Actualisation en cours — réessayez dans un instant.", + "regenerateSettling": "L’évaluation est encore en cours de préparation", + "regenerateSettleFailed": "L’actualisation n’a pas pu aboutir. Réessayez plus tard.", "deviceScore": { "learning": "Nous apprenons encore votre référence : quelques nuits de plus compléteront la tendance.", "average": "Moyenne sur 30 jours {value}", @@ -9504,6 +9506,17 @@ "generate": "Générer le résumé", "generating": "Génération…" }, + "index": { + "outcome": { + "local-empty": "Aucun texte lisible n’a été trouvé. Cela ressemble à un scan ; un fournisseur de vision IA peut le lire.", + "local-unsupported": "Ce type de fichier ne peut pas être lu pour la recherche.", + "decrypt-error": "Le fichier stocké n’a pas pu être ouvert pour la lecture.", + "raster-failed": "Les pages du PDF n’ont pas pu être rendues pour la lecture. Les formats de compression de fax numérisés en sont une cause connue.", + "provider-error": "Le fournisseur d’IA n’a pas pu lire ce document lors de la dernière tentative.", + "pdf-needs-anthropic": "Le fournisseur d’IA configuré ne peut pas lire les fichiers PDF. Un fournisseur Claude ou l’OCR local le peut.", + "empty-transcription": "Le fournisseur d’IA n’a renvoyé aucun texte pour ce document." + } + }, "loadError": "Impossible de charger ce document.", "saveError": "Impossible d'enregistrer la modification.", "deleteError": "Impossible de supprimer le document.", diff --git a/messages/it.json b/messages/it.json index 7ee83cfc5..203b69f05 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4172,6 +4172,8 @@ "regenerateRateLimited": "Limite di aggiornamento raggiunto — riprova più tardi.", "regenerateUnavailable": "Nessuna nuova analisi ricevuta — riprova più tardi.", "regenerateError": "Aggiornamento ancora in corso — riprova tra un momento.", + "regenerateSettling": "La valutazione è ancora in preparazione", + "regenerateSettleFailed": "Non è stato possibile completare l’aggiornamento. Riprova più tardi.", "deviceScore": { "learning": "Stiamo ancora imparando la tua linea di base: qualche notte in più completerà l'andamento.", "average": "Media a 30 giorni {value}", @@ -9504,6 +9506,17 @@ "generate": "Genera riepilogo", "generating": "Generazione…" }, + "index": { + "outcome": { + "local-empty": "Non è stato trovato testo leggibile. Sembra una scansione; un provider di visione IA può leggerla.", + "local-unsupported": "Questo tipo di file non può essere letto per la ricerca.", + "decrypt-error": "Il file archiviato non può essere aperto per la lettura.", + "raster-failed": "Le pagine del PDF non possono essere elaborate per la lettura. I formati di compressione fax digitalizzati sono una causa nota.", + "provider-error": "Il provider IA non è riuscito a leggere questo documento all’ultimo tentativo.", + "pdf-needs-anthropic": "Il provider IA configurato non può leggere i file PDF. Un provider Claude o l’OCR locale sì.", + "empty-transcription": "Il provider IA non ha restituito testo per questo documento." + } + }, "loadError": "Impossibile caricare questo documento.", "saveError": "Impossibile salvare la modifica.", "deleteError": "Impossibile eliminare il documento.", diff --git a/messages/pl.json b/messages/pl.json index 91caae79a..e0d72c8e7 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4172,6 +4172,8 @@ "regenerateRateLimited": "Osiągnięto limit odświeżeń — spróbuj ponownie później.", "regenerateUnavailable": "Nie otrzymano nowej analizy — spróbuj ponownie później.", "regenerateError": "Trwa odświeżanie — spróbuj ponownie za chwilę.", + "regenerateSettling": "Ocena jest wciąż przygotowywana", + "regenerateSettleFailed": "Nie udało się ukończyć odświeżania. Spróbuj ponownie później.", "deviceScore": { "learning": "Wciąż poznajemy Twoją linię bazową – kilka kolejnych nocy uzupełni trend.", "average": "Średnia z 30 dni {value}", @@ -9504,6 +9506,17 @@ "generate": "Wygeneruj podsumowanie", "generating": "Generowanie…" }, + "index": { + "outcome": { + "local-empty": "Nie znaleziono czytelnego tekstu. To wygląda na skan; dostawca wizji AI może go odczytać.", + "local-unsupported": "Tego typu pliku nie można odczytać na potrzeby wyszukiwania.", + "decrypt-error": "Nie udało się otworzyć zapisanego pliku do odczytu.", + "raster-failed": "Nie udało się przetworzyć stron PDF do odczytu. Znaną przyczyną są skany w faksowych formatach kompresji.", + "provider-error": "Dostawca AI nie zdołał odczytać tego dokumentu przy ostatniej próbie.", + "pdf-needs-anthropic": "Skonfigurowany dostawca AI nie odczytuje plików PDF. Poradzi sobie z tym dostawca Claude lub lokalny OCR.", + "empty-transcription": "Dostawca AI nie zwrócił tekstu dla tego dokumentu." + } + }, "loadError": "Nie udało się wczytać tego dokumentu.", "saveError": "Nie udało się zapisać zmiany.", "deleteError": "Nie udało się usunąć dokumentu.", diff --git a/package.json b/package.json index 461dd2c32..8cbb9ff70 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.37.14", + "version": "1.37.15", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/prisma/migrations/0333_summary_pending_heal_index_outcome/migration.sql b/prisma/migrations/0333_summary_pending_heal_index_outcome/migration.sql new file mode 100644 index 000000000..5740eca26 --- /dev/null +++ b/prisma/migrations/0333_summary_pending_heal_index_outcome/migration.sql @@ -0,0 +1,22 @@ +-- Refs #788 — heal phantom summary PENDING rows. +-- `enqueueDocumentSummary` used to claim PENDING even when the owner's +-- auto-read opt-in was OFF; the job's opt-out branch then left the state +-- alone, so the detail view said "being generated" forever. Both writers now +-- agree (the enqueue end no longer claims what the job cannot resolve, the +-- job end heals PENDING→NONE on opt-out); this one-time data fix drains the +-- rows already stranded. Safe by construction: READY / WITHHELD / UNAVAILABLE +-- are never touched, and a job that is genuinely mid-flight rewrites its +-- terminal state after this UPDATE anyway. +UPDATE "inbound_documents" +SET "summary_state" = 'NONE' +WHERE "summary_state" = 'PENDING'; + +-- Refs #776 — record what became of a document's content-index attempt. +-- Additive, nullable, no backfill: existing rows read NULL ("no attempt +-- recorded"), which is exactly true for them — outcomes only exist from the +-- release that writes them. `last_index_outcome` is a closed string enum +-- enforced in code (see `DocumentIndexOutcomeValue`); NULL after a +-- successful attempt, since the `document_content_index` row itself is the +-- success signal. +ALTER TABLE "inbound_documents" ADD COLUMN "last_index_attempt_at" TIMESTAMP(3); +ALTER TABLE "inbound_documents" ADD COLUMN "last_index_outcome" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6b3692997..25c7d873d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -7536,6 +7536,22 @@ model InboundDocument { /// and one mid-flight all leave it null. NONE is the default, which keeps /// every pre-existing row correct — none of them were ever attempted. summaryState DocumentSummaryState @default(NONE) @map("summary_state") + /// When the most recent content-index attempt finished (auto job, corpus + /// backfill, or the manual index route — they share one recorder), + /// successful or not. Null until any attempt ran. Diagnostic state the + /// detail view pairs with `lastIndexOutcome`; carried verbatim in the + /// disaster-recovery backup like its summary siblings. + lastIndexAttemptAt DateTime? @map("last_index_attempt_at") + /// Why the most recent index attempt produced NO index — a closed string + /// enum (`DocumentIndexOutcomeValue` in + /// `src/lib/validations/inbound-documents.ts`): local-empty | + /// local-unsupported | decrypt-error | raster-failed | provider-error | + /// pdf-needs-anthropic | empty-transcription. Null after a successful + /// attempt (the `DocumentContentIndex` row itself is the success signal) + /// and before the first attempt. Lets the detail view tell "never tried" + /// from "tried and here is why it failed" instead of showing a bare + /// not-indexed state for both. + lastIndexOutcome String? @map("last_index_outcome") facts ExtractedFact[] conditionLinks DocumentConditionLink[] diff --git a/public/sw.js b/public/sw.js index 1d5aa27db..e702c269b 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.37.14"; + /* @sw-version-fallback */ "v1.37.15"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/__tests__/proxy-csp-blob-preview.test.ts b/src/__tests__/proxy-csp-blob-preview.test.ts new file mode 100644 index 000000000..06811925f --- /dev/null +++ b/src/__tests__/proxy-csp-blob-preview.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * Refs #787 — the record fence made every vault preview load through + * `useFencedObjectUrl`, i.e. `blob:` URLs. The app CSP had no `frame-src`, so + * `default-src 'self'` blocked the PDF preview iframe loudly, and + * `img-src 'self' data:` degraded every blob thumbnail to an icon silently. + * + * These tests pin the fix exactly: + * - `frame-src` exists and admits precisely 'self' and blob: — nothing else. + * - `img-src` admits blob: alongside 'self' and data:. + * - The boundaries that make blob: safe stay untouched: `object-src 'none'`, + * `frame-ancestors 'none'`, `default-src 'self'`, nonce-bound script-src. + * - The vault SERVE route keeps its own stricter document CSP unchanged. + * + * Security rationale (mirrors the comment in proxy.ts): a `blob:` URL can only + * be minted by same-origin script, and script execution stays nonce-bound, so + * admitting blob: admits exactly the app's own decrypted documents. + */ + +vi.mock("@/lib/process-type", () => ({ + shouldRunWeb: () => true, +})); + +import { proxy } from "../proxy"; + +function setNodeEnv(value: "development" | "production") { + vi.stubEnv("NODE_ENV", value); +} + +beforeEach(() => { + setNodeEnv("production"); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +function makeRequest(pathname: string): NextRequest { + return new NextRequest(`http://localhost${pathname}`, { + headers: { cookie: "healthlog_session=sess-1" }, + }); +} + +/** Parse one directive's source list out of a CSP header string. */ +function directive(csp: string, name: string): string[] | null { + const match = csp + .split(";") + .map((part) => part.trim()) + .find((part) => part.startsWith(`${name} `) || part === name); + if (!match) return null; + return match.split(/\s+/).slice(1); +} + +describe("proxy.ts blob preview CSP (#787)", () => { + it("production frame-src admits exactly 'self' and blob:", () => { + const res = proxy(makeRequest("/documents")); + const csp = res.headers.get("content-security-policy") ?? ""; + expect(directive(csp, "frame-src")).toEqual(["'self'", "blob:"]); + }); + + it("production img-src admits blob: alongside 'self' and data:", () => { + const res = proxy(makeRequest("/documents")); + const csp = res.headers.get("content-security-policy") ?? ""; + const sources = directive(csp, "img-src"); + expect(sources).toContain("blob:"); + expect(sources).toContain("'self'"); + expect(sources).toContain("data:"); + }); + + it("production keeps the boundaries that make blob: safe", () => { + const res = proxy(makeRequest("/documents")); + const csp = res.headers.get("content-security-policy") ?? ""; + expect(directive(csp, "object-src")).toEqual(["'none'"]); + expect(directive(csp, "frame-ancestors")).toEqual(["'none'"]); + expect(directive(csp, "default-src")).toEqual(["'self'"]); + expect(csp).toMatch(/script-src 'self' 'nonce-/); + }); + + it("development frame-src admits exactly 'self' and blob:", () => { + setNodeEnv("development"); + const res = proxy(makeRequest("/documents")); + const csp = res.headers.get("content-security-policy") ?? ""; + expect(directive(csp, "frame-src")).toEqual(["'self'", "blob:"]); + }); + + it("development img-src admits blob:", () => { + setNodeEnv("development"); + const res = proxy(makeRequest("/documents")); + const csp = res.headers.get("content-security-policy") ?? ""; + expect(directive(csp, "img-src")).toContain("blob:"); + }); + + it("leaves the vault serve route's document CSP untouched (no frame-src, no blob:)", () => { + const res = proxy(makeRequest("/api/documents/inbound/doc-1/original")); + const csp = res.headers.get("content-security-policy") ?? ""; + expect(csp).toBe("default-src 'none'; frame-ancestors 'self';"); + }); +}); diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 52dc674fa..6bfb9b39b 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -1319,6 +1319,10 @@ const handler = apiHandler( ? new Date(document.summaryGeneratedAt) : null, summaryState: document.summaryState as never, + lastIndexAttemptAt: document.lastIndexAttemptAt + ? new Date(document.lastIndexAttemptAt) + : null, + lastIndexOutcome: document.lastIndexOutcome ?? null, createdAt: new Date(document.createdAt!), updatedAt: new Date(document.updatedAt!), })), diff --git a/src/app/api/documents/inbound/[id]/index/route.ts b/src/app/api/documents/inbound/[id]/index/route.ts index 072271263..d64166fe1 100644 --- a/src/app/api/documents/inbound/[id]/index/route.ts +++ b/src/app/api/documents/inbound/[id]/index/route.ts @@ -49,6 +49,7 @@ import { } from "@/lib/documents/ai-route-support"; import { maybeAutoStageLabFacts } from "@/lib/documents/auto-stage-labs"; import { upsertContentIndex } from "@/lib/documents/content-index"; +import { recordIndexAttempt } from "@/lib/documents/index-document"; import { DocumentDescribeError, transcribeDocument, @@ -93,6 +94,15 @@ async function finishIndex( source: "vision" | "text-ocr", tokenCount: number, ): Promise { + // Refs #776 — the manual route is the second writer of the attempt record + // (the auto job + backfill share `indexLoadedDocument`): a success clears + // any stored failure reason so the detail view stops explaining a problem + // that no longer exists. + await recordIndexAttempt(userId, documentId, { + indexed: true, + source, + tokenCount, + }); await auditLog("documents.inbound.index", { userId, ipAddress: getClientIp(request), @@ -183,21 +193,44 @@ async function handleVisionIndex( const vision = await prepareVisionInput(document, pick.pdfSupported); if (!vision.ok) { // Preparation failed before any provider dispatch — the slot goes back. + // Refs #776 — each failure is also recorded on the row so the detail view + // can explain the missing index after the toast is gone. await refundDocumentAiSlot(userId); if (vision.reason === "pdfNeedsAnthropic") { + await recordIndexAttempt(userId, document.id, { + indexed: false, + reason: "pdf-needs-anthropic", + }); return apiError( "PDF scanning needs a Claude vision provider; use local OCR instead.", 422, { errorCode: "documents.inbound.pdfNeedsAnthropic" }, ); } + if (vision.reason === "rasterFailed") { + await recordIndexAttempt(userId, document.id, { + indexed: false, + reason: "raster-failed", + }); + return apiError("The PDF pages couldn't be rendered for scanning.", 422, { + errorCode: "documents.inbound.extractFailed", + }); + } if (vision.reason === "fileType") { + await recordIndexAttempt(userId, document.id, { + indexed: false, + reason: "local-unsupported", + }); return apiError( "This document can't be scanned. Use local OCR (text mode).", 422, { errorCode: "documents.inbound.fileType" }, ); } + await recordIndexAttempt(userId, document.id, { + indexed: false, + reason: "decrypt-error", + }); return apiError("Couldn't read the stored document.", 422, { errorCode: "documents.inbound.extractFailed", }); @@ -230,6 +263,22 @@ async function handleVisionIndex( reservation.reserved, dateKey, ); + // Refs #776 — the empty-transcription guard, same contract as the auto + // path (`tryProviderIndex`): a provider answer with no text must never + // become a "successful" empty index. The spend stays charged (the + // provider was called); the honest answer is an error plus the recorded + // reason. + if (text.trim().length === 0) { + await recordIndexAttempt(userId, document.id, { + indexed: false, + reason: "empty-transcription", + }); + return apiError( + "The provider returned no text for this document. Try a clearer copy.", + 422, + { errorCode: "documents.inbound.extractFailed" }, + ); + } const { tokenCount } = await upsertContentIndex({ userId, documentId: document.id, @@ -240,6 +289,10 @@ async function handleVisionIndex( return finishIndex(request, userId, document.id, "vision", tokenCount); } catch (err) { await reconcileSpend(userId, reservation.reserved, 0, dateKey); + await recordIndexAttempt(userId, document.id, { + indexed: false, + reason: "provider-error", + }); if (err instanceof DocumentDescribeError) { return apiError("Couldn't read the document. Try a clearer copy.", 422, { errorCode: "documents.inbound.extractFailed", diff --git a/src/app/api/documents/inbound/reindex/__tests__/route.test.ts b/src/app/api/documents/inbound/reindex/__tests__/route.test.ts new file mode 100644 index 000000000..ddcf1900b --- /dev/null +++ b/src/app/api/documents/inbound/reindex/__tests__/route.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * Refs #776 — the reindex trigger's wire honesty. The client types + * `{ enqueued: number }` and interpolates it into "Indexing {count} + * document(s)"; the route used to return `Boolean(jobId)`, so the toast said + * "Indexing true document(s)". Pins: `enqueued` is a NUMBER — the count of + * live documents still lacking a content index (the same figure the usage + * gauge derives) when a job was created, and an honest 0 when none was. + */ + +vi.mock("@/lib/db", () => ({ + prisma: { + inboundDocument: { count: vi.fn() }, + documentContentIndex: { count: vi.fn() }, + }, +})); +vi.mock("@/lib/jobs/document-content-index-backfill", () => ({ + enqueueContentIndexBackfill: vi.fn(), +})); +vi.mock("@/lib/documents/provider-order", () => ({ + resolveDocumentVisionProvider: vi.fn(), +})); +vi.mock("@/lib/ai/consent-guard", () => ({ + assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined), + ConsentRequiredError: class ConsentRequiredError extends Error {}, +})); +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }), + rateLimitHeaders: () => ({}), +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { enqueueContentIndexBackfill } from "@/lib/jobs/document-content-index-backfill"; +import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order"; + +const SESSION_OK = { + session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; + +const PICK = { + chain: [{ providerType: "anthropic", instance: {} }], + pick: { + entry: { providerType: "anthropic", instance: {} }, + providerType: "anthropic", + pdfSupported: true, + }, +}; + +const req = () => + new NextRequest(new URL("http://localhost/api/documents/inbound/reindex"), { + method: "POST", + }); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(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); + vi.mocked(prisma.documentContentIndex.count).mockResolvedValue(4 as never); +}); + +describe("POST /api/documents/inbound/reindex", () => { + it("returns the remaining un-indexed count as a NUMBER, never a boolean", async () => { + const res = await POST(req() as never); + expect(res.status).toBe(200); + const body = await res.json(); + expect(typeof body.data.enqueued).toBe("number"); + expect(body.data.enqueued).toBe(3); + }); + + it("returns an honest 0 when no job was created", async () => { + vi.mocked(enqueueContentIndexBackfill).mockResolvedValue({ + enqueued: false, + }); + const res = await POST(req() as never); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.enqueued).toBe(0); + }); + + it("scopes both counts to the caller's LIVE documents (the gauge's own figures)", async () => { + await POST(req() as never); + expect(prisma.inboundDocument.count).toHaveBeenCalledWith({ + where: { userId: "user-1", deletedAt: null }, + }); + expect(prisma.documentContentIndex.count).toHaveBeenCalledWith({ + where: { userId: "user-1", document: { deletedAt: null } }, + }); + }); +}); diff --git a/src/app/api/documents/inbound/reindex/route.ts b/src/app/api/documents/inbound/reindex/route.ts index c8f97cf9b..de2a13ff8 100644 --- a/src/app/api/documents/inbound/reindex/route.ts +++ b/src/app/api/documents/inbound/reindex/route.ts @@ -11,6 +11,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard"; 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 { annotate } from "@/lib/logging/context"; @@ -58,16 +59,37 @@ export const POST = apiHandler(async (request) => { surface: "insights", }); - const { enqueued } = await enqueueContentIndexBackfill(user.id); + const { enqueued: jobCreated } = await enqueueContentIndexBackfill(user.id); + + // Wire honesty (#776): the client types `enqueued` as a NUMBER and + // interpolates it into "Indexing {count} document(s)" — a boolean here once + // rendered as "Indexing true document(s)". Report the count of live + // documents still lacking a content index (the exact figures the usage + // gauge already derives, same live-scoping: a soft-deleted document keeps + // its index row until hard purge, so both counts filter `deletedAt: null`). + // An honest 0 when no job was created — the client then says "everything is + // already indexed" instead of promising work nobody queued. + let enqueued = 0; + if (jobCreated) { + const [indexedCount, totalCount] = await Promise.all([ + prisma.documentContentIndex.count({ + where: { userId: user.id, document: { deletedAt: null } }, + }), + prisma.inboundDocument.count({ + where: { userId: user.id, deletedAt: null }, + }), + ]); + enqueued = Math.max(0, totalCount - indexedCount); + } await auditLog("documents.inbound.reindex", { userId: user.id, ipAddress: getClientIp(request), - details: { enqueued }, + details: { enqueued, jobCreated }, }); annotate({ action: { name: "documents.contentIndex.backfillEnqueue" }, - meta: { enqueued }, + meta: { enqueued, jobCreated }, }); return apiSuccess({ enqueued }); diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 1cd21297c..8c13b2c62 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -1,3 +1,16 @@ +/** + * Refs #786 — async-truth contract, server end (the client end lives in + * `src/components/insights/use-insights-advisor.ts`, `AdvisorSettleState`): + * the force POST generates INLINE and, on success, writes the fresh briefing + * to the user cache (`insightsCachedAt` advances) — even when the client has + * long since aborted its 45 s request. A failed inline generation writes a + * failure marker the GET reports as `generationFailed`. The client relies on + * exactly those two signals to settle a timed-out regenerate: it polls the + * GET until `cachedAt` advances past its baseline (success) or a NEWER + * failure marker appears (honest error). Renaming or short-circuiting either + * write breaks that convergence — the settling seam tests pin the client + * half, this comment is the server half of the contract. + */ import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { diff --git a/src/app/api/labs/ocr/extract/__tests__/route.test.ts b/src/app/api/labs/ocr/extract/__tests__/route.test.ts index 5f1f4583d..f5358fa51 100644 --- a/src/app/api/labs/ocr/extract/__tests__/route.test.ts +++ b/src/app/api/labs/ocr/extract/__tests__/route.test.ts @@ -196,7 +196,10 @@ describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => { }); it("falls back to pdfNeedsAnthropic when rasterization fails", async () => { - vi.mocked(rasterizePdf).mockResolvedValue({ ok: false }); + vi.mocked(rasterizePdf).mockResolvedValue({ + ok: false, + reason: "render-failed", + }); const res = await POST(pdfReq()); expect(res.status).toBe(422); diff --git a/src/components/documents/__tests__/document-ai-section.test.tsx b/src/components/documents/__tests__/document-ai-section.test.tsx index 9eecdbefb..c0af03212 100644 --- a/src/components/documents/__tests__/document-ai-section.test.tsx +++ b/src/components/documents/__tests__/document-ai-section.test.tsx @@ -213,3 +213,44 @@ describe("", () => { expect(html).toContain("Summarise"); }); }); + +describe(" index outcome (#776)", () => { + it("explains a failed index attempt in plain words, muted", () => { + const html = render( + base({ hasContentIndex: false, lastIndexOutcome: "local-empty" }), + ); + expect(html).toContain('data-slot="document-index-outcome"'); + expect(html).toContain("No readable text was found"); + expect(html).toContain("text-muted-foreground"); + }); + + it("also explains it on the auto-read unread branch", () => { + const html = render( + base({ + autoReadEnabled: true, + hasContentIndex: false, + lastIndexOutcome: "raster-failed", + }), + ); + expect(html).toContain('data-slot="document-index-outcome"'); + expect(html).toContain("couldn't be rendered"); + }); + + it("says nothing once the document IS indexed (a past failure is history)", () => { + const html = render( + base({ + hasContentIndex: true, + contentIndexSource: "vision", + lastIndexOutcome: "provider-error", + }), + ); + expect(html).not.toContain('data-slot="document-index-outcome"'); + }); + + it("says nothing when no attempt ever ran (never tried is not a failure)", () => { + const html = render( + base({ hasContentIndex: false, lastIndexOutcome: null }), + ); + expect(html).not.toContain('data-slot="document-index-outcome"'); + }); +}); diff --git a/src/components/documents/__tests__/document-card.test.tsx b/src/components/documents/__tests__/document-card.test.tsx index 0d4aab26a..7b0aa82a1 100644 --- a/src/components/documents/__tests__/document-card.test.tsx +++ b/src/components/documents/__tests__/document-card.test.tsx @@ -34,6 +34,8 @@ function doc(overrides: Partial = {}): InboundDocumentDto { servingClass: "inline", hasContentIndex: false, contentIndexSource: null, + lastIndexAttemptAt: null, + lastIndexOutcome: null, hasThumbnail: false, createdAt: "2025-10-05T08:00:00.000Z", updatedAt: "2025-10-05T08:00:00.000Z", diff --git a/src/components/documents/__tests__/document-facts-review.test.tsx b/src/components/documents/__tests__/document-facts-review.test.tsx index a90b630b5..75dfb3f7b 100644 --- a/src/components/documents/__tests__/document-facts-review.test.tsx +++ b/src/components/documents/__tests__/document-facts-review.test.tsx @@ -85,6 +85,8 @@ function doc( servingClass: "inline", hasContentIndex: true, contentIndexSource: "vision", + lastIndexAttemptAt: null, + lastIndexOutcome: null, hasThumbnail: false, createdAt: "2026-08-01T10:00:00.000Z", updatedAt: "2026-08-01T10:00:00.000Z", diff --git a/src/components/documents/__tests__/documents-view-reindex-toast.test.tsx b/src/components/documents/__tests__/documents-view-reindex-toast.test.tsx new file mode 100644 index 000000000..8b679a628 --- /dev/null +++ b/src/components/documents/__tests__/documents-view-reindex-toast.test.tsx @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +/** + * Refs #776 — the "index all" toast's count honesty. + * + * The reindex route reports `enqueued` as a NUMBER (documents still to + * index); the toast interpolates it into "Indexing {count} document(s)". + * Before the route fix the wire carried `Boolean(jobId)` and the toast said + * "Indexing true document(s)". This pins the component end of that contract: + * fed the real wire shape, the success toast renders the count and NEVER the + * word "true"; a 0 says "everything is already indexed" instead of promising + * queued work. + * + * House convention: `renderToStaticMarkup` + handler exercised via a mocked + * child reference (the filter bar captures `onIndexAll`), no DOM events. + */ + +let mockSearch = ""; +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/documents", + useSearchParams: () => new URLSearchParams(mockSearch), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", modules: { inboundDocuments: true } }, + isLoading: false, + isAuthenticated: true, + }), +})); + +vi.mock("@/lib/api/api-fetch", async () => { + const actual = await vi.importActual( + "@/lib/api/api-fetch", + ); + return { + ...actual, + apiGet: () => new Promise(() => {}), + apiPatch: vi.fn(), + apiPost: vi.fn(), + apiDelete: vi.fn(), + }; +}); + +const toastSuccess = vi.fn(); +const toastError = vi.fn(); +vi.mock("sonner", () => ({ + toast: { + success: (...args: unknown[]) => toastSuccess(...args), + error: (...args: unknown[]) => toastError(...args), + }, +})); + +// Capture the view's index-all handler through the filter bar's prop — the +// static render cannot click, so the mocked child is the seam. +let capturedOnIndexAll: (() => void) | undefined; +vi.mock("../document-filter-bar", () => ({ + DocumentFilterBar: (props: { onIndexAll?: () => void }) => { + capturedOnIndexAll = props.onIndexAll; + return null; + }, +})); + +// The reindex mutation resolves synchronously with a configurable wire +// payload — exactly what `apiPost` would unwrap from the route envelope. +let wirePayload: { enqueued: number }; +vi.mock("../use-content-index", () => ({ + useIndexDocument: () => ({ mutate: vi.fn(), isPending: false }), + useReindexAll: () => ({ + mutate: ( + _vars: void, + opts?: { onSuccess?: (result: { enqueued: number }) => void }, + ) => { + opts?.onSuccess?.(wirePayload); + }, + isPending: false, + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { DocumentsView } from "../documents-view"; + +function render() { + mockSearch = ""; + capturedOnIndexAll = undefined; + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + renderToStaticMarkup( + + + + + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe(" index-all toast (#776)", () => { + it("renders the remaining count — never the word 'true'", () => { + wirePayload = { enqueued: 3 }; + render(); + expect(capturedOnIndexAll).toBeDefined(); + + capturedOnIndexAll!(); + + expect(toastSuccess).toHaveBeenCalledTimes(1); + const message = String(toastSuccess.mock.calls[0]![0]); + expect(message).toContain("3"); + expect(message).not.toContain("true"); + expect(toastError).not.toHaveBeenCalled(); + }); + + it("says everything is indexed on an honest 0, not 'indexing 0 documents'", () => { + wirePayload = { enqueued: 0 }; + render(); + + capturedOnIndexAll!(); + + expect(toastSuccess).toHaveBeenCalledTimes(1); + const message = String(toastSuccess.mock.calls[0]![0]); + expect(message).toContain("already indexed"); + expect(message).not.toContain("true"); + }); +}); diff --git a/src/components/documents/__tests__/episode-documents-card.test.tsx b/src/components/documents/__tests__/episode-documents-card.test.tsx index 2d5547c69..db5856609 100644 --- a/src/components/documents/__tests__/episode-documents-card.test.tsx +++ b/src/components/documents/__tests__/episode-documents-card.test.tsx @@ -54,6 +54,8 @@ function doc(id: string, title: string): InboundDocumentDto { servingClass: "inline", hasContentIndex: false, contentIndexSource: null, + lastIndexAttemptAt: null, + lastIndexOutcome: null, hasThumbnail: false, createdAt: "2025-10-05T08:00:00.000Z", updatedAt: "2025-10-05T08:00:00.000Z", diff --git a/src/components/documents/__tests__/vault-utils.test.ts b/src/components/documents/__tests__/vault-utils.test.ts index 6947140aa..201963f85 100644 --- a/src/components/documents/__tests__/vault-utils.test.ts +++ b/src/components/documents/__tests__/vault-utils.test.ts @@ -42,6 +42,8 @@ function doc(overrides: Partial = {}): InboundDocumentDto { servingClass: "inline", hasContentIndex: false, contentIndexSource: null, + lastIndexAttemptAt: null, + lastIndexOutcome: null, hasThumbnail: false, createdAt: "2026-03-11T08:00:00.000Z", updatedAt: "2026-03-11T08:00:00.000Z", diff --git a/src/components/documents/document-ai-section.tsx b/src/components/documents/document-ai-section.tsx index 0e85d1e05..33f19e58b 100644 --- a/src/components/documents/document-ai-section.tsx +++ b/src/components/documents/document-ai-section.tsx @@ -38,6 +38,7 @@ import { cn } from "@/lib/utils"; import { isAiReadSource, type DocumentContentIndexSourceValue, + type DocumentIndexOutcomeValue, type DocumentSuggestionDto, type DocumentSummaryMode, } from "@/lib/validations/inbound-documents"; @@ -74,6 +75,7 @@ export function DocumentAiSection({ onCloseSummary, hasContentIndex, contentIndexSource, + lastIndexOutcome = null, indexPending, onIndex, }: { @@ -111,11 +113,24 @@ export function DocumentAiSection({ onCloseSummary: () => void; hasContentIndex: boolean; contentIndexSource: DocumentContentIndexSourceValue | null; + /** + * Refs #776 — why the last index attempt produced no index (null: never + * tried, or the document IS indexed). Rendered as a muted plain-words line + * beside the read action so a failed read is explainable, not a mystery. + */ + lastIndexOutcome?: DocumentIndexOutcomeValue | null; indexPending: boolean; onIndex: () => void; }) { const { t } = useTranslations(); const aiRead = hasContentIndex && isAiReadSource(contentIndexSource); + // Refs #776 — only an un-indexed document explains itself; once an index + // exists the last failure is history. Muted per the text-colour rule: this + // is meta about the document, not content. + const indexOutcomeText = + !hasContentIndex && lastIndexOutcome + ? t(`documents.detail.index.outcome.${lastIndexOutcome}`) + : null; const readLabel = indexPending ? t("documents.ai.reading") : aiRead @@ -233,6 +248,15 @@ export function DocumentAiSection({ ) : null} + {indexOutcomeText ? ( +

+ {indexOutcomeText} +

+ ) : null} + {suggestErrorKey ? (

{suggestErrorKey} diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index fd37ddc07..1559d1c53 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -875,6 +875,7 @@ export function DocumentDetailSheet({ }} hasContentIndex={doc.hasContentIndex} contentIndexSource={doc.contentIndexSource} + lastIndexOutcome={doc.lastIndexOutcome} indexPending={indexDoc.isPending} onIndex={runIndex} /> diff --git a/src/components/documents/use-content-index.ts b/src/components/documents/use-content-index.ts index 3d9434cac..28f164ba8 100644 --- a/src/components/documents/use-content-index.ts +++ b/src/components/documents/use-content-index.ts @@ -41,7 +41,16 @@ export function useIndexDocument() { }); } -/** Fire the corpus backfill; resolves with how many docs were enqueued. */ +/** + * Fire the corpus backfill; resolves with how many docs were enqueued. + * + * Wire contract (#776): `enqueued` is the NUMBER of live documents the + * backfill still has to index — the reindex route computes it from the same + * live-scoped counts the usage gauge reads, and returns an honest 0 when no + * job was created. The toast interpolates it as a count, so this type is + * load-bearing: it must match the route's shape exactly (a boolean here once + * rendered as "Indexing true document(s)"). + */ export function useReindexAll() { const queryClient = useQueryClient(); return useMutation<{ enqueued: number }, Error, void>({ diff --git a/src/components/insights/__tests__/insights-tab-strip-settling.test.tsx b/src/components/insights/__tests__/insights-tab-strip-settling.test.tsx new file mode 100644 index 000000000..c296cab43 --- /dev/null +++ b/src/components/insights/__tests__/insights-tab-strip-settling.test.tsx @@ -0,0 +1,89 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +/** + * Refs #786 — the regenerate button's settling state. + * + * While a timed-out force regenerate is settling (the server is still + * generating; the hook polls for the outcome) the button may NOT offer a + * retry or read as failed: it stays disabled, keeps the spinner, and its + * label says the assessment is still being prepared. Static renders pin the + * three visible facts; the toast edges live in the hook/strip effect and are + * pinned by the outcome mapping (`regenerateToastKind`). + */ + +vi.mock("next/navigation", () => ({ + usePathname: () => "/insights", +})); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +import { InsightsTabStrip, regenerateToastKind } from "../insights-tab-strip"; +import { I18nProvider } from "@/lib/i18n/context"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +describe(" regenerate settling state (#786)", () => { + it("settling: button disabled, spinner on, label says still being prepared", () => { + const html = render( + {}} + regenerating={false} + regenerateSettling + />, + ); + expect(html).toContain('data-slot="insights-tab-strip-regenerate"'); + expect(html).toContain("Your assessment is still being prepared"); + expect(html).toContain("disabled"); + expect(html).toContain("animate-spin"); + }); + + it("idle: button enabled with the regenerate label", () => { + const html = render( + {}} regenerating={false} />, + ); + expect(html).toContain("Re-run assessment"); + expect(html).not.toContain("Your assessment is still being prepared"); + }); +}); + +describe("regenerateToastKind — falling-edge outcome mapping (#786)", () => { + it("maps settle-failed to the honest settle error, never success", () => { + expect(regenerateToastKind("settle-failed")).toEqual({ + kind: "error", + messageKey: "insights.regenerateSettleFailed", + }); + }); + + it("keeps the existing mappings", () => { + expect(regenerateToastKind("fresh")).toEqual({ + kind: "success", + messageKey: "insights.regenerateSuccess", + }); + expect(regenerateToastKind("timeout")).toEqual({ + kind: "error", + messageKey: "insights.regenerateError", + }); + expect(regenerateToastKind("rate-limited")).toEqual({ + kind: "error", + messageKey: "insights.regenerateRateLimited", + }); + expect(regenerateToastKind("empty")).toEqual({ + kind: "error", + messageKey: "insights.regenerateUnavailable", + }); + expect(regenerateToastKind("no-provider")).toBe(null); + // Legacy mounts without an outcome keep the unconditional success. + expect(regenerateToastKind(null)).toEqual({ + kind: "success", + messageKey: "insights.regenerateSuccess", + }); + expect(regenerateToastKind(undefined)).toEqual({ + kind: "success", + messageKey: "insights.regenerateSuccess", + }); + }); +}); diff --git a/src/components/insights/__tests__/use-insights-advisor-settling.test.ts b/src/components/insights/__tests__/use-insights-advisor-settling.test.ts new file mode 100644 index 000000000..855c1c6cf --- /dev/null +++ b/src/components/insights/__tests__/use-insights-advisor-settling.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect } from "vitest"; + +/** + * Refs #786 — the async-truth contract for the force regenerate. + * + * A regenerate the client gives up on at 45 s is NOT a failure: the server's + * inline generation keeps running and usually writes the cache moments later. + * The old behaviour fired an error toast and then never converged (staleTime + * 1 h + focus refetch off), so the page kept showing the old assessment while + * claiming the refresh failed. The fix enters a "settling" state: remember + * the baseline `cachedAt`, keep polling on the existing bounded cadence, and + * resolve to a REAL outcome — fresh (cache advanced), settle-failed (a newer + * failure marker, or the attempt cap). + * + * Two pure seams pin the state machine: + * - `nextAdvisorPollInterval` gains settling as the second poll reason, + * with attempts counted from the settle start. + * - `resolveAdvisorSettle` decides fresh / settle-failed / keep-waiting. + */ + +import { + ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS, + ADVISOR_REVALIDATE_POLL_MS, + nextAdvisorPollInterval, + resolveAdvisorSettle, + type AdvisorSettleState, +} from "../use-insights-advisor"; + +function settle( + overrides: Partial = {}, +): AdvisorSettleState { + return { + baselineCachedAt: "2026-08-13T06:00:00.000Z", + baselineFailed: false, + startedAtUpdateCount: 4, + ...overrides, + }; +} + +describe("nextAdvisorPollInterval — settling is the second poll reason (#786)", () => { + it("polls while settling even when nothing reports revalidating", () => { + expect(nextAdvisorPollInterval(false, 5, settle())).toBe( + ADVISOR_REVALIDATE_POLL_MS, + ); + expect(nextAdvisorPollInterval(undefined, 5, settle())).toBe( + ADVISOR_REVALIDATE_POLL_MS, + ); + }); + + it("counts settling attempts from the settle start, not from mount", () => { + // 12 data updates total but only 8 since settling began → keep polling. + expect( + nextAdvisorPollInterval(false, 12, settle({ startedAtUpdateCount: 4 })), + ).toBe(ADVISOR_REVALIDATE_POLL_MS); + }); + + it("stops at the settling attempt ceiling", () => { + expect( + nextAdvisorPollInterval( + false, + 4 + ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS, + settle({ startedAtUpdateCount: 4 }), + ), + ).toBe(false); + }); + + it("keeps the pre-existing revalidating behaviour when not settling", () => { + expect(nextAdvisorPollInterval(true, 1, null)).toBe( + ADVISOR_REVALIDATE_POLL_MS, + ); + expect(nextAdvisorPollInterval(false, 1, null)).toBe(false); + expect(nextAdvisorPollInterval(true, 1)).toBe(ADVISOR_REVALIDATE_POLL_MS); + }); +}); + +describe("resolveAdvisorSettle — the settle state machine (#786)", () => { + it("resolves fresh once cachedAt advances past the baseline", () => { + expect( + resolveAdvisorSettle( + settle(), + { cachedAt: "2026-08-13T06:02:31.000Z", generationFailed: false }, + 6, + ), + ).toBe("fresh"); + }); + + it("resolves fresh on ANY cachedAt when there was no baseline (first generation)", () => { + expect( + resolveAdvisorSettle( + settle({ baselineCachedAt: null }), + { cachedAt: "2026-08-13T06:02:31.000Z", generationFailed: false }, + 6, + ), + ).toBe("fresh"); + }); + + it("keeps waiting while the cache is unchanged and attempts remain", () => { + expect( + resolveAdvisorSettle( + settle(), + { cachedAt: "2026-08-13T06:00:00.000Z", generationFailed: false }, + 6, + ), + ).toBe(null); + }); + + it("resolves settle-failed on a NEWER failure marker (failed flipped during settling)", () => { + expect( + resolveAdvisorSettle( + settle({ baselineFailed: false }), + { cachedAt: "2026-08-13T06:00:00.000Z", generationFailed: true }, + 6, + ), + ).toBe("settle-failed"); + }); + + it("does NOT treat a failure marker that predates the settle as fresh evidence", () => { + // The baseline already carried the failed flag — an unchanged flag says + // nothing about THIS generation; keep waiting for cachedAt or the cap. + expect( + resolveAdvisorSettle( + settle({ baselineFailed: true }), + { cachedAt: "2026-08-13T06:00:00.000Z", generationFailed: true }, + 6, + ), + ).toBe(null); + }); + + it("resolves settle-failed at the attempt cap — never waits forever", () => { + expect( + resolveAdvisorSettle( + settle({ startedAtUpdateCount: 4 }), + { cachedAt: "2026-08-13T06:00:00.000Z", generationFailed: false }, + 4 + ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS, + ), + ).toBe("settle-failed"); + }); + + it("a missing payload keeps waiting (no evidence either way)", () => { + expect(resolveAdvisorSettle(settle(), null, 6)).toBe(null); + expect(resolveAdvisorSettle(settle(), undefined, 6)).toBe(null); + }); +}); diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx index bc861d344..dbf4d0ea6 100644 --- a/src/components/insights/insights-layout-shell.tsx +++ b/src/components/insights/insights-layout-shell.tsx @@ -200,6 +200,7 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { onRegenerate={advisorEnabled ? advisor.regenerate : undefined} regenerating={advisor.isRegenerating} regenerateOutcome={advisor.regenerateOutcome} + regenerateSettling={advisor.regenerateSettling} availability={availability} visibleTileIds={visibleTileIds} tileOrder={tileOrder} diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index 33e41e837..60f6613d0 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -88,7 +88,20 @@ export interface InsightsTabStripProps { * mounts / no regenerate) falls back to the prior unconditional success. */ regenerateOutcome?: - "fresh" | "empty" | "rate-limited" | "timeout" | "no-provider" | null; + | "fresh" + | "empty" + | "rate-limited" + | "timeout" + | "no-provider" + | "settle-failed" + | null; + /** + * Refs #786 — true while a timed-out regenerate is settling (the server is + * still generating; the hook polls for the proven outcome). The button + * stays busy with a "still being prepared" label, and the falling-edge + * toast waits for the COMBINED busy state so no error fires mid-settle. + */ + regenerateSettling?: boolean; /** * v1.4.27 F19 — analytics + event-driven availability inputs the * gating helper reads. When omitted the strip falls back to its @@ -637,10 +650,38 @@ function buildTabs( return entries; } +/** + * Refs #786 — the falling-edge toast decision as a pure seam. Maps the last + * settled outcome to the toast that fires when the busy state falls: + * `settle-failed` (a settling poll proved the generation failed, or the + * attempt cap ran out) gets its own honest error copy; `no-provider` stays + * silent (the surface already shows the connect-AI empty state); a missing + * outcome (legacy mounts) keeps the historical unconditional success. + */ +export function regenerateToastKind( + outcome: InsightsTabStripProps["regenerateOutcome"] | undefined, +): { kind: "success" | "error"; messageKey: string } | null { + switch (outcome) { + case "timeout": + return { kind: "error", messageKey: "insights.regenerateError" }; + case "rate-limited": + return { kind: "error", messageKey: "insights.regenerateRateLimited" }; + case "empty": + return { kind: "error", messageKey: "insights.regenerateUnavailable" }; + case "settle-failed": + return { kind: "error", messageKey: "insights.regenerateSettleFailed" }; + case "no-provider": + return null; + default: + return { kind: "success", messageKey: "insights.regenerateSuccess" }; + } +} + function InsightsTabStripImpl({ onRegenerate, regenerating = false, regenerateOutcome, + regenerateSettling = false, availability, visibleTileIds, tileOrder, @@ -686,24 +727,33 @@ function InsightsTabStripImpl({ // regenerate claimed "refreshed" while serving the old text. The latest // outcome is read through a ref-free dep so the effect fires only on the // spinner edge. - const lastRegeneratingRef = useRef(regenerating); + // + // Refs #786 — the edge watches the COMBINED busy state (`regenerating || + // regenerateSettling`): a timed-out regenerate flows straight into + // settling, so the spinner never falls at 45 s and NO error toast fires + // while the server may still deliver. The edge fires once settling + // resolves, with the proven outcome — `"fresh"` (success) or + // `"settle-failed"` (its own honest error copy). The mapping is the pure + // `regenerateToastKind` seam above. + const busy = regenerating || regenerateSettling; + const lastBusyRef = useRef(busy); useEffect(() => { - if (lastRegeneratingRef.current && !regenerating) { - if (regenerateOutcome === "timeout") { - toast.error(t("insights.regenerateError")); - } else if (regenerateOutcome === "rate-limited") { - toast.error(t("insights.regenerateRateLimited")); - } else if (regenerateOutcome === "empty") { - toast.error(t("insights.regenerateUnavailable")); - } else if (regenerateOutcome !== "no-provider") { - // `"fresh"` or legacy `undefined`/null → success. - toast.success(t("insights.regenerateSuccess")); + if (lastBusyRef.current && !busy) { + const decision = regenerateToastKind(regenerateOutcome); + if (decision) { + if (decision.kind === "error") { + toast.error(t(decision.messageKey)); + } else { + toast.success(t(decision.messageKey)); + } } } - lastRegeneratingRef.current = regenerating; - }, [regenerating, regenerateOutcome, t]); + lastBusyRef.current = busy; + }, [busy, regenerateOutcome, t]); - const regenerateLabel = t("insights.regenerateAnalysis"); + const regenerateLabel = regenerateSettling + ? t("insights.regenerateSettling") + : t("insights.regenerateAnalysis"); const customizeLabel = t("insights.customize"); // Overflow affordance for the pill row. The row hides its scrollbar @@ -1000,7 +1050,9 @@ function InsightsTabStripImpl({