mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Ten routes: lab results and their detail, the biomarker catalogue and its detail, allergies, family history, the mental-health screener history, and the health-profile facts. All GET arms; every write arm in the same files keeps `requireAuth()` and refuses under a switch. The family-history payload is the one admitted read that carries third-party health information by design — it describes the owner's relatives — and it is admitted because a caregiver reading a family history is what the feature is for and the rows are the owner's own. The mental-health module gate resolves against the record, not the caller. That is the recorded decision: the module map says which parts of a record exist, so a delegate who enabled the screener on their own account gains no screener view inside somebody else's. The integration test asserts both directions.
122 lines
3.7 KiB
TypeScript
122 lines
3.7 KiB
TypeScript
/**
|
|
* `GET /api/allergies` — newest-first list of the account's allergies.
|
|
* `POST /api/allergies` — create one allergy/intolerance record.
|
|
*
|
|
* A structured AllergyIntolerance-style RECORD, patient-reported — never a
|
|
* clinical diagnosis the app asserts. `userId` is narrowed from auth and fed
|
|
* to the Prisma `where`; it is never a body field. The `data` object is built
|
|
* field-by-field (no mass assignment); the free-text `reaction` + `note` are
|
|
* encrypted at rest.
|
|
*/
|
|
import { NextRequest } from "next/server";
|
|
|
|
import { prisma } from "@/lib/db";
|
|
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
|
import { annotate } from "@/lib/logging/context";
|
|
import { auditLog } from "@/lib/auth/audit";
|
|
import {
|
|
apiSuccess,
|
|
getClientIp,
|
|
returnAllZodIssues,
|
|
safeJson,
|
|
} from "@/lib/api-response";
|
|
import { withIdempotency } from "@/lib/idempotency";
|
|
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
|
import {
|
|
allergyCreateSchema,
|
|
allergyListQuerySchema,
|
|
} from "@/lib/validations/allergy";
|
|
import { toAllergyDTO } from "@/lib/records/dto";
|
|
|
|
export const GET = apiHandler(async (request: NextRequest) => {
|
|
const { user } = await requireRecordAuth("read");
|
|
|
|
const params = new URL(request.url).searchParams;
|
|
const parsed = allergyListQuerySchema.safeParse({
|
|
limit: params.get("limit") ?? undefined,
|
|
includeInactive: params.get("includeInactive") ?? undefined,
|
|
});
|
|
if (!parsed.success) {
|
|
return returnAllZodIssues(parsed.error, 422, {
|
|
errorCode: "allergy.invalid",
|
|
});
|
|
}
|
|
|
|
const limit = parsed.data.limit ?? 100;
|
|
const rows = await prisma.allergy.findMany({
|
|
where: {
|
|
userId: user.id,
|
|
deletedAt: null,
|
|
...(parsed.data.includeInactive === "false" ? { status: "ACTIVE" } : {}),
|
|
},
|
|
orderBy: { createdAt: "desc" },
|
|
take: limit,
|
|
});
|
|
|
|
annotate({
|
|
action: { name: "allergy.list", entity_type: "allergy" },
|
|
meta: { count: rows.length },
|
|
});
|
|
|
|
return apiSuccess(rows.map(toAllergyDTO));
|
|
});
|
|
|
|
// `withIdempotency` lets an iOS retry / double-tap re-send the same
|
|
// `Idempotency-Key` without minting a duplicate record (the labs /
|
|
// illness create precedent).
|
|
export const POST = apiHandler(withIdempotency<[NextRequest]>(postAllergy));
|
|
|
|
async function postAllergy(request: NextRequest): Promise<Response> {
|
|
const { user } = await requireAuth();
|
|
|
|
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
|
maxBytes: 16 * 1024,
|
|
});
|
|
if (jsonError) return jsonError;
|
|
|
|
const parsed = allergyCreateSchema.safeParse(rawBody);
|
|
if (!parsed.success) {
|
|
annotate({
|
|
action: { name: "allergy.validation-failed" },
|
|
meta: { issue_count: parsed.error.issues.length },
|
|
});
|
|
return returnAllZodIssues(parsed.error, 422, {
|
|
errorCode: "allergy.invalid",
|
|
});
|
|
}
|
|
|
|
const entry = parsed.data;
|
|
|
|
// Field-by-field — never spread the parsed object whole.
|
|
const created = await prisma.allergy.create({
|
|
data: {
|
|
userId: user.id,
|
|
substance: entry.substance,
|
|
category: entry.category,
|
|
type: entry.type,
|
|
severity: entry.severity ?? null,
|
|
status: entry.status,
|
|
onsetAt: entry.onsetAt ? new Date(entry.onsetAt) : null,
|
|
reactionEncrypted: entry.reaction ? encryptToBytes(entry.reaction) : null,
|
|
notesEncrypted: entry.note ? encryptToBytes(entry.note) : null,
|
|
},
|
|
});
|
|
|
|
await auditLog("allergy.create", {
|
|
userId: user.id,
|
|
ipAddress: getClientIp(request),
|
|
details: { allergyId: created.id, category: created.category },
|
|
});
|
|
|
|
annotate({
|
|
action: {
|
|
name: "allergy.create",
|
|
entity_type: "allergy",
|
|
entity_id: created.id,
|
|
},
|
|
meta: { category: created.category, type: created.type },
|
|
});
|
|
|
|
return apiSuccess(toAllergyDTO(created), 201);
|
|
}
|