mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(backup): carry how hard a symptom hit through export and restore
A cycle symptom can be recorded with a 1-4 intensity. The log-day sheet writes it, the day-log reads it back, and the backup never carried it: the file held the symptom keys and nothing else. Export a day recorded as "cramps, and they were a 4", restore it, and you get "cramps". The symptom is there, the number is gone, and nothing fails, which is why nobody noticed. The file now carries `symptomSeverities` alongside `symptomKeys` — one entry per symptom that was actually rated. `symptomKeys` still decides which symptoms a day had, so a release that only knows that field reads every symptom out of a new file unchanged, and the two lists can never disagree about presence. A link nobody rated is simply not listed: no entry means never rated, which is not the same as rated zero. A file written before this restores exactly as it did, with every intensity absent rather than invented. An intensity outside 1-4, from a hand-edited file or some later scale, costs that one number and leaves the symptom and the rest of the restore alone.
This commit is contained in:
1 parent
340d1db6d9
commit
92da02f824
4 files changed
+407
-12
No files matched your search
@@ -87,7 +87,11 @@ describe("buildCycleBackupSection disaster-recovery mode", () => {
|
||||
deletedAt,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
symptomLinks: [{ symptom: { key: "cramps" } }],
|
||||
// One symptom the person put a number on, one they did not.
|
||||
symptomLinks: [
|
||||
{ severity: 4, symptom: { key: "cramps" } },
|
||||
{ severity: null, symptom: { key: "fatigue" } },
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
@@ -160,7 +164,10 @@ describe("buildCycleBackupSection disaster-recovery mode", () => {
|
||||
deletedAt: deletedAt.toISOString(),
|
||||
createdAt: createdAt.toISOString(),
|
||||
updatedAt: updatedAt.toISOString(),
|
||||
symptomKeys: ["cramps"],
|
||||
symptomKeys: ["cramps", "fatigue"],
|
||||
// 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 }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+61
-9
@@ -90,7 +90,30 @@ export interface CycleBackupSection {
|
||||
deletedAt?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
/**
|
||||
* Which symptoms the day carries. The single presence list — one entry per
|
||||
* link, nothing else decides whether a symptom was on the day.
|
||||
*/
|
||||
symptomKeys: string[];
|
||||
/**
|
||||
* How hard each of those symptoms hit, where the person said so.
|
||||
*
|
||||
* `CycleSymptomLink.severity` is a 1-4 Likert the log-day sheet writes and
|
||||
* the day-log DTO reads back, and the backup carried only the keys — so a
|
||||
* day recorded as "cramps, and they were a 4" came back as "cramps" and the
|
||||
* 4 was gone. Nothing failed; the number was simply not in the file.
|
||||
*
|
||||
* Kept as a SPARSE annotation over `symptomKeys` rather than folded into
|
||||
* it, for two reasons. A release that only knows `symptomKeys` still reads
|
||||
* every symptom out of a file written here, instead of finding the key it
|
||||
* looks for replaced by one it does not know and restoring a day with no
|
||||
* symptoms at all. And presence stays decided in exactly one place, so the
|
||||
* two lists cannot disagree about which symptoms a day had.
|
||||
*
|
||||
* A link with no severity is simply not listed. No entry means the person
|
||||
* never rated it — which is not the same as rating it zero.
|
||||
*/
|
||||
symptomSeverities: Array<{ key: string; severity: number }>;
|
||||
}>;
|
||||
/**
|
||||
* The account's OWN symptom definitions.
|
||||
@@ -231,6 +254,9 @@ export async function buildCycleBackupSection(
|
||||
externalId: d.externalId,
|
||||
tz: d.tz,
|
||||
symptomKeys: d.symptomLinks.map((l) => l.symptom.key),
|
||||
symptomSeverities: d.symptomLinks
|
||||
.filter((l): l is typeof l & { severity: number } => l.severity != null)
|
||||
.map((l) => ({ key: l.symptom.key, severity: l.severity })),
|
||||
})),
|
||||
customSymptoms: customSymptoms.map((sym) => ({
|
||||
...(disasterRecovery ? { id: sym.id } : {}),
|
||||
@@ -313,6 +339,25 @@ const CYCLE_GOALS = new Set<CycleTrackingGoal>([
|
||||
"OFF",
|
||||
]);
|
||||
|
||||
/**
|
||||
* A symptom severity is the 1-4 Likert the log-day sheet writes, or nothing.
|
||||
*
|
||||
* Anything else — a value from a hand-edited file, a wider scale some later
|
||||
* release might use, a null — reads as "not rated". It is dropped rather than
|
||||
* clamped or rounded into range, because a severity nobody recorded is absent,
|
||||
* and inventing a 1 or a 4 for it would be a guess printed next to the person's
|
||||
* own numbers. It never fails the restore: refusing the whole file over one
|
||||
* unfamiliar intensity would cost far more than the intensity does.
|
||||
*/
|
||||
function severityOrNull(value: number | null | undefined): number | null {
|
||||
return typeof value === "number" &&
|
||||
Number.isInteger(value) &&
|
||||
value >= 1 &&
|
||||
value <= 4
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
function enumOrNull<T extends string>(
|
||||
value: string | null | undefined,
|
||||
allow: ReadonlySet<T>,
|
||||
@@ -326,7 +371,8 @@ function enumOrNull<T extends string>(
|
||||
* Restore the cycle tables for `ownerId` from a parsed backup payload,
|
||||
* inside an existing transaction. Delete-then-recreate, matching the
|
||||
* measurement/mood restore contract. Symptom links are re-resolved against
|
||||
* the seeded catalogue by key (unknown keys dropped). `notesEncrypted` is
|
||||
* the seeded catalogue by key, and each one gets back the 1-4 intensity the
|
||||
* file recorded for it, or none if the file recorded none. `notesEncrypted` is
|
||||
* written back as the ciphertext envelope verbatim — never re-encrypted.
|
||||
*
|
||||
* Returns the wiped counts for the audit trail. A pre-v1.15 payload (empty
|
||||
@@ -468,13 +514,23 @@ 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 key was proven resolvable above, so this maps rather than filters:
|
||||
// a `.filter(Boolean)` here would silently re-open the hole that check
|
||||
// just closed.
|
||||
const symptomIds = (d.symptomKeys ?? []).map((k) => {
|
||||
const symptomLinks = (d.symptomKeys ?? []).map((k) => {
|
||||
const id = symptomIdByKey.get(k);
|
||||
if (!id) throw new Error(`Unresolved cycle symptom key: ${k}`);
|
||||
return id;
|
||||
return { symptomId: id, severity: severityByKey.get(k) ?? null };
|
||||
});
|
||||
const cycleId =
|
||||
d.cycleId !== undefined
|
||||
@@ -516,12 +572,8 @@ export async function restoreCycleData(
|
||||
deletedAt: d.deletedAt ? new Date(d.deletedAt) : null,
|
||||
...(d.createdAt ? { createdAt: new Date(d.createdAt) } : {}),
|
||||
...(d.updatedAt ? { updatedAt: new Date(d.updatedAt) } : {}),
|
||||
...(symptomIds.length > 0
|
||||
? {
|
||||
symptomLinks: {
|
||||
create: symptomIds.map((symptomId) => ({ symptomId })),
|
||||
},
|
||||
}
|
||||
...(symptomLinks.length > 0
|
||||
? { symptomLinks: { create: symptomLinks } }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -267,8 +267,26 @@ const cycleSpanSchema = z
|
||||
* ciphertext envelope verbatim — the backup never decrypts it, so the
|
||||
* owner's free-text note round-trips encrypted (and a wrong-surface leak is
|
||||
* impossible). `symptomKeys` carries the seeded catalogue keys so the
|
||||
* restore can re-link without exporting internal join ids.
|
||||
* restore can re-link without exporting internal join ids, and
|
||||
* `symptomSeverities` carries how hard each of them hit.
|
||||
*/
|
||||
/**
|
||||
* The intensity recorded against one of a day's symptoms.
|
||||
*
|
||||
* A sparse annotation over `symptomKeys`, not a replacement for it: the keys
|
||||
* still say which symptoms the day had, this says how hard the ones that were
|
||||
* rated hit. Only rated links appear, so a missing entry means the person never
|
||||
* put a number on it. `severity` is left as a plain integer here on purpose —
|
||||
* the 1-4 range is checked when the row is written, so a file carrying a value
|
||||
* outside it loses that one intensity instead of failing to restore at all.
|
||||
*/
|
||||
const cycleSymptomSeveritySchema = z
|
||||
.object({
|
||||
key: z.string().min(1),
|
||||
severity: z.number().int().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const cycleDayLogSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).optional(),
|
||||
@@ -298,6 +316,7 @@ const cycleDayLogSchema = z
|
||||
createdAt: isoDateTime.optional(),
|
||||
updatedAt: isoDateTime.optional(),
|
||||
symptomKeys: z.array(z.string()).default([]),
|
||||
symptomSeverities: z.array(cycleSymptomSeveritySchema).default([]),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* How hard a symptom hit, through the whole backup pipe.
|
||||
*
|
||||
* `CycleSymptomLink.severity` is the 1-4 intensity the log-day sheet writes and
|
||||
* the day-log DTO reads back. The backup carried only `symptomKeys`, so a day
|
||||
* recorded as "cramps, and they were a 4" exported as "cramps" and restored as
|
||||
* "cramps". The symptom was there, the number was gone, and nothing failed —
|
||||
* which is why it went unnoticed.
|
||||
*
|
||||
* These tests assert on ROWS, never on the payload object. A writer test and a
|
||||
* reader test were both green through the last defect on this exact path while
|
||||
* the assembly between them dropped the field, so the only thing worth proving
|
||||
* is what ends up in `cycle_symptom_links` after a real export and a real
|
||||
* restore: the REAL `buildFullBackupPayload` writes the file, the REAL
|
||||
* `parseBackupPayload` reads it back, and the REAL admin restore route puts the
|
||||
* rows down.
|
||||
*
|
||||
* The restore wipes day-logs and recreates them, so the links it writes are new
|
||||
* rows. To make that impossible to mistake, the live severities are overwritten
|
||||
* with a different number before the restore runs: whatever comes back came out
|
||||
* of the file.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { parseBackupPayload } from "@/lib/validations/backup";
|
||||
import { POST } from "@/app/api/admin/backups/[id]/restore/route";
|
||||
import { invalidateUserData } from "@/lib/cache/invalidate";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
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),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserData: vi.fn(),
|
||||
}));
|
||||
|
||||
const RATED_KEY = "custom:severity-rated";
|
||||
const UNRATED_KEY = "custom:severity-unrated";
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
vi.mocked(invalidateUserData).mockClear();
|
||||
});
|
||||
|
||||
function makeRequest(id: string) {
|
||||
return new Request(`http://localhost/api/admin/backups/${id}/restore`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "RESTORE" }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One account with a day-log carrying two symptoms: one rated 4, one never
|
||||
* rated. Returns the ids the assertions read back through.
|
||||
*/
|
||||
async function seedAccount(username: string) {
|
||||
const prisma = getPrismaClient();
|
||||
const owner = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
email: `${username}@example.test`,
|
||||
role: "ADMIN",
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
|
||||
const category = await prisma.cycleSymptomCategory.findFirstOrThrow();
|
||||
const rated = await prisma.cycleSymptom.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
categoryId: category.id,
|
||||
key: RATED_KEY,
|
||||
labelKey: "cycle.symptom.custom",
|
||||
labelEncrypted: encrypt("Cramps"),
|
||||
sortOrder: 1,
|
||||
},
|
||||
});
|
||||
const unrated = await prisma.cycleSymptom.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
categoryId: category.id,
|
||||
key: UNRATED_KEY,
|
||||
labelKey: "cycle.symptom.custom",
|
||||
labelEncrypted: encrypt("Tender skin"),
|
||||
sortOrder: 2,
|
||||
},
|
||||
});
|
||||
await prisma.cycleProfile.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
goal: "GENERAL_HEALTH",
|
||||
cycleTrackingEnabled: true,
|
||||
typicalCycleLength: 29,
|
||||
},
|
||||
});
|
||||
const cycle = await prisma.menstrualCycle.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
startDate: "2026-07-01",
|
||||
periodEndDate: "2026-07-05",
|
||||
tz: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
const dayLog = await prisma.cycleDayLog.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
cycleId: cycle.id,
|
||||
date: "2026-07-03",
|
||||
flow: "MEDIUM",
|
||||
source: "MANUAL",
|
||||
tz: "Europe/Berlin",
|
||||
symptomLinks: {
|
||||
create: [
|
||||
{ symptomId: rated.id, severity: 4 },
|
||||
// Never rated. NULL here is the whole point of the second case: it
|
||||
// has to come back NULL, not 0 and not 1.
|
||||
{ symptomId: unrated.id },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { prisma, owner, rated, unrated, dayLog };
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrite the live intensities so a severity that survives the restore cannot
|
||||
* have come from a row that was simply left alone.
|
||||
*/
|
||||
async function poisonLiveSeverities(userId: string) {
|
||||
const prisma = getPrismaClient();
|
||||
const links = await prisma.cycleSymptomLink.findMany({
|
||||
where: { dayLog: { userId } },
|
||||
select: { dayLogId: true, symptomId: true },
|
||||
});
|
||||
for (const link of links) {
|
||||
await prisma.cycleSymptomLink.update({
|
||||
where: {
|
||||
dayLogId_symptomId: {
|
||||
dayLogId: link.dayLogId,
|
||||
symptomId: link.symptomId,
|
||||
},
|
||||
},
|
||||
data: { severity: 1 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function storeBackup(userId: string, parsed: unknown, type: string) {
|
||||
const prisma = getPrismaClient();
|
||||
return prisma.dataBackup.create({
|
||||
data: { userId, type, data: encrypt(JSON.stringify(parsed)) },
|
||||
});
|
||||
}
|
||||
|
||||
async function severityByKey(userId: string) {
|
||||
const prisma = getPrismaClient();
|
||||
const links = await prisma.cycleSymptomLink.findMany({
|
||||
where: { dayLog: { userId } },
|
||||
select: { severity: true, symptom: { select: { key: true } } },
|
||||
});
|
||||
return Object.fromEntries(
|
||||
links.map((l) => [l.symptom.key, l.severity]),
|
||||
) as Record<string, number | null>;
|
||||
}
|
||||
|
||||
describe("a symptom's recorded intensity across export and restore", () => {
|
||||
it("comes back on the row it was recorded against", async () => {
|
||||
const { prisma, owner, dayLog } = await seedAccount("cycle-severity-owner");
|
||||
|
||||
const { payload: built } = await buildFullBackupPayload(prisma, owner.id, {
|
||||
purpose: "disaster-recovery",
|
||||
});
|
||||
const parsed = parseBackupPayload(JSON.stringify(built));
|
||||
const backup = await storeBackup(
|
||||
owner.id,
|
||||
parsed,
|
||||
"CYCLE_SYMPTOM_SEVERITY_ROUND_TRIP",
|
||||
);
|
||||
|
||||
await poisonLiveSeverities(owner.id);
|
||||
|
||||
const response = await POST(
|
||||
makeRequest(backup.id) as unknown as Parameters<typeof POST>[0],
|
||||
{ params: Promise.resolve({ id: backup.id }) },
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// Rows, not payload keys.
|
||||
const restoredDayLog = await prisma.cycleDayLog.findUniqueOrThrow({
|
||||
where: { id: dayLog.id },
|
||||
});
|
||||
expect(restoredDayLog.date).toBe("2026-07-03");
|
||||
expect(await severityByKey(owner.id)).toEqual({
|
||||
[RATED_KEY]: 4,
|
||||
[UNRATED_KEY]: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("stays absent when the file was written before intensities were carried", async () => {
|
||||
const { prisma, owner } = await seedAccount("cycle-severity-legacy");
|
||||
|
||||
const { payload: built } = await buildFullBackupPayload(prisma, owner.id, {
|
||||
purpose: "disaster-recovery",
|
||||
});
|
||||
|
||||
// A file from the release before this one: the key is not there at all.
|
||||
// Not an empty list — absent, the way an older writer left it.
|
||||
const legacy = JSON.parse(JSON.stringify(built)) as {
|
||||
cycleDayLogs: Array<Record<string, unknown>>;
|
||||
};
|
||||
for (const day of legacy.cycleDayLogs) delete day.symptomSeverities;
|
||||
expect(legacy.cycleDayLogs.some((d) => "symptomSeverities" in d)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(legacy.cycleDayLogs[0].symptomKeys).toEqual(
|
||||
expect.arrayContaining([RATED_KEY, UNRATED_KEY]),
|
||||
);
|
||||
|
||||
// The old file has to parse, not be refused for the field it never had.
|
||||
const parsed = parseBackupPayload(JSON.stringify(legacy));
|
||||
const backup = await storeBackup(
|
||||
owner.id,
|
||||
parsed,
|
||||
"CYCLE_SYMPTOM_SEVERITY_LEGACY_FILE",
|
||||
);
|
||||
|
||||
await poisonLiveSeverities(owner.id);
|
||||
|
||||
const response = await POST(
|
||||
makeRequest(backup.id) as unknown as Parameters<typeof POST>[0],
|
||||
{ params: Promise.resolve({ id: backup.id }) },
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// Both symptoms survive; neither is given a number the file never held.
|
||||
expect(await severityByKey(owner.id)).toEqual({
|
||||
[RATED_KEY]: null,
|
||||
[UNRATED_KEY]: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the symptom and drops an intensity outside the scale", async () => {
|
||||
const { prisma, owner } = await seedAccount("cycle-severity-offscale");
|
||||
|
||||
const { payload: built } = await buildFullBackupPayload(prisma, owner.id, {
|
||||
purpose: "disaster-recovery",
|
||||
});
|
||||
|
||||
// A hand-edited file, or one from some later release with a wider scale.
|
||||
// Refusing the whole restore over it would cost the account everything to
|
||||
// save one number.
|
||||
const offScale = JSON.parse(JSON.stringify(built)) as {
|
||||
cycleDayLogs: Array<{
|
||||
symptomSeverities: Array<{ key: string; severity: number }>;
|
||||
}>;
|
||||
};
|
||||
for (const day of offScale.cycleDayLogs) {
|
||||
day.symptomSeverities = [
|
||||
{ key: RATED_KEY, severity: 9 },
|
||||
// Names a symptom this day does not carry — nothing to attach it to.
|
||||
{ key: "custom:not-on-this-day", severity: 2 },
|
||||
];
|
||||
}
|
||||
|
||||
const parsed = parseBackupPayload(JSON.stringify(offScale));
|
||||
const backup = await storeBackup(
|
||||
owner.id,
|
||||
parsed,
|
||||
"CYCLE_SYMPTOM_SEVERITY_OFF_SCALE",
|
||||
);
|
||||
|
||||
await poisonLiveSeverities(owner.id);
|
||||
|
||||
const response = await POST(
|
||||
makeRequest(backup.id) as unknown as Parameters<typeof POST>[0],
|
||||
{ params: Promise.resolve({ id: backup.id }) },
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
expect(await severityByKey(owner.id)).toEqual({
|
||||
[RATED_KEY]: null,
|
||||
[UNRATED_KEY]: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user