import { environment } from '../environments/environment'; import { DPOP_HEADER, dpopHeaderFor } from './dpopRequestHeader'; import { getMountContext } from './mountContext'; export type PortalPageType = 'patient' | 'staff'; export type PortalPageState = | { status: 'owned'; id: number; title: string; slug: string; url: string; revision?: number } | { status: 'none' }; // What a page answer must be before anything is settled by it: a page the portal owns, complete, or none. const isPageState = (value: unknown): value is PortalPageState => { const page = value as Record | null; if (!page || typeof page !== 'object') return false; if (page.status === 'none') return true; return page.status === 'owned' && typeof page.id === 'number' && typeof page.title === 'string' && typeof page.slug === 'string' && typeof page.url === 'string' && (page.revision === undefined || typeof page.revision === 'number'); }; // A success that is not the result it should be does not say what happened; with no status, a write stays unconfirmed. const unreadable = () => new Error('WordPress answered in a form we could not read. Check the website before trying again.'); // What WordPress or the Akashic API refused, in words; anything else is a failure to reach it. export const PORTAL_PAGE_REFUSALS: Record = { slug_conflict: 'That address is already used by another page. Choose a different one.', page_not_portal: 'That page no longer holds the portal, so it was not changed. Create a new portal page instead.', page_not_found: 'The portal page could not be found. Create a new one.', insufficient_permissions: 'Your WordPress account is not allowed to publish pages.', title_required: 'Enter a page title.', not_authorized: 'Only an Administrator or Platform Manager of this practice can change its portal pages.', wrong_site: 'This website is not the one your practice is connected to, so its pages cannot be changed from here.', site_not_connected: 'Your practice is not connected to this website, so its pages cannot be changed from here.', practice_inactive: 'This practice is closed, so its portal pages cannot be changed.', insecure_site: 'This website is not using https, so its pages cannot be changed from here.', verification_unavailable: 'We could not confirm your access right now, so nothing was changed. Try again.', operation_busy: 'Another change to this portal page is being saved. Try again in a moment.', page_changed: 'The portal page changed since this was opened, so this change was not made.', page_exists: 'This portal already has its page, so no second one was created.', page_superseded: 'That change was made, but another page has since taken over this portal.', }; // The API the site asks before acting for an Akashic session. PHP calls this exact URL, and the // proof below is signed for it. export const WEBSITE_PUBLISHING_AUTHORIZE_URL = `${environment.fhirBaseApi}/website-publishing/authorize`; // Outside wp-admin there is no WordPress user; the signed-in Akashic session is the authority. export type WebsiteSession = { getToken: () => Promise; organizationId: string | undefined; }; /** * The operation a website request belongs to, as HttpService takes it: admitted again after the token and proof * waits and before its answer is used, cancelled by `signal`, and `onSend` once it is handed to the network. */ export type WebsiteTransport = { admit?: (phase?: 'answer') => void; signal?: AbortSignal; onSend?: () => void; }; const postPortalPagesAjax = async ( action: string, params: Record = {}, session?: WebsiteSession, transport?: WebsiteTransport, ): Promise => { const wpData = environment.wpData; const ajaxUrl: string | undefined = wpData?.wpAjaxUrl; const nonce: string | undefined = wpData?.wpNonce; const asSession = getMountContext() === 'staff'; if (!ajaxUrl || (asSession ? !session?.organizationId : !nonce)) { throw new Error( asSession ? 'This page cannot reach your website. Reload the page and try again.' : 'WordPress admin context is unavailable. Reload the admin page and try again.', ); } const body = new URLSearchParams(); body.set('action', action); const headers: Record = { 'Content-Type': 'application/x-www-form-urlencoded' }; if (asSession && session?.organizationId) { headers['X-Akashic-Token'] = await session.getToken(); headers['X-Organization-ID'] = session.organizationId; const proof = (await dpopHeaderFor('POST', WEBSITE_PUBLISHING_AUTHORIZE_URL))[DPOP_HEADER]; if (proof) headers['X-Akashic-Proof'] = proof; } else if (nonce) { body.set('_ajax_nonce', nonce); } Object.entries(params).forEach(([key, value]) => body.set(key, value)); // The token and proof waits can outlast the caller; one cancelled or retired meanwhile is never sent. if (transport?.signal?.aborted) throw new DOMException('The request was cancelled before it was sent.', 'AbortError'); transport?.admit?.(); transport?.onSend?.(); const response = await fetch(ajaxUrl, { method: 'POST', credentials: asSession ? 'omit' : 'same-origin', headers, body: body.toString(), ...(transport?.signal ? { signal: transport.signal } : {}), }); const json = await response.json().catch(() => undefined); transport?.admit?.('answer'); // Only WordPress's own answer says what happened: { success, data }. A success is its data; success false is a // refusal made before anything changed (with the response's own status, or 400 when it came as a 2xx). const answered = json !== null && typeof json === 'object' && typeof json.success === 'boolean'; if (answered && json.success) return json.data; const code = answered && typeof json.data === 'string' ? json.data : undefined; const message = (code && PORTAL_PAGE_REFUSALS[code]) ?? 'Failed to reach WordPress. Please try again.'; if (answered) throw Object.assign(new Error(message), { status: response.ok ? 400 : response.status, refusal: code }); // Anything else - an unreadable or cut-off body - does not say whether the request was carried out. A 2xx of // that kind carries no status, so a write is treated as unconfirmed; a failed status keeps its own. throw Object.assign(new Error(message), response.ok ? {} : { status: response.status }); }; export const getPortalPages = async (session?: WebsiteSession, transport?: WebsiteTransport): Promise<{ patient: PortalPageState; staff: PortalPageState; }> => { const data = await postPortalPagesAjax('akashere_get_portal_pages', {}, session, transport) as Record | null; if (!data || !isPageState(data.patient) || !isPageState(data.staff)) throw unreadable(); return data as { patient: PortalPageState; staff: PortalPageState }; }; export const savePortalPage = async ( type: PortalPageType, mode: 'create' | 'edit', title: string, slug: string, session?: WebsiteSession, transport?: WebsiteTransport, // The operation this save is, so the same one sent again is answered with what it did; and for an edit, the page it // is for and the title and address it was opened with, so it never lands on another page or over a newer change. bound?: { operation: string; pageId?: number; expectedTitle?: string; expectedSlug?: string; expectedRevision?: number }, ): Promise => { const data = await postPortalPagesAjax('akashere_save_portal_page', { type, mode, title, slug, ...(bound ? { operation: bound.operation } : {}), ...(bound?.pageId !== undefined ? { page_id: String(bound.pageId) } : {}), ...(bound?.expectedTitle !== undefined ? { expected_title: bound.expectedTitle } : {}), ...(bound?.expectedSlug !== undefined ? { expected_slug: bound.expectedSlug } : {}), ...(bound?.expectedRevision !== undefined ? { expected_revision: String(bound.expectedRevision) } : {}), }, session, transport); if (!isPageState(data) || data.status !== 'owned') throw unreadable(); return data; }; // Persists the AWS `public-form/register` response (`{ handle, secret }`) for a published // anonymous form to WP options, keyed by the FHIR Questionnaire id. The PHP endpoint // (`akashere_save_public_form`) expects exactly these param names. export const savePublicForm = async ( questionnaireId: string, handle: string, secret: string, publicUrl: string, title: string, ): Promise => { await postPortalPagesAjax('akashere_save_public_form', { questionnaire_id: questionnaireId, handle, secret, public_url: publicUrl, // `captcha_required` is no longer sent. Captcha is forced on every public form (Spencer, // 2026-08-20), so there is no per-form choice for WordPress to cache. The PHP handler ignores an // absent param; old option rows keep a dead key that nothing reads. api_base: environment.apiUrl, // Stored so the Gutenberg "Public Form" block can show a friendly form-title label in its picker // (falls back to the handle for forms published before titles were captured). title, }); }; // Reads the questionnaire ids that have been published as public forms (WP option // `akashere_public_forms`). Returns only the count/ids - no handles or secrets. Used by the // org onboarding surface to detect the "create a public form" step. export const getPublicFormCount = async (): Promise => { const data = (await postPortalPagesAjax('akashere_get_public_forms')) as | { questionnaireIds?: string[]; count?: number } | null | undefined; return data?.count ?? data?.questionnaireIds?.length ?? 0; }; // Reads the published public forms as a map keyed by questionnaire id { handle, publicUrl }. // Never returns the mint secret. Used by the questionnaire builder (shortcode read-back) and the // questionnaires list ("Public form" badge). export const getPublicForms = async (): Promise<{ forms: Record; questionnaireIds: string[]; count: number; }> => { const data = (await postPortalPagesAjax('akashere_get_public_forms')) as | { forms?: Record; questionnaireIds?: string[]; count?: number } | null | undefined; return { // PHP encodes an empty map as JSON `[]`, not `{}`, so `?? {}` (which only catches null/undefined) // wouldn't normalize the common "no published forms yet" case - guard against a non-object shape. forms: data?.forms && !Array.isArray(data.forms) ? data.forms : {}, questionnaireIds: data?.questionnaireIds ?? [], count: data?.count ?? 0, }; }; // Removes a questionnaire's public-form WP-option entry (the WP side of Unpublish). The AWS // registration is deactivated separately via PublicFormService.deactivatePublicForm. export const deletePublicForm = async (questionnaireId: string): Promise => { await postPortalPagesAjax('akashere_delete_public_form', { questionnaire_id: questionnaireId, }); }; /** * The outcome of offering this site a contractor's installation code. * * IT RETURNS THE REASON RATHER THAN THROWING, unlike every helper above, and that is the point of it * being separate. `postPortalPagesAjax` collapses every failure into one message because for a portal * page there is only one useful thing to say. Here there are two, and they send the reader to different * places: `malformed` means look at the code again, `already-adopted` means look at which SITE you are * on. Losing that distinction would make the second one read as a typo. */ export type AdoptCodeOutcome = | { ok: true; result: 'adopted' | 'unchanged' } | { ok: false; reason: 'malformed' | 'already-adopted' | 'unavailable' }; export const adoptInstallationCode = async (code: string): Promise => { const wpData = environment.wpData; const ajaxUrl: string | undefined = wpData?.wpAjaxUrl; const nonce: string | undefined = wpData?.wpNonce; if (!ajaxUrl || !nonce) return { ok: false, reason: 'unavailable' }; const body = new URLSearchParams(); body.set('action', 'akashere_adopt_installation_code'); body.set('_ajax_nonce', nonce); body.set('code', code); try { const response = await fetch(ajaxUrl, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), }); const json = await response.json().catch(() => null); if (json?.success) { return { ok: true, result: json?.data?.result === 'unchanged' ? 'unchanged' : 'adopted' }; } const reason = typeof json?.data === 'string' ? json.data : ''; // Anything the server did not name is reported as `unavailable` rather than guessed at. Reading an // unknown refusal as "malformed" would tell somebody to correct a code that was perfectly good. return { ok: false, reason: reason === 'malformed' || reason === 'already-adopted' ? reason : 'unavailable', }; } catch { // A network fault is not the code being wrong, and must not be reported as if it were. return { ok: false, reason: 'unavailable' }; } }; /** * The outcome of letting go of the practice this site was pointed at. * * `not-adopted` IS ITS OWN ANSWER rather than a generic failure, and it reads the same 409 the server * sends for it. Nothing is wrong with the request - the site is simply running its own generated id and * has nothing to disconnect - so reporting it as an error would send somebody looking for a fault. */ /** * IS THIS SITE RUNNING A CONTRACTOR'S CODE, OR AN ID IT MINTED FOR ITSELF? * * SHAPE IS THE ONLY THING THAT TELLS THEM APART, and the server decides it the same way. A site the * plugin set up generates an md5; a site that took a handover code stores that code verbatim, prefix and * all. There is no separate flag recording which happened, deliberately - a second fact about the same * thing is the one that drifts. * * IT DECIDES WHETHER A DISCONNECT CONTROL IS OFFERED AT ALL. Showing one to a site that never adopted * anything invites somebody to break a connection that was never made, and the server would refuse it * anyway. */ export const isAdoptedInstallationCode = (installationId: string | undefined): boolean => typeof installationId === 'string' && installationId.startsWith('contractor-enrolled:'); export type DetachCodeOutcome = | { ok: true; previous: string | undefined } | { ok: false; reason: 'not-adopted' | 'unavailable' }; export const detachInstallationCode = async (): Promise => { const wpData = environment.wpData; const ajaxUrl: string | undefined = wpData?.wpAjaxUrl; const nonce: string | undefined = wpData?.wpNonce; if (!ajaxUrl || !nonce) return { ok: false, reason: 'unavailable' }; const body = new URLSearchParams(); // A DISTINCT ACTION, never a flag on the adopt one. Re-pointing a site must not be reachable inside a // request somebody thought was an adopt. body.set('action', 'akashere_detach_installation_code'); body.set('_ajax_nonce', nonce); try { const response = await fetch(ajaxUrl, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), }); const json = await response.json().catch(() => null); if (json?.success) { // Handed back so a screen can offer it again. Somebody who disconnects the wrong site will not // have a copy - it was shown once, on a console they have long since navigated away from. const previous = json?.data?.previous; return { ok: true, previous: typeof previous === 'string' ? previous : undefined }; } const reason = typeof json?.data === 'string' ? json.data : ''; return { ok: false, reason: reason === 'not-adopted' ? 'not-adopted' : 'unavailable' }; } catch { // A network fault did not disconnect anything, and must not be reported as if it had. return { ok: false, reason: 'unavailable' }; } }; /** * THE SETUP TOKEN VAULT - the token that lets somebody become a practice's FIRST admin. * * It is minted once, when the organization is created, and it is the only proof the signup endpoint * accepts. It used to live in sessionStorage alone, which is per-tab: closing the tab between creating * the practice and signing up its admin stranded the practice with an organization it could never get * into, on a screen telling it to restart a setup the routing makes unreachable. * * These calls put a copy behind a WordPress administrator login on the site the organization is tagged * to, so setup can be finished later, from another browser, or by somebody else at the practice. * * EVERY ONE OF THESE FAILS SOFT, deliberately, and that is not the usual choice here. The vault is a * SECOND copy: sessionStorage still carries the token through the ordinary same-tab path, which needs no * request at all. So a WordPress route that is unreachable - an older plugin build, a stale nonce, a * mount with no admin context - must not turn the working path into a failure. It costs the recovery, * never the happy path. */ const setupTokenAjax = async ( action: string, params: Record = {}, ): Promise | null> => { const wpData = environment.wpData; const ajaxUrl: string | undefined = wpData?.wpAjaxUrl; const nonce: string | undefined = wpData?.wpNonce; if (!ajaxUrl || !nonce) return null; const body = new URLSearchParams(); body.set('action', action); body.set('_ajax_nonce', nonce); Object.entries(params).forEach(([key, value]) => body.set(key, value)); try { const response = await fetch(ajaxUrl, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), }); const json = await response.json().catch(() => null); if (!response.ok || !json?.success) return null; return (json.data ?? null) as Record | null; } catch { return null; } }; export const storeSetupToken = async (organizationId: string, token: string): Promise => { await setupTokenAjax('akashere_store_setup_token', { organization_id: organizationId, token, }); }; /** * Returns the stored token only when it names the organization being asked about. * * A site can be pointed at a different practice - that is what the handover code does - and a token left * behind by the previous one names the wrong organization. Returning it would send somebody to a signup * that fails on a message about a token, when what is actually true is that this site now belongs to a * different practice. */ export const readSetupToken = async (organizationId: string): Promise => { const data = await setupTokenAjax('akashere_get_setup_token'); if (!data) return null; const token = typeof data.token === 'string' ? data.token : null; const storedFor = typeof data.organizationId === 'string' ? data.organizationId : null; return token && storedFor === organizationId ? token : null; }; export const clearSetupToken = async (): Promise => { await setupTokenAjax('akashere_clear_setup_token'); };