mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
buildFullBackupPayload now asks every domain for its slice at once and lays the slices out in the file's key order (backup-export-sections.ts), merging the manifest where it first appears. The measurement, medication, mood, instance-settings and nutrient readers and serialisers move out of the payload builder into their domain modules; the records section keeps its composition and manifest, while labs, illness, allergies, family history, workouts and documents each export from their own module. The measurement restore half moves to measurements-restore.ts so the export graph does not pull in the restore's dependencies. The file, its counts and their order are unchanged; the golden copies still match. The measurement completeness guard now reads the domain module and also checks that every carried column is written back.
259 lines
9.2 KiB
TypeScript
259 lines
9.2 KiB
TypeScript
/**
|
|
* The illness history: episodes, their day logs, and the symptoms each day
|
|
* recorded. Both backup ends in one file.
|
|
*/
|
|
import { Buffer } from "node:buffer";
|
|
|
|
import type { PrismaClient } from "@/generated/prisma/client";
|
|
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
|
import {
|
|
toIllnessEpisodeDTO,
|
|
toIllnessDayLogDTO,
|
|
dayLogSymptomInclude,
|
|
type IllnessEpisodeDTO,
|
|
type IllnessDayLogDTO,
|
|
} from "@/lib/illness/dto";
|
|
import {
|
|
decodeEncryptedBytes,
|
|
type RestoreContext,
|
|
} from "@/lib/export/restore-context";
|
|
import { recordUnknownKeys } from "@/lib/export/restore-skips";
|
|
|
|
// ── Export ───────────────────────────────────────────────────────────────────
|
|
|
|
type CanonicalIllnessDayLog = IllnessDayLogDTO & {
|
|
noteEncrypted?: string | null;
|
|
tz?: string | null;
|
|
createdAt?: string;
|
|
deletedAt?: string | null;
|
|
};
|
|
|
|
export type CanonicalIllnessEpisode = IllnessEpisodeDTO & {
|
|
noteEncrypted?: string | null;
|
|
/** The body site's ciphertext, disaster-recovery purpose only (v1.39.2). */
|
|
bodySiteEncrypted?: string | null;
|
|
deletedAt?: string | null;
|
|
dayLogs: CanonicalIllnessDayLog[];
|
|
};
|
|
|
|
/** The illness slice of the records section: episodes with their day logs. */
|
|
export async function buildIllnessBackupSlice(
|
|
prisma: Pick<PrismaClient, "illnessEpisode">,
|
|
userId: string,
|
|
disasterRecovery: boolean,
|
|
): Promise<{ illnessEpisodes: CanonicalIllnessEpisode[] }> {
|
|
const episodeRows = await prisma.illnessEpisode.findMany({
|
|
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
|
|
orderBy: { onsetAt: "desc" },
|
|
include: {
|
|
dayLogs: {
|
|
...(disasterRecovery ? {} : { where: { deletedAt: null } }),
|
|
orderBy: { date: "asc" },
|
|
include: dayLogSymptomInclude,
|
|
},
|
|
},
|
|
});
|
|
|
|
const illnessEpisodes: CanonicalIllnessEpisode[] = disasterRecovery
|
|
? episodeRows.map((row) => ({
|
|
id: row.id,
|
|
label: row.label,
|
|
type: row.type,
|
|
lifecycle: row.lifecycle,
|
|
onsetAt: row.onsetAt.toISOString(),
|
|
resolvedAt: row.resolvedAt?.toISOString() ?? null,
|
|
parentConditionId: row.parentConditionId,
|
|
note: null,
|
|
noteEncrypted: row.noteEncrypted
|
|
? Buffer.from(row.noteEncrypted).toString("base64")
|
|
: null,
|
|
// Ciphertext verbatim, like the note: the disaster-recovery file never
|
|
// carries the site in the clear.
|
|
bodySite: null,
|
|
bodySiteEncrypted: row.bodySiteEncrypted
|
|
? Buffer.from(row.bodySiteEncrypted).toString("base64")
|
|
: null,
|
|
laterality: row.laterality,
|
|
createdAt: row.createdAt.toISOString(),
|
|
updatedAt: row.updatedAt.toISOString(),
|
|
deletedAt: row.deletedAt?.toISOString() ?? null,
|
|
dayLogs: row.dayLogs.map((dayLog) => ({
|
|
id: dayLog.id,
|
|
episodeId: dayLog.episodeId,
|
|
date: dayLog.date,
|
|
functionalImpact: dayLog.functionalImpact,
|
|
feverC: dayLog.feverC,
|
|
note: null,
|
|
noteEncrypted: dayLog.noteEncrypted
|
|
? Buffer.from(dayLog.noteEncrypted).toString("base64")
|
|
: null,
|
|
tz: dayLog.tz,
|
|
createdAt: dayLog.createdAt.toISOString(),
|
|
updatedAt: dayLog.updatedAt.toISOString(),
|
|
deletedAt: dayLog.deletedAt?.toISOString() ?? null,
|
|
symptoms: dayLog.symptomLinks.map((link) => ({
|
|
key: link.symptom.key,
|
|
severity: link.severity,
|
|
})),
|
|
})),
|
|
}))
|
|
: episodeRows.map((row) => ({
|
|
...toIllnessEpisodeDTO(row),
|
|
dayLogs: row.dayLogs.map(toIllnessDayLogDTO),
|
|
}));
|
|
|
|
return { illnessEpisodes };
|
|
}
|
|
|
|
// ── Restore ──────────────────────────────────────────────────────────────────
|
|
|
|
export async function clearIllness(ctx: RestoreContext): Promise<void> {
|
|
const illnessEpisodes = await ctx.tx.illnessEpisode.deleteMany({
|
|
where: { userId: ctx.ownerId },
|
|
});
|
|
ctx.cleared.illnessEpisodes = illnessEpisodes.count;
|
|
}
|
|
|
|
/** The episodes, then the parent links once every episode exists. */
|
|
async function restoreEpisodes(ctx: RestoreContext): Promise<void> {
|
|
const { tx, ownerId, payload } = ctx;
|
|
const episodeIds = ctx.links.episodeIds;
|
|
for (const episode of payload.illnessEpisodes) {
|
|
if (
|
|
episode.parentConditionId &&
|
|
!episodeIds.has(episode.parentConditionId)
|
|
) {
|
|
throw new Error(`Unknown illness parent: ${episode.parentConditionId}`);
|
|
}
|
|
await tx.illnessEpisode.create({
|
|
data: {
|
|
id: episode.id,
|
|
userId: ownerId,
|
|
label: episode.label,
|
|
type: episode.type as never,
|
|
lifecycle: episode.lifecycle as never,
|
|
onsetAt: new Date(episode.onsetAt),
|
|
resolvedAt: episode.resolvedAt ? new Date(episode.resolvedAt) : null,
|
|
parentConditionId: null,
|
|
noteEncrypted:
|
|
episode.noteEncrypted !== undefined
|
|
? episode.noteEncrypted === null
|
|
? null
|
|
: decodeEncryptedBytes(episode.noteEncrypted)
|
|
: episode.note == null
|
|
? null
|
|
: encryptToBytes(episode.note),
|
|
// The same two-armed read as the note: ciphertext verbatim from a
|
|
// disaster-recovery file, plaintext re-encrypted from a portable one,
|
|
// nothing from a file older than the field.
|
|
bodySiteEncrypted:
|
|
episode.bodySiteEncrypted !== undefined &&
|
|
episode.bodySiteEncrypted !== null
|
|
? decodeEncryptedBytes(episode.bodySiteEncrypted)
|
|
: episode.bodySite?.trim()
|
|
? encryptToBytes(episode.bodySite.trim())
|
|
: null,
|
|
laterality: episode.laterality ?? null,
|
|
deletedAt: episode.deletedAt ? new Date(episode.deletedAt) : null,
|
|
...(episode.createdAt
|
|
? { createdAt: new Date(episode.createdAt) }
|
|
: {}),
|
|
...(episode.updatedAt
|
|
? { updatedAt: new Date(episode.updatedAt) }
|
|
: {}),
|
|
},
|
|
});
|
|
}
|
|
for (const episode of payload.illnessEpisodes) {
|
|
if (episode.parentConditionId) {
|
|
await tx.illnessEpisode.update({
|
|
where: { id: episode.id, userId: ownerId },
|
|
data: { parentConditionId: episode.parentConditionId },
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The day logs with their symptom links.
|
|
*
|
|
* `IllnessSymptom` has no `userId` column at all — it is a purely seeded
|
|
* catalogue, so a key that will not resolve here can ONLY be catalogue drift
|
|
* and never a definition the file failed to carry. Which makes refusing the
|
|
* file over it the least defensible of the three: the file was never able to
|
|
* supply the missing row.
|
|
*/
|
|
async function restoreDayLogs(ctx: RestoreContext): Promise<void> {
|
|
const { tx, ownerId, payload, skips } = ctx;
|
|
const referencedSymptomKeys = payload.illnessEpisodes.flatMap((episode) =>
|
|
episode.dayLogs.flatMap((dayLog) =>
|
|
dayLog.symptoms.map((symptom) => symptom.key),
|
|
),
|
|
);
|
|
const symptomKeys = [...new Set(referencedSymptomKeys)];
|
|
const symptomRows =
|
|
symptomKeys.length === 0
|
|
? []
|
|
: await tx.illnessSymptom.findMany({
|
|
where: { key: { in: symptomKeys } },
|
|
select: { id: true, key: true },
|
|
});
|
|
const symptomByKey = new Map(
|
|
symptomRows.map((symptom) => [symptom.key, symptom.id]),
|
|
);
|
|
recordUnknownKeys(
|
|
skips,
|
|
"illnessSymptom",
|
|
symptomKeys.filter((key) => !symptomByKey.has(key)),
|
|
referencedSymptomKeys,
|
|
);
|
|
for (const episode of payload.illnessEpisodes) {
|
|
for (const dayLog of episode.dayLogs) {
|
|
const symptomLinks = dayLog.symptoms.flatMap((symptom) => {
|
|
const symptomId = symptomByKey.get(symptom.key);
|
|
return symptomId
|
|
? [{ symptomId, severity: symptom.severity ?? null }]
|
|
: [];
|
|
});
|
|
await tx.illnessDayLog.create({
|
|
data: {
|
|
...(dayLog.id ? { id: dayLog.id } : {}),
|
|
userId: ownerId,
|
|
episodeId: episode.id,
|
|
date: dayLog.date,
|
|
functionalImpact: dayLog.functionalImpact ?? null,
|
|
feverC: dayLog.feverC ?? null,
|
|
noteEncrypted:
|
|
dayLog.noteEncrypted !== undefined
|
|
? dayLog.noteEncrypted === null
|
|
? null
|
|
: decodeEncryptedBytes(dayLog.noteEncrypted)
|
|
: dayLog.note == null
|
|
? null
|
|
: encryptToBytes(dayLog.note),
|
|
tz: dayLog.tz ?? null,
|
|
deletedAt: dayLog.deletedAt ? new Date(dayLog.deletedAt) : null,
|
|
...(dayLog.createdAt
|
|
? { createdAt: new Date(dayLog.createdAt) }
|
|
: {}),
|
|
...(dayLog.updatedAt
|
|
? { updatedAt: new Date(dayLog.updatedAt) }
|
|
: {}),
|
|
...(symptomLinks.length > 0
|
|
? { symptomLinks: { create: symptomLinks } }
|
|
: {}),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function restoreIllness(ctx: RestoreContext): Promise<void> {
|
|
ctx.reportSection("illness");
|
|
for (const episode of ctx.payload.illnessEpisodes) {
|
|
ctx.links.episodeIds.add(episode.id);
|
|
}
|
|
await restoreEpisodes(ctx);
|
|
await restoreDayLogs(ctx);
|
|
}
|