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:
Marc-André Bombeck committed 2026-08-10 05:32:59 +02:00
1 parent 9e52285acc
commit 772da42348
5 files changed
+141 -2

No files matched your search

+19
View File
@@ -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) ─────
{
+70
View File
@@ -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
+19
View File
@@ -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(),