import { useCallback } from 'react'; import { environment } from '../environments/environment.ts'; import type { PractitionerRole } from 'fhir/r4'; import { HttpError, useHttpService } from './HttpService.tsx'; import type { SaveTransport } from '../utils/saveOperation'; import { newInvitationOperationId } from '../utils/staff/invitationOutcome'; /** * INVITE-FIRST CLINICIANS (akashic-docs identity/invite-first-clinician-contract.md). * * The practice invites a work inbox; nothing is created for the person until they sign in and accept. * None of these calls assigns a clinician seat: that stays the staff record's explicit act once the * person holds a Practitioner role here. * * These routes answer on the org-authorization gateway, not the FHIR base one. */ const API_URL = environment.orgAuthorizationApi; export type ClinicianInvitationStatus = 'pending' | 'expired' | 'accepted' | 'revoked'; // 'unconfirmed': an email was asked for and the queue's answer is not known. Queued is never delivered. export type ClinicianInvitationDelivery = 'queued' | 'failed' | 'not-sent' | 'draft' | 'unconfirmed'; export interface ClinicianInvitation { id: string; firstname: string; lastname: string; email: string; npi?: string; kind?: 'clinician' | 'staff'; /** The role a staff invitation will bind on acceptance; absent for clinician invitations. */ practitionerRole?: PractitionerRole; status: ClinicianInvitationStatus; delivery: ClinicianInvitationDelivery; attention?: 'identity-conflict'; createdAt: string; sentAt?: string; expiresAt: string; acceptedAt?: string; practitionerId?: string; practitionerRoleId?: string; revokedAt?: string; } export type InvitationSendState = 'not-requested' | 'not-sent' | 'queued' | 'unconfirmed'; /** What one create or resend did, under the operation the client named: whether it saved, and what about the email. */ export interface InvitationReceipt { operationId: string; /** Local interpretation of an older answer, never a native send attempt. */ compatibility?: 'legacy-not-sent'; save: 'saved' | 'unconfirmed'; invitationId?: string; standing?: 'created' | 'reused' | 'resent'; send: { state: InvitationSendState; attemptId?: string }; } export interface InvitationOutcome { receipt: InvitationReceipt; /** The invitation as the server reads it now, when it could be read. */ invitation?: ClinicianInvitation; /** An open invitation to the same inbox was reused: nothing new was sent. */ existing: boolean; } /** A successful answer that does not say what happened: treated like a lost answer, never as done or as empty. */ export class InvitationAnswerUnreadable extends Error { constructor() { super('The invitation answer could not be read.'); this.name = 'InvitationAnswerUnreadable'; } } const STATES: InvitationSendState[] = ['not-requested', 'not-sent', 'queued', 'unconfirmed']; const STATUSES: ClinicianInvitationStatus[] = ['pending', 'expired', 'accepted', 'revoked']; const DELIVERIES: ClinicianInvitationDelivery[] = ['queued', 'failed', 'not-sent', 'draft', 'unconfirmed']; const isInvitation = (value: unknown): value is ClinicianInvitation => { const invitation = value as Partial | undefined; return typeof invitation?.id === 'string' && STATUSES.includes(invitation.status as ClinicianInvitationStatus) && DELIVERIES.includes(invitation.delivery as ClinicianInvitationDelivery) && typeof invitation.expiresAt === 'string'; }; const readReceipt = (value: unknown): InvitationReceipt | undefined => { const receipt = value as Partial | undefined; if (typeof receipt?.operationId !== 'string' || (receipt.save !== 'saved' && receipt.save !== 'unconfirmed')) return undefined; if (receipt.compatibility !== undefined) return undefined; if (!receipt.send || !STATES.includes(receipt.send.state)) return undefined; if (receipt.invitationId !== undefined && (typeof receipt.invitationId !== 'string' || !receipt.invitationId.trim())) return undefined; if (receipt.standing !== undefined && !['created', 'reused', 'resent'].includes(receipt.standing)) return undefined; if (receipt.send.attemptId !== undefined && (typeof receipt.send.attemptId !== 'string' || !receipt.send.attemptId.trim())) return undefined; if (receipt.save === 'saved' && typeof receipt.invitationId !== 'string') return undefined; // Queue admission and permanent custody are separate facts. Queued names its attempt; reuse requests no send. if (receipt.send.state === 'queued' && typeof receipt.send.attemptId !== 'string') return undefined; if (receipt.standing === 'reused' && receipt.send.state !== 'not-requested') return undefined; if (receipt.invitationId === undefined && (receipt.standing !== undefined || receipt.send.attemptId !== undefined || receipt.send.state === 'queued' || receipt.send.state === 'not-sent')) return undefined; if (receipt.invitationId !== undefined && receipt.standing === undefined) return undefined; if (receipt.send.state === 'not-requested' && receipt.send.attemptId !== undefined) return undefined; if (receipt.send.state === 'not-sent' && receipt.send.attemptId === undefined) return undefined; return { operationId: receipt.operationId, save: receipt.save, ...(typeof receipt.invitationId === 'string' && { invitationId: receipt.invitationId }), ...(receipt.standing && ['created', 'reused', 'resent'].includes(receipt.standing) && { standing: receipt.standing }), send: { state: receipt.send.state, ...(typeof receipt.send.attemptId === 'string' && { attemptId: receipt.send.attemptId }) }, }; }; /** * The outcome an answer states. An older server gives no receipt: a reuse is a reuse, a draft asked for nothing and * a "not sent" was not sent, but nothing it says proves this press's email was queued, so that stays unconfirmed. */ export const invitationOutcomeOf = (body: unknown, operationId: string, kind: 'create' | 'resend' | 'check', originalInvitationId?: string): InvitationOutcome => { const answer = (body ?? {}) as { invitation?: unknown; receipt?: unknown; existing?: unknown }; const invitation = isInvitation(answer.invitation) ? answer.invitation : undefined; const receipt = readReceipt(answer.receipt); if (receipt) { // The answer is this press's only if it names this press's operation, and its invitation is the receipt's. if (receipt.operationId !== operationId) throw new InvitationAnswerUnreadable(); if (originalInvitationId && receipt.invitationId !== originalInvitationId) throw new InvitationAnswerUnreadable(); if ((kind === 'create' && receipt.standing === 'resent') || (kind === 'resend' && receipt.standing && receipt.standing !== 'resent')) throw new InvitationAnswerUnreadable(); if (answer.existing === true && receipt.standing !== 'reused') throw new InvitationAnswerUnreadable(); if (answer.invitation !== undefined && (!invitation || invitation.id !== receipt.invitationId)) throw new InvitationAnswerUnreadable(); return { receipt, ...(invitation && { invitation }), existing: answer.existing === true || receipt.standing === 'reused' }; } // A receipt that is there but cannot be read is not an older server's answer. if (kind === 'check' || !invitation || answer.receipt !== undefined) throw new InvitationAnswerUnreadable(); if (originalInvitationId && invitation.id !== originalInvitationId) throw new InvitationAnswerUnreadable(); const existing = kind === 'create' && answer.existing === true; const state: InvitationSendState = existing || invitation.delivery === 'draft' ? 'not-requested' : invitation.delivery === 'not-sent' ? 'not-sent' : 'unconfirmed'; return { receipt: { operationId, save: 'saved', invitationId: invitation.id, standing: existing ? 'reused' : kind === 'resend' ? 'resent' : 'created', send: { state }, ...(state === 'not-sent' && { compatibility: 'legacy-not-sent' as const }) }, invitation, existing, }; }; export { newInvitationOperationId }; export interface NewClinicianInvitation { firstname: string; lastname: string; email: string; npi?: string; personalMobile?: string; kind?: 'clinician' | 'staff'; practitionerRole?: PractitionerRole; } export interface InvitationPreview { organizationName: string; kind?: 'clinician' | 'staff'; firstname: string; lastname: string; invitedEmail: string; signedInAsInvitedEmail: boolean; status: 'pending' | 'accepted'; } export type AcceptOutcome = // signIn: the one-time staff sign-in secret an accept returns, so the invite page reaches the workspace // without a second emailed code. Null or absent: the page sends them to the staff sign-in. | { kind: 'accepted'; organizationId: string; practitionerId?: string; practitionerRoleId?: string; signIn?: { secret: string } | null } | { kind: 'code-required'; invitedEmail: string } | { kind: 'refused'; reason: string; message: string; detail?: string }; const reasonOf = (error: unknown): { reason?: string; message?: string; invitedEmail?: string; detail?: string } => error instanceof HttpError && error.body && typeof error.body === 'object' ? error.body as { reason?: string; message?: string; invitedEmail?: string; detail?: string } : {}; export const useClinicianInvitationService = () => { const { get, post } = useHttpService(); const transportOptions = (transport?: SaveTransport) => (transport ? { admit: transport.admit, signal: transport.signal, ...(transport.onSend && { onSend: transport.onSend }) } : {}); // A list body without its list, or with anything in it that is not an invitation, is not an empty or a complete // practice: it is unreadable, and never recovery evidence. const listInvitations = useCallback(async (options: { transport?: SaveTransport } = {}): Promise => { const response = await get<{ invitations?: unknown }>(`${API_URL}/clinician-invitations`, transportOptions(options.transport)); if (!Array.isArray(response?.invitations) || !response.invitations.every(isInvitation)) throw new InvitationAnswerUnreadable(); return response.invitations; }, [get]); /** * An open invitation to the same inbox comes back as `existing` and nothing is sent again. * `send: false` saves a draft; resend sends it later. The operation names this press, so its result can be read * back with `checkInvitationOperation` if the answer is lost. */ const createInvitation = useCallback( async ( invitation: NewClinicianInvitation, options: { send?: boolean; operationId?: string; originalInvitationId?: string; transport?: SaveTransport } = {}, ): Promise => { const operationId = options.operationId ?? newInvitationOperationId(); const response = await post( `${API_URL}/clinician-invitations`, { ...invitation, ...(options.send === false && { send: false }), operationId, commandProtocol: 2, ...(options.originalInvitationId && { originalInvitationId: options.originalInvitationId }) }, transportOptions(options.transport), ); options.transport?.admit(); return invitationOutcomeOf(response, operationId, 'create', options.originalInvitationId); }, [post], ); /** A new link for the same invitation, as its own named attempt; the old link stops working. */ const resendInvitation = useCallback( async (id: string, options: { operationId?: string; transport?: SaveTransport } = {}): Promise => { const operationId = options.operationId ?? newInvitationOperationId(); const response = await post(`${API_URL}/clinician-invitations/${id}/resend`, { operationId }, transportOptions(options.transport)); options.transport?.admit(); return invitationOutcomeOf(response, operationId, 'resend', id); }, [post], ); /** What one earlier create or resend did, read only: never sends, rotates or saves anything. */ const checkInvitationOperation = useCallback(async (operationId: string, options: { originalInvitationId?: string; transport?: SaveTransport } = {}): Promise => { const answer = await get(`${API_URL}/clinician-invitations?operation=${encodeURIComponent(operationId)}`, transportOptions(options.transport)); options.transport?.admit(); return invitationOutcomeOf(answer, operationId, 'check', options.originalInvitationId); }, [get]); const revokeInvitation = useCallback(async (id: string, options: { transport?: SaveTransport } = {}): Promise => (await post<{ invitation: ClinicianInvitation }>(`${API_URL}/clinician-invitations/${id}/revoke`, {}, transportOptions(options.transport))).invitation, [post]); const previewInvitation = useCallback(async (token: string): Promise => post(`${API_URL}/clinician-invitations/preview`, { token }), [post]); /** The server's refusals are part of the flow (a code to enter, an expired link), so they are returned. */ const acceptInvitation = useCallback(async (token: string, code?: string, signal?: AbortSignal): Promise => { try { const response = await post<{ organizationId: string; practitionerId?: string; practitionerRoleId?: string }>( `${API_URL}/clinician-invitations/accept`, code ? { token, code } : { token }, signal ? { signal } : undefined, ); return { kind: 'accepted', ...response }; } catch (error) { const body = reasonOf(error); if (body.reason === 'email-verification-required') { return { kind: 'code-required', invitedEmail: body.invitedEmail ?? '' }; } if (body.reason) { return { kind: 'refused', reason: body.reason, message: body.message ?? 'This invitation could not be accepted.', ...(typeof body.detail === 'string' && body.detail ? { detail: body.detail } : {}), }; } throw error; } }, [post]); /** "Make myself the first clinician": a Practitioner role for the signed-in administrator, no seat. */ const addMyPractitionerRole = useCallback(async (npi?: string, practitionerRole?: PractitionerRole) => post<{ practitionerId: string; practitionerRoleId: string; reused: boolean }>( `${API_URL}/my-practitioner-role`, { ...(npi && { npi }), ...(practitionerRole && { practitionerRole }) }, ), [post]); return { listInvitations, createInvitation, resendInvitation, checkInvitationOperation, revokeInvitation, previewInvitation, acceptInvitation, addMyPractitionerRole, }; };