/** * @file artikelAutomat.js * @description In-memory reactive cache and mutator for full single-article * detail payloads (`GET /api/article/:sku`), with inflight request deduplication. * * Upstream Triggers: * - `sseAutomat`: Calls `reloadArticle(sku)` via `invalidateArticle(sku)` when an * `article-` SSE mutation event arrives. * - `authAutomat`: Subscribes to auth state changes — calls `actions.clear()` when * status transitions to `'anon'` (logout). * - UI / Routes: Calls `loadArticle(sku)` or mutations (`setName`, `setEans`, * `setBrand`, `setCategories`, `setPrices`, `setWeight`, etc.). * * Downstream Triggers: * - `articleIndexAutomat`: Calls `setArticleIndexName(sku, name)` on optimistic * article renames, and `articleIndexAutomat.actions.reset()` on mutation failure rollback. */ import { Automat } from 'react-automat'; import { article as articleApi } from '../api'; import { requireDefined, requireObject } from '../utils/apiShape'; import authAutomat from './authAutomat'; import articleIndexAutomat, { setArticleIndexName } from './articleIndexAutomat'; /** * @typedef {Object} ArtikelState * @property {Object} bySku Cached article detail keyed by SKU. * Missing keys mean "not loaded"; a value is the full `GET /api/article/{sku}` * detail payload (prices, photos, eans, brand, unit, categoryIds, …). */ /** @type {ArtikelState} */ const initialState = { bySku: {}, }; /** * In-flight detail requests keyed by SKU. Concurrent consumers (view tiles, * route guards, SSE refreshes) share a single `GET /api/article/{sku}`. * * @type {Map>} */ const inflight = new Map(); /** @type {Automat} */ let artikelAutomat; const actions = { /** * Loads an article into the shared cache, deduplicating concurrent callers. * Resolves with the cached article when it is already present. * * @param {string} sku Article SKU. * @returns {Promise} Loaded article detail. */ loadArticle(sku) { const key = String(sku ?? '').trim(); if (!key) { return Promise.reject(new Error('artikelAutomat.loadArticle: SKU fehlt')); } const cached = artikelAutomat.state.bySku[key]; if (cached) return Promise.resolve(cached); const pending = inflight.get(key); if (pending) return pending; const request = articleApi .getBySku(key) .then((res) => { requireObject(res, 'GET /api/article/:sku'); requireObject(res.article, 'article'); requireDefined(res.article.sku, 'article.sku'); return artikelAutomat.actions.setArticle(res.article); }) .finally(() => { inflight.delete(key); }); inflight.set(key, request); return request; }, /** * Silently re-fetches an article, keeping the cached copy until the new one * arrives. Use for SSE invalidation and pull-to-refresh. * * @param {string} sku Article SKU. * @returns {Promise} Fresh article detail. */ async reloadArticle(sku) { const key = String(sku ?? '').trim(); if (!key) throw new Error('artikelAutomat.reloadArticle: SKU fehlt'); const res = await articleApi.getBySku(key); requireObject(res, 'GET /api/article/:sku'); requireObject(res.article, 'article'); requireDefined(res.article.sku, 'article.sku'); return artikelAutomat.actions.setArticle(res.article); }, /** * Stores an article detail payload in the cache. Call after any GET/PATCH/PUT * response so every subscriber sees the same object. * * @param {object} article Article detail with at least a `sku`. * @returns {object} The stored article. */ setArticle(article) { requireObject(article, 'article'); requireDefined(article.sku, 'article.sku'); const key = String(article.sku); artikelAutomat.setState({ bySku: { ...artikelAutomat.state.bySku, [key]: article }, }); return article; }, /** * Patches the cached article name without a round-trip (optimistic update). * * @param {string} sku Article SKU. * @param {string} name New name. * @returns {object|null} Updated article, or `null` when not cached. */ setArticleName(sku, name) { const key = String(sku); const current = artikelAutomat.state.bySku[key]; if (!current) return null; return artikelAutomat.actions.setArticle({ ...current, name }); }, /** * Drops an article from the cache (e.g. after deletion), forcing the next * `loadArticle` to hit the backend. * * @param {string} sku Article SKU. * @returns {void} */ clearArticle(sku) { const key = String(sku); if (!(key in artikelAutomat.state.bySku)) return; const bySku = { ...artikelAutomat.state.bySku }; delete bySku[key]; artikelAutomat.setState({ bySku }); }, /** * PATCH /api/article/:sku with optimistic name handling. * * A `name` in the body is applied to both caches immediately so the tile * does not flicker, then rolled back from the server on failure. * * @param {string} sku Article SKU. * @param {object} body Patch body (`{ name?, unitamount?, unitSi?, track_stock? }`). * @returns {Promise} Response body containing the fresh `article`. */ async update(sku, body) { const key = String(sku); const nameChange = body != null && Object.hasOwn(body, 'name'); if (nameChange) { setArticleIndexName(key, body.name); actions.setArticleName(key, body.name); } try { const res = await articleApi.update(key, body); actions.setArticle(res.article); return res; } catch (err) { if (nameChange) { // The optimistic patch was wrong: drop the index entry and refetch. articleIndexAutomat.actions.reset(); if (hasArticle(key)) { void actions.loadArticle(key).catch(() => {}); } } throw err; } }, /** * PUT /api/article/:sku/eans → refreshes the cached article. * * @param {string} sku Article SKU. * @param {Array} eans Replacement EAN list. * @returns {Promise} Response body containing the fresh `article`. */ async setEans(sku, eans) { const res = await articleApi.setEansBySku(String(sku), eans); actions.setArticle(res.article); return res; }, /** * PUT /api/article/:sku/brand → refreshes the cached article. * * @param {string} sku Article SKU. * @param {{ brandId: number|null }} body Brand payload. * @returns {Promise} Response body containing the fresh `article`. */ async setBrand(sku, body) { const res = await articleApi.setBrandIdBySku(String(sku), body?.brandId); actions.setArticle(res.article); return res; }, /** * PUT /api/article/:sku/categories → refreshes the cached article. * * @param {string} sku Article SKU. * @param {Array} categoryIds Replacement category ids. * @returns {Promise} Response body containing the fresh `article`. */ async setCategories(sku, categoryIds) { const res = await articleApi.setCategories(String(sku), categoryIds); actions.setArticle(res.article); return res; }, /** * PUT /api/article/:sku/prices → refreshes the cached article. * * @param {string} sku Article SKU. * @param {Array} prices Net price rows. * @returns {Promise} Response body containing the fresh `article`. */ async setPrices(sku, prices) { const res = await articleApi.setPrices(String(sku), prices); actions.setArticle(res.article); return res; }, /** * PUT /api/article/:sku/weight → refreshes the cached article. * * @param {string} sku Article SKU. * @param {object} body Weight payload. * @returns {Promise} Response body containing the fresh `article`. */ async setWeight(sku, body) { const res = await articleApi.setWeight(String(sku), body); actions.setArticle(res.article); return res; }, /** * PUT /api/article/:sku/track_stock → refreshes the cached article. * * @param {string} sku Article SKU. * @param {boolean|object} body Flag or full body. * @returns {Promise} Response body containing the fresh `article`. */ async setTrackStock(sku, body) { const res = await articleApi.setTrackStock(String(sku), body); if (res?.article) actions.setArticle(res.article); return res; }, /** * Forgets every cached article. * * @returns {void} */ clear() { artikelAutomat.setState({ bySku: {} }); }, }; /** * Shared cache for article detail payloads. State lives in module scope so * navigating between articles (and remounting the view) reuses the cache * instead of re-fetching. * * @type {Automat} */ artikelAutomat = new Automat(initialState, actions, { name: 'artikel' }); /** * Reads a cached article synchronously. * * @param {string} sku Article SKU. * @returns {object|null} Cached article detail, or `null`. */ export function getArticle(sku) { if (sku == null) return null; return artikelAutomat.state.bySku[String(sku)] ?? null; } /** * Whether an article detail payload is currently cached. * * @param {string} sku Article SKU. * @returns {boolean} `true` when cached. */ export function hasArticle(sku) { return getArticle(sku) != null; } /** * Builds a selector for one SKU, so a component can subscribe to just that * article instead of every cache change. * * @param {string} sku Article SKU. * @returns {(state: ArtikelState) => object|null} Selector function. */ export function createArticleSelector(sku) { const key = sku == null ? null : String(sku); return (state) => (key == null ? null : state.bySku[key] ?? null); } /** * Shorthand for `artikelAutomat.select(createArticleSelector(sku))`. * * @param {string} sku Article SKU. * @returns {import('react-automat').ScopedSlice} Scoped slice. */ export function selectArticle(sku) { return artikelAutomat.select(createArticleSelector(sku)); } // Drop cached articles on logout. A company switch keeps them; the SSE hub // reconnects and revalidates whatever is on screen. authAutomat.subscribe((auth) => { if (auth.status === 'anon') { actions.clear(); } }); export default artikelAutomat;