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 { useSupportTicketRaised } from "../utils/supportTicketRaised"; /** * SUPPORT TICKETS - the practice's own follow-up screen, for a ticket `SupportTicketForm` already * raised. * * ON THE IDENTITY GATEWAY, matching `SupportTicketService.tsx`'s own reasoning: a ticket is about the * practice's own account, never about a patient's clinical record, so it lives beside the * identity-scoped routes (proofing, related-person, payment) rather than on the FHIR host. * * TWO HOOKS, ONE FILE. `useSupportTickets` is the list this screen opens on; `useSupportTicketThread` * is one ticket, its thread, and the reply that follows it. They share a base URL and the same * row-shaping, so splitting them into two files would only split one contract in half. */ const API_URL = environment.fhirIdentityApi; export type TicketStatus = "open" | "answered" | "resolved"; const isTicketStatus = (value: unknown): value is TicketStatus => value === "open" || value === "answered" || value === "resolved"; /** * ONE COPY, READ BY BOTH SCREENS. `SupportPage` (the list) and `SupportTicketDetail` (one ticket) each * paint the same status, and a status word is the whole signal on this screen - this app has already * had to fix a status-badge contrast bug once, and two independent copies of the map that decides the * colour is exactly how a fix like that lands on one screen and leaves the other wrong. */ export const TICKET_STATUS_LABELS: Record = { open: "Open", answered: "Answered", resolved: "Resolved", }; /** * Only three Badge variants read reliably in this app - `default`, `destructive` and `outline`. * `secondary` is the one this app already had to repair at the token level once, and a ghost/link * badge is not a status chip. `answered` is the state most worth the reader's eye here: a contractor * has replied and the ball is back with the practice, so it takes the strongest variant available. */ export const TICKET_STATUS_VARIANT: Record = { open: "outline", answered: "destructive", resolved: "default", }; /** * Who is holding the ticket right now - `owner` on the server, in the reader's own terms. Not a * contractor name: the server never sends one, only `AKASHIC` or `CONTRACTOR#`, so the honest * thing to say is which side holds it, the same granularity `speakerLabel` in `SupportTicketDetail` * already uses for a reply. */ export type TicketHolder = "contractor" | "akashic"; /** * ONE COPY, READ BY BOTH SCREENS - same reasoning as `TICKET_STATUS_LABELS` above. A ticket answered * by a stranger with no explanation is worse served than one told who is holding it, so the list and * the opened ticket both need this, not just one of them. */ export const TICKET_HOLDER_LABELS: Record = { contractor: "Your contractor", akashic: "Akashic support", }; export interface SupportTicketRow { ticketId: string; reference?: string; subject: string; status: TicketStatus; holder: TicketHolder; updatedAt: string; } export interface SupportTicketDetail extends SupportTicketRow { body: string; createdAt: string; } /** The three, and only three, acts that ever move a ticket's holder - see the server's `moveOwner`. */ export type OwnerTransitionKind = "escalation" | "hand-back" | "link-ended"; export interface SupportTicketThreadEvent { at: string; kind: "reply" | "status" | OwnerTransitionKind; /** Which side of the exchange spoke - never an account id, only which side it belongs to. */ side: "organization" | "contractor" | "akashic"; authorName?: string; /** Present on a reply. */ body?: string; /** Present on a status change. */ status?: TicketStatus; /** Present on an ownership move: the owner it left, in the server's `AKASHIC` / `CONTRACTOR#` shape. */ movedFrom?: string; /** Present on an ownership move: the owner it moved to, same shape as `movedFrom`. */ movedTo?: string; /** Optional on an ownership move - the thread is usually the reason on its own. */ reason?: string; } interface RawTicket { ticketId: string; reference?: string; subject?: string; body?: string; status?: string; /** `AKASHIC` or `CONTRACTOR#` - see `TicketHolder` for what this becomes in the reader's terms. */ owner?: string; createdAt?: string; updatedAt?: string; } interface RawEvent { at: string; kind?: string; author?: { name?: string; side?: string }; body?: string; status?: string; movedFrom?: string; movedTo?: string; reason?: string; } const isOwnerTransitionKind = (value: unknown): value is OwnerTransitionKind => value === "escalation" || value === "hand-back" || value === "link-ended"; const toHolder = (owner: unknown): TicketHolder => (owner === "AKASHIC" ? "akashic" : "contractor"); const toEventSide = (side: unknown): SupportTicketThreadEvent["side"] => { if (side === "contractor") return "contractor"; if (side === "akashic") return "akashic"; return "organization"; }; const toRow = (raw: RawTicket): SupportTicketRow => ({ ticketId: raw.ticketId, reference: raw.reference, subject: raw.subject ?? "", status: isTicketStatus(raw.status) ? raw.status : "open", holder: toHolder(raw.owner), // Falls back to createdAt so a ticket the server has not yet touched still sorts and displays // sensibly, rather than showing a blank "last updated". updatedAt: raw.updatedAt ?? raw.createdAt ?? "", }); const toDetail = (raw: RawTicket): SupportTicketDetail => ({ ...toRow(raw), body: raw.body ?? "", createdAt: raw.createdAt ?? "", }); const toThreadEvent = (raw: RawEvent): SupportTicketThreadEvent => ({ at: raw.at, kind: raw.kind === "status" || isOwnerTransitionKind(raw.kind) ? raw.kind : "reply", side: toEventSide(raw.author?.side), authorName: raw.author?.name, body: raw.body, status: isTicketStatus(raw.status) ? raw.status : undefined, movedFrom: raw.movedFrom, movedTo: raw.movedTo, reason: raw.reason, }); /** * The list this screen opens on - every ticket this practice has raised (or, for an Admin, every * ticket raised for it), in the order the server returns them. This hook does not re-sort it. */ export const useSupportTickets = () => { 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}/support/tickets`); if (read !== latestRead.current) return; setTickets((response?.tickets ?? []).map(toRow)); } catch (err) { if (read !== latestRead.current) return; console.error("Error loading support tickets:", err); setError(relayServerFailure(err, "Could not load your support tickets. Please try again.")); } finally { if (read === latestRead.current) setLoading(false); } }, [get]); useEffect(() => { // Not gated on an organization id: the SERVER takes it from the authorizer context, the same // way the lab queue beside this hook does. 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 useSupportTicketThread = (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}/support/tickets/${ticketId}`, ); setTicket(toDetail(response.ticket)); setEvents((response.events ?? []).map(toThreadEvent)); } catch (err) { // A ticket that does not exist, or belongs to a colleague this caller may not open, is an // ordinary state to land on - a stale link, a bookmark - never a failure to explain. if (err instanceof HttpError && err.status === 404) { setNotFound(true); return; } console.error("Error loading the support ticket:", err); setError(relayServerFailure(err, "Could not load this ticket. Please try again.")); } finally { setLoading(false); } }, [get, ticketId]); /** * APPENDS FROM THE 200 RESPONSE, NEVER BY RE-READING THE THREAD. `POST .../replies` hands back the * single event it just wrote, and that is rendered directly - re-reading the thread the instant * after writing to it can answer from a stale read before the write is visible, which is the same * eventual-consistency trap that left a placed diagnostic order invisible until a manual refresh. */ const reply = useCallback( async (body: string) => { if (!ticketId) return; setReplying(true); setReplyError(undefined); try { const response = await post<{ event: RawEvent }>( `${API_URL}/support/tickets/${ticketId}/replies`, { body }, ); setEvents((prev) => [...prev, toThreadEvent(response.event)]); // A reply the server accepted proves the ticket was not resolved a moment ago, and // `appendReply` hands it back to `open` on the server for exactly that reason - shown // here immediately rather than waiting on 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 }; };