import { useCallback, useEffect, useRef, useState } from "react"; import apiFetch from "../utils/api"; /** Lock holder as enriched by the detail endpoints (offers, issued orders). */ export interface DocumentLockInfo { user_id: number; username: string; full_name: string; } /** * Client side of the document edit lock shared by the offer / issued-order * detail pages (extracted from OfferDetail). The server exposes the same * trio under the entity base URL: POST `:base/lock`, `:base/heartbeat`, * `:base/unlock` (lock expires after 30 s without a heartbeat — 3 missed * 10 s beats, so background-tab interval throttling can't hand the lock away). * * Responsibilities: * - `lockedBy` state — the page hydrates it from the detail response via * `setLockedBy(detail.locked_by ?? null)`. * - `acquire()` — fire-and-forget lock acquisition; the page calls it from * its one-shot hydration effect when the doc is editable, the user holds * the edit permission and nobody else holds the lock. * - 10 s heartbeat while `enabled` (and not locked by another user), with * hard-401 self-stop: a 401 here means the access token expired AND the * refresh failed (apiFetch retries recoverable 401s itself), so we stop * the interval instead of spamming a dead session every 10 s. * - unlock on unmount/disable, with an AbortController so a stale in-flight * unlock can be cancelled by the next one. */ export function useDocumentLock({ baseUrl, enabled, }: { /** Entity endpoint base (e.g. `/api/admin/offers/42`); null in create mode. */ baseUrl: string | null; /** * External conditions for holding the lock: edit mode + editable status + * edit permission. The hook adds `!isLockedByOther` itself. */ enabled: boolean; }) { const [lockedBy, setLockedBy] = useState(null); const heartbeatRef = useRef | null>(null); const unlockAbortRef = useRef(null); const isLockedByOther = lockedBy !== null; const acquire = useCallback(() => { if (!baseUrl) return; apiFetch(`${baseUrl}/lock`, { method: "POST" }).catch(() => {}); }, [baseUrl]); // Heartbeat to keep the lock alive + release on unmount/disable. useEffect(() => { if (!baseUrl || !enabled || isLockedByOther) return; heartbeatRef.current = setInterval(() => { apiFetch(`${baseUrl}/heartbeat`, { method: "POST" }) .then((res) => { if (res.status === 401 && heartbeatRef.current) { clearInterval(heartbeatRef.current); heartbeatRef.current = null; } }) .catch(() => {}); }, 10 * 1000); // every 10 seconds return () => { if (heartbeatRef.current) clearInterval(heartbeatRef.current); if (unlockAbortRef.current) unlockAbortRef.current.abort(); const controller = new AbortController(); unlockAbortRef.current = controller; apiFetch(`${baseUrl}/unlock`, { method: "POST", signal: controller.signal, }).catch(() => {}); }; }, [baseUrl, enabled, isLockedByOther]); return { lockedBy, isLockedByOther, setLockedBy, acquire }; }