mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
84 lines
2.7 KiB
TypeScript
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,
|
|
});
|
|
}
|
|
}
|