feat(anamnesis): serve the life circumstances, record AUDIT-C, and keep both off the closed legacy lists

This commit is contained in:
Marc-André Bombeck committed 2026-10-16 00:54:38 +02:00
1 parent 1e7fc08f57
commit f2be772eaf
28 files changed
+2131 -220

No files matched your search

+33 -10
View File
@@ -1,21 +1,44 @@
/**
* `DELETE /api/anamnesis/audit-c/{id}` — remove an AUDIT-C entry (#614).
* Soft delete; answers `{ id, removedAt }`, 404 `anamnesis.auditC.notFound`.
*
* Contract authority: `requireRecordAuth("manage", "profile")`. Until AUDIT-C
* lands the stub resolves the caller's own record only.
* Soft delete (`deletedAt`); answers `{ id, removedAt }`, 404
* `anamnesis.auditC.notFound` for an id that is not a live AUDIT-C entry of
* the record. Authority `requireRecordAuth("manage", "profile")`, like the
* create.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import { removeAuditC } from "@/lib/profile/life-circumstances";
type RouteParams = { params: Promise<{ id: string }> };
export const DELETE = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
await requireAuth();
await params;
return notImplemented("anamnesis.audit_c.delete");
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("manage", "profile");
const { id } = await params;
const removed = await removeAuditC(user.id, id);
if (!removed) {
return apiError("AUDIT-C entry not found", 404, {
errorCode: "anamnesis.auditC.notFound",
});
}
await auditLog("anamnesis.audit_c.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { assessmentId: removed.id, instrument: "AUDIT_C" },
});
annotate({
action: {
name: "anamnesis.audit_c.delete",
entity_type: "mental_health_assessment",
entity_id: removed.id,
},
});
return apiSuccess(removed);
},
);
+44 -9
View File
@@ -6,26 +6,36 @@
* the entry's language and the profile's sex (null without one); a replay
* with the same `externalId` answers 200 with the same result. Stored beside
* the mental-health screeners but never listed by
* `/api/mental-health/assessments`. At most 30 an hour.
* `/api/mental-health/assessments`. At most 30 an hour, counted against the
* person acting.
*
* Contract authority: `requireRecordAuth("manage", "profile")`. Until AUDIT-C
* lands the stub resolves the caller's own record only.
* Authority: `requireRecordAuth("manage", "profile")`, the level allergies and
* family history are written at; the entry lands in the record and the trail
* names the manager as actor.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, returnAllZodIssues, safeJson } from "@/lib/api-response";
import { notImplemented } from "@/lib/http/not-implemented";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { withIdempotency } from "@/lib/idempotency";
import { annotate } from "@/lib/logging/context";
import { createAuditC } from "@/lib/profile/life-circumstances";
import { checkRateLimit } from "@/lib/rate-limit";
import { auditCRequestSchema } from "@/lib/validations/life-circumstances";
export const POST = apiHandler(withIdempotency<[NextRequest]>(postAuditC));
async function postAuditC(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
const { user, actor } = await requireRecordAuth("manage", "profile");
const rl = await checkRateLimit(`audit-c:${user.id}`, 30, 60 * 60 * 1000);
const rl = await checkRateLimit(`audit-c:${actor.id}`, 30, 60 * 60 * 1000);
if (!rl.allowed) {
return apiError("Too many entries, try again later", 429, {
errorCode: "anamnesis.auditC.rateLimited",
@@ -43,5 +53,30 @@ async function postAuditC(request: NextRequest): Promise<Response> {
});
}
return notImplemented("anamnesis.audit_c.create");
const input = parsed.data;
const { result, replayed } = await createAuditC(user.id, {
items: input.items,
takenAt: input.takenAt ? new Date(input.takenAt) : undefined,
tz: input.tz,
locale: input.locale ?? user.locale ?? "en",
source: input.source,
externalId: input.externalId,
});
annotate({
action: {
name: replayed ? "anamnesis.audit_c.replay" : "anamnesis.audit_c.create",
entity_type: "mental_health_assessment",
entity_id: result.id,
},
});
if (replayed) return apiSuccess({ result }, 200);
// The id and the instrument only; the answers and the sum stay out of the trail.
await auditLog("anamnesis.audit_c.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: { assessmentId: result.id, instrument: "AUDIT_C" },
});
return apiSuccess({ result }, 201);
}
+45 -12
View File
@@ -13,16 +13,28 @@ import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import {
correctHealthProfileFact,
correctLifeFact,
removeHealthProfileFact,
} from "@/lib/profile/health-facts";
import { dayStartUtc } from "@/lib/profile/life-derived";
import {
healthProfileFactCorrectionSchema,
validateCorrectedFactValue,
type HealthProfileFactKind,
} from "@/lib/validations/health-profile-facts";
import {
factCorrectionSchema,
isLifeFactKind,
parseLifeFactValue,
} from "@/lib/validations/life-circumstances";
type RouteParams = { params: Promise<{ id: string }> };
function invalidValue(): Response {
return apiError("Value is not valid for this fact kind", 422, {
errorCode: "anamnesis.fact.invalidValue",
});
}
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
@@ -32,7 +44,7 @@ export const PATCH = apiHandler(
});
if (error) return error;
const parsed = healthProfileFactCorrectionSchema.safeParse(rawBody);
const parsed = factCorrectionSchema.safeParse(rawBody);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const current = await prisma.healthProfileFactRevision.findFirst({
@@ -46,17 +58,38 @@ export const PATCH = apiHandler(
});
if (!current) return apiError("Current profile fact not found", 404);
const value = validateCorrectedFactValue(
current.kind as HealthProfileFactKind,
parsed.data.value,
);
if (!value) {
return apiError("Value is not valid for this fact kind", 422, {
errorCode: "anamnesis.fact.invalidValue",
});
// The value's form follows the stored kind: a string from the closed set
// for the three original kinds, an object with `schema: 1` for the life
// circumstances (v1.43, #614).
const now = new Date();
const validFrom = parsed.data.validFrom
? dayStartUtc(parsed.data.validFrom)
: undefined;
let corrected: { id: string; kind: string } | null;
if (isLifeFactKind(current.kind)) {
const value =
typeof parsed.data.value === "string"
? null
: parseLifeFactValue(current.kind, parsed.data.value);
if (!value) return invalidValue();
corrected = await correctLifeFact(user.id, id, value, now, validFrom);
} else {
const value =
typeof parsed.data.value === "string"
? validateCorrectedFactValue(
current.kind as HealthProfileFactKind,
parsed.data.value,
)
: null;
if (!value) return invalidValue();
corrected = await correctHealthProfileFact(
user.id,
id,
value,
now,
validFrom,
);
}
const corrected = await correctHealthProfileFact(user.id, id, value);
if (!corrected) {
return apiError("Profile fact changed since it was loaded", 409, {
errorCode: "anamnesis.fact.conflict",
+33 -7
View File
@@ -14,10 +14,18 @@ import { annotate } from "@/lib/logging/context";
import { isP2002 } from "@/lib/prisma-errors";
import {
createHealthProfileFact,
createLifeFact,
readHealthProfileFacts,
} from "@/lib/profile/health-facts";
import { healthProfileFactWriteSchema } from "@/lib/validations/health-profile-facts";
import { dayStartUtc } from "@/lib/profile/life-derived";
import { factWriteSchema } from "@/lib/validations/life-circumstances";
/**
* `GET` lists the three closed kinds only (`SMOKING_STATUS`, `ALCOHOL_PATTERN`,
* `SHIFT_SCHEDULE`): older app builds decode this list closed. `POST` also
* takes the six life-circumstance kinds (v1.43, #614), which are read back
* through `GET /api/anamnesis/life-circumstances`.
*/
export const GET = apiHandler(async () => {
const { user } = await requireRecordAuth("read", "profile");
const payload = await readHealthProfileFacts(user.id);
@@ -38,15 +46,33 @@ async function postFact(request: NextRequest): Promise<Response> {
});
if (error) return error;
const parsed = healthProfileFactWriteSchema.safeParse(rawBody);
const parsed = factWriteSchema.safeParse(rawBody);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const now = new Date();
const validFrom = parsed.data.validFrom
? dayStartUtc(parsed.data.validFrom)
: undefined;
try {
const fact = await createHealthProfileFact(
user.id,
parsed.data.kind,
parsed.data.value,
);
const input = parsed.data;
const fact =
input.kind === "SMOKING_STATUS" ||
input.kind === "ALCOHOL_PATTERN" ||
input.kind === "SHIFT_SCHEDULE"
? await createHealthProfileFact(
user.id,
input.kind,
input.value,
now,
validFrom,
)
: await createLifeFact(
user.id,
input.kind,
input.value,
now,
validFrom,
);
await auditLog("anamnesis.fact.create", {
userId: user.id,
ipAddress: getClientIp(request),
@@ -6,17 +6,26 @@
* key, over `SMOKING_STATUS`, `ALCOHOL_PATTERN`, `SHIFT_SCHEDULE`, `ACTIVITY`,
* `WORK`, `HABITS`, `GOALS`. Sections not named keep their state. A stale
* `baseUpdatedAt` is 409 `about_me_conflict`, the token `about-me` uses.
* Own account only.
* Own account only: which of a person's answers a model may read is that
* person's decision.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
import { notImplemented } from "@/lib/http/not-implemented";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import { updateLifeAiSections } from "@/lib/profile/life-circumstances";
import { lifeAiSectionsPatchSchema } from "@/lib/validations/life-circumstances";
export const PATCH = apiHandler(async (request: NextRequest) => {
await requireAuth();
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 4 * 1024,
@@ -29,5 +38,30 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
});
}
return notImplemented("anamnesis.life_circumstances.ai_sections.patch");
const outcome = await updateLifeAiSections(
user.id,
parsed.data.sections,
parsed.data.baseUpdatedAt ? new Date(parsed.data.baseUpdatedAt) : undefined,
);
if (outcome.conflict) {
annotate({ action: { name: "anamnesis.life_sections.conflict" } });
return apiError("Self-context changed since it was loaded", 409, {
errorCode: "about_me_conflict",
});
}
// Section names and switch states only; nothing about the answers.
await auditLog("anamnesis.life_sections.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { sections: parsed.data.sections },
});
annotate({
action: { name: "anamnesis.life_sections.update" },
meta: { changed: Object.keys(parsed.data.sections).length },
});
return apiSuccess({
aiSections: outcome.aiSections,
updatedAt: outcome.updatedAt,
});
});
@@ -1,17 +1,20 @@
/**
* `POST /api/anamnesis/life-circumstances/confirm` — "still true" for one
* section (#614). `{ section }`; stores the confirmation instant only, never a
* new revision, and answers `{ section, lastConfirmedAt }`. Own account only.
* new revision, and answers `{ section, lastConfirmedAt }`. Own account only,
* like the answers' AI switches: it vouches for the person's own account of
* their life, which a delegate cannot do for them.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
import { notImplemented } from "@/lib/http/not-implemented";
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { confirmLifeSection } from "@/lib/profile/life-circumstances";
import { lifeConfirmSchema } from "@/lib/validations/life-circumstances";
export const POST = apiHandler(async (request: NextRequest) => {
await requireAuth();
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 1024,
@@ -24,5 +27,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
return notImplemented("anamnesis.life_circumstances.confirm");
const lastConfirmedAt = await confirmLifeSection(
user.id,
parsed.data.section,
);
annotate({
action: { name: "anamnesis.life_section.confirm" },
meta: { section: parsed.data.section },
});
return apiSuccess({ section: parsed.data.section, lastConfirmedAt });
});
@@ -9,11 +9,24 @@
* read it under the `profile` grant, like the facts.
*/
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { buildLifeCircumstances } from "@/lib/profile/life-circumstances";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
await requireRecordAuth("read", "profile");
return notImplemented("anamnesis.life_circumstances.get");
const { user } = await requireRecordAuth("read", "profile");
const payload = await buildLifeCircumstances(user.id);
annotate({
action: { name: "anamnesis.life_circumstances.get" },
meta: {
fact_count: payload.sections.reduce(
(sum, section) => sum + section.facts.length,
0,
),
audit_c_count: payload.auditC.history.length,
},
});
return apiSuccess(payload);
});
+31 -12
View File
@@ -64,7 +64,9 @@ import {
} from "@/lib/validations/about-me";
import {
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
type HealthProfileAiSection,
HEALTH_PROFILE_AI_SECTIONS,
aboutMeAiSections,
type StoredHealthProfileAiSection,
} from "@/lib/validations/health-profile-facts";
const PUT_RATE_LIMIT = 30;
@@ -101,9 +103,13 @@ export const GET = apiHandler(async () => {
}),
getAiCapability("aboutMeQuestions"),
]);
const aiIncludedSections =
(row?.aiIncludedSections as HealthProfileAiSection[] | undefined) ??
DEFAULT_HEALTH_PROFILE_AI_SECTIONS;
// The eight sections this wire has always carried. The life-circumstance
// sections (v1.43) live in the same column but are switched and read
// through `/api/anamnesis/life-circumstances`; older app builds decode this
// list closed.
const aiIncludedSections = aboutMeAiSections(
row?.aiIncludedSections ?? DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
);
// A stored set may be model-written. While the capability is unavailable
// an outstanding set is swapped for the deterministic hints; an answered
// (empty) set stays empty.
@@ -201,7 +207,7 @@ export const PUT = apiHandler(async (req: Request) => {
conditionsEncrypted?: Uint8Array<ArrayBuffer> | null;
allergiesEncrypted?: Uint8Array<ArrayBuffer> | null;
coachFocusEncrypted?: Uint8Array<ArrayBuffer> | null;
aiIncludedSections?: HealthProfileAiSection[];
aiIncludedSections?: StoredHealthProfileAiSection[];
} = {};
const aboutMePayload = encryptOptional(parsed.data.aboutMe);
if (aboutMePayload !== undefined) {
@@ -219,9 +225,6 @@ export const PUT = apiHandler(async (req: Request) => {
if (coachFocusPayload !== undefined) {
update.coachFocusEncrypted = coachFocusPayload;
}
if (parsed.data.aiIncludedSections !== undefined) {
update.aiIncludedSections = parsed.data.aiIncludedSections;
}
const fieldLengths: Record<string, number> = {
...(text !== undefined ? { aboutMe: text.length } : {}),
...(parsed.data.conditions !== undefined
@@ -240,6 +243,23 @@ export const PUT = apiHandler(async (req: Request) => {
// commit while an hour-fresh advisor payload still references excluded
// profile content.
const persistence = await prisma.$transaction(async (tx) => {
// A PUT here replaces the eight sections it lists and leaves the
// life-circumstance switches (v1.43) exactly as stored.
if (parsed.data.aiIncludedSections !== undefined) {
const stored = await tx.userHealthProfile.findUnique({
where: { userId: user.id },
select: { aiIncludedSections: true },
});
update.aiIncludedSections = [
...parsed.data.aiIncludedSections,
...(stored?.aiIncludedSections ?? []).filter(
(section) =>
!(HEALTH_PROFILE_AI_SECTIONS as readonly string[]).includes(
section,
),
),
];
}
let updatedAtIso: string;
let savedProfileUpdatedAt: Date | null = null;
@@ -334,10 +354,9 @@ export const PUT = apiHandler(async (req: Request) => {
select: { aiIncludedSections: true },
}),
]);
const aiIncludedSections = (controlRow?.aiIncludedSections as
HealthProfileAiSection[] | undefined) ?? [
...DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
];
const aiIncludedSections = aboutMeAiSections(
controlRow?.aiIncludedSections ?? DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
);
const aiContext = filterSelfContextForAi(ctx, aiIncludedSections);
const aiContextIsEmpty =
aiContext.aboutMe === null &&
@@ -30,6 +30,7 @@
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { SCREENER_INSTRUMENTS } from "@/lib/validations/mental-health";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { annotate, getEvent } from "@/lib/logging/context";
@@ -122,7 +123,12 @@ export const GET = apiHandler(
// Fetch-then-guard against the resolved user (the by-id sibling pattern);
// a soft-deleted row 404s like it does on the list.
const row = await prisma.mentalHealthAssessment.findFirst({
where: { id, deletedAt: null },
// Screeners only; an AUDIT-C entry (v1.43) is a 404 here.
where: {
id,
deletedAt: null,
instrument: { in: [...SCREENER_INSTRUMENTS] },
},
select: {
id: true,
userId: true,
+14 -3
View File
@@ -36,6 +36,7 @@ import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
SCREENER_INSTRUMENTS,
createAssessmentSchema,
listAssessmentsSchema,
} from "@/lib/validations/mental-health";
@@ -121,7 +122,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
where: {
userId: user.id,
deletedAt: null,
...(instrument ? { instrument } : {}),
// Screeners only: the table also stores AUDIT-C (v1.43), which older
// app builds cannot decode in this list.
instrument: instrument ?? { in: [...SCREENER_INSTRUMENTS] },
},
// Unique tiebreaker — `takenAt` is day-resolution and two instruments
// taken in one sitting tie.
@@ -245,7 +248,11 @@ async function postAssessment(request: NextRequest): Promise<Response> {
// per user, not globally.
if (externalId) {
const existing = await prisma.mentalHealthAssessment.findFirst({
where: { userId: user.id, externalId },
where: {
userId: user.id,
externalId,
instrument: { in: [...SCREENER_INSTRUMENTS] },
},
select: {
id: true,
instrument: true,
@@ -332,7 +339,11 @@ async function postAssessment(request: NextRequest): Promise<Response> {
(err as { code?: unknown }).code === "P2002"
) {
const winner = await prisma.mentalHealthAssessment.findFirst({
where: { userId: user.id, externalId },
where: {
userId: user.id,
externalId,
instrument: { in: [...SCREENER_INSTRUMENTS] },
},
select: assessmentSelect,
});
if (winner) {