/** * @file warehouseAutomat.js * @description Manages cached warehouse locations (`GET /api/warehouse`) for the active company. * * Upstream Triggers: * - `authAutomat`: Subscribes to auth state changes (`syncWithAuth`) — triggers * `actions.load()` on `'authed'` with a `selectedCompanyId`, and `actions.clear()` on `'anon'`. * - `sseAutomat`: Calls `actions.reload()` upon receiving a `'warehouse'` SSE event. * - UI / Routes: Calls to `actions.load()`, `actions.reload()`, or `actions.setWarehouses()`. */ import { Automat } from 'react-automat'; import { warehouse as warehouseApi } from '../api'; import { requireArray, requireDefined, requireObject } from '../utils/apiShape'; import authAutomat from './authAutomat'; /** * @typedef {Object} Warehouse * @property {string|number} id Warehouse identifier. * @property {string} name Warehouse name. */ /** * @typedef {Object} WarehouseState * @property {Array} warehouses Company-scoped warehouses. * @property {Array} filteredWarehouses Filtered warehouses according to active filter query. * @property {string} filter Active search/filter string. * @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state. * @property {Error|null} error Last load error. */ /** @type {WarehouseState} */ const initialState = { warehouses: [], filteredWarehouses: [], filter: '', status: 'idle', error: null, }; let warehouseAutomat; const applyFilter = (warehouses, filter) => { const query = String(filter ?? '').trim().toLowerCase(); return query ? warehouses.filter((w) => String(w.name ?? '').toLowerCase().includes(query)) : warehouses; }; const updateStateWithWarehouses = (warehouses, extra = {}) => { const list = Array.isArray(warehouses) ? warehouses : []; const filteredWarehouses = applyFilter(list, warehouseAutomat.state.filter); warehouseAutomat.setState({ warehouses: list, filteredWarehouses, ...extra }); return list; }; /** * Fetches the company-scoped warehouse list. * * @returns {Promise>} Warehouse list. */ async function fetchWarehouses() { const data = await warehouseApi.list(); requireObject(data, 'GET /api/warehouse'); return requireArray(data.warehouses, 'warehouses'); } /** In-flight `load()` promise so concurrent callers share one request. */ let inflightLoad = null; /** * Loads the warehouse list once, deduplicating concurrent callers. * * @returns {Promise>} Loaded warehouses. */ function loadWarehouses() { if (inflightLoad) return inflightLoad; inflightLoad = (async () => { warehouseAutomat.setState({ status: 'loading', error: null }); try { const warehouses = await fetchWarehouses(); updateStateWithWarehouses(warehouses, { status: 'ready', error: null }); return warehouses; } catch (error) { warehouseAutomat.setState({ status: 'failed', error }); throw error; } finally { inflightLoad = null; } })(); return inflightLoad; } const actions = { /** Sets the active warehouse search filter and updates filteredWarehouses. */ filter(query) { const filter = String(query ?? ''); warehouseAutomat.setState({ filter, filteredWarehouses: applyFilter(warehouseAutomat.state.warehouses, filter), }); }, /** * Loads the warehouse list, exposing a loading/failed lifecycle. * * @returns {Promise>} Loaded warehouses. */ load() { return loadWarehouses(); }, /** * Silently re-fetches the warehouse list while keeping the current state. * * @returns {Promise>} Fresh warehouses. */ async reload() { try { const warehouses = await fetchWarehouses(); updateStateWithWarehouses(warehouses, { status: 'ready', error: null }); return warehouses; } catch (error) { warehouseAutomat.setState({ error }); throw error; } }, /** * Creates a warehouse and refreshes the cached list. * * @param {{name: string}} body Creation payload. * @returns {Promise} Created warehouse. */ async create(body) { const data = await warehouseApi.create(body); requireObject(data, 'POST /api/warehouse'); requireObject(data.warehouse, 'warehouse'); requireDefined(data.warehouse.id, 'warehouse.id'); await warehouseAutomat.actions.reload(); return data.warehouse; }, /** * Renames a warehouse and refreshes the cached list. * * @param {string|number} id Warehouse identifier. * @param {{name: string}} body Update payload. * @returns {Promise} Updated warehouse. */ async update(id, body) { const data = await warehouseApi.update(id, body); requireObject(data, 'PATCH /api/warehouse/:id'); requireObject(data.warehouse, 'warehouse'); requireDefined(data.warehouse.id, 'warehouse.id'); await warehouseAutomat.actions.reload(); return data.warehouse; }, rename(id, body) { return actions.update(id, body); }, /** * Deletes a warehouse and refreshes the cached list. * * @param {string|number} id Warehouse identifier. * @returns {Promise} */ async remove(id) { await warehouseApi.delete(id); await warehouseAutomat.actions.reload(); }, /** * Persists a new sort order for all warehouses. * * @param {Array} warehouseIds IDs in the desired order. * @returns {Promise} */ async reorder(warehouseIds) { const byId = new Map(warehouseAutomat.state.warehouses.map((w) => [w.id, w])); const reordered = warehouseIds.map((id) => byId.get(id)).filter(Boolean); if (reordered.length === warehouseAutomat.state.warehouses.length) { updateStateWithWarehouses(reordered); } await warehouseApi.order({ warehouseIds }); await warehouseAutomat.actions.reload(); }, /** * Replaces the cached list without a round-trip. * * @param {Array} warehouses Warehouses to cache. * @returns {void} */ setWarehouses(warehouses) { updateStateWithWarehouses(warehouses, { status: 'ready', error: null, }); }, /** * Forgets the cached list and returns to the initial state. * * @returns {void} */ clear() { warehouseAutomat.setState({ ...initialState }); }, }; /** * Company-scoped warehouse cache. Loads automatically once an authenticated * session with a selected company exists and is cleared when auth is gone. * * @type {Automat} */ warehouseAutomat = new Automat(initialState, actions, { name: 'warehouse' }); /** * Derives the cache key from the auth state: the selected company while * authenticated, otherwise `null`. * * @param {import('./authAutomat').AuthState} authState Auth automat state. * @returns {string|null} Cache key. */ function authKey(authState) { if (authState?.status !== 'authed' || !authState.user) return null; const companyId = authState.user.selectedCompanyId; return companyId == null ? null : String(companyId); } let lastAuthKey; /** * Loads the warehouse list when auth/company becomes available and forgets it * when the session ends. * * @param {import('./authAutomat').AuthState} authState Auth automat state. * @returns {void} */ function syncWithAuth(authState) { const key = authKey(authState); if (key === lastAuthKey) return; lastAuthKey = key; if (key == null) { warehouseAutomat.actions.clear(); } else { void warehouseAutomat.actions.load().catch(() => {}); } } authAutomat.subscribe(syncWithAuth); syncWithAuth(authAutomat.state); export default warehouseAutomat;