mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
Apply the review of the setup flow: the child's answers reach the child
"Someone I look after" derived the child's answers onto the guardian's own record. The completion now takes the managed record the profile step just created, checks the same access rule the guardian's modules route applies, and lands the same derivation and dashboard seed on that record through the same record-keyed write; the guardian's own flow is stamped complete and latched without touching its map. Three integration cases pin it, and the child journey reads the child's map through a real switch — it asserted the guardian's map before, which pinned the wrong record. Also from the review: the checklist's visit row read a meta the visits list never publishes and now counts its body; the first-result tile read the measurements list as an array and now shows the reading; the demo allowlist admits the disclaimer and tour writes, with a structural test over every onboarding route file; restart clears the old first result, and the screen trusts the ledger before painting a result as done; the headroom sweep's raw-key check matches the flow's own key shapes; the demo migration script stops copying the dropped column; the questions-to-pass rule and the shell's onboarding-path rule are pure and pinned; the baseline form composes the shared action row; the schema diff loses the unrelated formatting churn.
This commit is contained in:
1 parent
5c5a592177
commit
6adbee581e
27 files changed
+594
-146
No files matched your search
@@ -3,6 +3,7 @@ import { Prisma } from "@/generated/prisma/client";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
getClientIp,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
readOnboardingRecordState,
|
||||
toOnboardingStateDto,
|
||||
} from "@/lib/onboarding/needs-store";
|
||||
import { readManagedProfileForGuardian } from "@/lib/managed-profiles/lifecycle";
|
||||
import { writeRecordModulePreferences } from "@/lib/record-settings/modules";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
@@ -57,6 +59,17 @@ import { onboardingCompleteSchema } from "@/lib/validations/onboarding";
|
||||
* carry both halves of Q2 — which modules are on, and what is on top — and
|
||||
* the seed keeps the wizard's one contract: only while the layout column is
|
||||
* still unset.
|
||||
*
|
||||
* `managedRecordId` (C2) — "someone I look after". The answers were given
|
||||
* FOR the profile the confirm screen just created, so the derivation and the
|
||||
* seed land on THAT record: the same access rule the guardian's own
|
||||
* `PATCH /api/record-settings/modules` applies (an active guardian grant on a
|
||||
* managed record), the same `writeRecordModulePreferences`, the same audit
|
||||
* event. The caller's own record is stamped complete and its derivation
|
||||
* marker set — latched, so a later confirm cannot derive the child's answers
|
||||
* onto the guardian — and its module map is not touched. A guardian's own
|
||||
* dashboard and modules are theirs; the child's answers say nothing about
|
||||
* them.
|
||||
*/
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -102,6 +115,15 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
data.displayName = result.data.displayName;
|
||||
}
|
||||
|
||||
// The managed arm is checked BEFORE anything is stamped: a refused record
|
||||
// must leave the flow exactly where it was, so the person can fix the
|
||||
// answer or the profile and confirm again.
|
||||
const managedRecordId = result.data.managedRecordId ?? null;
|
||||
if (managedRecordId !== null) {
|
||||
const refusal = await refuseManagedRecord(user.id, managedRecordId);
|
||||
if (refusal) return refusal;
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data,
|
||||
@@ -115,11 +137,54 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
user.id,
|
||||
readHeldUnitPreferences(user),
|
||||
request,
|
||||
managedRecordId,
|
||||
);
|
||||
|
||||
return apiSuccess({ completed: true, ...(onboarding ? { onboarding } : {}) });
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether `managedRecordId` may receive this caller's answers: the record
|
||||
* must be a managed profile the caller actively guards, and the answer to Q1
|
||||
* must be "someone-else" — the one answer that says the questions were about
|
||||
* another person. A `null` means proceed.
|
||||
*/
|
||||
async function refuseManagedRecord(
|
||||
userId: string,
|
||||
managedRecordId: string,
|
||||
): Promise<Response | null> {
|
||||
const guarded = await readManagedProfileForGuardian({
|
||||
profileId: managedRecordId,
|
||||
guardianId: userId,
|
||||
});
|
||||
if (!guarded) {
|
||||
annotate({
|
||||
action: { name: "onboarding.needs.complete" },
|
||||
meta: { outcome: "not_guardian" },
|
||||
});
|
||||
return apiError("Not a guardian of that record", 403, {
|
||||
errorCode: "onboarding.complete.notGuardian",
|
||||
});
|
||||
}
|
||||
const record = await prisma.onboardingRecord.findUnique({
|
||||
where: { userId },
|
||||
select: ONBOARDING_RECORD_SELECT,
|
||||
});
|
||||
const state = readOnboardingRecordState(record);
|
||||
if (state.needs.recordTarget !== "someone-else") {
|
||||
annotate({
|
||||
action: { name: "onboarding.needs.complete" },
|
||||
meta: { outcome: "record_target_mismatch" },
|
||||
});
|
||||
return apiError(
|
||||
"The answers were not given for somebody else's record",
|
||||
422,
|
||||
{ errorCode: "onboarding.complete.recordTargetMismatch" },
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The needs half: derive the module map once, mark the confirm step, stamp the
|
||||
* flow's own completion.
|
||||
@@ -132,6 +197,7 @@ async function completeNeedsFlow(
|
||||
userId: string,
|
||||
held: HeldUnitPreferences,
|
||||
request: NextRequest,
|
||||
managedRecordId: string | null,
|
||||
) {
|
||||
const record = await prisma.onboardingRecord.findUnique({
|
||||
where: { userId },
|
||||
@@ -169,10 +235,14 @@ async function completeNeedsFlow(
|
||||
return toOnboardingStateDto(record, held);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
let derived = false;
|
||||
let dashboardSeeded = false;
|
||||
let keptForData: string[] = [];
|
||||
if (record.modulesDerivedAt === null) {
|
||||
// The record the answers are about: the managed profile for "someone I
|
||||
// look after" (verified above), otherwise the caller's own.
|
||||
const targetId = managedRecordId ?? userId;
|
||||
const defaults = deriveOnboardingModuleDefaults({
|
||||
recordTarget: state.needs.recordTarget,
|
||||
areas: state.needs.areas,
|
||||
@@ -184,7 +254,7 @@ async function completeNeedsFlow(
|
||||
});
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
where: { id: targetId },
|
||||
select: { modulePreferencesJson: true },
|
||||
});
|
||||
// Only the modules the answers would switch OFF are worth asking about,
|
||||
@@ -196,7 +266,7 @@ async function completeNeedsFlow(
|
||||
);
|
||||
const holdsData = await modulesHoldingRecordData(
|
||||
prisma,
|
||||
userId,
|
||||
targetId,
|
||||
wouldSwitchOff,
|
||||
);
|
||||
const merged = mergeDerivedModulePreferences(
|
||||
@@ -212,7 +282,7 @@ async function completeNeedsFlow(
|
||||
// unanswered cycle from recorded sex, and an untick is not a request to
|
||||
// retract that.
|
||||
await writeRecordModulePreferences({
|
||||
recordId: userId,
|
||||
recordId: targetId,
|
||||
modulePreferences: merged,
|
||||
...(defaults.cycleTracking ? { cycleTrackingEnabled: true } : {}),
|
||||
});
|
||||
@@ -228,9 +298,14 @@ async function completeNeedsFlow(
|
||||
...(defaults.cycleTracking ? ["cycleTrackingEnabled"] : []),
|
||||
];
|
||||
await auditLog("user.modules.update", {
|
||||
userId,
|
||||
userId: targetId,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { changed, keptForData: [...holdsData], source: "onboarding" },
|
||||
details: {
|
||||
changed,
|
||||
keptForData: [...holdsData],
|
||||
source: "onboarding",
|
||||
...(managedRecordId ? { guardianId: userId } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
keptForData = [...holdsData];
|
||||
@@ -249,16 +324,43 @@ async function completeNeedsFlow(
|
||||
if (seededLayout) {
|
||||
const seeded = await prisma.user.updateMany({
|
||||
where: {
|
||||
id: userId,
|
||||
id: targetId,
|
||||
dashboardWidgetsJson: { equals: Prisma.AnyNull },
|
||||
},
|
||||
data: { dashboardWidgetsJson: toJson(seededLayout) },
|
||||
});
|
||||
dashboardSeeded = seeded.count === 1;
|
||||
}
|
||||
|
||||
// The managed record's own setup row: the answers it was configured
|
||||
// from, every step settled, both stamps set. Its account payload then
|
||||
// reads a finished setup rather than nine pending steps, and a guardian
|
||||
// who opens the questions from inside that record later is offered a
|
||||
// re-run, not a first run.
|
||||
if (managedRecordId) {
|
||||
const settled = steps.map((step) => ({
|
||||
...step,
|
||||
status: "done" as const,
|
||||
}));
|
||||
await prisma.onboardingRecord.upsert({
|
||||
where: { userId: managedRecordId },
|
||||
create: {
|
||||
userId: managedRecordId,
|
||||
needsJson: toJson(state.needs),
|
||||
stepsJson: toJson(settled),
|
||||
completedAt: now,
|
||||
modulesDerivedAt: now,
|
||||
},
|
||||
update: {
|
||||
needsJson: toJson(state.needs),
|
||||
stepsJson: toJson(settled),
|
||||
completedAt: now,
|
||||
modulesDerivedAt: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const confirmed = steps.map((step) =>
|
||||
step.id === "confirm" && step.status === "pending"
|
||||
? { ...step, status: "done" as const }
|
||||
@@ -283,6 +385,7 @@ async function completeNeedsFlow(
|
||||
outcome: derived ? "derived" : "already_derived",
|
||||
keptForData: keptForData.length,
|
||||
dashboard_seeded: dashboardSeeded,
|
||||
...(managedRecordId ? { target: "managed_record" } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -17,9 +17,13 @@
|
||||
* - 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.
|
||||
*
|
||||
* What it DOES clear, since v1.39 (C2): the first result. The reading, the
|
||||
* medication or the connection the task produced stays where it landed — the
|
||||
* ledger entry is only the record of the OFFER. Kept, it painted the re-run's
|
||||
* first-result screen as already done, "Next" then skipped the write, and the
|
||||
* step stayed pending: the flow resumed there forever and the checklist never
|
||||
* went away. A re-run offers a task again and records what it produces again.
|
||||
*
|
||||
* `requireAuth()` for the reason the answers route gives: the caller's own
|
||||
* record, refused outright under an acting-account switch.
|
||||
@@ -36,6 +40,7 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
defaultOnboardingSteps,
|
||||
@@ -93,6 +98,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
stepsJson: toJson(steps),
|
||||
completedAt: null,
|
||||
modulesDerivedAt: null,
|
||||
firstResultJson: Prisma.DbNull,
|
||||
},
|
||||
select: ONBOARDING_RECORD_SELECT,
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user