mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
feat(documents): link a document to visits and vaccinations from its own sheet
A document could show its visits read-only and its vaccinations not at all, so a childhood record covering a dozen doses had to be attached from a dozen dose forms. The document's detail sheet now lists the visits and vaccinations it is filed against and, for someone who may manage the vault, links and unlinks them in one place, with the records dated near the document on top. Both ends write the same link tables through the link service, so the dose's form and the document's sheet cannot disagree about what is filed where. The document detail response carries the linked doses, named by catalogue slug or the person's own wording. It is null rather than empty for a grant that opens the vault but not the health background, so a shared page never claims to record no doses. The update route takes a replace-set of vaccination ids, up to 100 so one record can cover a whole primary schedule; ownership is re-checked, a foreign id answers 404 and the change lands in the audit log beside the other link fields. Linking from the dose suggestion now refreshes the sheet. Refs #1024, #1005
This commit is contained in:
1 parent
f46ed99def
commit
b480ac4543
23 files changed
+1012
-87
No files matched your search
@@ -82,6 +82,7 @@ function doc(
|
||||
pendingCount: 1,
|
||||
conditionLinks: [],
|
||||
encounterLinks: [],
|
||||
vaccinationLinks: [],
|
||||
servingClass: "inline",
|
||||
hasContentIndex: true,
|
||||
contentIndexSource: "vision",
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ user: { modules: { vaccinations: true } } }),
|
||||
}));
|
||||
vi.mock("@/hooks/use-encounters", () => ({
|
||||
useEncounters: () => ({
|
||||
data: { upcoming: [], past: [] },
|
||||
isPending: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/components/vaccinations/use-vaccinations", () => ({
|
||||
useVaccinations: () => ({
|
||||
data: {
|
||||
vaccinations: [
|
||||
{
|
||||
id: "dose-1",
|
||||
occurredAt: "1991-04-02T00:00:00.000Z",
|
||||
catalogEntry: {
|
||||
slug: "tetanus",
|
||||
atc: "J07AM01",
|
||||
category: "standard",
|
||||
},
|
||||
vaccineName: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
isPending: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents";
|
||||
import { DocumentRecordLinks, recordOptions } from "../document-record-links";
|
||||
|
||||
function doc(
|
||||
over: Partial<InboundDocumentDetailDto> = {},
|
||||
): InboundDocumentDetailDto {
|
||||
return {
|
||||
id: "doc-1",
|
||||
kind: "VACCINATION",
|
||||
title: "Childhood record",
|
||||
filename: null,
|
||||
mimeType: "application/pdf",
|
||||
byteSize: 1,
|
||||
status: "STORED",
|
||||
providerType: null,
|
||||
reportDate: null,
|
||||
documentDate: "1991-04-02",
|
||||
errorReason: null,
|
||||
factCount: 0,
|
||||
pendingCount: 0,
|
||||
conditionLinks: [],
|
||||
encounterLinks: [],
|
||||
servingClass: "inline",
|
||||
hasContentIndex: false,
|
||||
contentIndexSource: null,
|
||||
lastIndexAttemptAt: null,
|
||||
lastIndexOutcome: null,
|
||||
hasThumbnail: false,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
facts: [],
|
||||
vaccinationLinks: [],
|
||||
summary: null,
|
||||
summaryGeneratedAt: null,
|
||||
summaryState: "NONE",
|
||||
...over,
|
||||
} as InboundDocumentDetailDto;
|
||||
}
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("recordOptions", () => {
|
||||
it("puts records near the document first and groups the rest by year", () => {
|
||||
const options = recordOptions(
|
||||
[
|
||||
{
|
||||
id: "late",
|
||||
label: "Tetanus",
|
||||
occurredAt: "2010-06-01T00:00:00.000Z",
|
||||
},
|
||||
{ id: "near", label: "Polio", occurredAt: "1991-04-05T00:00:00.000Z" },
|
||||
],
|
||||
"1991-04-02",
|
||||
{ suggested: "Around this date", date: (iso) => iso.slice(0, 10) },
|
||||
);
|
||||
expect(options.map((o) => o.id)).toEqual(["near", "late"]);
|
||||
expect(options[0]!.group?.key).toBe("suggested");
|
||||
expect(options[1]!.group).toEqual({ key: "2010", label: "2010" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("<DocumentRecordLinks>", () => {
|
||||
it("lets a manager link the page to doses and visits", () => {
|
||||
const html = render(
|
||||
<DocumentRecordLinks doc={doc()} canManage onChange={() => undefined} />,
|
||||
);
|
||||
expect(html).toContain('data-slot="document-vaccination-links"');
|
||||
expect(html).toContain('data-slot="document-vaccination-links-add"');
|
||||
expect(html).toContain('data-slot="document-visit-links"');
|
||||
});
|
||||
|
||||
it("shows a read-only reader the doses the page is filed against", () => {
|
||||
const html = render(
|
||||
<DocumentRecordLinks
|
||||
doc={doc({
|
||||
vaccinationLinks: [
|
||||
{
|
||||
vaccinationId: "dose-1",
|
||||
occurredAt: "1991-04-02T00:00:00.000Z",
|
||||
catalogSlug: null,
|
||||
vaccineName: "DTP",
|
||||
},
|
||||
],
|
||||
})}
|
||||
canManage={false}
|
||||
onChange={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="document-vaccination-link"');
|
||||
expect(html).toContain("DTP");
|
||||
expect(html).not.toContain('data-slot="document-vaccination-links-add"');
|
||||
});
|
||||
|
||||
it("shows no dose block at all when the grant withholds the doses", () => {
|
||||
const html = render(
|
||||
<DocumentRecordLinks
|
||||
doc={doc({ vaccinationLinks: null })}
|
||||
canManage
|
||||
onChange={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain("document-vaccination-links");
|
||||
});
|
||||
});
|
||||
@@ -61,9 +61,7 @@ import {
|
||||
apiPatch,
|
||||
apiPost,
|
||||
} from "@/lib/api/api-fetch";
|
||||
import { encounterKindText } from "@/components/encounters/encounter-labels";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
@@ -80,6 +78,7 @@ import {
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
import { DocumentAiSection } from "./document-ai-section";
|
||||
import { DocumentEncounterSuggestion } from "./document-encounter-suggestion";
|
||||
import { DocumentRecordLinks } from "./document-record-links";
|
||||
import { DocumentFactsSection } from "./document-facts-review";
|
||||
import { VaccinationDocumentSuggestion } from "@/components/vaccinations/vaccination-document-suggestion";
|
||||
import { DocumentSummaryBlock } from "./document-summary-block";
|
||||
@@ -102,6 +101,7 @@ type PatchInput = {
|
||||
documentDate?: string | null;
|
||||
episodeIds?: string[];
|
||||
encounterIds?: string[];
|
||||
vaccinationIds?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -408,6 +408,8 @@ export function DocumentDetailSheet({
|
||||
const indexDoc = useIndexDocument();
|
||||
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
/** Bumped on a failed write; remounts the link block onto server state. */
|
||||
const [linkResets, setLinkResets] = useState(0);
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
/**
|
||||
@@ -463,11 +465,22 @@ export function DocumentDetailSheet({
|
||||
`/api/documents/inbound/${documentId}`,
|
||||
input,
|
||||
),
|
||||
onSuccess: () => {
|
||||
onSuccess: (_data, input) => {
|
||||
setMutationError(null);
|
||||
void invalidateKeys(queryClient, [queryKeys.documents()]);
|
||||
// A link written from this side changes what the visit's and the
|
||||
// dose's own views list, so their reads refresh with the vault's.
|
||||
void invalidateKeys(queryClient, [
|
||||
queryKeys.documents(),
|
||||
...(input.encounterIds !== undefined ? [queryKeys.encounters()] : []),
|
||||
...(input.vaccinationIds !== undefined
|
||||
? [queryKeys.vaccinations()]
|
||||
: []),
|
||||
]);
|
||||
},
|
||||
onError: () => {
|
||||
setMutationError(t("documents.detail.saveError"));
|
||||
setLinkResets((n) => n + 1);
|
||||
},
|
||||
onError: () => setMutationError(t("documents.detail.saveError")),
|
||||
});
|
||||
|
||||
const restore = useMutation({
|
||||
@@ -1074,36 +1087,16 @@ export function DocumentDetailSheet({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* "Belongs to visit" — read-only here on purpose. The filing
|
||||
happens at the moment the document arrives, or from the
|
||||
visit's own sheet; a second editor for the same link would
|
||||
be a second place the set can be changed. Each row deep-links
|
||||
to the vault filtered to that visit. */}
|
||||
{doc.encounterLinks.length > 0 ? (
|
||||
<div className="space-y-1.5" data-slot="document-visit-links">
|
||||
<p className="text-sm leading-none font-medium">
|
||||
{t("documents.detail.visitsLabel")}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{doc.encounterLinks.map((link) => (
|
||||
<Link
|
||||
key={link.encounterId}
|
||||
href={`/documents?encounter=${encodeURIComponent(link.encounterId)}`}
|
||||
className="bg-muted text-foreground hover:bg-muted/70 focus-visible:ring-ring/50 inline-flex max-w-64 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<span className="truncate">
|
||||
{encounterKindText(t, link.kind as EncounterKind)}
|
||||
</span>
|
||||
{link.occurredAt ? (
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{format.date(link.occurredAt)}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{/* Which visits and doses this page is filed against, and — for
|
||||
someone who may manage the vault — the links themselves.
|
||||
Keyed on the write-failure count so a refused write drops
|
||||
the local selection back to what the server confirmed. */}
|
||||
<DocumentRecordLinks
|
||||
key={`${doc.id}:${linkResets}`}
|
||||
doc={doc}
|
||||
canManage={canManageDocuments}
|
||||
onChange={(part) => patch.mutate(part)}
|
||||
/>
|
||||
|
||||
{/* Staged-facts review + the stored-text extract recovery. The
|
||||
existing extract/confirm chain finally gets its control on
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* "What is this page filed against?" — the document side of the visit and
|
||||
* vaccination links, on the vault's detail sheet.
|
||||
*
|
||||
* Both links already existed as tables, but only one end could write them:
|
||||
* a visit's form picked its documents, a dose's form picked its pages, and
|
||||
* the document itself could show its visits read-only and its doses not at
|
||||
* all. A childhood record covering a dozen doses had to be attached dose by
|
||||
* dose, from twelve forms. Here the page lists what it is filed against and,
|
||||
* for someone who may manage the vault, links and unlinks it in one place.
|
||||
* Both ends write the SAME table through the link service, so the dose's
|
||||
* form and this sheet cannot disagree about what is filed where.
|
||||
*
|
||||
* Every name and date comes from the server: the visit kind as its enum
|
||||
* constant for the reader's bundle, the dose as its catalogue slug or the
|
||||
* person's own wording. The pick lists come from the visit and vaccination
|
||||
* list routes, with the records dated near the document on top under the
|
||||
* same seven-day window the upload suggestion uses.
|
||||
*
|
||||
* A module that is off leaves no block behind, and a grant that does not
|
||||
* reach the doses (`vaccinationLinks: null`) shows no dose block rather than
|
||||
* an empty one that would claim the page records none.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { CalendarDays, Syringe } from "lucide-react";
|
||||
|
||||
import { EntityLinkPicker } from "@/components/links/entity-link-picker";
|
||||
import type { EntityLinkOption } from "@/components/links/entity-link-picker";
|
||||
import { isNearAnchor } from "@/components/links/vault-document-options";
|
||||
import { encounterKindText } from "@/components/encounters/encounter-labels";
|
||||
import { useVaccinations } from "@/components/vaccinations/use-vaccinations";
|
||||
import { useEncounters } from "@/hooks/use-encounters";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents";
|
||||
|
||||
type Translate = ReturnType<typeof useTranslations>["t"];
|
||||
|
||||
/** Records near the document first, then the rest by year, newest first. */
|
||||
export function recordOptions(
|
||||
records: ReadonlyArray<{
|
||||
id: string;
|
||||
label: string;
|
||||
meta?: string | null;
|
||||
occurredAt: string;
|
||||
}>,
|
||||
anchor: string | null,
|
||||
labels: { suggested: string; date: (iso: string) => string },
|
||||
): EntityLinkOption[] {
|
||||
const near: EntityLinkOption[] = [];
|
||||
const rest: EntityLinkOption[] = [];
|
||||
for (const record of records) {
|
||||
const option = {
|
||||
id: record.id,
|
||||
label: record.label,
|
||||
meta: record.meta ?? null,
|
||||
dateLabel: labels.date(record.occurredAt),
|
||||
};
|
||||
if (isNearAnchor(record.occurredAt, anchor)) {
|
||||
near.push({
|
||||
...option,
|
||||
group: { key: "suggested", label: labels.suggested },
|
||||
});
|
||||
} else {
|
||||
const year = record.occurredAt.slice(0, 4);
|
||||
rest.push({ ...option, group: { key: year, label: year } });
|
||||
}
|
||||
}
|
||||
return [...near, ...rest];
|
||||
}
|
||||
|
||||
/** A dose named the way the vaccination page names it. */
|
||||
function doseName(
|
||||
t: Translate,
|
||||
catalogSlug: string | null,
|
||||
vaccineName: string | null,
|
||||
): string {
|
||||
if (catalogSlug) return t(`vaccinations.catalog.${catalogSlug}`);
|
||||
return vaccineName ?? t("vaccinations.suggestion.unnamed");
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller mounts this keyed on the document and on its write-failure
|
||||
* count, so the local selection below starts over from the server's answer
|
||||
* whenever the sheet moves to another document or a write fails.
|
||||
*/
|
||||
export function DocumentRecordLinks({
|
||||
doc,
|
||||
canManage,
|
||||
onChange,
|
||||
}: {
|
||||
doc: InboundDocumentDetailDto;
|
||||
canManage: boolean;
|
||||
onChange: (part: {
|
||||
encounterIds?: string[];
|
||||
vaccinationIds?: string[];
|
||||
}) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const { user } = useAuth();
|
||||
const vaccinationsOn = user?.modules?.vaccinations !== false;
|
||||
const anchor = doc.documentDate ?? doc.reportDate;
|
||||
|
||||
const encounters = useEncounters(canManage);
|
||||
const vaccinations = useVaccinations(
|
||||
null,
|
||||
canManage && vaccinationsOn && doc.vaccinationLinks !== null,
|
||||
);
|
||||
|
||||
// The selection the person is editing, ahead of the server. Each change is
|
||||
// sent as the whole set (a replace-set PATCH), so a quick second tap never
|
||||
// computes from a list the first tap has not refreshed yet.
|
||||
const [visitIds, setVisitIds] = useState<string[] | null>(null);
|
||||
const [doseIds, setDoseIds] = useState<string[] | null>(null);
|
||||
|
||||
const labels = {
|
||||
suggested: t("links.picker.suggested"),
|
||||
date: (iso: string) => format.date(iso),
|
||||
};
|
||||
|
||||
const visitOptions = recordOptions(
|
||||
[
|
||||
...(encounters.data?.upcoming ?? []),
|
||||
...(encounters.data?.past ?? []),
|
||||
].map((visit) => ({
|
||||
id: visit.id,
|
||||
label: encounterKindText(t, visit.kind as EncounterKind),
|
||||
meta: visit.practitioner?.name ?? null,
|
||||
occurredAt: visit.occurredAt,
|
||||
})),
|
||||
anchor,
|
||||
labels,
|
||||
);
|
||||
const doseOptions = recordOptions(
|
||||
(vaccinations.data?.vaccinations ?? []).map((dose) => ({
|
||||
id: dose.id,
|
||||
label: doseName(t, dose.catalogEntry?.slug ?? null, dose.vaccineName),
|
||||
occurredAt: dose.occurredAt,
|
||||
})),
|
||||
anchor,
|
||||
labels,
|
||||
);
|
||||
|
||||
const showDoses = vaccinationsOn && doc.vaccinationLinks !== null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{canManage ? (
|
||||
<EntityLinkPicker
|
||||
icon={CalendarDays}
|
||||
title={t("documents.detail.visitsLabel")}
|
||||
slot="document-visit-links"
|
||||
pending={encounters.isPending}
|
||||
error={encounters.isError}
|
||||
errorLabel={t("links.picker.loadError")}
|
||||
onRetry={() => void encounters.refetch()}
|
||||
selected={visitIds ?? doc.encounterLinks.map((l) => l.encounterId)}
|
||||
onChange={(ids) => {
|
||||
setVisitIds(ids);
|
||||
onChange({ encounterIds: ids });
|
||||
}}
|
||||
options={visitOptions}
|
||||
searchPlaceholder={t("links.picker.searchPlaceholder")}
|
||||
emptyLabel={t("documents.detail.noVisitsToLink")}
|
||||
/>
|
||||
) : doc.encounterLinks.length > 0 ? (
|
||||
<div className="space-y-1.5" data-slot="document-visit-links">
|
||||
<p className="text-sm leading-none font-medium">
|
||||
{t("documents.detail.visitsLabel")}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{doc.encounterLinks.map((link) => (
|
||||
<Link
|
||||
key={link.encounterId}
|
||||
href={`/documents?encounter=${encodeURIComponent(link.encounterId)}`}
|
||||
className="bg-muted text-foreground hover:bg-muted/70 focus-visible:ring-ring/50 inline-flex max-w-64 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<span className="truncate">
|
||||
{encounterKindText(t, link.kind as EncounterKind)}
|
||||
</span>
|
||||
{link.occurredAt ? (
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{format.date(link.occurredAt)}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showDoses && canManage ? (
|
||||
<EntityLinkPicker
|
||||
icon={Syringe}
|
||||
title={t("documents.detail.vaccinationsLabel")}
|
||||
slot="document-vaccination-links"
|
||||
pending={vaccinations.isPending}
|
||||
error={vaccinations.isError}
|
||||
errorLabel={t("links.picker.loadError")}
|
||||
onRetry={() => void vaccinations.refetch()}
|
||||
selected={
|
||||
doseIds ??
|
||||
(doc.vaccinationLinks ?? []).map((link) => link.vaccinationId)
|
||||
}
|
||||
onChange={(ids) => {
|
||||
setDoseIds(ids);
|
||||
onChange({ vaccinationIds: ids });
|
||||
}}
|
||||
options={doseOptions}
|
||||
searchPlaceholder={t("links.picker.searchPlaceholder")}
|
||||
emptyLabel={t("documents.detail.noVaccinationsToLink")}
|
||||
/>
|
||||
) : showDoses && (doc.vaccinationLinks?.length ?? 0) > 0 ? (
|
||||
<div className="space-y-1.5" data-slot="document-vaccination-links">
|
||||
<p className="text-sm leading-none font-medium">
|
||||
{t("documents.detail.vaccinationsLabel")}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{(doc.vaccinationLinks ?? []).map((link) => (
|
||||
<Link
|
||||
key={link.vaccinationId}
|
||||
href="/vaccinations"
|
||||
data-slot="document-vaccination-link"
|
||||
className="bg-muted text-foreground hover:bg-muted/70 focus-visible:ring-ring/50 inline-flex max-w-64 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<span className="truncate">
|
||||
{doseName(t, link.catalogSlug, link.vaccineName)}
|
||||
</span>
|
||||
{link.occurredAt ? (
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{format.date(link.occurredAt)}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -158,6 +158,9 @@ export function useLinkDocumentToVaccination() {
|
||||
onSuccess: () =>
|
||||
Promise.all([
|
||||
invalidateKeys(qc, vaccinationDependentKeys),
|
||||
// The document's own sheet lists the doses it is filed against, so
|
||||
// a link made from the suggestion there has to repaint it.
|
||||
invalidateKeys(qc, [queryKeys.documents()]),
|
||||
// The linked-document badge can surface on the daily reads' checkup
|
||||
// items; force the unmounted snapshot/digest to refetch like the
|
||||
// sibling mutations do (via `invalidateReminderReads`).
|
||||
|
||||
Reference in new issue
Block a user