mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(vaccinations): publish the routes, classify them, gate the module
- four route files: list and create, read and edit and delete, restore, and the link pair; every delegable one is classified as health background, in each leg of the sharing guard, with its reason written - link and unlink stay bare, consistent with the document and visit link routes; the three are revisited as one decision - vaccinations becomes the nineteenth module key, default-on and surface-gated like medications: the data routes stay exempt so a restore keeps working and re-enabling finds every dose intact - a delegate's dose entries get their own sentence in the owner's activity view rather than reading as a generic change
This commit is contained in:
1 parent
4dc3b1aa27
commit
7ac81f3eed
20 files changed
+853
-3
No files matched your search
@@ -8523,6 +8523,10 @@
|
||||
"nutrients": {
|
||||
"label": "Nährstoffzufuhr",
|
||||
"description": "Speichert tägliche Mikronährstoff-Summen (Vitamine, Mineralstoffe, Wasser, Koffein) aus Apple Health. Standardmäßig aus."
|
||||
},
|
||||
"vaccinations": {
|
||||
"label": "Impfungen",
|
||||
"description": "Der Impfpass in strukturierter Form: welche Dosis wann gegeben wurde, in welcher Serie sie steht und wann die nächste Auffrischung ansteht."
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
@@ -9944,6 +9948,10 @@
|
||||
"encounterVisitUpdate": "{name} hat einen Arztbesuch bearbeitet",
|
||||
"encounterVisitDelete": "{name} hat einen Arztbesuch gelöscht",
|
||||
"encounterVisitRestore": "{name} hat einen Arztbesuch wiederhergestellt",
|
||||
"vaccinationRecordCreate": "{name} hat eine Impfung eingetragen",
|
||||
"vaccinationRecordUpdate": "{name} hat eine Impfung bearbeitet",
|
||||
"vaccinationRecordDelete": "{name} hat eine Impfung gelöscht",
|
||||
"vaccinationRecordRestore": "{name} hat eine Impfung wiederhergestellt",
|
||||
"practitionerContactCreate": "{name} hat eine Praxis zum Adressbuch hinzugefügt",
|
||||
"practitionerContactUpdate": "{name} hat einen Adressbucheintrag bearbeitet",
|
||||
"practitionerContactDelete": "{name} hat einen Adressbucheintrag gelöscht",
|
||||
|
||||
@@ -8523,6 +8523,10 @@
|
||||
"nutrients": {
|
||||
"label": "Nutrient intake",
|
||||
"description": "Store daily micronutrient totals (vitamins, minerals, water, caffeine) synced from Apple Health. Off by default."
|
||||
},
|
||||
"vaccinations": {
|
||||
"label": "Vaccinations",
|
||||
"description": "The vaccination record in structured form: which dose was given when, where it sits in its series, and when the next booster is due."
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
@@ -9944,6 +9948,10 @@
|
||||
"encounterVisitUpdate": "{name} edited a visit",
|
||||
"encounterVisitDelete": "{name} deleted a visit",
|
||||
"encounterVisitRestore": "{name} restored a visit",
|
||||
"vaccinationRecordCreate": "{name} added a vaccination",
|
||||
"vaccinationRecordUpdate": "{name} edited a vaccination",
|
||||
"vaccinationRecordDelete": "{name} deleted a vaccination",
|
||||
"vaccinationRecordRestore": "{name} restored a vaccination",
|
||||
"practitionerContactCreate": "{name} added a practice to the address book",
|
||||
"practitionerContactUpdate": "{name} edited an address book entry",
|
||||
"practitionerContactDelete": "{name} deleted an address book entry",
|
||||
|
||||
@@ -8523,6 +8523,10 @@
|
||||
"nutrients": {
|
||||
"label": "Ingesta de nutrientes",
|
||||
"description": "Guarda los totales diarios de micronutrientes (vitaminas, minerales, agua, cafeína) sincronizados desde Apple Health. Desactivado por defecto."
|
||||
},
|
||||
"vaccinations": {
|
||||
"label": "Vacunas",
|
||||
"description": "La cartilla de vacunación en forma estructurada: qué dosis se administró y cuándo, en qué lugar de la serie está y cuándo toca el próximo refuerzo."
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
@@ -9944,6 +9948,10 @@
|
||||
"encounterVisitUpdate": "{name} editó una visita médica",
|
||||
"encounterVisitDelete": "{name} eliminó una visita médica",
|
||||
"encounterVisitRestore": "{name} restauró una visita médica",
|
||||
"vaccinationRecordCreate": "{name} añadió una vacuna",
|
||||
"vaccinationRecordUpdate": "{name} editó una vacuna",
|
||||
"vaccinationRecordDelete": "{name} eliminó una vacuna",
|
||||
"vaccinationRecordRestore": "{name} restauró una vacuna",
|
||||
"practitionerContactCreate": "{name} añadió una consulta a la agenda",
|
||||
"practitionerContactUpdate": "{name} editó una entrada de la agenda",
|
||||
"practitionerContactDelete": "{name} eliminó una entrada de la agenda",
|
||||
|
||||
@@ -8523,6 +8523,10 @@
|
||||
"nutrients": {
|
||||
"label": "Apports en nutriments",
|
||||
"description": "Enregistre les totaux quotidiens de micronutriments (vitamines, minéraux, eau, caféine) synchronisés depuis Apple Health. Désactivé par défaut."
|
||||
},
|
||||
"vaccinations": {
|
||||
"label": "Vaccinations",
|
||||
"description": "Le carnet de vaccination sous forme structurée : quelle dose a été administrée et quand, sa place dans la série, et la date du prochain rappel."
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
@@ -9944,6 +9948,10 @@
|
||||
"encounterVisitUpdate": "{name} a modifié une consultation",
|
||||
"encounterVisitDelete": "{name} a supprimé une consultation",
|
||||
"encounterVisitRestore": "{name} a restauré une consultation",
|
||||
"vaccinationRecordCreate": "{name} a ajouté une vaccination",
|
||||
"vaccinationRecordUpdate": "{name} a modifié une vaccination",
|
||||
"vaccinationRecordDelete": "{name} a supprimé une vaccination",
|
||||
"vaccinationRecordRestore": "{name} a restauré une vaccination",
|
||||
"practitionerContactCreate": "{name} a ajouté un cabinet au carnet d'adresses",
|
||||
"practitionerContactUpdate": "{name} a modifié une entrée du carnet d'adresses",
|
||||
"practitionerContactDelete": "{name} a supprimé une entrée du carnet d'adresses",
|
||||
|
||||
@@ -8523,6 +8523,10 @@
|
||||
"nutrients": {
|
||||
"label": "Assunzione di nutrienti",
|
||||
"description": "Salva i totali giornalieri di micronutrienti (vitamine, minerali, acqua, caffeina) sincronizzati da Apple Health. Disattivato per impostazione predefinita."
|
||||
},
|
||||
"vaccinations": {
|
||||
"label": "Vaccinazioni",
|
||||
"description": "Il libretto vaccinale in forma strutturata: quale dose è stata somministrata e quando, in che punto della serie si trova e quando è previsto il prossimo richiamo."
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
@@ -9944,6 +9948,10 @@
|
||||
"encounterVisitUpdate": "{name} ha modificato una visita medica",
|
||||
"encounterVisitDelete": "{name} ha eliminato una visita medica",
|
||||
"encounterVisitRestore": "{name} ha ripristinato una visita medica",
|
||||
"vaccinationRecordCreate": "{name} ha aggiunto una vaccinazione",
|
||||
"vaccinationRecordUpdate": "{name} ha modificato una vaccinazione",
|
||||
"vaccinationRecordDelete": "{name} ha eliminato una vaccinazione",
|
||||
"vaccinationRecordRestore": "{name} ha ripristinato una vaccinazione",
|
||||
"practitionerContactCreate": "{name} ha aggiunto uno studio alla rubrica",
|
||||
"practitionerContactUpdate": "{name} ha modificato una voce della rubrica",
|
||||
"practitionerContactDelete": "{name} ha eliminato una voce della rubrica",
|
||||
|
||||
@@ -8523,6 +8523,10 @@
|
||||
"nutrients": {
|
||||
"label": "Spożycie składników odżywczych",
|
||||
"description": "Zapisuje dzienne sumy mikroskładników (witaminy, minerały, woda, kofeina) synchronizowane z Apple Health. Domyślnie wyłączone."
|
||||
},
|
||||
"vaccinations": {
|
||||
"label": "Szczepienia",
|
||||
"description": "Książeczka szczepień w formie uporządkowanej: która dawka i kiedy została podana, w którym miejscu serii się znajduje i kiedy przypada następne przypomnienie."
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
@@ -9944,6 +9948,10 @@
|
||||
"encounterVisitUpdate": "{name} zmienił wizytę lekarską",
|
||||
"encounterVisitDelete": "{name} usunął wizytę lekarską",
|
||||
"encounterVisitRestore": "{name} przywrócił wizytę lekarską",
|
||||
"vaccinationRecordCreate": "{name} dodał szczepienie",
|
||||
"vaccinationRecordUpdate": "{name} zmienił szczepienie",
|
||||
"vaccinationRecordDelete": "{name} usunął szczepienie",
|
||||
"vaccinationRecordRestore": "{name} przywrócił szczepienie",
|
||||
"practitionerContactCreate": "{name} dodał gabinet do książki adresowej",
|
||||
"practitionerContactUpdate": "{name} zmienił wpis w książce adresowej",
|
||||
"practitionerContactDelete": "{name} usunął wpis w książce adresowej",
|
||||
|
||||
@@ -935,6 +935,14 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
domain: "profile",
|
||||
why: "One visit of the record, fetch-then-guard against the resolved user. The links it resolves are read through the link service, which narrows both ends to the same resolved id.",
|
||||
},
|
||||
"app/api/vaccinations/route.ts": {
|
||||
domain: "profile",
|
||||
why: "The record's immunization history. Health background in the same sense as the allergies and the visits already under this domain: it names no credential, no integration and no notification channel. It carries the same honest cost as those two, which is that a delegate granted `profile` to read an allergy list also sees every dose the person has had; the consent copy names the immunization history for exactly this reason. The create arm is a delegable write.",
|
||||
},
|
||||
"app/api/vaccinations/[id]/route.ts": {
|
||||
domain: "profile",
|
||||
why: "One dose of the record, fetch-then-guard against the resolved user. The pages it resolves are read through the link service, which narrows both ends to the same resolved id, and their names are withheld from a grant that does not reach the vault — the filename of a scanned page is itself the sensitive part.",
|
||||
},
|
||||
"app/api/practitioners/route.ts": {
|
||||
domain: "profile",
|
||||
why: "The record's own address book of doctors and practices. Record content, not account configuration — it touches no credential, no integration and no notification channel, which is the fence the classification turns on. The create arm is a delegable write.",
|
||||
@@ -1136,6 +1144,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
domain: "profile",
|
||||
why: "Reopening a deleted address-book entry. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
|
||||
},
|
||||
"app/api/vaccinations/[id]/restore/route.ts": {
|
||||
domain: "profile",
|
||||
why: "Reopening a deleted dose with the page it was transcribed from still filed against it. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
|
||||
},
|
||||
"app/api/mood-entries/bulk-delete/route.ts": {
|
||||
domain: "mind",
|
||||
why: "Tombstoning a selection. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
|
||||
@@ -1379,6 +1391,8 @@ const DELEGABLE_WRITE_ROUTES: Record<string, string> = {
|
||||
"Filing a visit, or booking one. Every id the body may carry — the practitioner, the checkup it closes, the three link arrays — is re-narrowed to the resolved record before anything is written, so a delegate cannot attach one record's document to another's visit. Booking mints the appointment reminder under the RECORD, which is correct: it is the owner's appointment and the owner's channels that should be nudged about it.",
|
||||
"app/api/practitioners/route.ts":
|
||||
"Adding a doctor or practice to the record's address book. Nothing is unique across accounts here by design, so a delegate adding a practice the owner already has produces a second row rather than a collision with somebody else's namespace.",
|
||||
"app/api/vaccinations/route.ts":
|
||||
"Logging a dose. Every id the body may carry — the practitioner, the visit, the pages to file it against — is re-narrowed to the resolved record before anything is written, so a delegate cannot attach one record's scan to another's dose. The booster reminders it clears are the RECORD's, which is correct: it is the owner's booster plan, and a helper transcribing an Impfpass is doing exactly the work that should settle it.",
|
||||
"app/api/medications/[id]/side-effects/route.ts":
|
||||
"Recording a side effect. Admitted on one condition, met at the call site: the POST rate bucket keys on the ACTOR, so a delegate burns their own allowance rather than the owner's and cannot collect a fresh one by switching records.",
|
||||
"app/api/medications/route.ts":
|
||||
@@ -1505,6 +1519,16 @@ const DELEGABLE_MANAGE_ROUTES: Record<string, ManageEntry> = {
|
||||
conditions: [],
|
||||
why: "Reopening a deleted address-book entry. It does not re-attach the visits whose reference was nulled, because which visits meant this practice is not recoverable.",
|
||||
},
|
||||
"app/api/vaccinations/[id]/route.ts": {
|
||||
domain: "profile",
|
||||
conditions: ["C4"],
|
||||
why: "Correcting and removing one dose; the delete soft-deletes and deliberately does not rewind the booster reminder the dose once cleared, because tidying a transcription is not evidence the dose never happened. C4 on the edit: the date, the antigen, the dose number and the two references are filed before and after, and the encrypted note deliberately is not. The edit never re-runs the satisfy matcher, so a delegate cannot move a booster's due date by correcting a typo.",
|
||||
},
|
||||
"app/api/vaccinations/[id]/restore/route.ts": {
|
||||
domain: "profile",
|
||||
conditions: [],
|
||||
why: "Reopening a deleted dose. The document links survived the tombstone, so it comes back with its page still filed against it.",
|
||||
},
|
||||
"app/api/illness/episodes/[id]/resolve/route.ts": {
|
||||
domain: "illness",
|
||||
conditions: [],
|
||||
@@ -1861,8 +1885,12 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
* read-only profile summary makes the next explicit admission: 197 → 198.
|
||||
* The two initial Cycle reads follow as separate, scoped admissions: 198 → 200.
|
||||
* The mood day's linked figures, read-only, add one: 200 → 201.
|
||||
*
|
||||
* v1.38.0 — the immunization log adds six: two reads and one restore on the
|
||||
* record list, one create on the write literal, and the edit/delete pair plus
|
||||
* the restore on the manage literal. 213 → 219.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 213;
|
||||
const FROZEN_ENTRY_COUNT = 219;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
@@ -2623,7 +2651,7 @@ describe("(g) the MANAGE route set is frozen", () => {
|
||||
|
||||
it("keeps the admitted mutation inventory complete and discoverable", () => {
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0);
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(70);
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(74);
|
||||
|
||||
const expected = ADMITTED_MUTATING_HANDLERS.map(
|
||||
({ handlerModule, action, level }) =>
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => {
|
||||
});
|
||||
|
||||
it("keeps every admitted mutation uniquely addressable and fully controlled", () => {
|
||||
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(70);
|
||||
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(74);
|
||||
expect(
|
||||
new Set(
|
||||
ADMITTED_MUTATING_HANDLERS.map(
|
||||
|
||||
@@ -140,6 +140,13 @@ const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
|
||||
// phone whose user never opted in cannot land rows server-side. Walking
|
||||
// the tree means a NEW ungated nutrients route fails BY NAME.
|
||||
"src/app/api/nutrients",
|
||||
// v1.38.0 — the immunization log (`vaccinations` module). SURFACE-gated
|
||||
// like medications, not data-layer-gated: every `/api/vaccinations/*` route
|
||||
// is raw CRUD over the person's own doses, so all four are EXEMPT below
|
||||
// under the same reasoning. Walking the tree means a NEW vaccination route
|
||||
// must justify itself — gate or documented exemption — rather than
|
||||
// silently appearing.
|
||||
"src/app/api/vaccinations",
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -283,6 +290,19 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
|
||||
// rationale as glp1-timeline above: a deterministic detector over weight +
|
||||
// dose history, no AI narrative, medications is core/always-on.
|
||||
"src/app/api/insights/glp1-plateau/route.ts",
|
||||
// ── DATA LAYER (vaccinations) ─────────────────────────────────────
|
||||
// v1.38.0 — the immunization log is SURFACE-gated (nav entry, dashboard
|
||||
// and report leaves, the picker), not data-layer-gated. Every
|
||||
// `/api/vaccinations/*` route is raw CRUD over the person's own doses and
|
||||
// the pages they were transcribed from — the same reasoning as mood, labs
|
||||
// and medications above: the module toggle governs whether vaccinations
|
||||
// SURFACE, not whether the row store accepts rows. A restore or an import
|
||||
// must keep working while the module is off, and re-enabling must find
|
||||
// every dose intact rather than a gap in an Impfpass nobody can rebuild.
|
||||
"src/app/api/vaccinations/route.ts",
|
||||
"src/app/api/vaccinations/[id]/route.ts",
|
||||
"src/app/api/vaccinations/[id]/restore/route.ts",
|
||||
"src/app/api/vaccinations/[id]/links/route.ts",
|
||||
];
|
||||
|
||||
const MODULE_GATE_NEEDLE = "requireModuleEnabled(";
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* `POST /api/vaccinations/{id}/links` — file a scanned page against a dose.
|
||||
* `DELETE /api/vaccinations/{id}/links` — unfile it.
|
||||
*
|
||||
* Deliberately bare `requireAuth()` rather than a delegable declaration, and
|
||||
* the reason is consistency rather than oversight: document link and unlink
|
||||
* are not delegable today, and neither is the visit link beside it. Making a
|
||||
* third answer to the same question would be the drift. The set 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 dose must never be un-saveable because
|
||||
* one page 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 { vaccinationLinkSchema } from "@/lib/validations/vaccinations";
|
||||
import { linkTargets, unlinkTargets } from "@/lib/links";
|
||||
import {
|
||||
actingDomainVisibility,
|
||||
loadVaccinationDocuments,
|
||||
} from "@/lib/vaccinations/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 = vaccinationLinkSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "vaccination.link.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const apply = verb === "link" ? linkTargets : unlinkTargets;
|
||||
const result = await apply(prisma, {
|
||||
userId: user.id,
|
||||
sourceKind: "vaccination",
|
||||
sourceId: id,
|
||||
targetKind: parsed.data.targetKind,
|
||||
targetIds: parsed.data.targetIds,
|
||||
});
|
||||
|
||||
if (result.unknownSource) {
|
||||
return apiError("Vaccination not found", 404);
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: `vaccination.link.${verb}`,
|
||||
entity_type: "vaccination",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: {
|
||||
target_kind: parsed.data.targetKind,
|
||||
changed: result.changed,
|
||||
unknown: result.unknownTargetIds.length,
|
||||
},
|
||||
});
|
||||
|
||||
// Bare `requireAuth()` resolves the caller's OWN record — this route is not
|
||||
// delegable — so there is no narrowed grant to honour and every domain is
|
||||
// the caller's own.
|
||||
const visible = await actingDomainVisibility(prisma, null);
|
||||
const documents = await loadVaccinationDocuments(
|
||||
prisma,
|
||||
user.id,
|
||||
id,
|
||||
visible,
|
||||
);
|
||||
return apiSuccess({ documents, 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,75 @@
|
||||
/**
|
||||
* `POST /api/vaccinations/{id}/restore` — clear a dose's tombstone.
|
||||
*
|
||||
* MANAGE rather than WRITE: undoing a deletion is a management act when the
|
||||
* manager is the one who holds delete.
|
||||
*
|
||||
* The document links survived the soft delete — they cascade only on a hard
|
||||
* one — so a restored dose comes back with the page it was transcribed from
|
||||
* still filed against it. The booster satisfy matcher does not run: restoring
|
||||
* is not logging a dose, and the reminder it once satisfied was never rewound
|
||||
* by the deletion either.
|
||||
*/
|
||||
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 { toVaccinationDTO } from "@/lib/vaccinations/dto";
|
||||
import {
|
||||
VACCINATION_INCLUDE,
|
||||
actingDomainVisibility,
|
||||
loadVaccinationDocuments,
|
||||
resolveSeriesFor,
|
||||
} from "@/lib/vaccinations/service";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user, grantId } = await requireRecordAuth("manage", "profile");
|
||||
|
||||
const { id } = await params;
|
||||
const existing = await prisma.vaccinationRecord.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
return apiError("Vaccination not found", 404);
|
||||
}
|
||||
|
||||
if (existing.deletedAt !== null) {
|
||||
await prisma.vaccinationRecord.update({
|
||||
where: { id },
|
||||
data: { deletedAt: null },
|
||||
});
|
||||
}
|
||||
|
||||
const row = await prisma.vaccinationRecord.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: VACCINATION_INCLUDE,
|
||||
});
|
||||
const visible = await actingDomainVisibility(prisma, grantId);
|
||||
const [documents, series] = await Promise.all([
|
||||
loadVaccinationDocuments(prisma, user.id, id, visible),
|
||||
resolveSeriesFor(prisma, user.id),
|
||||
]);
|
||||
|
||||
await auditLog("vaccination.record.restore", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { vaccinationId: id, occurredAt: row.occurredAt.toISOString() },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "vaccination.record.restore",
|
||||
entity_type: "vaccination",
|
||||
entity_id: id,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(toVaccinationDTO(row, series.get(id) ?? [], documents));
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* `GET /api/vaccinations/{id}` — one owned, live dose with its links.
|
||||
* `PATCH /api/vaccinations/{id}` — edit it (partial; field-by-field).
|
||||
* `DELETE /api/vaccinations/{id}` — soft-delete it.
|
||||
*
|
||||
* Owner-scoped by fetch-then-guard. `userId` is narrowed from auth; the body
|
||||
* never carries it.
|
||||
*
|
||||
* PATCH deliberately does NOT re-run the booster satisfy matcher. An edit is a
|
||||
* correction of a transcription, not a new clinical event, and re-satisfying
|
||||
* on edit would make `occurredAt` edits side-effectful in a way no other
|
||||
* clinical model here is: fixing a typo in a date would silently push a
|
||||
* booster reminder's next due date out. The side effect belongs to the act of
|
||||
* logging a dose, which happens once.
|
||||
*/
|
||||
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 { vaccinationUpdateSchema } from "@/lib/validations/vaccinations";
|
||||
import { toVaccinationDTO } from "@/lib/vaccinations/dto";
|
||||
import {
|
||||
VACCINATION_INCLUDE,
|
||||
actingDomainVisibility,
|
||||
applyVaccinationLinks,
|
||||
loadVaccinationDocuments,
|
||||
resolveOwnedEncounter,
|
||||
resolveOwnedPractitioner,
|
||||
resolveSeriesFor,
|
||||
} from "@/lib/vaccinations/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, grantId } = await requireRecordAuth("read", "profile");
|
||||
|
||||
const { id } = await params;
|
||||
const row = await prisma.vaccinationRecord.findUnique({
|
||||
where: { id },
|
||||
include: VACCINATION_INCLUDE,
|
||||
});
|
||||
if (!row || row.userId !== user.id || row.deletedAt !== null) {
|
||||
return apiError("Vaccination not found", 404);
|
||||
}
|
||||
|
||||
const visible = await actingDomainVisibility(prisma, grantId);
|
||||
const [documents, series] = await Promise.all([
|
||||
loadVaccinationDocuments(prisma, user.id, id, visible),
|
||||
resolveSeriesFor(prisma, user.id),
|
||||
]);
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "vaccination.record.read",
|
||||
entity_type: "vaccination",
|
||||
entity_id: id,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(toVaccinationDTO(row, series.get(id) ?? [], documents));
|
||||
},
|
||||
);
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user, grantId } = await requireRecordAuth("manage", "profile");
|
||||
|
||||
const { id } = await params;
|
||||
const existing = await prisma.vaccinationRecord.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (
|
||||
!existing ||
|
||||
existing.userId !== user.id ||
|
||||
existing.deletedAt !== null
|
||||
) {
|
||||
return apiError("Vaccination not found", 404);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 32 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = vaccinationUpdateSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "vaccination.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const entry = parsed.data;
|
||||
|
||||
// The identity rule is checked against the MERGED row rather than the
|
||||
// body, because a PATCH naming only the batch code says nothing about the
|
||||
// identity and must not be refused for it — while a PATCH that clears the
|
||||
// one arm a record was saved with would leave a dose with no name at all.
|
||||
const nextSlug =
|
||||
entry.antigenSlug !== undefined
|
||||
? entry.antigenSlug
|
||||
: existing.antigenSlug;
|
||||
const nextName =
|
||||
entry.vaccineName !== undefined
|
||||
? entry.vaccineName
|
||||
: existing.vaccineName;
|
||||
if (!nextSlug && !nextName) {
|
||||
return apiError(
|
||||
"A dose keeps a name: pick one from the catalogue or type what the record says",
|
||||
422,
|
||||
{ errorCode: "vaccination.identity-required" },
|
||||
);
|
||||
}
|
||||
|
||||
const outcome = await prisma.$transaction(async (tx) => {
|
||||
if (entry.practitionerId) {
|
||||
const owned = await resolveOwnedPractitioner(
|
||||
tx,
|
||||
user.id,
|
||||
entry.practitionerId,
|
||||
);
|
||||
if (!owned) return "unknown-practitioner" as const;
|
||||
}
|
||||
if (entry.encounterId) {
|
||||
const owned = await resolveOwnedEncounter(
|
||||
tx,
|
||||
user.id,
|
||||
entry.encounterId,
|
||||
);
|
||||
if (!owned) return "unknown-encounter" as const;
|
||||
}
|
||||
|
||||
const data: Prisma.VaccinationRecordUpdateInput = {};
|
||||
if (entry.occurredAt !== undefined) {
|
||||
data.occurredAt = new Date(entry.occurredAt);
|
||||
}
|
||||
if (entry.antigenSlug !== undefined) data.antigenSlug = entry.antigenSlug;
|
||||
if (entry.vaccineName !== undefined) data.vaccineName = entry.vaccineName;
|
||||
if (entry.doseNumber !== undefined) data.doseNumber = entry.doseNumber;
|
||||
if (entry.seriesDoses !== undefined) data.seriesDoses = entry.seriesDoses;
|
||||
if (entry.lotNumber !== undefined) data.lotNumber = entry.lotNumber;
|
||||
if (entry.site !== undefined) data.site = entry.site;
|
||||
if (entry.note !== undefined) {
|
||||
data.noteEncrypted = entry.note ? encryptToBytes(entry.note) : null;
|
||||
}
|
||||
if (entry.practitionerId !== undefined) {
|
||||
data.practitioner = entry.practitionerId
|
||||
? { connect: { id: entry.practitionerId } }
|
||||
: { disconnect: true };
|
||||
}
|
||||
if (entry.encounterId !== undefined) {
|
||||
data.encounter = entry.encounterId
|
||||
? { connect: { id: entry.encounterId } }
|
||||
: { disconnect: true };
|
||||
}
|
||||
|
||||
const updated = await tx.vaccinationRecord.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
await applyVaccinationLinks(tx, user.id, id, entry.documentIds);
|
||||
return { updated };
|
||||
});
|
||||
|
||||
if (outcome === "unknown-practitioner") {
|
||||
return apiError("Practitioner not found", 404, {
|
||||
errorCode: "vaccination.practitioner-not-found",
|
||||
});
|
||||
}
|
||||
if (outcome === "unknown-encounter") {
|
||||
return apiError("Visit not found", 404, {
|
||||
errorCode: "vaccination.encounter-not-found",
|
||||
});
|
||||
}
|
||||
|
||||
const row = await prisma.vaccinationRecord.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: VACCINATION_INCLUDE,
|
||||
});
|
||||
const visible = await actingDomainVisibility(prisma, grantId);
|
||||
const [documents, series] = await Promise.all([
|
||||
loadVaccinationDocuments(prisma, user.id, id, visible),
|
||||
resolveSeriesFor(prisma, user.id),
|
||||
]);
|
||||
|
||||
await auditLog("vaccination.record.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
vaccinationId: id,
|
||||
// The note is deliberately absent: encrypted PHI, and the audit table
|
||||
// is not a second copy of the record.
|
||||
...overwriteDetails({
|
||||
before: {
|
||||
occurredAt: existing.occurredAt,
|
||||
antigenSlug: existing.antigenSlug,
|
||||
doseNumber: existing.doseNumber,
|
||||
practitionerId: existing.practitionerId,
|
||||
encounterId: existing.encounterId,
|
||||
},
|
||||
after: {
|
||||
occurredAt: outcome.updated.occurredAt,
|
||||
antigenSlug: outcome.updated.antigenSlug,
|
||||
doseNumber: outcome.updated.doseNumber,
|
||||
practitionerId: outcome.updated.practitionerId,
|
||||
encounterId: outcome.updated.encounterId,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "vaccination.record.update",
|
||||
entity_type: "vaccination",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: { antigen_slug: row.antigenSlug ?? "free-text" },
|
||||
});
|
||||
|
||||
return apiSuccess(toVaccinationDTO(row, series.get(id) ?? [], documents));
|
||||
},
|
||||
);
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("manage", "profile");
|
||||
|
||||
const { id } = await params;
|
||||
const existing = await prisma.vaccinationRecord.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
return apiError("Vaccination not found", 404);
|
||||
}
|
||||
|
||||
// Already tombstoned: deleting twice is a no-op that still succeeds.
|
||||
//
|
||||
// A booster reminder this dose satisfied is deliberately NOT re-opened.
|
||||
// The reminder belongs to the person's preventive-care list and outlives
|
||||
// the record; rewinding its due date because a transcription was tidied up
|
||||
// would turn a correction into a nudge.
|
||||
if (existing.deletedAt === null) {
|
||||
await prisma.vaccinationRecord.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
await auditLog("vaccination.record.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
vaccinationId: id,
|
||||
occurredAt: existing.occurredAt.toISOString(),
|
||||
antigenSlug: existing.antigenSlug,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "vaccination.record.delete",
|
||||
entity_type: "vaccination",
|
||||
entity_id: id,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ deleted: true });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* `GET /api/vaccinations` — the account's immunization log, series resolved.
|
||||
* `POST /api/vaccinations` — log one dose, optionally pre-linked to the page
|
||||
* it was transcribed from.
|
||||
*
|
||||
* Classified `profile`: an immunization history is health background in the
|
||||
* same sense as the allergy list and the visit record already under that
|
||||
* domain. No credential, no integration, no outbound channel.
|
||||
*
|
||||
* The module gate governs the SURFACES, not this route. A disabled
|
||||
* `vaccinations` module hides the nav entry, the dashboard and report leaves
|
||||
* and the picker; the data routes stay reachable so a restore or an import
|
||||
* keeps working and re-enabling the module finds every row intact. That is the
|
||||
* `medications` posture, and the route-gate inventory records it.
|
||||
*
|
||||
* `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.
|
||||
*/
|
||||
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 {
|
||||
vaccinationCreateSchema,
|
||||
vaccinationListQuerySchema,
|
||||
} from "@/lib/validations/vaccinations";
|
||||
import {
|
||||
toVaccinationDTO,
|
||||
type VaccinationListDTO,
|
||||
} from "@/lib/vaccinations/dto";
|
||||
import {
|
||||
VACCINATION_INCLUDE,
|
||||
applyVaccinationLinks,
|
||||
resolveOwnedEncounter,
|
||||
resolveOwnedPractitioner,
|
||||
resolveSeriesFor,
|
||||
} from "@/lib/vaccinations/service";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read", "profile");
|
||||
|
||||
const params = new URL(request.url).searchParams;
|
||||
const parsed = vaccinationListQuerySchema.safeParse({
|
||||
antigenSlug: params.get("antigenSlug") ?? undefined,
|
||||
from: params.get("from") ?? undefined,
|
||||
to: params.get("to") ?? undefined,
|
||||
limit: params.get("limit") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "vaccination.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const query = parsed.data;
|
||||
// Full and bounded rather than paged: an Impfpass is a lifetime document but
|
||||
// a small one, and the default already covers a long life.
|
||||
const take = query.limit ?? 300;
|
||||
|
||||
const [rows, series] = await Promise.all([
|
||||
prisma.vaccinationRecord.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
...(query.antigenSlug ? { antigenSlug: query.antigenSlug } : {}),
|
||||
...(query.from || query.to
|
||||
? {
|
||||
occurredAt: {
|
||||
...(query.from ? { gte: new Date(query.from) } : {}),
|
||||
...(query.to ? { lte: new Date(query.to) } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: { occurredAt: "desc" },
|
||||
take,
|
||||
include: VACCINATION_INCLUDE,
|
||||
}),
|
||||
// Over the WHOLE live set, never over the filtered page: a position is a
|
||||
// count of what came before it, so deriving it from a window would report
|
||||
// the oldest dose in that window as the first one ever given.
|
||||
resolveSeriesFor(prisma, user.id),
|
||||
]);
|
||||
|
||||
annotate({
|
||||
action: { name: "vaccination.record.list", entity_type: "vaccination" },
|
||||
meta: { count: rows.length, filtered: Boolean(query.antigenSlug) },
|
||||
});
|
||||
|
||||
const body: VaccinationListDTO = {
|
||||
vaccinations: rows.map((row) =>
|
||||
toVaccinationDTO(row, series.get(row.id) ?? []),
|
||||
),
|
||||
};
|
||||
return apiSuccess(body);
|
||||
});
|
||||
|
||||
// A double-tap on "save" must not log the same dose twice.
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postVaccination));
|
||||
|
||||
async function postVaccination(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 = vaccinationCreateSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "vaccination.record.validation-failed" },
|
||||
meta: { issue_count: parsed.error.issues.length },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "vaccination.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const entry = parsed.data;
|
||||
const occurredAt = new Date(entry.occurredAt);
|
||||
|
||||
const created = await prisma.$transaction(async (tx) => {
|
||||
// Every id the body carries is re-narrowed to the resolved account before
|
||||
// anything is written. A practice or a visit the caller does not own is a
|
||||
// refusal rather than a silently dropped field: the person named one and
|
||||
// would otherwise watch the dose save without it.
|
||||
if (entry.practitionerId) {
|
||||
const owned = await resolveOwnedPractitioner(
|
||||
tx,
|
||||
user.id,
|
||||
entry.practitionerId,
|
||||
);
|
||||
if (!owned) return "unknown-practitioner" as const;
|
||||
}
|
||||
if (entry.encounterId) {
|
||||
const owned = await resolveOwnedEncounter(tx, user.id, entry.encounterId);
|
||||
if (!owned) return "unknown-encounter" as const;
|
||||
}
|
||||
|
||||
// Field-by-field — never spread the parsed object whole.
|
||||
const row = await tx.vaccinationRecord.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
occurredAt,
|
||||
antigenSlug: entry.antigenSlug ?? null,
|
||||
vaccineName: entry.vaccineName ?? null,
|
||||
doseNumber: entry.doseNumber ?? null,
|
||||
seriesDoses: entry.seriesDoses ?? null,
|
||||
lotNumber: entry.lotNumber ?? null,
|
||||
site: entry.site ?? null,
|
||||
practitionerId: entry.practitionerId ?? null,
|
||||
encounterId: entry.encounterId ?? null,
|
||||
noteEncrypted: entry.note ? encryptToBytes(entry.note) : null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await applyVaccinationLinks(tx, user.id, row.id, entry.documentIds);
|
||||
|
||||
return tx.vaccinationRecord.findUniqueOrThrow({
|
||||
where: { id: row.id },
|
||||
include: VACCINATION_INCLUDE,
|
||||
});
|
||||
});
|
||||
|
||||
if (created === "unknown-practitioner") {
|
||||
return apiError("Practitioner not found", 404, {
|
||||
errorCode: "vaccination.practitioner-not-found",
|
||||
});
|
||||
}
|
||||
if (created === "unknown-encounter") {
|
||||
return apiError("Visit not found", 404, {
|
||||
errorCode: "vaccination.encounter-not-found",
|
||||
});
|
||||
}
|
||||
|
||||
const series = await resolveSeriesFor(prisma, user.id);
|
||||
|
||||
await auditLog("vaccination.record.create", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
vaccinationId: created.id,
|
||||
occurredAt: created.occurredAt.toISOString(),
|
||||
antigenSlug: created.antigenSlug,
|
||||
// The note is deliberately absent: it is encrypted PHI and the audit
|
||||
// table is not a second copy of the record.
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "vaccination.record.create",
|
||||
entity_type: "vaccination",
|
||||
entity_id: created.id,
|
||||
},
|
||||
meta: {
|
||||
antigen_slug: created.antigenSlug ?? "free-text",
|
||||
linked_documents: entry.documentIds?.length ?? 0,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
toVaccinationDTO(created, series.get(created.id) ?? []),
|
||||
201,
|
||||
);
|
||||
}
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
Pill,
|
||||
Smile,
|
||||
Sparkles,
|
||||
Syringe,
|
||||
TestTube,
|
||||
Thermometer,
|
||||
type LucideIcon,
|
||||
@@ -110,6 +111,8 @@ const MODULE_ICONS: Record<ModuleKey, LucideIcon> = {
|
||||
mentalHealth: Brain,
|
||||
// v1.28 — opt-in micronutrient-intake sync (Apple Health day totals).
|
||||
nutrients: Leaf,
|
||||
// v1.38.0 — the immunization log.
|
||||
vaccinations: Syringe,
|
||||
};
|
||||
|
||||
export function ModulesSection() {
|
||||
|
||||
@@ -142,6 +142,7 @@ function moduleMap(
|
||||
inboundDocuments: true,
|
||||
mentalHealth: true,
|
||||
nutrients: true,
|
||||
vaccinations: true,
|
||||
};
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
|
||||
@@ -154,6 +154,25 @@ export const MODULE_KEYS = [
|
||||
// ingest route refuses (403 `module.disabled`) and the settings card
|
||||
// does not render. The `optIn` marker inverts the per-user default.
|
||||
"nutrients",
|
||||
// v1.38.0 — the immunization log (`/vaccinations`). A tracked clinical
|
||||
// vertical a person may not use at all, which is exactly the shape the
|
||||
// existing opt-out verticals have (`labs`, `illness`, `medications`), so it
|
||||
// gets a key rather than riding somebody else's. Default-on, no `optIn`
|
||||
// marker: no egress, no new attack surface — the `mentalHealth` /
|
||||
// `inboundDocuments` posture.
|
||||
//
|
||||
// SURFACE-gated like `medications`: the nav entry, the dashboard and report
|
||||
// surfaces and the picker hide, and the `/api/vaccinations*` data routes
|
||||
// stay exempt so a restore / import keeps working and re-enabling finds
|
||||
// every row intact.
|
||||
//
|
||||
// The one subtlety, stated so nobody reads it as a leak: a booster reminder
|
||||
// the user CONFIRMED does not follow this off-switch. It is an ordinary
|
||||
// preventive-care row on an engine that is never gated, and a reminder
|
||||
// somebody agreed to keeps its promise. Turning the module off hides
|
||||
// surfaces the person made content for; it does not silently retract a
|
||||
// commitment the app made to them.
|
||||
"vaccinations",
|
||||
] as const;
|
||||
|
||||
export type ModuleKey = (typeof MODULE_KEYS)[number];
|
||||
@@ -344,6 +363,14 @@ export const MODULE_REGISTRY: Readonly<Record<ModuleKey, ModuleDefinition>> =
|
||||
// ingest refuses while off (refuse-ingest posture, not surface-only).
|
||||
optIn: true,
|
||||
},
|
||||
vaccinations: {
|
||||
key: "vaccinations",
|
||||
labelKey: "modules.vaccinations.label",
|
||||
descriptionKey: "modules.vaccinations.description",
|
||||
category: "tracking",
|
||||
// No `optIn`: default-on. Nothing leaves the instance and the surfaces
|
||||
// are inert until the person logs a dose.
|
||||
},
|
||||
});
|
||||
|
||||
const MODULE_KEY_SET: ReadonlySet<string> = new Set(MODULE_KEYS);
|
||||
|
||||
@@ -74,6 +74,8 @@ export function recordActivityVerbLine(
|
||||
return t("recordSharing.activityVerb.illnessEpisodeCreate", { name });
|
||||
case "encounter.visit.create":
|
||||
return t("recordSharing.activityVerb.encounterVisitCreate", { name });
|
||||
case "vaccination.record.create":
|
||||
return t("recordSharing.activityVerb.vaccinationRecordCreate", { name });
|
||||
case "practitioner.contact.create":
|
||||
return t("recordSharing.activityVerb.practitionerContactCreate", {
|
||||
name,
|
||||
@@ -187,6 +189,12 @@ export function recordActivityVerbLine(
|
||||
return t("recordSharing.activityVerb.encounterVisitDelete", { name });
|
||||
case "encounter.visit.restore":
|
||||
return t("recordSharing.activityVerb.encounterVisitRestore", { name });
|
||||
case "vaccination.record.update":
|
||||
return t("recordSharing.activityVerb.vaccinationRecordUpdate", { name });
|
||||
case "vaccination.record.delete":
|
||||
return t("recordSharing.activityVerb.vaccinationRecordDelete", { name });
|
||||
case "vaccination.record.restore":
|
||||
return t("recordSharing.activityVerb.vaccinationRecordRestore", { name });
|
||||
case "practitioner.contact.update":
|
||||
return t("recordSharing.activityVerb.practitionerContactUpdate", {
|
||||
name,
|
||||
|
||||
+33
@@ -400,6 +400,39 @@ export const ADMITTED_MUTATING_HANDLERS = [
|
||||
"manage",
|
||||
"restore",
|
||||
),
|
||||
// v1.38.0 — the immunization log.
|
||||
handler(
|
||||
"app/api/vaccinations/route.ts",
|
||||
"/api/vaccinations",
|
||||
"POST",
|
||||
"profile",
|
||||
"write",
|
||||
"create",
|
||||
),
|
||||
handler(
|
||||
"app/api/vaccinations/[id]/route.ts",
|
||||
"/api/vaccinations/[id]",
|
||||
"PATCH",
|
||||
"profile",
|
||||
"manage",
|
||||
"update",
|
||||
),
|
||||
handler(
|
||||
"app/api/vaccinations/[id]/route.ts",
|
||||
"/api/vaccinations/[id]",
|
||||
"DELETE",
|
||||
"profile",
|
||||
"manage",
|
||||
"delete",
|
||||
),
|
||||
handler(
|
||||
"app/api/vaccinations/[id]/restore/route.ts",
|
||||
"/api/vaccinations/[id]/restore",
|
||||
"POST",
|
||||
"profile",
|
||||
"manage",
|
||||
"restore",
|
||||
),
|
||||
handler(
|
||||
"app/api/illness/episodes/[id]/day-logs/route.ts",
|
||||
"/api/illness/episodes/[id]/day-logs",
|
||||
|
||||
@@ -31,6 +31,7 @@ const MODULES = {
|
||||
inboundDocuments: true,
|
||||
mentalHealth: true,
|
||||
nutrients: true,
|
||||
vaccinations: true,
|
||||
} as const;
|
||||
|
||||
/** The dense path is about pulse and glucose; nothing else is in scope here. */
|
||||
|
||||
@@ -335,6 +335,7 @@ describe("doctor-report — module enable/disable gating", () => {
|
||||
inboundDocuments: true,
|
||||
mentalHealth: true,
|
||||
nutrients: true,
|
||||
vaccinations: true,
|
||||
} as const;
|
||||
|
||||
it("includes every module's data when all modules are enabled", async () => {
|
||||
|
||||
Reference in new issue
Block a user