mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(backup): carry the health profile and custom metrics through export and restore
Both were classified as backed up when the backup plan was written and carried by nothing. The custom metric is live data loss: a series the account defined itself is the one kind no integration can re-sync, so a restore rebuilt the account without the metric and every one of its readings, and reported success. Builder and restore live in one file beside each other, the way the cycle pair does — a grep of the restore route alone does not find either, because the route delegates to both. Readings nest inside the metric that owns them rather than referencing it by name, so the restore has no parent to look up and no lookup to miss. A duplicate metric name and an undecodable ciphertext field both throw and name the value. Intake events whose medication did not restore now throw with the unresolved reference instead of being filtered away into a dose history that comes back short while the restore reports success.
This commit is contained in:
1 parent
ffe97d9ad9
commit
c6593b292f
15 files changed
+826
-7
No files matched your search
+1
-1
@@ -4499,7 +4499,7 @@
|
||||
},
|
||||
"fullBackup": {
|
||||
"title": "Voll-Backup",
|
||||
"description": "Deine Messwerte, Medikamente, Stimmung, Zyklus, Laborwerte, Krankheitsepisoden, Allergien, Familienanamnese, Nährstoffe und Workouts in einer JSON-Datei. Gleiche Form wie die wöchentliche automatische Sicherung. Einige Satzarten sind noch nicht enthalten — das Manifest der Datei nennt, was drinsteht.",
|
||||
"description": "Deine Messwerte, Medikamente, Stimmung, Zyklus, Laborwerte, Krankheitsepisoden, Allergien, Familienanamnese, Nährstoffe, Workouts, dein Gesundheitsprofil und deine eigenen Messgrößen in einer JSON-Datei. Gleiche Form wie die wöchentliche automatische Sicherung. Einige Satzarten sind noch nicht enthalten — das Manifest der Datei nennt, was drinsteht.",
|
||||
"scopeNote": "Dokumentmetadaten und die KI-Zusammenfassung, falls vorhanden, sind als Klartext enthalten; die Originaldateien der Dokumente sowie GPS-Routen und Einzelmesswerte von Workouts sind es nicht — lade diese separat aus dem Dokumententresor bzw. dem Workout-Export herunter.",
|
||||
"encryptToggle": "Mit einer Passphrase verschlüsseln",
|
||||
"passphrasePlaceholder": "Passphrase (mind. 12 Zeichen)",
|
||||
|
||||
+1
-1
@@ -4499,7 +4499,7 @@
|
||||
},
|
||||
"fullBackup": {
|
||||
"title": "Full backup",
|
||||
"description": "Your measurements, medications, mood, cycle, labs, illness episodes, allergies, family history, nutrients and workouts in one JSON file. Same shape as the weekly auto-backup. Some record types are not carried yet — the file's manifest names what is in it.",
|
||||
"description": "Your measurements, medications, mood, cycle, labs, illness episodes, allergies, family history, nutrients, workouts, health profile and custom metrics in one JSON file. Same shape as the weekly auto-backup. Some record types are not carried yet — the file's manifest names what is in it.",
|
||||
"scopeNote": "Document metadata and the AI-generated summary, if one exists, are included as plain text; original document files and workout GPS/sample data are not — download those separately from the vault or the workout export.",
|
||||
"encryptToggle": "Encrypt with a passphrase",
|
||||
"passphrasePlaceholder": "Passphrase (min. 12 characters)",
|
||||
|
||||
+1
-1
@@ -4499,7 +4499,7 @@
|
||||
},
|
||||
"fullBackup": {
|
||||
"title": "Copia completa",
|
||||
"description": "Tus mediciones, medicamentos, estado de ánimo, ciclo, analíticas, episodios de enfermedad, alergias, antecedentes familiares, nutrientes y entrenamientos en un archivo JSON. El mismo formato que la copia automática semanal. Algunos tipos de registro aún no se incluyen: el manifiesto del archivo indica qué contiene.",
|
||||
"description": "Tus mediciones, medicamentos, estado de ánimo, ciclo, analíticas, episodios de enfermedad, alergias, antecedentes familiares, nutrientes, entrenamientos, tu perfil de salud y tus métricas personalizadas en un archivo JSON. El mismo formato que la copia automática semanal. Algunos tipos de registro aún no se incluyen: el manifiesto del archivo indica qué contiene.",
|
||||
"scopeNote": "Los metadatos de los documentos y el resumen de IA, si existe, se incluyen en texto plano; los archivos originales de los documentos y los datos de rutas GPS/muestras de los entrenamientos no están incluidos — descárgalos por separado desde el archivo de documentos o la exportación de entrenamientos.",
|
||||
"encryptToggle": "Cifrar con una contraseña",
|
||||
"passphrasePlaceholder": "Contraseña (mín. 12 caracteres)",
|
||||
|
||||
+1
-1
@@ -4499,7 +4499,7 @@
|
||||
},
|
||||
"fullBackup": {
|
||||
"title": "Sauvegarde complète",
|
||||
"description": "Vos mesures, médicaments, humeur, cycle, analyses, épisodes de maladie, allergies, antécédents familiaux, nutriments et séances dans un seul fichier JSON. Même format que la sauvegarde automatique hebdomadaire. Certains types d'enregistrement ne sont pas encore inclus : le manifeste du fichier indique ce qu'il contient.",
|
||||
"description": "Vos mesures, médicaments, humeur, cycle, analyses, épisodes de maladie, allergies, antécédents familiaux, nutriments, séances, votre profil de santé et vos mesures personnalisées dans un seul fichier JSON. Même format que la sauvegarde automatique hebdomadaire. Certains types d'enregistrement ne sont pas encore inclus : le manifeste du fichier indique ce qu'il contient.",
|
||||
"scopeNote": "Les métadonnées des documents et le résumé IA, s'il existe, sont inclus en texte clair ; les fichiers originaux des documents ainsi que les tracés GPS/données d'échantillons des entraînements ne le sont pas — téléchargez-les séparément depuis le coffre de documents ou l'export des entraînements.",
|
||||
"encryptToggle": "Chiffrer avec une phrase secrète",
|
||||
"passphrasePlaceholder": "Phrase secrète (12 caractères min.)",
|
||||
|
||||
+1
-1
@@ -4499,7 +4499,7 @@
|
||||
},
|
||||
"fullBackup": {
|
||||
"title": "Backup completo",
|
||||
"description": "Le tue misurazioni, i farmaci, l'umore, il ciclo, le analisi, gli episodi di malattia, le allergie, l'anamnesi familiare, i nutrienti e gli allenamenti in un unico file JSON. Stesso formato del backup automatico settimanale. Alcuni tipi di dato non sono ancora inclusi: il manifest del file indica cosa contiene.",
|
||||
"description": "Le tue misurazioni, i farmaci, l'umore, il ciclo, le analisi, gli episodi di malattia, le allergie, l'anamnesi familiare, i nutrienti, gli allenamenti, il tuo profilo di salute e le tue metriche personalizzate in un unico file JSON. Stesso formato del backup automatico settimanale. Alcuni tipi di dato non sono ancora inclusi: il manifest del file indica cosa contiene.",
|
||||
"scopeNote": "I metadati dei documenti e il riepilogo IA, se presente, sono inclusi in chiaro; i file originali dei documenti e i dati di percorso GPS/campionamento degli allenamenti non lo sono — scaricali separatamente dall'archivio documenti o dall'esportazione degli allenamenti.",
|
||||
"encryptToggle": "Cifra con una passphrase",
|
||||
"passphrasePlaceholder": "Passphrase (min. 12 caratteri)",
|
||||
|
||||
+1
-1
@@ -4499,7 +4499,7 @@
|
||||
},
|
||||
"fullBackup": {
|
||||
"title": "Pełna kopia zapasowa",
|
||||
"description": "Twoje pomiary, leki, nastrój, cykl, wyniki badań, epizody chorobowe, alergie, wywiad rodzinny, składniki odżywcze i treningi w jednym pliku JSON. Ten sam kształt co cotygodniowa kopia automatyczna. Niektóre rodzaje wpisów nie są jeszcze przenoszone — manifest pliku wskazuje, co zawiera.",
|
||||
"description": "Twoje pomiary, leki, nastrój, cykl, wyniki badań, epizody chorobowe, alergie, wywiad rodzinny, składniki odżywcze, treningi, Twój profil zdrowia i własne wskaźniki w jednym pliku JSON. Ten sam kształt co cotygodniowa kopia automatyczna. Niektóre rodzaje wpisów nie są jeszcze przenoszone — manifest pliku wskazuje, co zawiera.",
|
||||
"scopeNote": "Metadane dokumentów oraz podsumowanie AI, jeśli istnieje, są uwzględnione jako czysty tekst; oryginalne pliki dokumentów oraz dane tras GPS/próbek treningów nie są uwzględnione — pobierz je osobno z archiwum dokumentów lub eksportu treningów.",
|
||||
"encryptToggle": "Zaszyfruj hasłem-frazą",
|
||||
"passphrasePlaceholder": "Hasło-fraza (min. 12 znaków)",
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from "@/lib/rollups/medication-compliance-rollups";
|
||||
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
|
||||
import { restoreCycleData } from "@/lib/cycle/backup";
|
||||
import { restoreProfileData } from "@/lib/export/profile-backup";
|
||||
import { invalidateUserData } from "@/lib/cache/invalidate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -64,6 +65,8 @@ interface RestoreResponse {
|
||||
familyHistory: number;
|
||||
workouts: number;
|
||||
documents: number;
|
||||
healthProfile: number;
|
||||
customMetrics: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -562,6 +565,32 @@ const handler = apiHandler(
|
||||
}
|
||||
|
||||
if (payload.intakeEvents.length > 0) {
|
||||
// An event whose medication did not come back used to be mapped to
|
||||
// null and filtered away: the dose history came back short and the
|
||||
// restore reported success. That is the same silent-drop shape the
|
||||
// cycle symptom links had, and it gets the same answer — name the
|
||||
// unresolved reference and stop, so the operator learns from the
|
||||
// error rather than from a compliance rate that quietly moved.
|
||||
const unresolvedMedications = [
|
||||
...new Set(
|
||||
payload.intakeEvents
|
||||
.filter(
|
||||
(e) =>
|
||||
!(
|
||||
e.medicationId &&
|
||||
restoredMedicationIds.has(e.medicationId)
|
||||
) && !medByName.has(e.medication),
|
||||
)
|
||||
.map((e) => e.medicationId ?? e.medication),
|
||||
),
|
||||
];
|
||||
if (unresolvedMedications.length > 0) {
|
||||
throw new Error(
|
||||
`Unknown medication references in intake events: ${unresolvedMedications.join(
|
||||
", ",
|
||||
)}. The backup records doses against medications it does not carry.`,
|
||||
);
|
||||
}
|
||||
const rows = payload.intakeEvents
|
||||
.map((e) => {
|
||||
const medId =
|
||||
@@ -718,6 +747,12 @@ const handler = apiHandler(
|
||||
// above. `notesEncrypted` is restored as ciphertext verbatim.
|
||||
const cycleCleared = await restoreCycleData(tx, ownerId, payload);
|
||||
|
||||
// Durable self-context + user-defined metrics. Both ends of this pair
|
||||
// live in `src/lib/export/profile-backup.ts` beside its builder — a
|
||||
// grep of THIS file alone will not find them, exactly as it does not
|
||||
// find the cycle restore one line above.
|
||||
const profileCleared = await restoreProfileData(tx, ownerId, payload);
|
||||
|
||||
const biomarkerByName = new Map<string, string>();
|
||||
const restoredBiomarkerIds = new Set<string>();
|
||||
for (const biomarker of payload.biomarkers) {
|
||||
@@ -1073,6 +1108,8 @@ const handler = apiHandler(
|
||||
familyHistory: familyHistory.count,
|
||||
workouts: workouts.count,
|
||||
documents: documents.count,
|
||||
healthProfile: profileCleared.healthProfile,
|
||||
customMetrics: profileCleared.customMetrics,
|
||||
};
|
||||
},
|
||||
{
|
||||
@@ -1197,6 +1234,9 @@ const handler = apiHandler(
|
||||
familyHistory: summary.familyHistory,
|
||||
workouts: summary.workouts,
|
||||
documents: summary.documents,
|
||||
healthProfile: summary.healthProfile,
|
||||
customMetrics: summary.customMetrics,
|
||||
customMetricEntries: summary.customMetricEntries,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -18,6 +18,10 @@ vi.mock("@/lib/db", () => ({
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
moodTag: { findMany: vi.fn() },
|
||||
nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// Durable self-context + user-defined metrics, read by
|
||||
// `buildProfileBackupSection`.
|
||||
userHealthProfile: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
customMetric: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// v1.15.0 — cycle tables read by the full-backup helper.
|
||||
cycleProfile: { findUnique: vi.fn() },
|
||||
menstrualCycle: { findMany: vi.fn() },
|
||||
|
||||
@@ -16,6 +16,10 @@ vi.mock("@/lib/db", () => ({
|
||||
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodTag: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// Durable self-context + user-defined metrics, read by
|
||||
// `buildProfileBackupSection`.
|
||||
userHealthProfile: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
customMetric: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
|
||||
@@ -117,6 +117,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
export_workout_count: counts.workouts,
|
||||
export_document_count: counts.documents,
|
||||
export_nutrient_day_count: counts.nutrientDays,
|
||||
export_health_profile_count: counts.healthProfile,
|
||||
export_custom_metric_count: counts.customMetrics,
|
||||
export_custom_metric_entry_count: counts.customMetricEntries,
|
||||
export_archive_bytes: archive.byteLength,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -76,6 +76,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
export_workout_count: counts.workouts,
|
||||
export_document_count: counts.documents,
|
||||
export_nutrient_day_count: counts.nutrientDays,
|
||||
export_health_profile_count: counts.healthProfile,
|
||||
export_custom_metric_count: counts.customMetrics,
|
||||
export_custom_metric_entry_count: counts.customMetricEntries,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -187,6 +187,49 @@ function makePrisma() {
|
||||
},
|
||||
]),
|
||||
},
|
||||
// Left unmocked on purpose: `buildProfileBackupSection` runs for real
|
||||
// against these, so the assertions below exercise the builder rather than
|
||||
// a stand-in that would agree with whatever the payload happened to do.
|
||||
userHealthProfile: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "profile-1",
|
||||
userId: "user-1",
|
||||
aboutMeEncrypted: encryptToBytes("desk job, two kids"),
|
||||
conditionsEncrypted: null,
|
||||
allergiesEncrypted: encryptToBytes("penicillin"),
|
||||
coachFocusEncrypted: null,
|
||||
pendingQuestionsEncrypted: encryptToBytes('["How is your sleep?"]'),
|
||||
createdAt: new Date("2026-07-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-07-19T00:00:00.000Z"),
|
||||
}),
|
||||
},
|
||||
customMetric: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "metric-1",
|
||||
userId: "user-1",
|
||||
name: "Morning grip strength",
|
||||
unit: "kg",
|
||||
targetLow: 40,
|
||||
targetHigh: null,
|
||||
decimals: 1,
|
||||
description: "Right hand, before breakfast",
|
||||
createdAt: new Date("2026-07-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-07-19T00:00:00.000Z"),
|
||||
deletedAt: null,
|
||||
entries: [
|
||||
{
|
||||
id: "entry-1",
|
||||
value: 44.5,
|
||||
unit: "kg",
|
||||
measuredAt: new Date("2026-07-19T06:30:00.000Z"),
|
||||
note: "felt strong",
|
||||
createdAt: new Date("2026-07-19T06:31:00.000Z"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -229,6 +272,19 @@ function installSectionMocks() {
|
||||
cycleProfile: null,
|
||||
cycles: [],
|
||||
cycleDayLogs: [],
|
||||
// The key the payload assembly used to drop on the floor.
|
||||
customSymptoms: [
|
||||
{
|
||||
id: "symptom-1",
|
||||
key: "custom:jaw_tension",
|
||||
labelKey: "custom",
|
||||
categoryId: "cat-1",
|
||||
icon: null,
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
labelEncrypted: "Y2lwaGVy",
|
||||
},
|
||||
],
|
||||
});
|
||||
mocks.buildRecordsBackupSection.mockResolvedValue({
|
||||
labResults: [],
|
||||
@@ -443,3 +499,180 @@ describe("buildFullBackupPayload — aggregation provenance", () => {
|
||||
expect(rows[0]).not.toHaveProperty("externalSourceVersion");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The assembly step must carry EVERY key its sections produce.
|
||||
*
|
||||
* The custom cycle symptoms were lost here and nowhere else: the section
|
||||
* builder returned them, the wire schema declared them, the restore read them,
|
||||
* and the payload was assembled by copying keys out one at a time. Nothing in
|
||||
* the tree compared the two lists, so the omission survived a release that was
|
||||
* specifically about that data.
|
||||
*
|
||||
* Mutation check: hand-pick the cycle keys again instead of spreading the
|
||||
* section, and this goes red naming the key that fell out.
|
||||
*/
|
||||
describe("buildFullBackupPayload — section keys reach the payload", () => {
|
||||
it("carries every key of every section it builds", async () => {
|
||||
installSectionMocks();
|
||||
const prisma = makePrisma();
|
||||
|
||||
const { payload } = await buildFullBackupPayload(
|
||||
prisma as never,
|
||||
"user-1",
|
||||
{
|
||||
purpose: "disaster-recovery",
|
||||
exportedAt: new Date("2026-07-20T00:00:00.000Z"),
|
||||
},
|
||||
);
|
||||
|
||||
const sectionKeys = [
|
||||
...Object.keys(await mocks.buildCycleBackupSection.mock.results[0].value),
|
||||
...Object.keys(
|
||||
await mocks.buildRecordsBackupSection.mock.results[0].value,
|
||||
),
|
||||
// The profile section runs for real, so its keys come from the builder.
|
||||
"healthProfile",
|
||||
"customMetrics",
|
||||
];
|
||||
|
||||
const missing = sectionKeys.filter((key) => !(key in payload));
|
||||
expect(
|
||||
missing,
|
||||
"a section key that never reaches the payload is data the file does not carry, however complete the builder is",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("carries the account's own cycle symptoms", async () => {
|
||||
installSectionMocks();
|
||||
const prisma = makePrisma();
|
||||
|
||||
const { payload } = await buildFullBackupPayload(
|
||||
prisma as never,
|
||||
"user-1",
|
||||
{
|
||||
purpose: "disaster-recovery",
|
||||
exportedAt: new Date("2026-07-20T00:00:00.000Z"),
|
||||
},
|
||||
);
|
||||
|
||||
expect(payload.customSymptoms).toEqual([
|
||||
expect.objectContaining({
|
||||
key: "custom:jaw_tension",
|
||||
labelEncrypted: "Y2lwaGVy",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Durable self-context and user-defined metrics ride the file.
|
||||
*
|
||||
* Both were classified `BACKED_UP` and carried by nothing. The custom metric is
|
||||
* the live loss: a series the account defined itself is the one kind no
|
||||
* integration can re-sync, so a restore without it rebuilt the account minus
|
||||
* the metric AND every reading, and reported success.
|
||||
*/
|
||||
describe("buildFullBackupPayload — profile and custom metrics", () => {
|
||||
it("carries ciphertext verbatim and no plaintext in the recovery payload", async () => {
|
||||
installSectionMocks();
|
||||
const prisma = makePrisma();
|
||||
|
||||
const { payload, counts } = await buildFullBackupPayload(
|
||||
prisma as never,
|
||||
"user-1",
|
||||
{
|
||||
purpose: "disaster-recovery",
|
||||
exportedAt: new Date("2026-07-20T00:00:00.000Z"),
|
||||
},
|
||||
);
|
||||
|
||||
const profile = payload.healthProfile as Record<string, unknown>;
|
||||
expect(profile.id).toBe("profile-1");
|
||||
// The envelope travels; the plaintext of a column carried encrypted must
|
||||
// not be duplicated beside it.
|
||||
expect(typeof profile.aboutMeEncrypted).toBe("string");
|
||||
expect(profile.aboutMe).toBeNull();
|
||||
expect(profile.conditionsEncrypted).toBeNull();
|
||||
expect(typeof profile.pendingQuestionsEncrypted).toBe("string");
|
||||
expect(counts.healthProfile).toBe(1);
|
||||
});
|
||||
|
||||
it("decrypts the profile free text into the portable export", async () => {
|
||||
installSectionMocks();
|
||||
const prisma = makePrisma();
|
||||
|
||||
const { payload } = await buildFullBackupPayload(
|
||||
prisma as never,
|
||||
"user-1",
|
||||
{
|
||||
purpose: "portable-export",
|
||||
exportedAt: new Date("2026-07-20T00:00:00.000Z"),
|
||||
},
|
||||
);
|
||||
|
||||
const profile = payload.healthProfile as Record<string, unknown>;
|
||||
expect(profile.aboutMe).toBe("desk job, two kids");
|
||||
expect(profile.allergies).toBe("penicillin");
|
||||
expect(profile.conditions).toBeNull();
|
||||
// A portable file is the human-readable artefact; it carries no envelopes,
|
||||
// and it does not carry the server-derived pending questions at all.
|
||||
expect(profile).not.toHaveProperty("aboutMeEncrypted");
|
||||
expect(profile).not.toHaveProperty("pendingQuestionsEncrypted");
|
||||
expect(profile).not.toHaveProperty("id");
|
||||
});
|
||||
|
||||
it("carries each metric with its readings nested inside it", async () => {
|
||||
installSectionMocks();
|
||||
const prisma = makePrisma();
|
||||
|
||||
const { payload, counts } = await buildFullBackupPayload(
|
||||
prisma as never,
|
||||
"user-1",
|
||||
{
|
||||
purpose: "disaster-recovery",
|
||||
exportedAt: new Date("2026-07-20T00:00:00.000Z"),
|
||||
},
|
||||
);
|
||||
|
||||
expect(payload.customMetrics).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "metric-1",
|
||||
name: "Morning grip strength",
|
||||
unit: "kg",
|
||||
targetLow: 40,
|
||||
targetHigh: null,
|
||||
decimals: 1,
|
||||
description: "Right hand, before breakfast",
|
||||
deletedAt: null,
|
||||
entries: [
|
||||
expect.objectContaining({
|
||||
id: "entry-1",
|
||||
value: 44.5,
|
||||
unit: "kg",
|
||||
measuredAt: "2026-07-19T06:30:00.000Z",
|
||||
note: "felt strong",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(counts.customMetrics).toBe(1);
|
||||
expect(counts.customMetricEntries).toBe(1);
|
||||
});
|
||||
|
||||
it("excludes deleted metrics from a portable export", async () => {
|
||||
installSectionMocks();
|
||||
const prisma = makePrisma();
|
||||
|
||||
await buildFullBackupPayload(prisma as never, "user-1", {
|
||||
purpose: "portable-export",
|
||||
exportedAt: new Date("2026-07-20T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(prisma.customMetric.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId: "user-1", deletedAt: null },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -25,8 +25,15 @@ import {
|
||||
type RecordsBackupCounts,
|
||||
type RecordsBackupSection,
|
||||
} from "@/lib/export/records-backup";
|
||||
import {
|
||||
buildProfileBackupSection,
|
||||
countProfileBackupSection,
|
||||
type ProfileBackupCounts,
|
||||
type ProfileBackupSection,
|
||||
} from "@/lib/export/profile-backup";
|
||||
|
||||
export interface FullBackupCounts extends RecordsBackupCounts {
|
||||
export interface FullBackupCounts
|
||||
extends RecordsBackupCounts, ProfileBackupCounts {
|
||||
measurements: number;
|
||||
medications: number;
|
||||
intakeEvents: number;
|
||||
@@ -99,6 +106,7 @@ export async function buildFullBackupPayload(
|
||||
customMoodTags,
|
||||
cycle,
|
||||
records,
|
||||
profile,
|
||||
nutrientDays,
|
||||
] = await Promise.all([
|
||||
disasterRecovery
|
||||
@@ -195,6 +203,13 @@ export async function buildFullBackupPayload(
|
||||
buildRecordsBackupSection(prisma, userId, {
|
||||
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
|
||||
}),
|
||||
// Durable self-context + user-defined metrics. Both were classified as
|
||||
// carried when the backup plan was written and carried by nothing; the
|
||||
// metrics are the live loss, since a series the person defined themselves
|
||||
// is the one kind no integration can re-sync.
|
||||
buildProfileBackupSection(prisma, userId, {
|
||||
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
|
||||
}),
|
||||
// Nutrient day totals were absent from every export path, which
|
||||
// contradicted the schema's own reason for denormalising the unit column
|
||||
// ("rows stay self-describing in exports even if the catalog ever drifts").
|
||||
@@ -218,6 +233,7 @@ export async function buildFullBackupPayload(
|
||||
// (or narrow) what reaches the wire.
|
||||
const cycleSection: CycleBackupSection = cycle;
|
||||
const recordsSection: RecordsBackupSection = records;
|
||||
const profileSection: ProfileBackupSection = profile;
|
||||
|
||||
const payload = {
|
||||
schemaVersion: BACKUP_SCHEMA_VERSION,
|
||||
@@ -362,6 +378,7 @@ export async function buildFullBackupPayload(
|
||||
// itself, not just in the export UI copy.
|
||||
...cycleSection,
|
||||
...recordsSection,
|
||||
...profileSection,
|
||||
nutrientDays: nutrientDays.map((n) => ({
|
||||
day: n.day,
|
||||
nutrient: n.nutrient,
|
||||
@@ -383,6 +400,7 @@ export async function buildFullBackupPayload(
|
||||
cycleDayLogs: cycle.cycleDayLogs.length,
|
||||
nutrientDays: nutrientDays.length,
|
||||
...countRecordsBackupSection(records),
|
||||
...countProfileBackupSection(profile),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* Durable self-context and user-defined metrics — both ends in one file.
|
||||
*
|
||||
* Two models classified `BACKED_UP` since `backup-plan.ts` was written and
|
||||
* carried by nothing: `UserHealthProfile` (what a person told the app is
|
||||
* durably true of them) and `CustomMetric` / `CustomMetricEntry` (a series the
|
||||
* person defined themselves, which by definition no integration can re-sync).
|
||||
* The custom-metric gap is live data loss today: a restore rebuilt the account
|
||||
* without the metric or a single one of its readings and reported success.
|
||||
*
|
||||
* The builder and the restore live together deliberately, the way
|
||||
* `src/lib/cycle/backup.ts` does. A reader asking "is this carried at both
|
||||
* ends?" answers it in one file, and — the reason it matters — a reader who
|
||||
* greps only the restore ROUTE gets a false negative, because the route
|
||||
* delegates. That mistake has already been made once about the cycle data and
|
||||
* had to be retracted; keeping the pair adjacent is what makes the answer
|
||||
* cheap enough to get right.
|
||||
*
|
||||
* Encrypted columns follow the established split. A portable export is the
|
||||
* human-readable artefact, so the profile's free text is decrypted into it. A
|
||||
* disaster-recovery payload carries the AES-256-GCM envelope verbatim as
|
||||
* base64 and never decrypts, so the same instance's key reads it back
|
||||
* unchanged and no plaintext is duplicated into the file.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
|
||||
export interface ProfileBackupOptions {
|
||||
purpose?: "portable-export" | "disaster-recovery";
|
||||
}
|
||||
|
||||
/**
|
||||
* The account's durable self-context. One row per user, so there is no
|
||||
* reference to resolve on the way back in — the owner is the key.
|
||||
*/
|
||||
export interface HealthProfileBackupEntry {
|
||||
/** Present in canonical DR payloads so the row keeps a stable identity. */
|
||||
id?: string;
|
||||
/**
|
||||
* Portable exports carry the decrypted text; DR payloads carry `null` here
|
||||
* and the ciphertext below. Never both — a DR file must not duplicate the
|
||||
* plaintext of a column it is already carrying encrypted.
|
||||
*/
|
||||
aboutMe: string | null;
|
||||
conditions: string | null;
|
||||
allergies: string | null;
|
||||
coachFocus: string | null;
|
||||
/** Base64 of the already-encrypted BYTEA value. DR payloads only. */
|
||||
aboutMeEncrypted?: string | null;
|
||||
conditionsEncrypted?: string | null;
|
||||
allergiesEncrypted?: string | null;
|
||||
coachFocusEncrypted?: string | null;
|
||||
/**
|
||||
* Server-derived clarifying questions awaiting an answer, encrypted JSON.
|
||||
*
|
||||
* DR only, and deliberately: they are not the person's own words, they are
|
||||
* regenerated after the next profile save, and a portable export that is
|
||||
* meant to be readable gains nothing from an opaque envelope. Carried in the
|
||||
* canonical payload because a DR restore should land the account in the
|
||||
* state it was in, pending prompts included.
|
||||
*/
|
||||
pendingQuestionsEncrypted?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
/** One reading of a user-defined metric. */
|
||||
export interface CustomMetricEntryBackupEntry {
|
||||
/** Present in canonical DR payloads so the row keeps a stable identity. */
|
||||
id?: string;
|
||||
value: number;
|
||||
/** Unit snapshot taken from the metric at write time, not a live join. */
|
||||
unit: string;
|
||||
measuredAt: string;
|
||||
note: string | null;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A metric the account defined, with its readings nested inside it.
|
||||
*
|
||||
* Nested rather than a flat array keyed by metric name, and that is a
|
||||
* deliberate difference from the intake-event section next door. A flat array
|
||||
* needs the restore to resolve every row's parent by a natural key, which is
|
||||
* the exact shape that produces a lookup miss — and a lookup miss is a row
|
||||
* that either vanishes quietly or stops the restore. Nesting removes the
|
||||
* reference, so an entry cannot dangle and there is nothing to drop.
|
||||
*/
|
||||
export interface CustomMetricBackupEntry {
|
||||
/** Present in canonical DR payloads so the row keeps a stable identity. */
|
||||
id?: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
targetLow: number | null;
|
||||
targetHigh: number | null;
|
||||
decimals: number | null;
|
||||
description: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
/** Tombstone, DR payloads only — a portable export never carries deleted rows. */
|
||||
deletedAt?: string | null;
|
||||
entries: CustomMetricEntryBackupEntry[];
|
||||
}
|
||||
|
||||
export interface ProfileBackupSection {
|
||||
healthProfile: HealthProfileBackupEntry | null;
|
||||
customMetrics: CustomMetricBackupEntry[];
|
||||
}
|
||||
|
||||
export interface ProfileBackupCounts {
|
||||
healthProfile: number;
|
||||
customMetrics: number;
|
||||
customMetricEntries: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt one profile field fail-soft.
|
||||
*
|
||||
* Fail-soft here and NOT on the restore side, which is the asymmetry that
|
||||
* matters: a field this instance can no longer read is already lost, and
|
||||
* refusing to build the rest of the backup over it would turn one unreadable
|
||||
* column into no backup at all. The warning rides the wide event so the
|
||||
* operator sees it. On the way back in, by contrast, an unresolvable
|
||||
* reference throws — absence must read as absence, not as a quiet drop.
|
||||
*/
|
||||
function decryptProfileFieldSoft(
|
||||
buf: Uint8Array | null,
|
||||
field: string,
|
||||
): string | null {
|
||||
if (!buf || buf.byteLength === 0) return null;
|
||||
try {
|
||||
return decryptFromBytes(buf);
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
`health profile ${field} decrypt failed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function toBase64(buf: Uint8Array | null): string | null {
|
||||
return buf && buf.byteLength > 0 ? Buffer.from(buf).toString("base64") : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the profile + custom-metric slice of a user's full backup.
|
||||
*
|
||||
* Takes the delegates it uses rather than a whole `PrismaClient` so the route's
|
||||
* global client and the worker's local one share one read, matching
|
||||
* `buildCycleBackupSection` and `buildRecordsBackupSection`.
|
||||
*/
|
||||
export async function buildProfileBackupSection(
|
||||
prisma: Pick<PrismaClient, "userHealthProfile" | "customMetric">,
|
||||
userId: string,
|
||||
options: ProfileBackupOptions = {},
|
||||
): Promise<ProfileBackupSection> {
|
||||
const disasterRecovery = options.purpose === "disaster-recovery";
|
||||
|
||||
const [profileRow, metricRows] = await Promise.all([
|
||||
prisma.userHealthProfile.findUnique({ where: { userId } }),
|
||||
prisma.customMetric.findMany({
|
||||
// A portable export never resurrects a tombstone; a DR payload restores
|
||||
// the account exactly as it stood, deletions included.
|
||||
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
|
||||
orderBy: { name: "asc" },
|
||||
include: {
|
||||
// `CustomMetricEntry` carries no tombstone of its own — a deleted
|
||||
// metric cascades its readings away — so both purposes read the same
|
||||
// set and only the parent filter differs.
|
||||
entries: { orderBy: { measuredAt: "asc" } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const healthProfile: HealthProfileBackupEntry | null = profileRow
|
||||
? disasterRecovery
|
||||
? {
|
||||
id: profileRow.id,
|
||||
aboutMe: null,
|
||||
conditions: null,
|
||||
allergies: null,
|
||||
coachFocus: null,
|
||||
aboutMeEncrypted: toBase64(profileRow.aboutMeEncrypted),
|
||||
conditionsEncrypted: toBase64(profileRow.conditionsEncrypted),
|
||||
allergiesEncrypted: toBase64(profileRow.allergiesEncrypted),
|
||||
coachFocusEncrypted: toBase64(profileRow.coachFocusEncrypted),
|
||||
pendingQuestionsEncrypted: toBase64(
|
||||
profileRow.pendingQuestionsEncrypted,
|
||||
),
|
||||
createdAt: profileRow.createdAt.toISOString(),
|
||||
updatedAt: profileRow.updatedAt.toISOString(),
|
||||
}
|
||||
: {
|
||||
aboutMe: decryptProfileFieldSoft(
|
||||
profileRow.aboutMeEncrypted,
|
||||
"aboutMe",
|
||||
),
|
||||
conditions: decryptProfileFieldSoft(
|
||||
profileRow.conditionsEncrypted,
|
||||
"conditions",
|
||||
),
|
||||
allergies: decryptProfileFieldSoft(
|
||||
profileRow.allergiesEncrypted,
|
||||
"allergies",
|
||||
),
|
||||
coachFocus: decryptProfileFieldSoft(
|
||||
profileRow.coachFocusEncrypted,
|
||||
"coachFocus",
|
||||
),
|
||||
}
|
||||
: null;
|
||||
|
||||
const customMetrics: CustomMetricBackupEntry[] = metricRows.map((metric) => ({
|
||||
...(disasterRecovery
|
||||
? {
|
||||
id: metric.id,
|
||||
createdAt: metric.createdAt.toISOString(),
|
||||
updatedAt: metric.updatedAt.toISOString(),
|
||||
deletedAt: metric.deletedAt?.toISOString() ?? null,
|
||||
}
|
||||
: {}),
|
||||
name: metric.name,
|
||||
unit: metric.unit,
|
||||
targetLow: metric.targetLow,
|
||||
targetHigh: metric.targetHigh,
|
||||
decimals: metric.decimals,
|
||||
description: metric.description,
|
||||
entries: metric.entries.map((entry) => ({
|
||||
...(disasterRecovery
|
||||
? {
|
||||
id: entry.id,
|
||||
createdAt: entry.createdAt.toISOString(),
|
||||
}
|
||||
: {}),
|
||||
value: entry.value,
|
||||
unit: entry.unit,
|
||||
measuredAt: entry.measuredAt.toISOString(),
|
||||
note: entry.note,
|
||||
})),
|
||||
}));
|
||||
|
||||
return { healthProfile, customMetrics };
|
||||
}
|
||||
|
||||
/** Row counts for the audit trail, mirroring the other section counters. */
|
||||
export function countProfileBackupSection(
|
||||
section: ProfileBackupSection,
|
||||
): ProfileBackupCounts {
|
||||
return {
|
||||
healthProfile: section.healthProfile ? 1 : 0,
|
||||
customMetrics: section.customMetrics.length,
|
||||
customMetricEntries: section.customMetrics.reduce(
|
||||
(sum, metric) => sum + metric.entries.length,
|
||||
0,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Counts the profile restore wiped, for the audit trail. */
|
||||
export interface ProfileRestoreCleared {
|
||||
healthProfile: number;
|
||||
customMetrics: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The slice of a parsed backup this restore consumes.
|
||||
*
|
||||
* Both fields are REQUIRED rather than optional. `BackupPayload` defaults them,
|
||||
* so every caller already satisfies this — and a caller that stops satisfying
|
||||
* it fails to compile instead of passing `undefined` into a loop that iterates
|
||||
* zero times and reports success.
|
||||
*/
|
||||
export interface ProfileRestoreInput {
|
||||
healthProfile: HealthProfileBackupEntry | null;
|
||||
customMetrics: CustomMetricBackupEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a base64 ciphertext field, refusing a value that is not decodable
|
||||
* rather than writing an envelope no key can ever open.
|
||||
*/
|
||||
function decodeEncryptedField(
|
||||
encoded: string,
|
||||
field: string,
|
||||
): Uint8Array<ArrayBuffer> {
|
||||
const decoded = Buffer.from(encoded, "base64");
|
||||
if (decoded.byteLength === 0) {
|
||||
throw new Error(
|
||||
`Health profile field '${field}' carries an unreadable ciphertext value. ` +
|
||||
"Restoring it would write an envelope no key can open, which reads as " +
|
||||
"a present-but-empty answer rather than as the missing one it is.",
|
||||
);
|
||||
}
|
||||
const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength));
|
||||
bytes.set(decoded);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one encrypted profile column from the payload.
|
||||
*
|
||||
* Mirrors the lab-note contract: ciphertext wins when the DR payload carried
|
||||
* it, plaintext is re-encrypted when a portable export carried that instead,
|
||||
* and an absent column comes back absent.
|
||||
*/
|
||||
function resolveProfileColumn(
|
||||
ciphertext: string | null | undefined,
|
||||
plaintext: string | null,
|
||||
field: string,
|
||||
): Uint8Array<ArrayBuffer> | null {
|
||||
if (ciphertext !== undefined) {
|
||||
return ciphertext === null ? null : decodeEncryptedField(ciphertext, field);
|
||||
}
|
||||
return plaintext == null ? null : encryptToBytes(plaintext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-create the account's self-context and user-defined metrics.
|
||||
*
|
||||
* Delete-then-recreate inside the caller's transaction, matching every other
|
||||
* section. The metric delete cascades its entries, so the readings are wiped
|
||||
* and rebuilt with their parent rather than orphaned against it.
|
||||
*/
|
||||
export async function restoreProfileData(
|
||||
tx: Prisma.TransactionClient,
|
||||
ownerId: string,
|
||||
payload: ProfileRestoreInput,
|
||||
): Promise<ProfileRestoreCleared> {
|
||||
const profileCleared = await tx.userHealthProfile.deleteMany({
|
||||
where: { userId: ownerId },
|
||||
});
|
||||
const metricsCleared = await tx.customMetric.deleteMany({
|
||||
where: { userId: ownerId },
|
||||
});
|
||||
|
||||
if (payload.healthProfile) {
|
||||
const p = payload.healthProfile;
|
||||
await tx.userHealthProfile.create({
|
||||
data: {
|
||||
...(p.id ? { id: p.id } : {}),
|
||||
userId: ownerId,
|
||||
aboutMeEncrypted: resolveProfileColumn(
|
||||
p.aboutMeEncrypted,
|
||||
p.aboutMe,
|
||||
"aboutMe",
|
||||
),
|
||||
conditionsEncrypted: resolveProfileColumn(
|
||||
p.conditionsEncrypted,
|
||||
p.conditions,
|
||||
"conditions",
|
||||
),
|
||||
allergiesEncrypted: resolveProfileColumn(
|
||||
p.allergiesEncrypted,
|
||||
p.allergies,
|
||||
"allergies",
|
||||
),
|
||||
coachFocusEncrypted: resolveProfileColumn(
|
||||
p.coachFocusEncrypted,
|
||||
p.coachFocus,
|
||||
"coachFocus",
|
||||
),
|
||||
// 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
|
||||
// regenerates. Only a DR payload can restore them.
|
||||
pendingQuestionsEncrypted:
|
||||
p.pendingQuestionsEncrypted == null
|
||||
? null
|
||||
: decodeEncryptedField(
|
||||
p.pendingQuestionsEncrypted,
|
||||
"pendingQuestions",
|
||||
),
|
||||
...(p.createdAt ? { createdAt: new Date(p.createdAt) } : {}),
|
||||
...(p.updatedAt ? { updatedAt: new Date(p.updatedAt) } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// `(userId, name)` is unique, so two metrics sharing a name in one file
|
||||
// would collide on insert and abort the transaction with a constraint error
|
||||
// that names a column rather than the problem. Say what is wrong instead.
|
||||
const seenNames = new Set<string>();
|
||||
for (const metric of payload.customMetrics) {
|
||||
if (seenNames.has(metric.name)) {
|
||||
throw new Error(
|
||||
`Duplicate custom metric name: ${metric.name}. ` +
|
||||
"A backup cannot carry two metrics with the same name for one " +
|
||||
"account, and picking a winner here would silently discard the " +
|
||||
"readings of the other.",
|
||||
);
|
||||
}
|
||||
seenNames.add(metric.name);
|
||||
|
||||
await tx.customMetric.create({
|
||||
data: {
|
||||
...(metric.id ? { id: metric.id } : {}),
|
||||
userId: ownerId,
|
||||
name: metric.name,
|
||||
unit: metric.unit,
|
||||
targetLow: metric.targetLow,
|
||||
targetHigh: metric.targetHigh,
|
||||
decimals: metric.decimals,
|
||||
description: metric.description,
|
||||
...(metric.createdAt ? { createdAt: new Date(metric.createdAt) } : {}),
|
||||
...(metric.updatedAt ? { updatedAt: new Date(metric.updatedAt) } : {}),
|
||||
deletedAt: metric.deletedAt ? new Date(metric.deletedAt) : null,
|
||||
entries: {
|
||||
create: metric.entries.map((entry) => ({
|
||||
...(entry.id ? { id: entry.id } : {}),
|
||||
userId: ownerId,
|
||||
value: entry.value,
|
||||
unit: entry.unit,
|
||||
measuredAt: new Date(entry.measuredAt),
|
||||
note: entry.note,
|
||||
...(entry.createdAt
|
||||
? { createdAt: new Date(entry.createdAt) }
|
||||
: {}),
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
healthProfile: profileCleared.count,
|
||||
customMetrics: metricsCleared.count,
|
||||
};
|
||||
}
|
||||
@@ -390,6 +390,70 @@ const customCycleSymptomSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/**
|
||||
* The account's durable self-context — one row per user.
|
||||
*
|
||||
* Portable exports carry the decrypted free text; disaster-recovery payloads
|
||||
* carry the AES-256-GCM envelopes as base64 and leave the plaintext fields
|
||||
* null. The restore prefers ciphertext when the file has it and re-encrypts
|
||||
* the plaintext otherwise, which is the same contract the lab note uses.
|
||||
*/
|
||||
const healthProfileBackupSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).optional(),
|
||||
aboutMe: z.string().nullable().default(null),
|
||||
conditions: z.string().nullable().default(null),
|
||||
allergies: z.string().nullable().default(null),
|
||||
coachFocus: z.string().nullable().default(null),
|
||||
aboutMeEncrypted: base64BytesSchema.nullable().optional(),
|
||||
conditionsEncrypted: base64BytesSchema.nullable().optional(),
|
||||
allergiesEncrypted: base64BytesSchema.nullable().optional(),
|
||||
coachFocusEncrypted: base64BytesSchema.nullable().optional(),
|
||||
pendingQuestionsEncrypted: base64BytesSchema.nullable().optional(),
|
||||
createdAt: isoDateTime.optional(),
|
||||
updatedAt: isoDateTime.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/** One reading of a user-defined metric, nested under the metric that owns it. */
|
||||
const customMetricEntryBackupSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).optional(),
|
||||
value: z.number(),
|
||||
unit: z.string().min(1),
|
||||
measuredAt: isoDateTime,
|
||||
note: z.string().nullable().default(null),
|
||||
createdAt: isoDateTime.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/**
|
||||
* A metric the account defined itself.
|
||||
*
|
||||
* The one class of series no integration can ever re-sync: nobody else has the
|
||||
* definition and nobody else has the readings. It was classified as carried
|
||||
* from the day the backup plan was written and carried by nothing, so a
|
||||
* restore rebuilt the account without it and reported success.
|
||||
*
|
||||
* Readings are nested rather than flat-with-a-name-reference so the restore
|
||||
* has no parent to look up and therefore no lookup to miss.
|
||||
*/
|
||||
const customMetricBackupSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).optional(),
|
||||
name: z.string().min(1),
|
||||
unit: z.string().min(1),
|
||||
targetLow: z.number().nullable().default(null),
|
||||
targetHigh: z.number().nullable().default(null),
|
||||
decimals: z.number().int().nullable().default(null),
|
||||
description: z.string().nullable().default(null),
|
||||
createdAt: isoDateTime.optional(),
|
||||
updatedAt: isoDateTime.optional(),
|
||||
deletedAt: isoDateTime.nullable().optional(),
|
||||
entries: z.array(customMetricEntryBackupSchema).default([]),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const appSettingsBackupSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
@@ -652,6 +716,11 @@ export const backupPayloadSchema = z
|
||||
workouts: z.array(workoutBackupSchema).default([]),
|
||||
documents: z.array(documentBackupSchema).default([]),
|
||||
nutrientDays: z.array(nutrientDaySchema).default([]),
|
||||
// Durable self-context and user-defined series. Defaulted so files written
|
||||
// before either rode the wire still parse; an account with neither writes
|
||||
// `null` / `[]`.
|
||||
healthProfile: healthProfileBackupSchema.nullable().default(null),
|
||||
customMetrics: z.array(customMetricBackupSchema).default([]),
|
||||
manifest: backupManifestSchema.nullable().default(null),
|
||||
})
|
||||
.passthrough()
|
||||
@@ -704,6 +773,12 @@ export interface BackupSummary {
|
||||
workouts: number;
|
||||
/** Document records (ciphertext included in canonical DR payloads). */
|
||||
documents: number;
|
||||
/** 1 when the account's durable self-context rides the file, 0 otherwise. */
|
||||
healthProfile: number;
|
||||
/** Metrics the account defined itself. */
|
||||
customMetrics: number;
|
||||
/** Readings across every user-defined metric. */
|
||||
customMetricEntries: number;
|
||||
}
|
||||
|
||||
export function summarizeBackup(payload: BackupPayload): BackupSummary {
|
||||
@@ -729,6 +804,12 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary {
|
||||
familyHistory: payload.familyHistory.length,
|
||||
workouts: payload.workouts.length,
|
||||
documents: payload.documents.length,
|
||||
healthProfile: payload.healthProfile ? 1 : 0,
|
||||
customMetrics: payload.customMetrics.length,
|
||||
customMetricEntries: payload.customMetrics.reduce(
|
||||
(sum, metric) => sum + metric.entries.length,
|
||||
0,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user