mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Merge branch 'main' into fix/wipe-geo-truths
This commit is contained in:
93 files changed
+5417
-236
No files matched your search
@@ -684,6 +684,10 @@ const handler = apiHandler(
|
||||
entry.factors.map((factor) => factor.key),
|
||||
);
|
||||
const factorKeys = [...new Set(referencedFactorKeys)];
|
||||
const referencedTagKeys = payload.moodEntries.flatMap(
|
||||
(entry) => entry.structuredTags,
|
||||
);
|
||||
const tagKeys = [...new Set(referencedTagKeys)];
|
||||
// Re-create the account's own tag definitions first. The lookup
|
||||
// below used to ask only for the seeded catalogue (`userId: null`),
|
||||
// so a single custom rated tag made the whole restore throw
|
||||
@@ -740,6 +744,30 @@ const handler = apiHandler(
|
||||
referencedFactorKeys,
|
||||
);
|
||||
|
||||
// The BINARY half of the same taxonomy, looked up the same way and
|
||||
// reported the same way. It gets its own family because an
|
||||
// operator reading the report needs to know whether a rated
|
||||
// factor's score or a ticked tag went missing; they are different
|
||||
// losses on the same entry.
|
||||
const tagRows =
|
||||
tagKeys.length === 0
|
||||
? []
|
||||
: await tx.moodTag.findMany({
|
||||
where: {
|
||||
key: { in: tagKeys },
|
||||
kind: "BINARY",
|
||||
OR: [{ userId: null }, { userId: ownerId }],
|
||||
},
|
||||
select: { id: true, key: true },
|
||||
});
|
||||
const tagByKey = new Map(tagRows.map((tag) => [tag.key, tag.id]));
|
||||
recordUnknownKeys(
|
||||
skips,
|
||||
"moodTag",
|
||||
tagKeys.filter((key) => !tagByKey.has(key)),
|
||||
referencedTagKeys,
|
||||
);
|
||||
|
||||
for (const entry of payload.moodEntries) {
|
||||
const moodLoggedAt = new Date(entry.loggedAt);
|
||||
const restoredData = {
|
||||
@@ -747,10 +775,44 @@ const handler = apiHandler(
|
||||
mood: entry.mood,
|
||||
score: entry.score,
|
||||
tags: entry.tags ?? null,
|
||||
// The note comes back the way every other dual-column note
|
||||
// does: ciphertext verbatim when the file carries it, and a
|
||||
// portable file's plaintext re-encrypted on the way in, with
|
||||
// the legacy column left null either way.
|
||||
note: null,
|
||||
noteEncrypted:
|
||||
entry.noteEncrypted == null
|
||||
? encryptNote(entry.note ?? null)
|
||||
: decodeEncryptedBytes(entry.noteEncrypted),
|
||||
source: entry.source ?? "MOODLOG",
|
||||
externalId: entry.externalId ?? null,
|
||||
moodLoggedAt,
|
||||
// The zone the `date` string is anchored to. A file written
|
||||
// before this rode the payload carries nothing, and NULL is
|
||||
// what such a row meant anyway: the legacy Europe/Berlin
|
||||
// reading. So an old file keeps its old day boundaries and a
|
||||
// new one keeps the zone the person actually logged in.
|
||||
tz: entry.tz ?? null,
|
||||
// Mood reconciles last-writer-wins by `syncVersion`. Restoring
|
||||
// at the schema default would hand the next sync round to any
|
||||
// paired device still holding a higher number, and the device
|
||||
// would overwrite the recovered row with what it had — a
|
||||
// restore that loses to the client is worse than one that
|
||||
// fails. An old file carries nothing here and keeps the
|
||||
// default, which is the value those rows were written with.
|
||||
...(entry.syncVersion === undefined
|
||||
? {}
|
||||
: { syncVersion: entry.syncVersion }),
|
||||
deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null,
|
||||
...(entry.syncedAt
|
||||
? { syncedAt: new Date(entry.syncedAt) }
|
||||
: {}),
|
||||
...(entry.createdAt
|
||||
? { createdAt: new Date(entry.createdAt) }
|
||||
: {}),
|
||||
...(entry.updatedAt
|
||||
? { updatedAt: new Date(entry.updatedAt) }
|
||||
: {}),
|
||||
};
|
||||
const createData = { userId: ownerId, ...restoredData };
|
||||
const restored = entry.id
|
||||
@@ -798,8 +860,19 @@ const handler = apiHandler(
|
||||
]
|
||||
: [];
|
||||
});
|
||||
if (factorLinks.length > 0) {
|
||||
await tx.moodEntryTagLink.createMany({ data: factorLinks });
|
||||
// The other arm. A BINARY link carries no score — the presence
|
||||
// of the row is the signal — so `rating` is NULL by definition,
|
||||
// which is exactly what the backup's old tag-link filter took to
|
||||
// mean "nothing here".
|
||||
const tagLinks = entry.structuredTags.flatMap((key) => {
|
||||
const moodTagId = tagByKey.get(key);
|
||||
return moodTagId
|
||||
? [{ moodEntryId: restored.id, moodTagId, rating: null }]
|
||||
: [];
|
||||
});
|
||||
const links = [...factorLinks, ...tagLinks];
|
||||
if (links.length > 0) {
|
||||
await tx.moodEntryTagLink.createMany({ data: links });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -894,6 +967,9 @@ const handler = apiHandler(
|
||||
unit: lab.unit,
|
||||
referenceLow: lab.referenceLow ?? null,
|
||||
referenceHigh: lab.referenceHigh ?? null,
|
||||
sourceReferenceLow: lab.sourceReferenceLow ?? null,
|
||||
sourceReferenceHigh: lab.sourceReferenceHigh ?? null,
|
||||
sourceReferenceText: lab.sourceReferenceText ?? null,
|
||||
takenAt: new Date(lab.takenAt),
|
||||
source: lab.source,
|
||||
noteEncrypted:
|
||||
|
||||
@@ -57,6 +57,7 @@ function editToFactData(edit: InboundFactEdit): FactData {
|
||||
unit: edit.unit ?? null,
|
||||
referenceLow: edit.referenceLow ?? null,
|
||||
referenceHigh: edit.referenceHigh ?? null,
|
||||
referenceText: edit.referenceText ?? null,
|
||||
effectiveDate: edit.effectiveDate ?? null,
|
||||
};
|
||||
return data;
|
||||
|
||||
@@ -46,6 +46,9 @@ export const GET = apiHandler(async () => {
|
||||
value: true,
|
||||
referenceLow: true,
|
||||
referenceHigh: true,
|
||||
sourceReferenceLow: true,
|
||||
sourceReferenceHigh: true,
|
||||
sourceReferenceText: true,
|
||||
takenAt: true,
|
||||
},
|
||||
orderBy: { takenAt: "desc" },
|
||||
@@ -58,6 +61,9 @@ export const GET = apiHandler(async () => {
|
||||
value: r.value as number,
|
||||
referenceLow: r.referenceLow,
|
||||
referenceHigh: r.referenceHigh,
|
||||
sourceReferenceLow: r.sourceReferenceLow,
|
||||
sourceReferenceHigh: r.sourceReferenceHigh,
|
||||
sourceReferenceText: r.sourceReferenceText,
|
||||
takenAt: r.takenAt,
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -143,6 +143,12 @@ export const PUT = apiHandler(
|
||||
// historical columns the serialiser ignores. Reject the attempt with a
|
||||
// 422 rather than silently no-op, so the client never believes an edit
|
||||
// landed. Edit such fields on the Biomarker itself instead.
|
||||
//
|
||||
// `sourceReference*` is deliberately NOT on that list. The catalog band is
|
||||
// a property of the marker; the source window is a property of THIS
|
||||
// reading — it is what one report printed beside one value — so it is
|
||||
// editable on a linked row and there is nothing on the biomarker to edit
|
||||
// it on instead.
|
||||
const isLinked = existing.biomarkerId !== null;
|
||||
if (
|
||||
isLinked &&
|
||||
@@ -194,6 +200,15 @@ export const PUT = apiHandler(
|
||||
if (d.unit !== undefined) data.unit = d.unit;
|
||||
if (d.referenceLow !== undefined) data.referenceLow = d.referenceLow;
|
||||
if (d.referenceHigh !== undefined) data.referenceHigh = d.referenceHigh;
|
||||
if (d.sourceReferenceLow !== undefined) {
|
||||
data.sourceReferenceLow = d.sourceReferenceLow;
|
||||
}
|
||||
if (d.sourceReferenceHigh !== undefined) {
|
||||
data.sourceReferenceHigh = d.sourceReferenceHigh;
|
||||
}
|
||||
if (d.sourceReferenceText !== undefined) {
|
||||
data.sourceReferenceText = d.sourceReferenceText;
|
||||
}
|
||||
if (d.takenAt !== undefined) data.takenAt = d.takenAt;
|
||||
|
||||
// Inverted-range guard for a PARTIAL bound update. The schema-level
|
||||
@@ -212,6 +227,20 @@ export const PUT = apiHandler(
|
||||
errorCode: "labs.update.referenceRangeInvalid",
|
||||
});
|
||||
}
|
||||
// The same partial-update hole exists on the source window, and closes the
|
||||
// same way.
|
||||
if (
|
||||
isInvertedRange(
|
||||
effectiveBound(d.sourceReferenceLow, existing.sourceReferenceLow),
|
||||
effectiveBound(d.sourceReferenceHigh, existing.sourceReferenceHigh),
|
||||
)
|
||||
) {
|
||||
return apiError(
|
||||
"sourceReferenceLow must not exceed sourceReferenceHigh",
|
||||
422,
|
||||
{ errorCode: "labs.update.sourceReferenceRangeInvalid" },
|
||||
);
|
||||
}
|
||||
if (d.note !== undefined) {
|
||||
data.noteEncrypted = d.note ? encryptNoteToBytes(d.note) : null;
|
||||
}
|
||||
@@ -239,6 +268,9 @@ export const PUT = apiHandler(
|
||||
panel: existing.panel,
|
||||
referenceLow: existing.referenceLow,
|
||||
referenceHigh: existing.referenceHigh,
|
||||
sourceReferenceLow: existing.sourceReferenceLow,
|
||||
sourceReferenceHigh: existing.sourceReferenceHigh,
|
||||
sourceReferenceText: existing.sourceReferenceText,
|
||||
takenAt: existing.takenAt,
|
||||
},
|
||||
after: {
|
||||
@@ -249,6 +281,9 @@ export const PUT = apiHandler(
|
||||
panel: updated.panel,
|
||||
referenceLow: updated.referenceLow,
|
||||
referenceHigh: updated.referenceHigh,
|
||||
sourceReferenceLow: updated.sourceReferenceLow,
|
||||
sourceReferenceHigh: updated.sourceReferenceHigh,
|
||||
sourceReferenceText: updated.sourceReferenceText,
|
||||
takenAt: updated.takenAt,
|
||||
},
|
||||
redacted: d.note !== undefined ? ["note"] : [],
|
||||
|
||||
@@ -33,6 +33,7 @@ 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 { parseReferenceRange } from "@/lib/labs/parse-reference-range";
|
||||
import { serialiseLabResult } from "@/lib/labs/serialise";
|
||||
import {
|
||||
linkOcrLabsToVaultDocument,
|
||||
@@ -151,12 +152,19 @@ async function commitOcrRows(request: NextRequest) {
|
||||
}
|
||||
|
||||
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.
|
||||
const printed = parseReferenceRange(row.referenceText, row.unit);
|
||||
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 : (row.referenceLow ?? null),
|
||||
referenceHigh: isQualitative ? null : (row.referenceHigh ?? null),
|
||||
referenceLow: isQualitative ? null : sourceLow,
|
||||
referenceHigh: isQualitative ? null : sourceHigh,
|
||||
panel: row.panel ?? null,
|
||||
});
|
||||
|
||||
@@ -173,6 +181,9 @@ async function commitOcrRows(request: NextRequest) {
|
||||
unit: biomarker.unit,
|
||||
referenceLow: biomarker.lowerBound,
|
||||
referenceHigh: biomarker.upperBound,
|
||||
sourceReferenceLow: isQualitative ? null : sourceLow,
|
||||
sourceReferenceHigh: isQualitative ? null : sourceHigh,
|
||||
sourceReferenceText: isQualitative ? null : sourceText,
|
||||
takenAt: row.takenAt,
|
||||
source: "OCR",
|
||||
noteEncrypted: null,
|
||||
|
||||
@@ -184,6 +184,9 @@ async function postLabResult(request: NextRequest) {
|
||||
unit,
|
||||
referenceLow,
|
||||
referenceHigh,
|
||||
sourceReferenceLow,
|
||||
sourceReferenceHigh,
|
||||
sourceReferenceText,
|
||||
takenAt,
|
||||
note,
|
||||
source,
|
||||
@@ -248,6 +251,13 @@ async function postLabResult(request: NextRequest) {
|
||||
unit: biomarker.unit,
|
||||
referenceLow: biomarker.lowerBound,
|
||||
referenceHigh: biomarker.upperBound,
|
||||
// The window this reading's own report printed, kept beside the catalog
|
||||
// stamp rather than folded into it: it governs THIS reading's verdict,
|
||||
// and the catalog band stays the net for every reading without one. A
|
||||
// qualitative reading has no window to record.
|
||||
sourceReferenceLow: isQualitative ? null : (sourceReferenceLow ?? null),
|
||||
sourceReferenceHigh: isQualitative ? null : (sourceReferenceHigh ?? null),
|
||||
sourceReferenceText: isQualitative ? null : (sourceReferenceText ?? null),
|
||||
takenAt,
|
||||
// v1.25 (iOS #36) — provenance: the on-device-OCR path posts
|
||||
// `source: "OCR"`; an omitted field stays "MANUAL" so the legacy
|
||||
@@ -258,6 +268,7 @@ async function postLabResult(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
invalidateUserHealthScore(user.id);
|
||||
const dto = serialiseLabResult(created, biomarker);
|
||||
|
||||
await auditLog("labResult.create", {
|
||||
userId: user.id,
|
||||
@@ -275,6 +286,9 @@ async function postLabResult(request: NextRequest) {
|
||||
structured: biomarkerId !== undefined,
|
||||
// v1.25 (iOS #36) — provenance of the reading ("MANUAL" | "OCR").
|
||||
source: source ?? "MANUAL",
|
||||
// Which window governs this reading's verdict, so a dashboard can see
|
||||
// how often a report's own range displaces the catalog band.
|
||||
referenceOrigin: dto.referenceOrigin,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -298,5 +312,5 @@ async function postLabResult(request: NextRequest) {
|
||||
source: "manual",
|
||||
}).catch(() => {});
|
||||
|
||||
return apiSuccess(serialiseLabResult(created, biomarker), 201);
|
||||
return apiSuccess(dto, 201);
|
||||
}
|
||||
@@ -620,6 +620,17 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
};
|
||||
failedCount++;
|
||||
continue;
|
||||
case "rejected_range":
|
||||
// The reconciler carries the same plausibility gate this route
|
||||
// already applied above, so reaching here means the two disagreed —
|
||||
// report it exactly as the earlier guard would have, and let the
|
||||
// wide-event tally record which end refused.
|
||||
results[p.index] = {
|
||||
index: p.index,
|
||||
status: "skipped",
|
||||
reason: "value_out_of_range",
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isMergeableSource(p.row.source as string)) {
|
||||
|
||||
Reference in new issue
Block a user