import { useCallback } from 'react'; import { parsePracticeRemoval, type PracticeRemovalIntent, type PracticeRemovalOutcome } from '../utils/practiceAgencyRemoval'; import { environment } from '../environments/environment.ts'; import { useHttpService, type RequestAdmission } from './HttpService.tsx'; /** * WHO MANAGES US - the practice's own side of the agency relationship. * * THE THREE ACTS ARE ONE JOB. Seeing which agency holds access, ending it, and taking the billing back * are the same question asked three ways, and the server treats them that way: the list and the * revocation share a gate deliberately, because *"a list an admin can read but not act on would be a * worse screen than none."* * * ALL THREE ARE UNILATERAL BY DESIGN. The contractor is not consulted and does not have to agree, because * every one of them has to work when the agency has stopped answering. That is the whole reason the * practice-side routes exist separately from the agency-side ones. * * `environment.orgAuthorizationApi`, NOT `apiUrl`. All three of these paths are declared in the * org-authorization template and exist ONLY on that API, so the wrong base does not produce a readable * 404 - the path is unknown to the api service, its OPTIONS preflight is refused, and the browser blocks * the response before any status reaches us. The screen then reports a load failure it cannot explain. */ const API_URL = environment.orgAuthorizationApi; export interface OrganizationContractorRow { contractorId: string; /** The agency's name, so a practice recognises who this is. Absent if the profile row has gone. */ name?: string; /** The agency's own lifecycle state - a suspended agency is refused even on a live link. */ status?: string; grants: string[]; contractorOrgBaaAttested: boolean; /** Whether this contractor takes the practice's support requests. Set by the contractor; absent reads as yes. */ handlesSupport?: boolean; /** * Present ONLY on a severed link, and its presence is what makes the row history rather than access. * The server keeps revoked rows on purpose - a practice asked in November about a contractor it fired * in March has nowhere else to look. */ revokedAt?: string; linkedAt?: string; } export type AssumeSubscriptionResult = | { /** Embedded Stripe Checkout, opened in the same modal the subscription page already uses. */ clientSecret: string; /** The practice's next two questions, answered by the server before they are asked. */ contractorRelationshipUnchanged?: boolean; currentSubscriptionRemainsLiveUntilPaid?: boolean; } | { /** * Nothing is billed on the sponsored subscription, so taking it back costs nothing: `confirm` asks the Owner * first, `completed` is done, and `sent` means Stripe ended it but the practice's record has not caught up. */ zeroCost: true; state: 'confirm' | 'completed' | 'sent'; message?: string; }; export const useOrganizationContractorsService = () => { const { get, post, delete: del } = useHttpService(); // Freeze the visible practice in the header; the server resolves its authority. const listContractors = useCallback( async (admit?: RequestAdmission, organizationId?: string): Promise<{ contractors: OrganizationContractorRow[] }> => get<{ contractors: OrganizationContractorRow[] }>(`${API_URL}/organization/contractors`, { ...(admit ? { admit } : {}), ...(organizationId ? { organizationId } : {}) }), [get], ); /** * END THIS AGENCY'S ACCESS. Unilateral, and it does NOT cancel a sponsored subscription - the handler * is explicit about that, because firing an agency must never silently suspend the practice's own * service. The link row survives with a timestamp rather than being deleted. */ const revokeContractor = useCallback( async (intent: PracticeRemovalIntent, mode: 'send' | 'status' | 'resume', admit: RequestAdmission): Promise => { admit(); const raw = await del(`${API_URL}/organization/contractors/${encodeURIComponent(intent.contractorId)}`, { body: JSON.stringify({ operationId: intent.operationId, ...(mode === 'send' ? {} : { action: mode }) }), organizationId: intent.organizationId, admit, }); admit('answer'); return parsePracticeRemoval(intent, raw); }, [del], ); /** * TAKE THE BILLING BACK, and this one is OWNER-ONLY - the designation on a person, never the Admin * role. The server refuses everyone else with `NOT_THE_OWNER`, precisely so that a contractor holding * an org Admin role cannot satisfy it. The client hides the control for the same reason; the server * is the guard. */ const assumeSubscription = useCallback( // `admit` is the caller's opening: checked again after the token and proof waits, so a request made for an // opening that has since closed is never sent, and its answer is never used. async (organizationId: string, returnUrl: string, confirmZeroCost = false, admit?: RequestAdmission): Promise => post( `${API_URL}/Organization/${organizationId}/subscription/assume`, confirmZeroCost ? { returnUrl, zeroCost: 'confirm' } : { returnUrl }, ...(admit ? [{ admit }] : []), ), [post], ); return { listContractors, revokeContractor, assumeSubscription }; };