mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
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:
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 });
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user