mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
Merge the needs-based onboarding's server state and the derivation that never hides data
This commit is contained in:
commit
3017e24bb1
41 files changed
+5272
-83
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,
|
||||
|
||||
+102
-29
@@ -54,6 +54,14 @@ import {
|
||||
resolveModuleMap,
|
||||
getOperatorModuleAvailability,
|
||||
} from "@/lib/modules/gate";
|
||||
import {
|
||||
emptyOnboardingNeeds,
|
||||
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";
|
||||
@@ -107,35 +115,63 @@ export const GET = apiHandler(async () => {
|
||||
// no switch at all, and the reason the own-record payload is untouched.
|
||||
const sections = accountAccess.active?.sections ?? null;
|
||||
|
||||
const [record, cycleProfile, resolvedModules, moduleAvailability] =
|
||||
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.
|
||||
recordId === user.id
|
||||
? Promise.resolve({ gender: user.gender })
|
||||
: prisma.user.findUnique({
|
||||
where: { id: recordId },
|
||||
select: { gender: true },
|
||||
}),
|
||||
prisma.cycleProfile.findUnique({
|
||||
where: { userId: recordId },
|
||||
select: { cycleTrackingEnabled: true },
|
||||
}),
|
||||
// v1.18.0 — resolved module enable/disable map for every toggleable
|
||||
// module. cycle/coach reflect their real delegated state (the cycle
|
||||
// gate / disableCoach + operator assistant flag); the rest read the
|
||||
// disabled-allowlist `modulePreferencesJson`. Default-on. Clients
|
||||
// hide a whole module surface end-to-end when its key is `false`.
|
||||
resolveModuleMap(recordId),
|
||||
// v1.18.0 — operator-layer availability map (server-wide kill-switch).
|
||||
// The resolved `modules` map above already AND-s this in, so it cannot
|
||||
// distinguish operator-off from user-off. The Modules hub needs that
|
||||
// distinction to render an operator-disabled module as a read-only
|
||||
// "disabled server-wide" row instead of a live toggle that no-ops.
|
||||
// Server-WIDE, so it is the same map for every record and is not
|
||||
// re-scoped here.
|
||||
getOperatorModuleAvailability(),
|
||||
]);
|
||||
const [
|
||||
record,
|
||||
cycleProfile,
|
||||
resolvedModules,
|
||||
moduleAvailability,
|
||||
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.
|
||||
recordId === user.id
|
||||
? Promise.resolve({
|
||||
gender: user.gender,
|
||||
glucoseUnit: user.glucoseUnit,
|
||||
unitPreference: user.unitPreference,
|
||||
})
|
||||
: prisma.user.findUnique({
|
||||
where: { id: recordId },
|
||||
select: {
|
||||
gender: true,
|
||||
// v1.39 (C1) — the record's own unit columns, for the setup
|
||||
// flow's "never re-ask a value the account holds" rule.
|
||||
glucoseUnit: true,
|
||||
unitPreference: true,
|
||||
},
|
||||
}),
|
||||
prisma.cycleProfile.findUnique({
|
||||
where: { userId: recordId },
|
||||
select: { cycleTrackingEnabled: true },
|
||||
}),
|
||||
// v1.18.0 — resolved module enable/disable map for every toggleable
|
||||
// module. cycle/coach reflect their real delegated state (the cycle
|
||||
// gate / disableCoach + operator assistant flag); the rest read the
|
||||
// disabled-allowlist `modulePreferencesJson`. Default-on. Clients
|
||||
// hide a whole module surface end-to-end when its key is `false`.
|
||||
resolveModuleMap(recordId),
|
||||
// v1.18.0 — operator-layer availability map (server-wide kill-switch).
|
||||
// The resolved `modules` map above already AND-s this in, so it cannot
|
||||
// distinguish operator-off from user-off. The Modules hub needs that
|
||||
// distinction to render an operator-disabled module as a read-only
|
||||
// "disabled server-wide" row instead of a live toggle that no-ops.
|
||||
// Server-WIDE, so it is the same map for every record and is not
|
||||
// re-scoped here.
|
||||
getOperatorModuleAvailability(),
|
||||
// v1.39 (C1) — the needs-based setup state for the RECORD, like the two
|
||||
// module fields above and for the same reason: it says what that record
|
||||
// tracks, and its only consumer reads it beside that record's reading
|
||||
// count, medication count and integration state. Ordering a record's rows
|
||||
// by somebody else's answers is the one thing this field must not do.
|
||||
//
|
||||
// The three WRITE routes stay actor-only (`requireAuth()` refuses outright
|
||||
// under a switch) until a guardian's route into a managed record's setup
|
||||
// arrives with the rest of that record's configuration (#939 / C2). A read
|
||||
// that is wider than the write is the honest shape here: the storage is
|
||||
// keyed by record already, and a delegate seeing the record's own setup
|
||||
// state is what makes the dashboard they are looking at coherent.
|
||||
loadOnboardingRecordRow(prisma, recordId),
|
||||
]);
|
||||
|
||||
// The masking step, and the one place the record scoping is narrowed rather
|
||||
// than resolved. `active` is set for ANY live grant — a READ grant scoped to
|
||||
@@ -149,6 +185,25 @@ 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: a record whose
|
||||
// account already holds both unit preferences reads `units` as `done`, so
|
||||
// the flow never re-asks a value the account holds.
|
||||
const recordOnboarding = toOnboardingStateDto(
|
||||
onboardingRow,
|
||||
readHeldUnitPreferences({
|
||||
glucoseUnit: record?.glucoseUnit ?? null,
|
||||
unitPreference: record?.unitPreference ?? null,
|
||||
}),
|
||||
);
|
||||
// The answers name the domains the record tracks, which is exactly what the
|
||||
// module map below is masked for. A SCOPED grant therefore gets the shape of
|
||||
// the flow — how far it got, what it ended on — with the answers emptied;
|
||||
// an unscoped grant, and the record's own session, get them whole.
|
||||
const onboarding =
|
||||
sections === null
|
||||
? recordOnboarding
|
||||
: { ...recordOnboarding, needs: emptyOnboardingNeeds() };
|
||||
|
||||
const { modules, moduleAccess } = buildModuleDisclosure(
|
||||
resolvedModules,
|
||||
moduleAvailability,
|
||||
@@ -186,6 +241,24 @@ export const GET = apiHandler(async () => {
|
||||
timezone: user.timezone,
|
||||
onboardingCompletedAt: user.onboardingCompletedAt,
|
||||
onboardingTourCompleted: user.onboardingTourCompleted,
|
||||
// v1.39 (C1) — the needs-based setup flow, published additively:
|
||||
// `steps` (the nine stable ids and their `pending | done | skipped`
|
||||
// status), `needs` (the answers as given), `completedAt` (the flow's OWN
|
||||
// completion, distinct from `onboardingCompletedAt` above, which keeps its
|
||||
// meaning as the first-run redirect's gate) and `firstResult` (the one
|
||||
// task the flow offered, and whether it produced its result).
|
||||
//
|
||||
// Record-scoped, like `modules` and `cycleTrackingEnabled`: it says what
|
||||
// the record tracks, and the checklist reads it beside that record's
|
||||
// counts. Under a scoped grant the answers are emptied for the reason the
|
||||
// module map is masked.
|
||||
//
|
||||
// Always present, never null: a record that never entered the flow reads
|
||||
// as empty answers with nine `pending` steps, so a client branches on the
|
||||
// step statuses rather than on the field's existence. A client that meets
|
||||
// a step id it does not know skips it — the list is the contract, not the
|
||||
// client's own copy of it.
|
||||
onboarding,
|
||||
// v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment. Null
|
||||
// when never acknowledged; the onboarding welcome gate reads this to
|
||||
// decide whether to require the acknowledgment before "Get started".
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)),
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
invalidateUserHealthScore,
|
||||
invalidateUserInsightsLayout,
|
||||
invalidateUserMedications,
|
||||
invalidateUserProfile,
|
||||
@@ -24,6 +23,7 @@ import {
|
||||
getAllEffectiveRanges,
|
||||
type ThresholdOverridesJson,
|
||||
} from "@/lib/analytics/effective-range";
|
||||
import { writeRecordModulePreferences } from "@/lib/record-settings/modules";
|
||||
import {
|
||||
isManagedRecordSettingsFamily,
|
||||
managedModulePreferencesFrom,
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
resolveGuardianRecordSettingsAccess,
|
||||
} from "@/lib/record-settings";
|
||||
import { isCycleEnabled } from "@/lib/cycle/gate";
|
||||
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
|
||||
import {
|
||||
resolveInsightsLayout,
|
||||
serializeInsightsLayout,
|
||||
@@ -251,34 +250,25 @@ export const PATCH = apiHandler(
|
||||
...managedModulePreferencesFrom(current?.modulePreferencesJson),
|
||||
...patch.modulePreferences,
|
||||
};
|
||||
if (patch.modulePreferences !== undefined) {
|
||||
await prisma.user.update({
|
||||
where: { id: access.recordId },
|
||||
data: { modulePreferencesJson: toJson(modulePreferences) },
|
||||
});
|
||||
}
|
||||
// The delegated key writes its own column. An explicit boolean is
|
||||
// One write path, shared with the setup flow's derivation. The
|
||||
// delegated key writes its own column there: an explicit boolean is
|
||||
// what the cycle gate honours over the sex-derived default, so a
|
||||
// guardian turning Cycle off for a record whose sex says otherwise
|
||||
// is recorded as the deliberate answer it is.
|
||||
if (patch.cycleTrackingEnabled !== undefined) {
|
||||
await getOrCreateCycleProfile(access.recordId);
|
||||
await prisma.cycleProfile.update({
|
||||
where: { userId: access.recordId },
|
||||
data: { cycleTrackingEnabled: patch.cycleTrackingEnabled },
|
||||
});
|
||||
}
|
||||
const cycleProfile = await prisma.cycleProfile.findUnique({
|
||||
where: { userId: access.recordId },
|
||||
select: { cycleTrackingEnabled: true },
|
||||
const written = await writeRecordModulePreferences({
|
||||
recordId: access.recordId,
|
||||
...(patch.modulePreferences !== undefined
|
||||
? { modulePreferences }
|
||||
: {}),
|
||||
...(patch.cycleTrackingEnabled !== undefined
|
||||
? { cycleTrackingEnabled: patch.cycleTrackingEnabled }
|
||||
: {}),
|
||||
});
|
||||
invalidateUserHealthScore(access.recordId);
|
||||
settings = {
|
||||
modulePreferences,
|
||||
cycleTrackingEnabled: isCycleEnabled(
|
||||
current?.gender ?? null,
|
||||
cycleProfile,
|
||||
),
|
||||
cycleTrackingEnabled: isCycleEnabled(current?.gender ?? null, {
|
||||
cycleTrackingEnabled: written.cycleTrackingEnabled,
|
||||
}),
|
||||
};
|
||||
// The module keys themselves, not the wrapper field: the record's
|
||||
// trail should say which module moved. `cycleTrackingEnabled` is
|
||||
|
||||
Reference in new issue
Block a user