import { useCallback, useRef, useState } from "react"; import type { Practitioner } from "fhir/r4"; import { useAuthServiceContext, usePractitionerServiceContext, useScheduleServiceContext, useStaffPermissionServiceContext, getOrganizationPolicyFlags, } from "../../services"; import { buildOrgSystemTasks, resolveOnboarding } from "../../utils/orgSystemTasks"; import { readOperatingScope } from "../../utils/operatingScope"; import { getOnboardingStatus } from "../../utils/orgOnboardingStatus"; import { buildPatientSystemTasks } from "../../utils/patientSystemTasks"; import { getPatientPortalUrl } from "../../utils/patientPortalUrl"; import { readSiteTransportSecure } from "../../utils/siteTransport"; import { rolesForOrganization } from "../../utils/practitionerRoleOrgScope"; import { isClinicianEnabled } from "../../utils/clinicianEnabled"; import { holdsClinicalRole } from "../../utils/staffRoleCatalog"; import { ORG_TASKS_ANSWERED_BY_THE_ORGANIZATION, PATIENT_TASKS_ANSWERED_BY_THE_PATIENT, SETUP_GAP_TEXT, anyIncomplete, anyOutstanding, chooseSetupNudge, type SetupGapProbe, type SetupNudge, } from "../../utils/setupNudge"; /** * WHAT THIS VIEWER HAS LEFT UNFINISHED, asked only when the assistant is actually used. * * LAZY ON PURPOSE, AND IT IS THE DIFFERENCE BETWEEN THIS AND THE CHECKLIST IT REPLACES. The onboarding * checklist assembled its inputs at mount, which put three FHIR list loads behind every screen an admin * opened in order to render a line most of them never read. A nudge is appended AFTER an answer, so it * is only ever needed once somebody has asked something - and most sessions never open this panel at * all. Nothing here runs until `probe()` is called. * * ONLY WHAT THIS PERSON CAN ACT ON. Nudging somebody about work they cannot do is the noise the widget * this lives in was designed to avoid: a receptionist is not onboarding anybody, and neither the * practice's setup nor a colleague's schedule is theirs to finish. Every probe below is read from the * SIGNED-IN PRINCIPAL - `currentUser.fhir` and the auth context's own roles - never from a directory, * so there is no input to it that names somebody else. * * IT FAILS TO SILENT. Every probe reports `undefined` rather than `false` when its fact has not arrived * or could not be read, and `undefined` produces no nudge. See `setupNudge.ts` for why that distinction * is the whole safety argument here. */ export interface SetupNudgeResult { /** The one thing worth saying, once it has been asked for. */ nudge: SetupNudge | undefined; /** Ask. Safe to call repeatedly; the answer is computed once per session of the panel. */ probe: () => Promise; } export const useSetupNudge = (): SetupNudgeResult => { const { currentUser, currentOrganization, practitionerRoles } = useAuthServiceContext(); const { fetchOwnPersonalProfile } = usePractitionerServiceContext(); // The SERVICE rather than `useFhirSchedules`, because that hook reports its result through // context state - which is a render away, so reading it inside this callback would see the list // as it was BEFORE the load. `fetchSchedulesWithPolling` hands the bundle straight back. const { fetchSchedulesWithPolling } = useScheduleServiceContext(); const { canManage } = useStaffPermissionServiceContext(); const [nudge, setNudge] = useState(undefined); // ONCE PER CONVERSATION, not once per answer. Spencer asked for the nudge "after an answer even if // it's not related", which is right - the person is already here and already asking. But a nudge on // every answer becomes wallpaper, and then it registers on none of them. This is the flag that keeps // the fifth answer's nudge worth reading by there not being one. const asked = useRef(false); const probe = useCallback(async () => { if (asked.current) return; asked.current = true; const own: Practitioner | undefined = currentUser?.fhir?.resourceType === "Practitioner" ? currentUser.fhir : undefined; const probes: SetupGapProbe[] = []; if (own?.id) { // NARROWED TO THIS ORGANIZATION. A practitioner holds a role per organization and the specialties // that matter are the ones they hold HERE - a specialty set at another practice says nothing // about whether this one can place them. const ownRoles = rolesForOrganization(practitionerRoles, currentOrganization?.id); // The seat is on the ROLE, never on the person - one human can hold two roles here and only one // may be seated - so ANY active seated role makes them bookable, which is the question the two // probes below actually depend on. const holdsAClinicianSeat = ownRoles.some( (role) => role.active !== false && isClinicianEnabled(role), ); probes.push({ kind: "practitionerClinicianSeat", // WHO THIS IS FOR: somebody who holds the Practitioner workspace and has no seat. That is the // broken state - a provider set up in every other way whom nobody can book. // // Silent for everybody else, and each `undefined` is a different fact: // not a clinician an admin or receptionist does not want a seat; it is not their gap // no roles resolved a list that has not landed is not an empty one // cannot grant seats the gap is REAL and is not theirs to close. Nudging a plain // clinician here sends them to a screen with no seat control on it, // which is exactly the dead end this exists to prevent. incomplete: !holdsClinicalRole(ownRoles) ? undefined : ownRoles.length === 0 ? undefined : !canManage("clinicians") ? undefined : !holdsAClinicianSeat, text: SETUP_GAP_TEXT.practitionerClinicianSeat, route: "/dashboard/staff", }); probes.push({ kind: "practitionerSpecialties", // No roles at all is NOT "no specialties" - it is a role list that has not resolved, or a // practitioner who is not affiliated here yet. Neither is a thing they can go and fix. // // AND NEITHER IS THIS ONE, unless they can reach where it is set. A specialty lives on the // PractitionerRole, and roles are governed by the `staff` section - so a plain clinician has // no surface for it. Walked in a browser 2026-08-27: the nudge fired, "Finish this" went to // /dashboard/staff, RequireStaffPermission bounced them to /dashboard/profile, and that page // has no specialty field. A dead end, offered to exactly the population that cannot use it. // // This is the card's own control - "the nudge names only what THIS person can act on" - and // it was the one arm nobody had run. `undefined` rather than `false`, because the gap is // REAL; it is simply not theirs to close, and false would assert their specialties are set. incomplete: !canManage("staff") ? undefined : ownRoles.length === 0 ? undefined : ownRoles.every((role) => (role.specialty ?? []).length === 0), text: SETUP_GAP_TEXT.practitionerSpecialties, route: "/dashboard/staff", }); // A caller scoped to their own schedules gets exactly their own back, but a caller who can see // everybody gets everybody - so it is filtered by actor either way rather than trusting the // scope to have done it. A block carries both a Practitioner and a PractitionerRole actor; // the Practitioner reference is the one that survives a role being replaced. let mySchedules: { actor?: { reference?: string }[] }[] | undefined; try { const bundle = await fetchSchedulesWithPolling(); mySchedules = (bundle.entry ?? []) .map((entry) => entry.resource) .filter((schedule): schedule is NonNullable => Boolean(schedule)) .filter((schedule) => (schedule.actor ?? []).some((actor) => actor.reference === `Practitioner/${own.id}`), ); } catch { // A failed load is NOT an empty schedule. Telling a working clinician the front desk cannot // book them, because a request timed out, is the exact false claim this must never make. mySchedules = undefined; } probes.push({ kind: "practitionerSchedule", // NOT ASKED OF SOMEBODY WHO CANNOT BE BOOKED ANYWAY, and this is the fault the card records: // an admin was told "you have no schedule yet, so the front desk cannot book anyone in with // you" while both practitioners had schedules. Admins and receptionists have Practitioner // records too, so `own?.id` admits them - and for anyone without a clinician seat the // sentence is simply FALSE. A seat, not a schedule, is what makes somebody bookable. // // `undefined` rather than `false`: their schedule state is unknown-and-irrelevant, not fine. incomplete: !holdsAClinicianSeat ? undefined : mySchedules === undefined ? undefined : mySchedules.length === 0, text: SETUP_GAP_TEXT.practitionerSchedule, route: "/dashboard/schedules", }); /** * IS ANY OF THOSE BLOCKS OFFERING A SERVICE AT ALL? * * Read off `Schedule.actor`, which is where the booking flow reads it - NOT off * `PractitionerRole.healthcareService`. Those two disagree, and `practitionerBookingReadiness` * already paid for that once: it counted services from the role while the patient flow matched * the schedule, so it could report a provider ready when no patient could book them. Reading * the same field is what makes this not a second derivation. * * AND IT IS THE BROADER QUESTION ON PURPOSE. `resolveScheduleSelfBookableServices` answers * *"which of these may a PATIENT book"* - a narrower thing, needing the service catalogue and * three layers of override. A practice that deliberately keeps a service behind the front desk * is not misconfigured, and nudging them about it would be wrong. The gap here is *nothing to * be booked AS*, which is a service reference or no service reference. * * Unknown when there is no schedule at all: that is the same fact as the probe above, and * reporting one cause as two gaps would be dishonest even though only one is ever shown. */ probes.push({ kind: "practitionerServices", // Same seat condition, and here it is stronger than "the sentence would be false": without a // seat the schedule editor does not render the services field at all, so this would name a // control that is not on the screen - the dead-end shape we have already paid for. incomplete: !holdsAClinicianSeat ? undefined : mySchedules === undefined || mySchedules.length === 0 ? undefined : !mySchedules.some((schedule) => (schedule.actor ?? []).some((actor) => actor.reference?.startsWith("HealthcareService/"), ), ), text: SETUP_GAP_TEXT.practitionerServices, route: "/dashboard/schedules", }); } // STAFF, INCLUDING PRACTITIONERS. The personal contact is the second door out of a lost password, // and it is the one piece of a staff profile only its owner can supply - the server resolves the // subject from the caller's own token, so this can only ever be asked about oneself. if (currentUser?.fhir?.resourceType === "Practitioner") { try { const profile = await fetchOwnPersonalProfile(); probes.push({ kind: "staffPersonalContact", // `hasHomeProfile: false` means the two fields are ABSENT, which the service's own type // is explicit is not the same as empty. Nobody has declined to fill anything in; there is // nowhere yet for it to be filled in. Unknown, so silent. incomplete: profile?.hasHomeProfile === false ? undefined : !profile?.personalPhone?.trim() && !profile?.personalEmail?.trim(), text: SETUP_GAP_TEXT.staffPersonalContact, route: "/dashboard/staff", }); } catch { // Refused or unreachable. Both mean the fact is unknown, and unknown means silence. } } /** * THE ORGANIZATION'S OWN SETUP, for somebody who can actually finish it. * * Built from the Organization the session already holds and nothing else: `ownerAssigned` and * `baaActive` are left UNDEFINED, which that module already treats as "not yet known" and omits * the step for - the same tri-state contract as the probes here, and the reason this needs no * fetch to be honest. Only the steps in ORG_TASKS_ANSWERED_BY_THE_ORGANIZATION are then read; * the list-fed ones are passed as zero and deliberately never consulted, because an unloaded * list is indistinguishable from an empty practice. */ if (currentOrganization && canManage("organization")) { const { resolved } = resolveOnboarding( buildOrgSystemTasks({ organization: currentOrganization, locationCount: 0, sitesMissingTimezone: 0, questionnaireCount: 0, publicFormCount: 0, portalPagesConfigured: getPatientPortalUrl(currentOrganization) !== undefined, hasReceptionistWorkspace: false, practitionerWorkspaceExists: false, practitionerHoldsClinicianSeat: false, practitionerOffersService: false, practitionerHasBookableSchedule: false, practitionerBookingReady: false, bookingMode: getOrganizationPolicyFlags(currentOrganization).bookingMode, // Not coalesced: `undefined` means the app is not running inside WordPress and the step // is omitted, which is that module's own rule and this probe's rule too. siteTransportSecure: readSiteTransportSecure(), operatingScope: readOperatingScope(currentOrganization), }), getOnboardingStatus(currentOrganization), ); probes.push({ kind: "organizationSetup", incomplete: anyOutstanding(resolved, ORG_TASKS_ANSWERED_BY_THE_ORGANIZATION), text: SETUP_GAP_TEXT.organizationSetup, route: "/dashboard/organization", }); } /** * THE PATIENT'S OWN DETAILS. Same shape, same reason: profile and communication preferences are * both answered by the Patient resource this session is signed in as. Related persons, * medications and allergies each depend on a list AND on a "I have none" flag, so an unloaded * list cannot be told from a patient who genuinely has none - those are passed empty and never * read. */ if (currentUser?.fhir?.resourceType === "Patient") { const tasks = buildPatientSystemTasks({ patient: currentUser.fhir, relatedPersons: [], medicationStatements: [], allergyIntolerances: [], coverages: [], hasConfirmedNoCurrentMedications: false, hasConfirmedNoKnownAllergies: false, familyMemberHistories: [], hasConfirmedNoKnownFamilyHistory: false, appointments: [], }); probes.push({ kind: "patientOnboarding", incomplete: anyIncomplete(tasks, PATIENT_TASKS_ANSWERED_BY_THE_PATIENT), text: SETUP_GAP_TEXT.patientOnboarding, route: "/dashboard/profile", }); } setNudge(chooseSetupNudge(probes)); }, [ currentUser?.fhir, currentOrganization?.id, practitionerRoles, fetchOwnPersonalProfile, fetchSchedulesWithPolling, canManage, ]); return { nudge, probe }; };