mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
feat(vaccinations): mint the booster reminder a logged dose suggests
- rung 2: a prefilled, user-confirmed, editable, declinable prompt writes an ordinary VORSORGE reminder keyed on the dose's primary antigen; the published origin enum and reminder DTO are unchanged - idempotent by antigen — a second confirmation re-anchors the existing reminder rather than minting a second (WR-9 seen red on the reminder count, reverted by content hash) - rolling day cadence so logging the next dose re-anchors the due date through the Phase 1 satisfy matcher; the whole loop proven through real routes - booster prompt copy in six locales, plural-aware
This commit is contained in:
1 parent
f540298521
commit
b5ce44628a
14 files changed
+849
No files matched your search
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* `POST /api/vaccinations/{id}/booster` — mint (or re-anchor) the booster
|
||||
* reminder a logged dose suggests.
|
||||
*
|
||||
* Rung 2: the reminder only exists because the person confirmed it, prefilled
|
||||
* from the catalogue's interval and fully editable before this call. The minted
|
||||
* row is an ordinary `origin: VORSORGE` reminder that carries the dose's
|
||||
* primary antigen as a match key; it lists on `/checkups` and rings through the
|
||||
* same engine as every other checkup, with no new code.
|
||||
*
|
||||
* WRITE, classified `profile`: filing the owner's Impfpass and arming the
|
||||
* owner's booster plan are the same act, and both run on the owner's reminders
|
||||
* even when a delegate is transcribing. The antigen is read from the dose's
|
||||
* catalogue entry server-side, never from the body — a client cannot key a
|
||||
* reminder onto an antigen the dose does not contain.
|
||||
*/
|
||||
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 { vaccinationBoosterSchema } from "@/lib/validations/vaccinations";
|
||||
import { mintOrReanchorBooster } from "@/lib/vaccinations/booster-mint";
|
||||
import { resolveOwnerTimezone } from "@/lib/vaccinations/service";
|
||||
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("write", "profile");
|
||||
const { id } = await params;
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 8 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = vaccinationBoosterSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "vaccination.booster-invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const timezone = await resolveOwnerTimezone(user.id);
|
||||
const result = await prisma.$transaction((tx) =>
|
||||
mintOrReanchorBooster(
|
||||
tx,
|
||||
user.id,
|
||||
{ vaccinationId: id, ...parsed.data },
|
||||
timezone,
|
||||
),
|
||||
);
|
||||
|
||||
if (result.outcome === "unknown-record") {
|
||||
return apiError("Vaccination not found", 404, {
|
||||
errorCode: "vaccination.not-found",
|
||||
});
|
||||
}
|
||||
if (result.outcome === "no-antigen") {
|
||||
// A free-text-only dose has no antigen to key a booster on. The prompt is
|
||||
// never offered for one; this is the defence if a request arrives anyway.
|
||||
return apiError("This dose has no catalogue antigen to remind on", 422, {
|
||||
errorCode: "vaccination.booster-no-antigen",
|
||||
});
|
||||
}
|
||||
|
||||
const reminder = await prisma.measurementReminder.findUniqueOrThrow({
|
||||
where: { id: result.reminderId },
|
||||
});
|
||||
|
||||
await auditLog("vaccination.booster.planned", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
vaccinationId: id,
|
||||
reminderId: result.reminderId,
|
||||
minted: result.outcome === "minted",
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "vaccination.booster.planned",
|
||||
entity_type: "measurement-reminder",
|
||||
entity_id: result.reminderId,
|
||||
},
|
||||
meta: {
|
||||
antigen_slug: result.antigen,
|
||||
minted: result.outcome === "minted",
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
{
|
||||
reminder: toMeasurementReminderDto(reminder),
|
||||
minted: result.outcome === "minted",
|
||||
},
|
||||
result.outcome === "minted" ? 201 : 200,
|
||||
);
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user