mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
Seven findings from the adversarial pass before v1.36.1 was tagged. One
blocking defect shipped in that release; these did not, and they are all places
where somebody looking after another person's health record meets a wall with
no explanation, or where a guard cannot fail.
The document vault was the largest. `/documents` is marked as a shared
destination, so the shell offers it under a switch, and every read behind it
resolved the caller instead of the record: the vault painted a query-error card
and the illness view painted another beside it. That is the same failure the
release describes as fixed on the dashboard. Five GET arms move to the record
resolver now, and only the GET arms: the list, one document, its file, its
thumbnail and the vault's usage payload, which the filter bar is built from.
Uploading, retyping, deleting, linking a document to an illness and every AI
verb stay with the owner, and the two reads that spend decryption CPU key
their quota on the person calling rather than the record, the rule
`medications/compliance` already follows.
Logging a value on a metric you track yourself leaves the admitted set. It was
admitted, named on the consent screen in six languages, and unreachable: the
only form that posts one sits on a page a shared record does not open, and the
component that renders it says so in its own comment while returning null. It
is the third route the rule that withdrew allergies and family history fitted
exactly, and it was applied to two of the three. Reading those values stays.
Two guards could not fail, and both were proved by mutation before and after.
The prefetch guard checked rule three with two regexes: one was satisfied by
the doc comment every page carries, the other missed an aliased import, and
the pair stayed green with the dashboard genuinely prefetching off the
switch-blind session. It reads the AST now, resolves aliases through the
module's own import table, and pins the five prefetching pages by name so a
page that stops being detected is loud. The activity verb map decided
membership on a literal `requireRecordAuth("write")`, so putting the argument
on its own line, which is what the formatter does the moment the call grows,
dropped a route out of the scan and turned a genuinely-red run green. Both
counts are of matches now rather than of files walked.
The rest are single instances of rules applied everywhere else. "Take all due"
was withheld on a note saying it rides the bulk intake route; it does not, and
its own docblock says why not, so a helper with five morning tablets tapped
five times for nothing. Marking a dose skipped is offered wherever marking it
taken is. A form could be opened in the frame before the app knew whose record
was on screen and then stood there once the answer arrived, on the capture
sheet and on the dashboard's quick-entry sheets. Two more copies of the intake
toast were still building their own, one of them with an Undo pointing at a
route no delegate may call. The customise link on the mood page led to a page
that exists to say it is not part of what was shared. And the access-level
radios on the invitation screen are hidden by design with no focus ring on
their label, so keyboard focus moved invisibly across the one screen where
somebody grants write access to their record.
Three new structural guards, each watched fail first: the customise link sits
inside a capability branch on every surface a delegate can reach, every intake
surface confirms through the one shared decision, and the batch sweep is
offered at the level that admits it.
100 lines
4.1 KiB
TypeScript
100 lines
4.1 KiB
TypeScript
/**
|
|
* Document vault: storage usage + effective limits for the calling user.
|
|
*
|
|
* The UI reads this before offering an upload (quota bar above 80 % usage,
|
|
* client-side pre-flight against `maxFileBytes`, picker `accept` list from
|
|
* `acceptedExtensions`). `usedBytes` counts every non-purged row — tombstones
|
|
* still hold TOAST bytes until the purge job reclaims them, so "deleted"
|
|
* bytes are never invisible weight and an undo never changes usage.
|
|
*/
|
|
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
|
import { apiSuccess } from "@/lib/api-response";
|
|
import { prisma } from "@/lib/db";
|
|
import {
|
|
DOCUMENT_ACCEPTED_EXTENSIONS,
|
|
resolveDocumentLimits,
|
|
} from "@/lib/documents/upload-policy";
|
|
import { resolveOcrCapability } from "@/lib/labs/ocr-capability";
|
|
import { annotate } from "@/lib/logging/context";
|
|
import { requireModuleEnabled } from "@/lib/modules/gate";
|
|
import type { DocumentUsageDto } from "@/lib/validations/inbound-documents";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
/**
|
|
* v1.36.x — delegable. The quota gauge is the least of it: this payload also
|
|
* carries the filter bar's condition chips (the record's episodes that hold a
|
|
* live document link) and the index-coverage counts, so without it the vault a
|
|
* delegate may read loses its filters and reads as a flat pile. Every figure is
|
|
* the record's own. The `assistAvailable` flag is an availability boolean of
|
|
* the same integration-adjacent kind `nutrients` already returns — no
|
|
* credential, endpoint or token crosses the wire, and every AI action it would
|
|
* gate stays refused under a switch.
|
|
*
|
|
* No write arm exists on this route.
|
|
*/
|
|
export const GET = apiHandler(async () => {
|
|
const { user } = await requireRecordAuth("read");
|
|
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
|
if (!gate.enabled) return gate.response;
|
|
|
|
const [limits, rows, linkRows, capability, indexedCount, totalCount] =
|
|
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" },
|
|
}),
|
|
// Whether an AI action can run for this caller (assist + indexing share
|
|
// the same provider precondition). Honest availability so the UI never
|
|
// offers what the endpoint would 422.
|
|
resolveOcrCapability(user.id),
|
|
// Content-index coverage: how many live documents are indexed …
|
|
// Scope the count to LIVE documents: a soft-deleted document keeps its
|
|
// index row (cascade fires on hard purge only), so an unscoped count
|
|
// would drift above `totalCount` and read the gauge past 100 %.
|
|
prisma.documentContentIndex.count({
|
|
where: { userId: user.id, document: { deletedAt: null } },
|
|
}),
|
|
// … out of how many live documents there are.
|
|
prisma.inboundDocument.count({
|
|
where: { userId: user.id, deletedAt: null },
|
|
}),
|
|
]);
|
|
const usedBytes = Number(rows[0]?.used ?? 0);
|
|
|
|
annotate({
|
|
action: { name: "documents.vault.usage" },
|
|
meta: { usedBytes, quotaBytes: limits.quotaBytes },
|
|
});
|
|
|
|
const payload: DocumentUsageDto = {
|
|
usedBytes,
|
|
quotaBytes: limits.quotaBytes,
|
|
maxFileBytes: limits.maxFileBytes,
|
|
acceptedExtensions: [...DOCUMENT_ACCEPTED_EXTENSIONS],
|
|
linkedEpisodes: linkRows.map((row) => ({
|
|
episodeId: row.episodeId,
|
|
name: row.episode.label,
|
|
})),
|
|
assistAvailable: capability.available,
|
|
contentIndex: {
|
|
enabled: capability.available,
|
|
indexedCount,
|
|
totalCount,
|
|
},
|
|
};
|
|
return apiSuccess(payload);
|
|
});
|