mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
Merge the needs-based onboarding's server state and the derivation that never hides data
This commit is contained in:
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.
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user