mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge branch 'v143/labs' into release/v1.43.0
This commit is contained in:
commit
74fa4e10b6
96 files changed
+6992
-626
No files matched your search
@@ -89,7 +89,7 @@ const SERVER_FEATURES = {
|
||||
// form work; until then a client must not send it (contract §0.6).
|
||||
cumulativeHourBuckets: false,
|
||||
syncTriggerBackfill: false,
|
||||
biomarkerMerge: false,
|
||||
biomarkerMerge: true,
|
||||
lifeCircumstances: false,
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
biomarker: { findFirst: vi.fn(), update: vi.fn(), delete: vi.fn() },
|
||||
vi.mock("@/lib/db", () => {
|
||||
const prisma = {
|
||||
biomarker: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(async () => []),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
// v1.43 — the marker's other spellings, read and replaced with it.
|
||||
biomarkerAlias: {
|
||||
findMany: vi.fn(async () => []),
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
biomarkerMerge: { findFirst: vi.fn(async () => null) },
|
||||
labResult: { deleteMany: vi.fn() },
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
$transaction: vi.fn(async (ops: Promise<unknown>[]) => Promise.all(ops)),
|
||||
},
|
||||
}));
|
||||
};
|
||||
// `$transaction` is set in `beforeEach`: the batch form for the delete, the
|
||||
// callback form for the edit.
|
||||
return { prisma: { ...prisma, $transaction: vi.fn() } };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
@@ -82,9 +96,13 @@ beforeEach(() => {
|
||||
// v1.37.0 — the 422 breadcrumb goes through `auditLog()` and the route
|
||||
// chains `.catch()` on it; `resetAllMocks` clears the factory's resolution.
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
||||
vi.mocked(prisma.$transaction).mockImplementation((async (
|
||||
ops: Promise<unknown>[],
|
||||
) => Promise.all(ops)) as never);
|
||||
vi.mocked(prisma.$transaction).mockImplementation((async (arg: unknown) =>
|
||||
typeof arg === "function"
|
||||
? (arg as (tx: unknown) => Promise<unknown>)(prisma)
|
||||
: Promise.all(arg as Promise<unknown>[])) as never);
|
||||
vi.mocked(prisma.biomarkerAlias.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.biomarker.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.biomarkerMerge.findFirst).mockResolvedValue(null as never);
|
||||
});
|
||||
|
||||
describe("DELETE /api/biomarkers/[id]", () => {
|
||||
|
||||
@@ -7,19 +7,32 @@
|
||||
* (`MERGE`), one `biomarker_merges` row is written per source and the sources
|
||||
* are deleted. A pair of units without a known conversion refuses the whole
|
||||
* merge (422 `labs.biomarker.merge.unitMismatch`); nothing moves. `dryRun`
|
||||
* answers the same result and writes nothing. There is no undo.
|
||||
* answers the same result and writes nothing, refusals included, so the
|
||||
* preview can show them. There is no undo. See `src/lib/labs/biomarker-merge.ts`.
|
||||
*
|
||||
* Contract authority: `requireRecordAuth("manage", "labs")` with the shared
|
||||
* record-write ceiling. Until the merge lands the stub resolves the caller's
|
||||
* own record only.
|
||||
* Authority: `requireRecordAuth("manage", "labs")`, the same as the edit and
|
||||
* the delete beside it, because a merge rewrites and deletes markers. The
|
||||
* shared record-write ceiling is charged to the ACTOR (C1). Each deleted
|
||||
* source files what was destroyed (C3): its id and how many readings it took
|
||||
* along, never its name; the name stays in the merge ledger.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { mergeBiomarkers as runMerge } from "@/lib/labs/biomarker-merge";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { destroyedDetails } from "@/lib/sharing/audit-details";
|
||||
import { biomarkerMergeSchema } from "@/lib/validations/biomarker-merge";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
@@ -32,10 +45,10 @@ async function mergeBiomarkers(
|
||||
request: NextRequest,
|
||||
{ params }: RouteParams,
|
||||
): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
const { user, actor } = await requireRecordAuth("manage", "labs");
|
||||
const { id } = await params;
|
||||
|
||||
const writeRl = await checkRecordWriteRateLimit(user.id);
|
||||
const writeRl = await checkRecordWriteRateLimit(actor.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429, {
|
||||
errorCode: "record_write.rate_limited",
|
||||
@@ -58,5 +71,74 @@ async function mergeBiomarkers(
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("labs.biomarker.merge");
|
||||
const outcome = await runMerge({
|
||||
userId: user.id,
|
||||
targetId: id,
|
||||
sourceIds: parsed.data.sourceIds,
|
||||
dryRun: parsed.data.dryRun,
|
||||
});
|
||||
|
||||
if (outcome.kind === "notFound") {
|
||||
return apiError("Biomarker not found", 404, {
|
||||
errorCode: "labs.biomarker.notFound",
|
||||
ids: outcome.ids,
|
||||
});
|
||||
}
|
||||
if (outcome.kind === "unitMismatch") {
|
||||
annotate({
|
||||
action: { name: "labs.biomarker.merge.unit-mismatch" },
|
||||
meta: { biomarkerId: id, refused: outcome.sources.length },
|
||||
});
|
||||
return apiError(
|
||||
"Some readings are in a unit that cannot be converted to the target's unit",
|
||||
422,
|
||||
{
|
||||
errorCode: "labs.biomarker.merge.unitMismatch",
|
||||
sources: outcome.sources,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { result } = outcome;
|
||||
annotate({
|
||||
action: {
|
||||
name: result.dryRun
|
||||
? "labs.biomarker.merge.preview"
|
||||
: "labs.biomarker.merge",
|
||||
},
|
||||
meta: {
|
||||
biomarkerId: id,
|
||||
sources: result.sources.length,
|
||||
movedReadings: result.movedReadings,
|
||||
convertedReadings: result.convertedReadings,
|
||||
},
|
||||
});
|
||||
if (result.dryRun) return apiSuccess(result);
|
||||
|
||||
invalidateUserHealthScore(user.id);
|
||||
const ipAddress = getClientIp(request);
|
||||
for (const source of result.sources) {
|
||||
await auditLog("labs.biomarker.merge", {
|
||||
userId: user.id,
|
||||
ipAddress,
|
||||
// IDs and numbers only. The source's name is not filed here; the merge
|
||||
// ledger keeps it with the row this audit points at.
|
||||
details: {
|
||||
biomarkerId: id,
|
||||
mergeId: result.mergeId,
|
||||
...destroyedDetails({
|
||||
model: "Biomarker",
|
||||
id: source.id,
|
||||
label: null,
|
||||
effectiveAt: new Date(),
|
||||
extra: {
|
||||
readingCount: source.readingCount,
|
||||
converted: source.conversion === "converted",
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return apiSuccess(result);
|
||||
}
|
||||
@@ -17,9 +17,13 @@ import {
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import {
|
||||
decryptContextFromBytes,
|
||||
encryptContextToBytes,
|
||||
} from "@/lib/labs/biomarker-store";
|
||||
aliasNamesByMarker,
|
||||
findAliasConflict,
|
||||
replaceAliases,
|
||||
} from "@/lib/labs/biomarker-aliases";
|
||||
import { serialiseBiomarker } from "@/lib/labs/biomarker-dto";
|
||||
import { mergedInto } from "@/lib/labs/biomarker-merge";
|
||||
import { encryptContextToBytes } from "@/lib/labs/biomarker-store";
|
||||
import { matchAnalyteKey } from "@/lib/labs/analyte-match";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { updateBiomarkerSchema } from "@/lib/validations/biomarkers";
|
||||
@@ -38,37 +42,6 @@ import { effectiveBound, isInvertedRange } from "@/lib/validations/labs";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
function serialiseBiomarker(row: {
|
||||
id: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
lowerBound: number | null;
|
||||
upperBound: number | null;
|
||||
panel: string | null;
|
||||
analyteKey: string | null;
|
||||
hidden: boolean;
|
||||
contextEncrypted: Uint8Array | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
unit: row.unit,
|
||||
lowerBound: row.lowerBound,
|
||||
upperBound: row.upperBound,
|
||||
panel: row.panel,
|
||||
analyteKey: row.analyteKey,
|
||||
hasContext: row.contextEncrypted !== null,
|
||||
context: row.contextEncrypted
|
||||
? decryptContextFromBytes(row.contextEncrypted)
|
||||
: null,
|
||||
hidden: row.hidden,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("read", "labs");
|
||||
@@ -76,15 +49,23 @@ export const GET = apiHandler(
|
||||
|
||||
const row = await prisma.biomarker.findFirst({ where: { id } });
|
||||
if (!row || row.userId !== user.id) {
|
||||
return apiError("Biomarker not found", 404);
|
||||
// v1.43 — a marker merged into another names where it went, so a
|
||||
// client holding the old id (a cached screen, a bookmark) can follow.
|
||||
const into = await mergedInto(user.id, id);
|
||||
return apiError("Biomarker not found", 404, {
|
||||
errorCode: "labs.biomarker.notFound",
|
||||
...(into ? { mergedInto: into } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const aliases = await aliasNamesByMarker(prisma, user.id, [row]);
|
||||
|
||||
annotate({
|
||||
action: { name: "labs.biomarker.get" },
|
||||
meta: { biomarkerId: id },
|
||||
});
|
||||
|
||||
return apiSuccess(serialiseBiomarker(row));
|
||||
return apiSuccess(serialiseBiomarker(row, aliases.get(row.id) ?? []));
|
||||
},
|
||||
);
|
||||
|
||||
@@ -171,6 +152,20 @@ export const PUT = apiHandler(
|
||||
}
|
||||
}
|
||||
|
||||
// v1.43 — the aliases, when sent, become the marker's whole set. A
|
||||
// spelling another marker already answers to (its name or one of its
|
||||
// aliases) is refused rather than silently moved.
|
||||
if (d.aliases !== undefined) {
|
||||
const conflict = await findAliasConflict(prisma, user.id, id, d.aliases);
|
||||
if (conflict) {
|
||||
return apiError("Another biomarker already answers to this name", 409, {
|
||||
errorCode: "labs.biomarker.alias.taken",
|
||||
alias: conflict.alias,
|
||||
biomarkerId: conflict.biomarkerId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Build `data` field-by-field. `undefined` → leave the column untouched;
|
||||
// an explicit `null` on `context` / `panel` / a bound clears it.
|
||||
const data: Record<string, unknown> = {};
|
||||
@@ -212,7 +207,17 @@ export const PUT = apiHandler(
|
||||
: null;
|
||||
}
|
||||
|
||||
const updated = await prisma.biomarker.update({ where: { id }, data });
|
||||
const aliasesBefore =
|
||||
(await aliasNamesByMarker(prisma, user.id, [existing])).get(id) ?? [];
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
const row = await tx.biomarker.update({ where: { id }, data });
|
||||
if (d.aliases !== undefined) {
|
||||
await replaceAliases(tx, user.id, row, d.aliases);
|
||||
}
|
||||
return row;
|
||||
});
|
||||
const aliasesAfter =
|
||||
(await aliasNamesByMarker(prisma, user.id, [updated])).get(id) ?? [];
|
||||
invalidateUserHealthScore(user.id);
|
||||
|
||||
await auditLog("biomarker.update", {
|
||||
@@ -241,7 +246,13 @@ export const PUT = apiHandler(
|
||||
panel: updated.panel,
|
||||
hidden: updated.hidden,
|
||||
},
|
||||
redacted: d.context !== undefined ? ["context"] : [],
|
||||
redacted: [
|
||||
...(d.context !== undefined ? ["context"] : []),
|
||||
// Named, not quoted: the spellings are the person's own text.
|
||||
...(aliasesBefore.join("\n") !== aliasesAfter.join("\n")
|
||||
? ["aliases"]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -251,7 +262,7 @@ export const PUT = apiHandler(
|
||||
meta: { biomarkerId: id },
|
||||
});
|
||||
|
||||
return apiSuccess(serialiseBiomarker(updated));
|
||||
return apiSuccess(serialiseBiomarker(updated, aliasesAfter));
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -13,10 +13,9 @@ import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import {
|
||||
decryptContextSoft,
|
||||
encryptContextToBytes,
|
||||
} from "@/lib/labs/biomarker-store";
|
||||
import { aliasNamesByMarker } from "@/lib/labs/biomarker-aliases";
|
||||
import { serialiseBiomarker } from "@/lib/labs/biomarker-dto";
|
||||
import { encryptContextToBytes } from "@/lib/labs/biomarker-store";
|
||||
import { matchAnalyteKey } from "@/lib/labs/analyte-match";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { createBiomarkerSchema } from "@/lib/validations/biomarkers";
|
||||
@@ -45,35 +44,6 @@ import { createBiomarkerSchema } from "@/lib/validations/biomarkers";
|
||||
// a bad-key / malformed row) so one un-decryptable row never 500s the whole
|
||||
// catalog — the single-resource GET is the authoritative read for the
|
||||
// plaintext context. This bounds the fail-closed blast radius to one marker.
|
||||
function serialiseBiomarker(row: {
|
||||
id: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
lowerBound: number | null;
|
||||
upperBound: number | null;
|
||||
panel: string | null;
|
||||
analyteKey: string | null;
|
||||
hidden: boolean;
|
||||
contextEncrypted: Uint8Array | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
unit: row.unit,
|
||||
lowerBound: row.lowerBound,
|
||||
upperBound: row.upperBound,
|
||||
panel: row.panel,
|
||||
analyteKey: row.analyteKey,
|
||||
hasContext: row.contextEncrypted !== null,
|
||||
context: decryptContextSoft(row.contextEncrypted),
|
||||
hidden: row.hidden,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("read", "labs");
|
||||
|
||||
@@ -84,12 +54,19 @@ export const GET = apiHandler(async () => {
|
||||
orderBy: [{ hidden: "asc" }, { name: "asc" }],
|
||||
});
|
||||
|
||||
// v1.43 — every marker's other spellings, one read for the whole list.
|
||||
const aliases = await aliasNamesByMarker(prisma, user.id, rows);
|
||||
|
||||
annotate({
|
||||
action: { name: "labs.biomarker.list" },
|
||||
meta: { total: rows.length },
|
||||
});
|
||||
|
||||
return apiSuccess({ biomarkers: rows.map(serialiseBiomarker) });
|
||||
return apiSuccess({
|
||||
biomarkers: rows.map((row) =>
|
||||
serialiseBiomarker(row, aliases.get(row.id) ?? [], { soft: true }),
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBiomarker));
|
||||
@@ -179,5 +156,5 @@ async function postBiomarker(request: NextRequest) {
|
||||
meta: { biomarkerId: created.id },
|
||||
});
|
||||
|
||||
return apiSuccess(serialiseBiomarker(created), 201);
|
||||
return apiSuccess(serialiseBiomarker(created, [], { soft: true }), 201);
|
||||
}
|
||||
@@ -100,7 +100,12 @@ async function confirmDocument(
|
||||
}
|
||||
|
||||
try {
|
||||
const ref = await commitApprovedFact(user.id, fact);
|
||||
// v1.43 (D1223) — an OBSERVATION may name the marker the person chose
|
||||
// on the review screen; the label is then remembered as its alias.
|
||||
const ref = await commitApprovedFact(user.id, fact, {
|
||||
biomarkerId: decision.biomarkerId,
|
||||
rememberAlias: decision.rememberAlias,
|
||||
});
|
||||
await prisma.extractedFact.update({
|
||||
where: { id: fact.id },
|
||||
data: {
|
||||
|
||||
@@ -51,6 +51,7 @@ vi.mock("@/lib/documents/store", () => ({
|
||||
encryptFactData: vi.fn(() => new Uint8Array([1])),
|
||||
encryptFactProvenance: vi.fn(() => new Uint8Array([2])),
|
||||
serialiseDocumentDetail: vi.fn(() => ({ id: "doc-1", facts: [] })),
|
||||
markerIndexForFacts: vi.fn(async () => null),
|
||||
}));
|
||||
vi.mock("@/lib/documents/content-index", () => ({
|
||||
loadDocumentChatText: vi.fn(),
|
||||
|
||||
@@ -55,7 +55,10 @@ import {
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import type { ProviderChainType } from "@/lib/ai/provider-chain";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { serialiseDocumentDetail } from "@/lib/documents/store";
|
||||
import {
|
||||
markerIndexForFacts,
|
||||
serialiseDocumentDetail,
|
||||
} from "@/lib/documents/store";
|
||||
import {
|
||||
requireDocumentTextProvider,
|
||||
requireDocumentVisionProvider,
|
||||
@@ -241,7 +244,19 @@ async function runNow(
|
||||
origin: { ipAddress: getClientIp(request), worker: false },
|
||||
});
|
||||
if (!outcome.ok) return outcomeResponse(outcome);
|
||||
return apiSuccess(serialiseDocumentDetail(outcome.data, outcome.data.facts));
|
||||
return apiSuccess(
|
||||
serialiseDocumentDetail(
|
||||
outcome.data,
|
||||
outcome.data.facts,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
await markerIndexForFacts(userId, outcome.data.facts),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** TEXT mode — structure in-browser-OCR'd text against the stored row. */
|
||||
|
||||
@@ -18,7 +18,11 @@ import {
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { encryptFactData, serialiseFact } from "@/lib/documents/store";
|
||||
import {
|
||||
encryptFactData,
|
||||
markerIndexForFacts,
|
||||
serialiseFact,
|
||||
} from "@/lib/documents/store";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import {
|
||||
@@ -125,6 +129,8 @@ export const PATCH = apiHandler(
|
||||
meta: { documentId: id, factId: fact.id, factType: fact.factType },
|
||||
});
|
||||
|
||||
return apiSuccess(serialiseFact(updated));
|
||||
return apiSuccess(
|
||||
serialiseFact(updated, await markerIndexForFacts(user.id, [updated])),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -32,7 +32,10 @@ import {
|
||||
replaceVaccinationLinks,
|
||||
} from "@/lib/documents/links";
|
||||
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
|
||||
import { serialiseDocumentDetail } from "@/lib/documents/store";
|
||||
import {
|
||||
markerIndexForFacts,
|
||||
serialiseDocumentDetail,
|
||||
} from "@/lib/documents/store";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
@@ -138,6 +141,8 @@ export const GET = apiHandler(
|
||||
thumbnail !== null,
|
||||
visitLinks.get(document.id) ?? [],
|
||||
doseLinks === null ? null : (doseLinks.get(document.id) ?? []),
|
||||
// v1.43 — the marker each pending lab value resolves to, or choices.
|
||||
await markerIndexForFacts(user.id, document.facts),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -86,6 +86,8 @@ vi.mock("@/lib/db", () => ({
|
||||
// helper now also reads (`buildRecordsBackupSection`).
|
||||
labResult: { findMany: vi.fn() },
|
||||
biomarker: { findMany: vi.fn() },
|
||||
biomarkerAlias: { findMany: vi.fn() },
|
||||
biomarkerMerge: { findMany: vi.fn() },
|
||||
illnessEpisode: { findMany: vi.fn() },
|
||||
allergy: { findMany: vi.fn() },
|
||||
familyHistoryEntry: { findMany: vi.fn() },
|
||||
@@ -135,6 +137,8 @@ beforeEach(() => {
|
||||
// `resetAllMocks` clears the inline defaults, and the full-backup payload
|
||||
// builder reads these unconditionally.
|
||||
vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.biomarkerAlias.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.biomarkerMerge.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationCategoryLabel.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
|
||||
@@ -67,6 +67,8 @@ vi.mock("@/lib/db", () => ({
|
||||
cycleDayLog: { findMany: vi.fn() },
|
||||
labResult: { findMany: vi.fn() },
|
||||
biomarker: { findMany: vi.fn() },
|
||||
biomarkerAlias: { findMany: vi.fn() },
|
||||
biomarkerMerge: { findMany: vi.fn() },
|
||||
allergy: { findMany: vi.fn() },
|
||||
familyHistoryEntry: { findMany: vi.fn() },
|
||||
workout: { findMany: vi.fn() },
|
||||
@@ -205,6 +207,8 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.biomarker.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.biomarkerAlias.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.biomarkerMerge.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.allergy.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never);
|
||||
|
||||
@@ -77,6 +77,8 @@ vi.mock("@/lib/db", () => ({
|
||||
// builder now also reads (`buildRecordsBackupSection`).
|
||||
labResult: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
biomarker: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
biomarkerAlias: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
biomarkerMerge: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
illnessEpisode: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
allergy: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
familyHistoryEntry: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
|
||||
@@ -16,6 +16,9 @@ vi.mock("@/lib/db", () => ({
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
// v1.43 — the account's markers and aliases, loaded once per commit.
|
||||
biomarker: { findMany: vi.fn() },
|
||||
biomarkerAlias: { findMany: vi.fn(), upsert: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
@@ -107,6 +110,8 @@ beforeEach(() => {
|
||||
// No live reading collides at commit time — the in-batch path is what's
|
||||
// under test, so the DB-visibility check returns empty for every row.
|
||||
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.biomarker.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.biomarkerAlias.findMany).mockResolvedValue([] as never);
|
||||
let nextId = 0;
|
||||
vi.mocked(prisma.labResult.create).mockImplementation((() =>
|
||||
Promise.resolve({ id: `lr-${++nextId}` })) as never);
|
||||
|
||||
@@ -34,6 +34,13 @@ import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
|
||||
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
|
||||
import { rememberImportAlias } from "@/lib/labs/biomarker-aliases";
|
||||
import {
|
||||
addAliasToIndex,
|
||||
loadMarkerIndex,
|
||||
resolveInIndex,
|
||||
type IndexedMarker,
|
||||
} from "@/lib/labs/biomarker-resolve";
|
||||
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
|
||||
import { readingInMarkerUnit } from "@/lib/labs/reading-conversion";
|
||||
import { sameLabUnit } from "@/lib/labs/unit-normalise";
|
||||
@@ -69,13 +76,20 @@ async function isDuplicate(
|
||||
userId: string,
|
||||
row: OcrCommitRow,
|
||||
tz: string,
|
||||
/** The marker the row resolves to, when it resolves to one already. */
|
||||
biomarkerId: string | null,
|
||||
): Promise<boolean> {
|
||||
const day = labReadingDay(new Date(row.takenAt), tz);
|
||||
const rows = await prisma.labResult.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
analyte: { equals: row.analyte.trim(), mode: "insensitive" },
|
||||
// The printed name, or a reading already filed under the marker this
|
||||
// name resolves to: an earlier report may have spelled it another way.
|
||||
OR: [
|
||||
{ analyte: { equals: row.analyte.trim(), mode: "insensitive" } },
|
||||
...(biomarkerId ? [{ biomarkerId }] : []),
|
||||
],
|
||||
takenAt: labReadingDaySearchRange(day),
|
||||
},
|
||||
select: {
|
||||
@@ -123,8 +137,15 @@ async function isDuplicate(
|
||||
* Mirrors the `isDuplicate` match semantics so in-batch dedup no longer depends
|
||||
* on a prior row autocommitting before the next row's live query runs.
|
||||
*/
|
||||
function inBatchKey(row: OcrCommitRow, tz: string): string {
|
||||
const analyte = row.analyte.trim().toLowerCase();
|
||||
function inBatchKey(
|
||||
row: OcrCommitRow,
|
||||
tz: string,
|
||||
biomarkerId: string | null,
|
||||
): string {
|
||||
// Two spellings of one marker in one document are one marker (D1223).
|
||||
const analyte = biomarkerId
|
||||
? `bm:${biomarkerId}`
|
||||
: row.analyte.trim().toLowerCase();
|
||||
const dayKey = labReadingDay(new Date(row.takenAt), tz);
|
||||
const valuePart =
|
||||
row.value !== undefined
|
||||
@@ -188,14 +209,59 @@ async function commitOcrRows(request: NextRequest) {
|
||||
const writtenInBatch = new Set<string>();
|
||||
|
||||
const tz = await resolveUserTimezone(user.id);
|
||||
// v1.43 (D1223) — the account's markers and aliases, loaded once: every row
|
||||
// resolves its name against them (name, alias, catalog abbreviation) the
|
||||
// way the review did, and a marker minted for one row is found by the next.
|
||||
const markerIndex = await loadMarkerIndex(prisma, user.id);
|
||||
let aliasesRemembered = 0;
|
||||
for (const row of parsed.data.rows) {
|
||||
const key = inBatchKey(row, tz);
|
||||
if (writtenInBatch.has(key) || (await isDuplicate(user.id, row, tz))) {
|
||||
const isQualitative = row.valueText !== undefined;
|
||||
|
||||
// The marker the person picked on the review screen, if they picked one.
|
||||
let chosen: IndexedMarker | null = null;
|
||||
if (row.biomarkerId) {
|
||||
chosen =
|
||||
markerIndex.markers.find((m) => m.id === row.biomarkerId) ?? null;
|
||||
if (!chosen) {
|
||||
skipped.push({
|
||||
analyte: row.analyte.trim(),
|
||||
reason: "unknown_biomarker",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// The choice is remembered, so the next report spelled this way
|
||||
// matches on its own.
|
||||
if (row.rememberAlias !== false) {
|
||||
const wrote = await rememberImportAlias(
|
||||
prisma,
|
||||
user.id,
|
||||
chosen,
|
||||
row.analyte,
|
||||
);
|
||||
if (wrote) {
|
||||
addAliasToIndex(markerIndex, chosen.id, row.analyte);
|
||||
aliasesRemembered += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
const resolvedId =
|
||||
chosen?.id ??
|
||||
resolveInIndex(
|
||||
markerIndex,
|
||||
row.analyte,
|
||||
isQualitative ? null : (row.unit ?? null),
|
||||
)?.marker.id ??
|
||||
null;
|
||||
|
||||
const key = inBatchKey(row, tz, resolvedId);
|
||||
if (
|
||||
writtenInBatch.has(key) ||
|
||||
(await isDuplicate(user.id, row, tz, resolvedId))
|
||||
) {
|
||||
skipped.push({ analyte: row.analyte.trim(), reason: "duplicate" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const isQualitative = row.valueText !== undefined;
|
||||
// The window the scan read off the report. The confirmed numeric bounds
|
||||
// win over a re-parse of the string (the human may have corrected them on
|
||||
// the review screen); the string rides along either way.
|
||||
@@ -203,14 +269,20 @@ async function commitOcrRows(request: NextRequest) {
|
||||
const sourceLow = row.referenceLow ?? printed?.low ?? null;
|
||||
const sourceHigh = row.referenceHigh ?? printed?.high ?? null;
|
||||
const sourceText = printed?.text ?? null;
|
||||
const biomarker = await resolveOrMintBiomarker(user.id, {
|
||||
analyte: row.analyte,
|
||||
// A qualitative reading has no numeric unit / range.
|
||||
unit: isQualitative ? (row.unit ?? "") : (row.unit as string),
|
||||
referenceLow: isQualitative ? null : sourceLow,
|
||||
referenceHigh: isQualitative ? null : sourceHigh,
|
||||
panel: row.panel ?? null,
|
||||
});
|
||||
const biomarker = chosen
|
||||
? chosen
|
||||
: await resolveOrMintBiomarker(
|
||||
user.id,
|
||||
{
|
||||
analyte: row.analyte,
|
||||
// A qualitative reading has no numeric unit / range.
|
||||
unit: isQualitative ? (row.unit ?? "") : (row.unit as string),
|
||||
referenceLow: isQualitative ? null : sourceLow,
|
||||
referenceHigh: isQualitative ? null : sourceHigh,
|
||||
panel: row.panel ?? null,
|
||||
},
|
||||
{ index: markerIndex },
|
||||
);
|
||||
|
||||
// The marker's unit is the one the reading is stored under. A row that
|
||||
// states another unit is converted when the marker's analyte is known and
|
||||
@@ -273,6 +345,8 @@ async function commitOcrRows(request: NextRequest) {
|
||||
});
|
||||
|
||||
writtenInBatch.add(key);
|
||||
// A freshly minted marker is now the row's; key the batch on it too.
|
||||
writtenInBatch.add(inBatchKey(row, tz, biomarker.id));
|
||||
inserted.push(serialiseLabResult(created, biomarker));
|
||||
linkable.push({
|
||||
labResultId: created.id,
|
||||
@@ -319,7 +393,11 @@ async function commitOcrRows(request: NextRequest) {
|
||||
await auditLog("labs.ocr.commit", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { count: inserted.length, skipped: skipped.length },
|
||||
details: {
|
||||
count: inserted.length,
|
||||
skipped: skipped.length,
|
||||
aliasesRemembered,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
|
||||
Reference in new issue
Block a user