mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
A disaster-recovery restore wrote these rows stamped with the restore's own time: the medication's updatedAt (moved again by the course window), the category assignment, custom category labels, courses and composition, the mood day context and tag links, illness and cycle symptom links, nutrient days, and a flare's updatedAt after the parent pointer pass. The recovery file now carries each stamp (additive fields: linkCreatedAt on a mood entry, symptomLinkCreatedAt on a cycle day log, createdAt on an illness symptom, categoryUpdatedAt on a medication, updatedAt where missing) and the restore writes it back. A file without them restores as before. A custom category label, a course note and a marker's context rode the recovery file as readable text and were sealed again under the active key. They now ride as ciphertext (labelEncrypted, noteEncrypted, contextEncrypted) and come back as the same bytes; a file that carries the readable value instead is sealed on the way in, as before. The portable file keeps the readable values. The rotation script and the encrypted-column writer guard already cover all three columns. The parity golden copies change for exactly this: the new fields in the recovery file, the restored stamps in place of <now>, and the three values coming back under the key they were sealed with. The legacy readable coach title and custom metric note are still sealed on restore, since the row holds no ciphertext to carry. The round-trip column audit no longer excludes the four link stamps.
486 lines
16 KiB
TypeScript
486 lines
16 KiB
TypeScript
/**
|
|
* The lab record: markers with their other spellings, the merge ledger, and
|
|
* the results filed under them. Both backup ends in one file.
|
|
*
|
|
* A result travels with its marker's NAME as well as its id: a portable
|
|
* restore mints fresh marker ids, and `(userId, name)` is what the catalogue
|
|
* is unique on, so the name is what survives the trip.
|
|
*/
|
|
import { Buffer } from "node:buffer";
|
|
|
|
import type { PrismaClient } from "@/generated/prisma/client";
|
|
import {
|
|
decryptContextFromBytes,
|
|
encryptContextToBytes,
|
|
} from "@/lib/labs/biomarker-store";
|
|
import { decryptNoteFromBytes, encryptNoteToBytes } from "@/lib/labs/store";
|
|
import { foldForMatch } from "@/lib/i18n/fold-for-match";
|
|
import { annotate, getEvent } from "@/lib/logging/context";
|
|
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
|
|
import {
|
|
decodeEncryptedBytes,
|
|
type RestoreContext,
|
|
} from "@/lib/export/restore-context";
|
|
|
|
// ── Export ───────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Decrypt an encrypted free-text column for a portable export, or say plainly
|
|
* that this one could not be read.
|
|
*
|
|
* A fail-soft decrypt answers `null` on a wrong or dropped key. In a list view
|
|
* that is right — one bad row must not take the page down. In an export it is
|
|
* not: `null` is exactly what a column that was never written looks like, so
|
|
* the restore importer writes nothing and a note the person did write
|
|
* disappears without anyone being told. The Coach transcript and the doctor
|
|
* report both refuse that trade, one with a placeholder sentence and one with
|
|
* a rendered flag; this follows the same rule.
|
|
*
|
|
* The warning the soft helpers emitted is kept, so a systemic key gap still
|
|
* shows up in the wide event rather than only in the file.
|
|
*/
|
|
function readForExport(
|
|
buf: Uint8Array | null,
|
|
label: string,
|
|
decode: (bytes: Uint8Array) => string,
|
|
): string | null {
|
|
if (!buf || buf.byteLength === 0) return null;
|
|
try {
|
|
return decode(buf);
|
|
} catch (err) {
|
|
getEvent()?.addWarning(
|
|
`${label} decrypt failed: ${
|
|
err instanceof Error ? err.message : String(err)
|
|
}`,
|
|
);
|
|
return UNREADABLE_EXPORT_MARKER;
|
|
}
|
|
}
|
|
|
|
export interface LabResultBackupEntry {
|
|
/** Present in canonical DR payloads so rows keep a stable identity. */
|
|
id?: string;
|
|
/** The source row's id in a portable payload, for the relation links. */
|
|
ref?: string;
|
|
panel: string | null;
|
|
analyte: string;
|
|
value: number | null;
|
|
valueText: string | null;
|
|
unit: string;
|
|
referenceLow: number | null;
|
|
referenceHigh: number | null;
|
|
/** The window the reading's own source report printed, verbatim + derived. */
|
|
sourceReferenceLow: number | null;
|
|
sourceReferenceHigh: number | null;
|
|
sourceReferenceText: string | null;
|
|
/**
|
|
* The value and unit the source report printed, kept when a write
|
|
* converted the reading into its marker's unit.
|
|
*/
|
|
sourceValue?: number | null;
|
|
sourceUnit?: string | null;
|
|
takenAt: string;
|
|
source: string;
|
|
/** Human-readable cross-reference into `biomarkers` below, not an id. */
|
|
/** Stable canonical FK; legacy portable payloads use biomarkerName. */
|
|
biomarkerId?: string | null;
|
|
biomarkerName: string | null;
|
|
note: string | null;
|
|
/** Base64 ciphertext in canonical DR payloads. */
|
|
noteEncrypted?: string | null;
|
|
createdAt?: string;
|
|
updatedAt?: string;
|
|
deletedAt?: string | null;
|
|
}
|
|
|
|
export interface BiomarkerBackupEntry {
|
|
/** Present in canonical DR payloads; name remains the natural key. */
|
|
id?: string;
|
|
name: string;
|
|
unit: string;
|
|
lowerBound: number | null;
|
|
upperBound: number | null;
|
|
panel: string | null;
|
|
hidden: boolean;
|
|
context: string | null;
|
|
/**
|
|
* The context's ciphertext as base64, disaster-recovery files only, where
|
|
* `context` is null. A file without it carries the readable context.
|
|
*/
|
|
contextEncrypted?: string | null;
|
|
/** The catalogue key the marker's name and unit resolve to. */
|
|
analyteKey?: string | null;
|
|
/**
|
|
* The other spellings the marker answers to, with where
|
|
* each came from. Absent on files written before them.
|
|
*/
|
|
aliases?: BiomarkerAliasBackupEntry[];
|
|
createdAt?: string;
|
|
updatedAt?: string;
|
|
}
|
|
|
|
export interface BiomarkerAliasBackupEntry {
|
|
/** Present in canonical DR payloads. */
|
|
id?: string;
|
|
name: string;
|
|
source: "MERGE" | "IMPORT_CHOICE" | "MANUAL";
|
|
createdAt?: string;
|
|
}
|
|
|
|
/**
|
|
* One marker folded into another. The target travels by id
|
|
* in a disaster-recovery file and by name in both, the way a lab result's
|
|
* marker does; the source is gone, so its id and name are the record.
|
|
*/
|
|
export interface BiomarkerMergeBackupEntry {
|
|
id?: string;
|
|
targetBiomarkerId?: string;
|
|
targetName: string;
|
|
sourceBiomarkerId: string;
|
|
sourceName: string;
|
|
readingCount: number;
|
|
mergedAt: string;
|
|
}
|
|
|
|
/** The lab slice of the records section: results, markers, merge ledger. */
|
|
export async function buildLabsBackupSlice(
|
|
prisma: Pick<
|
|
PrismaClient,
|
|
"labResult" | "biomarker" | "biomarkerAlias" | "biomarkerMerge"
|
|
>,
|
|
userId: string,
|
|
disasterRecovery: boolean,
|
|
): Promise<{
|
|
labResults: LabResultBackupEntry[];
|
|
biomarkers: BiomarkerBackupEntry[];
|
|
biomarkerMerges: BiomarkerMergeBackupEntry[];
|
|
}> {
|
|
const [labResultRows, biomarkerRows, biomarkerAliasRows, biomarkerMergeRows] =
|
|
await Promise.all([
|
|
prisma.labResult.findMany({
|
|
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
|
|
orderBy: { takenAt: "desc" },
|
|
include: { biomarker: { select: { name: true } } },
|
|
}),
|
|
prisma.biomarker.findMany({
|
|
where: { userId },
|
|
orderBy: { name: "asc" },
|
|
}),
|
|
prisma.biomarkerAlias.findMany({
|
|
where: { userId },
|
|
orderBy: [{ biomarkerId: "asc" }, { nameFolded: "asc" }],
|
|
select: {
|
|
id: true,
|
|
biomarkerId: true,
|
|
name: true,
|
|
source: true,
|
|
createdAt: true,
|
|
},
|
|
}),
|
|
prisma.biomarkerMerge.findMany({
|
|
where: { userId },
|
|
orderBy: { mergedAt: "asc" },
|
|
include: { target: { select: { name: true } } },
|
|
}),
|
|
]);
|
|
|
|
const labResults: LabResultBackupEntry[] = labResultRows.map((r) => ({
|
|
...(disasterRecovery
|
|
? {
|
|
id: r.id,
|
|
biomarkerId: r.biomarkerId,
|
|
noteEncrypted: r.noteEncrypted
|
|
? Buffer.from(r.noteEncrypted).toString("base64")
|
|
: null,
|
|
createdAt: r.createdAt.toISOString(),
|
|
updatedAt: r.updatedAt.toISOString(),
|
|
deletedAt: r.deletedAt?.toISOString() ?? null,
|
|
note: null,
|
|
}
|
|
: {
|
|
// No id: a portable restore mints a fresh one. The relation links
|
|
// still name this row, so the file says which row they mean.
|
|
ref: r.id,
|
|
note: readForExport(
|
|
r.noteEncrypted,
|
|
"lab note",
|
|
decryptNoteFromBytes,
|
|
),
|
|
}),
|
|
panel: r.panel,
|
|
analyte: r.analyte,
|
|
value: r.value,
|
|
valueText: r.valueText,
|
|
unit: r.unit,
|
|
referenceLow: r.referenceLow,
|
|
referenceHigh: r.referenceHigh,
|
|
sourceReferenceLow: r.sourceReferenceLow,
|
|
sourceReferenceHigh: r.sourceReferenceHigh,
|
|
sourceReferenceText: r.sourceReferenceText,
|
|
sourceValue: r.sourceValue,
|
|
sourceUnit: r.sourceUnit,
|
|
takenAt: r.takenAt.toISOString(),
|
|
source: r.source,
|
|
biomarkerName: r.biomarker?.name ?? null,
|
|
}));
|
|
|
|
const aliasesByMarker = new Map<string, BiomarkerAliasBackupEntry[]>();
|
|
for (const alias of biomarkerAliasRows) {
|
|
const list = aliasesByMarker.get(alias.biomarkerId) ?? [];
|
|
list.push({
|
|
name: alias.name,
|
|
source: alias.source as BiomarkerAliasBackupEntry["source"],
|
|
...(disasterRecovery
|
|
? { id: alias.id, createdAt: alias.createdAt.toISOString() }
|
|
: {}),
|
|
});
|
|
aliasesByMarker.set(alias.biomarkerId, list);
|
|
}
|
|
|
|
const biomarkerMerges: BiomarkerMergeBackupEntry[] = biomarkerMergeRows.map(
|
|
(m) => ({
|
|
...(disasterRecovery
|
|
? { id: m.id, targetBiomarkerId: m.targetBiomarkerId }
|
|
: {}),
|
|
targetName: m.target.name,
|
|
sourceBiomarkerId: m.sourceBiomarkerId,
|
|
sourceName: m.sourceName,
|
|
readingCount: m.readingCount,
|
|
mergedAt: m.mergedAt.toISOString(),
|
|
}),
|
|
);
|
|
|
|
const biomarkers: BiomarkerBackupEntry[] = biomarkerRows.map((b) => ({
|
|
...(disasterRecovery
|
|
? {
|
|
id: b.id,
|
|
createdAt: b.createdAt.toISOString(),
|
|
updatedAt: b.updatedAt.toISOString(),
|
|
}
|
|
: {}),
|
|
name: b.name,
|
|
unit: b.unit,
|
|
lowerBound: b.lowerBound,
|
|
upperBound: b.upperBound,
|
|
panel: b.panel,
|
|
hidden: b.hidden,
|
|
// Ciphertext verbatim in a disaster-recovery file, like every other
|
|
// sealed column; the readable context in a portable one.
|
|
...(disasterRecovery
|
|
? {
|
|
context: null,
|
|
contextEncrypted: b.contextEncrypted
|
|
? Buffer.from(b.contextEncrypted).toString("base64")
|
|
: null,
|
|
}
|
|
: {
|
|
context: readForExport(
|
|
b.contextEncrypted,
|
|
"biomarker context",
|
|
decryptContextFromBytes,
|
|
),
|
|
}),
|
|
analyteKey: b.analyteKey,
|
|
aliases: aliasesByMarker.get(b.id) ?? [],
|
|
}));
|
|
|
|
return { labResults, biomarkers, biomarkerMerges };
|
|
}
|
|
|
|
// ── Restore ──────────────────────────────────────────────────────────────────
|
|
|
|
export async function clearLabResults(ctx: RestoreContext): Promise<void> {
|
|
const labResults = await ctx.tx.labResult.deleteMany({
|
|
where: { userId: ctx.ownerId },
|
|
});
|
|
ctx.cleared.labResults = labResults.count;
|
|
}
|
|
|
|
export async function clearBiomarkers(ctx: RestoreContext): Promise<void> {
|
|
const biomarkers = await ctx.tx.biomarker.deleteMany({
|
|
where: { userId: ctx.ownerId },
|
|
});
|
|
ctx.cleared.biomarkers = biomarkers.count;
|
|
}
|
|
|
|
/**
|
|
* The merge ledger, against the markers just written: by id where the file
|
|
* carries one this restore kept, by name otherwise. A row whose target the
|
|
* file does not carry has nothing to point at and is left out (counted on the
|
|
* event).
|
|
*/
|
|
async function restoreBiomarkerMerges(ctx: RestoreContext): Promise<void> {
|
|
const { tx, ownerId, payload } = ctx;
|
|
const { biomarkerByName, restoredBiomarkerIds } = ctx.links;
|
|
let unresolvedMerges = 0;
|
|
const mergeRows = (payload.biomarkerMerges ?? []).flatMap((merge) => {
|
|
const targetId =
|
|
merge.targetBiomarkerId &&
|
|
restoredBiomarkerIds.has(merge.targetBiomarkerId)
|
|
? merge.targetBiomarkerId
|
|
: biomarkerByName.get(merge.targetName);
|
|
if (!targetId) {
|
|
unresolvedMerges += 1;
|
|
return [];
|
|
}
|
|
return [
|
|
{
|
|
...(merge.id ? { id: merge.id } : {}),
|
|
userId: ownerId,
|
|
targetBiomarkerId: targetId,
|
|
sourceBiomarkerId: merge.sourceBiomarkerId,
|
|
sourceName: merge.sourceName,
|
|
readingCount: merge.readingCount,
|
|
mergedAt: new Date(merge.mergedAt),
|
|
},
|
|
];
|
|
});
|
|
if (mergeRows.length > 0) {
|
|
await tx.biomarkerMerge.createMany({ data: mergeRows });
|
|
}
|
|
if (unresolvedMerges > 0) {
|
|
annotate({
|
|
action: { name: "backup.restore.biomarkerMerge.unresolved" },
|
|
meta: { count: unresolvedMerges },
|
|
});
|
|
}
|
|
}
|
|
|
|
/** The markers, each with its other spellings, then the merge ledger. */
|
|
export async function restoreLabMarkers(ctx: RestoreContext): Promise<void> {
|
|
const { tx, ownerId, payload } = ctx;
|
|
const { biomarkerByName, restoredBiomarkerIds } = ctx.links;
|
|
ctx.reportSection("labs");
|
|
for (const biomarker of payload.biomarkers) {
|
|
const created = await tx.biomarker.create({
|
|
data: {
|
|
...(biomarker.id ? { id: biomarker.id } : {}),
|
|
userId: ownerId,
|
|
name: biomarker.name,
|
|
unit: biomarker.unit,
|
|
lowerBound: biomarker.lowerBound ?? null,
|
|
upperBound: biomarker.upperBound ?? null,
|
|
panel: biomarker.panel ?? null,
|
|
hidden: biomarker.hidden ?? false,
|
|
contextEncrypted:
|
|
biomarker.contextEncrypted !== undefined
|
|
? biomarker.contextEncrypted === null
|
|
? null
|
|
: decodeEncryptedBytes(biomarker.contextEncrypted)
|
|
: biomarker.context == null
|
|
? null
|
|
: encryptContextToBytes(biomarker.context),
|
|
analyteKey: biomarker.analyteKey ?? null,
|
|
...(biomarker.createdAt
|
|
? { createdAt: new Date(biomarker.createdAt) }
|
|
: {}),
|
|
...(biomarker.updatedAt
|
|
? { updatedAt: new Date(biomarker.updatedAt) }
|
|
: {}),
|
|
},
|
|
});
|
|
biomarkerByName.set(biomarker.name, created.id);
|
|
restoredBiomarkerIds.add(created.id);
|
|
// The other spellings, folded the way the resolver reads them, so the
|
|
// next scan matches them again.
|
|
const aliasRows = (biomarker.aliases ?? [])
|
|
.map((alias) => ({
|
|
id: alias.id,
|
|
name: alias.name.trim().slice(0, 120),
|
|
nameFolded: foldForMatch(alias.name).slice(0, 120),
|
|
source: alias.source,
|
|
createdAt: alias.createdAt,
|
|
}))
|
|
.filter(
|
|
(alias) =>
|
|
alias.nameFolded !== "" &&
|
|
alias.nameFolded !== foldForMatch(biomarker.name),
|
|
);
|
|
if (aliasRows.length > 0) {
|
|
await tx.biomarkerAlias.createMany({
|
|
data: aliasRows.map((alias) => ({
|
|
...(alias.id ? { id: alias.id } : {}),
|
|
userId: ownerId,
|
|
biomarkerId: created.id,
|
|
name: alias.name,
|
|
nameFolded: alias.nameFolded,
|
|
source: alias.source,
|
|
...(alias.createdAt ? { createdAt: new Date(alias.createdAt) } : {}),
|
|
})),
|
|
// One spelling answers to one marker per account; a file that names
|
|
// it twice keeps the first.
|
|
skipDuplicates: true,
|
|
});
|
|
}
|
|
}
|
|
await restoreBiomarkerMerges(ctx);
|
|
}
|
|
|
|
/**
|
|
* The results, against the markers written above. The ids written are
|
|
* collected for the staged facts later on: an approved fact's
|
|
* `committedRecordId` names the lab result it was committed to.
|
|
*/
|
|
export async function restoreLabResults(ctx: RestoreContext): Promise<void> {
|
|
const { tx, ownerId, payload } = ctx;
|
|
const {
|
|
biomarkerByName,
|
|
restoredBiomarkerIds,
|
|
restoredLabResultIds,
|
|
labResultIdBySourceId,
|
|
} = ctx.links;
|
|
for (const lab of payload.labResults) {
|
|
const biomarkerId =
|
|
lab.biomarkerId !== undefined
|
|
? lab.biomarkerId === null
|
|
? null
|
|
: restoredBiomarkerIds.has(lab.biomarkerId)
|
|
? lab.biomarkerId
|
|
: undefined
|
|
: lab.biomarkerName
|
|
? biomarkerByName.get(lab.biomarkerName)
|
|
: null;
|
|
if (biomarkerId === undefined) {
|
|
throw new Error(
|
|
`Unknown biomarker reference: ${lab.biomarkerId ?? lab.biomarkerName}`,
|
|
);
|
|
}
|
|
const createdLab = await tx.labResult.create({
|
|
data: {
|
|
...(lab.id ? { id: lab.id } : {}),
|
|
userId: ownerId,
|
|
biomarkerId: biomarkerId ?? null,
|
|
panel: lab.panel ?? null,
|
|
analyte: lab.analyte,
|
|
value: lab.value ?? null,
|
|
valueText: lab.valueText ?? null,
|
|
unit: lab.unit,
|
|
referenceLow: lab.referenceLow ?? null,
|
|
referenceHigh: lab.referenceHigh ?? null,
|
|
sourceReferenceLow: lab.sourceReferenceLow ?? null,
|
|
sourceReferenceHigh: lab.sourceReferenceHigh ?? null,
|
|
sourceReferenceText: lab.sourceReferenceText ?? null,
|
|
sourceValue: lab.sourceValue ?? null,
|
|
sourceUnit: lab.sourceUnit ?? null,
|
|
takenAt: new Date(lab.takenAt),
|
|
source: lab.source,
|
|
noteEncrypted:
|
|
lab.noteEncrypted !== undefined
|
|
? lab.noteEncrypted === null
|
|
? null
|
|
: decodeEncryptedBytes(lab.noteEncrypted)
|
|
: lab.note == null
|
|
? null
|
|
: encryptNoteToBytes(lab.note),
|
|
deletedAt: lab.deletedAt ? new Date(lab.deletedAt) : null,
|
|
...(lab.createdAt ? { createdAt: new Date(lab.createdAt) } : {}),
|
|
...(lab.updatedAt ? { updatedAt: new Date(lab.updatedAt) } : {}),
|
|
},
|
|
});
|
|
restoredLabResultIds.add(createdLab.id);
|
|
// Same arrangement as the medications' source-id map.
|
|
const labSourceId = lab.id ?? lab.ref;
|
|
if (labSourceId) labResultIdBySourceId.set(labSourceId, createdLab.id);
|
|
}
|
|
}
|