import type { SaveTransport } from '../utils/saveOperation'; import { useCallback, createContext, useContext, ReactNode } from 'react'; import type { TimeZoneLookupAddress, TimeZoneLookupResult } from '../utils/locationTimeZoneCheck'; import { Bundle, Organization, PractitionerRole, Identifier, Address, ContactPoint, Coding, Extension, Attachment } from 'fhir/r4'; import { environment } from '../environments/environment.ts'; import { authStorage } from './authStorage'; import { ConsentInstrumentId } from '../config/consentInstruments.ts'; import { useFhirServiceContext } from './FhirService.tsx'; import { useHttpService, HttpError } from './HttpService.tsx'; import { ORG_LOGO_EXTENSION_URL } from '../config/organizationExtensions'; import type { ReminderPolicy } from '../config/appointmentReminderPolicy'; import type { AppointmentPolicy } from '../config/appointmentPolicy'; import type { ReferralRoutingPolicy } from '../config/referralRouting'; import type { DiagnosticsDeclaration } from '../utils/organizationDiagnostics'; import type { Part2DeclarationAnswer, Part2Status } from '../utils/part2Declaration'; import type { SmsRegistrationForm, SmsRegistrationPublic } from '../config/smsRegistration'; import type { CampaignSendingAnswer, CampaignSendingCheck, CampaignSendingRequest } from '../config/campaignMailingAddress'; import { fetchAllPagesAsBundle } from '../utils/fetchAllPagesAsBundle'; /** * What a hostname resolves to: presentation, and the id needed to address the API. Never authority. * * `hostname` and `url` come from the server rather than being spelled here - the suffix is a * per-environment parameter, so a client that assembles the address breaks in the first environment * where it differs. `branding` is an allow-list the server assembles field by field. */ export interface PracticeHostname { label: string; hostname: string; url: string; organizationId: string; name?: string; branding?: Extension[]; } // The address this practice holds, or the plain fact that it holds none. export interface MyPracticeHostname { claimed: boolean; label?: string; hostname?: string; url?: string; } const API_URL = environment.fhirBaseApi; /** The server's read-only answer about the practice's own processor account; "unknown" claims neither way. */ export type PaymentReadiness = | { state: 'not-configured' } | { state: 'ready' } | { state: 'unable'; reason: 'credentials-rejected' | 'charges-disabled' | 'wrong-mode' } | { state: 'unknown' }; const LOCAL_STORAGE_PREFIX = environment.localStoragePrefix; // Booking policy extensions and their reader live in config/bookingPolicy; re-exported for existing callers. export * from '../config/bookingPolicy'; import { getOrganizationPolicyFlags, ORGANIZATION_BOOKING_MODE_EXTENSION_URL, ORGANIZATION_DEFAULT_APPOINTMENT_DURATION_EXTENSION_URL, RETIRED_ORGANIZATION_NEW_PATIENT_EXAM_ROLES_EXTENSION_URL, ORGANIZATION_REQUEST_STYLE_EXTENSION_URL, ORGANIZATION_REQUIRE_NEW_PATIENT_EXAM_FIRST_EXTENSION_URL } from '../config/bookingPolicy'; /** * SOMEBODY ELSE SAVED WHILE THIS PAGE WAS OPEN -. * * Its own class rather than a generic failure, because the two need opposite treatment: an ordinary * error means *"try again"*, and this one means *"do NOT try again with what you have - reload first."* * Retrying a stale save is the defect. */ export class StaleOrganizationError extends Error { constructor(message: string) { super(message); this.name = 'StaleOrganizationError'; } } const STALE_ORGANIZATION_FALLBACK = 'Someone else changed your organization settings while this page was open. Reload the page and make your change again — nothing was saved.'; export const useOrganizationService = () => { const { get, post, put } = useHttpService(); const { transformToAddress, transformToTelecom, transformToIdentifier, transformToCodeableConcept, transformFromIdentifier, transformFromAddress, transformFromTelecom, transformFromCodeableConcept } = useFhirServiceContext(); const npiSystem = 'http://hl7.org/fhir/sid/us-npi'; const cliaSystem = 'urn:oid:2.16.840.1.113883.4.7'; const naicSystem = 'urn:oid:2.16.840.1.113883.6.300'; const TAG_BASE_URL = 'https://akashichealthrecords.com/fhir/tags'; const extensionUrls = { // tzCode: 'http://hl7.org/fhir/StructureDefinition/tz-code' tzCode: 'https://akashichealthrecords.com/fhir/extensions/tz-code', photo: ORG_LOGO_EXTENSION_URL }; /** * WHICH PRACTICE THIS HOSTNAME BELONGS TO - the hosted target's answer to `wpInstallationId`. * * PUBLIC, AND CALLED BEFORE ANYBODY SIGNS IN. The caller is a browser that has just arrived at an * address with no session, and the sign-in screen it is about to draw carries the practice's name and * logo - so there is no token to offer and none is sent. * * NOT `fetchOrganization`. That route requires a `wpInstallationId` in the query string and then * requires the record's own tag to equal it, so it refuses a hosted caller twice over: there is no * installation id to send, and a practice onboarded outside WordPress carries no installation tag to * match. This is the read that replaces it. * * The response grants nothing. It carries `organizationId` because a client cannot address the API * without one - exactly as the WordPress mount already knows the org it is mounted for - and authority * comes from the token at every route that needs it. Nothing here may be used to decide access, and * nothing here decides which experience renders: that is the path's job. * * A hostname nobody holds answers 404, which is a real answer and must stay distinguishable from a * server we could not reach - `classifyOrgFetchFailure` is what tells them apart, and the two have * opposite consequences for the session. */ const resolvePracticeHostname = useCallback(async (label: string): Promise => { // The live stack in every mode: the address names the live practice, and the sandbox holds none. return get( `${environment.liveFhirBaseApi}/practice-hostname/${encodeURIComponent(label)}`, { bypassAuth: true, bypassOrganization: true } ); }, [get]); /** * THE PRACTICE CLAIMS ITS WEB ADDRESS. * * Every organization gets a portal at its own subdomain whichever door it onboarded through * (2026-09-05). Signup collects one for a practice founding itself; a practice that came in through * its WordPress site had no way to claim one at all until this. * * NOT bypassAuth, unlike the resolve above: claiming is organization:manage and the server checks it. */ const claimPracticeHostname = useCallback( async (label: string, transport?: SaveTransport): Promise => { return post(`${API_URL}/practice-hostname`, { label }, { ...transport }); }, [post], ); /** * WHAT ADDRESS DOES THIS PRACTICE ALREADY HOLD? * * A read, not a memory of the last write: the page asks the server each time it mounts, so a * reload, a second administrator or a claim made elsewhere all show the same answer. * * Holding none is an ordinary state and answers claimed: false, never a refusal. */ const fetchMyPracticeHostname = useCallback(async (transport?: SaveTransport): Promise => { return get(`${API_URL}/practice-hostname-mine`, { ...transport }); }, [get]); const fetchOrganizationHistory = useCallback(async (id: string): Promise> => { return get>(`${API_URL}/Organization/${id}/_history`); }, [get]); const fetchOrganization = useCallback(async (id: string, wpInstallationId: string): Promise => { const params = new URLSearchParams(); params.append('wpInstallationId', wpInstallationId); return get(`${API_URL}/Organization/${id}?${params.toString()}`, { bypassAuth: true, bypassOrganization: true }); }, [get]); /** * THE SAME READ, MADE AS OURSELVES -, and the difference is the whole record. * * `fetchOrganization` above is `bypassAuth`, which sends **no Authorization header**, so the API * answers it as an anonymous caller and returns `projectPublicOrganization(...)`: name, address, * telecom, the logo, the portal URL, the installation tag - and **nothing else**. No `meta.versionId`, * no `stripeCustomerId` tag, no subscription, no consent settings, no appointment policies, no role * permissions. That projection is correct and deliberate it is what an anonymous visitor may see. * * THE DEFECT WAS THAT SIGNED-IN STAFF HELD IT TOO. The bootstrap runs before anyone signs in, so * `wpOrganization` kept the projected copy for the whole session - and **every screen that saves the * organization builds its PUT from `wpOrganization`**. An admin's PUT is taken literally by the server * (`preserveSecurityTags`/`preserveSubscriptionExtensions` defend the billing anchors and nothing * else), so saving anything at all would have written a document missing the practice's entire * configuration `handlers/Organization/__tests__/anAdminPutIsTakenLiterally.test.ts` proves exactly * what survives and what does not. * * Once there is a token the server returns the record whole, so this is the same URL and the same * guard - only the credential changes. */ // `transport`: the save this read belongs to, so it is not sent once that save has moved on. const fetchOrganizationAsCaller = useCallback(async (id: string, wpInstallationId?: string, organizationId?: string, transport?: SaveTransport): Promise => { const params = new URLSearchParams(); // A hosted practice has no WordPress site and no installation id to send. The server answers a // caller their OWN organization without one; it still demands the tag for anybody else. if (wpInstallationId) params.append('wpInstallationId', wpInstallationId); return get(`${API_URL}/Organization/${id}?${params.toString()}`, { organizationId, ...transport }); }, [get]); /** * - the practices this patient is a patient of, resolved SERVER-SIDE. * * IT REPLACES N CALLS TO `fetchOrganization`, AND THAT IS NOT AN OPTIMISATION. Those calls passed * the installation id of the site the patient is STANDING ON for every organization in their record, * and the Organization route's installation guard is unconditional - so a practice on a different * WordPress site answered 400. A genuinely different practice has its own installation id by * definition, so a patient of two practices could never see the second one. The failure was silent: * a per-org failure degraded to a stub card, so they saw a nameless tile rather than an error. * * NO `wpInstallationId`, and its absence IS the fix - the client no longer needs to know one. The * server derives the whole list from the caller's own record, so there is no id in the request to be * wrong about the authorization is structural rather than checked. * * `orgAuthorizationApi`, NOT `fhirBaseApi` where the rest of Organization lives. Which gateway a * route answers on is decided by which SAM template declares the Lambda, and this one is on * 04-org-authorization because 02-fhir-base is the stack that hit the 500-resource cap. Getting this * wrong 404s silently. `Organization/consent-settings` below * already goes to the same host. */ const fetchMyOrganizations = useCallback(async (organizationId?: string): Promise => { // SEARCH-BOUNDED: `/my-organizations` is a purpose-built endpoint, not a searchset. Checked in // the api handler (`MyOrganizations/get.ts`): it derives the caller's affiliations and returns // them whole - it emits no `next` link and accepts no `_count`, so there is no page to follow. // If that handler ever starts paging, this is one of the reads that must change with it. // // NO `bypassOrganization` HERE, AND THE ABSENCE IS LOAD-BEARING - this call carried it and the // page was dead. `X-Organization-ID` and `wpInstallationId` look like the same idea and // are not: the query parameter NAMES A PRACTICE TO LOOK UP, and dropping it is this route's whole // point; the header is the caller's AUTH CONTEXT, and it is the only thing that makes them a // `Patients`. The authorizer resolves a patient's role only through a branch requiring an // organization id (`authorizer.ts`, `organizationId && decodedToken.patient_id`); with no header // it falls to the org-less collaborator branch, which returns `[]` for a patient. The route then // denies its own designed caller - observed 2026-08-18, `userRoles: []` against // `requiredAuthorities: ['Patients','RelatedPersons']`. // It cannot narrow the result: `MyOrganizations/get.ts` never reads an organization id. It // resolves the subject from the caller's own record and derives the list from `generalPractitioner`. // Same defect, second time - the sign-out beacon was 403'd silently for the same reason on // 2026-07-31 (see `AuthService.logout`). Pinned by `authenticatedCallsKeepTheOrgHeader.test.ts`. // `organizationId` re-reads one practice; the server still answers only from the caller's own list. const only = organizationId ? `?organization=${encodeURIComponent(organizationId)}` : ''; // SEARCH-BOUNDED: the same whole-list route as the reason above; no `next` link to follow. const bundle = await get>( `${environment.orgAuthorizationApi}/my-organizations${only}`, ); return (bundle?.entry ?? []) .map((entry) => entry?.resource) .filter((resource): resource is Organization => Boolean(resource)); }, [get]); // Practices the patient is affiliated with but has not answered the agreements of. Same route and // the same no-header rule as above; the server derives them from the caller's own record and marks // each one pending, so this list never doubles as the authorized-care list. const fetchPendingPractices = useCallback(async (): Promise => { // SEARCH-BOUNDED: `/my-organizations` returns the caller's practices whole, pending ones included. const bundle = await get>( `${environment.orgAuthorizationApi}/my-organizations?relationship=pending`, ); return (bundle?.entry ?? []) .map((entry) => entry?.resource) .filter((resource): resource is Organization => Boolean(resource)); }, [get]); // Every practice that could hold a bill for the subject, whatever the patient has agreed to: the server lists the // practices it has affiliated the subject's own record with, which is what a bill is issued against. Same route // and the same no-header rule as above; the server derives the subject and never reads a practice from us. const fetchBillingPractices = useCallback(async (request: { signal?: AbortSignal; admit?: () => void } = {}): Promise => { // SEARCH-BOUNDED: `relationship=billing` answers with every practice the subject's own record names, built // whole by the route from those references and sent as one Bundle with no continuation. const bundle = await get>( `${environment.orgAuthorizationApi}/my-organizations?relationship=billing`, request, ); return (bundle?.entry ?? []) .map((entry) => entry?.resource) .filter((resource): resource is Organization => Boolean(resource)); }, [get]); const setOrganization = useCallback((organization: Organization) => { authStorage.setItem(`${LOCAL_STORAGE_PREFIX}_organizationId`, organization.id as string); }, []); const getOrganization = useCallback((): string | null => { return authStorage.getItem(`${LOCAL_STORAGE_PREFIX}_organizationId`); }, []); const fetchWordPressOrganization = useCallback(async (wpInstallationId: string): Promise> => { const params = new URLSearchParams(); params.append('wpInstallationId', wpInstallationId); // SEARCH-BOUNDED: one WordPress installation maps to at most one Organization - this is an // identity lookup keyed on a unique installation id, not a list. return get>(`${API_URL}/Organization?${params.toString()}`, { bypassAuth: true, bypassOrganization: true }); }, [get]); const createWordPressOrganization = useCallback(async ( organization: Organization, wpInstallationId: string, subdomain?: string ): Promise<{ organization: Organization; setupToken: string }> => { const params = new URLSearchParams(); params.append('wpInstallationId', wpInstallationId); // The server claims the address without storing it in the FHIR body. if (subdomain?.trim()) params.append('subdomain', subdomain.trim()); return post<{ organization: Organization; setupToken: string }>( `${API_URL}/Organization?${params.toString()}`, organization, { bypassAuth: true, bypassOrganization: true } ); }, []); const fetchOrganizationUserCount = useCallback(async (organization: Organization): Promise<{ total: number; }> => { const params = new URLSearchParams(); params.append('organizationId', organization.id as string); return get<{ total: number; }>(`${API_URL}/Organization/UserCount?${params.toString()}`, { bypassAuth: true }); }, [get]); const createOrganizationAdmin = useCallback(( data: { email: string; password: string; firstname: string; lastname: string }, organization: Organization, setupToken: string ) => { const payload = { user: data, organization, setupToken }; return post(`${API_URL}/Organization/Signup`, payload, { bypassAuth: true }); }, [post]); /** * EVERY ORGANIZATION SAVE IDENTIFIES THE VERSION IT IS WRITING OVER -, and this ONE function is * why that took one change rather than ten. * * The Organization record carries the practice's role permissions, consent settings, appointment policies, * tax and invoice terms, reminder settings, campaign identity, branding and portal URL - **nine or ten * separate screens write it**, and a FHIR PUT replaces the whole thing. Two people saving within seconds * meant the last writer won the WHOLE document, silently, with both saves reporting success. * * **Every one of those screens goes through here**, so the header goes on in a single place and no screen * had to be taught anything. * * AND THE 412 IS TRANSLATED, NOT PASSED THROUGH. The generic handler would surface it as *"Request * failed: Someone else changed..."*, and that prefix is noise on a sentence written for a receptionist. Every * screen already renders `err.message`, so a clean sentence reaches all of them for free. * * IT DOES NOT RETRY, DELIBERATELY. Re-applying the user's edit onto a record somebody else just changed * is how you overwrite them a second time, quietly - which is the defect wearing a different hat. Telling * the person plainly is the correct outcome. */ // `transport`: the save this belongs to (a location save's companion write, say) - admitted and cancelled with it. const updateOrganization = useCallback(async (organization: Organization, transport?: SaveTransport): Promise => { const version = organization.meta?.versionId; try { return await put(`${API_URL}/Organization/${organization.id}`, organization, { // Omitted when the record carries no version - a header of `W/""` is a claim about // nothing, and the server would have to decide what that means. Absent says it honestly. ...(version ? { headers: { 'If-Match': `W/"${version}"` } } : {}), ...transport, }); } catch (error) { if (error instanceof HttpError && error.status === 412) { const body = error.body as { message?: string } | undefined; throw new StaleOrganizationError(body?.message ?? STALE_ORGANIZATION_FALLBACK); } throw error; } }, [put]); // Dedicated PM-writable endpoint (Anonymous Forms Plan 3d, Task 2/7) - bypasses the generic // Organization PUT's ADMINS/organization:manage gate. Server applies a system-token fetch+PUT that // touches ONLY the notifications extension, so Platform Managers can save without an org-write grant. const updateFormSubmissionNotifications = useCallback( async (settings: { enabled: boolean; recipients: string[] }, transport?: SaveTransport) => put<{ organization: Organization; settings: { enabled: boolean; recipients: string[] } }>( `${API_URL}/form-submission-notification-settings`, settings, { ...transport }), [put], ); // Dedicated PM-writable endpoint ( item 32 - the patient portal's URL). Third of this // shape, and the reason is the one the two neighbours record: the portal-pages screen is already // gated to the PM workspace, but writing the URL went through the generic Organization PUT above, // which is ADMINS/organization:manage. A PM was denied. // THE DENY FIRED ON LOAD, not only on save - the settings screen self-heals a mismatched URL in // an effect, so a PM opening a screen they are entitled to got a silent 403 into console.error. // Both portal-link writes take `expectedUrl` and `expectedRevision`: the link, and its revision, the change was decided // from; the server writes only over that revision. const updatePatientPortalUrl = useCallback( async (url: string, transport?: SaveTransport, expectedUrl?: string, expectedRevision?: number) => put<{ organization: Organization; url: string }>(`${API_URL}/patient-portal-url`, { url, ...(expectedUrl !== undefined ? { expectedUrl } : {}), ...(expectedRevision !== undefined ? { expectedRevision } : {}) }, { ...transport }), [put], ); // The STAFF portal page's URL - the second permalink the Portal Pages screen already resolves and // never sent. Dedicated endpoint for the same reason as its patient sibling directly above, not for // symmetry: the generic Organization PUT is ADMINS/organization:manage, this screen is PM-gated, and // the denial would fire on LOAD via the self-heal effect where nobody would see it. const updateStaffPortalUrl = useCallback( async (url: string, transport?: SaveTransport, expectedUrl?: string, expectedRevision?: number) => put<{ organization: Organization; url: string }>(`${API_URL}/staff-portal-url`, { url, ...(expectedUrl !== undefined ? { expectedUrl } : {}), ...(expectedRevision !== undefined ? { expectedRevision } : {}) }, { ...transport }), [put], ); // `updateTurnstileSettings` WAS HERE - a PM-writable endpoint for the org's own Cloudflare Turnstile // keys. Spencer, 2026-08-20: *"Turnstile is now an internal Akashic thing, it's no longer something // the orgs have to setup since the public forms are rendered in an iframe."* The widget's hostname is // ours once the form is framed, so there is one platform key and nothing per-org to write. // Removed rather than left unused: a dead writer into a per-org CREDENTIAL store is the kind of thing // that gets re-wired by someone who assumes it is still the supported path. The server route outlives // it for now - retiring that is the other half, on the platform-Turnstile card. // Dedicated PM-writable endpoint (org-wide sales-tax rate, Invoicing Phase C Slice 2) - mirrors // updateFormSubmissionNotifications. Bypasses the generic Organization // PUT's organization:manage gate so Platform Managers can save without an org-write grant. // S1 - the org's appointment-reminder policy. Same PM-writable shape as // updateFormSubmissionNotifications above, and on fhirBaseApi because the route lives in // 02-fhir-base.yaml. Pointing this at apiUrl would 404 silently. const updateAppointmentReminderSettings = useCallback( async (settings: ReminderPolicy, transport?: SaveTransport) => put<{ organization: Organization; settings: ReminderPolicy }>( `${API_URL}/appointment-reminder-settings`, settings, { ...transport }), [put], ); // S3 - the org's appointment policy (today: the telehealth toggle). Same PM-writable shape // and the same fhirBaseApi reasoning as updateAppointmentReminderSettings directly above; its route // sits beside that one in routes.ts, so pointing this at apiUrl would 404 silently. const updateAppointmentPolicySettings = useCallback( async (settings: AppointmentPolicy, transport?: SaveTransport) => put<{ organization: Organization; settings: AppointmentPolicy }>( `${API_URL}/appointment-policy-settings`, settings, { ...transport }), [put], ); // Whether the practice runs a laboratory or a machine, which is what its Diagnostics settings // area has to know before it can offer anything. Admin-only rather than PM-writable, unlike the // two above: this decides whether a clinical department exists, not how one is configured. // // On fhirBaseApi for the same reason as its neighbours - the route sits with them in // 02-fhir-base.yaml, and pointing it at the clinical gateway would 404 silently. const updateDiagnosticsSettings = useCallback( async (settings: DiagnosticsDeclaration, transport?: SaveTransport) => put(`${API_URL}/diagnostics-settings`, settings, { ...transport }), [put], ); // The practice administrator's Part 2 program declaration. Its own audited route (04-org-authorization.yaml, so on // orgAuthorizationApi), the only writer of the declaration; the server binds it to the caller's practice. const declarePart2Program = useCallback( async (answer: Part2DeclarationAnswer, transport?: SaveTransport) => put<{ status: Part2Status; coversAllPrograms: boolean; version: string }>( `${environment.orgAuthorizationApi}/Organization/part2-attestation-settings`, answer, { ...transport }), [put], ); // The practice chooses to run its practice here, which returns the full setup checklist. const updateOperatingScope = useCallback( async (scope: 'full' | 'receiving', transport?: SaveTransport) => put<{ scope: 'full' | 'receiving'; organization?: Organization }>(`${API_URL}/operating-scope-settings`, { scope }, { ...transport }), [put], ); /** * The practice records or corrects its NPI. * * ADMIN-ONLY, unlike most of its neighbours here, and the difference is deliberate rather than an * oversight: those admit a Platform Manager because configuring a practice's integrations is the * PM's job, and an NPI is not an integration - it is who the practice legally is, and the key * another practice's referral matches on. Its route sits beside appointment-policy-settings in * routes.ts, which drops PM for the same kind of reason. * * `organization` IS OPTIONAL IN THE RESPONSE AND CALLERS MUST TREAT IT SO. Re-saving the number * already on file is answered idempotently - `{ settings, unchanged: true }` with no organization, * because nothing was written. A caller that pushes that straight into auth blanks the org for * every screen. * * On fhirBaseApi for the same reason as its neighbours: the route is declared in 02-fhir-base.yaml, * so apiUrl would 404 silently. */ const updateOrganizationNpi = useCallback( // `change`: the change this save is an attempt of, and which attempt; the server runs them in order. async (npi: string, transport?: SaveTransport, change?: { operationId: string; attempt: number }) => put<{ organization?: Organization; settings: { npi: string }; unchanged?: boolean }>( `${API_URL}/organization-npi`, { npi, ...change }, { ...transport }), [put], ); // A location's time zone from its address (Spencer, 2026-09-23). Only looks it up; the caller // shows the answer for confirmation and saves the Location. const lookupLocationTimeZone = useCallback( async (address: TimeZoneLookupAddress) => post(`${API_URL}/organization-time-zone-lookup`, address), [post], ); // - where this practice's incoming referrals land. Same top-level, system-token shape as the // settings siblings above, and on fhirBaseApi for the same reason: the route is declared in // 02-fhir-base.yaml, so apiUrl would 404 silently. // // `null` is how a practice WITHDRAWS, and it must be sent explicitly. The server refuses an absent // `destination` rather than reading it as a clear, because withdrawing stops every sending practice // from delivering here and a request that merely forgot the field must not do that silently. const updateReferralRoutingSettings = useCallback( async (settings: ReferralRoutingPolicy, transport?: SaveTransport) => put<{ organization: Organization; settings: ReferralRoutingPolicy }>( `${API_URL}/referral-routing-settings`, { destination: settings.destination ?? null }, { ...transport }, ), [put], ); // - the OPTIONAL campaign mailing-address override (CAN-SPAM). Same top-level, // system-token shape as the settings siblings above, and on fhirBaseApi for the same reason: // the route is declared in 02-fhir-base.yaml, so apiUrl would 404 silently. // // UNLIKE ITS NEIGHBOURS THIS ONE IS ADMINS-ONLY, not PM-writable - a practice's legal sender // identity is organization:manage territory. A PM will get a 403 here by design. // // The server returns the EFFECTIVE address it resolved. Render that rather than re-deriving // which address will be used: two implementations of "what will actually be mailed from" is // exactly the drift exists to prevent, and the client copy is the one nobody updates. const updateCampaignMailingAddress = useCallback( async (settings: { address: Address | null }, transport: SaveTransport & { organizationId: string; versionId: string }) => { if (!transport.organizationId || !transport.versionId) throw new Error('Read the current practice before saving its mailing address.'); return put<{ organization: Organization; settings: { effectiveAddress: Address | null; source: 'override' | 'primary' | 'none' }; }>(`${API_URL}/campaign-mailing-address-settings`, settings, { admit: transport.admit, signal: transport.signal, onSend: transport.onSend, organizationId: transport.organizationId, headers: { 'If-Match': `W/"${transport.versionId}"` }, }); }, [put], ); // phase 2 - the practice's own sending domain. ADMINS-only server-side (a practice's // identity to the outside world is organization:manage, not a PM's), and on fhirBaseApi because // the routes live in 02-fhir-base.yaml - apiUrl would 404 silently. // // The server returns the DNS records to publish. Render those; never re-derive them here. A // wrong host does not error anywhere - it just means the domain never verifies while the // practice has followed our instructions exactly. // // Every action names what it acts on and the revision it read; the answer is always the stored state. // The read lives on the org-authorization gateway (the base stack is at its ceiling); its PUT and check stay here. const getCampaignSendingIdentity = useCallback( async (transport?: SaveTransport) => get(`${environment.orgAuthorizationApi}/campaign-sending-identity`, { ...transport }), [get], ); const updateCampaignSendingIdentity = useCallback( async (request: CampaignSendingRequest, transport?: SaveTransport) => put(`${API_URL}/campaign-sending-identity`, request, { ...transport }), [put], ); // Checks the selection or continues a setup. The server only writes when something actually // changed, so checking does not churn the Organization's version history. const checkCampaignSendingIdentity = useCallback( async (target: CampaignSendingCheck, transport?: SaveTransport) => post(`${API_URL}/campaign-sending-identity/check`, target, { ...transport }), [post], ); const getSmsRegistration = useCallback( async (transport?: SaveTransport) => get(`${API_URL}/sms-registration`, { ...transport }), [get], ); const saveSmsRegistration = useCallback( async (form: SmsRegistrationForm, transport?: SaveTransport) => put(`${API_URL}/sms-registration`, form, { ...transport }), [put], ); const submitSmsRegistration = useCallback( async (form: SmsRegistrationForm, transport?: SaveTransport) => post(`${API_URL}/sms-registration/submit`, form, { ...transport }), [post], ); const checkSmsRegistration = useCallback( async (transport?: SaveTransport) => post(`${API_URL}/sms-registration/check`, {}, { ...transport }), [post], ); const updateTaxSettings = useCallback( async (settings: { ratePercent: number }, transport?: SaveTransport) => put<{ organization: Organization; settings: { ratePercent: number } }>( `${API_URL}/organization-tax-settings`, settings, { ...transport }), [put], ); // Dedicated billing:manage-writable endpoint (org default invoice payment terms, Billing v2-2) - // mirrors updateTaxSettings. Bypasses the generic Organization PUT's organization:manage gate so // billing:manage staff can set the org's default net-days without an org-write grant. Task 5a/5b // extended the same endpoint to optionally also carry `allowPartialPayments` - the org's house // default for whether staff can allow partial payment on an invoice. It's OPTIONAL on the request: // omit it to leave the org's current value unchanged (the InvoiceTermsSettingsSection save does // exactly that), or pass it to set it (AllowPartialPaymentsSettingsSection's save). const updateInvoiceTermsSettings = useCallback( async (settings: { netDays: number; allowPartialPayments?: boolean }, transport?: SaveTransport) => put<{ organization: Organization; settings: { netDays: number; allowPartialPayments: boolean }; }>(`${API_URL}/organization-invoice-terms`, settings, { ...transport }), [put], ); // Dedicated PM-writable endpoint (per-org BYO payment processor, Invoicing Phase 3 Slice A) - // mirrors updateTaxSettings. The secretKey + webhookSecret are write-only: // the response NEVER echoes them, returning only the public { gatewayId, publishableKey, // secretConfigured }. An all-blank body is the endpoint's "disconnect" (clears both halves). const updatePaymentSettings = useCallback( async (settings: { gatewayId: string; publishableKey: string; secretKey: string; webhookSecret: string; /** The change this is an attempt of, and which attempt: the server runs attempts of one change in order. */ operationId?: string; attempt?: number; }, transport?: SaveTransport) => put<{ organization: Organization; settings: { gatewayId: string; publishableKey: string; secretConfigured: boolean }; }>(`${API_URL}/payment-settings`, settings, { ...transport }), [put], ); // Whether the practice's own processor can take online payments now. Served by the org-authorization API. const readPaymentReadiness = useCallback( async (transport?: SaveTransport) => get(`${environment.orgAuthorizationApi}/payment-settings/readiness`, { ...transport }), [get], ); /** * - the practice's OWN fax vendor account. * * THE PRACTICE HOLDS THE ACCOUNT AND THE BAA WITH ITS VENDOR, which is why Akashic needs no * fax-vendor BAA at all. Same arrangement as the payment gateway above, and the same write-only * shape: the API key and secret are NEVER echoed back - only which vendor is chosen, the * from-number, and whether credentials exist. * * An empty `vendorId` is the disconnect, exactly as an all-blank body is for payments. * On a vendor CHANGE the server demands a fresh key: the stored one belongs to the old vendor, * and sending would hand one vendor's credential to another vendor's endpoint. */ const updateFaxSettings = useCallback( async (settings: { vendorId: string; fromNumber?: string; apiKey?: string; apiSecret?: string; /** Sinch only - every call is scoped to a project, so without it there is no endpoint to call. */ projectId?: string; /** Sinch only - switch on receiving on this service and number, once the account is saved. */ receive?: { serviceId: string; faxNumber: string }; }, transport?: SaveTransport) => put<{ settings: { vendorId: string; fromNumber?: string; credentialsConfigured: boolean } | null; /** Present only on a save with `receive`: how receiving stands, in the server's own words. */ receiving?: | { status: 'active'; faxNumber: string } | { status: 'pending' | 'refused' | 'failed'; message: string }; }>( `${API_URL}/fax-settings`, settings, { ...transport }, ), [put], ); /** * The saved Sinch account's fax services, and whether each one's incoming webhook is free, already this * practice's, or taken by something else. Uses the stored keys; nothing is saved by asking. */ const listSinchFaxServices = useCallback( async (transport?: SaveTransport) => put<{ services: SinchFaxService[] }>(`${API_URL}/fax-settings`, { vendorId: 'sinch', step: 'services' }, { ...transport }), [put], ); /** * Where the practice's Sinch receiving stands for one service and number, read under its fax lock. Nothing is saved * by asking; a setup in progress answers 409 and a status that cannot be established answers 503. */ const readSinchReceiving = useCallback( async (selection: { serviceId: string; faxNumber: string }, transport?: SaveTransport) => put<{ receiving: SinchReceivingStatus }>(`${API_URL}/fax-settings`, { vendorId: 'sinch', step: 'receiving', ...selection }, { ...transport }), [put], ); // Clinical-authorization gate (Phase 2), client Task 13 - Admin-only partial-update endpoint for // the org's consent settings. Route is `/Organization/consent-settings` (nested under Organization, // unlike the top-level turnstile-settings/organization-tax-settings/payment-settings siblings), // on the orgAuthorizationApi gateway ( T7 relocated it there with the rest of the consent // family, unlike the turnstile/tax/payment siblings which stay on fhirBaseApi). PARTIAL UPDATE by design (server handler comment, // aws/sam/services/api/src/handlers/Organization/ConsentSettings/put.ts): send ONLY the ONE field // being changed, never both together - `requireSignedAuthForm` toggles the org's signed-form // requirement; `agreementText` non-empty saves a NEW immutable version, empty/whitespace reverts // the org to the platform default (version 0). Mirrors updateTaxSettings/updatePaymentSettings' // `{organization, settings}` response shape exactly. // `instrument` selects WHICH of the three agreements the fields apply to, so a practice // rewriting its Consent to Treat does not touch its HIPAA Authorization. The two fields still go in // separate requests by server design - the toggle and the text are never combined. const updateConsentSettings = useCallback( async (settings: ( | { requireSignedAuthForm: boolean } | { agreementText: string } ) & { instrument?: ConsentInstrumentId }, transport?: SaveTransport) => put<{ organization: Organization; settings: { requireSignedAuthForm: boolean } }>( `${environment.orgAuthorizationApi}/Organization/consent-settings`, settings, { ...transport }), [put], ); const fetchPractitionerRolesForOrganization = useCallback(async (organization: Organization, transport?: SaveTransport): Promise> => { return fetchAllPagesAsBundle((pageParam) => { const params = new URLSearchParams(); params.append('organization', `Organization/${organization.id}`); if (pageParam) params.set('page', pageParam); return get>(`${environment.fhirIdentityApi}/PractitionerRole?${params.toString()}`, { ...transport }); }); }, [get]); const transformToFhirOrganization = useCallback((item: any, photo?: Attachment | undefined): Organization => { const { extension, name, alias, active, identifier, npi, clia, naic, address, telecom, type, isPartOf, partOf, timezone, meta } = item; const extensions: Extension[] = ((extension ?? []) as Extension[]).filter( (itemExtension) => itemExtension.url !== extensionUrls.tzCode && itemExtension.url !== extensionUrls.photo && itemExtension.url !== ORGANIZATION_REQUIRE_NEW_PATIENT_EXAM_FIRST_EXTENSION_URL && itemExtension.url !== ORGANIZATION_BOOKING_MODE_EXTENSION_URL && itemExtension.url !== ORGANIZATION_REQUEST_STYLE_EXTENSION_URL && itemExtension.url !== RETIRED_ORGANIZATION_NEW_PATIENT_EXAM_ROLES_EXTENSION_URL, ); const usCoreOrganizationProfile: string = 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-organization'; const transformedAddress: Address[] = transformToAddress(address, 'Organization'); const transformedTelecom: ContactPoint[] = transformToTelecom(telecom, 'Organization'); const transformedIdentifier: Identifier[] = [ ...transformToIdentifier(identifier), ...transformToIdentifier(npi, npiSystem), ...transformToIdentifier(clia, cliaSystem), ...transformToIdentifier(naic, naicSystem) ]; if (timezone) { extensions.push({ url: extensionUrls.tzCode, valueCode: timezone }); } if (photo) { extensions.push({ url: extensionUrls.photo, valueAttachment: photo }); } if (item.requireNewPatientExamFirst) { extensions.push({ url: ORGANIZATION_REQUIRE_NEW_PATIENT_EXAM_FIRST_EXTENSION_URL, valueBoolean: true, }); } if (item.bookingMode === 'request-only' || item.bookingMode === 'review') { extensions.push({ url: ORGANIZATION_BOOKING_MODE_EXTENSION_URL, valueCode: item.bookingMode, }); } if (item.requestStyle === 'time-window') { extensions.push({ url: ORGANIZATION_REQUEST_STYLE_EXTENSION_URL, valueCode: 'time-window', }); } if ( typeof item.defaultAppointmentDurationMinutes === 'number' && item.defaultAppointmentDurationMinutes > 0 ) { extensions.push({ url: ORGANIZATION_DEFAULT_APPOINTMENT_DURATION_EXTENSION_URL, valueInteger: item.defaultAppointmentDurationMinutes, }); } return { resourceType: 'Organization', name: name, active: active, meta: { ...meta, profile: [usCoreOrganizationProfile] }, ...(extensions.length !== 0 && { extension: extensions }), ...(alias && alias.length !== 0 && alias.every((a: string) => a !== '') && { alias: alias }), ...(type && type.length !== 0 && { type: transformToCodeableConcept(type) }), ...(isPartOf && partOf && { partOf: partOf }), ...(transformedIdentifier && transformedIdentifier.length !== 0 && { identifier: transformedIdentifier }), ...(transformedAddress && transformedAddress.length !== 0 && { address: transformedAddress }), ...(transformedTelecom && transformedTelecom.length !== 0 && { telecom: transformedTelecom }) }; }, [transformToAddress, transformToTelecom, transformToIdentifier, transformToCodeableConcept]); const transformFromFhirOrganization = useCallback((organization: Organization): { photo: Attachment | undefined; formData: any } => { const TAG_BASE_URL = 'https://akashichealthrecords.com/fhir/tags'; const npiIdentifiers = (organization.identifier ?? []).filter((id: Identifier) => id.system === npiSystem); const cliaIdentifiers = (organization.identifier ?? []).filter((id: Identifier) => id.system === cliaSystem); const naicIdentifiers = (organization.identifier ?? []).filter((id: Identifier) => id.system === naicSystem); const identifiers = (organization.identifier ?? []).filter((id: Identifier) => id.system !== npiSystem && id.system !== cliaSystem && id.system !== naicSystem); const stripeTags = (organization.meta?.tag ?? []).filter((tag: Coding) => tag.system === `${TAG_BASE_URL}/stripeSubscriptionId` || tag.system === `${TAG_BASE_URL}/stripeCustomerId` || tag.system === `${TAG_BASE_URL}/stripeSubscriptionStatus` ); const extensions: Extension[] = organization.extension ?? []; const tzCodeExtension = extensions.find((ext: Extension) => ext.url === extensionUrls.tzCode); const photoExtension = extensions.find((ext: Extension) => ext.url === extensionUrls.photo); const subscriptionExtension = extensions.find(ext => ext.url === 'https://akashichealthrecords.com/fhir/extensions/subscription'); const stripeData = subscriptionExtension ? { subscriptionId: subscriptionExtension.extension?.find(ext => ext.url === 'subscriptionId')?.valueString, status: subscriptionExtension.extension?.find(ext => ext.url === 'status')?.valueString, currentPeriodStart: subscriptionExtension.extension?.find(ext => ext.url === 'currentPeriodStart')?.valueDateTime, currentPeriodEnd: subscriptionExtension.extension?.find(ext => ext.url === 'currentPeriodEnd')?.valueDateTime, createdAt: subscriptionExtension.extension?.find(ext => ext.url === 'createdAt')?.valueDateTime, cancelAtPeriodEnd: subscriptionExtension.extension?.find(ext => ext.url === 'cancelAtPeriodEnd')?.valueBoolean, canceledAt: subscriptionExtension.extension?.find(ext => ext.url === 'canceledAt')?.valueDateTime, cancellationReason: subscriptionExtension.extension?.find(ext => ext.url === 'cancellationReason')?.valueString, plan: { id: subscriptionExtension.extension?.find(ext => ext.url === 'planId')?.valueString, interval: subscriptionExtension.extension?.find(ext => ext.url === 'planInterval')?.valueString, amount: subscriptionExtension.extension?.find(ext => ext.url === 'planAmount')?.valueInteger, currency: subscriptionExtension.extension?.find(ext => ext.url === 'planCurrency')?.valueString, }, product: { id: subscriptionExtension.extension?.find(ext => ext.url === 'productId')?.valueString, name: subscriptionExtension.extension?.find(ext => ext.url === 'productName')?.valueString, description: subscriptionExtension.extension?.find(ext => ext.url === 'productDescription')?.valueString, }, tagValues: stripeTags.reduce((acc, tag) => { if (tag.system === `${TAG_BASE_URL}/stripeSubscriptionId`) { acc.subscriptionId = tag.code; } else if (tag.system === `${TAG_BASE_URL}/stripeCustomerId`) { acc.customerId = tag.code; } else if (tag.system === `${TAG_BASE_URL}/stripeSubscriptionStatus`) { acc.status = tag.code; } return acc; }, {} as { subscriptionId?: string, customerId?: string, status?: string }) } : null; return { photo: photoExtension?.valueAttachment as Attachment | undefined, formData: { meta: organization.meta, extension: organization.extension ?? [], id: organization.id, name: organization.name ?? '', alias: (organization.alias ?? ['']), active: organization.active ?? true, npi: transformFromIdentifier(npiIdentifiers), clia: transformFromIdentifier(cliaIdentifiers), naic: transformFromIdentifier(naicIdentifiers), identifier: transformFromIdentifier(identifiers), address: transformFromAddress(organization), telecom: transformFromTelecom(organization), type: transformFromCodeableConcept(organization.type ?? []), timezone: tzCodeExtension?.valueCode, stripe: stripeData, requireNewPatientExamFirst: getOrganizationPolicyFlags(organization).requireNewPatientExamFirst, bookingMode: getOrganizationPolicyFlags(organization).bookingMode, requestStyle: getOrganizationPolicyFlags(organization).requestStyle } }; }, [transformFromIdentifier, transformFromAddress, transformFromTelecom, transformFromCodeableConcept]); return { npiSystem, cliaSystem, naicSystem, TAG_BASE_URL, fetchWordPressOrganization, resolvePracticeHostname, claimPracticeHostname, fetchMyPracticeHostname, fetchOrganization, fetchOrganizationAsCaller, fetchMyOrganizations, fetchBillingPractices, fetchPendingPractices, setOrganization, getOrganization, fetchOrganizationHistory, createWordPressOrganization, fetchOrganizationUserCount, createOrganizationAdmin, updateOrganization, updateFormSubmissionNotifications, updatePatientPortalUrl, updateStaffPortalUrl, updateAppointmentReminderSettings, updateAppointmentPolicySettings, updateDiagnosticsSettings, updateOperatingScope, declarePart2Program, updateOrganizationNpi, lookupLocationTimeZone, updateReferralRoutingSettings, updateCampaignMailingAddress, getCampaignSendingIdentity, updateCampaignSendingIdentity, checkCampaignSendingIdentity, getSmsRegistration, saveSmsRegistration, submitSmsRegistration, checkSmsRegistration, updateTaxSettings, updateInvoiceTermsSettings, updatePaymentSettings, readPaymentReadiness, updateFaxSettings, listSinchFaxServices, readSinchReceiving, updateConsentSettings, fetchPractitionerRolesForOrganization, transformToFhirOrganization, transformFromFhirOrganization }; }; /** One of the practice's Sinch fax services, as the fax-settings `services` step reports it. */ /** Where the practice's Sinch receiving stands for one service and number. */ export interface SinchReceivingStatus { /** The setup for exactly that service and number. */ attempt: 'active' | 'pending' | 'none'; /** What receives now, if anything. */ current: { serviceId: string; faxNumber: string } | null; /** The setup described is in the Sinch project of the account stored now. */ sameProject: boolean; } export interface SinchFaxService { serviceId: string; name: string; defaultForProject: boolean; /** free: nothing receives here; ours: this practice already receives here; taken: something else does. */ webhook: 'free' | 'ours' | 'taken'; } const OrganizationServiceContext = createContext | undefined>(undefined); export const OrganizationServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const organizationService = useOrganizationService(); return ( {children} ); }; export const useOrganizationServiceContext = () => { const context = useContext(OrganizationServiceContext); if (context === undefined) { throw new Error('useOrganizationServiceContext must be used within an OrganizationServiceProvider'); } return context; };