diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 747976e0e..002089ea5 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -8851,8 +8851,8 @@ paths: summary: Storage usage + effective limits description: "The caller's vault usage and effective limits: `usedBytes` (every non-purged row — tombstones inside the 30-day undo grace still count), `quotaBytes` (per-user override ?? instance default), `maxFileBytes` - (admin-tunable per-file cap), and `acceptedExtensions` for picker `accept` lists. Read this before offering an - upload." + (admin-tunable per-file cap), `acceptedExtensions` for picker `accept` lists, and `linkedEpisodes` (episodes + carrying at least one live document link — the condition-filter chips). Read this before offering an upload." responses: "200": description: Usage + limits. @@ -26021,11 +26021,25 @@ components: type: array items: type: string + linkedEpisodes: + type: array + items: + type: object + properties: + episodeId: + type: string + name: + type: string + required: + - episodeId + - name + additionalProperties: false required: - usedBytes - quotaBytes - maxFileBytes - acceptedExtensions + - linkedEpisodes additionalProperties: false DocumentBulkEnvelope: type: object diff --git a/src/app/api/documents/inbound/usage/route.ts b/src/app/api/documents/inbound/usage/route.ts index bdaf44be9..bd8171388 100644 --- a/src/app/api/documents/inbound/usage/route.ts +++ b/src/app/api/documents/inbound/usage/route.ts @@ -25,13 +25,23 @@ export const GET = apiHandler(async () => { const gate = await requireModuleEnabled(user.id, "inboundDocuments"); if (!gate.enabled) return gate.response; - const [limits, rows] = await Promise.all([ + const [limits, rows, linkRows] = await Promise.all([ resolveDocumentLimits(user.id), prisma.$queryRaw>` SELECT COALESCE(SUM(byte_size), 0)::bigint AS used FROM inbound_documents WHERE user_id = ${user.id} `, + // Episodes that carry at least one LIVE document link — the filter + // bar's condition chips. Sourced here (not from the loaded corpus) so + // a chip exists even when its documents sit pages deep in the + // timeline; one indexed grouped query, no blobs. + prisma.documentConditionLink.findMany({ + where: { userId: user.id, document: { deletedAt: null } }, + select: { episodeId: true, episode: { select: { label: true } } }, + distinct: ["episodeId"], + orderBy: { episodeId: "asc" }, + }), ]); const usedBytes = Number(rows[0]?.used ?? 0); @@ -45,6 +55,10 @@ export const GET = apiHandler(async () => { quotaBytes: limits.quotaBytes, maxFileBytes: limits.maxFileBytes, acceptedExtensions: [...DOCUMENT_ACCEPTED_EXTENSIONS], + linkedEpisodes: linkRows.map((row) => ({ + episodeId: row.episodeId, + name: row.episode.label, + })), }; return apiSuccess(payload); }); diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx index 2075025d7..ea817d8b9 100644 --- a/src/components/documents/documents-view.tsx +++ b/src/components/documents/documents-view.tsx @@ -244,15 +244,15 @@ export function DocumentsView() { const episodes = useIllnessEpisodes(true); - // Condition chips: the user's episodes that actually carry links in the - // loaded corpus — plus the actively filtered episode (a deep link must - // always show its own chip, even before its documents load). + // Condition chips: every episode carrying at least one live document + // link, served by the usage endpoint (NOT derived from the loaded corpus + // — an old linked document pages deep must still surface its chip), plus + // the actively filtered episode (a deep link must always show its own + // chip, even when its last link was just removed). const conditionChips = useMemo(() => { const byId = new Map(); - for (const doc of documents) { - for (const link of doc.conditionLinks) { - if (!byId.has(link.episodeId)) byId.set(link.episodeId, link.name); - } + for (const link of usage.data?.linkedEpisodes ?? []) { + byId.set(link.episodeId, link.name); } if (filters.episodeId && !byId.has(filters.episodeId)) { const episode = episodes.data?.find((e) => e.id === filters.episodeId); @@ -261,7 +261,7 @@ export function DocumentsView() { return [...byId.entries()] .map(([episodeId, name]) => ({ episodeId, name })) .sort((a, b) => a.name.localeCompare(b.name)); - }, [documents, filters.episodeId, episodes.data]); + }, [usage.data?.linkedEpisodes, filters.episodeId, episodes.data]); // Year segmenter: years present in the loaded corpus (+ the active year). const years = useMemo(() => { diff --git a/src/lib/openapi/routes/documents.ts b/src/lib/openapi/routes/documents.ts index c0798adcc..4a0b50aa1 100644 --- a/src/lib/openapi/routes/documents.ts +++ b/src/lib/openapi/routes/documents.ts @@ -335,7 +335,7 @@ export const inboundDocumentPaths: NonNullable = { tags: ["Documents"], summary: "Storage usage + effective limits", description: - "The caller's vault usage and effective limits: `usedBytes` (every non-purged row — tombstones inside the 30-day undo grace still count), `quotaBytes` (per-user override ?? instance default), `maxFileBytes` (admin-tunable per-file cap), and `acceptedExtensions` for picker `accept` lists. Read this before offering an upload.", + "The caller's vault usage and effective limits: `usedBytes` (every non-purged row — tombstones inside the 30-day undo grace still count), `quotaBytes` (per-user override ?? instance default), `maxFileBytes` (admin-tunable per-file cap), `acceptedExtensions` for picker `accept` lists, and `linkedEpisodes` (episodes carrying at least one live document link — the condition-filter chips). Read this before offering an upload.", responses: { "200": { description: "Usage + limits.", @@ -348,6 +348,12 @@ export const inboundDocumentPaths: NonNullable = { quotaBytes: z.number(), maxFileBytes: z.number(), acceptedExtensions: z.array(z.string()), + linkedEpisodes: z.array( + z.object({ + episodeId: z.string(), + name: z.string(), + }), + ), }) .meta({ id: "DocumentUsage" }), "DocumentUsageEnvelope", diff --git a/src/lib/validations/inbound-documents.ts b/src/lib/validations/inbound-documents.ts index e7c8307e4..28b2f4841 100644 --- a/src/lib/validations/inbound-documents.ts +++ b/src/lib/validations/inbound-documents.ts @@ -517,4 +517,10 @@ export interface DocumentUsageDto { quotaBytes: number; maxFileBytes: number; acceptedExtensions: string[]; + /** + * Episodes carrying at least one LIVE document link — the vault filter + * bar's condition chips. Sourced server-side so a chip exists even when + * every linked document sits pages deep in the timeline. + */ + linkedEpisodes: DocumentConditionLinkDto[]; } diff --git a/tests/integration/documents-vault.test.ts b/tests/integration/documents-vault.test.ts index a8833b6b0..16a4a92b9 100644 --- a/tests/integration/documents-vault.test.ts +++ b/tests/integration/documents-vault.test.ts @@ -682,16 +682,45 @@ describe("document vault — bulk + usage", () => { ctx(id), ); + // Condition chips source: an episode linked to a LIVE document surfaces + // in `linkedEpisodes`; an episode whose only link sits on the tombstoned + // row does not. + const liveEpisode = await prisma.illnessEpisode.create({ + data: { + userId: user.id, + label: "Knie", + type: "INJURY", + onsetAt: new Date("2026-01-01T00:00:00Z"), + }, + }); + const deadEpisode = await prisma.illnessEpisode.create({ + data: { + userId: user.id, + label: "Erkältung", + type: "INFECTION", + onsetAt: new Date("2026-02-01T00:00:00Z"), + }, + }); + await post( + uploadRequest(PDF_SMALL, "knie.pdf", { episodeIds: [liveEpisode.id] }), + ); + await prisma.documentConditionLink.create({ + data: { documentId: id, episodeId: deadEpisode.id, userId: user.id }, + }); + const res = await getUsage( new Request("http://localhost/api/documents/inbound/usage"), ); expect(res.status).toBe(200); const { data } = await res.json(); // Tombstoned bytes still count until the purge reclaims them. - expect(data.usedBytes).toBe(PNG_1X1.byteLength); + expect(data.usedBytes).toBe(PNG_1X1.byteLength + PDF_SMALL.byteLength); expect(data.quotaBytes).toBe(123_456_789); expect(data.maxFileBytes).toBe(26_214_400); expect(data.acceptedExtensions).toContain(".pdf"); expect(data.acceptedExtensions).not.toContain(".heic"); + expect(data.linkedEpisodes).toEqual([ + { episodeId: liveEpisode.id, name: "Knie" }, + ]); }); });