Files
HealthLog/src/app/api/allergies/route.ts
T
Marc-André Bombeck af0f749740 feat(sharing): open the lab and clinical-record reads to a delegate
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.
2026-08-02 16:38:39 +02:00

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);
}