import { useCallback, useEffect, useRef, useState } from "react"; import { useHttpService, HttpError } from "../services/HttpService.tsx"; import { environment } from "../environments/environment"; import { relayServerFailure } from "../utils/fetchFailureMessage"; import { type TicketStatus } from "./useSupportTickets"; import { useSupportTicketRaised } from "../utils/supportTicketRaised"; /** * A patient's own support tickets - the list, one thread, and the reply that follows it. * * A separate module on separate routes, matching the server, so nothing here calls * `/support/tickets` - see `_TESTS/__tests__/aPatientTicketNeverReachesThePracticeSurface.test.ts`. * * The status vocabulary is the single exception, imported rather than copied: it carries no request * and no ticket, and a second copy of the map that decides a badge's colour is how a contrast fix * lands on one screen and leaves the other wrong. Do not follow it with a hook. */ export { TICKET_STATUS_LABELS, TICKET_STATUS_VARIANT } from "./useSupportTickets"; export type { TicketStatus } from "./useSupportTickets"; const API_URL = environment.fhirIdentityApi; const isTicketStatus = (value: unknown): value is TicketStatus => value === "open" || value === "answered" || value === "resolved"; /** Who spoke. A ticket in an account partition is owned by Akashic from birth, so there is no contractor. */ export type PatientTicketSide = "patient" | "akashic"; export interface PatientSupportTicketRow { ticketId: string; reference?: string; subject: string; status: TicketStatus; updatedAt: string; } export interface PatientSupportTicketDetail extends PatientSupportTicketRow { body: string; createdAt: string; } export interface PatientSupportTicketEvent { at: string; kind: "reply" | "status"; side: PatientTicketSide; authorName?: string; /** Present on a reply. */ body?: string; /** Present on a status change. */ status?: TicketStatus; } interface RawTicket { ticketId: string; reference?: string; subject?: string; body?: string; status?: string; createdAt?: string; updatedAt?: string; } interface RawEvent { at: string; kind?: string; author?: { name?: string; side?: string }; body?: string; status?: string; } /** * Anything that is not the patient's own side reads as Akashic. The two are the only sides an * account partition can hold, and attributing somebody else's words to the reader is the worse * mistake of the two available. */ const toSide = (side: unknown): PatientTicketSide => (side === "patient" ? "patient" : "akashic"); const toRow = (raw: RawTicket): PatientSupportTicketRow => ({ ticketId: raw.ticketId, reference: raw.reference, subject: raw.subject ?? "", status: isTicketStatus(raw.status) ? raw.status : "open", // Falls back to createdAt so a ticket nothing has happened to yet still shows when it was raised // rather than a blank date. updatedAt: raw.updatedAt ?? raw.createdAt ?? "", }); const toDetail = (raw: RawTicket): PatientSupportTicketDetail => ({ ...toRow(raw), body: raw.body ?? "", createdAt: raw.createdAt ?? "", }); const toThreadEvent = (raw: RawEvent): PatientSupportTicketEvent => ({ at: raw.at, kind: raw.kind === "status" ? "status" : "reply", side: toSide(raw.author?.side), authorName: raw.author?.name, body: raw.body, status: isTicketStatus(raw.status) ? raw.status : undefined, }); /** Every ticket this patient has raised, in the order the server returns them. */ export const usePatientSupportTickets = () => { const { get } = useHttpService(); const [tickets, setTickets] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(); // Sending a report re-reads the list while the first read may still be in flight; only the newest // read may change what is shown. const latestRead = useRef(0); const load = useCallback(async () => { const read = ++latestRead.current; setLoading(true); setError(undefined); try { const response = await get<{ tickets?: RawTicket[] }>(`${API_URL}/patient-support/tickets`); if (read !== latestRead.current) return; setTickets((response?.tickets ?? []).map(toRow)); } catch (err) { if (read !== latestRead.current) return; console.error("Error loading your support tickets:", err); setError(relayServerFailure(err, "Could not load your support tickets. Please try again.")); } finally { if (read === latestRead.current) setLoading(false); } }, [get]); useEffect(() => { void load(); }, [load]); useSupportTicketRaised(load); return { tickets, loading, error, reload: load }; }; /** One ticket, its thread oldest-first, and the reply box that follows it. */ export const usePatientSupportTicketThread = (ticketId: string | undefined) => { const { get, post } = useHttpService(); const [ticket, setTicket] = useState(); const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(); const [notFound, setNotFound] = useState(false); const [replying, setReplying] = useState(false); const [replyError, setReplyError] = useState(); const load = useCallback(async () => { if (!ticketId) return; setLoading(true); setError(undefined); setNotFound(false); try { const response = await get<{ ticket: RawTicket; events?: RawEvent[] }>( `${API_URL}/patient-support/tickets/${ticketId}`, ); setTicket(toDetail(response.ticket)); setEvents((response.events ?? []).map(toThreadEvent)); } catch (err) { // A ticket raised by somebody else answers 404 with the same body as one that never // existed, so this is a stale link rather than a failure to explain. if (err instanceof HttpError && err.status === 404) { setNotFound(true); return; } console.error("Error loading your support ticket:", err); setError(relayServerFailure(err, "Could not load this ticket. Please try again.")); } finally { setLoading(false); } }, [get, ticketId]); /** * Rendered from the 200 response, never by re-reading the thread: a read issued the instant * after a write can answer from before it, and the reader would see their own reply vanish. */ const reply = useCallback( async (body: string) => { if (!ticketId) return; setReplying(true); setReplyError(undefined); try { const response = await post<{ event: RawEvent }>( `${API_URL}/patient-support/tickets/${ticketId}/replies`, { body }, ); setEvents((prev) => [...prev, toThreadEvent(response.event)]); // The server reopens a ticket the raising side replied on, so the status shown here // matches without a reload that would race the same write. setTicket((prev) => (prev ? { ...prev, status: "open" } : prev)); } catch (err) { setReplyError(relayServerFailure(err, "That reply could not be sent. Please try again.")); throw err; } finally { setReplying(false); } }, [post, ticketId], ); useEffect(() => { void load(); }, [load]); return { ticket, events, loading, error, notFound, reload: load, reply, replying, replyError }; };