330 lines
10 KiB
JavaScript
330 lines
10 KiB
JavaScript
/**
|
|
* @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-<sku>` 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<string, 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<string, Promise<object>>}
|
|
*/
|
|
const inflight = new Map();
|
|
|
|
/** @type {Automat<ArtikelState>} */
|
|
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<object>} 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<object>} 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<object>} 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<string>} eans Replacement EAN list.
|
|
* @returns {Promise<object>} 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<object>} 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<number>} categoryIds Replacement category ids.
|
|
* @returns {Promise<object>} 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<object>} prices Net price rows.
|
|
* @returns {Promise<object>} 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<object>} 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<object>} 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<ArtikelState>}
|
|
*/
|
|
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<object|null>} 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;
|