mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
feat(api): let a record answer the setup questions, and act on them
Three writes, all on the caller's own record. `PATCH /api/onboarding/answers` saves one step the moment it is answered, so leaving the flow and coming back resumes where it stopped. It is idempotent because the whole computation is a pure function of the row and the body, including the first-result instant, which is kept rather than re-taken. There is no ordering contract at all: a back button has to work. `POST /api/onboarding/complete` keeps everything it did and gains the confirm screen's job. For a record that answered the questions it derives the module map once and applies it, and the once-only latch is the point: a second confirm would re-apply a questionnaire over decisions taken in Settings since. Cycle is switched on through its own column and never off, because the gate already derives an unanswered cycle from recorded sex and an untick is not a request to retract that. `POST /api/onboarding/restart` puts the steps back and clears the latch. It writes no module state whatever: a re-run never turns off something switched on by hand, and the safest way to hold that is for this route to have no opinion about modules at all. It also leaves the first-run redirect's own stamp alone, so asking the questions again does not push anybody back through the old wizard. All three refuse outright while the session is acting on somebody else's record, at every grant level, so a delegate can never write another record's setup. The route into a managed profile's flow belongs with the rest of that record's configuration and is not smuggled in here. The demo allowlist gains the three: the flow has to be completable there with preset answers, and none of them creates health data.
This commit is contained in:
1 parent
cc98c56172
commit
021ea8f1ff
5 files changed
+587
-6
No files matched your search
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 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,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { applyOnboardingAnswer } from "@/lib/onboarding/needs-apply";
|
||||
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.answer.save" } });
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`onboarding-answers:${user.id}`,
|
||||
RATE_LIMIT,
|
||||
RATE_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
annotate({
|
||||
action: { name: "onboarding.answer.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.answer.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.
|
||||
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 (Object.keys(data).length > 0) {
|
||||
await prisma.user.update({ where: { id: user.id }, data });
|
||||
}
|
||||
}
|
||||
|
||||
const written = await 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,
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "onboarding.answer.save" },
|
||||
meta: {
|
||||
step: answer.step,
|
||||
status: answer.status === "skipped" ? "skipped" : "done",
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ onboarding: toOnboardingStateDto(written) });
|
||||
});
|
||||
@@ -1,8 +1,20 @@
|
||||
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, apiError, safeJson } from "@/lib/api-response";
|
||||
import { setOnboardingPendingCookie } from "@/lib/auth/session";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
|
||||
import { normalisePrefs } from "@/lib/modules/gate";
|
||||
import {
|
||||
deriveOnboardingModuleDefaults,
|
||||
mergeDerivedModulePreferences,
|
||||
} from "@/lib/modules/registry";
|
||||
import {
|
||||
ONBOARDING_RECORD_SELECT,
|
||||
readOnboardingRecordState,
|
||||
toOnboardingStateDto,
|
||||
} from "@/lib/onboarding/needs-store";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { onboardingCompleteSchema } from "@/lib/validations/onboarding";
|
||||
@@ -10,6 +22,22 @@ 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 answered the questions
|
||||
* (`needs.recordTarget` is set) and is what turns the answers 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();
|
||||
@@ -59,5 +87,100 @@ 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);
|
||||
|
||||
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) {
|
||||
const record = await prisma.onboardingRecord.findUnique({
|
||||
where: { userId },
|
||||
select: ONBOARDING_RECORD_SELECT,
|
||||
});
|
||||
if (!record) return null;
|
||||
|
||||
const state = readOnboardingRecordState(record);
|
||||
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);
|
||||
}
|
||||
|
||||
let derived = false;
|
||||
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 },
|
||||
});
|
||||
const merged = mergeDerivedModulePreferences(
|
||||
normalisePrefs(row?.modulePreferencesJson),
|
||||
defaults.preferences,
|
||||
);
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { modulePreferencesJson: merged },
|
||||
});
|
||||
|
||||
// `cycle` delegates to `CycleProfile.cycleTrackingEnabled`, so the module
|
||||
// blob never owns it. Only ever switched ON here: the gate derives an
|
||||
// unanswered cycle from recorded sex, and an untick is not a request to
|
||||
// retract that.
|
||||
if (defaults.cycleTracking) {
|
||||
await getOrCreateCycleProfile(userId);
|
||||
await prisma.cycleProfile.update({
|
||||
where: { userId },
|
||||
data: { cycleTrackingEnabled: true },
|
||||
});
|
||||
}
|
||||
|
||||
// A module toggle adds or removes a Health Score pillar, so the cached
|
||||
// composite was computed from a composition that no longer holds.
|
||||
invalidateUserHealthScore(userId);
|
||||
derived = true;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const steps = state.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(steps),
|
||||
completedAt: now,
|
||||
...(derived ? { modulesDerivedAt: now } : {}),
|
||||
},
|
||||
select: ONBOARDING_RECORD_SELECT,
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "onboarding.needs.complete" },
|
||||
meta: { outcome: derived ? "derived" : "already_derived" },
|
||||
});
|
||||
|
||||
return toOnboardingStateDto(written);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 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 } 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) });
|
||||
});
|
||||
Reference in new issue
Block a user