import type { Organization } from 'fhir/r4'; import { PRACTICE_TYPE_SYSTEM, STAFF_WORKSPACES } from './staffRoleCatalog'; import type { OrgOnboardingPersistedStatus } from './orgOnboardingStatus'; import { ORGANIZATION_DEFAULT_APPOINTMENT_DURATION_EXTENSION_URL } from '../services/OrganizationService'; import { ORG_LOGO_EXTENSION_URL } from '../config/organizationExtensions'; import { MSA_ROUTE } from './msaAgreement'; import { acceptsInSystemReferrals } from '../config/referralRouting'; export type OrgSystemTaskAction = | { kind: 'modal'; modalId: 'logo' | 'contact' | 'practice-types' | 'location' | 'appointment-policies' | 'role-receptionist' | 'role-practitioner' } /** * 🧭 CARD 697 — `workspace` NAMES THE HAT THE ROUTE LIVES UNDER, when it is not the admin's own. * * 🗣️ The admin workspace design settles this and it needed no ruling: *"Onboarding crosses every * workspace, and that is inherent… **the checklist navigates; the items do not move.** So the answer to * 'should an admin have to switch workspaces to onboard?' is **no** — without relocating anything."* * * 🚨 WITHOUT THIS THE STEP IS A DEAD BUTTON. Three of these routes sit behind `RequirePlatformManager`, * which gates on the **active workspace** rather than on a permission — so an admin clicking them was * bounced by a `` and the dialog simply closed with the route unchanged. **No error, no toast, * nothing to tell them they had been refused**, on the guided setup that is a new practice's first * impression. * ⛔ It is NOT a permissions fix: the same person with the same grants is allowed or refused purely by * which hat is currently selected. ⇒ The checklist switches the hat and navigates, which is what the * design says it is for. */ | { kind: 'route'; route: string; workspace?: string; focusId?: string }; export type OrgSystemTaskDefinition = { id: string; order: number; title: string; shortLabel: string; actionLabel: string; whatItDoes: string; whyItMatters: string; dependencyNote?: string; dependsOn?: string[]; // A required task cannot be skipped, so onboarding never counts as complete until it is actually done. required?: boolean; // A LIVE CONDITION, NOT AN ACHIEVEMENT - never remembered, always re-measured. // // Most steps here are something that STAYS done: a signed BAA does not become unsigned. So onboarding // persists `done` stickily, and `resolveOnboarding` honours that memory. **https is not like that.** A // certificate expires, a host migration drops the redirect, someone edits the Site Address back - and a // remembered `done` would then report a secure site while patients are being turned away at the door. // For these, a persisted value is IGNORED in both directions and the live reading is the only answer. // // CORRECTED 2026-08-22, and the correction is the useful part. This note used to offer *"a created // location does not uncreate itself"* as its worked example of a step that stays done - and by then it // was false. had changed that step to mean "a location exists AND every one has a time zone", // and a time zone can be cleared or a second site added without one. **The example had become the // counter-example**, which is worse than no example: it read as a settled reason not to set this flag. // THE TEST IS NOT "is this hard to undo" BUT "can this stop being true without anyone re-doing the // step". Ask it again whenever a step's MEANING is tightened - that is the moment a sticky `done` // silently outranks the new condition, for every practice that already ticked it. liveOnly?: boolean; // OPTIONAL, and the absence is a real state rather than an oversight. Every step here but one is // something the practice DOES inside Akashic; the https step is a condition they satisfy on their web // host, and there is no control in this product that changes it. Rendering a button anyway would repeat // this very card's other defect - sending someone at a control that cannot fix their problem. action?: OrgSystemTaskAction; liveComplete: boolean; checklistItems?: { id: string; label: string; completed: boolean }[]; }; export type OrgSystemTaskStatus = 'todo' | 'done' | 'skipped' | 'locked'; export type ResolvedOrgTask = OrgSystemTaskDefinition & { status: OrgSystemTaskStatus; lockHint?: string; }; export type OrgSystemTasksInput = { organization?: Organization | null; locationCount: number; /** * - HOW MANY SITES BOOKING CANNOT RESOLVE A CLOCK FOR. * * The count is deliberately of sites for which `resolveBookingTimeZone` returns null - the SAME * question booking asks, not a similar one. If this step asked "does the Organization carry a * timezone" and booking asked "site, falling back to the org", the two could disagree and the * checklist would report a practice ready while its schedules were still read in the viewer's zone. * **The step is complete exactly when booking can name a clock for every site.** * * SITES ONLY. The org's virtual (telehealth) Location has no inherent clock - that is what makes it * virtual - and a video visit inherits the practitioner's physical site. Requiring one there would be * an unsatisfiable step. */ sitesMissingTimezone: number; questionnaireCount: number; publicFormCount: number; portalPagesConfigured: boolean; hasReceptionistWorkspace: boolean; practitionerWorkspaceExists: boolean; /** Does any provider hold an ACTIVE clinician seat? The gate on being bookable at all. */ practitionerHoldsClinicianSeat: boolean; practitionerOffersService: boolean; practitionerHasBookableSchedule: boolean; practitionerBookingReady: boolean; bookingMode: 'direct' | 'review' | 'request-only'; //. `undefined` means NOT MEASURABLE - the app is not running inside WordPress - and the step // is then omitted entirely rather than shown as done or as outstanding. Never coalesce this to a // boolean at a call site: `?? false` accuses every non-WordPress deployment of being insecure and // wedges a REQUIRED step forever, and `?? true` is the silent pass the card exists to close. siteTransportSecure?: boolean; /** * - DOES THIS PRACTICE HAVE A DESIGNATED OWNER? Spencer, 2026-08-18: *"326 should be one of * the first onboarding tasks right with the baa signing task since owner needs assigned before baa is * signed."* * * THE ORDERING IS THE RULING, NOT A DETAIL. Only the Owner may sign the BAA, so this step * sits IMMEDIATELY BEFORE it and the sequence enforces itself - a practice that tries to sign first is * told what is missing rather than refused by something it cannot see. * * `undefined` means NOT YET ANSWERED, and the step is then OMITTED rather than shown as outstanding * - the same rule `baaActive` follows and for the same reason: a step that accuses a practice of having * no owner because a fetch had not returned yet is the "red beside a card saying it is fine" defect card * 494 was raised for. Never coalesce this to a boolean at a call site. * * AND IT DOES NOT CLOSE THE HOLE IT SITS NEXT TO, which the card says in terms: nothing buildable * today stops an agency naming ITSELF. *"Belongs to the organization"* is not machine-checkable, and a * blanket "the creator may not self-nominate" rule would tax every single-practitioner practice, which * legitimately is both. What this does is shorten the window from indefinite to one onboarding flow, and * pair it with an attestation that puts a false claim on record. Do not "strengthen" it later by * refusing self-nomination - that breaks the common case to defend against one the attestation covers. */ ownerAssigned?: boolean; /** * - WHETHER A BAA IS IN FORCE, FROM THE RECORD, NOT FROM THE ORGANIZATION RESOURCE. * * The step used to read the `baa-status` extension off the organization it was handed. That extension * is **decorated onto an authenticated GET and never stored**, and public projection drops * it on the copy the client holds before it re-reads as itself, it is **absent for everyone** - and * absent read as *unsigned*. So the hub showed a red **"Sign a BAA"** beside an Agreements card * reading **"IN FORCE, signed by... on June 26, 2026"**, on the same screen, about the one agreement * that decides whether the practice may lawfully handle PHI. * * `utils/patientPortalAccess.ts` is the SAME defect, found first: *"do not read * `baa-status` here - it is deliberately not public, so such a read is false for everyone, silently."* * This was the second reader, and it was never fixed. The signal now comes from the authoritative * `GET /R4/Organization/baa` that the Agreements card beside it already reads, so the two agree **by * construction** rather than by two sources happening to concur. * * `undefined` means NOT YET KNOWN - same contract as `siteTransportSecure` above, and the step is * OMITTED rather than shown as outstanding. Never coalesce it: `?? false` re-creates exactly the * contradiction this card is about, and it is the safe-looking default. */ baaActive?: boolean; /** The owner's MSA acceptance. OMITTED while unknown, and while no MSA is published, like `baaActive`. */ msaAccepted?: boolean; /** * - WHICH TRACK THIS ORGANIZATION IS ONBOARDING ON. * * `full` is a practice standing itself up to OPERATE here: locations, schedules, staff, a portal. * `receiving` is a practice that arrived through a referral claim to RECEIVE patients - it needs to * be reachable and legally able to hold PHI, and nothing else. * * THE POINT IS WHAT IT REMOVES. A coordinator claiming a referral is trying to receive one * patient's records today; a thirteen-step checklist about clinician seats, appointment policies and * public forms is not a smaller version of that job, it is a different one. **The most likely arrival * is somebody who cannot finish any of it**, and a hub that shows them all of it reads as "you are not * welcome until you buy a practice management system". * * Defaults to `full`, and that direction is deliberate: an org whose track is unknown gets the * complete checklist. The opposite default would silently hide setup steps from a real practice. */ operatingScope?: 'full' | 'receiving'; /** * Whether anybody at this organization holds the referral-coordinator role - the person who works * incoming referrals. Only consulted on the `receiving` track. */ hasReferralCoordinator?: boolean; }; export type ResolvedOnboarding = { resolved: ResolvedOrgTask[]; currentStepIndex: number; completedCount: number; totalCount: number; allComplete: boolean; }; const hasExtension = (organization: Organization | null | undefined, url: string): boolean => Boolean(organization?.extension?.some((e) => e.url === url)); const contactComplete = (organization?: Organization | null): boolean => // Contact = a reachable PHONE. The org address is owned by the separate "Add a location" step (the // contact modal doesn't capture an address), so it is intentionally NOT required here - requiring it // would stall the flow on this step. // // - this used to accept phone OR email, so an org with only an email address satisfied it // and could walk straight into staff setup. Spencer, 2026-08-12: *"This is also why we need admin to // set the primary location before they're able to add staff. Same with primary phone number."* A phone // is the channel a patient reaches a practice on and the one the front desk is reachable at; an email // address does not substitute for it, which is why the OR was the wrong operator rather than a // convenience. // Email is deliberately NOT also required. The ruling named the phone; widening this to "phone AND // email" would stall existing orgs on a requirement nobody asked for. (organization?.telecom ?? []).some( (t) => t.system === 'phone' && Boolean(t.value), ); const practiceTypesComplete = (organization?: Organization | null): boolean => (organization?.type ?? []).some((t) => (t.coding ?? []).some((c) => c.system === PRACTICE_TYPE_SYSTEM && Boolean(c.code)), ); export function buildOrgSystemTasks( input: OrgSystemTasksInput, ): OrgSystemTaskDefinition[] { const { organization, locationCount, sitesMissingTimezone, questionnaireCount, publicFormCount, portalPagesConfigured, hasReceptionistWorkspace, practitionerWorkspaceExists, practitionerHoldsClinicianSeat, practitionerOffersService, practitionerHasBookableSchedule, practitionerBookingReady, bookingMode, siteTransportSecure, ownerAssigned, baaActive, msaAccepted, operatingScope = 'full', hasReferralCoordinator, } = input; // `order` is DERIVED from position at the end of this function, never written by hand. It drives the // visible "Step N of M" label, and the moment a step can be conditionally present a hand-numbered field // is a renumbering waiting to go wrong silently - the label would still read plausibly while naming the // wrong step. Authoring order remains dependency order, which `resolveOnboarding` relies on. const steps: Omit[] = []; // - FIRST, and Spencer's reason is the ordering argument: *"a practice must not be able to // finish setup on an insecure site"*, and EVERY LATER STEP WRITES PHI. It also precedes "set up your // portal pages" for a concrete reason rather than a thematic one: that step publishes the page patients // log in through, and publishing it at an http address publishes a door that refuses everyone. // // OMITTED, not defaulted, when the fact is unmeasurable - see `siteTransportSecure`'s own note. if (siteTransportSecure !== undefined) { steps.push({ id: 'https', // Required: this is the half that makes it Spencer's ask rather than a hint. A skippable step // would let onboarding report itself complete on a site whose patients cannot reach the portal. required: true, // A certificate can expire long after setup finished. See `liveOnly` on the type. liveOnly: true, title: 'Serve your site over https', shortLabel: 'https', // THE LABEL AND THE `action` ANSWER TWO DIFFERENT BUTTONS, which is why one is present and the // other is not. The checklist's summary button calls `openTask` and merely OPENS this step's // explanation - worth having, so it needs a label. The focus panel's button calls `runAction` and // is supposed to PERFORM the fix - and no control in Akashic changes a web host's certificate, so // that one must not exist. An `actionLabel` of `''` would have rendered the summary button // empty, which is the same "control that cannot work" defect this card's other half is about. actionLabel: 'See what’s needed', whatItDoes: 'Make sure your website address starts with https, and that WordPress is configured to use it. Your web host or whoever maintains your site sets this up.', whyItMatters: 'Patient records, messages and appointments are only served over a secure connection. Until your site address uses https, patients following your links reach a notice instead of the portal — and everything you set up after this handles protected health information.', liveComplete: siteTransportSecure, }); } // - IMMEDIATELY BEFORE THE BAA STEP, and the position is Spencer's ruling rather than a // layout choice: *"owner needs assigned before baa is signed."* Only the Owner may sign, so // authoring it here makes the sequence enforce itself - an admin who tries to sign first is told what is // missing instead of being refused by a rule they cannot see. // OMITTED, not defaulted, until the owner record has answered - same rule as `baaActive` below. if (ownerAssigned !== undefined) { steps.push({ id: 'owner', // Required, for the same reason the https step is: the practice cannot go patient-facing without // it. The BAA is unsignable until an Owner exists, and the portal is closed until the BAA is // active, so a skippable step here would let onboarding report itself complete on a practice that // can never open. required: true, title: 'Say who owns this practice', shortLabel: 'Practice owner', actionLabel: 'Assign an owner', whatItDoes: 'Name the person who owns this practice. They are the one who signs your Business Associate Agreement, and the one person an outside company managing your site cannot remove or replace.', whyItMatters: 'Only the owner can sign the agreement that lets your practice handle patient records, so nothing patient-facing works until this is set. If someone outside the practice set Akashic up for you, this is how the practice keeps control of its own account.', // `/dashboard/organization`, NOT a dedicated `/owner` route - there isn't one, and inventing a // route in a task definition is how a step ships a button that 404s. `OrganizationOwnerNotice` is // rendered at the TOP of the organization page, above every other card, precisely so nobody has to // go looking for it this lands the admin on it. // `focusId`: the checklist lives ON the organization page, so routing there is a no-op - the // guidance sheet would close and nothing else happen. The notice is brought into view instead. action: { kind: 'route', route: '/dashboard/organization', focusId: 'organization-owner-notice' }, // A transfer can happen long after setup finished, and the record is authoritative either way. liveOnly: true, liveComplete: ownerAssigned, }); } // - OMITTED, not defaulted, until the BAA record has answered. See `baaActive` on the input // type: an unknown BAA rendered as "Sign a BAA" in red beside a card saying the agreement is in force. if (msaAccepted !== undefined) { steps.push({ id: 'msa', required: true, title: 'Accept the Master Subscription Agreement', shortLabel: 'Accept the MSA', actionLabel: 'Review and accept', whatItDoes: 'Your practice owner reads and accepts the Master Subscription Agreement, the terms your practice uses Akashic under.', whyItMatters: 'It comes before your first Business Associate Agreement. Later updates have their own acceptance and never change a BAA you have signed.', action: { kind: 'route', route: MSA_ROUTE }, liveOnly: true, liveComplete: msaAccepted, }); } if (baaActive !== undefined) { steps.push({ id: 'baa', // Required, for the reason the owner step above already names: the portal is closed until the BAA // is active, so onboarding must never report itself complete on a practice that cannot open. // Deliberately `required` and deliberately NOT a `dependsOn` of anything - those are different // mechanisms and only the second one blocks. The practice carries on adding staff, locations and // policies with the BAA outstanding; it is the patient portal, and only the patient portal, that // waits for it. Making it a dependency would lock the receptionist and practitioner steps behind // an unsigned agreement, which is the one thing this must not do. required: true, title: 'Sign a BAA', shortLabel: 'Sign a BAA', actionLabel: 'Sign your BAA', whatItDoes: 'Sign the Business Associate Agreement that lets your practice handle protected health information in Akashic.', whyItMatters: "HIPAA requires a signed BAA before you can work with patient data — it's what unlocks the patient portal and every PHI feature. Nothing patient-facing is compliant until it's in place, so it comes first.", action: { kind: 'route', route: '/dashboard/organization/baa' }, // A BAA can be superseded long after setup finished, and the pointer is authoritative either way. liveOnly: true, liveComplete: baaActive, }); } steps.push( { id: 'portal-pages', required: true, title: 'Set up your portal pages', shortLabel: 'Portal pages', actionLabel: 'Set up portal pages', whatItDoes: 'Publish the patient portal page on your website — the page where patients log in to reach their records, forms, and appointments.', whyItMatters: 'Patients reach everything you set up — forms, booking, messages — through the portal page. Until it’s published on your site, patients have no way to log in.', action: { kind: 'route', route: '/dashboard/portal-pages', workspace: STAFF_WORKSPACES.PLATFORM_MANAGER }, liveComplete: portalPagesConfigured, }, { id: 'logo', title: 'Add a logo', shortLabel: 'Add a logo', actionLabel: 'Upload a logo', whatItDoes: "Upload your practice's logo.", whyItMatters: 'It brands the patient portal, login screens, and notification emails so patients recognize you — it’s how your practice shows up across everything patients see.', action: { kind: 'modal', modalId: 'logo' }, liveComplete: hasExtension(organization, ORG_LOGO_EXTENSION_URL), }, { id: 'contact', // - required, so it can never be SKIPPED. Staff setup assumes the practice is reachable: // a receptionist working a front desk for an org with no phone number has nothing to be the front // desk of. Spencer: *"If the org isn't setup yet then they shouldn't be able to start setting up // staff under it."* required: true, title: 'Complete contact information', shortLabel: 'Contact info', actionLabel: 'Edit contact info', whatItDoes: "Add your practice's phone and email.", whyItMatters: 'Patients and the system reach you through it, and it appears on patient-facing surfaces. (Your practice street address comes next, with your first location.)', action: { kind: 'modal', modalId: 'contact' }, liveComplete: contactComplete(organization), }, { id: 'practice-types', title: 'Set practice types', shortLabel: 'Practice types', actionLabel: 'Set practice types', whatItDoes: 'Choose the kinds of care your practice provides (e.g. Dental / Oral Health).', whyItMatters: 'Your practice types ARE your service categories — everything service-related is chosen from them. When you add a service, or set the services a location, schedule, or practitioner offers, the category picker is scoped to your practice types. That’s why this is foundational and comes before locations, services, and schedules.', action: { kind: 'modal', modalId: 'practice-types' }, liveComplete: practiceTypesComplete(organization), }, { id: 'location', // - REQUIRED, and this is the load-bearing half of that card's client work. // // Until now this task could be SKIPPED, so onboarding could complete with ZERO locations - and a // staff member created under such an org gets an empty `PractitionerRole.location`, which the whole // product reads as "covers EVERY site". A missing value producing maximum visibility is the // fail-open the card exists to close, and it is about to carry front-desk message routing. // // This COMPLETES an intent rather than reversing a decision: `contactComplete` above already says // the org address "is owned by the separate 'Add a location' step". The author deliberately put the // address here. This step was simply never marked required. required: true, // - SAY WHICH OF THE TWO THINGS IS MISSING. // // Watched in a browser 2026-08-23: adding a site with no time zone reopened this step, and the // step said **"Add a location"** - title, detail line and button - to a practice that had just gone // from two locations to THREE. The only action offered adds a FOURTH site, which will also have no // zone, which reopens the step again. **An instruction that cannot satisfy its own condition.** // And nothing anywhere in the checklist said the words "time zone" at hub level, so there was no // way to find the real problem from the hub at all. // // THE TWO CONDITIONS STAY SEPARATE, which is the fence this card was raised with. A brand-new // practice genuinely does need to add its first site, and "fixing" the copy to say *time zone* // everywhere would mislead exactly the reader onboarding exists for. Branch on which one is // actually outstanding; `locationCount === 0` keeps the original wording untouched. ...(locationCount === 0 ? { title: 'Add a location', shortLabel: 'Add a location', actionLabel: 'Add a location', whatItDoes: 'Add the place(s) where your practice delivers care.', } : { // Named for the thing to DO, not for the thing that is wrong - the hub is a to-do list. title: sitesMissingTimezone === 1 ? 'Set the time zone on your new location' : `Set the time zone on ${sitesMissingTimezone} of your locations`, shortLabel: 'Location time zone', actionLabel: 'Set the time zone', whatItDoes: 'Tell us which clock this site runs on, so patients are offered the right times.', }), whyItMatters: 'A location is where care happens — practitioners’ schedules and bookable appointments are tied to a location, and the services offered there are chosen from your practice-type categories.', dependencyNote: 'Set your practice types first — a location’s services are chosen from them.', action: { kind: 'modal', modalId: 'location' }, // - A LOCATION WITHOUT A CLOCK IS NOT A FINISHED LOCATION. // // The product already says so, ~30 times per page load, in its own words: // "no Location or Organization timezone was resolved, so this schedule's hours are being read in // the viewer's zone. A viewer in a different timezone from the practice will be offered - and // can book - the wrong times." // Somebody wrote that warning and shipped the fallback anyway, and NOTHING ever asked the practice // for the value. Great River - a fully set-up fixture - has no timezone at any level. // // THE FIX THE CARD PROPOSED IS THE FORBIDDEN ONE. It said "one line in // `getOrganizationSetupMissingItems`". That function gates FIVE surfaces, three of them read-only // staff VIEWS, and its own file warns: *"adding a requirement there would lock an existing org out // of its own staff list to fix a creation-time problem."* Several staging orgs have no timezone, so // that one line locks them out of their own staff on the day it lands. It belongs HERE, where the // hub PROMPTS and nothing gates: no product surface reads onboarding completeness. // // AND IT CHECKS THE WRONG RESOURCE. Spencer ruled 2026-08-10: *"the timezone belongs on the // location, single site orgs use the org."* `hasOrganizationTimezone` reads the Organization only, // so a multi-state practice would satisfy it with one zone and put every site but one on the wrong // clock `resolveBookingTimeZone` (site, falling back to the org) is the authority. // // This flips the step back to outstanding for an existing org that has locations but no zone. // That is the intent - it is TELLING them, not blocking them - and it is honest: the location // genuinely is not finished. checklistItems: [ { id: 'exists', label: 'At least one location', completed: locationCount > 0 }, { id: 'timezone', label: 'A time zone for each location', completed: locationCount > 0 && sitesMissingTimezone === 0, }, ], liveComplete: locationCount > 0 && sitesMissingTimezone === 0, // `liveOnly` ADDED 2026-08-22, AND WITHOUT IT EVERYTHING ABOVE THIS LINE WAS INERT. // // Observed on Great River: this step's own dialog rendered " A time zone for each location" while // the hub beside it showed "Add a location " and the page emitted the no-timezone warning 24 times. // `resolveOnboarding` honours a persisted `done` - `task.liveComplete || persistedState === 'done'` // - and EVERY existing practice ticked this step back when it only meant "a location exists". So the // tightened meaning could never reopen it, and the paragraph above promising exactly that was wrong. // // The flag's own doc justified omitting it here in as many words: *"a created location does not // uncreate itself."* That was true of the OLD step. A time zone can be cleared, and a second site can // be added without one - so this is now the same shape as https, and its rule applies: a live // condition that reads its own memory is the false-clean the flag exists to stop. liveOnly: true, }, { id: 'appointment-policies', title: 'Set up your appointment policies', shortLabel: 'Appointment policies', actionLabel: 'Set appointment policies', whatItDoes: 'Choose how patients book (direct self-booking, self-booking held for review, or request-only), your default appointment length, and whether new patients need a consultation first.', whyItMatters: 'Your booking mode shapes how the rest of setup is framed (patients self-booking into slots vs. submitting requests), so we put this before your staff so their setup matches how you actually book.', action: { kind: 'modal', modalId: 'appointment-policies' }, liveComplete: hasExtension(organization, ORGANIZATION_DEFAULT_APPOINTMENT_DURATION_EXTENSION_URL), }, { id: 'receptionist', title: 'Set up your receptionist', shortLabel: 'Receptionist', actionLabel: 'Set up receptionist', // - SAY WHAT IS FREE. Spencer, 2026-08-14: *"the onboarding tasks were created prior to // refining our subscription model so it's outdated."* Under the pre-2026-07-30 model staff were // capped at three, so adding people was something to be careful about. Under the settled model // (`subscriptions/billing-subscriptions.md` § SETTLED PRICING MODEL) non-clinician staff are FREE. // // THIS STEP COMPLETES WHEN *ANY* ROLE HOLDS THE RECEPTIONIST WORKSPACE - one is enough - so a // practice watching costs adds one and stops, never learning the rest are free. The fix is copy; // the defect is revenue: **a practice that under-staffs its own trial never sees the product work**, // and every extra free staff member is what eventually produces the SECOND clinician, which is the // thing that actually earns. // A FACT, NOT A PITCH, and deliberately not a new step (Spencer: *"We don't want to system task // users into having to purchase something, that's not the goal of it."*). whatItDoes: 'Pick who works your front desk — yourself, a team member, or a new hire — and give them the Receptionist workspace. Receptionists, billers and admins are free — add as many as you need.', whyItMatters: 'The receptionist manages appointments and the front desk; the booking workflow assumes someone holds this role.', // - `location` added. The practitioner task already listed it; the receptionist task did // not, which is how a receptionist could be created at an org with no site at all. It matters more // for this role than for any other: routes patient messages to the front desk BY LOCATION, // so a receptionist with no location would receive every site's patient messages. dependsOn: ['appointment-policies', 'location'], action: { kind: 'modal', modalId: 'role-receptionist' }, liveComplete: hasReceptionistWorkspace, }, { id: 'practitioner', title: 'Set up your practitioner', shortLabel: 'Practitioner', actionLabel: practitionerWorkspaceExists ? 'Finish making them bookable' : 'Set up practitioner', // - the other half. The settled model makes the FIRST clinician seat free, so a solo // practice runs the whole platform at no cost. Stated plainly here because the step that asks a // practice to add their first provider is the exact moment a cost-conscious one hesitates. // No price, no upsell, no call to action - the fact only. // - THE SEAT IS NAMED, and it is named SECOND because it gates the two steps after it. // A practice that followed this sentence exactly ended with a provider nobody could book: without // a seat the schedule editor does not render the services field at all, so "choose the services // they offer" describes a control that is not on the screen. A step list that is complete except // for the step gating the outcome is worse than no list, because finishing it reads as done. whatItDoes: 'Set up your first bookable provider — assign the Practitioner workspace, give them a clinician seat, choose the services they offer, and set their schedule. Your first clinician seat is free.', whyItMatters: bookingMode === 'request-only' ? 'A provider can take appointments once they offer a service and have a schedule with hours. Patients will request these times for staff to confirm.' : 'A provider can take appointments once they offer a service and have a schedule with hours. Patients can self-book these services and times.', dependsOn: ['practice-types', 'location', 'appointment-policies'], action: { kind: 'modal', modalId: 'role-practitioner' }, liveComplete: practitionerBookingReady, checklistItems: [ { id: 'workspace', label: 'Practitioner workspace', completed: practitionerWorkspaceExists }, { id: 'seat', label: 'Clinician seat', completed: practitionerHoldsClinicianSeat }, { id: 'service', label: 'Offers a service', completed: practitionerOffersService }, { id: 'schedule', label: 'Bookable schedule', completed: practitionerHasBookableSchedule }, ], }, { id: 'questionnaire', title: 'Create a questionnaire', shortLabel: 'Questionnaire', actionLabel: 'Create a questionnaire', whatItDoes: 'Build your first intake or health questionnaire.', whyItMatters: 'Questionnaires are the forms you assign to patients — the foundation of your patient forms and tasks. Creating one gives you a reusable form and the hands-on experience.', action: { kind: 'route', route: '/dashboard/questionnaires', workspace: STAFF_WORKSPACES.PLATFORM_MANAGER }, liveComplete: questionnaireCount > 0, }, { id: 'public-form', title: 'Create an Akashic Public Form', shortLabel: 'Akashic Public Form', actionLabel: 'Create an Akashic Public Form', whatItDoes: 'Publish a questionnaire as an Akashic Public Form — a public, no-login form for your website.', whyItMatters: 'It lets website visitors submit inquiries/intake without an account — your front-door lead capture. An Akashic Public Form is a published questionnaire, so you need a questionnaire first.', dependsOn: ['questionnaire'], action: { kind: 'route', route: '/dashboard/questionnaires', workspace: STAFF_WORKSPACES.PLATFORM_MANAGER }, liveComplete: publicFormCount > 0, }, ); /* * - THE RECEIVING-ORG TRACK IS A FILTER ON THIS LIST, NOT A SECOND CHECKLIST. * * It runs HERE, after every step has been authored and before `order` is derived, so the visible * "Step N of M" is computed over what this org is actually shown. Filtering after numbering would * leave a six-step track labelled "Step 6 of 13". * * AN ALLOW-LIST, AND THE DIRECTION IS THE ARGUMENT. A deny-list would silently ADD every future * onboarding step to a track whose whole purpose is to be short - a step about clinician * seats would appear in front of a referral coordinator with nobody having decided it should. With an * allow-list a new step is absent until somebody names it here, which is a decision rather than a * drift. */ if (operatingScope === 'receiving') { // Reachable, owned, and lawfully able to hold PHI. Everything else is about OPERATING a practice. // // ORDERED, not just selected. The full list is authored in ITS dependency order, where `owner` // and `baa` are pushed before `contact` - so preserving that order would open this track on // 'say who owns this practice' before it has asked who the practice IS. The design names the // sequence: identity, then owner and the BAA they sign, then the coordinator and where // referrals land. // The owner-before-BAA pair is a settled ruling and is preserved inside this order, not despite it. // The office location 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 RECEIVING_TRACK = ['contact', 'owner', 'msa', 'baa', 'location']; const kept = RECEIVING_TRACK .map((id) => steps.find((step) => step.id === id)) .filter((step): step is (typeof steps)[number] => step !== undefined); /* * A KEPT STEP MUST NOT DEPEND ON A REMOVED ONE. `resolveOnboarding` treats an absent dependency * as UNMET - `!dep || dep.status !== 'done'` - so such a step is permanently `locked`, with a lock * hint naming a step that is not on the list. The coordinator would see "Finish 'Add a location' * first" beside a checklist containing no such step, and nothing they could do would clear it. * None of the three depends on anything today; this is here because the filter is what makes it * possible, and the failure is silent. `theReceivingTrackHasNoDanglingDependency` pins it too. */ const keptIds = new Set(kept.map((step) => step.id)); for (const step of kept) { const dangling = (step.dependsOn ?? []).filter((id) => !keptIds.has(id)); if (dangling.length > 0) { console.error( `ORG_ONBOARDING_TRACK_DANGLING_DEPENDENCY step=${step.id} needs=${dangling.join(',')} — ` + 'it is not on the receiving track, so this step would lock permanently. Dropping the ' + 'dependency so the coordinator is not stuck behind a step they cannot see.', ); } } // Typed explicitly: without it the `action` literals widen `kind` to `string` and the array stops // being assignable to the return type. /* * "FORWARD TO MY ADMINISTRATOR" - the card's third named requirement, and the one that decides * whether the most likely arrival can finish at all. * * THE MECHANISM ALREADY EXISTS AND NOBODY WOULD EVER FIND IT. A coordinator who claims a referral * becomes the founding administrator, so they CAN add staff (`POST /R4/Practitioner` creates the * Cognito account) and they CAN nominate an owner. But the owner picker offers EXISTING STAFF * ONLY - *"Choose a member of staff"* - and a coordinator arriving alone has none. The screen they * are sent to is, for them, a list containing only themselves. * * The gap is not machinery, it is that the step never says the person can be ADDED first. So this * is copy, on one step, on this track only. Deliberately NOT a permission change: widening who * may designate an owner to make a button work is the anti-pattern recorded. * * It keeps BOTH doors open rather than assuming which applies. A coordinator genuinely authorised * to sign may name themselves - the self-nomination path already carries the attestation that puts * a false claim on record - and one who is not needs to hand off. The step cannot know which they * are, so it must not choose for them. */ const forwardToAdministrator = (step: (typeof kept)[number]) => (step.id !== 'owner' ? step : { ...step, whatItDoes: 'Name the person who owns this practice — the one who signs your Business Associate Agreement. ' + 'If that is not you, add them under Staff first and then choose them here: the owner can only ' + 'be someone who already has an account at this practice.', whyItMatters: 'Your practice owner must sign the agreement before patient records can arrive. If someone else ' + 'signs for the practice, designate them as owner.', dependencyNote: 'Adding an administrator is free, and they can sign as soon as they accept.', }); const track: Omit[] = [ ...kept.map(forwardToAdministrator).map((step) => ({ ...step, // Dropped rather than kept-and-unmet, for the reason above. A dependency on a step nobody can // see is not a dependency, it is a dead end. dependsOn: (step.dependsOn ?? []).filter((id) => keptIds.has(id)), })), { id: 'referral-coordinator', title: 'Say who works your incoming referrals', shortLabel: 'Referral coordinator', actionLabel: 'Assign a referral coordinator', whatItDoes: 'Give somebody the Referral Coordinator role. They see referrals sent to you, and they are who ' + 'another practice is really reaching when they refer a patient here.', whyItMatters: 'A referral that lands in a queue nobody watches is worse than a fax, because the practice that ' + 'sent it believes it arrived.', // The same fact the receptionist step carries, and it matters more here: a guest organization // pays nothing to RECEIVE. Charging to read a patient's own referred record would let an org // gatekeep that record by declining to buy a seat, which is the thing Akashic exists to prevent. dependencyNote: 'Staff who receive and coordinate referrals are free.', action: { kind: 'route', route: '/dashboard/users' }, liveComplete: Boolean(hasReferralCoordinator), }, { id: 'referral-routing', title: 'Choose where referrals land', shortLabel: 'Referral routing', actionLabel: 'Choose a destination', whatItDoes: 'Pick whether incoming referrals go to your front desk or to the referral workspace, and record ' + 'the fax number and contact details another practice should use.', whyItMatters: 'Until you choose, a practice referring a patient to you has to fall back to a fax and a phone ' + 'call. Choosing is what makes you deliverable in-system.', dependsOn: ['referral-coordinator'], action: { kind: 'route', route: '/dashboard/organization' }, liveComplete: acceptsInSystemReferrals(organization), }, ]; return track.map((step, index) => ({ ...step, order: index + 1 })); } return steps.map((step, index) => ({ ...step, order: index + 1 })); } export function resolveOnboarding( tasks: OrgSystemTaskDefinition[], persisted: OrgOnboardingPersistedStatus, ): ResolvedOnboarding { const byId: Record = {}; const resolved: ResolvedOrgTask[] = []; // Tasks are authored in dependency order, so deps are always resolved first. for (const task of tasks) { const persistedState = persisted[task.id]; let status: OrgSystemTaskStatus; let lockHint: string | undefined; if (task.liveOnly) { // The persisted value is not consulted AT ALL here - not as a `done` and not as a `skipped`. // A live condition that reads its own memory is exactly the false-clean this flag exists to stop. status = task.liveComplete ? 'done' : 'todo'; } else if (task.liveComplete || persistedState === 'done') { status = 'done'; } else if (persistedState === 'skipped' && !task.required) { // A required task can never be skipped - a stale persisted 'skipped' falls through to todo/locked. status = 'skipped'; } else { const unmet = (task.dependsOn ?? []).filter((depId) => { const dep = byId[depId]; return !dep || (dep.status !== 'done' && dep.status !== 'skipped'); }); if (unmet.length > 0) { status = 'locked'; const depTitle = byId[unmet[0]]?.title ?? unmet[0]; lockHint = `Finish “${depTitle}” first`; } else { status = 'todo'; } } const entry: ResolvedOrgTask = { ...task, status, lockHint }; byId[task.id] = entry; resolved.push(entry); } const currentStepIndex = resolved.findIndex((t) => t.status === 'todo'); const completedCount = resolved.filter( (t) => t.status === 'done' || t.status === 'skipped', ).length; return { resolved, currentStepIndex, completedCount, totalCount: resolved.length, allComplete: completedCount === resolved.length, }; }