mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Publish the visit contract and name visits at the point of consent
Query keys land in the central factory before any surface reads them, because the lint rule that bans a bare array means the first component listing visits cannot compile without a slot. There is deliberately no server-side cache invalidator beside them: nothing cached reads a visit yet, and a sweep over caches nobody reads would look like coverage without being any. The module says so at the place a reader would go looking. The OpenAPI table publishes seven paths with everything resolved — the practitioner as an object rather than an id, each link with its label and date, the appointment's next due instant computed. What it does not publish is the reminder origin enum, which still reads with two members: the third value exists in storage and reaches no response, because every preventive-care read excludes it. Keeping that contract closed is why the exclusion is worth a guard. Classifying visits under the existing health-background domain widens what that domain grants. Somebody who shared it so a relative could see an allergy list now also shares every appointment and the address book. That is stated where it is agreed to rather than left in a code comment: the consent screen's sentence names visits and practices, in all six locales, and the activity feed gains a verb line for each of the eight new delegated actions so an owner reads what was done rather than 'made a change'.
This commit is contained in:
1 parent
f158494def
commit
4a1bca0761
12 files changed
+1700
-39
No files matched your search
+1115
-32
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -9888,7 +9888,7 @@
|
||||
},
|
||||
"profile": {
|
||||
"label": "Gesundheitliche Vorgeschichte",
|
||||
"detail": "Allergien, Familienanamnese und die Angaben in deinem Gesundheitsprofil."
|
||||
"detail": "Allergien, Familienanamnese, die Angaben in deinem Gesundheitsprofil sowie Arztbesuche und Praxen."
|
||||
},
|
||||
"illness": {
|
||||
"label": "Krankheiten",
|
||||
|
||||
+1
-1
@@ -9888,7 +9888,7 @@
|
||||
},
|
||||
"profile": {
|
||||
"label": "Health background",
|
||||
"detail": "Allergies, family history and the facts in your health profile."
|
||||
"detail": "Allergies, family history, the facts in your health profile, and your visits and practices."
|
||||
},
|
||||
"illness": {
|
||||
"label": "Illness",
|
||||
|
||||
+1
-1
@@ -9888,7 +9888,7 @@
|
||||
},
|
||||
"profile": {
|
||||
"label": "Antecedentes de salud",
|
||||
"detail": "Alergias, antecedentes familiares y los datos de tu perfil de salud."
|
||||
"detail": "Alergias, antecedentes familiares, los datos de tu perfil de salud y tus visitas médicas y consultas."
|
||||
},
|
||||
"illness": {
|
||||
"label": "Enfermedades",
|
||||
|
||||
+1
-1
@@ -9888,7 +9888,7 @@
|
||||
},
|
||||
"profile": {
|
||||
"label": "Antécédents de santé",
|
||||
"detail": "Allergies, antécédents familiaux et les informations de votre profil de santé."
|
||||
"detail": "Allergies, antécédents familiaux, les informations de votre profil de santé, ainsi que vos consultations et cabinets."
|
||||
},
|
||||
"illness": {
|
||||
"label": "Maladies",
|
||||
|
||||
+1
-1
@@ -9888,7 +9888,7 @@
|
||||
},
|
||||
"profile": {
|
||||
"label": "Anamnesi",
|
||||
"detail": "Allergie, storia familiare e i dati del tuo profilo di salute."
|
||||
"detail": "Allergie, anamnesi familiare, i dati del tuo profilo sanitario e le tue visite mediche e studi."
|
||||
},
|
||||
"illness": {
|
||||
"label": "Malattie",
|
||||
|
||||
+1
-1
@@ -9888,7 +9888,7 @@
|
||||
},
|
||||
"profile": {
|
||||
"label": "Historia zdrowia",
|
||||
"detail": "Alergie, wywiad rodzinny i dane z Twojego profilu zdrowia."
|
||||
"detail": "Alergie, wywiad rodzinny, dane z Twojego profilu zdrowotnego oraz wizyty lekarskie i gabinety."
|
||||
},
|
||||
"illness": {
|
||||
"label": "Choroby",
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
/**
|
||||
* OpenAPI route table for visits (`/api/encounters`) and the address book
|
||||
* behind them (`/api/practitioners`).
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`. Request bodies
|
||||
* and queries reuse the runtime Zod schemas so the wire contract stays
|
||||
* single-source. Response shapes are declared here to mirror the
|
||||
* server-authoritative DTOs: the practitioner arrives resolved rather than as
|
||||
* an id, the links arrive with a label and a date, and the appointment's next
|
||||
* due instant arrives computed. A client renders these, it never rebuilds them.
|
||||
*
|
||||
* Note what is NOT here: the reminder `origin` enum is unchanged. A booked
|
||||
* visit mints a reminder with a third origin value internally, and that value
|
||||
* reaches no response, because every preventive-care read excludes it. The
|
||||
* published contract stays at two members deliberately.
|
||||
*/
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
encounterCreateSchema,
|
||||
encounterUpdateSchema,
|
||||
encounterListQuerySchema,
|
||||
encounterLinkSchema,
|
||||
encounterStatusEnum,
|
||||
encounterKindEnum,
|
||||
} from "@/lib/validations/encounters";
|
||||
import {
|
||||
practitionerCreateSchema,
|
||||
practitionerUpdateSchema,
|
||||
practitionerListQuerySchema,
|
||||
} from "@/lib/validations/practitioners";
|
||||
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
recordRefusal,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
|
||||
encounterCreateSchema.meta({
|
||||
id: "CreateEncounterRequest",
|
||||
description:
|
||||
"File a visit, or book one. `occurredAt` is the only required field — a visit saves with a date and nothing else. A future instant with `status: PLANNED` books an appointment and mints exactly one reminder for it. `reason` and `outcome` are encrypted at rest. The three id arrays pre-link documents, lab results and condition episodes; an id naming nothing the caller owns is dropped rather than refused, so a link never blocks a save.",
|
||||
});
|
||||
|
||||
encounterUpdateSchema.meta({
|
||||
id: "UpdateEncounterRequest",
|
||||
description:
|
||||
"Partial edit of a visit; an omitted key leaves the column untouched, and a body naming nothing is a 422. Moving `occurredAt` re-anchors the appointment reminder the visit already owns and never mints a second. Moving `status` to DONE closes the checkup named by `reminderId`, on the transition only. CANCELLED and NO_SHOW stop the reminder without deleting the row. A present id array replaces that link family, empty array included.",
|
||||
});
|
||||
|
||||
encounterListQuerySchema.meta({
|
||||
id: "ListEncountersQuery",
|
||||
description:
|
||||
"Window and filters for the visit list: `from` / `to` (ISO-8601 instants), `status`, `practitionerId`, and `limit` (1–200, default 100).",
|
||||
});
|
||||
|
||||
encounterLinkSchema.meta({
|
||||
id: "EncounterLinkRequest",
|
||||
description:
|
||||
"Targets to file against, or unfile from, a visit. Idempotent in both directions and capped at 100 ids. Ids naming nothing the caller owns come back in `unknown` rather than failing the request.",
|
||||
});
|
||||
|
||||
practitionerCreateSchema.meta({
|
||||
id: "CreatePractitionerRequest",
|
||||
description:
|
||||
"Add a doctor or practice to the caller's own address book. `name` is the only required field and is stored as queryable plaintext so the picker can search and sort it; `note` is encrypted at rest. `specialty` is free text rather than an enum. Nothing is unique across accounts.",
|
||||
});
|
||||
|
||||
practitionerUpdateSchema.meta({
|
||||
id: "UpdatePractitionerRequest",
|
||||
description:
|
||||
"Partial edit; an omitted key leaves the column untouched, an explicit null clears it, and a body naming nothing is a 422.",
|
||||
});
|
||||
|
||||
practitionerListQuerySchema.meta({
|
||||
id: "ListPractitionersQuery",
|
||||
description:
|
||||
"Query params for the address book: `q` (case-insensitive substring of the name) and `limit` (1–200, default 100). Name-ordered.",
|
||||
});
|
||||
|
||||
const practitioner = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
specialty: z.string().nullable(),
|
||||
practice: z.string().nullable(),
|
||||
location: z.string().nullable(),
|
||||
phone: z.string().nullable(),
|
||||
note: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
.meta({
|
||||
id: "Practitioner",
|
||||
description:
|
||||
"One entry in the caller's address book. `note` is the decrypted free text, or null on a key-rotation gap — fail-soft, never a 500.",
|
||||
});
|
||||
|
||||
const linkedTarget = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
date: z.string().nullable(),
|
||||
})
|
||||
.meta({
|
||||
id: "EncounterLinkedTarget",
|
||||
description:
|
||||
"One thing filed against a visit, resolved. The label and date are computed server-side from the target itself — a document's title and filing date, a lab result's panel and analyte and draw date, a condition's label and onset — so the client never re-derives them and the two cannot drift.",
|
||||
});
|
||||
|
||||
const encounterLinks = z
|
||||
.object({
|
||||
documents: z.array(linkedTarget),
|
||||
labResults: z.array(linkedTarget),
|
||||
conditions: z.array(linkedTarget),
|
||||
})
|
||||
.meta({
|
||||
id: "EncounterLinks",
|
||||
description:
|
||||
"The three link families of one visit, each ordered by when it was filed. Present on the detail response, absent from the list.",
|
||||
});
|
||||
|
||||
const encounter = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
occurredAt: z.string(),
|
||||
status: encounterStatusEnum,
|
||||
kind: encounterKindEnum,
|
||||
practitioner: practitioner.nullable(),
|
||||
reason: z.string().nullable(),
|
||||
outcome: z.string().nullable(),
|
||||
reminderNextDueAt: z.string().nullable(),
|
||||
links: encounterLinks.optional(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
.meta({
|
||||
id: "Encounter",
|
||||
description:
|
||||
"One contact with the healthcare system: a visit that happened, or one that is booked. `practitioner` is resolved rather than an id. `reason` and `outcome` are the decrypted free text (null on a key-rotation gap). `reminderNextDueAt` is the server-computed instant a booked appointment next nudges, and is null for a visit that already happened or whose one-shot reminder is spent.",
|
||||
});
|
||||
|
||||
const encounterList = z
|
||||
.object({
|
||||
upcoming: z.array(encounter),
|
||||
past: z.array(encounter),
|
||||
})
|
||||
.meta({
|
||||
id: "EncounterList",
|
||||
description:
|
||||
"Visits split server-side rather than client-side. `upcoming` reads soonest-first and `past` reads newest-first — opposite directions, resolved once here so the ordering lives in one place.",
|
||||
});
|
||||
|
||||
const encounterNotFound = {
|
||||
"404": {
|
||||
description: "Visit not found (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const practitionerNotFound = {
|
||||
"404": {
|
||||
description: "Practitioner not found (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const idPath = { path: z.object({ id: z.string() }) };
|
||||
|
||||
export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/encounters": {
|
||||
get: {
|
||||
tags: ["Records"],
|
||||
summary: "List visits",
|
||||
description:
|
||||
"Returns the caller's live visits, split into `upcoming` and `past`. Bounded and windowed; soft-deleted visits are excluded.",
|
||||
requestParams: { query: encounterListQuerySchema },
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
description: "The caller's visits.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(encounterList, "ListEncountersEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Records"],
|
||||
summary: "File or book a visit",
|
||||
description:
|
||||
"Creates one visit. A future instant with `status: PLANNED` books an appointment and mints its reminder; a past instant with `status: DONE` and a `reminderId` closes that checkup. Claiming both is a 422 — a visit that has not happened cannot have closed anything. Honours `Idempotency-Key`. Audits as `encounter.visit.create`.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: encounterCreateSchema } },
|
||||
},
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"201": {
|
||||
description: "Visit filed.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(encounter, "CreateEncounterEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/encounters/{id}": {
|
||||
get: {
|
||||
tags: ["Records"],
|
||||
summary: "Read a single visit",
|
||||
description:
|
||||
"Returns the visit with its three link families resolved. Owner-scoped; a cross-user or tombstoned id 404s.",
|
||||
requestParams: idPath,
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
description: "The visit.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(encounter, "GetEncounterEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...encounterNotFound,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
tags: ["Records"],
|
||||
summary: "Edit a visit",
|
||||
description:
|
||||
"Partial edit; omitted fields are left untouched. Audits as `encounter.visit.update`. Owner-scoped.",
|
||||
requestParams: idPath,
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: encounterUpdateSchema } },
|
||||
},
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
description: "Visit updated.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(encounter, "UpdateEncounterEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...encounterNotFound,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Records"],
|
||||
summary: "Soft-delete a visit",
|
||||
description:
|
||||
"Stamps `deletedAt` and retires the visit's own appointment reminder with it — never a checkup the visit closed, which belongs to the preventive-care list and outlives it. Idempotent. Audits as `encounter.visit.delete`.",
|
||||
requestParams: idPath,
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
description: "Soft-deleted.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ deleted: z.boolean() }),
|
||||
"DeleteEncounterEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...encounterNotFound,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/encounters/{id}/restore": {
|
||||
post: {
|
||||
tags: ["Records"],
|
||||
summary: "Restore a soft-deleted visit",
|
||||
description:
|
||||
"Clears the tombstone. The link rows survived the soft delete, so the visit returns with its documents, labs and conditions still filed against it. Its appointment reminder returns only if the appointment is still in the future. Restoring a live visit is a no-op that still succeeds.",
|
||||
requestParams: idPath,
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
description: "Visit restored.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(encounter, "RestoreEncounterEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...encounterNotFound,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/encounters/{id}/links": {
|
||||
post: {
|
||||
tags: ["Records"],
|
||||
summary: "File targets against a visit",
|
||||
description:
|
||||
"Adds links. Idempotent: a pair that already exists succeeds and changes nothing. Session-authenticated only, matching document link and unlink.",
|
||||
requestParams: idPath,
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: encounterLinkSchema } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The visit's links after the change.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({
|
||||
links: encounterLinks,
|
||||
unknown: z.array(z.string()),
|
||||
}),
|
||||
"LinkEncounterEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...encounterNotFound,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Records"],
|
||||
summary: "Unfile targets from a visit",
|
||||
description:
|
||||
"Removes links. Idempotent, and it still works for a target the account has since deleted — refusing would leave a filing nobody can clear.",
|
||||
requestParams: idPath,
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: encounterLinkSchema } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The visit's links after the change.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({
|
||||
links: encounterLinks,
|
||||
unknown: z.array(z.string()),
|
||||
}),
|
||||
"UnlinkEncounterEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...encounterNotFound,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/practitioners": {
|
||||
get: {
|
||||
tags: ["Records"],
|
||||
summary: "List practitioners",
|
||||
description:
|
||||
"Returns the caller's address book, name-ordered, with an optional case-insensitive name search.",
|
||||
requestParams: { query: practitionerListQuerySchema },
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
description: "The caller's address book.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.array(practitioner),
|
||||
"ListPractitionersEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Records"],
|
||||
summary: "Add a practitioner",
|
||||
description:
|
||||
"Creates one address-book entry. Honours `Idempotency-Key`. Audits as `practitioner.contact.create`.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: practitionerCreateSchema } },
|
||||
},
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"201": {
|
||||
description: "Practitioner added.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(practitioner, "CreatePractitionerEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/practitioners/{id}": {
|
||||
get: {
|
||||
tags: ["Records"],
|
||||
summary: "Read a single practitioner",
|
||||
description:
|
||||
"Returns the entry including its decrypted `note`. Owner-scoped; a cross-user or tombstoned id 404s.",
|
||||
requestParams: idPath,
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
description: "The practitioner.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(practitioner, "GetPractitionerEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...practitionerNotFound,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
tags: ["Records"],
|
||||
summary: "Edit a practitioner",
|
||||
description:
|
||||
"Partial edit; omitted fields are left untouched. Audits as `practitioner.contact.update`. Owner-scoped.",
|
||||
requestParams: idPath,
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: practitionerUpdateSchema } },
|
||||
},
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
description: "Practitioner updated.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(practitioner, "UpdatePractitionerEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...practitionerNotFound,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Records"],
|
||||
summary: "Soft-delete a practitioner",
|
||||
description:
|
||||
"Stamps `deletedAt`. Every visit that named this entry survives with no practice attached, because the reference nulls rather than cascades. Idempotent. Audits as `practitioner.contact.delete`.",
|
||||
requestParams: idPath,
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
description: "Soft-deleted.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ deleted: z.boolean() }),
|
||||
"DeletePractitionerEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...practitionerNotFound,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/practitioners/{id}/restore": {
|
||||
post: {
|
||||
tags: ["Records"],
|
||||
summary: "Restore a soft-deleted practitioner",
|
||||
description:
|
||||
"Clears the tombstone. It does NOT re-attach the visits whose reference was nulled on delete — which visits meant this practice is not recoverable, and guessing would be worse than the gap. Restoring a live entry is a no-op that still succeeds.",
|
||||
requestParams: idPath,
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
description: "Practitioner restored.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(practitioner, "RestorePractitionerEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...practitionerNotFound,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -48,6 +48,7 @@ import { devicePaths } from "./devices";
|
||||
import { exportPaths } from "./export";
|
||||
import { familyHistoryPaths } from "./family-history";
|
||||
import { healthRecordPaths } from "./health-record";
|
||||
import { encounterPaths } from "./encounters";
|
||||
import { illnessPaths } from "./illness";
|
||||
import { importPaths } from "./import";
|
||||
import { insightsPaths } from "./insights";
|
||||
@@ -102,6 +103,7 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...labsPaths,
|
||||
...ocrPaths,
|
||||
...biomarkerPaths,
|
||||
...encounterPaths,
|
||||
...illnessPaths,
|
||||
...mentalHealthPaths,
|
||||
...allergyPaths,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Query keys — visits and the address book behind them.
|
||||
* Part of the centralized factory; aggregated in `./index.ts`.
|
||||
*
|
||||
* These exist before any client reads them, because the in-repo
|
||||
* `healthlog/queryKey-factory` rule errors on a bare array: the first
|
||||
* component that lists visits cannot compile without a slot here. Same key
|
||||
* with a different `queryFn` shape silently poisons the cache, which is the
|
||||
* whole reason the factory is the only legal source.
|
||||
*
|
||||
* There is deliberately no matching `invalidateUserVisits` in
|
||||
* `src/lib/cache/invalidate.ts`. That module evicts SERVER-side cached
|
||||
* payloads, and no cached payload reads a visit: the dashboard snapshot, the
|
||||
* insights targets and the analytics buckets are all computed from
|
||||
* measurements, medications and mood. Adding a sweep over caches nothing reads
|
||||
* would be a placeholder that looks like coverage. It belongs in the release
|
||||
* that puts visits into one of those payloads, alongside the reader that makes
|
||||
* it necessary.
|
||||
*/
|
||||
export const encounterKeys = {
|
||||
/**
|
||||
* The root prefix every visit write invalidates through
|
||||
* (`encounterDependentKeys` in `./index.ts`). TanStack's hierarchical
|
||||
* prefix semantics mean evicting `["encounters"]` clears the windowed list
|
||||
* and every open detail in one tick, so a visit edited in a sheet and the
|
||||
* list behind it never disagree.
|
||||
*/
|
||||
encounters: () => ["encounters"] as const,
|
||||
/**
|
||||
* The windowed list. The window is part of the key: the page can ask for a
|
||||
* year at a time, and two windows are two different answers rather than one
|
||||
* answer that overwrites the other.
|
||||
*/
|
||||
encounterList: (from: string | null, to: string | null, status?: string) =>
|
||||
["encounters", "list", from, to, status ?? null] as const,
|
||||
encounter: (id: string) => ["encounters", "detail", id] as const,
|
||||
/**
|
||||
* The address book. Under its own root rather than nested beneath
|
||||
* `["encounters"]`: a practitioner outlives the visits that name it, and a
|
||||
* visit write should not evict a list that did not change.
|
||||
*/
|
||||
practitioners: () => ["practitioners"] as const,
|
||||
practitionerList: (q?: string) =>
|
||||
["practitioners", "list", q ?? null] as const,
|
||||
practitioner: (id: string) => ["practitioners", "detail", id] as const,
|
||||
};
|
||||
@@ -25,6 +25,7 @@ import { dailyKeys } from "./daily";
|
||||
import { dashboardKeys } from "./dashboard";
|
||||
import { environmentKeys } from "./environment";
|
||||
import { documentKeys } from "./documents";
|
||||
import { encounterKeys } from "./encounters";
|
||||
import { illnessKeys } from "./illness";
|
||||
import { insightsKeys } from "./insights";
|
||||
import { integrationKeys } from "./integrations";
|
||||
@@ -63,6 +64,7 @@ export const queryKeys = {
|
||||
...familyHistoryKeys,
|
||||
...environmentKeys,
|
||||
...documentKeys,
|
||||
...encounterKeys,
|
||||
...customMetrics,
|
||||
...nutrientKeys,
|
||||
...recordSettingsKeys,
|
||||
@@ -236,6 +238,20 @@ export const medicationDependentKeys = [
|
||||
*/
|
||||
export const cycleDependentKeys = [queryKeys.cycle(), queryKeys.insightsRoot()];
|
||||
|
||||
/**
|
||||
* Keys invalidated when a visit or an address-book entry changes.
|
||||
*
|
||||
* Both roots ride together in one bundle rather than two, because the two
|
||||
* writes reach each other: renaming a practice changes the label every visit
|
||||
* that names it renders, and deleting one nulls the reference on visits that
|
||||
* stay. A bundle that evicted only the surface being written would leave the
|
||||
* other showing the pre-write name.
|
||||
*/
|
||||
export const encounterDependentKeys = [
|
||||
queryKeys.encounters(),
|
||||
queryKeys.practitioners(),
|
||||
];
|
||||
|
||||
/**
|
||||
* Invalidate every key in the bundle in parallel. Use this from mutation
|
||||
* `onSuccess` handlers so the call site stays a one-liner instead of repeating
|
||||
|
||||
@@ -46,7 +46,17 @@ export const SHARE_DOMAINS = [
|
||||
"medications",
|
||||
/** Lab results and the biomarker catalogue behind them. */
|
||||
"labs",
|
||||
/** Health background: allergies, family history, anamnesis facts. */
|
||||
/**
|
||||
* Health background: allergies, family history, anamnesis facts, and visits
|
||||
* and practitioners.
|
||||
*
|
||||
* Visits joined this domain rather than getting one of their own, and the
|
||||
* cost is worth naming: somebody granted `profile` so a relative could see
|
||||
* an allergy list also gains the record of every appointment and the address
|
||||
* book behind it. The mitigation is not this comment — it is the consent
|
||||
* copy, which names visits in all six locales, so the widening is stated
|
||||
* where it is agreed to rather than inferred from the code.
|
||||
*/
|
||||
"profile",
|
||||
/** Illness episodes and the day logs under them. */
|
||||
"illness",
|
||||
|
||||
Reference in new issue
Block a user