fix(backup): carry deleted family-history entries in the disaster-recovery file

Every record with a tombstone (allergies, labs, conditions, vaccinations,
life events, measurements and the rest) rides the disaster-recovery file
deleted rows included, so a restore gives back the account as it was. The
family history read only live entries for both files, so a restore lost
its deletions, and it carried the note as readable text that the restore
sealed again under the active key.

The disaster-recovery slice now carries every entry with its deletion and
the note as the ciphertext at rest, and the restore writes both back
verbatim; the portable file keeps the readable live entries. The parity
golden copies change for exactly that: the deleted entry, its note's key
and the shifted ids after it.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 18:59:07 +02:00
1 parent faedcbba93
commit 3b2c2c45f3
8 files changed
+576 -423

No files matched your search

@@ -8,7 +8,7 @@
*/
import { z } from "zod/v4";
import { FamilyRelationship } from "@/generated/prisma/enums";
import { isoDateTime } from "@/lib/export/backup-schema-kit";
import { base64BytesSchema, isoDateTime } from "@/lib/export/backup-schema-kit";
export const familyHistoryBackupSchema = z
.object({
@@ -17,7 +17,9 @@ export const familyHistoryBackupSchema = z
condition: z.string().min(1),
ageAtOnset: z.number().int().nullable().optional(),
note: z.string().nullable().optional(),
notesEncrypted: base64BytesSchema.nullable().optional(),
createdAt: isoDateTime.optional(),
updatedAt: isoDateTime.optional(),
deletedAt: isoDateTime.nullable().optional(),
})
.passthrough();
+52 -6
View File
@@ -1,26 +1,61 @@
/**
* The family history, with 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 {
toFamilyHistoryEntryDTO,
type FamilyHistoryEntryDTO,
} from "@/lib/records/dto";
import type { RestoreContext } from "@/lib/export/restore-context";
import {
decodeEncryptedBytes,
type RestoreContext,
} from "@/lib/export/restore-context";
// ── Export ───────────────────────────────────────────────────────────────────
/** The family-history slice of the records section. */
/**
* A family-history entry as the disaster-recovery file carries it: the note
* as the ciphertext at rest, and the deletion. The portable file carries the
* readable DTO of the live entries only.
*/
export type CanonicalFamilyHistoryEntry = FamilyHistoryEntryDTO & {
notesEncrypted?: string | null;
deletedAt?: string | null;
};
/**
* The family-history slice of the records section. A disaster-recovery file
* carries deleted entries too, as every other record with a tombstone does:
* a restore from it has to give back the account as it was, deletions
* included, and the file used to drop them.
*/
export async function buildFamilyHistoryBackupSlice(
prisma: Pick<PrismaClient, "familyHistoryEntry">,
userId: string,
): Promise<{ familyHistory: FamilyHistoryEntryDTO[] }> {
disasterRecovery: boolean,
): Promise<{ familyHistory: CanonicalFamilyHistoryEntry[] }> {
const familyRows = await prisma.familyHistoryEntry.findMany({
where: { userId, deletedAt: null },
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { createdAt: "desc" },
});
const familyHistory = familyRows.map(toFamilyHistoryEntryDTO);
const familyHistory: CanonicalFamilyHistoryEntry[] = disasterRecovery
? familyRows.map((row) => ({
id: row.id,
relationship: row.relationship,
condition: row.condition,
ageAtOnset: row.ageAtOnset,
note: null,
notesEncrypted: row.notesEncrypted
? Buffer.from(row.notesEncrypted).toString("base64")
: null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null,
}))
: familyRows.map(toFamilyHistoryEntryDTO);
return { familyHistory };
}
@@ -44,8 +79,19 @@ export async function restoreFamilyHistory(ctx: RestoreContext): Promise<void> {
relationship: familyEntry.relationship as never,
condition: familyEntry.condition,
ageAtOnset: familyEntry.ageAtOnset ?? null,
// The ciphertext verbatim when the file carries it, the readable note
// sealed here otherwise.
notesEncrypted:
familyEntry.note == null ? null : encryptToBytes(familyEntry.note),
familyEntry.notesEncrypted !== undefined
? familyEntry.notesEncrypted === null
? null
: decodeEncryptedBytes(familyEntry.notesEncrypted)
: familyEntry.note == null
? null
: encryptToBytes(familyEntry.note),
deletedAt: familyEntry.deletedAt
? new Date(familyEntry.deletedAt)
: null,
...(familyEntry.createdAt
? { createdAt: new Date(familyEntry.createdAt) }
: {}),
+1 -1
View File
@@ -122,7 +122,7 @@ export async function buildRecordsBackupSection(
buildLabsBackupSlice(prisma, userId, disasterRecovery),
buildIllnessBackupSlice(prisma, userId, disasterRecovery),
buildAllergiesBackupSlice(prisma, userId, disasterRecovery),
buildFamilyHistoryBackupSlice(prisma, userId),
buildFamilyHistoryBackupSlice(prisma, userId, disasterRecovery),
buildWorkoutsBackupSlice(prisma, userId, disasterRecovery),
buildDocumentsBackupSlice(
prisma,