import { requireArray, requireDefined, requireObject } from '../utils/apiShape'; import { notifyError } from '../utils/notify'; import { ApiClient } from './ApiClient'; import artikelAutomat from '../automats/artikelAutomat'; import companyAutomat from '../automats/companyAutomat'; import brandAutomat from '../automats/brandAutomat'; import unitAutomat from '../automats/unitAutomat'; import authAutomat from '../automats/authAutomat'; import categoryAutomat, { invalidateAllCategoryViews } from '../automats/categoryAutomat'; import posterminalAutomat from '../automats/posterminalAutomat'; import warehouseAutomat from '../automats/warehouseAutomat'; import attributeAutomat from '../automats/attributeAutomat'; import propertyAutomat from '../automats/propertyAutomat'; /** * Stale-while-revalidate cache: readers keep the current promise; update() * swaps only after a successful reload. Failed updates leave the cache intact. * replace() writes local data without a round-trip. */ function createCache(label, load) { /** Settled value, or `undefined` if not loaded yet / last load failed. */ let current; let promise = load() .then((data) => { current = data; return data; }) .catch((err) => { console.error(`[storageMgr] ${label} failed`, err); throw err; }); async function update() { try { const data = await load(); current = data; promise = Promise.resolve(data); return data; } catch (err) { console.error(`[storageMgr] update ${label} failed`, err); throw err; } } function replace(data) { current = data; promise = Promise.resolve(data); return data; } function clear() { current = undefined; promise = load() .then((data) => { current = data; return data; }) .catch((err) => { console.error(`[storageMgr] ${label} failed`, err); throw err; }); } return { update, replace, clear, get promise() { return promise; }, /** Loaded value, or `undefined` if the initial load has not settled successfully. */ get value() { return current; }, }; } /** * ApiClient plus stale-while-revalidate caches and GET /api/events invalidation. */ export class StorageManager extends ApiClient { /** @type {EventSource | null} */ #events = null; /** @type {ReturnType | null} */ #articleIndexCache = null; /** @type {Map void>>} sku → article listeners */ #articleWatchers = new Map(); /** @type {Map void>>} sku → history refreshers */ #historyWatchers = new Map(); /** @type {Map void>>} sku → supplier refreshers */ #supplierWatchers = new Map(); constructor(baseUrl = window.location.origin) { super(baseUrl); this.#initCaches(); this.article.watch = (sku, onArticle) => this.watchArticle(sku, onArticle); this.article.supplierWatch = (sku, onSuppliers) => this.watchArticleSuppliers(sku, onSuppliers); } /** Cached article detail from the shared artikelAutomat, or null. */ getArticle(sku) { return artikelAutomat.state.bySku[String(sku)] ?? null; } /** * Keep the shared article cache in sync after GET/PATCH/PUT responses and * notify any StorageManager-level watchers. */ setArticle(article) { requireObject(article, 'article'); requireDefined(article.sku, 'article.sku'); const key = String(article.sku); const stored = artikelAutomat.actions.setArticle(article); const watchers = this.#articleWatchers.get(key); if (watchers) { for (const onArticle of watchers) onArticle(stored); } return stored; } /** * GET /api/article/{sku} via the shared cache (deduplicated), notify watchers. * @param {string} sku */ async loadArticle(sku) { const article = await artikelAutomat.actions.loadArticle(sku); const watchers = this.#articleWatchers.get(String(sku)); if (watchers) { for (const onArticle of watchers) onArticle(article); } return article; } /** * Subscribe to article detail updates (local setArticle + SSE `article-`). * @param {string} sku * @param {(article: object) => void} onArticle * @returns {{ article: object | null, unwatch: () => void }} */ watchArticle(sku, onArticle) { const key = String(sku); const needListen = !this.#articleWatchers.has(key) && !this.#historyWatchers.has(key) && !this.#supplierWatchers.has(key); let watchers = this.#articleWatchers.get(key); if (!watchers) { watchers = new Set(); this.#articleWatchers.set(key, watchers); } watchers.add(onArticle); if (needListen) { this.#events?.addEventListener(`article-${key}`, this.#onArticleSignal); } return { article: this.getArticle(key) ?? null, unwatch: () => { const set = this.#articleWatchers.get(key); if (!set) return; set.delete(onArticle); if (set.size === 0) { this.#articleWatchers.delete(key); if ( !this.#historyWatchers.has(key) && !this.#supplierWatchers.has(key) ) { this.#events?.removeEventListener(`article-${key}`, this.#onArticleSignal); } } }, }; } /** * Parsed category map + root id from categoryAutomat, or null if not yet loaded. * @returns {{ categories: Record, rootCategoryId: unknown } | null} */ getCategoryState() { const catState = categoryAutomat.state; if (catState.categories == null || catState.treeStatus !== 'succeeded') return null; return { categories: catState.categories, rootCategoryId: catState.rootCategoryId, }; } /** * Fetch article history, then refetch whenever SSE emits `article-`. * Paginates in the store (full list from HTTP; slice via setSkipAndOffset). * @param {string} sku * @param {(history: unknown[], total: number) => void} onHistory * @returns {{ unwatch: () => void, setSkipAndOffset: (skip: number, offset: number) => void }} */ watchArticleHistory(sku, onHistory) { const key = String(sku); let active = true; let loaded = false; /** @type {unknown[]} */ let all = []; let skip = 0; /** Page size (how many rows to emit). */ let offset = Number.POSITIVE_INFINITY; const emit = () => { if (!active || !loaded) return; const total = all.length; const end = offset === Number.POSITIVE_INFINITY ? total : skip + offset; onHistory(all.slice(skip, end), total); }; const refresh = () => this.article.history(key).then((res) => { if (!active) return; requireObject(res, 'GET /api/article/{sku}/history'); all = requireArray(res.history, 'history'); loaded = true; emit(); }); const run = () => void refresh().catch((err) => { if (!active) return; notifyError(err); }); const needListen = !this.#articleWatchers.has(key) && !this.#historyWatchers.has(key) && !this.#supplierWatchers.has(key); let watchers = this.#historyWatchers.get(key); if (!watchers) { watchers = new Set(); this.#historyWatchers.set(key, watchers); } watchers.add(run); if (needListen) { this.#events?.addEventListener(`article-${key}`, this.#onArticleSignal); } run(); return { unwatch: () => { active = false; const set = this.#historyWatchers.get(key); if (!set) return; set.delete(run); if (set.size === 0) { this.#historyWatchers.delete(key); if ( !this.#articleWatchers.has(key) && !this.#supplierWatchers.has(key) ) { this.#events?.removeEventListener(`article-${key}`, this.#onArticleSignal); } } }, setSkipAndOffset: (nextSkip, nextOffset) => { skip = nextSkip; offset = nextOffset; emit(); }, }; } /** * Load suppliers for an article and refetch on SSE `article-`. * @param {string} sku * @param {(suppliers: Array) => void} onSuppliers * @returns {{ unwatch: () => void, refresh: () => Promise }} */ watchArticleSuppliers(sku, onSuppliers) { const key = String(sku); let active = true; const refresh = async () => { const res = await this.article.getSuppliers(key); if (!active) return; requireObject(res, 'GET /api/article/{sku}/supplier'); const list = requireArray(res.suppliers, 'suppliers'); onSuppliers(list); }; const run = () => { void refresh().catch((err) => { if (!active) return; notifyError(err); }); }; const needListen = !this.#articleWatchers.has(key) && !this.#historyWatchers.has(key) && !this.#supplierWatchers.has(key); let watchers = this.#supplierWatchers.get(key); if (!watchers) { watchers = new Set(); this.#supplierWatchers.set(key, watchers); } watchers.add(run); if (needListen) { this.#events?.addEventListener(`article-${key}`, this.#onArticleSignal); } run(); return { refresh, unwatch: () => { active = false; const set = this.#supplierWatchers.get(key); if (!set) return; set.delete(run); if (set.size === 0) { this.#supplierWatchers.delete(key); if ( !this.#articleWatchers.has(key) && !this.#historyWatchers.has(key) ) { this.#events?.removeEventListener(`article-${key}`, this.#onArticleSignal); } } }, }; } #onArticleSignal = (event) => { const sku = event.type.slice('article-'.length); // Refresh the shared cache; silently when nothing is watching this SKU. if (this.#articleWatchers.has(sku)) { void artikelAutomat.actions.reloadArticle(sku).catch((err) => notifyError(err)); } else { void artikelAutomat.actions.reloadArticle(sku).catch(() => {}); } const historyWatchers = this.#historyWatchers.get(sku); if (historyWatchers) { for (const run of historyWatchers) run(); } const supplierWatchers = this.#supplierWatchers.get(sku); if (supplierWatchers) { for (const run of supplierWatchers) run(); } }; /** Patch local articleIndex name if already loaded; no-op otherwise. */ setArticleIndexName(sku, name) { const articles = this.#articleIndexCache.value; if (!articles) return; this.#articleIndexCache.replace( articles.map((a) => (String(a.sku) === String(sku) ? { ...a, name } : a)), ); } /** Patch cached article detail name if already cached; no-op otherwise. */ setArticleName(sku, name) { const current = this.getArticle(sku); if (!current) return; this.setArticle({ ...current, name }); } /** Drop local articleIndex if loaded (forces reload on next read). */ clearArticleIndex() { if (!this.#articleIndexCache.value) return; this.#articleIndexCache.clear(); } #initCaches() { const authMeCache = createCache('GET /auth/me', () => this.#loadAuthMe()); const deliveryCache = this.#createDeferredCache('GET /delivery', () => this.#loadDeliveries(), ); const customergroupCache = this.#createDeferredCache('GET /customergroup', () => this.#loadCustomergroups(), ); const entityCache = this.#createDeferredCache('GET /entity', () => this.#loadEntities(), ); const numberCache = this.#createDeferredCache('GET /number', () => this.#loadNumbers(), ); const quoteCache = this.#createDeferredCache('GET /quote', () => this.#loadQuotes(), ); const orderCache = this.#createDeferredCache('GET /order', () => this.#loadOrders(), ); // articleIndex is auth-gated — create a deferred cache that only loads after auth this.#articleIndexCache = this.#createDeferredCache( 'GET /articleIndex', () => this.#loadArticleIndex(), ); const articleIndexCache = this.#articleIndexCache; Object.defineProperties(this, { authMePromise: { get: () => authMeCache.promise }, updateAuthMe: { value: authMeCache.update }, /** Settled GET /auth/me body, or undefined. */ authMe: { get: () => authMeCache.value }, articleIndexPromise: { get: () => articleIndexCache.promise }, updateArticleIndex: { value: articleIndexCache.update }, // ── Automat bridges (brand, unit, company, posterminal) ────────────────── /** Brand list from brandAutomat (may be empty until authed). */ brands: { get: () => brandAutomat.state.brands }, /** Promise that resolves when brands are first loaded. */ brandPromise: { get: () => brandAutomat.state.status === 'succeeded' ? Promise.resolve(brandAutomat.state.brands) : new Promise((resolve) => { const unsub = brandAutomat.subscribe((s) => { if (s.status === 'succeeded') { unsub(); resolve(s.brands); } }); }), }, /** Reload brands from API. */ updateBrand: { value: () => brandAutomat.actions.load() }, /** Unit list from unitAutomat. */ units: { get: () => unitAutomat.state.units }, /** Promise that resolves when units are first loaded. */ unitPromise: { get: () => unitAutomat.state.status === 'succeeded' ? Promise.resolve(unitAutomat.state.units) : new Promise((resolve) => { const unsub = unitAutomat.subscribe((s) => { if (s.status === 'succeeded') { unsub(); resolve(s.units); } }); }), }, /** Reload units from API. */ updateUnit: { value: () => unitAutomat.actions.load() }, /** Posterminal list from posterminalAutomat. */ posterminals: { get: () => posterminalAutomat.state.terminals }, /** Promise that resolves when posterminals are first loaded. */ posterminalPromise: { get: () => posterminalAutomat.state.status === 'succeeded' ? Promise.resolve(posterminalAutomat.state.terminals) : new Promise((resolve, reject) => { const unsub = posterminalAutomat.subscribe((s) => { if (s.status === 'succeeded') { unsub(); resolve(s.terminals); } else if (s.status === 'failed') { unsub(); reject(new Error(s.error)); } }); }), }, /** Reload posterminals from API. */ updatePosterminal: { value: () => posterminalAutomat.actions.load() }, /** * Current company object from companyAutomat, or null. * Previously: companies array from GET /api/company. * Consumers that used companies.find(c => c.id === companyId) can use .company directly. */ company: { get: () => companyAutomat.state.current ?? null }, /** Promise that resolves when company data is loaded. */ companyPromise: { get: () => companyAutomat.state.status === 'succeeded' ? Promise.resolve(companyAutomat.state.current) : new Promise((resolve, reject) => { const unsub = companyAutomat.subscribe((s) => { if (s.status === 'succeeded') { unsub(); resolve(s.current); } else if (s.status === 'failed') { unsub(); reject(new Error(s.error)); } }); }), }, /** Reload company data from API. */ updateCompany: { value: () => companyAutomat.actions.load() }, // Legacy compat: some components call store.companies.find(c => c.id === companyId). // Return a single-element array so .find() still works. companies: { get: () => companyAutomat.state.current ? [companyAutomat.state.current] : [] }, // ── Category tree bridge (delegated to categoryAutomat) ────────────────── /** Category tree promise resolving when categoryAutomat tree is loaded. */ categoryTreePromise: { get: () => categoryAutomat.state.treeStatus === 'succeeded' ? Promise.resolve(categoryAutomat.state.categories) : new Promise((resolve, reject) => { const unsub = categoryAutomat.subscribe((s) => { if (s.treeStatus === 'succeeded') { unsub(); resolve(s.categories); } else if (s.treeStatus === 'failed') { unsub(); reject(new Error(s.error)); } }); }), }, /** Reload category tree via categoryAutomat. */ updateCategoryTree: { value: () => categoryAutomat.actions.loadTree() }, /** Current categories map from categoryAutomat, or null. */ categoryTree: { get: () => categoryAutomat.state.categories }, // ── Deferred caches (auth-gated) ───────────────────────────────────────── deliveryPromise: { get: () => deliveryCache.promise }, updateDelivery: { value: deliveryCache.update }, /** Settled GET /delivery list, or undefined. */ deliveries: { get: () => deliveryCache.value }, customergroupPromise: { get: () => customergroupCache.promise }, updateCustomergroup: { value: customergroupCache.update }, /** Settled GET /customergroup list, or undefined. */ customergroups: { get: () => customergroupCache.value }, entityPromise: { get: () => entityCache.promise }, updateEntity: { value: entityCache.update }, /** Settled GET /entity list, or undefined. */ entities: { get: () => entityCache.value }, numberPromise: { get: () => numberCache.promise }, updateNumber: { value: numberCache.update }, /** Settled GET /number list, or undefined. */ numbers: { get: () => numberCache.value }, quotePromise: { get: () => quoteCache.promise }, updateQuote: { value: quoteCache.update }, /** Settled GET /quote list, or undefined. */ quotes: { get: () => quoteCache.value }, orderPromise: { get: () => orderCache.promise }, updateOrder: { value: orderCache.update }, /** Settled GET /order list, or undefined. */ orders: { get: () => orderCache.value }, /** Close and reopen GET /api/events (required after selected-company change). */ reconnectEvents: { value: () => this.#reconnectEvents() }, }); let lastCompanyId = null; const syncWithAuth = (auth) => { const companyId = auth.status === 'authed' && auth.user?.selectedCompanyId != null ? String(auth.user.selectedCompanyId) : null; if (companyId === lastCompanyId) return; const prevCompanyId = lastCompanyId; lastCompanyId = companyId; if (!companyId) { // Unauthenticated or logged out this.#disconnectEvents(); articleIndexCache.clear(); deliveryCache.clear(); customergroupCache.clear(); entityCache.clear(); numberCache.clear(); quoteCache.clear(); orderCache.clear(); artikelAutomat.actions.clear(); } else { // Company established or switched if (prevCompanyId) { articleIndexCache.clear(); deliveryCache.clear(); customergroupCache.clear(); entityCache.clear(); numberCache.clear(); quoteCache.clear(); orderCache.clear(); artikelAutomat.actions.clear(); } this.#reconnectEvents(); void articleIndexCache.trigger().catch(() => {}); void deliveryCache.trigger().catch(() => {}); void customergroupCache.trigger().catch(() => {}); void entityCache.trigger().catch(() => {}); void numberCache.trigger().catch(() => {}); void quoteCache.trigger().catch(() => {}); void orderCache.trigger().catch(() => {}); } }; authAutomat.subscribe(syncWithAuth); syncWithAuth(authAutomat.state); } /** * Create a cache that does NOT load immediately but waits for an explicit trigger * or loads on-demand once authenticated. */ #createDeferredCache(label, load) { let current; let loadPromise = null; const trigger = () => { if (loadPromise) return loadPromise; loadPromise = (async () => { try { const data = await load(); current = data; return data; } catch (err) { console.error(`[storageMgr] ${label} failed`, err); loadPromise = null; throw err; } })(); return loadPromise; }; const update = async () => { try { const data = await load(); current = data; loadPromise = Promise.resolve(data); return data; } catch (err) { console.error(`[storageMgr] update ${label} failed`, err); throw err; } }; const replace = (data) => { current = data; loadPromise = Promise.resolve(data); return data; }; const clear = () => { current = undefined; loadPromise = null; }; return { update, replace, clear, trigger, get promise() { if (!loadPromise) { if (authAutomat.state.status === 'authed' && authAutomat.state.user?.selectedCompanyId) { return trigger(); } return new Promise((resolve, reject) => { const unsub = authAutomat.subscribe((auth) => { if (auth.status === 'authed' && auth.user?.selectedCompanyId) { unsub(); trigger().then(resolve, reject); } else if (auth.status === 'anon') { unsub(); reject(new Error(`Cannot load ${label}: unauthenticated`)); } }); }); } return loadPromise; }, get value() { return current; }, }; } /** * GET /api/events — SSE signals for the selected company. * Stream is bound at connect time; reconnect after company switch. */ #reconnectEvents() { this.#disconnectEvents(); const es = new EventSource(this.urlFor('/api/events'), { withCredentials: true }); es.addEventListener('category', () => { void categoryAutomat.actions.loadTree().catch((err) => { console.error('[storageMgr] category tree SSE refresh failed', err); }); invalidateAllCategoryViews(); }); es.addEventListener('articles', () => { invalidateAllCategoryViews(); void this.updateArticleIndex().catch((err) => { console.error('[storageMgr] articles SSE refresh failed', err); }); }); es.addEventListener('warehouse', () => { void warehouseAutomat.actions.reload().catch((err) => { console.error('[storageMgr] warehouse SSE refresh failed', err); }); }); es.addEventListener('attributename', () => { void attributeAutomat.actions.load().catch((err) => { console.error('[storageMgr] attributename SSE refresh failed', err); }); }); es.addEventListener('propertyname', () => { void propertyAutomat.actions.load().catch((err) => { console.error('[storageMgr] propertyname SSE refresh failed', err); }); }); for (const sku of new Set([ ...this.#articleWatchers.keys(), ...this.#historyWatchers.keys(), ...this.#supplierWatchers.keys(), ])) { es.addEventListener(`article-${sku}`, this.#onArticleSignal); } es.onerror = () => { console.error('[storageMgr] events stream error'); }; this.#events = es; } #disconnectEvents() { if (!this.#events) return; this.#events.close(); this.#events = null; } #syncEvents(user) { if (user?.selectedCompanyId != null) { this.#reconnectEvents(); } else { this.#disconnectEvents(); } } async #loadAuthMe() { try { const data = await this.auth.me(); requireObject(data, 'GET /api/auth/me'); const session = { user: data.user, googleClientId: data.googleClientId, }; this.#syncEvents(session.user); return session; } catch (err) { if (err.status === 401 || err.status === 403) { this.#syncEvents(null); return { user: null, googleClientId: err.data?.googleClientId, forbidden: err.status === 403, message: err.message, }; } throw err; } } async #loadArticleIndex() { const data = await this.article.index(); requireObject(data, 'GET /api/articleIndex'); const articles = requireArray(data.articles, 'articleIndex.articles'); // Map backend rows to the recordset shape used by the result list. // `categories` is a comma-joined string (formatCategoryList splits on ','). return articles.map((a) => ({ id: a.id, sku: a.sku, name: a.name, eans: a.eans ?? [], categories: Array.isArray(a.categoryIds) ? a.categoryIds.join(',') : null, })); } async #loadDeliveries() { const data = await this.delivery.list(); requireObject(data, 'GET /api/delivery'); return requireArray(data.deliveries, 'deliveries'); } async #loadCustomergroups() { const data = await this.customergroup.list(); requireObject(data, 'GET /api/customergroup'); return requireArray(data.customergroups, 'customergroups'); } async #loadEntities() { const data = await this.entity.list(); requireObject(data, 'GET /api/entity'); return requireArray(data.entities, 'entities'); } async #loadNumbers() { const data = await this.number.list(); requireObject(data, 'GET /api/number'); return requireArray(data.numbers, 'numbers'); } async #loadQuotes() { const data = await this.quote.list(); requireObject(data, 'GET /api/quote'); return requireArray(data.quotes, 'quotes'); } async #loadOrders() { const data = await this.order.list(); requireObject(data, 'GET /api/order'); return requireArray(data.orders, 'orders'); } async doSearch({ target, filter, offset, maxCount, onUpdate }) { const articles = await this.articleIndexPromise; const query = target.trim().toLowerCase(); const matches = articles.filter( (p) => p.name.toLowerCase().includes(query) || p.sku.toLowerCase().includes(query) || (p.eans ?? []).some((ean) => String(ean).includes(query)), ); onUpdate({ categoryFacets: {}, totalCount: matches.length, recordset: matches, error: null, }); } } export default StorageManager;