// F1 re-foundation (Slice A, Phase 1), client Task 9 - pure orchestration behind the related-person // invite create-flow, shared by the two create submitters (RelatedPersonModal.tsx, // RelatedPersonsPage.tsx). Kept dependency-free (no React/hook/HTTP imports) so the payload shape + // tier-application sequencing is testable without mounting either form. Mirrors the // relatedPersonAuthority.ts / careAccess.helpers.ts split already used by the rest of the F1 work. // // Server context: a RelatedPerson is now a thin link with NO demographics (no birthDate/gender/ // prefix/suffix) - the invite form only captures identity + contact. The F1 authority tier is applied // via a SEPARATE endpoint (RelatedPersonAuthorityService.putAuthority), not stored on the RelatedPerson // resource itself, so it's a second network call sequenced after the create resolves. import type { Attachment, CodeableConcept, Reference, RelatedPerson } from 'fhir/r4'; import { isPermanentDenial, relayServerFailure, UNCURATED_DENIAL_MESSAGE } from '../utils/fetchFailureMessage'; import type { AuthorityIntent, AuthoritySaveOutcome, AuthorityTier } from './relatedPersonAuthority'; import { authoritySaveOutcome, newAuthorityIntent } from './relatedPersonAuthority'; import { AUTHORITY_SAVE_DEADLINE_MS, recordAuthoritySave } from './unconfirmedAuthoritySaves'; import { withinDeadline } from '../utils/withinDeadline'; import type { DesignationAttestationRequest } from './relatedPersonDesignation'; import { effectiveAllowMessaging, type CareAccessState } from '../components/FhirRelatedPersonDisplay/careAccess.helpers'; import { normalizeBillingGrant } from './relatedPersonAuthority'; /** The Separately grants chosen with the invitation; absent means none, as before. */ export interface InviteGrants { allowMessaging: boolean; allowBillingView: boolean; allowBillingPay: boolean; } export interface RelatedPersonInviteInput { email: string; firstname: string; lastname: string; tier: AuthorityTier; grants?: InviteGrants; patient: Reference; relationship?: CodeableConcept[]; photo?: Attachment; /** * Present only on the desk path: the receptionist recording that the PATIENT asked for this * person, rather than the patient granting it themselves in their own portal. */ attestation?: DesignationAttestationRequest; } export type CreateRelatedPersonFn = (payload: Record) => Promise; export type PutAuthorityFn = ( intent: AuthorityIntent, relatedPersonId: string, tier: AuthorityTier, allowMessaging: boolean, allowBillingView?: boolean, allowBillingPay?: boolean, attestation?: DesignationAttestationRequest, ) => Promise; // Thrown when the RelatedPerson thin-link was created successfully but applying the F1 authority tier // failed. The link already exists at the server's default assist-only floor at that point - this is a // RETRYABLE, non-silent failure, not a failed invite. Carries the created RelatedPerson + the tier that // failed to apply so a caller can retry via applyRelatedPersonAuthority WITHOUT resubmitting the create // (which would mint a duplicate RelatedPerson link). It also carries the intent that was sent, so the // retry is the same decision (applied once if the first did land) rather than a second one. export class RelatedPersonAuthorityError extends Error { readonly relatedPerson: RelatedPerson; readonly tier: AuthorityTier; readonly grants?: InviteGrants; readonly attestation?: DesignationAttestationRequest; readonly intent: AuthorityIntent; readonly outcome: AuthoritySaveOutcome; readonly cause?: unknown; constructor( relatedPerson: RelatedPerson, tier: AuthorityTier, cause: unknown, grants?: InviteGrants, intent: AuthorityIntent = newAuthorityIntent(0), attestation?: DesignationAttestationRequest, ) { const outcome = authoritySaveOutcome(cause); super(RelatedPersonAuthorityError.sentence(outcome, cause)); this.name = 'RelatedPersonAuthorityError'; this.relatedPerson = relatedPerson; this.tier = tier; this.grants = grants; this.attestation = attestation; this.intent = intent; this.outcome = outcome; this.cause = cause; } // The cause stays on `this.cause` for the log; only a sentence the server wrote for people is shown. private static sentence(outcome: AuthoritySaveOutcome, cause: unknown): string { // Not confirmed is not "couldn't be saved": it may have been. The retry is the same change, applied once. // No answer at all is the same: the server may have applied it before the connection failed. if (outcome === 'unconfirmed' || outcome === 'unanswered') { return "They were invited, but we couldn't confirm the access level you chose was saved. Try again to finish setting it."; } // Another decision came first. Nothing more is sent for this one. if (outcome === 'superseded') { return 'They were invited, but their access was changed elsewhere first, so nothing more was saved. Open their access to review it.'; } const status = (cause as { status?: unknown } | null)?.status; // A refusal says so rather than inviting an identical retry. const denied = typeof status === 'number' && isPermanentDenial(status); const reason = typeof status === 'number' ? relayServerFailure(cause, denied ? UNCURATED_DENIAL_MESSAGE : '') : ''; return `They were invited, but the access level you chose couldn't be saved. ${reason || 'Try again to set it.'}`; } } /** Whether this failure leaves the same decision to retry, or ends it (overtaken or refused). */ export const authorityFailureMayRetry = (error: RelatedPersonAuthorityError): boolean => error.outcome === 'unconfirmed' || error.outcome === 'unanswered'; /** * The invite's form or page was closed, or the viewer changed, after the link was created and before its access * was sent. Nothing more is sent; the link stays at the assist-only floor and its card can set it. */ export class RelatedPersonInviteAbandoned extends Error { readonly relatedPerson: RelatedPerson; constructor(relatedPerson: RelatedPerson) { super('The invitation was left before its access level was sent.'); this.name = 'RelatedPersonInviteAbandoned'; this.relatedPerson = relatedPerson; } } // Builds the minimum-capture create payload for the thin-link RelatedPerson. Deliberately has NO // birthDate/gender/prefix/suffix in its type or output - the server no longer stores demographics on // RelatedPerson, so there's nothing here to accidentally forward. Exported standalone so the payload // shape is assertable without a network call. export const buildRelatedPersonCreatePayload = ( input: Pick, ): Record => ({ email: input.email, firstname: input.firstname, lastname: input.lastname, role: 'RelatedPersons', active: true, patient: input.patient, ...(input.relationship !== undefined && { relationship: input.relationship }), ...(input.photo !== undefined && { photo: input.photo }), }); // Applies the chosen F1 tier to an already-created RelatedPerson. The messaging default mirrors // CareAccess.tsx's first-touch behavior (see careAccess.helpers.ts): allowMessaging starts false, and // effectiveAllowMessaging() forces it true above assist-only. Safe to call again on retry - it doesn't // touch the RelatedPerson resource itself. // What applyRelatedPersonAuthority writes, so a screen can show it before the grants read sees it. export const writtenAuthorityState = (tier: AuthorityTier, grants?: InviteGrants): CareAccessState => { // Paying needs seeing, so a pay grant without the view is dropped, as the permissions panel does. // A desk designation passes no grants and keeps every billing flag off. const billing = normalizeBillingGrant(grants?.allowBillingView ?? false, grants?.allowBillingPay ?? false); return { tier, allowMessaging: effectiveAllowMessaging(tier, grants?.allowMessaging ?? false), allowBillingView: billing.allowBillingView, allowBillingPay: billing.allowBillingPay, }; }; export const applyRelatedPersonAuthority = async ( relatedPerson: RelatedPerson, tier: AuthorityTier, putAuthority: PutAuthorityFn, attestation?: DesignationAttestationRequest, grants?: InviteGrants, // The intent a retry carries back from its RelatedPersonAuthorityError. A link just created has had no // decision yet, so its first one is based on generation zero. intent: AuthorityIntent = newAuthorityIntent(0), // Whether the form that asked for this is still the one on screen, for the same viewer. Checked before the // send: a closed or replaced form sends nothing. stillCurrent: () => boolean = () => true, ): Promise => { if (!relatedPerson.id) return; // defensive: nothing to attach a grant to if (!stillCurrent()) throw new RelatedPersonInviteAbandoned(relatedPerson); const state = writtenAuthorityState(tier, grants); // Recorded before it is sent, so a page that goes away before the answer can still finish it as itself. const record = recordAuthoritySave({ relatedPersonId: relatedPerson.id, intent, ...state, ...(attestation ? { attestation } : {}) }); const sent = putAuthority( intent, relatedPerson.id, tier, state.allowMessaging, state.allowBillingView, state.allowBillingPay, attestation, ); // Its answer settles the record whenever it comes, even after this wait has given up: confirmed or ended is // settled; anything else stays recorded, to be finished as itself. void sent.then( () => record?.settle(), (cause) => { if (!authorityFailureMayRetry(new RelatedPersonAuthorityError(relatedPerson, tier, cause, grants, intent))) record?.settle(); }, ).finally(() => record?.answered()); try { // Waited for only so long: past it the save is not confirmed (it may yet land), never refused. await withinDeadline(sent, AUTHORITY_SAVE_DEADLINE_MS, 'access save'); } catch (cause) { throw new RelatedPersonAuthorityError(relatedPerson, tier, cause, grants, intent, attestation); } }; // Orchestrates the full invite: create the thin-link RelatedPerson, then apply the chosen tier. On a // putAuthority failure, rejects with RelatedPersonAuthorityError (see above) - the RelatedPerson it // carries is already created, so callers must not resubmit createRelatedPerson to retry, only // applyRelatedPersonAuthority. export const submitRelatedPersonInvite = async ( input: RelatedPersonInviteInput, createRelatedPerson: CreateRelatedPersonFn, putAuthority: PutAuthorityFn, stillCurrent: () => boolean = () => true, ): Promise => { const relatedPerson = await createRelatedPerson(buildRelatedPersonCreatePayload(input)); await applyRelatedPersonAuthority(relatedPerson, input.tier, putAuthority, input.attestation, input.grants, undefined, stillCurrent); return relatedPerson; };