feat(documents): a document can say which visit it belongs to

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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 13:41:42 +02:00
1 parent ed1438230c
commit be9c79974f
27 files changed
+839 -52

No files matched your search

+55
View File
@@ -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
+6 -2
View File
@@ -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",
+6 -2
View File
@@ -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",
+6 -2
View File
@@ -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",
+6 -2
View File
@@ -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",
+6 -2
View File
@@ -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",
+6 -2
View File
@@ -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",
@@ -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) ?? [],
),
);
},
+33 -2
View File
@@ -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) ?? [],
),
);
},
+48 -4
View File
@@ -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.
+77 -12
View File
@@ -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<NextResponse> {
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,
@@ -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(
<I18nProvider initialLocale="en">
<DocumentBulkBar
selectedCount={3}
episodes={episodes}
encounters={encounters}
busy={false}
onSetKind={() => {}}
onLinkEpisode={() => {}}
onLinkEncounter={() => {}}
onShare={() => {}}
onDelete={() => {}}
onClear={() => {}}
@@ -36,6 +44,7 @@ describe("<DocumentBulkBar>", () => {
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("<DocumentBulkBar>", () => {
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");
});
});
@@ -30,6 +30,7 @@ function doc(overrides: Partial<InboundDocumentDto> = {}): InboundDocumentDto {
factCount: 0,
pendingCount: 0,
conditionLinks: [{ episodeId: "ep-knee", name: "Knie" }],
encounterLinks: [],
servingClass: "inline",
hasContentIndex: false,
contentIndexSource: null,
@@ -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,
@@ -38,6 +38,7 @@ function doc(overrides: Partial<InboundDocumentDto> = {}): InboundDocumentDto {
factCount: 0,
pendingCount: 0,
conditionLinks: [],
encounterLinks: [],
servingClass: "inline",
hasContentIndex: false,
contentIndexSource: null,
+55 -3
View File
@@ -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({
</DropdownMenu>
) : null}
{encounters.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={busy}
data-slot="document-bulk-link-visit"
aria-label={t("documents.bulk.linkVisit")}
>
<CalendarClock className="size-4" aria-hidden />
<span className="hidden sm:inline">
{t("documents.bulk.linkVisit")}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{encounters.map((encounter) => (
<DropdownMenuItem
key={encounter.id}
onSelect={() => onLinkEncounter(encounter.id)}
>
<span className="max-w-56 truncate">{encounter.label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<Button
variant="outline"
size="sm"
@@ -61,7 +61,9 @@ import {
apiPatch,
apiPost,
} from "@/lib/api/api-fetch";
import { encounterKindText } from "@/components/encounters/encounter-labels";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { EncounterKind } from "@/generated/prisma/client";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import {
@@ -77,6 +79,7 @@ import {
type InboundDocumentKindValue,
} from "@/lib/validations/inbound-documents";
import { DocumentAiSection } from "./document-ai-section";
import { DocumentEncounterSuggestion } from "./document-encounter-suggestion";
import { DocumentSummaryBlock } from "./document-summary-block";
import type { DocumentAiTarget } from "./document-ai-transport";
import { DocumentShareSheet } from "./document-share-sheet";
@@ -96,6 +99,7 @@ type PatchInput = {
kind?: InboundDocumentKindValue;
documentDate?: string | null;
episodeIds?: string[];
encounterIds?: string[];
};
/**
@@ -396,6 +400,17 @@ export function DocumentDetailSheet({
const [mutationError, setMutationError] = useState<string | null>(null);
const [shareOpen, setShareOpen] = useState(false);
const [editingTitle, setEditingTitle] = useState(false);
/**
* The visit this SESSION filed the document against, if any.
*
* Kept beside the server state rather than derived from it, because the two
* answer different questions: `doc.encounterLinks` says what is filed, this
* says what the person just did here — which is what an undo can take back
* without touching a filing they made somewhere else.
*/
const [pickedEncounterId, setPickedEncounterId] = useState<string | null>(
null,
);
const [titleDraft, setTitleDraft] = useState("");
const [suggestion, setSuggestion] = useState<DocumentSuggestionDto | null>(
null,
@@ -499,6 +514,25 @@ export function DocumentDetailSheet({
patch.mutate({ episodeIds: next });
};
/**
* File this document against a visit, or clear that filing again.
*
* A replace-set PATCH like the condition links, so the undo is the same
* write with the id removed rather than a second endpoint. Nothing here can
* fail the document: an unknown id is refused by the route before anything
* is written, and the sheet keeps showing what the server last confirmed.
*/
const setSuggestedEncounter = (encounterId: string | null) => {
if (!doc) return;
const current = doc.encounterLinks.map((l) => l.encounterId);
const next =
encounterId === null
? current.filter((id) => id !== pickedEncounterId)
: [...new Set([...current, encounterId])];
setPickedEncounterId(encounterId);
patch.mutate({ encounterIds: next });
};
// The affordance gate falls back to the capability probe when the sheet is
// rendered without the usage-derived props (deep link before usage loads).
const aiEnabled = assistAvailable ?? capability.data?.available ?? false;
@@ -1001,6 +1035,48 @@ export function DocumentDetailSheet({
</div>
</div>
<DocumentEncounterSuggestion
anchor={doc.reportDate ?? doc.documentDate}
alreadyLinked={
doc.encounterLinks.length > 0 && pickedEncounterId === null
}
disabled={!canManage}
pickedId={pickedEncounterId}
onPick={(id) => setSuggestedEncounter(id)}
onUndo={() => setSuggestedEncounter(null)}
/>
{/* "Belongs to visit" — read-only here on purpose. The filing
happens at the moment the document arrives, or from the
visit's own sheet; a second editor for the same link would
be a second place the set can be changed. Each row deep-links
to the vault filtered to that visit. */}
{doc.encounterLinks.length > 0 ? (
<div className="space-y-1.5" data-slot="document-visit-links">
<p className="text-sm leading-none font-medium">
{t("documents.detail.visitsLabel")}
</p>
<div className="flex flex-wrap items-center gap-1.5">
{doc.encounterLinks.map((link) => (
<Link
key={link.encounterId}
href={`/documents?encounter=${encodeURIComponent(link.encounterId)}`}
className="bg-muted text-foreground hover:bg-muted/70 focus-visible:ring-ring/50 inline-flex max-w-64 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs focus-visible:ring-[3px] focus-visible:outline-none"
>
<span className="truncate">
{encounterKindText(t, link.kind as EncounterKind)}
</span>
{link.occurredAt ? (
<span className="text-muted-foreground shrink-0">
{format.date(link.occurredAt)}
</span>
) : null}
</Link>
))}
</div>
</div>
) : null}
{labsModuleEnabled && labFactCount > 0 ? (
<Link
href="/labs"
@@ -0,0 +1,147 @@
"use client";
/**
* "Gehört zu Termin …" — the incidental link, offered at the moment a document
* is being looked at rather than as a filing chore afterwards.
*
* The rule is not implemented here. It lives in `suggest-window.ts` and is
* resolved server-side, so this surface, the lab review and the manual lab form
* cannot answer the same question differently. What this component owns is only
* how the three verdicts LOOK:
*
* one candidate → pre-selected, visibly, with one undo;
* two or more → a picker with NOTHING pre-selected;
* none → nothing at all. No empty picker, no "create a visit?" nudge.
*
* It never blocks anything, it is never behind an AI call — the match is a
* date-window query over the person's own rows and resolves on an account with
* no provider configured — and it renders nothing once the document already
* names a visit: an offer to file something already filed is noise.
*
* The row is a content sentence, not meta, so it is `text-foreground`.
*/
import { CalendarClock, Check, Undo2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { encounterKindText } from "@/components/encounters/encounter-labels";
import { useEncounterSuggestion } from "@/hooks/use-encounters";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { EncounterKind } from "@/generated/prisma/client";
import type { EncounterSuggestion } from "@/lib/encounters/suggest-window";
export function DocumentEncounterSuggestion({
anchor,
alreadyLinked,
disabled,
onPick,
pickedId,
onUndo,
}: {
/** `reportDate ?? documentDate`, ISO-8601, or null when the document has none. */
anchor: string | null;
/** The document already names a visit — there is nothing to offer. */
alreadyLinked: boolean;
disabled?: boolean;
onPick: (encounterId: string) => void;
/** The visit this surface has just filed against, for the undo affordance. */
pickedId: string | null;
onUndo: () => void;
}) {
const { t } = useTranslations();
const format = useFormatters();
const suggestion = useEncounterSuggestion(
anchor,
!alreadyLinked && !disabled,
);
if (alreadyLinked || disabled) return null;
const result = suggestion.data;
if (!result || result.kind === "none") return null;
const label = (candidate: EncounterSuggestion) =>
`${
candidate.practitionerName ??
encounterKindText(t, candidate.kind as EncounterKind)
} · ${format.date(candidate.occurredAt)}`;
// ── One candidate: pre-selected, and undoable in one tap ────────────────
if (result.kind === "one") {
const candidate = result.encounter;
const applied = pickedId === candidate.id;
return (
<div
className="space-y-1.5"
data-slot="document-encounter-suggestion"
data-verdict="one"
>
<p className="text-foreground flex items-center gap-2 text-sm">
<CalendarClock className="size-4 shrink-0" aria-hidden />
{t("documents.detail.visitSuggestion")}
</p>
<div className="flex flex-wrap items-center gap-1.5">
<Button
type="button"
variant={applied ? "default" : "outline"}
size="sm"
className="min-h-11"
aria-pressed={applied}
data-slot="document-encounter-suggestion-apply"
onClick={() => onPick(candidate.id)}
>
<Check
className={cn("size-4", applied ? "opacity-100" : "opacity-0")}
aria-hidden
/>
{label(candidate)}
</Button>
{applied ? (
<Button
type="button"
variant="ghost"
size="sm"
className="min-h-11"
onClick={onUndo}
>
<Undo2 className="size-4" aria-hidden />
{t("common.undo")}
</Button>
) : null}
</div>
</div>
);
}
// ── Two or more: a picker with nothing pre-selected ─────────────────────
return (
<div
className="space-y-1.5"
data-slot="document-encounter-suggestion"
data-verdict="many"
>
<p className="text-foreground flex items-center gap-2 text-sm">
<CalendarClock className="size-4 shrink-0" aria-hidden />
{t("documents.detail.visitSuggestionChoose")}
</p>
<div className="flex flex-wrap items-center gap-1.5">
{result.encounters.map((candidate) => {
const applied = pickedId === candidate.id;
return (
<Button
key={candidate.id}
type="button"
variant={applied ? "default" : "outline"}
size="sm"
className="min-h-11"
aria-pressed={applied}
data-slot="document-encounter-suggestion-option"
onClick={() => (applied ? onUndo() : onPick(candidate.id))}
>
{label(candidate)}
</Button>
);
})}
</div>
</div>
);
}
+42 -2
View File
@@ -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<number>();
@@ -589,7 +621,11 @@ export function DocumentsView() {
const runBulk = useCallback(
(
action: Exclude<DocumentBulkAction, "delete" | "restore">,
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}
+11 -1
View File
@@ -18,7 +18,7 @@ import type { DocumentVaultFilters } from "@/lib/query-keys/documents";
const KIND_SET = new Set<string>(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");
+80 -1
View File
@@ -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<Map<string, DocumentEncounterLinkDto[]>> {
const map = new Map<string, DocumentEncounterLinkDto[]>();
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<string[] | null> {
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<void> {
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 /
+5
View File
@@ -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,
+11 -1
View File
@@ -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<LinkTargetKind, ShareDomain> = {
document: "documents",
labResult: "labs",
conditionEpisode: "illness",
encounter: "profile",
};
/**
+28 -1
View File
@@ -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<OwnedRowDelegate>(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<OwnedRowDelegate>(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<LinkRowDelegate>(tx.encounterDocumentLink),
sourceColumn: "documentId",
targetColumn: "encounterId",
target: ENCOUNTER_ENDPOINT,
},
};
function tableFor(
+26 -2
View File
@@ -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<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
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.",
}),
}),
},
},
+2
View File
@@ -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;
}
+57 -3
View File
@@ -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<typeof documentCreateSchema>;
@@ -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<typeof documentBulkSchema>;