mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(backup): carry emergency profile through rotation and backup
Register the three encrypted emergency columns in the rotation registry and the key-rotation script so a legacy-key drop cannot strand them. Carry all six new columns through the profile backup builder and restore (the enums by value, the free text as ciphertext in a DR payload or decrypted plaintext in a portable export) and extend the wire schema. The full-backup round-trip integration test seeds the six columns and asserts each survives an export and restore.
This commit is contained in:
1 parent
9e52285acc
commit
772da42348
5 files changed
+141
-2
No files matched your search
@@ -369,12 +369,17 @@ async function main() {
|
||||
// ───── UserHealthProfile (Bytes columns) ─────
|
||||
// "aboutMeEncrypted" "conditionsEncrypted" "allergiesEncrypted"
|
||||
// "coachFocusEncrypted" "pendingQuestionsEncrypted"
|
||||
// "emergencyContactsEncrypted" "emergencyImplantsEncrypted"
|
||||
// "emergencyNoteEncrypted"
|
||||
for (const field of [
|
||||
"aboutMeEncrypted",
|
||||
"conditionsEncrypted",
|
||||
"allergiesEncrypted",
|
||||
"coachFocusEncrypted",
|
||||
"pendingQuestionsEncrypted",
|
||||
"emergencyContactsEncrypted",
|
||||
"emergencyImplantsEncrypted",
|
||||
"emergencyNoteEncrypted",
|
||||
]) {
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
|
||||
@@ -167,6 +167,25 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
|
||||
field: "pendingQuestionsEncrypted",
|
||||
kind: "bytes",
|
||||
},
|
||||
// v1.38 — emergency ("Notfalldaten") free text: contacts, implants/devices,
|
||||
// and the ICE note. Same Bytes codec as the self-context columns above; the
|
||||
// three enum companions (blood type, organ-donor, advance-directive) are
|
||||
// plaintext closed sets and are deliberately NOT here.
|
||||
{
|
||||
model: "UserHealthProfile",
|
||||
field: "emergencyContactsEncrypted",
|
||||
kind: "bytes",
|
||||
},
|
||||
{
|
||||
model: "UserHealthProfile",
|
||||
field: "emergencyImplantsEncrypted",
|
||||
kind: "bytes",
|
||||
},
|
||||
{
|
||||
model: "UserHealthProfile",
|
||||
field: "emergencyNoteEncrypted",
|
||||
kind: "bytes",
|
||||
},
|
||||
|
||||
// ───── Effective-dated health profile facts (Bytes column) ─────
|
||||
{
|
||||
|
||||
@@ -32,6 +32,11 @@ import {
|
||||
type HealthProfileAiSection,
|
||||
type HealthProfileFactKind,
|
||||
} from "@/lib/validations/health-profile-facts";
|
||||
import type {
|
||||
AdvanceDirectiveStatusValue,
|
||||
EmergencyBloodTypeValue,
|
||||
OrganDonorStatusValue,
|
||||
} from "@/lib/validations/emergency-profile";
|
||||
|
||||
export interface ProfileBackupOptions {
|
||||
purpose?: "portable-export" | "disaster-recovery";
|
||||
@@ -59,6 +64,22 @@ export interface HealthProfileBackupEntry {
|
||||
allergiesEncrypted?: string | null;
|
||||
coachFocusEncrypted?: string | null;
|
||||
aiIncludedSections: HealthProfileAiSection[];
|
||||
/**
|
||||
* Emergency ("Notfalldaten") profile. The three enums are plaintext closed
|
||||
* sets, carried by value in both purposes exactly like `aiIncludedSections`.
|
||||
* The three free-text columns follow the same split as the self-context above:
|
||||
* a portable export decrypts them into `emergency*`, a DR payload leaves those
|
||||
* null and carries the ciphertext in `emergency*Encrypted`.
|
||||
*/
|
||||
emergencyBloodType: EmergencyBloodTypeValue | null;
|
||||
organDonorStatus: OrganDonorStatusValue | null;
|
||||
advanceDirectiveStatus: AdvanceDirectiveStatusValue | null;
|
||||
emergencyContacts: string | null;
|
||||
emergencyImplants: string | null;
|
||||
emergencyNote: string | null;
|
||||
emergencyContactsEncrypted?: string | null;
|
||||
emergencyImplantsEncrypted?: string | null;
|
||||
emergencyNoteEncrypted?: string | null;
|
||||
/**
|
||||
* Server-derived clarifying questions awaiting an answer, encrypted JSON.
|
||||
*
|
||||
@@ -248,6 +269,19 @@ export async function buildProfileBackupSection(
|
||||
HealthProfileAiSection[] | undefined) ?? [
|
||||
...DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
|
||||
],
|
||||
emergencyBloodType: profileRow.emergencyBloodType ?? null,
|
||||
organDonorStatus: profileRow.organDonorStatus ?? null,
|
||||
advanceDirectiveStatus: profileRow.advanceDirectiveStatus ?? null,
|
||||
emergencyContacts: null,
|
||||
emergencyImplants: null,
|
||||
emergencyNote: null,
|
||||
emergencyContactsEncrypted: toBase64(
|
||||
profileRow.emergencyContactsEncrypted,
|
||||
),
|
||||
emergencyImplantsEncrypted: toBase64(
|
||||
profileRow.emergencyImplantsEncrypted,
|
||||
),
|
||||
emergencyNoteEncrypted: toBase64(profileRow.emergencyNoteEncrypted),
|
||||
aboutMeEncrypted: toBase64(profileRow.aboutMeEncrypted),
|
||||
conditionsEncrypted: toBase64(profileRow.conditionsEncrypted),
|
||||
allergiesEncrypted: toBase64(profileRow.allergiesEncrypted),
|
||||
@@ -279,6 +313,21 @@ export async function buildProfileBackupSection(
|
||||
HealthProfileAiSection[] | undefined) ?? [
|
||||
...DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
|
||||
],
|
||||
emergencyBloodType: profileRow.emergencyBloodType ?? null,
|
||||
organDonorStatus: profileRow.organDonorStatus ?? null,
|
||||
advanceDirectiveStatus: profileRow.advanceDirectiveStatus ?? null,
|
||||
emergencyContacts: decryptProfileFieldSoft(
|
||||
profileRow.emergencyContactsEncrypted,
|
||||
"emergencyContacts",
|
||||
),
|
||||
emergencyImplants: decryptProfileFieldSoft(
|
||||
profileRow.emergencyImplantsEncrypted,
|
||||
"emergencyImplants",
|
||||
),
|
||||
emergencyNote: decryptProfileFieldSoft(
|
||||
profileRow.emergencyNoteEncrypted,
|
||||
"emergencyNote",
|
||||
),
|
||||
}
|
||||
: null;
|
||||
|
||||
@@ -640,6 +689,27 @@ export async function restoreProfileData(
|
||||
p.coachFocus,
|
||||
"coachFocus",
|
||||
),
|
||||
// Emergency profile. The enums restore by value (null clears);
|
||||
// the three free-text columns follow the same ciphertext-or-plaintext
|
||||
// resolution as the self-context columns above.
|
||||
emergencyBloodType: p.emergencyBloodType,
|
||||
organDonorStatus: p.organDonorStatus,
|
||||
advanceDirectiveStatus: p.advanceDirectiveStatus,
|
||||
emergencyContactsEncrypted: resolveProfileColumn(
|
||||
p.emergencyContactsEncrypted,
|
||||
p.emergencyContacts,
|
||||
"emergencyContacts",
|
||||
),
|
||||
emergencyImplantsEncrypted: resolveProfileColumn(
|
||||
p.emergencyImplantsEncrypted,
|
||||
p.emergencyImplants,
|
||||
"emergencyImplants",
|
||||
),
|
||||
emergencyNoteEncrypted: resolveProfileColumn(
|
||||
p.emergencyNoteEncrypted,
|
||||
p.emergencyNote,
|
||||
"emergencyNote",
|
||||
),
|
||||
// Portable exports do not carry the pending questions at all, so
|
||||
// `undefined` means "this file has nothing to say" and the account
|
||||
// comes back with no prompts pending — which the next profile save
|
||||
|
||||
@@ -65,6 +65,11 @@ import {
|
||||
healthProfileFactKindSchema,
|
||||
isHealthProfileFactValue,
|
||||
} from "@/lib/validations/health-profile-facts";
|
||||
import {
|
||||
advanceDirectiveStatusSchema,
|
||||
emergencyBloodTypeSchema,
|
||||
organDonorStatusSchema,
|
||||
} from "@/lib/validations/emergency-profile";
|
||||
|
||||
export const BACKUP_SCHEMA_VERSION = "2" as const;
|
||||
const LEGACY_BACKUP_SCHEMA_VERSION = "1" as const;
|
||||
@@ -529,6 +534,20 @@ const healthProfileBackupSchema = z
|
||||
aiIncludedSections: z
|
||||
.array(healthProfileAiSectionSchema)
|
||||
.default([...DEFAULT_HEALTH_PROFILE_AI_SECTIONS]),
|
||||
// Emergency profile: three plaintext enums (carried by value in both
|
||||
// purposes), three free-text columns following the ciphertext-or-plaintext
|
||||
// split of the self-context fields above.
|
||||
emergencyBloodType: emergencyBloodTypeSchema.nullable().default(null),
|
||||
organDonorStatus: organDonorStatusSchema.nullable().default(null),
|
||||
advanceDirectiveStatus: advanceDirectiveStatusSchema
|
||||
.nullable()
|
||||
.default(null),
|
||||
emergencyContacts: z.string().nullable().default(null),
|
||||
emergencyImplants: z.string().nullable().default(null),
|
||||
emergencyNote: z.string().nullable().default(null),
|
||||
emergencyContactsEncrypted: base64BytesSchema.nullable().optional(),
|
||||
emergencyImplantsEncrypted: base64BytesSchema.nullable().optional(),
|
||||
emergencyNoteEncrypted: base64BytesSchema.nullable().optional(),
|
||||
aboutMeEncrypted: base64BytesSchema.nullable().optional(),
|
||||
conditionsEncrypted: base64BytesSchema.nullable().optional(),
|
||||
allergiesEncrypted: base64BytesSchema.nullable().optional(),
|
||||
|
||||
@@ -24,6 +24,9 @@ async function seedProfile() {
|
||||
role: "USER",
|
||||
},
|
||||
});
|
||||
const emergencyContactsCiphertext = encryptToBytes(
|
||||
"ICE contact, reachable on the recorded number",
|
||||
);
|
||||
await prisma.userHealthProfile.create({
|
||||
data: {
|
||||
id: "profile-roundtrip-row",
|
||||
@@ -31,6 +34,13 @@ async function seedProfile() {
|
||||
aboutMeEncrypted: encryptToBytes("Works rotating shifts"),
|
||||
conditionsEncrypted: encryptToBytes("Asthma"),
|
||||
aiIncludedSections: ["CONDITIONS", "SMOKING_STATUS", "SHIFT_SCHEDULE"],
|
||||
// Emergency profile: three plaintext enums + three encrypted columns.
|
||||
emergencyBloodType: "O_NEG",
|
||||
organDonorStatus: "YES",
|
||||
advanceDirectiveStatus: "EXISTS",
|
||||
emergencyContactsEncrypted: emergencyContactsCiphertext,
|
||||
emergencyImplantsEncrypted: encryptToBytes("Pacemaker fitted 2021"),
|
||||
emergencyNoteEncrypted: encryptToBytes("Reacts to contrast dye"),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -68,13 +78,14 @@ async function seedProfile() {
|
||||
},
|
||||
});
|
||||
|
||||
return { user, currentCiphertext };
|
||||
return { user, currentCiphertext, emergencyContactsCiphertext };
|
||||
}
|
||||
|
||||
describe("health profile disaster-recovery round trip", () => {
|
||||
it("restores deleted encrypted profile rows and effective-dated history", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const { user, currentCiphertext } = await seedProfile();
|
||||
const { user, currentCiphertext, emergencyContactsCiphertext } =
|
||||
await seedProfile();
|
||||
const built = await buildFullBackupPayload(prisma, user.id, {
|
||||
purpose: "disaster-recovery",
|
||||
});
|
||||
@@ -116,6 +127,21 @@ describe("health profile disaster-recovery round trip", () => {
|
||||
"SHIFT_SCHEDULE",
|
||||
]);
|
||||
|
||||
// Every emergency column has to survive the round trip. The three enums
|
||||
// come back by value; the three encrypted columns come back as ciphertext,
|
||||
// and the contact column decrypts to exactly what was seeded. Dropping any
|
||||
// one of the six from the profile backup builder turns the matching
|
||||
// assertion red naming that column.
|
||||
expect(profile.emergencyBloodType).toBe("O_NEG");
|
||||
expect(profile.organDonorStatus).toBe("YES");
|
||||
expect(profile.advanceDirectiveStatus).toBe("EXISTS");
|
||||
expect(profile.emergencyContactsEncrypted).not.toBeNull();
|
||||
expect(profile.emergencyImplantsEncrypted).not.toBeNull();
|
||||
expect(profile.emergencyNoteEncrypted).not.toBeNull();
|
||||
expect(Buffer.from(profile.emergencyContactsEncrypted!)).toEqual(
|
||||
Buffer.from(emergencyContactsCiphertext),
|
||||
);
|
||||
|
||||
const revisions = await prisma.healthProfileFactRevision.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { validFrom: "asc" },
|
||||
|
||||
Reference in new issue
Block a user