import { useCallback } from 'react'; import { environment } from '../environments/environment.ts'; import { useHttpService } from './HttpService.tsx'; import type { SaveTransport } from '../utils/saveOperation'; // - the organization Owner: the one person nobody else can remove. // // `environment.apiUrl`, NOT `fhirBaseApi`. These are api-service routes; the wrong base is a silent // 404 rather than an error anyone can read. const API_URL = environment.apiUrl; export interface OwnerAttestation { version: string; text: string; } /** The owner gate's own decision; its refusal reaches staff reads only as a bare 403. */ export interface PracticeOperability { operable: boolean; refusal?: 'owner-has-not-accepted' | 'agency-baa-not-signed'; message?: string; } /** One owner action as the server admits it; the same id and fingerprint are sent on every repeat. */ export interface OwnerOperation { id: string; fingerprint: string; } /** * Where an owner transition stands. `accepted` alone is designation; only `settled` says every stage the change of * owner owes - roles, accountability, publication - is done. The operation id is given only to the person whose * operation it is, with the ones of theirs a later change has superseded. */ export interface OwnerTransitionView { operationId?: string; fingerprint?: string; generation?: number; route?: 'accept' | 'nominate' | 'transfer-accept' | 'claim-confirm' | 'accept-link' | 'stranger-accept'; state: 'settling' | 'settled' | 'superseded'; completed?: boolean; owed?: string[]; admittedAt?: string; settledAt?: string; yourEarlierOperations?: Array<{ operationId: string; state: string }>; } export interface OwnerState { status: 'none' | 'nominated' | 'accepted'; /** The latest owner transition, or this action's own on an action's answer. */ transition?: OwnerTransitionView; /** * Whether the accepted owner can still administer the practice. Distinct from settlement: a settled owner whose * Admin role later ended needs recovery, and `unavailable` means their roles could not be read. */ ownerStanding?: 'effective' | 'recovery-required' | 'unavailable'; /** The lawful next step when standing needs recovery. */ ownerRecovery?: 'role-repair-or-transfer' | 'support'; /** How the practice came to exist; only a direct founder may name themselves. Absent means unknown. */ foundingOrigin?: 'direct' | 'agency'; /** Set only for the signed-in person an agency named as owner, while no owner has accepted. */ youAreTheNamedOwner?: boolean; /** Absent when the server could not read it. */ operability?: PracticeOperability; ownerPractitionerId?: string; /** When the settled owner accepted; present only once ownership is accepted. */ acceptedAt?: string; nomineePractitionerId?: string; /** * TRUE FOR A PENDING NOMINATION AS WELL AS FOR NONE, and that is the point: a nomination nobody * has accepted is exactly the state that LOOKS handled and is not. Never re-derive this on the * client from `status` - the server owns the judgement, and a client that computed it would quietly * disagree the first time the rule changed. */ needsOwner: boolean; /** Set only for the signed-in founder whose stored Admin role here may name themselves owner, while nobody owns it. */ canSelfNominate?: boolean; attestation?: OwnerAttestation; /** * - the wording shown to somebody who is naming SOMEBODY ELSE as owner. * * It comes from the server rather than living here, for the same reason the owner wording does: * the sentence rendered has to be the sentence stored, and a copy on the client drifts the first time * the text is versioned. */ contractorAttestation?: OwnerAttestation; /** Present on a nomination response. `false` means go and tell them in person. */ nomineeNotified?: boolean; /** Why `nomineeNotified` is false: no address anywhere, or a send that failed. */ nomineeNotNotifiedReason?: 'no-email' | 'send-failed'; /** Set only for the person named, with who named them when that could be read. */ youAreTheNominee?: boolean; nominatedByName?: string; /** * A hand-off the owner has started and this person has not yet accepted. * * Deliberately NOT reflected in `needsOwner`: the practice has an owner for the whole time a * transfer is pending. Treating it as "needs an owner" would show the alarm to a healthy practice. */ pendingTransferToPractitionerId?: string; /** Present on a transfer response, same meaning as `nomineeNotified`. */ notified?: boolean; } export const useOrganizationOwnerService = () => { const { get, post } = useHttpService(); const fetchOwner = useCallback( async (organizationId: string, transport?: SaveTransport): Promise => get(`${API_URL}/Organization/${organizationId}/owner`, { ...transport }), [get], ); /** * TWO ATTESTATIONS, BY TWO DIFFERENT PEOPLE -. * * `attestation` is *"I am a direct representative of this organization"*, and it travels only with a * SELF-nomination: the person is the one being designated, so they are the one whose claim is material. * * `contractorAttestation` is the other branch - *"I am a contractor... I am designating an Owner from * within it"* - and the server REFUSES a nomination of somebody else without it. Before this, * naming somebody else carried nothing, so the one person the accountability was built for left no * record at all. */ const nominateOwner = useCallback( async ( organizationId: string, practitionerId: string, attestation?: { statementVersion: string; attestedByName?: string }, contractorAttestation?: { statementVersion: string; attestedByName?: string }, transport?: SaveTransport, operation?: OwnerOperation, ): Promise => post(`${API_URL}/Organization/${organizationId}/owner/nominate`, { practitionerId, ...(attestation ? { attestation } : {}), ...(contractorAttestation ? { contractorAttestation } : {}), ...(operation ? { operation } : {}), }, { ...transport }), [post], ); const acceptOwner = useCallback( async (organizationId: string, attestation: { statementVersion: string; attestedByName?: string }, transport?: SaveTransport, operation?: OwnerOperation): Promise => post(`${API_URL}/Organization/${organizationId}/owner/accept`, { attestation, ...(operation ? { operation } : {}) }, { ...transport }), [post], ); /** * The sitting owner hands the practice on. * * No attestation here, deliberately. The person clicking is asserting nothing about themselves - * they already hold the designation. The RECEIVING person attests when they accept, which is the * only moment somebody is making a claim about who they are. */ const declineOwner = useCallback( async (organizationId: string, transport?: SaveTransport): Promise => post(`${API_URL}/Organization/${organizationId}/owner/accept`, { decision: 'decline' }, { ...transport }), [post], ); const startOwnerTransfer = useCallback( async (organizationId: string, practitionerId: string, transport?: SaveTransport): Promise => post(`${API_URL}/Organization/${organizationId}/owner/transfer`, { practitionerId }, { ...transport }), [post], ); const acceptOwnerTransfer = useCallback( async (organizationId: string, attestation: { statementVersion: string; attestedByName?: string }, transport?: SaveTransport, operation?: OwnerOperation): Promise => post(`${API_URL}/Organization/${organizationId}/owner/transfer/accept`, { attestation, ...(operation ? { operation } : {}) }, { ...transport }), [post], ); /** * A member of staff asks to take back a practice that has nobody at its head. * * THE CODE DOES NOT COME HERE. It goes to the ORGANIZATION'S own recorded contact details, which is * the whole verification: that address was recorded long before any dispute, and a departed * contractor cannot read it. A code sent to the person asking would prove only that they can read * their own email. * * The response says WHERE it went, masked, so the screen can tell somebody which inbox to open * without handing a browser an address it did not already have. */ const startOwnerClaim = useCallback( async (organizationId: string, transport?: SaveTransport): Promise<{ sentTo: string; expiresAt: string }> => post<{ sentTo: string; expiresAt: string }>( `${API_URL}/Organization/${organizationId}/owner/claim`, {}, { ...transport }, ), [post], ); /** * The code comes back, and the practice gets its owner. * * The attestation is the same statement the nomination path takes, from the same constant on the * server. The code proves the channel; this puts a named person on record saying what they are. */ const confirmOwnerClaim = useCallback( async ( organizationId: string, code: string, attestation: { statementVersion: string; attestedByName?: string }, transport?: SaveTransport, operation?: OwnerOperation, ): Promise => post( `${API_URL}/Organization/${organizationId}/owner/claim/confirm`, { code, attestation, ...(operation ? { operation } : {}) }, { ...transport }, ), [post], ); /** * Finishes an owner action the server already admitted, from its own operation. Nothing is asked again: the * server resumes what that operation still owes, replays it when done, or says a later change superseded it. */ const resumeOwnerOperation = useCallback( async ( organizationId: string, route: 'accept' | 'nominate' | 'transfer-accept' | 'claim-confirm', operation: OwnerOperation, nomineePractitionerId: string | undefined, transport?: SaveTransport, ): Promise => { const path = { accept: 'owner/accept', nominate: 'owner/nominate', 'transfer-accept': 'owner/transfer/accept', 'claim-confirm': 'owner/claim/confirm' }[route]; return post(`${API_URL}/Organization/${organizationId}/${path}`, { operation, ...(route === 'nominate' ? { practitionerId: nomineePractitionerId } : {}), }, { ...transport }); }, [post], ); return { fetchOwner, nominateOwner, acceptOwner, declineOwner, startOwnerTransfer, acceptOwnerTransfer, startOwnerClaim, confirmOwnerClaim, resumeOwnerOperation, }; };