fix(documents): source the condition chips from the usage endpoint

The filter bar derived its condition chips from the loaded corpus, so an
episode whose only linked documents sat pages deep in the timeline never
surfaced a chip — the condition route into an older document silently
disappeared at four-digit corpus sizes. The usage endpoint (already read
on page mount) now returns `linkedEpisodes` — every episode carrying at
least one live document link, one indexed grouped query, no blobs — and
the view unions it with the active-filter fallback. Additive DTO change;
OpenAPI regenerated.
This commit is contained in:
Marc-André Bombeck committed 2026-07-07 14:15:40 +02:00
1 parent af45899f89
commit d937991af4
6 files changed
+82 -13

No files matched your search

+16 -2
View File
@@ -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
+15 -1
View File
@@ -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<Array<{ used: bigint }>>`
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);
});
+8 -8
View File
@@ -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<ConditionChip[]>(() => {
const byId = new Map<string, string>();
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(() => {
+7 -1
View File
@@ -335,7 +335,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
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",
+6
View File
@@ -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[];
}
+30 -1
View File
@@ -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" },
]);
});
});