Files
HealthLog/src/lib/vaccinations/service.ts
T
Marc-André Bombeck 82982038cb 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
2026-08-08 14:36:08 +02:00

190 lines
6.3 KiB
TypeScript

/**
* What the vaccination routes share: the query shapes, the ownership probes,
* the domain redaction, and the link application.
*
* Extracted for the same reason `src/lib/encounters/service.ts` is: four route
* files need the same include, the same "does the caller own this practice"
* question and the same seam handling, and four copies of a redaction rule is
* three chances to get it wrong.
*/
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";
import type { VaccinationDocumentDTO } from "@/lib/vaccinations/dto";
import {
deriveSeries,
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,
encounter: { select: { id: true, occurredAt: true, kind: true } },
} as const satisfies Prisma.VaccinationRecordInclude;
/** What the series derivation needs, and nothing more. */
export const SERIES_SELECT = {
id: true,
occurredAt: true,
antigenSlug: true,
doseNumber: true,
seriesDoses: true,
} as const satisfies Prisma.VaccinationRecordSelect;
/**
* Which sections of the record the caller is actually inside.
*
* Returns a predicate rather than a set so the owner path costs nothing: with
* no grant there is nothing to look up and every domain is open. A grant that
* vanished between the auth check and here answers "no" to everything, which
* is the fail-closed direction. Same shape as the visits service, deliberately
* — the two features sit on the same seam and a second spelling of this would
* be a second thing to keep in step.
*/
export async function actingDomainVisibility(
tx: Prisma.TransactionClient,
grantId: string | null,
): Promise<(domain: ShareDomain) => boolean> {
if (grantId === null) return () => true;
const grant = await tx.accountGrant.findUnique({
where: { id: grantId },
select: { scopeJson: true },
});
if (!grant) return () => false;
return (domain) => grantCoversDomain(grant, domain);
}
/**
* The whole live history for one account, in the shape the derivation wants.
*
* The series is a property of the set, so every read that publishes a position
* loads the set — including the detail route, which holds one row and cannot
* answer the question from it. The rows are small (five scalar columns) and an
* Impfpass is a short document even after a long life, so this is cheaper than
* the cache that would keep it consistent.
*/
export async function loadSeriesHistory(
tx: Prisma.TransactionClient,
userId: string,
): Promise<SeriesInputRecord[]> {
return tx.vaccinationRecord.findMany({
where: { userId, deletedAt: null },
orderBy: { occurredAt: "asc" },
select: SERIES_SELECT,
});
}
/** Every record's resolved series positions, keyed by record id. */
export async function resolveSeriesFor(
tx: Prisma.TransactionClient,
userId: string,
) {
return deriveSeries(await loadSeriesHistory(tx, userId));
}
/**
* The linked pages, resolved and redacted against the caller's reach.
*
* A dose lives in the health background and points into the document vault. A
* grant that opened the background was never consent for the vault, and while
* no document CONTENT crosses the seam here, the filename is itself the
* sensitive part: a page called "oncology discharge letter" says most of what
* the person was worried about.
*
* So the label and the date are withheld and the LINK is not: the caller may
* know the dose was transcribed from a page, because the dose is theirs to
* read. `label: null` rather than an invented placeholder, so an absence reads
* as an absence, and `redacted` says which of the two nulls this is.
*/
export async function loadVaccinationDocuments(
tx: Prisma.TransactionClient,
userId: string,
vaccinationId: string,
visible: (domain: ShareDomain) => boolean,
): Promise<VaccinationDocumentDTO[]> {
const rows = await listTargets(tx, {
userId,
sourceKind: "vaccination",
sourceId: vaccinationId,
targetKind: "document",
});
if (visible("documents")) {
return rows.map((row) => ({ ...row, redacted: false }));
}
return rows.map((row) => ({
id: row.id,
label: null,
date: null,
redacted: true,
}));
}
/**
* Apply the document array, when the request named one.
*
* An absent array leaves the links alone; a present one replaces them, empty
* array included. Unknown ids are dropped by the link service and never block
* the save — a dose is never refused because a page it pointed at has since
* been deleted.
*/
export async function applyVaccinationLinks(
tx: Prisma.TransactionClient,
userId: string,
vaccinationId: string,
documentIds: string[] | undefined,
): Promise<void> {
if (documentIds === undefined) return;
await replaceTargets(tx, {
userId,
sourceKind: "vaccination",
sourceId: vaccinationId,
targetKind: "document",
targetIds: documentIds,
});
}
/** Does the caller own a live practitioner with this id? */
export async function resolveOwnedPractitioner(
tx: Prisma.TransactionClient,
userId: string,
practitionerId: string,
): Promise<{ id: string } | null> {
return tx.practitioner.findFirst({
where: { id: practitionerId, userId, deletedAt: null },
select: { id: true },
});
}
/**
* 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,
userId: string,
encounterId: string,
): Promise<{ id: string } | null> {
return tx.encounter.findFirst({
where: { id: encounterId, userId, deletedAt: null },
select: { id: true },
});
}