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); + }); +});