mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
feat(body-sites): record a body site on conditions and add a body-site view
A condition (illness episode) can carry where on the body it sits, in the person's own words and encrypted at rest, plus an optional side (LEFT, RIGHT, BOTH). Same enum, codec and reasoning as the procedure's site. - migration 0354: two nullable columns on illness_episodes, reusing the laterality type from 0349 - create and edit accept bodySite and laterality; on PATCH an absent key keeps the stored value, so an edit from the shipped iPhone app, which never sends either field, leaves them untouched - GET /api/body-sites: the sites the record holds across visits and conditions, folded after the decrypt and bounded per kind; with site (and laterality) the visits and conditions there, one hop to their documents, lab results and visits. Read in the profile section; conditions only under an illness grant with the module on, and links into ungranted sections come back as unlabelled placeholders - the link service reads document and visit links from the condition's end of the two existing tables - /checkups gains a Body sites tab; the visit and condition forms share one body-site field pair that suggests sites already used; a condition's page links its site into the view - the doctor report, the clinician page and the MCP doctor-visit summary name the site on conditions; the Coach illness block carries it sanitised, and MCP get_illness_recovery returns it fenced - backup and restore carry both fields in both file kinds; the site is registered as ciphertext and rotates with the key - i18n in all seven locales, OpenAPI regenerated Refs #1025
This commit is contained in:
1 parent
af65f01fec
commit
14f0fc4d4f
57 files changed
+3501
-133
No files matched your search
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { EpisodeVisitsCard } from "@/components/illness/episode-visits-card";
|
||||
import { EpisodeDocumentsCard } from "@/components/documents/episode-documents-card";
|
||||
@@ -20,6 +21,7 @@ import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { bodySiteText } from "@/components/encounters/encounter-labels";
|
||||
|
||||
import { IllnessCorrelationCard } from "./illness-correlation-card";
|
||||
import { IllnessDayTimeline } from "./illness-day-timeline";
|
||||
@@ -41,7 +43,10 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
|
||||
const { t } = useTranslations();
|
||||
// v1.36.x — logging a day, editing and resolving an episode are the
|
||||
// owner's; a delegate reads the episode.
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const { canManageDomain, sections } = useRecordCapabilities();
|
||||
// The body-site view is read in the visits' section; a grant without it
|
||||
// gets the site as text rather than a link that would be refused.
|
||||
const canOpenBodySites = sections === null || sections.includes("profile");
|
||||
const canManageIllness = canManageDomain("illness");
|
||||
const fmt = useFormatters();
|
||||
const {
|
||||
@@ -66,6 +71,15 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
|
||||
}
|
||||
|
||||
const active = episode ? episode.resolvedAt === null : false;
|
||||
const bodySite = episode
|
||||
? bodySiteText(t, episode.bodySite, episode.laterality)
|
||||
: null;
|
||||
const bodySiteHref = episode?.bodySite
|
||||
? `/checkups?${new URLSearchParams({
|
||||
site: episode.bodySite,
|
||||
...(episode.laterality ? { side: episode.laterality } : {}),
|
||||
}).toString()}`
|
||||
: "/checkups";
|
||||
const isChronic = episode?.lifecycle === "CHRONIC_ONGOING";
|
||||
|
||||
return (
|
||||
@@ -95,6 +109,20 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
|
||||
})}`
|
||||
: ""}
|
||||
</span>
|
||||
{bodySite && !canOpenBodySites ? (
|
||||
<span className="text-foreground text-xs">{bodySite}</span>
|
||||
) : null}
|
||||
{bodySite && canOpenBodySites ? (
|
||||
// Where on the body, in the person's own words, so foreground;
|
||||
// it opens the body-site view on that site and side.
|
||||
<Link
|
||||
href={bodySiteHref}
|
||||
className="text-foreground text-xs underline-offset-4 hover:underline"
|
||||
data-slot="episode-body-site"
|
||||
>
|
||||
{bodySite}
|
||||
</Link>
|
||||
) : null}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
BodySiteFields,
|
||||
type BodySiteSideValue,
|
||||
} from "@/components/body-sites/body-site-fields";
|
||||
|
||||
import {
|
||||
useCreateEpisode,
|
||||
@@ -89,6 +93,10 @@ export function NewEpisodeSheet({
|
||||
const [resolved, setResolved] = useState("");
|
||||
const [parentId, setParentId] = useState<string>(NONE);
|
||||
const [note, setNote] = useState("");
|
||||
// v1.39.2 — where on the body. Offered on every type: an injury or a joint
|
||||
// condition is the usual case, but nothing about a type rules a site out.
|
||||
const [bodySite, setBodySite] = useState("");
|
||||
const [laterality, setLaterality] = useState<BodySiteSideValue>(null);
|
||||
|
||||
// Reset / hydrate the form each time the sheet opens — adjusted during
|
||||
// render keyed on the open transition (React's recommended alternative to a
|
||||
@@ -105,6 +113,8 @@ export function NewEpisodeSheet({
|
||||
);
|
||||
setParentId(editEpisode?.parentConditionId ?? NONE);
|
||||
setNote(editEpisode?.note ?? "");
|
||||
setBodySite(editEpisode?.bodySite ?? "");
|
||||
setLaterality(editEpisode?.laterality ?? null);
|
||||
} else if (!open && wasOpen) {
|
||||
setWasOpen(false);
|
||||
}
|
||||
@@ -135,6 +145,8 @@ export function NewEpisodeSheet({
|
||||
resolvedAt,
|
||||
parentConditionId,
|
||||
note: note.trim() === "" ? null : note,
|
||||
bodySite: bodySite.trim() || null,
|
||||
laterality,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
@@ -146,6 +158,8 @@ export function NewEpisodeSheet({
|
||||
resolvedAt,
|
||||
parentConditionId,
|
||||
note: note.trim() === "" ? null : note,
|
||||
bodySite: bodySite.trim() || null,
|
||||
laterality,
|
||||
});
|
||||
}
|
||||
onOpenChange(false);
|
||||
@@ -204,6 +218,15 @@ export function NewEpisodeSheet({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<BodySiteFields
|
||||
idPrefix="illness"
|
||||
bodySite={bodySite}
|
||||
laterality={laterality}
|
||||
onBodySiteChange={setBodySite}
|
||||
onLateralityChange={setLaterality}
|
||||
hint={t("illness.new.bodySiteHint")}
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("illness.new.lifecycle")}</Label>
|
||||
<Select
|
||||
|
||||
@@ -29,6 +29,9 @@ export interface IllnessEpisodeDTO {
|
||||
resolvedAt: string | null;
|
||||
parentConditionId: string | null;
|
||||
note: string | null;
|
||||
/** Where on the body, decrypted; null when not stated (v1.39.2). */
|
||||
bodySite: string | null;
|
||||
laterality: "LEFT" | "RIGHT" | "BOTH" | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -64,6 +67,8 @@ export interface IllnessEpisodeCreateInput {
|
||||
resolvedAt?: string | null;
|
||||
parentConditionId?: string | null;
|
||||
note?: string | null;
|
||||
bodySite?: string | null;
|
||||
laterality?: "LEFT" | "RIGHT" | "BOTH" | null;
|
||||
}
|
||||
|
||||
export interface IllnessDayLogInput {
|
||||
@@ -82,6 +87,9 @@ export interface IllnessEpisodeUpdateInput {
|
||||
resolvedAt?: string | null;
|
||||
parentConditionId?: string | null;
|
||||
note?: string | null;
|
||||
/** Absent keeps the stored value; null or blank clears it. */
|
||||
bodySite?: string | null;
|
||||
laterality?: "LEFT" | "RIGHT" | "BOTH" | null;
|
||||
}
|
||||
|
||||
/* ── P3 correlation + retrospective DTOs (server-authoritative) ───────── */
|
||||
|
||||
Reference in new issue
Block a user