mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 05:25:41 +00:00
155 lines
5.6 KiB
TypeScript
155 lines
5.6 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 type { MyBookingWindow } from "@/services/bookings.service";
|
||
|
||
function getAuthToken(): string | undefined {
|
||
return document.cookie
|
||
.split("; ")
|
||
.find((row) => row.startsWith("auth-token="))
|
||
?.split("=")[1];
|
||
}
|
||
|
||
// 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\/?$/, "");
|
||
|
||
// Both window lists live under this key prefix (myBookingWindows +
|
||
// contractBookingWindows/*), so one predicate patches every cached list.
|
||
const WINDOW_KEY_PREFIX = ["train-scheduling"] as const;
|
||
const WINDOW_ACTIONS = new Set(["myBookingWindows", "contractBookingWindows"]);
|
||
|
||
/**
|
||
* Fold a server phase push onto a cached window row. `isOpenNow` is recomputed
|
||
* exactly as the server's mapBookingWindowRow does (phase OPEN + status OPEN) so
|
||
* the live-patched state can never disagree with what a fresh REST fetch returns
|
||
* on refresh — both come from the same server timestamps, not the client clock.
|
||
*/
|
||
function applyEvent(
|
||
row: MyBookingWindow,
|
||
event: BookingWindowPhaseEvent,
|
||
): MyBookingWindow {
|
||
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. Every phase transition the window
|
||
* engine applies (open, doc review, payment, reopen, done) carries the schedule's
|
||
* full new state; we fold it straight into the cached window lists with
|
||
* setQueriesData rather than invalidating.
|
||
*
|
||
* Why not invalidate: at ~200 concurrent users a namespace-wide broadcast made
|
||
* every client refetch two heavy window queries on every schedule transition —
|
||
* an O(users × schedules) stampede that lagged the whole population. Patching the
|
||
* cache in place means a push costs each client one array map, no network. It
|
||
* also fixes the refresh-jump: the live state and a post-refresh REST fetch now
|
||
* derive isOpenNow/phase from the same server fields, so they agree.
|
||
*
|
||
* A push for a schedule not present in any cached list (a brand-new window) can't
|
||
* be patched in — those fall back to a debounced invalidate so the new row still
|
||
* appears, without the storm.
|
||
*/
|
||
export function useBookingWindowSocket(enabled: boolean) {
|
||
const qc = useQueryClient();
|
||
|
||
useEffect(() => {
|
||
if (!enabled) return;
|
||
const token = getAuthToken();
|
||
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 "unknown schedule → refetch" fallback so a burst of pushes
|
||
// for new schedules triggers at most one invalidation per window.
|
||
let refetchTimer: ReturnType<typeof setTimeout> | null = null;
|
||
const scheduleRefetch = () => {
|
||
if (refetchTimer) return;
|
||
refetchTimer = setTimeout(() => {
|
||
refetchTimer = null;
|
||
void qc.invalidateQueries({
|
||
predicate: (q) => {
|
||
const [prefix, action] = q.queryKey as unknown[];
|
||
return prefix === WINDOW_KEY_PREFIX[0] && WINDOW_ACTIONS.has(String(action));
|
||
},
|
||
});
|
||
}, 800);
|
||
};
|
||
|
||
socket.on(
|
||
BOOKING_WINDOW_WS_EVENTS.PHASE,
|
||
(event: BookingWindowPhaseEvent) => {
|
||
let patchedSomewhere = false;
|
||
|
||
qc.setQueriesData<MyBookingWindow[]>(
|
||
{
|
||
predicate: (q) => {
|
||
const [prefix, action] = q.queryKey as unknown[];
|
||
return (
|
||
prefix === WINDOW_KEY_PREFIX[0] &&
|
||
WINDOW_ACTIONS.has(String(action))
|
||
);
|
||
},
|
||
},
|
||
(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;
|
||
},
|
||
);
|
||
|
||
// The schedule wasn't in any cached list — a newly announced window (or a
|
||
// lane the client hasn't fetched). Fall back to a debounced refetch so it
|
||
// surfaces, without the per-push stampede that patching avoids.
|
||
if (!patchedSomewhere) scheduleRefetch();
|
||
},
|
||
);
|
||
|
||
return () => {
|
||
if (refetchTimer) clearTimeout(refetchTimer);
|
||
socket.off();
|
||
socket.disconnect();
|
||
};
|
||
}, [enabled, qc]);
|
||
}
|