Files
HealthLog/src/lib/navigation/shared-record.ts
T
Marc-André Bombeck eefc151617 feat(vaccinations): add the immunization log route, nav entry and shared-record family
- register /vaccinations in the clinical nav spine, module-gated on
  vaccinations (default-on) and part of the shared record
- join /vaccinations to the profile shared-record route family so a scoped
  profile grant can present the page it is allowed to read
- add the page shell: authenticated, module-gated, with the standard
  module-off redirect the medications surface uses
- add the nav label and the surface title/subtitle/empty copy in six locales
2026-08-08 20:24:42 +02:00

129 lines
4.7 KiB
TypeScript

import {
ENTIRE_RECORD,
SHARE_DOMAINS,
type ShareDomain,
type ShareScope,
} from "@/lib/sharing/scope";
/**
* The browser route families a scoped grant can present.
*
* This is deliberately a presentation inventory, not an authorization table.
* `sections` arrives here as the server-resolved value from `accountAccess`;
* the request handler still evaluates the active grant for every read and
* mutation. Keeping the browser's route families closed lets a new domain
* fail closed in chrome until it has been intentionally classified.
*/
export const SHARED_RECORD_DOMAIN_ROUTE_FAMILIES = {
// `/checkups` carries content from two domains and is listed under both.
// The preventive-care list has always been a `measurements` read
// (`/api/measurement-reminders` declares it), and the page was in neither
// list — under a whole-record grant it painted anyway, because a null
// section set admits everything, so the omission stayed invisible for as
// long as a scoped grant had no reason to open it. The visits section makes
// it visible: a delegate scoped to `profile` is granted the visit data by
// the API and would be denied the only page that shows it. The table is
// built by a flatMap over (domain, href) pairs, so one href under two
// domains means a grant naming either presents the page — and the handler
// still evaluates the grant on every read, because this is a presentation
// inventory and not an authorization table.
measurements: ["/measurements", "/checkups"],
medications: ["/medications"],
labs: ["/labs"],
// `/vaccinations` joins `profile`: an immunization history is health
// background in the same sense as the visits already listed here, so a
// delegate scoped to `profile` must be able to present the page the API
// already lets them read — without this line the grant reads the data and
// the chrome hides the only page that shows it.
profile: ["/profile", "/checkups", "/vaccinations"],
illness: ["/illness"],
mind: ["/mood", "/mental-wellbeing"],
cycle: ["/cycle"],
documents: ["/documents"],
} as const satisfies Record<ShareDomain, readonly `/${string}`[]>;
/**
* Routes whose reads span multiple domains. A selected-domain grant never
* opens these, even when it happens to name every currently known domain.
*/
const WHOLE_RECORD_ROUTE_FAMILIES = ["/", "/achievements"] as const;
interface RouteFamily {
href: string;
scope: ShareScope;
}
const SHARED_RECORD_ROUTE_FAMILIES: readonly RouteFamily[] = [
...WHOLE_RECORD_ROUTE_FAMILIES.map((href): RouteFamily => ({
href,
scope: ENTIRE_RECORD,
})),
...SHARE_DOMAINS.flatMap((domain): RouteFamily[] =>
SHARED_RECORD_DOMAIN_ROUTE_FAMILIES[domain].map((href): RouteFamily => ({
href,
scope: domain,
})),
),
];
function matchesRouteFamily(pathname: string, href: string): boolean {
return href === "/"
? pathname === href
: pathname === href || pathname.startsWith(`${href}/`);
}
function includesScope(
sections: readonly ShareDomain[] | null,
scope: ShareScope,
): boolean {
if (sections === null) return true;
return scope !== ENTIRE_RECORD && sections.includes(scope);
}
/**
* Server-resolved navigation paint for the active shared record.
*
* The return value never makes an authorization decision. It only prevents a
* known-unavailable page from mounting its reads after the server has already
* said which record sections the browser is inside.
*/
export function resolveSharedRecordNavigation(
sections: readonly ShareDomain[] | null,
) {
const routeFamilies = SHARED_RECORD_ROUTE_FAMILIES.filter((family) =>
includesScope(sections, family.scope),
);
return {
// Deduplicated: an href listed under two domains is one destination, and a
// grant holding both would otherwise offer it twice — and make
// `sharedRecordLandingHref` count a page as two doorways.
destinationHrefs: [...new Set(routeFamilies.map((family) => family.href))],
allowsPath(pathname: string): boolean {
return routeFamilies.some((family) =>
matchesRouteFamily(pathname, family.href),
);
},
};
}
/** The first scope-presentable location to load after a record switch. */
export function sharedRecordLandingHref(
sections: readonly ShareDomain[] | null,
): string {
return resolveSharedRecordNavigation(sections).destinationHrefs[0] ?? "/";
}
/**
* Does the server-resolved scope make this browser route presentable?
*
* This helper is intentionally named for presentation. It must never be used
* by a route handler or to choose an API request's target record.
*/
export function isSharedRecordPathPresentable(
pathname: string,
sections: readonly ShareDomain[] | null,
): boolean {
return resolveSharedRecordNavigation(sections).allowsPath(pathname);
}