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

+186
View File
@@ -0,0 +1,186 @@
/**
* v1.39 (C1) — `PATCH /api/onboarding/answers`.
*
* One step at a time, saved the moment it is answered, so leaving the flow and
* coming back resumes where it stopped (design spec §Principles 4). The body
* names the step and either its answer or a deliberate skip; the union in
* `src/lib/validations/onboarding-needs.ts` is what makes "skipped, and here is
* the answer anyway" unrepresentable.
*
* Idempotent: the state is computed by a pure function from the stored row and
* the body, so sending the same step twice writes the same value twice and the
* second write changes nothing a reader can see. The one instant that could
* drift — the first-result completion stamp — is kept from the existing row
* rather than re-taken.
*
* `requireAuth()` rather than `requireRecordAuth`: this route writes the
* CALLER's own record and refuses outright while the session is acting on
* somebody else's (403 `sharing.not_permitted`), at every grant level. The
* storage is keyed by record so a guardian running a managed profile's setup
* has a row to write, but the route into it belongs with the rest of the
* managed-record configuration (#939 / C2), not here — a delegable declaration
* is a reviewed entry in the sharing surface guard, not a default.
*/
import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
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,
toOnboardingStateDto,
} from "@/lib/onboarding/needs-store";
import { checkRateLimit } from "@/lib/rate-limit";
import { onboardingAnswerSchema } from "@/lib/validations/onboarding-needs";
export const dynamic = "force-dynamic";
const RATE_LIMIT = 120;
const RATE_WINDOW_MS = 10 * 60 * 1000;
export const PATCH = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "onboarding.needs.save" } });
const rl = await checkRateLimit(
`onboarding-answers:${user.id}`,
RATE_LIMIT,
RATE_WINDOW_MS,
);
if (!rl.allowed) {
annotate({
action: { name: "onboarding.needs.save" },
meta: { outcome: "rate_limited" },
});
return apiError("Too many onboarding writes, try again later", 429, {
errorCode: "onboarding.answers.rateLimited",
});
}
const { data: body, error: jsonError } = await safeJson<unknown>(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = onboardingAnswerSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "onboarding.needs.save" },
meta: { outcome: "validation_failed" },
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "onboarding.answers.invalid",
});
}
const answer = parsed.data;
const existing = await prisma.onboardingRecord.findUnique({
where: { userId: user.id },
select: ONBOARDING_RECORD_SELECT,
});
const next = applyOnboardingAnswer(
readOnboardingRecordState(existing),
answer,
new Date(),
);
// The unit answers are the one step whose consequence lives outside this
// table: `User.glucoseUnit` and `User.unitPreference` are what every display
// surface already reads, so the flow writes them rather than inventing a
// 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") {
if (answer.units.glucoseUnit) {
unitData.glucoseUnit = answer.units.glucoseUnit;
}
if (answer.units.unitPreference) {
unitData.unitPreference = answer.units.unitPreference;
}
}
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
// without passing through it.
create: {
userId: user.id,
needsJson: toJson(next.needs),
stepsJson: toJson(next.steps),
firstResultJson: next.firstResult ? toJson(next.firstResult) : undefined,
},
update: {
needsJson: toJson(next.needs),
stepsJson: toJson(next.steps),
...(next.firstResult
? { firstResultJson: toJson(next.firstResult) }
: {}),
},
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,
});
// Both columns have a dedicated settings route that writes an audit row, and
// the trail should not depend on which surface the person changed them from.
// Same event names, so the activity panel groups them.
if (unitData.glucoseUnit !== undefined) {
await auditLog("user.glucose-unit.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
previous: user.glucoseUnit,
next: unitData.glucoseUnit,
source: "onboarding",
},
});
}
if (unitData.unitPreference !== undefined) {
await auditLog("user.unit-preference.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
previous: user.unitPreference,
next: unitData.unitPreference,
source: "onboarding",
},
});
}
annotate({
action: { name: "onboarding.needs.save" },
meta: {
step: answer.step,
status: answer.status === "skipped" ? "skipped" : "done",
},
});
return apiSuccess({ onboarding: toOnboardingStateDto(written, held) });
});
@@ -1,12 +1,21 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: { update: vi.fn() },
auditLog: { create: vi.fn() },
},
}));
vi.mock("@/lib/db", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/db")>();
return {
...actual,
prisma: {
user: { update: vi.fn(), findUnique: vi.fn() },
auditLog: { create: vi.fn() },
// v1.39 (C1) — the needs half of the route looks for a setup row before
// it does anything. These cases are the LEGACY caller, which has none,
// so the lookup answers null and the route falls straight through to the
// acknowledgement it always returned.
onboardingRecord: { findUnique: vi.fn(), update: vi.fn() },
},
};
});
vi.mock("@/lib/auth/session", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/auth/session")>();
@@ -58,6 +67,7 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(prisma.onboardingRecord.findUnique).mockResolvedValue(null);
});
describe("POST /api/onboarding/complete", () => {
+192 -2
View File
@@ -1,13 +1,34 @@
import { prisma } from "@/lib/db";
import { prisma, toJson } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { setOnboardingPendingCookie } from "@/lib/auth/session";
import { normalisePrefs } from "@/lib/modules/gate";
import { modulesHoldingRecordData } from "@/lib/modules/domain-data";
import {
deriveOnboardingModuleDefaults,
mergeDerivedModulePreferences,
OWNED_MODULE_KEYS,
} from "@/lib/modules/registry";
import {
everyOnboardingQuestionSettled,
readHeldUnitPreferences,
resolveOnboardingSteps,
type HeldUnitPreferences,
} from "@/lib/onboarding/needs";
import {
ONBOARDING_RECORD_SELECT,
readOnboardingRecordState,
toOnboardingStateDto,
} from "@/lib/onboarding/needs-store";
import { writeRecordModulePreferences } from "@/lib/record-settings/modules";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { onboardingCompleteSchema } from "@/lib/validations/onboarding";
@@ -15,6 +36,23 @@ import { onboardingCompleteSchema } from "@/lib/validations/onboarding";
/**
* Complete the onboarding flow. Saves optional profile data and marks
* onboarding as completed.
*
* v1.39 (C1) — this is also the confirm screen's endpoint for the needs-based
* flow, and the two live side by side rather than one replacing the other.
* The legacy half is unchanged: the optional profile fields, the completion
* stamp on `User.onboardingCompletedAt`, and the cleared pending cookie. The
* needs half runs only for a record that actually FINISHED the questions —
* every one of them answered or deliberately passed — and is what turns them
* into a module
* map — ONCE, guarded by `OnboardingRecord.modulesDerivedAt`, because a
* re-derivation would re-apply the questionnaire over decisions taken in
* Settings since. `POST /api/onboarding/restart` clears that marker when the
* person asks for the questions again.
*
* The goal slugs of today (`src/lib/onboarding/goals.ts`, the four-step wizard
* at `POST /api/onboarding/step`) are untouched and stay live until C2
* replaces that surface. They decide TILE ORDER; the answers below decide
* WHICH MODULES ARE ON. Neither reads the other.
*/
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -69,5 +107,157 @@ export const POST = apiHandler(async (request: NextRequest) => {
// next navigation drops the /onboarding redirect immediately.
await setOnboardingPendingCookie(false);
return apiSuccess({ completed: true });
const onboarding = await completeNeedsFlow(
user.id,
readHeldUnitPreferences(user),
request,
);
return apiSuccess({ completed: true, ...(onboarding ? { onboarding } : {}) });
});
/**
* The needs half: derive the module map once, mark the confirm step, stamp the
* flow's own completion.
*
* Returns the published state, or `null` for a caller that never entered the
* needs flow — a legacy wizard completion then answers exactly the body it
* always did.
*/
async function completeNeedsFlow(
userId: string,
held: HeldUnitPreferences,
request: NextRequest,
) {
const record = await prisma.onboardingRecord.findUnique({
where: { userId },
select: ONBOARDING_RECORD_SELECT,
});
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.
annotate({
action: { name: "onboarding.needs.complete" },
meta: { outcome: "no_answers" },
});
return toOnboardingStateDto(record, held);
}
if (!everyOnboardingQuestionSettled(steps)) {
// The confirm screen is the only caller that may derive, and it is reached
// only once every question has an answer or a deliberate pass. Q1 alone is
// not enough: an abandoned flow would have every remaining question read
// as its conservative default, switch off the surfaces that default
// implies, and latch the result so nobody could re-derive it. This route
// is also the LEGACY wizard's endpoint, and that wizard knows nothing
// about these questions — so a person who answered Q1, left, and later
// finished the old wizard must not have a module map derived for them.
annotate({
action: { name: "onboarding.needs.complete" },
meta: { outcome: "incomplete" },
});
return toOnboardingStateDto(record, held);
}
let derived = false;
let keptForData: string[] = [];
if (record.modulesDerivedAt === null) {
const defaults = deriveOnboardingModuleDefaults({
recordTarget: state.needs.recordTarget,
areas: state.needs.areas,
// An unanswered (skipped) question is the conservative answer: no
// medication surface, no doctor report. Both stay one click away in
// Settings, which is the whole ordering-versus-removal decision.
medication: state.needs.medication ?? "no",
visit: state.needs.visit ?? "no",
});
const row = await prisma.user.findUnique({
where: { id: userId },
select: { modulePreferencesJson: true },
});
// Only the modules the answers would switch OFF are worth asking about,
// and a record that already holds rows in one of those domains keeps its
// surface: the flow orders a record, and it cannot order away content
// somebody already has.
const wouldSwitchOff = OWNED_MODULE_KEYS.filter(
(key) => defaults.preferences[key] === false,
);
const holdsData = await modulesHoldingRecordData(
prisma,
userId,
wouldSwitchOff,
);
const merged = mergeDerivedModulePreferences(
normalisePrefs(row?.modulePreferencesJson),
defaults.preferences,
holdsData,
);
// The same record-keyed write the guardian's modules route uses, so the
// two surfaces that decide a record's modules cannot drift apart on how
// the decision is stored or what it invalidates. `cycle` delegates to
// `CycleProfile.cycleTrackingEnabled`, so the module blob never owns it,
// and it is only ever switched ON from here: the gate derives an
// unanswered cycle from recorded sex, and an untick is not a request to
// retract that.
await writeRecordModulePreferences({
recordId: userId,
modulePreferences: merged,
...(defaults.cycleTracking ? { cycleTrackingEnabled: true } : {}),
});
// The same trail the dedicated modules route writes, under the same event
// name, because it is the same column and the activity panel is where
// somebody goes to ask why their modules changed. The flow was writing
// three audited settings columns and leaving no trail at all, which made
// the answer to that question "nothing did it".
const stored = normalisePrefs(row?.modulePreferencesJson);
const changed = [
...Object.keys(merged).filter((key) => merged[key] !== stored[key]),
...(defaults.cycleTracking ? ["cycleTrackingEnabled"] : []),
];
await auditLog("user.modules.update", {
userId,
ipAddress: getClientIp(request),
details: { changed, keptForData: [...holdsData], source: "onboarding" },
});
keptForData = [...holdsData];
derived = true;
}
const now = new Date();
const confirmed = steps.map((step) =>
step.id === "confirm" && step.status === "pending"
? { ...step, status: "done" as const }
: step,
);
const written = await prisma.onboardingRecord.update({
where: { userId },
data: {
stepsJson: toJson(confirmed),
// Stamped once. The derivation is latched and the stamp should be too,
// or a replay of the confirm quietly moves the instant the setup
// finished at.
completedAt: record.completedAt ?? now,
...(derived ? { modulesDerivedAt: now } : {}),
},
select: ONBOARDING_RECORD_SELECT,
});
annotate({
action: { name: "onboarding.needs.complete" },
meta: {
outcome: derived ? "derived" : "already_derived",
keptForData: keptForData.length,
},
});
return toOnboardingStateDto(written, held);
}
+114
View File
@@ -0,0 +1,114 @@
/**
* v1.39 (C1) — `POST /api/onboarding/restart`.
*
* "Set up again", from Settings. It puts the nine steps back to `pending`,
* clears the flow's completion stamp and clears the derivation marker so the
* next confirm may derive a module map again.
*
* What it deliberately does NOT do:
*
* - touch a single module. The design spec's ordering-versus-removal
* decision says a restart never turns off a module the person turned on by
* hand, and the safest way to hold that is for this route to write no
* module state at all. The protection against the SECOND confirm is
* elsewhere, in `mergeDerivedModulePreferences`.
* - clear the answers. They come back as the prefill for the re-run, which
* is what "never re-ask a value the account holds" means here.
* - clear `User.onboardingCompletedAt`. That column gates the first-run
* redirect in `src/proxy.ts`; clearing it would drop the person back into
* the wizard of today rather than into the questions they asked for.
* - forget a first result that really happened. The task produced a reading,
* a medication or a connection; a re-run of the questions does not unmake
* it.
*
* `requireAuth()` for the reason the answers route gives: the caller's own
* record, refused outright under an acting-account switch.
*/
import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma, toJson } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import {
defaultOnboardingSteps,
readHeldUnitPreferences,
} from "@/lib/onboarding/needs";
import {
ONBOARDING_RECORD_SELECT,
toOnboardingStateDto,
} from "@/lib/onboarding/needs-store";
import { checkRateLimit } from "@/lib/rate-limit";
import { onboardingRestartSchema } from "@/lib/validations/onboarding-needs";
export const dynamic = "force-dynamic";
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "onboarding.needs.restart" } });
const rl = await checkRateLimit(
`onboarding-restart:${user.id}`,
10,
10 * 60 * 1000,
);
if (!rl.allowed) {
annotate({
action: { name: "onboarding.needs.restart" },
meta: { outcome: "rate_limited" },
});
return apiError("Too many onboarding writes, try again later", 429, {
errorCode: "onboarding.restart.rateLimited",
});
}
const { data: body, error: jsonError } = await safeJson<unknown>(request, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = onboardingRestartSchema.safeParse(body ?? {});
if (!parsed.success) {
annotate({
action: { name: "onboarding.needs.restart" },
meta: { outcome: "validation_failed" },
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "onboarding.restart.invalid",
});
}
const steps = defaultOnboardingSteps();
const written = await prisma.onboardingRecord.upsert({
where: { userId: user.id },
create: { userId: user.id, stepsJson: toJson(steps) },
update: {
stepsJson: toJson(steps),
completedAt: null,
modulesDerivedAt: null,
},
select: ONBOARDING_RECORD_SELECT,
});
await auditLog("onboarding.restart", {
userId: user.id,
ipAddress: getClientIp(request),
details: { steps: steps.length },
});
annotate({
action: { name: "onboarding.needs.restart" },
meta: { outcome: "reset" },
});
return apiSuccess({
onboarding: toOnboardingStateDto(written, readHeldUnitPreferences(user)),
});
});