mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
fix(backup): restore row stamps and carry sealed labels, course notes and marker context as ciphertext
A disaster-recovery restore wrote these rows stamped with the restore's own time: the medication's updatedAt (moved again by the course window), the category assignment, custom category labels, courses and composition, the mood day context and tag links, illness and cycle symptom links, nutrient days, and a flare's updatedAt after the parent pointer pass. The recovery file now carries each stamp (additive fields: linkCreatedAt on a mood entry, symptomLinkCreatedAt on a cycle day log, createdAt on an illness symptom, categoryUpdatedAt on a medication, updatedAt where missing) and the restore writes it back. A file without them restores as before. A custom category label, a course note and a marker's context rode the recovery file as readable text and were sealed again under the active key. They now ride as ciphertext (labelEncrypted, noteEncrypted, contextEncrypted) and come back as the same bytes; a file that carries the readable value instead is sealed on the way in, as before. The portable file keeps the readable values. The rotation script and the encrypted-column writer guard already cover all three columns. The parity golden copies change for exactly this: the new fields in the recovery file, the restored stamps in place of <now>, and the three values coming back under the key they were sealed with. The legacy readable coach title and custom metric note are still sealed on restore, since the row holds no ciphertext to carry. The round-trip column audit no longer excludes the four link stamps.
This commit is contained in:
1 parent
6c4c097517
commit
811fcf5509
29 files changed
+901
-154
No files matched your search
+72
-2
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -55,18 +55,12 @@ const EXCLUDED_MOOD_CONTEXT_COLUMNS: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
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 {
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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). */
|
||||
|
||||
+59
-20
@@ -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<T extends string>(
|
||||
: 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<string, string>,
|
||||
) {
|
||||
const severityByKey = new Map<string, number>();
|
||||
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<string, number>();
|
||||
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
|
||||
|
||||
@@ -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" }
|
||||
: {}),
|
||||
},
|
||||
]);
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> {
|
||||
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) }
|
||||
|
||||
@@ -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:<uuid>`, 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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, string>();
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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<void> {
|
||||
source: n.source,
|
||||
externalSourceVersion: n.externalSourceVersion ?? null,
|
||||
...(n.createdAt ? { createdAt: new Date(n.createdAt) } : {}),
|
||||
...(n.updatedAt ? { updatedAt: new Date(n.updatedAt) } : {}),
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -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() }
|
||||
: {}),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -295,10 +295,12 @@
|
||||
"customMedicationCategories": [
|
||||
{
|
||||
"key": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10",
|
||||
"label": "Heart and circulation",
|
||||
"label": null,
|
||||
"labelEncrypted": "<sealed:v1:Heart and circulation>",
|
||||
"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": "<id:12>",
|
||||
"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": "<sealed:v1:the March flu>",
|
||||
"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": "<id:26>",
|
||||
"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": "<id:27>",
|
||||
"date": "2026-07-01",
|
||||
"mood": "GUT",
|
||||
@@ -673,6 +688,8 @@
|
||||
"structuredTags": [],
|
||||
"context": {
|
||||
"notesEncrypted": "<sealed:v1:rt-MoodContext-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": "<sealed:v1:fasted, morning draw>",
|
||||
"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": "<sealed:v1:the March flu>",
|
||||
"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": "<sealed:v1:Heart and circulation>",
|
||||
"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": "<sealed:v1:rt-MoodContext-notesEncrypted>"
|
||||
"notesEncrypted": "<sealed:v1:rt-MoodContext-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": "<sealed:v1:fasted, morning draw>",
|
||||
"aliases": [
|
||||
{
|
||||
"id": "<id:40>",
|
||||
@@ -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": {
|
||||
|
||||
@@ -629,7 +629,7 @@
|
||||
"externalSource": "WEB",
|
||||
"externalId": "rt-Medication-externalId",
|
||||
"createdAt": "2026-05-01T11:50:00.000Z",
|
||||
"updatedAt": "<now>"
|
||||
"updatedAt": "2026-05-01T11:51:00.000Z"
|
||||
}
|
||||
],
|
||||
"MedicationSchedule": [
|
||||
@@ -778,7 +778,7 @@
|
||||
{
|
||||
"medicationId": "<id:7>",
|
||||
"category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10",
|
||||
"updatedAt": "<now>"
|
||||
"updatedAt": "2026-05-01T21:22:00.000Z"
|
||||
}
|
||||
],
|
||||
"MedicationCategoryLabel": [
|
||||
@@ -786,11 +786,11 @@
|
||||
"id": "<id:17>",
|
||||
"userId": "round-trip-owner",
|
||||
"key": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10",
|
||||
"labelEncrypted": "<sealed:v2:Heart and circulation>",
|
||||
"labelEncrypted": "<sealed:v1:Heart and circulation>",
|
||||
"sortOrder": 2,
|
||||
"isActive": false,
|
||||
"createdAt": "2026-05-01T23:06:00.000Z",
|
||||
"updatedAt": "<now>"
|
||||
"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": "<sealed:v2:the March flu>",
|
||||
"noteEncrypted": "<sealed:v1:the March flu>",
|
||||
"createdAt": "2026-05-02T00:46:00.000Z",
|
||||
"updatedAt": "<now>"
|
||||
"updatedAt": "2026-05-02T00:47:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "<id:19>",
|
||||
@@ -812,7 +812,7 @@
|
||||
"endsOn": "2026-06-16T00:00:00.000Z",
|
||||
"noteEncrypted": null,
|
||||
"createdAt": "2026-05-02T00:46:01.000Z",
|
||||
"updatedAt": "<now>"
|
||||
"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": "<now>"
|
||||
"updatedAt": "2026-05-02T02:30:00.000Z"
|
||||
}
|
||||
],
|
||||
"MoodEntry": [
|
||||
@@ -902,15 +902,15 @@
|
||||
"eventValence": 3,
|
||||
"eventAt": "2026-06-30T12:34:56.789Z",
|
||||
"notesEncrypted": "<sealed:v1:rt-MoodContext-notesEncrypted>",
|
||||
"createdAt": "<now>",
|
||||
"updatedAt": "<now>"
|
||||
"createdAt": "2026-05-02T06:00:00.000Z",
|
||||
"updatedAt": "2026-05-02T06:01:00.000Z"
|
||||
}
|
||||
],
|
||||
"MoodEntryTagLink": [
|
||||
{
|
||||
"moodEntryId": "<id:21>",
|
||||
"moodTagId": "<id:23>",
|
||||
"createdAt": "<now>",
|
||||
"createdAt": "2026-05-02T07:22:00.000Z",
|
||||
"rating": 4
|
||||
}
|
||||
],
|
||||
@@ -987,7 +987,7 @@
|
||||
"unit": "ng/mL",
|
||||
"lowerBound": 2.5,
|
||||
"upperBound": 2.5,
|
||||
"contextEncrypted": "<sealed:v2:fasted, morning draw>",
|
||||
"contextEncrypted": "<sealed:v1:fasted, morning draw>",
|
||||
"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": "<now>",
|
||||
"updatedAt": "2026-05-02T17:32:03.000Z",
|
||||
"deletedAt": null
|
||||
},
|
||||
{
|
||||
@@ -1150,7 +1150,7 @@
|
||||
"dayLogId": "<id:36>",
|
||||
"symptomId": "<id:38>",
|
||||
"severity": 2,
|
||||
"createdAt": "<now>"
|
||||
"createdAt": "2026-05-02T20:43:00.000Z"
|
||||
}
|
||||
],
|
||||
"SymptomDefinition": [
|
||||
@@ -1449,7 +1449,7 @@
|
||||
"dayLogId": "<id:54>",
|
||||
"symptomId": "<id:56>",
|
||||
"severity": 3,
|
||||
"createdAt": "<now>"
|
||||
"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": "<now>"
|
||||
"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": "<sealed:v1:Heart and circulation>",
|
||||
"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": "<now>",
|
||||
"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": "<id:12>",
|
||||
"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": "<sealed:v1:the March flu>",
|
||||
"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": "<id:26>",
|
||||
"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": "<id:27>",
|
||||
"date": "2026-07-01",
|
||||
"mood": "GUT",
|
||||
@@ -3568,6 +3583,8 @@
|
||||
"structuredTags": [],
|
||||
"context": {
|
||||
"notesEncrypted": "<sealed:v1:rt-MoodContext-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": "<sealed:v1:fasted, morning draw>",
|
||||
"analyteKey": "rt-Biomarker-analyteKey",
|
||||
"aliases": [
|
||||
{
|
||||
@@ -3866,7 +3891,7 @@
|
||||
"bodySiteEncrypted": null,
|
||||
"laterality": null,
|
||||
"createdAt": "2026-05-02T17:31:03.000Z",
|
||||
"updatedAt": "<now>",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1047,7 +1047,7 @@
|
||||
"bodySiteEncrypted": null,
|
||||
"laterality": null,
|
||||
"createdAt": "2026-05-02T17:31:03.000Z",
|
||||
"updatedAt": "<now>",
|
||||
"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": "<now>",
|
||||
"updatedAt": "2026-05-02T17:32:03.000Z",
|
||||
"dayLogs": []
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1391,6 +1391,7 @@
|
||||
"externalSource": null,
|
||||
"externalId": null,
|
||||
"updatedAt": "<now>",
|
||||
"categoryUpdatedAt": null,
|
||||
"name": "Ramipril",
|
||||
"dose": "5 mg",
|
||||
"active": true,
|
||||
@@ -1454,6 +1455,7 @@
|
||||
"externalSource": null,
|
||||
"externalId": null,
|
||||
"updatedAt": "<now>",
|
||||
"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": "<now>",
|
||||
"createdAt": "<now>"
|
||||
}
|
||||
],
|
||||
@@ -1534,6 +1538,7 @@
|
||||
"syncVersion": 0,
|
||||
"createdAt": "<now>",
|
||||
"updatedAt": "<now>",
|
||||
"linkCreatedAt": [],
|
||||
"id": "<id:10>",
|
||||
"date": "2026-07-03",
|
||||
"mood": "OKAY",
|
||||
@@ -1560,6 +1565,7 @@
|
||||
"syncVersion": 0,
|
||||
"createdAt": "<now>",
|
||||
"updatedAt": "<now>",
|
||||
"linkCreatedAt": [],
|
||||
"id": "<id:11>",
|
||||
"date": "2026-07-02",
|
||||
"mood": "GOOD",
|
||||
@@ -1624,7 +1630,8 @@
|
||||
"upperBound": 400,
|
||||
"panel": "Iron",
|
||||
"hidden": false,
|
||||
"context": "fasted",
|
||||
"context": null,
|
||||
"contextEncrypted": "<sealed:v2:fasted>",
|
||||
"analyteKey": null,
|
||||
"aliases": []
|
||||
}
|
||||
@@ -1802,7 +1809,8 @@
|
||||
"unit": "mL",
|
||||
"source": "MANUAL",
|
||||
"externalSourceVersion": null,
|
||||
"createdAt": "<now>"
|
||||
"createdAt": "<now>",
|
||||
"updatedAt": "<now>"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<Date> {
|
||||
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<string, unknown> & {
|
||||
customMedicationCategories: Array<Record<string, unknown>>;
|
||||
medications: Array<Record<string, unknown>>;
|
||||
moodEntries: Array<Record<string, unknown>>;
|
||||
illnessEpisodes: Array<Record<string, unknown>>;
|
||||
cycleDayLogs: Array<Record<string, unknown>>;
|
||||
nutrientDays: Array<Record<string, unknown>>;
|
||||
biomarkers: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
/** 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<Record<string, unknown>>) {
|
||||
delete i.updatedAt;
|
||||
}
|
||||
for (const c of m.courses as Array<Record<string, unknown>>) {
|
||||
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<string, unknown> | null;
|
||||
if (context) {
|
||||
delete context.createdAt;
|
||||
delete context.updatedAt;
|
||||
}
|
||||
}
|
||||
for (const episode of older.illnessEpisodes) {
|
||||
for (const day of episode.dayLogs as Array<Record<string, unknown>>) {
|
||||
for (const s of day.symptoms as Array<Record<string, unknown>>) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1374,17 +1374,6 @@ export async function seedAdminSession(prisma: PrismaClient) {
|
||||
export const COLUMN_EXCLUSIONS: Readonly<Record<string, string>> = {
|
||||
"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",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
Reference in new issue
Block a user