/** * @file supplierAutomat.js * @description Manages cached supplier list (`GET /api/supplier`) 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 `'supplier'` SSE event. * - UI / Routes: Calls to `actions.load()`, `actions.reload()`, or `actions.setSuppliers()`. */ import { Automat } from 'react-automat'; import { supplier as supplierApi } from '../api'; import { requireArray, requireDefined, requireObject } from '../utils/apiShape'; import authAutomat from './authAutomat'; /** * @typedef {Object} Supplier * @property {string|number} id Supplier identifier. * @property {string} name Supplier name. */ /** * @typedef {Object} SupplierState * @property {Array} suppliers Company-scoped suppliers. * @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state. * @property {Error|null} error Last load error. */ /** @type {SupplierState} */ const initialState = { suppliers: [], articlesBySupplier: {}, articlesLoading: false, status: 'idle', error: null, }; let supplierAutomat; /** * Fetches the company-scoped supplier list. * * @returns {Promise>} Supplier list. */ async function fetchSuppliers() { const data = await supplierApi.list(); requireObject(data, 'GET /api/supplier'); return requireArray(data.suppliers, 'suppliers'); } /** In-flight `load()` promise so concurrent consumers share one request. */ let inflightLoad = null; /** * Loads the supplier list once, deduplicating concurrent callers. * * @returns {Promise>} Loaded suppliers. */ function loadSuppliers() { if (inflightLoad) return inflightLoad; inflightLoad = (async () => { supplierAutomat.setState({ status: 'loading', error: null }); try { const suppliers = await fetchSuppliers(); supplierAutomat.setState({ suppliers, status: 'ready', error: null }); void actions.loadAllSupplierArticles().catch(() => {}); return suppliers; } catch (error) { supplierAutomat.setState({ status: 'failed', error }); throw error; } finally { inflightLoad = null; } })(); return inflightLoad; } const actions = { /** * Loads the supplier list, exposing a loading/failed lifecycle. * * @returns {Promise>} Loaded suppliers. */ load() { return loadSuppliers(); }, /** * Silently re-fetches the supplier list while keeping the current state. * * @returns {Promise>} Fresh suppliers. */ async reload() { try { const suppliers = await fetchSuppliers(); supplierAutomat.setState({ suppliers, status: 'ready', error: null }); void actions.loadAllSupplierArticles().catch(() => {}); return suppliers; } catch (error) { supplierAutomat.setState({ error }); throw error; } }, /** * Loads articles for a single supplier, caching them in state.articlesBySupplier. * * @param {string|number} supplierId * @returns {Promise>} */ async loadArticlesForSupplier(supplierId) { const sId = String(supplierId); if (supplierAutomat.state.articlesBySupplier?.[sId]) { return supplierAutomat.state.articlesBySupplier[sId]; } const res = await supplierApi.getArticles(supplierId); const articles = Array.isArray(res?.articles) ? res.articles : []; supplierAutomat.setState({ articlesBySupplier: { ...(supplierAutomat.state.articlesBySupplier || {}), [sId]: articles, }, }); return articles; }, /** * Loads articles for all suppliers in the supplier list, caching them in state.articlesBySupplier. * * @returns {Promise>>} */ async loadAllSupplierArticles() { const suppliers = supplierAutomat.state.suppliers; if (!suppliers.length) return {}; supplierAutomat.setState({ articlesLoading: true }); try { const results = await Promise.allSettled( suppliers.map(async (s) => { const res = await supplierApi.getArticles(s.id); return { id: String(s.id), articles: Array.isArray(res?.articles) ? res.articles : [] }; }), ); const nextMap = { ...(supplierAutomat.state.articlesBySupplier || {}) }; for (const r of results) { if (r.status === 'fulfilled') { nextMap[r.value.id] = r.value.articles; } } supplierAutomat.setState({ articlesBySupplier: nextMap, articlesLoading: false }); return nextMap; } catch { supplierAutomat.setState({ articlesLoading: false }); return supplierAutomat.state.articlesBySupplier || {}; } }, /** * Creates a supplier and refreshes the cached list. * * @param {{name: string}} body Creation payload. * @returns {Promise} Created supplier. */ async create(body) { const data = await supplierApi.create(body); requireObject(data, 'POST /api/supplier'); requireObject(data.supplier, 'supplier'); requireDefined(data.supplier.id, 'supplier.id'); await supplierAutomat.actions.reload(); return data.supplier; }, /** * Renames a supplier and refreshes the cached list. * * @param {string|number} id Supplier identifier. * @param {{name: string}} body Update payload. * @returns {Promise} Updated supplier. */ async update(id, body) { const data = await supplierApi.update(id, body); requireObject(data, 'PATCH /api/supplier/:id'); requireObject(data.supplier, 'supplier'); requireDefined(data.supplier.id, 'supplier.id'); await supplierAutomat.actions.reload(); return data.supplier; }, /** * Deletes a supplier and refreshes the cached list. * * @param {string|number} id Supplier identifier. * @returns {Promise} */ async remove(id) { await supplierApi.delete(id); await supplierAutomat.actions.reload(); }, /** * Replaces the cached list without a round-trip. * * @param {Array} suppliers Suppliers to cache. * @returns {void} */ setSuppliers(suppliers) { supplierAutomat.setState({ suppliers: Array.isArray(suppliers) ? suppliers : [], status: 'ready', error: null, }); }, /** * Forgets the cached list and returns to the initial state. * * @returns {void} */ clear() { supplierAutomat.setState({ ...initialState }); }, }; /** * Company-scoped supplier cache. Loads automatically once an authenticated * session with a selected company exists and is cleared when auth is gone. * * @type {Automat} */ supplierAutomat = new Automat(initialState, actions, { name: 'supplier' }); /** * 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 supplier 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) { supplierAutomat.actions.clear(); } else { void supplierAutomat.actions.load().catch(() => {}); } } authAutomat.subscribe(syncWithAuth); syncWithAuth(authAutomat.state); export default supplierAutomat;