mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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();
|
||||
@@ -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) }
|
||||
: {}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -22,7 +22,7 @@
|
||||
"illnessEpisodes": 4,
|
||||
"illnessDayLogs": 2,
|
||||
"allergies": 2,
|
||||
"familyHistory": 1,
|
||||
"familyHistory": 2,
|
||||
"workouts": 1,
|
||||
"documents": 1,
|
||||
"documentConditionLinks": 1,
|
||||
@@ -78,7 +78,7 @@
|
||||
"nutrientDays": 1,
|
||||
"illnessEpisodes": 4,
|
||||
"allergies": 2,
|
||||
"familyHistory": 1,
|
||||
"familyHistory": 2,
|
||||
"workouts": 1,
|
||||
"documents": 1,
|
||||
"documentConditionLinks": 0,
|
||||
@@ -294,7 +294,7 @@
|
||||
"nutrientDays": 1,
|
||||
"illnessEpisodes": 4,
|
||||
"allergies": 2,
|
||||
"familyHistory": 1,
|
||||
"familyHistory": 2,
|
||||
"workouts": 1,
|
||||
"documents": 1,
|
||||
"documentConditionLinks": 0,
|
||||
@@ -350,7 +350,7 @@
|
||||
"illnessEpisodes": 4,
|
||||
"illnessDayLogs": 2,
|
||||
"allergies": 2,
|
||||
"familyHistory": 1,
|
||||
"familyHistory": 2,
|
||||
"workouts": 1,
|
||||
"documents": 1,
|
||||
"healthProfile": 1,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1707,9 +1707,11 @@
|
||||
"relationship": "FATHER",
|
||||
"condition": "Diabetes",
|
||||
"ageAtOnset": 55,
|
||||
"note": "type 2",
|
||||
"note": null,
|
||||
"notesEncrypted": "<sealed:v2:type 2>",
|
||||
"createdAt": "<now>",
|
||||
"updatedAt": "<now>"
|
||||
"updatedAt": "<now>",
|
||||
"deletedAt": null
|
||||
}
|
||||
],
|
||||
"workouts": [
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* The disaster-recovery file carries deleted family-history entries, as it
|
||||
* carries every other record's tombstones, and carries their note as the
|
||||
* ciphertext at rest. The portable file carries the live entries only, with
|
||||
* the note readable. The full round trip, the restored tombstone included, is
|
||||
* pinned by the backup parity golden copies.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { buildFamilyHistoryBackupSlice } from "@/lib/export/family-history-backup";
|
||||
|
||||
import { makeUser } from "../kit/factories";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
beforeEach(() => truncateAllTables(prisma));
|
||||
|
||||
describe("family history in the backup", () => {
|
||||
it("keeps a deleted entry in the disaster-recovery file and out of the portable one", async () => {
|
||||
const user = await makeUser();
|
||||
const notes = encryptToBytes("diagnosed at fifty");
|
||||
await prisma.familyHistoryEntry.create({
|
||||
data: { userId: user.id, relationship: "MOTHER", condition: "Asthma" },
|
||||
});
|
||||
const deleted = await prisma.familyHistoryEntry.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
relationship: "FATHER",
|
||||
condition: "Hypertension",
|
||||
notesEncrypted: notes,
|
||||
deletedAt: new Date("2026-09-30T10:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
|
||||
const dr = await buildFamilyHistoryBackupSlice(prisma, user.id, true);
|
||||
expect(dr.familyHistory).toHaveLength(2);
|
||||
const tombstone = dr.familyHistory.find((e) => e.id === deleted.id);
|
||||
expect(tombstone?.deletedAt).toBe("2026-09-30T10:00:00.000Z");
|
||||
expect(tombstone?.note).toBeNull();
|
||||
expect(tombstone?.notesEncrypted).toBe(
|
||||
Buffer.from(notes).toString("base64"),
|
||||
);
|
||||
|
||||
const portable = await buildFamilyHistoryBackupSlice(
|
||||
prisma,
|
||||
user.id,
|
||||
false,
|
||||
);
|
||||
expect(portable.familyHistory.map((e) => e.condition)).toEqual(["Asthma"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user