diff --git a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts index 224e1148d..c34397817 100644 --- a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts +++ b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts @@ -259,6 +259,10 @@ function sourceClient() { }, ]), }, + // The setup answers. Absent for this fixture's account, which is the + // ordinary case: the export has to answer for a record that never entered + // the flow without inventing one. + onboardingRecord: { findUnique: vi.fn().mockResolvedValue(null) }, // The score as it was SHOWN on a local day. Not recomputable: today's // number always comes from today's rows, so once the readings behind this // day moved, nothing can reproduce what it said. diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 409a5b345..2a2049d47 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -57,6 +57,7 @@ import { import { restoreProfileData } from "@/lib/export/profile-backup"; import { restoreIntradayProfileData } from "@/lib/export/intraday-profile-backup"; import { restoreHealthScoreData } from "@/lib/export/health-score-backup"; +import { restoreOnboardingData } from "@/lib/export/onboarding-backup"; import { restoreVisitsData } from "@/lib/export/visits-backup"; import { restoreVaccinationsData } from "@/lib/export/vaccinations-backup"; import { restoreSensitiveData } from "@/lib/export/sensitive-backup"; @@ -111,6 +112,7 @@ interface RestoreResponse { correlationPatterns: number; intradayProfiles: number; healthScoreRecords: number; + onboardingRecords: number; practitioners: number; encounters: number; encounterLinks: number; @@ -1377,6 +1379,14 @@ const handler = apiHandler( payload, ); + // The needs-based setup answers. Both ends live in + // `src/lib/export/onboarding-backup.ts`, same as the three above. + const onboardingCleared = await restoreOnboardingData( + tx, + ownerId, + payload, + ); + const biomarkerByName = new Map(); const restoredBiomarkerIds = new Set(); for (const biomarker of payload.biomarkers) { @@ -1979,6 +1989,7 @@ const handler = apiHandler( correlationPatterns: profileCleared.correlationPatterns, intradayProfiles: intradayCleared.intradayProfiles, healthScoreRecords: healthScoreCleared.healthScoreRecords, + onboardingRecords: onboardingCleared.onboardingRecords, practitioners: visitsCleared.practitioners, encounters: visitsCleared.encounters, encounterLinks: visitsCleared.encounterLinks, diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index f3ca60494..4f6ea8736 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -28,6 +28,7 @@ vi.mock("@/lib/db", () => ({ // `buildIntradayProfileBackupSection`. intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) }, healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) }, + onboardingRecord: { findUnique: vi.fn().mockResolvedValue(null) }, // The visit tables. Empty here: this file asserts the sections it seeds, // and an account with no visits is the ordinary case the builder must // still answer for. diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index f24aef437..d98b3ebf5 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -26,6 +26,7 @@ vi.mock("@/lib/db", () => ({ // `buildIntradayProfileBackupSection`. intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) }, healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) }, + onboardingRecord: { findUnique: vi.fn().mockResolvedValue(null) }, // The visit tables. Empty here: this file asserts the sections it seeds, // and an account with no visits is the ordinary case the builder must // still answer for. diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 43fab3294..3d71f5cab 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -213,6 +213,12 @@ export const WIPE_MODELS = [ "AuditLog", "ConsentReceipt", "UserKnownDevice", + // v1.39 — the setup answers and the step ledger. The USER_RESET block below + // already puts the wizard of today back to the start; this is the same + // decision for the needs-based flow, and it belongs on the same side of the + // line for the same reason: the record the answers were about is gone, so + // the answers describe nothing. + "OnboardingRecord", // ── Parents, last ─────────────────────────────────────────────────────── "Medication", diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 99d035567..93626363b 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -373,6 +373,7 @@ function makePrisma() { ]), }, healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) }, + onboardingRecord: { findUnique: vi.fn().mockResolvedValue(null) }, // The visit tables. Empty here: this file asserts the sections it seeds, // and an account with no visits is the ordinary case the builder must // still answer for. diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 5f47cadf2..8ed0c9065 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -176,6 +176,12 @@ export const BACKED_UP_MODELS = [ // for a restored preference to attach to. "ReminderPhaseConfig", "ConsentReceipt", + // The setup answers. Nothing recomputes them: the module map they produced + // survives on the `User` row, but the map says what is switched on, not that + // the person said they take medication daily and have a visit next month. A + // restore without the row opens a blank questionnaire for somebody who has + // already answered it. + "OnboardingRecord", ] as const; /** @@ -236,6 +242,9 @@ export const BACKUP_WRITER_FILES: readonly string[] = [ // the reason the visits comment gives. "src/lib/export/awards-backup.ts", "src/lib/export/environment-backup.ts", + // The needs-based setup answers, both ends beside each other for the reason + // the visits comment gives. + "src/lib/export/onboarding-backup.ts", // The ECG strips, same arrangement again. The recording reads through its // OWN delegate here rather than riding the measurement it references, // because that reference is a pointer between two independently carried @@ -257,6 +266,7 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [ "src/lib/export/document-filing-backup.ts", "src/lib/export/awards-backup.ts", "src/lib/export/environment-backup.ts", + "src/lib/export/onboarding-backup.ts", "src/lib/export/ecg-backup.ts", "src/lib/cycle/backup.ts", ]; @@ -495,6 +505,18 @@ export const TWO_ENDED_MODELS = [ // theoretical. The reference is resolved against what the restore wrote, // nulled when it cannot be, and reported either way. "EcgRecording", + // The setup answers, carried from the release that introduces them. There is + // no debt entry to write instead: the row is one small object with no + // reference to resolve, so "carried" and "owed" would have cost the same + // afternoon and only one of them returns a record that can say "set up + // again" and mean it. + // + // `modulesDerivedAt` is the field that makes this more than history. It is + // the once-only latch on the module derivation, so a restore that let it + // default to null would hand the next confirm permission to re-apply the + // questionnaire over every module decision taken in Settings since. It is + // carried and asserted, not defaulted. + "OnboardingRecord", ] as const; /** One model claimed to travel both ways. */ diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 9aa30beb9..89f63c045 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -112,6 +112,12 @@ import { type IntradayProfileBackupCounts, type IntradayProfileBackupSection, } from "@/lib/export/intraday-profile-backup"; +import { + buildOnboardingBackupSection, + countOnboardingBackupSection, + type OnboardingBackupCounts, + type OnboardingBackupSection, +} from "@/lib/export/onboarding-backup"; export interface FullBackupCounts extends @@ -128,6 +134,7 @@ export interface FullBackupCounts DocumentFilingBackupCounts, AwardsBackupCounts, EnvironmentBackupCounts, + OnboardingBackupCounts, EcgBackupCounts { measurements: number; medications: number; @@ -696,6 +703,7 @@ export async function buildFullBackupPayload( documentFiling, awards, environment, + onboardingRecord, ecg, nutrientDays, ] = await Promise.all([ @@ -860,6 +868,12 @@ export async function buildFullBackupPayload( // `src/lib/export/environment-backup.ts`, and the purpose is absent for // the same reason as the awards above. buildEnvironmentBackupSection(prisma, userId), + // The needs-based setup answers. One small row, and the only copy of what + // the person told the flow. Both ends live in + // `src/lib/export/onboarding-backup.ts` beside each other. + buildOnboardingBackupSection(prisma, userId, { + purpose: disasterRecovery ? "disaster-recovery" : "portable-export", + }), // The ECG strips, their rhythm verdicts and their traces. Both ends live // in `src/lib/export/ecg-backup.ts` beside each other, the same // arrangement as the sections above and for the same reason. @@ -892,6 +906,7 @@ export async function buildFullBackupPayload( const profileSection: ProfileBackupSection = profile; const intradaySection: IntradayProfileBackupSection = intradayProfiles; const healthScoreSection: HealthScoreBackupSection = healthScoreRecords; + const onboardingSection: OnboardingBackupSection = onboardingRecord; const visitsSection: VisitsBackupSection = visits; const vaccinationsSection: VaccinationsBackupSection = vaccinations; const remindersSection: RemindersBackupSection = reminders; @@ -1238,6 +1253,7 @@ export async function buildFullBackupPayload( ...profileSection, ...intradaySection, ...healthScoreSection, + ...onboardingSection, ...visitsSection, ...vaccinationsSection, ...remindersSection, @@ -1306,6 +1322,7 @@ export async function buildFullBackupPayload( ...countProfileBackupSection(profile), ...countIntradayProfileBackupSection(intradayProfiles), ...countHealthScoreBackupSection(healthScoreRecords), + ...countOnboardingBackupSection(onboardingRecord), ...countVisitsBackupSection(visits), ...countVaccinationsBackupSection(vaccinations), ...countRemindersBackupSection(reminders), diff --git a/src/lib/export/onboarding-backup.ts b/src/lib/export/onboarding-backup.ts new file mode 100644 index 000000000..48c3e31af --- /dev/null +++ b/src/lib/export/onboarding-backup.ts @@ -0,0 +1,199 @@ +/** + * The needs-based setup answers, with both backup ends in one file. + * + * The builder and the restore live together deliberately, the way + * `health-score-backup.ts` and `src/lib/cycle/backup.ts` do: a reader asking + * "is this carried at both ends?" answers it here, and a reader who greps only + * the restore ROUTE gets a false negative, because the route delegates. + * + * Why it is `BACKED_UP` rather than `DERIVED`. Nothing recomputes an answer. + * The module map the answers produced survives on the `User` row, but the map + * is not the answers: it says what is switched on, not that the person said + * they take medication daily and have a visit next month. Losing the row would + * make "Set up again" open a blank questionnaire for someone who already + * answered it, and would leave the getting-started checklist ordering itself + * from nothing. It is one small row and it is the only copy. + * + * Nothing here is encrypted, so there is no portable / disaster-recovery split + * on the values: the three JSON columns hold closed enum vocabularies rather + * than free text. The only difference between the two purposes is identity — a + * canonical disaster-recovery payload carries the row id and its timestamps so + * the row comes back as itself. + */ +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; + +import { + parseOnboardingFirstResult, + parseOnboardingNeeds, + parseOnboardingSteps, + type OnboardingFirstResult, + type OnboardingNeeds, + type OnboardingStepState, +} from "@/lib/onboarding/needs"; + +export interface OnboardingBackupOptions { + purpose?: "portable-export" | "disaster-recovery"; +} + +/** The record's setup state, exactly as it was answered. */ +export interface OnboardingBackupEntry { + /** Present in canonical DR payloads so the row keeps a stable identity. */ + id?: string; + /** + * Read through the same fail-soft parsers the account payload uses rather + * than copied out of the column raw. A blob written by a release that named + * an answer this one retired therefore reaches the file as the answers this + * release can honour, and a restore cannot re-import a value its own + * validation would refuse. + */ + needs: OnboardingNeeds; + steps: OnboardingStepState[]; + firstResult: OnboardingFirstResult | null; + /** + * When the module map was derived. Carried, and this is the field that makes + * the row worth carrying rather than defaulting: a restore that dropped it + * would let the next confirm re-derive the whole map over whatever the + * account has changed in Settings since. + */ + modulesDerivedAt: string | null; + completedAt: string | null; + createdAt?: string; + updatedAt?: string; +} + +export interface OnboardingBackupSection { + onboardingRecord: OnboardingBackupEntry | null; +} + +export interface OnboardingBackupCounts { + onboardingRecords: number; +} + +/** + * Build the setup-answers slice of a user's full backup. + * + * Takes the delegate it uses rather than a whole `PrismaClient`, matching the + * other section builders, so the route's global client and the worker's local + * one share one read. + */ +export async function buildOnboardingBackupSection( + prisma: Pick, + userId: string, + options: OnboardingBackupOptions = {}, +): Promise { + const disasterRecovery = options.purpose === "disaster-recovery"; + + const row = await prisma.onboardingRecord.findUnique({ where: { userId } }); + if (!row) return { onboardingRecord: null }; + + return { + onboardingRecord: { + ...(disasterRecovery + ? { + id: row.id, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } + : {}), + needs: parseOnboardingNeeds(row.needsJson), + steps: parseOnboardingSteps(row.stepsJson), + firstResult: parseOnboardingFirstResult(row.firstResultJson), + modulesDerivedAt: row.modulesDerivedAt?.toISOString() ?? null, + completedAt: row.completedAt?.toISOString() ?? null, + }, + }; +} + +/** Row counts for the audit trail, mirroring the other section counters. */ +export function countOnboardingBackupSection( + section: OnboardingBackupSection, +): OnboardingBackupCounts { + return { onboardingRecords: section.onboardingRecord ? 1 : 0 }; +} + +/** Counts the setup-state restore wiped, for the audit trail. */ +export interface OnboardingRestoreCleared { + onboardingRecords: number; +} + +/** + * One entry as the parsed FILE carries it. + * + * The three answer objects arrive as opaque JSON rather than as the typed + * shapes the builder wrote, and that is deliberate: the file's Zod schema + * validates them loosely so a value this release retired cannot refuse a whole + * account's restore, and the narrowing happens below, through the same + * fail-soft parsers the account payload reads with. A file hand-edited to name + * an area that no longer exists therefore restores without it, rather than + * either failing or writing a value nothing can read. + */ +export interface OnboardingRestoreEntry { + id?: string; + needs: unknown; + steps: unknown; + firstResult: unknown; + modulesDerivedAt: string | null; + completedAt: string | null; + createdAt?: string; + updatedAt?: string; +} + +/** + * The slice of a parsed backup this restore consumes. + * + * Required rather than optional: `BackupPayload` defaults it, so every caller + * already satisfies this, and a caller that stops satisfying it fails to + * compile instead of passing `undefined` into a branch that does nothing and + * reports success. + */ +export interface OnboardingRestoreInput { + onboardingRecord: OnboardingRestoreEntry | null; +} + +/** + * Re-create the account's setup state. + * + * Delete-then-recreate inside the caller's transaction, matching every other + * section. A file carrying no row leaves the account with none, which is the + * honest state for a record that never entered the flow — and the delete still + * runs, because the restore replaces the account's data rather than merging + * into it. + */ +export async function restoreOnboardingData( + tx: Prisma.TransactionClient, + ownerId: string, + payload: OnboardingRestoreInput, +): Promise { + const cleared = await tx.onboardingRecord.deleteMany({ + where: { userId: ownerId }, + }); + + const entry = payload.onboardingRecord; + if (entry) { + const firstResult = parseOnboardingFirstResult(entry.firstResult); + await tx.onboardingRecord.create({ + data: { + ...(entry.id ? { id: entry.id } : {}), + userId: ownerId, + needsJson: parseOnboardingNeeds( + entry.needs, + ) as unknown as Prisma.InputJsonValue, + stepsJson: parseOnboardingSteps( + entry.steps, + ) as unknown as Prisma.InputJsonValue, + firstResultJson: + firstResult === null + ? undefined + : (firstResult as unknown as Prisma.InputJsonValue), + modulesDerivedAt: entry.modulesDerivedAt + ? new Date(entry.modulesDerivedAt) + : null, + completedAt: entry.completedAt ? new Date(entry.completedAt) : null, + ...(entry.createdAt ? { createdAt: new Date(entry.createdAt) } : {}), + ...(entry.updatedAt ? { updatedAt: new Date(entry.updatedAt) } : {}), + }, + }); + } + + return { onboardingRecords: cleared.count }; +} diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 9be51be04..d163e6e36 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -886,6 +886,33 @@ const healthScoreRecordBackupSchema = z }) .passthrough(); +/** + * v1.39 (C1) — the needs-based setup state. One row per record. + * + * The three answer objects are validated loosely on purpose: the closed + * vocabularies live in `src/lib/onboarding/needs.ts`, and the restore reads + * every one of them back through the same fail-soft parsers the account + * payload uses. Duplicating the enums here would give the file a second, + * quietly divergent opinion about what an answer is, and the failure mode of + * being stricter here is refusing a whole account's restore over one retired + * chip. + */ +const onboardingRecordBackupSchema = z + .object({ + id: z.string().min(1).optional(), + needs: z.record(z.string(), z.unknown()).default({}), + steps: z.array(z.record(z.string(), z.unknown())).default([]), + firstResult: z.record(z.string(), z.unknown()).nullable().default(null), + // The once-only latch on the module derivation. Carried rather than + // defaulted: a null here would hand the next confirm permission to + // re-apply the questionnaire over every module decision made since. + modulesDerivedAt: isoDateTime.nullable().default(null), + completedAt: isoDateTime.nullable().default(null), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), + }) + .passthrough(); + const appSettingsBackupSchema = z .object({ id: z.string().min(1), @@ -1668,6 +1695,10 @@ export const backupPayloadSchema = z // the sections above: a file written before the table existed carries no // key, and an account whose score never resolved writes []. healthScoreRecords: z.array(healthScoreRecordBackupSchema).default([]), + // The needs-based setup answers. Defaulted to null for the same reason as + // the sections above: a file written before the table existed carries no + // key, and a record that never entered the flow has no row. + onboardingRecord: onboardingRecordBackupSchema.nullable().default(null), // Visits, the address book behind them, and the three link tables. // Defaulted for the same reason as the sections above: a file written // before the tables existed carries no key, and an account that has never @@ -1796,6 +1827,8 @@ export interface BackupSummary { intradayProfiles: number; /** Local days whose health score was written down as it was shown. */ healthScoreRecords: number; + /** 1 when the record's setup answers ride the file, 0 otherwise. */ + onboardingRecords: number; /** v1.37.19 (A6-8) — the visit address book. */ practitioners: number; /** v1.37.19 (A6-8) — doctor visits, planned and past. */ @@ -1884,6 +1917,7 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary { correlationPatterns: payload.correlationPatterns.length, intradayProfiles: payload.intradayProfiles.length, healthScoreRecords: payload.healthScoreRecords.length, + onboardingRecords: payload.onboardingRecord ? 1 : 0, // v1.37.19 (A6-8) — the sections restored since 08-01 were written and // restored but absent from this report, so the admin's "what did I just // restore" answer silently under-counted a file that carried visits or diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 7ecb17c1a..8376beb3e 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -239,6 +239,8 @@ const COUNT_BACK: Record< p.medicationEfficacyTarget.count({ where: { medication: { userId } } }), MedicationScheduleRevision: (p, userId) => p.medicationScheduleRevision.count({ where: { medication: { userId } } }), + OnboardingRecord: (p, userId) => + p.onboardingRecord.count({ where: { userId } }), }; /** @@ -743,6 +745,35 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { lastComputedAt: AT("2026-07-01T00:00:00.000Z"), }, }); + await prisma.onboardingRecord.create({ + data: { + userId: OWNER_ID, + needsJson: { + recordTarget: "me", + areas: ["blood-pressure", "sleep"], + medication: "yes", + sources: ["withings"], + visit: "within-a-month", + units: { glucoseUnit: "mmol/L", unitPreference: "metric" }, + }, + stepsJson: [ + { id: "who", status: "done" }, + { id: "areas", status: "done" }, + { id: "sources", status: "skipped" }, + ], + firstResultJson: { + task: "connect-source", + target: "withings", + completedAt: "2026-07-01T08:30:00.000Z", + }, + // The once-only latch on the module derivation. Seeded non-null so the + // assertion after the restore can show it came back rather than + // defaulting — a null here would hand the next confirm permission to + // re-apply the questionnaire over every module decision made since. + modulesDerivedAt: AT("2026-07-01T08:00:00.000Z"), + completedAt: AT("2026-07-01T08:00:00.000Z"), + }, + }); await prisma.healthScoreRecord.create({ data: { userId: OWNER_ID, @@ -1413,6 +1444,55 @@ describe("every model the plan claims two-ended survives a real restore", () => { pausedAt: "2026-07-15T08:00:00.000Z", resumedAt: null }, ]); + // The setup answers came back as answers, not as an empty questionnaire. + // + // Asserted column by column rather than by count: the row returning proves + // nothing about `modulesDerivedAt`, and that field is the once-only latch + // on the module derivation — a restore that let it default to null would + // hand the next confirm permission to re-apply the questionnaire over + // every module decision the account has taken since, silently. + const restoredOnboarding = await prisma.onboardingRecord.findUniqueOrThrow({ + where: { userId: OWNER_ID }, + }); + expect({ + needs: restoredOnboarding.needsJson, + steps: restoredOnboarding.stepsJson, + firstResult: restoredOnboarding.firstResultJson, + modulesDerivedAt: restoredOnboarding.modulesDerivedAt?.toISOString(), + completedAt: restoredOnboarding.completedAt?.toISOString(), + }).toEqual({ + needs: { + recordTarget: "me", + areas: ["blood-pressure", "sleep"], + medication: "yes", + sources: ["withings"], + visit: "within-a-month", + units: { glucoseUnit: "mmol/L", unitPreference: "metric" }, + }, + // The stored blob is a status LOOKUP, and the reader answers with the + // full ordered nine — a step the fixture never mentioned comes back + // `pending` rather than missing, which is what lets the flow grow a + // screen without a migration. + steps: [ + { id: "who", status: "done" }, + { id: "areas", status: "done" }, + { id: "medication", status: "pending" }, + { id: "sources", status: "skipped" }, + { id: "visit", status: "pending" }, + { id: "units", status: "pending" }, + { id: "confirm", status: "pending" }, + { id: "first-result", status: "pending" }, + { id: "done", status: "pending" }, + ], + firstResult: { + task: "connect-source", + target: "withings", + completedAt: "2026-07-01T08:30:00.000Z", + }, + modulesDerivedAt: "2026-07-01T08:00:00.000Z", + completedAt: "2026-07-01T08:00:00.000Z", + }); + // The Coach came back able to speak, and the fence held. // // `documentScoped` is asserted per thread rather than in aggregate: a