Files
HealthLog/src/lib/onboarding/needs-store.ts
T
Marc-André Bombeck 7a1f3fe32d 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.
2026-09-10 11:39:32 +02:00

104 lines
3.3 KiB
TypeScript

/**
* v1.39 (C1) — reading the needs-onboarding row, for the payload and for the
* three writes.
*
* One place that turns an `OnboardingRecord` row (or its absence) into the
* published DTO, so `GET /api/auth/me` and the write routes cannot disagree
* about what "nothing answered yet" looks like. A record with no row reads as
* empty answers and nine pending steps rather than as a missing object — a
* client then never branches on absence, and the account payload stays
* additive for every account that predates the flow.
*/
import type { PrismaClient } from "@/generated/prisma/client";
import {
defaultOnboardingSteps,
emptyOnboardingNeeds,
parseOnboardingFirstResult,
parseOnboardingNeeds,
parseOnboardingSteps,
resolveOnboardingSteps,
type HeldUnitPreferences,
type OnboardingStateDto,
} from "./needs";
import type { OnboardingRecordState } from "./needs-apply";
/** The columns every reader below needs. */
export const ONBOARDING_RECORD_SELECT = {
needsJson: true,
stepsJson: true,
firstResultJson: true,
modulesDerivedAt: true,
completedAt: true,
} as const;
export interface OnboardingRecordRow {
needsJson: unknown;
stepsJson: unknown;
firstResultJson: unknown;
modulesDerivedAt: Date | null;
completedAt: Date | null;
}
/** The answers / steps / first result, parsed fail-soft. */
export function readOnboardingRecordState(
row: OnboardingRecordRow | null,
): OnboardingRecordState {
if (!row) {
return {
needs: emptyOnboardingNeeds(),
steps: defaultOnboardingSteps(),
firstResult: null,
};
}
return {
needs: parseOnboardingNeeds(row.needsJson),
steps: parseOnboardingSteps(row.stepsJson),
firstResult: parseOnboardingFirstResult(row.firstResultJson),
};
}
/**
* The shape published on the account payload.
*
* `held` is what the RECORD's account already carries in its two unit columns,
* and it is a required argument rather than an optional one: the Q6 "skipped
* when the account already holds them" rule has to hold for every reader of
* this DTO, and a caller that has not thought about it should fail to compile.
*/
export function toOnboardingStateDto(
row: OnboardingRecordRow | null,
held: HeldUnitPreferences,
): OnboardingStateDto {
const state = readOnboardingRecordState(row);
return {
steps: resolveOnboardingSteps(state.steps, held),
needs: state.needs,
completedAt: row?.completedAt?.toISOString() ?? null,
firstResult: state.firstResult,
};
}
/**
* Load one record's stored onboarding row, or `null` when it never entered the
* flow.
*
* The row rather than the DTO, because the DTO needs the record's unit columns
* as well and the caller is the one holding them — the account payload already
* reads that user row for the cycle gate, and a second read of the same row on
* the hottest endpoint in the app would buy nothing.
*
* Takes the delegate rather than the whole client, matching the section
* builders next door, so the route's global client and a worker's local one
* share the same read.
*/
export async function loadOnboardingRecordRow(
prisma: Pick<PrismaClient, "onboardingRecord">,
recordId: string,
): Promise<OnboardingRecordRow | null> {
return prisma.onboardingRecord.findUnique({
where: { userId: recordId },
select: ONBOARDING_RECORD_SELECT,
});
}