'use client' /** * TicketLiveProvider — the ONE client home for support-ticket realtime. * * DOMAIN logic only — connection lifecycle (subscribed-confirm, server * retry grace, failed-reconnect pacing, hidden-tab suspend/resume) is * owned entirely by the common SSE client (`createSseSubscription`); * this provider consumes its consolidated `onConnectedChange` signal. * * Owns: * - the unread summary — delivered EXCLUSIVELY by the stream * (`ticket-summary` frames on connect + debounced after events) and * by `ticket-read` responses. The client holds NO summary fetch * path: resync-on-(re)connect is served by the SERVER (every * connect pushes a fresh summary); * - query invalidation on events (list + open drawer refresh live); * - `markRead` with local write-through zeroing for 0-latency, * reconciled by the response's server-computed summary; * - the ticket-contract `no-stream` (204) retry policy. * * There is NO polling anywhere and NO summary endpoint. Hosts whose * stream endpoint 404s (terminal) or is absent get no unread indication * — the indication IS a realtime feature. * * Invalidation semantics on events — deliberately `invalidateQueries`, * NOT `setQueryData` patches: the ticket hooks run `gcTime: 0`, so * there is usually no cache entry to patch, and `TicketsCacheSlot` * shape rules apply when there is. */ import React from 'react' import { useQueryClient } from '@tanstack/react-query' import { useChatRuntime } from '../../contexts/chat-runtime-context' import { embedAuthedFetch } from '../../utils/embed-authed-fetch' import { createSseSubscription, type SseSubscription, type SseTransportStatus, } from '../../utils/sse-subscription' import { useChatIdentity } from '../chat/hooks/use-chat-identity' import type { TicketMessageStreamData, TicketStreamEvent, TicketUnreadSummary, } from './types' // Ticket realtime is its OWN api surface — deliberately NOT under // `/api/chat/agent/*` (that prefix is the chat agent's tool surface; // ticket streaming/read-receipts are unrelated to chat). const TICKET_STREAM_ENDPOINT = '/api/tickets/stream' const TICKET_READ_ENDPOINT = '/api/tickets/read' const EMPTY_SUMMARY: TicketUnreadSummary = { totalUnread: 0, tickets: {}, latestUnreadAt: {} } /** ISO stamps can carry mixed timezone offsets (server `+00:00` vs an * optimistic local `Z`) — lexicographic string comparison mis-orders * those, so every stamp comparison goes through parsed epoch ms. * Missing/unparseable stamps sort oldest. */ const stampMs = (stamp: string | null | undefined): number => { if (!stamp) return 0 const ms = Date.parse(stamp) return Number.isNaN(ms) ? 0 : ms } /** Trailing debounce for event-burst invalidations (an agent pasting 5 * messages must not fire 5 list refetches). */ const INVALIDATE_DEBOUNCE_MS = 1_500 export interface TicketLiveContextValue { /** Viewer has a resolved, non-anon chat identity. Surfaces (e.g. the * header cell) hide themselves when false — computed here so they * don't each spin up their own identity resolution. */ authed: boolean /** Realtime-subscribed (server-confirmed), not merely HTTP-open. */ connected: boolean /** Unread total across tickets — drives the header count badge. */ unreadTotal: number /** Per-ticket unread counts (missing key = 0) — drives row dots. * The currently-open ticket is masked to 0. */ unreadByTicket: Record /** The ticket with the NEWEST unread reply (open ticket excluded) — * the header cell routes to it via the SSOT deep link * (`buildTicketOpenHref`). Null when nothing is unread. */ nextUnreadTicketId: string | null /** Mark a ticket read: POSTs the receipt, zeroes the ticket locally * (0-latency), then applies the response's server-computed summary. */ markRead: (ticketExternalId: string) => Promise /** Drawer open/close registration — suppresses unread bumps for the * open ticket and masks it in the derived map. */ setOpenTicketId: (ticketExternalId: string | null) => void /** Hint that the viewer just created a ticket — reconnects the stream * (fresh ownership set + fresh server-pushed summary). */ notifyTicketCreated: () => void } const TicketLiveContext = React.createContext(null) export function useOptionalTicketLive(): TicketLiveContextValue | null { return React.useContext(TicketLiveContext) } export function useTicketLive(): TicketLiveContextValue { const ctx = React.useContext(TicketLiveContext) if (!ctx) { throw new Error('useTicketLive must be used within ') } return ctx } function isTicketMessageData(value: unknown): value is TicketMessageStreamData { return ( typeof value === 'object' && value !== null && typeof (value as TicketMessageStreamData).ticket_external_id === 'string' ) } function isSummaryData(value: unknown): value is TicketUnreadSummary { return ( typeof value === 'object' && value !== null && typeof (value as TicketUnreadSummary).totalUnread === 'number' && typeof (value as TicketUnreadSummary).tickets === 'object' ) } export function TicketLiveProvider({ children }: { children: React.ReactNode }) { const runtime = useChatRuntime() const identity = useChatIdentity() const queryClient = useQueryClient() const streamUrl = runtime?.endpoints.ticketStreamUrl ?? TICKET_STREAM_ENDPOINT const readUrl = runtime?.endpoints.ticketReadUrl ?? TICKET_READ_ENDPOINT const authed = identity.authTier !== 'anon' && !!identity.user?.email const [connected, setConnected] = React.useState(false) // The SINGLE unread source — written ONLY from server-computed // summaries (stream frames + markRead responses) plus the two // 0-latency optimistic writes (event bump, markRead zero), both // reconciled by the next server summary. const [summary, setSummary] = React.useState(EMPTY_SUMMARY) // State + ref for the open drawer: state drives the derived map, // the ref lets stream callbacks read it without re-subscribing. const [openTicketIdState, setOpenTicketIdState] = React.useState(null) const openTicketIdRef = React.useRef(null) // ---- Debounced (trailing) invalidation of ticket queries on events ---- const pendingTicketIdsRef = React.useRef>(new Set()) const invalidateTimerRef = React.useRef | null>(null) const flushInvalidations = React.useCallback(() => { invalidateTimerRef.current = null const ids = Array.from(pendingTicketIdsRef.current) pendingTicketIdsRef.current.clear() void queryClient.invalidateQueries({ queryKey: ['tickets'] }) for (const id of ids) { void queryClient.invalidateQueries({ queryKey: ['ticket-engagements', id] }) } }, [queryClient]) const scheduleInvalidation = React.useCallback( (ticketId?: string) => { if (ticketId) pendingTicketIdsRef.current.add(ticketId) if (invalidateTimerRef.current) return invalidateTimerRef.current = setTimeout(flushInvalidations, INVALIDATE_DEBOUNCE_MS) }, [flushInvalidations], ) // ---- Optimistic unread writes (reconciled by server summaries) ---- const bumpUnread = React.useCallback((ticketId: string, createdAt?: string | null) => { setSummary((prev) => { const stamp = createdAt ?? new Date().toISOString() const existing = prev.latestUnreadAt[ticketId] return { totalUnread: prev.totalUnread + 1, tickets: { ...prev.tickets, [ticketId]: (prev.tickets[ticketId] ?? 0) + 1 }, latestUnreadAt: { ...prev.latestUnreadAt, [ticketId]: existing && stampMs(existing) > stampMs(stamp) ? existing : stamp, }, } }) }, []) const zeroUnread = React.useCallback((ticketId: string) => { setSummary((prev) => { const current = prev.tickets[ticketId] ?? 0 if (current === 0) return prev const tickets = { ...prev.tickets } delete tickets[ticketId] const latestUnreadAt = { ...prev.latestUnreadAt } delete latestUnreadAt[ticketId] return { totalUnread: Math.max(0, prev.totalUnread - current), tickets, latestUnreadAt } }) }, []) // ---- Stream lifecycle (owned by the SSE CLIENT — the provider only // consumes its consolidated signals) ---- const subscriptionRef = React.useRef(null) const noStreamRef = React.useRef(false) // Stable handler refs so the subscription effect doesn't churn. const handlersRef = React.useRef<{ onEvent: (eventName: string, data: unknown) => void onStatus: (status: SseTransportStatus) => void onConnected: (connected: boolean) => void }>({ onEvent: () => {}, onStatus: () => {}, onConnected: () => {} }) handlersRef.current.onConnected = (isConnected) => { setConnected(isConnected) if (isConnected) { // Event-gap healing on every (re)connect: the SERVER pushes a // fresh summary; the open drawer refetches through the ACL'd path. const openId = openTicketIdRef.current if (openId) { void queryClient.invalidateQueries({ queryKey: ['ticket-engagements', openId] }) } } } handlersRef.current.onEvent = (eventName, data) => { if (eventName === 'ticket-summary') { const frame = data as TicketStreamEvent | null if (isSummaryData(frame?.data)) { setSummary({ totalUnread: frame.data.totalUnread, tickets: frame.data.tickets ?? {}, latestUnreadAt: frame.data.latestUnreadAt ?? {}, }) } return } if (eventName === 'ticket-resync') { // Ownership gained / degraded payload — a fresh ticket-summary // frame follows from the server; refresh the query surfaces. scheduleInvalidation() return } if (eventName === 'ticket-status') { // Meaningful ticket-row change (close/reopen/pipeline move). The // server pushes an updated summary right after (closures count as // unread server-side); here we just refresh the query surfaces so // the status badge flips live. const frame = data as TicketStreamEvent | null const ticketId = (frame?.data as { ticket_external_id?: string } | undefined) ?.ticket_external_id scheduleInvalidation(ticketId) return } if (eventName === 'ticket-message') { const frame = data as TicketStreamEvent | null const payload = frame?.data if (!isTicketMessageData(payload)) { scheduleInvalidation() return } // Always invalidate (own replies from another tab render live); // bump unread ONLY on the server-stamped predicate, and never for // the ticket whose drawer is open. The server's debounced summary // push reconciles moments later. scheduleInvalidation(payload.ticket_external_id) if (payload.countsAsUnread && payload.ticket_external_id !== openTicketIdRef.current) { bumpUnread(payload.ticket_external_id, payload.hubspot_created_at) } } } handlersRef.current.onStatus = (status) => { // The client owns connection lifecycle (confirm timeout, server // retry grace, failed-reconnect pacing, hidden-suspend). The only // transport status with DOMAIN meaning is the ticket contract's // `no-stream` (204 = zero owned tickets): remember it so a domain // signal (own create_ticket, window focus) can retry. if (status === 'open') noStreamRef.current = false if (status === 'no-stream') noStreamRef.current = true } // Mount/unmount of the subscription — gated on auth + a usable URL. const streamEnabled = authed && !!streamUrl React.useEffect(() => { if (!streamEnabled) return if (typeof window === 'undefined') return subscriptionRef.current = createSseSubscription({ url: streamUrl, onEvent: (name, data) => handlersRef.current.onEvent(name, data), onStatusChange: (status) => handlersRef.current.onStatus(status), onConnectedChange: (isConnected) => handlersRef.current.onConnected(isConnected), // Long-lived per-user stream: suspend while hidden (scale relief), // resume + reconnect on visible/online — all inside the client. pauseWhenHidden: true, }) const retryIfIdle = () => { // After a 204 (no-stream), a focus signal is the retry trigger. if (noStreamRef.current) { noStreamRef.current = false subscriptionRef.current?.reconnectNow() } } window.addEventListener('focus', retryIfIdle) return () => { window.removeEventListener('focus', retryIfIdle) if (invalidateTimerRef.current) { clearTimeout(invalidateTimerRef.current) invalidateTimerRef.current = null } subscriptionRef.current?.close() subscriptionRef.current = null setConnected(false) setSummary(EMPTY_SUMMARY) } // handlersRef is stable; streamUrl/auth changes remount the stream. // eslint-disable-next-line react-hooks/exhaustive-deps }, [streamEnabled, streamUrl]) // ---- Public API ---- const markRead = React.useCallback( async (ticketExternalId: string) => { try { const response = await embedAuthedFetch(readUrl, { method: 'POST', body: JSON.stringify({ ticket_id: ticketExternalId }), }) if (!response.ok) return // 0-latency local zero, then reconcile with the response's // server-computed summary (THE truth — includes closure // accounting and other tickets). zeroUnread(ticketExternalId) const body = (await response.json().catch(() => null)) as | { summary?: TicketUnreadSummary } | null if (isSummaryData(body?.summary)) { setSummary({ totalUnread: body.summary.totalUnread, tickets: body.summary.tickets ?? {}, latestUnreadAt: body.summary.latestUnreadAt ?? {}, }) } } catch { // Non-fatal — the receipt lands on the next markRead; unread // state reconciles via the next server summary push. } }, [readUrl, zeroUnread], ) const setOpenTicketId = React.useCallback((ticketExternalId: string | null) => { openTicketIdRef.current = ticketExternalId setOpenTicketIdState(ticketExternalId) }, []) const notifyTicketCreated = React.useCallback(() => { // Fresh connect → fresh ownership set + fresh server summary // (covers both the no-stream 204 wait state and an already-live // stream whose set predates the new ticket). noStreamRef.current = false subscriptionRef.current?.reconnectNow() }, []) // Derived unread map — open ticket masked to 0 so a server summary // landing inside the markRead debounce window can't transiently // re-badge it. const value = React.useMemo(() => { let unreadByTicket = summary.tickets let unreadTotal = summary.totalUnread if (openTicketIdState && unreadByTicket[openTicketIdState]) { const masked = unreadByTicket[openTicketIdState] unreadByTicket = { ...unreadByTicket } delete unreadByTicket[openTicketIdState] unreadTotal = Math.max(0, unreadTotal - masked) } // Ticket with the NEWEST unread reply (compared as parsed epoch ms // via `stampMs` — mixed timezone offsets break string ordering) — // header cell routes there. Falls back to any unread key if a // timestamp is missing. let nextUnreadTicketId: string | null = null let nextStampMs = 0 for (const id of Object.keys(unreadByTicket)) { const ms = stampMs(summary.latestUnreadAt[id]) if (nextUnreadTicketId === null || ms > nextStampMs) { nextUnreadTicketId = id nextStampMs = ms } } return { authed, connected, unreadTotal, unreadByTicket, nextUnreadTicketId, markRead, setOpenTicketId, notifyTicketCreated, } }, [ summary, authed, connected, openTicketIdState, markRead, setOpenTicketId, notifyTicketCreated, ]) return {children} }