mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
feat(vaccinations): let a logged dose clear the boosters it answers
- the dose is expanded to its component antigens first, so one Tdap entry settles a tetanus booster and a pertussis booster at once; matching the preparation's own slug would leave both standing - it runs inside the create transaction and nowhere else: no cron, no ingest hook, and an edit never re-runs it, so correcting a typo in a date cannot move a reminder - forward-only, which is what makes transcribing an old record safe: a page from decades ago never clears a booster that is due now - a free-text-only dose matches nothing; a name string is not evidence about what was in the syringe - an ordinary preventive-care reminder carries no antigen key and is structurally out of reach
This commit is contained in:
1 parent
7ac81f3eed
commit
82982038cb
4 files changed
+572
No files matched your search
@@ -44,8 +44,10 @@ import {
|
||||
applyVaccinationLinks,
|
||||
resolveOwnedEncounter,
|
||||
resolveOwnedPractitioner,
|
||||
resolveOwnerTimezone,
|
||||
resolveSeriesFor,
|
||||
} from "@/lib/vaccinations/service";
|
||||
import { satisfyBoostersForDose } from "@/lib/vaccinations/satisfy-matching";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read", "profile");
|
||||
@@ -130,6 +132,11 @@ async function postVaccination(request: NextRequest): Promise<Response> {
|
||||
|
||||
const entry = parsed.data;
|
||||
const occurredAt = new Date(entry.occurredAt);
|
||||
const timezone = await resolveOwnerTimezone(user.id);
|
||||
|
||||
// Filled inside the transaction, read after it for the wide event: an
|
||||
// annotation is not worth holding a transaction open for.
|
||||
let satisfiedReminderIds: string[] = [];
|
||||
|
||||
const created = await prisma.$transaction(async (tx) => {
|
||||
// Every id the body carries is re-narrowed to the resolved account before
|
||||
@@ -169,6 +176,26 @@ async function postVaccination(request: NextRequest): Promise<Response> {
|
||||
|
||||
await applyVaccinationLinks(tx, user.id, row.id, entry.documentIds);
|
||||
|
||||
// The booster arm, inside the same transaction so a dose that failed to
|
||||
// save cannot leave a reminder looking settled. It runs on the OWNER's
|
||||
// reminders, which is correct even when a delegate is transcribing: it is
|
||||
// the owner's booster plan, and filing their Impfpass is exactly the work
|
||||
// that should settle it.
|
||||
const boosters = await satisfyBoostersForDose(
|
||||
tx,
|
||||
user.id,
|
||||
entry.antigenSlug ?? null,
|
||||
occurredAt,
|
||||
timezone,
|
||||
);
|
||||
satisfiedReminderIds = boosters.satisfiedReminderIds;
|
||||
if (boosters.primaryReminderId) {
|
||||
await tx.vaccinationRecord.update({
|
||||
where: { id: row.id },
|
||||
data: { reminderId: boosters.primaryReminderId },
|
||||
});
|
||||
}
|
||||
|
||||
return tx.vaccinationRecord.findUniqueOrThrow({
|
||||
where: { id: row.id },
|
||||
include: VACCINATION_INCLUDE,
|
||||
@@ -209,8 +236,19 @@ async function postVaccination(request: NextRequest): Promise<Response> {
|
||||
meta: {
|
||||
antigen_slug: created.antigenSlug ?? "free-text",
|
||||
linked_documents: entry.documentIds?.length ?? 0,
|
||||
satisfied_boosters: satisfiedReminderIds.length,
|
||||
},
|
||||
});
|
||||
for (const reminderId of satisfiedReminderIds) {
|
||||
annotate({
|
||||
action: {
|
||||
name: "vaccination.booster.satisfied",
|
||||
entity_type: "measurement-reminder",
|
||||
entity_id: reminderId,
|
||||
},
|
||||
meta: { antigen_slug: created.antigenSlug ?? "free-text" },
|
||||
});
|
||||
}
|
||||
|
||||
return apiSuccess(
|
||||
toVaccinationDTO(created, series.get(created.id) ?? []),
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Logging a dose clears the booster reminders it answers.
|
||||
*
|
||||
* This is the reader half of `MeasurementReminder.vaccinationAntigen`, and the
|
||||
* whole of it. The writer — the booster mint, which is rung 2: prefilled,
|
||||
* user-confirmed, editable, declinable — lands with the capture surface. The
|
||||
* column is a match key and nothing else: no cron query changes, no reminder
|
||||
* DTO field, no new queue, no new notification event type, no new preference,
|
||||
* and the published `origin` enum still reads `["VORSORGE","COACH"]`. A
|
||||
* booster reminder is an ordinary preventive-care row that happens to say
|
||||
* which antigen would settle it.
|
||||
*
|
||||
* ── Why components, not the slug ───────────────────────────────────────────
|
||||
*
|
||||
* A reminder is keyed by an ANTIGEN. A dose is recorded as whatever
|
||||
* preparation the person was given, which is usually a combination. Matching
|
||||
* the record's own slug against the reminder's key would mean a Tdap dose left
|
||||
* a tetanus booster standing, which is the named failure mode of this whole
|
||||
* design. So the dose is expanded to its component antigens first, and every
|
||||
* reminder any of them matches is satisfied.
|
||||
*
|
||||
* A free-text-only dose matches nothing. Nothing here reads `vaccineName`: a
|
||||
* name string is not evidence about which antigens were in the syringe, and a
|
||||
* booster cleared by a guess is worse than a booster not cleared at all.
|
||||
*
|
||||
* ── Cold path only ─────────────────────────────────────────────────────────
|
||||
*
|
||||
* It runs inside the create transaction of `POST /api/vaccinations` and
|
||||
* nowhere else. Never on a cron, never on an ingest hook, never on an edit —
|
||||
* the never-nag query in `src/lib/jobs/measurement-reminder.ts` is untouched
|
||||
* by this feature, and PATCH deliberately does not re-run this, because
|
||||
* correcting a transcription typo is not a second clinical event.
|
||||
*
|
||||
* ── The two properties that come from `satisfyReminder` ────────────────────
|
||||
*
|
||||
* Both are the design rather than an accident, and both have a fixture:
|
||||
*
|
||||
* - **Forward-only.** A dose older than the reminder's `lastSatisfiedAt` is
|
||||
* a no-op. Transcribing a 1987 Pass page can never clear a booster that is
|
||||
* due today, which is the property that makes bulk back-entry safe.
|
||||
* - **Re-anchored.** `nextDueAt` recomputes from the satisfy instant, so
|
||||
* logging a tetanus dose from 2019 against a ten-year reminder moves the
|
||||
* due date to 2029 — and logging one old enough that the recomputed date
|
||||
* is already past leaves the reminder due, which is correct. An old dose
|
||||
* does not silence a real gap.
|
||||
*/
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
import {
|
||||
satisfyReminder,
|
||||
type SatisfiableReminder,
|
||||
} from "@/lib/measurement-reminders/satisfy";
|
||||
import { componentsForSlug } from "@/lib/vaccinations/vaccine-catalog";
|
||||
|
||||
/** What the create route needs back: which reminders moved, and the first. */
|
||||
export interface BoosterSatisfyResult {
|
||||
/** Ids of every reminder this dose advanced, in catalogue component order. */
|
||||
satisfiedReminderIds: string[];
|
||||
/**
|
||||
* The id stamped onto `VaccinationRecord.reminderId`.
|
||||
*
|
||||
* The first satisfied reminder in catalogue component order, so a Tdap dose
|
||||
* that clears a tetanus and a pertussis booster always records the same one
|
||||
* rather than whichever the database returned first. The column records ONE
|
||||
* act; the engine records them all, in each row's own `lastSatisfiedAt`.
|
||||
*/
|
||||
primaryReminderId: string | null;
|
||||
}
|
||||
|
||||
const EMPTY: BoosterSatisfyResult = {
|
||||
satisfiedReminderIds: [],
|
||||
primaryReminderId: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Satisfy every live booster reminder this dose answers.
|
||||
*
|
||||
* Takes the transaction client the create runs in, so the reminder advance and
|
||||
* the record insert commit or roll back together: a dose that failed to save
|
||||
* must not leave a booster looking settled.
|
||||
*/
|
||||
export async function satisfyBoostersForDose(
|
||||
tx: Prisma.TransactionClient,
|
||||
userId: string,
|
||||
antigenSlug: string | null,
|
||||
occurredAt: Date,
|
||||
timezone: string,
|
||||
): Promise<BoosterSatisfyResult> {
|
||||
const components = componentsForSlug(antigenSlug);
|
||||
if (components.length === 0) return EMPTY;
|
||||
|
||||
const reminders = await tx.measurementReminder.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
// An ordinary Vorsorge reminder carries NULL here and is never touched:
|
||||
// the `in` filter cannot match a null, so the fence is structural rather
|
||||
// than a condition somebody has to remember.
|
||||
vaccinationAntigen: { in: [...components] },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
vaccinationAntigen: true,
|
||||
intervalDays: true,
|
||||
rrule: true,
|
||||
anchorDate: true,
|
||||
notifyHour: true,
|
||||
lastSatisfiedAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
if (reminders.length === 0) return EMPTY;
|
||||
|
||||
// Deterministic: catalogue component order, then id, so two reminders for
|
||||
// the same antigen still resolve to a stable primary.
|
||||
const rank = new Map(components.map((antigen, index) => [antigen, index]));
|
||||
const ordered = [...reminders].sort((a, b) => {
|
||||
const byAntigen =
|
||||
(rank.get(a.vaccinationAntigen as never) ?? Number.MAX_SAFE_INTEGER) -
|
||||
(rank.get(b.vaccinationAntigen as never) ?? Number.MAX_SAFE_INTEGER);
|
||||
return byAntigen !== 0 ? byAntigen : a.id.localeCompare(b.id);
|
||||
});
|
||||
|
||||
const satisfiedReminderIds: string[] = [];
|
||||
for (const reminder of ordered) {
|
||||
const row: SatisfiableReminder = {
|
||||
id: reminder.id,
|
||||
intervalDays: reminder.intervalDays,
|
||||
rrule: reminder.rrule,
|
||||
anchorDate: reminder.anchorDate,
|
||||
notifyHour: reminder.notifyHour,
|
||||
lastSatisfiedAt: reminder.lastSatisfiedAt,
|
||||
createdAt: reminder.createdAt,
|
||||
};
|
||||
// `satisfyReminder` types its client as the full `PrismaClient` because
|
||||
// every other caller hands it one; the only member it touches is
|
||||
// `measurementReminder.updateMany`, which a transaction client has. The
|
||||
// cast is confined to this line so the advance stays inside the create
|
||||
// transaction rather than committing on its own.
|
||||
const result = await satisfyReminder(
|
||||
tx as unknown as PrismaClient,
|
||||
row,
|
||||
timezone,
|
||||
occurredAt,
|
||||
);
|
||||
if (result.satisfied) satisfiedReminderIds.push(reminder.id);
|
||||
}
|
||||
|
||||
return {
|
||||
satisfiedReminderIds,
|
||||
primaryReminderId: satisfiedReminderIds[0] ?? null,
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { listTargets, replaceTargets } from "@/lib/links";
|
||||
import { grantCoversDomain } from "@/lib/sharing/grants";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
@@ -18,6 +19,9 @@ import {
|
||||
type SeriesInputRecord,
|
||||
} from "@/lib/vaccinations/series";
|
||||
|
||||
/** Matches the visits service: the fallback when an account never set one. */
|
||||
const DEFAULT_TIMEZONE = "Europe/Berlin";
|
||||
|
||||
/** The relations every vaccination response resolves. */
|
||||
export const VACCINATION_INCLUDE = {
|
||||
practitioner: true,
|
||||
@@ -157,6 +161,21 @@ export async function resolveOwnedPractitioner(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The RECORD owner's timezone, for the booster reschedule.
|
||||
*
|
||||
* The owner's, never the acting delegate's: a reminder fires on the owner's
|
||||
* phone at the owner's local hour, and a helper transcribing from another
|
||||
* timezone must not shift when it rings.
|
||||
*/
|
||||
export async function resolveOwnerTimezone(userId: string): Promise<string> {
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { timezone: true },
|
||||
});
|
||||
return row?.timezone || DEFAULT_TIMEZONE;
|
||||
}
|
||||
|
||||
/** Does the caller own a live visit with this id? */
|
||||
export async function resolveOwnedEncounter(
|
||||
tx: Prisma.TransactionClient,
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* Logging a dose clears the boosters it answers — through the real route, real
|
||||
* Postgres, real reminder engine.
|
||||
*
|
||||
* Every property here is about ROWS: which `lastSatisfiedAt` moved, which did
|
||||
* not, and whether a reminder nobody keyed to an antigen was touched. None of
|
||||
* them can be answered by a mocked client, and the one that matters most is a
|
||||
* negative: a fake that ignored the `where` would report the ordinary Vorsorge
|
||||
* reminder as untouched while having satisfied it.
|
||||
*
|
||||
* The combination case is the reason this file exists. A Tdap dose has to
|
||||
* clear a tetanus booster AND a pertussis booster in one call, because a
|
||||
* reminder is keyed by an antigen and a dose is recorded as whatever
|
||||
* preparation the person was given. Matching the dose's own slug would leave
|
||||
* the tetanus booster standing.
|
||||
*
|
||||
* Fixture dates are relative. A fixed calendar date in a test about "older
|
||||
* than" and "moves forward" rots the moment it slides out of the window it
|
||||
* was written inside.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { POST as createVaccination } from "@/app/api/vaccinations/route";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } 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 = "booster-owner";
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function daysAgo(days: number): Date {
|
||||
return new Date(Date.now() - days * DAY_MS);
|
||||
}
|
||||
|
||||
function daysFromNow(days: number): Date {
|
||||
return new Date(Date.now() + days * DAY_MS);
|
||||
}
|
||||
|
||||
async function seedSession() {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: OWNER_ID,
|
||||
username: "booster-owner",
|
||||
email: "booster-owner@example.test",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
expiresAt: daysFromNow(7),
|
||||
mfaVerifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* A booster reminder as the mint will write it: an ordinary ten-year VORSORGE
|
||||
* row that names the antigen a dose would settle.
|
||||
*/
|
||||
async function seedBooster(
|
||||
antigen: string | null,
|
||||
overrides: { lastSatisfiedAt?: Date | null; label?: string } = {},
|
||||
) {
|
||||
return getPrismaClient().measurementReminder.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
label: overrides.label ?? `${antigen ?? "generic"} booster`,
|
||||
intervalDays: 3650,
|
||||
notifyHour: 9,
|
||||
vaccinationAntigen: antigen,
|
||||
lastSatisfiedAt: overrides.lastSatisfiedAt ?? null,
|
||||
nextDueAt: daysAgo(1),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function post(body: unknown): Promise<Response> {
|
||||
return createVaccination(
|
||||
new Request("http://localhost/api/vaccinations", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}) as never,
|
||||
);
|
||||
}
|
||||
|
||||
async function reminderById(id: string) {
|
||||
return getPrismaClient().measurementReminder.findUniqueOrThrow({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
await seedSession();
|
||||
});
|
||||
|
||||
describe("a dose satisfies the boosters it answers", () => {
|
||||
it("clears a monovalent antigen's own booster and re-anchors it", async () => {
|
||||
const reminder = await seedBooster("tetanus");
|
||||
const occurredAt = daysAgo(2);
|
||||
|
||||
const res = await post({
|
||||
occurredAt: occurredAt.toISOString(),
|
||||
antigenSlug: "tetanus",
|
||||
});
|
||||
expect(res.status, JSON.stringify(await res.clone().json())).toBe(201);
|
||||
|
||||
const after = await reminderById(reminder.id);
|
||||
expect(after.lastSatisfiedAt?.toISOString()).toBe(occurredAt.toISOString());
|
||||
// Re-anchored from the satisfy instant, so the next one lands roughly ten
|
||||
// years out rather than staying on the date it was already overdue on.
|
||||
expect(after.nextDueAt).not.toBeNull();
|
||||
expect(after.nextDueAt!.getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it("records the satisfied reminder on the dose", async () => {
|
||||
const reminder = await seedBooster("tetanus");
|
||||
const res = await post({
|
||||
occurredAt: daysAgo(2).toISOString(),
|
||||
antigenSlug: "tetanus",
|
||||
});
|
||||
const payload = (await res.json()) as {
|
||||
data: { id: string; reminderId: string | null };
|
||||
};
|
||||
expect(payload.data.reminderId).toBe(reminder.id);
|
||||
});
|
||||
|
||||
it("clears a tetanus AND a pertussis booster from one Tdap dose", async () => {
|
||||
// The property the whole component axis exists for. A matcher keyed on the
|
||||
// record's own slug would find neither of these.
|
||||
const tetanus = await seedBooster("tetanus");
|
||||
const pertussis = await seedBooster("pertussis");
|
||||
const occurredAt = daysAgo(3);
|
||||
|
||||
const res = await post({
|
||||
occurredAt: occurredAt.toISOString(),
|
||||
antigenSlug: "tdap",
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
expect((await reminderById(tetanus.id)).lastSatisfiedAt).not.toBeNull();
|
||||
expect((await reminderById(pertussis.id)).lastSatisfiedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it("clears every component of a six-antigen infant dose", async () => {
|
||||
const reminders = await Promise.all(
|
||||
["diphtheria", "tetanus", "pertussis", "polio", "hib", "hepatitis-b"].map(
|
||||
(antigen) => seedBooster(antigen),
|
||||
),
|
||||
);
|
||||
|
||||
const res = await post({
|
||||
occurredAt: daysAgo(1).toISOString(),
|
||||
antigenSlug: "hexavalent",
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const after = await Promise.all(
|
||||
reminders.map((reminder) => reminderById(reminder.id)),
|
||||
);
|
||||
expect(after.filter((row) => row.lastSatisfiedAt !== null)).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what a dose must never touch", () => {
|
||||
it("leaves an ordinary Vorsorge reminder alone", async () => {
|
||||
// No antigen key at all — the blood-panel reminder somebody set years ago.
|
||||
// A matcher whose `where` did not hold would clear this and nothing in the
|
||||
// response would say so.
|
||||
const ordinary = await seedBooster(null, { label: "Blood panel" });
|
||||
|
||||
const res = await post({
|
||||
occurredAt: daysAgo(1).toISOString(),
|
||||
antigenSlug: "tetanus",
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const after = await reminderById(ordinary.id);
|
||||
expect(after.lastSatisfiedAt).toBeNull();
|
||||
expect(after.nextDueAt?.toISOString()).toBe(
|
||||
ordinary.nextDueAt?.toISOString(),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a booster for an antigen the dose does not contain", async () => {
|
||||
const measles = await seedBooster("measles");
|
||||
|
||||
const res = await post({
|
||||
occurredAt: daysAgo(1).toISOString(),
|
||||
antigenSlug: "tdap",
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect((await reminderById(measles.id)).lastSatisfiedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("matches nothing at all for a free-text-only dose", async () => {
|
||||
// A name string is not evidence about which antigens were in the syringe.
|
||||
const tetanus = await seedBooster("tetanus");
|
||||
|
||||
const res = await post({
|
||||
occurredAt: daysAgo(1).toISOString(),
|
||||
vaccineName: "Tetanus, as the 1987 Pass wrote it",
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const payload = (await res.json()) as {
|
||||
data: { reminderId: string | null };
|
||||
};
|
||||
expect(payload.data.reminderId).toBeNull();
|
||||
expect((await reminderById(tetanus.id)).lastSatisfiedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves another account's booster alone", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: "booster-stranger",
|
||||
username: "booster-stranger",
|
||||
email: "booster-stranger@example.test",
|
||||
},
|
||||
});
|
||||
const foreign = await prisma.measurementReminder.create({
|
||||
data: {
|
||||
userId: "booster-stranger",
|
||||
label: "tetanus booster",
|
||||
intervalDays: 3650,
|
||||
vaccinationAntigen: "tetanus",
|
||||
nextDueAt: daysAgo(1),
|
||||
},
|
||||
});
|
||||
|
||||
const res = await post({
|
||||
occurredAt: daysAgo(1).toISOString(),
|
||||
antigenSlug: "tetanus",
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect((await reminderById(foreign.id)).lastSatisfiedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves a soft-deleted booster alone", async () => {
|
||||
const deleted = await seedBooster("tetanus");
|
||||
await getPrismaClient().measurementReminder.update({
|
||||
where: { id: deleted.id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
|
||||
const res = await post({
|
||||
occurredAt: daysAgo(1).toISOString(),
|
||||
antigenSlug: "tetanus",
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect((await reminderById(deleted.id)).lastSatisfiedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the two properties the engine brings with it", () => {
|
||||
it("is forward-only: a backdated transcription never clears a due booster", async () => {
|
||||
// The property that makes bulk back-entry of an old Pass safe. The
|
||||
// reminder was satisfied a month ago; a page from 1987 must not move it.
|
||||
const satisfiedAt = daysAgo(30);
|
||||
const reminder = await seedBooster("tetanus", {
|
||||
lastSatisfiedAt: satisfiedAt,
|
||||
});
|
||||
|
||||
const res = await post({
|
||||
occurredAt: daysAgo(14000).toISOString(),
|
||||
antigenSlug: "tetanus",
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const after = await reminderById(reminder.id);
|
||||
expect(after.lastSatisfiedAt?.toISOString()).toBe(
|
||||
satisfiedAt.toISOString(),
|
||||
);
|
||||
// And the record says so: no reminder was satisfied, so none is recorded.
|
||||
const payload = (await res.json()) as {
|
||||
data: { reminderId: string | null };
|
||||
};
|
||||
expect(payload.data.reminderId).toBeNull();
|
||||
});
|
||||
|
||||
it("is idempotent: logging the same dose twice moves nothing the second time", async () => {
|
||||
const reminder = await seedBooster("tetanus");
|
||||
const occurredAt = daysAgo(5);
|
||||
const bodyIn = {
|
||||
occurredAt: occurredAt.toISOString(),
|
||||
antigenSlug: "tetanus",
|
||||
};
|
||||
|
||||
expect((await post(bodyIn)).status).toBe(201);
|
||||
const afterFirst = await reminderById(reminder.id);
|
||||
|
||||
// A duplicate entry is still a valid dose — two doses on one day happen —
|
||||
// so it returns 201. What must not happen is the reminder advancing again.
|
||||
const second = await post(bodyIn);
|
||||
expect(second.status).toBe(201);
|
||||
|
||||
const afterSecond = await reminderById(reminder.id);
|
||||
expect(afterSecond.lastSatisfiedAt?.toISOString()).toBe(
|
||||
afterFirst.lastSatisfiedAt?.toISOString(),
|
||||
);
|
||||
expect(afterSecond.nextDueAt?.toISOString()).toBe(
|
||||
afterFirst.nextDueAt?.toISOString(),
|
||||
);
|
||||
const payload = (await second.json()) as {
|
||||
data: { reminderId: string | null };
|
||||
};
|
||||
expect(
|
||||
payload.data.reminderId,
|
||||
"the second dose satisfied nothing, so it records nothing",
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves a reminder due when the dose is too old to have settled it", async () => {
|
||||
// Re-anchoring from an old instant can land the next due date in the past,
|
||||
// and that is correct: an old dose does not silence a real gap.
|
||||
const reminder = await seedBooster("tetanus");
|
||||
|
||||
const res = await post({
|
||||
occurredAt: daysAgo(5000).toISOString(),
|
||||
antigenSlug: "tetanus",
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const after = await reminderById(reminder.id);
|
||||
expect(after.lastSatisfiedAt).not.toBeNull();
|
||||
expect(
|
||||
after.nextDueAt!.getTime(),
|
||||
"a dose from fourteen years ago on a ten-year cadence is still overdue",
|
||||
).toBeLessThan(Date.now());
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user