Files
dashfront/src/api/StorageManager.js
2026-09-22 07:20:31 +02:00

743 lines
24 KiB
JavaScript

import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
import { notifyError } from '../utils/notify';
import { parseCategoryTreeResponse } from '../utils/categoryUtils';
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 from '../automats/categoryAutomat';
/**
* 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<typeof createCache> | null} */
#articleIndexCache = null;
/** @type {Map<string, Set<(article: object) => void>>} sku → article listeners */
#articleWatchers = new Map();
/** @type {Map<string, Set<() => void>>} sku → history refreshers */
#historyWatchers = new Map();
/** @type {Map<string, Set<() => 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-<sku>`).
* @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 settled caches, or null if any piece missing.
* @returns {{ categories: object, rootCategoryId: unknown } | null}
*/
getCategoryState() {
const company = this.company;
if (company == null) return null;
const treeData = this.categoryTree;
if (treeData != null) {
// Legacy: tree was loaded via the old cache mechanism
const parsed = parseCategoryTreeResponse(treeData, company.rootCategoryId);
return {
categories: parsed.categories,
rootCategoryId: parsed.rootCategoryId,
};
}
// New path: use categoryAutomat state directly
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-<sku>`.
* 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-<sku>`.
* @param {string} sku
* @param {(suppliers: Array<object>) => void} onSuppliers
* @returns {{ unwatch: () => void, refresh: () => Promise<void> }}
*/
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 = createCache('GET /delivery', () => this.#loadDeliveries());
const customergroupCache = createCache('GET /customergroup', () =>
this.#loadCustomergroups(),
);
const entityCache = createCache('GET /entity', () => this.#loadEntities());
const numberCache = createCache('GET /number', () => this.#loadNumbers());
const quoteCache = createCache('GET /quote', () => this.#loadQuotes());
const orderCache = createCache('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;
// category tree: deferred, reloaded via categoryAutomat
const categoryTreeCache = this.#createDeferredCache(
'GET /category/tree',
() => this.#loadCategoryTree(),
);
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() },
/**
* 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 ─────────────────────────────────────────────────
categoryTreePromise: { get: () => categoryTreeCache.promise },
updateCategoryTree: { value: categoryTreeCache.update },
/** Settled GET /category/tree body, or undefined. */
categoryTree: { get: () => categoryTreeCache.value },
// ── Remaining non-auth-gated caches ─────────────────────────────────────
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() },
});
// Trigger articleIndex load once auth is established
authAutomat.subscribe((auth) => {
if (auth.status === 'authed' && auth.user?.selectedCompanyId) {
if (!articleIndexCache.value) {
void articleIndexCache.update?.().catch(() => {});
}
}
});
}
/**
* Create a cache that does NOT load immediately but waits for an explicit trigger.
* Differs from createCache in that the initial load promise is not started.
*/
#createDeferredCache(label, load) {
let current;
let resolveInitial;
let rejectInitial;
let promise = new Promise((res, rej) => {
resolveInitial = res;
rejectInitial = rej;
});
async function update() {
try {
const data = await load();
current = data;
promise = Promise.resolve(data);
resolveInitial?.(data);
resolveInitial = null;
return data;
} catch (err) {
console.error(`[storageMgr] update ${label} failed`, err);
rejectInitial?.(err);
rejectInitial = null;
throw err;
}
}
function replace(data) {
current = data;
promise = Promise.resolve(data);
resolveInitial?.(data);
resolveInitial = null;
return data;
}
return {
update,
replace,
get promise() { return promise; },
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 this.updateCategoryTree().catch((err) => {
console.error('[storageMgr] category SSE refresh failed', err);
});
});
es.addEventListener('articles', () => {
void this.updateArticleIndex().catch((err) => {
console.error('[storageMgr] articles 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 #loadBrands() {
const data = await this.brand.list();
requireObject(data, 'GET /api/brand');
return requireArray(data.brands, 'brands');
}
async #loadUnits() {
const data = await this.unit.list();
requireObject(data, 'GET /api/unit');
return requireArray(data.units, 'units');
}
async #loadCompanies() {
const data = await this.company.list();
requireObject(data, 'GET /api/company');
return requireArray(data.companies, 'companies');
}
async #loadCategoryTree() {
const data = await this.category.tree();
requireObject(data, 'GET /api/category/tree');
requireArray(data.tree, 'category/tree.tree');
requireArray(data.articles, 'category/tree.articles');
requireDefined(data.languageId, 'category/tree.languageId');
return data;
}
async #loadPosterminals() {
const data = await this.posterminal.list();
requireObject(data, 'GET /api/posterminal');
return requireArray(data.posterminals, 'posterminals');
}
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;