Files
edr-platform/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts
2026-07-07 21:42:59 +00:00

154 lines
5.7 KiB
TypeScript

import {
BOOKING_WINDOW_WS_EVENTS,
BOOKING_WINDOW_WS_NAMESPACE,
type BookingWindowPhaseEvent,
} from "@edr/types";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { io } from "socket.io-client";
import { API_BASE_URL } from "@/constants/apiConfig";
import { AUTH_TOKEN_COOKIE, getCookie } from "@/auth/cookies";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
// The socket namespace lives at the server root, not under the `/api` REST
// prefix — strip a trailing `/api` if the base URL carries one.
const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, "");
// The two carousel window lists share the MyBookingWindow-shaped row and can be
// patched in place. The batch board is a richer, differently-shaped view, so it
// stays on a (debounced) invalidate.
const WINDOW_ACTIONS = new Set(["all-booking-windows", "contractBookingWindows"]);
/** Shape shared by both carousel window lists (all-lanes + contract-scoped). */
interface WindowRow {
scheduleId: string;
windowPhase: string | null;
isOpenNow: boolean;
windowOpensAt: string | null;
windowClosesAt: string | null;
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
bookingWindowStatus: string;
bookingCycleNo: number;
departureDate: string;
}
function isWindowKey(key: readonly unknown[]): boolean {
return key[0] === "train-scheduling" && WINDOW_ACTIONS.has(String(key[1]));
}
/**
* Fold a server phase push onto a cached window row, recomputing isOpenNow the
* same way the server does (phase OPEN + status OPEN) so live-patched state can
* never disagree with a fresh REST fetch on refresh.
*/
function applyEvent<T extends WindowRow>(row: T, event: BookingWindowPhaseEvent): T {
return {
...row,
windowPhase: event.phase,
bookingWindowStatus: event.bookingWindowStatus ?? row.bookingWindowStatus,
bookingCycleNo: event.bookingCycleNo,
isOpenNow: event.phase === "OPEN" && event.bookingWindowStatus === "OPEN",
windowOpensAt: event.windowOpensAt,
windowClosesAt: event.windowClosesAt,
docReviewEndsAt: event.docReviewEndsAt,
paymentPhaseEndsAt: event.paymentPhaseEndsAt,
departureDate: event.scheduledDepartureDate ?? row.departureDate,
};
}
/**
* Subscribes to live booking-window pushes for staff. A phase transition carries
* the schedule's full new state; we fold it straight into the carousel window
* lists with setQueriesData rather than invalidating — same rationale as the
* portal hook (no per-push refetch storm; live + refreshed state agree, killing
* the refresh-jump). The batch board is a different-shaped view, so it keeps a
* debounced invalidate, as do pushes for schedules not present in any list.
*/
export function useBookingWindowSocket(enabled: boolean = true) {
const qc = useQueryClient();
useEffect(() => {
if (!enabled) return;
const token = getCookie(AUTH_TOKEN_COOKIE);
if (!token) return;
const socket = io(`${SOCKET_ORIGIN}/${BOOKING_WINDOW_WS_NAMESPACE}`, {
auth: { token },
transports: ["websocket"],
withCredentials: true,
});
// Deliberate console breadcrumbs: "live updates not arriving" is only
// diagnosable from the browser when connect/reject outcomes are visible.
socket.on("connect", () =>
console.debug("[booking-windows] socket connected", socket.id),
);
socket.on("connect_error", (err) =>
console.warn("[booking-windows] socket connect failed:", err.message),
);
socket.on("disconnect", (reason) =>
console.debug("[booking-windows] socket disconnected:", reason),
);
// Coalesce the batch-board refresh (and the unknown-schedule fallback) so a
// burst of pushes triggers at most one invalidation per window.
let refetchTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleRefetch = (includeWindowLists: boolean) => {
if (refetchTimer) return;
refetchTimer = setTimeout(() => {
refetchTimer = null;
void qc.invalidateQueries({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(),
});
if (includeWindowLists) {
void qc.invalidateQueries({
predicate: (q) => isWindowKey(q.queryKey),
});
}
}, 800);
};
socket.on(
BOOKING_WINDOW_WS_EVENTS.PHASE,
(event: BookingWindowPhaseEvent) => {
let patchedSomewhere = false;
qc.setQueriesData<WindowRow[]>(
{ predicate: (q) => isWindowKey(q.queryKey) },
(rows) => {
if (!rows) return rows;
let changed = false;
const next = rows.map((row) => {
if (row.scheduleId !== event.scheduleId) return row;
changed = true;
patchedSomewhere = true;
return applyEvent(row, event);
});
return changed ? next : rows;
},
);
// Refresh the batch-board DETAIL for the schedule that transitioned so the
// Priority Tracking tab reranks + updates its countdowns immediately (the
// detail is a different shape from the list — invalidate, don't patch).
void qc.invalidateQueries({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(event.scheduleId),
});
// Always refresh the batch board (different shape, not patched). When the
// schedule wasn't in any window list either, refresh those too so a newly
// announced window surfaces. Both debounced — no per-push stampede.
scheduleRefetch(!patchedSomewhere);
},
);
return () => {
if (refetchTimer) clearTimeout(refetchTimer);
socket.off();
socket.disconnect();
};
}, [enabled, qc]);
}