/** * @file carrierAutomat.js * @description Manages cached shipping carriers / Versandarten (`GET /api/carrier`) 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 `'carrier'` SSE event. * - UI / Routes: Calls to `actions.load()`, `actions.reload()`, or mutation actions. */ import { Automat } from 'react-automat'; import { carrier as carrierApi } from '../api'; import authAutomat from './authAutomat'; /** * @typedef {Object} Carrier * @property {number} id Carrier identifier. * @property {string} name Carrier name. * @property {number} sort Sort order. * @property {boolean} hasPackages Whether carrier uses package tracking. * @property {boolean} isReverse Whether carrier is used for returns. */ /** * @typedef {Object} CarrierState * @property {Array} carriers Company-scoped carriers. * @property {Array} filteredCarriers Filtered carriers 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 {CarrierState} */ const initialState = { carriers: [], filteredCarriers: [], filter: '', status: 'idle', error: null, }; let carrierAutomat; const applyFilter = (carriers, filter) => { const query = String(filter ?? '').trim().toLowerCase(); return query ? carriers.filter((c) => String(c.name ?? '').toLowerCase().includes(query)) : carriers; }; const updateStateWithCarriers = (carriers, extra = {}) => { const list = Array.isArray(carriers) ? carriers : []; const filteredCarriers = applyFilter(list, carrierAutomat.state.filter); carrierAutomat.setState({ carriers: list, filteredCarriers, ...extra }); return list; }; /** * Fetches the company-scoped carrier list. * * @returns {Promise>} Carrier list. */ async function fetchCarriers() { const data = await carrierApi.list(); if (Array.isArray(data)) return data; if (data && Array.isArray(data.carriers)) return data.carriers; return []; } /** In-flight `load()` promise so concurrent callers share one request. */ let inflightLoad = null; /** * Loads the carrier list once, deduplicating concurrent callers. * * @returns {Promise>} Loaded carriers. */ function loadCarriers() { if (inflightLoad) return inflightLoad; inflightLoad = (async () => { carrierAutomat.setState({ status: 'loading', error: null }); try { const carriers = await fetchCarriers(); updateStateWithCarriers(carriers, { status: 'ready', error: null }); return carriers; } catch (error) { carrierAutomat.setState({ status: 'failed', error }); throw error; } finally { inflightLoad = null; } })(); return inflightLoad; } const actions = { /** Sets the active carrier search filter and updates filteredCarriers. */ filter(query) { const filter = String(query ?? ''); carrierAutomat.setState({ filter, filteredCarriers: applyFilter(carrierAutomat.state.carriers, filter), }); }, /** * Loads the carrier list, exposing a loading/failed lifecycle. * * @returns {Promise>} Loaded carriers. */ load() { return loadCarriers(); }, /** * Silently re-fetches the carrier list while keeping the current state. * * @returns {Promise>} Fresh carriers. */ async reload() { try { const carriers = await fetchCarriers(); updateStateWithCarriers(carriers, { status: 'ready', error: null }); return carriers; } catch (error) { carrierAutomat.setState({ error }); throw error; } }, /** * Creates a carrier and refreshes the cached list. * * @param {{name: string, sort?: number, hasPackages?: boolean, isReverse?: boolean}} body Creation payload. * @returns {Promise} Created carrier. */ async create(body) { const data = await carrierApi.create(body); const created = data?.carrier || data; await carrierAutomat.actions.reload(); return created; }, /** * Updates a carrier and refreshes the cached list. * * @param {string|number} id Carrier identifier. * @param {{name?: string, sort?: number, hasPackages?: boolean, isReverse?: boolean}} body Update payload. * @returns {Promise} Updated carrier. */ async update(id, body) { const data = await carrierApi.update(id, body); const updated = data?.carrier || data; await carrierAutomat.actions.reload(); return updated; }, /** * Deletes a carrier and refreshes the cached list. * * @param {string|number} id Carrier identifier. * @returns {Promise} */ async remove(id) { await carrierApi.delete(id); await carrierAutomat.actions.reload(); }, /** * Persists a new sort order for all carriers. * * @param {Array} carrierIds IDs in the desired order. * @returns {Promise} */ async reorder(carrierIds) { const byId = new Map(carrierAutomat.state.carriers.map((c) => [c.id, c])); const reordered = carrierIds.map((id) => byId.get(id)).filter(Boolean); if (reordered.length === carrierAutomat.state.carriers.length) { updateStateWithCarriers(reordered); } await carrierApi.order({ carrierIds }); await carrierAutomat.actions.reload(); }, /** * Replaces the cached list without a round-trip. * * @param {Array} carriers Carriers to cache. * @returns {void} */ setCarriers(carriers) { updateStateWithCarriers(carriers, { status: 'ready', error: null, }); }, /** * Forgets the cached list and returns to the initial state. * * @returns {void} */ clear() { carrierAutomat.setState({ ...initialState }); }, }; /** * Company-scoped carrier cache. Loads automatically once an authenticated * session with a selected company exists and is cleared when auth is gone. * * @type {Automat} */ carrierAutomat = new Automat(initialState, actions, { name: 'carrier' }); /** * 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 carrier 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) { carrierAutomat.actions.clear(); } else { void carrierAutomat.actions.load().catch(() => {}); } } authAutomat.subscribe(syncWithAuth); syncWithAuth(authAutomat.state); export default carrierAutomat;