import { useCallback, createContext, useContext, ReactNode } from 'react'; import { environment } from '../environments/environment.ts'; import { useHttpService } from './HttpService.tsx'; import { buildInviteRequest } from './collaborationTeam'; import type { SaveTransport } from '../utils/saveOperation'; import type { CollaborationTeam, TeamMembership, MemberClass, InviteMemberRequest, ShownIdentity, } from './collaborationTeam'; export type { CollaborationTeam, RemovedTeamMember, TeamMembership, MembershipState, TeamStatus, MemberClass } from './collaborationTeam'; export type { ShownIdentity } from './collaborationTeam'; export { STATE_LABELS, STATUS_LABELS, TEAM_STATUS_TONE, buildInviteRequest, cameFromOutsideInvite, decisionKeyOf, shownIdentityOf } from './collaborationTeam'; export type { ReferralAuthorizationRequest } from './referralAuthorization'; export type { ResolveInviteeQuery, ExternalInviteRequest } from './collaborationTeamExternalInvite'; export { buildResolveInviteeQuery, buildExternalInviteRequest } from './collaborationTeamExternalInvite'; export { REFERRAL_AUTH_VERSION, REFERRAL_AUTH_HEADLINE, REFERRAL_AUTH_SUMMARY, buildReferralAuthorization, } from './referralAuthorization'; import type { ReferralAuthorizationRequest } from './referralAuthorization'; import type { ResolveInviteeQuery, ExternalInviteRequest } from './collaborationTeamExternalInvite'; /** `{ found: false }` on EVERY deny path - see resolveInvitee below. */ export interface ResolveInviteeResponse { found: boolean; // THE FIELD NAME IS THE SERVER'S, AND IT IS `memberRef`. It arrives as a whole reference, // `Practitioner/{id}`, which is also the shape the member invite wants - so it is passed // through rather than taken apart and put back together. // // This said `practitionerId` and the server has never sent one. Optional plus a name nobody // writes reads as `undefined` for ever, which is not a type error anywhere: the branch that // needed it simply took the other road, and a practitioner already on Akashic was invited // down the not-on-Akashic path. That answers 400 for want of an NPI the screen never asks // for, and the person sees "Failed to send the invite. Please try again." memberRef?: string; name?: string; } const API_URL = environment.fhirClinicalApi; export type TeamAction = 'rename' | 'withdraw' | 'rejoin' | 'dissolve' | 'reactivate'; export type MemberActionType = 'approve' | 'deny' | 'accept' | 'decline' | 'leave' | 'remove'; export interface TeamActionRequest { action: TeamAction; basis: number; decisionId: string; name?: string; } export interface TeamActionResponse { team: CollaborationTeam; decision: 'committed' | 'adopted'; cleanup: 'complete' | 'owed'; } export interface ResendInvitationResponse { status: string; delivery?: 'queued' | 'not-sent' | 'unknown'; invitationGeneration?: string; } export interface ListTeamsResponse { teams: Array; } // A decision's request carries the operation that owns it, so a retired one is never sent or answered. const sentBy = (transport?: SaveTransport): [] | [{ admit: () => void; signal: AbortSignal }] => transport ? [{ admit: transport.admit, signal: transport.signal }] : []; // Thin client for the patient-owned collaboration-team sub-route (C1a plan, Task 7). A patient creates a // collaboration team and invites related persons/practitioners onto it; practitioners can also invite on // the patient's behalf, subject to server-side approval. The routes live in the 03-fhir-clinical infra // stack, so the base is environment.fhirClinicalApi (like CareTeamService.tsx) - NOT fhirBaseApi. Mirrors // the Context/Provider/hook triple shape from CareTeamService.tsx:277-297. export const useCollaborationTeamService = () => { const { get, post, put } = useHttpService(); const createTeam = useCallback( (name: string): Promise => post(`${API_URL}/collaboration-team`, { name }), [post], ); // `request`: the reader's admission and cancellation, when it has an owner; a plain read without one. const listTeams = useCallback(async (request: { admit?: () => void; signal?: AbortSignal } = {}): Promise> => { const response = await get(`${API_URL}/collaboration-team`, request); return response.teams ?? []; }, [get]); const teamAction = useCallback( (teamId: string, request: TeamActionRequest): Promise => put(`${API_URL}/collaboration-team/${teamId}`, request), [put], ); // `authorization` ( S1c-c) is REQUIRED by the server for a patient-initiated PRACTITIONER // invite and rejected as unnecessary for a related person - the referral Consent that needs the // patient's agreement is only ever minted for a practitioner. Optional here so the related-person // path stays a plain call; omitting it on a practitioner invite gets a 400, by design. const inviteMember = useCallback( ( teamId: string, { memberRef, memberClass, role, authorization }: { memberRef: string; memberClass: MemberClass; role?: string; authorization?: ReferralAuthorizationRequest; }, ): Promise => post( `${API_URL}/collaboration-team/${teamId}/members`, { ...(buildInviteRequest(memberRef, memberClass, role) as InviteMemberRequest), ...(authorization ? { authorization } : {}), }, ), [post], ); // Flat route: teamId/memberRef/action all go in the body, NOT a nested path. // // `authorization` is REQUIRED by the server on `approve` for a practitioner member and ignored on // every other action - approving is one of the two moments the patient authorizes the grant. const memberAction = useCallback( ( teamId: string, memberRef: string, action: MemberActionType, authorization?: ReferralAuthorizationRequest, shownIdentity?: ShownIdentity, transport?: SaveTransport, ): Promise => put(`${API_URL}/collaboration-team-member`, { teamId, memberRef, action, ...(authorization ? { authorization } : {}), ...(shownIdentity ? { shownIdentity } : {}), }, ...sentBy(transport)), [put], ); // The patient gives an active member the access their invitation never recorded. Bound to the row // revision the patient was shown, so a changed or removed member is refused rather than revived. const authorizeMember = useCallback( ( teamId: string, member: Pick, authorization: ReferralAuthorizationRequest, transport?: SaveTransport, ): Promise => put(`${API_URL}/collaboration-team-member`, { teamId, memberRef: member.memberRef, action: 'authorize', rowRevision: member.rowRevision, authorization, }, ...sentBy(transport)), [put], ); // S1a - resolve a practitioner the inviter knows only by email or NPI. // // ANTI-ORACLE: every deny path returns the SAME `{ found: false }` and status, so there is // deliberately nothing here to distinguish "no such practitioner" from "not permitted to look". // Do not add error handling that tries to tell them apart - there is no signal, by design. const resolveInvitee = useCallback( (query: ResolveInviteeQuery): Promise => post(`${API_URL}/collaboration-team-resolve-invitee`, query), [post], ); // S1b - invite someone who is NOT on Akashic at all. Creates nothing for them: just a // pending row plus an emailed single-use token they use to claim an account. `authorization` is // required when the PATIENT initiates (see collaborationTeamExternalInvite.ts). const inviteExternalPractitioner = useCallback( (request: ExternalInviteRequest): Promise<{ status: string; state: string }> => post<{ status: string; state: string }>(`${API_URL}/collaboration-team-external-invite`, request), [post], ); // Re-mint and re-send a lost or expired invite. Deliberately separate from the invite itself: // re-inviting an outstanding invite 409s (conditional write), so this is the ONLY route back. const resendExternalInvite = useCallback( (teamId: string, email: string, invitationGeneration: string): Promise => post(`${API_URL}/collaboration-team-external-invite-resend`, { teamId, email, invitationGeneration }), [post], ); // Takes back an emailed invitation not yet accepted; the server drops its token, so the link stops working. const withdrawExternalInvite = useCallback( (teamId: string, memberRef: string, invitationGeneration: string): Promise => put(`${API_URL}/collaboration-team-member`, { teamId, memberRef, action: 'withdraw', invitationGeneration }), [put], ); // S1c-c-2 - the IN-OFFICE path: a practitioner records that the patient authorized // this referral in person. Attesting IS the approval, so there is no separate approve call. // The witness is taken from the authenticated caller server-side and is never sent from here. const attestMemberAuthorization = useCallback( ( teamId: string, memberRef: string, agreedVersion: number, shownIdentity?: ShownIdentity, transport?: SaveTransport, ): Promise<{ status: string; state: string }> => post<{ status: string; state: string }>(`${API_URL}/collaboration-team-member-attest`, { teamId, memberRef, agreedVersion, ...(shownIdentity ? { shownIdentity } : {}), }, ...sentBy(transport)), [post], ); return { createTeam, listTeams, teamAction, inviteMember, memberAction, authorizeMember, resolveInvitee, inviteExternalPractitioner, resendExternalInvite, withdrawExternalInvite, attestMemberAuthorization, }; }; const CollaborationTeamServiceContext = createContext | undefined>(undefined); export const CollaborationTeamServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const collaborationTeamService = useCollaborationTeamService(); return ( {children} ); }; export const useCollaborationTeamServiceContext = () => { const context = useContext(CollaborationTeamServiceContext); if (context === undefined) { throw new Error('useCollaborationTeamServiceContext must be used within a CollaborationTeamServiceProvider'); } return context; };