import { useCallback, useMemo } from 'react'; import { environment } from '../environments/environment.ts'; import { HttpError, useHttpService } from './HttpService.tsx'; import { describeServerRefusal } from '../utils/serverRefusalMessage'; import { useAuthServiceContext } from './AuthService.tsx'; import type { ReferralRequestScope } from '../utils/referralRequestScope'; const API_URL = environment.orgAuthorizationApi; // Wire types mirror the server's packet module (infra src/utils/questionnairePacket.ts is authoritative). export type PacketStatus = 'draft' | 'active' | 'retired'; export type PacketTrigger = 'off' | 'all-appointments' | 'new-patient-appointments'; export type PacketPriority = 'routine' | 'urgent' | 'asap' | 'stat'; /** An implementation limit the server also enforces, not an entitlement. */ export const MAX_PACKET_MEMBERS = 20; export const MAX_PACKET_TITLE = 120; export interface PacketInput { title: string; status: PacketStatus; members: string[]; trigger: PacketTrigger; priority: PacketPriority; } export interface QuestionnairePacket { id: string; version?: string; title: string; status: PacketStatus; members: { questionnaireId: string; title?: string }[]; trigger: PacketTrigger; priority: PacketPriority; updatedAt?: string; } /** What a staff member asks for: one packet for one patient, due at a visit, on a date, or neither. */ export interface PacketAssignmentRequest { packetId: string; priority: PacketPriority; dueDate?: string; appointmentId?: string; } /** The assignment as the server recorded it, with its forms in the packet's order as they were when assigned. */ export interface PacketAssignment { id: string; status: string; title?: string; members: { taskId?: string; questionnaireId?: string; version?: string; title?: string; status?: string }[]; } /** Whether the patient's email was queued; anything but 'queued' is still owed and sent at most once. */ export type PacketNotification = 'queued' | 'not-queued' | 'unknown' | 'pending'; export interface PacketAssignmentAnswer { assignment: PacketAssignment; notification: PacketNotification; } /** A form's place in an assigned packet. Only a submitted form is done; an unavailable one still counts toward the total. */ export type PacketMemberState = 'submitted' | 'open' | 'withdrawn' | 'unavailable'; export interface PacketSummaryMember { position: number; state: PacketMemberState; title?: string; taskId?: string; questionnaireId?: string; version?: string; } /** One assigned packet as the server projects it for this reader, its forms in their assigned order. */ export interface PacketSummary { id: string; title?: string; organizationId?: string; assignedOn?: string; publishedAt?: string; due?: { mode?: string; date?: string; appointmentId?: string }; lifecycle: 'open' | 'completed' | 'cancelled'; progress: { submitted: number; total: number; unavailable: number }; members: PacketSummaryMember[]; } export interface PacketSummaryList { assignments: PacketSummary[]; /** False when the server stopped before reading every assignment, so one missing here may still exist. */ complete: boolean; } export interface PacketList { packets: QuestionnairePacket[]; /** False when the server could not read every page, so a missing packet is not proof there is none. */ complete: boolean; } /** Another author saved first; `current` is the packet as it is now, when the server could read it. */ export class PacketChangedError extends Error { constructor(public current?: QuestionnairePacket) { super('This packet changed since you opened it.'); this.name = 'PacketChangedError'; } } type PacketRefusalBody = { code?: unknown; message?: unknown; current?: unknown }; const packetBody = (error: unknown): PacketRefusalBody | undefined => { const body = error instanceof HttpError ? error.body : undefined; return body && typeof body === 'object' ? body as PacketRefusalBody : undefined; }; const SERVER_WORDED = /^(PACKET_|ASSIGNMENT_|APPOINTMENT_|OPERATION_)/; /** The server's packet refusals are written for the author; anything else gets the shared wording. */ export const describePacketRefusal = (error: unknown, context: string): string => { const body = packetBody(error); if (typeof body?.code === 'string' && SERVER_WORDED.test(body.code) && typeof body.message === 'string') { return body.message; } return describeServerRefusal(error, context); }; /** The practice packet requests are pinned to. Without a signed-in provider there is none to pin, and none is sent. */ export const usePacketPractice = (): string | undefined => { try { return useAuthServiceContext().wpOrganization?.current?.id; } catch { return undefined; } }; // Every request belongs to the opening that made it: admitted after the token wait and before its answer is used, // cancelled when that opening retires, and sent for the practice it was started in rather than the current one. const opened = (scope?: ReferralRequestScope) => (scope ? { admit: scope.admit, signal: scope.signal, organizationId: scope.organizationId } : {}); export const useQuestionnairePacketService = () => { const { get, post, put } = useHttpService(); const listPackets = useCallback( (scope?: ReferralRequestScope) => get(`${API_URL}/questionnaire-packet`, opened(scope)), [get], ); const createPacket = useCallback(async (input: PacketInput, scope?: ReferralRequestScope) => { const answer = await post<{ packet: QuestionnairePacket }>(`${API_URL}/questionnaire-packet`, input, opened(scope)); return answer.packet; }, [post]); // An edit names the version it was made from, so a second author's save is never overwritten unseen. const updatePacket = useCallback(async (id: string, version: string, input: PacketInput, scope?: ReferralRequestScope) => { try { const answer = await put<{ packet: QuestionnairePacket }>( `${API_URL}/questionnaire-packet/${encodeURIComponent(id)}`, input, { ...opened(scope), headers: { 'If-Match': `W/"${version}"` } }, ); return answer.packet; } catch (error) { const body = packetBody(error); if (error instanceof HttpError && error.status === 409 && body?.code === 'PACKET_CHANGED') { throw new PacketChangedError(body.current as QuestionnairePacket | undefined); } throw error; } }, [put]); // The operation id names the assignment: sent again after an unknown answer, it finds the one already made. const assignPacket = useCallback( (patientId: string, operationId: string, request: PacketAssignmentRequest, scope?: ReferralRequestScope) => post( `${API_URL}/questionnaire-packet-assignment?patient=${encodeURIComponent(patientId)}`, { operationId, ...request }, opened(scope), ), [post], ); const listPacketSummaries = useCallback( (patientId: string, scope?: ReferralRequestScope) => get( `${API_URL}/questionnaire-packet-assignment?patient=${encodeURIComponent(patientId)}`, opened(scope), ), [get], ); return useMemo( () => ({ listPackets, createPacket, updatePacket, assignPacket, listPacketSummaries }), [listPackets, createPacket, updatePacket, assignPacket, listPacketSummaries], ); };