mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
fix(onboarding): stop the setup flow asking for units the account already holds
The spec's sixth question is skipped when the account already carries the preferences, and the flow had no way to know: `User.unitPreference` is nullable with null meaning "default metric", and the account payload coerces that away, so nothing downstream could tell a chosen metric from an unset column. The two columns are read raw on the server now, into the same vocabulary the wire publishes, and a record whose account holds both reads its `units` step as done — the question answers itself and is never put again. Read-side only: the stored ledger keeps saying pending, because the person really has not answered it, and an answer given later still lands. The two writes the units answer makes — the display columns and the record of the answer — go in one transaction, so a failure between them can no longer leave the columns changed with no record of what changed them.
This commit is contained in:
1 parent
3f015effec
commit
7a1f3fe32d
10 files changed
+275
-29
No files matched your search
@@ -54,7 +54,11 @@ import {
|
||||
resolveModuleMap,
|
||||
getOperatorModuleAvailability,
|
||||
} from "@/lib/modules/gate";
|
||||
import { loadOnboardingState } from "@/lib/onboarding/needs-store";
|
||||
import { readHeldUnitPreferences } from "@/lib/onboarding/needs";
|
||||
import {
|
||||
loadOnboardingRecordRow,
|
||||
toOnboardingStateDto,
|
||||
} from "@/lib/onboarding/needs-store";
|
||||
import { parseTourProgress } from "@/lib/onboarding/tour-progress";
|
||||
import { parseNotificationPrefs } from "@/lib/validations/notification-prefs";
|
||||
import { resolveAccountAccess } from "@/lib/sharing/account-access";
|
||||
@@ -113,7 +117,7 @@ export const GET = apiHandler(async () => {
|
||||
cycleProfile,
|
||||
resolvedModules,
|
||||
moduleAvailability,
|
||||
onboarding,
|
||||
onboardingRow,
|
||||
] = await Promise.all([
|
||||
// The actor's own row is already in hand; a switched session needs the
|
||||
// record's `gender`, which is the column the cycle gate derives from.
|
||||
@@ -150,7 +154,7 @@ export const GET = apiHandler(async () => {
|
||||
// act on and never complete. A guardian's route into a managed record's
|
||||
// flow arrives with the rest of that record's configuration (#939 / C2);
|
||||
// the storage is already keyed by record for it.
|
||||
loadOnboardingState(prisma, user.id),
|
||||
loadOnboardingRecordRow(prisma, user.id),
|
||||
]);
|
||||
|
||||
// The masking step, and the one place the record scoping is narrowed rather
|
||||
@@ -165,6 +169,14 @@ export const GET = apiHandler(async () => {
|
||||
// exactly as it was, and `moduleAccess` is the same answer with the reason
|
||||
// attached — the record's own switch, the grant's edge, or the operator's.
|
||||
// Every client that only wants "paint it or not" keeps reading the boolean.
|
||||
// v1.39 (C1) — the published steps carry the Q6 resolution: an account that
|
||||
// already holds both unit preferences reads `units` as `done`, so the flow
|
||||
// never re-asks a value the account holds.
|
||||
const onboarding = toOnboardingStateDto(
|
||||
onboardingRow,
|
||||
readHeldUnitPreferences(user),
|
||||
);
|
||||
|
||||
const { modules, moduleAccess } = buildModuleDisclosure(
|
||||
resolvedModules,
|
||||
moduleAvailability,
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { applyOnboardingAnswer } from "@/lib/onboarding/needs-apply";
|
||||
import { readHeldUnitPreferences } from "@/lib/onboarding/needs";
|
||||
import {
|
||||
ONBOARDING_RECORD_SELECT,
|
||||
readOnboardingRecordState,
|
||||
@@ -98,18 +99,17 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
// second source of truth. What is kept beside them in `needs.units` is the
|
||||
// answer as given, so a restart can show it back without re-deriving it from
|
||||
// a column Settings may have changed since.
|
||||
const unitData: { glucoseUnit?: string; unitPreference?: string } = {};
|
||||
if (answer.step === "units" && answer.status !== "skipped") {
|
||||
const data: { glucoseUnit?: string; unitPreference?: string } = {};
|
||||
if (answer.units.glucoseUnit) data.glucoseUnit = answer.units.glucoseUnit;
|
||||
if (answer.units.unitPreference) {
|
||||
data.unitPreference = answer.units.unitPreference;
|
||||
if (answer.units.glucoseUnit) {
|
||||
unitData.glucoseUnit = answer.units.glucoseUnit;
|
||||
}
|
||||
if (Object.keys(data).length > 0) {
|
||||
await prisma.user.update({ where: { id: user.id }, data });
|
||||
if (answer.units.unitPreference) {
|
||||
unitData.unitPreference = answer.units.unitPreference;
|
||||
}
|
||||
}
|
||||
|
||||
const written = await prisma.onboardingRecord.upsert({
|
||||
const upsert = prisma.onboardingRecord.upsert({
|
||||
where: { userId: user.id },
|
||||
// Field-by-field, never a spread of the parsed body: the three columns are
|
||||
// the pure function's output, and nothing the client sent reaches Prisma
|
||||
@@ -130,6 +130,22 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
select: ONBOARDING_RECORD_SELECT,
|
||||
});
|
||||
|
||||
// One transaction, because the two writes are one answer: a failure between
|
||||
// them would leave the display columns changed with no record of the answer
|
||||
// that changed them.
|
||||
const [written] =
|
||||
Object.keys(unitData).length > 0
|
||||
? await prisma.$transaction([
|
||||
upsert,
|
||||
prisma.user.update({ where: { id: user.id }, data: unitData }),
|
||||
])
|
||||
: await prisma.$transaction([upsert]);
|
||||
|
||||
const held = readHeldUnitPreferences({
|
||||
glucoseUnit: unitData.glucoseUnit ?? user.glucoseUnit,
|
||||
unitPreference: unitData.unitPreference ?? user.unitPreference,
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "onboarding.answer.save" },
|
||||
meta: {
|
||||
@@ -138,5 +154,5 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ onboarding: toOnboardingStateDto(written) });
|
||||
return apiSuccess({ onboarding: toOnboardingStateDto(written, held) });
|
||||
});
|
||||
@@ -10,6 +10,11 @@ import {
|
||||
deriveOnboardingModuleDefaults,
|
||||
mergeDerivedModulePreferences,
|
||||
} from "@/lib/modules/registry";
|
||||
import {
|
||||
readHeldUnitPreferences,
|
||||
resolveOnboardingSteps,
|
||||
type HeldUnitPreferences,
|
||||
} from "@/lib/onboarding/needs";
|
||||
import {
|
||||
ONBOARDING_RECORD_SELECT,
|
||||
readOnboardingRecordState,
|
||||
@@ -87,7 +92,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// next navigation drops the /onboarding redirect immediately.
|
||||
await setOnboardingPendingCookie(false);
|
||||
|
||||
const onboarding = await completeNeedsFlow(user.id);
|
||||
const onboarding = await completeNeedsFlow(
|
||||
user.id,
|
||||
readHeldUnitPreferences(user),
|
||||
);
|
||||
|
||||
return apiSuccess({ completed: true, ...(onboarding ? { onboarding } : {}) });
|
||||
});
|
||||
@@ -100,7 +108,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
* needs flow — a legacy wizard completion then answers exactly the body it
|
||||
* always did.
|
||||
*/
|
||||
async function completeNeedsFlow(userId: string) {
|
||||
async function completeNeedsFlow(userId: string, held: HeldUnitPreferences) {
|
||||
const record = await prisma.onboardingRecord.findUnique({
|
||||
where: { userId },
|
||||
select: ONBOARDING_RECORD_SELECT,
|
||||
@@ -108,6 +116,10 @@ async function completeNeedsFlow(userId: string) {
|
||||
if (!record) return null;
|
||||
|
||||
const state = readOnboardingRecordState(record);
|
||||
// The units question answers itself for an account that already holds both
|
||||
// preferences, so the gate and the published state read the same resolved
|
||||
// ledger rather than the raw one.
|
||||
const steps = resolveOnboardingSteps(state.steps, held);
|
||||
if (state.needs.recordTarget === null) {
|
||||
// The row exists but Q1 was never answered, so there is nothing to derive
|
||||
// from. Stamping a completion here would claim a setup that did not happen.
|
||||
@@ -115,7 +127,7 @@ async function completeNeedsFlow(userId: string) {
|
||||
action: { name: "onboarding.needs.complete" },
|
||||
meta: { outcome: "no_answers" },
|
||||
});
|
||||
return toOnboardingStateDto(record);
|
||||
return toOnboardingStateDto(record, held);
|
||||
}
|
||||
|
||||
let derived = false;
|
||||
@@ -162,7 +174,7 @@ async function completeNeedsFlow(userId: string) {
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const steps = state.steps.map((step) =>
|
||||
const confirmed = steps.map((step) =>
|
||||
step.id === "confirm" && step.status === "pending"
|
||||
? { ...step, status: "done" as const }
|
||||
: step,
|
||||
@@ -170,7 +182,7 @@ async function completeNeedsFlow(userId: string) {
|
||||
const written = await prisma.onboardingRecord.update({
|
||||
where: { userId },
|
||||
data: {
|
||||
stepsJson: toJson(steps),
|
||||
stepsJson: toJson(confirmed),
|
||||
completedAt: now,
|
||||
...(derived ? { modulesDerivedAt: now } : {}),
|
||||
},
|
||||
@@ -182,5 +194,5 @@ async function completeNeedsFlow(userId: string) {
|
||||
meta: { outcome: derived ? "derived" : "already_derived" },
|
||||
});
|
||||
|
||||
return toOnboardingStateDto(written);
|
||||
return toOnboardingStateDto(written, held);
|
||||
}
|
||||
@@ -37,7 +37,10 @@ import {
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { defaultOnboardingSteps } from "@/lib/onboarding/needs";
|
||||
import {
|
||||
defaultOnboardingSteps,
|
||||
readHeldUnitPreferences,
|
||||
} from "@/lib/onboarding/needs";
|
||||
import {
|
||||
ONBOARDING_RECORD_SELECT,
|
||||
toOnboardingStateDto,
|
||||
@@ -105,5 +108,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
meta: { outcome: "reset" },
|
||||
});
|
||||
|
||||
return apiSuccess({ onboarding: toOnboardingStateDto(written) });
|
||||
return apiSuccess({
|
||||
onboarding: toOnboardingStateDto(written, readHeldUnitPreferences(user)),
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user