diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c271ca188..bba317fee 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -40862,6 +40862,11 @@ components: category: type: string maxLength: 64 + categoryUpdatedAt: + anyOf: + - type: string + minLength: 1 + - type: "null" deliveryForm: type: string enum: @@ -41138,6 +41143,9 @@ components: createdAt: type: string minLength: 1 + updatedAt: + type: string + minLength: 1 required: - nutrient - amountPerUnit @@ -41492,9 +41500,17 @@ components: - type: string maxLength: 280 - type: "null" + noteEncrypted: + anyOf: + - type: string + minLength: 1 + - type: "null" createdAt: type: string minLength: 1 + updatedAt: + type: string + minLength: 1 required: - startsOn - endsOn @@ -41522,9 +41538,14 @@ components: type: string pattern: ^custom:[0-9a-f-]{36}$ label: + anyOf: + - type: string + minLength: 1 + maxLength: 40 + - type: "null" + labelEncrypted: type: string minLength: 1 - maxLength: 40 sortOrder: default: 0 type: integer @@ -41536,9 +41557,11 @@ components: createdAt: type: string minLength: 1 + updatedAt: + type: string + minLength: 1 required: - key - - label - sortOrder - isActive additionalProperties: {} @@ -41749,6 +41772,21 @@ components: items: type: string minLength: 1 + linkCreatedAt: + type: array + items: + type: object + properties: + key: + type: string + minLength: 1 + createdAt: + type: string + minLength: 1 + required: + - key + - createdAt + additionalProperties: false context: anyOf: - type: object @@ -41851,6 +41889,12 @@ components: - type: string minLength: 1 - type: "null" + createdAt: + type: string + minLength: 1 + updatedAt: + type: string + minLength: 1 additionalProperties: {} - type: "null" required: @@ -42214,6 +42258,21 @@ components: required: - key additionalProperties: {} + symptomLinkCreatedAt: + type: array + items: + type: object + properties: + key: + type: string + minLength: 1 + createdAt: + type: string + minLength: 1 + required: + - key + - createdAt + additionalProperties: false required: - date - symptomKeys @@ -42515,6 +42574,11 @@ components: type: - string - "null" + contextEncrypted: + anyOf: + - type: string + minLength: 1 + - type: "null" aliases: type: array items: @@ -42705,6 +42769,9 @@ components: minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" + createdAt: + type: string + minLength: 1 required: - key additionalProperties: {} @@ -43381,6 +43448,9 @@ components: createdAt: type: string minLength: 1 + updatedAt: + type: string + minLength: 1 required: - day - nutrient diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index d977290b1..d984004ea 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 411, - "total": 148227, + "total": 148208, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -305,7 +305,7 @@ "lib/api-handler.ts :: apiHandler > wrapped": 201, "lib/auth/profile-update.ts :: applyProfileUpdate": 353, "lib/cache/server-cache.ts :: createServerCaches": 230, - "lib/cycle/backup.ts :: restoreCycleData": 243, + "lib/cycle/backup.ts :: restoreCycleData": 224, "lib/cycle/day-log-write.ts :: writeDayLog": 311, "lib/daily/digest.ts :: buildDailyDigest": 227, "lib/daily/load-digest.ts :: loadDailyDigest": 452, diff --git a/src/__tests__/mood-backup-completeness.test.ts b/src/__tests__/mood-backup-completeness.test.ts index 050953e4f..041112000 100644 --- a/src/__tests__/mood-backup-completeness.test.ts +++ b/src/__tests__/mood-backup-completeness.test.ts @@ -55,18 +55,12 @@ const EXCLUDED_MOOD_CONTEXT_COLUMNS: Record = { moodEntryId: "re-bound to the restored parent row's id; the context rides inside its entry", userId: "user-scoped backup; owner is re-derived from the restoring account", - createdAt: - "the sub-row's own bookkeeping stamp; the entry carries the moment the person logged the day, and the context has nothing separate to say about when it was typed", - updatedAt: - "the sub-row's own bookkeeping stamp, moved by every write; a restore re-stamps it and nothing reads the old value", }; const EXCLUDED_TAG_LINK_COLUMNS: Record = { moodEntryId: "re-bound to the restored parent row's id, never carried", moodTagId: "the link travels by the tag's key; a source-instance id need not exist in the target catalogue", - createdAt: - "the link's own row stamp; the entry carries the moment the person logged it, and a key-only BINARY tag has nowhere to put a second one", }; function modelBlock(schema: string, model: string): string { diff --git a/src/lib/cycle/__tests__/backup.test.ts b/src/lib/cycle/__tests__/backup.test.ts index cd7318a86..88d644c62 100644 --- a/src/lib/cycle/__tests__/backup.test.ts +++ b/src/lib/cycle/__tests__/backup.test.ts @@ -92,8 +92,16 @@ describe("buildCycleBackupSection disaster-recovery mode", () => { updatedAt, // One symptom the person put a number on, one they did not. symptomLinks: [ - { severity: 4, symptom: { key: "cramps" } }, - { severity: null, symptom: { key: "fatigue" } }, + { + severity: 4, + symptom: { key: "cramps" }, + createdAt: new Date("2026-04-10T07:00:00.000Z"), + }, + { + severity: null, + symptom: { key: "fatigue" }, + createdAt: new Date("2026-04-10T07:00:00.000Z"), + }, ], }, ]), @@ -171,6 +179,11 @@ describe("buildCycleBackupSection disaster-recovery mode", () => { // Only the rated link is listed. An unrated one is left out rather than // written as a zero, so the file says "never rated" and not "rated none". symptomSeverities: [{ key: "cramps", severity: 4 }], + // When each link was written, every link, disaster recovery only. + symptomLinkCreatedAt: [ + { key: "cramps", createdAt: "2026-04-10T07:00:00.000Z" }, + { key: "fatigue", createdAt: "2026-04-10T07:00:00.000Z" }, + ], }); }); }); diff --git a/src/lib/cycle/backup-schema.ts b/src/lib/cycle/backup-schema.ts index 4dae4e9ca..66dd41e35 100644 --- a/src/lib/cycle/backup-schema.ts +++ b/src/lib/cycle/backup-schema.ts @@ -122,6 +122,9 @@ export const cycleDayLogSchema = z updatedAt: isoDateTime.optional(), symptomKeys: z.array(z.string()).default([]), symptomSeverities: z.array(cycleSymptomSeveritySchema).default([]), + symptomLinkCreatedAt: z + .array(z.object({ key: z.string().min(1), createdAt: isoDateTime })) + .optional(), }) .passthrough(); /** Cycle-tracking preferences (one row per user). */ diff --git a/src/lib/cycle/backup.ts b/src/lib/cycle/backup.ts index c04859d87..6bf55b3d4 100644 --- a/src/lib/cycle/backup.ts +++ b/src/lib/cycle/backup.ts @@ -154,6 +154,12 @@ export interface CycleBackupSection { * never rated it — which is not the same as rating it zero. */ symptomSeverities: Array<{ key: string; severity: number }>; + /** + * When each symptom link was written, by key. Disaster-recovery files + * only, and sparse like `symptomSeverities`; a file without it restores + * the links stamped with the restore. + */ + symptomLinkCreatedAt?: Array<{ key: string; createdAt: string }>; }>; /** * The account's OWN symptom definitions. @@ -329,6 +335,14 @@ export async function buildCycleBackupSection( symptomSeverities: d.symptomLinks .filter((l): l is typeof l & { severity: number } => l.severity != null) .map((l) => ({ key: l.symptom.key, severity: l.severity })), + ...(disasterRecovery + ? { + symptomLinkCreatedAt: d.symptomLinks.map((l) => ({ + key: l.symptom.key, + createdAt: l.createdAt.toISOString(), + })), + } + : {}), })), customSymptoms: customSymptoms.map((sym) => ({ ...(disasterRecovery ? { id: sym.id } : {}), @@ -445,6 +459,50 @@ function enumOrNull( : null; } +/** + * The symptom links of one restored day log. + * + * Severities: the day's rated intensities, keyed by symptom. A file written + * before severities were carried has no list at all and every link comes back + * unrated, which is what it was. An entry naming a symptom the day does not + * have is simply never read. Each link's own write time comes back when the + * file carries it (disaster recovery), and is the restore's otherwise. + * + * Every unresolvable key was counted into `skips` by the caller, so dropping + * it here is a reported drop rather than a silent one. The rest of the day is + * unaffected by a symptom chip that will not resolve. + */ +function dayLogSymptomLinks( + d: { + symptomKeys?: string[]; + symptomSeverities?: Array<{ key: string; severity?: number | null }>; + symptomLinkCreatedAt?: Array<{ key: string; createdAt: string }>; + }, + symptomIdByKey: ReadonlyMap, +) { + const severityByKey = new Map(); + for (const s of d.symptomSeverities ?? []) { + const severity = severityOrNull(s.severity); + if (severity !== null) severityByKey.set(s.key, severity); + } + const linkCreatedAt = new Map( + (d.symptomLinkCreatedAt ?? []).map((l) => [l.key, l.createdAt]), + ); + return (d.symptomKeys ?? []).flatMap((k) => { + const id = symptomIdByKey.get(k); + const createdAt = linkCreatedAt.get(k); + return id + ? [ + { + symptomId: id, + severity: severityByKey.get(k) ?? null, + ...(createdAt ? { createdAt: new Date(createdAt) } : {}), + }, + ] + : []; + }); +} + /** * Restore the cycle tables for `ownerId` from a parsed backup payload, * inside an existing transaction. Delete-then-recreate, matching the @@ -617,26 +675,7 @@ export async function restoreCycleData( }; for (const d of payload.cycleDayLogs) { - // The day's rated intensities, keyed by symptom. A file written before - // severities were carried has no list at all and every link comes back - // unrated, which is what it was. An entry naming a symptom the day does not - // have is simply never read. - const severityByKey = new Map(); - for (const s of d.symptomSeverities ?? []) { - const severity = severityOrNull(s.severity); - if (severity !== null) severityByKey.set(s.key, severity); - } - - // Every unresolvable key was counted into `skips` above, so dropping it - // here is a reported drop rather than a silent one. The rest of the day — - // flow, temperature, the encrypted note, every observation the person - // actually wrote — is unaffected by a symptom chip that will not resolve. - const symptomLinks = (d.symptomKeys ?? []).flatMap((k) => { - const id = symptomIdByKey.get(k); - return id - ? [{ symptomId: id, severity: severityByKey.get(k) ?? null }] - : []; - }); + const symptomLinks = dayLogSymptomLinks(d, symptomIdByKey); const cycleId = d.cycleId !== undefined ? d.cycleId === null diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 0f5f486d5..d8270f380 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { Buffer } from "node:buffer"; -import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { parseBackupPayload } from "@/lib/validations/backup"; process.env.ENCRYPTION_KEY = @@ -201,6 +201,7 @@ function makePrisma() { enteredUnit: "IU", source: "MANUAL", createdAt: new Date("2026-07-01T08:00:00.000Z"), + updatedAt: new Date("2026-07-01T08:05:00.000Z"), }, ], // v1.40 (#1024) — one ended course with a note, one running. @@ -211,6 +212,7 @@ function makePrisma() { endsOn: new Date("2026-03-07T00:00:00.000Z"), noteEncrypted: encryptToBytes("the March flu"), createdAt: new Date("2026-03-01T08:00:00.000Z"), + updatedAt: new Date("2026-03-01T08:05:00.000Z"), }, { id: "course-june", @@ -218,6 +220,7 @@ function makePrisma() { endsOn: null, noteEncrypted: null, createdAt: new Date("2026-06-10T08:00:00.000Z"), + updatedAt: new Date("2026-06-10T08:05:00.000Z"), }, ], // A two-step ramp, one row carrying an encrypted note and one @@ -374,10 +377,12 @@ function makePrisma() { { rating: 4, moodTag: { key: "sleep_quality", kind: "RATED" }, + createdAt: new Date("2026-07-19T20:00:00.000Z"), }, { rating: null, moodTag: { key: "headache", kind: "BINARY" }, + createdAt: new Date("2026-07-19T20:00:01.000Z"), }, ], }, @@ -393,6 +398,7 @@ function makePrisma() { source: "MANUAL", externalSourceVersion: null, createdAt: new Date("2026-07-19T20:15:00.000Z"), + updatedAt: new Date("2026-07-19T20:16:00.000Z"), }, ]), }, @@ -785,32 +791,47 @@ describe("buildFullBackupPayload disaster-recovery mode", () => { * decision. */ describe("buildFullBackupPayload — medication courses (v1.40, #1024)", () => { - it.each(["portable-export", "disaster-recovery"] as const)( - "carries every course with its note readable in a %s file", - async (purpose) => { - installSectionMocks(); - const { payload } = await buildFullBackupPayload( - makePrisma() as never, - "user-1", - { purpose, exportedAt: new Date("2026-07-20T00:00:00.000Z") }, - ); - const parsed = parseBackupPayload(payload); - expect(parsed.medications[0].courses).toEqual([ - { - startsOn: "2026-03-01", - endsOn: "2026-03-07", - note: "the March flu", - createdAt: "2026-03-01T08:00:00.000Z", - }, - { - startsOn: "2026-06-10", - endsOn: null, - note: null, - createdAt: "2026-06-10T08:00:00.000Z", - }, - ]); - }, - ); + async function courses(purpose: "portable-export" | "disaster-recovery") { + installSectionMocks(); + const { payload } = await buildFullBackupPayload( + makePrisma() as never, + "user-1", + { purpose, exportedAt: new Date("2026-07-20T00:00:00.000Z") }, + ); + return parseBackupPayload(payload).medications[0].courses; + } + + it("carries every course with its note readable in a portable file", async () => { + expect(await courses("portable-export")).toEqual([ + { + startsOn: "2026-03-01", + endsOn: "2026-03-07", + note: "the March flu", + createdAt: "2026-03-01T08:00:00.000Z", + }, + { + startsOn: "2026-06-10", + endsOn: null, + note: null, + createdAt: "2026-06-10T08:00:00.000Z", + }, + ]); + }); + + it("carries every course with its note as ciphertext in a disaster-recovery file", async () => { + const [march, june] = (await courses("disaster-recovery"))!; + expect(march).toMatchObject({ + startsOn: "2026-03-01", + endsOn: "2026-03-07", + note: null, + createdAt: "2026-03-01T08:00:00.000Z", + updatedAt: "2026-03-01T08:05:00.000Z", + }); + expect(decryptFromBytes(Buffer.from(march!.noteEncrypted!, "base64"))).toBe( + "the March flu", + ); + expect(june).toMatchObject({ note: null, noteEncrypted: null }); + }); }); describe("buildFullBackupPayload — supplement composition (v1.43)", () => { @@ -836,6 +857,9 @@ describe("buildFullBackupPayload — supplement composition (v1.43)", () => { enteredUnit: "IU", source: "MANUAL", createdAt: "2026-07-01T08:00:00.000Z", + ...(purpose === "disaster-recovery" + ? { updatedAt: "2026-07-01T08:05:00.000Z" } + : {}), }, ]); }, diff --git a/src/lib/export/__tests__/records-backup.test.ts b/src/lib/export/__tests__/records-backup.test.ts index c0d68ee1b..406b60bc1 100644 --- a/src/lib/export/__tests__/records-backup.test.ts +++ b/src/lib/export/__tests__/records-backup.test.ts @@ -112,7 +112,13 @@ function buildMockPrisma() { feverC: null, noteEncrypted: encryptToBytes("Bad day."), updatedAt: new Date("2026-04-10T00:00:00.000Z"), - symptomLinks: [{ severity: 3, symptom: { key: "headache" } }], + symptomLinks: [ + { + severity: 3, + symptom: { key: "headache" }, + createdAt: new Date("2026-04-10T07:00:00.000Z"), + }, + ], }, ], }, @@ -298,7 +304,13 @@ describe("buildRecordsBackupSection", () => { createdAt, updatedAt, deletedAt, - symptomLinks: [{ severity: 3, symptom: { key: "headache" } }], + symptomLinks: [ + { + severity: 3, + symptom: { key: "headache" }, + createdAt: new Date("2026-04-10T07:00:00.000Z"), + }, + ], }, ], }, @@ -384,7 +396,13 @@ describe("buildRecordsBackupSection", () => { createdAt: createdAt.toISOString(), updatedAt: updatedAt.toISOString(), deletedAt: deletedAt.toISOString(), - symptoms: [{ key: "headache", severity: 3 }], + symptoms: [ + { + key: "headache", + severity: 3, + createdAt: "2026-04-10T07:00:00.000Z", + }, + ], }, ], }); diff --git a/src/lib/export/__tests__/restore-backup-round-trip.test.ts b/src/lib/export/__tests__/restore-backup-round-trip.test.ts index b647ccbf4..45266aa47 100644 --- a/src/lib/export/__tests__/restore-backup-round-trip.test.ts +++ b/src/lib/export/__tests__/restore-backup-round-trip.test.ts @@ -139,7 +139,12 @@ function sourceClient() { deletedAt: null, createdAt: new Date("2026-07-19T00:00:00.000Z"), updatedAt: new Date("2026-07-19T00:00:00.000Z"), - symptomLinks: [{ symptom: { key: "custom:jaw_tension" } }], + symptomLinks: [ + { + symptom: { key: "custom:jaw_tension" }, + createdAt: new Date("2026-07-19T08:00:00.000Z"), + }, + ], }, ]), }, diff --git a/src/lib/export/illness-backup-schema.ts b/src/lib/export/illness-backup-schema.ts index 09f6de025..2022d5ced 100644 --- a/src/lib/export/illness-backup-schema.ts +++ b/src/lib/export/illness-backup-schema.ts @@ -18,6 +18,9 @@ export const illnessSymptomBackupSchema = z .object({ key: z.string().min(1), severity: z.number().int().nullable().optional(), + // When the link was written. Disaster-recovery files only; without it the + // link is stamped with the restore, as before. + createdAt: isoDateTime.optional(), }) .passthrough(); export const illnessDayLogBackupSchema = z diff --git a/src/lib/export/illness-backup.ts b/src/lib/export/illness-backup.ts index 64486bb95..19eb4423c 100644 --- a/src/lib/export/illness-backup.ts +++ b/src/lib/export/illness-backup.ts @@ -94,6 +94,7 @@ export async function buildIllnessBackupSlice( symptoms: dayLog.symptomLinks.map((link) => ({ key: link.symptom.key, severity: link.severity, + createdAt: link.createdAt.toISOString(), })), })), })) @@ -168,7 +169,14 @@ async function restoreEpisodes(ctx: RestoreContext): Promise { if (episode.parentConditionId) { await tx.illnessEpisode.update({ where: { id: episode.id, userId: ownerId }, - data: { parentConditionId: episode.parentConditionId }, + // The pointer is a second write to the row; the row's own last write + // stays the one the file carries. + data: { + parentConditionId: episode.parentConditionId, + ...(episode.updatedAt + ? { updatedAt: new Date(episode.updatedAt) } + : {}), + }, }); } } @@ -212,7 +220,15 @@ async function restoreDayLogs(ctx: RestoreContext): Promise { const symptomLinks = dayLog.symptoms.flatMap((symptom) => { const symptomId = symptomByKey.get(symptom.key); return symptomId - ? [{ symptomId, severity: symptom.severity ?? null }] + ? [ + { + symptomId, + severity: symptom.severity ?? null, + ...(symptom.createdAt + ? { createdAt: new Date(symptom.createdAt) } + : {}), + }, + ] : []; }); await tx.illnessDayLog.create({ diff --git a/src/lib/export/labs-backup-schema.ts b/src/lib/export/labs-backup-schema.ts index b28577007..df15b82e6 100644 --- a/src/lib/export/labs-backup-schema.ts +++ b/src/lib/export/labs-backup-schema.ts @@ -81,6 +81,9 @@ export const biomarkerBackupSchema = z // The catalogue analyte the marker resolves to. analyteKey: z.string().nullable().optional(), context: z.string().nullable().optional(), + // The context's ciphertext, disaster-recovery files only. A file without + // it carries the readable context, which the restore seals. + contextEncrypted: base64BytesSchema.nullable().optional(), // The other spellings the marker answers to. Absent on // files written before them. aliases: z diff --git a/src/lib/export/labs-backup.ts b/src/lib/export/labs-backup.ts index f2e5291d5..2205385d9 100644 --- a/src/lib/export/labs-backup.ts +++ b/src/lib/export/labs-backup.ts @@ -103,6 +103,11 @@ export interface BiomarkerBackupEntry { panel: string | null; hidden: boolean; context: string | null; + /** + * The context's ciphertext as base64, disaster-recovery files only, where + * `context` is null. A file without it carries the readable context. + */ + contextEncrypted?: string | null; /** The catalogue key the marker's name and unit resolve to. */ analyteKey?: string | null; /** @@ -259,11 +264,22 @@ export async function buildLabsBackupSlice( upperBound: b.upperBound, panel: b.panel, hidden: b.hidden, - context: readForExport( - b.contextEncrypted, - "biomarker context", - decryptContextFromBytes, - ), + // Ciphertext verbatim in a disaster-recovery file, like every other + // sealed column; the readable context in a portable one. + ...(disasterRecovery + ? { + context: null, + contextEncrypted: b.contextEncrypted + ? Buffer.from(b.contextEncrypted).toString("base64") + : null, + } + : { + context: readForExport( + b.contextEncrypted, + "biomarker context", + decryptContextFromBytes, + ), + }), analyteKey: b.analyteKey, aliases: aliasesByMarker.get(b.id) ?? [], })); @@ -347,9 +363,13 @@ export async function restoreLabMarkers(ctx: RestoreContext): Promise { panel: biomarker.panel ?? null, hidden: biomarker.hidden ?? false, contextEncrypted: - biomarker.context == null - ? null - : encryptContextToBytes(biomarker.context), + biomarker.contextEncrypted !== undefined + ? biomarker.contextEncrypted === null + ? null + : decodeEncryptedBytes(biomarker.contextEncrypted) + : biomarker.context == null + ? null + : encryptContextToBytes(biomarker.context), analyteKey: biomarker.analyteKey ?? null, ...(biomarker.createdAt ? { createdAt: new Date(biomarker.createdAt) } diff --git a/src/lib/export/medications-backup-schema.ts b/src/lib/export/medications-backup-schema.ts index eade10b55..4b43396fd 100644 --- a/src/lib/export/medications-backup-schema.ts +++ b/src/lib/export/medications-backup-schema.ts @@ -109,6 +109,9 @@ export const medicationIngredientSchema = z enteredUnit: z.string().max(8).nullable().optional(), source: z.enum(["MANUAL", "LABEL_AI"]).default("MANUAL"), createdAt: isoDateTime.optional(), + // The row's last write. Disaster-recovery files only; a file without it + // restores the row stamped with the restore, as before. + updatedAt: isoDateTime.optional(), }) .passthrough(); /** @@ -121,7 +124,11 @@ export const medicationCourseSchema = z startsOn: z.iso.date(), endsOn: z.iso.date().nullable(), note: z.string().max(280).nullable().optional(), + // The note's ciphertext, disaster-recovery files only. A file without it + // (any written before) carries the readable note, which the restore seals. + noteEncrypted: base64BytesSchema.nullable().optional(), createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), }) .passthrough(); /** @@ -248,12 +255,21 @@ export const medicationEfficacyTargetSchema = z export const customMedicationCategorySchema = z .object({ key: z.string().regex(/^custom:[0-9a-f-]{36}$/), - label: z.string().trim().min(1).max(40), + // The readable label: a portable file, and every disaster-recovery file + // written before the label travelled as ciphertext. + label: z.string().trim().min(1).max(40).nullable().optional(), + // The label's ciphertext, as a disaster-recovery file carries it now. + labelEncrypted: base64BytesSchema.optional(), sortOrder: z.number().int().min(0).default(0), isActive: z.boolean().default(true), createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), }) - .passthrough(); + .passthrough() + .refine((c) => c.labelEncrypted !== undefined || (c.label ?? "") !== "", { + message: "a custom category needs its label or its ciphertext", + path: ["label"], + }); export const medicationSchema = z .object({ id: z.string().min(1).optional(), @@ -293,6 +309,9 @@ export const medicationSchema = z // whole file. Since v1.40 it may be `custom:`, the key of an entry // in `customMedicationCategories`. category: z.string().max(64).optional(), + // When the category was last set. Disaster-recovery files only; without + // it the restored assignment is stamped with the restore, as before. + categoryUpdatedAt: isoDateTime.nullable().optional(), deliveryForm: z.enum(MedicationDeliveryForm).optional(), trackInjectionSites: z.boolean().optional(), allowedInjectionSites: z.array(z.enum(InjectionSite)).optional(), diff --git a/src/lib/export/medications-backup.ts b/src/lib/export/medications-backup.ts index 2ffbfd80c..990d579e5 100644 --- a/src/lib/export/medications-backup.ts +++ b/src/lib/export/medications-backup.ts @@ -54,6 +54,9 @@ const MEDICATION_BACKUP_INCLUDE = { { id: "asc" }, ] as const, }, + // When the category was last set. Read for the disaster-recovery file, + // which gives the assignment back with its own timestamp. + categoryAssignment: { select: { updatedAt: true } }, // What the drug was supposed to move. The biomarker is read by NAME // rather than by id, exactly as the lab results beside it are: a // portable restore mints a fresh biomarker id, and `(userId, name)` is @@ -101,6 +104,8 @@ function serialiseMedicationRow( externalSource: m.externalSource, externalId: m.externalId, updatedAt: m.updatedAt.toISOString(), + categoryUpdatedAt: + m.categoryAssignment?.updatedAt.toISOString() ?? null, } : // No id: a portable restore mints a fresh one. The relation links // still name this drug, so the file says which row they mean. @@ -206,7 +211,9 @@ function serialiseMedicationHistory( // counting toward the day's micronutrients. Canonical amount plus the // amount as entered, so the form shows what was typed. ingredients: m.ingredients.map((i) => ({ - ...(disasterRecovery ? { id: i.id } : {}), + ...(disasterRecovery + ? { id: i.id, updatedAt: i.updatedAt.toISOString() } + : {}), nutrient: i.nutrient, amountPerUnit: i.amountPerUnit, unit: i.unit, @@ -215,10 +222,21 @@ function serialiseMedicationHistory( source: i.source, createdAt: i.createdAt.toISOString(), })), + // The note follows the contract of every other sealed column: the + // disaster-recovery file carries the ciphertext verbatim (and the row's + // own last write), a portable file the decrypted text. courses: m.courses.map((c) => ({ startsOn: dayKeyOfDate(c.startsOn), endsOn: c.endsOn ? dayKeyOfDate(c.endsOn) : null, - note: c.noteEncrypted ? decryptFromBytes(c.noteEncrypted) : null, + ...(disasterRecovery + ? { + note: null, + noteEncrypted: c.noteEncrypted + ? Buffer.from(c.noteEncrypted).toString("base64") + : null, + updatedAt: c.updatedAt.toISOString(), + } + : { note: c.noteEncrypted ? decryptFromBytes(c.noteEncrypted) : null }), createdAt: c.createdAt.toISOString(), })), // Titration: when the dose moved, to what, and why. The drug comes @@ -473,11 +491,15 @@ export async function buildMedicationsBackupPart( prisma, ); // The account's own categories, restored before the medications that name - // them by key. Labels travel decrypted so a file restores on an instance - // with a different key; every one is carried, hidden ones included, - // because a medication can still be filed under it. + // them by key. Every one is carried, hidden ones included, because a + // medication can still be filed under it. A portable file carries the + // label decrypted, so it restores on an instance with another key; a + // disaster-recovery file carries the ciphertext verbatim, like every other + // sealed column, and the row's own last write. const customMedicationCategories = - await readMedicationCategoryLabelsForBackup(userId, prisma); + await readMedicationCategoryLabelsForBackup(userId, prisma, { + disasterRecovery, + }); // Where each archived era sits in its OWN drug's ordered list. Built once // here rather than per row, and scoped per medication because the // supersede pointer never crosses a drug — every route that writes it diff --git a/src/lib/export/medications-restore.ts b/src/lib/export/medications-restore.ts index 90c7d5cb2..d71a2505f 100644 --- a/src/lib/export/medications-restore.ts +++ b/src/lib/export/medications-restore.ts @@ -77,10 +77,18 @@ async function restoreCustomCategories( data: { userId: ownerId, key, - labelEncrypted: encryptCategoryLabel(c.label), + // The ciphertext verbatim when the file carries it (a + // disaster-recovery file), the readable label sealed here otherwise + // (a portable file, or a recovery file written before the label + // travelled sealed). + labelEncrypted: + c.labelEncrypted !== undefined + ? decodeEncryptedBytes(c.labelEncrypted) + : encryptCategoryLabel(c.label ?? ""), sortOrder: c.sortOrder, isActive: c.isActive, ...(c.createdAt ? { createdAt: new Date(c.createdAt) } : {}), + ...(c.updatedAt ? { updatedAt: new Date(c.updatedAt) } : {}), }, }); } @@ -254,6 +262,7 @@ function medicationChildrenData(m: BackupMedication, ownerId: string) { enteredUnit: i.enteredUnit ?? null, source: i.source, ...(i.createdAt ? { createdAt: new Date(i.createdAt) } : {}), + ...(i.updatedAt ? { updatedAt: new Date(i.updatedAt) } : {}), })), }, // `resumedAt` passes through as null when the era is open. It is not @@ -341,14 +350,20 @@ async function restoreCourses( startsOn: Date; endsOn: Date | null; note: string | null; + noteEncrypted?: string | null; createdAt?: Date; + updatedAt?: Date; }> = m.courses !== undefined ? m.courses.map((c) => ({ startsOn: dateOfDayKey(c.startsOn), endsOn: c.endsOn ? dateOfDayKey(c.endsOn) : null, note: c.note ?? null, + ...(c.noteEncrypted !== undefined + ? { noteEncrypted: c.noteEncrypted } + : {}), ...(c.createdAt ? { createdAt: new Date(c.createdAt) } : {}), + ...(c.updatedAt ? { updatedAt: new Date(c.updatedAt) } : {}), })) : (() => { const derived = courseFromWindow( @@ -365,8 +380,18 @@ async function restoreCourses( userId: ownerId, startsOn: c.startsOn, endsOn: c.endsOn, - noteEncrypted: c.note ? encryptToBytes(c.note) : null, + // Ciphertext verbatim from a disaster-recovery file; the readable note + // of a portable or older file sealed here. + noteEncrypted: + c.noteEncrypted !== undefined + ? c.noteEncrypted === null + ? null + : decodeEncryptedBytes(c.noteEncrypted) + : c.note + ? encryptToBytes(c.note) + : null, ...(c.createdAt ? { createdAt: c.createdAt } : {}), + ...(c.updatedAt ? { updatedAt: c.updatedAt } : {}), })), }); const window = projectCourseWindow(courseRows); @@ -589,6 +614,12 @@ export async function restoreMedications(ctx: RestoreContext): Promise { categoryKeyRemap.get(m.category) ?? m.category, tx, ); + if (m.categoryUpdatedAt) { + await tx.medicationCategoryAssignment.update({ + where: { medicationId: created.id }, + data: { updatedAt: new Date(m.categoryUpdatedAt) }, + }); + } } await restoreCourses(tx, ownerId, m, created, restoreTz); await restoreScheduleRevisions( @@ -598,6 +629,14 @@ export async function restoreMedications(ctx: RestoreContext): Promise { medicationIndex, unresolvedRevisionLinks, ); + // The writes above (the course window) moved the row's `updatedAt` to + // now; a file that carries the row's own last write puts it back. + if (m.updatedAt) { + await tx.medication.update({ + where: { id: created.id }, + data: { updatedAt: new Date(m.updatedAt) }, + }); + } for (const target of m.efficacyTargets) { links.pendingEfficacyTargets.push({ medicationId: created.id, target }); } diff --git a/src/lib/export/mood-backup-schema.ts b/src/lib/export/mood-backup-schema.ts index 4f571f411..a61ea130c 100644 --- a/src/lib/export/mood-backup-schema.ts +++ b/src/lib/export/mood-backup-schema.ts @@ -81,6 +81,12 @@ export const moodEntrySchema = z // RATED-only meaning, so every file written before this one parses // unchanged and restores identically: absent reads as none. structuredTags: z.array(z.string().min(1)).default([]), + // When each link (rated or not) was written, by tag key. Disaster-recovery + // files only; a file without it restores the links stamped with the + // restore, as before. + linkCreatedAt: z + .array(z.object({ key: z.string().min(1), createdAt: isoDateTime })) + .optional(), // The day context, when the entry had one. Optional and nullable at the // top: a file written before this existed carries no `context` key at all // and must parse and restore exactly as it did. Every field inside is @@ -116,6 +122,9 @@ export const moodEntrySchema = z // plain text, a disaster-recovery file the ciphertext. note: z.string().nullable().optional(), notesEncrypted: base64BytesSchema.nullable().optional(), + // The context row's own stamps, disaster-recovery files only. + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), }) .passthrough() .nullable() diff --git a/src/lib/export/mood-backup.ts b/src/lib/export/mood-backup.ts index 439a3f4d9..69f4cc380 100644 --- a/src/lib/export/mood-backup.ts +++ b/src/lib/export/mood-backup.ts @@ -60,6 +60,7 @@ const MOOD_ENTRY_BACKUP_SELECT = { */ const MOOD_ENTRY_TAG_LINK_SELECT = { rating: true, + createdAt: true, moodTag: { select: { key: true, kind: true } }, } as const satisfies Prisma.MoodEntryTagLinkSelect; @@ -69,9 +70,8 @@ const MOOD_ENTRY_TAG_LINK_SELECT = { * `moodEntryId` and `userId` are absent for the reasons the exclusion list in * `mood-backup-completeness.test.ts` states: the context rides inside its * entry and is re-bound to whatever id that entry got, and the owner is - * re-derived from the restoring account. The two row stamps are absent for the - * third reason there — they describe when the sub-row was written, not when - * the day happened, and the entry already carries the moment that matters. + * re-derived from the restoring account. The two row stamps travel in the + * disaster-recovery file only, which gives the account back as it was. * * Named rather than inlined so the guard can compare it field-by-field against * the model, exactly as it does for the two selects above. This is the whole @@ -97,6 +97,8 @@ const MOOD_CONTEXT_BACKUP_SELECT = { eventValence: true, eventAt: true, notesEncrypted: true, + createdAt: true, + updatedAt: true, } as const satisfies Prisma.MoodContextSelect; type BackupMoodContext = Prisma.MoodContextGetPayload<{ @@ -149,6 +151,8 @@ function serialiseMoodContext( notesEncrypted: moodContext.notesEncrypted ? Buffer.from(moodContext.notesEncrypted).toString("base64") : null, + createdAt: moodContext.createdAt.toISOString(), + updatedAt: moodContext.updatedAt.toISOString(), } : { note: readNote(moodContext.notesEncrypted, null) }), workStatus: moodContext.workStatus, @@ -220,6 +224,11 @@ function serialiseMoodEntry( syncVersion: moodEntry.syncVersion, createdAt: moodEntry.createdAt.toISOString(), updatedAt: moodEntry.updatedAt.toISOString(), + // When each link was written, by tag key, for both link arms. + linkCreatedAt: moodEntry.tagLinks.map((tagLink) => ({ + key: tagLink.moodTag.key, + createdAt: tagLink.createdAt.toISOString(), + })), } : { note: readNote(moodEntry.noteEncrypted, moodEntry.note) }), id: moodEntry.id, diff --git a/src/lib/export/mood-restore.ts b/src/lib/export/mood-restore.ts index 938195124..26596eb76 100644 --- a/src/lib/export/mood-restore.ts +++ b/src/lib/export/mood-restore.ts @@ -256,6 +256,9 @@ async function restoreMoodContext( context.notesEncrypted == null ? encryptNote(context.note ?? null) : decodeEncryptedBytes(context.notesEncrypted), + // The row's own stamps when the file carries them (disaster recovery). + ...(context.createdAt ? { createdAt: new Date(context.createdAt) } : {}), + ...(context.updatedAt ? { updatedAt: new Date(context.updatedAt) } : {}), }, }); } @@ -320,7 +323,16 @@ async function restoreMoodEntry( ? [{ moodEntryId: restored.id, moodTagId, rating: null }] : []; }); - const links = [...factorLinks, ...tagLinks]; + // Each link's own write time, when the file carries it. + const linkCreatedAt = new Map( + (entry.linkCreatedAt ?? []).map((link) => [link.key, link.createdAt]), + ); + const keyOfTag = new Map(); + for (const [key, id] of [...factorByKey, ...tagByKey]) keyOfTag.set(id, key); + const links = [...factorLinks, ...tagLinks].map((link) => { + const createdAt = linkCreatedAt.get(keyOfTag.get(link.moodTagId) ?? ""); + return createdAt ? { ...link, createdAt: new Date(createdAt) } : link; + }); if (links.length > 0) { await tx.moodEntryTagLink.createMany({ data: links }); } diff --git a/src/lib/export/nutrients-backup-schema.ts b/src/lib/export/nutrients-backup-schema.ts index edd766e67..b7b701e93 100644 --- a/src/lib/export/nutrients-backup-schema.ts +++ b/src/lib/export/nutrients-backup-schema.ts @@ -29,5 +29,8 @@ export const nutrientDaySchema = z // Absent from files written before v1.39.7. externalSourceVersion: z.string().nullable().optional(), createdAt: isoDateTime.optional(), + // Disaster-recovery files only; without it the day is stamped with the + // restore, as before. + updatedAt: isoDateTime.optional(), }) .passthrough(); diff --git a/src/lib/export/nutrients-backup.ts b/src/lib/export/nutrients-backup.ts index 09693fc4d..ecfef114a 100644 --- a/src/lib/export/nutrients-backup.ts +++ b/src/lib/export/nutrients-backup.ts @@ -33,6 +33,7 @@ export async function buildNutrientsBackupPart( source: true, externalSourceVersion: true, createdAt: true, + updatedAt: true, }, orderBy: [{ day: "desc" }, { nutrient: "asc" }], }); @@ -46,6 +47,11 @@ export async function buildNutrientsBackupPart( source: n.source, externalSourceVersion: n.externalSourceVersion, createdAt: n.createdAt.toISOString(), + // The day's last write, for the disaster-recovery file, which gives + // the account back as it was. + ...(ctx.disasterRecovery + ? { updatedAt: n.updatedAt.toISOString() } + : {}), })), }, counts: { nutrientDays: nutrientDays.length }, @@ -79,6 +85,7 @@ export async function restoreNutrientDays(ctx: RestoreContext): Promise { source: n.source, externalSourceVersion: n.externalSourceVersion ?? null, ...(n.createdAt ? { createdAt: new Date(n.createdAt) } : {}), + ...(n.updatedAt ? { updatedAt: new Date(n.updatedAt) } : {}), })), }); } diff --git a/src/lib/medication-category.ts b/src/lib/medication-category.ts index f6240db5a..00ef279ee 100644 --- a/src/lib/medication-category.ts +++ b/src/lib/medication-category.ts @@ -1,3 +1,4 @@ +import { Buffer } from "node:buffer"; import { randomUUID } from "node:crypto"; import type { Prisma } from "@/generated/prisma/client"; @@ -254,13 +255,16 @@ export async function listMedicationCategoryLabels( export async function readMedicationCategoryLabelsForBackup( userId: string, client: CategoryClient = prisma, + options: { disasterRecovery?: boolean } = {}, ): Promise< Array<{ key: string; - label: string; + label: string | null; + labelEncrypted?: string; sortOrder: number; isActive: boolean; createdAt: string; + updatedAt?: string; }> > { const rows = await client.medicationCategoryLabel.findMany({ @@ -272,14 +276,23 @@ export async function readMedicationCategoryLabelsForBackup( sortOrder: true, isActive: true, createdAt: true, + updatedAt: true, }, }); return rows.map((r) => ({ key: r.key, - label: decryptCategoryLabel(r.labelEncrypted), + ...(options.disasterRecovery + ? { + label: null, + labelEncrypted: Buffer.from(r.labelEncrypted).toString("base64"), + } + : { label: decryptCategoryLabel(r.labelEncrypted) }), sortOrder: r.sortOrder, isActive: r.isActive, createdAt: r.createdAt.toISOString(), + ...(options.disasterRecovery + ? { updatedAt: r.updatedAt.toISOString() } + : {}), })); } diff --git a/tests/integration/__golden__/backup-parity/export-disaster-recovery.json b/tests/integration/__golden__/backup-parity/export-disaster-recovery.json index 81e8ccae0..48a526e97 100644 --- a/tests/integration/__golden__/backup-parity/export-disaster-recovery.json +++ b/tests/integration/__golden__/backup-parity/export-disaster-recovery.json @@ -295,10 +295,12 @@ "customMedicationCategories": [ { "key": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "label": "Heart and circulation", + "label": null, + "labelEncrypted": "", "sortOrder": 2, "isActive": false, - "createdAt": "2026-05-01T23:06:00.000Z" + "createdAt": "2026-05-01T23:06:00.000Z", + "updatedAt": "2026-05-01T23:07:00.000Z" } ], "medications": [ @@ -329,6 +331,7 @@ "externalSource": "WEB", "externalId": "rt-Medication-externalId", "updatedAt": "2026-05-01T11:51:00.000Z", + "categoryUpdatedAt": "2026-05-01T21:22:00.000Z", "name": "Round-trip tablet", "dose": "5 mg", "active": false, @@ -406,6 +409,7 @@ "ingredients": [ { "id": "", + "updatedAt": "2026-05-02T02:30:00.000Z", "nutrient": "vitamin_d", "amountPerUnit": 25, "unit": "ug", @@ -419,13 +423,17 @@ { "startsOn": "2026-03-01", "endsOn": "2026-03-07", - "note": "the March flu", + "note": null, + "noteEncrypted": "", + "updatedAt": "2026-05-02T00:47:00.000Z", "createdAt": "2026-05-02T00:46:00.000Z" }, { "startsOn": "2026-06-10", "endsOn": "2026-06-16", "note": null, + "noteEncrypted": null, + "updatedAt": "2026-05-02T00:47:01.000Z", "createdAt": "2026-05-02T00:46:01.000Z" } ], @@ -623,6 +631,7 @@ "syncVersion": 0, "createdAt": "2026-05-02T04:18:01.000Z", "updatedAt": "2026-05-02T04:19:01.000Z", + "linkCreatedAt": [], "id": "", "date": "2026-07-03", "mood": "SCHLECHT", @@ -649,6 +658,12 @@ "syncVersion": 3, "createdAt": "2026-05-02T04:18:00.000Z", "updatedAt": "2026-05-02T04:19:00.000Z", + "linkCreatedAt": [ + { + "key": "round_trip_factor", + "createdAt": "2026-05-02T07:22:00.000Z" + } + ], "id": "", "date": "2026-07-01", "mood": "GUT", @@ -673,6 +688,8 @@ "structuredTags": [], "context": { "notesEncrypted": "", + "createdAt": "2026-05-02T06:00:00.000Z", + "updatedAt": "2026-05-02T06:01:00.000Z", "workStatus": "regular", "workMinutes": 3, "overtimeMinutes": 3, @@ -827,6 +844,12 @@ "key": "custom:round-trip-cramps", "severity": 3 } + ], + "symptomLinkCreatedAt": [ + { + "key": "custom:round-trip-cramps", + "createdAt": "2026-05-03T13:23:00.000Z" + } ] }, { @@ -857,7 +880,8 @@ "externalId": null, "tz": null, "symptomKeys": [], - "symptomSeverities": [] + "symptomSeverities": [], + "symptomLinkCreatedAt": [] } ], "customSymptoms": [ @@ -933,7 +957,8 @@ "upperBound": 2.5, "panel": "rt-Biomarker-panel", "hidden": true, - "context": "fasted, morning draw", + "context": null, + "contextEncrypted": "", "analyteKey": "rt-Biomarker-analyteKey", "aliases": [ { @@ -1007,7 +1032,8 @@ "symptoms": [ { "key": "round_trip_cough", - "severity": 2 + "severity": 2, + "createdAt": "2026-05-02T20:43:00.000Z" } ] }, @@ -2275,7 +2301,8 @@ "unit": "ml", "source": "MANUAL", "externalSourceVersion": "rt-NutrientIntakeDay-externalSourceVersion", - "createdAt": "2026-05-03T23:27:00.000Z" + "createdAt": "2026-05-03T23:27:00.000Z", + "updatedAt": "2026-05-03T23:28:00.000Z" } ] }, @@ -2718,6 +2745,7 @@ "autoMarkTaken": true, "autoMarkTakenSince": "2026-06-30T12:34:56.789Z", "category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", + "categoryUpdatedAt": "2026-05-01T21:22:00.000Z", "deliveryForm": "INJECTION", "trackInjectionSites": true, "allowedInjectionSites": [ @@ -2809,7 +2837,8 @@ "enteredAmount": 1000, "enteredUnit": "IU", "source": "LABEL_AI", - "createdAt": "2026-05-02T02:29:00.000Z" + "createdAt": "2026-05-02T02:29:00.000Z", + "updatedAt": "2026-05-02T02:30:00.000Z" } ], "doseChanges": [ @@ -2954,14 +2983,18 @@ { "startsOn": "2026-03-01", "endsOn": "2026-03-07", - "note": "the March flu", - "createdAt": "2026-05-02T00:46:00.000Z" + "note": null, + "noteEncrypted": "", + "createdAt": "2026-05-02T00:46:00.000Z", + "updatedAt": "2026-05-02T00:47:00.000Z" }, { "startsOn": "2026-06-10", "endsOn": "2026-06-16", "note": null, - "createdAt": "2026-05-02T00:46:01.000Z" + "noteEncrypted": null, + "createdAt": "2026-05-02T00:46:01.000Z", + "updatedAt": "2026-05-02T00:47:01.000Z" } ] } @@ -2969,10 +3002,12 @@ "customMedicationCategories": [ { "key": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "label": "Heart and circulation", + "label": null, + "labelEncrypted": "", "sortOrder": 2, "isActive": false, - "createdAt": "2026-05-01T23:06:00.000Z" + "createdAt": "2026-05-01T23:06:00.000Z", + "updatedAt": "2026-05-01T23:07:00.000Z" } ], "intakeEvents": [ @@ -3046,6 +3081,7 @@ "updatedAt": "2026-05-02T04:19:01.000Z", "factors": [], "structuredTags": [], + "linkCreatedAt": [], "context": null }, { @@ -3077,6 +3113,12 @@ } ], "structuredTags": [], + "linkCreatedAt": [ + { + "key": "round_trip_factor", + "createdAt": "2026-05-02T07:22:00.000Z" + } + ], "context": { "workStatus": "regular", "workMinutes": 3, @@ -3095,7 +3137,9 @@ "eventType": "rt-MoodContext-eventType", "eventValence": 3, "eventAt": "2026-06-30T12:34:56.789Z", - "notesEncrypted": "" + "notesEncrypted": "", + "createdAt": "2026-05-02T06:00:00.000Z", + "updatedAt": "2026-05-02T06:01:00.000Z" } } ], @@ -3200,6 +3244,12 @@ "key": "custom:round-trip-cramps", "severity": 3 } + ], + "symptomLinkCreatedAt": [ + { + "key": "custom:round-trip-cramps", + "createdAt": "2026-05-03T13:23:00.000Z" + } ] }, { @@ -3230,7 +3280,8 @@ "createdAt": "2026-05-03T10:25:01.000Z", "updatedAt": "2026-05-03T10:26:01.000Z", "symptomKeys": [], - "symptomSeverities": [] + "symptomSeverities": [], + "symptomLinkCreatedAt": [] } ], "customSymptoms": [ @@ -3338,7 +3389,8 @@ "panel": "rt-Biomarker-panel", "hidden": true, "analyteKey": "rt-Biomarker-analyteKey", - "context": "fasted, morning draw", + "context": null, + "contextEncrypted": "", "aliases": [ { "id": "", @@ -3407,7 +3459,8 @@ "symptoms": [ { "key": "round_trip_cough", - "severity": 2 + "severity": 2, + "createdAt": "2026-05-02T20:43:00.000Z" } ], "note": null, @@ -3702,7 +3755,8 @@ "unit": "ml", "source": "MANUAL", "externalSourceVersion": "rt-NutrientIntakeDay-externalSourceVersion", - "createdAt": "2026-05-03T23:27:00.000Z" + "createdAt": "2026-05-03T23:27:00.000Z", + "updatedAt": "2026-05-03T23:28:00.000Z" } ], "healthProfile": { diff --git a/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json b/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json index 852833970..439fe95bc 100644 --- a/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json +++ b/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json @@ -629,7 +629,7 @@ "externalSource": "WEB", "externalId": "rt-Medication-externalId", "createdAt": "2026-05-01T11:50:00.000Z", - "updatedAt": "" + "updatedAt": "2026-05-01T11:51:00.000Z" } ], "MedicationSchedule": [ @@ -778,7 +778,7 @@ { "medicationId": "", "category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "updatedAt": "" + "updatedAt": "2026-05-01T21:22:00.000Z" } ], "MedicationCategoryLabel": [ @@ -786,11 +786,11 @@ "id": "", "userId": "round-trip-owner", "key": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "labelEncrypted": "", + "labelEncrypted": "", "sortOrder": 2, "isActive": false, "createdAt": "2026-05-01T23:06:00.000Z", - "updatedAt": "" + "updatedAt": "2026-05-01T23:07:00.000Z" } ], "MedicationCourse": [ @@ -800,9 +800,9 @@ "userId": "round-trip-owner", "startsOn": "2026-03-01T00:00:00.000Z", "endsOn": "2026-03-07T00:00:00.000Z", - "noteEncrypted": "", + "noteEncrypted": "", "createdAt": "2026-05-02T00:46:00.000Z", - "updatedAt": "" + "updatedAt": "2026-05-02T00:47:00.000Z" }, { "id": "", @@ -812,7 +812,7 @@ "endsOn": "2026-06-16T00:00:00.000Z", "noteEncrypted": null, "createdAt": "2026-05-02T00:46:01.000Z", - "updatedAt": "" + "updatedAt": "2026-05-02T00:47:01.000Z" } ], "MedicationIngredient": [ @@ -827,7 +827,7 @@ "enteredUnit": "IU", "source": "LABEL_AI", "createdAt": "2026-05-02T02:29:00.000Z", - "updatedAt": "" + "updatedAt": "2026-05-02T02:30:00.000Z" } ], "MoodEntry": [ @@ -902,15 +902,15 @@ "eventValence": 3, "eventAt": "2026-06-30T12:34:56.789Z", "notesEncrypted": "", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T06:00:00.000Z", + "updatedAt": "2026-05-02T06:01:00.000Z" } ], "MoodEntryTagLink": [ { "moodEntryId": "", "moodTagId": "", - "createdAt": "", + "createdAt": "2026-05-02T07:22:00.000Z", "rating": 4 } ], @@ -987,7 +987,7 @@ "unit": "ng/mL", "lowerBound": 2.5, "upperBound": 2.5, - "contextEncrypted": "", + "contextEncrypted": "", "panel": "rt-Biomarker-panel", "hidden": true, "analyteKey": "rt-Biomarker-analyteKey", @@ -1081,7 +1081,7 @@ "bodySiteEncrypted": null, "laterality": null, "createdAt": "2026-05-02T17:31:03.000Z", - "updatedAt": "", + "updatedAt": "2026-05-02T17:32:03.000Z", "deletedAt": null }, { @@ -1150,7 +1150,7 @@ "dayLogId": "", "symptomId": "", "severity": 2, - "createdAt": "" + "createdAt": "2026-05-02T20:43:00.000Z" } ], "SymptomDefinition": [ @@ -1449,7 +1449,7 @@ "dayLogId": "", "symptomId": "", "severity": 3, - "createdAt": "" + "createdAt": "2026-05-03T13:23:00.000Z" } ], "CustomMetric": [ @@ -1604,7 +1604,7 @@ "source": "MANUAL", "externalSourceVersion": "rt-NutrientIntakeDay-externalSourceVersion", "createdAt": "2026-05-03T23:27:00.000Z", - "updatedAt": "" + "updatedAt": "2026-05-03T23:28:00.000Z" } ], "InboundDocument": [ @@ -3190,10 +3190,12 @@ "customMedicationCategories": [ { "key": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "label": "Heart and circulation", + "label": null, + "labelEncrypted": "", "sortOrder": 2, "isActive": false, - "createdAt": "2026-05-01T23:06:00.000Z" + "createdAt": "2026-05-01T23:06:00.000Z", + "updatedAt": "2026-05-01T23:07:00.000Z" } ], "medications": [ @@ -3223,7 +3225,8 @@ "reorderLeadDays": 3, "externalSource": "WEB", "externalId": "rt-Medication-externalId", - "updatedAt": "", + "updatedAt": "2026-05-01T11:51:00.000Z", + "categoryUpdatedAt": "2026-05-01T21:22:00.000Z", "name": "Round-trip tablet", "dose": "5 mg", "active": false, @@ -3301,6 +3304,7 @@ "ingredients": [ { "id": "", + "updatedAt": "2026-05-02T02:30:00.000Z", "nutrient": "vitamin_d", "amountPerUnit": 25, "unit": "ug", @@ -3314,13 +3318,17 @@ { "startsOn": "2026-03-01", "endsOn": "2026-03-07", - "note": "the March flu", + "note": null, + "noteEncrypted": "", + "updatedAt": "2026-05-02T00:47:00.000Z", "createdAt": "2026-05-02T00:46:00.000Z" }, { "startsOn": "2026-06-10", "endsOn": "2026-06-16", "note": null, + "noteEncrypted": null, + "updatedAt": "2026-05-02T00:47:01.000Z", "createdAt": "2026-05-02T00:46:01.000Z" } ], @@ -3518,6 +3526,7 @@ "syncVersion": 0, "createdAt": "2026-05-02T04:18:01.000Z", "updatedAt": "2026-05-02T04:19:01.000Z", + "linkCreatedAt": [], "id": "", "date": "2026-07-03", "mood": "SCHLECHT", @@ -3544,6 +3553,12 @@ "syncVersion": 3, "createdAt": "2026-05-02T04:18:00.000Z", "updatedAt": "2026-05-02T04:19:00.000Z", + "linkCreatedAt": [ + { + "key": "round_trip_factor", + "createdAt": "2026-05-02T07:22:00.000Z" + } + ], "id": "", "date": "2026-07-01", "mood": "GUT", @@ -3568,6 +3583,8 @@ "structuredTags": [], "context": { "notesEncrypted": "", + "createdAt": "2026-05-02T06:00:00.000Z", + "updatedAt": "2026-05-02T06:01:00.000Z", "workStatus": "regular", "workMinutes": 3, "overtimeMinutes": 3, @@ -3722,6 +3739,12 @@ "key": "custom:round-trip-cramps", "severity": 3 } + ], + "symptomLinkCreatedAt": [ + { + "key": "custom:round-trip-cramps", + "createdAt": "2026-05-03T13:23:00.000Z" + } ] }, { @@ -3752,7 +3775,8 @@ "externalId": null, "tz": null, "symptomKeys": [], - "symptomSeverities": [] + "symptomSeverities": [], + "symptomLinkCreatedAt": [] } ], "customSymptoms": [ @@ -3828,7 +3852,8 @@ "upperBound": 2.5, "panel": "rt-Biomarker-panel", "hidden": true, - "context": "fasted, morning draw", + "context": null, + "contextEncrypted": "", "analyteKey": "rt-Biomarker-analyteKey", "aliases": [ { @@ -3866,7 +3891,7 @@ "bodySiteEncrypted": null, "laterality": null, "createdAt": "2026-05-02T17:31:03.000Z", - "updatedAt": "", + "updatedAt": "2026-05-02T17:32:03.000Z", "deletedAt": null, "dayLogs": [] }, @@ -3902,7 +3927,8 @@ "symptoms": [ { "key": "round_trip_cough", - "severity": 2 + "severity": 2, + "createdAt": "2026-05-02T20:43:00.000Z" } ] }, @@ -5170,7 +5196,8 @@ "unit": "ml", "source": "MANUAL", "externalSourceVersion": "rt-NutrientIntakeDay-externalSourceVersion", - "createdAt": "2026-05-03T23:27:00.000Z" + "createdAt": "2026-05-03T23:27:00.000Z", + "updatedAt": "2026-05-03T23:28:00.000Z" } ] } diff --git a/tests/integration/__golden__/backup-parity/restore-portable.json b/tests/integration/__golden__/backup-parity/restore-portable.json index 479f19de8..5b81b82e2 100644 --- a/tests/integration/__golden__/backup-parity/restore-portable.json +++ b/tests/integration/__golden__/backup-parity/restore-portable.json @@ -1047,7 +1047,7 @@ "bodySiteEncrypted": null, "laterality": null, "createdAt": "2026-05-02T17:31:03.000Z", - "updatedAt": "", + "updatedAt": "2026-05-02T17:32:03.000Z", "deletedAt": null }, { @@ -2959,7 +2959,7 @@ "bodySite": null, "laterality": null, "createdAt": "2026-05-02T17:31:03.000Z", - "updatedAt": "", + "updatedAt": "2026-05-02T17:32:03.000Z", "dayLogs": [] }, { diff --git a/tests/integration/__golden__/backup-parity/restore-version-1.json b/tests/integration/__golden__/backup-parity/restore-version-1.json index f079e68ac..7128d4c26 100644 --- a/tests/integration/__golden__/backup-parity/restore-version-1.json +++ b/tests/integration/__golden__/backup-parity/restore-version-1.json @@ -1391,6 +1391,7 @@ "externalSource": null, "externalId": null, "updatedAt": "", + "categoryUpdatedAt": null, "name": "Ramipril", "dose": "5 mg", "active": true, @@ -1454,6 +1455,7 @@ "externalSource": null, "externalId": null, "updatedAt": "", + "categoryUpdatedAt": null, "name": "Vitamin D", "dose": "1000 IU", "active": true, @@ -1471,6 +1473,8 @@ "startsOn": "2026-07-01", "endsOn": "2026-08-01", "note": null, + "noteEncrypted": null, + "updatedAt": "", "createdAt": "" } ], @@ -1534,6 +1538,7 @@ "syncVersion": 0, "createdAt": "", "updatedAt": "", + "linkCreatedAt": [], "id": "", "date": "2026-07-03", "mood": "OKAY", @@ -1560,6 +1565,7 @@ "syncVersion": 0, "createdAt": "", "updatedAt": "", + "linkCreatedAt": [], "id": "", "date": "2026-07-02", "mood": "GOOD", @@ -1624,7 +1630,8 @@ "upperBound": 400, "panel": "Iron", "hidden": false, - "context": "fasted", + "context": null, + "contextEncrypted": "", "analyteKey": null, "aliases": [] } @@ -1802,7 +1809,8 @@ "unit": "mL", "source": "MANUAL", "externalSourceVersion": null, - "createdAt": "" + "createdAt": "", + "updatedAt": "" } ] } diff --git a/tests/integration/backup-dr-stamps-and-ciphertext.test.ts b/tests/integration/backup-dr-stamps-and-ciphertext.test.ts new file mode 100644 index 000000000..512f70636 --- /dev/null +++ b/tests/integration/backup-dr-stamps-and-ciphertext.test.ts @@ -0,0 +1,316 @@ +/** + * A disaster-recovery restore gives back the timestamps and the ciphertext the + * account had, and still takes a recovery file written before it did. + * + * - The row stamps of the medication rows (the drug, its courses, + * composition, category and category label), of the mood day context and + * tag links, of the illness and cycle symptom links and of the nutrient + * days used to come back as the restore's own time. The file now carries + * them and the restore writes them back. + * - A custom category's label, a course note and a marker's context rode a + * recovery file as readable text and were sealed again under whatever key + * was active; the file now carries the ciphertext and the restore writes + * the bytes back unchanged. + * - A recovery file without the new fields (any written before) restores + * the way it always did: the readable values sealed here, the stamps the + * restore's own. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { PrismaClient } from "@/generated/prisma/client"; +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { encrypt } from "@/lib/crypto"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { + decryptContextFromBytes, + encryptContextToBytes, +} from "@/lib/labs/biomarker-store"; + +import { + createOwner, + OWNER_ID, + seedAdminSession, + seedEveryTwoEndedModel, +} from "./backup-fixture"; +import { cookieJar, headerJar } from "./mock-next-headers"; +import { POST } from "./restore-job-driver"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const prisma = getPrismaClient(); +const CREATED = new Date("2026-03-03T03:03:03.000Z"); +const UPDATED = new Date("2026-04-04T04:04:04.000Z"); +const CONTEXT = "fasted, morning draw"; + +beforeEach(async () => { + await truncateAllTables(prisma); + cookieJar.clear(); + headerJar.clear(); +}); + +/** Give every tracked row a historical stamp, and the marker a context. */ +async function seed(): Promise { + await seedAdminSession(prisma); + await createOwner(prisma); + await seedEveryTwoEndedModel(prisma); + const owner = { userId: OWNER_ID }; + const both = { createdAt: CREATED, updatedAt: UPDATED }; + await prisma.biomarker.updateMany({ + where: owner, + data: { contextEncrypted: encryptContextToBytes(CONTEXT), ...both }, + }); + await prisma.medicationCategoryLabel.updateMany({ where: owner, data: both }); + await prisma.medicationCourse.updateMany({ where: owner, data: both }); + await prisma.medicationIngredient.updateMany({ where: owner, data: both }); + await prisma.medicationCategoryAssignment.updateMany({ + where: { medication: owner }, + data: { updatedAt: UPDATED }, + }); + await prisma.moodContext.updateMany({ where: owner, data: both }); + await prisma.moodEntryTagLink.updateMany({ + where: { moodEntry: owner }, + data: { createdAt: CREATED }, + }); + await prisma.illnessSymptomLink.updateMany({ + where: { dayLog: owner }, + data: { createdAt: CREATED }, + }); + await prisma.cycleSymptomLink.updateMany({ + where: { dayLog: owner }, + data: { createdAt: CREATED }, + }); + await prisma.nutrientIntakeDay.updateMany({ where: owner, data: both }); + // Last: the writes above do not touch the drug, this pins its own stamp. + await prisma.medication.updateMany({ + where: owner, + data: { updatedAt: UPDATED }, + }); +} + +const b64 = (bytes: Uint8Array | null) => + bytes ? Buffer.from(bytes).toString("base64") : null; + +/** The tracked stamps and ciphertext of the account, in a stable order. */ +async function tracked(client: PrismaClient) { + const owner = { userId: OWNER_ID }; + return { + medication: await client.medication.findMany({ + where: owner, + orderBy: { name: "asc" }, + select: { updatedAt: true }, + }), + assignment: await client.medicationCategoryAssignment.findMany({ + where: { medication: owner }, + select: { updatedAt: true }, + }), + labels: ( + await client.medicationCategoryLabel.findMany({ + where: owner, + select: { labelEncrypted: true, createdAt: true, updatedAt: true }, + }) + ).map((r) => ({ ...r, labelEncrypted: b64(r.labelEncrypted) })), + courses: ( + await client.medicationCourse.findMany({ + where: owner, + orderBy: { startsOn: "asc" }, + select: { noteEncrypted: true, createdAt: true, updatedAt: true }, + }) + ).map((r) => ({ ...r, noteEncrypted: b64(r.noteEncrypted) })), + ingredients: await client.medicationIngredient.findMany({ + where: owner, + select: { createdAt: true, updatedAt: true }, + }), + moodContexts: await client.moodContext.findMany({ + where: owner, + select: { createdAt: true, updatedAt: true }, + }), + moodLinks: await client.moodEntryTagLink.findMany({ + where: { moodEntry: owner }, + select: { createdAt: true }, + }), + illnessLinks: await client.illnessSymptomLink.findMany({ + where: { dayLog: owner }, + select: { createdAt: true }, + }), + cycleLinks: await client.cycleSymptomLink.findMany({ + where: { dayLog: owner }, + select: { createdAt: true }, + }), + nutrients: await client.nutrientIntakeDay.findMany({ + where: owner, + select: { createdAt: true, updatedAt: true }, + }), + markers: ( + await client.biomarker.findMany({ + where: owner, + select: { contextEncrypted: true }, + }) + ).map((r) => ({ contextEncrypted: b64(r.contextEncrypted) })), + }; +} + +async function restore(payload: unknown): Promise { + await prisma.user.delete({ where: { id: OWNER_ID } }); + await createOwner(prisma); + const backup = await prisma.dataBackup.create({ + data: { + userId: OWNER_ID, + type: "DR_STAMPS", + data: encrypt(JSON.stringify(payload)), + }, + }); + const startedAt = new Date(); + const response = await POST( + new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }) as never, + { params: Promise.resolve({ id: backup.id }) }, + ); + expect(response.status, JSON.stringify(await response.json())).toBe(200); + return startedAt; +} + +type Payload = Record & { + customMedicationCategories: Array>; + medications: Array>; + moodEntries: Array>; + illnessEpisodes: Array>; + cycleDayLogs: Array>; + nutrientDays: Array>; + biomarkers: Array>; +}; + +/** The same file as a release before this one wrote it. */ +function asOlderRecoveryFile(payload: Payload): Payload { + const older = structuredClone(payload); + for (const c of older.customMedicationCategories) { + c.label = decryptFromBytes( + Buffer.from(c.labelEncrypted as string, "base64"), + ); + delete c.labelEncrypted; + delete c.updatedAt; + } + for (const m of older.medications) { + delete m.categoryUpdatedAt; + for (const i of m.ingredients as Array>) { + delete i.updatedAt; + } + for (const c of m.courses as Array>) { + c.note = c.noteEncrypted + ? decryptFromBytes(Buffer.from(c.noteEncrypted as string, "base64")) + : null; + delete c.noteEncrypted; + delete c.updatedAt; + } + } + for (const e of older.moodEntries) { + delete e.linkCreatedAt; + const context = e.context as Record | null; + if (context) { + delete context.createdAt; + delete context.updatedAt; + } + } + for (const episode of older.illnessEpisodes) { + for (const day of episode.dayLogs as Array>) { + for (const s of day.symptoms as Array>) { + delete s.createdAt; + } + } + } + for (const day of older.cycleDayLogs) delete day.symptomLinkCreatedAt; + for (const n of older.nutrientDays) delete n.updatedAt; + for (const marker of older.biomarkers) { + marker.context = marker.contextEncrypted + ? decryptContextFromBytes( + Buffer.from(marker.contextEncrypted as string, "base64"), + ) + : null; + delete marker.contextEncrypted; + } + return older; +} + +describe("a disaster-recovery restore", () => { + it("gives back the row stamps and the ciphertext as they were", async () => { + await seed(); + const before = await tracked(prisma); + const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, { + purpose: "disaster-recovery", + }); + const file = JSON.stringify(payload); + // None of the three sealed values rides the file readable. + expect(file).not.toContain("Heart and circulation"); + expect(file).not.toContain("the March flu"); + expect(file).not.toContain(CONTEXT); + + await restore(payload); + const after = await tracked(prisma); + expect(after).toEqual(before); + // The comparison saw rows, not two empty lists. + for (const rows of Object.values(after)) + expect(rows.length).toBeGreaterThan(0); + }); + + it("takes an older recovery file the way it always did", async () => { + await seed(); + const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, { + purpose: "disaster-recovery", + }); + const startedAt = await restore(asOlderRecoveryFile(payload as Payload)); + + const label = await prisma.medicationCategoryLabel.findFirstOrThrow({ + where: { userId: OWNER_ID }, + }); + expect(decryptFromBytes(label.labelEncrypted)).toBe( + "Heart and circulation", + ); + const course = await prisma.medicationCourse.findFirstOrThrow({ + where: { userId: OWNER_ID, noteEncrypted: { not: null } }, + }); + expect(decryptFromBytes(course.noteEncrypted!)).toBe("the March flu"); + const marker = await prisma.biomarker.findFirstOrThrow({ + where: { userId: OWNER_ID }, + }); + expect(decryptContextFromBytes(marker.contextEncrypted!)).toBe(CONTEXT); + + const after = await tracked(prisma); + const restamped = [ + ...after.labels.map((r) => r.updatedAt), + ...after.courses.map((r) => r.updatedAt), + ...after.ingredients.map((r) => r.updatedAt), + ...after.assignment.map((r) => r.updatedAt), + ...after.moodContexts.map((r) => r.createdAt), + ...after.moodLinks.map((r) => r.createdAt), + ...after.illnessLinks.map((r) => r.createdAt), + ...after.cycleLinks.map((r) => r.createdAt), + ...after.nutrients.map((r) => r.updatedAt), + ]; + expect(restamped.length).toBeGreaterThan(8); + for (const at of restamped) { + expect(at.getTime()).toBeGreaterThanOrEqual(startedAt.getTime() - 1000); + } + }); +}); diff --git a/tests/integration/backup-fixture.ts b/tests/integration/backup-fixture.ts index 1ce127aee..bfe84a6c5 100644 --- a/tests/integration/backup-fixture.ts +++ b/tests/integration/backup-fixture.ts @@ -1374,17 +1374,6 @@ export async function seedAdminSession(prisma: PrismaClient) { export const COLUMN_EXCLUSIONS: Readonly> = { "MeasurementReminder.lastNotifiedAt": "operational repeat cursor, documented on the column as not in the backup: a restored reminder re-reminds at most once, on its next notify hour", - // When a join row was written. Nothing reads these: the links are read for - // which tag, symptom or context applies, never for when the row appeared, - // and the parent entry carries its own timestamps, which do travel. - "MoodContext.createdAt": - "insert stamp of the per-entry context row; no reader, the mood entry's own timestamps travel", - "MoodEntryTagLink.createdAt": - "insert stamp of a tag link; no reader, the mood entry's own timestamps travel", - "CycleSymptomLink.createdAt": - "insert stamp of a symptom link; no reader, the day log's own timestamps travel", - "IllnessSymptomLink.createdAt": - "insert stamp of a symptom link; no reader, the illness day log's own timestamps travel", }; /** diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index fcd577d42..5b7c100da 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -1924,14 +1924,26 @@ describe("every model the plan claims two-ended survives a real restore", () => Unfiled: "OTHER", Tamiflu: ROUND_TRIP_CATEGORY_KEY, }); + // A portable file carries the label readable; a disaster-recovery file + // carries its ciphertext, like every other sealed column. expect(payload.customMedicationCategories).toEqual([ - { - key: ROUND_TRIP_CATEGORY_KEY, - label: "Travel kit", - sortOrder: 0, - isActive: false, - createdAt: expect.any(String), - }, + purpose === "disaster-recovery" + ? { + key: ROUND_TRIP_CATEGORY_KEY, + label: null, + labelEncrypted: expect.any(String), + sortOrder: 0, + isActive: false, + createdAt: expect.any(String), + updatedAt: expect.any(String), + } + : { + key: ROUND_TRIP_CATEGORY_KEY, + label: "Travel kit", + sortOrder: 0, + isActive: false, + createdAt: expect.any(String), + }, ]); await prisma.user.delete({ where: { id: OWNER_ID } });