From be9c79974f7725f72b38663d94f5353679808f25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 13:41:42 +0200 Subject: [PATCH] feat(documents): a document can say which visit it belongs to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every arm here is the same shape as the one that already exists for conditions, because the vault's filing model is settled and a second shape would be the thing to explain rather than the thing to use: encounterIds on upload and on PATCH with replace-set semantics, linkEncounter / unlinkEncounter beside the episode pair in the bulk action, and an encounterId list filter. Both directions ride ONE table. The visit's sheet writes encounter_document_links from its end and the document reads it from the other, declared as a second direction in the link service rather than a fourth table. A visit's kind crosses the wire as the enum constant rather than a rendered name: unlike a condition label, which is the person's own words, a kind is one of eight closed values whose human name the reader's own bundle owns. The suggestion is offered where the document is being looked at, resolved by the shared rule rather than by this surface: one candidate pre-selects with an undo, two offer a picker with nothing pre-selected, none offers nothing at all. It resolves with no AI provider configured, and it renders nothing once the document already names a visit. The bulk verb has a caller — the vault's selection bar files a whole selection against a visit, mirroring the condition verb and hiding itself on an account with no visits. Nothing here is one-ended. OpenAPI regenerated in the same commit. --- docs/api/openapi.yaml | 55 +++++++ messages/de.json | 8 +- messages/en.json | 8 +- messages/es.json | 8 +- messages/fr.json | 8 +- messages/it.json | 8 +- messages/pl.json | 8 +- .../documents/inbound/[id]/restore/route.ts | 29 +++- src/app/api/documents/inbound/[id]/route.ts | 35 ++++- src/app/api/documents/inbound/bulk/route.ts | 52 ++++++- src/app/api/documents/inbound/route.ts | 89 +++++++++-- .../__tests__/document-bulk-bar.test.tsx | 26 +++- .../__tests__/document-card.test.tsx | 1 + .../__tests__/episode-documents-card.test.tsx | 1 + .../documents/__tests__/vault-utils.test.ts | 1 + .../documents/document-bulk-bar.tsx | 58 ++++++- .../documents/document-detail-sheet.tsx | 76 +++++++++ .../document-encounter-suggestion.tsx | 147 ++++++++++++++++++ src/components/documents/documents-view.tsx | 44 +++++- src/components/documents/vault-utils.ts | 12 +- src/lib/documents/links.ts | 81 +++++++++- src/lib/documents/store.ts | 5 + src/lib/encounters/service.ts | 12 +- src/lib/links/link-service.ts | 29 +++- src/lib/openapi/routes/documents.ts | 28 +++- src/lib/query-keys/documents.ts | 2 + src/lib/validations/inbound-documents.ts | 60 ++++++- 27 files changed, 839 insertions(+), 52 deletions(-) create mode 100644 src/components/documents/document-encounter-suggestion.tsx diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6a3dbc7d4..8098b4198 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -11427,6 +11427,12 @@ paths: schema: type: string description: Only documents linked to this illness/condition episode. + - name: encounterId + in: query + required: false + schema: + type: string + description: Only documents linked to this visit. - name: year in: query required: false @@ -11545,6 +11551,12 @@ paths: type: array items: type: string + encounterIds: + description: Optional visit pre-links (repeat the field per id). Every id must be a live visit of the caller; an unknown + one refuses the upload rather than dropping the link the person asked for. + type: array + items: + type: string required: - file responses: @@ -11648,6 +11660,8 @@ paths: - setKind - linkEpisode - unlinkEpisode + - linkEncounter + - unlinkEncounter - delete - restore kind: @@ -11666,6 +11680,10 @@ paths: type: string minLength: 1 maxLength: 40 + encounterId: + type: string + minLength: 1 + maxLength: 40 required: - ids - action @@ -11750,6 +11768,13 @@ paths: type: string minLength: 1 maxLength: 40 + encounterIds: + maxItems: 20 + type: array + items: + type: string + minLength: 1 + maxLength: 40 responses: "200": description: The updated document. @@ -36481,6 +36506,10 @@ components: type: array items: $ref: "#/components/schemas/DocumentConditionLink" + encounterLinks: + type: array + items: + $ref: "#/components/schemas/DocumentEncounterLink" servingClass: type: string enum: @@ -36518,6 +36547,7 @@ components: - factCount - pendingCount - conditionLinks + - encounterLinks - servingClass - hasContentIndex - contentIndexSource @@ -36547,6 +36577,26 @@ components: - name additionalProperties: false description: A link to one of the caller's illness/condition episodes. `name` is the episode's user-facing label. + DocumentEncounterLink: + type: object + properties: + encounterId: + type: string + kind: + type: string + occurredAt: + anyOf: + - type: string + - type: "null" + required: + - encounterId + - kind + - occurredAt + additionalProperties: false + description: "A link to one of the caller's visits — \"this letter belongs to that appointment\". `kind` is the + visit-kind ENUM CONSTANT rather than a rendered name: unlike a condition label, which is the person's own words, + a visit kind is one of eight closed values whose human name the reader's own bundle owns, so publishing the + constant is what lets a German reader and an English one each see it named correctly." InboundDocumentEnvelope: type: object properties: @@ -36804,6 +36854,10 @@ components: type: array items: $ref: "#/components/schemas/DocumentConditionLink" + encounterLinks: + type: array + items: + $ref: "#/components/schemas/DocumentEncounterLink" servingClass: type: string enum: @@ -36861,6 +36915,7 @@ components: - factCount - pendingCount - conditionLinks + - encounterLinks - servingClass - hasContentIndex - contentIndexSource diff --git a/messages/de.json b/messages/de.json index 8d03ea71b..d40205bd2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9305,7 +9305,10 @@ "previewUnavailable": "Keine Inline-Vorschau für diesen Dateityp", "uploadedMeta": "Hinzugefügt am {date} · {size}", "share": "Teilen", - "labValuesLink": "{count} Laborwerte ansehen" + "labValuesLink": "{count} Laborwerte ansehen", + "visitsLabel": "Gehört zu Termin", + "visitSuggestion": "Gehört zu diesem Termin?", + "visitSuggestionChoose": "Zu welchem Termin gehört das?" }, "dropOverlay": { "title": "Zum Hochladen ablegen", @@ -9322,7 +9325,8 @@ "partialFailure": "{failed} von {total} konnten nicht aktualisiert werden", "failed": "Die Aktion konnte nicht ausgeführt werden", "share": "Teilen", - "shareTooMany": "Ein Freigabelink fasst höchstens {max} Dokumente. Wähle einige ab und versuche es erneut." + "shareTooMany": "Ein Freigabelink fasst höchstens {max} Dokumente. Wähle einige ab und versuche es erneut.", + "linkVisit": "Termin zuordnen" }, "episodeCard": { "title": "Dokumente", diff --git a/messages/en.json b/messages/en.json index c62cff149..30a13c858 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9305,7 +9305,10 @@ "previewUnavailable": "No inline preview for this file type", "uploadedMeta": "Added {date} · {size}", "share": "Share", - "labValuesLink": "View {count} lab values" + "labValuesLink": "View {count} lab values", + "visitsLabel": "Belongs to visit", + "visitSuggestion": "Does this belong to this visit?", + "visitSuggestionChoose": "Which visit does this belong to?" }, "dropOverlay": { "title": "Drop files to upload", @@ -9322,7 +9325,8 @@ "partialFailure": "{failed} of {total} could not be updated", "failed": "The action could not be completed", "share": "Share", - "shareTooMany": "A share link can hold at most {max} documents. Deselect some and try again." + "shareTooMany": "A share link can hold at most {max} documents. Deselect some and try again.", + "linkVisit": "File against visit" }, "episodeCard": { "title": "Documents", diff --git a/messages/es.json b/messages/es.json index a7c38c795..16a08b543 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9305,7 +9305,10 @@ "previewUnavailable": "Sin vista previa integrada para este tipo de archivo", "uploadedMeta": "Añadido el {date} · {size}", "share": "Compartir", - "labValuesLink": "Ver {count} valores de laboratorio" + "labValuesLink": "Ver {count} valores de laboratorio", + "visitsLabel": "Pertenece a la cita", + "visitSuggestion": "¿Pertenece a esta cita?", + "visitSuggestionChoose": "¿A qué cita pertenece?" }, "dropOverlay": { "title": "Suelta los archivos para subirlos", @@ -9322,7 +9325,8 @@ "partialFailure": "No se pudieron actualizar {failed} de {total}", "failed": "No se pudo completar la acción", "share": "Compartir", - "shareTooMany": "Un enlace para compartir admite como máximo {max} documentos. Deselecciona algunos e inténtalo de nuevo." + "shareTooMany": "Un enlace para compartir admite como máximo {max} documentos. Deselecciona algunos e inténtalo de nuevo.", + "linkVisit": "Asignar a una cita" }, "episodeCard": { "title": "Documentos", diff --git a/messages/fr.json b/messages/fr.json index 3b69dd66d..151dc5f57 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9305,7 +9305,10 @@ "previewUnavailable": "Pas d'aperçu intégré pour ce type de fichier", "uploadedMeta": "Ajouté le {date} · {size}", "share": "Partager", - "labValuesLink": "Voir {count} valeurs de laboratoire" + "labValuesLink": "Voir {count} valeurs de laboratoire", + "visitsLabel": "Rattaché au rendez-vous", + "visitSuggestion": "Cela concerne-t-il ce rendez-vous ?", + "visitSuggestionChoose": "À quel rendez-vous cela se rattache-t-il ?" }, "dropOverlay": { "title": "Déposez les fichiers pour les téléverser", @@ -9322,7 +9325,8 @@ "partialFailure": "Impossible de mettre à jour {failed} sur {total}", "failed": "L'action n'a pas pu être effectuée", "share": "Partager", - "shareTooMany": "Un lien de partage contient au maximum {max} documents. Désélectionnez-en quelques-uns et réessayez." + "shareTooMany": "Un lien de partage contient au maximum {max} documents. Désélectionnez-en quelques-uns et réessayez.", + "linkVisit": "Classer sous un rendez-vous" }, "episodeCard": { "title": "Documents", diff --git a/messages/it.json b/messages/it.json index c79d33ad0..629c44858 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9305,7 +9305,10 @@ "previewUnavailable": "Nessuna anteprima integrata per questo tipo di file", "uploadedMeta": "Aggiunto il {date} · {size}", "share": "Condividi", - "labValuesLink": "Vedi {count} valori di laboratorio" + "labValuesLink": "Vedi {count} valori di laboratorio", + "visitsLabel": "Appartiene all'appuntamento", + "visitSuggestion": "Appartiene a questo appuntamento?", + "visitSuggestionChoose": "A quale appuntamento appartiene?" }, "dropOverlay": { "title": "Rilascia i file per caricarli", @@ -9322,7 +9325,8 @@ "partialFailure": "Impossibile aggiornare {failed} su {total}", "failed": "Impossibile completare l'azione", "share": "Condividi", - "shareTooMany": "Un link di condivisione contiene al massimo {max} documenti. Deseleziona alcuni documenti e riprova." + "shareTooMany": "Un link di condivisione contiene al massimo {max} documenti. Deseleziona alcuni documenti e riprova.", + "linkVisit": "Assegna a un appuntamento" }, "episodeCard": { "title": "Documenti", diff --git a/messages/pl.json b/messages/pl.json index 6ac56d55c..e69ef3f34 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9305,7 +9305,10 @@ "previewUnavailable": "Brak podglądu dla tego typu pliku", "uploadedMeta": "Dodano {date} · {size}", "share": "Udostępnij", - "labValuesLink": "Zobacz {count} wyników laboratoryjnych" + "labValuesLink": "Zobacz {count} wyników laboratoryjnych", + "visitsLabel": "Należy do wizyty", + "visitSuggestion": "Czy to należy do tej wizyty?", + "visitSuggestionChoose": "Do której wizyty to należy?" }, "dropOverlay": { "title": "Upuść pliki, aby je przesłać", @@ -9322,7 +9325,8 @@ "partialFailure": "Nie udało się zaktualizować {failed} z {total}", "failed": "Nie udało się wykonać akcji", "share": "Udostępnij", - "shareTooMany": "Link do udostępniania obejmuje maksymalnie {max} dokumentów. Odznacz kilka i spróbuj ponownie." + "shareTooMany": "Link do udostępniania obejmuje maksymalnie {max} dokumentów. Odznacz kilka i spróbuj ponownie.", + "linkVisit": "Przypisz do wizyty" }, "episodeCard": { "title": "Dokumenty", diff --git a/src/app/api/documents/inbound/[id]/restore/route.ts b/src/app/api/documents/inbound/[id]/restore/route.ts index 1d43a8c60..68f847f48 100644 --- a/src/app/api/documents/inbound/[id]/restore/route.ts +++ b/src/app/api/documents/inbound/[id]/restore/route.ts @@ -15,7 +15,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; -import { loadConditionLinks } from "@/lib/documents/links"; +import { + loadConditionLinks, + loadDocumentEncounterLinks, +} from "@/lib/documents/links"; import { serialiseDocumentDetail } from "@/lib/documents/store"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; @@ -83,9 +86,20 @@ export const POST = apiHandler( if (row.deletedAt === null) { // Already live — restoring twice is a no-op, not an error (the undo // toast may fire after a concurrent restore). - const links = await loadConditionLinks(user.id, [row.id]); + const [links, visitLinks] = await Promise.all([ + loadConditionLinks(user.id, [row.id]), + loadDocumentEncounterLinks(user.id, [row.id]), + ]); return apiSuccess( - serialiseDocumentDetail(row, row.facts, links.get(row.id) ?? []), + serialiseDocumentDetail( + row, + row.facts, + links.get(row.id) ?? [], + false, + null, + false, + visitLinks.get(row.id) ?? [], + ), ); } @@ -131,7 +145,10 @@ export const POST = apiHandler( omit: { contentEncrypted: true }, include: { facts: { orderBy: { createdAt: "asc" } } }, }); - const links = await loadConditionLinks(user.id, [restored.id]); + const [links, visitLinks] = await Promise.all([ + loadConditionLinks(user.id, [restored.id]), + loadDocumentEncounterLinks(user.id, [restored.id]), + ]); await auditLog("documents.inbound.restore", { userId: user.id, @@ -149,6 +166,10 @@ export const POST = apiHandler( restored, restored.facts, links.get(restored.id) ?? [], + false, + null, + false, + visitLinks.get(restored.id) ?? [], ), ); }, diff --git a/src/app/api/documents/inbound/[id]/route.ts b/src/app/api/documents/inbound/[id]/route.ts index cf0bd1f7c..22fef5f43 100644 --- a/src/app/api/documents/inbound/[id]/route.ts +++ b/src/app/api/documents/inbound/[id]/route.ts @@ -20,8 +20,11 @@ import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { loadConditionLinks, + loadDocumentEncounterLinks, + narrowOwnedEncounterIds, narrowOwnedEpisodeIds, replaceConditionLinks, + replaceEncounterLinks, } from "@/lib/documents/links"; import { serialiseDocumentDetail } from "@/lib/documents/store"; import { annotate } from "@/lib/logging/context"; @@ -92,8 +95,9 @@ export const GET = apiHandler( }); } - const [links, contentIndex, thumbnail] = await Promise.all([ + const [links, visitLinks, contentIndex, thumbnail] = await Promise.all([ loadConditionLinks(user.id, [document.id]), + loadDocumentEncounterLinks(user.id, [document.id]), prisma.documentContentIndex.findUnique({ where: { documentId: document.id }, select: { source: true }, @@ -117,6 +121,7 @@ export const GET = apiHandler( contentIndex !== null, toContentIndexSource(contentIndex?.source), thumbnail !== null, + visitLinks.get(document.id) ?? [], ), ); }, @@ -169,6 +174,21 @@ export const PATCH = apiHandler( nextEpisodeIds = narrowed; } + // Replace-set visit links, same semantics and same refusal. + let nextEncounterIds: string[] | undefined; + if (parsed.data.encounterIds !== undefined) { + const narrowed = await narrowOwnedEncounterIds( + user.id, + parsed.data.encounterIds, + ); + if (narrowed === null) { + return apiError("Visit not found", 404, { + errorCode: "documents.inbound.encounterNotFound", + }); + } + nextEncounterIds = narrowed; + } + // No mass assignment — each editable column is set explicitly only when // the client sent it. `userId` is never a body field. const data: Prisma.InboundDocumentUpdateInput = {}; @@ -189,14 +209,23 @@ export const PATCH = apiHandler( if (nextEpisodeIds !== undefined) { await replaceConditionLinks(prisma, user.id, existing.id, nextEpisodeIds); } + if (nextEncounterIds !== undefined) { + await replaceEncounterLinks( + prisma, + user.id, + existing.id, + nextEncounterIds, + ); + } const document = await prisma.inboundDocument.findFirstOrThrow({ where: { id: existing.id }, omit: { contentEncrypted: true }, include: { facts: { orderBy: { createdAt: "asc" } } }, }); - const [links, contentIndex, thumbnail] = await Promise.all([ + const [links, visitLinks, contentIndex, thumbnail] = await Promise.all([ loadConditionLinks(user.id, [document.id]), + loadDocumentEncounterLinks(user.id, [document.id]), prisma.documentContentIndex.findUnique({ where: { documentId: document.id }, select: { source: true }, @@ -210,6 +239,7 @@ export const PATCH = apiHandler( const touched = [ ...Object.keys(data), ...(nextEpisodeIds !== undefined ? ["episodeIds"] : []), + ...(nextEncounterIds !== undefined ? ["encounterIds"] : []), ]; await auditLog("documents.inbound.update", { @@ -234,6 +264,7 @@ export const PATCH = apiHandler( contentIndex !== null, toContentIndexSource(contentIndex?.source), thumbnail !== null, + visitLinks.get(document.id) ?? [], ), ); }, diff --git a/src/app/api/documents/inbound/bulk/route.ts b/src/app/api/documents/inbound/bulk/route.ts index 35c141934..a57f49df9 100644 --- a/src/app/api/documents/inbound/bulk/route.ts +++ b/src/app/api/documents/inbound/bulk/route.ts @@ -1,9 +1,11 @@ /** * Document vault: bulk actions over up to 100 owner-scoped documents. * - * `{ ids[], action, kind?, episodeId? }` → a per-id result array. Actions: - * `setKind` (recategorise), `linkEpisode` / `unlinkEpisode` (condition - * links), `delete` (tombstone, undo-able), `restore` (clear tombstone). A + * `{ ids[], action, kind?, episodeId?, encounterId? }` → a per-id result + * array. Actions: `setKind` (recategorise), `linkEpisode` / `unlinkEpisode` + * (condition links), `linkEncounter` / `unlinkEncounter` (visit links, the + * same table the visit's own sheet writes from the other end), + * `delete` (tombstone, undo-able), `restore` (clear tombstone). A * partial failure never aborts the batch — each id reports `ok` or a short * machine reason. Every id is narrowed to the caller; a foreign id reads as * "notFound", exactly like the single-document routes. @@ -68,7 +70,7 @@ export const POST = apiHandler(async (request: NextRequest) => { if (!parsed.success) { return returnAllZodIssues(parsed.error, 422); } - const { ids, action, kind, episodeId } = parsed.data; + const { ids, action, kind, episodeId, encounterId } = parsed.data; const uniqueIds = [...new Set(ids)]; // Episode actions: the target episode must be a LIVE episode of the caller. @@ -84,6 +86,21 @@ export const POST = apiHandler(async (request: NextRequest) => { } } + // Visit actions: the target visit must be a LIVE visit of the caller. Same + // shape as the episode gate above — the endpoint is checked once, before the + // batch, so a foreign id costs one refusal rather than a hundred. + if (action === "linkEncounter" || action === "unlinkEncounter") { + const encounter = await prisma.encounter.findFirst({ + where: { id: encounterId!, userId: user.id, deletedAt: null }, + select: { id: true }, + }); + if (!encounter) { + return apiError("Visit not found", 404, { + errorCode: "documents.inbound.encounterNotFound", + }); + } + } + // One owner-scoped sweep resolves every id's current state; per-id work // then never touches a row the caller does not own. const owned = await prisma.inboundDocument.findMany({ @@ -136,6 +153,33 @@ export const POST = apiHandler(async (request: NextRequest) => { }); break; } + case "linkEncounter": { + if (row.deletedAt !== null) { + results.push({ id, ok: false, error: "notFound" }); + continue; + } + await linkTargets(prisma, { + userId: user.id, + sourceKind: "document", + sourceId: id, + targetKind: "encounter", + targetIds: [encounterId!], + }); + break; + } + case "unlinkEncounter": { + // No tombstone check, matching `unlinkEpisode`: clearing a filing on + // a document the account has since deleted must stay possible, or + // the filing is unreachable. + await unlinkTargets(prisma, { + userId: user.id, + sourceKind: "document", + sourceId: id, + targetKind: "encounter", + targetIds: [encounterId!], + }); + break; + } case "delete": { if (row.deletedAt !== null) { // Already tombstoned — deleting twice is a no-op success. diff --git a/src/app/api/documents/inbound/route.ts b/src/app/api/documents/inbound/route.ts index 1a743ad68..60d26ffdf 100644 --- a/src/app/api/documents/inbound/route.ts +++ b/src/app/api/documents/inbound/route.ts @@ -39,6 +39,8 @@ import { prisma } from "@/lib/db"; import { hashQueryTokens } from "@/lib/documents/content-index"; import { loadConditionLinks, + loadDocumentEncounterLinks, + narrowOwnedEncounterIds, narrowOwnedEpisodeIds, } from "@/lib/documents/links"; import { @@ -116,8 +118,9 @@ async function duplicateResponse( userId: string, existing: SerialisableDocument, ): Promise { - const [links, groups] = await Promise.all([ + const [links, visitLinks, groups] = await Promise.all([ loadConditionLinks(userId, [existing.id]), + loadDocumentEncounterLinks(userId, [existing.id]), prisma.extractedFact.groupBy({ by: ["status"], where: { userId, documentId: existing.id }, @@ -140,6 +143,10 @@ async function duplicateResponse( existing, { factCount, pendingCount }, links.get(existing.id) ?? [], + false, + null, + false, + visitLinks.get(existing.id) ?? [], ), error: null, meta: { duplicate: true }, @@ -212,17 +219,21 @@ async function processUpload( return apiError("Field 'file' must be a file", 422); } - // Optional metadata (title / kind / documentDate / episodeIds). The file is - // read separately; these are the form fields beside it. `episodeIds` may be - // repeated. + // Optional metadata (title / kind / documentDate / episodeIds / + // encounterIds). The file is read separately; these are the form fields + // beside it. Both id lists may be repeated. const rawEpisodeIds = formData .getAll("episodeIds") .filter((v): v is string => typeof v === "string"); + const rawEncounterIds = formData + .getAll("encounterIds") + .filter((v): v is string => typeof v === "string"); const parsed = documentCreateSchema.safeParse({ title: formData.get("title") ?? undefined, kind: formData.get("kind") ?? undefined, documentDate: formData.get("documentDate") ?? undefined, episodeIds: rawEpisodeIds.length > 0 ? rawEpisodeIds : undefined, + encounterIds: rawEncounterIds.length > 0 ? rawEncounterIds : undefined, }); if (!parsed.success) { return apiError("Invalid document metadata", 422, { @@ -268,6 +279,19 @@ async function processUpload( }); } + // Same for the visit ids the review step offered. A refusal rather than a + // silent drop: the person saw a visit named and would otherwise watch the + // upload succeed without the link they asked for. + const encounterIds = await narrowOwnedEncounterIds( + user.id, + parsed.data.encounterIds ?? [], + ); + if (encounterIds === null) { + return apiError("Visit not found", 404, { + errorCode: "documents.inbound.encounterNotFound", + }); + } + // sha256 of the PLAINTEXT for same-user duplicate detection. const contentSha256 = createHash("sha256").update(buffer).digest("hex"); @@ -346,6 +370,15 @@ async function processUpload( targetIds: episodeIds, }); } + if (encounterIds.length > 0) { + await linkTargets(tx, { + userId: user.id, + sourceKind: "document", + sourceId: created.id, + targetKind: "encounter", + targetIds: encounterIds, + }); + } return created; }); } catch (err) { @@ -382,6 +415,7 @@ async function processUpload( byteSize: document.byteSize, servingClass: detected.servingClass, linked: episodeIds.length, + linkedVisits: encounterIds.length, }, }); @@ -406,12 +440,19 @@ async function processUpload( // because of it. Only fresh inserts reach here (a duplicate returns early). void enqueueDocumentSummary(user.id, document.id); - const links = await loadConditionLinks(user.id, [document.id]); + const [links, visitLinks] = await Promise.all([ + loadConditionLinks(user.id, [document.id]), + loadDocumentEncounterLinks(user.id, [document.id]), + ]); return apiSuccess( serialiseDocument( document, { factCount: 0, pendingCount: 0 }, links.get(document.id) ?? [], + false, + null, + false, + visitLinks.get(document.id) ?? [], ), 201, ); @@ -481,8 +522,19 @@ export const GET = apiHandler(async (request: Request) => { errorCode: "documents.inbound.invalidQuery", }); } - const { q, kind, episodeId, year, from, to, sort, order, cursor, limit } = - parsed.data; + const { + q, + kind, + episodeId, + encounterId, + year, + from, + to, + sort, + order, + cursor, + limit, + } = parsed.data; const where: Prisma.InboundDocumentWhereInput = { userId: user.id, @@ -490,6 +542,10 @@ export const GET = apiHandler(async (request: Request) => { }; if (kind && kind.length > 0) where.kind = { in: kind }; if (episodeId) where.conditionLinks = { some: { episodeId } }; + // The visit filter reads the SAME table the visit's own sheet writes, from + // the document side. Filtering rather than post-filtering: a page of fifty + // documents narrowed in Node would page wrongly. + if (encounterId) where.encounterLinks = { some: { encounterId } }; if (year !== undefined) { where.documentDate = { gte: new Date(Date.UTC(year, 0, 1)), @@ -576,10 +632,16 @@ export const GET = apiHandler(async (request: Request) => { } // Condition links for the page in ONE grouped query (no N+1). - const linkMap = await loadConditionLinks( - user.id, - page.map((d) => d.id), - ); + const [linkMap, visitLinkMap] = await Promise.all([ + loadConditionLinks( + user.id, + page.map((d) => d.id), + ), + loadDocumentEncounterLinks( + user.id, + page.map((d) => d.id), + ), + ]); // Which of the page's documents have a content index (drives the searchable // status + the provenance the UI reads to tell an AI-read document from a @@ -610,7 +672,9 @@ export const GET = apiHandler(async (request: Request) => { count: page.length, sort, order, - filtered: Boolean(q || (kind && kind.length > 0) || episodeId || year), + filtered: Boolean( + q || (kind && kind.length > 0) || episodeId || encounterId || year, + ), }, }); @@ -623,6 +687,7 @@ export const GET = apiHandler(async (request: Request) => { indexSources.has(doc.id), toContentIndexSource(indexSources.get(doc.id)), thumbnailIds.has(doc.id), + visitLinkMap.get(doc.id) ?? [], ), ), nextCursor, diff --git a/src/components/documents/__tests__/document-bulk-bar.test.tsx b/src/components/documents/__tests__/document-bulk-bar.test.tsx index 2222b1800..2770facfe 100644 --- a/src/components/documents/__tests__/document-bulk-bar.test.tsx +++ b/src/components/documents/__tests__/document-bulk-bar.test.tsx @@ -4,22 +4,30 @@ import { renderToStaticMarkup } from "react-dom/server"; /** * The floating bulk bar, pinned via single-pass static renders: a labelled * toolbar carrying the selected count and the bulk verbs (set type, link - * condition, share, delete, clear). The link-condition menu only renders when - * the account actually has episodes to link — no dead affordance on an - * episode-free account. + * condition, file against a visit, share, delete, clear). Each link menu only + * renders when the account actually has something to link to — no dead + * affordance on an account with no episodes, and none on an account with no + * visits either. */ import { I18nProvider } from "@/lib/i18n/context"; import { DocumentBulkBar } from "../document-bulk-bar"; -function render(episodes: { id: string; label: string }[]) { +function render( + episodes: { id: string; label: string }[], + encounters: { id: string; label: string }[] = [ + { id: "v1", label: "Routine visit · 01/08/2026" }, + ], +) { return renderToStaticMarkup( {}} onLinkEpisode={() => {}} + onLinkEncounter={() => {}} onShare={() => {}} onDelete={() => {}} onClear={() => {}} @@ -36,6 +44,7 @@ describe("", () => { expect(html).toContain("3 selected"); expect(html).toContain("Change type"); expect(html).toContain("Link condition"); + expect(html).toContain("File against visit"); expect(html).toContain('data-slot="document-bulk-share"'); expect(html).toContain("Share"); expect(html).toContain("Delete"); @@ -47,4 +56,13 @@ describe("", () => { expect(html).not.toContain("Link condition"); expect(html).toContain("Change type"); }); + + it("omits the file-against-visit verb when the account has no visits", () => { + const html = render([{ id: "ep1", label: "Knee" }], []); + expect(html).not.toContain("File against visit"); + expect(html).not.toContain('data-slot="document-bulk-link-visit"'); + // The positive control: the neighbouring verb is still there, so the + // assertion above is about the visit menu and not about an empty render. + expect(html).toContain("Link condition"); + }); }); diff --git a/src/components/documents/__tests__/document-card.test.tsx b/src/components/documents/__tests__/document-card.test.tsx index 68e39ec2f..0d4aab26a 100644 --- a/src/components/documents/__tests__/document-card.test.tsx +++ b/src/components/documents/__tests__/document-card.test.tsx @@ -30,6 +30,7 @@ function doc(overrides: Partial = {}): InboundDocumentDto { factCount: 0, pendingCount: 0, conditionLinks: [{ episodeId: "ep-knee", name: "Knie" }], + encounterLinks: [], servingClass: "inline", hasContentIndex: false, contentIndexSource: null, diff --git a/src/components/documents/__tests__/episode-documents-card.test.tsx b/src/components/documents/__tests__/episode-documents-card.test.tsx index 500f78a3b..2d5547c69 100644 --- a/src/components/documents/__tests__/episode-documents-card.test.tsx +++ b/src/components/documents/__tests__/episode-documents-card.test.tsx @@ -50,6 +50,7 @@ function doc(id: string, title: string): InboundDocumentDto { factCount: 0, pendingCount: 0, conditionLinks: [{ episodeId: "ep1", name: "Knee" }], + encounterLinks: [], servingClass: "inline", hasContentIndex: false, contentIndexSource: null, diff --git a/src/components/documents/__tests__/vault-utils.test.ts b/src/components/documents/__tests__/vault-utils.test.ts index 122d0afab..6947140aa 100644 --- a/src/components/documents/__tests__/vault-utils.test.ts +++ b/src/components/documents/__tests__/vault-utils.test.ts @@ -38,6 +38,7 @@ function doc(overrides: Partial = {}): InboundDocumentDto { factCount: 0, pendingCount: 0, conditionLinks: [], + encounterLinks: [], servingClass: "inline", hasContentIndex: false, contentIndexSource: null, diff --git a/src/components/documents/document-bulk-bar.tsx b/src/components/documents/document-bulk-bar.tsx index 3c677d12c..45d3a2069 100644 --- a/src/components/documents/document-bulk-bar.tsx +++ b/src/components/documents/document-bulk-bar.tsx @@ -4,8 +4,13 @@ * Floating bulk-action bar for the vault's multi-select mode. Appears once * at least one document is selected, pinned above the bottom nav on phones * and near the bottom edge on desktop. Carries the selected count and the - * bulk verbs — set type, link condition, share (one link for the whole - * selection), delete (undo-able), clear — all driving the page's handlers. + * bulk verbs — set type, link condition, file against a visit, share (one + * link for the whole selection), delete (undo-able), clear — all driving the + * page's handlers. + * + * The two link verbs are the same shape and stay that way: each renders only + * when the account HAS something to link to, so an empty menu is never + * offered. * * A `role="toolbar"` with proper labels; the destructive verb sits last * before Clear so a stray tap sequence never ends on Delete. Share sits just @@ -13,7 +18,14 @@ * shell (a floating toolbar is not a Card surface): dense-tile padding `p-3` * per the standards. */ -import { FolderPlus, Share2, Tag, Trash2, X } from "lucide-react"; +import { + CalendarClock, + FolderPlus, + Share2, + Tag, + Trash2, + X, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -32,12 +44,20 @@ export interface BulkEpisodeOption { label: string; } +/** One visit the selection can be filed against. Label resolved by the page. */ +export interface BulkEncounterOption { + id: string; + label: string; +} + export function DocumentBulkBar({ selectedCount, episodes, + encounters, busy, onSetKind, onLinkEpisode, + onLinkEncounter, onShare, onDelete, onClear, @@ -45,10 +65,13 @@ export function DocumentBulkBar({ selectedCount: number; /** The caller's live illness episodes — the link-condition targets. */ episodes: BulkEpisodeOption[]; + /** The caller's recent visits — the file-against-a-visit targets. */ + encounters: BulkEncounterOption[]; /** A bulk call is in flight — the verbs disable, Clear stays live. */ busy: boolean; onSetKind: (kind: InboundDocumentKindValue) => void; onLinkEpisode: (episodeId: string) => void; + onLinkEncounter: (encounterId: string) => void; /** * Share the whole selection as ONE documents-only link. The page caps the * selection at `SHARE_LINK_MAX_DOCUMENTS` and surfaces the over-cap hint — @@ -135,6 +158,35 @@ export function DocumentBulkBar({ ) : null} + {encounters.length > 0 ? ( + + + + + + {encounters.map((encounter) => ( + onLinkEncounter(encounter.id)} + > + {encounter.label} + + ))} + + + ) : null} + + {applied ? ( + + ) : null} + + + ); + } + + // ── Two or more: a picker with nothing pre-selected ───────────────────── + return ( +
+

+ + {t("documents.detail.visitSuggestionChoose")} +

+
+ {result.encounters.map((candidate) => { + const applied = pickedId === candidate.id; + return ( + + ); + })} +
+
+ ); +} diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx index dbb049ba4..a534608fc 100644 --- a/src/components/documents/documents-view.tsx +++ b/src/components/documents/documents-view.tsx @@ -42,7 +42,10 @@ import { useAuth } from "@/hooks/use-auth"; import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; import { apiGet, apiPost } from "@/lib/api/api-fetch"; -import { useTranslations } from "@/lib/i18n/context"; +import { encounterKindText } from "@/components/encounters/encounter-labels"; +import { useEncounters } from "@/hooks/use-encounters"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import type { EncounterKind } from "@/generated/prisma/client"; import { type CoachCloseIntent, useCoachLaunch, @@ -85,6 +88,13 @@ interface ListPage { } // Strict end-of-input: `$` also accepts a trailing newline in JavaScript. +/** + * How many visits the bulk bar offers. A menu is a picker, not a history: past + * the first screenful a person scrolls rather than reads, and filing against a + * visit older than this belongs on the visit's own sheet. + */ +const BULK_VISIT_OPTIONS = 12; + const DOCUMENT_QUERY_ID_PATTERN = /^[a-zA-Z0-9_-]{1,40}(?![\s\S])/; const DOCUMENT_SELECTION_HISTORY_KEY = "__healthlogPushedDocumentSelection"; @@ -226,6 +236,7 @@ export function closeDocumentSelectionAfterCoachHandoff( export function DocumentsView() { const { t } = useTranslations(); + const format = useFormatters(); const { user, isLoading: authLoading, isAuthenticated } = useAuth(); // v1.36.x — uploading a document, filing it, sharing it and asking the AI // about it are none of them delegated verbs. Inside somebody else's record @@ -476,6 +487,27 @@ export function DocumentsView() { .sort((a, b) => a.name.localeCompare(b.name)); }, [usage.data?.linkedEpisodes, filters.episodeId, episodes.data]); + // The visits the bulk bar can file a selection against: the account's own, + // read through the same list the checkups page shows. Labelled here rather + // than in the bar so the label uses the reader's locale for the kind and the + // reader's timezone for the date — both presentation, both this side's job. + const visits = useEncounters( + !inSharedRecord || sections === null || sections.includes("profile"), + ); + const bulkEncounterOptions = useMemo( + () => + [...(visits.data?.upcoming ?? []), ...(visits.data?.past ?? [])] + .slice(0, BULK_VISIT_OPTIONS) + .map((visit) => ({ + id: visit.id, + label: `${ + visit.practitioner?.name ?? + encounterKindText(t, visit.kind as EncounterKind) + } · ${format.date(visit.occurredAt)}`, + })), + [visits.data, t, format], + ); + // Year segmenter: years present in the loaded corpus (+ the active year). const years = useMemo(() => { const set = new Set(); @@ -589,7 +621,11 @@ export function DocumentsView() { const runBulk = useCallback( ( action: Exclude, - extra: { kind?: InboundDocumentKindValue; episodeId?: string }, + extra: { + kind?: InboundDocumentKindValue; + episodeId?: string; + encounterId?: string; + }, ) => { const ids = [...selectedIds]; bulk.mutate( @@ -957,6 +993,10 @@ export function DocumentsView() { busy={bulk.isPending} onSetKind={(kind) => runBulk("setKind", { kind })} onLinkEpisode={(episodeId) => runBulk("linkEpisode", { episodeId })} + encounters={bulkEncounterOptions} + onLinkEncounter={(encounterId) => + runBulk("linkEncounter", { encounterId }) + } onShare={openBulkShare} onDelete={() => deleteBulk([...selectedIds])} onClear={clearSelection} diff --git a/src/components/documents/vault-utils.ts b/src/components/documents/vault-utils.ts index 2062e5683..9e25f9399 100644 --- a/src/components/documents/vault-utils.ts +++ b/src/components/documents/vault-utils.ts @@ -18,7 +18,7 @@ import type { DocumentVaultFilters } from "@/lib/query-keys/documents"; const KIND_SET = new Set(INBOUND_DOCUMENT_KINDS); /** - * Parse the vault's URL search params (`?q&kind&episode&year`) into the + * Parse the vault's URL search params (`?q&kind&episode&encounter&year`) into the * filter object. Unknown kinds are dropped (a hand-edited URL never 422s the * page), `kind` accepts repeats AND comma-separated values, and the result is * normalised (kinds sorted, empties omitted) so the same view always produces @@ -42,6 +42,13 @@ export function parseVaultSearchParams( const episode = params.get("episode")?.trim(); if (episode) filters.episodeId = episode.slice(0, 40); + // `?encounter=` is how a visit's own detail row reaches the vault filtered + // to what that visit produced. No chip in the filter bar backs it: the facet + // arrives by deep link from the visit and clears with the clear-all pill, + // which is exactly what a one-way link needs and no more. + const encounter = params.get("encounter")?.trim(); + if (encounter) filters.encounterId = encounter.slice(0, 40); + const yearRaw = params.get("year"); if (yearRaw && /^\d{4}$/.test(yearRaw)) { const year = Number(yearRaw); @@ -64,6 +71,7 @@ export function vaultFiltersToSearch(filters: DocumentVaultFilters): string { sp.set("kind", [...filters.kinds].sort().join(",")); } if (filters.episodeId) sp.set("episode", filters.episodeId); + if (filters.encounterId) sp.set("encounter", filters.encounterId); if (filters.year !== undefined) sp.set("year", String(filters.year)); return sp.toString(); } @@ -74,6 +82,7 @@ export function countActiveFilters(filters: DocumentVaultFilters): number { if (filters.q) count += 1; if (filters.kinds && filters.kinds.length > 0) count += filters.kinds.length; if (filters.episodeId) count += 1; + if (filters.encounterId) count += 1; if (filters.year !== undefined) count += 1; return count; } @@ -95,6 +104,7 @@ export function buildVaultListApiSearch( sp.set("kind", filters.kinds.join(",")); } if (filters.episodeId) sp.set("episodeId", filters.episodeId); + if (filters.encounterId) sp.set("encounterId", filters.encounterId); if (filters.year !== undefined) sp.set("year", String(filters.year)); sp.set("sort", "documentDate"); sp.set("order", "desc"); diff --git a/src/lib/documents/links.ts b/src/lib/documents/links.ts index dfd9ed170..79e981381 100644 --- a/src/lib/documents/links.ts +++ b/src/lib/documents/links.ts @@ -8,7 +8,10 @@ import type { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { listTargetsBySource, replaceTargets } from "@/lib/links"; -import type { DocumentConditionLinkDto } from "@/lib/validations/inbound-documents"; +import type { + DocumentConditionLinkDto, + DocumentEncounterLinkDto, +} from "@/lib/validations/inbound-documents"; /** * Load the condition links for a page of documents in ONE grouped query. @@ -39,6 +42,82 @@ export async function loadConditionLinks( return map; } +/** + * Load the VISIT links for a page of documents in ONE grouped query. + * + * Unlike the condition chips this filters tombstoned visits out: the condition + * behaviour is a long-standing one this refactor deliberately did not change, + * while a visit link is new and starts from the honest default — a deleted + * visit is not something the document still belongs to. + */ +export async function loadDocumentEncounterLinks( + userId: string, + documentIds: string[], +): Promise> { + const map = new Map(); + if (documentIds.length === 0) return map; + const byDocument = await listTargetsBySource(prisma, { + userId, + sourceKind: "document", + sourceIds: documentIds, + targetKind: "encounter", + }); + for (const [documentId, targets] of byDocument) { + map.set( + documentId, + targets.map((target) => ({ + encounterId: target.id, + kind: target.label, + occurredAt: target.date, + })), + ); + } + return map; +} + +/** + * Narrow a client-sent visit-id list to the caller's LIVE visits. + * + * Same shape and same refusal as {@link narrowOwnedEpisodeIds}: null when any + * id names nothing, so an attacker probing another account's visit ids learns + * only "not found". + */ +export async function narrowOwnedEncounterIds( + userId: string, + encounterIds: string[], +): Promise { + const unique = [...new Set(encounterIds)]; + if (unique.length === 0) return []; + const owned = await prisma.encounter.findMany({ + where: { id: { in: unique }, userId, deletedAt: null }, + select: { id: true }, + }); + if (owned.length !== unique.length) return null; + return unique; +} + +/** + * Replace-set a document's visit links inside the given client. + * + * Goes through the link service in the `document → encounter` direction, which + * is the same table the visit's own sheet writes from the other end, so the two + * surfaces cannot disagree about what is filed where. + */ +export async function replaceEncounterLinks( + tx: Prisma.TransactionClient, + userId: string, + documentId: string, + encounterIds: string[], +): Promise { + await replaceTargets(tx, { + userId, + sourceKind: "document", + sourceId: documentId, + targetKind: "encounter", + targetIds: encounterIds, + }); +} + /** * Narrow a client-sent episode-id list to the caller's LIVE episodes. * Returns the deduplicated owned ids, or null when any id is unknown / diff --git a/src/lib/documents/store.ts b/src/lib/documents/store.ts index 65998724e..e2c6e0b95 100644 --- a/src/lib/documents/store.ts +++ b/src/lib/documents/store.ts @@ -19,6 +19,7 @@ import type { import { servingClassFor } from "@/lib/documents/upload-policy"; import type { DocumentConditionLinkDto, + DocumentEncounterLinkDto, DocumentContentIndexSourceValue, DocumentSummaryStateValue, ExtractedFactDto, @@ -199,6 +200,7 @@ export function serialiseDocument( hasContentIndex = false, contentIndexSource: DocumentContentIndexSourceValue | null = null, hasThumbnail = false, + encounterLinks: DocumentEncounterLinkDto[] = [], ): InboundDocumentDto { return { id: doc.id, @@ -219,6 +221,7 @@ export function serialiseDocument( factCount: counts.factCount, pendingCount: counts.pendingCount, conditionLinks, + encounterLinks, servingClass: servingClassFor(doc.mimeType), hasContentIndex, contentIndexSource: hasContentIndex ? contentIndexSource : null, @@ -251,6 +254,7 @@ export function serialiseDocumentDetail( hasContentIndex = false, contentIndexSource: DocumentContentIndexSourceValue | null = null, hasThumbnail = false, + encounterLinks: DocumentEncounterLinkDto[] = [], ): InboundDocumentDetailDto { const pendingCount = facts.filter((f) => f.status === "PENDING").length; // `factCount` excludes REJECTED facts (a rejected fact is discarded, not part @@ -286,6 +290,7 @@ export function serialiseDocumentDetail( hasContentIndex, contentIndexSource, hasThumbnail, + encounterLinks, ), facts: facts.map(serialiseFact), summary, diff --git a/src/lib/encounters/service.ts b/src/lib/encounters/service.ts index b1078ff8f..d98bd748e 100644 --- a/src/lib/encounters/service.ts +++ b/src/lib/encounters/service.ts @@ -83,11 +83,21 @@ export async function actingDomainVisibility( return (domain) => grantCoversDomain(grant, domain); } -/** Which section of the record each link family's TARGET belongs to. */ +/** + * Which section of the record each link family's TARGET belongs to. + * + * Exhaustive over `LinkTargetKind` by the compiler, so a link kind added to + * the service cannot reach a visit's link list without somebody deciding which + * domain governs its names. `encounter` is here for that reason and is never + * read from this side — a visit does not point at another visit — but a + * `Partial` here would turn "nobody classified it" into "undefined", and an + * undefined domain read as a falsy check is a leak. + */ const TARGET_DOMAIN: Record = { document: "documents", labResult: "labs", conditionEpisode: "illness", + encounter: "profile", }; /** diff --git a/src/lib/links/link-service.ts b/src/lib/links/link-service.ts index e52c226d3..7a2515f15 100644 --- a/src/lib/links/link-service.ts +++ b/src/lib/links/link-service.ts @@ -15,7 +15,8 @@ export const MAX_TARGETS_PER_CALL = 100; export type LinkSourceKind = "encounter" | "document"; /** The record a link points at. */ -export type LinkTargetKind = "document" | "labResult" | "conditionEpisode"; +export type LinkTargetKind = + "document" | "labResult" | "conditionEpisode" | "encounter"; export interface LinkRequest { userId: string; @@ -168,6 +169,20 @@ const LAB_RESULT_ENDPOINT: EndpointSpec = { date: (row) => asDate(row.takenAt), }; +const ENCOUNTER_ENDPOINT: EndpointSpec = { + delegate: (tx) => narrow(tx.encounter), + select: { id: true, kind: true, occurredAt: true }, + // The visit's KIND, as the enum constant, and this is the one endpoint whose + // label is deliberately not prose. A document title and an analyte name are + // the person's own words and mean the same thing in every language; a visit + // kind is one of eight closed values whose human name is a presentation + // choice. Publishing the constant is what lets a German reader and an + // English one each see it named correctly from their own bundle, which a + // server-side string chosen once could not do. + label: (row) => String(row.kind), + date: (row) => asDate(row.occurredAt), +}; + const CONDITION_ENDPOINT: EndpointSpec = { delegate: (tx) => narrow(tx.illnessEpisode), select: { id: true, label: true, onsetAt: true }, @@ -211,6 +226,18 @@ const LINK_TABLES: Partial< targetColumn: "episodeId", target: CONDITION_ENDPOINT, }, + // The SAME table as `encounter:document`, read from the other end. A + // document's detail sheet answers "which visits is this filed against" + // and the visit's own sheet answers "what did this visit produce"; both + // questions live in `encounter_document_links`, so this is a second + // direction rather than a fourth table. The three-table ceiling counts + // tables. + "document:encounter": { + delegate: (tx) => narrow(tx.encounterDocumentLink), + sourceColumn: "documentId", + targetColumn: "encounterId", + target: ENCOUNTER_ENDPOINT, + }, }; function tableFor( diff --git a/src/lib/openapi/routes/documents.ts b/src/lib/openapi/routes/documents.ts index e4081882c..3d4afe2be 100644 --- a/src/lib/openapi/routes/documents.ts +++ b/src/lib/openapi/routes/documents.ts @@ -91,13 +91,13 @@ inboundFactEditSchema.meta({ documentUpdateSchema.meta({ id: "DocumentUpdateRequest", description: - "Metadata edit for a stored document: `title` (user label; null clears it), `kind` (category), `documentDate` (user filing date, YYYY-MM-DD; null clears it), `episodeIds` (REPLACE-SET of condition links — the document's links become exactly this set; an empty array unlinks everything; every id must be a live episode of the caller or the whole request answers 404). At least one field required. No `userId` field — narrowed from the session and fed to the Prisma `where` with the row id.", + "Metadata edit for a stored document: `title` (user label; null clears it), `kind` (category), `documentDate` (user filing date, YYYY-MM-DD; null clears it), `episodeIds` (REPLACE-SET of condition links — the document's links become exactly this set; an empty array unlinks everything; every id must be a live episode of the caller or the whole request answers 404), `encounterIds` (the same replace-set semantics over the caller's visits). At least one field required. No `userId` field — narrowed from the session and fed to the Prisma `where` with the row id.", }); documentBulkSchema.meta({ id: "DocumentBulkRequest", description: - "One bulk action over up to 100 owner-scoped documents: `setKind` (requires `kind`), `linkEpisode` / `unlinkEpisode` (require `episodeId`), `delete` (tombstone, undo-able for 30 days), `restore` (clear tombstone). Partial failures never abort the batch — see the per-id result array.", + "One bulk action over up to 100 owner-scoped documents: `setKind` (requires `kind`), `linkEpisode` / `unlinkEpisode` (require `episodeId`), `linkEncounter` / `unlinkEncounter` (require `encounterId`), `delete` (tombstone, undo-able for 30 days), `restore` (clear tombstone). Partial failures never abort the batch — see the per-id result array.", }); const factProvenance = z.object({ @@ -137,6 +137,18 @@ const conditionLink = z "A link to one of the caller's illness/condition episodes. `name` is the episode's user-facing label.", }); +const encounterLink = z + .object({ + encounterId: z.string(), + kind: z.string(), + occurredAt: z.string().nullable(), + }) + .meta({ + id: "DocumentEncounterLink", + description: + "A link to one of the caller's visits — \"this letter belongs to that appointment\". `kind` is the visit-kind ENUM CONSTANT rather than a rendered name: unlike a condition label, which is the person's own words, a visit kind is one of eight closed values whose human name the reader's own bundle owns, so publishing the constant is what lets a German reader and an English one each see it named correctly.", + }); + const inboundDocument = z .object({ id: z.string(), @@ -153,6 +165,7 @@ const inboundDocument = z factCount: z.number(), pendingCount: z.number(), conditionLinks: z.array(conditionLink), + encounterLinks: z.array(encounterLink), servingClass: z.enum(["inline", "attachment"]), hasContentIndex: z.boolean(), contentIndexSource: z @@ -256,6 +269,13 @@ export const inboundDocumentPaths: NonNullable = { description: "Only documents linked to this illness/condition episode.", }, + { + name: "encounterId", + in: "query", + required: false, + schema: { type: "string" }, + description: "Only documents linked to this visit.", + }, { name: "year", in: "query", @@ -356,6 +376,10 @@ export const inboundDocumentPaths: NonNullable = { description: "Optional condition pre-links (repeat the field per id). Every id must be a live episode of the caller.", }), + encounterIds: z.array(z.string()).optional().meta({ + description: + "Optional visit pre-links (repeat the field per id). Every id must be a live visit of the caller; an unknown one refuses the upload rather than dropping the link the person asked for.", + }), }), }, }, diff --git a/src/lib/query-keys/documents.ts b/src/lib/query-keys/documents.ts index 193d89a4a..b2d973a0f 100644 --- a/src/lib/query-keys/documents.ts +++ b/src/lib/query-keys/documents.ts @@ -22,6 +22,8 @@ export interface DocumentVaultFilters { kinds?: InboundDocumentKindValue[]; /** Only documents linked to this illness/condition episode. */ episodeId?: string; + /** Only documents linked to this visit. Arrives by deep link from it. */ + encounterId?: string; /** Only documents whose filing date falls in this calendar year. */ year?: number; } diff --git a/src/lib/validations/inbound-documents.ts b/src/lib/validations/inbound-documents.ts index 31cfce53e..7f325bf07 100644 --- a/src/lib/validations/inbound-documents.ts +++ b/src/lib/validations/inbound-documents.ts @@ -290,6 +290,24 @@ export interface DocumentConditionLinkDto { name: string; } +/** + * One visit link on a document DTO — "this letter belongs to that appointment". + * + * Carries the visit's KIND as the enum constant rather than a rendered name. + * A condition's label is the person's own words and reads the same in every + * language; a visit kind is one of eight closed values whose human name the + * reader's own bundle owns, so publishing the constant is what lets a German + * reader and an English one each see it named correctly. The date and the id + * are resolved server-side as usual. + */ +export interface DocumentEncounterLinkDto { + encounterId: string; + /** `ROUTINE` | `ACUTE` | … — the client renders the name. */ + kind: string; + /** ISO-8601 instant of the visit. */ + occurredAt: string | null; +} + /** The document DTO (list + detail). */ export interface InboundDocumentDto { id: string; @@ -312,6 +330,8 @@ export interface InboundDocumentDto { pendingCount: number; /** Linked illness/condition episodes (empty when unlinked). */ conditionLinks: DocumentConditionLinkDto[]; + /** Visits this document is filed against (empty when unlinked). */ + encounterLinks: DocumentEncounterLinkDto[]; /** How the serve route delivers the original: render inline or download. */ servingClass: "inline" | "attachment"; /** @@ -484,19 +504,36 @@ const episodeIdList = z .array(z.string().trim().min(1).max(40)) .max(DOCUMENT_MAX_EPISODE_LINKS); +/** + * Max visit links a single document may carry / receive per request. + * + * The same bound as the condition links, and for the same reason: a document + * filed against twenty visits is a client defect rather than a large but + * legitimate batch. + */ +export const DOCUMENT_MAX_ENCOUNTER_LINKS = 20; + +const encounterIdList = z + .array(z.string().trim().min(1).max(40)) + .max(DOCUMENT_MAX_ENCOUNTER_LINKS); + /** * The store-only upload metadata (the multipart form fields beside the file). * Every field is optional — a bare file upload is valid and lands as a STORED * document with no title / category / filing date. `episodeIds` pre-links the * document to the caller's illness/condition episodes (repeated `episodeIds` - * form fields; ownership is re-checked in the route). No `userId` field; it - * is always narrowed from the session. + * form fields; ownership is re-checked in the route), and `encounterIds` does + * the same for the caller's visits — the review step offers the visit the + * document's own date falls near, and the link rides the upload rather than + * becoming a second trip. No `userId` field; it is always narrowed from the + * session. */ export const documentCreateSchema = z.object({ title: z.string().trim().min(1).max(DOCUMENT_TITLE_MAX).optional(), kind: z.enum(INBOUND_DOCUMENT_KINDS).optional(), documentDate: isoDateString.optional(), episodeIds: episodeIdList.optional(), + encounterIds: encounterIdList.optional(), }); export type DocumentCreateInput = z.infer; @@ -524,13 +561,16 @@ export const documentUpdateSchema = z * is re-checked in the route against the caller's episodes. */ episodeIds: episodeIdList.optional(), + /** Replace-set visit links, same semantics as `episodeIds`. */ + encounterIds: encounterIdList.optional(), }) .refine( (d) => d.title !== undefined || d.kind !== undefined || d.documentDate !== undefined || - d.episodeIds !== undefined, + d.episodeIds !== undefined || + d.encounterIds !== undefined, { message: "Provide at least one field to update" }, ); @@ -561,6 +601,8 @@ export const documentListQuerySchema = z.object({ kind: z.array(z.enum(INBOUND_DOCUMENT_KINDS)).max(16).optional(), /** Only documents linked to this illness/condition episode. */ episodeId: z.string().trim().min(1).max(40).optional(), + /** Only documents linked to this visit. */ + encounterId: z.string().trim().min(1).max(40).optional(), /** Only documents whose filing date falls in this calendar year (UTC). */ year: z.coerce.number().int().min(1900).max(9999).optional(), from: isoDateString.optional(), @@ -587,6 +629,8 @@ export const DOCUMENT_BULK_ACTIONS = [ "setKind", "linkEpisode", "unlinkEpisode", + "linkEncounter", + "unlinkEncounter", "delete", "restore", ] as const; @@ -608,6 +652,7 @@ export const documentBulkSchema = z action: z.enum(DOCUMENT_BULK_ACTIONS), kind: z.enum(INBOUND_DOCUMENT_KINDS).optional(), episodeId: z.string().trim().min(1).max(40).optional(), + encounterId: z.string().trim().min(1).max(40).optional(), }) .refine((d) => d.action !== "setKind" || d.kind !== undefined, { message: "Action 'setKind' requires a kind", @@ -621,6 +666,15 @@ export const documentBulkSchema = z message: "Episode actions require an episodeId", path: ["episodeId"], }, + ) + .refine( + (d) => + (d.action !== "linkEncounter" && d.action !== "unlinkEncounter") || + d.encounterId !== undefined, + { + message: "Visit actions require an encounterId", + path: ["encounterId"], + }, ); export type DocumentBulkInput = z.infer;