From 6ab72aec3efad32c56a5b8210dab98d0cf023e12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 11:40:02 +0200 Subject: [PATCH 1/2] Fold Health Connect heart rate like Apple Health The nightly dense-intraday fold scanned Apple Health rows only, so the per-minute heart rate a Health Connect import writes for the last 90 days never aged into hourly means and every new export added another quarter of raw samples. The fold now walks each fold source in turn, Apple Health first and unchanged, and folds Health Connect samples into hourly stats: rows under their own source, deleting the raw rows outright. The folded_window rule covers both sources for the hour window and looks a covering row up under the sample's own source; the day window stays Apple Health only. The Health Connect importer applies the same rule to raw samples, so an export that still holds folded minutes does not put them back. --- docs/integrations/health-connect.md | 6 + src/lib/import/health-connect/import.ts | 46 +- src/lib/jobs/dense-intraday-retention.ts | 7 +- .../dense-intraday-pulse-summary.test.ts | 8 +- .../dense-intraday-resting-mint.test.ts | 9 +- .../dense-intraday-retention.test.ts | 30 +- .../dense-intraday-spo2-summary.test.ts | 9 +- .../__tests__/folded-window.test.ts | 77 +++ .../measurements/dense-intraday-retention.ts | 516 ++++++++++-------- src/lib/measurements/folded-window.ts | 105 ++-- .../dense-intraday-retention.test.ts | 85 +++ .../integration/health-connect-import.test.ts | 48 ++ 12 files changed, 649 insertions(+), 297 deletions(-) diff --git a/docs/integrations/health-connect.md b/docs/integrations/health-connect.md index 5ebf70738..df370ea98 100644 --- a/docs/integrations/health-connect.md +++ b/docs/integrations/health-connect.md @@ -135,6 +135,12 @@ export again writes nothing; a newer export that overlaps an older one adds only what is new. A value that changed on the phone is updated; a value you deleted in HealthLog stays deleted. +Heart rate imported per minute ages the way Apple Health heart rate +does: once a minute is older than 90 days, the nightly pass folds its +hour into one hourly mean and removes the minutes. A later import of +an export that still holds those minutes leaves them out, so an hour +is never counted twice. + ## Privacy - The uploaded ZIP and the extracted database are deleted as soon as diff --git a/src/lib/import/health-connect/import.ts b/src/lib/import/health-connect/import.ts index 320f27745..ec20132fc 100644 --- a/src/lib/import/health-connect/import.ts +++ b/src/lib/import/health-connect/import.ts @@ -19,8 +19,9 @@ * would count a step the phone and the watch both saw twice. * - Heart rate: per-minute samples of the last 90 days as raw rows, older * ones as hourly means (min and max kept), one app per hour by the vitals - * priority. The Apple Health retention fold only folds Apple Health rows, - * so this import folds its own history as it writes it. + * priority. The nightly retention fold folds the raw samples into the + * same hourly `stats:` rows once they age past 90 days, and a raw sample + * whose hour is already folded is left out (`folded_window`). * - Sleep: one row per stage (`sleep-stage-map.ts`), a session without * stages as one asleep row; of two overlapping sessions from different * apps the higher-ranked app's is kept. @@ -58,6 +59,12 @@ import { valuesMatch, } from "@/lib/measurements/cross-source-merge"; import { reconcileExternalMeasurement } from "@/lib/measurements/reconcile-external-measurement"; +import { + FOLDED_WINDOW_REASON, + findFoldedWindowDuplicates, + isFoldedWindowCandidate, + loadFoldTimezone, +} from "@/lib/measurements/folded-window"; import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz"; import { validateMeasurementRange } from "@/lib/validations/measurement"; import { isModuleEnabled } from "@/lib/modules/gate"; @@ -214,6 +221,8 @@ class ImportRun { private readonly userId: string; private readonly tz: string; private readonly now: Date; + /** The account zone the fold keys its hours in, read once on first need. */ + private foldTz: string | undefined; private readonly apps = new Map(); private readonly leftOutApps = new Set(); private readonly devices = new Map(); @@ -1335,6 +1344,7 @@ class ImportRun { }); if (sameReadingCheck) batch = await this.withoutOtherSourceTwins(batch); if (hourlyPulse) batch = await this.withoutRawPulseHours(batch); + else batch = await this.withoutFoldedHours(batch); if (batch.length === 0) return; const inserted = await insertNewMeasurementRows( @@ -1521,6 +1531,38 @@ class ImportRun { }); } + /** + * A raw sample of a folded type is left out when the nightly fold has + * already turned its hour into a `stats:` mean under this source. The fold + * deletes the samples it summarises, so without this a later import of an + * export that still holds them would put them back beside their own mean. + * Same rule as the Apple Health batch route and ZIP import + * (`folded-window.ts`); rows too young to have been folded cost no query. + */ + private async withoutFoldedHours(rows: PendingRow[]): Promise { + if (rows.length === 0) return rows; + const candidates = rows.map((row) => ({ + type: row.type, + source: SOURCE, + externalId: row.externalId, + measuredAt: row.measuredAt, + })); + if (!candidates.some((row) => isFoldedWindowCandidate(row))) return rows; + this.foldTz ??= await loadFoldTimezone(this.prisma, this.userId); + const folded = await findFoldedWindowDuplicates( + this.prisma, + this.userId, + candidates, + { tz: this.foldTz }, + ); + if (folded.size === 0) return rows; + return rows.filter((row, index) => { + if (!folded.has(index)) return true; + this.skip(FOLDED_WINDOW_REASON, 1, row.type); + return false; + }); + } + /** Called by the collapse when two apps write the same instant. */ noteCollision(type: string): void { this.skip("same_instant_other_app", 1, type); diff --git a/src/lib/jobs/dense-intraday-retention.ts b/src/lib/jobs/dense-intraday-retention.ts index a0c52a11a..7cbcdfad1 100644 --- a/src/lib/jobs/dense-intraday-retention.ts +++ b/src/lib/jobs/dense-intraday-retention.ts @@ -25,6 +25,7 @@ import { runDenseIntradayRetention, DENSE_INTRADAY_RETENTION_TYPES, DENSE_INTRADAY_RETENTION_DAYS, + DENSE_INTRADAY_FOLD_SOURCES, } from "@/lib/measurements/dense-intraday-retention"; export const DENSE_INTRADAY_RETENTION_QUEUE = "dense-intraday-retention"; @@ -140,8 +141,8 @@ export async function runDenseIntradayRetentionForUser( /** * Boot-time discovery. Finds every user holding at least one LIVE - * per-sample dense-tier row (an `APPLE_HEALTH` row of a - * `DENSE_INTRADAY_RETENTION_TYPES` type whose externalId does NOT start + * per-sample dense-tier row (a row of a `DENSE_INTRADAY_FOLD_SOURCES` source + * and a `DENSE_INTRADAY_RETENTION_TYPES` type whose externalId does NOT start * with the daily-stats prefix, that is not tombstoned, AND whose * `measuredAt` is older than the retention window) and enqueues one * retention job per account. @@ -187,7 +188,7 @@ export async function enqueueBootTimeDenseIntradayRetention(): Promise<{ const users = await prisma.measurement.groupBy({ by: ["userId"], where: { - source: "APPLE_HEALTH", + source: { in: [...DENSE_INTRADAY_FOLD_SOURCES] }, type: { in: types }, deletedAt: null, NOT: { externalId: { startsWith: "stats:" } }, diff --git a/src/lib/measurements/__tests__/dense-intraday-pulse-summary.test.ts b/src/lib/measurements/__tests__/dense-intraday-pulse-summary.test.ts index aee9a1c02..714fd0819 100644 --- a/src/lib/measurements/__tests__/dense-intraday-pulse-summary.test.ts +++ b/src/lib/measurements/__tests__/dense-intraday-pulse-summary.test.ts @@ -84,8 +84,12 @@ function buildPrismaMock(opts: { }; // Top-level client. - const findMany = vi.fn(async (args: { where: { type: string } }) => - args.where.type === "PULSE" ? opts.pulseRows : [], + // The seeded rows are Apple Health's; the Health Connect walk finds none. + const findMany = vi.fn( + async (args: { where: { type: string; source: string } }) => + args.where.type === "PULSE" && args.where.source === "APPLE_HEALTH" + ? opts.pulseRows + : [], ); // Native-resting probe is the only top-level `findFirst` call. const topFindFirst = vi diff --git a/src/lib/measurements/__tests__/dense-intraday-resting-mint.test.ts b/src/lib/measurements/__tests__/dense-intraday-resting-mint.test.ts index e72ae5db1..9c9c0ce2a 100644 --- a/src/lib/measurements/__tests__/dense-intraday-resting-mint.test.ts +++ b/src/lib/measurements/__tests__/dense-intraday-resting-mint.test.ts @@ -106,8 +106,13 @@ function buildPrismaMock(existingResting: Array<{ source: string }>) { .mockResolvedValue([{ id: "user-1", timezone: "Europe/Berlin" }]), }, measurement: { - findMany: vi.fn(async (args: { where: { type: string } }) => - args.where.type === "PULSE" ? pulseRows() : [], + // The seeded rows are Apple Health's; the Health Connect walk + // finds none. + findMany: vi.fn( + async (args: { where: { type: string; source: string } }) => + args.where.type === "PULSE" && args.where.source === "APPLE_HEALTH" + ? pulseRows() + : [], ), findFirst: probeFindFirst, upsert, diff --git a/src/lib/measurements/__tests__/dense-intraday-retention.test.ts b/src/lib/measurements/__tests__/dense-intraday-retention.test.ts index 740f40d21..e660d5731 100644 --- a/src/lib/measurements/__tests__/dense-intraday-retention.test.ts +++ b/src/lib/measurements/__tests__/dense-intraday-retention.test.ts @@ -62,9 +62,12 @@ function buildPrismaMock( ); const updateMany = vi.fn().mockResolvedValue({ count: 0 }); const deleteMany = vi.fn().mockResolvedValue({ count: 0 }); + // The seeded rows are Apple Health's; the Health Connect walk finds none. const findManyMeasurement = vi.fn( - async (args: { where: { type: string } }) => - rowsByType[args.where.type] ?? [], + async (args: { where: { type: string; source: string } }) => + args.where.source === "APPLE_HEALTH" + ? (rowsByType[args.where.type] ?? []) + : [], ); const tx = { measurement: { @@ -140,20 +143,21 @@ describe("runDenseIntradayRetention — retention bound", () => { } }); - it("scans exactly the dense-tier types, source-scoped to APPLE_HEALTH", async () => { + it("scans exactly the dense-tier types, once per fold source, Apple Health first", async () => { const { mock, findManyMeasurement } = buildPrismaMock({}); await runDenseIntradayRetention(mock, { log: () => {} }); - const scannedTypes = findManyMeasurement.mock.calls.map( - (c) => (c[0] as { where: { type: string } }).where.type, - ); - expect(scannedTypes.sort()).toEqual( - Array.from(DENSE_INTRADAY_RETENTION_TYPES).sort(), - ); - for (const call of findManyMeasurement.mock.calls) { - const where = (call[0] as unknown as { where: { source: string } }).where; - expect(where.source).toBe("APPLE_HEALTH"); - } + const scans = findManyMeasurement.mock.calls.map((c) => { + const where = (c[0] as { where: { type: string; source: string } }).where; + return `${where.source}:${where.type}`; + }); + const types = Array.from(DENSE_INTRADAY_RETENTION_TYPES); + // One walk per source, each scoped to its one source: two sources + // never share a mean, and manual or Withings rows are never scanned. + expect(scans).toEqual([ + ...types.map((type) => `APPLE_HEALTH:${type}`), + ...types.map((type) => `HEALTH_CONNECT:${type}`), + ]); }); }); diff --git a/src/lib/measurements/__tests__/dense-intraday-spo2-summary.test.ts b/src/lib/measurements/__tests__/dense-intraday-spo2-summary.test.ts index 6ae4152e5..23c4e2fa7 100644 --- a/src/lib/measurements/__tests__/dense-intraday-spo2-summary.test.ts +++ b/src/lib/measurements/__tests__/dense-intraday-spo2-summary.test.ts @@ -75,8 +75,13 @@ function buildPrismaMock(opts: { spo2Rows: PerSampleRow[] }) { }, }; - const findMany = vi.fn(async (args: { where: { type: string } }) => - args.where.type === "OXYGEN_SATURATION" ? opts.spo2Rows : [], + // The seeded rows are Apple Health's; the Health Connect walk finds none. + const findMany = vi.fn( + async (args: { where: { type: string; source: string } }) => + args.where.type === "OXYGEN_SATURATION" && + args.where.source === "APPLE_HEALTH" + ? opts.spo2Rows + : [], ); // Native-resting probe + derived-resting upsert: PULSE-only surfaces. const topFindFirst = vi.fn().mockResolvedValue(null); diff --git a/src/lib/measurements/__tests__/folded-window.test.ts b/src/lib/measurements/__tests__/folded-window.test.ts index f75359642..4c9bc9b0d 100644 --- a/src/lib/measurements/__tests__/folded-window.test.ts +++ b/src/lib/measurements/__tests__/folded-window.test.ts @@ -226,3 +226,80 @@ describe("findCompactionTombstones", () => { expect(found.size).toBe(0); }); }); + +/** + * Health Connect folds its raw heart-rate history the way Apple Health does: + * past the 90-day window into one `stats:` row per local hour, under its own + * source. The mean-day rule stays Apple Health only, because the nightly + * mean consolidation never reads another source. + */ +describe("Health Connect", () => { + const old = new Date("2026-03-01T10:15:00.000Z"); + const hc = ( + type: string, + at: Date, + overrides: Record = {}, + ) => + raw(type, at, { + source: "HEALTH_CONNECT", + externalId: "hc:00000000-0000-0000-0000-000000000001:1", + ...overrides, + }); + + /** A lookup that answers per source, as the database does. */ + function sourceAwareClient( + live: Array<{ type: string; source: string; externalId: string }>, + ) { + const findMany = vi.fn(async (args: { where: { source: string } }) => + live.filter((row) => row.source === args.where.source), + ); + return { + findMany, + client: { + measurement: { findMany }, + } as unknown as Parameters[0], + }; + } + + it("takes a raw dense sample past the window, and not a mean-type one", () => { + expect(isFoldedWindowCandidate(hc("PULSE", old), NOW)).toBe(true); + expect( + isFoldedWindowCandidate( + hc("PULSE", new Date(NOW.getTime() - 89 * DAY)), + NOW, + ), + ).toBe(false); + expect(isFoldedWindowCandidate(hc("RESPIRATORY_RATE", old), NOW)).toBe( + false, + ); + expect( + isFoldedWindowCandidate( + hc("PULSE", old, { + externalId: "stats:HKQuantityTypeIdentifierHeartRate:2026-03-01T10", + }), + NOW, + ), + ).toBe(false); + }); + + it("is covered only by a `stats:` row of its own source", async () => { + const hour = "stats:HKQuantityTypeIdentifierHeartRate:2026-03-01T10"; + const { client: c, findMany } = sourceAwareClient([ + { type: "PULSE", source: "HEALTH_CONNECT", externalId: hour }, + ]); + const found = await findFoldedWindowDuplicates( + c, + "u1", + [hc("PULSE", old), raw("PULSE", old)], + { now: NOW, tz: "UTC" }, + ); + // The Health Connect sample is a duplicate; the Apple Health sample of + // the same hour is not, because no Apple Health `stats:` row covers it. + expect([...found]).toEqual([0]); + const sources = findMany.mock.calls.map( + (call) => + (call as unknown as [{ where: { source: string } }])[0].where.source, + ); + expect(sources.sort()).toEqual(["APPLE_HEALTH", "HEALTH_CONNECT"]); + }); +}); diff --git a/src/lib/measurements/dense-intraday-retention.ts b/src/lib/measurements/dense-intraday-retention.ts index f13ec2fb9..1cbf1ab9d 100644 --- a/src/lib/measurements/dense-intraday-retention.ts +++ b/src/lib/measurements/dense-intraday-retention.ts @@ -50,6 +50,7 @@ */ import { holdAccountAgainstRestore } from "@/lib/export/restore-lock"; import type { + MeasurementSource, MeasurementType, Prisma, PrismaClient, @@ -154,6 +155,23 @@ export const DENSE_INTRADAY_RETENTION_TYPES: ReadonlySet = */ export const DENSE_INTRADAY_RETENTION_DAYS = 90; +/** + * The sources whose raw dense samples fold (v1.42). Apple Health from the + * start; Health Connect because its importer writes the samples of the last + * 90 days raw, exactly like an Apple Health sync, and without a fold every + * new export would add another quarter of minute-by-minute heart rate that + * never ages into hourly means. Each source folds into its OWN `stats:` rows, + * so an hour two sources both covered keeps one mean per source, the same + * way the raw samples kept their source. + */ +export const DENSE_INTRADAY_FOLD_SOURCES = [ + "APPLE_HEALTH", + "HEALTH_CONNECT", +] as const satisfies readonly MeasurementSource[]; + +export type DenseIntradayFoldSource = + (typeof DENSE_INTRADAY_FOLD_SOURCES)[number]; + /** The stats externalId prefix marks an already-collapsed row. */ const DAILY_STATS_PREFIX = "stats:"; @@ -249,10 +267,13 @@ export async function adoptOrMintHourlyRows( input: { userId: string; type: MeasurementType; + /** The source the folded samples carry; the hourly rows carry it too. */ + source?: DenseIntradayFoldSource; unit: string; slots: readonly HourlySlot[]; }, ): Promise { + const source = input.source ?? "APPLE_HEALTH"; if (input.slots.length === 0) return []; // Every row that either index could put in the way, lowest id first — the @@ -263,7 +284,7 @@ export async function adoptOrMintHourlyRows( where: { userId: input.userId, type: input.type, - source: "APPLE_HEALTH", + source, OR: [ { externalId: { in: input.slots.map((slot) => slot.externalId) } }, { @@ -338,7 +359,7 @@ export async function adoptOrMintHourlyRows( type: input.type, value: slot.value, unit: input.unit, - source: "APPLE_HEALTH" as const, + source, measuredAt: slot.anchor, externalId: slot.externalId, })), @@ -538,263 +559,280 @@ export async function runDenseIntradayRetention( // for the dry-run hourly fan-out estimate (writeDay never runs on dry-run). let currentTz = DEFAULT_TIMEZONE; - const walk = await runConsolidation({ - prismaClient, - options: { ...options, cutoffHours }, - types: DENSE_INTRADAY_RETENTION_TYPES, - hkIdentifierForType, - dailyStatsExternalId, - statsPrefix: DAILY_STATS_PREFIX, - // The base's per-day reducedValue is the day MEAN; the hourly fold - // recomputes per-hour means itself (same `meanBucketValue` reducer, so - // the two surfaces can never drift), and the day-level value only - // feeds the summary/dry-run reporting. - reduce: meanBucketValue, - // Live per-sample rows for the type, source-scoped to APPLE_HEALTH so - // manual + Withings spot rows survive. The minted hourly stats rows are - // excluded by the NOT-startsWith predicate so they are never re-folded; - // soft-deleted rows are excluded so a re-run converges. `cutoffAt` is - // the retention boundary — rows newer than it (inside the window) are - // NOT scanned, so the dense intra-day shape is preserved. - buildScanWhere: ({ userId, type, cutoffAt, statsPrefix }) => ({ - userId, - source: "APPLE_HEALTH", - type, - deletedAt: null, - NOT: { externalId: { startsWith: statsPrefix } }, - ...(cutoffAt ? { measuredAt: { lt: cutoffAt } } : {}), - }), - scanSelect: { - id: true, - type: true, - value: true, - measuredAt: true, - externalId: true, - unit: true, - }, - writeDay: async ({ - prismaClient: pc, - userId, - type, - externalId, - canonicalTimestamp, - dayRows, - sourceRowIds, - tz, - dateKey, - }): Promise => { - const unit = dayRows[0]?.unit ?? "unknown"; - const isPulse = type === "PULSE"; - const hkIdentifier = hkIdentifierForType(type); - // Unreachable: the base skips types without an HK identifier before - // scanning; asserted so a future type-set edit fails loud, not by - // minting a malformed externalId. - if (!hkIdentifier) { - throw new Error(`no HK identifier for dense-tier type ${type}`); - } + // Awaited inside, not returned bare: the restore-lock guard finds the + // consolidation passes by their `await runConsolidation`. + const walkSource = async (source: DenseIntradayFoldSource) => + await runConsolidation({ + prismaClient, + options: { ...options, cutoffHours }, + types: DENSE_INTRADAY_RETENTION_TYPES, + hkIdentifierForType, + dailyStatsExternalId, + statsPrefix: DAILY_STATS_PREFIX, + // The base's per-day reducedValue is the day MEAN; the hourly fold + // recomputes per-hour means itself (same `meanBucketValue` reducer, so + // the two surfaces can never drift), and the day-level value only + // feeds the summary/dry-run reporting. + reduce: meanBucketValue, + // Live per-sample rows for the type, scoped to the one source this walk + // folds so manual + Withings spot rows survive and two sources never + // share a mean. The minted hourly stats rows are + // excluded by the NOT-startsWith predicate so they are never re-folded; + // soft-deleted rows are excluded so a re-run converges. `cutoffAt` is + // the retention boundary — rows newer than it (inside the window) are + // NOT scanned, so the dense intra-day shape is preserved. + buildScanWhere: ({ userId, type, cutoffAt, statsPrefix }) => ({ + userId, + source, + type, + deletedAt: null, + NOT: { externalId: { startsWith: statsPrefix } }, + ...(cutoffAt ? { measuredAt: { lt: cutoffAt } } : {}), + }), + scanSelect: { + id: true, + type: true, + value: true, + measuredAt: true, + externalId: true, + unit: true, + }, + writeDay: async ({ + prismaClient: pc, + userId, + type, + externalId, + canonicalTimestamp, + dayRows, + sourceRowIds, + tz, + dateKey, + }): Promise => { + const unit = dayRows[0]?.unit ?? "unknown"; + const isPulse = type === "PULSE"; + const hkIdentifier = hkIdentifierForType(type); + // Unreachable: the base skips types without an HK identifier before + // scanning; asserted so a future type-set edit fails loud, not by + // minting a malformed externalId. + if (!hkIdentifier) { + throw new Error(`no HK identifier for dense-tier type ${type}`); + } - // DAY-rollup fidelity — mean AND min/max (all dense-tier types). The - // persistent `MeasurementRollup` DAY bucket aggregates LIVE rows; - // recomputing it AFTER the fold would aggregate the hourly means: the - // min/max collapse to the extreme hourly MEANS (for SpO2 hiding the - // overnight-desaturation nadir; for PULSE the resting floor + workout - // peak) and the mean becomes an unweighted mean-of-hourly-means that - // drifts whenever per-hour sample counts differ. So the DAY bucket is - // recomputed from the RAW rows BEFORE the fold, while they are still - // live, and the post-fold DAY recompute is skipped entirely. The - // WEEK/MONTH/YEAR recomputes this call enqueues run post-commit - // against whatever is live then, as before. - await recomputeBucketsForMeasurement(userId, type, canonicalTimestamp); + // DAY-rollup fidelity — mean AND min/max (all dense-tier types). The + // persistent `MeasurementRollup` DAY bucket aggregates LIVE rows; + // recomputing it AFTER the fold would aggregate the hourly means: the + // min/max collapse to the extreme hourly MEANS (for SpO2 hiding the + // overnight-desaturation nadir; for PULSE the resting floor + workout + // peak) and the mean becomes an unweighted mean-of-hourly-means that + // drifts whenever per-hour sample counts differ. So the DAY bucket is + // recomputed from the RAW rows BEFORE the fold, while they are still + // live, and the post-fold DAY recompute is skipped entirely. The + // WEEK/MONTH/YEAR recomputes this call enqueues run post-commit + // against whatever is live then, as before. + await recomputeBucketsForMeasurement(userId, type, canonicalTimestamp); - // Fold the day into per-LOCAL-HOUR mean rows, anchored at local - // HH:30. Sorted for a deterministic write order across runs. - const byHour = [...bucketRowsByLocalHour(dayRows, tz).entries()].sort( - (a, b) => a[0] - b[0], - ); + // Fold the day into per-LOCAL-HOUR mean rows, anchored at local + // HH:30. Sorted for a deterministic write order across runs. + const byHour = [...bucketRowsByLocalHour(dayRows, tz).entries()].sort( + (a, b) => a[0] - b[0], + ); - const foldOnce = async (): Promise<{ - removed: number; - retiredDaily: boolean; - }> => { - let removed = 0; - let retiredDaily = false; - await pc.$transaction(async (tx) => { - // First, before any reading is touched: see `restore-lock.ts`. - await holdAccountAgainstRestore(tx, userId); - const canonicalRowIds = await adoptOrMintHourlyRows(tx, { - userId, - type, - unit, - slots: byHour.map(([hour, hourRows]) => ({ - externalId: hourlyStatsExternalId(hkIdentifier, dateKey, hour), - anchor: canonicalHourlyTimestamp(dateKey, hour, tz), - value: meanBucketValue(hourRows), - })), - }); - - // Retire a live pre-hourly DAILY `stats:` row for this day (the - // late-sync path: raw samples arriving for a day folded to the - // daily grain before v1.28.31). Same transaction as the hourly - // mint — an AVG-over-live-rows reader must never see the daily - // row and the hourly rows live at once (double count). - const dailyRow = await tx.measurement.findFirst({ - where: { + const foldOnce = async (): Promise<{ + removed: number; + retiredDaily: boolean; + }> => { + let removed = 0; + let retiredDaily = false; + await pc.$transaction(async (tx) => { + // First, before any reading is touched: see `restore-lock.ts`. + await holdAccountAgainstRestore(tx, userId); + const canonicalRowIds = await adoptOrMintHourlyRows(tx, { userId, type, - source: "APPLE_HEALTH", - externalId, - deletedAt: null, - }, - select: { id: true }, - orderBy: { id: "asc" }, - }); - if (dailyRow && !canonicalRowIds.includes(dailyRow.id)) { - await tx.measurement.update({ - where: { id: dailyRow.id }, - data: { deletedAt: new Date() }, + source, + unit, + slots: byHour.map(([hour, hourRows]) => ({ + externalId: hourlyStatsExternalId(hkIdentifier, dateKey, hour), + anchor: canonicalHourlyTimestamp(dateKey, hour, tz), + value: meanBucketValue(hourRows), + })), }); - retiredDaily = true; - } - // Delete the out-of-window per-sample rows in the same - // transaction. EXCLUDE the adopted canonical rows: a per-sample row - // that happened to fall on an hourly anchor is the row just adopted - // as that hour's mean, so removing it would erase the fold. - // - // v1.42 — deleted outright, like the cumulative drain's rows, - // where they used to be tombstoned for the 75-day retention. No - // client ever pulled these rows, so the tombstone told nobody - // anything; it only held the re-upload of a folded sample off, - // and the `folded_window` ingest guard (`folded-window.ts`) does - // that now from the live hourly row. The tombstones made up most - // of the table and its indexes. - const del = await tx.measurement.deleteMany({ - where: { - id: { in: sourceRowIds, notIn: canonicalRowIds }, - deletedAt: null, - }, + // Retire a live pre-hourly DAILY `stats:` row for this day (the + // late-sync path: raw samples arriving for a day folded to the + // daily grain before v1.28.31). Same transaction as the hourly + // mint — an AVG-over-live-rows reader must never see the daily + // row and the hourly rows live at once (double count). + const dailyRow = await tx.measurement.findFirst({ + where: { + userId, + type, + source, + externalId, + deletedAt: null, + }, + select: { id: true }, + orderBy: { id: "asc" }, + }); + if (dailyRow && !canonicalRowIds.includes(dailyRow.id)) { + await tx.measurement.update({ + where: { id: dailyRow.id }, + data: { deletedAt: new Date() }, + }); + retiredDaily = true; + } + + // Delete the out-of-window per-sample rows in the same + // transaction. EXCLUDE the adopted canonical rows: a per-sample row + // that happened to fall on an hourly anchor is the row just adopted + // as that hour's mean, so removing it would erase the fold. + // + // v1.42 — deleted outright, like the cumulative drain's rows, + // where they used to be tombstoned for the 75-day retention. No + // client ever pulled these rows, so the tombstone told nobody + // anything; it only held the re-upload of a folded sample off, + // and the `folded_window` ingest guard (`folded-window.ts`) does + // that now from the live hourly row. The tombstones made up most + // of the table and its indexes. + const del = await tx.measurement.deleteMany({ + where: { + id: { in: sourceRowIds, notIn: canonicalRowIds }, + deletedAt: null, + }, + }); + removed = del.count; }); - removed = del.count; - }); - return { removed, retiredDaily }; - }; + return { removed, retiredDaily }; + }; - let foldResult: { removed: number; retiredDaily: boolean }; - try { - foldResult = await foldOnce(); - } catch (err) { - if (!isUniqueConstraintViolation(err)) throw err; - // A concurrent writer won a slot mid-transaction. Retry once: the - // deterministic lookup now resolves the winning row and adopts it, - // so this pass cannot create a duplicate. - foldResult = await foldOnce(); - } - // Counters mutate OUTSIDE the retryable transaction so a P2002 retry - // cannot double-count. - summary.totals.hourlyRowsUpserted += byHour.length; - if (foldResult.retiredDaily) summary.totals.dailyRowsRetired += 1; + let foldResult: { removed: number; retiredDaily: boolean }; + try { + foldResult = await foldOnce(); + } catch (err) { + if (!isUniqueConstraintViolation(err)) throw err; + // A concurrent writer won a slot mid-transaction. Retry once: the + // deterministic lookup now resolves the winning row and adopts it, + // so this pass cannot create a duplicate. + foldResult = await foldOnce(); + } + // Counters mutate OUTSIDE the retryable transaction so a P2002 retry + // cannot double-count. + summary.totals.hourlyRowsUpserted += byHour.length; + if (foldResult.retiredDaily) summary.totals.dailyRowsRetired += 1; - // iOS#34 — preserve the resting signal for PROXY users. For a user - // with zero native `RESTING_HEART_RATE` rows, the read-path resolver - // derives resting from the 20th-percentile of each day's RAW PULSE; the - // fold has just deleted those rows, so that day's resting figure would - // vanish (the hourly means flatten the low tail the percentile reads). - // Mint one derived `RESTING_HEART_RATE` row from the same percentile, - // sourced `COMPUTED` (distinct from Apple's own resting rows on both - // unique indexes), so the day reads back as the clean resting series - // the resolver prefers. Skipped for users WITH native resting rows - // (the resolver ignores the proxy entirely for them, so a derived row - // would only double-count). - if (isPulse) { - const restingValue = deriveDailyRestingFromPulse(dayRows); - if (restingValue !== null && !(await userHasNativeResting(userId))) { - const restingExternalId = dailyStatsExternalId( - RESTING_HK_IDENTIFIER, - // The fold's canonical timestamp is local-noon of the day; reuse - // its UTC date for the resting row's `stats:` key so a re-run - // upserts the same row instead of minting a sibling. - // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: stable stats: externalId key; changing it would mint a sibling row on re-run - canonicalTimestamp.toISOString().slice(0, 10), - ); - await pc.measurement.upsert({ - where: { - userId_type_source_externalId: { + // iOS#34 — preserve the resting signal for PROXY users. For a user + // with zero native `RESTING_HEART_RATE` rows, the read-path resolver + // derives resting from the 20th-percentile of each day's RAW PULSE; the + // fold has just deleted those rows, so that day's resting figure would + // vanish (the hourly means flatten the low tail the percentile reads). + // Mint one derived `RESTING_HEART_RATE` row from the same percentile, + // sourced `COMPUTED` (distinct from Apple's own resting rows on both + // unique indexes), so the day reads back as the clean resting series + // the resolver prefers. Skipped for users WITH native resting rows + // (the resolver ignores the proxy entirely for them, so a derived row + // would only double-count). + if (isPulse) { + const restingValue = deriveDailyRestingFromPulse(dayRows); + if (restingValue !== null && !(await userHasNativeResting(userId))) { + const restingExternalId = dailyStatsExternalId( + RESTING_HK_IDENTIFIER, + // The fold's canonical timestamp is local-noon of the day; reuse + // its UTC date for the resting row's `stats:` key so a re-run + // upserts the same row instead of minting a sibling. + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: stable stats: externalId key; changing it would mint a sibling row on re-run + canonicalTimestamp.toISOString().slice(0, 10), + ); + await pc.measurement.upsert({ + where: { + userId_type_source_externalId: { + userId, + type: "RESTING_HEART_RATE", + source: "COMPUTED", + externalId: restingExternalId, + }, + }, + create: { userId, type: "RESTING_HEART_RATE", + value: restingValue, + unit: "bpm", source: "COMPUTED", + measuredAt: canonicalTimestamp, externalId: restingExternalId, }, - }, - create: { + update: { value: restingValue, deletedAt: null }, + }); + summary.totals.derivedRestingRowsUpserted += 1; + // The derived row is its own (type, day); recompute its DAY bucket + // so the rollup tier serves it on a covered read. + await recomputeBucketsForMeasurement( userId, - type: "RESTING_HEART_RATE", - value: restingValue, - unit: "bpm", - source: "COMPUTED", - measuredAt: canonicalTimestamp, - externalId: restingExternalId, - }, - update: { value: restingValue, deletedAt: null }, - }); - summary.totals.derivedRestingRowsUpserted += 1; - // The derived row is its own (type, day); recompute its DAY bucket - // so the rollup tier serves it on a covered read. - await recomputeBucketsForMeasurement( - userId, - "RESTING_HEART_RATE", - canonicalTimestamp, - ); + "RESTING_HEART_RATE", + canonicalTimestamp, + ); + } } - } - // No post-fold DAY recompute for ANY dense-tier type — it would - // overwrite the true raw-derived mean/min/max the pre-fold recompute - // above captured with hourly-mean-derived approximations (see the - // fidelity note there). + // No post-fold DAY recompute for ANY dense-tier type — it would + // overwrite the true raw-derived mean/min/max the pre-fold recompute + // above captured with hourly-mean-derived approximations (see the + // fidelity note there). - return { kind: "written", sourceRowsRemoved: foldResult.removed }; - }, - recordBucket: ({ dayRows, outcome }) => { - summary.totals.daysConsolidated += 1; - if (outcome === null) { - // Dry-run: writeDay never ran, so estimate the hourly fan-out here - // from the same bucketing the real fold would use. - summary.totals.hourlyRowsUpserted += bucketRowsByLocalHour( - dayRows, - currentTz, - ).size; - } - summary.totals.perSampleRowsSoftDeleted += - outcome?.kind === "written" - ? outcome.sourceRowsRemoved - : dayRows.length; - }, - onUserStart: ({ tz }) => { - currentTz = tz; - }, - onUserComplete: ({ userId, tz, dryRun }) => { - log( - `[dense-intraday-retention] user=${userId} tz=${tz}${dryRun ? " (dry-run)" : ""}`, - ); - }, - // Per-day failure boundary (v1.18.10 lesson). A day whose fold throws — - // e.g. a residual unique-index collision the adopt-in-place path could - // not absorb, or a derived-resting upsert that races a native row — is - // logged and STEPPED OVER so the global walk keeps draining every other - // user / type / day. The failed day keeps its raw rows live (the fold - // deletes only on a committed transaction), so the next nightly run - // retries it. Without this, one poisoned day aborted the whole walk and - // stranded every later account. - onBucketError: ({ userId, type, dateKey, error }) => { - const reason = error instanceof Error ? error.message : String(error); - log( - `[dense-intraday-retention] user=${userId} type=${type} day=${dateKey} failed — ${reason}; continuing with the next bucket`, - ); - }, - }); + return { kind: "written", sourceRowsRemoved: foldResult.removed }; + }, + recordBucket: ({ dayRows, outcome }) => { + summary.totals.daysConsolidated += 1; + if (outcome === null) { + // Dry-run: writeDay never ran, so estimate the hourly fan-out here + // from the same bucketing the real fold would use. + summary.totals.hourlyRowsUpserted += bucketRowsByLocalHour( + dayRows, + currentTz, + ).size; + } + summary.totals.perSampleRowsSoftDeleted += + outcome?.kind === "written" + ? outcome.sourceRowsRemoved + : dayRows.length; + }, + onUserStart: ({ tz }) => { + currentTz = tz; + }, + onUserComplete: ({ userId, tz, dryRun }) => { + log( + `[dense-intraday-retention] user=${userId} source=${source} tz=${tz}${dryRun ? " (dry-run)" : ""}`, + ); + }, + // Per-day failure boundary (v1.18.10 lesson). A day whose fold throws — + // e.g. a residual unique-index collision the adopt-in-place path could + // not absorb, or a derived-resting upsert that races a native row — is + // logged and STEPPED OVER so the global walk keeps draining every other + // user / type / day. The failed day keeps its raw rows live (the fold + // deletes only on a committed transaction), so the next nightly run + // retries it. Without this, one poisoned day aborted the whole walk and + // stranded every later account. + onBucketError: ({ userId, type, dateKey, error }) => { + const reason = error instanceof Error ? error.message : String(error); + log( + `[dense-intraday-retention] user=${userId} source=${source} type=${type} day=${dateKey} failed — ${reason}; continuing with the next bucket`, + ); + }, + }); - summary.totals.usersScanned = walk.usersScanned; - summary.stoppedEarly = walk.stoppedEarly; + // One walk per source, Apple Health first so its behaviour and its order + // are exactly what they were before Health Connect folded too. A walk that + // stopped early stops the pass: the next run resumes where it left off. + for (const source of DENSE_INTRADAY_FOLD_SOURCES) { + const walk = await walkSource(source); + summary.totals.usersScanned = Math.max( + summary.totals.usersScanned, + walk.usersScanned, + ); + if (walk.stoppedEarly) { + summary.stoppedEarly = true; + break; + } + } log( `[dense-intraday-retention] done — usersScanned=${summary.totals.usersScanned} daysConsolidated=${summary.totals.daysConsolidated} perSampleRowsSoftDeleted=${summary.totals.perSampleRowsSoftDeleted} hourlyRowsUpserted=${summary.totals.hourlyRowsUpserted} dailyRowsRetired=${summary.totals.dailyRowsRetired} derivedRestingRowsUpserted=${summary.totals.derivedRestingRowsUpserted}${options.dryRun ? " (dry-run)" : ""}`, diff --git a/src/lib/measurements/folded-window.ts b/src/lib/measurements/folded-window.ts index 2aeff4167..b6fbdb227 100644 --- a/src/lib/measurements/folded-window.ts +++ b/src/lib/measurements/folded-window.ts @@ -1,14 +1,13 @@ /** * The `folded_window` ingest guard and the compaction-tombstone class (v1.42). * - * The dense-intraday consolidation folds the raw Apple Health samples of a - * DENSE type into one `stats:` row per local hour once they are older than + * The dense-intraday consolidation folds the raw samples of a DENSE type into one `stats:` row per local hour once they are older than * the retention window, and a MEAN type into one `stats:` row per local day * once the consolidation grace has passed. Since v1.42 the folded raw rows are * deleted outright rather than left as tombstones, so a client that re-uploads * an old sample after losing its anchor would put it back beside the hour or - * day that already accounts for it. A raw (non-`stats:`) Apple Health value of - * such a type, old enough to have been folded, is therefore a duplicate when a + * day that already accounts for it. A raw (non-`stats:`) value of such a source + * and type, old enough to have been folded, is therefore a duplicate when a * live `stats:` row covers its hour or day. The batch route and the ZIP import * apply the same rule. * @@ -29,8 +28,12 @@ * the other zone; such a row is then simply not recognised, which is the * pre-v1.42 behaviour, never a wrong refusal. * - * Health Connect does not use this: its importer writes the folded shape - * itself. + * Health Connect folds its raw heart-rate history the same way under its own + * source (the importer writes samples of the last 90 days raw, and the + * nightly pass folds them once they age out), so the hour rule covers both + * sources and a covering row is always looked up under the sample's own + * source. The day rule stays Apple Health only: the nightly mean + * consolidation reads no other source. */ import type { MeasurementSource, @@ -49,6 +52,7 @@ import { hourOfDayForUserTz, } from "@/lib/measurements/consolidation-tz"; import { + DENSE_INTRADAY_FOLD_SOURCES, DENSE_INTRADAY_RETENTION_DAYS, DENSE_INTRADAY_RETENTION_TYPES, hourlyStatsExternalId, @@ -60,15 +64,17 @@ export const FOLDED_WINDOW_REASON: FoldedWindowReason = "folded_window"; const HOUR_MS = 3_600_000; -/** Which types fold into which window, and from what age. */ +/** Which sources and types fold into which window, and from what age. */ export const FOLDED_WINDOW_RULES = [ { window: "hour", + sources: DENSE_INTRADAY_FOLD_SOURCES, types: DENSE_INTRADAY_RETENTION_TYPES, foldedAfterMs: DENSE_INTRADAY_RETENTION_DAYS * 24 * HOUR_MS, }, { window: "day", + sources: ["APPLE_HEALTH"] as const, types: HIGH_FREQUENCY_MEAN_TYPES, foldedAfterMs: CONSOLIDATION_GRACE_CUTOFF_HOURS * HOUR_MS, }, @@ -98,7 +104,6 @@ export function isFoldedWindowCandidate( row: FoldedWindowCandidateRow, now: Date = new Date(), ): boolean { - if (row.source !== "APPLE_HEALTH") return false; if (row.externalId === null) return false; if (row.externalId.startsWith("stats:")) return false; // A collision retirement (`reconcile-external-measurement.ts`) parks the @@ -106,7 +111,10 @@ export function isFoldedWindowCandidate( if (row.externalId.startsWith("retired:")) return false; const age = now.getTime() - row.measuredAt.getTime(); return FOLDED_WINDOW_RULES.some( - (rule) => rule.types.has(row.type) && age > rule.foldedAfterMs, + (rule) => + (rule.sources as readonly MeasurementSource[]).includes(row.source) && + rule.types.has(row.type) && + age > rule.foldedAfterMs, ); } @@ -153,8 +161,9 @@ export async function loadFoldTimezone( } /** - * Indexes (into `rows`) whose covering `stats:` row is live. One query over - * the unique `(user_id, type, source, external_id)` index for the whole set. + * Indexes (into `rows`) whose covering `stats:` row is live under the row's + * own source. One query per source present, each over the unique + * `(user_id, type, source, external_id)` index for the whole set. */ async function coveredIndexes( client: Pick, @@ -163,35 +172,49 @@ async function coveredIndexes( rows: ReadonlyArray<{ index: number; type: MeasurementType; + source: MeasurementSource; measuredAt: Date; }>, ): Promise> { const covered = new Set(); if (rows.length === 0) return covered; - const keyOf = new Map(); - const ids = new Set(); - const types = new Set(); + const bySource = new Map< + MeasurementSource, + { + keyOf: Map; + ids: Set; + types: Set; + } + >(); for (const row of rows) { const id = coveringStatsExternalId(row, tz); if (!id) continue; - keyOf.set(row.index, `${row.type}|${id}`); - ids.add(id); - types.add(row.type); + let group = bySource.get(row.source); + if (!group) { + group = { keyOf: new Map(), ids: new Set(), types: new Set() }; + bySource.set(row.source, group); + } + group.keyOf.set(row.index, `${row.type}|${id}`); + group.ids.add(id); + group.types.add(row.type); } - if (ids.size === 0) return covered; - const live = await client.measurement.findMany({ - where: { - userId, - type: { in: [...types] }, - source: "APPLE_HEALTH", - externalId: { in: [...ids] }, - deletedAt: null, - }, - select: { type: true, externalId: true }, - }); - const liveKeys = new Set(live.map((row) => `${row.type}|${row.externalId}`)); - for (const [index, key] of keyOf) { - if (liveKeys.has(key)) covered.add(index); + for (const [source, group] of bySource) { + const live = await client.measurement.findMany({ + where: { + userId, + type: { in: [...group.types] }, + source, + externalId: { in: [...group.ids] }, + deletedAt: null, + }, + select: { type: true, externalId: true }, + }); + const liveKeys = new Set( + live.map((row) => `${row.type}|${row.externalId}`), + ); + for (const [index, key] of group.keyOf) { + if (liveKeys.has(key)) covered.add(index); + } } return covered; } @@ -211,7 +234,14 @@ export async function findFoldedWindowDuplicates( const now = options.now ?? new Date(); const candidates = rows.flatMap((row, index) => isFoldedWindowCandidate(row, now) - ? [{ index, type: row.type, measuredAt: row.measuredAt }] + ? [ + { + index, + type: row.type, + source: row.source, + measuredAt: row.measuredAt, + }, + ] : [], ); if (candidates.length === 0) return new Set(); @@ -230,7 +260,7 @@ export interface TombstoneCandidateRow extends FoldedWindowCandidateRow { /** * Indexes (into `rows`) of the compaction tombstones ("class A"): soft-deleted - * raw Apple Health samples of a folded type that were already past the fold + * raw samples of a folded source and type that were already past the fold * threshold when they were deleted, and whose hour or day a live `stats:` row * covers. Everything else, in particular a person's own deletions of recent * samples, is class B and keeps the 75-day tombstone retention. @@ -243,7 +273,14 @@ export async function findCompactionTombstones( ): Promise> { const candidates = rows.flatMap((row, index) => row.deletedAt !== null && isFoldedWindowCandidate(row, row.deletedAt) - ? [{ index, type: row.type, measuredAt: row.measuredAt }] + ? [ + { + index, + type: row.type, + source: row.source, + measuredAt: row.measuredAt, + }, + ] : [], ); return coveredIndexes(client, userId, tz, candidates); diff --git a/tests/integration/dense-intraday-retention.test.ts b/tests/integration/dense-intraday-retention.test.ts index db955362e..06a7d6fba 100644 --- a/tests/integration/dense-intraday-retention.test.ts +++ b/tests/integration/dense-intraday-retention.test.ts @@ -377,4 +377,89 @@ describe("runDenseIntradayRetention (real Postgres)", () => { // Both raw samples survive — the intra-day shape is preserved. expect(live).toHaveLength(2); }); + + it("folds Health Connect raw heart rate past the window into its own hourly rows, beside Apple Health's", async () => { + const prisma = getPrismaClient(); + // Berlin UTC+2 on 1 May: 08:xx UTC is local hour 10 — far past 90 days. + const at = (minute: number) => + new Date(`2026-05-01T08:${String(minute).padStart(2, "0")}:00.000Z`); + await prisma.measurement.createMany({ + data: [ + ...[60, 70, 80].map((value, i) => ({ + userId: TEST_USER_ID, + type: "PULSE" as const, + value, + unit: "bpm", + source: "HEALTH_CONNECT" as const, + measuredAt: at(i * 10 + 1), + externalId: `hc:00000000-0000-0000-0000-00000000000a:${i}`, + })), + { + userId: TEST_USER_ID, + type: "PULSE" as const, + value: 100, + unit: "bpm", + source: "APPLE_HEALTH" as const, + measuredAt: at(5), + externalId: "hk-pulse-1", + }, + ], + }); + + const summary = await runDenseIntradayRetention(prisma, { + userId: TEST_USER_ID, + // The default 90-day window: the samples are well past it. + log: () => {}, + }); + expect(summary.totals.perSampleRowsSoftDeleted).toBe(4); + + const rows = await prisma.measurement.findMany({ + where: { userId: TEST_USER_ID, type: "PULSE" }, + orderBy: { source: "asc" }, + }); + // Raw rows of both sources are gone outright, not tombstoned. + expect(rows.every((row) => row.externalId?.startsWith("stats:"))).toBe( + true, + ); + expect(rows.every((row) => row.deletedAt === null)).toBe(true); + const id = "stats:HKQuantityTypeIdentifierHeartRate:2026-05-01T10"; + // One mean per source: the two sources never share a row. + expect(rows.map((row) => [row.source, row.externalId, row.value])).toEqual([ + ["APPLE_HEALTH", id, 100], + ["HEALTH_CONNECT", id, 70], + ]); + + // A second run finds nothing left to fold. + const again = await runDenseIntradayRetention(prisma, { + userId: TEST_USER_ID, + log: () => {}, + }); + expect(again.totals.daysConsolidated).toBe(0); + }); + + it("keeps Health Connect raw heart rate inside the window raw", async () => { + const prisma = getPrismaClient(); + const recent = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); + await prisma.measurement.create({ + data: { + userId: TEST_USER_ID, + type: "PULSE", + value: 64, + unit: "bpm", + source: "HEALTH_CONNECT", + measuredAt: recent, + externalId: "hc:00000000-0000-0000-0000-00000000000b:1", + }, + }); + const summary = await runDenseIntradayRetention(prisma, { + userId: TEST_USER_ID, + log: () => {}, + }); + expect(summary.totals.daysConsolidated).toBe(0); + expect( + await prisma.measurement.count({ + where: { userId: TEST_USER_ID, source: "HEALTH_CONNECT" }, + }), + ).toBe(1); + }); }); diff --git a/tests/integration/health-connect-import.test.ts b/tests/integration/health-connect-import.test.ts index 9cd102bc9..87cd33fd9 100644 --- a/tests/integration/health-connect-import.test.ts +++ b/tests/integration/health-connect-import.test.ts @@ -30,6 +30,7 @@ import { zipHealthConnectDb, } from "../fixtures/health-connect/build-fixture"; import { importHealthConnectExport } from "@/lib/import/health-connect/import"; +import { runDenseIntradayRetention } from "@/lib/measurements/dense-intraday-retention"; import { HEALTH_CONNECT_IMPORT_KIND, HEALTH_CONNECT_IMPORT_QUEUE, @@ -406,6 +407,53 @@ describe("Health Connect import, heart rate history", () => { }); }); +describe("Health Connect import, folded heart rate", () => { + it("folds raw heart rate once it ages out, and a later import does not put it back", async () => { + const prisma = getPrismaClient(); + const user = await createUser("hc-folded"); + // A day of minute-by-minute heart rate, 120 days back, imported while it + // was recent so it lands raw. + const { path, endUtc } = fixtureDb("folded", { + days: 1, + endUtc: today - 120 * DAY, + }); + const args = { + prisma, + dbPath: path, + userId: user.id, + userTimezone: "Europe/Berlin", + connectedIntegrations: [], + now: new Date(endUtc), + } as const; + await importHealthConnectExport(args); + expect((await countBy(user.id)).PULSE).toBe(24 * 60); + + // The nightly fold, at today's clock: the samples are past the window. + await runDenseIntradayRetention(prisma, { + userId: user.id, + log: () => {}, + }); + const folded = await prisma.measurement.findMany({ + where: { userId: user.id, type: "PULSE", source: "HEALTH_CONNECT" }, + }); + expect(folded).toHaveLength(24); + expect( + folded.every( + (row) => + row.deletedAt === null && + row.externalId?.startsWith( + "stats:HKQuantityTypeIdentifierHeartRate:", + ), + ), + ).toBe(true); + + // The same export again: every raw sample's hour is folded already. + const again = await importHealthConnectExport(args); + expect(again.skipped["PULSE::folded_window"]).toBe(24 * 60); + expect((await countBy(user.id)).PULSE).toBe(24); + }); +}); + describe("Health Connect import, worker", () => { async function stagedJob(name: string, zipBuilder: (zip: string) => void) { const prisma = getPrismaClient(); From b299904552c00e7614e097a745633159c2f89098 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 13:00:37 +0200 Subject: [PATCH 2/2] Let a record define its own vaccines A dose can now name a vaccine the catalogue does not list. The person defines it once (a name, the diseases it protects against from the catalogue's antigen list, the series length and the booster interval) and logs doses against it; such a dose counts into each listed antigen's series, settles a booster reminder keyed to one of them and offers a booster, the way a catalogue combination does. One resolver (resolve-vaccine-entry.ts) now answers for every consumer that asked the catalogue directly: the series, the satisfy matcher, the booster mint, the dose DTO, the doctor report, document links, the upload suggestion, MCP search and the client's info sentences and booster offer. The catalogue wins when a dose carries both. The custom routes resolve the record through requireRecordAuth (list at READ, create at WRITE with the actor-keyed write bucket, edit and remove at MANAGE with the C4 overwrite audit) and join the sharing guard, the MANAGE matrix and the activity feed. Removing a definition tombstones it and lets go of its doses, each keeping a name. Definitions travel in the backup and restore before the doses that name them; the dose form picks them, adds one without leaving the form, and the page lists them for editing. With the last contract stub implemented, the 501 helper and the not_implemented code leave the tree. --- docs/api/openapi.yaml | 595 +++++++++++------- messages/de.json | 19 +- messages/en.json | 19 +- messages/es.json | 19 +- messages/fr.json | 19 +- messages/it.json | 19 +- messages/ko.json | 17 +- messages/pl.json | 19 +- prisma/schema.prisma | 17 +- src/__tests__/i18n-reverse-coverage.test.ts | 1 - .../record-write-rate-limit-contract.test.ts | 1 + src/__tests__/sharing-surface-guard.test.ts | 23 +- src/__tests__/v137-wave-zero-fixtures.test.ts | 2 +- .../export/__tests__/per-type-routes.test.ts | 2 + .../__tests__/soft-delete-filter.test.ts | 1 + .../export/encrypted/__tests__/route.test.ts | 1 + src/app/api/vaccinations/[id]/route.ts | 34 +- src/app/api/vaccinations/custom/[id]/route.ts | 202 +++++- src/app/api/vaccinations/custom/route.ts | 145 ++++- src/app/api/vaccinations/route.ts | 31 +- src/components/admin/backups-section.tsx | 2 +- .../__tests__/custom-vaccine.test.tsx | 128 ++++ .../__tests__/vaccination-form.test.ts | 2 + .../__tests__/vaccination-list.test.tsx | 2 + .../vaccinations/booster-mint-prompt.tsx | 38 +- src/components/vaccinations/catalog-info.tsx | 49 +- .../vaccinations/catalog-picker.tsx | 115 +++- .../vaccinations/custom-vaccine-list.tsx | 121 ++++ .../vaccinations/custom-vaccine-sheet.tsx | 332 ++++++++++ .../vaccinations/use-vaccinations.ts | 59 ++ .../vaccinations/vaccination-form.tsx | 55 +- .../vaccinations/vaccination-list.tsx | 14 +- .../vaccinations/vaccinations-view.tsx | 17 +- src/lib/doctor-report/clinical-records.ts | 15 +- src/lib/documents/links.ts | 23 +- .../__tests__/full-backup-payload.test.ts | 1 + .../restore-backup-round-trip.test.ts | 1 + src/lib/export/backup-plan.ts | 3 +- src/lib/export/backup-preview.ts | 1 + src/lib/export/restore-backup.ts | 3 + src/lib/export/vaccinations-backup.ts | 146 ++++- src/lib/http/not-implemented.ts | 21 - src/lib/mcp/record-search.ts | 56 +- src/lib/openapi/error-codes.ts | 5 +- src/lib/openapi/routes/admin.ts | 8 +- src/lib/openapi/routes/shared.ts | 15 - src/lib/openapi/routes/vaccinations.ts | 135 ++-- src/lib/query-keys/vaccinations.ts | 9 + src/lib/record-activity/activity-verb.ts | 6 + .../__tests__/catalog-integrity.test.ts | 8 +- .../__tests__/resolve-vaccine-entry.test.ts | 138 ++++ src/lib/vaccinations/booster-mint.ts | 24 +- src/lib/vaccinations/custom-vaccines.ts | 69 ++ src/lib/vaccinations/document-suggestion.ts | 9 +- src/lib/vaccinations/dto.ts | 66 +- src/lib/vaccinations/resolve-vaccine-entry.ts | 191 ++++++ src/lib/vaccinations/satisfy-matching.ts | 19 +- src/lib/vaccinations/series.ts | 28 +- src/lib/vaccinations/service.ts | 32 +- src/lib/vaccinations/vaccine-catalog.ts | 20 +- .../__tests__/vaccinations.test.ts | 14 +- src/lib/validations/backup-summary.ts | 3 + src/lib/validations/backup.ts | 21 + src/lib/validations/vaccinations.ts | 72 ++- tests/fixtures/v137/sharing-matrix.ts | 25 + tests/integration/backup-round-trip.test.ts | 29 + .../sharing-manage-handler-matrix.test.ts | 87 ++- tests/integration/vaccination-custom.test.ts | 424 +++++++++++++ 68 files changed, 3326 insertions(+), 521 deletions(-) create mode 100644 src/components/vaccinations/__tests__/custom-vaccine.test.tsx create mode 100644 src/components/vaccinations/custom-vaccine-list.tsx create mode 100644 src/components/vaccinations/custom-vaccine-sheet.tsx delete mode 100644 src/lib/http/not-implemented.ts create mode 100644 src/lib/vaccinations/__tests__/resolve-vaccine-entry.test.ts create mode 100644 src/lib/vaccinations/custom-vaccines.ts create mode 100644 src/lib/vaccinations/resolve-vaccine-entry.ts create mode 100644 tests/integration/vaccination-custom.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 60b4595b0..c0e817834 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -11073,13 +11073,6 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": &a32 - description: "Not implemented yet: the contract is published ahead of the implementation on a release branch. - `meta.errorCode: not_implemented`." - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" "503": description: No background worker is bound, so nothing would run it. content: @@ -14974,7 +14967,7 @@ paths: schema: $ref: "#/components/schemas/OcrTextExtractRequest" responses: - "202": &a37 + "202": &a38 description: Queued for the background worker. Every refusal the route can answer quickly (capability, provider, rate limit, budget, validation) has already been answered; anything that goes wrong from here is reported on the run. @@ -15736,8 +15729,8 @@ paths: tags: - Records summary: List the record's own vaccines - description: Vaccine definitions the record's owner added for products the shipped catalogue does not list. Soft-deleted - definitions are excluded. + description: The vaccine definitions the record's owner added for products the shipped catalogue does not list, by name. + Removed definitions are excluded. Readable under a `profile` grant like the dose list. responses: "200": description: The definitions, by name. @@ -15746,15 +15739,17 @@ paths: schema: $ref: "#/components/schemas/ListCustomVaccinesEnvelope" "401": *a1 + "403": *a11 "422": *a4 "429": *a5 - "501": *a32 post: tags: - Records summary: Add an own vaccine - description: Defines a vaccine in the same shape a catalogue entry resolves to, so series and booster logic treat both - alike. Names are unique per record. + description: Defines a vaccine in the same shape a catalogue entry resolves to, so the series and booster logic treat + both alike. Names are unique per record, compared without case; adding a name the person removed earlier brings + that definition back with the new values (its doses were let go on removal and stay so). Audits as + `vaccination.custom.create`. requestBody: required: true content: @@ -15769,21 +15764,23 @@ paths: schema: $ref: "#/components/schemas/CreateCustomVaccineEnvelope" "401": *a1 - "409": - description: A live definition with this name already exists. + "403": *a11 + "409": &a32 + description: A live definition on the record already carries this name, compared without case + (`vaccination.custom.name-taken`). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a12 - "501": *a32 /api/vaccinations/custom/{id}: patch: tags: - Records summary: Edit an own vaccine - description: Changes one definition. Owner-scoped; an unknown id 404s. + description: Changes one definition; every dose logged against it reads the change. Owner-scoped; an unknown or removed + id 404s. Audits as `vaccination.custom.update`, naming a rename without quoting it. parameters: - in: path name: id @@ -15804,26 +15801,23 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomVaccineEnvelope" "401": *a1 - "404": - description: No such definition on this record. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" - "409": - description: A live definition with this name already exists. + "403": *a11 + "404": &a33 + description: No live definition with this id on the record (`vaccination.custom.not-found`). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "409": *a32 "422": *a4 "429": *a5 - "501": *a32 delete: tags: - Records summary: Remove an own vaccine - description: Soft-deletes one definition. Doses logged against it stay, on their `vaccineName`. + description: "Soft-deletes one definition and lets go of every dose that named it. A dose keeps a name: one without + wording of its own takes the definition's name as its `vaccineName` first. Removing twice succeeds. Audits as + `vaccination.custom.delete` with the number of doses let go." parameters: - in: path name: id @@ -15838,15 +15832,10 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomVaccineEnvelope" "401": *a1 - "404": - description: No such definition on this record. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" + "403": *a11 + "404": *a33 "422": *a4 "429": *a5 - "501": *a32 /api/vaccinations: get: tags: @@ -15915,6 +15904,13 @@ paths: $ref: "#/components/schemas/CreateVaccinationEnvelope" "401": *a1 "403": *a11 + "404": + description: A practice, visit or own vaccine the body names is not the record's (`vaccination.practitioner-not-found`, + `vaccination.encounter-not-found`, `vaccination.custom-vaccine-not-found`). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "409": *a7 "422": *a4 "429": *a12 @@ -15939,7 +15935,7 @@ paths: $ref: "#/components/schemas/GetVaccinationEnvelope" "401": *a1 "403": *a11 - "404": &a33 + "404": &a34 description: Vaccination not found (or owned by another user). content: application/json: @@ -15974,7 +15970,7 @@ paths: $ref: "#/components/schemas/UpdateVaccinationEnvelope" "401": *a1 "403": *a11 - "404": *a33 + "404": *a34 "422": *a4 "429": *a5 delete: @@ -15999,7 +15995,7 @@ paths: $ref: "#/components/schemas/DeleteVaccinationEnvelope" "401": *a1 "403": *a11 - "404": *a33 + "404": *a34 "422": *a4 "429": *a5 /api/vaccinations/{id}/restore: @@ -16024,7 +16020,7 @@ paths: $ref: "#/components/schemas/RestoreVaccinationEnvelope" "401": *a1 "403": *a11 - "404": *a33 + "404": *a34 "422": *a4 "429": *a5 /api/vaccinations/{id}/booster: @@ -16090,7 +16086,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": *a33 + "404": *a34 "422": *a4 "429": *a5 /api/vaccinations/suggest: @@ -16148,7 +16144,7 @@ paths: schema: $ref: "#/components/schemas/LinkVaccinationEnvelope" "401": *a1 - "404": *a33 + "404": *a34 "422": *a4 "429": *a5 delete: @@ -16177,7 +16173,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkVaccinationEnvelope" "401": *a1 - "404": *a33 + "404": *a34 "422": *a4 "429": *a5 /api/illness/episodes: @@ -16261,7 +16257,7 @@ paths: $ref: "#/components/schemas/GetIllnessEpisodeEnvelope" "401": *a1 "403": *a11 - "404": &a34 + "404": &a35 description: Illness episode not found (or owned by another user). content: application/json: @@ -16296,7 +16292,7 @@ paths: $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" "401": *a1 "403": *a11 - "404": *a34 + "404": *a35 "422": *a4 "429": *a5 delete: @@ -16320,7 +16316,7 @@ paths: $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 "403": *a11 - "404": *a34 + "404": *a35 "422": *a4 "429": *a5 /api/illness/episodes/{id}/restore: @@ -16348,7 +16344,7 @@ paths: $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" "401": *a1 "403": *a11 - "404": *a34 + "404": *a35 "409": *a7 "422": *a4 "429": *a5 @@ -16381,7 +16377,7 @@ paths: $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" "401": *a1 "403": *a11 - "404": *a34 + "404": *a35 "422": *a4 "429": *a5 /api/illness/episodes/{id}/day-logs: @@ -16432,7 +16428,7 @@ paths: $ref: "#/components/schemas/GetIllnessDayLogEnvelope" "401": *a1 "403": *a11 - "404": *a34 + "404": *a35 "422": *a4 "429": *a5 post: @@ -16469,7 +16465,7 @@ paths: $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" "401": *a1 "403": *a11 - "404": *a34 + "404": *a35 "422": *a4 "429": *a5 /api/illness/episodes/{id}/correlation: @@ -16496,7 +16492,7 @@ paths: schema: $ref: "#/components/schemas/IllnessCorrelationEnvelope" "401": *a1 - "404": *a34 + "404": *a35 "422": *a4 "429": *a5 /api/illness/insights: @@ -16712,7 +16708,7 @@ paths: $ref: "#/components/schemas/GetAllergyEnvelope" "401": *a1 "403": *a11 - "404": &a35 + "404": &a36 description: Allergy not found (or owned by another user). content: application/json: @@ -16746,7 +16742,7 @@ paths: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 "403": *a11 - "404": *a35 + "404": *a36 "422": *a4 "429": *a5 delete: @@ -16770,7 +16766,7 @@ paths: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 "403": *a11 - "404": *a35 + "404": *a36 "422": *a4 "429": *a5 /api/family-history: @@ -16844,7 +16840,7 @@ paths: $ref: "#/components/schemas/GetFamilyHistoryEnvelope" "401": *a1 "403": *a11 - "404": &a36 + "404": &a37 description: Family history entry not found (or owned by another user). content: application/json: @@ -16878,7 +16874,7 @@ paths: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 "403": *a11 - "404": *a36 + "404": *a37 "422": *a4 "429": *a5 delete: @@ -16902,7 +16898,7 @@ paths: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 "403": *a11 - "404": *a36 + "404": *a37 "422": *a4 "429": *a5 /api/documents/inbound: @@ -17527,7 +17523,7 @@ paths: required: true schema: type: string - - &a38 + - &a39 name: Prefer in: header required: false @@ -17552,9 +17548,9 @@ paths: application/json: schema: $ref: "#/components/schemas/InboundExtractEnvelope" - "202": *a37 + "202": *a38 "401": *a1 - "403": &a39 + "403": &a40 description: "AI refusal: the `documentAi` capability is unavailable for this record. The envelope carries `meta.errorCode`, `meta.capability` and `meta.reason` (one of `AiUnavailableReason`). `assistant.disabled.documentAi` — the operator turned off reading documents, or every AI feature (the master @@ -17578,7 +17574,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "503": &a40 + "503": &a41 description: "`ai.unavailable` — the inputs behind the AI capability could not be read, and the answer failed closed. Nothing was sent. Retry later." content: @@ -17608,7 +17604,7 @@ paths: required: true schema: type: string - - *a38 + - *a39 requestBody: required: false content: @@ -17644,12 +17640,12 @@ paths: application/json: schema: $ref: "#/components/schemas/DocumentSuggestEnvelope" - "202": *a37 + "202": *a38 "401": *a1 - "403": *a39 + "403": *a40 "422": *a4 "429": *a5 - "503": *a40 + "503": *a41 /api/documents/inbound/{id}/summary: post: tags: @@ -17699,7 +17695,7 @@ paths: type: boolean default: false description: With `persist=true`, explicitly replace an existing stored summary. - - *a38 + - *a39 requestBody: required: false content: @@ -17735,12 +17731,12 @@ paths: application/json: schema: $ref: "#/components/schemas/DocumentSummaryEnvelope" - "202": *a37 + "202": *a38 "401": *a1 - "403": *a39 + "403": *a40 "422": *a4 "429": *a5 - "503": *a40 + "503": *a41 /api/documents/inbound/{id}/index: post: tags: @@ -17766,7 +17762,7 @@ paths: required: true schema: type: string - - *a38 + - *a39 requestBody: required: false content: @@ -17802,12 +17798,12 @@ paths: application/json: schema: $ref: "#/components/schemas/DocumentIndexEnvelope" - "202": *a37 + "202": *a38 "401": *a1 - "403": *a39 + "403": *a40 "422": *a4 "429": *a5 - "503": *a40 + "503": *a41 /api/documents/inbound/{id}/chat: post: tags: @@ -17847,10 +17843,10 @@ paths: type: string description: "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes." "401": *a1 - "403": *a39 + "403": *a40 "422": *a4 "429": *a5 - "503": *a40 + "503": *a41 get: tags: - Documents @@ -17913,10 +17909,10 @@ paths: schema: $ref: "#/components/schemas/DocumentReindexEnvelope" "401": *a1 - "403": *a39 + "403": *a40 "422": *a4 "429": *a5 - "503": *a40 + "503": *a41 /api/documents/inbound/{id}/facts/{factId}: patch: tags: @@ -18023,7 +18019,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 - "409": &a42 + "409": &a43 description: A custom metric with this name already exists for the caller. content: application/json: @@ -18052,7 +18048,7 @@ paths: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 "403": *a11 - "404": &a41 + "404": &a42 description: Custom metric not found (or owned by another user). content: application/json: @@ -18086,8 +18082,8 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a41 - "409": *a42 + "404": *a42 + "409": *a43 "422": *a4 "429": *a5 delete: @@ -18110,7 +18106,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a41 + "404": *a42 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries: @@ -18154,7 +18150,7 @@ paths: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 "403": *a11 - "404": *a41 + "404": *a42 "422": *a4 "429": *a5 post: @@ -18183,7 +18179,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 - "404": *a41 + "404": *a42 "409": *a7 "422": *a4 "429": *a12 @@ -18219,7 +18215,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 - "404": &a43 + "404": &a44 description: Custom metric entry not found (or owned by another user). content: application/json: @@ -18252,7 +18248,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a43 + "404": *a44 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries/restore: @@ -18664,7 +18660,7 @@ paths: schema: $ref: "#/components/schemas/NutrientOverviewEnvelope" "401": *a1 - "403": &a44 + "403": &a45 description: >- The opt-in `nutrients` module is off for this account (errorCode `module.disabled`). Stop syncing and do not retry; offer the enable-in-settings hint. Only run the HealthKit read-authorization flow while the module is @@ -18719,7 +18715,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a44 + "403": *a45 "422": *a4 "429": *a5 /api/nutrients/water: @@ -18748,7 +18744,7 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a44 + "403": *a45 "409": *a7 "422": *a4 "429": *a5 @@ -19250,26 +19246,26 @@ paths: application/json: schema: $ref: "#/components/schemas/WithingsCredentialsUpdateEnvelope" - "400": &a45 + "400": &a46 description: Body is not parseable JSON. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "413": &a46 + "413": &a47 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a47 + "415": &a48 description: Content-Type is not `application/json`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a48 + "422": &a49 description: "`clientId` and `clientSecret` are both required and each must be 1–200 characters. The envelope carries a single fixed message, not per-field issues." content: @@ -19455,11 +19451,11 @@ paths: application/json: schema: $ref: "#/components/schemas/FitbitCredentialsUpdateEnvelope" - "400": *a45 + "400": *a46 "401": *a1 - "413": *a46 - "415": *a47 - "422": *a48 + "413": *a47 + "415": *a48 + "422": *a49 "429": *a5 delete: tags: @@ -19602,11 +19598,11 @@ paths: application/json: schema: $ref: "#/components/schemas/GoogleHealthCredentialsUpdateEnvelope" - "400": *a45 + "400": *a46 "401": *a1 - "413": *a46 - "415": *a47 - "422": *a48 + "413": *a47 + "415": *a48 + "422": *a49 "429": *a5 delete: tags: @@ -19759,11 +19755,11 @@ paths: application/json: schema: $ref: "#/components/schemas/OuraCredentialsUpdateEnvelope" - "400": *a45 + "400": *a46 "401": *a1 - "413": *a46 - "415": *a47 - "422": *a48 + "413": *a47 + "415": *a48 + "422": *a49 "429": *a5 delete: tags: @@ -19839,21 +19835,21 @@ paths: schema: $ref: "#/components/schemas/OuraTestEnvelope" "401": *a1 - "422": &a49 + "422": &a50 description: No usable connection for this provider — nothing stored, or the stored grant could no longer be resolved into an access token. `meta.errorCode` = `not_configured`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": &a50 + "429": &a51 description: More than 5 requests in 60 s from this user. `meta.errorCode` = `rate_limited_self` — this is the local limiter, distinct from a `rate_limited` 502 which is the provider's. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "502": &a51 + "502": &a52 description: "The probe did not complete. `meta.errorCode` names the class: `credentials_rejected` (the provider answered 401/403 — the stored grant is gone), `rate_limited` (the PROVIDER answered 429, which is not this endpoint's own limiter), `upstream_error` (5xx), `timeout` (no answer within 5 s), `connection_failed` @@ -19898,11 +19894,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PolarCredentialsUpdateEnvelope" - "400": *a45 + "400": *a46 "401": *a1 - "413": *a46 - "415": *a47 - "422": *a48 + "413": *a47 + "415": *a48 + "422": *a49 "429": *a5 delete: tags: @@ -19978,9 +19974,9 @@ paths: schema: $ref: "#/components/schemas/PolarTestEnvelope" "401": *a1 - "422": *a49 - "429": *a50 - "502": *a51 + "422": *a50 + "429": *a51 + "502": *a52 /api/strava/credentials: get: tags: @@ -20016,11 +20012,11 @@ paths: application/json: schema: $ref: "#/components/schemas/StravaCredentialsUpdateEnvelope" - "400": *a45 + "400": *a46 "401": *a1 - "413": *a46 - "415": *a47 - "422": *a48 + "413": *a47 + "415": *a48 + "422": *a49 "429": *a5 delete: tags: @@ -20098,9 +20094,9 @@ paths: schema: $ref: "#/components/schemas/StravaTestEnvelope" "401": *a1 - "422": *a49 - "429": *a50 - "502": *a51 + "422": *a50 + "429": *a51 + "502": *a52 /api/integrations/status: get: tags: @@ -20136,7 +20132,7 @@ paths: $ref: "#/components/schemas/FitbitResumeEnvelope" "401": *a1 "422": *a4 - "429": *a50 + "429": *a51 /api/integrations/fitbit/test: post: tags: @@ -20154,9 +20150,9 @@ paths: schema: $ref: "#/components/schemas/FitbitIntegrationTestEnvelope" "401": *a1 - "422": *a49 - "429": *a50 - "502": *a51 + "422": *a50 + "429": *a51 + "502": *a52 /api/integrations/google-health/resume: post: tags: @@ -20173,7 +20169,7 @@ paths: $ref: "#/components/schemas/GoogleHealthResumeEnvelope" "401": *a1 "422": *a4 - "429": *a50 + "429": *a51 /api/integrations/google-health/test: post: tags: @@ -20206,9 +20202,9 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a49 - "429": *a50 - "502": *a51 + "422": *a50 + "429": *a51 + "502": *a52 /api/integrations/whoop/resume: post: tags: @@ -20225,7 +20221,7 @@ paths: $ref: "#/components/schemas/WhoopResumeEnvelope" "401": *a1 "422": *a4 - "429": *a50 + "429": *a51 /api/integrations/whoop/test: post: tags: @@ -20242,9 +20238,9 @@ paths: schema: $ref: "#/components/schemas/WhoopIntegrationTestEnvelope" "401": *a1 - "422": *a49 - "429": *a50 - "502": *a51 + "422": *a50 + "429": *a51 + "502": *a52 /api/integrations/withings/resume: post: tags: @@ -20262,7 +20258,7 @@ paths: $ref: "#/components/schemas/WithingsResumeEnvelope" "401": *a1 "422": *a4 - "429": *a50 + "429": *a51 /api/integrations/withings/test: post: tags: @@ -20280,8 +20276,8 @@ paths: schema: $ref: "#/components/schemas/WithingsIntegrationTestEnvelope" "401": *a1 - "422": *a49 - "429": *a50 + "422": *a50 + "429": *a51 "502": description: "The probe did not complete. The shared codes apply (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed`) and this path adds one of its own: @@ -20683,7 +20679,7 @@ paths: schema: $ref: "#/components/schemas/RecordActivityEnvelope" "401": *a1 - "403": &a52 + "403": &a53 description: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another account (grant management is never delegable), or `sharing.access.denied` when the caller may not act as the account they named. The latter is byte-identical for an account that does not exist and one that granted @@ -20708,7 +20704,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a52 + "403": *a53 "422": *a4 "429": *a5 post: @@ -20810,7 +20806,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a52 + "403": *a53 "404": description: No such invitation for this account. content: @@ -20854,7 +20850,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a52 + "403": *a53 "404": description: No such grant on this account's record. content: @@ -20895,7 +20891,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a52 + "403": *a53 "404": description: No such grant held by this account. content: @@ -21277,7 +21273,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a52 + "403": *a53 "409": description: "The record this browser session is on is not the record the request asserted. `meta.errorCode` is `sharing.session.changed`. It is not a refusal of access — the caller may well be entitled to both records — @@ -21607,7 +21603,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionListEnvelope" "401": *a1 - "403": &a53 + "403": &a54 description: The operator has disabled the API instance-wide. Every route in this module refuses while that setting is off, for a cookie session as well as for a Bearer token; it is not a per-user permission and retrying will not help. @@ -21640,7 +21636,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionRevokeEnvelope" "401": *a1 - "403": *a53 + "403": *a54 "404": description: No live connection with this id for this caller — also the answer for an already-revoked one, so a repeat revoke is 404 rather than an idempotent 200. @@ -21667,7 +21663,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenListEnvelope" "401": *a1 - "403": *a53 + "403": *a54 "422": *a4 "429": *a5 post: @@ -21700,7 +21696,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a53 + "403": *a54 "413": description: Body exceeds 16 KiB. content: @@ -21743,7 +21739,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenRevokeEnvelope" "401": *a1 - "403": *a53 + "403": *a54 "404": description: No `health:read` token with this id for this caller. An already-revoked token is still found, so a repeat revoke answers 200. @@ -21801,7 +21797,7 @@ paths: description: Returns the named family's resolved settings for the record the session is acting on. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - &a54 + - &a55 name: family in: path required: true @@ -21845,7 +21841,7 @@ paths: ride the audit row. Body capped at 64 KiB. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - *a54 + - *a55 requestBody: required: true content: @@ -22408,13 +22404,13 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupDocument" - "403": &a55 + "403": &a56 description: Caller is not an admin. `requireAdmin()` is cookie-only, so a Bearer token lands here whatever its scope. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": &a56 + "404": &a57 description: No stored copy with this id. content: application/json: @@ -22488,8 +22484,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "403": *a55 - "404": *a56 + "403": *a56 + "404": *a57 "409": description: A restore of the same account is already queued or running (`meta.errorCode` = `backup.restore.active`, `meta.jobId` names it), the stored copy was replaced by a newer one while it was being checked @@ -22533,7 +22529,7 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobListResponse" - "403": *a55 + "403": *a56 /api/admin/backups/restores/{jobId}: get: tags: @@ -22556,7 +22552,7 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobResponse" - "403": *a55 + "403": *a56 "404": description: No restore job with this id. content: @@ -22575,7 +22571,7 @@ paths: request leaves the server. Needs neither the Documents module nor the list: with the list unset, `available` is false and any connection still stored is listed (with `originAllowed: false`) so it can be removed. Cookie session only; not delegable." - security: &a57 + security: &a58 - cookieAuth: [] responses: "200": @@ -22585,7 +22581,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourcesStatusEnvelope" "401": *a1 - "403": &a58 + "403": &a59 description: "Refused before any request to the source: `documents.sources.browserOnly` for a Bearer caller, `sharing.not_permitted` while acting on another person's record (a delegate or guardian never uses the owner's archive), or `module.disabled` when the Documents module is off." @@ -22608,9 +22604,9 @@ paths: `documents.sources.tokenRequired`), so a stored token is never sent to an address it was not saved for. A Papra organization id is letters, digits, `_` and `-`. The token is stored encrypted (AES-256-GCM) and never returned. Ten saves and tests a minute per person. Cookie session only; not delegable." - security: *a57 + security: *a58 parameters: - - &a59 + - &a60 name: system in: path required: true @@ -22649,8 +22645,8 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceConnectionEnvelope" "401": *a1 - "403": *a58 - "404": &a60 + "403": *a59 + "404": &a61 description: "`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so the picker is off. `documents.sources.notConnected`: no connection is saved for this system. `documents.sources.notFound`: the source has no such document, or (Papra) no such organization, including @@ -22669,7 +22665,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "502": &a61 + "502": &a62 description: "The source could not answer usefully, with the reason in `meta.errorCode` and, when the source answered at all, its HTTP status in `meta.upstreamStatus` (its body is never passed on): `documents.sources.unreachable` (no connection, timeout, or a destination refused at dial time), `documents.sources.redirected` (the source @@ -22688,9 +22684,9 @@ paths: description: Deletes the caller's connection for this system, and its token with it. Works even after the operator removed the origin from the list, and with the Documents module switched off. `disconnected` is false when there was nothing to delete. Cookie session only; not delegable. - security: *a57 + security: *a58 parameters: - - *a59 + - *a60 responses: "200": description: Removed, or there was nothing to remove. @@ -22699,7 +22695,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceDisconnectEnvelope" "401": *a1 - "403": *a58 + "403": *a59 "422": *a4 "429": *a5 /api/documents/sources/{system}/test: @@ -22710,9 +22706,9 @@ paths: description: One request to the source with the stored token. Answers `ok` with the round-trip time and stamps `lastVerifiedAt`, or the reason in `meta.errorCode`. Shares the save's allowance of ten a minute. Cookie session only; not delegable. - security: *a57 + security: *a58 parameters: - - *a59 + - *a60 responses: "200": description: The source answered. @@ -22721,9 +22717,9 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceTestEnvelope" "401": *a1 - "403": *a58 - "404": *a60 - "422": &a62 + "403": *a59 + "404": *a61 + "422": &a63 description: "`documents.sources.originNotAllowed`: the base address's origin is not on the operator's list (checked on every request, so a connection saved earlier is cut off once its origin is removed), or another validation failure." @@ -22732,7 +22728,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "502": *a61 + "502": *a62 /api/documents/sources/{system}/tags: get: tags: @@ -22740,9 +22736,9 @@ paths: summary: Tags of a connected archive description: The source's tags for the picker's filter, the first 250 by name, in the source's own ids. Shares the search allowance of sixty a minute. Cookie session only; not delegable. - security: *a57 + security: *a58 parameters: - - *a59 + - *a60 responses: "200": description: The tags. @@ -22751,11 +22747,11 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceTagsEnvelope" "401": *a1 - "403": *a58 - "404": *a60 - "422": *a62 + "403": *a59 + "404": *a61 + "422": *a63 "429": *a5 - "502": *a61 + "502": *a62 /api/documents/sources/{system}/search: get: tags: @@ -22769,9 +22765,9 @@ paths: under that source key: `new`, `imported` with the vault document's `documentId`, or `deleted` (deleted in HealthLog; importing it again stores nothing). Nothing is stored. Sixty searches a minute, shared with the tag read. Cookie session only; not delegable." - security: *a57 + security: *a58 parameters: - - *a59 + - *a60 - name: q in: query required: false @@ -22812,11 +22808,11 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceSearchEnvelope" "401": *a1 - "403": *a58 - "404": *a60 - "422": *a62 + "403": *a59 + "404": *a61 + "422": *a63 "429": *a5 - "502": *a61 + "502": *a62 /api/documents/sources/{system}/import: post: tags: @@ -22835,9 +22831,9 @@ paths: fetched), including a duplicate. `kind` sets the document type (default OTHER). Automatic AI reading follows the person's own setting. Counts against the person's upload allowance (60 an hour) and storage quota. 201 for a new document, 200 otherwise. Cookie session only; not delegable." - security: *a57 + security: *a58 parameters: - - *a59 + - *a60 requestBody: required: true content: @@ -22895,7 +22891,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceImportEnvelope" "401": *a1 - "403": *a58 + "403": *a59 "404": description: "`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so the picker is off. `documents.sources.notConnected`: no connection is saved for this system. @@ -22926,7 +22922,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a62 + "422": *a63 "429": description: "`documents.inbound.rateLimited` (the person's hourly upload allowance, with the rate-limit headers) or `documents.inbound.uploadBusy` (too many uploads in progress; `Retry-After: 1`)." @@ -22934,7 +22930,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "502": *a61 + "502": *a62 /api/symptoms/definitions: get: tags: @@ -22958,7 +22954,7 @@ paths: schema: $ref: "#/components/schemas/ListSymptomDefinitionsEnvelope" "401": *a1 - "403": &a63 + "403": &a64 description: >- `illness.disabled`: the record has the illness module switched off (or the operator turned it off server-wide); symptoms ride that module. @@ -23001,7 +22997,7 @@ paths: schema: $ref: "#/components/schemas/CreateSymptomDefinitionEnvelope" "401": *a1 - "403": *a63 + "403": *a64 "409": *a7 "422": *a4 "429": *a5 @@ -23031,8 +23027,8 @@ paths: schema: $ref: "#/components/schemas/UpdateSymptomDefinitionEnvelope" "401": *a1 - "403": *a63 - "404": &a64 + "403": *a64 + "404": &a65 description: "`symptoms.definition.notFound`: no definition with this id on the record." content: application/json: @@ -23066,8 +23062,8 @@ paths: schema: $ref: "#/components/schemas/DeleteSymptomDefinitionEnvelope" "401": *a1 - "403": *a63 - "404": *a64 + "403": *a64 + "404": *a65 "422": *a4 "429": *a5 /api/symptoms/events: @@ -23116,7 +23112,7 @@ paths: schema: $ref: "#/components/schemas/ListSymptomEventsEnvelope" "401": *a1 - "403": *a63 + "403": *a64 "422": *a4 "429": *a5 post: @@ -23142,8 +23138,8 @@ paths: schema: $ref: "#/components/schemas/CreateSymptomEventEnvelope" "401": *a1 - "403": *a63 - "404": *a64 + "403": *a64 + "404": *a65 "409": *a7 "422": *a4 "429": *a5 @@ -23173,8 +23169,8 @@ paths: schema: $ref: "#/components/schemas/UpdateSymptomEventEnvelope" "401": *a1 - "403": *a63 - "404": &a65 + "403": *a64 + "404": &a66 description: "`symptoms.event.notFound`: no occurrence with this id on the record." content: application/json: @@ -23201,8 +23197,8 @@ paths: schema: $ref: "#/components/schemas/DeleteSymptomEventEnvelope" "401": *a1 - "403": *a63 - "404": *a65 + "403": *a64 + "404": *a66 "422": *a4 "429": *a5 /api/bugreport: @@ -30144,48 +30140,115 @@ components: properties: name: type: string + minLength: 1 + maxLength: 100 components: + minItems: 1 + maxItems: 26 type: array items: type: string + enum: + - tetanus + - diphtheria + - pertussis + - polio + - measles + - mumps + - rubella + - varicella + - hib + - hepatitis-a + - hepatitis-b + - influenza + - pneumococcal + - herpes-zoster + - rsv + - covid-19 + - fsme + - hpv + - meningococcal-acwy + - meningococcal-b + - rotavirus + - typhoid + - rabies + - yellow-fever + - japanese-encephalitis + - cholera typicalSeriesDoses: anyOf: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 + minimum: 1 + maximum: 10 - type: "null" boosterIntervalMonths: anyOf: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 + minimum: 1 + maximum: 600 - type: "null" required: - name - components additionalProperties: false + description: A new definition. `components` lists one to all of the catalogue's antigen slugs, each once — a disease + outside that list is logged as a free-text dose instead. `typicalSeriesDoses` (1–10) and `boosterIntervalMonths` + (1–600) are optional; the interval prefills the booster offer after a dose. UpdateCustomVaccineRequest: type: object properties: name: type: string + minLength: 1 + maxLength: 100 components: + minItems: 1 + maxItems: 26 type: array items: type: string + enum: + - tetanus + - diphtheria + - pertussis + - polio + - measles + - mumps + - rubella + - varicella + - hib + - hepatitis-a + - hepatitis-b + - influenza + - pneumococcal + - herpes-zoster + - rsv + - covid-19 + - fsme + - hpv + - meningococcal-acwy + - meningococcal-b + - rotavirus + - typhoid + - rabies + - yellow-fever + - japanese-encephalitis + - cholera typicalSeriesDoses: anyOf: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 + minimum: 1 + maximum: 10 - type: "null" boosterIntervalMonths: anyOf: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 + minimum: 1 + maximum: 600 - type: "null" additionalProperties: false + description: Partial edit; an omitted key is left untouched and an empty body is a 422. The change applies to every dose + logged against the definition, because the series is derived on read; it never re-runs booster satisfaction. CreateVaccinationRequest: type: object properties: @@ -30247,6 +30310,12 @@ components: minLength: 1 maxLength: 40 - type: "null" + customVaccineId: + anyOf: + - type: string + minLength: 1 + maxLength: 40 + - type: "null" note: anyOf: - type: string @@ -30263,12 +30332,15 @@ components: - occurredAt additionalProperties: false description: "Log one administered dose. `occurredAt` is required and must not be in the future — a vaccination is - recorded after the fact, and a planned booster is a reminder rather than a record. Exactly one identity arm is - required: either `antigenSlug` (a slug the catalogue offers) or `vaccineName` (whatever the person's own record - says, verbatim, which may be a trade name). Everything else is optional, so a decades-old paper entry with - nothing but a date and a name is loggable. `note` is encrypted at rest. `documentIds` pre-links the scanned - pages; an id naming nothing the caller owns is dropped rather than refused, so a link never blocks a save. - Logging a dose satisfies any booster reminder keyed to one of its component antigens." + recorded after the fact, and a planned booster is a reminder rather than a record. At least one identity arm is + required: `antigenSlug` (a slug the catalogue offers), `customVaccineId` (one of the record's own vaccine + definitions, see `/api/vaccinations/custom`; an id the record does not hold live is a 404 + `vaccination.custom-vaccine-not-found`) or `vaccineName` (whatever the person's own record says, verbatim, which + may be a trade name). When both a slug and a definition are given, the catalogue is the answer. Everything else + is optional, so a decades-old paper entry with nothing but a date and a name is loggable. `note` is encrypted at + rest. `documentIds` pre-links the scanned pages; an id naming nothing the caller owns is dropped rather than + refused, so a link never blocks a save. Logging a dose satisfies any booster reminder keyed to one of its + component antigens, the antigens of an own definition included." UpdateVaccinationRequest: type: object properties: @@ -30330,6 +30402,12 @@ components: minLength: 1 maxLength: 40 - type: "null" + customVaccineId: + anyOf: + - type: string + minLength: 1 + maxLength: 40 + - type: "null" note: anyOf: - type: string @@ -30343,11 +30421,11 @@ components: minLength: 1 maxLength: 40 additionalProperties: false - description: Partial edit of a dose; an omitted key leaves the column untouched, and a body naming nothing is a 422. + description: "Partial edit of a dose; an omitted key leaves the column untouched, and a body naming nothing is a 422. `occurredAt` is editable because a transcription typo is the common case — and editing it deliberately does NOT re-run the booster satisfaction, so correcting a date can never move a reminder's due date. An edit that would - leave the record with neither identity arm is refused. A present `documentIds` array replaces the links, empty - array included. + leave the record with no identity arm (slug, own definition or name) is refused. `customVaccineId: null` unlinks + the dose from its own definition. A present `documentIds` array replaces the links, empty array included." VaccinationBoosterRequest: type: object properties: @@ -33724,16 +33802,18 @@ components: symptoms.definition.notFound, symptoms.episode.notOpen, symptoms.event.notFound, symptoms.invalid; tokens — tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached, tokens.workouts.ceiling_reached; vaccination — vaccination.booster-invalid, - vaccination.booster-no-antigen, vaccination.booster-out-of-scope, vaccination.encounter-not-found, - vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found, - vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large, - workout.batch.source_not_permitted, workout.batch.too_large, workout.delete.synced_source; (unprefixed) - — about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file, + vaccination.booster-no-antigen, vaccination.booster-out-of-scope, vaccination.custom-vaccine-not-found, + vaccination.custom.invalid, vaccination.custom.name-taken, vaccination.custom.not-found, + vaccination.encounter-not-found, vaccination.identity-required, vaccination.invalid, + vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout — + workout.batch.invalid, workout.batch.payload_too_large, workout.batch.source_not_permitted, + workout.batch.too_large, workout.delete.synced_source; (unprefixed) — about_me_conflict, + ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file, health_score_config_conflict, insights_layout_conflict, invalid_base_updated_at, json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, missing_required_columns, - modules_conflict, mood_tag_layout_conflict, not_configured, not_implemented, - notification_prefs_conflict, oidc_only, private_origin_not_approved, private_origin_not_grantable, - rate_limited_self, too_many_rows, unreadable_json, upstream_invalid_json, vapid_not_configured" + modules_conflict, mood_tag_layout_conflict, not_configured, notification_prefs_conflict, oidc_only, + private_origin_not_approved, private_origin_not_grantable, rate_limited_self, too_many_rows, + unreadable_json, upstream_invalid_json, vapid_not_configured" type: string capability: description: "On an AI refusal: the capability that is unavailable, one of the keys of `AiCapabilities` (`coach`, @@ -39681,6 +39761,51 @@ components: - targetId - createdAt additionalProperties: {} + customVaccines: + default: [] + type: array + items: + type: object + properties: + id: + type: string + minLength: 1 + name: + type: string + minLength: 1 + components: + type: array + items: + type: string + typicalSeriesDoses: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + boosterIntervalMonths: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + createdAt: + type: string + minLength: 1 + updatedAt: + type: string + minLength: 1 + deletedAt: + anyOf: + - type: string + minLength: 1 + - type: "null" + required: + - id + - name + - createdAt + - updatedAt + additionalProperties: {} vaccinations: default: [] type: array @@ -41009,6 +41134,7 @@ components: - encounterDocumentLinks - encounterLabLinks - encounterConditionLinks + - customVaccines - vaccinations - vaccinationDocumentLinks - coachConversations @@ -68942,7 +69068,7 @@ components: type: array items: type: string - description: Catalogue antigen slugs the product covers. + description: Catalogue antigen slugs the product protects against, only ones this release knows. typicalSeriesDoses: anyOf: - type: integer @@ -68972,6 +69098,10 @@ components: - createdAt - updatedAt additionalProperties: false + description: A vaccine the record's owner defined for a product the shipped catalogue does not list, in the shape a + catalogue entry resolves to. A dose logged against it counts into the series of each listed antigen, settles a + booster reminder keyed to one of them and offers one from `boosterIntervalMonths`, exactly as a catalogue pick + does. `name` is the person's own wording. CreateCustomVaccineEnvelope: type: object properties: @@ -69115,6 +69245,17 @@ components: anyOf: - $ref: "#/components/schemas/VaccinationCatalogEntry" - type: "null" + customVaccineId: + anyOf: + - type: string + - type: "null" + description: The record's own vaccine definition the dose names (v1.42), verbatim. + customVaccine: + anyOf: + - $ref: "#/components/schemas/CustomVaccine" + - type: "null" + description: That definition, resolved, or null when the dose names none or it was removed. When `catalogEntry` is also + set, the catalogue is the answer. series: type: array items: @@ -69153,6 +69294,8 @@ components: - lotNumber - site - catalogEntry + - customVaccineId + - customVaccine - series - practitioner - encounter diff --git a/messages/de.json b/messages/de.json index 5dae1f00d..742c37f82 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9611,8 +9611,8 @@ }, "form": { "date": "Datum", - "catalogLabel": "Impfstoff (Katalog)", - "identityHint": "Katalog wählen oder unten frei eintragen — eins genügt.", + "catalogLabel": "Impfstoff", + "identityHint": "Aus der Liste wählen oder unten frei eintragen. Eins genügt.", "catalogNone": "Impfstoff wählen", "catalogSearch": "Impfstoff suchen", "catalogNoMatch": "Kein Katalogeintrag gefunden", @@ -9744,7 +9744,15 @@ "deleted": "Impfstoff entfernt", "deleteConfirm": "Diesen Impfstoff entfernen? Bereits eingetragene Impfungen behalten ihren Namen.", "saveFailed": "Der Impfstoff konnte nicht gespeichert werden.", - "nameTaken": "Du hast schon einen Impfstoff mit diesem Namen." + "nameTaken": "Du hast schon einen Impfstoff mit diesem Namen.", + "editTitle": "Eigenen Impfstoff bearbeiten", + "description": "Ein Impfstoff, den der Katalog nicht kennt, gezählt wie jeder Katalogeintrag.", + "componentsHint": "Alle Krankheiten wählen, vor denen er schützt.", + "boosterHint": "Leer lassen, wenn keine Auffrischung nötig ist.", + "protectsAgainst": "Schützt vor {list}.", + "deleteTitle": "Eigenen Impfstoff entfernen?", + "sectionDescription": "Selbst angelegte Impfstoffe, die der Katalog nicht kennt.", + "catalogGroup": "Katalog" } }, "illness": { @@ -11786,7 +11794,10 @@ "symptomEventDelete": "{name} hat einen Symptom-Eintrag gelöscht", "symptomDefinitionCreate": "{name} hat ein Symptom angelegt", "symptomDefinitionUpdate": "{name} hat ein Symptom geändert", - "symptomDefinitionDelete": "{name} hat ein Symptom entfernt" + "symptomDefinitionDelete": "{name} hat ein Symptom entfernt", + "customVaccineCreate": "{name} hat einen eigenen Impfstoff angelegt", + "customVaccineUpdate": "{name} hat einen eigenen Impfstoff bearbeitet", + "customVaccineDelete": "{name} hat einen eigenen Impfstoff entfernt" }, "section": { "measurements": { diff --git a/messages/en.json b/messages/en.json index 18a19ecce..83db99a11 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9611,8 +9611,8 @@ }, "form": { "date": "Date", - "catalogLabel": "Vaccine (catalogue)", - "identityHint": "Pick from the catalogue or type it in below — either is enough.", + "catalogLabel": "Vaccine", + "identityHint": "Pick a vaccine from the list or type it in below. Either is enough.", "catalogNone": "Choose a vaccine", "catalogSearch": "Search vaccines", "catalogNoMatch": "No catalogue entry found", @@ -9744,7 +9744,15 @@ "deleted": "Vaccine removed", "deleteConfirm": "Remove this vaccine? Doses already logged keep their name.", "saveFailed": "The vaccine could not be saved.", - "nameTaken": "You already have a vaccine with this name." + "nameTaken": "You already have a vaccine with this name.", + "editTitle": "Edit own vaccine", + "description": "A vaccine the catalogue does not list, counted like any catalogue entry.", + "componentsHint": "Choose every disease it protects against.", + "boosterHint": "Leave empty if there is no booster.", + "protectsAgainst": "Protects against {list}.", + "deleteTitle": "Remove own vaccine?", + "sectionDescription": "Vaccines you added yourself because the catalogue does not list them.", + "catalogGroup": "Catalogue" } }, "illness": { @@ -11786,7 +11794,10 @@ "symptomEventDelete": "{name} deleted a symptom entry", "symptomDefinitionCreate": "{name} added a symptom", "symptomDefinitionUpdate": "{name} changed a symptom", - "symptomDefinitionDelete": "{name} removed a symptom" + "symptomDefinitionDelete": "{name} removed a symptom", + "customVaccineCreate": "{name} added an own vaccine", + "customVaccineUpdate": "{name} edited an own vaccine", + "customVaccineDelete": "{name} removed an own vaccine" }, "section": { "measurements": { diff --git a/messages/es.json b/messages/es.json index d62c428d6..d98447bbc 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9611,8 +9611,8 @@ }, "form": { "date": "Fecha", - "catalogLabel": "Vacuna (catálogo)", - "identityHint": "Elige del catálogo o escríbela abajo: basta con una.", + "catalogLabel": "Vacuna", + "identityHint": "Elige una vacuna de la lista o escríbela abajo. Basta con una.", "catalogNone": "Elegir una vacuna", "catalogSearch": "Buscar vacunas", "catalogNoMatch": "No se encontró ninguna entrada del catálogo", @@ -9744,7 +9744,15 @@ "deleted": "Vacuna eliminada", "deleteConfirm": "¿Eliminar esta vacuna? Las dosis ya registradas conservan su nombre.", "saveFailed": "No se pudo guardar la vacuna.", - "nameTaken": "Ya tienes una vacuna con este nombre." + "nameTaken": "Ya tienes una vacuna con este nombre.", + "editTitle": "Editar vacuna propia", + "description": "Una vacuna que el catálogo no recoge, contada como cualquier entrada del catálogo.", + "componentsHint": "Elige todas las enfermedades de las que protege.", + "boosterHint": "Déjalo vacío si no hay refuerzo.", + "protectsAgainst": "Protege contra {list}.", + "deleteTitle": "¿Quitar la vacuna propia?", + "sectionDescription": "Vacunas que añadiste tú porque el catálogo no las recoge.", + "catalogGroup": "Catálogo" } }, "illness": { @@ -11786,7 +11794,10 @@ "symptomEventDelete": "{name} eliminó una entrada de síntoma", "symptomDefinitionCreate": "{name} añadió un síntoma", "symptomDefinitionUpdate": "{name} cambió un síntoma", - "symptomDefinitionDelete": "{name} quitó un síntoma" + "symptomDefinitionDelete": "{name} quitó un síntoma", + "customVaccineCreate": "{name} añadió una vacuna propia", + "customVaccineUpdate": "{name} editó una vacuna propia", + "customVaccineDelete": "{name} quitó una vacuna propia" }, "section": { "measurements": { diff --git a/messages/fr.json b/messages/fr.json index 0e919cfcd..1435313ee 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9611,8 +9611,8 @@ }, "form": { "date": "Date", - "catalogLabel": "Vaccin (catalogue)", - "identityHint": "Choisissez dans le catalogue ou saisissez ci-dessous : l'un suffit.", + "catalogLabel": "Vaccin", + "identityHint": "Choisissez un vaccin dans la liste ou saisissez-le ci-dessous. L'un suffit.", "catalogNone": "Choisir un vaccin", "catalogSearch": "Rechercher des vaccins", "catalogNoMatch": "Aucune entrée du catalogue trouvée", @@ -9744,7 +9744,15 @@ "deleted": "Vaccin supprimé", "deleteConfirm": "Supprimer ce vaccin ? Les doses déjà saisies gardent leur nom.", "saveFailed": "Le vaccin n’a pas pu être enregistré.", - "nameTaken": "Tu as déjà un vaccin portant ce nom." + "nameTaken": "Tu as déjà un vaccin portant ce nom.", + "editTitle": "Modifier le vaccin personnel", + "description": "Un vaccin absent du catalogue, compté comme n'importe quelle entrée du catalogue.", + "componentsHint": "Choisissez chaque maladie contre laquelle il protège.", + "boosterHint": "Laissez vide s'il n'y a pas de rappel.", + "protectsAgainst": "Protège contre {list}.", + "deleteTitle": "Retirer ce vaccin personnel ?", + "sectionDescription": "Les vaccins que vous avez ajoutés parce que le catalogue ne les connaît pas.", + "catalogGroup": "Catalogue" } }, "illness": { @@ -11786,7 +11794,10 @@ "symptomEventDelete": "{name} a supprimé une entrée de symptôme", "symptomDefinitionCreate": "{name} a ajouté un symptôme", "symptomDefinitionUpdate": "{name} a modifié un symptôme", - "symptomDefinitionDelete": "{name} a retiré un symptôme" + "symptomDefinitionDelete": "{name} a retiré un symptôme", + "customVaccineCreate": "{name} a ajouté un vaccin personnel", + "customVaccineUpdate": "{name} a modifié un vaccin personnel", + "customVaccineDelete": "{name} a retiré un vaccin personnel" }, "section": { "measurements": { diff --git a/messages/it.json b/messages/it.json index f45e5f433..86bac5f7e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9611,8 +9611,8 @@ }, "form": { "date": "Data", - "catalogLabel": "Vaccino (catalogo)", - "identityHint": "Scegli dal catalogo o scrivilo sotto: ne basta uno.", + "catalogLabel": "Vaccino", + "identityHint": "Scegli un vaccino dall'elenco o scrivilo sotto. Ne basta uno.", "catalogNone": "Scegli un vaccino", "catalogSearch": "Cerca vaccini", "catalogNoMatch": "Nessuna voce del catalogo trovata", @@ -9744,7 +9744,15 @@ "deleted": "Vaccino rimosso", "deleteConfirm": "Rimuovere questo vaccino? Le dosi già registrate mantengono il loro nome.", "saveFailed": "Impossibile salvare il vaccino.", - "nameTaken": "Hai già un vaccino con questo nome." + "nameTaken": "Hai già un vaccino con questo nome.", + "editTitle": "Modifica vaccino personale", + "description": "Un vaccino assente dal catalogo, contato come qualsiasi voce del catalogo.", + "componentsHint": "Scegli tutte le malattie da cui protegge.", + "boosterHint": "Lascia vuoto se non serve un richiamo.", + "protectsAgainst": "Protegge da {list}.", + "deleteTitle": "Rimuovere il vaccino personale?", + "sectionDescription": "Vaccini aggiunti da te perché il catalogo non li elenca.", + "catalogGroup": "Catalogo" } }, "illness": { @@ -11786,7 +11794,10 @@ "symptomEventDelete": "{name} ha eliminato una voce di sintomo", "symptomDefinitionCreate": "{name} ha aggiunto un sintomo", "symptomDefinitionUpdate": "{name} ha modificato un sintomo", - "symptomDefinitionDelete": "{name} ha rimosso un sintomo" + "symptomDefinitionDelete": "{name} ha rimosso un sintomo", + "customVaccineCreate": "{name} ha aggiunto un vaccino personale", + "customVaccineUpdate": "{name} ha modificato un vaccino personale", + "customVaccineDelete": "{name} ha rimosso un vaccino personale" }, "section": { "measurements": { diff --git a/messages/ko.json b/messages/ko.json index d1984258e..d155f313f 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -9611,7 +9611,7 @@ }, "form": { "date": "날짜", - "catalogLabel": "백신 (목록)", + "catalogLabel": "백신", "identityHint": "목록에서 고르거나 아래에 직접 입력해 주세요. 둘 중 하나면 충분해요.", "catalogNone": "백신 선택", "catalogSearch": "백신 검색", @@ -9744,7 +9744,15 @@ "deleted": "백신을 삭제했습니다", "deleteConfirm": "이 백신을 삭제할까요? 이미 기록된 접종은 이름이 그대로 남습니다.", "saveFailed": "백신을 저장하지 못했습니다.", - "nameTaken": "같은 이름의 백신이 이미 있습니다." + "nameTaken": "같은 이름의 백신이 이미 있습니다.", + "editTitle": "직접 추가한 백신 수정", + "description": "목록에 없는 백신도 목록의 백신과 똑같이 계산돼요.", + "componentsHint": "예방하는 질병을 모두 골라 주세요.", + "boosterHint": "추가 접종이 없으면 비워 두세요.", + "protectsAgainst": "{list} 예방", + "deleteTitle": "직접 추가한 백신을 삭제할까요?", + "sectionDescription": "목록에 없어서 직접 추가한 백신이에요.", + "catalogGroup": "목록" } }, "illness": { @@ -11786,7 +11794,10 @@ "symptomEventDelete": "{name}님이 증상 기록을 삭제했습니다", "symptomDefinitionCreate": "{name}님이 증상을 추가했습니다", "symptomDefinitionUpdate": "{name}님이 증상을 변경했습니다", - "symptomDefinitionDelete": "{name}님이 증상을 제거했습니다" + "symptomDefinitionDelete": "{name}님이 증상을 제거했습니다", + "customVaccineCreate": "{name} 님이 백신을 직접 추가했어요", + "customVaccineUpdate": "{name} 님이 직접 추가한 백신을 수정했어요", + "customVaccineDelete": "{name} 님이 직접 추가한 백신을 삭제했어요" }, "section": { "measurements": { diff --git a/messages/pl.json b/messages/pl.json index 20f2c3aa7..a250d050d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9611,8 +9611,8 @@ }, "form": { "date": "Data", - "catalogLabel": "Szczepionka (katalog)", - "identityHint": "Wybierz z katalogu lub wpisz poniżej — wystarczy jedno.", + "catalogLabel": "Szczepionka", + "identityHint": "Wybierz szczepionkę z listy lub wpisz ją poniżej. Wystarczy jedno.", "catalogNone": "Wybierz szczepionkę", "catalogSearch": "Szukaj szczepionek", "catalogNoMatch": "Nie znaleziono wpisu w katalogu", @@ -9744,7 +9744,15 @@ "deleted": "Szczepionka usunięta", "deleteConfirm": "Usunąć tę szczepionkę? Zapisane już dawki zachowają swoją nazwę.", "saveFailed": "Nie udało się zapisać szczepionki.", - "nameTaken": "Masz już szczepionkę o tej nazwie." + "nameTaken": "Masz już szczepionkę o tej nazwie.", + "editTitle": "Edytuj własną szczepionkę", + "description": "Szczepionka spoza katalogu, liczona jak każdy wpis z katalogu.", + "componentsHint": "Wybierz wszystkie choroby, przed którymi chroni.", + "boosterHint": "Zostaw puste, jeśli nie ma dawki przypominającej.", + "protectsAgainst": "Chroni przed: {list}.", + "deleteTitle": "Usunąć własną szczepionkę?", + "sectionDescription": "Szczepionki dodane przez Ciebie, bo nie ma ich w katalogu.", + "catalogGroup": "Katalog" } }, "illness": { @@ -11786,7 +11794,10 @@ "symptomEventDelete": "{name} usunął(-ęła) wpis objawu", "symptomDefinitionCreate": "{name} dodał(a) objaw", "symptomDefinitionUpdate": "{name} zmienił(a) objaw", - "symptomDefinitionDelete": "{name} usunął(-ęła) objaw" + "symptomDefinitionDelete": "{name} usunął(-ęła) objaw", + "customVaccineCreate": "{name} dodał własną szczepionkę", + "customVaccineUpdate": "{name} zmienił własną szczepionkę", + "customVaccineDelete": "{name} usunął własną szczepionkę" }, "section": { "measurements": { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9b23caf54..17a45b90d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9330,9 +9330,11 @@ model VaccinationRecord { /// `lastSatisfiedAt` per reminder, this keeps the first match in catalogue /// component order. reminderId String? @map("reminder_id") - /// @pending-consumer(#1005) v1.42 — the user's own vaccine definition when - /// the dose is for a product the catalogue does not list. Catalogue wins - /// when both resolve. SetNull on delete: the dose keeps its `vaccineName`. + /// v1.42 (#1005) — the user's own vaccine definition when the dose is for a + /// product the catalogue does not list, resolved through + /// `src/lib/vaccinations/resolve-vaccine-entry.ts`. Catalogue wins when both + /// resolve. Removing the definition unlinks the dose and leaves it a name + /// (`removeCustomVaccine`); SetNull covers a hard delete the same way. customVaccineId String? @map("custom_vaccine_id") /// The one free-text field (AES-256-GCM at rest, the `*Encrypted` Bytes /// convention). Impfreaktionen live here in v1 rather than in a structured @@ -9365,13 +9367,14 @@ model VaccinationRecord { model CustomVaccine { id String @id @default(cuid()) userId String @map("user_id") - /// @pending-consumer(#1005) Display name, unique per user. + /// Display name, unique per user (the API also refuses a case-only twin). name String - /// @pending-consumer(#1005) Antigen slugs the product covers. + /// Antigen slugs the product covers, from `ANTIGEN_SLUGS`; storage is + /// tolerant, the resolver skips a slug it does not know. components String[] - /// @pending-consumer(#1005) Typical primary-series length. + /// Typical primary-series length; the series total for its doses. typicalSeriesDoses Int? @map("typical_series_doses") - /// @pending-consumer(#1005) Booster interval in months; NULL = no booster. + /// Booster interval in months, the booster offer's prefill; NULL = none. boosterIntervalMonths Int? @map("booster_interval_months") /// Soft-delete tombstone. NULL = live. deletedAt DateTime? @map("deleted_at") diff --git a/src/__tests__/i18n-reverse-coverage.test.ts b/src/__tests__/i18n-reverse-coverage.test.ts index 92917bc04..5dd71bf64 100644 --- a/src/__tests__/i18n-reverse-coverage.test.ts +++ b/src/__tests__/i18n-reverse-coverage.test.ts @@ -83,7 +83,6 @@ const RELEASE_PENDING_PREFIXES: Record = { "insights.workouts.manual.editTitle": "v1.42 manual workout edit (#1162)", "insights.workouts.manual.editAction": "v1.42 manual workout edit (#1162)", "insights.workouts.manual.updated": "v1.42 manual workout edit (#1162)", - "vaccinations.custom": "v1.42 user-defined vaccines (#1005)", "labs.convertedFrom": "v1.42 lab unit conversion note (#1095)", }; diff --git a/src/__tests__/record-write-rate-limit-contract.test.ts b/src/__tests__/record-write-rate-limit-contract.test.ts index 55c327147..3330c46c4 100644 --- a/src/__tests__/record-write-rate-limit-contract.test.ts +++ b/src/__tests__/record-write-rate-limit-contract.test.ts @@ -58,6 +58,7 @@ const ON_THE_SHARED_BUCKET: Array<[route: string, openApiPath: string]> = [ ], ["src/app/api/cycle/day-logs/route.ts", "/api/cycle/day-logs"], ["src/app/api/vaccinations/route.ts", "/api/vaccinations"], + ["src/app/api/vaccinations/custom/route.ts", "/api/vaccinations/custom"], ["src/app/api/allergies/route.ts", "/api/allergies"], ["src/app/api/biomarkers/route.ts", "/api/biomarkers"], ["src/app/api/encounters/route.ts", "/api/encounters"], diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index e2177cc3e..d156dffeb 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -971,6 +971,14 @@ const DELEGABLE_ROUTES: Record = { domain: "profile", why: "Which of the record's doses a scanned page dated around a given day belongs to. A read over the same rows the immunization list serves, reduced to a verdict — the caller learns nothing it could not learn from the list itself, and the anchor it passes is a date rather than an id, so it cannot address a row.", }, + "app/api/vaccinations/custom/route.ts": { + domain: "profile", + why: "The record's own vaccine definitions: a name, the antigens from the catalogue's closed list, a series length and a booster interval. Part of the immunization history the dose list already shows a `profile` grant, and it names no credential, no integration and no notification channel. Every row is read and written under the resolved user id. The create arm is a delegable write.", + }, + "app/api/vaccinations/custom/[id]/route.ts": { + domain: "profile", + why: "One of the record's own vaccine definitions, fetch-then-guard against the resolved user. Reached only through its MANAGE arms; a definition another record holds is a 404 like any foreign id.", + }, "app/api/practitioners/route.ts": { domain: "profile", why: "The record's own address book of doctors and practices. Record content, not account configuration — it touches no credential, no integration and no notification channel, which is the fence the classification turns on. The create arm is a delegable write.", @@ -1457,6 +1465,8 @@ const DELEGABLE_WRITE_ROUTES: Record = { "Logging a dose. Every id the body may carry — the practitioner, the visit, the pages to file it against — is re-narrowed to the resolved record before anything is written, so a delegate cannot attach one record's scan to another's dose. The booster reminders it clears are the RECORD's, which is correct: it is the owner's booster plan, and a helper transcribing an Impfpass is doing exactly the work that should settle it.", "app/api/vaccinations/[id]/booster/route.ts": "Confirming the booster reminder a dose suggests. The reminder is minted under the RECORD and keyed on the antigen the server reads from the dose's catalogue entry, never from the body, so a delegate cannot point it at an antigen the dose does not contain; a second confirmation re-anchors the one reminder rather than minting another.", + "app/api/vaccinations/custom/route.ts": + "Adding a vaccine definition to the record. It lands under the RECORD, which is correct: it is the owner's Impfpass a helper is transcribing, and the doses logged against it are the owner's. The rate bucket keys on the ACTOR, and a name the record already holds is the ordinary 409 the owner would hit themselves.", "app/api/medications/[id]/side-effects/route.ts": "Recording a side effect. Admitted on one condition, met at the call site: the POST rate bucket keys on the ACTOR, so a delegate burns their own allowance rather than the owner's and cannot collect a fresh one by switching records.", "app/api/medications/route.ts": @@ -1603,6 +1613,11 @@ const DELEGABLE_MANAGE_ROUTES: Record = { conditions: [], why: "Reopening a deleted dose. The document links survived the tombstone, so it comes back with its page still filed against it.", }, + "app/api/vaccinations/custom/[id]/route.ts": { + domain: "profile", + conditions: ["C4"], + why: "Correcting and removing one vaccine definition. The removal soft-deletes it and lets go of the doses that named it, each keeping a name, so no dose leaves the record; the audit row names the definition and how many doses it let go. C4 on the edit: the antigens, the series length and the booster interval are filed before and after, and a rename is named, never quoted. Neither verb re-runs the booster satisfy matcher, so a delegate cannot move a booster's due date by correcting a definition.", + }, "app/api/illness/episodes/[id]/resolve/route.ts": { domain: "illness", conditions: [], @@ -2010,8 +2025,12 @@ const ACTOR_ROUTES: Record = { * list, the occurrence create on the write literal, and the definition create, * the definition edit/delete and the occurrence edit/delete on the manage * literal. 232 -> 242 with both. + * + * v1.42 -- the person's own vaccine definitions (#1005) add four: both + * modules on the record list, the create on the write literal and the + * edit/delete on the manage literal. 242 -> 246. */ -const FROZEN_ENTRY_COUNT = 242; +const FROZEN_ENTRY_COUNT = 246; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — @@ -2772,7 +2791,7 @@ describe("(g) the MANAGE route set is frozen", () => { it("keeps the admitted mutation inventory complete and discoverable", () => { expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0); - expect(ADMITTED_MUTATING_HANDLERS.length).toBe(84); + expect(ADMITTED_MUTATING_HANDLERS.length).toBe(87); const expected = ADMITTED_MUTATING_HANDLERS.map( ({ handlerModule, action, level }) => diff --git a/src/__tests__/v137-wave-zero-fixtures.test.ts b/src/__tests__/v137-wave-zero-fixtures.test.ts index 860b45b45..82ba66592 100644 --- a/src/__tests__/v137-wave-zero-fixtures.test.ts +++ b/src/__tests__/v137-wave-zero-fixtures.test.ts @@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => { }); it("keeps every admitted mutation uniquely addressable and fully controlled", () => { - expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(84); + expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(87); expect( new Set( ADMITTED_MUTATING_HANDLERS.map( diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index 392263eff..2d5f00af6 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -50,6 +50,7 @@ vi.mock("@/lib/db", () => ({ encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) }, encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) }, + customVaccine: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, // The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the // same reason as the visit tables above. @@ -158,6 +159,7 @@ beforeEach(() => { [] as never, ); vi.mocked(prisma.vaccinationRecord.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.customVaccine.findMany).mockResolvedValue([] as never); vi.mocked(prisma.vaccinationDocumentLink.findMany).mockResolvedValue( [] as never, ); diff --git a/src/app/api/export/__tests__/soft-delete-filter.test.ts b/src/app/api/export/__tests__/soft-delete-filter.test.ts index 0e04c6bba..2e859e00a 100644 --- a/src/app/api/export/__tests__/soft-delete-filter.test.ts +++ b/src/app/api/export/__tests__/soft-delete-filter.test.ts @@ -88,6 +88,7 @@ vi.mock("@/lib/db", () => ({ measurementReminder: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) }, + customVaccine: { findMany: vi.fn().mockResolvedValue([]) }, encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) }, encounterDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index 8c01d3a7b..04294c2a3 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -42,6 +42,7 @@ vi.mock("@/lib/db", () => ({ encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) }, encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) }, + customVaccine: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, // The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the // same reason as the visit tables above. diff --git a/src/app/api/vaccinations/[id]/route.ts b/src/app/api/vaccinations/[id]/route.ts index 1b85c023d..376016267 100644 --- a/src/app/api/vaccinations/[id]/route.ts +++ b/src/app/api/vaccinations/[id]/route.ts @@ -34,6 +34,7 @@ import { VACCINATION_INCLUDE, applyVaccinationLinks, loadVaccinationDocuments, + resolveOwnedCustomVaccine, resolveOwnedEncounter, resolveOwnedPractitioner, resolveSeriesFor, @@ -116,9 +117,13 @@ export const PATCH = apiHandler( entry.vaccineName !== undefined ? entry.vaccineName : existing.vaccineName; - if (!nextSlug && !nextName) { + const nextCustom = + entry.customVaccineId !== undefined + ? entry.customVaccineId + : existing.customVaccineId; + if (!nextSlug && !nextName && !nextCustom) { return apiError( - "A dose keeps a name: pick one from the catalogue or type what the record says", + "A dose keeps a name: pick one from the catalogue or your own vaccines, or type what the record says", 422, { errorCode: "vaccination.identity-required" }, ); @@ -141,6 +146,14 @@ export const PATCH = apiHandler( ); if (!owned) return "unknown-encounter" as const; } + if (entry.customVaccineId) { + const owned = await resolveOwnedCustomVaccine( + tx, + user.id, + entry.customVaccineId, + ); + if (!owned) return "unknown-custom-vaccine" as const; + } const data: Prisma.VaccinationRecordUpdateInput = {}; if (entry.occurredAt !== undefined) { @@ -165,6 +178,11 @@ export const PATCH = apiHandler( ? { connect: { id: entry.encounterId } } : { disconnect: true }; } + if (entry.customVaccineId !== undefined) { + data.customVaccine = entry.customVaccineId + ? { connect: { id: entry.customVaccineId } } + : { disconnect: true }; + } const updated = await tx.vaccinationRecord.update({ where: { id }, @@ -184,6 +202,11 @@ export const PATCH = apiHandler( errorCode: "vaccination.encounter-not-found", }); } + if (outcome === "unknown-custom-vaccine") { + return apiError("Vaccine not found", 404, { + errorCode: "vaccination.custom-vaccine-not-found", + }); + } const row = await prisma.vaccinationRecord.findUniqueOrThrow({ where: { id }, @@ -209,6 +232,7 @@ export const PATCH = apiHandler( doseNumber: existing.doseNumber, practitionerId: existing.practitionerId, encounterId: existing.encounterId, + customVaccineId: existing.customVaccineId, }, after: { occurredAt: outcome.updated.occurredAt, @@ -216,6 +240,7 @@ export const PATCH = apiHandler( doseNumber: outcome.updated.doseNumber, practitionerId: outcome.updated.practitionerId, encounterId: outcome.updated.encounterId, + customVaccineId: outcome.updated.customVaccineId, }, }), }, @@ -227,7 +252,10 @@ export const PATCH = apiHandler( entity_type: "vaccination", entity_id: id, }, - meta: { antigen_slug: row.antigenSlug ?? "free-text" }, + meta: { + antigen_slug: + row.antigenSlug ?? (row.customVaccineId ? "custom" : "free-text"), + }, }); return apiSuccess(toVaccinationDTO(row, series.get(id) ?? [], documents)); diff --git a/src/app/api/vaccinations/custom/[id]/route.ts b/src/app/api/vaccinations/custom/[id]/route.ts index 069ef1d2f..777b61a96 100644 --- a/src/app/api/vaccinations/custom/[id]/route.ts +++ b/src/app/api/vaccinations/custom/[id]/route.ts @@ -1,22 +1,190 @@ /** - * `PATCH /api/vaccinations/custom/[id]` edits one of the record's own vaccine - * definitions; `DELETE` soft-deletes it, leaving every dose logged against it - * on its `vaccineName` (v1.42, #1005). Record-scoped like the dose log. + * `PATCH /api/vaccinations/custom/{id}` — edit one of the record's own + * vaccine definitions. + * `DELETE /api/vaccinations/custom/{id}` — remove it (v1.42, #1005). * - * Contract stub: owner-only until implemented, then answers 501. The - * finished routes resolve the record through `requireRecordAuth` like the - * dose log and join the sharing guard's delegable lists with their audit - * rows; a stub cannot carry those, so it does not claim the admission. + * MANAGE, classified `profile`, the same split as the dose edit and delete. + * + * An edit is a correction of the definition and applies to every dose logged + * against it: the series those doses sit in is derived on read, so changing + * the antigens or the series length re-reads them, exactly as a corrected + * catalogue entry would. It deliberately does not re-run the booster satisfy + * matcher — that belongs to the act of logging a dose, which happened once. + * + * The removal soft-deletes the definition and lets go of every dose that + * named it; each dose keeps a name (`removeCustomVaccine`). Owner-scoped by + * fetch-then-guard; `userId` is narrowed from auth and never a body field. */ -import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { notImplemented } from "@/lib/http/not-implemented"; +import { NextRequest } from "next/server"; -export const PATCH = apiHandler(async () => { - await requireAuth(); - return notImplemented(); -}); +import { prisma } from "@/lib/db"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { overwriteDetails } from "@/lib/sharing/audit-details"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { customVaccineUpdateSchema } from "@/lib/validations/vaccinations"; +import { toCustomVaccineDTO } from "@/lib/vaccinations/dto"; +import { + CUSTOM_VACCINE_SELECT, + findLiveNameClash, + removeCustomVaccine, +} from "@/lib/vaccinations/custom-vaccines"; +import type { Prisma } from "@/generated/prisma/client"; -export const DELETE = apiHandler(async () => { - await requireAuth(); - return notImplemented(); -}); +type RouteParams = { params: Promise<{ id: string }> }; + +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireRecordAuth("manage", "profile"); + + const { id } = await params; + const existing = await prisma.customVaccine.findUnique({ where: { id } }); + if ( + !existing || + existing.userId !== user.id || + existing.deletedAt !== null + ) { + return apiError("Vaccine not found", 404, { + errorCode: "vaccination.custom.not-found", + }); + } + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 8 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = customVaccineUpdateSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "vaccination.custom.invalid", + }); + } + const entry = parsed.data; + + const outcome = await prisma.$transaction(async (tx) => { + if ( + entry.name !== undefined && + entry.name !== existing.name && + (await findLiveNameClash(tx, user.id, entry.name, id)) + ) { + return "name-taken" as const; + } + // A removed definition may still hold the new name on the unique + // index. It is gone from the person's view, so it gives the name up + // rather than blocking the rename. + if (entry.name !== undefined && entry.name !== existing.name) { + await tx.customVaccine.deleteMany({ + where: { + userId: user.id, + name: entry.name, + deletedAt: { not: null }, + }, + }); + } + // Field-by-field — never spread the parsed object whole. + const data: Prisma.CustomVaccineUpdateInput = {}; + if (entry.name !== undefined) data.name = entry.name; + if (entry.components !== undefined) data.components = entry.components; + if (entry.typicalSeriesDoses !== undefined) { + data.typicalSeriesDoses = entry.typicalSeriesDoses; + } + if (entry.boosterIntervalMonths !== undefined) { + data.boosterIntervalMonths = entry.boosterIntervalMonths; + } + return tx.customVaccine.update({ + where: { id }, + data, + select: CUSTOM_VACCINE_SELECT, + }); + }); + + if (outcome === "name-taken") { + return apiError("A vaccine with this name already exists", 409, { + errorCode: "vaccination.custom.name-taken", + }); + } + + await auditLog("vaccination.custom.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + customVaccineId: id, + // The name is the person's own wording: a rename is named in + // `fields`, never quoted. + ...overwriteDetails({ + before: { + components: existing.components.join(","), + typicalSeriesDoses: existing.typicalSeriesDoses, + boosterIntervalMonths: existing.boosterIntervalMonths, + }, + after: { + components: outcome.components.join(","), + typicalSeriesDoses: outcome.typicalSeriesDoses, + boosterIntervalMonths: outcome.boosterIntervalMonths, + }, + redacted: outcome.name !== existing.name ? ["name"] : [], + }), + }, + }); + + annotate({ + action: { + name: "vaccination.custom.update", + entity_type: "custom-vaccine", + entity_id: id, + }, + }); + + return apiSuccess(toCustomVaccineDTO(outcome)); + }, +); + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireRecordAuth("manage", "profile"); + + const { id } = await params; + const existing = await prisma.customVaccine.findUnique({ + where: { id }, + select: { id: true, userId: true, name: true, deletedAt: true }, + }); + if (!existing || existing.userId !== user.id) { + return apiError("Vaccine not found", 404, { + errorCode: "vaccination.custom.not-found", + }); + } + + // Already removed: removing twice is a no-op that still succeeds. + let dosesUnlinked = 0; + if (existing.deletedAt === null) { + dosesUnlinked = await prisma.$transaction((tx) => + removeCustomVaccine(tx, user.id, existing), + ); + } + + await auditLog("vaccination.custom.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { customVaccineId: id, dosesUnlinked }, + }); + + annotate({ + action: { + name: "vaccination.custom.delete", + entity_type: "custom-vaccine", + entity_id: id, + }, + meta: { doses_unlinked: dosesUnlinked }, + }); + + return apiSuccess({ deleted: true }); + }, +); diff --git a/src/app/api/vaccinations/custom/route.ts b/src/app/api/vaccinations/custom/route.ts index 371792b40..79c745473 100644 --- a/src/app/api/vaccinations/custom/route.ts +++ b/src/app/api/vaccinations/custom/route.ts @@ -1,21 +1,140 @@ /** - * `GET /api/vaccinations/custom` lists the record's own vaccine definitions; - * `POST` adds one (v1.42, #1005). Record-scoped like the dose log. + * `GET /api/vaccinations/custom` — the record's own vaccine definitions. + * `POST /api/vaccinations/custom` — add one (v1.42, #1005). * - * Contract stub: owner-only until implemented, then answers 501. The - * finished routes resolve the record through `requireRecordAuth` like the - * dose log and join the sharing guard's delegable lists with their audit - * rows; a stub cannot carry those, so it does not claim the admission. + * A definition is a product the shipped catalogue does not list, described in + * the shape a catalogue entry resolves to: a name, the antigens it protects + * against (from the catalogue's closed antigen list), a typical series length + * and a booster interval. A dose logged against it counts into a series, + * clears a booster and offers one exactly the way a catalogue pick does + * (`src/lib/vaccinations/resolve-vaccine-entry.ts`). + * + * Record-scoped like the dose log and classified `profile` with it: a + * definition is part of the immunization history a delegate granted `profile` + * already sees, names no credential, no integration and no notification + * channel. The list is READ, the create WRITE, the same split as the doses. + * + * `userId` is narrowed from auth and fed to every `where`; it is never a body + * field. The `data` object is built field-by-field. */ -import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { notImplemented } from "@/lib/http/not-implemented"; +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; +import { customVaccineCreateSchema } from "@/lib/validations/vaccinations"; +import { toCustomVaccineDTO } from "@/lib/vaccinations/dto"; +import { + CUSTOM_VACCINE_SELECT, + findLiveNameClash, +} from "@/lib/vaccinations/custom-vaccines"; export const GET = apiHandler(async () => { - await requireAuth(); - return notImplemented(); + const { user } = await requireRecordAuth("read", "profile"); + + const rows = await prisma.customVaccine.findMany({ + where: { userId: user.id, deletedAt: null }, + orderBy: [{ name: "asc" }, { id: "asc" }], + select: CUSTOM_VACCINE_SELECT, + }); + + annotate({ + action: { name: "vaccination.custom.list", entity_type: "custom-vaccine" }, + meta: { count: rows.length }, + }); + + return apiSuccess(rows.map(toCustomVaccineDTO)); }); -export const POST = apiHandler(async () => { - await requireAuth(); - return notImplemented(); +export const POST = apiHandler(async (request: NextRequest) => { + const { user, actor } = await requireRecordAuth("write", "profile"); + + // The shared per-account write ceiling, keyed on the ACTOR so a delegate + // spends their own allowance (`checkRecordWriteRateLimit`). + const writeRl = await checkRecordWriteRateLimit(actor.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); + } + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 8 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = customVaccineCreateSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "vaccination.custom.invalid", + }); + } + const entry = parsed.data; + + const outcome = await prisma.$transaction(async (tx) => { + if (await findLiveNameClash(tx, user.id, entry.name)) { + return "name-taken" as const; + } + // Names are unique per record, removed definitions included, because the + // index is. A name the person removed and now adds again brings that row + // back with the new values rather than failing on the index: deleting + // unlinked every dose, so nothing re-attaches on the way back. + const removed = await tx.customVaccine.findFirst({ + where: { userId: user.id, name: entry.name, deletedAt: { not: null } }, + select: { id: true }, + }); + const data = { + name: entry.name, + components: entry.components, + typicalSeriesDoses: entry.typicalSeriesDoses ?? null, + boosterIntervalMonths: entry.boosterIntervalMonths ?? null, + }; + if (removed) { + return tx.customVaccine.update({ + where: { id: removed.id }, + data: { ...data, deletedAt: null }, + select: CUSTOM_VACCINE_SELECT, + }); + } + return tx.customVaccine.create({ + data: { userId: user.id, ...data }, + select: CUSTOM_VACCINE_SELECT, + }); + }); + + if (outcome === "name-taken") { + return apiError("A vaccine with this name already exists", 409, { + errorCode: "vaccination.custom.name-taken", + }); + } + + await auditLog("vaccination.custom.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + customVaccineId: outcome.id, + components: outcome.components, + boosterIntervalMonths: outcome.boosterIntervalMonths, + }, + }); + + annotate({ + action: { + name: "vaccination.custom.create", + entity_type: "custom-vaccine", + entity_id: outcome.id, + }, + meta: { components: outcome.components.length }, + }); + + return apiSuccess(toCustomVaccineDTO(outcome), 201); }); diff --git a/src/app/api/vaccinations/route.ts b/src/app/api/vaccinations/route.ts index beeb81826..f7cda6128 100644 --- a/src/app/api/vaccinations/route.ts +++ b/src/app/api/vaccinations/route.ts @@ -43,6 +43,7 @@ import { import { VACCINATION_INCLUDE, applyVaccinationLinks, + resolveOwnedCustomVaccine, resolveOwnedEncounter, resolveOwnedPractitioner, resolveOwnerTimezone, @@ -194,6 +195,14 @@ async function postVaccination(request: NextRequest): Promise { const owned = await resolveOwnedEncounter(tx, user.id, entry.encounterId); if (!owned) return "unknown-encounter" as const; } + if (entry.customVaccineId) { + const owned = await resolveOwnedCustomVaccine( + tx, + user.id, + entry.customVaccineId, + ); + if (!owned) return "unknown-custom-vaccine" as const; + } // Field-by-field — never spread the parsed object whole. const row = await tx.vaccinationRecord.create({ @@ -208,6 +217,7 @@ async function postVaccination(request: NextRequest): Promise { site: entry.site ?? null, practitionerId: entry.practitionerId ?? null, encounterId: entry.encounterId ?? null, + customVaccineId: entry.customVaccineId ?? null, noteEncrypted: entry.note ? encryptToBytes(entry.note) : null, }, select: { id: true }, @@ -223,7 +233,10 @@ async function postVaccination(request: NextRequest): Promise { const boosters = await satisfyBoostersForDose( tx, user.id, - entry.antigenSlug ?? null, + { + antigenSlug: entry.antigenSlug ?? null, + customVaccineId: entry.customVaccineId ?? null, + }, occurredAt, timezone, ); @@ -251,6 +264,11 @@ async function postVaccination(request: NextRequest): Promise { errorCode: "vaccination.encounter-not-found", }); } + if (created === "unknown-custom-vaccine") { + return apiError("Vaccine not found", 404, { + errorCode: "vaccination.custom-vaccine-not-found", + }); + } const series = await resolveSeriesFor(prisma, user.id); @@ -261,6 +279,7 @@ async function postVaccination(request: NextRequest): Promise { vaccinationId: created.id, occurredAt: created.occurredAt.toISOString(), antigenSlug: created.antigenSlug, + customVaccineId: created.customVaccineId, // The note is deliberately absent: it is encrypted PHI and the audit // table is not a second copy of the record. }, @@ -273,7 +292,9 @@ async function postVaccination(request: NextRequest): Promise { entity_id: created.id, }, meta: { - antigen_slug: created.antigenSlug ?? "free-text", + antigen_slug: + created.antigenSlug ?? + (created.customVaccineId ? "custom" : "free-text"), linked_documents: entry.documentIds?.length ?? 0, satisfied_boosters: satisfiedReminderIds.length, }, @@ -285,7 +306,11 @@ async function postVaccination(request: NextRequest): Promise { entity_type: "measurement-reminder", entity_id: reminderId, }, - meta: { antigen_slug: created.antigenSlug ?? "free-text" }, + meta: { + antigen_slug: + created.antigenSlug ?? + (created.customVaccineId ? "custom" : "free-text"), + }, }); } diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index c9c5aa862..e890109ec 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -151,7 +151,7 @@ const RESTORE_PREVIEW_SECTIONS: ReadonlyArray<{ }, { labelKey: "previewVaccinations", - keys: ["vaccinations", "vaccinationLinks"], + keys: ["vaccinations", "vaccinationLinks", "customVaccines"], }, { labelKey: "previewReminders", diff --git a/src/components/vaccinations/__tests__/custom-vaccine.test.tsx b/src/components/vaccinations/__tests__/custom-vaccine.test.tsx new file mode 100644 index 000000000..8f79a0581 --- /dev/null +++ b/src/components/vaccinations/__tests__/custom-vaccine.test.tsx @@ -0,0 +1,128 @@ +/** + * v1.42 (#1005) — the person's own vaccines on the client: the booster offer + * a dose logged against one raises, the name it carries in the list, and the + * body the definition sheet sends. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { VaccinationDTO } from "@/lib/vaccinations/dto"; + +import { boosterOfferFor } from "../booster-mint-prompt"; +import { customVaccineBody } from "../custom-vaccine-sheet"; +import { draftHasIdentity, draftToBody, emptyDraft } from "../vaccination-form"; +import { VaccinationList } from "../vaccination-list"; + +const OWN = { + id: "cv-1", + name: "Travel combo", + components: ["typhoid", "hepatitis-a"], + typicalSeriesDoses: 1, + boosterIntervalMonths: 36, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}; + +function dose(over: Partial): VaccinationDTO { + return { + id: "dose-1", + occurredAt: "2026-02-01T00:00:00.000Z", + antigenSlug: null, + vaccineName: null, + doseNumber: null, + seriesDoses: null, + lotNumber: null, + site: null, + catalogEntry: null, + customVaccineId: OWN.id, + customVaccine: OWN, + series: [ + { antigen: "typhoid", position: 1, total: 1, booster: false }, + { antigen: "hepatitis-a", position: 1, total: 1, booster: false }, + ], + practitioner: null, + encounter: null, + reminderId: null, + note: null, + createdAt: "2026-02-01T00:00:00.000Z", + updatedAt: "2026-02-01T00:00:00.000Z", + ...over, + }; +} + +describe("the booster offer", () => { + it("is raised for an own vaccine with an interval, under its own name", () => { + expect(boosterOfferFor(dose({}))).toEqual({ + intervalMonths: 36, + slug: null, + name: "Travel combo", + }); + }); + + it("is not raised for an own vaccine without an interval, or a removed one", () => { + expect( + boosterOfferFor( + dose({ customVaccine: { ...OWN, boosterIntervalMonths: null } }), + ), + ).toBeNull(); + expect(boosterOfferFor(dose({ customVaccine: null }))).toBeNull(); + }); +}); + +describe("the list", () => { + it("files the dose under each antigen, marked with the own vaccine's name", () => { + const html = renderToStaticMarkup( + + + , + ); + expect(html).toContain('data-antigen="typhoid"'); + expect(html).toContain('data-antigen="hepatitis-a"'); + expect(html.match(/· Travel combo/g)).toHaveLength(2); + }); +}); + +describe("the forms", () => { + it("accepts an own vaccine as the dose's only identity and sends it", () => { + const draft = emptyDraft({ customVaccineId: "cv-1" }); + expect(draftHasIdentity(draft)).toBe(true); + expect(draftToBody(draft)).toMatchObject({ + customVaccineId: "cv-1", + antigenSlug: null, + vaccineName: null, + }); + }); + + it("sends a definition only with a name and a disease, antigens in catalogue order", () => { + expect( + customVaccineBody({ + name: " ", + components: ["tetanus"], + typicalSeriesDoses: "", + boosterIntervalMonths: "", + }), + ).toBeNull(); + expect( + customVaccineBody({ + name: "X", + components: [], + typicalSeriesDoses: "", + boosterIntervalMonths: "", + }), + ).toBeNull(); + expect( + customVaccineBody({ + name: " Travel combo ", + components: ["typhoid", "tetanus"], + typicalSeriesDoses: "2", + boosterIntervalMonths: "", + }), + ).toEqual({ + name: "Travel combo", + components: ["tetanus", "typhoid"], + typicalSeriesDoses: 2, + boosterIntervalMonths: null, + }); + }); +}); diff --git a/src/components/vaccinations/__tests__/vaccination-form.test.ts b/src/components/vaccinations/__tests__/vaccination-form.test.ts index fac6d1426..65da1e69b 100644 --- a/src/components/vaccinations/__tests__/vaccination-form.test.ts +++ b/src/components/vaccinations/__tests__/vaccination-form.test.ts @@ -93,6 +93,8 @@ describe("vaccination form document links", () => { lotNumber: null, site: null, catalogEntry: null, + customVaccineId: null, + customVaccine: null, series: [], practitioner: null, encounter: null, diff --git a/src/components/vaccinations/__tests__/vaccination-list.test.tsx b/src/components/vaccinations/__tests__/vaccination-list.test.tsx index 0493561ce..24d6a7451 100644 --- a/src/components/vaccinations/__tests__/vaccination-list.test.tsx +++ b/src/components/vaccinations/__tests__/vaccination-list.test.tsx @@ -26,6 +26,8 @@ function record( lotNumber: null, site: null, catalogEntry: null, + customVaccineId: null, + customVaccine: null, practitioner: null, encounter: null, reminderId: null, diff --git a/src/components/vaccinations/booster-mint-prompt.tsx b/src/components/vaccinations/booster-mint-prompt.tsx index 9fa6caa4e..cb80e2982 100644 --- a/src/components/vaccinations/booster-mint-prompt.tsx +++ b/src/components/vaccinations/booster-mint-prompt.tsx @@ -34,22 +34,40 @@ import { FieldGroup } from "@/components/ui/field-group"; import { Input } from "@/components/ui/input"; import { NativeSelect } from "@/components/ui/native-select"; import { useTranslations } from "@/lib/i18n/context"; -import { resolveCatalogEntry } from "@/lib/vaccinations/vaccine-catalog"; +import { + customLookupOf, + resolveVaccineEntry, +} from "@/lib/vaccinations/resolve-vaccine-entry"; import { useBoosterMint, type Vaccination } from "./use-vaccinations"; /** - * Whether a freshly created dose should raise the offer at all. Only a - * catalogue entry that carries a booster interval does; a free-text row and a + * Whether a freshly created dose should raise the offer at all. Only an entry + * that carries a booster interval and an antigen to key it on does — a + * catalogue pick or the person's own definition alike; a free-text row and a * one-off vaccine never prompt. */ -export function boosterOfferFor( - record: Vaccination, -): { intervalMonths: number; catalogName: string } | null { - const entry = resolveCatalogEntry(record.antigenSlug); - if (!entry || entry.boosterIntervalMonths === null) return null; +export function boosterOfferFor(record: Vaccination): { + intervalMonths: number; + /** The catalogue slug, or null for the person's own definition. */ + slug: string | null; + /** The definition's own name, or null for a catalogue entry. */ + name: string | null; +} | null { + const entry = resolveVaccineEntry( + record, + customLookupOf([record.customVaccine]), + ); + if ( + !entry || + entry.boosterIntervalMonths === null || + entry.components.length === 0 + ) { + return null; + } return { intervalMonths: entry.boosterIntervalMonths, - catalogName: entry.slug, + slug: entry.slug, + name: entry.name, }; } @@ -71,7 +89,7 @@ export function BoosterMintPrompt({ // the person's own locale — the server never resolves an i18n key. const defaultLabel = record && offer - ? `${t(`vaccinations.catalog.${record.antigenSlug}`)} ${t("vaccinations.booster.labelSuffix")}` + ? `${offer.slug ? t(`vaccinations.catalog.${offer.slug}`) : offer.name} ${t("vaccinations.booster.labelSuffix")}` : ""; const [label, setLabel] = useState(defaultLabel); const [months, setMonths] = useState(offer?.intervalMonths ?? 12); diff --git a/src/components/vaccinations/catalog-info.tsx b/src/components/vaccinations/catalog-info.tsx index 49a434a63..66febcd93 100644 --- a/src/components/vaccinations/catalog-info.tsx +++ b/src/components/vaccinations/catalog-info.tsx @@ -14,24 +14,40 @@ * * The per-entry `source` renders verbatim as the footnote — a citation is a * proper noun, like a unit, and is not translated. + * + * The person's own vaccine definitions (v1.42, #1005) render through the same + * templates, with the antigens they listed first and no footnote: their + * numbers are the person's, not a cited schedule's. */ import { useTranslations } from "@/lib/i18n/context"; -import { resolveCatalogEntry } from "@/lib/vaccinations/vaccine-catalog"; +import { + resolveVaccineEntry, + type ResolvedVaccineEntry, +} from "@/lib/vaccinations/resolve-vaccine-entry"; type Translate = ReturnType["t"]; type TranslateCount = ReturnType["tCount"]; /** The impersonal sentences that apply to this entry, in reading order. */ function infoSentences( - slug: string | null, + entry: ResolvedVaccineEntry | null, t: Translate, tCount: TranslateCount, -): { lines: string[]; source: string } | null { - const entry = resolveCatalogEntry(slug); +): { lines: string[]; source: string | null } | null { if (!entry) return null; const lines: string[] = []; + if (entry.kind === "custom" && entry.components.length > 0) { + lines.push( + t("vaccinations.custom.protectsAgainst", { + list: entry.components + .map((antigen) => t(`vaccinations.catalog.${antigen}`)) + .join(", "), + }), + ); + } + if (entry.typicalSeriesDoses !== null) { lines.push( tCount("vaccinations.info.primarySeries", entry.typicalSeriesDoses, { @@ -70,7 +86,7 @@ function infoSentences( * empty popover for an entry the templates say nothing about. */ export function catalogInfoAvailable(slug: string | null): boolean { - const entry = resolveCatalogEntry(slug); + const entry = resolveVaccineEntry({ antigenSlug: slug }); if (!entry) return false; return ( entry.typicalSeriesDoses !== null || @@ -79,9 +95,20 @@ export function catalogInfoAvailable(slug: string | null): boolean { ); } -export function CatalogInfo({ slug }: { slug: string | null }) { +export function CatalogInfo({ + slug = null, + entry, +}: { + slug?: string | null; + /** An already resolved entry, e.g. one of the person's own definitions. */ + entry?: ResolvedVaccineEntry | null; +}) { const { t, tCount } = useTranslations(); - const info = infoSentences(slug, t, tCount); + const info = infoSentences( + entry ?? resolveVaccineEntry({ antigenSlug: slug }), + t, + tCount, + ); if (!info) return null; return ( @@ -91,9 +118,11 @@ export function CatalogInfo({ slug }: { slug: string | null }) {
  • {line}
  • ))} -

    - {t("vaccinations.info.sourceLabel", { source: info.source })} -

    + {info.source ? ( +

    + {t("vaccinations.info.sourceLabel", { source: info.source })} +

    + ) : null} ); } diff --git a/src/components/vaccinations/catalog-picker.tsx b/src/components/vaccinations/catalog-picker.tsx index 8c2507f59..5e57d4c4a 100644 --- a/src/components/vaccinations/catalog-picker.tsx +++ b/src/components/vaccinations/catalog-picker.tsx @@ -13,9 +13,18 @@ * The list is a static import, so the search is an in-memory filter with no * round trip and no provider dependency. Grouped by category so a lifetime Pass * (childhood through travel) reads in the order a person recognises. + * + * v1.42 (#1005) — the person's own vaccine definitions list above the + * catalogue under their own heading, and the popover ends in "Add own + * vaccine" for a product neither list has. Picking a definition sets + * `customVaccineId` and clears the catalogue pick, and the other way round: + * a dose names one entry, and the catalogue would win a tie anyway. The + * caller applies each pick as one change (`onChange` clears the definition, + * `onCustomChange` the slug), so two updates never race over one draft; + * clearing calls `onChange(null)` and empties both. */ import { useMemo, useState } from "react"; -import { Check, ChevronsUpDown, X } from "lucide-react"; +import { Check, ChevronsUpDown, Plus, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -30,16 +39,33 @@ import { VACCINE_CATALOG, type VaccineSeed, } from "@/lib/vaccinations/vaccine-catalog"; +import { + customLookupOf, + resolveVaccineEntry, +} from "@/lib/vaccinations/resolve-vaccine-entry"; import { CatalogInfo } from "./catalog-info"; +import type { CustomVaccine } from "./use-vaccinations"; export function CatalogPicker({ value, onChange, + customValue = null, + customs = [], + onCustomChange, + onAddCustom, disabled, }: { /** The chosen catalogue slug, or null. */ value: string | null; + /** A catalogue pick, or `null` to clear every pick. */ onChange: (slug: string | null) => void; + /** The chosen own definition's id, or null. */ + customValue?: string | null; + /** The record's own definitions, listed above the catalogue. */ + customs?: readonly CustomVaccine[]; + onCustomChange?: (id: string | null) => void; + /** Opens the definition sheet; absent when the caller may not add one. */ + onAddCustom?: () => void; disabled?: boolean; }) { const { t } = useTranslations(); @@ -63,9 +89,28 @@ export function CatalogPicker({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [term, t]); + const customMatches = useMemo(() => { + const needle = term.trim().toLowerCase(); + return customs.filter( + (custom) => !needle || custom.name.toLowerCase().includes(needle), + ); + }, [customs, term]); + const selected: VaccineSeed | undefined = value ? VACCINE_CATALOG.find((entry) => entry.slug === value) : undefined; + const selectedCustom = + !selected && customValue + ? (customs.find((custom) => custom.id === customValue) ?? null) + : null; + const customEntry = selectedCustom + ? resolveVaccineEntry( + { customVaccineId: selectedCustom.id }, + customLookupOf([selectedCustom]), + ) + : null; + const hasPick = Boolean(selected || selectedCustom); + const nothingMatches = matches.length === 0 && customMatches.length === 0; return (
    @@ -89,12 +134,14 @@ export function CatalogPicker({ {selected ? name(selected.slug) - : t("vaccinations.form.catalogNone")} + : selectedCustom + ? selectedCustom.name + : t("vaccinations.form.catalogNone")}
    - {matches.length === 0 ? ( + {customMatches.length > 0 ? ( + <> +

    + {t("vaccinations.custom.title")} +

    + {customMatches.map((custom) => ( + + ))} + {matches.length > 0 ? ( +

    + {t("vaccinations.custom.catalogGroup")} +

    + ) : null} + + ) : null} + {nothingMatches ? (

    {t("vaccinations.form.catalogNoMatch")}

    @@ -142,10 +227,26 @@ export function CatalogPicker({ )) )}
    + {onAddCustom ? ( + + ) : null} - {selected ? ( + {hasPick ? (
    ); } diff --git a/src/components/vaccinations/custom-vaccine-list.tsx b/src/components/vaccinations/custom-vaccine-list.tsx new file mode 100644 index 000000000..48ec2bcdf --- /dev/null +++ b/src/components/vaccinations/custom-vaccine-list.tsx @@ -0,0 +1,121 @@ +"use client"; + +/** + * v1.42 (#1005) — the record's own vaccine definitions, below the dose log. + * + * Rendered only once the record holds at least one: the way in is the dose + * form's picker ("Add own vaccine"), and an empty heading on every Impfpass + * would advertise a feature most people never need. Each row names the + * definition and reads it back in the catalogue's own sentences, so a + * definition and a catalogue entry describe themselves the same way. A row + * opens the definition for someone who may edit it. + */ +import { useState } from "react"; +import { Plus, Syringe } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { SectionHeading } from "@/components/ui/section-heading"; +import { useTranslations } from "@/lib/i18n/context"; +import { + customLookupOf, + resolveVaccineEntry, +} from "@/lib/vaccinations/resolve-vaccine-entry"; +import { CatalogInfo } from "./catalog-info"; +import { CustomVaccineSheet } from "./custom-vaccine-sheet"; +import type { CustomVaccine } from "./use-vaccinations"; + +export function CustomVaccineList({ + customs, + canAdd, + canManage, +}: { + customs: readonly CustomVaccine[]; + /** WRITE on `profile`: may add a definition. */ + canAdd: boolean; + /** MANAGE on `profile`: may edit and remove one. */ + canManage: boolean; +}) { + const { t } = useTranslations(); + const [open, setOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [session, setSession] = useState(0); + + if (customs.length === 0) return null; + + const openSheet = (row: CustomVaccine | null) => { + setEditing(row); + setSession((n) => n + 1); + setOpen(true); + }; + + return ( +
    + openSheet(null)} + > + + {t("vaccinations.custom.add")} + + ) : null + } + /> + +
    + {customs.map((custom) => { + const entry = resolveVaccineEntry( + { customVaccineId: custom.id }, + customLookupOf([custom]), + ); + const editable = canManage; + return ( + openSheet(custom) : undefined} + onKeyDown={ + editable + ? (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openSheet(custom); + } + } + : undefined + } + > + +

    + {custom.name} +

    + +
    +
    + ); + })} +
    + + +
    + ); +} diff --git a/src/components/vaccinations/custom-vaccine-sheet.tsx b/src/components/vaccinations/custom-vaccine-sheet.tsx new file mode 100644 index 000000000..929ac6819 --- /dev/null +++ b/src/components/vaccinations/custom-vaccine-sheet.tsx @@ -0,0 +1,332 @@ +"use client"; + +/** + * v1.42 (#1005) — define, edit or remove one of the record's own vaccines. + * + * A definition is a product the shipped catalogue does not list: a name, the + * diseases it protects against (the catalogue's own antigen list, because the + * series and the booster match key on it), and optionally the series length + * and the booster interval. A dose logged against it then counts like a + * catalogue pick (`resolve-vaccine-entry.ts`). + * + * Save needs a name and at least one disease; nothing else is required. + * Removing asks first and says what happens to the doses: they stay, under + * the definition's name. + * + * Capability note: the same posture as the dose sheet. Create needs WRITE on + * `profile`, edit and remove need MANAGE; the mounts ask before opening it. + */ +import { useState } from "react"; +import { Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { FieldGroup } from "@/components/ui/field-group"; +import { Input } from "@/components/ui/input"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { ApiError } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { ANTIGEN_SLUGS } from "@/lib/vaccinations/vaccine-catalog"; +import { + useCustomVaccineMutations, + type CustomVaccine, + type CustomVaccineWriteBody, +} from "./use-vaccinations"; + +export interface CustomVaccineDraft { + name: string; + components: string[]; + typicalSeriesDoses: string; + boosterIntervalMonths: string; +} + +export function customVaccineDraft( + row: CustomVaccine | null, +): CustomVaccineDraft { + return { + name: row?.name ?? "", + components: row ? [...row.components] : [], + typicalSeriesDoses: row?.typicalSeriesDoses?.toString() ?? "", + boosterIntervalMonths: row?.boosterIntervalMonths?.toString() ?? "", + }; +} + +function toInt(value: string): number | null { + const trimmed = value.trim(); + if (!trimmed) return null; + const n = Number.parseInt(trimmed, 10); + return Number.isNaN(n) ? null : n; +} + +/** The body a draft becomes; null while it cannot be saved. */ +export function customVaccineBody( + draft: CustomVaccineDraft, +): CustomVaccineWriteBody | null { + const name = draft.name.trim(); + if (!name || draft.components.length === 0) return null; + return { + name, + // Catalogue order, so the same choice always reads the same way. + components: ANTIGEN_SLUGS.filter((slug) => draft.components.includes(slug)), + typicalSeriesDoses: toInt(draft.typicalSeriesDoses), + boosterIntervalMonths: toInt(draft.boosterIntervalMonths), + }; +} + +export function CustomVaccineSheet({ + open, + onOpenChange, + customVaccine, + canRemove = false, + onSaved, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + /** `null` creates a definition; a row edits it. */ + customVaccine: CustomVaccine | null; + /** Offer the removal; the caller asks for MANAGE first. */ + canRemove?: boolean; + /** Raised with the saved definition, e.g. to pick it in the dose form. */ + onSaved?: (saved: CustomVaccine) => void; +}) { + const { t } = useTranslations(); + const { create, update, remove } = useCustomVaccineMutations(); + // Seeded once at mount; the caller remounts on every open by varying `key`. + const [draft, setDraft] = useState(() => + customVaccineDraft(customVaccine), + ); + const [error, setError] = useState(null); + const [confirmingRemove, setConfirmingRemove] = useState(false); + // The diseases an edit opens with lead the list, so what the definition + // covers is visible without scrolling; the order is fixed at mount so a + // box never jumps away from the pointer that ticked it. + const [antigenOrder] = useState(() => { + const chosen = new Set(customVaccine?.components ?? []); + return [ + ...ANTIGEN_SLUGS.filter((slug) => chosen.has(slug)), + ...ANTIGEN_SLUGS.filter((slug) => !chosen.has(slug)), + ]; + }); + + const isEdit = customVaccine !== null; + const pending = create.isPending || update.isPending; + const body = customVaccineBody(draft); + const patch = (part: Partial) => + setDraft((current) => ({ ...current, ...part })); + + const toggle = (antigen: string, on: boolean) => + setDraft((current) => ({ + ...current, + components: on + ? [...current.components.filter((a) => a !== antigen), antigen] + : current.components.filter((a) => a !== antigen), + })); + + const submit = async () => { + if (!body) return; + setError(null); + try { + const saved = isEdit + ? await update.mutateAsync({ id: customVaccine.id, body }) + : await create.mutateAsync(body); + toastWrittenOutcome("success", t("vaccinations.custom.saved")); + onOpenChange(false); + onSaved?.(saved); + } catch (err) { + const message = + err instanceof ApiError && + err.meta?.errorCode === "vaccination.custom.name-taken" + ? t("vaccinations.custom.nameTaken") + : t("vaccinations.custom.saveFailed"); + setError(message); + toast.error(message); + } + }; + + return ( + + + + + } + > +
    + + patch({ name: event.target.value })} + data-slot="custom-vaccine-name" + /> + + +
    + + {t("vaccinations.custom.components")} + +

    + {t("vaccinations.custom.componentsHint")} +

    +
    + {antigenOrder.map((antigen) => { + const id = `custom-vaccine-antigen-${antigen}`; + const checked = draft.components.includes(antigen); + return ( + + ); + })} +
    +
    + +
    + + + patch({ typicalSeriesDoses: event.target.value }) + } + /> + + + + patch({ boosterIntervalMonths: event.target.value }) + } + /> + +
    + + {error ? ( +

    + {error} +

    + ) : null} + + {isEdit && canRemove ? ( + + + + + + + + {t("vaccinations.custom.deleteTitle")} + + + {t("vaccinations.custom.deleteConfirm")} + + + + {t("common.cancel")} + { + remove.mutate(customVaccine.id, { + onSuccess: () => { + toastWrittenOutcome( + "success", + t("vaccinations.custom.deleted"), + ); + onOpenChange(false); + }, + onError: () => + toast.error(t("vaccinations.custom.saveFailed")), + }); + }} + > + {t("common.delete")} + + + + + ) : null} +
    +
    + ); +} diff --git a/src/components/vaccinations/use-vaccinations.ts b/src/components/vaccinations/use-vaccinations.ts index 2834e093e..faa4cfb24 100644 --- a/src/components/vaccinations/use-vaccinations.ts +++ b/src/components/vaccinations/use-vaccinations.ts @@ -23,12 +23,14 @@ import { vaccinationDependentKeys, } from "@/lib/query-keys"; import type { + CustomVaccineDTO, VaccinationDTO, VaccinationListDTO, } from "@/lib/vaccinations/dto"; import type { VaccinationSuggestionResult } from "@/lib/vaccinations/document-suggestion"; export type Vaccination = VaccinationDTO; +export type CustomVaccine = CustomVaccineDTO; /** * The write body — every field optional but the identity pair and the date. @@ -44,6 +46,7 @@ export interface VaccinationWriteBody { site?: string | null; practitionerId?: string | null; encounterId?: string | null; + customVaccineId?: string | null; note?: string | null; documentIds?: string[]; } @@ -212,3 +215,59 @@ export function useBoosterMint() { ]), }); } + +/** What a definition write carries; `userId` is never here. */ +export interface CustomVaccineWriteBody { + name?: string; + components?: string[]; + typicalSeriesDoses?: number | null; + boosterIntervalMonths?: number | null; +} + +/** v1.42 (#1005) — the record's own vaccine definitions, by name. */ +export function useCustomVaccines(enabled = true) { + return useQuery({ + queryKey: queryKeys.customVaccines(), + enabled, + queryFn: () => apiGet(`${BASE}/custom`), + }); +} + +/** + * Add, edit and remove a definition. Every write evicts the whole + * vaccinations root: an edit re-reads the series of every dose that names + * the definition, and a removal lets those doses go and names them, which + * the document sheet's dose chips show too. No reminder moves — a definition + * write never runs the satisfy matcher — so the reminder reads and the daily + * reads they feed are left alone. + */ +export function useCustomVaccineMutations() { + const qc = useQueryClient(); + const invalidate = () => + invalidateKeys(qc, [queryKeys.vaccinations(), queryKeys.documents()]); + + const create = useMutation({ + mutationKey: queryKeys.customVaccineCreate(), + mutationFn: (body: CustomVaccineWriteBody) => + apiPost(`${BASE}/custom`, body), + onSuccess: invalidate, + }); + + const update = useMutation({ + mutationKey: queryKeys.customVaccineUpdate(), + mutationFn: ({ id, body }: { id: string; body: CustomVaccineWriteBody }) => + apiPatch(`${BASE}/custom/${encodeURIComponent(id)}`, body), + onSuccess: invalidate, + }); + + const remove = useMutation({ + mutationKey: queryKeys.customVaccineDelete(), + mutationFn: (id: string) => + apiDelete<{ deleted: boolean }>( + `${BASE}/custom/${encodeURIComponent(id)}`, + ), + onSuccess: invalidate, + }); + + return { create, update, remove }; +} diff --git a/src/components/vaccinations/vaccination-form.tsx b/src/components/vaccinations/vaccination-form.tsx index f65b2ac37..aff06ca44 100644 --- a/src/components/vaccinations/vaccination-form.tsx +++ b/src/components/vaccinations/vaccination-form.tsx @@ -10,9 +10,11 @@ * feature exists for. The only thing that can block Save is a missing date or * neither identity arm. * - * The two identity arms are independent: picking a catalogue entry never + * The identity arms are independent: picking a catalogue entry never * overwrites what the person typed, and typing never clears a pick. Either is - * sufficient; both are allowed. + * sufficient; both are allowed. Since v1.42 (#1005) the pick may also be one + * of the record's own vaccine definitions, chosen in the same picker, and a + * new definition can be added from it without leaving the form. * * Follows the encounter form's controlled-draft shape rather than a * react-hook-form instance — the sibling surface this plan cites as its @@ -32,8 +34,14 @@ import { EncounterSuggestionField } from "@/components/encounters/encounter-sugg import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import type { Practitioner } from "@/hooks/use-practitioners"; -import type { Vaccination, VaccinationWriteBody } from "./use-vaccinations"; +import { + useCustomVaccines, + type CustomVaccine, + type Vaccination, + type VaccinationWriteBody, +} from "./use-vaccinations"; import { CatalogPicker } from "./catalog-picker"; +import { CustomVaccineSheet } from "./custom-vaccine-sheet"; import { VaccinationDocumentPicker } from "./vaccination-document-picker"; import { dayKeyAsUtcMidnight, isCalendarDateKey } from "@/lib/tz/date-only"; @@ -52,6 +60,8 @@ export interface VaccinationDraft { /** `YYYY-MM-DD`, the `DateField` contract. */ occurredAt: string; antigenSlug: string | null; + /** One of the record's own vaccine definitions, or null. */ + customVaccineId: string | null; vaccineName: string; doseNumber: string; seriesDoses: string; @@ -85,6 +95,7 @@ export function emptyDraft( return { occurredAt: todayLocal(), antigenSlug: null, + customVaccineId: null, vaccineName: "", doseNumber: "", seriesDoses: "", @@ -102,6 +113,8 @@ export function draftFromVaccination(row: Vaccination): VaccinationDraft { return { occurredAt: row.occurredAt.slice(0, 10), antigenSlug: row.antigenSlug, + // A definition removed since stays unpicked: the picker cannot name it. + customVaccineId: row.customVaccine ? row.customVaccineId : null, vaccineName: row.vaccineName ?? "", doseNumber: row.doseNumber?.toString() ?? "", seriesDoses: row.seriesDoses?.toString() ?? "", @@ -125,7 +138,11 @@ export function draftInstant(day: string): string | null { /** Does the draft carry at least one identity arm? */ export function draftHasIdentity(draft: VaccinationDraft): boolean { - return draft.antigenSlug !== null || draft.vaccineName.trim().length > 0; + return ( + draft.antigenSlug !== null || + draft.customVaccineId !== null || + draft.vaccineName.trim().length > 0 + ); } function toInt(value: string): number | null { @@ -145,6 +162,7 @@ export function draftToBody( return { occurredAt, antigenSlug: draft.antigenSlug, + customVaccineId: draft.customVaccineId, vaccineName: draft.vaccineName.trim() || null, doseNumber: toInt(draft.doseNumber), seriesDoses: toInt(draft.seriesDoses), @@ -186,6 +204,11 @@ export function VaccinationForm({ const [seriesOpen, setSeriesOpen] = useState( draft.doseNumber.length > 0 || draft.seriesDoses.length > 0, ); + const customs = useCustomVaccines(); + // The definition sheet, opened from the picker. Bumped `session` remounts + // it so a second open starts empty. + const [addingCustom, setAddingCustom] = useState(false); + const [customSession, setCustomSession] = useState(0); const anchor = draftInstant(draft.occurredAt); @@ -212,10 +235,32 @@ export function VaccinationForm({ > patch({ antigenSlug: slug })} + // One change per pick, so the two arms never race over one draft. + onChange={(slug) => + patch({ antigenSlug: slug, customVaccineId: null }) + } + customValue={draft.customVaccineId} + customs={customs.data ?? []} + onCustomChange={(id) => + patch({ customVaccineId: id, antigenSlug: null }) + } + onAddCustom={() => { + setCustomSession((n) => n + 1); + setAddingCustom(true); + }} /> + + patch({ customVaccineId: saved.id, antigenSlug: null }) + } + /> + )} + + ); } diff --git a/src/lib/doctor-report/clinical-records.ts b/src/lib/doctor-report/clinical-records.ts index ec08a6bf1..b22921a3f 100644 --- a/src/lib/doctor-report/clinical-records.ts +++ b/src/lib/doctor-report/clinical-records.ts @@ -12,6 +12,11 @@ */ import { prisma } from "@/lib/db"; import { deriveSeries } from "@/lib/vaccinations/series"; +import { + CUSTOM_VACCINE_RESOLVE_SELECT, + customLookupOf, + resolveVaccineEntry, +} from "@/lib/vaccinations/resolve-vaccine-entry"; import { resolveEffectiveReferenceRange } from "@/lib/labs/reference-range"; import { getEvent } from "@/lib/logging/context"; import { decryptAllergyReaction } from "@/lib/doctor-report-helpers"; @@ -298,24 +303,32 @@ export async function loadImmunizations( doseNumber: true, site: true, practitioner: { select: { name: true } }, + // v1.42 (#1005) — the person's own definition: its antigens place the + // dose in a series, and its name stands in when the dose has none. + customVaccineId: true, + customVaccine: { select: CUSTOM_VACCINE_RESOLVE_SELECT }, }, }); if (rows.length === 0) return null; + const customs = customLookupOf(rows.map((row) => row.customVaccine)); const series = deriveSeries( rows.map((row) => ({ id: row.id, occurredAt: row.occurredAt, antigenSlug: row.antigenSlug, + customVaccineId: row.customVaccineId, doseNumber: row.doseNumber, seriesDoses: row.seriesDoses, })), + customs, ); return rows.map((row) => ({ occurredAt: row.occurredAt.toISOString(), antigenSlug: row.antigenSlug, - vaccineName: row.vaccineName, + vaccineName: + row.vaccineName ?? resolveVaccineEntry(row, customs)?.name ?? null, lotNumber: row.lotNumber, site: row.site, practitionerName: row.practitioner?.name ?? null, diff --git a/src/lib/documents/links.ts b/src/lib/documents/links.ts index 92d26419c..8e5e34538 100644 --- a/src/lib/documents/links.ts +++ b/src/lib/documents/links.ts @@ -16,7 +16,11 @@ import type { DocumentLinkedProcedureDto, DocumentVaccinationLinkDto, } from "@/lib/validations/inbound-documents"; -import { resolveCatalogEntry } from "@/lib/vaccinations/vaccine-catalog"; +import { + CUSTOM_VACCINE_RESOLVE_SELECT, + customLookupOf, + resolveVaccineEntry, +} from "@/lib/vaccinations/resolve-vaccine-entry"; /** * Load the condition links for a page of documents in ONE grouped query. @@ -237,20 +241,29 @@ export async function loadDocumentVaccinationLinks( if (doseIds.length === 0) return map; const doses = await prisma.vaccinationRecord.findMany({ where: { id: { in: doseIds }, userId }, - select: { id: true, antigenSlug: true, vaccineName: true }, + select: { + id: true, + antigenSlug: true, + vaccineName: true, + customVaccineId: true, + customVaccine: { select: CUSTOM_VACCINE_RESOLVE_SELECT }, + }, }); const doseById = new Map(doses.map((dose) => [dose.id, dose])); + const customs = customLookupOf(doses.map((dose) => dose.customVaccine)); for (const [documentId, targets] of byDocument) { map.set( documentId, targets.map((target) => { const dose = doseById.get(target.id); + const entry = dose ? resolveVaccineEntry(dose, customs) : null; return { vaccinationId: target.id, occurredAt: target.date, - catalogSlug: - resolveCatalogEntry(dose?.antigenSlug ?? null)?.slug ?? null, - vaccineName: dose?.vaccineName ?? null, + catalogSlug: entry?.slug ?? null, + // A dose logged against the person's own definition and carrying no + // wording of its own is named by that definition. + vaccineName: dose?.vaccineName ?? entry?.name ?? null, }; }), ); diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 61402aedb..69c48c449 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -413,6 +413,7 @@ function makePrisma() { encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) }, encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) }, + customVaccine: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, // The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the // same reason as the visit tables above. diff --git a/src/lib/export/__tests__/restore-backup-round-trip.test.ts b/src/lib/export/__tests__/restore-backup-round-trip.test.ts index 39a42b987..cfa5e8088 100644 --- a/src/lib/export/__tests__/restore-backup-round-trip.test.ts +++ b/src/lib/export/__tests__/restore-backup-round-trip.test.ts @@ -290,6 +290,7 @@ function sourceClient() { encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) }, encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) }, + customVaccine: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, // The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the // same reason as the visit tables above. diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index bc5f46dab..26b39770e 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -378,6 +378,7 @@ export const TWO_ENDED_MODELS = [ // from anywhere else. "VaccinationRecord", "VaccinationDocumentLink", + "CustomVaccine", // The Vorsorge reminders, off the debt register at last (v1.37.20, #223 / // iOS #68), and the completion ledger carried with them from the release // that introduces it. The pairing is deliberate: the ledger arriving is @@ -589,8 +590,6 @@ export const STRUCTURALLY_UNATTRIBUTABLE: Readonly> = { * rather than by what is cheapest to write. */ export const COVERAGE_PENDING: Readonly> = { - CustomVaccine: - "The person's own vaccine definitions (v1.42, #1005). Until both ends land, a restored dose that pointed at one comes back as its bare `vaccineName`: no antigens, no series position, no booster rule.", WorkoutRoute: "GPS traces. Deliberately absent from the payload today and DISCLOSED as absent in the file's own manifest, which is why this is a documented exclusion rather than a silent one — but it is still a loss for a self-hoster with no other copy.", WorkoutSamples: diff --git a/src/lib/export/backup-preview.ts b/src/lib/export/backup-preview.ts index 446998d36..1109963f0 100644 --- a/src/lib/export/backup-preview.ts +++ b/src/lib/export/backup-preview.ts @@ -180,6 +180,7 @@ const SUMMARY_COUNTS = [ "encounterLinks", "vaccinations", "vaccinationLinks", + "customVaccines", "measurementReminders", "measurementReminderEvents", "coachConversations", diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index 9dc633e95..add18ce1f 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -179,6 +179,7 @@ export interface RestoreResponse { encounterLinks: number; vaccinations: number; vaccinationLinks: number; + customVaccines: number; measurementReminders: number; measurementReminderEvents: number; coachConversations: number; @@ -2646,6 +2647,7 @@ export async function restoreBackup( encounterLinks: visitsCleared.encounterLinks, vaccinations: vaccinationsCleared.vaccinations, vaccinationLinks: vaccinationsCleared.vaccinationLinks, + customVaccines: vaccinationsCleared.customVaccines, measurementReminders: remindersCleared.measurementReminders, measurementReminderEvents: remindersCleared.measurementReminderEvents, coachConversations: coachCleared.coachConversations, @@ -2905,6 +2907,7 @@ export async function restoreBackup( encounterLinks: summary.encounterLinks, vaccinations: summary.vaccinations, vaccinationLinks: summary.vaccinationLinks, + customVaccines: summary.customVaccines, measurementReminders: summary.measurementReminders, measurementReminderEvents: summary.measurementReminderEvents, }, diff --git a/src/lib/export/vaccinations-backup.ts b/src/lib/export/vaccinations-backup.ts index 2e15242b0..ebf3da8c5 100644 --- a/src/lib/export/vaccinations-backup.ts +++ b/src/lib/export/vaccinations-backup.ts @@ -6,8 +6,9 @@ * greps only the restore ROUTE gets a false negative because the route * delegates. * - * Two models ride: the dose and the link that keeps the scanned Impfpass page - * beside it. Both are carried rather than owed. `DocumentConditionLink` on the + * Three models ride: the dose, the link that keeps the scanned Impfpass page + * beside it, and since v1.42 (#1005) the person's own vaccine definitions a + * dose may name. Both are carried rather than owed. `DocumentConditionLink` on the * debt register says what the alternative costs — documents and conditions * both restore, the filing between them does not — and an immunization history * is the record with the least chance of being reconstructed from anywhere @@ -29,6 +30,9 @@ * the same way, and resolves to NULL, with the drop reported, only when * the file genuinely lacks the reminder — a portable export omits * tombstoned ones. + * - `customVaccineId` points at a definition restored by this same section + * first. It drops to NULL, with the drop named, when the file lacks the + * definition; the dose then keeps its own `vaccineName`. * - `antigenSlug` is restored verbatim with no validation against the * current catalogue. A slug this release no longer resolves must still * come back; the renderer degrades to the free-text arm, which is the @@ -76,6 +80,8 @@ export interface VaccinationBackupEntry { * lacks the reminder. */ reminderId: string | null; + /** The person's own definition the dose names (v1.42, #1005). */ + customVaccineId: string | null; /** Base64 ciphertext; disaster-recovery payloads only. */ noteEncrypted?: string | null; /** The note readable; portable payloads only. */ @@ -85,6 +91,22 @@ export interface VaccinationBackupEntry { deletedAt?: string | null; } +/** + * One of the person's own vaccine definitions (v1.42, #1005). `components` + * travels verbatim, never checked against the antigen list this release + * ships, for the same reason a dose's `antigenSlug` does. + */ +export interface CustomVaccineBackupEntry { + id: string; + name: string; + components: string[]; + typicalSeriesDoses: number | null; + boosterIntervalMonths: number | null; + createdAt: string; + updatedAt: string; + deletedAt?: string | null; +} + /** One edge from a dose to the page it was transcribed from. */ export interface VaccinationDocumentLinkBackupEntry { vaccinationId: string; @@ -93,11 +115,13 @@ export interface VaccinationDocumentLinkBackupEntry { } export interface VaccinationsBackupSection { + customVaccines: CustomVaccineBackupEntry[]; vaccinations: VaccinationBackupEntry[]; vaccinationDocumentLinks: VaccinationDocumentLinkBackupEntry[]; } export interface VaccinationsBackupCounts { + customVaccines: number; vaccinations: number; vaccinationLinks: number; } @@ -119,12 +143,24 @@ const VACCINATION_BACKUP_SELECT = { practitionerId: true, encounterId: true, reminderId: true, + customVaccineId: true, noteEncrypted: true, createdAt: true, updatedAt: true, deletedAt: true, } as const; +const CUSTOM_VACCINE_BACKUP_SELECT = { + id: true, + name: true, + components: true, + typicalSeriesDoses: true, + boosterIntervalMonths: true, + createdAt: true, + updatedAt: true, + deletedAt: true, +} as const; + const VACCINATION_DOCUMENT_LINK_BACKUP_SELECT = { vaccinationId: true, documentId: true, @@ -140,26 +176,47 @@ const VACCINATION_DOCUMENT_LINK_BACKUP_SELECT = { * back as itself. */ export async function buildVaccinationsBackupSection( - prisma: Pick, + prisma: Pick< + PrismaClient, + "vaccinationRecord" | "vaccinationDocumentLink" | "customVaccine" + >, userId: string, options: VaccinationsBackupOptions = {}, ): Promise { const disasterRecovery = options.purpose === "disaster-recovery"; - const [vaccinationRows, documentLinkRows] = await Promise.all([ - prisma.vaccinationRecord.findMany({ - where: disasterRecovery ? { userId } : { userId, deletedAt: null }, - orderBy: { occurredAt: "desc" }, - select: VACCINATION_BACKUP_SELECT, - }), - prisma.vaccinationDocumentLink.findMany({ - where: { userId }, - orderBy: { createdAt: "asc" }, - select: VACCINATION_DOCUMENT_LINK_BACKUP_SELECT, - }), - ]); + const [customVaccineRows, vaccinationRows, documentLinkRows] = + await Promise.all([ + prisma.customVaccine.findMany({ + where: disasterRecovery ? { userId } : { userId, deletedAt: null }, + orderBy: [{ name: "asc" }, { id: "asc" }], + select: CUSTOM_VACCINE_BACKUP_SELECT, + }), + prisma.vaccinationRecord.findMany({ + where: disasterRecovery ? { userId } : { userId, deletedAt: null }, + orderBy: { occurredAt: "desc" }, + select: VACCINATION_BACKUP_SELECT, + }), + prisma.vaccinationDocumentLink.findMany({ + where: { userId }, + orderBy: { createdAt: "asc" }, + select: VACCINATION_DOCUMENT_LINK_BACKUP_SELECT, + }), + ]); return { + customVaccines: customVaccineRows.map((row) => ({ + id: row.id, + name: row.name, + components: [...row.components], + typicalSeriesDoses: row.typicalSeriesDoses, + boosterIntervalMonths: row.boosterIntervalMonths, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + ...(disasterRecovery + ? { deletedAt: row.deletedAt?.toISOString() ?? null } + : {}), + })), vaccinations: vaccinationRows.map((row) => ({ id: row.id, occurredAt: row.occurredAt.toISOString(), @@ -172,6 +229,7 @@ export async function buildVaccinationsBackupSection( practitionerId: row.practitionerId, encounterId: row.encounterId, reminderId: row.reminderId, + customVaccineId: row.customVaccineId, ...(disasterRecovery ? { noteEncrypted: encodeSealedBytes(row.noteEncrypted) } : { @@ -199,6 +257,7 @@ export function countVaccinationsBackupSection( section: VaccinationsBackupSection, ): VaccinationsBackupCounts { return { + customVaccines: section.customVaccines.length, vaccinations: section.vaccinations.length, vaccinationLinks: section.vaccinationDocumentLinks.length, }; @@ -206,6 +265,7 @@ export function countVaccinationsBackupSection( /** Counts the immunization restore wiped, for the audit trail. */ export interface VaccinationsRestoreCleared { + customVaccines: number; vaccinations: number; vaccinationLinks: number; } @@ -219,10 +279,22 @@ export interface VaccinationsRestoreCleared { * times and reports success. */ export interface VaccinationsRestoreInput { + customVaccines: RestoredCustomVaccine[]; vaccinations: RestoredVaccination[]; vaccinationDocumentLinks: VaccinationDocumentLinkBackupEntry[]; } +/** A definition as the parsed file presents it. */ +export type RestoredCustomVaccine = Pick< + CustomVaccineBackupEntry, + "id" | "name" | "createdAt" | "updatedAt" +> & { + components?: string[] | undefined; + typicalSeriesDoses?: number | null | undefined; + boosterIntervalMonths?: number | null | undefined; + deletedAt?: string | null | undefined; +}; + /** * What the restore reads, as the parsed file actually presents it — a wider * set than what this release writes: a portable export omits the tombstone, @@ -246,6 +318,7 @@ export type RestoredVaccination = Pick< | "practitionerId" | "encounterId" | "reminderId" + | "customVaccineId" | "noteEncrypted" | "note" > @@ -276,6 +349,32 @@ export async function restoreVaccinationsData( const clearedVaccinations = await tx.vaccinationRecord.deleteMany({ where: { userId: ownerId }, }); + // After the doses that point at them, so neither count reads short. + const clearedCustomVaccines = await tx.customVaccine.deleteMany({ + where: { userId: ownerId }, + }); + + // The definitions first: a dose addresses one by id. + if (payload.customVaccines.length > 0) { + await tx.customVaccine.createMany({ + data: payload.customVaccines.map((entry) => ({ + id: entry.id, + userId: ownerId, + name: entry.name, + // Verbatim, like a dose's slug: an antigen this release does not know + // still says what the person meant, and the resolver skips it. + components: entry.components ?? [], + typicalSeriesDoses: entry.typicalSeriesDoses ?? null, + boosterIntervalMonths: entry.boosterIntervalMonths ?? null, + createdAt: new Date(entry.createdAt), + updatedAt: new Date(entry.updatedAt), + deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null, + })), + }); + } + const restoredCustomVaccines = new Set( + payload.customVaccines.map((entry) => entry.id), + ); // All three far sides are restored by other branches of the same // transaction, so the check is against the database rather than against the @@ -308,6 +407,7 @@ export async function restoreVaccinationsData( const droppedPractitioners: string[] = []; const droppedEncounters: string[] = []; const droppedReminders: string[] = []; + const droppedCustomVaccines: string[] = []; // Sealed notes this host's keys do not open, by file path. const unopened: string[] = []; @@ -336,6 +436,14 @@ export async function restoreVaccinationsData( if (entry.reminderId && reminderId === null) { droppedReminders.push(entry.reminderId); } + const customVaccineId = + entry.customVaccineId && + restoredCustomVaccines.has(entry.customVaccineId) + ? entry.customVaccineId + : null; + if (entry.customVaccineId && customVaccineId === null) { + droppedCustomVaccines.push(entry.customVaccineId); + } return { id: entry.id, userId: ownerId, @@ -352,6 +460,7 @@ export async function restoreVaccinationsData( practitionerId, encounterId, reminderId, + customVaccineId, noteEncrypted: sealBytesForRestore( entry.noteEncrypted, entry.note, @@ -384,6 +493,12 @@ export async function restoreVaccinationsData( [...new Set(droppedReminders)], droppedReminders, ); + recordUnknownKeys( + skips, + "vaccinationReference", + [...new Set(droppedCustomVaccines)], + droppedCustomVaccines, + ); recordUnknownKeys(skips, "vaccinationCiphertext", unopened, unopened); const restoredVaccinations = new Set( @@ -433,6 +548,7 @@ export async function restoreVaccinationsData( ); return { + customVaccines: clearedCustomVaccines.count, vaccinations: clearedVaccinations.count, vaccinationLinks: clearedLinks.count, }; diff --git a/src/lib/http/not-implemented.ts b/src/lib/http/not-implemented.ts deleted file mode 100644 index 1e4b0bac7..000000000 --- a/src/lib/http/not-implemented.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * The answer a route gives while its contract exists and its body does not. - * - * A release built in parallel packages lands every new route as a stub first, - * registered in the OpenAPI table with its final shape, so the route-coverage - * and contract checks hold in every package's worktree from the start. The - * stub authenticates exactly as the finished route will and then answers 501. - * - * Nothing may ship with a stub in it: the error-code catalogue check fails on - * a listed code no route emits, so the code leaves the catalogue, and this - * file with it, once the last stub is implemented. - */ -import { apiError } from "@/lib/api-response"; - -export const NOT_IMPLEMENTED_ERROR_CODE = "not_implemented"; - -export function notImplemented() { - return apiError("Not implemented", 501, { - errorCode: NOT_IMPLEMENTED_ERROR_CODE, - }); -} diff --git a/src/lib/mcp/record-search.ts b/src/lib/mcp/record-search.ts index bb1b69ffc..38eaf0870 100644 --- a/src/lib/mcp/record-search.ts +++ b/src/lib/mcp/record-search.ts @@ -66,7 +66,11 @@ import { listTargets } from "@/lib/links"; import type { LinkedTarget } from "@/lib/links/link-service"; import { isModuleEnabled, type ModuleKey } from "@/lib/modules/gate"; import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format"; -import { resolveCatalogEntry } from "@/lib/vaccinations/vaccine-catalog"; +import { + CUSTOM_VACCINE_RESOLVE_SELECT, + customLookupOf, + resolveVaccineEntry, +} from "@/lib/vaccinations/resolve-vaccine-entry"; // ── Bounds ─────────────────────────────────────────────────────────── @@ -430,8 +434,8 @@ function vaccineCatalogName( slug: string | null, locale: Locale, ): string | null { - const entry = resolveCatalogEntry(slug); - if (!entry) return null; + const entry = resolveVaccineEntry({ antigenSlug: slug }); + if (!entry?.slug) return null; return getServerTranslator(locale).t(`vaccinations.catalog.${entry.slug}`); } @@ -646,8 +650,27 @@ const VACCINATION_SELECT = { lotNumber: true, site: true, practitioner: PRACTITIONER_SELECT, + // v1.42 (#1005) — the person's own definition, for its name and antigens. + customVaccineId: true, + customVaccine: { select: CUSTOM_VACCINE_RESOLVE_SELECT }, } as const; +/** + * The person's own vaccine definition a dose names, when it is the answer + * (the catalogue wins when the dose also carries a slug). Its name is user + * text and is scrubbed or fenced by the caller. + */ +function ownVaccine(row: { + antigenSlug: string | null; + customVaccineId: string | null; + customVaccine: Parameters[0][number]; +}): { name: string; components: readonly string[] } | null { + const entry = resolveVaccineEntry(row, customLookupOf([row.customVaccine])); + return entry?.kind === "custom" && entry.name + ? { name: entry.name, components: entry.components } + : null; +} + async function conditionCandidates( userId: string, owner: Owner, @@ -858,10 +881,15 @@ async function vaccinationCandidates( }); return rows.map((row) => { const practitioner = livePractitioner(row.practitioner); - const entry = resolveCatalogEntry(row.antigenSlug); + const entry = resolveVaccineEntry({ antigenSlug: row.antigenSlug }); + const own = ownVaccine(row); const catalogEn = vaccineCatalogName(row.antigenSlug, "en"); const name = - row.vaccineName ?? catalogEn ?? row.antigenSlug ?? "Vaccination"; + row.vaccineName ?? + catalogEn ?? + own?.name ?? + row.antigenSlug ?? + "Vaccination"; const dose = row.doseNumber ? `dose ${row.doseNumber}, ` : ""; const day = dateOnly(row.occurredAt); return { @@ -881,6 +909,10 @@ async function vaccinationCandidates( ? enumWords(row.antigenSlug.replace(/-/g, "_")) : "", ...(entry?.synonyms ?? []), + own?.name ?? "", + ...(own?.components ?? []).map((antigen) => + enumWords(antigen.replace(/-/g, "_")), + ), ].join(" "), weight: WEIGHT.primary, }, @@ -1072,6 +1104,8 @@ async function fetchVisit( vaccineName: true, antigenSlug: true, occurredAt: true, + customVaccineId: true, + customVaccine: { select: CUSTOM_VACCINE_RESOLVE_SELECT }, }, orderBy: [{ occurredAt: "asc" }, { id: "asc" }], }) @@ -1087,6 +1121,7 @@ async function fetchVisit( label: scrubFenceMarkers( dose.vaccineName ?? vaccineCatalogName(dose.antigenSlug, "en") ?? + ownVaccine(dose)?.name ?? dose.antigenSlug ?? "Vaccination", ), @@ -1377,7 +1412,9 @@ async function fetchVaccination( ]); const catalog = vaccineCatalogName(row.antigenSlug, "en"); - const name = row.vaccineName ?? catalog ?? row.antigenSlug ?? "Vaccination"; + const own = ownVaccine(row); + const name = + row.vaccineName ?? catalog ?? own?.name ?? row.antigenSlug ?? "Vaccination"; const practitioner = livePractitioner(row.practitioner); const day = dateOnly(row.occurredAt); const dose = @@ -1388,6 +1425,13 @@ async function fetchVaccination( const sentences = [ `Vaccination ${fenceUserText(name)} on ${day}, ${dose}.`, catalog && row.vaccineName ? `Catalogue entry: ${catalog}.` : null, + own + ? `Logged against the person's own vaccine ${fenceUserText(own.name)}${ + own.components.length > 0 + ? `, which protects against ${own.components.join(", ")}` + : "" + }.` + : null, practitioner ? `Given by ${fenceUserText(practitioner.name)}.` : "No practitioner recorded.", diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index b11f0caf3..c393c79bf 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -380,6 +380,10 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "vaccination.booster-invalid", "vaccination.booster-no-antigen", "vaccination.booster-out-of-scope", + "vaccination.custom-vaccine-not-found", + "vaccination.custom.invalid", + "vaccination.custom.name-taken", + "vaccination.custom.not-found", "vaccination.encounter-not-found", "vaccination.identity-required", "vaccination.invalid", @@ -410,7 +414,6 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "modules_conflict", "mood_tag_layout_conflict", "not_configured", - "not_implemented", "notification_prefs_conflict", "oidc_only", "private_origin_not_approved", diff --git a/src/lib/openapi/routes/admin.ts b/src/lib/openapi/routes/admin.ts index b32c4f87c..750911d4b 100644 --- a/src/lib/openapi/routes/admin.ts +++ b/src/lib/openapi/routes/admin.ts @@ -9,12 +9,7 @@ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; import { inviteCreateSchema } from "@/lib/validations/invite"; import { adminReminderCheckSchema } from "@/lib/validations/notifications"; -import { - dataEnvelope, - errorEnvelope, - notImplementedResponse, - stdResponses, -} from "./shared"; +import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; // v1.4.48 H-APNs-1 — admin diagnostic endpoint for the notification // subsystem. Mirrors the runtime types in @@ -310,7 +305,6 @@ export const adminDiagnosticPaths: NonNullable = { content: { "application/json": { schema: errorEnvelope } }, }, ...stdResponses, - ...notImplementedResponse, }, }, }, diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index bfde7afe5..831933630 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -269,21 +269,6 @@ export const malformedJsonResponse = { }, }; -/** - * The 501 a route answers while its contract is published ahead of its - * implementation on a release branch. Every stub carries it; the owner of the - * route drops it with the stub. No tagged release answers it: the error-code - * catalogue check fails once `not_implemented` is listed and nothing emits - * it, and fails while something still does and it is not listed. - */ -export const notImplementedResponse = { - "501": { - description: - "Not implemented yet: the contract is published ahead of the implementation on a release branch. `meta.errorCode: not_implemented`.", - content: { "application/json": { schema: errorEnvelope } }, - }, -} as const; - // ── Standard 401 / 422 / 429 responses ─────────────────────────────── export const stdResponses = { diff --git a/src/lib/openapi/routes/vaccinations.ts b/src/lib/openapi/routes/vaccinations.ts index 9eb67bad2..d38b6fec9 100644 --- a/src/lib/openapi/routes/vaccinations.ts +++ b/src/lib/openapi/routes/vaccinations.ts @@ -29,6 +29,8 @@ import { vaccinationSiteEnum, vaccinationBoosterSchema, vaccinationSuggestQuerySchema, + customVaccineCreateSchema, + customVaccineUpdateSchema, } from "@/lib/validations/vaccinations"; import { measurementReminderDto } from "@/lib/validations/measurement-reminders"; @@ -37,7 +39,6 @@ import { errorEnvelope, idempotencyKeyParameter, idempotentWrite, - notImplementedResponse, recordRefusal, recordWriteRateLimitResponse, stdResponses, @@ -49,13 +50,13 @@ import { const createVaccinationRequest = vaccinationCreateSchema.meta({ id: "CreateVaccinationRequest", description: - "Log one administered dose. `occurredAt` is required and must not be in the future — a vaccination is recorded after the fact, and a planned booster is a reminder rather than a record. Exactly one identity arm is required: either `antigenSlug` (a slug the catalogue offers) or `vaccineName` (whatever the person's own record says, verbatim, which may be a trade name). Everything else is optional, so a decades-old paper entry with nothing but a date and a name is loggable. `note` is encrypted at rest. `documentIds` pre-links the scanned pages; an id naming nothing the caller owns is dropped rather than refused, so a link never blocks a save. Logging a dose satisfies any booster reminder keyed to one of its component antigens.", + "Log one administered dose. `occurredAt` is required and must not be in the future — a vaccination is recorded after the fact, and a planned booster is a reminder rather than a record. At least one identity arm is required: `antigenSlug` (a slug the catalogue offers), `customVaccineId` (one of the record's own vaccine definitions, see `/api/vaccinations/custom`; an id the record does not hold live is a 404 `vaccination.custom-vaccine-not-found`) or `vaccineName` (whatever the person's own record says, verbatim, which may be a trade name). When both a slug and a definition are given, the catalogue is the answer. Everything else is optional, so a decades-old paper entry with nothing but a date and a name is loggable. `note` is encrypted at rest. `documentIds` pre-links the scanned pages; an id naming nothing the caller owns is dropped rather than refused, so a link never blocks a save. Logging a dose satisfies any booster reminder keyed to one of its component antigens, the antigens of an own definition included.", }); const updateVaccinationRequest = vaccinationUpdateSchema.meta({ id: "UpdateVaccinationRequest", description: - "Partial edit of a dose; an omitted key leaves the column untouched, and a body naming nothing is a 422. `occurredAt` is editable because a transcription typo is the common case — and editing it deliberately does NOT re-run the booster satisfaction, so correcting a date can never move a reminder's due date. An edit that would leave the record with neither identity arm is refused. A present `documentIds` array replaces the links, empty array included.", + "Partial edit of a dose; an omitted key leaves the column untouched, and a body naming nothing is a 422. `occurredAt` is editable because a transcription typo is the common case — and editing it deliberately does NOT re-run the booster satisfaction, so correcting a date can never move a reminder's due date. An edit that would leave the record with no identity arm (slug, own definition or name) is refused. `customVaccineId: null` unlinks the dose from its own definition. A present `documentIds` array replaces the links, empty array included.", }); const listVaccinationsQuery = vaccinationListQuerySchema.meta({ @@ -150,6 +151,55 @@ const practitioner = z "The practice that administered the dose, resolved rather than an id. Null when the record names none.", }); +// v1.42 (#1005) — the record's own vaccine definitions. +const customVaccine = z + .object({ + id: z.string(), + name: z.string(), + components: z + .array(z.string()) + .describe( + "Catalogue antigen slugs the product protects against, only ones this release knows.", + ), + typicalSeriesDoses: z.number().int().nullable(), + boosterIntervalMonths: z.number().int().nullable(), + createdAt: z.iso.datetime({ offset: true }), + updatedAt: z.iso.datetime({ offset: true }), + }) + .meta({ + id: "CustomVaccine", + description: + "A vaccine the record's owner defined for a product the shipped catalogue does not list, in the shape a catalogue entry resolves to. A dose logged against it counts into the series of each listed antigen, settles a booster reminder keyed to one of them and offers one from `boosterIntervalMonths`, exactly as a catalogue pick does. `name` is the person's own wording.", + }); + +const customVaccineCreateRequest = customVaccineCreateSchema.meta({ + id: "CreateCustomVaccineRequest", + description: + "A new definition. `components` lists one to all of the catalogue's antigen slugs, each once — a disease outside that list is logged as a free-text dose instead. `typicalSeriesDoses` (1–10) and `boosterIntervalMonths` (1–600) are optional; the interval prefills the booster offer after a dose.", +}); + +const customVaccineUpdateRequest = customVaccineUpdateSchema.meta({ + id: "UpdateCustomVaccineRequest", + description: + "Partial edit; an omitted key is left untouched and an empty body is a 422. The change applies to every dose logged against the definition, because the series is derived on read; it never re-runs booster satisfaction.", +}); + +const customVaccineNotFound = { + "404": { + description: + "No live definition with this id on the record (`vaccination.custom.not-found`).", + content: { "application/json": { schema: errorEnvelope } }, + }, +} as const; + +const customVaccineNameTaken = { + "409": { + description: + "A live definition on the record already carries this name, compared without case (`vaccination.custom.name-taken`).", + content: { "application/json": { schema: errorEnvelope } }, + }, +} as const; + const vaccination = z .object({ id: z.string(), @@ -161,6 +211,14 @@ const vaccination = z lotNumber: z.string().nullable(), site: vaccinationSiteEnum.nullable(), catalogEntry: catalogEntry.nullable(), + customVaccineId: z.string().nullable().meta({ + description: + "The record's own vaccine definition the dose names (v1.42), verbatim.", + }), + customVaccine: customVaccine.nullable().meta({ + description: + "That definition, resolved, or null when the dose names none or it was removed. When `catalogEntry` is also set, the catalogue is the answer.", + }), series: z.array(seriesPosition), practitioner: practitioner.nullable(), encounter: vaccinationEncounter.nullable(), @@ -244,43 +302,15 @@ const vaccinationNotFound = { const idPath = { path: z.object({ id: z.string() }) }; -// v1.42 (#1005) — the record's own vaccine definitions. -const customVaccine = z - .object({ - id: z.string(), - name: z.string(), - components: z - .array(z.string()) - .describe("Catalogue antigen slugs the product covers."), - typicalSeriesDoses: z.number().int().nullable(), - boosterIntervalMonths: z.number().int().nullable(), - createdAt: z.iso.datetime({ offset: true }), - updatedAt: z.iso.datetime({ offset: true }), - }) - .meta({ id: "CustomVaccine" }); - -const customVaccineCreateRequest = z - .object({ - name: z.string(), - components: z.array(z.string()), - typicalSeriesDoses: z.number().int().nullable().optional(), - boosterIntervalMonths: z.number().int().nullable().optional(), - }) - .strict() - .meta({ id: "CreateCustomVaccineRequest" }); - -const customVaccineUpdateRequest = customVaccineCreateRequest - .partial() - .meta({ id: "UpdateCustomVaccineRequest" }); - export const vaccinationPaths: NonNullable = { "/api/vaccinations/custom": { get: { tags: ["Records"], summary: "List the record's own vaccines", description: - "Vaccine definitions the record's owner added for products the shipped catalogue does not list. Soft-deleted definitions are excluded.", + "The vaccine definitions the record's owner added for products the shipped catalogue does not list, by name. Removed definitions are excluded. Readable under a `profile` grant like the dose list.", responses: { + ...recordRefusal(), "200": { description: "The definitions, by name.", content: { @@ -293,19 +323,19 @@ export const vaccinationPaths: NonNullable = { }, }, ...stdResponses, - ...notImplementedResponse, }, }, post: { tags: ["Records"], summary: "Add an own vaccine", description: - "Defines a vaccine in the same shape a catalogue entry resolves to, so series and booster logic treat both alike. Names are unique per record.", + "Defines a vaccine in the same shape a catalogue entry resolves to, so the series and booster logic treat both alike. Names are unique per record, compared without case; adding a name the person removed earlier brings that definition back with the new values (its doses were let go on removal and stay so). Audits as `vaccination.custom.create`.", requestBody: { required: true, content: { "application/json": { schema: customVaccineCreateRequest } }, }, responses: { + ...recordRefusal(), "201": { description: "Created.", content: { @@ -317,13 +347,9 @@ export const vaccinationPaths: NonNullable = { }, }, }, - "409": { - description: "A live definition with this name already exists.", - content: { "application/json": { schema: errorEnvelope } }, - }, + ...customVaccineNameTaken, ...stdResponses, ...recordWriteRateLimitResponse, - ...notImplementedResponse, }, }, }, @@ -331,13 +357,15 @@ export const vaccinationPaths: NonNullable = { patch: { tags: ["Records"], summary: "Edit an own vaccine", - description: "Changes one definition. Owner-scoped; an unknown id 404s.", + description: + "Changes one definition; every dose logged against it reads the change. Owner-scoped; an unknown or removed id 404s. Audits as `vaccination.custom.update`, naming a rename without quoting it.", requestParams: idPath, requestBody: { required: true, content: { "application/json": { schema: customVaccineUpdateRequest } }, }, responses: { + ...recordRefusal(), "200": { description: "Updated.", content: { @@ -349,25 +377,19 @@ export const vaccinationPaths: NonNullable = { }, }, }, - "404": { - description: "No such definition on this record.", - content: { "application/json": { schema: errorEnvelope } }, - }, - "409": { - description: "A live definition with this name already exists.", - content: { "application/json": { schema: errorEnvelope } }, - }, + ...customVaccineNotFound, + ...customVaccineNameTaken, ...stdResponses, - ...notImplementedResponse, }, }, delete: { tags: ["Records"], summary: "Remove an own vaccine", description: - "Soft-deletes one definition. Doses logged against it stay, on their `vaccineName`.", + "Soft-deletes one definition and lets go of every dose that named it. A dose keeps a name: one without wording of its own takes the definition's name as its `vaccineName` first. Removing twice succeeds. Audits as `vaccination.custom.delete` with the number of doses let go.", requestParams: idPath, responses: { + ...recordRefusal(), "200": { description: "Removed.", content: { @@ -379,12 +401,8 @@ export const vaccinationPaths: NonNullable = { }, }, }, - "404": { - description: "No such definition on this record.", - content: { "application/json": { schema: errorEnvelope } }, - }, + ...customVaccineNotFound, ...stdResponses, - ...notImplementedResponse, }, }, }, @@ -421,6 +439,11 @@ export const vaccinationPaths: NonNullable = { responses: { ...idempotentWrite(), ...recordRefusal(), + "404": { + description: + "A practice, visit or own vaccine the body names is not the record's (`vaccination.practitioner-not-found`, `vaccination.encounter-not-found`, `vaccination.custom-vaccine-not-found`).", + content: { "application/json": { schema: errorEnvelope } }, + }, "201": { description: "Dose logged.", content: { diff --git a/src/lib/query-keys/vaccinations.ts b/src/lib/query-keys/vaccinations.ts index 6271cc3fc..09d3cbd7d 100644 --- a/src/lib/query-keys/vaccinations.ts +++ b/src/lib/query-keys/vaccinations.ts @@ -52,4 +52,13 @@ export const vaccinationKeys = { vaccinationRestore: () => ["vaccinations", "restore"] as const, vaccinationBooster: () => ["vaccinations", "booster"] as const, vaccinationLink: () => ["vaccinations", "link"] as const, + /** + * v1.42 (#1005) — the record's own vaccine definitions. Under the + * `["vaccinations"]` root on purpose: editing or removing a definition + * re-reads every dose that names it, and the root eviction repaints both. + */ + customVaccines: () => ["vaccinations", "custom"] as const, + customVaccineCreate: () => ["vaccinations", "custom", "create"] as const, + customVaccineUpdate: () => ["vaccinations", "custom", "update"] as const, + customVaccineDelete: () => ["vaccinations", "custom", "delete"] as const, } as const; diff --git a/src/lib/record-activity/activity-verb.ts b/src/lib/record-activity/activity-verb.ts index 15360ca9b..2c310f04a 100644 --- a/src/lib/record-activity/activity-verb.ts +++ b/src/lib/record-activity/activity-verb.ts @@ -78,6 +78,8 @@ export function recordActivityVerbLine( return t("recordSharing.activityVerb.encounterVisitCreate", { name }); case "vaccination.record.create": return t("recordSharing.activityVerb.vaccinationRecordCreate", { name }); + case "vaccination.custom.create": + return t("recordSharing.activityVerb.customVaccineCreate", { name }); case "practitioner.contact.create": return t("recordSharing.activityVerb.practitionerContactCreate", { name, @@ -218,6 +220,10 @@ export function recordActivityVerbLine( return t("recordSharing.activityVerb.vaccinationRecordDelete", { name }); case "vaccination.record.restore": return t("recordSharing.activityVerb.vaccinationRecordRestore", { name }); + case "vaccination.custom.update": + return t("recordSharing.activityVerb.customVaccineUpdate", { name }); + case "vaccination.custom.delete": + return t("recordSharing.activityVerb.customVaccineDelete", { name }); case "vaccination.booster.planned": return t("recordSharing.activityVerb.vaccinationBoosterPlanned", { name, diff --git a/src/lib/vaccinations/__tests__/catalog-integrity.test.ts b/src/lib/vaccinations/__tests__/catalog-integrity.test.ts index 5dc9dfe88..9c147c94b 100644 --- a/src/lib/vaccinations/__tests__/catalog-integrity.test.ts +++ b/src/lib/vaccinations/__tests__/catalog-integrity.test.ts @@ -23,10 +23,10 @@ import { ANTIGEN_SLUGS, VACCINE_CATALOG, VACCINE_CATALOG_SLUGS, - componentsForSlug, resolveCatalogEntry, type AntigenSlug, } from "@/lib/vaccinations/vaccine-catalog"; +import { componentsForDose } from "@/lib/vaccinations/resolve-vaccine-entry"; const MONOVALENT = new Set(ANTIGEN_SLUGS); @@ -193,8 +193,10 @@ describe("a slug the catalogue no longer knows degrades to the free text", () => it("matches no antigen at all rather than guessing from the name", () => { // The consequence that matters: an unresolvable dose must not clear a // booster reminder. No components means no match. - expect(componentsForSlug(ORPHANED_RECORD.antigenSlug)).toEqual([]); - expect(componentsForSlug(null)).toEqual([]); + expect( + componentsForDose({ antigenSlug: ORPHANED_RECORD.antigenSlug }), + ).toEqual([]); + expect(componentsForDose({ antigenSlug: null })).toEqual([]); }); it("still resolves a slug it does have, so the check above is not vacuous", () => { diff --git a/src/lib/vaccinations/__tests__/resolve-vaccine-entry.test.ts b/src/lib/vaccinations/__tests__/resolve-vaccine-entry.test.ts new file mode 100644 index 000000000..faddb0e6a --- /dev/null +++ b/src/lib/vaccinations/__tests__/resolve-vaccine-entry.test.ts @@ -0,0 +1,138 @@ +/** + * v1.42 (#1005) — the one resolver every consumer asks, and what the + * person's own vaccine definitions do through it: count into each listed + * antigen's series, take their own series length, and lose all of that the + * moment the definition is removed, falling back to the dose's name. + */ +import { describe, expect, it } from "vitest"; + +import { + componentsForDose, + customLookupOf, + knownAntigens, + resolveVaccineEntry, + type CustomVaccineDefinition, +} from "@/lib/vaccinations/resolve-vaccine-entry"; +import { deriveSeries } from "@/lib/vaccinations/series"; + +const MONTH_MS = 30 * 24 * 60 * 60 * 1000; +const monthsAgo = (months: number) => new Date(Date.now() - months * MONTH_MS); + +const dtp: CustomVaccineDefinition = { + id: "cv-dtp", + name: "DTP from abroad", + components: ["diphtheria", "tetanus", "pertussis"], + typicalSeriesDoses: 3, + boosterIntervalMonths: 120, +}; +const customs = customLookupOf([dtp]); + +describe("resolveVaccineEntry", () => { + it("resolves a catalogue slug, with its citation", () => { + const entry = resolveVaccineEntry({ antigenSlug: "tdap" }); + expect(entry?.kind).toBe("catalog"); + expect(entry?.slug).toBe("tdap"); + expect(entry?.source).not.toBeNull(); + expect(entry?.components).toEqual(["tetanus", "diphtheria", "pertussis"]); + }); + + it("resolves the person's own definition in the same shape, uncited", () => { + const entry = resolveVaccineEntry({ customVaccineId: "cv-dtp" }, customs); + expect(entry).toMatchObject({ + kind: "custom", + slug: null, + customVaccineId: "cv-dtp", + name: "DTP from abroad", + atc: null, + category: null, + source: null, + typicalSeriesDoses: 3, + boosterIntervalMonths: 120, + }); + expect(entry?.components).toEqual(["diphtheria", "tetanus", "pertussis"]); + }); + + it("lets the catalogue win when a dose carries both", () => { + const entry = resolveVaccineEntry( + { antigenSlug: "influenza", customVaccineId: "cv-dtp" }, + customs, + ); + expect(entry?.kind).toBe("catalog"); + expect(entry?.components).toEqual(["influenza"]); + }); + + it("answers null for nothing it knows: no arm, a dead slug, an unknown or removed definition", () => { + expect(resolveVaccineEntry({})).toBeNull(); + expect(resolveVaccineEntry({ antigenSlug: "no-such-slug" })).toBeNull(); + expect( + resolveVaccineEntry({ customVaccineId: "cv-missing" }, customs), + ).toBeNull(); + expect(resolveVaccineEntry({ customVaccineId: "cv-dtp" })).toBeNull(); + const removed = customLookupOf([{ ...dtp, deletedAt: new Date() }]); + expect( + resolveVaccineEntry({ customVaccineId: "cv-dtp" }, removed), + ).toBeNull(); + }); + + it("drops antigens the catalogue does not know, and duplicates, but keeps the rest", () => { + expect( + knownAntigens(["tetanus", "unicorn-pox", "tetanus", "rabies"]), + ).toEqual(["tetanus", "rabies"]); + const odd = customLookupOf([ + { ...dtp, id: "cv-odd", components: ["unicorn-pox", "cholera"] }, + ]); + expect(componentsForDose({ customVaccineId: "cv-odd" }, odd)).toEqual([ + "cholera", + ]); + }); +}); + +describe("a dose logged against an own definition, in the series", () => { + it("counts into each listed antigen, at that antigen's own position", () => { + const history = [ + // Two catalogue tetanus doses first, then the DTP definition. + { + id: "t1", + occurredAt: monthsAgo(48), + antigenSlug: "tetanus", + doseNumber: null, + seriesDoses: null, + }, + { + id: "t2", + occurredAt: monthsAgo(36), + antigenSlug: "tetanus", + doseNumber: null, + seriesDoses: null, + }, + { + id: "own", + occurredAt: monthsAgo(12), + antigenSlug: null, + customVaccineId: "cv-dtp", + doseNumber: null, + seriesDoses: null, + }, + ]; + const series = deriveSeries(history, customs).get("own"); + expect(series).toEqual([ + { antigen: "diphtheria", position: 1, total: 3, booster: false }, + { antigen: "tetanus", position: 3, total: 3, booster: false }, + { antigen: "pertussis", position: 1, total: 3, booster: false }, + ]); + }); + + it("places nothing once the definition is gone from the lookup", () => { + const series = deriveSeries([ + { + id: "own", + occurredAt: monthsAgo(1), + antigenSlug: null, + customVaccineId: "cv-dtp", + doseNumber: null, + seriesDoses: null, + }, + ]); + expect(series.get("own")).toEqual([]); + }); +}); diff --git a/src/lib/vaccinations/booster-mint.ts b/src/lib/vaccinations/booster-mint.ts index ccd0504ae..871fc68f1 100644 --- a/src/lib/vaccinations/booster-mint.ts +++ b/src/lib/vaccinations/booster-mint.ts @@ -28,7 +28,10 @@ import type { Prisma } from "@/generated/prisma/client"; import { computeReminderNextDueAt } from "@/lib/measurement-reminders/scheduling"; -import { componentsForSlug } from "@/lib/vaccinations/vaccine-catalog"; +import { + componentsForDose, + loadCustomVaccineLookup, +} from "@/lib/vaccinations/resolve-vaccine-entry"; /** What the confirm carries. All three are the user's edited-or-accepted values. */ export interface BoosterMintInput { @@ -80,14 +83,25 @@ export async function mintOrReanchorBooster( ): Promise { const record = await tx.vaccinationRecord.findFirst({ where: { id: input.vaccinationId, userId, deletedAt: null }, - select: { id: true, antigenSlug: true, occurredAt: true }, + select: { + id: true, + antigenSlug: true, + customVaccineId: true, + occurredAt: true, + }, }); if (!record) return { outcome: "unknown-record" }; // The reminder keys on the dose's PRIMARY component antigen — for a Tdap that - // is tetanus. A person who tracks the components separately can mint further - // reminders by hand; the offer covers the one the interval belongs to. - const [antigen] = componentsForSlug(record.antigenSlug); + // is tetanus, for the person's own definition the first antigen it lists. A + // person who tracks the components separately can mint further reminders by + // hand; the offer covers the one the interval belongs to. + const [antigen] = componentsForDose( + record, + record.customVaccineId + ? await loadCustomVaccineLookup(tx, userId) + : undefined, + ); if (!antigen) return { outcome: "no-antigen" }; const notifyHour = input.notifyHour ?? 9; diff --git a/src/lib/vaccinations/custom-vaccines.ts b/src/lib/vaccinations/custom-vaccines.ts new file mode 100644 index 000000000..8d5006f81 --- /dev/null +++ b/src/lib/vaccinations/custom-vaccines.ts @@ -0,0 +1,69 @@ +/** + * What the custom-vaccine routes share (v1.42, #1005): the columns they + * publish, the name-clash probe and the removal. + */ +import type { Prisma } from "@/generated/prisma/client"; + +/** The columns a definition is published with. */ +export const CUSTOM_VACCINE_SELECT = { + id: true, + name: true, + components: true, + typicalSeriesDoses: true, + boosterIntervalMonths: true, + createdAt: true, + updatedAt: true, +} as const satisfies Prisma.CustomVaccineSelect; + +/** + * A live definition on the record whose name equals `name` ignoring case, + * other than `exceptId`. "Dukoral" and "dukoral" are one product to the + * person reading the picker, even though the unique index tells them apart. + */ +export async function findLiveNameClash( + tx: Prisma.TransactionClient, + userId: string, + name: string, + exceptId?: string, +): Promise { + const clash = await tx.customVaccine.findFirst({ + where: { + userId, + deletedAt: null, + name: { equals: name, mode: "insensitive" }, + ...(exceptId ? { id: { not: exceptId } } : {}), + }, + select: { id: true }, + }); + return clash !== null; +} + +/** + * Remove a definition and let go of every dose that named it. + * + * The definition is tombstoned; the doses are not touched beyond the link. + * A dose that carried no wording of its own takes the definition's name as + * its `vaccineName` first, so it keeps a name — the same thing the foreign + * key's `ON DELETE SET NULL` promises for a hard delete, done by hand because + * a tombstone does not fire it. Runs in the caller's transaction so the name + * and the unlink land together. Returns how many doses were unlinked. + */ +export async function removeCustomVaccine( + tx: Prisma.TransactionClient, + userId: string, + definition: { id: string; name: string }, +): Promise { + await tx.vaccinationRecord.updateMany({ + where: { userId, customVaccineId: definition.id, vaccineName: null }, + data: { vaccineName: definition.name }, + }); + const unlinked = await tx.vaccinationRecord.updateMany({ + where: { userId, customVaccineId: definition.id }, + data: { customVaccineId: null }, + }); + await tx.customVaccine.update({ + where: { id: definition.id }, + data: { deletedAt: new Date() }, + }); + return unlinked.count; +} diff --git a/src/lib/vaccinations/document-suggestion.ts b/src/lib/vaccinations/document-suggestion.ts index e123b83c4..58b518dc7 100644 --- a/src/lib/vaccinations/document-suggestion.ts +++ b/src/lib/vaccinations/document-suggestion.ts @@ -89,6 +89,9 @@ export async function suggestVaccinationForDate( occurredAt: true, antigenSlug: true, vaccineName: true, + // v1.42 (#1005) — a dose named only by the person's own definition is + // offered under that definition's name. + customVaccine: { select: { name: true, deletedAt: true } }, }, }); @@ -96,7 +99,11 @@ export async function suggestVaccinationForDate( id: row.id, occurredAt: row.occurredAt.toISOString(), antigenSlug: row.antigenSlug, - vaccineName: row.vaccineName, + vaccineName: + row.vaccineName ?? + (row.customVaccine && row.customVaccine.deletedAt === null + ? row.customVaccine.name + : null), })); if (vaccinations.length === 0) return { kind: "none" }; diff --git a/src/lib/vaccinations/dto.ts b/src/lib/vaccinations/dto.ts index a1a5ac29b..0548432e5 100644 --- a/src/lib/vaccinations/dto.ts +++ b/src/lib/vaccinations/dto.ts @@ -19,6 +19,7 @@ import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; import { getEvent } from "@/lib/logging/context"; import type { + CustomVaccine, Encounter, Practitioner, VaccinationRecord, @@ -28,7 +29,10 @@ import { toPractitionerDTO, type PractitionerDTO, } from "@/lib/practitioners/dto"; -import { resolveCatalogEntry } from "@/lib/vaccinations/vaccine-catalog"; +import { + knownAntigens, + resolveVaccineEntry, +} from "@/lib/vaccinations/resolve-vaccine-entry"; import type { SeriesPosition } from "@/lib/vaccinations/series"; import type { VaccinationRenewalDTO } from "@/lib/vaccinations/renewal"; @@ -56,6 +60,43 @@ export interface VaccinationCatalogDTO { category: string; } +/** + * One of the record's own vaccine definitions (v1.42, #1005). `components` + * lists only antigens the catalogue knows, so a client can name each one. + */ +export interface CustomVaccineDTO { + id: string; + name: string; + components: string[]; + typicalSeriesDoses: number | null; + boosterIntervalMonths: number | null; + createdAt: string; + updatedAt: string; +} + +export type CustomVaccineRow = Pick< + CustomVaccine, + | "id" + | "name" + | "components" + | "typicalSeriesDoses" + | "boosterIntervalMonths" + | "createdAt" + | "updatedAt" +>; + +export function toCustomVaccineDTO(row: CustomVaccineRow): CustomVaccineDTO { + return { + id: row.id, + name: row.name, + components: knownAntigens(row.components), + typicalSeriesDoses: row.typicalSeriesDoses, + boosterIntervalMonths: row.boosterIntervalMonths, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + /** The visit this dose was given at, enough to recognise it by. */ export interface VaccinationEncounterDTO { id: string; @@ -80,6 +121,13 @@ export interface VaccinationDTO { * not know the slug. Null is the signal to render `vaccineName` instead. */ catalogEntry: VaccinationCatalogDTO | null; + /** The person's own definition the dose names, verbatim. */ + customVaccineId: string | null; + /** + * That definition, resolved, or `null` when the dose names none or it was + * removed. When `catalogEntry` is also set, the catalogue is the answer. + */ + customVaccine: CustomVaccineDTO | null; /** * Where this dose sits in each of its antigen series, resolved server-side. * @@ -123,6 +171,7 @@ function decryptNote(value: Uint8Array | null): string | null { export type VaccinationWithRelations = VaccinationRecord & { practitioner?: Practitioner | null; encounter?: Pick | null; + customVaccine?: (CustomVaccineRow & Pick) | null; }; export function toVaccinationDTO( @@ -130,7 +179,11 @@ export function toVaccinationDTO( series: SeriesPosition[], documents?: VaccinationDocumentDTO[], ): VaccinationDTO { - const entry = resolveCatalogEntry(row.antigenSlug); + const custom = + row.customVaccine && row.customVaccine.deletedAt === null + ? row.customVaccine + : null; + const entry = resolveVaccineEntry({ antigenSlug: row.antigenSlug }); return { id: row.id, occurredAt: row.occurredAt.toISOString(), @@ -140,9 +193,12 @@ export function toVaccinationDTO( seriesDoses: row.seriesDoses, lotNumber: row.lotNumber, site: row.site, - catalogEntry: entry - ? { slug: entry.slug, atc: entry.atc, category: entry.category } - : null, + catalogEntry: + entry?.slug && entry.atc && entry.category + ? { slug: entry.slug, atc: entry.atc, category: entry.category } + : null, + customVaccineId: row.customVaccineId, + customVaccine: custom ? toCustomVaccineDTO(custom) : null, series, practitioner: row.practitioner ? toPractitionerDTO(row.practitioner) : null, encounter: row.encounter diff --git a/src/lib/vaccinations/resolve-vaccine-entry.ts b/src/lib/vaccinations/resolve-vaccine-entry.ts new file mode 100644 index 000000000..3def70740 --- /dev/null +++ b/src/lib/vaccinations/resolve-vaccine-entry.ts @@ -0,0 +1,191 @@ +/** + * The one answer to "what was this dose", catalogue or the person's own. + * + * Since v1.42 (#1005) a dose can name a vaccine the shipped catalogue does + * not list: the person defines it once (`CustomVaccine`: a name, the + * antigens it protects against, a series length, a booster interval) and + * logs doses against it. Every consumer that used to ask the catalogue — + * the series derivation, the booster satisfy matcher, the booster mint, the + * DTO, the doctor report, the document links, MCP search, the picker's info + * sentences and the booster offer — asks this module instead, so a custom + * vaccine counts into a series, clears a booster and offers one exactly the + * way a catalogue entry does. + * + * ── Precedence ───────────────────────────────────────────────────────────── + * + * The catalogue wins when both resolve. A catalogue slug is the shipped, + * sourced answer; a definition is the person's own. A dose carries one or + * the other in practice, and the rule only decides the rare row that + * carries both (an edit that picked a catalogue entry without clearing the + * definition). + * + * A definition that is soft-deleted does not resolve, and neither does an + * antigen it lists that the catalogue does not know: storage is tolerant (a + * restore writes what the file held), the answer is not. A dose with nothing + * that resolves falls back to its `vaccineName`, the same degrade a dead + * catalogue slug has always had. + * + * Pure apart from {@link loadCustomVaccineLookup}, which takes the client it + * reads through, so the client components can import the resolver itself. + */ +import type { Prisma } from "@/generated/prisma/client"; + +import { + ANTIGEN_SLUGS, + resolveCatalogEntry, + type AntigenSlug, + type VaccineCategory, +} from "@/lib/vaccinations/vaccine-catalog"; + +/** A definition as the resolver reads it. */ +export interface CustomVaccineDefinition { + id: string; + name: string; + components: readonly string[]; + typicalSeriesDoses: number | null; + boosterIntervalMonths: number | null; + /** Absent on a DTO, which only ever carries live definitions. */ + deletedAt?: Date | null; +} + +/** The record's definitions, by id. */ +export type CustomVaccineLookup = ReadonlyMap; + +/** The fields of a dose the resolver reads. */ +export interface VaccineIdentity { + antigenSlug?: string | null; + customVaccineId?: string | null; +} + +/** The resolved entry, in the shape a catalogue seed has. */ +export interface ResolvedVaccineEntry { + kind: "catalog" | "custom"; + /** The catalogue slug, or null for a definition. */ + slug: string | null; + /** The definition's id, or null for a catalogue entry. */ + customVaccineId: string | null; + /** + * The definition's own name. Null for a catalogue entry, whose name + * resolves through i18n (`vaccinations.catalog.`). + */ + name: string | null; + /** WHO ATC code; a definition has none. */ + atc: string | null; + /** The antigens a dose counts into. */ + components: readonly AntigenSlug[]; + typicalSeriesDoses: number | null; + boosterIntervalMonths: number | null; + /** Picker grouping; a definition has none. */ + category: VaccineCategory | null; + /** The citation for the numbers; a definition is the person's, not cited. */ + source: string | null; + /** Generic search aliases; a definition has its name and nothing else. */ + synonyms: readonly string[]; +} + +const KNOWN_ANTIGENS: ReadonlySet = new Set(ANTIGEN_SLUGS); + +/** The listed antigens the catalogue knows, deduplicated, in stored order. */ +export function knownAntigens(raw: readonly string[]): AntigenSlug[] { + const seen = new Set(); + const out: AntigenSlug[] = []; + for (const value of raw) { + if (!KNOWN_ANTIGENS.has(value) || seen.has(value)) continue; + seen.add(value); + out.push(value as AntigenSlug); + } + return out; +} + +/** + * Resolve a dose to its entry, catalogue first. Null when neither arm + * resolves — the caller then falls back to the dose's `vaccineName`. + */ +export function resolveVaccineEntry( + record: VaccineIdentity, + customs?: CustomVaccineLookup, +): ResolvedVaccineEntry | null { + const catalog = resolveCatalogEntry(record.antigenSlug); + if (catalog) { + return { + kind: "catalog", + slug: catalog.slug, + customVaccineId: null, + name: null, + atc: catalog.atc, + components: catalog.components, + typicalSeriesDoses: catalog.typicalSeriesDoses, + boosterIntervalMonths: catalog.boosterIntervalMonths, + category: catalog.category, + source: catalog.source, + synonyms: catalog.synonyms ?? [], + }; + } + if (!record.customVaccineId || !customs) return null; + const custom = customs.get(record.customVaccineId); + if (!custom || custom.deletedAt) return null; + return { + kind: "custom", + slug: null, + customVaccineId: custom.id, + name: custom.name, + atc: null, + components: knownAntigens(custom.components), + typicalSeriesDoses: custom.typicalSeriesDoses, + boosterIntervalMonths: custom.boosterIntervalMonths, + category: null, + source: null, + synonyms: [], + }; +} + +/** + * The antigens a dose counts into. Empty for a free-text-only dose and for + * one whose arms no longer resolve: a name string is not evidence about + * which antigens were given, and guessing from one is how a booster reminder + * would be cleared by a dose that never contained it. + */ +export function componentsForDose( + record: VaccineIdentity, + customs?: CustomVaccineLookup, +): readonly AntigenSlug[] { + return resolveVaccineEntry(record, customs)?.components ?? []; +} + +/** A lookup over the definitions a list of rows carries. */ +export function customLookupOf( + definitions: ReadonlyArray, +): CustomVaccineLookup { + const map = new Map(); + for (const definition of definitions) { + if (definition) map.set(definition.id, definition); + } + return map; +} + +/** The columns a definition is resolved from. */ +export const CUSTOM_VACCINE_RESOLVE_SELECT = { + id: true, + name: true, + components: true, + typicalSeriesDoses: true, + boosterIntervalMonths: true, + deletedAt: true, + createdAt: true, + updatedAt: true, +} as const satisfies Prisma.CustomVaccineSelect; + +/** + * The record's live definitions, by id. An Impfpass names a handful at most, + * so the whole set is read rather than the ids a page happens to need. + */ +export async function loadCustomVaccineLookup( + client: Pick, + userId: string, +): Promise { + const rows = await client.customVaccine.findMany({ + where: { userId, deletedAt: null }, + select: CUSTOM_VACCINE_RESOLVE_SELECT, + }); + return customLookupOf(rows); +} diff --git a/src/lib/vaccinations/satisfy-matching.ts b/src/lib/vaccinations/satisfy-matching.ts index 7ec77fcac..0b4d89977 100644 --- a/src/lib/vaccinations/satisfy-matching.ts +++ b/src/lib/vaccinations/satisfy-matching.ts @@ -21,7 +21,9 @@ * * 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. + * booster cleared by a guess is worse than a booster not cleared at all. A + * dose logged against the person's own vaccine definition (v1.42, #1005) + * counts its listed antigens, the same way a catalogue combination does. * * ── Cold path only ───────────────────────────────────────────────────────── * @@ -50,7 +52,11 @@ import { satisfyReminder, type SatisfiableReminder, } from "@/lib/measurement-reminders/satisfy"; -import { componentsForSlug } from "@/lib/vaccinations/vaccine-catalog"; +import { + componentsForDose, + loadCustomVaccineLookup, + type VaccineIdentity, +} from "@/lib/vaccinations/resolve-vaccine-entry"; /** What the create route needs back: which reminders moved, and the first. */ export interface BoosterSatisfyResult { @@ -82,11 +88,16 @@ const EMPTY: BoosterSatisfyResult = { export async function satisfyBoostersForDose( tx: Prisma.TransactionClient, userId: string, - antigenSlug: string | null, + identity: VaccineIdentity, occurredAt: Date, timezone: string, ): Promise { - const components = componentsForSlug(antigenSlug); + const components = componentsForDose( + identity, + identity.customVaccineId + ? await loadCustomVaccineLookup(tx, userId) + : undefined, + ); if (components.length === 0) return EMPTY; const reminders = await tx.measurementReminder.findMany({ diff --git a/src/lib/vaccinations/series.ts b/src/lib/vaccinations/series.ts index d1749030b..2df8d0865 100644 --- a/src/lib/vaccinations/series.ts +++ b/src/lib/vaccinations/series.ts @@ -28,11 +28,11 @@ * medical-device boundary starts, and it is a separate venture's territory. * What this function reports is arithmetic on what a person wrote down. */ +import type { AntigenSlug } from "@/lib/vaccinations/vaccine-catalog"; import { - componentsForSlug, - resolveCatalogEntry, - type AntigenSlug, -} from "@/lib/vaccinations/vaccine-catalog"; + resolveVaccineEntry, + type CustomVaccineLookup, +} from "@/lib/vaccinations/resolve-vaccine-entry"; /** One antigen's view of one dose. */ export interface SeriesPosition { @@ -46,7 +46,8 @@ export interface SeriesPosition { position: number; /** * How long the series is: the record's own override first, then the - * catalogue's typical value, then null when neither knows. Null is honest — + * catalogue's (or the person's own definition's) typical value, then null + * when neither knows. Null is honest — * a seasonal vaccine has no "of M". */ total: number | null; @@ -62,6 +63,8 @@ export interface SeriesInputRecord { id: string; occurredAt: Date; antigenSlug: string | null; + /** The person's own definition (v1.42, #1005), resolved after the slug. */ + customVaccineId?: string | null; doseNumber: number | null; seriesDoses: number | null; } @@ -80,6 +83,7 @@ export interface SeriesInputRecord { */ export function deriveSeries( records: readonly SeriesInputRecord[], + customs?: CustomVaccineLookup, ): Map { const result = new Map(); for (const record of records) result.set(record.id, []); @@ -97,13 +101,14 @@ export function deriveSeries( for (const record of chronological) { // A free-text-only record — and a record whose slug the catalogue no - // longer resolves — yields nothing. Guessing an antigen from a name string - // is how a booster gets cleared by a dose that never contained it. - const components = componentsForSlug(record.antigenSlug); + // longer resolves, or whose own definition was removed — yields nothing. + // Guessing an antigen from a name string is how a booster gets cleared by + // a dose that never contained it. + const entry = resolveVaccineEntry(record, customs); + const components = entry?.components ?? []; if (components.length === 0) continue; - const catalogTotal = - resolveCatalogEntry(record.antigenSlug)?.typicalSeriesDoses ?? null; + const catalogTotal = entry?.typicalSeriesDoses ?? null; // The record's own override wins: it is what the person's actual schedule // was, and the catalogue only knows the typical one. const total = record.seriesDoses ?? catalogTotal; @@ -140,6 +145,7 @@ export function deriveSeries( export function seriesForRecord( recordId: string, history: readonly SeriesInputRecord[], + customs?: CustomVaccineLookup, ): SeriesPosition[] { - return deriveSeries(history).get(recordId) ?? []; + return deriveSeries(history, customs).get(recordId) ?? []; } diff --git a/src/lib/vaccinations/service.ts b/src/lib/vaccinations/service.ts index 686151f1a..0b3bf2e3e 100644 --- a/src/lib/vaccinations/service.ts +++ b/src/lib/vaccinations/service.ts @@ -17,6 +17,10 @@ import { deriveSeries, type SeriesInputRecord, } from "@/lib/vaccinations/series"; +import { + CUSTOM_VACCINE_RESOLVE_SELECT, + loadCustomVaccineLookup, +} from "@/lib/vaccinations/resolve-vaccine-entry"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; /** Matches the visits service: the fallback when an account never set one. */ @@ -25,6 +29,9 @@ import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; export const VACCINATION_INCLUDE = { practitioner: true, encounter: { select: { id: true, occurredAt: true, kind: true } }, + // v1.42 (#1005) — the person's own definition, resolved onto the DTO so no + // client needs a second read to name the dose or count its antigens. + customVaccine: { select: CUSTOM_VACCINE_RESOLVE_SELECT }, } as const satisfies Prisma.VaccinationRecordInclude; /** What the series derivation needs, and nothing more. */ @@ -32,6 +39,7 @@ export const SERIES_SELECT = { id: true, occurredAt: true, antigenSlug: true, + customVaccineId: true, doseNumber: true, seriesDoses: true, } as const satisfies Prisma.VaccinationRecordSelect; @@ -56,12 +64,20 @@ export async function loadSeriesHistory( }); } -/** Every record's resolved series positions, keyed by record id. */ +/** + * Every record's resolved series positions, keyed by record id. The record's + * own vaccine definitions are read beside the history, so a dose logged + * against one counts into the antigens it lists. + */ export async function resolveSeriesFor( tx: Prisma.TransactionClient, userId: string, ) { - return deriveSeries(await loadSeriesHistory(tx, userId)); + const [history, customs] = await Promise.all([ + loadSeriesHistory(tx, userId), + loadCustomVaccineLookup(tx, userId), + ]); + return deriveSeries(history, customs); } /** @@ -152,6 +168,18 @@ export async function resolveOwnerTimezone(userId: string): Promise { return row?.timezone || DEFAULT_TIMEZONE; } +/** Does the record own a live vaccine definition with this id? */ +export async function resolveOwnedCustomVaccine( + tx: Prisma.TransactionClient, + userId: string, + customVaccineId: string, +): Promise<{ id: string } | null> { + return tx.customVaccine.findFirst({ + where: { id: customVaccineId, userId, deletedAt: null }, + select: { id: true }, + }); +} + /** Does the caller own a live visit with this id? */ export async function resolveOwnedEncounter( tx: Prisma.TransactionClient, diff --git a/src/lib/vaccinations/vaccine-catalog.ts b/src/lib/vaccinations/vaccine-catalog.ts index a97e98d84..7be67c2a6 100644 --- a/src/lib/vaccinations/vaccine-catalog.ts +++ b/src/lib/vaccinations/vaccine-catalog.ts @@ -28,8 +28,11 @@ * A record stores `antigenSlug` verbatim, backups restore it without * validation, and a slug this file has stopped listing must still describe * what someone was given. {@link resolveCatalogEntry} is the ONLY lookup path - * and it returns `null` rather than throwing; every consumer answers `null` by - * falling back to the record's own `vaccineName`. That degrade is a tested + * into this file and it returns `null` rather than throwing. Consumers do not + * call it directly: since v1.42 (#1005) they ask + * `src/lib/vaccinations/resolve-vaccine-entry.ts`, which tries the catalogue + * first and the person's own vaccine definitions second, and every consumer + * answers `null` by falling back to the record's own `vaccineName`. That degrade is a tested * guarantee, not an accident — see * `src/lib/vaccinations/__tests__/catalog-integrity.test.ts`. * @@ -546,16 +549,3 @@ export function resolveCatalogEntry( if (!slug) return null; return BY_SLUG.get(slug) ?? null; } - -/** - * The antigens a dose with this slug counts into. - * - * Empty for an unknown slug and for a free-text-only record: a name string is - * not evidence about which antigens were given, and guessing from one is how - * a booster reminder would be cleared by a dose that never contained it. - */ -export function componentsForSlug( - slug: string | null | undefined, -): readonly AntigenSlug[] { - return resolveCatalogEntry(slug)?.components ?? []; -} diff --git a/src/lib/validations/__tests__/vaccinations.test.ts b/src/lib/validations/__tests__/vaccinations.test.ts index cba1936ab..bc27fa11a 100644 --- a/src/lib/validations/__tests__/vaccinations.test.ts +++ b/src/lib/validations/__tests__/vaccinations.test.ts @@ -183,13 +183,23 @@ describe("an edit names what it changes", () => { ).toBe(true); }); - it("refuses an edit that clears both identity arms at once", () => { + it("refuses an edit that clears every identity arm at once", () => { + expect( + vaccinationUpdateSchema.safeParse({ + antigenSlug: null, + vaccineName: null, + customVaccineId: null, + }).success, + ).toBe(false); + }); + + it("leaves clearing the slug and the name to the merged-row check, because an own vaccine may remain", () => { expect( vaccinationUpdateSchema.safeParse({ antigenSlug: null, vaccineName: null, }).success, - ).toBe(false); + ).toBe(true); }); it("allows clearing one arm, because the other may still be there", () => { diff --git a/src/lib/validations/backup-summary.ts b/src/lib/validations/backup-summary.ts index cd04d0eae..855f6dea9 100644 --- a/src/lib/validations/backup-summary.ts +++ b/src/lib/validations/backup-summary.ts @@ -85,6 +85,8 @@ export interface BackupSummary { vaccinations: number; /** v1.37.19 (A6-8) — vaccination↔document links. */ vaccinationLinks: number; + /** v1.42 (#1005) — the person's own vaccine definitions. */ + customVaccines: number; /** v1.37.20 (#223 / iOS #68) — Vorsorge reminder cadences. */ measurementReminders: number; /** v1.37.20 (#223 / iOS #68) — completion-ledger rows across every reminder. */ @@ -176,6 +178,7 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary { payload.encounterConditionLinks.length, vaccinations: payload.vaccinations.length, vaccinationLinks: payload.vaccinationDocumentLinks.length, + customVaccines: payload.customVaccines.length, // v1.37.20 (#223 / iOS #68) — counted from the release that carries them, // so the admin's "what did I just restore" answer never under-counts a // file with reminders the way it once did for visits. diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index eb9069d3c..a0694f9ab 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -1311,6 +1311,24 @@ const vaccinationBackupSchema = z }) .passthrough(); +/** + * One of the person's own vaccine definitions (v1.42, #1005). `components` + * is a list of free strings rather than the antigen enum, for the reason + * `antigenSlug` is: a file the app wrote must restore. + */ +const customVaccineBackupSchema = z + .object({ + id: z.string().min(1), + name: z.string().min(1), + components: z.array(z.string()).optional(), + typicalSeriesDoses: z.number().int().nullable().optional(), + boosterIntervalMonths: z.number().int().nullable().optional(), + createdAt: isoDateTime, + updatedAt: isoDateTime, + deletedAt: isoDateTime.nullable().optional(), + }) + .passthrough(); + const vaccinationLinkBackupSchema = z .object({ vaccinationId: z.string().min(1), @@ -2081,6 +2099,9 @@ export const backupPayloadSchema = z // for the same reason as the sections above: a file written before the // tables existed carries no key, and an account with an empty Impfpass // writes []. + // v1.42 (#1005) — defaulted: a file written before definitions + // travelled carries no key, and its doses name none. + customVaccines: z.array(customVaccineBackupSchema).default([]), vaccinations: z.array(vaccinationBackupSchema).default([]), vaccinationDocumentLinks: z.array(vaccinationLinkBackupSchema).default([]), // The Vorsorge reminders and their completion ledger (v1.37.20, #223 / diff --git a/src/lib/validations/vaccinations.ts b/src/lib/validations/vaccinations.ts index 4de5d2ee4..1299afa87 100644 --- a/src/lib/validations/vaccinations.ts +++ b/src/lib/validations/vaccinations.ts @@ -25,7 +25,10 @@ */ import { z } from "zod/v4"; -import { VACCINE_CATALOG_SLUGS } from "@/lib/vaccinations/vaccine-catalog"; +import { + ANTIGEN_SLUGS, + VACCINE_CATALOG_SLUGS, +} from "@/lib/vaccinations/vaccine-catalog"; const KNOWN_SLUGS: ReadonlySet = new Set(VACCINE_CATALOG_SLUGS); @@ -99,7 +102,7 @@ const linkIds = z.array(id).max(100).optional(); * rather than leaving the person guessing which of the two the server meant. */ const IDENTITY_MESSAGE = - "name the vaccine: pick one from the catalogue or type what the record says"; + "name the vaccine: pick one from the catalogue or your own vaccines, or type what the record says"; export const vaccinationCreateSchema = z .object({ @@ -114,13 +117,18 @@ export const vaccinationCreateSchema = z site: vaccinationSiteEnum.nullable().optional(), practitionerId: id.nullable().optional(), encounterId: id.nullable().optional(), + /** + * v1.42 (#1005) — one of the record's own vaccine definitions, the third + * identity arm. Re-narrowed to the resolved record in the route. + */ + customVaccineId: id.nullable().optional(), /** Becomes `noteEncrypted`. Impfreaktionen live here. */ note: z.string().max(2000).nullable().optional(), documentIds: linkIds, }) .strict() .superRefine((body, ctx) => { - if (body.antigenSlug || body.vaccineName) return; + if (body.antigenSlug || body.vaccineName || body.customVaccineId) return; for (const path of ["antigenSlug", "vaccineName"] as const) { ctx.addIssue({ code: "custom", path: [path], message: IDENTITY_MESSAGE }); } @@ -151,6 +159,7 @@ export const vaccinationUpdateSchema = z site: vaccinationSiteEnum.nullable().optional(), practitionerId: id.nullable().optional(), encounterId: id.nullable().optional(), + customVaccineId: id.nullable().optional(), note: z.string().max(2000).nullable().optional(), documentIds: linkIds, }) @@ -160,9 +169,13 @@ export const vaccinationUpdateSchema = z }) .refine( (body) => - // Only when the edit clears BOTH arms in one call. Clearing one while - // the other stays is legal and the route checks the merged row. - !(body.antigenSlug === null && body.vaccineName === null), + // Only when the edit clears EVERY arm in one call. Clearing one while + // another stays is legal and the route checks the merged row. + !( + body.antigenSlug === null && + body.vaccineName === null && + body.customVaccineId === null + ), { path: ["antigenSlug"], message: IDENTITY_MESSAGE }, ); @@ -190,6 +203,53 @@ export const vaccinationListQuerySchema = z export type VaccinationListQuery = z.infer; +/* ── the record's own vaccines (v1.42, #1005) ─────────────────────── */ + +/** + * The antigens a definition protects against: only from the catalogue's + * closed antigen list, because the series derivation and the booster match + * both key on those slugs. A disease the list does not carry is a free-text + * dose, not a definition. + */ +export const customVaccineComponentsSchema = z + .array(z.enum(ANTIGEN_SLUGS)) + .min(1) + .max(ANTIGEN_SLUGS.length) + .refine((list) => new Set(list).size === list.length, { + message: "name each antigen once", + }); + +/** The definition's name: what the person calls the product. */ +const customVaccineName = z.string().trim().min(1).max(100); + +export const customVaccineCreateSchema = z + .object({ + name: customVaccineName, + components: customVaccineComponentsSchema, + typicalSeriesDoses: z.number().int().min(1).max(10).nullable().optional(), + /** Months; a decade booster is 120, bounded like the booster confirm. */ + boosterIntervalMonths: z + .number() + .int() + .min(1) + .max(600) + .nullable() + .optional(), + }) + .strict(); + +export type CustomVaccineCreate = z.infer; + +/** Edit a definition; an omitted field is left untouched, an empty body 422s. */ +export const customVaccineUpdateSchema = customVaccineCreateSchema + .partial() + .strict() + .refine((body) => Object.keys(body).length > 0, { + message: "name at least one field to change", + }); + +export type CustomVaccineUpdate = z.infer; + /* ── links ────────────────────────────────────────────────────────── */ /** diff --git a/tests/fixtures/v137/sharing-matrix.ts b/tests/fixtures/v137/sharing-matrix.ts index c96600c11..5d70af0c0 100644 --- a/tests/fixtures/v137/sharing-matrix.ts +++ b/tests/fixtures/v137/sharing-matrix.ts @@ -457,6 +457,31 @@ export const ADMITTED_MUTATING_HANDLERS = [ "write", "upsert", ), + // v1.42 (#1005) — the record's own vaccine definitions. + handler( + "app/api/vaccinations/custom/route.ts", + "/api/vaccinations/custom", + "POST", + "profile", + "write", + "create", + ), + handler( + "app/api/vaccinations/custom/[id]/route.ts", + "/api/vaccinations/custom/[id]", + "PATCH", + "profile", + "manage", + "update", + ), + handler( + "app/api/vaccinations/custom/[id]/route.ts", + "/api/vaccinations/custom/[id]", + "DELETE", + "profile", + "manage", + "delete", + ), handler( "app/api/illness/episodes/[id]/day-logs/route.ts", "/api/illness/episodes/[id]/day-logs", diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 42ee942b2..7bfa62601 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -288,6 +288,7 @@ const COUNT_BACK: Record< p.vaccinationRecord.count({ where: { userId } }), VaccinationDocumentLink: (p, userId) => p.vaccinationDocumentLink.count({ where: { userId } }), + CustomVaccine: (p, userId) => p.customVaccine.count({ where: { userId } }), MeasurementReminder: (p, userId) => p.measurementReminder.count({ where: { userId } }), MeasurementReminderEvent: (p, userId) => @@ -1284,10 +1285,23 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { // the skip test below). `reminderId` used to be deliberately absent here — // it was the one reference that could never resolve — and is deliberately // present since v1.37.20, because resolving is now the behaviour under test. + // v1.42 (#1005) — the person's own vaccine definition the dose also names. + // The catalogue slug still wins on read; the reference is what has to + // survive the trip, and it can only if the definition comes back first. + const customVaccine = await prisma.customVaccine.create({ + data: { + userId: OWNER_ID, + name: "Round-trip travel combo", + components: ["typhoid", "hepatitis-a"], + typicalSeriesDoses: 2, + boosterIntervalMonths: 36, + }, + }); const vaccination = await prisma.vaccinationRecord.create({ data: { userId: OWNER_ID, occurredAt: AT("2026-05-14T00:00:00.000Z"), + customVaccineId: customVaccine.id, antigenSlug: "tdap", vaccineName: "Tetanus, diphtheria and pertussis", doseNumber: 3, @@ -2097,6 +2111,7 @@ describe("every model the plan claims two-ended survives a real restore", () => practitioner: { select: { name: true } }, encounter: { select: { occurredAt: true } }, reminder: { select: { label: true } }, + customVaccine: true, documentLinks: { select: { documentId: true } }, }, }); @@ -2114,6 +2129,14 @@ describe("every model the plan claims two-ended survives a real restore", () => // the id survived the round trip instead of dropping to NULL the way it // had to before the reminders travelled. reminder: dose.reminder?.label ?? null, + customVaccine: dose.customVaccine + ? { + name: dose.customVaccine.name, + components: dose.customVaccine.components, + typicalSeriesDoses: dose.customVaccine.typicalSeriesDoses, + boosterIntervalMonths: dose.customVaccine.boosterIntervalMonths, + } + : null, links: dose.documentLinks.length, note: dose.noteEncrypted ? decryptFromBytes(dose.noteEncrypted) : null, }).toEqual({ @@ -2127,6 +2150,12 @@ describe("every model the plan claims two-ended survives a real restore", () => practitioner: "Round-trip practice", encounterAt: "2026-06-30T08:00:00.000Z", reminder: "Blutdruck messen", + customVaccine: { + name: "Round-trip travel combo", + components: ["typhoid", "hepatitis-a"], + typicalSeriesDoses: 2, + boosterIntervalMonths: 36, + }, links: 1, note: "sore arm for a day, nothing else", }); diff --git a/tests/integration/sharing-manage-handler-matrix.test.ts b/tests/integration/sharing-manage-handler-matrix.test.ts index 7810307f0..34583f305 100644 --- a/tests/integration/sharing-manage-handler-matrix.test.ts +++ b/tests/integration/sharing-manage-handler-matrix.test.ts @@ -282,7 +282,7 @@ const ROUTE_IMPORTS = import.meta.glob>( describe("the complete MANAGE handler matrix", () => { it("starts with a non-empty, exact handler inventory", () => { - expect(ADMITTED_MUTATING_HANDLERS.length).toBe(84); + expect(ADMITTED_MUTATING_HANDLERS.length).toBe(87); }); }); @@ -552,6 +552,12 @@ async function makeVaccination(userId: string) { }); } +async function makeCustomVaccine(userId: string) { + return getPrismaClient().customVaccine.create({ + data: { userId, name: "Travel combo", components: ["typhoid"] }, + }); +} + async function makeFamilyHistory(userId: string) { return getPrismaClient().familyHistoryEntry.create({ data: { userId, relationship: "MOTHER", condition: "Hypertension" }, @@ -1794,6 +1800,81 @@ manageContract("POST /api/vaccinations/[id]/restore", { )?.deletedAt === null, }); +writeEffectContract("POST /api/vaccinations/custom", { + contract: ADMITTED_MUTATING_HANDLERS.find( + (entry) => + entry.route === "/api/vaccinations/custom" && entry.action === "POST", + )!, + prepare: async (ownerId) => ({ ownerId }), + act: async () => { + const { POST } = await import("@/app/api/vaccinations/custom/route"); + return call(POST as Handler, "POST", "/api/vaccinations/custom", { + name: "Cholera oral", + components: ["cholera"], + }); + }, + ok: 201, + auditAction: "vaccination.custom.create", + applied: async ({ ownerId }) => + (await getPrismaClient().customVaccine.count({ + where: { userId: ownerId }, + })) === 1, +}); + +manageContract("PATCH /api/vaccinations/custom/[id]", { + contract: ADMITTED_MUTATING_HANDLERS.find( + (entry) => + entry.route === "/api/vaccinations/custom/[id]" && + entry.action === "PATCH", + )!, + prepare: (ownerId) => makeCustomVaccine(ownerId), + act: async (row) => { + const { PATCH } = await import("@/app/api/vaccinations/custom/[id]/route"); + return call( + PATCH as Handler, + "PATCH", + `/api/vaccinations/custom/${row.id}`, + { boosterIntervalMonths: 36 }, + { id: row.id }, + ); + }, + ok: 200, + auditAction: "vaccination.custom.update", + applied: async (row) => + ( + await getPrismaClient().customVaccine.findUnique({ + where: { id: row.id }, + }) + )?.boosterIntervalMonths === 36, +}); + +manageContract("DELETE /api/vaccinations/custom/[id]", { + contract: ADMITTED_MUTATING_HANDLERS.find( + (entry) => + entry.route === "/api/vaccinations/custom/[id]" && + entry.action === "DELETE", + )!, + prepare: (ownerId) => makeCustomVaccine(ownerId), + act: async (row) => { + const { DELETE } = await import("@/app/api/vaccinations/custom/[id]/route"); + return call( + DELETE as Handler, + "DELETE", + `/api/vaccinations/custom/${row.id}`, + undefined, + { id: row.id }, + ); + }, + ok: 200, + auditAction: "vaccination.custom.delete", + applied: async (row) => + ( + await getPrismaClient().customVaccine.findUnique({ + where: { id: row.id }, + }) + )?.deletedAt !== null, +}); + proveWriteRoute("POST /api/vaccinations/[id]/booster", { route: "/api/vaccinations/[id]/booster", // The dose the booster is planned against. `makeVaccination` records a @@ -2765,13 +2846,13 @@ describe("the conditions the admissions were granted on", () => { describe("the complete MANAGE handler matrix", () => { it("registers one strict actor-and-effect driver for every admission", () => { const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort(); - expect(STRICT_DRIVER_KEYS.size).toBe(84); + expect(STRICT_DRIVER_KEYS.size).toBe(87); expect([...STRICT_DRIVER_KEYS].sort()).toEqual(expected); }); it("executes every registered driver through its owned effect and actor audit", () => { const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort(); - expect(REAL_EFFECT_KEYS.size).toBe(84); + expect(REAL_EFFECT_KEYS.size).toBe(87); expect([...REAL_EFFECT_KEYS].sort()).toEqual(expected); }); }); diff --git a/tests/integration/vaccination-custom.test.ts b/tests/integration/vaccination-custom.test.ts new file mode 100644 index 000000000..1092c0fc8 --- /dev/null +++ b/tests/integration/vaccination-custom.test.ts @@ -0,0 +1,424 @@ +/** + * v1.42 (#1005) — the record's own vaccine definitions, through the real + * routes and a real Postgres. + * + * Pinned here: a definition is created, listed, renamed and removed under the + * resolved record; a dose logged against it counts into every listed + * antigen's series and clears a booster keyed to one of them, the way a + * catalogue combination does; an id another record holds is refused; removing + * the definition leaves every dose standing with a name; the booster mint keys + * on its first antigen; and the sharing levels hold — a READ delegate lists + * but cannot add, a WRITE delegate adds onto the owner's record but cannot + * edit, and a Guardian inside a managed profile writes to the profile. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { + GET as listCustom, + POST as createCustom, +} from "@/app/api/vaccinations/custom/route"; +import { + DELETE as deleteCustom, + PATCH as patchCustom, +} from "@/app/api/vaccinations/custom/[id]/route"; +import { + GET as listVaccinations, + POST as createVaccination, +} from "@/app/api/vaccinations/route"; +import { POST as planBooster } from "@/app/api/vaccinations/[id]/booster/route"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, switchSessionTo, 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 DAY_MS = 24 * 60 * 60 * 1000; +const daysAgo = (days: number) => new Date(Date.now() - days * DAY_MS); +const daysFromNow = (days: number) => new Date(Date.now() + days * DAY_MS); + +let sequence = 0; + +interface Person { + id: string; + sessionId: string; +} + +async function person(label: string): Promise { + const suffix = sequence++; + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username: `${label}-${suffix}`, + email: `${label}-${suffix}@example.test`, + timezone: "Europe/Berlin", + }, + }); + const session = await prisma.session.create({ + data: { userId: user.id, expiresAt: daysFromNow(1) }, + }); + return { id: user.id, sessionId: session.id }; +} + +function signIn(who: Person): void { + headerJar.delete("authorization"); + cookieJar.set("healthlog_session", who.sessionId); +} + +function json(method: string, url: string, body?: unknown): Request { + return new Request(`http://localhost${url}`, { + method, + headers: { "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); +} + +const params = (id: string) => ({ params: Promise.resolve({ id }) }) as never; + +async function addCustom(body: unknown): Promise { + return createCustom(json("POST", "/api/vaccinations/custom", body) as never); +} +async function getCustoms(): Promise { + // The GET takes no request; the wrapper still hands one through. + return (listCustom as (request: Request) => Promise)( + json("GET", "/api/vaccinations/custom"), + ); +} +async function editCustom(id: string, body: unknown): Promise { + return patchCustom( + json("PATCH", `/api/vaccinations/custom/${id}`, body) as never, + params(id), + ); +} +async function removeCustom(id: string): Promise { + return deleteCustom( + json("DELETE", `/api/vaccinations/custom/${id}`) as never, + params(id), + ); +} +async function postDose(body: unknown): Promise { + return createVaccination(json("POST", "/api/vaccinations", body) as never); +} + +interface DoseDto { + id: string; + customVaccineId: string | null; + customVaccine: { name: string } | null; + series: { antigen: string; position: number; total: number | null }[]; + reminderId: string | null; +} + +async function doses(): Promise { + const res = await listVaccinations(json("GET", "/api/vaccinations") as never); + expect(res.status).toBe(200); + return ((await res.json()) as { data: { vaccinations: DoseDto[] } }).data + .vaccinations; +} + +const TRAVEL = { + name: "Travel combo", + components: ["typhoid", "hepatitis-a"], + typicalSeriesDoses: 2, + boosterIntervalMonths: 36, +}; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("an owner's own vaccine definitions", () => { + it("creates, lists and refuses a second definition of the same name", async () => { + const owner = await person("owner"); + signIn(owner); + + const created = await addCustom(TRAVEL); + expect(created.status).toBe(201); + const def = ((await created.json()) as { data: { id: string } }).data; + + const listed = await getCustoms(); + expect(listed.status).toBe(200); + expect( + ((await listed.json()) as { data: { id: string; name: string }[] }).data, + ).toEqual([expect.objectContaining({ id: def.id, name: "Travel combo" })]); + + // The same product, typed differently, is one product to the reader. + const clash = await addCustom({ ...TRAVEL, name: "travel COMBO" }); + expect(clash.status).toBe(409); + expect((await clash.json()).meta?.errorCode).toBe( + "vaccination.custom.name-taken", + ); + + // An antigen outside the closed list is refused. + const bad = await addCustom({ name: "Other", components: ["unicorn-pox"] }); + expect(bad.status).toBe(422); + }); + + it("counts a dose into every listed antigen and clears a booster keyed to one", async () => { + const owner = await person("owner"); + signIn(owner); + const prisma = getPrismaClient(); + const def = ( + (await (await addCustom(TRAVEL)).json()) as { data: { id: string } } + ).data; + // A hepatitis A booster already planned and due. + const reminder = await prisma.measurementReminder.create({ + data: { + userId: owner.id, + label: "hepatitis A booster", + intervalDays: 3650, + notifyHour: 9, + vaccinationAntigen: "hepatitis-a", + nextDueAt: daysAgo(1), + }, + }); + + const res = await postDose({ + occurredAt: daysAgo(3).toISOString(), + customVaccineId: def.id, + }); + expect(res.status, JSON.stringify(await res.clone().json())).toBe(201); + const dto = ((await res.json()) as { data: DoseDto }).data; + expect(dto.customVaccine?.name).toBe("Travel combo"); + expect(dto.series).toEqual([ + expect.objectContaining({ antigen: "typhoid", position: 1, total: 2 }), + expect.objectContaining({ + antigen: "hepatitis-a", + position: 1, + total: 2, + }), + ]); + expect(dto.reminderId).toBe(reminder.id); + expect( + ( + await prisma.measurementReminder.findUniqueOrThrow({ + where: { id: reminder.id }, + }) + ).lastSatisfiedAt, + ).not.toBeNull(); + + // An edit to the definition is read by every dose that names it. + expect((await editCustom(def.id, { components: ["typhoid"] })).status).toBe( + 200, + ); + expect((await doses())[0].series.map((p) => p.antigen)).toEqual([ + "typhoid", + ]); + }); + + it("keys the booster mint on the definition's first antigen", async () => { + const owner = await person("owner"); + signIn(owner); + const def = ( + (await (await addCustom(TRAVEL)).json()) as { data: { id: string } } + ).data; + const dose = ( + (await ( + await postDose({ + occurredAt: daysAgo(3).toISOString(), + customVaccineId: def.id, + }) + ).json()) as { data: { id: string } } + ).data; + + const res = await planBooster( + json("POST", `/api/vaccinations/${dose.id}/booster`, { + intervalMonths: 36, + label: "Travel combo booster", + }) as never, + params(dose.id), + ); + expect(res.status).toBe(201); + const minted = await getPrismaClient().measurementReminder.findFirstOrThrow( + { where: { userId: owner.id } }, + ); + expect(minted.vaccinationAntigen).toBe("typhoid"); + }); + + it("refuses a definition another record holds", async () => { + const stranger = await person("stranger"); + signIn(stranger); + const foreign = ( + (await (await addCustom(TRAVEL)).json()) as { data: { id: string } } + ).data; + + const owner = await person("owner"); + signIn(owner); + const res = await postDose({ + occurredAt: daysAgo(3).toISOString(), + customVaccineId: foreign.id, + }); + expect(res.status).toBe(404); + expect((await res.json()).meta?.errorCode).toBe( + "vaccination.custom-vaccine-not-found", + ); + expect((await editCustom(foreign.id, { name: "Mine" })).status).toBe(404); + expect((await removeCustom(foreign.id)).status).toBe(404); + expect( + await getPrismaClient().vaccinationRecord.count({ + where: { userId: owner.id }, + }), + ).toBe(0); + }); + + it("removing a definition keeps every dose, each with a name, and the name can be used again", async () => { + const owner = await person("owner"); + signIn(owner); + const prisma = getPrismaClient(); + const def = ( + (await (await addCustom(TRAVEL)).json()) as { data: { id: string } } + ).data; + await postDose({ + occurredAt: daysAgo(30).toISOString(), + customVaccineId: def.id, + }); + await postDose({ + occurredAt: daysAgo(3).toISOString(), + customVaccineId: def.id, + vaccineName: "as the pass says", + }); + + expect((await removeCustom(def.id)).status).toBe(200); + // Removing twice succeeds. + expect((await removeCustom(def.id)).status).toBe(200); + + const rows = await prisma.vaccinationRecord.findMany({ + where: { userId: owner.id, deletedAt: null }, + orderBy: { occurredAt: "asc" }, + }); + expect(rows.map((row) => [row.customVaccineId, row.vaccineName])).toEqual([ + [null, "Travel combo"], + [null, "as the pass says"], + ]); + expect( + ((await (await getCustoms()).json()) as { data: unknown[] }).data, + ).toEqual([]); + const audit = await prisma.auditLog.findFirstOrThrow({ + where: { action: "vaccination.custom.delete" }, + orderBy: { createdAt: "asc" }, + }); + const details = + typeof audit.details === "string" + ? JSON.parse(audit.details) + : audit.details; + expect(details).toMatchObject({ dosesUnlinked: 2 }); + + // The same name again brings the definition back, without its old doses. + const again = await addCustom({ ...TRAVEL, boosterIntervalMonths: 12 }); + expect(again.status).toBe(201); + expect(((await again.json()) as { data: { id: string } }).data.id).toBe( + def.id, + ); + expect( + await prisma.vaccinationRecord.count({ + where: { userId: owner.id, customVaccineId: def.id }, + }), + ).toBe(0); + }); +}); + +describe("sharing and managed profiles", () => { + async function switchInAs( + ownerId: string, + access: "READ" | "WRITE", + ): Promise { + const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants"); + const delegate = await person("delegate"); + const invited = await inviteGrant({ + grantorId: ownerId, + granteeId: delegate.id, + access, + scope: ["profile"], + }); + await acceptGrant({ grantId: invited.id, granteeId: delegate.id }); + signIn(delegate); + await switchSessionTo(delegate.sessionId, ownerId); + return delegate; + } + + it("lets a READ delegate list the definitions but not add one", async () => { + const owner = await person("owner"); + await getPrismaClient().customVaccine.create({ + data: { userId: owner.id, name: "Owner's own", components: ["rabies"] }, + }); + await switchInAs(owner.id, "READ"); + + const listed = await getCustoms(); + expect(listed.status).toBe(200); + expect( + ((await listed.json()) as { data: { name: string }[] }).data.map( + (row) => row.name, + ), + ).toEqual(["Owner's own"]); + + const res = await addCustom(TRAVEL); + expect(res.status).toBe(403); + expect((await res.json()).meta?.errorCode).toBe("sharing.access.denied"); + }); + + it("lets a WRITE delegate add onto the owner's record but not edit or remove", async () => { + const owner = await person("owner"); + const delegate = await switchInAs(owner.id, "WRITE"); + + const res = await addCustom(TRAVEL); + expect(res.status).toBe(201); + const def = ((await res.json()) as { data: { id: string } }).data; + const prisma = getPrismaClient(); + expect( + (await prisma.customVaccine.findUniqueOrThrow({ where: { id: def.id } })) + .userId, + ).toBe(owner.id); + expect( + await prisma.customVaccine.count({ where: { userId: delegate.id } }), + ).toBe(0); + + expect((await editCustom(def.id, { name: "Renamed" })).status).toBe(403); + expect((await removeCustom(def.id)).status).toBe(403); + }); + + it("writes a Guardian's definition to the managed profile", async () => { + const { createManagedProfile } = + await import("@/lib/managed-profiles/create"); + const guardian = await person("guardian"); + const { profile } = await createManagedProfile({ + creatorId: guardian.id, + displayName: "Child record", + dateOfBirth: null, + locale: "en", + timezone: "Europe/Berlin", + }); + signIn(guardian); + await switchSessionTo(guardian.sessionId, profile.id); + + expect((await addCustom(TRAVEL)).status).toBe(201); + const prisma = getPrismaClient(); + expect( + await prisma.customVaccine.count({ where: { userId: profile.id } }), + ).toBe(1); + expect( + await prisma.customVaccine.count({ where: { userId: guardian.id } }), + ).toBe(0); + }); +});