Merge remote-tracking branch 'origin/main' into fix/settings-ekg-polish

This commit is contained in:
Marc-André Bombeck committed 2026-08-08 16:17:40 +02:00
commit 4c2dfa57b4
138 files changed
+12046 -137

No files matched your search

@@ -23,6 +23,11 @@ vi.mock("@/lib/db", () => ({
deleteMany: vi.fn(),
createMany: vi.fn(),
},
encounterDocumentLink: {
findMany: vi.fn(),
deleteMany: vi.fn(),
createMany: vi.fn(),
},
documentContentIndex: {
findUnique: vi.fn(),
},
@@ -32,6 +37,9 @@ vi.mock("@/lib/db", () => ({
illnessEpisode: {
findMany: vi.fn(),
},
encounter: {
findMany: vi.fn(),
},
appSettings: {
findUnique: vi.fn(),
},
@@ -129,6 +137,10 @@ beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
vi.mocked(prisma.encounterDocumentLink.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue(
[] as never,
);
@@ -15,7 +15,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { loadConditionLinks } from "@/lib/documents/links";
import {
loadConditionLinks,
loadDocumentEncounterLinks,
} from "@/lib/documents/links";
import { serialiseDocumentDetail } from "@/lib/documents/store";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
@@ -83,9 +86,20 @@ export const POST = apiHandler(
if (row.deletedAt === null) {
// Already live — restoring twice is a no-op, not an error (the undo
// toast may fire after a concurrent restore).
const links = await loadConditionLinks(user.id, [row.id]);
const [links, visitLinks] = await Promise.all([
loadConditionLinks(user.id, [row.id]),
loadDocumentEncounterLinks(user.id, [row.id]),
]);
return apiSuccess(
serialiseDocumentDetail(row, row.facts, links.get(row.id) ?? []),
serialiseDocumentDetail(
row,
row.facts,
links.get(row.id) ?? [],
false,
null,
false,
visitLinks.get(row.id) ?? [],
),
);
}
@@ -131,7 +145,10 @@ export const POST = apiHandler(
omit: { contentEncrypted: true },
include: { facts: { orderBy: { createdAt: "asc" } } },
});
const links = await loadConditionLinks(user.id, [restored.id]);
const [links, visitLinks] = await Promise.all([
loadConditionLinks(user.id, [restored.id]),
loadDocumentEncounterLinks(user.id, [restored.id]),
]);
await auditLog("documents.inbound.restore", {
userId: user.id,
@@ -149,6 +166,10 @@ export const POST = apiHandler(
restored,
restored.facts,
links.get(restored.id) ?? [],
false,
null,
false,
visitLinks.get(restored.id) ?? [],
),
);
},
+33 -2
View File
@@ -20,8 +20,11 @@ import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import {
loadConditionLinks,
loadDocumentEncounterLinks,
narrowOwnedEncounterIds,
narrowOwnedEpisodeIds,
replaceConditionLinks,
replaceEncounterLinks,
} from "@/lib/documents/links";
import { serialiseDocumentDetail } from "@/lib/documents/store";
import { annotate } from "@/lib/logging/context";
@@ -92,8 +95,9 @@ export const GET = apiHandler(
});
}
const [links, contentIndex, thumbnail] = await Promise.all([
const [links, visitLinks, contentIndex, thumbnail] = await Promise.all([
loadConditionLinks(user.id, [document.id]),
loadDocumentEncounterLinks(user.id, [document.id]),
prisma.documentContentIndex.findUnique({
where: { documentId: document.id },
select: { source: true },
@@ -117,6 +121,7 @@ export const GET = apiHandler(
contentIndex !== null,
toContentIndexSource(contentIndex?.source),
thumbnail !== null,
visitLinks.get(document.id) ?? [],
),
);
},
@@ -169,6 +174,21 @@ export const PATCH = apiHandler(
nextEpisodeIds = narrowed;
}
// Replace-set visit links, same semantics and same refusal.
let nextEncounterIds: string[] | undefined;
if (parsed.data.encounterIds !== undefined) {
const narrowed = await narrowOwnedEncounterIds(
user.id,
parsed.data.encounterIds,
);
if (narrowed === null) {
return apiError("Visit not found", 404, {
errorCode: "documents.inbound.encounterNotFound",
});
}
nextEncounterIds = narrowed;
}
// No mass assignment — each editable column is set explicitly only when
// the client sent it. `userId` is never a body field.
const data: Prisma.InboundDocumentUpdateInput = {};
@@ -189,14 +209,23 @@ export const PATCH = apiHandler(
if (nextEpisodeIds !== undefined) {
await replaceConditionLinks(prisma, user.id, existing.id, nextEpisodeIds);
}
if (nextEncounterIds !== undefined) {
await replaceEncounterLinks(
prisma,
user.id,
existing.id,
nextEncounterIds,
);
}
const document = await prisma.inboundDocument.findFirstOrThrow({
where: { id: existing.id },
omit: { contentEncrypted: true },
include: { facts: { orderBy: { createdAt: "asc" } } },
});
const [links, contentIndex, thumbnail] = await Promise.all([
const [links, visitLinks, contentIndex, thumbnail] = await Promise.all([
loadConditionLinks(user.id, [document.id]),
loadDocumentEncounterLinks(user.id, [document.id]),
prisma.documentContentIndex.findUnique({
where: { documentId: document.id },
select: { source: true },
@@ -210,6 +239,7 @@ export const PATCH = apiHandler(
const touched = [
...Object.keys(data),
...(nextEpisodeIds !== undefined ? ["episodeIds"] : []),
...(nextEncounterIds !== undefined ? ["encounterIds"] : []),
];
await auditLog("documents.inbound.update", {
@@ -234,6 +264,7 @@ export const PATCH = apiHandler(
contentIndex !== null,
toContentIndexSource(contentIndex?.source),
thumbnail !== null,
visitLinks.get(document.id) ?? [],
),
);
},
@@ -20,6 +20,8 @@ const {
txCreateMany,
txDocumentFindMany,
txEpisodeFindMany,
txEncounterFindMany,
txEncounterLinkCreateMany,
} = vi.hoisted(() => ({
txQueryRaw: vi.fn(),
txCreate: vi.fn(),
@@ -28,6 +30,8 @@ const {
// writes, so the transaction double has to answer for them.
txDocumentFindMany: vi.fn(),
txEpisodeFindMany: vi.fn(),
txEncounterFindMany: vi.fn(),
txEncounterLinkCreateMany: vi.fn(),
}));
vi.mock("@/lib/db", () => {
@@ -35,7 +39,9 @@ vi.mock("@/lib/db", () => {
$queryRaw: txQueryRaw,
inboundDocument: { create: txCreate, findMany: txDocumentFindMany },
illnessEpisode: { findMany: txEpisodeFindMany },
encounter: { findMany: txEncounterFindMany },
documentConditionLink: { createMany: txCreateMany },
encounterDocumentLink: { createMany: txEncounterLinkCreateMany },
};
return {
prisma: {
@@ -50,6 +56,9 @@ vi.mock("@/lib/db", () => {
documentConditionLink: {
findMany: vi.fn(),
},
encounterDocumentLink: {
findMany: vi.fn(),
},
documentContentIndex: {
findMany: vi.fn(),
},
@@ -59,6 +68,9 @@ vi.mock("@/lib/db", () => {
illnessEpisode: {
findMany: vi.fn(),
},
encounter: {
findMany: vi.fn(),
},
appSettings: {
findUnique: vi.fn(),
},
@@ -183,6 +195,12 @@ beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
vi.mocked(prisma.extractedFact.groupBy).mockResolvedValue([] as never);
vi.mocked(prisma.encounterDocumentLink.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never);
txEncounterFindMany.mockResolvedValue([]);
txEncounterLinkCreateMany.mockResolvedValue({ count: 0 });
vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue(
[] as never,
);
+48 -4
View File
@@ -1,9 +1,11 @@
/**
* Document vault: bulk actions over up to 100 owner-scoped documents.
*
* `{ ids[], action, kind?, episodeId? }` → a per-id result array. Actions:
* `setKind` (recategorise), `linkEpisode` / `unlinkEpisode` (condition
* links), `delete` (tombstone, undo-able), `restore` (clear tombstone). A
* `{ ids[], action, kind?, episodeId?, encounterId? }` → a per-id result
* array. Actions: `setKind` (recategorise), `linkEpisode` / `unlinkEpisode`
* (condition links), `linkEncounter` / `unlinkEncounter` (visit links, the
* same table the visit's own sheet writes from the other end),
* `delete` (tombstone, undo-able), `restore` (clear tombstone). A
* partial failure never aborts the batch — each id reports `ok` or a short
* machine reason. Every id is narrowed to the caller; a foreign id reads as
* "notFound", exactly like the single-document routes.
@@ -68,7 +70,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
const { ids, action, kind, episodeId } = parsed.data;
const { ids, action, kind, episodeId, encounterId } = parsed.data;
const uniqueIds = [...new Set(ids)];
// Episode actions: the target episode must be a LIVE episode of the caller.
@@ -84,6 +86,21 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
}
// Visit actions: the target visit must be a LIVE visit of the caller. Same
// shape as the episode gate above — the endpoint is checked once, before the
// batch, so a foreign id costs one refusal rather than a hundred.
if (action === "linkEncounter" || action === "unlinkEncounter") {
const encounter = await prisma.encounter.findFirst({
where: { id: encounterId!, userId: user.id, deletedAt: null },
select: { id: true },
});
if (!encounter) {
return apiError("Visit not found", 404, {
errorCode: "documents.inbound.encounterNotFound",
});
}
}
// One owner-scoped sweep resolves every id's current state; per-id work
// then never touches a row the caller does not own.
const owned = await prisma.inboundDocument.findMany({
@@ -136,6 +153,33 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
break;
}
case "linkEncounter": {
if (row.deletedAt !== null) {
results.push({ id, ok: false, error: "notFound" });
continue;
}
await linkTargets(prisma, {
userId: user.id,
sourceKind: "document",
sourceId: id,
targetKind: "encounter",
targetIds: [encounterId!],
});
break;
}
case "unlinkEncounter": {
// No tombstone check, matching `unlinkEpisode`: clearing a filing on
// a document the account has since deleted must stay possible, or
// the filing is unreachable.
await unlinkTargets(prisma, {
userId: user.id,
sourceKind: "document",
sourceId: id,
targetKind: "encounter",
targetIds: [encounterId!],
});
break;
}
case "delete": {
if (row.deletedAt !== null) {
// Already tombstoned — deleting twice is a no-op success.
+77 -12
View File
@@ -39,6 +39,8 @@ import { prisma } from "@/lib/db";
import { hashQueryTokens } from "@/lib/documents/content-index";
import {
loadConditionLinks,
loadDocumentEncounterLinks,
narrowOwnedEncounterIds,
narrowOwnedEpisodeIds,
} from "@/lib/documents/links";
import {
@@ -116,8 +118,9 @@ async function duplicateResponse(
userId: string,
existing: SerialisableDocument,
): Promise<NextResponse> {
const [links, groups] = await Promise.all([
const [links, visitLinks, groups] = await Promise.all([
loadConditionLinks(userId, [existing.id]),
loadDocumentEncounterLinks(userId, [existing.id]),
prisma.extractedFact.groupBy({
by: ["status"],
where: { userId, documentId: existing.id },
@@ -140,6 +143,10 @@ async function duplicateResponse(
existing,
{ factCount, pendingCount },
links.get(existing.id) ?? [],
false,
null,
false,
visitLinks.get(existing.id) ?? [],
),
error: null,
meta: { duplicate: true },
@@ -212,17 +219,21 @@ async function processUpload(
return apiError("Field 'file' must be a file", 422);
}
// Optional metadata (title / kind / documentDate / episodeIds). The file is
// read separately; these are the form fields beside it. `episodeIds` may be
// repeated.
// Optional metadata (title / kind / documentDate / episodeIds /
// encounterIds). The file is read separately; these are the form fields
// beside it. Both id lists may be repeated.
const rawEpisodeIds = formData
.getAll("episodeIds")
.filter((v): v is string => typeof v === "string");
const rawEncounterIds = formData
.getAll("encounterIds")
.filter((v): v is string => typeof v === "string");
const parsed = documentCreateSchema.safeParse({
title: formData.get("title") ?? undefined,
kind: formData.get("kind") ?? undefined,
documentDate: formData.get("documentDate") ?? undefined,
episodeIds: rawEpisodeIds.length > 0 ? rawEpisodeIds : undefined,
encounterIds: rawEncounterIds.length > 0 ? rawEncounterIds : undefined,
});
if (!parsed.success) {
return apiError("Invalid document metadata", 422, {
@@ -268,6 +279,19 @@ async function processUpload(
});
}
// Same for the visit ids the review step offered. A refusal rather than a
// silent drop: the person saw a visit named and would otherwise watch the
// upload succeed without the link they asked for.
const encounterIds = await narrowOwnedEncounterIds(
user.id,
parsed.data.encounterIds ?? [],
);
if (encounterIds === null) {
return apiError("Visit not found", 404, {
errorCode: "documents.inbound.encounterNotFound",
});
}
// sha256 of the PLAINTEXT for same-user duplicate detection.
const contentSha256 = createHash("sha256").update(buffer).digest("hex");
@@ -346,6 +370,15 @@ async function processUpload(
targetIds: episodeIds,
});
}
if (encounterIds.length > 0) {
await linkTargets(tx, {
userId: user.id,
sourceKind: "document",
sourceId: created.id,
targetKind: "encounter",
targetIds: encounterIds,
});
}
return created;
});
} catch (err) {
@@ -382,6 +415,7 @@ async function processUpload(
byteSize: document.byteSize,
servingClass: detected.servingClass,
linked: episodeIds.length,
linkedVisits: encounterIds.length,
},
});
@@ -406,12 +440,19 @@ async function processUpload(
// because of it. Only fresh inserts reach here (a duplicate returns early).
void enqueueDocumentSummary(user.id, document.id);
const links = await loadConditionLinks(user.id, [document.id]);
const [links, visitLinks] = await Promise.all([
loadConditionLinks(user.id, [document.id]),
loadDocumentEncounterLinks(user.id, [document.id]),
]);
return apiSuccess(
serialiseDocument(
document,
{ factCount: 0, pendingCount: 0 },
links.get(document.id) ?? [],
false,
null,
false,
visitLinks.get(document.id) ?? [],
),
201,
);
@@ -481,8 +522,19 @@ export const GET = apiHandler(async (request: Request) => {
errorCode: "documents.inbound.invalidQuery",
});
}
const { q, kind, episodeId, year, from, to, sort, order, cursor, limit } =
parsed.data;
const {
q,
kind,
episodeId,
encounterId,
year,
from,
to,
sort,
order,
cursor,
limit,
} = parsed.data;
const where: Prisma.InboundDocumentWhereInput = {
userId: user.id,
@@ -490,6 +542,10 @@ export const GET = apiHandler(async (request: Request) => {
};
if (kind && kind.length > 0) where.kind = { in: kind };
if (episodeId) where.conditionLinks = { some: { episodeId } };
// The visit filter reads the SAME table the visit's own sheet writes, from
// the document side. Filtering rather than post-filtering: a page of fifty
// documents narrowed in Node would page wrongly.
if (encounterId) where.encounterLinks = { some: { encounterId } };
if (year !== undefined) {
where.documentDate = {
gte: new Date(Date.UTC(year, 0, 1)),
@@ -576,10 +632,16 @@ export const GET = apiHandler(async (request: Request) => {
}
// Condition links for the page in ONE grouped query (no N+1).
const linkMap = await loadConditionLinks(
user.id,
page.map((d) => d.id),
);
const [linkMap, visitLinkMap] = await Promise.all([
loadConditionLinks(
user.id,
page.map((d) => d.id),
),
loadDocumentEncounterLinks(
user.id,
page.map((d) => d.id),
),
]);
// Which of the page's documents have a content index (drives the searchable
// status + the provenance the UI reads to tell an AI-read document from a
@@ -610,7 +672,9 @@ export const GET = apiHandler(async (request: Request) => {
count: page.length,
sort,
order,
filtered: Boolean(q || (kind && kind.length > 0) || episodeId || year),
filtered: Boolean(
q || (kind && kind.length > 0) || episodeId || encounterId || year,
),
},
});
@@ -623,6 +687,7 @@ export const GET = apiHandler(async (request: Request) => {
indexSources.has(doc.id),
toContentIndexSource(indexSources.get(doc.id)),
thumbnailIds.has(doc.id),
visitLinkMap.get(doc.id) ?? [],
),
),
nextCursor,
+22 -3
View File
@@ -36,6 +36,7 @@ import {
ENCOUNTER_INCLUDE,
actingDomainVisibility,
applyEncounterLinks,
loadEncounterLinksForMany,
closeCheckupForVisit,
resolveClosableReminder,
resolveOwnedPractitioner,
@@ -50,7 +51,7 @@ import {
} from "@/lib/export/restore-skips";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "profile");
const { user, grantId } = await requireRecordAuth("read", "profile");
const params = new URL(request.url).searchParams;
const parsed = encounterListQuerySchema.safeParse({
@@ -58,6 +59,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
to: params.get("to") ?? undefined,
status: params.get("status") ?? undefined,
practitionerId: params.get("practitionerId") ?? undefined,
episodeId: params.get("episodeId") ?? undefined,
limit: params.get("limit") ?? undefined,
});
if (!parsed.success) {
@@ -75,6 +77,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
deletedAt: null,
...(query.status ? { status: query.status } : {}),
...(query.practitionerId ? { practitionerId: query.practitionerId } : {}),
// Filtered in SQL rather than in Node: the two arms are separately
// `take`-bounded, so narrowing after the read would page wrongly.
...(query.episodeId
? { conditionLinks: { some: { episodeId: query.episodeId } } }
: {}),
...(query.from || query.to
? {
occurredAt: {
@@ -118,14 +125,26 @@ export const GET = apiHandler(async (request: NextRequest) => {
}),
]);
// The links ride the list. The card counts what a visit produced, and the
// edit sheet seeds its pickers from the row it was handed — a list without
// them makes the first read zero and the second unfile everything on save.
// Three grouped queries for the page, not three per row.
const visible = await actingDomainVisibility(prisma, grantId);
const links = await loadEncounterLinksForMany(
prisma,
user.id,
[...upcoming, ...past].map((row) => row.id),
visible,
);
annotate({
action: { name: "encounter.visit.list", entity_type: "encounter" },
meta: { upcoming: upcoming.length, past: past.length },
});
const body: EncounterListDTO = {
upcoming: upcoming.map((row) => toEncounterDTO(row)),
past: past.map((row) => toEncounterDTO(row)),
upcoming: upcoming.map((row) => toEncounterDTO(row, links.get(row.id))),
past: past.map((row) => toEncounterDTO(row, links.get(row.id))),
};
return apiSuccess(body);
});
+59
View File
@@ -0,0 +1,59 @@
/**
* `GET /api/encounters/suggest?anchor=<iso>` — which visit does a record dated
* `anchor` most plausibly belong to?
*
* The three capture moments that ask this question live in the browser: the
* document upload review, the lab extraction review and the manual lab form.
* The rule they ask about — the ±7-day window, the DONE / PLANNED filter, and
* the one-pre-selects / two-offer-a-picker verdict — lives in
* `src/lib/encounters/suggest-window.ts` and is answered here so all three read
* the same answer. Handing the client a windowed list and letting it decide
* would put the rule in two places, and the copy in the browser is the one
* nobody re-reads when the rule changes.
*
* A read of the record's own visits, so it declares the same domain the visit
* list does. `userId` is narrowed from auth; the anchor is the only input and
* it is a date, not an id.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { encounterSuggestQuerySchema } from "@/lib/validations/encounters";
import { suggestEncounterForDate } from "@/lib/encounters/suggest-window";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "profile");
const params = new URL(request.url).searchParams;
const parsed = encounterSuggestQuerySchema.safeParse({
anchor: params.get("anchor") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "encounter.invalid",
});
}
const result = await suggestEncounterForDate(prisma, {
userId: user.id,
anchor: new Date(parsed.data.anchor),
});
annotate({
action: { name: "encounter.visit.suggest", entity_type: "encounter" },
meta: {
verdict: result.kind,
candidates:
result.kind === "many"
? result.encounters.length
: result.kind === "one"
? 1
: 0,
},
});
return apiSuccess(result);
});
@@ -25,6 +25,7 @@ vi.mock("@/lib/db", () => ({
moodEntry: { findMany: vi.fn() },
labResult: { findMany: vi.fn() },
illnessEpisode: { findMany: vi.fn() },
encounter: { findMany: vi.fn() },
allergy: { findMany: vi.fn() },
familyHistoryEntry: { findMany: vi.fn() },
userHealthProfile: { findUnique: vi.fn() },
@@ -165,6 +166,7 @@ beforeEach(() => {
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.allergy.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue(
+23 -1
View File
@@ -39,6 +39,7 @@ import {
linkOcrLabsToVaultDocument,
type InsertedLabForLink,
} from "@/lib/labs/vault-link";
import { linkTargets } from "@/lib/links";
import { annotate } from "@/lib/logging/context";
import { classifyWrittenOutcome } from "@/lib/outcome/written-outcome";
import {
@@ -217,6 +218,23 @@ async function commitOcrRows(request: NextRequest) {
).catch(() => {});
}
// The visit the review step offered, if it offered one. ONE LINK PER RESULT
// ROW rather than one for the panel: a marker re-run on a different day
// belongs to its own visit, which is exactly why the join is m:n and not a
// scalar column on the result. Best-effort like the vault cross-link — the
// readings are the authoritative write and must not be lost to a link that
// could not be made. The link service narrows both ends to this record, so
// an id naming nothing writes nothing.
if (parsed.data.encounterId && linkable.length > 0) {
await linkTargets(prisma, {
userId: user.id,
sourceKind: "encounter",
sourceId: parsed.data.encounterId,
targetKind: "labResult",
targetIds: linkable.map((row) => row.labResultId),
}).catch(() => {});
}
await auditLog("labs.ocr.commit", {
userId: user.id,
ipAddress: getClientIp(request),
@@ -225,7 +243,11 @@ async function commitOcrRows(request: NextRequest) {
annotate({
action: { name: "labs.ocr.committed" },
meta: { inserted: inserted.length, skipped: skipped.length },
meta: {
inserted: inserted.length,
skipped: skipped.length,
linkedVisit: parsed.data.encounterId ? 1 : 0,
},
});
// A lab panel just landed — resolve any "annual blood panel" reminders now
+17
View File
@@ -21,6 +21,7 @@ import {
type ResolvedBiomarker,
serialiseLabResult,
} from "@/lib/labs/serialise";
import { linkTargets } from "@/lib/links";
import { encryptNoteToBytes } from "@/lib/labs/store";
import { annotate } from "@/lib/logging/context";
import {
@@ -190,6 +191,7 @@ async function postLabResult(request: NextRequest) {
takenAt,
note,
source,
encounterId,
} = parsed.data;
// v1.18.9 — a qualitative reading carries `valueText` ("negativ" / …) instead
@@ -267,6 +269,21 @@ async function postLabResult(request: NextRequest) {
noteEncrypted: note ? encryptNoteToBytes(note) : null,
},
});
// The by-the-way half: file the reading against the visit the form offered,
// if it offered one. Best-effort by design — the reading is the write that
// matters, and a link that could not be made (a visit deleted between the
// suggestion and the save) must not lose it. The link service narrows both
// ends to this record, so an id naming nothing simply writes nothing.
if (encounterId) {
await linkTargets(prisma, {
userId: user.id,
sourceKind: "encounter",
sourceId: encounterId,
targetKind: "labResult",
targetIds: [created.id],
}).catch(() => {});
}
invalidateUserHealthScore(user.id);
const dto = serialiseLabResult(created, biomarker);
+74
View File
@@ -0,0 +1,74 @@
/**
* `GET /api/mood/prognosis` — the day's two readings, side by side.
*
* The person's own rating leads and is returned exactly as it was stored. The
* forecast rides beside it with its band, the number of days it was built
* from, and how old it is. The two are never merged: the distance between them
* is the whole finding, and a single blended number would destroy it.
*
* **This route declares `record`, not `mind`, and that is the security
* decision on it.** The payload is not only mood: the fit reads the day's
* level-B context AND the figures the sleep, activity and recovery modules
* hold, and the contributions it publishes name those features one by one — a
* reader can see that sleep or steps or resting heart rate carried weight on a
* given day, and roughly how much. Declared `mind` it would be a hole with a
* mood-shaped label on it: a delegate holding only the mind section would
* receive a derived view of two sections the owner had declined to share. The
* convention for a read that crosses sections is `record`
* (`/api/mood/linked-context`, `/api/dashboard/snapshot`, `/api/daily/digest`
* are the precedents) and a scoped grant never reaches one, so a scoped
* delegate loses the forecast entirely rather than receiving a filtered
* version of it. A filtered version would be worse: a contribution list with
* the linked features quietly removed would tell the reader that the model
* weighted only the mood fields, which is false.
*
* Nothing here writes. The rows come from the nightly job and the user cannot
* overwrite them — `src/__tests__/mood-prediction-write-surface-guard.test.ts`
* holds that, and this file is one of the ones it scans.
*/
import type { NextRequest } from "next/server";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { readMoodPrognosis } from "@/lib/analytics/mood-prognosis/read";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
export const dynamic = "force-dynamic";
// The parameter is declared although the handler body reads nothing off it,
// and the reason is the exported type rather than the runtime. `apiHandler`
// infers the type of `GET` from this callback, so a zero-argument handler
// would type `GET` as taking no arguments — and Next.js, and the integration
// tests, call it WITH the request. The wide event's method and path, and the
// read-vs-write check `requireRecordAuth` runs, both come from the request the
// wrapper receives from Next.js (not from this parameter), and both fall back
// to `GET` when no request is present — so an absent request keeps a read a
// read rather than escalating it.
export const GET = apiHandler(async (_request: NextRequest) => {
// `record`: see the file header. A scoped grant is refused here by
// construction rather than served a narrowed payload.
const { user } = await requireRecordAuth("read", "record");
const gate = await requireModuleEnabled(user.id, "mood");
if (!gate.enabled) return gate.response;
const reading = await readMoodPrognosis(user.id, new Date(), user.timezone);
annotate({
action: { name: "mood.prognosis.read" },
meta: reading.present
? {
present: true,
day: reading.date,
fit_rows: reading.n,
age_days: reading.ageDays,
current: reading.current,
provisional: reading.provisional,
deviation: reading.deviation,
}
: { present: false, reason: reading.reason, entries: reading.entries },
});
return apiSuccess(reading);
});
+20 -1
View File
@@ -50,8 +50,27 @@ export const GET = apiHandler(async (request: NextRequest) => {
where: {
userId: user.id,
deletedAt: null,
// Both plaintext columns, because a person looks a practice up by
// whichever of the two they remember: the doctor's name on the referral
// or the practice name on the door. Searching only the name made the
// second attempt return nothing and read as "not in the address book".
...(parsed.data.q
? { name: { contains: parsed.data.q, mode: "insensitive" as const } }
? {
OR: [
{
name: {
contains: parsed.data.q,
mode: "insensitive" as const,
},
},
{
practice: {
contains: parsed.data.q,
mode: "insensitive" as const,
},
},
],
}
: {}),
},
orderBy: { name: "asc" },