feat(medications): server-authoritative medication-efficacy view + API

Add the migration (0232), Prisma model MedicationEfficacyTarget (persists ONLY
the user's explicit target override; everything else derives each read), and
the server builder buildMedicationEfficacy that composes the target's series,
a before/after-start comparison, the cadence-aware adherence lane (never
recomputed), an optional conservative level-shift note, honest data-floor
states and the retarget options into one strictly-descriptive DTO — no
verdict/score field by construction. Expose GET /api/medications/[id]/efficacy
and PUT /api/medications/[id]/efficacy/target, regenerate the OpenAPI contract.
This commit is contained in:
Marc-André Bombeck committed 2026-07-09 12:45:50 +02:00
1 parent f3762cf0f9
commit 725f69097f
9 files changed
+1629 -2

No files matched your search

@@ -0,0 +1,42 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { buildMedicationEfficacy } from "@/lib/medications/efficacy/build-efficacy";
type RouteParams = { params: Promise<{ id: string }> };
/**
* GET /api/medications/[id]/efficacy — the resolved, server-authoritative
* efficacy DTO for one medication: target(s), the target's series with
* start/dose/pause markers, the before/after-start comparison, the adherence
* lane, the optional level-shift note, the data-floor state and the retarget
* options. Cookie- or Bearer-authenticated (the iOS client reads it). The DTO
* is strictly descriptive — no verdict / score field by construction.
*
* The builder's `findFirst({ where: { id, userId } })` is the ownership gate;
* a missing or foreign id yields the sealed "Medication not found" 404, the
* same shape `assertMedicationOwnership` mints.
*/
export const GET = apiHandler(
async (_request: Request, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const rl = await checkRateLimit(
`medication-efficacy:${user.id}`,
60,
60_000,
);
if (!rl.allowed) {
return apiError("Too many efficacy requests. Please retry later.", 429);
}
const userTz = user.timezone || "Europe/Berlin";
const dto = await buildMedicationEfficacy(user.id, id, userTz);
if (!dto) {
return apiError("Medication not found", 404);
}
return apiSuccess(dto);
},
);
@@ -0,0 +1,100 @@
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 { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { efficacyTargetOverrideSchema } from "@/lib/validations/medication-efficacy";
type RouteParams = { params: Promise<{ id: string }> };
/**
* PUT /api/medications/[id]/efficacy/target — set or clear the user's explicit
* efficacy-target override for a medication. `clear:true` removes the override
* rows so the resolver reverts to the derived (ATC / name) target; otherwise
* exactly one of `measurementType` / `biomarkerId` pins the new target. The
* override is the ONLY thing the "Wirkung" view persists — everything else is
* derived each read.
*
* `userId` is never a body field: ownership is narrowed through the parent
* medication (and the biomarker, when a lab target is pinned). The write
* builds its `data` object field-by-field (no mass assignment).
*/
export const PUT = apiHandler(
async (request: Request, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 8 * 1024,
});
if (jsonError) return jsonError;
const parsed = efficacyTargetOverrideSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
const { clear, measurementType, biomarkerId, primary } = parsed.data;
// Clearing reverts to the derived resolution — drop every override row.
if (clear) {
await prisma.medicationEfficacyTarget.deleteMany({
where: { medicationId: id },
});
annotate({
action: {
name: "medication.efficacy.target.clear",
entity_type: "medication",
entity_id: id,
},
meta: {},
});
return apiSuccess({ cleared: true });
}
// A lab target must reference a biomarker the caller owns.
if (biomarkerId) {
const biomarker = await prisma.biomarker.findFirst({
where: { id: biomarkerId, userId: user.id },
select: { id: true },
});
if (!biomarker) {
return apiError("Biomarker not found", 404);
}
}
// Replace the override with the single pinned target (field-by-field data).
await prisma.$transaction([
prisma.medicationEfficacyTarget.deleteMany({
where: { medicationId: id },
}),
prisma.medicationEfficacyTarget.create({
data: {
medicationId: id,
measurementType: measurementType ?? null,
biomarkerId: biomarkerId ?? null,
primary: primary ?? true,
},
select: { id: true },
}),
]);
annotate({
action: {
name: "medication.efficacy.target.set",
entity_type: "medication",
entity_id: id,
},
meta: { kind: measurementType ? "metric" : "lab" },
});
return apiSuccess({ ok: true });
},
);