mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
af45899f89
commit
d937991af4
6 files changed
+82
-13
No files matched your search
+16
-2
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user