feat(records): CRUD API + OpenAPI for allergies and family history

Add the `/api/allergies` and `/api/family-history` endpoints (list / create /
read / update / soft-delete), each wrapped in `apiHandler` with Zod
`safeParse` + `returnAllZodIssues`. `userId` is always narrowed from the
session/Bearer — never a body field — and the Prisma `data` is built
field-by-field (no mass assignment). Creates wrap in `withIdempotency`. The
free-text reaction/note are encrypted before write and decrypted fail-soft on
read via the server-authoritative DTOs.

Register the query-key factory entries and the OpenAPI route tables (new
`Records` tag); regenerate `docs/api/openapi.yaml`.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 16:03:05 +02:00
1 parent 3ded891641
commit 5052b2cc08
15 files changed
+1913

No files matched your search

+144
View File
@@ -0,0 +1,144 @@
/**
* `GET /api/allergies/{id}` — one owned, live allergy record.
* `PATCH /api/allergies/{id}` — edit it (partial; field-by-field).
* `DELETE /api/allergies/{id}` — soft-delete it (idempotent; `{ deleted: true }`).
*
* Owner-scoped. `userId` is narrowed from auth and fed to the Prisma `where`;
* the body never carries it. The free-text `reaction` + `note` are encrypted
* at rest.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { allergyUpdateSchema } from "@/lib/validations/allergy";
import { toAllergyDTO } from "@/lib/records/dto";
import type { Prisma } from "@/generated/prisma/client";
type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const row = await prisma.allergy.findUnique({ where: { id } });
if (!row || row.userId !== user.id || row.deletedAt !== null) {
return apiError("Allergy not found", 404);
}
annotate({
action: { name: "allergy.read", entity_type: "allergy", entity_id: id },
});
return apiSuccess(toAllergyDTO(row));
},
);
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const existing = await prisma.allergy.findUnique({
where: { id },
select: { id: true, userId: true, deletedAt: true },
});
if (
!existing ||
existing.userId !== user.id ||
existing.deletedAt !== null
) {
return apiError("Allergy not found", 404);
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = allergyUpdateSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "allergy.invalid",
});
}
const entry = parsed.data;
// Field-by-field — only fields actually present in the body are written.
const data: Prisma.AllergyUpdateInput = {};
if (entry.substance !== undefined) data.substance = entry.substance;
if (entry.category !== undefined) data.category = entry.category;
if (entry.type !== undefined) data.type = entry.type;
if (entry.severity !== undefined) data.severity = entry.severity;
if (entry.status !== undefined) data.status = entry.status;
if (entry.onsetAt !== undefined) {
data.onsetAt = entry.onsetAt ? new Date(entry.onsetAt) : null;
}
if (entry.reaction !== undefined) {
data.reactionEncrypted = entry.reaction
? encryptToBytes(entry.reaction)
: null;
}
if (entry.note !== undefined) {
data.notesEncrypted = entry.note ? encryptToBytes(entry.note) : null;
}
const updated = await prisma.allergy.update({ where: { id }, data });
await auditLog("allergy.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { allergyId: id },
});
annotate({
action: { name: "allergy.update", entity_type: "allergy", entity_id: id },
});
return apiSuccess(toAllergyDTO(updated));
},
);
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const existing = await prisma.allergy.findUnique({
where: { id },
select: { id: true, userId: true, deletedAt: true },
});
if (!existing || existing.userId !== user.id) {
return apiError("Allergy not found", 404);
}
if (existing.deletedAt === null) {
await prisma.allergy.update({
where: { id },
data: { deletedAt: new Date() },
});
await auditLog("allergy.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { allergyId: id },
});
}
annotate({
action: { name: "allergy.delete", entity_type: "allergy", entity_id: id },
});
return apiSuccess({ deleted: true });
},
);
+121
View File
@@ -0,0 +1,121 @@
/**
* `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 } 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 requireAuth();
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);
}
+151
View File
@@ -0,0 +1,151 @@
/**
* `GET /api/family-history/{id}` — one owned, live entry.
* `PATCH /api/family-history/{id}` — edit it (partial; field-by-field).
* `DELETE /api/family-history/{id}` — soft-delete it (idempotent;
* returns `{ deleted: true }`).
*
* Owner-scoped. `userId` is narrowed from auth and fed to the Prisma `where`;
* the body never carries it. The free-text `note` is encrypted at rest.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { familyHistoryUpdateSchema } from "@/lib/validations/family-history";
import { toFamilyHistoryEntryDTO } from "@/lib/records/dto";
import type { Prisma } from "@/generated/prisma/client";
type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const row = await prisma.familyHistoryEntry.findUnique({ where: { id } });
if (!row || row.userId !== user.id || row.deletedAt !== null) {
return apiError("Family history entry not found", 404);
}
annotate({
action: {
name: "family-history.read",
entity_type: "family_history",
entity_id: id,
},
});
return apiSuccess(toFamilyHistoryEntryDTO(row));
},
);
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const existing = await prisma.familyHistoryEntry.findUnique({
where: { id },
select: { id: true, userId: true, deletedAt: true },
});
if (
!existing ||
existing.userId !== user.id ||
existing.deletedAt !== null
) {
return apiError("Family history entry not found", 404);
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = familyHistoryUpdateSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "familyHistory.invalid",
});
}
const entry = parsed.data;
// Field-by-field — only fields actually present in the body are written.
const data: Prisma.FamilyHistoryEntryUpdateInput = {};
if (entry.relationship !== undefined) {
data.relationship = entry.relationship;
}
if (entry.condition !== undefined) data.condition = entry.condition;
if (entry.ageAtOnset !== undefined) data.ageAtOnset = entry.ageAtOnset;
if (entry.note !== undefined) {
data.notesEncrypted = entry.note ? encryptToBytes(entry.note) : null;
}
const updated = await prisma.familyHistoryEntry.update({
where: { id },
data,
});
await auditLog("family-history.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { entryId: id },
});
annotate({
action: {
name: "family-history.update",
entity_type: "family_history",
entity_id: id,
},
});
return apiSuccess(toFamilyHistoryEntryDTO(updated));
},
);
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const existing = await prisma.familyHistoryEntry.findUnique({
where: { id },
select: { id: true, userId: true, deletedAt: true },
});
if (!existing || existing.userId !== user.id) {
return apiError("Family history entry not found", 404);
}
if (existing.deletedAt === null) {
await prisma.familyHistoryEntry.update({
where: { id },
data: { deletedAt: new Date() },
});
await auditLog("family-history.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { entryId: id },
});
}
annotate({
action: {
name: "family-history.delete",
entity_type: "family_history",
entity_id: id,
},
});
return apiSuccess({ deleted: true });
},
);
+112
View File
@@ -0,0 +1,112 @@
/**
* `GET /api/family-history` — newest-first list of the account's family-history
* entries.
* `POST /api/family-history` — create one condition-by-relative record.
*
* A structured FamilyMemberHistory-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 `note` is encrypted at
* rest.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } 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 {
familyHistoryCreateSchema,
familyHistoryListQuerySchema,
} from "@/lib/validations/family-history";
import { toFamilyHistoryEntryDTO } from "@/lib/records/dto";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const params = new URL(request.url).searchParams;
const parsed = familyHistoryListQuerySchema.safeParse({
limit: params.get("limit") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "familyHistory.invalid",
});
}
const limit = parsed.data.limit ?? 100;
const rows = await prisma.familyHistoryEntry.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: { createdAt: "desc" },
take: limit,
});
annotate({
action: { name: "family-history.list", entity_type: "family_history" },
meta: { count: rows.length },
});
return apiSuccess(rows.map(toFamilyHistoryEntryDTO));
});
export const POST = apiHandler(
withIdempotency<[NextRequest]>(postFamilyHistory),
);
async function postFamilyHistory(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 = familyHistoryCreateSchema.safeParse(rawBody);
if (!parsed.success) {
annotate({
action: { name: "family-history.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "familyHistory.invalid",
});
}
const entry = parsed.data;
// Field-by-field — never spread the parsed object whole.
const created = await prisma.familyHistoryEntry.create({
data: {
userId: user.id,
relationship: entry.relationship,
condition: entry.condition,
ageAtOnset: entry.ageAtOnset ?? null,
notesEncrypted: entry.note ? encryptToBytes(entry.note) : null,
},
});
await auditLog("family-history.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: { entryId: created.id, relationship: created.relationship },
});
annotate({
action: {
name: "family-history.create",
entity_type: "family_history",
entity_id: created.id,
},
meta: { relationship: created.relationship },
});
return apiSuccess(toFamilyHistoryEntryDTO(created), 201);
}