mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(backup): carry the setup answers out of the account and back in
Nothing recomputes an answer. The module map they produced survives on the account row, but the map says what is switched on, not that the person told the flow they take medication daily and have a visit next month — so a restore without the row opens a blank questionnaire for somebody who already answered it, and leaves the checklist ordering itself from nothing. Both ends live in one file, the arrangement the visits and the score already use, because a reader who greps only the restore route gets a false negative when the route delegates. `modulesDerivedAt` is the field that makes this more than history and it is asserted rather than defaulted: 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 since, silently. The round trip seeds it non-null and reads it back. The file's schema validates the three answer objects loosely and the restore narrows them through the same fail-soft readers the account payload uses. Being stricter there would refuse a whole account's restore over one retired chip. The wipe takes the row for the reason the reset already takes the wizard's own columns: the record the answers were about is gone, so the answers describe nothing.
This commit is contained in:
1 parent
dbfad0f28e
commit
3f44b882ac
11 files changed
+376
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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<string, string>();
|
||||
const restoredBiomarkerIds = new Set<string>();
|
||||
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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> {
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user