diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d38b30a9f..750938941 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -71911,6 +71911,12 @@ components: $ref: "#/components/schemas/EncounterLinks" skipped: $ref: "#/components/schemas/EncounterSkipReport" + preparation: + description: "On the list read only (`GET /api/encounters`): the window before this visit when it is booked (PLANNED, + upcoming), null on every other row. Absent on the detail and on write responses." + anyOf: + - $ref: "#/components/schemas/EncounterPreparation" + - type: "null" createdAt: type: string updatedAt: @@ -72062,6 +72068,73 @@ components: checkup, which is a measurements-domain write a health-background delegate cannot perform. The visit still saves; the checkup stays due and the response says so, because somebody who believes a checkup was closed will not look at it again." + EncounterPreparation: + type: object + properties: + from: + type: string + description: "First day of the window, `YYYY-MM-DD` on the record's own calendar: the day of `since`, or 90 days before + today on `default`." + basis: + type: string + enum: + - practitioner + - specialty + - kind + - default + description: "The rule that chose `from`, first match wins over the visits that took place (DONE) before this one: + `practitioner` (the last visit with the same practitioner), `specialty` (the last visit with a practitioner + of the same specialty, compared without case or extra spaces), `kind` (the last visit of the same kind, for + ROUTINE, PREVENTIVE and THERAPY only), `default` (none matched: the last 90 days)." + since: + anyOf: + - $ref: "#/components/schemas/EncounterPreparationAnchor" + - type: "null" + required: + - from + - basis + - since + additionalProperties: false + description: "The window before a booked visit, resolved server-side: where the visit preparation and the doctor report + offered for the appointment start, and why there. Never the last visit of any kind, which is how a dermatology + appointment was offered a week of data from an internist visit. A client shows `from` with its reason and lets + the person change the dates; it does not recompute the rule." + EncounterPreparationAnchor: + type: object + properties: + id: + type: string + occurredAt: + type: string + kind: + type: string + enum: + - ROUTINE + - ACUTE + - SPECIALIST + - PREVENTIVE + - EMERGENCY + - HOSPITAL + - THERAPY + - OTHER + - PROCEDURE + practitionerName: + type: + - string + - "null" + specialty: + type: + - string + - "null" + required: + - id + - occurredAt + - kind + - practitionerName + - specialty + additionalProperties: false + description: "The earlier visit the window starts at: its id, instant and kind, and the practitioner's name and + specialty as they read now." CreateEncounterEnvelope: type: object properties: diff --git a/src/app/api/encounters/route.ts b/src/app/api/encounters/route.ts index 5686620b2..5c73973e6 100644 --- a/src/app/api/encounters/route.ts +++ b/src/app/api/encounters/route.ts @@ -35,6 +35,7 @@ import { } from "@/lib/validations/encounters"; import { encounterKindLabel } from "@/lib/encounters/kind-label"; import { toEncounterDTO, type EncounterListDTO } from "@/lib/encounters/dto"; +import { loadVisitPreparations } from "@/lib/encounters/preparation-read"; import { ENCOUNTER_INCLUDE, applyEncounterLinks, @@ -143,12 +144,17 @@ export const GET = apiHandler(async (request: NextRequest) => { // them makes the first read zero and the second unfile everything on save. // Three grouped queries for the page, not three per row. const visible = await actingDomainVisibility(prisma, grantId); - const links = await loadEncounterLinksForMany( - prisma, - user.id, - [...upcoming, ...past].map((row) => row.id), - visible, - ); + // The window before each booked visit reads the whole history, not this + // page of it: the visit it starts at may sit outside the caller's filter. + const [links, preparations] = await Promise.all([ + loadEncounterLinksForMany( + prisma, + user.id, + [...upcoming, ...past].map((row) => row.id), + visible, + ), + loadVisitPreparations(prisma, user.id, upcoming, now), + ]); annotate({ action: { name: "encounter.visit.list", entity_type: "encounter" }, @@ -156,8 +162,14 @@ export const GET = apiHandler(async (request: NextRequest) => { }); const body: EncounterListDTO = { - upcoming: upcoming.map((row) => toEncounterDTO(row, links.get(row.id))), - past: past.map((row) => toEncounterDTO(row, links.get(row.id))), + upcoming: upcoming.map((row) => ({ + ...toEncounterDTO(row, links.get(row.id)), + preparation: preparations.get(row.id) ?? null, + })), + past: past.map((row) => ({ + ...toEncounterDTO(row, links.get(row.id)), + preparation: null, + })), }; return apiListSuccess(body, combinedCap(upcomingCap, pastCap)); }); diff --git a/src/lib/encounters/__tests__/preparation.test.ts b/src/lib/encounters/__tests__/preparation.test.ts new file mode 100644 index 000000000..b219ef8b1 --- /dev/null +++ b/src/lib/encounters/__tests__/preparation.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from "vitest"; + +import type { EncounterKind } from "@/generated/prisma/client"; +import { + PREPARATION_DEFAULT_DAYS, + resolvePreparation, + specialtyKey, + type PreparationVisit, +} from "../preparation"; + +const TZ = "Europe/Berlin"; +const TODAY = "2026-10-21"; + +function visit( + id: string, + occurredAt: string, + over: { + kind?: EncounterKind; + practitionerId?: string | null; + name?: string; + specialty?: string | null; + } = {}, +): PreparationVisit { + const practitionerId = + over.practitionerId === undefined ? null : over.practitionerId; + return { + id, + occurredAt: new Date(occurredAt), + kind: over.kind ?? "OTHER", + practitionerId, + practitioner: practitionerId + ? { name: over.name ?? practitionerId, specialty: over.specialty ?? null } + : null, + }; +} + +const internist = (id: string, at: string) => + visit(id, at, { + kind: "SPECIALIST", + practitionerId: "p-int", + name: "Internal practice", + specialty: "Internal medicine", + }); + +// The report from the issue: an internist a week ago, a dermatologist next. +const next = visit("next", "2026-10-23T08:00:00.000Z", { + kind: "SPECIALIST", + practitionerId: "p-derm", + name: "Skin practice", + specialty: "Dermatology", +}); + +function resolve(target: PreparationVisit, done: PreparationVisit[]) { + return resolvePreparation({ next: target, done, tz: TZ, today: TODAY }); +} + +describe("the window before a booked visit", () => { + it("starts at the last visit with the same practitioner", () => { + const prep = resolve(next, [ + internist("int", "2026-10-16T09:00:00.000Z"), + visit("derm", "2026-03-02T09:00:00.000Z", { + kind: "SPECIALIST", + practitionerId: "p-derm", + name: "Skin practice", + specialty: "Dermatology", + }), + ]); + expect(prep).toEqual({ + from: "2026-03-02", + basis: "practitioner", + since: { + id: "derm", + occurredAt: "2026-03-02T09:00:00.000Z", + kind: "SPECIALIST", + practitionerName: "Skin practice", + specialty: "Dermatology", + }, + }); + }); + + it("else at the last visit in the same specialty, compared without case", () => { + const prep = resolve(next, [ + internist("int", "2026-10-16T09:00:00.000Z"), + visit("other-derm", "2025-11-20T09:00:00.000Z", { + kind: "SPECIALIST", + practitionerId: "p-derm-old", + name: "Former skin practice", + specialty: " dermatology ", + }), + ]); + expect(prep.basis).toBe("specialty"); + expect(prep.from).toBe("2025-11-20"); + expect(prep.since?.id).toBe("other-derm"); + expect(prep.since?.specialty).toBe("dermatology"); + }); + + it("never falls back to the last visit of any kind", () => { + // The issue: one week of internist data offered for the dermatologist. + const prep = resolve(next, [internist("int", "2026-10-16T09:00:00.000Z")]); + expect(prep).toEqual({ + from: "2026-07-23", + basis: "default", + since: null, + }); + expect(PREPARATION_DEFAULT_DAYS).toBe(90); + }); + + it("matches on kind only for a kind that names one line of care", () => { + const therapy = visit("next-t", "2026-10-25T08:00:00.000Z", { + kind: "THERAPY", + }); + const done = [ + visit("acute", "2026-10-18T09:00:00.000Z", { kind: "ACUTE" }), + visit("t1", "2026-10-11T09:00:00.000Z", { kind: "THERAPY" }), + ]; + const prep = resolve(therapy, done); + expect(prep.basis).toBe("kind"); + expect(prep.from).toBe("2026-10-11"); + + // A specialist visit with no specialty says nothing about which one. + const specialist = visit("next-s", "2026-10-25T08:00:00.000Z", { + kind: "SPECIALIST", + }); + expect( + resolve(specialist, [ + visit("s1", "2026-10-11T09:00:00.000Z", { kind: "SPECIALIST" }), + ]).basis, + ).toBe("default"); + // Neither does "other", the kind nobody picked. + const other = visit("next-o", "2026-10-25T08:00:00.000Z"); + expect( + resolve(other, [visit("o1", "2026-10-11T09:00:00.000Z")]).basis, + ).toBe("default"); + }); + + it("prefers the practitioner over a newer visit in the same specialty", () => { + const prep = resolve(next, [ + visit("derm-b", "2026-09-01T09:00:00.000Z", { + kind: "SPECIALIST", + practitionerId: "p-derm-b", + specialty: "Dermatology", + }), + visit("derm-a", "2026-01-15T09:00:00.000Z", { + kind: "SPECIALIST", + practitionerId: "p-derm", + specialty: "Dermatology", + }), + ]); + expect(prep.basis).toBe("practitioner"); + expect(prep.since?.id).toBe("derm-a"); + }); + + it("only counts visits before the booked one", () => { + const later = visit("later", "2026-10-24T09:00:00.000Z", { + practitionerId: "p-derm", + }); + expect(resolve(next, [later]).basis).toBe("default"); + }); + + it("starts on the matched visit's own day in the record's zone", () => { + // 23:30 UTC is the next calendar day in Berlin. + const prep = resolve(next, [ + visit("late", "2026-05-04T23:30:00.000Z", { practitionerId: "p-derm" }), + ]); + expect(prep.from).toBe("2026-05-05"); + }); +}); + +describe("specialty comparison", () => { + it("folds case and spacing, and nothing else", () => { + expect(specialtyKey(" Internal Medicine ")).toBe("internal medicine"); + expect(specialtyKey("")).toBeNull(); + expect(specialtyKey(" ")).toBeNull(); + expect(specialtyKey(null)).toBeNull(); + expect(specialtyKey("Dermatology")).not.toBe(specialtyKey("Skin doctor")); + }); +}); diff --git a/src/lib/encounters/dto.ts b/src/lib/encounters/dto.ts index f12c18d0d..117db5849 100644 --- a/src/lib/encounters/dto.ts +++ b/src/lib/encounters/dto.ts @@ -21,6 +21,7 @@ import type { MeasurementReminder, Practitioner, } from "@/generated/prisma/client"; +import type { EncounterPreparationDTO } from "@/lib/encounters/preparation"; import type { RestoreSkipSummary } from "@/lib/export/restore-skips"; import { toPractitionerDTO, @@ -93,6 +94,14 @@ export interface EncounterDTO { * at it again. */ skipped?: RestoreSkipSummary; + /** + * The window before a booked visit: the day the visit preparation and the + * doctor report offered for it start, the rule that chose it, and the + * earlier visit it is the day of (`src/lib/encounters/preparation.ts`). + * + * On the list read. Null on every row but a booked upcoming one. + */ + preparation?: EncounterPreparationDTO | null; createdAt: string; updatedAt: string; } diff --git a/src/lib/encounters/preparation-read.ts b/src/lib/encounters/preparation-read.ts new file mode 100644 index 000000000..9ff3f74ee --- /dev/null +++ b/src/lib/encounters/preparation-read.ts @@ -0,0 +1,61 @@ +/** + * The read behind `EncounterDTO.preparation`: one query of the record's past + * visits for every booked visit on the page, resolved by the rule in + * `preparation.ts`. + */ +import type { EncounterStatus, PrismaClient } from "@/generated/prisma/client"; +import { userDayKey } from "@/lib/tz/format"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; + +import { + resolvePreparation, + type EncounterPreparationDTO, + type PreparationVisit, +} from "./preparation"; + +/** + * How many past visits the rule looks through, newest first. Far more than a + * person files in years; a match older than the cap falls to the next rule, + * which on an account this busy is never the default. + */ +export const PREPARATION_HISTORY_CAP = 500; + +type Booked = PreparationVisit & { status: EncounterStatus }; + +/** The window before each booked visit in `upcoming`, by visit id. */ +export async function loadVisitPreparations( + db: Pick, + recordId: string, + upcoming: readonly Booked[], + now: Date, +): Promise> { + const booked = upcoming.filter((visit) => visit.status === "PLANNED"); + if (booked.length === 0) return new Map(); + const [tz, done] = await Promise.all([ + resolveUserTimezone(recordId), + db.encounter.findMany({ + where: { + userId: recordId, + deletedAt: null, + status: "DONE", + occurredAt: { lte: now }, + }, + select: { + id: true, + occurredAt: true, + kind: true, + practitionerId: true, + practitioner: { select: { name: true, specialty: true } }, + }, + orderBy: [{ occurredAt: "desc" }, { id: "desc" }], + take: PREPARATION_HISTORY_CAP, + }), + ]); + const today = userDayKey(now, tz); + return new Map( + booked.map((next) => [ + next.id, + resolvePreparation({ next, done, tz, today }), + ]), + ); +} diff --git a/src/lib/encounters/preparation.ts b/src/lib/encounters/preparation.ts new file mode 100644 index 000000000..d5dfb03a5 --- /dev/null +++ b/src/lib/encounters/preparation.ts @@ -0,0 +1,157 @@ +/** + * Where the window before a booked visit starts, and why. + * + * The visit preparation ("what happened since the last visit") and the doctor + * report offered for an appointment both need a start date. It used to be the + * last visit of any kind: a person seeing an internist on one day and a + * dermatologist a week later was offered a one-week report for the + * dermatologist, covering nothing that visit is about. + * + * The rule, first match wins, over the visits that took place (DONE) before + * the booked one: + * + * 1. practitioner — the last visit with the same practitioner; + * 2. specialty — else the last visit with a practitioner of the same + * specialty (the free-text field, compared without case + * or extra spaces, so "Dermatology" and "dermatology " + * match and "Dermatology" and "Skin doctor" do not); + * 3. kind — else the last visit of the same kind, for the kinds that + * name one line of care (routine, preventive, therapy); + * 4. default — else the last {@link PREPARATION_DEFAULT_DAYS} days. + * + * The other kinds never match by kind alone. A specialist visit says nothing + * about which specialty (that is what rule 2 is for), "other" is the default + * nobody picked, and an acute visit, an emergency, a hospital stay or a + * procedure is an event rather than a recurring line of care, so the last one + * is no better a start than the default. + * + * The window starts on the matched visit's own day: a dose changed at that + * visit belongs to what the next one should know. + * + * Resolved on the server, published on the visit list, never recomputed by a + * client. A person can still change both dates on the report. + * + * Pure and client-safe; the read lives in `preparation-read.ts`. + */ +import type { EncounterKind } from "@/generated/prisma/client"; +import { shiftDateKey, userDayKey } from "@/lib/tz/format"; + +/** The window when no earlier visit matches the booked one. */ +export const PREPARATION_DEFAULT_DAYS = 90; + +/** The kinds a visit is matched on when neither practitioner nor specialty is. */ +export const PREPARATION_KIND_MATCHES = [ + "ROUTINE", + "PREVENTIVE", + "THERAPY", +] as const satisfies readonly EncounterKind[]; + +export const PREPARATION_BASES = [ + "practitioner", + "specialty", + "kind", + "default", +] as const; + +export type PreparationBasis = (typeof PREPARATION_BASES)[number]; + +/** The earlier visit the window starts at. */ +export interface PreparationAnchorDTO { + id: string; + occurredAt: string; + kind: EncounterKind; + practitionerName: string | null; + specialty: string | null; +} + +/** The window before one booked visit, resolved. */ +export interface EncounterPreparationDTO { + /** First day of the window, `YYYY-MM-DD` on the record's own calendar. */ + from: string; + /** Which rule chose `from`. */ + basis: PreparationBasis; + /** The visit `from` is the day of; null on `default`. */ + since: PreparationAnchorDTO | null; +} + +/** The fields the rule reads, off a booked visit or an earlier one. */ +export interface PreparationVisit { + id: string; + occurredAt: Date; + kind: EncounterKind; + practitionerId: string | null; + practitioner: { name: string; specialty: string | null } | null; +} + +/** A specialty as compared: no case, no outer or doubled spaces. */ +export function specialtyKey(value: string | null | undefined): string | null { + const key = value + ?.normalize("NFKC") + .trim() + .replace(/\s+/g, " ") + .toLocaleLowerCase(); + return key ? key : null; +} + +/** + * The window before `next`. + * + * `done` is the record's visits that took place, newest first. Only those + * before `next` are considered; `today` (the record's local day) anchors the + * default window. + */ +export function resolvePreparation(args: { + next: PreparationVisit; + done: readonly PreparationVisit[]; + tz: string; + today: string; +}): EncounterPreparationDTO { + const { next, tz, today } = args; + const earlier = args.done.filter( + (visit) => + visit.id !== next.id && + visit.occurredAt.getTime() < next.occurredAt.getTime(), + ); + const specialty = specialtyKey(next.practitioner?.specialty); + const kindMatches = ( + PREPARATION_KIND_MATCHES as readonly EncounterKind[] + ).includes(next.kind); + + const rules: Array<[PreparationBasis, (visit: PreparationVisit) => boolean]> = + [ + [ + "practitioner", + (visit) => + next.practitionerId !== null && + visit.practitionerId === next.practitionerId, + ], + [ + "specialty", + (visit) => + specialty !== null && + specialtyKey(visit.practitioner?.specialty) === specialty, + ], + ["kind", (visit) => kindMatches && visit.kind === next.kind], + ]; + + for (const [basis, matches] of rules) { + const hit = earlier.find(matches); + if (!hit) continue; + return { + from: userDayKey(hit.occurredAt, tz), + basis, + since: { + id: hit.id, + occurredAt: hit.occurredAt.toISOString(), + kind: hit.kind, + practitionerName: hit.practitioner?.name ?? null, + specialty: hit.practitioner?.specialty?.trim() || null, + }, + }; + } + return { + from: shiftDateKey(today, -PREPARATION_DEFAULT_DAYS), + basis: "default", + since: null, + }; +} diff --git a/src/lib/openapi/routes/encounters.ts b/src/lib/openapi/routes/encounters.ts index 68136f0b2..d00485b32 100644 --- a/src/lib/openapi/routes/encounters.ts +++ b/src/lib/openapi/routes/encounters.ts @@ -35,6 +35,7 @@ import { practitionerListQuerySchema, } from "@/lib/validations/practitioners"; import { bodySiteQuerySchema } from "@/lib/validations/body-sites"; +import { PREPARATION_BASES } from "@/lib/encounters/preparation"; import { illnessLifecycleEnum, illnessTypeEnum, @@ -169,6 +170,40 @@ const skipReport = z "What the write could not do, named. `links: 0` with an empty list is the ordinary answer. Anything else is a side effect the caller's grant did not reach — today only `checkupClosure`, the closing of a preventive-care checkup, which is a measurements-domain write a health-background delegate cannot perform. The visit still saves; the checkup stays due and the response says so, because somebody who believes a checkup was closed will not look at it again.", }); +const preparationAnchor = z + .object({ + id: z.string(), + occurredAt: z.string(), + kind: encounterKindEnum, + practitionerName: z.string().nullable(), + specialty: z.string().nullable(), + }) + .meta({ + id: "EncounterPreparationAnchor", + description: + "The earlier visit the window starts at: its id, instant and kind, and the practitioner's name and specialty as they read now.", + }); + +const encounterPreparation = z + .object({ + from: z + .string() + .describe( + "First day of the window, `YYYY-MM-DD` on the record's own calendar: the day of `since`, or 90 days before today on `default`.", + ), + basis: z + .enum(PREPARATION_BASES) + .describe( + "The rule that chose `from`, first match wins over the visits that took place (DONE) before this one: `practitioner` (the last visit with the same practitioner), `specialty` (the last visit with a practitioner of the same specialty, compared without case or extra spaces), `kind` (the last visit of the same kind, for ROUTINE, PREVENTIVE and THERAPY only), `default` (none matched: the last 90 days).", + ), + since: preparationAnchor.nullable(), + }) + .meta({ + id: "EncounterPreparation", + description: + "The window before a booked visit, resolved server-side: where the visit preparation and the doctor report offered for the appointment start, and why there. Never the last visit of any kind, which is how a dermatology appointment was offered a week of data from an internist visit. A client shows `from` with its reason and lets the person change the dates; it does not recompute the rule.", + }); + const encounter = z .object({ id: z.string(), @@ -183,6 +218,12 @@ const encounter = z reminderNextDueAt: z.string().nullable(), links: encounterLinks.optional(), skipped: skipReport.optional(), + preparation: encounterPreparation + .nullable() + .optional() + .describe( + "On the list read only (`GET /api/encounters`): the window before this visit when it is booked (PLANNED, upcoming), null on every other row. Absent on the detail and on write responses.", + ), createdAt: z.string(), updatedAt: z.string(), }) diff --git a/tests/integration/encounter-preparation.test.ts b/tests/integration/encounter-preparation.test.ts new file mode 100644 index 000000000..6d9f00023 --- /dev/null +++ b/tests/integration/encounter-preparation.test.ts @@ -0,0 +1,243 @@ +/** + * The window before a booked visit, through the real list route and real + * Postgres. + * + * The unit test pins the rule over hand-built rows. What only the database can + * answer is the read: that it looks at visits that took place (DONE, not + * cancelled, not booked), that it ignores the list's own filters and paging, + * that a soft-deleted visit is not an anchor, and that the practitioner's + * specialty reaches the rule through the join. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { GET as listEncounters } from "@/app/api/encounters/route"; +import type { EncounterListDTO } from "@/lib/encounters/dto"; +import { shiftDateKey } from "@/lib/tz/format"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const OWNER_ID = "prep-owner"; +const DAY_MS = 24 * 60 * 60 * 1000; + +function daysFromNow(days: number): Date { + return new Date(Date.now() + days * DAY_MS); +} + +/** The local day of an instant on the owner's calendar. */ +function berlinDay(at: Date): string { + return at.toLocaleDateString("sv-SE", { timeZone: "Europe/Berlin" }); +} + +async function seed() { + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { + id: OWNER_ID, + username: "prep-owner", + email: "prep-owner@example.test", + timezone: "Europe/Berlin", + }, + }); + const session = await prisma.session.create({ + data: { + userId: OWNER_ID, + expiresAt: daysFromNow(7), + mfaVerifiedAt: new Date(), + }, + }); + cookieJar.set("healthlog_session", session.id); +} + +async function practitioner(name: string, specialty: string | null) { + return getPrismaClient().practitioner.create({ + data: { userId: OWNER_ID, name, specialty }, + }); +} + +async function encounter(data: { + occurredAt: Date; + status?: "PLANNED" | "DONE" | "CANCELLED" | "NO_SHOW"; + kind?: "ROUTINE" | "SPECIALIST" | "THERAPY" | "OTHER"; + practitionerId?: string | null; + deletedAt?: Date | null; +}) { + return getPrismaClient().encounter.create({ + data: { + userId: OWNER_ID, + occurredAt: data.occurredAt, + status: data.status ?? "DONE", + kind: data.kind ?? "OTHER", + practitionerId: data.practitionerId ?? null, + deletedAt: data.deletedAt ?? null, + }, + }); +} + +async function list(query = ""): Promise { + const res = await listEncounters( + new Request(`http://localhost/api/encounters${query}`) as never, + ); + expect(res.status).toBe(200); + return ((await res.json()) as { data: EncounterListDTO }).data; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await seed(); +}); + +describe("GET /api/encounters — the window before a booked visit", () => { + it("starts at the same practitioner's last visit, not the last visit of any kind", async () => { + const derm = await practitioner("Skin practice", "Dermatology"); + const internist = await practitioner( + "Internal practice", + "Internal medicine", + ); + const dermVisit = await encounter({ + occurredAt: daysFromNow(-200), + kind: "SPECIALIST", + practitionerId: derm.id, + }); + // The newer visit elsewhere the old rule anchored on. + await encounter({ + occurredAt: daysFromNow(-5), + kind: "SPECIALIST", + practitionerId: internist.id, + }); + const next = await encounter({ + occurredAt: daysFromNow(2), + status: "PLANNED", + kind: "SPECIALIST", + practitionerId: derm.id, + }); + + const body = await list(); + const booked = body.upcoming.find((v) => v.id === next.id); + expect(booked?.preparation).toEqual({ + from: berlinDay(dermVisit.occurredAt), + basis: "practitioner", + since: { + id: dermVisit.id, + occurredAt: dermVisit.occurredAt.toISOString(), + kind: "SPECIALIST", + practitionerName: "Skin practice", + specialty: "Dermatology", + }, + }); + // Only a booked visit carries one. + expect(body.past.every((v) => v.preparation === null)).toBe(true); + }); + + it("matches a specialty across practitioners, through the join", async () => { + const oldDerm = await practitioner("Former skin practice", "dermatology "); + const newDerm = await practitioner("Skin practice", "Dermatology"); + const anchor = await encounter({ + occurredAt: daysFromNow(-300), + kind: "SPECIALIST", + practitionerId: oldDerm.id, + }); + const next = await encounter({ + occurredAt: daysFromNow(3), + status: "PLANNED", + kind: "SPECIALIST", + practitionerId: newDerm.id, + }); + + const booked = (await list()).upcoming.find((v) => v.id === next.id); + expect(booked?.preparation?.basis).toBe("specialty"); + expect(booked?.preparation?.since?.id).toBe(anchor.id); + }); + + it("anchors only on a visit that took place and still exists", async () => { + const derm = await practitioner("Skin practice", "Dermatology"); + await encounter({ + occurredAt: daysFromNow(-20), + status: "CANCELLED", + practitionerId: derm.id, + }); + await encounter({ + occurredAt: daysFromNow(-30), + status: "NO_SHOW", + practitionerId: derm.id, + }); + await encounter({ + occurredAt: daysFromNow(-40), + practitionerId: derm.id, + deletedAt: new Date(), + }); + // Another booked visit with the same practice is not one that happened. + await encounter({ + occurredAt: daysFromNow(1), + status: "PLANNED", + practitionerId: derm.id, + }); + const next = await encounter({ + occurredAt: daysFromNow(9), + status: "PLANNED", + practitionerId: derm.id, + }); + + const booked = (await list()).upcoming.find((v) => v.id === next.id); + expect(booked?.preparation).toEqual({ + from: shiftDateKey(berlinDay(new Date()), -90), + basis: "default", + since: null, + }); + }); + + it("reads the whole history, whatever the list was filtered to", async () => { + const derm = await practitioner("Skin practice", "Dermatology"); + const anchor = await encounter({ + occurredAt: daysFromNow(-400), + practitionerId: derm.id, + }); + const next = await encounter({ + occurredAt: daysFromNow(4), + status: "PLANNED", + practitionerId: derm.id, + }); + + const from = daysFromNow(-1).toISOString(); + const body = await list(`?from=${encodeURIComponent(from)}`); + expect(body.past).toEqual([]); + expect( + body.upcoming.find((v) => v.id === next.id)?.preparation?.since?.id, + ).toBe(anchor.id); + }); + + it("leaves a cancelled upcoming visit without a window", async () => { + const cancelled = await encounter({ + occurredAt: daysFromNow(5), + status: "CANCELLED", + }); + const booked = (await list()).upcoming.find((v) => v.id === cancelled.id); + expect(booked?.preparation).toBeNull(); + }); +});