mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
feat(documents): vault list contract on the client
URL-backed facets (q / kind / episode / year) parse and serialise through one pure helper pair so deep links round-trip byte-stable; the API search builder pins the timeline sort. Month bucketing + row chunking feed the virtualized grid as a flat item list. The upload-response parser owns the duplicate contract: HTTP 200 with envelope-level meta.duplicate returns the existing row, every 413/415 reason maps to a translated-copy class, and proxy-stripped bodies fall back to the HTTP status. Query keys move to the vault filter shape and gain the usage read. Plan-Task: W2-T1 (URL state), W2-T5 (keys)
This commit is contained in:
1 parent
b1004958c3
commit
37e9d933e2
4 files changed
+636
-24
No files matched your search
@@ -0,0 +1,254 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
||||
import {
|
||||
buildTimelineItems,
|
||||
buildVaultListApiSearch,
|
||||
classifyUploadFailure,
|
||||
countActiveFilters,
|
||||
documentDateKey,
|
||||
formatBytes,
|
||||
formatMonthLabel,
|
||||
parseUploadResponse,
|
||||
parseVaultSearchParams,
|
||||
vaultFiltersToSearch,
|
||||
} from "../vault-utils";
|
||||
|
||||
function doc(overrides: Partial<InboundDocumentDto> = {}): InboundDocumentDto {
|
||||
return {
|
||||
id: "doc-1",
|
||||
kind: "DOCTOR_REPORT",
|
||||
title: null,
|
||||
filename: "letter.pdf",
|
||||
mimeType: "application/pdf",
|
||||
byteSize: 1024,
|
||||
status: "STORED",
|
||||
providerType: null,
|
||||
reportDate: null,
|
||||
documentDate: "2026-03-10",
|
||||
errorReason: null,
|
||||
factCount: 0,
|
||||
pendingCount: 0,
|
||||
conditionLinks: [],
|
||||
servingClass: "inline",
|
||||
createdAt: "2026-03-11T08:00:00.000Z",
|
||||
updatedAt: "2026-03-11T08:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("URL facet round-trip", () => {
|
||||
it("parses q / kind / episode / year and serialises back identically", () => {
|
||||
const search = "q=MRT&kind=IMAGING%2CLAB_RESULT&episode=ep1&year=2025";
|
||||
const filters = parseVaultSearchParams(new URLSearchParams(search));
|
||||
expect(filters).toEqual({
|
||||
q: "MRT",
|
||||
kinds: ["IMAGING", "LAB_RESULT"],
|
||||
episodeId: "ep1",
|
||||
year: 2025,
|
||||
});
|
||||
expect(vaultFiltersToSearch(filters)).toBe(
|
||||
"q=MRT&kind=IMAGING%2CLAB_RESULT&episode=ep1&year=2025",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts repeated kind params and drops unknown kinds", () => {
|
||||
const sp = new URLSearchParams();
|
||||
sp.append("kind", "IMAGING");
|
||||
sp.append("kind", "NOT_A_KIND");
|
||||
sp.append("kind", "IMAGING"); // duplicate collapses
|
||||
const filters = parseVaultSearchParams(sp);
|
||||
expect(filters.kinds).toEqual(["IMAGING"]);
|
||||
});
|
||||
|
||||
it("normalises kind order so the cache key is stable", () => {
|
||||
const a = parseVaultSearchParams(
|
||||
new URLSearchParams("kind=LAB_RESULT,IMAGING"),
|
||||
);
|
||||
const b = parseVaultSearchParams(
|
||||
new URLSearchParams("kind=IMAGING,LAB_RESULT"),
|
||||
);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
it("ignores a malformed year and returns the empty default view", () => {
|
||||
expect(parseVaultSearchParams(new URLSearchParams("year=20x5"))).toEqual(
|
||||
{},
|
||||
);
|
||||
expect(vaultFiltersToSearch({})).toBe("");
|
||||
});
|
||||
|
||||
it("counts active facets per chip (search counts once)", () => {
|
||||
expect(countActiveFilters({})).toBe(0);
|
||||
expect(
|
||||
countActiveFilters({
|
||||
q: "a",
|
||||
kinds: ["IMAGING", "LAB_RESULT"],
|
||||
episodeId: "e",
|
||||
year: 2026,
|
||||
}),
|
||||
).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("API list search", () => {
|
||||
it("pins timeline sort, maps episode → episodeId, threads the cursor", () => {
|
||||
const search = buildVaultListApiSearch(
|
||||
{ kinds: ["IMAGING"], episodeId: "ep1", year: 2025 },
|
||||
"cursor-9",
|
||||
50,
|
||||
);
|
||||
const sp = new URLSearchParams(search);
|
||||
expect(sp.get("kind")).toBe("IMAGING");
|
||||
expect(sp.get("episodeId")).toBe("ep1");
|
||||
expect(sp.get("year")).toBe("2025");
|
||||
expect(sp.get("sort")).toBe("documentDate");
|
||||
expect(sp.get("order")).toBe("desc");
|
||||
expect(sp.get("cursor")).toBe("cursor-9");
|
||||
expect(sp.get("limit")).toBe("50");
|
||||
});
|
||||
});
|
||||
|
||||
describe("timeline items", () => {
|
||||
it("buckets by month and chunks rows to the column count", () => {
|
||||
const docs = [
|
||||
doc({ id: "a", documentDate: "2026-03-20" }),
|
||||
doc({ id: "b", documentDate: "2026-03-15" }),
|
||||
doc({ id: "c", documentDate: "2026-03-10" }),
|
||||
doc({ id: "d", documentDate: "2026-02-28" }),
|
||||
];
|
||||
const items = buildTimelineItems(docs, 2);
|
||||
expect(items.map((i) => i.type)).toEqual([
|
||||
"month",
|
||||
"row",
|
||||
"row",
|
||||
"month",
|
||||
"row",
|
||||
]);
|
||||
expect(items[0]).toEqual({ type: "month", key: "2026-03" });
|
||||
expect(items[1]).toMatchObject({ documents: [docs[0], docs[1]] });
|
||||
expect(items[2]).toMatchObject({ documents: [docs[2]] });
|
||||
expect(items[4]).toMatchObject({ documents: [docs[3]] });
|
||||
});
|
||||
|
||||
it("files an undated document under its upload day", () => {
|
||||
const d = doc({ documentDate: null, createdAt: "2026-01-05T10:00:00Z" });
|
||||
expect(documentDateKey(d)).toBe("2026-01-05");
|
||||
const items = buildTimelineItems([d], 3);
|
||||
expect(items[0]).toEqual({ type: "month", key: "2026-01" });
|
||||
});
|
||||
|
||||
it("keeps the DOM bounded: item count scales with rows, not documents", () => {
|
||||
const docs = Array.from({ length: 1000 }, (_, i) =>
|
||||
doc({ id: `d${i}`, documentDate: "2026-03-01" }),
|
||||
);
|
||||
const items = buildTimelineItems(docs, 4);
|
||||
// 1 month header + 250 rows — the virtualizer then windows over these.
|
||||
expect(items).toHaveLength(251);
|
||||
});
|
||||
|
||||
it("formats a month key in the viewer's locale", () => {
|
||||
expect(formatMonthLabel("2026-03", "de")).toBe("März 2026");
|
||||
expect(formatMonthLabel("not-a-month", "de")).toBe("not-a-month");
|
||||
});
|
||||
});
|
||||
|
||||
describe("byte formatting", () => {
|
||||
it("scales units and keeps one decimal below 10", () => {
|
||||
expect(formatBytes(0, "en")).toBe("0 B");
|
||||
expect(formatBytes(512, "en")).toBe("512 B");
|
||||
expect(formatBytes(26_214_400, "en")).toBe("25 MB");
|
||||
expect(formatBytes(1_288_490_189, "en")).toBe("1.2 GB");
|
||||
});
|
||||
});
|
||||
|
||||
describe("upload response contract (§3.2)", () => {
|
||||
it("HTTP 200 + meta.duplicate returns the EXISTING row as a duplicate", () => {
|
||||
const body = JSON.stringify({
|
||||
data: doc({ id: "existing-row" }),
|
||||
error: null,
|
||||
meta: { duplicate: true },
|
||||
});
|
||||
const result = parseUploadResponse(200, body);
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
duplicate: true,
|
||||
document: { id: "existing-row" },
|
||||
});
|
||||
});
|
||||
|
||||
it("a plain 201 success is not a duplicate", () => {
|
||||
const body = JSON.stringify({ data: doc({ id: "fresh" }), error: null });
|
||||
expect(parseUploadResponse(201, body)).toMatchObject({
|
||||
ok: true,
|
||||
duplicate: false,
|
||||
document: { id: "fresh" },
|
||||
});
|
||||
});
|
||||
|
||||
it("maps every meta.reason to its translated-copy class", () => {
|
||||
expect(
|
||||
parseUploadResponse(
|
||||
413,
|
||||
JSON.stringify({
|
||||
data: null,
|
||||
error: "too large",
|
||||
meta: { reason: "fileTooLarge", maxFileBytes: 26_214_400 },
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
reason: "fileTooLarge",
|
||||
maxFileBytes: 26_214_400,
|
||||
quotaBytes: undefined,
|
||||
usedBytes: undefined,
|
||||
existingId: undefined,
|
||||
});
|
||||
|
||||
expect(
|
||||
parseUploadResponse(
|
||||
413,
|
||||
JSON.stringify({
|
||||
data: null,
|
||||
error: "quota",
|
||||
meta: {
|
||||
reason: "quotaExceeded",
|
||||
quotaBytes: 1_073_741_824,
|
||||
usedBytes: 1_000_000_000,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
reason: "quotaExceeded",
|
||||
quotaBytes: 1_073_741_824,
|
||||
usedBytes: 1_000_000_000,
|
||||
});
|
||||
|
||||
expect(
|
||||
classifyUploadFailure(415, { reason: "unsupportedType" }),
|
||||
).toMatchObject({ reason: "unsupportedType" });
|
||||
expect(classifyUploadFailure(409, { reason: "purged" })).toMatchObject({
|
||||
reason: "purged",
|
||||
});
|
||||
expect(
|
||||
classifyUploadFailure(409, {
|
||||
reason: "duplicateExists",
|
||||
existingId: "row-1",
|
||||
}),
|
||||
).toMatchObject({ reason: "duplicateExists", existingId: "row-1" });
|
||||
});
|
||||
|
||||
it("falls back to the HTTP status when a proxy strips the body", () => {
|
||||
expect(parseUploadResponse(413, "")).toMatchObject({
|
||||
reason: "fileTooLarge",
|
||||
});
|
||||
expect(parseUploadResponse(415, "<html>")).toMatchObject({
|
||||
reason: "unsupportedType",
|
||||
});
|
||||
expect(parseUploadResponse(429, "")).toMatchObject({
|
||||
reason: "rateLimited",
|
||||
});
|
||||
expect(parseUploadResponse(500, "")).toMatchObject({ reason: "generic" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* The vault's kind vocabulary: one Lucide glyph per document kind, in the
|
||||
* fixed display order the type-chip rail and the cards share. Icons are
|
||||
* passed as components — the consuming primitive owns size and colour per
|
||||
* the design standards.
|
||||
*/
|
||||
import type { ComponentType } from "react";
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
File,
|
||||
FileText,
|
||||
FlaskConical,
|
||||
Pill,
|
||||
ScanLine,
|
||||
ShieldCheck,
|
||||
Stethoscope,
|
||||
Syringe,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
|
||||
|
||||
/** Display order for the type-chip rail (everyday kinds first, OTHER last). */
|
||||
export const DOCUMENT_KIND_ORDER: readonly InboundDocumentKindValue[] = [
|
||||
"DOCTOR_REPORT",
|
||||
"LAB_RESULT",
|
||||
"IMAGING",
|
||||
"DISCHARGE_LETTER",
|
||||
"PRESCRIPTION",
|
||||
"REFERRAL",
|
||||
"VACCINATION",
|
||||
"INSURANCE",
|
||||
"OTHER",
|
||||
];
|
||||
|
||||
export const DOCUMENT_KIND_ICONS: Record<
|
||||
InboundDocumentKindValue,
|
||||
ComponentType<{ className?: string }>
|
||||
> = {
|
||||
DOCTOR_REPORT: Stethoscope,
|
||||
DISCHARGE_LETTER: FileText,
|
||||
LAB_RESULT: FlaskConical,
|
||||
IMAGING: ScanLine,
|
||||
PRESCRIPTION: Pill,
|
||||
REFERRAL: ArrowRightLeft,
|
||||
INSURANCE: ShieldCheck,
|
||||
VACCINATION: Syringe,
|
||||
OTHER: File,
|
||||
};
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Pure helpers for the Dokumente vault surface: URL-facet parsing and
|
||||
* serialisation (the page's filter state lives in the URL so every view is
|
||||
* deep-linkable and back-button-safe), the API query-string builder, the
|
||||
* month bucketing + row chunking the virtualized timeline renders from, the
|
||||
* upload-response contract (including the HTTP-200 `meta.duplicate` path),
|
||||
* and byte formatting. Kept out of the client components so they stay
|
||||
* trivially unit-testable without a render harness.
|
||||
*/
|
||||
import {
|
||||
DOCUMENT_LIST_DEFAULT_LIMIT,
|
||||
INBOUND_DOCUMENT_KINDS,
|
||||
type InboundDocumentDto,
|
||||
type InboundDocumentKindValue,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
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
|
||||
* 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
|
||||
* the same TanStack cache key.
|
||||
*/
|
||||
export function parseVaultSearchParams(
|
||||
params: URLSearchParams,
|
||||
): DocumentVaultFilters {
|
||||
const filters: DocumentVaultFilters = {};
|
||||
|
||||
const q = params.get("q")?.trim();
|
||||
if (q) filters.q = q.slice(0, 100);
|
||||
|
||||
const kinds = params
|
||||
.getAll("kind")
|
||||
.flatMap((v) => v.split(","))
|
||||
.map((v) => v.trim())
|
||||
.filter((v): v is InboundDocumentKindValue => KIND_SET.has(v));
|
||||
if (kinds.length > 0) filters.kinds = [...new Set(kinds)].sort();
|
||||
|
||||
const episode = params.get("episode")?.trim();
|
||||
if (episode) filters.episodeId = episode.slice(0, 40);
|
||||
|
||||
const yearRaw = params.get("year");
|
||||
if (yearRaw && /^\d{4}$/.test(yearRaw)) {
|
||||
const year = Number(yearRaw);
|
||||
if (year >= 1900 && year <= 9999) filters.year = year;
|
||||
}
|
||||
|
||||
return filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialise the filter object back into the page URL's search string (no
|
||||
* leading `?`; empty string for the default view). Inverse of
|
||||
* `parseVaultSearchParams` — round-tripping is pinned by test so deep links
|
||||
* from the illness page / labs (`?episode=`, `?kind=LAB_RESULT`) stay stable.
|
||||
*/
|
||||
export function vaultFiltersToSearch(filters: DocumentVaultFilters): string {
|
||||
const sp = new URLSearchParams();
|
||||
if (filters.q) sp.set("q", filters.q);
|
||||
if (filters.kinds && filters.kinds.length > 0) {
|
||||
sp.set("kind", [...filters.kinds].sort().join(","));
|
||||
}
|
||||
if (filters.episodeId) sp.set("episode", filters.episodeId);
|
||||
if (filters.year !== undefined) sp.set("year", String(filters.year));
|
||||
return sp.toString();
|
||||
}
|
||||
|
||||
/** Count of active facets (search counts as one) for the clear-all pill. */
|
||||
export function countActiveFilters(filters: DocumentVaultFilters): number {
|
||||
let count = 0;
|
||||
if (filters.q) count += 1;
|
||||
if (filters.kinds && filters.kinds.length > 0) count += filters.kinds.length;
|
||||
if (filters.episodeId) count += 1;
|
||||
if (filters.year !== undefined) count += 1;
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `/api/documents/inbound` query string from the active facets and
|
||||
* a keyset cursor. The sort is pinned to the filing date, newest first — the
|
||||
* timeline IS the sort order. The API takes `episodeId` (the page URL uses
|
||||
* the shorter `episode`).
|
||||
*/
|
||||
export function buildVaultListApiSearch(
|
||||
filters: DocumentVaultFilters,
|
||||
cursor: string | null,
|
||||
limit: number = DOCUMENT_LIST_DEFAULT_LIMIT,
|
||||
): string {
|
||||
const sp = new URLSearchParams();
|
||||
if (filters.q) sp.set("q", filters.q);
|
||||
if (filters.kinds && filters.kinds.length > 0) {
|
||||
sp.set("kind", filters.kinds.join(","));
|
||||
}
|
||||
if (filters.episodeId) sp.set("episodeId", filters.episodeId);
|
||||
if (filters.year !== undefined) sp.set("year", String(filters.year));
|
||||
sp.set("sort", "documentDate");
|
||||
sp.set("order", "desc");
|
||||
sp.set("limit", String(limit));
|
||||
if (cursor) sp.set("cursor", cursor);
|
||||
return sp.toString();
|
||||
}
|
||||
|
||||
/** The date a document files under — its user filing date, else upload day. */
|
||||
export function documentDateKey(doc: InboundDocumentDto): string {
|
||||
if (doc.documentDate) return doc.documentDate;
|
||||
// createdAt is an ISO timestamp; the leading YYYY-MM-DD is the upload day.
|
||||
return doc.createdAt.slice(0, 10);
|
||||
}
|
||||
|
||||
/** The YYYY-MM month bucket a document files under. */
|
||||
export function documentMonthKey(doc: InboundDocumentDto): string {
|
||||
return documentDateKey(doc).slice(0, 7);
|
||||
}
|
||||
|
||||
/**
|
||||
* One flat item in the virtualized timeline: a month section label or one
|
||||
* grid row of up to `columns` documents. The virtualizer windows over this
|
||||
* flat list so the mounted DOM stays bounded regardless of corpus size.
|
||||
*/
|
||||
export type TimelineItem =
|
||||
| { type: "month"; key: string }
|
||||
| { type: "row"; key: string; documents: InboundDocumentDto[] };
|
||||
|
||||
/**
|
||||
* Bucket an already-sorted document list into consecutive YYYY-MM sections
|
||||
* and chunk each section into rows of `columns` documents. Order is preserved
|
||||
* exactly as received — the server stays the single source of truth for
|
||||
* sorting; a month opens the first time its key appears.
|
||||
*/
|
||||
export function buildTimelineItems(
|
||||
documents: InboundDocumentDto[],
|
||||
columns: number,
|
||||
): TimelineItem[] {
|
||||
const cols = Math.max(1, columns);
|
||||
const items: TimelineItem[] = [];
|
||||
let month: string | null = null;
|
||||
let row: InboundDocumentDto[] = [];
|
||||
|
||||
const flushRow = () => {
|
||||
if (row.length > 0 && month !== null) {
|
||||
items.push({ type: "row", key: `${month}:${row[0].id}`, documents: row });
|
||||
row = [];
|
||||
}
|
||||
};
|
||||
|
||||
for (const doc of documents) {
|
||||
const key = documentMonthKey(doc);
|
||||
if (key !== month) {
|
||||
flushRow();
|
||||
month = key;
|
||||
items.push({ type: "month", key });
|
||||
}
|
||||
row.push(doc);
|
||||
if (row.length === cols) flushRow();
|
||||
}
|
||||
flushRow();
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a YYYY-MM month key as a locale month label ("März 2026"). Handed
|
||||
* over as a noon-UTC instant so the month never shifts under the display
|
||||
* timezone.
|
||||
*/
|
||||
export function formatMonthLabel(key: string, locale: string): string {
|
||||
if (!/^\d{4}-\d{2}$/.test(key)) return key;
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
}).format(new Date(`${key}-15T12:00:00.000Z`));
|
||||
}
|
||||
|
||||
/** Locale byte formatting: 0 B / 412 KB / 1.2 MB / 2.4 GB. */
|
||||
export function formatBytes(bytes: number, locale: string): string {
|
||||
const units = ["B", "KB", "MB", "GB"] as const;
|
||||
let value = Math.max(0, bytes);
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
const digits = unit === 0 || value >= 100 ? 0 : value >= 10 ? 1 : 1;
|
||||
return `${new Intl.NumberFormat(locale, {
|
||||
maximumFractionDigits: digits,
|
||||
}).format(value)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
// ─── Upload response contract (§3.2) ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The §3.2 failure reasons the upload/restore paths translate client-side,
|
||||
* plus the transport-level fallbacks. `rateLimited` maps 429; `generic`
|
||||
* covers everything unclassifiable (proxy-stripped bodies included).
|
||||
*/
|
||||
export type UploadFailureReason =
|
||||
| "fileTooLarge"
|
||||
| "quotaExceeded"
|
||||
| "unsupportedType"
|
||||
| "purged"
|
||||
| "duplicateExists"
|
||||
| "rateLimited"
|
||||
| "generic";
|
||||
|
||||
export interface UploadFailure {
|
||||
ok: false;
|
||||
reason: UploadFailureReason;
|
||||
/** §3.2 — present on `fileTooLarge`. */
|
||||
maxFileBytes?: number;
|
||||
/** §3.2 — present on `quotaExceeded`. */
|
||||
quotaBytes?: number;
|
||||
usedBytes?: number;
|
||||
/** §3.2 — present on `duplicateExists` (restore-conflict shape). */
|
||||
existingId?: string;
|
||||
}
|
||||
|
||||
export interface UploadSuccess {
|
||||
ok: true;
|
||||
document: InboundDocumentDto;
|
||||
/**
|
||||
* §3.2 — a same-user re-upload is HTTP 200 with envelope-level
|
||||
* `meta.duplicate: true` and the EXISTING live row as `data`. Not an
|
||||
* error: the UI toasts "already stored" and highlights that row.
|
||||
*/
|
||||
duplicate: boolean;
|
||||
}
|
||||
|
||||
export type UploadResult = UploadSuccess | UploadFailure;
|
||||
|
||||
const REASONS: ReadonlySet<string> = new Set([
|
||||
"fileTooLarge",
|
||||
"quotaExceeded",
|
||||
"unsupportedType",
|
||||
"purged",
|
||||
"duplicateExists",
|
||||
]);
|
||||
|
||||
function numberOrUndefined(v: unknown): number | undefined {
|
||||
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
||||
}
|
||||
|
||||
/** Map a `{ status, meta }` failure onto the translated-reason union. */
|
||||
export function classifyUploadFailure(
|
||||
status: number,
|
||||
meta: Record<string, unknown> | undefined,
|
||||
): UploadFailure {
|
||||
const reason = typeof meta?.reason === "string" ? meta.reason : "";
|
||||
if (REASONS.has(reason)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: reason as UploadFailureReason,
|
||||
maxFileBytes: numberOrUndefined(meta?.maxFileBytes),
|
||||
quotaBytes: numberOrUndefined(meta?.quotaBytes),
|
||||
usedBytes: numberOrUndefined(meta?.usedBytes),
|
||||
existingId:
|
||||
typeof meta?.existingId === "string" ? meta.existingId : undefined,
|
||||
};
|
||||
}
|
||||
// A reverse proxy can strip the JSON body (and its `meta`); fall back to
|
||||
// the HTTP status so size / type / rate-limit still classify.
|
||||
if (status === 413) return { ok: false, reason: "fileTooLarge" };
|
||||
if (status === 415) return { ok: false, reason: "unsupportedType" };
|
||||
if (status === 429) return { ok: false, reason: "rateLimited" };
|
||||
return { ok: false, reason: "generic" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a completed upload's raw response (status + body text) against the
|
||||
* envelope contract. This is the ONE place the duplicate contract lives:
|
||||
* unlike every other read (which unwraps `data` and ignores `meta`), the
|
||||
* upload MUST also read the success envelope's `meta.duplicate` flag.
|
||||
*/
|
||||
export function parseUploadResponse(
|
||||
status: number,
|
||||
bodyText: string,
|
||||
): UploadResult {
|
||||
let envelope: {
|
||||
data?: unknown;
|
||||
meta?: Record<string, unknown>;
|
||||
} | null = null;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(bodyText);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
envelope = parsed as { data?: unknown; meta?: Record<string, unknown> };
|
||||
}
|
||||
} catch {
|
||||
envelope = null;
|
||||
}
|
||||
|
||||
if (status >= 200 && status < 300) {
|
||||
const doc = envelope?.data;
|
||||
if (doc && typeof doc === "object" && "id" in doc) {
|
||||
return {
|
||||
ok: true,
|
||||
document: doc as InboundDocumentDto,
|
||||
duplicate: envelope?.meta?.duplicate === true,
|
||||
};
|
||||
}
|
||||
return { ok: false, reason: "generic" };
|
||||
}
|
||||
return classifyUploadFailure(status, envelope?.meta);
|
||||
}
|
||||
@@ -1,42 +1,47 @@
|
||||
/**
|
||||
* Query keys — clinical documents library (v1.25, W-DOCS-IN): the browsable
|
||||
* document list (search / category filter / sort, keyset-paginated) and a
|
||||
* single document's detail (document + staged facts). Part of the centralized
|
||||
* factory; aggregated in `./index.ts`. Any document write (upload / edit /
|
||||
* extract / confirm / discard) invalidates the `["documents"]` prefix so the
|
||||
* list and the open detail repaint in lockstep.
|
||||
* Query keys — the Dokumente vault: the filtered timeline list (keyset-
|
||||
* paginated), a single document's detail, and the usage/limits read the
|
||||
* upload path pre-flights against. Part of the centralized factory;
|
||||
* aggregated in `./index.ts`. Any document write (upload / edit / link /
|
||||
* delete / restore / bulk) invalidates the `["documents"]` prefix so the
|
||||
* timeline, an open detail sheet, and the quota bar repaint in lockstep.
|
||||
*/
|
||||
import type {
|
||||
DocumentListSort,
|
||||
InboundDocumentKindValue,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
|
||||
|
||||
/**
|
||||
* The parameter set that distinguishes one library list view from another.
|
||||
* Lives in the key so two different filter/sort combinations never share a
|
||||
* cache bucket. `undefined` fields are dropped by TanStack's stable hash, so a
|
||||
* bare `{ sort, order }` and `{ q: undefined, kind: undefined, sort, order }`
|
||||
* resolve to the same key.
|
||||
* The parameter set that distinguishes one vault timeline view from another —
|
||||
* mirrors the URL facets (`?q&kind&episode&year`) one-to-one so a deep link
|
||||
* and an in-page filter change resolve to the same cache bucket. `undefined`
|
||||
* fields are dropped by TanStack's stable hash, so an all-defaults view and
|
||||
* an explicit `{ q: undefined, … }` share a key. Sort is pinned server-side
|
||||
* (filing date, newest first) and is deliberately NOT part of the key.
|
||||
*/
|
||||
export interface DocumentListParams {
|
||||
export interface DocumentVaultFilters {
|
||||
q?: string;
|
||||
kind?: InboundDocumentKindValue;
|
||||
from?: string;
|
||||
to?: string;
|
||||
sort: DocumentListSort;
|
||||
order: "asc" | "desc";
|
||||
/** Type facet — OR inside the facet, sorted before keying by the caller. */
|
||||
kinds?: InboundDocumentKindValue[];
|
||||
/** Only documents linked to this illness/condition episode. */
|
||||
episodeId?: string;
|
||||
/** Only documents whose filing date falls in this calendar year. */
|
||||
year?: number;
|
||||
}
|
||||
|
||||
export const documentKeys = {
|
||||
documents: () => ["documents"] as const,
|
||||
inboundDocuments: () => ["documents", "inbound"] as const,
|
||||
/**
|
||||
* The parameterised library list (keyset-paginated via `useInfiniteQuery`).
|
||||
* The parameterised vault list (keyset-paginated via `useInfiniteQuery`).
|
||||
* The literal `"list"` segment keeps it distinct from a single-document key
|
||||
* (`inboundDocument(id)`); cuid ids never collide with it. Stays under the
|
||||
* `["documents", "inbound"]` prefix so prefix invalidation still reaches it.
|
||||
*/
|
||||
inboundDocumentList: (params: DocumentListParams) =>
|
||||
["documents", "inbound", "list", params] as const,
|
||||
inboundDocumentList: (filters: DocumentVaultFilters) =>
|
||||
["documents", "inbound", "list", filters] as const,
|
||||
inboundDocument: (id: string) => ["documents", "inbound", id] as const,
|
||||
/**
|
||||
* Storage usage + effective limits (`GET /api/documents/inbound/usage`).
|
||||
* Invalidated through the `["documents"]` prefix after every upload /
|
||||
* delete so the quota bar tracks reality.
|
||||
*/
|
||||
inboundDocumentUsage: () => ["documents", "inbound", "usage"] as const,
|
||||
};
|
||||
Reference in new issue
Block a user