mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Merge remote-tracking branch 'origin/main' into fix/settings-ekg-polish
This commit is contained in:
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) ?? [],
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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" },
|
||||
|
||||
Reference in new issue
Block a user