import { useCallback, createContext, useContext, ReactNode } from 'react'; import { environment } from '../environments/environment'; import { useHttpService } from './HttpService.tsx'; import { PublicFormItem } from '../utils/publicFormSnapshot.ts'; // `public-form/register` is an AUTHENTICATED route in the fhir-base API stack (alongside // Task/Patient), so it's served on fhirBaseApi - NOT environment.apiUrl (the public/unauth stack // that serves the anonymous get/token/submission routes). Calling it on apiUrl yields a SigV4/IAM // "Invalid key=value pair in Authorization header" 403 because that route doesn't exist there. const API_URL = environment.fhirBaseApi; interface PublicFormRegisterResponse { formHandle: string; mintSecret: string; } /** * One published form as the SERVER describes it. * * Deliberately carries neither the form's authorized domains (an enforcement input the server keeps to * itself) nor its snapshot - a list needs neither, and twenty forms would mean twenty questionnaire bodies. */ /** The practice and operation a request belongs to: checked before it is sent and before its answer is used. */ export interface PublicFormTransport { organizationId?: string; signal?: AbortSignal; admit?: () => void; } export interface PublishedPublicForm { formHandle: string; questionnaireId: string; active: boolean; createdAt: string; title?: string; publicUrl?: string; /** * - the hosts this form will load and accept submissions on. * * THE LIST USED TO WITHHOLD THIS DELIBERATELY, as *"an enforcement input rather than something to * display"*, and that was right while nothing could change it. A practice moving from a staging host to * their real one has to SEE which forms still point at the old one - every form breaks at once, and * silently. The PUBLIC read still refuses it and always must: an anonymous visitor is not told the * fence they are being measured against. * Optional because a registration written before this shipped has none, and an absent list must not * render as an empty one - *"authorized nowhere"* is a different and much more alarming sentence. */ authorizedDomains?: string[]; /** * - WHEN THIS FORM WAS LAST ACTUALLY SERVED TO SOMEBODY, and from which page. * * A MEASUREMENT RATHER THAN A CLAIM. `publicUrl` above was meant to answer *"is this on a page?"* and * **nothing ever writes it** - the form is registered before anyone embeds it, and only WordPress ever * learns where a shortcode ended up. The screen could only print an INSTRUCTION. * And this is a better fact than the one originally asked for: a shortcode sitting on an * **unpublished draft** page is registered, embedded, and still unreachable - which only this catches, * because nobody ever loaded it. * * `lastServedFrom` COMES FROM THE `Referer` AND IS SPOOFABLE. It is a hint for the practice's own * eyes, shown as TEXT and never as a link, and nothing decides anything on it. */ lastServedAt?: string; lastServedFrom?: string; } // Authoring-side client for the anonymous-forms feature. `publishPublicForm` registers a // sanitized Questionnaire snapshot (see `buildPublicFormSnapshot`) with the AWS // `public-form/register` endpoint, which mints the `{ formHandle, mintSecret }` pair the caller // then persists to WP options via `savePublicForm` (see `wpAdminApi.ts`). Auth + org context // headers auto-attach via `useHttpService`, same as `createQuestionnaire`. export const usePublicFormService = () => { const { get, post } = useHttpService(); const publishPublicForm = useCallback(async ( questionnaireId: string, authorizedDomains: string[], formSnapshot: { title: string; items: PublicFormItem[] } ): Promise<{ formHandle: string; mintSecret: string }> => { return post(`${API_URL}/public-form/register`, { questionnaireId, authorizedDomains, formSnapshot, }); }, [post]); // Unpublish: flip the AWS registration inactive (the public handlers gate on `active`). const deactivatePublicForm = useCallback(async (formHandle: string, transport?: PublicFormTransport): Promise => { await post(`${API_URL}/public-form/deactivate`, { formHandle }, transport ?? {}); }, [post]); /** * - MOVE A PUBLISHED FORM TO A DIFFERENT DOMAIN, keeping its handle. * * THE BREAKAGE THIS UNDOES IS SILENT AND TOTAL. `authorizedDomains` is enforced at three * chokepoints - the token mint, the submission and the framed render - so the day a practice switches * from the staging host their site was built on to their real one, EVERY published form stops working * at once and looks like a broken website rather than a wrong setting. * * THE HANDLE IS PRESERVED. It is already inside the shortcode pasted into their pages, so * re-registering under a new one would break every page that embeds the form. * * SEND THE WHOLE LIST, because a cutover is ADD-and-REMOVE rather than a swap: both hosts run for a * while, so `["staging.example.com", "example.com"]` is the honest middle state. * * NOT `publishPublicForm` with the same handle, which also works and is the trap. That path takes * the WHOLE record - it re-stamps `createdAt`, forces `active: true`, and needs the full snapshot - so * a domain change made that way silently republishes a form the practice had unpublished, re-dates it, * and rewrites the published questionnaire from whatever this client happened to be holding. */ const updatePublicFormDomains = useCallback(async ( formHandle: string, authorizedDomains: string[], ): Promise<{ formHandle: string; authorizedDomains: string[] }> => { return post<{ formHandle: string; authorizedDomains: string[] }>( `${API_URL}/public-form/domains`, { formHandle, authorizedDomains }, ); }, [post]); /** * WHAT THIS PRACTICE HAS PUBLISHED - from the record, not from a WordPress option. * * Spencer, 2026-08-17: *"it should NOT be stored in a wordpress option. It should be stored server * side and just rendered in the wordpress page through a shortcode."* * * THE OPTION WAS NEVER THE REGISTRY - it was a duplicate CACHE of one, and the duplication is what * hurt: the standalone client at akashic.health has no `wp_options` to read, so it could never list a * published form; the org setup checklist's public-form step was unresolvable without a WordPress admin * session and therefore read as *not done* while a live form was published; and the org's mint secret * sat in that same option, one copy per form. * * The org is never sent - the server takes it from the token, so this can only ever list the practice * the caller is signed in to. */ const listPublicForms = useCallback(async (transport?: PublicFormTransport): Promise => { const { forms } = await get<{ forms: PublishedPublicForm[] }>(`${API_URL}/public-forms`, transport ?? {}); return forms ?? []; }, [get]); // What visitors are shown now; reading it records no visit. const getPublishedSnapshot = useCallback(async (formHandle: string): Promise<{ title: string; items: PublicFormItem[] }> => { const { formSnapshot } = await get<{ formSnapshot: { title: string; items: PublicFormItem[] } }>( `${API_URL}/public-form-snapshot?handle=${encodeURIComponent(formHandle)}`, ); return formSnapshot; }, [get]); return { publishPublicForm, deactivatePublicForm, updatePublicFormDomains, listPublicForms, getPublishedSnapshot }; }; const PublicFormServiceContext = createContext | undefined>(undefined); export const PublicFormServiceProvider = ({ children }: { children: ReactNode }) => { const publicFormService = usePublicFormService(); return ( {children} ); }; /** For surfaces that must work without the provider; absent, the caller treats public forms as unknown. */ export const useOptionalPublicFormService = () => useContext(PublicFormServiceContext); export const usePublicFormServiceContext = () => { const context = useContext(PublicFormServiceContext); if (context === undefined) { throw new Error('usePublicFormServiceContext must be used within a PublicFormServiceProvider'); } return context; };