mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Every ingest path in the product can now emit a typed, salience-classified event into pg-boss, consumed by one worker that claims a durable per-day reaction marker and fans out to the reaction surfaces. The recency classifier runs at the seam, before anything is enqueued, so a mass backfill — a multi-year Apple Health export, a provider re-sync, a 30-day catch-up — emits nothing at all. Non-salient writes are annotated and dropped. The worker makes no provider call by construction: it imports no provider module, writes only markers, and enqueues nothing that is not budget-gated downstream. Business refusals return a status rather than throwing, so a refusal is never retried against a ceiling that will not move until the local day rolls over. Seams: sleep (one hook, inherited by all seven transports), workouts (the batch route, the four provider syncs, the export importer), weight and blood pressure (both measurement routes), labs (manual, OCR commit, document fact commit). The provider workout syncs could not previously tell a created row from an updated one, so a re-sync of a recent session would have re-fired on every poll; they now recover the distinction from the row the upsert already returns. Reaction lines are AES-256-GCM at rest, following the InsightNarrative precedent, and the column is registered for key rotation. Markers are pruned after 14 days by a daily maintenance job.
240 lines
8.3 KiB
TypeScript
240 lines
8.3 KiB
TypeScript
/**
|
|
* v1.18.9 — POST /api/labs/ocr/commit
|
|
*
|
|
* Writes ONLY the rows the human confirmed on the review screen. Each row
|
|
* resolves-or-mints a user-scoped biomarker by `(userId, lower(analyte))` —
|
|
* exactly like the manual lab-write path — then creates a `LabResult` with
|
|
* `source: "OCR"`. A row that now duplicates a live reading (re-checked at
|
|
* commit time) is skipped rather than written. Idempotent (Idempotency-Key).
|
|
*
|
|
* `userId` is always narrowed from the session — never a body field. The write
|
|
* `data` object is built field-by-field (no mass assignment).
|
|
*/
|
|
import { NextRequest } from "next/server";
|
|
import { fireAndForget } from "@/lib/logging/fire-and-forget";
|
|
|
|
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
|
import {
|
|
apiSuccess,
|
|
getClientIp,
|
|
returnAllZodIssues,
|
|
safeJson,
|
|
sanitiseZodIssues,
|
|
} from "@/lib/api-response";
|
|
import { auditLog } from "@/lib/auth/audit";
|
|
import { prisma } from "@/lib/db";
|
|
import { withIdempotency } from "@/lib/idempotency";
|
|
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
|
|
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
|
|
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
|
|
import { serialiseLabResult } from "@/lib/labs/serialise";
|
|
import {
|
|
linkOcrLabsToVaultDocument,
|
|
type InsertedLabForLink,
|
|
} from "@/lib/labs/vault-link";
|
|
import { annotate } from "@/lib/logging/context";
|
|
import {
|
|
ocrCommitSchema,
|
|
type OcrCommitRow,
|
|
type OcrSkippedRowDto,
|
|
} from "@/lib/validations/labs-ocr";
|
|
|
|
export const POST = apiHandler(withIdempotency<[NextRequest]>(commitOcrRows));
|
|
|
|
/** True when a live reading already records this analyte+day+value. */
|
|
async function isDuplicate(
|
|
userId: string,
|
|
row: OcrCommitRow,
|
|
): Promise<boolean> {
|
|
const dayStart = new Date(row.takenAt);
|
|
dayStart.setUTCHours(0, 0, 0, 0);
|
|
const dayEnd = new Date(row.takenAt);
|
|
dayEnd.setUTCHours(23, 59, 59, 999);
|
|
|
|
const candidates = await prisma.labResult.findMany({
|
|
where: {
|
|
userId,
|
|
deletedAt: null,
|
|
analyte: { equals: row.analyte.trim(), mode: "insensitive" },
|
|
takenAt: { gte: dayStart, lte: dayEnd },
|
|
},
|
|
select: { value: true, valueText: true },
|
|
take: 25,
|
|
});
|
|
for (const c of candidates) {
|
|
if (row.value !== undefined && c.value !== null && c.value === row.value) {
|
|
return true;
|
|
}
|
|
if (
|
|
row.valueText !== undefined &&
|
|
c.valueText !== null &&
|
|
c.valueText.trim().toLowerCase() === row.valueText.trim().toLowerCase()
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Stable in-batch dedup key for a confirmed row: analyte (lower+trim) + UTC day
|
|
* + the value dimension that `isDuplicate` matches on. Two rows in ONE document
|
|
* that resolve to the same key are the same reading; only the first is written.
|
|
* 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): string {
|
|
const analyte = row.analyte.trim().toLowerCase();
|
|
const dayKey = new Date(row.takenAt).toISOString().slice(0, 10);
|
|
const valuePart =
|
|
row.value !== undefined
|
|
? `n:${row.value}`
|
|
: row.valueText !== undefined
|
|
? `t:${row.valueText.trim().toLowerCase()}`
|
|
: "∅";
|
|
return `${analyte}|${dayKey}|${valuePart}`;
|
|
}
|
|
|
|
async function commitOcrRows(request: NextRequest) {
|
|
const { user } = await requireAuth();
|
|
|
|
const { data: body, error: jsonError } = await safeJson(request, {
|
|
maxBytes: 256 * 1024,
|
|
});
|
|
if (jsonError) return jsonError;
|
|
|
|
const parsed = ocrCommitSchema.safeParse(body);
|
|
if (!parsed.success) {
|
|
annotate({
|
|
action: { name: "labs.ocr.commit.validation-failed" },
|
|
meta: { issue_count: parsed.error.issues.length },
|
|
});
|
|
// Free-text analyte / unit could land in a Zod issue message — strip values.
|
|
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
|
stripValuesFromMessage: true,
|
|
});
|
|
prisma.auditLog
|
|
.create({
|
|
data: {
|
|
userId: user.id,
|
|
action: "labs.ocr.commit.validation-failed",
|
|
details: JSON.stringify({ issues: auditIssues }),
|
|
},
|
|
})
|
|
.catch(() => {});
|
|
return returnAllZodIssues(parsed.error, 422);
|
|
}
|
|
|
|
const inserted: ReturnType<typeof serialiseLabResult>[] = [];
|
|
const skipped: OcrSkippedRowDto[] = [];
|
|
// S9 — the rows actually written, kept for the vault cross-link so a re-commit
|
|
// (every row a duplicate → nothing inserted) links nothing new.
|
|
const linkable: InsertedLabForLink[] = [];
|
|
// Tracks the keys already written in THIS request so an in-document duplicate
|
|
// is caught even before the prior row is visible to a live query. The
|
|
// mint-then-create on a mid-row failure can leave an orphan biomarker, which
|
|
// is benign and self-healing — the manual lab-write path mints the same way.
|
|
const writtenInBatch = new Set<string>();
|
|
|
|
for (const row of parsed.data.rows) {
|
|
const key = inBatchKey(row);
|
|
if (writtenInBatch.has(key) || (await isDuplicate(user.id, row))) {
|
|
skipped.push({ analyte: row.analyte.trim(), reason: "duplicate" });
|
|
continue;
|
|
}
|
|
|
|
const isQualitative = row.valueText !== undefined;
|
|
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 : (row.referenceLow ?? null),
|
|
referenceHigh: isQualitative ? null : (row.referenceHigh ?? null),
|
|
panel: row.panel ?? null,
|
|
});
|
|
|
|
// Field-by-field — never spread the parsed row. The row stamps the resolved
|
|
// catalog name/unit/range as historical truth and keeps the FK.
|
|
const created = await prisma.labResult.create({
|
|
data: {
|
|
userId: user.id,
|
|
biomarkerId: biomarker.id,
|
|
panel: biomarker.panel,
|
|
analyte: biomarker.name,
|
|
value: row.value ?? null,
|
|
valueText: row.valueText ?? null,
|
|
unit: biomarker.unit,
|
|
referenceLow: biomarker.lowerBound,
|
|
referenceHigh: biomarker.upperBound,
|
|
takenAt: row.takenAt,
|
|
source: "OCR",
|
|
noteEncrypted: null,
|
|
},
|
|
});
|
|
|
|
writtenInBatch.add(key);
|
|
inserted.push(serialiseLabResult(created, biomarker));
|
|
linkable.push({
|
|
labResultId: created.id,
|
|
analyte: created.analyte,
|
|
value: created.value,
|
|
valueText: created.valueText,
|
|
unit: created.unit,
|
|
referenceLow: created.referenceLow,
|
|
referenceHigh: created.referenceHigh,
|
|
takenAt: created.takenAt,
|
|
});
|
|
}
|
|
|
|
// S9 — cross-link the freshly inserted labs to the vault document the client
|
|
// filed the scanned bytes into. Best-effort: the labs are the authoritative
|
|
// write, so a link failure (module off, foreign / missing document) never
|
|
// fails the commit. Owner + module checks live inside the linker.
|
|
if (parsed.data.documentId && linkable.length > 0) {
|
|
await linkOcrLabsToVaultDocument(
|
|
user.id,
|
|
parsed.data.documentId,
|
|
linkable,
|
|
).catch(() => {});
|
|
}
|
|
|
|
await auditLog("labs.ocr.commit", {
|
|
userId: user.id,
|
|
ipAddress: getClientIp(request),
|
|
details: { count: inserted.length, skipped: skipped.length },
|
|
});
|
|
|
|
annotate({
|
|
action: { name: "labs.ocr.committed" },
|
|
meta: { inserted: inserted.length, skipped: skipped.length },
|
|
});
|
|
|
|
// A lab panel just landed — resolve any "annual blood panel" reminders now
|
|
// rather than waiting on the cron. Fire-and-forget.
|
|
if (inserted.length > 0) {
|
|
fireAndForget(enqueueReminderSatisfy(user.id), {
|
|
action: "reminder.satisfy.enqueue",
|
|
});
|
|
|
|
// v1.31.0 — the labs arm of the data-arrival spine. This seam DOES know
|
|
// its whole panel (the commit builds a per-row `linkable` list), so it
|
|
// emits once with the newest draw date and the true inserted count rather
|
|
// than leaning on the singleton key to collapse per-row emits.
|
|
const newestTakenAt = linkable.reduce<Date | null>(
|
|
(acc, r) => (!acc || r.takenAt > acc ? r.takenAt : acc),
|
|
null,
|
|
);
|
|
if (newestTakenAt) {
|
|
void emitDataArrival({
|
|
userId: user.id,
|
|
kind: "labs_panel",
|
|
newestSampleAt: newestTakenAt,
|
|
insertedCount: inserted.length,
|
|
source: "ocr",
|
|
}).catch(() => {});
|
|
}
|
|
}
|
|
|
|
return apiSuccess({ inserted, skipped });
|
|
}
|