From 15d6251ad23d22b8b70bedc7da3315a5458836d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 09:44:51 +0200 Subject: [PATCH] feat(vaccinations): show the planned booster's renewal state on the log Whether a vaccination is still current was known only on the checkups page. The vaccination log now shows it on each antigen's heading: the booster the person planned when they logged a dose, marked as overdue, due soon (within 30 days) or scheduled, tinted only in the first two cases. The state is resolved on the server from the person's own confirmed booster reminder and published on the list response as renewals, so the web page and the native client read the same answer. Nothing is worked out from age, history or the catalogue: an antigen without a planned booster shows no state. Renewals are null for a grant that does not reach the measurements section the reminders live in. Refs #1005 --- docs/api/openapi.yaml | 40 ++++ messages/de.json | 5 + messages/en.json | 5 + messages/es.json | 5 + messages/fr.json | 5 + messages/it.json | 5 + messages/ko.json | 5 + messages/pl.json | 5 + src/app/api/vaccinations/route.ts | 30 ++- .../__tests__/vaccination-list.test.tsx | 108 +++++++++++ .../vaccinations/vaccination-list.tsx | 64 ++++++- .../vaccinations/vaccinations-view.tsx | 6 +- src/lib/openapi/routes/vaccinations.ts | 32 +++- .../vaccinations/__tests__/renewal.test.ts | 95 ++++++++++ src/lib/vaccinations/dto.ts | 8 + src/lib/vaccinations/renewal.ts | 92 +++++++++ tests/integration/vaccination-renewal.test.ts | 177 ++++++++++++++++++ 17 files changed, 672 insertions(+), 15 deletions(-) create mode 100644 src/lib/vaccinations/__tests__/renewal.test.ts create mode 100644 src/lib/vaccinations/renewal.ts create mode 100644 tests/integration/vaccination-renewal.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e7162f99e..8b642d9a3 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -60594,8 +60594,17 @@ components: type: array items: $ref: "#/components/schemas/Vaccination" + renewals: + anyOf: + - type: array + items: + $ref: "#/components/schemas/VaccinationRenewal" + - type: "null" + description: Each antigen's renewal state, earliest due first. Null when the caller acts under a grant that does not + cover the measurements section the booster reminders live in. required: - vaccinations + - renewals additionalProperties: false description: The caller's immunization history, newest dose first, with every entry's series already resolved over the whole live set — never over the filtered page, which would report the oldest dose in a window as the first ever @@ -60831,6 +60840,37 @@ components: grant does not cover the document vault: a dose lives in the health background but points into the vault, and a page's filename is itself the sensitive part. The link is still listed — the caller may know the dose was transcribed from a page — and `redacted` tells a withheld name apart from a page that simply has no date." + VaccinationRenewal: + type: object + properties: + antigen: + type: string + reminderId: + type: string + dueAt: + type: string + daysUntil: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + state: + type: string + enum: + - current + - dueSoon + - overdue + required: + - antigen + - reminderId + - dueAt + - daysUntil + - state + additionalProperties: false + description: "One antigen's renewal state, read from the booster reminder the person confirmed for it (`dueAt` is that + reminder's next due instant). `daysUntil` counts calendar days on the person's own clock and is negative once + the date has passed. `state` is `overdue` once it has passed, `dueSoon` within 30 days, `current` otherwise. + Render from `state`; do not recompute it. Nothing here is derived from age, history or the catalogue: an antigen + without a confirmed booster has no entry." CreateVaccinationEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index 1d7950b25..ca08bcc20 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9034,6 +9034,11 @@ "mmrv": "MMRV (Masern, Mumps, Röteln, Varizellen)", "hepa-hepb": "Hepatitis A und B", "hepa-typhoid": "Hepatitis A und Typhus" + }, + "renewal": { + "overdue": "Auffrischung fällig seit {date}", + "dueSoon": "Auffrischung fällig am {date}", + "current": "Nächste Auffrischung {date}" } }, "illness": { diff --git a/messages/en.json b/messages/en.json index 9649ed33a..dc8749a5e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9034,6 +9034,11 @@ "mmrv": "MMRV (measles, mumps, rubella, varicella)", "hepa-hepb": "Hepatitis A and B", "hepa-typhoid": "Hepatitis A and typhoid" + }, + "renewal": { + "overdue": "Booster overdue since {date}", + "dueSoon": "Booster due {date}", + "current": "Next booster {date}" } }, "illness": { diff --git a/messages/es.json b/messages/es.json index 12d133552..a1d6f8b27 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9034,6 +9034,11 @@ "mmrv": "Tetravírica (sarampión, paperas, rubéola, varicela)", "hepa-hepb": "Hepatitis A y B", "hepa-typhoid": "Hepatitis A y fiebre tifoidea" + }, + "renewal": { + "overdue": "Refuerzo pendiente desde el {date}", + "dueSoon": "Refuerzo previsto el {date}", + "current": "Próximo refuerzo el {date}" } }, "illness": { diff --git a/messages/fr.json b/messages/fr.json index d357e27ee..ea84233ff 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9034,6 +9034,11 @@ "mmrv": "RORV (rougeole, oreillons, rubéole, varicelle)", "hepa-hepb": "Hépatite A et B", "hepa-typhoid": "Hépatite A et typhoïde" + }, + "renewal": { + "overdue": "Rappel en retard depuis le {date}", + "dueSoon": "Rappel prévu le {date}", + "current": "Prochain rappel le {date}" } }, "illness": { diff --git a/messages/it.json b/messages/it.json index 9ed5eff0a..26f199139 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9034,6 +9034,11 @@ "mmrv": "MPRV (morbillo, parotite, rosolia, varicella)", "hepa-hepb": "Epatite A e B", "hepa-typhoid": "Epatite A e febbre tifoide" + }, + "renewal": { + "overdue": "Richiamo scaduto dal {date}", + "dueSoon": "Richiamo previsto il {date}", + "current": "Prossimo richiamo il {date}" } }, "illness": { diff --git a/messages/ko.json b/messages/ko.json index 9f6e1bbfd..dee6cfc36 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -9034,6 +9034,11 @@ "mmrv": "MMRV(홍역, 유행성이하선염, 풍진, 수두)", "hepa-hepb": "A형·B형간염", "hepa-typhoid": "A형간염·장티푸스" + }, + "renewal": { + "overdue": "{date}부터 추가접종 기한 지남", + "dueSoon": "{date} 추가접종 예정", + "current": "다음 추가접종 {date}" } }, "illness": { diff --git a/messages/pl.json b/messages/pl.json index 017568820..2a1e64284 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9034,6 +9034,11 @@ "mmrv": "MMRV (odra, świnka, różyczka, ospa wietrzna)", "hepa-hepb": "WZW typu A i B", "hepa-typhoid": "WZW typu A i dur brzuszny" + }, + "renewal": { + "overdue": "Dawka przypominająca zaległa od {date}", + "dueSoon": "Dawka przypominająca {date}", + "current": "Następna dawka przypominająca {date}" } }, "illness": { diff --git a/src/app/api/vaccinations/route.ts b/src/app/api/vaccinations/route.ts index b1ddc7ae5..beeb81826 100644 --- a/src/app/api/vaccinations/route.ts +++ b/src/app/api/vaccinations/route.ts @@ -49,9 +49,11 @@ import { resolveSeriesFor, } from "@/lib/vaccinations/service"; import { satisfyBoostersForDose } from "@/lib/vaccinations/satisfy-matching"; +import { resolveRenewals } from "@/lib/vaccinations/renewal"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireRecordAuth("read", "profile"); + const { user, grantId } = await requireRecordAuth("read", "profile"); const params = new URL(request.url).searchParams; const parsed = vaccinationListQuerySchema.safeParse({ @@ -96,6 +98,31 @@ export const GET = apiHandler(async (request: NextRequest) => { resolveSeriesFor(prisma, user.id), ]); + // v1.39 (#1005) — the renewal state of each antigen, from the booster the + // person confirmed. That reminder lives in the measurements section, so a + // grant that opened only the health background gets null rather than a + // reminder it was never given. + const visible = await actingDomainVisibility(prisma, grantId); + const renewals = visible("measurements") + ? resolveRenewals( + await prisma.measurementReminder.findMany({ + where: { + userId: user.id, + deletedAt: null, + vaccinationAntigen: { not: null }, + }, + select: { + id: true, + vaccinationAntigen: true, + nextDueAt: true, + enabled: true, + }, + }), + new Date(), + await resolveOwnerTimezone(user.id), + ) + : null; + annotate({ action: { name: "vaccination.record.list", entity_type: "vaccination" }, meta: { count: rows.length, filtered: Boolean(query.antigenSlug) }, @@ -105,6 +132,7 @@ export const GET = apiHandler(async (request: NextRequest) => { vaccinations: rows.map((row) => toVaccinationDTO(row, series.get(row.id) ?? []), ), + renewals, }; return apiSuccess(body); }); diff --git a/src/components/vaccinations/__tests__/vaccination-list.test.tsx b/src/components/vaccinations/__tests__/vaccination-list.test.tsx index 031b499b7..0493561ce 100644 --- a/src/components/vaccinations/__tests__/vaccination-list.test.tsx +++ b/src/components/vaccinations/__tests__/vaccination-list.test.tsx @@ -133,3 +133,111 @@ describe("VaccinationList grouping", () => { ); }); }); + +describe("VaccinationList renewal state (#1005)", () => { + const tdap = record({ + id: "tdap-1", + antigenSlug: "tdap", + catalogEntry: { slug: "tdap", atc: "J07AJ52", category: "standard" }, + series: [ + { antigen: "tetanus", position: 3, total: 3, booster: false }, + { antigen: "pertussis", position: 1, total: 3, booster: false }, + ], + }); + + function renderWith( + renewals: Parameters[0]["renewals"], + ): string { + return renderToStaticMarkup( + + + , + ); + } + + it("marks an overdue booster on its antigen's heading, and only there", () => { + const html = renderWith([ + { + antigen: "tetanus", + reminderId: "rem-1", + dueAt: "2026-01-10T09:00:00.000Z", + daysUntil: -30, + state: "overdue", + }, + ]); + expect(html.match(/data-slot="vaccination-renewal"/g)).toHaveLength(1); + expect(html).toContain('data-state="overdue"'); + expect(html).toContain("Booster overdue since"); + expect(html).toContain("text-warning"); + // It sits inside the tetanus group, not the pertussis one. + const tetanusGroup = html.slice( + html.indexOf('data-antigen="tetanus"'), + html.indexOf('data-antigen="pertussis"') > + html.indexOf('data-antigen="tetanus"') + ? html.indexOf('data-antigen="pertussis"') + : undefined, + ); + expect(tetanusGroup).toContain('data-slot="vaccination-renewal"'); + }); + + it("tints a booster that is due soon and leaves a distant one as plain meta", () => { + const soon = renderWith([ + { + antigen: "tetanus", + reminderId: "rem-1", + dueAt: "2026-10-10T09:00:00.000Z", + daysUntil: 16, + state: "dueSoon", + }, + ]); + expect(soon).toContain("Booster due"); + expect(soon).toContain("text-info"); + const later = renderWith([ + { + antigen: "tetanus", + reminderId: "rem-1", + dueAt: "2034-10-10T09:00:00.000Z", + daysUntil: 2900, + state: "current", + }, + ]); + expect(later).toContain("Next booster"); + expect(later).not.toContain("text-warning"); + expect(later).not.toContain("text-info"); + }); + + it("gives each antigen its own renewal when several are planned", () => { + const html = renderWith([ + { + antigen: "pertussis", + reminderId: "rem-p", + dueAt: "2034-10-10T09:00:00.000Z", + daysUntil: 2900, + state: "current", + }, + { + antigen: "tetanus", + reminderId: "rem-t", + dueAt: "2026-01-10T09:00:00.000Z", + daysUntil: -30, + state: "overdue", + }, + ]); + const pertussisAt = html.indexOf('data-antigen="pertussis"'); + const tetanusAt = html.indexOf('data-antigen="tetanus"'); + const [first, second] = + pertussisAt < tetanusAt + ? [html.slice(pertussisAt, tetanusAt), html.slice(tetanusAt)] + : [html.slice(tetanusAt, pertussisAt), html.slice(pertussisAt)]; + const pertussis = pertussisAt < tetanusAt ? first : second; + const tetanus = pertussisAt < tetanusAt ? second : first; + expect(pertussis).toContain('data-state="current"'); + expect(pertussis).not.toContain('data-state="overdue"'); + expect(tetanus).toContain('data-state="overdue"'); + }); + + it("shows nothing when there is no renewal or the grant withholds it", () => { + expect(renderWith([])).not.toContain("vaccination-renewal"); + expect(renderWith(null)).not.toContain("vaccination-renewal"); + }); +}); diff --git a/src/components/vaccinations/vaccination-list.tsx b/src/components/vaccinations/vaccination-list.tsx index 442803175..8f84e14f7 100644 --- a/src/components/vaccinations/vaccination-list.tsx +++ b/src/components/vaccinations/vaccination-list.tsx @@ -18,19 +18,25 @@ * `vaccineName` instead — the degrade the DTO's `catalogEntry: null` signals, * now visible. * - * ── Neutral, retrospective ───────────────────────────────────────────────── + * ── Neutral, retrospective — and the booster the person planned ─────────── * - * No due status anywhere on this page. A booster the user set shows on - * `/checkups` like every other reminder; this surface reproduces the record - * and does not adjudicate it. Cards are neutral — content in foreground, meta - * in muted, no tint. + * The page reproduces the record and does not adjudicate it: nothing here + * works out what a person is due from their age or history. The one status it + * shows is the booster the person confirmed themselves — the reminder planned + * when a dose was logged — resolved server-side into a renewal state + * (`src/lib/vaccinations/renewal.ts`) and rendered on the antigen's heading, + * tinted only when it has passed or is close. Dose cards stay neutral — + * content in foreground, meta in muted, no tint. */ -import { Syringe } from "lucide-react"; +import { AlarmClock, Syringe } from "lucide-react"; import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; import { InfoPopover } from "@/components/ui/info-popover"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import type { SeriesPosition } from "@/lib/vaccinations/series"; +import type { VaccinationRenewalDTO } from "@/lib/vaccinations/renewal"; +import { cn } from "@/lib/utils"; import type { Vaccination } from "./use-vaccinations"; import { CatalogInfo, catalogInfoAvailable } from "./catalog-info"; @@ -208,15 +214,56 @@ function DoseRow({ ); } +/** + * The booster the person planned for this antigen, on its heading. Overdue + * and due-soon are tinted so they are seen at a glance; a booster years away + * reads as plain meta. Composed from the server's resolved state — the client + * never compares dates itself. + */ +function RenewalBadge({ renewal }: { renewal: VaccinationRenewalDTO }) { + const { t } = useTranslations(); + const format = useFormatters(); + const date = format.date(renewal.dueAt); + const text = + renewal.state === "overdue" + ? t("vaccinations.renewal.overdue", { date }) + : renewal.state === "dueSoon" + ? t("vaccinations.renewal.dueSoon", { date }) + : t("vaccinations.renewal.current", { date }); + const tone = + renewal.state === "overdue" + ? "border-warning/30 bg-warning/10 text-warning" + : renewal.state === "dueSoon" + ? "border-info/30 bg-info/10 text-info" + : "text-muted-foreground"; + return ( + + + {text} + + ); +} + export function VaccinationList({ records, + renewals, onEdit, }: { records: readonly Vaccination[]; + /** Resolved per antigen by the server; null when the grant withholds them. */ + renewals?: readonly VaccinationRenewalDTO[] | null; onEdit?: (record: Vaccination) => void; }) { const { t } = useTranslations(); const groups = groupByAntigen(records); + const renewalByAntigen = new Map( + (renewals ?? []).map((renewal) => [renewal.antigen, renewal]), + ); return (
@@ -227,7 +274,7 @@ export function VaccinationList({ data-slot="vaccination-group" data-antigen={group.antigen ?? "free"} > -

+

{group.antigen ? t(`vaccinations.catalog.${group.antigen}`) @@ -241,6 +288,9 @@ export function VaccinationList({ triggerDataSlot="vaccination-info-trigger" /> ) : null} + {group.antigen && renewalByAntigen.has(group.antigen) ? ( + + ) : null}

{group.rows.map((row, index) => ( diff --git a/src/components/vaccinations/vaccinations-view.tsx b/src/components/vaccinations/vaccinations-view.tsx index b13cff14e..59acbd2c7 100644 --- a/src/components/vaccinations/vaccinations-view.tsx +++ b/src/components/vaccinations/vaccinations-view.tsx @@ -7,8 +7,9 @@ * doses by their component antigen and shows where each series stands, with the * numbers resolved server-side (`src/lib/vaccinations/series.ts`) so this * client never re-derives "N von M". Neutral cards, status through a discreet - * badge only — no red card, no overdue tint. It reproduces the record; it does - * not adjudicate what is due. + * badge only — no red card. It reproduces the record; it does not adjudicate + * what is due. The one status shown is the booster the person planned, on the + * antigen's heading (see `vaccination-list.tsx`). */ import { useState } from "react"; import { Plus, Syringe } from "lucide-react"; @@ -121,6 +122,7 @@ export function VaccinationsView() { ) : records.length > 0 ? ( ) : ( diff --git a/src/lib/openapi/routes/vaccinations.ts b/src/lib/openapi/routes/vaccinations.ts index 3a4708dae..bfc8a4be7 100644 --- a/src/lib/openapi/routes/vaccinations.ts +++ b/src/lib/openapi/routes/vaccinations.ts @@ -175,11 +175,33 @@ const vaccination = z "One administered dose. `occurredAt` is a day at UTC midnight — the source record carries dates, never times. `series` carries one entry per component antigen, so a combined preparation reports several at once and each may be at a different position: a dose can be a booster for one antigen and a first dose for another in the same act. An empty `series` means the dose has no resolvable antigen (free text only, or a slug this release does not know) and nothing is guessed from the name. `note` is the decrypted free text, or null on a key-rotation gap — fail-soft, never a 500. `reminderId` names the booster reminder this dose settled, when one matched. `documents` is present on the detail response and absent from the list.", }); -const vaccinationList = z.object({ vaccinations: z.array(vaccination) }).meta({ - id: "VaccinationList", - description: - "The caller's immunization history, newest dose first, with every entry's series already resolved over the whole live set — never over the filtered page, which would report the oldest dose in a window as the first ever given.", -}); +const vaccinationRenewal = z + .object({ + antigen: z.string(), + reminderId: z.string(), + dueAt: z.string(), + daysUntil: z.number().int(), + state: z.enum(["current", "dueSoon", "overdue"]), + }) + .meta({ + id: "VaccinationRenewal", + description: + "One antigen's renewal state, read from the booster reminder the person confirmed for it (`dueAt` is that reminder's next due instant). `daysUntil` counts calendar days on the person's own clock and is negative once the date has passed. `state` is `overdue` once it has passed, `dueSoon` within 30 days, `current` otherwise. Render from `state`; do not recompute it. Nothing here is derived from age, history or the catalogue: an antigen without a confirmed booster has no entry.", + }); + +const vaccinationList = z + .object({ + vaccinations: z.array(vaccination), + renewals: z.array(vaccinationRenewal).nullable().meta({ + description: + "Each antigen's renewal state, earliest due first. Null when the caller acts under a grant that does not cover the measurements section the booster reminders live in.", + }), + }) + .meta({ + id: "VaccinationList", + description: + "The caller's immunization history, newest dose first, with every entry's series already resolved over the whole live set — never over the filtered page, which would report the oldest dose in a window as the first ever given.", + }); const vaccinationSuggestion = z .object({ diff --git a/src/lib/vaccinations/__tests__/renewal.test.ts b/src/lib/vaccinations/__tests__/renewal.test.ts new file mode 100644 index 000000000..8ae808a02 --- /dev/null +++ b/src/lib/vaccinations/__tests__/renewal.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; + +import { + VACCINATION_RENEWAL_SOON_DAYS, + resolveRenewals, +} from "@/lib/vaccinations/renewal"; + +const NOW = new Date("2026-09-24T10:00:00.000Z"); +const TZ = "Europe/Berlin"; + +function reminder( + over: Partial<{ + id: string; + vaccinationAntigen: string | null; + nextDueAt: Date | null; + enabled: boolean; + }> = {}, +) { + return { + id: "rem-1", + vaccinationAntigen: "tetanus", + nextDueAt: new Date("2030-01-01T08:00:00.000Z"), + enabled: true, + ...over, + }; +} + +describe("resolveRenewals", () => { + it("reads a booster far off as current", () => { + expect(resolveRenewals([reminder()], NOW, TZ)).toEqual([ + { + antigen: "tetanus", + reminderId: "rem-1", + dueAt: "2030-01-01T08:00:00.000Z", + daysUntil: expect.any(Number), + state: "current", + }, + ]); + }); + + it("reads a booster inside the soon window as due soon, edges included", () => { + const soon = new Date( + NOW.getTime() + VACCINATION_RENEWAL_SOON_DAYS * 24 * 60 * 60 * 1000, + ); + const [renewal] = resolveRenewals([reminder({ nextDueAt: soon })], NOW, TZ); + expect(renewal!.daysUntil).toBe(VACCINATION_RENEWAL_SOON_DAYS); + expect(renewal!.state).toBe("dueSoon"); + const [today] = resolveRenewals([reminder({ nextDueAt: NOW })], NOW, TZ); + expect(today!.state).toBe("dueSoon"); + }); + + it("reads a booster whose date has passed as overdue", () => { + const [renewal] = resolveRenewals( + [reminder({ nextDueAt: new Date("2026-09-01T08:00:00.000Z") })], + NOW, + TZ, + ); + expect(renewal!.state).toBe("overdue"); + expect(renewal!.daysUntil).toBeLessThan(0); + }); + + it("ignores a switched-off reminder, one with no antigen, and one with no date", () => { + expect( + resolveRenewals( + [ + reminder({ enabled: false }), + reminder({ vaccinationAntigen: null }), + reminder({ nextDueAt: null }), + ], + NOW, + TZ, + ), + ).toEqual([]); + }); + + it("answers one renewal per antigen, the earliest due", () => { + const renewals = resolveRenewals( + [ + reminder({ id: "late", nextDueAt: new Date("2031-01-01T08:00:00Z") }), + reminder({ id: "early", nextDueAt: new Date("2029-01-01T08:00:00Z") }), + reminder({ + id: "flu", + vaccinationAntigen: "influenza", + nextDueAt: new Date("2026-10-01T08:00:00Z"), + }), + ], + NOW, + TZ, + ); + expect(renewals.map((r) => [r.antigen, r.reminderId])).toEqual([ + ["influenza", "flu"], + ["tetanus", "early"], + ]); + }); +}); diff --git a/src/lib/vaccinations/dto.ts b/src/lib/vaccinations/dto.ts index 653b96908..a1a5ac29b 100644 --- a/src/lib/vaccinations/dto.ts +++ b/src/lib/vaccinations/dto.ts @@ -30,6 +30,7 @@ import { } from "@/lib/practitioners/dto"; import { resolveCatalogEntry } from "@/lib/vaccinations/vaccine-catalog"; import type { SeriesPosition } from "@/lib/vaccinations/series"; +import type { VaccinationRenewalDTO } from "@/lib/vaccinations/renewal"; /** * One page a dose was transcribed from, as the caller is allowed to see it. @@ -162,4 +163,11 @@ export function toVaccinationDTO( /** The list response: one array, newest dose first. */ export interface VaccinationListDTO { vaccinations: VaccinationDTO[]; + /** + * v1.39 — each antigen's renewal state, from the booster reminder the + * person confirmed (`src/lib/vaccinations/renewal.ts`). An antigen with no + * confirmed booster is absent. `null` when the caller acts under a grant + * that does not cover the measurements section the reminders live in. + */ + renewals: VaccinationRenewalDTO[] | null; } diff --git a/src/lib/vaccinations/renewal.ts b/src/lib/vaccinations/renewal.ts new file mode 100644 index 000000000..7bd4a2546 --- /dev/null +++ b/src/lib/vaccinations/renewal.ts @@ -0,0 +1,92 @@ +/** + * Whether a vaccination is still current, surfaced where the person looks. + * + * The data already exists and is the person's own: when a dose is logged, the + * app offers to plan the booster from the catalogue's interval, and what the + * person confirms becomes an ordinary reminder keyed on the antigen + * (`MeasurementReminder.vaccinationAntigen`, minted in `booster-mint.ts`). + * Logging the next dose re-anchors it. Its `nextDueAt` is the renewal date. + * Until now it lived only on `/checkups`, and the vaccination page itself said + * nothing about it. + * + * This module reads that date and resolves it to a state, once, on the + * server, so the web page and the native client agree. It does NOT compute a + * due date from age, history or the catalogue: an antigen with no confirmed + * booster has no renewal here. That line is the one `series.ts` draws — due + * computation from a person's history is a different kind of product — and it + * is why the source is the confirmed reminder and nothing else. + * + * Pure: no DB read. The route owns the read and the grant check. + */ +import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day"; + +/** + * How close a renewal has to be before it counts as due soon. A month is + * enough to book an appointment for a ten-year booster and still meaningful + * for a seasonal one. + */ +export const VACCINATION_RENEWAL_SOON_DAYS = 30; + +export type VaccinationRenewalState = "current" | "dueSoon" | "overdue"; + +/** One antigen's renewal, resolved. */ +export interface VaccinationRenewalDTO { + /** The component antigen slug the booster reminder keys on. */ + antigen: string; + /** The reminder behind it, for a deep link to `/checkups`. */ + reminderId: string; + /** ISO-8601 instant the booster is due. */ + dueAt: string; + /** + * Calendar days from today to `dueAt` on the person's own clock; negative + * once it has passed. The same count the checkups page shows. + */ + daysUntil: number; + state: VaccinationRenewalState; +} + +export interface RenewalReminderRow { + id: string; + vaccinationAntigen: string | null; + nextDueAt: Date | null; + enabled: boolean; +} + +export function resolveRenewals( + reminders: readonly RenewalReminderRow[], + now: Date, + timeZone: string, +): VaccinationRenewalDTO[] { + const byAntigen = new Map(); + for (const reminder of reminders) { + if (!reminder.enabled) continue; + if (!reminder.vaccinationAntigen || !reminder.nextDueAt) continue; + // The mint keeps one live reminder per antigen; should two exist anyway, + // the earlier date is the one that needs the person first. + const current = byAntigen.get(reminder.vaccinationAntigen); + if (!current || reminder.nextDueAt < current.due) { + byAntigen.set(reminder.vaccinationAntigen, { + ...reminder, + due: reminder.nextDueAt, + }); + } + } + return [...byAntigen.values()] + .map((reminder) => { + const daysUntil = calendarDaysUntil(reminder.due, now, timeZone); + const state: VaccinationRenewalState = + daysUntil < 0 + ? "overdue" + : daysUntil <= VACCINATION_RENEWAL_SOON_DAYS + ? "dueSoon" + : "current"; + return { + antigen: reminder.vaccinationAntigen!, + reminderId: reminder.id, + dueAt: reminder.due.toISOString(), + daysUntil, + state, + }; + }) + .sort((a, b) => a.dueAt.localeCompare(b.dueAt)); +} diff --git a/tests/integration/vaccination-renewal.test.ts b/tests/integration/vaccination-renewal.test.ts new file mode 100644 index 000000000..a51e0025b --- /dev/null +++ b/tests/integration/vaccination-renewal.test.ts @@ -0,0 +1,177 @@ +/** + * The vaccination list carries each antigen's renewal state, read from the + * booster reminder the person confirmed, and only to a caller whose grant + * covers the measurements section that reminder lives in. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { NextRequest } from "next/server"; + +import type { ShareDomain } from "@/lib/sharing/scope"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const OWNER_ID = "renewal-owner"; +const DELEGATE_ID = "renewal-delegate"; + +/** A catalogue antigen that carries a booster interval. */ +const ANTIGEN = "tetanus"; + +let doseId: string; + +async function seedRecords() { + const prisma = getPrismaClient(); + for (const [id, name] of [ + [OWNER_ID, "owner"], + [DELEGATE_ID, "delegate"], + ] as const) { + await prisma.user.create({ + data: { + id, + username: `renewal-${name}`, + email: `renewal-${name}@example.test`, + timezone: "UTC", + }, + }); + } + const dose = await prisma.vaccinationRecord.create({ + data: { + userId: OWNER_ID, + occurredAt: new Date("2026-03-01T00:00:00.000Z"), + antigenSlug: ANTIGEN, + }, + }); + doseId = dose.id; +} + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) }, + }); + cookieJar.set("healthlog_session", session.id); + return session; +} + +async function switchInto( + access: "WRITE" | "MANAGE", + scope: ShareDomain[] | null, +) { + const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants"); + const invited = await inviteGrant({ + grantorId: OWNER_ID, + granteeId: DELEGATE_ID, + access, + scope, + }); + await acceptGrant({ grantId: invited.id, granteeId: DELEGATE_ID }); + const session = await signIn(DELEGATE_ID); + await switchSessionTo(session.id, OWNER_ID); +} + +async function planBooster(): Promise { + const { POST } = await import("@/app/api/vaccinations/[id]/booster/route"); + return POST( + new NextRequest(`http://localhost/api/vaccinations/${doseId}/booster`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ intervalMonths: 120, label: "Tetanus booster" }), + }), + { params: Promise.resolve({ id: doseId }) }, + ); +} + +async function listVaccinations(): Promise<{ + renewals: Array<{ antigen: string; state: string; daysUntil: number }> | null; +}> { + const { GET } = await import("@/app/api/vaccinations/route"); + const res = await GET(new NextRequest("http://localhost/api/vaccinations")); + expect(res.status).toBe(200); + return (await res.json()).data; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await seedRecords(); +}); + +describe("the vaccination list shows each antigen's renewal state (#1005)", () => { + it("reads the booster the owner confirmed as current", async () => { + await signIn(OWNER_ID); + expect((await planBooster()).status).toBe(201); + const { renewals } = await listVaccinations(); + expect(renewals).toHaveLength(1); + expect(renewals![0]).toMatchObject({ antigen: ANTIGEN, state: "current" }); + }); + + it("reads a booster whose date has passed as overdue", async () => { + await signIn(OWNER_ID); + expect((await planBooster()).status).toBe(201); + await getPrismaClient().measurementReminder.updateMany({ + where: { userId: OWNER_ID }, + data: { nextDueAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000) }, + }); + const { renewals } = await listVaccinations(); + expect(renewals![0]).toMatchObject({ antigen: ANTIGEN, state: "overdue" }); + expect(renewals![0]!.daysUntil).toBeLessThan(0); + }); + + it("claims nothing for an antigen without a confirmed booster", async () => { + await signIn(OWNER_ID); + const { renewals } = await listVaccinations(); + expect(renewals).toEqual([]); + }); + + it("never shows another account's booster", async () => { + await signIn(OWNER_ID); + expect((await planBooster()).status).toBe(201); + cookieJar.clear(); + await signIn(DELEGATE_ID); + const { renewals } = await listVaccinations(); + expect(renewals).toEqual([]); + }); + + it("withholds renewals from a grant that opened only the health background", async () => { + await signIn(OWNER_ID); + expect((await planBooster()).status).toBe(201); + cookieJar.clear(); + await switchInto("WRITE", ["profile"]); + const { renewals } = await listVaccinations(); + expect(renewals).toBeNull(); + }); + + it("shows them to a grant that also covers measurements", async () => { + await signIn(OWNER_ID); + expect((await planBooster()).status).toBe(201); + cookieJar.clear(); + await switchInto("WRITE", ["profile", "measurements"]); + const { renewals } = await listVaccinations(); + expect(renewals).toHaveLength(1); + }); +});