/** * View-model hook for a single ticket's detail/edit surface (F3/F4/F5). * * Owns optimistic-concurrency handling: every mutation sends the ticket's * current `version` as `expectedVersion`. When the backend reports a version * conflict (the ticket moved underneath — e.g. an agent edited it), the hook * flips `conflict`, reloads the fresh ticket and surfaces a clear message so the * user can re-apply their change against the new version. * * The loaded ticket always includes its carnet body (F4); mutations that return * a carnet-less ticket preserve the last known carnet so the editor never blanks * out unexpectedly. */ import { useCallback, useEffect, useState } from "react"; import { isTicketEvent, type GatewayError, type Ticket, type TicketLinkKind } from "@/domain"; import { useGateways } from "@/app/di"; export interface TicketDetailViewModel { ticket: Ticket | null; error: string | null; /** Set when the last write lost an optimistic-concurrency race (F3). */ conflict: boolean; /** * Set once the open ticket has been deleted — by this view or any other actor * (ticket #6). Driven by the `issueDeleted` domain event (single source of * truth); the surface reacts by closing itself. */ deleted: boolean; busy: boolean; /** * Monotonic counter bumped **only** when the ticket is (re)loaded from the * gateway — initial load, an external-event refresh, or a conflict reload. It * is deliberately **not** bumped by an ordinary optimistic mutation (e.g. a * priority change), so a consumer can re-seed its local edit draft on genuine * reloads without clobbering an in-progress edit on every version bump (#9). */ reloadCount: number; refresh: () => Promise; updateFields: (input: { title?: string; description?: string; status?: Ticket["status"]; priority?: Ticket["priority"]; }) => Promise; saveCarnet: (carnet: string) => Promise; link: (targetRef: string, kind: TicketLinkKind) => Promise; unlink: (targetRef: string, kind?: TicketLinkKind) => Promise; assign: (agentId: string, assigned: boolean) => Promise; /** * Deletes this ticket (ticket #6). Returns `true` on success. The removal from * lists and the closing of this surface flow from the resulting `issueDeleted` * event (see {@link TicketDetailViewModel.deleted}), not from local mutation. */ remove: () => Promise; } function describe(e: unknown): string { if (e && typeof e === "object" && "message" in e) { return String((e as GatewayError).message); } return String(e); } /** A generic `INVALID` from the backend whose message flags a version conflict. */ function isVersionConflict(e: unknown): boolean { return ( !!e && typeof e === "object" && typeof (e as GatewayError).message === "string" && (e as GatewayError).message.includes("version conflict") ); } export function useTicketDetail( projectId: string, ref: string, ): TicketDetailViewModel { const { ticket: gateway, system } = useGateways(); const [ticket, setTicket] = useState(null); const [error, setError] = useState(null); const [conflict, setConflict] = useState(false); const [deleted, setDeleted] = useState(false); const [busy, setBusy] = useState(false); const [reloadCount, setReloadCount] = useState(0); const refresh = useCallback(async () => { setBusy(true); setError(null); try { setTicket(await gateway.read(projectId, ref, true)); setConflict(false); // Signal a benign (re)load so consumers can refresh their edit draft; an // optimistic mutation below never bumps this (#9). A benign reload leaves // `conflict` false, so the draft is preserved field-by-field; a conflict // reload (see `run`) keeps `conflict` true to force a clean re-apply. setReloadCount((c) => c + 1); } catch (e) { setError(describe(e)); } finally { setBusy(false); } }, [projectId, ref, gateway]); useEffect(() => { void refresh(); }, [refresh]); // Refresh when *another* actor mutates this same ticket while it is open. useEffect(() => { if (!system) return; let unsubscribe: (() => void) | undefined; let cancelled = false; void system .onDomainEvent((event) => { if (event.type === "issueDeleted" && event.issueRef === ref) { // The open ticket was deleted (here or elsewhere): don't refresh (it // would 404) — flag it so the surface closes (#6). setDeleted(true); return; } if (isTicketEvent(event) && event.issueRef === ref) void refresh(); }) .then((u) => { if (cancelled) u(); else unsubscribe = u; }); return () => { cancelled = true; unsubscribe?.(); }; }, [refresh, system, ref]); /** * Runs one mutation, preserving the loaded carnet (mutations return a * carnet-less ticket) and translating a version conflict into `conflict` + * a reload so the UI can prompt a clean retry. */ const run = useCallback( async (op: (version: number) => Promise, carnet?: string): Promise => { if (!ticket) return false; setBusy(true); setError(null); try { const updated = await op(ticket.version); setTicket({ ...updated, carnet: carnet !== undefined ? carnet : ticket.carnet, }); setConflict(false); return true; } catch (e) { if (isVersionConflict(e)) { // Conflict reload: reload the fresh ticket but keep `conflict` true so // the consumer force-re-seeds the draft (re-apply cleanly) rather than // preserving the now-stale edit. Bump `reloadCount` to trigger it. setError( "This ticket was modified elsewhere. It has been reloaded — re-apply your change.", ); try { setTicket(await gateway.read(projectId, ref, true)); } catch (reloadErr) { setError(describe(reloadErr)); } setConflict(true); setReloadCount((c) => c + 1); } else { setError(describe(e)); } return false; } finally { setBusy(false); } }, [ticket, gateway, projectId, ref], ); const updateFields: TicketDetailViewModel["updateFields"] = useCallback( (input) => run((version) => gateway.update(projectId, ref, { ...input, expectedVersion: version })), [run, gateway, projectId, ref], ); const saveCarnet: TicketDetailViewModel["saveCarnet"] = useCallback( (carnet) => run((version) => gateway.updateCarnet(projectId, ref, carnet, version), carnet), [run, gateway, projectId, ref], ); const link: TicketDetailViewModel["link"] = useCallback( (targetRef, kind) => run((version) => gateway.link(projectId, ref, targetRef, kind, version)), [run, gateway, projectId, ref], ); const unlink: TicketDetailViewModel["unlink"] = useCallback( (targetRef, kind) => run((version) => gateway.unlink(projectId, ref, targetRef, version, kind)), [run, gateway, projectId, ref], ); const assign: TicketDetailViewModel["assign"] = useCallback( (agentId, assigned) => run((version) => gateway.assign(projectId, ref, agentId, assigned, version)), [run, gateway, projectId, ref], ); const remove: TicketDetailViewModel["remove"] = useCallback(async () => { setBusy(true); setError(null); try { await gateway.delete(projectId, ref); // Closing/removal flows from the `issueDeleted` event (single source of // truth) — see the `deleted` flag set in the subscription above. return true; } catch (e) { setError(describe(e)); return false; } finally { setBusy(false); } }, [gateway, projectId, ref]); return { ticket, error, conflict, deleted, busy, reloadCount, refresh, updateFields, saveCarnet, link, unlink, assign, remove, }; }