Merge remote-tracking branch 'origin/main' into feat/onboarding-wizard

# Conflicts:
#	docs/api/openapi.yaml
#	src/lib/openapi/error-codes.ts
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 18:20:07 +02:00
commit 55ff6df205
87 files changed
+2080 -399

No files matched your search

+6
View File
@@ -121,6 +121,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
// Apple Watch wrist/skin temperature feeds the temperature-trend
// ovulation layer. Read the WRIST_TEMPERATURE measurements as nightly
// values; the engine derives the trailing-mean deviation itself.
//
// This is a measurements read on a route declared under `cycle`, and it
// needs no section predicate for one reason: no value from it reaches the
// response. It goes into `predictCycle` and what comes out the other side
// is a shifted date. The crosstab next door reads the same channel and IS
// fenced, because there the values are the answer.
prisma.measurement.findMany({
where: {
userId: user.id,
+51 -29
View File
@@ -16,6 +16,7 @@
*/
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError } from "@/lib/api-response";
import { requireCycleEnabled } from "@/lib/cycle/gate";
@@ -50,7 +51,7 @@ const WINDOW_DAYS = 365;
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireRecordAuth("read", "cycle");
const { user, grantId } = await requireRecordAuth("read", "cycle");
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
@@ -68,6 +69,19 @@ export const GET = apiHandler(async () => {
const today = moodDateKey(new Date(), tz);
const from = addDays(today, -WINDOW_DAYS);
// The crosstab is a cycle surface that reads two other sections. The phase
// labels are cycle data; the outcome columns are not. `lutealAvg`,
// `follicularAvg` and `delta` ship resting heart rate, HRV, sleep duration,
// steps, weight, temperature, glucose and the mood score in display units,
// and a grant scoped to the cycle alone was never consent for any of them.
// So the two foreign channels are fenced at the query, not at the response:
// a section the caller is outside of is never read, so nothing downstream
// can leak it back — the rows simply do not exist, exactly as they do not
// for a record with no measurements.
const visible = await actingDomainVisibility(prisma, grantId);
const seesMeasurements = visible("measurements");
const seesMind = visible("mind");
const [
cycles,
dayLogRows,
@@ -116,25 +130,28 @@ export const GET = apiHandler(async () => {
select: { measuredAt: true, value: true },
}),
// The outcome metrics the phase contrast compares — soft-delete-scoped,
// canonical-source deduped per day inside `metricDayMap`.
prisma.measurement.findMany({
where: {
userId: user.id,
deletedAt: null,
type: { in: PHASE_CROSSTAB_METRIC_TYPES },
measuredAt: {
gte: new Date(Date.parse(`${from}T00:00:00Z`)),
},
},
orderBy: { measuredAt: "asc" },
select: {
type: true,
value: true,
measuredAt: true,
source: true,
deviceType: true,
},
}),
// canonical-source deduped per day inside `metricDayMap`. Skipped whole
// for a caller outside `measurements`.
seesMeasurements
? prisma.measurement.findMany({
where: {
userId: user.id,
deletedAt: null,
type: { in: PHASE_CROSSTAB_METRIC_TYPES },
measuredAt: {
gte: new Date(Date.parse(`${from}T00:00:00Z`)),
},
},
orderBy: { measuredAt: "asc" },
select: {
type: true,
value: true,
measuredAt: true,
source: true,
deviceType: true,
},
})
: [],
prisma.user.findUnique({
where: { id: user.id },
select: { sourcePriorityJson: true },
@@ -142,15 +159,18 @@ export const GET = apiHandler(async () => {
// MOOD outcome (QA HIGH): mood lives in MoodEntry (1–5 score), not a
// Measurement row, so read it here and inject it into the crosstab as a
// synthetic MOOD_CHANNEL_KEY measurement (same FDR / day-floor guards).
prisma.moodEntry.findMany({
where: {
userId: user.id,
deletedAt: null,
moodLoggedAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) },
},
orderBy: { moodLoggedAt: "asc" },
select: { score: true, moodLoggedAt: true },
}),
// Skipped whole for a caller outside `mind`.
seesMind
? prisma.moodEntry.findMany({
where: {
userId: user.id,
deletedAt: null,
moodLoggedAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) },
},
orderBy: { moodLoggedAt: "asc" },
select: { score: true, moodLoggedAt: true },
})
: [],
]);
// The latest open cycle runs to the predicted next-period start so the
@@ -239,6 +259,8 @@ export const GET = apiHandler(async () => {
lagged_discovered: lagged.discovered.length,
lagged_pairs_tested: lagged.pairsTested,
symptom_patterns: symptomPatterns.length,
measurements_visible: seesMeasurements,
mind_visible: seesMind,
},
});
+2 -4
View File
@@ -27,10 +27,8 @@ import {
} from "@/lib/api-response";
import { encounterLinkSchema } from "@/lib/validations/encounters";
import { linkTargets, unlinkTargets } from "@/lib/links";
import {
actingDomainVisibility,
loadEncounterLinks,
} from "@/lib/encounters/service";
import { loadEncounterLinks } from "@/lib/encounters/service";
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
type RouteParams = { params: Promise<{ id: string }> };
+1 -1
View File
@@ -22,12 +22,12 @@ import { encounterKindLabel } from "@/lib/encounters/kind-label";
import { toEncounterDTO } from "@/lib/encounters/dto";
import {
ENCOUNTER_INCLUDE,
actingDomainVisibility,
loadEncounterLinks,
resolveOwnerNotificationContext,
shouldHaveReminder,
} from "@/lib/encounters/service";
import { reanchorAppointmentReminder } from "@/lib/encounters/appointment-reminder";
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
type RouteParams = { params: Promise<{ id: string }> };
+1 -1
View File
@@ -35,7 +35,6 @@ import { encounterKindLabel } from "@/lib/encounters/kind-label";
import { toEncounterDTO } from "@/lib/encounters/dto";
import {
ENCOUNTER_INCLUDE,
actingDomainVisibility,
applyEncounterLinks,
closeCheckupForVisit,
findOwnAppointmentReminderId,
@@ -54,6 +53,7 @@ import {
type RestoreSkipLog,
} from "@/lib/export/restore-skips";
import type { Prisma } from "@/generated/prisma/client";
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
type RouteParams = { params: Promise<{ id: string }> };
+1 -1
View File
@@ -36,7 +36,6 @@ import { encounterKindLabel } from "@/lib/encounters/kind-label";
import { toEncounterDTO, type EncounterListDTO } from "@/lib/encounters/dto";
import {
ENCOUNTER_INCLUDE,
actingDomainVisibility,
applyEncounterLinks,
loadEncounterLinksForMany,
closeCheckupForVisit,
@@ -51,6 +50,7 @@ import {
summarizeRestoreSkips,
type RestoreSkipLog,
} from "@/lib/export/restore-skips";
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
export const GET = apiHandler(async (request: NextRequest) => {
const { user, grantId } = await requireRecordAuth("read", "profile");
@@ -159,6 +159,17 @@ async function postAssessment(request: NextRequest): Promise<Response> {
// into the record: the ordinary way this instrument is used when the person
// it is about cannot run their own record. The row records what the person
// in the room reported; the audit row says who typed it.
//
// It writes ONE row outside its declared section, and the crossing is
// deliberate rather than an oversight: the `*_SCORE` Measurement below is a
// projection of this administration, minted `COMPUTED`, keyed on
// `assessment:<id>`, and it exists only so the trend reads through the chart
// and rollup tier without ever touching the encrypted item content. Fencing
// it on `measurements` the way the booster mint is fenced would file a
// screener whose own total is missing from the record — an inconsistency
// inside the section the grant DOES open, to avoid a derived point in one it
// does not. The consent copy for this section says so, which is where a
// widening belongs.
const { user, actor } = await requireRecordAuth("manage", "mind");
// Opt-in module (default OFF) — same gate as GET.
+26 -1
View File
@@ -13,11 +13,21 @@
* even when a delegate is transcribing. The antigen is read from the dose's
* catalogue entry server-side, never from the body — a client cannot key a
* reminder onto an antigen the dose does not contain.
*
* The row it mints is a `MeasurementReminder`, and that model belongs to the
* `measurements` section: every direct route over it declares that domain, and
* creating one needs MANAGE there. So the act crosses a seam, and a grant
* scoped to the health background alone was never consent for the other side
* of it — it would produce a row on the owner's checkup list that the delegate
* can neither read nor change through the section that owns it. The seam is
* fenced the way the visits service fences a checkup closure: the mint runs
* only while the caller is inside `measurements`.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -36,9 +46,24 @@ type RouteParams = { params: Promise<{ id: string }> };
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("write", "profile");
const { user, grantId } = await requireRecordAuth("write", "profile");
const { id } = await params;
// Refused rather than skipped, and that is the difference from the visit's
// checkup closure: there the closure is one effect of a larger save and
// saying "not done" keeps the save honest, while here the reminder IS the
// request. Reporting success over a row that was never written would tell
// the person their booster is planned when nothing rings.
const visible = await actingDomainVisibility(prisma, grantId);
if (!visible("measurements")) {
annotate({ meta: { sharing_refusal: "booster_out_of_scope" } });
return apiError(
"Planning a booster needs access to this record's measurements",
403,
{ errorCode: "vaccination.booster-out-of-scope" },
);
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 8 * 1024,
});
+2 -4
View File
@@ -26,10 +26,8 @@ import {
} from "@/lib/api-response";
import { vaccinationLinkSchema } from "@/lib/validations/vaccinations";
import { linkTargets, unlinkTargets } from "@/lib/links";
import {
actingDomainVisibility,
loadVaccinationDocuments,
} from "@/lib/vaccinations/service";
import { loadVaccinationDocuments } from "@/lib/vaccinations/service";
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
type RouteParams = { params: Promise<{ id: string }> };
@@ -20,10 +20,10 @@ import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { toVaccinationDTO } from "@/lib/vaccinations/dto";
import {
VACCINATION_INCLUDE,
actingDomainVisibility,
loadVaccinationDocuments,
resolveSeriesFor,
} from "@/lib/vaccinations/service";
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
type RouteParams = { params: Promise<{ id: string }> };
+1 -1
View File
@@ -32,7 +32,6 @@ import { vaccinationUpdateSchema } from "@/lib/validations/vaccinations";
import { toVaccinationDTO } from "@/lib/vaccinations/dto";
import {
VACCINATION_INCLUDE,
actingDomainVisibility,
applyVaccinationLinks,
loadVaccinationDocuments,
resolveOwnedEncounter,
@@ -40,6 +39,7 @@ import {
resolveSeriesFor,
} from "@/lib/vaccinations/service";
import type { Prisma } from "@/generated/prisma/client";
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
type RouteParams = { params: Promise<{ id: string }> };
+1 -3
View File
@@ -4,7 +4,6 @@ import { Suspense, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { CycleView } from "@/components/cycle/cycle-view";
@@ -23,10 +22,9 @@ import { CycleView } from "@/components/cycle/cycle-view";
*/
export default function CyclePage() {
const { user, isLoading, isAuthenticated } = useAuth();
const { inSharedRecord } = useRecordCapabilities();
const router = useRouter();
const enabled = inSharedRecord || user?.modules?.cycle !== false;
const enabled = user?.modules?.cycle !== false;
useEffect(() => {
if (isLoading) return;
+1 -3
View File
@@ -14,7 +14,6 @@ import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { IllnessEpisodeDetail } from "@/components/illness/illness-episode-detail";
import { BackLink } from "@/components/ui/back-link";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
@@ -25,12 +24,11 @@ export default function IllnessEpisodePage({
}) {
const { id } = use(params);
const { user, isAuthenticated, isLoading } = useAuth();
const { inSharedRecord } = useRecordCapabilities();
const mounted = useMounted();
const router = useRouter();
const { t } = useTranslations();
const enabled = inSharedRecord || user?.modules?.illness === true;
const enabled = user?.modules?.illness === true;
useEffect(() => {
if (isLoading) return;
+1 -3
View File
@@ -4,7 +4,6 @@ import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { IllnessView } from "@/components/illness/illness-view";
@@ -21,11 +20,10 @@ import { IllnessView } from "@/components/illness/illness-view";
*/
export default function IllnessPage() {
const { user, isLoading, isAuthenticated } = useAuth();
const { inSharedRecord } = useRecordCapabilities();
const router = useRouter();
// Born-gated: enabled ONLY when the resolved map says `true`.
const enabled = inSharedRecord || user?.modules?.illness === true;
const enabled = user?.modules?.illness === true;
useEffect(() => {
if (isLoading) return;
+4 -3
View File
@@ -44,7 +44,8 @@ export default function LabsPage() {
// Scanning a report is not: it files a document and commits through the OCR
// route, and neither is delegated. So a WRITE delegate keeps the manual add
// and loses the choice menu around it.
const { canAdd, inSharedRecord } = useRecordCapabilities();
const { canWriteDomain, inSharedRecord } = useRecordCapabilities();
const canAddLab = canWriteDomain("labs");
const mounted = useMounted();
const router = useRouter();
const queryClient = useQueryClient();
@@ -62,7 +63,7 @@ export default function LabsPage() {
// absent key reads as enabled, so a direct URL hit only bounces on an
// explicit `false`. Every `/api/labs/*` route also enforces the gate
// server-side, so this is a UX redirect, not the security boundary.
const enabled = inSharedRecord || user?.modules?.labs !== false;
const enabled = user?.modules?.labs !== false;
const ocrCapability = useOcrCapability(
shouldProbeOcrCapability({
isAuthenticated,
@@ -152,7 +153,7 @@ export default function LabsPage() {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : canAdd ? (
) : canAddLab ? (
<Button
onClick={() => setDialogOpen(true)}
className="min-h-11 sm:min-h-9"
+5 -4
View File
@@ -35,7 +35,8 @@ export function resolveMeasurementReturnTo(
export default function MeasurementsPage() {
const { isAuthenticated, isLoading } = useAuth();
const { canAdd } = useRecordCapabilities();
const { canWriteDomain } = useRecordCapabilities();
const canAddMeasurement = canWriteDomain("measurements");
const mounted = useMounted();
const router = useRouter();
const queryClient = useQueryClient();
@@ -148,7 +149,7 @@ export default function MeasurementsPage() {
// admits, so this is the header action a delegate keeps. A read-only
// delegate loses it entirely rather than meeting a disabled one.
actions={
canAdd ? (
canAddMeasurement ? (
<Button
data-slot="measurement-add"
className="min-h-11 sm:min-h-9"
@@ -168,12 +169,12 @@ export default function MeasurementsPage() {
answers. `?add=<TYPE>` opens it without passing the button, and a
deep link is the same affordance as the control that produces it, so
it gets the same gate. Gating the open rather than only the param
also covers the first-paint window: `canAdd` reads true until
also covers the first-paint window: `canAddMeasurement` reads true until
`/api/auth/me` settles, and a sheet opened in that frame withdraws
when the answer lands instead of standing on a form the server
refuses. */}
<ResponsiveSheet
open={dialogOpen && canAdd}
open={dialogOpen && canAddMeasurement}
onOpenChange={(open) => {
setDialogOpen(open);
if (!open) {
@@ -48,6 +48,7 @@ vi.mock("@/components/medications/log-intake-dialog", () => ({
import MedicationsPage from "@/app/medications/page-client";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { delegatedDomains } from "@/lib/sharing/domain-write-support";
const OWNER = {
accountId: "acct-owner",
@@ -75,7 +76,17 @@ const READ_ONLY: AccountAccess = {
};
const WRITABLE: AccountAccess = {
accounts: [OWNER],
active: { ...OWNER, access: "write", level: "write", canWrite: true },
active: {
...OWNER,
access: "write",
level: "write",
canWrite: true,
// Published the way the server publishes it: the sections a WRITE grant
// over the whole record can add to. A fixture that said `canWrite: true`
// over an empty list described a grant the server never mints, and the
// control asks the list.
writableDomains: delegatedDomains("write", null, "write"),
},
canSwitch: true,
};
+10 -10
View File
@@ -169,14 +169,14 @@ export default function MedicationsPageClient() {
// to tapping each card in turn — so withholding it made a caregiver with
// five morning tablets tap five times for nothing. The customize page stays
// withheld; sharing does not cover it at all.
const { canAdd, inSharedRecord } = useRecordCapabilities();
const { canWriteDomain, inSharedRecord } = useRecordCapabilities();
const canAddMedication = canWriteDomain("medications");
// v1.18.1 (D3) — medications is an opt-out module. When the account has it
// turned off the whole page disappears (the nav entry is hidden by the same
// gate) and the list query never fires (the API would 403 anyway).
// Default-on: an absent key reads as enabled, so the page only hides on an
// explicit `false`.
const medicationsEnabled =
inSharedRecord || user?.modules?.medications !== false;
const medicationsEnabled = user?.modules?.medications !== false;
const router = useRouter();
const queryClient = useQueryClient();
const searchParams = useSearchParams();
@@ -417,7 +417,7 @@ export default function MedicationsPageClient() {
card's own one-tap job). Calm outline variant so the primary
Add button keeps the visual lead; same responsive 44-px
mobile tap floor as its neighbours. */}
{canAdd && dueMeds.length >= 2 && (
{canAddMedication && dueMeds.length >= 2 && (
<Button
variant="outline"
onClick={() => setTakeAllOpen(true)}
@@ -455,7 +455,7 @@ export default function MedicationsPageClient() {
button's responsive tap-target floor (`min-h-11 sm:min-h-9`) so
both primary "add" entry points clear the WCAG 2.5.5 44px mobile
minimum identically. */}
{canAdd && (
{canAddMedication && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="min-h-11 sm:min-h-9">
@@ -516,7 +516,7 @@ export default function MedicationsPageClient() {
title={t("medications.emptyTitle")}
description={t("medications.emptyDescription")}
action={
canAdd ? (
canAddMedication ? (
<Button size="sm" onClick={openCreate}>
<Plus className="h-4 w-4" />
{t("medications.firstMedication")}
@@ -614,9 +614,9 @@ export default function MedicationsPageClient() {
its own ResponsiveSheet shell with the sticky footer. */}
{/* v1.36.x — `?new=1` (the retired `/medications/new` route redirects
here) opens the create wizard without passing the gated Add control,
so the wizard asks the same `canAdd` the control asks. */}
so the wizard asks the same `canAddMedication` the control asks. */}
<MedicationWizardDialog
open={dialogOpen && canAdd}
open={dialogOpen && canAddMedication}
onOpenChange={setDialogOpen}
mode="create"
onSuccess={closeDialog}
@@ -632,10 +632,10 @@ export default function MedicationsPageClient() {
attribution + inventory consumption identical to N individual
taps — see take-all-due.ts); failed medications stay due. */}
{/* Gated on the same answer the button is, for the first-paint reason
`/measurements` documents: `canAdd` reads true until `/api/auth/me`
`/measurements` documents: `canAddMedication` reads true until `/api/auth/me`
settles, and a dialog opened in that frame withdraws when the answer
lands rather than standing on a confirm the server would refuse. */}
{takeAllOpen && canAdd && (
{takeAllOpen && canAddMedication && (
<TakeAllDueDialog
open={takeAllOpen}
onOpenChange={setTakeAllOpen}
+1 -3
View File
@@ -4,7 +4,6 @@ import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { MentalWellbeing } from "@/components/mental-health/mental-wellbeing";
@@ -27,10 +26,9 @@ import { MentalWellbeing } from "@/components/mental-health/mental-wellbeing";
*/
export default function MentalWellbeingPage() {
const { user, isLoading, isAuthenticated } = useAuth();
const { inSharedRecord } = useRecordCapabilities();
const router = useRouter();
const enabled = inSharedRecord || user?.modules?.mentalHealth === true;
const enabled = user?.modules?.mentalHealth === true;
useEffect(() => {
if (isLoading) return;
+1 -1
View File
@@ -39,7 +39,7 @@ export default function MoodPageClient() {
// absent key reads as enabled, so a direct URL hit only bounces on an
// explicit `false`. Every `/api/mood-entries/*` route also enforces the gate
// server-side, so this is a UX redirect, not the security boundary.
const enabled = inSharedRecord || user?.modules?.mood !== false;
const enabled = user?.modules?.mood !== false;
// v1.16.4 — PWA pull-to-refresh: a top-anchored touch pull refetches
// whatever this page currently has mounted (`type: "active"` scopes the
+3 -2
View File
@@ -254,7 +254,8 @@ export default function DashboardPageClient({
value: number | null | undefined,
): number | null =>
value == null ? null : unitDisplay.toDisplayDelta(type, value);
const { canAdd } = useRecordCapabilities();
const { canWriteDomain } = useRecordCapabilities();
const canAddMeasurement = canWriteDomain("measurements");
const [quickEntryDialog, setQuickEntryDialog] =
useState<QuickEntryDialog>(null);
@@ -2028,7 +2029,7 @@ export default function DashboardPageClient({
title={t("dashboard.emptyTitle")}
description={t("dashboard.emptyDescription")}
action={
canAdd ? (
canAddMeasurement ? (
<Button
size="sm"
onClick={() => setQuickEntryDialog("measurement")}
+5 -6
View File
@@ -4,7 +4,6 @@ import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { VaccinationsView } from "@/components/vaccinations/vaccinations-view";
@@ -22,13 +21,13 @@ import { VaccinationsView } from "@/components/vaccinations/vaccinations-view";
*/
export default function VaccinationsPage() {
const { user, isLoading, isAuthenticated } = useAuth();
const { inSharedRecord } = useRecordCapabilities();
const router = useRouter();
// Default-on: hidden only when the resolved map says `false` explicitly. A
// shared-record session always sees it — the delegate's own module map does
// not govern the owner's record.
const enabled = inSharedRecord || user?.modules?.vaccinations !== false;
// Default-on: hidden only when the resolved map says `false` explicitly.
// The map is the RECORD's, masked to what the grant opens, so a delegate is
// answered about the record they are in rather than about their own — and a
// module the owner has switched off closes this page for them too.
const enabled = user?.modules?.vaccinations !== false;
useEffect(() => {
if (isLoading) return;