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;