Files
HealthLog/src/lib/documents/links.ts
T
Marc-André Bombeck b76873ee1f feat(documents): rebuild upload and list on the vault contract
POST /api/documents/inbound now runs the policy layer end to end: bounded
read aborts at the admin-set cap (413 + meta.reason=fileTooLarge with the
limit), magic-byte classification (415 + unsupportedType), per-user quota
checked inside the insert transaction over every non-purged row —
tombstones keep counting, so deleted bytes are never invisible weight
(413 + quotaExceeded with the figures), sha256 dedupe (a same-bytes
re-upload answers 200 + meta.duplicate with the existing live row; the
partial unique index closes the race), an honoured Idempotency-Key, the
binary2 codec on write, and optional episodeIds[] pre-linking narrowed to
the caller's live episodes.

GET stops fetching the encrypted blob per row (omit: contentEncrypted —
a 50-row page dragged up to 50 × cap ciphertext before), learns kind[] /
episodeId / year facets on top of q/from/to, and returns conditionLinks +
servingClass on every DTO via one grouped link query (no N+1). PATCH gains
replace-set episodeIds with the same 404-shaped refusal for a foreign
episode. The extract path decrypts through the codec dispatcher.

The document-kind labels gain REFERRAL in all six locales.

Tasks: W1-T5, W1-T6, W1-T7.
2026-07-07 11:05:12 +02:00

84 lines
2.7 KiB
TypeScript

/**
* Document ⇄ condition link helpers shared by the vault routes.
*
* One grouped query per page (no N+1), owner-scoped everywhere: every episode
* id a client sends is re-narrowed against the caller's live episodes before
* it can land in `document_condition_links`.
*/
import { prisma } from "@/lib/db";
import type { DocumentConditionLinkDto } from "@/lib/validations/inbound-documents";
/**
* Load the condition links for a page of documents in ONE grouped query.
* Returns a map documentId → link DTOs (documents without links are absent).
*/
export async function loadConditionLinks(
userId: string,
documentIds: string[],
): Promise<Map<string, DocumentConditionLinkDto[]>> {
const map = new Map<string, DocumentConditionLinkDto[]>();
if (documentIds.length === 0) return map;
const links = await prisma.documentConditionLink.findMany({
where: { userId, documentId: { in: documentIds } },
select: {
documentId: true,
episodeId: true,
episode: { select: { label: true } },
},
orderBy: { createdAt: "asc" },
});
for (const link of links) {
const entry = map.get(link.documentId) ?? [];
entry.push({ episodeId: link.episodeId, name: link.episode.label });
map.set(link.documentId, entry);
}
return map;
}
/**
* 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 /
* foreign / deleted — the caller answers with a 404-shaped refusal (an
* attacker probing another user's episode ids learns nothing beyond
* "not found").
*/
export async function narrowOwnedEpisodeIds(
userId: string,
episodeIds: string[],
): Promise<string[] | null> {
const unique = [...new Set(episodeIds)];
if (unique.length === 0) return [];
const owned = await prisma.illnessEpisode.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 condition links inside the given transaction-ish
* client: delete links no longer in the set, insert the missing ones.
* `episodeIds` MUST already be owner-narrowed (`narrowOwnedEpisodeIds`).
*/
export async function replaceConditionLinks(
tx: Pick<typeof prisma, "documentConditionLink">,
userId: string,
documentId: string,
episodeIds: string[],
): Promise<void> {
await tx.documentConditionLink.deleteMany({
where: { userId, documentId, episodeId: { notIn: episodeIds } },
});
if (episodeIds.length > 0) {
await tx.documentConditionLink.createMany({
data: episodeIds.map((episodeId) => ({
documentId,
episodeId,
userId,
})),
skipDuplicates: true,
});
}
}