import { useCallback, createContext, useContext, ReactNode } from 'react'; import { PractitionerRole, Practitioner, Organization, CodeableConcept, HealthcareServiceAvailableTime, HealthcareServiceNotAvailable, ContactPoint, Coding, Extension } from 'fhir/r4'; import { environment } from '../environments/environment.ts'; import { useFhirServiceContext } from './FhirService.tsx'; import { HttpError, useHttpService } from './HttpService.tsx'; import { formatFhirHumanName } from '../utils/fhirHumanName.tsx'; import { useAuthServiceContext } from './AuthService.tsx'; import { STAFF_PRIMARY_ROLE_EXTENSION_URL, STAFF_TRAINING_LEVEL_EXTENSION_URL, STAFF_WORKSPACE_EXTENSION_URL, STAFF_DEFAULT_WORKSPACE_EXTENSION_URL, SNOMED_CT_SYSTEM, getOrgRoleByCode, getPractitionerRoleLeadershipCodings, getPractitionerRoleOrganizationCodings, getPractitionerRolePrimaryCoding, getPractitionerRolePrimaryDisplay, getPractitionerRoleTrainingLevelCoding, getPractitionerRoleWorkspace, getPractitionerRoleWorkspaces, getPractitionerRoleDefaultWorkspace, getWorkspaceDisplay, getWorkspaceFormValue, getWorkspaceFormValues, getWorkspaceFromFormValue, } from '../utils/staffRoleCatalog.ts'; import { deriveAutoSpecialtyCodings } from '../utils/deriveAutoSpecialty.ts'; import { hasProtectedAdminIntent, isProtectedNonclinicalRole, clinicianSeatCreateResult, clinicianSeatCheckResult, ClinicianSeatResultUnconfirmed, isClinicianSeatOperationId, type ClinicianSeatCreateExpected, type KeyedClinicianSeatCreateResult, type ClinicianSeatCheckResult } from '../utils/clinicianSeatCreate'; import type { SaveTransport } from '../utils/saveOperation'; import { holdWrite, startWrite } from '../utils/writeThroughHold'; import { isClinicianEnabled } from '../utils/clinicianEnabled'; const API_URL = environment.fhirIdentityApi; // Auto-derived clinical specialties: roles carrying a snomedSpecialty (and NOT generic/specialtyRequired) // contribute a SNOMED specialty coding automatically. These helpers let both directions avoid (a) duplicating // them into user-entered specialties on save and (b) surfacing them in the editable field on load. // // (b)'s reason CHANGED with and the filtering is still correct: these codings used to carry no // `text`/`display` at all, so they rendered as empty chips that piled up on every save. They now carry the // role's display, so they are no longer blank - but they must STILL be excluded, because they are DERIVED // from the org roles and re-computed on every save. Surfacing them would invite a user to edit or delete a // value that the next save silently reinstates. const getAutoSpecialtyKeys = (roleCodings: Array<{ code?: string }>): Set => { const keys = new Set(); for (const roleCoding of roleCodings) { const entry = roleCoding?.code ? getOrgRoleByCode(roleCoding.code) : undefined; if (entry?.snomedSpecialty && !entry.specialtyRequired) { keys.add(`${SNOMED_CT_SYSTEM}|${entry.snomedSpecialty}`); } } return keys; }; const specialtyKey = (concept: CodeableConcept): string => { const coding = concept.coding?.[0]; return `${coding?.system ?? ''}|${coding?.code ?? ''}`; }; export interface ExtendedPractitionerRole { practitioner: Practitioner; practitionerRole: PractitionerRole; userGroup: string; } export interface ClinicianRoleCreateOptions extends ClinicianSeatCreateExpected { mode: 'check' | 'resume'; transport: SaveTransport; } interface PractitionerRoleDisplayOptions { includeStatus?: boolean; } export const usePractitionerRoleService = () => { const { getCognitoGroup } = useAuthServiceContext(); const { transformToCodeableConcept, transformFromCodeableConcept, addFhirPeriodIfApplicable, addPeriodControlsIfApplicable, createReferenceObject, transformToOperationalHours, transformToOperationalHoursNotAvailable, transformFromOperationalHours, transformFromOperationalHoursNotAvailable, transformToTelecom, transformFromTelecom } = useFhirServiceContext(); const { get, post, put, delete: del, fetchWithAuth, handleErrorResponse } = useHttpService(); const handleSeatErrorResponse = useCallback(async (response: Response, transport: SaveTransport): Promise => { transport.admit(); if (response.status !== 402) return handleErrorResponse(response); let body: unknown; try { body = await response.json(); } catch { transport.admit(); } transport.admit(); throw new HttpError(402, 'Clinician capacity needs checkout. Check this staff role operation before continuing.', body, response.headers.get('x-amzn-errortype') ?? undefined); }, [handleErrorResponse]); const createPractitionerRole = useCallback(async (extendedPractitionerRole: ExtendedPractitionerRole, transport?: SaveTransport): Promise => { const value = await post(`${API_URL}/PractitionerRole`, extendedPractitionerRole, transport); transport?.admit(); return clinicianSeatCreateResult(value).role; }, [post]); const createClinicianPractitionerRole = useCallback(async ( command: ExtendedPractitionerRole, options: ClinicianRoleCreateOptions, ): Promise => { try { command = JSON.parse(JSON.stringify(command)); options = { ...options, transport: { ...options.transport } }; } catch { throw new ClinicianSeatResultUnconfirmed(); } options.transport.admit(); const purpose = isClinicianEnabled(command.practitionerRole) ? 'clinical' : 'nonclinical'; if (!isClinicianSeatOperationId(options.operationId) || !['check', 'resume'].includes(options.mode) || options.purpose !== undefined && options.purpose !== purpose || purpose === 'nonclinical' && !isProtectedNonclinicalRole(command.practitionerRole, command.userGroup) || command.practitioner.id !== options.practitionerId || command.practitionerRole.practitioner?.reference !== `Practitioner/${options.practitionerId}` || command.practitionerRole.organization?.reference !== `Organization/${options.organizationId}`) throw new ClinicianSeatResultUnconfirmed(); const started = startWrite(); const response = await fetchWithAuth(`${API_URL}/PractitionerRole?seatOperation=${options.mode}`, { ...options.transport, organizationId: options.organizationId, method: 'POST', body: JSON.stringify(command), headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-Operation-Id': options.operationId }, }); try { await handleSeatErrorResponse(response, options.transport); } catch (error) { options.transport.admit(); throw error; } let value: unknown; try { value = await response.json(); } catch { options.transport.admit(); throw new ClinicianSeatResultUnconfirmed(); } options.transport.admit(); const result = clinicianSeatCreateResult(value, { ...options, purpose, nonclinicalGroup: command.userGroup, nonclinicalAdmin: hasProtectedAdminIntent(command.practitionerRole, command.userGroup), nonclinicalWorkspaces: getPractitionerRoleWorkspaces(command.practitionerRole, command.userGroup) }); options.transport.admit(); if (options.mode === 'resume' && result.role) holdWrite(result.role, Date.now(), started); return result; }, [fetchWithAuth, handleSeatErrorResponse]); const checkClinicianSeat = useCallback(async ( roleId: string, options: Pick & { transport: SaveTransport }, ): Promise => { options.transport.admit(); if (!/^[A-Za-z0-9.-]{1,64}$/.test(roleId)) throw new ClinicianSeatResultUnconfirmed(); const response = await fetchWithAuth(`${API_URL}/PractitionerRole/${roleId}/seat/check`, { ...options.transport, organizationId: options.organizationId, method: 'POST', body: '{}', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, }); try { await handleSeatErrorResponse(response, options.transport); } catch (error) { options.transport.admit(); throw error; } let value: unknown; try { value = await response.json(); } catch { options.transport.admit(); throw new ClinicianSeatResultUnconfirmed(); } options.transport.admit(); return clinicianSeatCheckResult(value, { ...options, roleId }); }, [fetchWithAuth, handleSeatErrorResponse]); const readPractitionerRole = useCallback(async (id: string): Promise => get(`${API_URL}/PractitionerRole/${id}`), [get]); /** `expectIncludedSeat`: the screen showed this seat as the free included one; the server refuses if it no longer is. */ const updatePractitionerRole = useCallback(async ( practitionerRole: PractitionerRole, options: { expectIncludedSeat?: boolean; expectedVersion?: string; organizationId?: string; transport?: SaveTransport } = {}, ): Promise => { options.transport?.admit(); if (options.expectedVersion !== undefined && !/^[A-Za-z0-9.-]{1,64}$/.test(options.expectedVersion)) throw new ClinicianSeatResultUnconfirmed(); const query = options.expectIncludedSeat ? '?expectSeat=included' : ''; const url = `${API_URL}/PractitionerRole/${practitionerRole.id}${query}`; if (!options.expectedVersion) return put(url, practitionerRole, { ...options.transport, ...(options.organizationId ? { organizationId: options.organizationId } : {}), }); const body = JSON.parse(JSON.stringify(practitionerRole)) as PractitionerRole; const practitionerId = /^Practitioner\/([A-Za-z0-9.-]{1,64})$/.exec(body.practitioner?.reference ?? '')?.[1]; const organizationId = options.organizationId; const transport = options.transport; if (!transport || !practitionerId || !organizationId || !body.id || body.organization?.reference !== `Organization/${organizationId}`) throw new ClinicianSeatResultUnconfirmed(); const standing = isClinicianEnabled(body) && body.active !== false ? 'enabled' : 'disabled'; const started = startWrite(); const response = await fetchWithAuth(url, { ...transport, organizationId, method: 'PUT', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'If-Match': `W/"${options.expectedVersion}"` } }); try { await handleSeatErrorResponse(response, transport); } catch (error) { transport.admit(); throw error; } let value: unknown; try { value = await response.json(); } catch { transport.admit(); throw new ClinicianSeatResultUnconfirmed(); } transport.admit(); const result = clinicianSeatCheckResult({ standing, role: value }, { roleId: body.id, organizationId, practitionerId }); if (!result.role) throw new ClinicianSeatResultUnconfirmed(); transport.admit(); holdWrite(result.role, Date.now(), started); return result.role; }, [put, fetchWithAuth, handleSeatErrorResponse]); const deletePractitionerRole = useCallback(async (practitionerRole: PractitionerRole): Promise => { return del(`${API_URL}/PractitionerRole/${practitionerRole.id}`); }, [del]); const transformToFhirPractitionerRole = useCallback((item: any, organization?: Organization, practitioner?: Practitioner): PractitionerRole => { const { active, code, primaryOrganizationRole, leadershipDesignations, trainingLevel, workspace, workspaces, defaultWorkspace, specialty, location, healthcareService, availabilityExceptions, availableTime, notAvailable, telecom, meta, extension } = item; const usCorePractitionerroleProfile: string = 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-practitionerrole'; const transformedAvailableTime = transformToOperationalHours(availableTime); const transformedNotAvailable = transformToOperationalHoursNotAvailable(notAvailable); const transformedRoleTelecom: ContactPoint[] = transformToTelecom(telecom); const practitionerTelecom: ContactPoint[] = (practitioner?.telecom ?? []) .filter((contactPoint: ContactPoint) => !!contactPoint.value); const organizationTelecom: ContactPoint[] = (organization?.telecom ?? []) .filter((contactPoint: ContactPoint) => !!contactPoint.value); const transformedTelecom: ContactPoint[] = transformedRoleTelecom.length !== 0 ? transformedRoleTelecom : practitionerTelecom.length !== 0 ? practitionerTelecom : organizationTelecom; const organizationRoles = Array.isArray(code) ? [...code] : []; const leadershipRoles = Array.isArray(leadershipDesignations) ? leadershipDesignations : []; const primaryRoleIndex = organizationRoles.findIndex( (role) => role?.code === primaryOrganizationRole, ); if (primaryRoleIndex > 0) { const [primaryRole] = organizationRoles.splice(primaryRoleIndex, 1); organizationRoles.unshift(primaryRole); } const combinedRoleCodes = [...organizationRoles, ...leadershipRoles]; const workspaceAssignmentProvided = Object.prototype.hasOwnProperty.call(item, 'workspace') || Object.prototype.hasOwnProperty.call(item, 'workspaces') || Object.prototype.hasOwnProperty.call(item, 'defaultWorkspace'); const preservedExtensions = ((extension ?? []) as Extension[]).filter( (itemExtension) => { if ( itemExtension.url === STAFF_PRIMARY_ROLE_EXTENSION_URL || itemExtension.url === STAFF_TRAINING_LEVEL_EXTENSION_URL ) { return false; } if ( workspaceAssignmentProvided && ( itemExtension.url === STAFF_WORKSPACE_EXTENSION_URL || itemExtension.url === STAFF_DEFAULT_WORKSPACE_EXTENSION_URL ) ) { return false; } return true; }, ); const requestedWorkspaceValues = Array.isArray(workspaces) ? workspaces : workspace ? [workspace] : []; const workspaceCodes = Array.from(new Set( requestedWorkspaceValues .map((value) => getWorkspaceFromFormValue(value) || value) .filter(Boolean), )); const requestedDefaultWorkspace = getWorkspaceFromFormValue(defaultWorkspace) || getWorkspaceFromFormValue(workspace) || defaultWorkspace || workspace; const resolvedDefaultWorkspace = requestedDefaultWorkspace && workspaceCodes.includes(requestedDefaultWorkspace) ? requestedDefaultWorkspace : workspaceCodes[0]; const primaryRole = organizationRoles.find( (role) => role?.code === primaryOrganizationRole, ) ?? organizationRoles[0]; const staffRoleExtensions: Extension[] = [ ...preservedExtensions, ...(primaryRole?.code ? [{ url: STAFF_PRIMARY_ROLE_EXTENSION_URL, valueCoding: { system: primaryRole.system, code: primaryRole.code, display: primaryRole.display, }, }] : []), ...(trainingLevel?.code ? [{ url: STAFF_TRAINING_LEVEL_EXTENSION_URL, valueCoding: { system: trainingLevel.system, code: trainingLevel.code, display: trainingLevel.display, }, }] : []), ...(workspaceAssignmentProvided ? workspaceCodes.map((workspaceCode) => ({ url: STAFF_WORKSPACE_EXTENSION_URL, valueCode: workspaceCode, })) : []), ...(workspaceAssignmentProvided && resolvedDefaultWorkspace ? [{ url: STAFF_DEFAULT_WORKSPACE_EXTENSION_URL, valueCode: resolvedDefaultWorkspace, }] : []), ]; // Auto-populate PractitionerRole.specialty from the catalog's snomedSpecialty mapping. // Roles with specialtyRequired: true are generic (e.g. "Physician") and need the user // to choose the specialty explicitly - those values come from the form's specialty field. // - the auto-derive lives in utils/deriveAutoSpecialty.ts so the regression test can // exercise the REAL writer instead of a hand-authored fixture. That is precisely how this bug // shipped: every existing test built its own specialty object, so nothing ever asserted on what // this code actually emitted, and it emitted a bare code with no `text` and no `coding.display`. // Every consumer reads `text || coding[0].display`, so the specialty resolved to '' for the 44 of // 46 catalog roles that auto-derive - which silently made recommender inert // (`qualifiesForRecommendation` needs >= 2 DISTINCT non-empty specialties, so the client never // called the endpoint and Bedrock was never invoked). const autoSpecialtyCodings: CodeableConcept[] = deriveAutoSpecialtyCodings(organizationRoles); const userSpecialtyCodings = (specialty && specialty.length !== 0) ? (transformToCodeableConcept(specialty) as CodeableConcept[]) : []; // Keep user-entered specialties only if they have a real code and aren't already auto-derived - this // stops the auto specialty (re-added below) from duplicating and piling up blank tokens on every save. const autoSpecialtyKeys = new Set(autoSpecialtyCodings.map(specialtyKey)); const userSpecialtyFiltered = userSpecialtyCodings.filter((concept) => { const coding = concept.coding?.[0]; return Boolean(coding?.code) && !autoSpecialtyKeys.has(specialtyKey(concept)); }); const combinedSpecialty = [...autoSpecialtyCodings, ...userSpecialtyFiltered]; // Role catalog options carry non-FHIR metadata (nucc, snomedSpecialty, specialtyRequired) used // only for form logic + the specialty auto-population above. Strip to valid Coding fields so they // never leak into PractitionerRole.code - HealthLake rejects unrecognised properties ('@nucc'...). const sanitizedRoleCodes = combinedRoleCodes.map((roleCode) => ({ system: roleCode.system, code: roleCode.code, display: roleCode.display, })); const practitionerRole: PractitionerRole = { resourceType: 'PractitionerRole', active: active, meta: { ...meta, profile: Array.from(new Set([ ...(meta?.profile ?? []), usCorePractitionerroleProfile, ])), }, ...(staffRoleExtensions.length !== 0 && { extension: staffRoleExtensions }), ...(practitioner && { practitioner: createReferenceObject('Practitioner', practitioner.id as string, formatFhirHumanName(practitioner.name)) }), ...(organization && { organization: createReferenceObject('Organization', organization.id as string, organization.name) }), ...(location && location.length !== 0 && { location: location }), ...(healthcareService && healthcareService.length !== 0 && { healthcareService: healthcareService }), ...(sanitizedRoleCodes.length !== 0 && { code: transformToCodeableConcept(sanitizedRoleCodes) as CodeableConcept[] }), ...(combinedSpecialty.length !== 0 && { specialty: combinedSpecialty }), ...(availabilityExceptions && { availabilityExceptions: availabilityExceptions }), ...(transformedAvailableTime.length !== 0 && { availableTime: transformedAvailableTime }), ...(transformedNotAvailable.length !== 0 && { notAvailable: transformedNotAvailable }), ...(transformedTelecom.length !== 0 && { telecom: transformedTelecom }) }; return addFhirPeriodIfApplicable(practitionerRole, item) as PractitionerRole; }, [ transformToCodeableConcept, addFhirPeriodIfApplicable, createReferenceObject, transformToOperationalHours, transformToOperationalHoursNotAvailable, transformToTelecom ]); const transformFromFhirPractitionerRole = useCallback((practitionerRole: PractitionerRole): any => { const { active, specialty, location, healthcareService, availabilityExceptions, availableTime, notAvailable, meta, extension } = practitionerRole; const allDayCoverage = new Set(); (availableTime ?? []).forEach((entry) => { if (entry.allDay !== true) { return; } (entry.daysOfWeek ?? []).forEach((day) => allDayCoverage.add(day)); }); const alwaysAvailable = !availableTime?.length || ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'].every((day) => allDayCoverage.has(day)); const toFormRole = (coding: Coding) => ({ system: coding.system ?? '', code: coding.code ?? '', display: coding.display ?? coding.code ?? 'Unknown role', }); const organizationRoleCodes = getPractitionerRoleOrganizationCodings(practitionerRole) .map(toFormRole); // Auto-derived specialties are re-computed on save, so keep them out of the editable field. (They are // no longer blank since gave them a `text`; the reason to exclude them is that they are // derived, not that they render empty - see the note on getAutoSpecialtyKeys above.) const autoSpecialtyKeysForForm = getAutoSpecialtyKeys(organizationRoleCodes); const leadershipRoleCodes = getPractitionerRoleLeadershipCodings(practitionerRole) .map(toFormRole); const primaryRoleCoding = getPractitionerRolePrimaryCoding(practitionerRole); const trainingLevelCoding = getPractitionerRoleTrainingLevelCoding(practitionerRole); const formData = { meta: meta, extension: extension ?? [], active: active ?? true, code: organizationRoleCodes, primaryOrganizationRole: primaryRoleCoding?.code ?? organizationRoleCodes[0]?.code ?? '', leadershipDesignations: leadershipRoleCodes, trainingLevel: trainingLevelCoding ? toFormRole(trainingLevelCoding) : null, specialty: transformFromCodeableConcept( (specialty ?? []).filter((concept) => !autoSpecialtyKeysForForm.has(specialtyKey(concept))), ), location: location ?? [], allLocations: !location?.length, healthcareService: healthcareService ?? [], availabilityExceptions: availabilityExceptions ?? '', alwaysAvailable: alwaysAvailable, availableTime: transformFromOperationalHours(availableTime as HealthcareServiceAvailableTime[]), notAvailable: transformFromOperationalHoursNotAvailable(notAvailable as HealthcareServiceNotAvailable[]), telecom: transformFromTelecom(practitionerRole), workspaces: getWorkspaceFormValues( getPractitionerRoleWorkspaces( practitionerRole, getCognitoGroup(practitionerRole), ), ), defaultWorkspace: getWorkspaceFormValue( getPractitionerRoleDefaultWorkspace( practitionerRole, getCognitoGroup(practitionerRole), ), ), // Legacy alias retained for forms that still expect a single value. role: getWorkspaceFormValue( getPractitionerRoleWorkspace(practitionerRole, getCognitoGroup(practitionerRole)), ) }; return addPeriodControlsIfApplicable(formData, practitionerRole); }, [ transformFromCodeableConcept, addPeriodControlsIfApplicable, transformFromOperationalHours, transformFromOperationalHoursNotAvailable, transformFromTelecom, getCognitoGroup ]); const getPractitionerRoleDisplay = ( practitionerRole: PractitionerRole, options: PractitionerRoleDisplayOptions = {} ): string => { if (!practitionerRole) { return 'Unknown Practitioner Role'; } const practitionerName = practitionerRole.practitioner?.display || practitionerRole.practitioner?.reference?.split('/').pop() || 'Unknown Practitioner'; const cognitoGroup = getCognitoGroup(practitionerRole); const workspace = getPractitionerRoleWorkspace(practitionerRole, cognitoGroup); const workspaceLabel = getWorkspaceDisplay(workspace); const role = getPractitionerRolePrimaryDisplay(practitionerRole, workspaceLabel || 'Staff'); const status = options.includeStatus === false ? '' : practitionerRole.active !== false ? ' [Active]' : ' [Inactive]'; return `${practitionerName}${role ? ` - ${role}` : ''}${status}`; }; return { createPractitionerRole, createClinicianPractitionerRole, checkClinicianSeat, updatePractitionerRole, readPractitionerRole, deletePractitionerRole, transformToFhirPractitionerRole, transformFromFhirPractitionerRole, getPractitionerRoleDisplay }; }; const PractitionerRoleServiceContext = createContext | undefined>(undefined); export const PractitionerRoleServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const practitionerRoleService = usePractitionerRoleService(); return ( {children} ); }; export const usePractitionerRoleServiceContext = () => { const context = useContext(PractitionerRoleServiceContext); if (context === undefined) { throw new Error('usePractitionerRoleServiceContext must be used within a PractitionerRoleServiceProvider'); } return context; };