Add the visit and address-book API, and the appointment arm

Nine visit routes and five practitioner routes, all classified under the
existing profile domain and each reviewed into the frozen sharing
literal with the sentence that argues for it. An address book of a
person's own doctors is record content, not account configuration: it
names no credential, no integration and no notification channel, which
is the fence that classification turns on. The eight sharing domains are
unchanged. Link and unlink stay bare, matching document link and unlink,
and the pair is revisited as one decision later.

Nothing is a required field. A visit saves with a date and nothing else.
Every id a body may carry is re-narrowed to the caller before anything
is written, and an unknown link id never blocks a save.

A booked visit mints exactly one reminder on the existing engine with
the new origin, and the appointment half is the part worth reading
twice. Both cadence columns stay null, which is the schema's one-shot,
so the first due instant is stamped directly and the never-nag property
is structural rather than guarded: after it fires, the cadence helper
has no occurrence to return. Rescheduling re-anchors that row and never
mints a second, which the tests assert by counting rows rather than by
checking the date moved.

Filing a visit as done closes the checkup it was filed against, on the
transition rather than on every later edit. Writing the test first found
that: satisfying is forward-only, so re-saving a completed visit was
pushing the checkup's next due date out again as if the person had been
a second time.

An appointment must appear on no preventive-care surface, and that is
four separate reads, one of them in the assistant tooling. Each carries
the exclusion, a guard proves all four by source text with a non-zero
match count, and the reminder mapper refuses such a row outright — so a
read site that loses its filter fails loudly instead of showing an
appointment as a checkup. The published origin enum is untouched.
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 07:51:29 +02:00
1 parent ba21c3a9bc
commit f158494def
30 files changed
+2743 -6

No files matched your search

@@ -0,0 +1,93 @@
/**
* `POST /api/encounters/{id}/links` — file things against a visit.
* `DELETE /api/encounters/{id}/links` — unfile them.
*
* Deliberately bare `requireAuth()` rather than a delegable declaration, and
* the reason is consistency rather than oversight: document link and unlink are
* not delegable today — only the vault reads are — and an encounter link is the
* same verb one layer up. Making one delegable and not the other would mean two
* answers to the same question. The pair is revisited as one decision when the
* polymorphic-linking work lands.
*
* Both verbs are idempotent, both are capped, and neither ever blocks a save:
* an id that names nothing this account owns comes back in `unknown` rather
* than failing the request, because a visit must never be un-saveable because
* one document it pointed at has since been deleted.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
apiSuccess,
apiError,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { encounterLinkSchema } from "@/lib/validations/encounters";
import { linkTargets, unlinkTargets } from "@/lib/links";
import { loadEncounterLinks } from "@/lib/encounters/service";
type RouteParams = { params: Promise<{ id: string }> };
async function handle(
request: NextRequest,
{ params }: RouteParams,
verb: "link" | "unlink",
): Promise<Response> {
const { user } = await requireAuth();
const { id } = await params;
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = encounterLinkSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "encounter.link.invalid",
});
}
const apply = verb === "link" ? linkTargets : unlinkTargets;
const result = await apply(prisma, {
userId: user.id,
sourceKind: "encounter",
sourceId: id,
targetKind: parsed.data.targetKind,
targetIds: parsed.data.targetIds,
});
if (result.unknownSource) {
return apiError("Visit not found", 404);
}
annotate({
action: {
name: `encounter.link.${verb}`,
entity_type: "encounter",
entity_id: id,
},
meta: {
target_kind: parsed.data.targetKind,
changed: result.changed,
unknown: result.unknownTargetIds.length,
},
});
const links = await loadEncounterLinks(prisma, user.id, id);
return apiSuccess({ links, unknown: result.unknownTargetIds });
}
export const POST = apiHandler(
async (request: NextRequest, context: RouteParams) =>
handle(request, context, "link"),
);
export const DELETE = apiHandler(
async (request: NextRequest, context: RouteParams) =>
handle(request, context, "unlink"),
);
@@ -0,0 +1,100 @@
/**
* `POST /api/encounters/{id}/restore` — clear a visit's tombstone.
*
* MANAGE rather than WRITE: undoing a deletion is a management act when the
* manager is the one who holds delete.
*
* The link rows survived the soft delete — they cascade only on a hard one —
* so a restored visit comes back with its documents, labs and conditions still
* filed against it. Its appointment reminder comes back too, but only if the
* appointment is still in the future: restoring a visit whose date has passed
* must not produce a nudge for something that already did or did not happen.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { toEncounterDTO } from "@/lib/encounters/dto";
import {
ENCOUNTER_INCLUDE,
loadEncounterLinks,
resolveTimezone,
shouldHaveReminder,
} from "@/lib/encounters/service";
import { reanchorAppointmentReminder } from "@/lib/encounters/appointment-reminder";
type RouteParams = { params: Promise<{ id: string }> };
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("manage", "profile");
const { id } = await params;
const existing = await prisma.encounter.findUnique({
where: { id },
include: { practitioner: true },
});
if (!existing || existing.userId !== user.id) {
return apiError("Visit not found", 404);
}
if (existing.deletedAt !== null) {
const timezone = await resolveTimezone(user.id);
const now = new Date();
await prisma.$transaction(async (tx) => {
await tx.encounter.update({
where: { id },
data: { deletedAt: null },
});
if (
existing.reminderId &&
shouldHaveReminder(existing.status, existing.occurredAt, now)
) {
const reminder = await tx.measurementReminder.findUnique({
where: { id: existing.reminderId },
select: { id: true, origin: true },
});
if (reminder?.origin === "ENCOUNTER") {
await reanchorAppointmentReminder(
tx,
reminder.id,
{
userId: user.id,
occurredAt: existing.occurredAt,
practitionerName: existing.practitioner?.name ?? null,
practitionerLocation: existing.practitioner?.location ?? null,
kindLabel: existing.kind,
},
timezone,
);
}
}
});
}
const row = await prisma.encounter.findUniqueOrThrow({
where: { id },
include: ENCOUNTER_INCLUDE,
});
const links = await loadEncounterLinks(prisma, user.id, id);
await auditLog("encounter.visit.restore", {
userId: user.id,
ipAddress: getClientIp(request),
details: { encounterId: id, occurredAt: row.occurredAt.toISOString() },
});
annotate({
action: {
name: "encounter.visit.restore",
entity_type: "encounter",
entity_id: id,
},
});
return apiSuccess(toEncounterDTO(row, links));
},
);
+361
View File
@@ -0,0 +1,361 @@
/**
* `GET /api/encounters/{id}` — one owned, live visit with its links resolved.
* `PATCH /api/encounters/{id}` — edit it (partial; field-by-field).
* `DELETE /api/encounters/{id}` — soft-delete it, and retire its appointment
* reminder with it.
*
* Owner-scoped by fetch-then-guard. `userId` is narrowed from auth; the body
* never carries it.
*
* The status transitions are where the appointment arm lives. Moving a visit to
* DONE closes the checkup it was filed against — on the transition, so re-saving
* a completed visit does not push that checkup's next due date out again.
* Moving it to CANCELLED or NO_SHOW stops its reminder without deleting the
* row, so the history stays honest. Moving its date re-anchors the reminder it
* already owns and never mints a second.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { overwriteDetails } from "@/lib/sharing/audit-details";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { encounterUpdateSchema } from "@/lib/validations/encounters";
import { toEncounterDTO } from "@/lib/encounters/dto";
import {
ENCOUNTER_INCLUDE,
applyEncounterLinks,
closeCheckupForVisit,
loadEncounterLinks,
resolveClosableReminder,
resolveOwnedPractitioner,
resolveTimezone,
shouldHaveReminder,
syncAppointmentReminder,
} from "@/lib/encounters/service";
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 requireRecordAuth("read", "profile");
const { id } = await params;
const row = await prisma.encounter.findUnique({
where: { id },
include: ENCOUNTER_INCLUDE,
});
if (!row || row.userId !== user.id || row.deletedAt !== null) {
return apiError("Visit not found", 404);
}
const links = await loadEncounterLinks(prisma, user.id, id);
annotate({
action: {
name: "encounter.visit.read",
entity_type: "encounter",
entity_id: id,
},
});
return apiSuccess(toEncounterDTO(row, links));
},
);
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("manage", "profile");
const { id } = await params;
const existing = await prisma.encounter.findUnique({ where: { id } });
if (
!existing ||
existing.userId !== user.id ||
existing.deletedAt !== null
) {
return apiError("Visit not found", 404);
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 32 * 1024,
});
if (jsonError) return jsonError;
const parsed = encounterUpdateSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "encounter.invalid",
});
}
const entry = parsed.data;
const timezone = await resolveTimezone(user.id);
const now = new Date();
// The state the visit will be in once this edit lands. Both the reminder
// decision and the checkup decision read from here rather than from the
// body, so an edit that moves only the date is judged against the status
// the visit already has.
const nextOccurredAt = entry.occurredAt
? new Date(entry.occurredAt)
: existing.occurredAt;
const nextStatus = entry.status ?? existing.status;
const outcome = await prisma.$transaction(async (tx) => {
let practitionerId = existing.practitionerId;
let practitionerName: string | null = null;
let practitionerLocation: string | null = null;
if (entry.practitionerId !== undefined) {
if (entry.practitionerId === null) {
practitionerId = null;
} else {
const resolved = await resolveOwnedPractitioner(
tx,
user.id,
entry.practitionerId,
);
if (!resolved) return "unknown-practitioner" as const;
practitionerId = resolved.id;
practitionerName = resolved.name;
practitionerLocation = resolved.location;
}
} else if (practitionerId) {
const resolved = await resolveOwnedPractitioner(
tx,
user.id,
practitionerId,
);
practitionerName = resolved?.name ?? null;
practitionerLocation = resolved?.location ?? null;
}
const data: Prisma.EncounterUpdateInput = {};
if (entry.occurredAt !== undefined) data.occurredAt = nextOccurredAt;
if (entry.status !== undefined) data.status = entry.status;
if (entry.kind !== undefined) data.kind = entry.kind;
if (entry.reason !== undefined) {
data.reasonEncrypted = entry.reason
? encryptToBytes(entry.reason)
: null;
}
if (entry.outcome !== undefined) {
data.outcomeEncrypted = entry.outcome
? encryptToBytes(entry.outcome)
: null;
}
if (entry.practitionerId !== undefined) {
data.practitioner = practitionerId
? { connect: { id: practitionerId } }
: { disconnect: true };
}
// Whether the visit's own appointment reminder — the ENCOUNTER-origin
// row this code minted — is the thing `reminderId` currently holds. A
// checkup the visit closes lives in the same column and must not be
// re-anchored onto the appointment's date.
const currentReminder = existing.reminderId
? await tx.measurementReminder.findUnique({
where: { id: existing.reminderId },
select: { id: true, origin: true },
})
: null;
const ownAppointmentReminderId =
currentReminder?.origin === "ENCOUNTER" ? currentReminder.id : null;
let reminderId = existing.reminderId;
if (shouldHaveReminder(nextStatus, nextOccurredAt, now)) {
reminderId = await syncAppointmentReminder(
tx,
{
userId: user.id,
occurredAt: nextOccurredAt,
practitionerName,
practitionerLocation,
kindLabel: entry.kind ?? existing.kind,
status: nextStatus,
existingReminderId: ownAppointmentReminderId,
},
timezone,
now,
);
} else if (ownAppointmentReminderId) {
// No longer a future appointment: the row stays, switched off.
reminderId = await syncAppointmentReminder(
tx,
{
userId: user.id,
occurredAt: nextOccurredAt,
practitionerName,
practitionerLocation,
kindLabel: entry.kind ?? existing.kind,
status: nextStatus,
existingReminderId: ownAppointmentReminderId,
},
timezone,
now,
);
}
// A body may re-point the visit at the checkup it closes.
if (entry.reminderId !== undefined) {
if (entry.reminderId === null) {
reminderId = ownAppointmentReminderId;
} else {
const checkup = await resolveClosableReminder(
tx,
user.id,
entry.reminderId,
);
if (!checkup) return "unknown-reminder" as const;
reminderId = checkup.id;
}
}
if (reminderId !== existing.reminderId) {
data.reminder = reminderId
? { connect: { id: reminderId } }
: { disconnect: true };
}
const updated = await tx.encounter.update({ where: { id }, data });
await applyEncounterLinks(tx, user.id, id, entry);
// Filing the visit as done closes the checkup it names — on the
// TRANSITION, not on every edit of a visit that is already done.
//
// `satisfyReminder` is forward-only, which means an older instant is a
// no-op and a NEWER one always advances. So re-saving a completed visit
// would push the checkup's next due date out again, as if the person had
// been a second time. Gating on the transition is what makes re-filing
// genuinely idempotent rather than merely non-erroring.
const closesCheckupNow =
nextStatus === "DONE" && existing.status !== "DONE";
if (closesCheckupNow && reminderId) {
const checkup = await resolveClosableReminder(tx, user.id, reminderId);
if (checkup) await closeCheckupForVisit(tx, checkup, timezone, now);
}
return { updated };
});
if (outcome === "unknown-practitioner") {
return apiError("Practitioner not found", 404, {
errorCode: "encounter.practitioner-not-found",
});
}
if (outcome === "unknown-reminder") {
return apiError("Checkup not found", 404, {
errorCode: "encounter.reminder-not-found",
});
}
const row = await prisma.encounter.findUniqueOrThrow({
where: { id },
include: ENCOUNTER_INCLUDE,
});
const links = await loadEncounterLinks(prisma, user.id, id);
await auditLog("encounter.visit.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
encounterId: id,
// The free text is deliberately absent: it is encrypted PHI and the
// audit table is not a second copy of the record.
...overwriteDetails({
before: {
occurredAt: existing.occurredAt,
status: existing.status,
kind: existing.kind,
practitionerId: existing.practitionerId,
},
after: {
occurredAt: outcome.updated.occurredAt,
status: outcome.updated.status,
kind: outcome.updated.kind,
practitionerId: outcome.updated.practitionerId,
},
}),
},
});
annotate({
action: {
name: "encounter.visit.update",
entity_type: "encounter",
entity_id: id,
},
meta: { status: row.status },
});
return apiSuccess(toEncounterDTO(row, links));
},
);
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("manage", "profile");
const { id } = await params;
const existing = await prisma.encounter.findUnique({ where: { id } });
if (!existing || existing.userId !== user.id) {
return apiError("Visit not found", 404);
}
// Already tombstoned: deleting twice is a no-op that still succeeds.
if (existing.deletedAt === null) {
const at = new Date();
await prisma.$transaction(async (tx) => {
await tx.encounter.update({ where: { id }, data: { deletedAt: at } });
// Only the visit's OWN appointment reminder goes with it. A checkup the
// visit closed belongs to the person's Vorsorge list and outlives it.
if (existing.reminderId) {
const reminder = await tx.measurementReminder.findUnique({
where: { id: existing.reminderId },
select: { id: true, origin: true },
});
if (reminder?.origin === "ENCOUNTER") {
await tx.measurementReminder.update({
where: { id: reminder.id },
data: { deletedAt: at, enabled: false, nextDueAt: null },
});
}
}
});
}
await auditLog("encounter.visit.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
encounterId: id,
occurredAt: existing.occurredAt.toISOString(),
status: existing.status,
},
});
annotate({
action: {
name: "encounter.visit.delete",
entity_type: "encounter",
entity_id: id,
},
});
return apiSuccess({ deleted: true });
},
);
+255
View File
@@ -0,0 +1,255 @@
/**
* `GET /api/encounters` — the account's visits, split into upcoming and past.
* `POST /api/encounters` — file one, optionally pre-linked and optionally
* booked as a future appointment.
*
* No module gate: a visit is core. The reasoning is written at the `Encounter`
* model in `prisma/schema.prisma`, and the gate is relocated to the link
* pickers, which follow the modules that own what they point at.
*
* `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. Nothing is mandatory
* except the date: a visit saves with an instant and nothing else.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } 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 { withIdempotency } from "@/lib/idempotency";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
encounterCreateSchema,
encounterListQuerySchema,
} from "@/lib/validations/encounters";
import { toEncounterDTO, type EncounterListDTO } from "@/lib/encounters/dto";
import {
ENCOUNTER_INCLUDE,
applyEncounterLinks,
closeCheckupForVisit,
resolveClosableReminder,
resolveOwnedPractitioner,
resolveTimezone,
shouldHaveReminder,
syncAppointmentReminder,
} from "@/lib/encounters/service";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "profile");
const params = new URL(request.url).searchParams;
const parsed = encounterListQuerySchema.safeParse({
from: params.get("from") ?? undefined,
to: params.get("to") ?? undefined,
status: params.get("status") ?? undefined,
practitionerId: params.get("practitionerId") ?? undefined,
limit: params.get("limit") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "encounter.invalid",
});
}
const query = parsed.data;
const take = query.limit ?? 100;
const now = new Date();
const where = {
userId: user.id,
deletedAt: null,
...(query.status ? { status: query.status } : {}),
...(query.practitionerId ? { practitionerId: query.practitionerId } : {}),
...(query.from || query.to
? {
occurredAt: {
...(query.from ? { gte: new Date(query.from) } : {}),
...(query.to ? { lte: new Date(query.to) } : {}),
},
}
: {}),
};
// Two queries rather than one sorted list. Upcoming reads soonest-first and
// past reads newest-first, which is the opposite direction; resolving both
// here means the client renders two lists instead of splitting and
// re-sorting one, and the ordering lives in one place.
const [upcoming, past] = await Promise.all([
prisma.encounter.findMany({
where: { ...where, occurredAt: { ...where.occurredAt, gt: now } },
orderBy: { occurredAt: "asc" },
take,
include: ENCOUNTER_INCLUDE,
}),
prisma.encounter.findMany({
where: { ...where, occurredAt: { ...where.occurredAt, lte: now } },
orderBy: { occurredAt: "desc" },
take,
include: ENCOUNTER_INCLUDE,
}),
]);
annotate({
action: { name: "encounter.visit.list", entity_type: "encounter" },
meta: { upcoming: upcoming.length, past: past.length },
});
const body: EncounterListDTO = {
upcoming: upcoming.map((row) => toEncounterDTO(row)),
past: past.map((row) => toEncounterDTO(row)),
};
return apiSuccess(body);
});
// A double-tap on "save" must not file the same visit twice.
export const POST = apiHandler(withIdempotency<[NextRequest]>(postEncounter));
async function postEncounter(request: NextRequest): Promise<Response> {
const { user } = await requireRecordAuth("write", "profile");
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 32 * 1024,
});
if (jsonError) return jsonError;
const parsed = encounterCreateSchema.safeParse(rawBody);
if (!parsed.success) {
annotate({
action: { name: "encounter.visit.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "encounter.invalid",
});
}
const entry = parsed.data;
const occurredAt = new Date(entry.occurredAt);
const timezone = await resolveTimezone(user.id);
const now = new Date();
const created = await prisma.$transaction(async (tx) => {
// A practitioner the caller does not own is a refusal rather than a
// silently dropped field: the person named a practice and would otherwise
// see the visit save without it.
let practitioner:
{ id: string; name: string; location: string | null } | undefined;
if (entry.practitionerId) {
practitioner = await resolveOwnedPractitioner(
tx,
user.id,
entry.practitionerId,
);
if (!practitioner) return null;
}
// Field-by-field — never spread the parsed object whole.
const row = await tx.encounter.create({
data: {
userId: user.id,
occurredAt,
status: entry.status,
kind: entry.kind,
practitionerId: practitioner?.id ?? null,
reasonEncrypted: entry.reason ? encryptToBytes(entry.reason) : null,
outcomeEncrypted: entry.outcome ? encryptToBytes(entry.outcome) : null,
},
select: { id: true },
});
await applyEncounterLinks(tx, user.id, row.id, entry);
// `Encounter.reminderId` carries one of two things, never both, and which
// one follows from the visit's own state. A booked appointment owns a
// reminder this code mints; a completed visit may instead close a checkup
// the person already had. A body claiming both is contradictory — a visit
// that has not happened cannot have closed anything — and is refused.
let reminderId: string | null = null;
if (shouldHaveReminder(entry.status, occurredAt, now)) {
if (entry.reminderId) return "contradictory-reminder" as const;
reminderId = await syncAppointmentReminder(
tx,
{
userId: user.id,
occurredAt,
practitionerName: practitioner?.name ?? null,
practitionerLocation: practitioner?.location ?? null,
kindLabel: entry.kind,
status: entry.status,
existingReminderId: null,
},
timezone,
now,
);
} else if (entry.reminderId) {
const checkup = await resolveClosableReminder(
tx,
user.id,
entry.reminderId,
);
if (!checkup) return "unknown-reminder" as const;
reminderId = checkup.id;
// Filing a visit as done against a due checkup closes it. Forward-only
// and idempotent, so re-filing the same visit changes nothing.
if (entry.status === "DONE") {
await closeCheckupForVisit(tx, checkup, timezone, now);
}
}
if (reminderId) {
await tx.encounter.update({
where: { id: row.id },
data: { reminderId },
});
}
return tx.encounter.findUniqueOrThrow({
where: { id: row.id },
include: ENCOUNTER_INCLUDE,
});
});
if (created === null) {
return apiError("Practitioner not found", 404, {
errorCode: "encounter.practitioner-not-found",
});
}
if (created === "unknown-reminder") {
return apiError("Checkup not found", 404, {
errorCode: "encounter.reminder-not-found",
});
}
if (created === "contradictory-reminder") {
return apiError(
"A planned visit cannot also close a checkup — it has not happened yet",
422,
{ errorCode: "encounter.reminder-conflict" },
);
}
await auditLog("encounter.visit.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: { encounterId: created.id, status: created.status },
});
annotate({
action: {
name: "encounter.visit.create",
entity_type: "encounter",
entity_id: created.id,
},
meta: { status: created.status, kind: created.kind },
});
return apiSuccess(toEncounterDTO(created), 201);
}
+7 -1
View File
@@ -39,7 +39,13 @@ export const GET = apiHandler(async () => {
const { user } = await requireRecordAuth("read", "measurements");
const reminders = await prisma.measurementReminder.findMany({
where: { userId: user.id, deletedAt: null },
// A booked visit's one-shot reminder rides this same engine with
// `origin: ENCOUNTER`. It is not a checkup and must not appear on a
// Vorsorge surface, or the list fills with appointments. Four read
// sites carry this exclusion; `encounter-reminder-exclusion.test.ts`
// proves every one of them, and the DTO mapper refuses such a row
// outright so a site that lost its filter fails loudly.
where: { userId: user.id, deletedAt: null, origin: { not: "ENCOUNTER" } },
// Most-urgent first; a null next-due (uncomputable / disabled) sinks
// to the end so the actionable items float to the top.
orderBy: [
@@ -0,0 +1,58 @@
/**
* `POST /api/practitioners/{id}/restore` — clear a practitioner's tombstone.
*
* MANAGE rather than WRITE: undoing a deletion is a management act when the
* manager is the one who holds delete.
*
* Restoring the address-book entry does NOT re-attach it to the visits that
* named it. Those references were nulled when the row was deleted, and
* inventing them back would be guessing at which visits meant this practice.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { toPractitionerDTO } from "@/lib/practitioners/dto";
type RouteParams = { params: Promise<{ id: string }> };
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("manage", "profile");
const { id } = await params;
const existing = await prisma.practitioner.findUnique({ where: { id } });
if (!existing || existing.userId !== user.id) {
return apiError("Practitioner not found", 404);
}
// Restoring a live row is a no-op that still succeeds, so a retry after a
// dropped response does not answer 404 for work that already happened.
const restored =
existing.deletedAt === null
? existing
: await prisma.practitioner.update({
where: { id },
data: { deletedAt: null },
});
await auditLog("practitioner.contact.restore", {
userId: user.id,
ipAddress: getClientIp(request),
details: { practitionerId: id, name: restored.name },
});
annotate({
action: {
name: "practitioner.contact.restore",
entity_type: "practitioner",
entity_id: id,
},
});
return apiSuccess(toPractitionerDTO(restored));
},
);
+174
View File
@@ -0,0 +1,174 @@
/**
* `GET /api/practitioners/{id}` — one owned, live practitioner.
* `PATCH /api/practitioners/{id}` — edit it (partial; field-by-field).
* `DELETE /api/practitioners/{id}` — soft-delete it (idempotent).
*
* Owner-scoped by fetch-then-guard, the episode-detail pattern: the row is
* read by id and then checked against the resolved user, so a foreign id and a
* missing id give the same 404 and probing learns nothing.
*
* Deleting a practitioner keeps every visit that named it. The foreign key is
* SetNull, so the visits survive with no practice attached rather than
* disappearing with the address-book entry.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { overwriteDetails } from "@/lib/sharing/audit-details";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { practitionerUpdateSchema } from "@/lib/validations/practitioners";
import { toPractitionerDTO } from "@/lib/practitioners/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 requireRecordAuth("read", "profile");
const { id } = await params;
const row = await prisma.practitioner.findUnique({ where: { id } });
if (!row || row.userId !== user.id || row.deletedAt !== null) {
return apiError("Practitioner not found", 404);
}
annotate({
action: {
name: "practitioner.contact.read",
entity_type: "practitioner",
entity_id: id,
},
});
return apiSuccess(toPractitionerDTO(row));
},
);
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("manage", "profile");
const { id } = await params;
const existing = await prisma.practitioner.findUnique({ where: { id } });
if (
!existing ||
existing.userId !== user.id ||
existing.deletedAt !== null
) {
return apiError("Practitioner not found", 404);
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = practitionerUpdateSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "practitioner.invalid",
});
}
const entry = parsed.data;
// Field-by-field, and only the keys the body actually named: an absent key
// leaves the column alone, an explicit null clears it.
const data: Prisma.PractitionerUpdateInput = {};
if (entry.name !== undefined) data.name = entry.name;
if (entry.specialty !== undefined) data.specialty = entry.specialty;
if (entry.practice !== undefined) data.practice = entry.practice;
if (entry.location !== undefined) data.location = entry.location;
if (entry.phone !== undefined) data.phone = entry.phone;
if (entry.note !== undefined) {
data.noteEncrypted = entry.note ? encryptToBytes(entry.note) : null;
}
const updated = await prisma.practitioner.update({ where: { id }, data });
await auditLog("practitioner.contact.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
practitionerId: id,
// A manager may edit somebody else's address book, so the owner's
// activity feed has to say what changed rather than only that
// something did. The note is deliberately absent: it is encrypted PHI
// and the audit table is not a second copy of the record.
...overwriteDetails({
before: {
name: existing.name,
specialty: existing.specialty,
practice: existing.practice,
location: existing.location,
phone: existing.phone,
},
after: {
name: updated.name,
specialty: updated.specialty,
practice: updated.practice,
location: updated.location,
phone: updated.phone,
},
}),
},
});
annotate({
action: {
name: "practitioner.contact.update",
entity_type: "practitioner",
entity_id: id,
},
});
return apiSuccess(toPractitionerDTO(updated));
},
);
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("manage", "profile");
const { id } = await params;
const existing = await prisma.practitioner.findUnique({ where: { id } });
if (!existing || existing.userId !== user.id) {
return apiError("Practitioner not found", 404);
}
// Already tombstoned: deleting twice is a no-op that still succeeds.
if (existing.deletedAt === null) {
await prisma.practitioner.update({
where: { id },
data: { deletedAt: new Date() },
});
}
await auditLog("practitioner.contact.delete", {
userId: user.id,
ipAddress: getClientIp(request),
// Soft delete: the row and its name survive, so the id is enough to
// reconstruct what went missing from the list.
details: { practitionerId: id, name: existing.name },
});
annotate({
action: {
name: "practitioner.contact.delete",
entity_type: "practitioner",
entity_id: id,
},
});
return apiSuccess({ deleted: true });
},
);
+123
View File
@@ -0,0 +1,123 @@
/**
* `GET /api/practitioners` — the account's own address book of doctors and
* practices, name-ordered and searchable.
* `POST /api/practitioners` — add one.
*
* No module gate: an address book is core, like the checkups page the visits
* that reference it live on. The reasoning is written at the `Practitioner`
* model in `prisma/schema.prisma`.
*
* `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, never spread from the
* parsed body. `note` is encrypted at rest; `name` stays plaintext so the
* picker can search and sort it in SQL.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, 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 {
practitionerCreateSchema,
practitionerListQuerySchema,
} from "@/lib/validations/practitioners";
import { toPractitionerDTO } from "@/lib/practitioners/dto";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "profile");
const params = new URL(request.url).searchParams;
const parsed = practitionerListQuerySchema.safeParse({
q: params.get("q") ?? undefined,
limit: params.get("limit") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "practitioner.invalid",
});
}
const rows = await prisma.practitioner.findMany({
where: {
userId: user.id,
deletedAt: null,
...(parsed.data.q
? { name: { contains: parsed.data.q, mode: "insensitive" as const } }
: {}),
},
orderBy: { name: "asc" },
take: parsed.data.limit ?? 100,
});
annotate({
action: { name: "practitioner.contact.list", entity_type: "practitioner" },
meta: { count: rows.length },
});
return apiSuccess(rows.map(toPractitionerDTO));
});
// A double-tap on "save" must not mint a second copy of the same practice.
export const POST = apiHandler(
withIdempotency<[NextRequest]>(postPractitioner),
);
async function postPractitioner(request: NextRequest): Promise<Response> {
const { user } = await requireRecordAuth("write", "profile");
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = practitionerCreateSchema.safeParse(rawBody);
if (!parsed.success) {
annotate({
action: { name: "practitioner.contact.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "practitioner.invalid",
});
}
const entry = parsed.data;
// Field-by-field — never spread the parsed object whole.
const created = await prisma.practitioner.create({
data: {
userId: user.id,
name: entry.name,
specialty: entry.specialty ?? null,
practice: entry.practice ?? null,
location: entry.location ?? null,
phone: entry.phone ?? null,
noteEncrypted: entry.note ? encryptToBytes(entry.note) : null,
},
});
await auditLog("practitioner.contact.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: { practitionerId: created.id },
});
annotate({
action: {
name: "practitioner.contact.create",
entity_type: "practitioner",
entity_id: created.id,
},
});
return apiSuccess(toPractitionerDTO(created), 201);
}