import type { Location, Organization } from "fhir/r4"; import type { EntitlementsUnavailable } from "../services/BillingContext"; import { normalizeFhirAddress } from "./fhirAddress"; import { isSiteLocation } from "./roomLocation"; import { locationTimeZoneOf } from "./practiceTimezone"; import { PRACTICE_TYPE_SYSTEM } from "./staffRoleCatalog"; import { isValidPhoneNumber } from "./phoneNumber"; import { EMAIL_PATTERN } from "./registerPatientRequirements"; import { getPatientPortalUrl } from "./patientPortalUrl"; import { getStaffPortalUrl } from "./staffPortalUrl"; import { isLocalPortalAddress } from "./publicPortalAddress"; import type { PortalPageState, PortalPageType } from "./wpAdminApi"; // Set by "Done adding services". One saved category is not the whole offering, so the step waits // for the administrator to say the list is complete (admin-v2 notes, 2026-09-18). export const ORGANIZATION_SERVICES_REVIEWED_EXTENSION_URL = "https://akashichealthrecords.com/fhir/extensions/services-reviewed"; export type SetupStepId = "owner" | "msa" | "baa" | "location" | "contact" | "services" | "clinician" | "coordinator" | "routing"; // checking: the read has not answered. unavailable: it failed. Neither counts as done or as todo. export type SetupStepState = "done" | "todo" | "checking" | "unavailable"; export type SetupStep = { id: SetupStepId; state: SetupStepState }; // A fact is true, false, "checking" while its read is in flight, or undefined when the read failed. export type SetupFact = boolean | "checking" | undefined; export type SetupFacts = { agencyEnrolled: SetupFact; ownerAssigned: SetupFact; baaActive: SetupFact; /** The owner's MSA acceptance; "none" while no MSA is published. A page that does not read it leaves it out. */ msaAccepted?: SetupFact | "none"; primaryLocationReady: SetupFact; contactReady: boolean; servicesReviewed: boolean; clinicianReady: SetupFact; /** "receiving" for a practice founded through a referral claim, until it chooses to operate. */ operatingScope?: "full" | "receiving"; coordinatorAssigned?: SetupFact; routingChosen?: boolean; }; // A referred practice is here to receive one patient's records, not to run a practice yet: until it // chooses to operate, the dashboard leaves out every card and capability about operating. const OPERATING_ONLY = new Set([ "clinician-readiness", "locations", "services", "booking-reminders", "practice-account", "video", "payments", "reminders", "booking", "portal", ]); export const hidesUntilOperating = (scope: "full" | "receiving" | undefined, id: string): boolean => scope === "receiving" && OPERATING_ONLY.has(id); const stateOf = (fact: SetupFact): SetupStepState => fact === "checking" ? "checking" : fact === undefined ? "unavailable" : fact ? "done" : "todo"; // The MSA comes before the first BAA. It is a step only where the page read it and an MSA is published. const msaStep = (facts: SetupFacts): SetupStep[] => !("msaAccepted" in facts) || facts.msaAccepted === "none" ? [] : [{ id: "msa", state: stateOf(facts.msaAccepted as SetupFact) }]; export const buildSetupSteps = (facts: SetupFacts): SetupStep[] => { if (facts.operatingScope === "receiving") { // Identity, then the owner and the agreement they sign, then who works referrals and where they land. // The office address comes before the coordinator: staff are only added under a practice with an address // and a site, so without it the coordinator step leads to a staff page that cannot be used. const owner: SetupStep[] = facts.agencyEnrolled === false ? [] : [{ id: "owner", state: facts.agencyEnrolled === true ? stateOf(facts.ownerAssigned) : facts.ownerAssigned === false ? "todo" : "unavailable" }]; return [ { id: "contact", state: stateOf(facts.contactReady) }, ...owner, ...msaStep(facts), { id: "baa", state: stateOf(facts.baaActive) }, { id: "location", state: stateOf(facts.primaryLocationReady) }, { id: "coordinator", state: stateOf(facts.coordinatorAssigned) }, { id: "routing", state: stateOf(Boolean(facts.routingChosen)) }, ]; } const steps: SetupStep[] = [ ...msaStep(facts), { id: "baa", state: stateOf(facts.baaActive) }, { id: "location", state: stateOf(facts.primaryLocationReady) }, { id: "contact", state: stateOf(facts.contactReady) }, { id: "services", state: stateOf(facts.servicesReviewed) }, { id: "clinician", state: stateOf(facts.clinicianReady) }, ]; if (facts.agencyEnrolled === true) { return [{ id: "owner", state: stateOf(facts.ownerAssigned) }, ...steps]; } // Unknown enrolment keeps the owner step in view rather than dropping a step that may be required. if (facts.agencyEnrolled === undefined) { return [{ id: "owner", state: facts.ownerAssigned === false ? "todo" : "unavailable" }, ...steps]; } // A self-founded practice confirms its owner before the checklist (needsFounderOwnerConfirmation), never as a step. return steps; }; export const needsFounderOwnerConfirmation = (facts: Pick): boolean => facts.agencyEnrolled === false && facts.ownerAssigned === false; // The steps the practice status lists (locations, staff, billing) feed. When those lists cannot be read, a finished // practice may still read as complete with these unknown; an unread BAA or ownership never may, since it could be unsigned. const STATUS_LIST_STEPS: ReadonlySet = new Set(["location", "clinician"]); export const setupSettledDespiteUnreadStatus = (steps: SetupStep[], ownerAssigned: SetupFact): boolean => steps.every((s) => s.state === "done" || (s.state === "unavailable" && (STATUS_LIST_STEPS.has(s.id) || (s.id === "owner" && ownerAssigned === true)))); // guide: a step is still to do or could not be read. checking: nothing is known to be missing yet. export const setupView = (steps: SetupStep[]): "complete" | "guide" | "checking" => { if (steps.every((s) => s.state === "done")) return "complete"; if (steps.some((s) => s.state === "todo" || s.state === "unavailable")) return "guide"; return "checking"; }; // admin-v2/_implementation-notes.md:47: on the WordPress client the guide stays after the essentials until the // portal pages exist. "pages" is that stage; nothing else about the view changes. export const withPortalPages = ( view: "complete" | "guide" | "checking", portalPagesPending: boolean, ): "complete" | "guide" | "checking" | "pages" => view === "complete" && portalPagesPending ? "pages" : view; // A page counts once the practice holds its link, or once this site owns it at a local or private address, // which is never saved as a link: no link will ever arrive for that one to wait on. export const portalPagesReady = ( organization: Organization | null | undefined, sitePages: Record | undefined, ): boolean => { const settled = (stored: string | undefined, page: PortalPageState | undefined) => Boolean(stored) || (page?.status === "owned" && isLocalPortalAddress(page.url)); return settled(getPatientPortalUrl(organization), sitePages?.patient) && settled(getStaffPortalUrl(organization), sitePages?.staff); }; export const practiceCategoryCodes = (org: Organization | null | undefined): string[] => [ ...new Set( (org?.type ?? []) .flatMap((t) => t.coding ?? []) .filter((c) => c.system === PRACTICE_TYPE_SYSTEM && c.code) .map((c) => c.code as string), ), ]; export const hasReviewedServices = (org: Organization | null | undefined): boolean => practiceCategoryCodes(org).length > 0 && (org?.extension ?? []).some( (e) => e.url === ORGANIZATION_SERVICES_REVIEWED_EXTENSION_URL && e.valueBoolean === true, ); export const hasPracticeContact = (org: Organization | null | undefined): boolean => { const phone = org?.telecom?.find((c) => c.system === "phone")?.value ?? ""; const email = org?.telecom?.find((c) => c.system === "email")?.value ?? ""; return isValidPhoneNumber(phone) && EMAIL_PATTERN.test(email.trim()); }; // The primary location is the site whose address is the practice's primary address. export const findPrimaryLocation = ( org: Organization | null | undefined, locations: Location[], ): Location | undefined => { const primary = normalizeFhirAddress(org?.address?.[0]); if (!primary) return undefined; return locations.find((l) => isSiteLocation(l) && normalizeFhirAddress(l.address) === primary); }; // Ready once that site also carries its time zone; its schedules have no clock without one. export const hasPrimaryLocation = ( org: Organization | null | undefined, locations: Location[], ): boolean => Boolean(locationTimeZoneOf(findPrimaryLocation(org, locations))); // The permanent web address comes before the setup steps. Only a confirmed claim opens the guide: // an unread or failed read never counts as claimed and never offers a second claim. export type AddressGate = "checking" | "failed" | "choose" | "claimed"; export const addressGate = (claimed: boolean | "checking" | undefined): AddressGate => claimed === true ? "claimed" : claimed === false ? "choose" : claimed === "checking" ? "checking" : "failed"; export type PracticeStatusRead = "checking" | "ready" | "failed" | "denied" | "noPractice"; // The capability statuses read the practice lists and billing. A refused billing read is its own // state: retrying will not help, and nothing billing-derived may read as working. export const practiceStatusRead = (reads: { listError: boolean; listsLoaded: boolean; billingChecked: boolean; billingUnavailable: EntitlementsUnavailable; }): PracticeStatusRead => { if (reads.billingUnavailable === "noPractice") return "noPractice"; if (reads.billingUnavailable === "denied") return "denied"; if (reads.listError || reads.billingUnavailable === "failed") return "failed"; return reads.listsLoaded && reads.billingChecked ? "ready" : "checking"; };