mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
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:
1 parent
3ded891641
commit
5052b2cc08
15 files changed
+1913
No files matched your search
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user