mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
feat(anamnesis): serve the life circumstances, record AUDIT-C, and keep both off the closed legacy lists
This commit is contained in:
1 parent
1e7fc08f57
commit
f2be772eaf
28 files changed
+2131
-220
No files matched your search
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user