mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(visits): resolve where the window before a booked visit starts
The visit preparation and the doctor report offered for an appointment started at the last visit of any kind. An internist one week and a dermatologist the next gave the dermatologist a one-week window of nothing it is about. The list read now publishes `preparation` on every booked upcoming visit: the first day of the window, the rule that chose it and the earlier visit it is the day of. First match wins over the visits that took place before it: the same practitioner, else a practitioner of the same specialty (free text, compared without case or spacing), else the same kind for routine, preventive and therapy visits, else the last 90 days. One query of past visits per page, independent of the list's own filters. Null on every other row; absent on the detail and on write responses.
This commit is contained in:
1 parent
0dcafeeb70
commit
402cf1a35a
8 files changed
+781
-8
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<PrismaClient, "encounter">,
|
||||
recordId: string,
|
||||
upcoming: readonly Booked[],
|
||||
now: Date,
|
||||
): Promise<Map<string, EncounterPreparationDTO>> {
|
||||
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 }),
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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<EncounterListDTO> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user