Merge the needs-based onboarding's server state and the derivation that never hides data

This commit is contained in:
Marc-André Bombeck committed 2026-09-10 12:23:34 +02:00
commit 3017e24bb1
41 files changed
+5272 -83

No files matched your search

@@ -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.
+22
View File
@@ -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. */
+17
View File
@@ -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),
+199
View File
@@ -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<PrismaClient, "onboardingRecord">,
userId: string,
options: OnboardingBackupOptions = {},
): Promise<OnboardingBackupSection> {
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<OnboardingRestoreCleared> {
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 };
}