This commit is contained in:
seb
2026-08-05 08:48:12 +02:00
parent a88a11224f
commit debd246012
21 changed files with 259 additions and 244 deletions

208
src/api/StorageManager.js Normal file
View File

@@ -0,0 +1,208 @@
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
import { ApiClient } from './ApiClient';
/**
* Stale-while-revalidate cache: readers keep the current promise; update()
* swaps only after a successful reload. Failed updates leave the cache intact.
*/
function createCache(label, load) {
let promise = load().catch((err) => {
console.error(`[storageMgr] ${label} failed`, err);
throw err;
});
async function update() {
try {
const data = await load();
promise = Promise.resolve(data);
return data;
} catch (err) {
console.error(`[storageMgr] update ${label} failed`, err);
throw err;
}
}
return {
update,
get promise() {
return promise;
},
};
}
/**
* ApiClient plus stale-while-revalidate caches and GET /api/events invalidation.
*/
export class StorageManager extends ApiClient {
/** @type {EventSource | null} */
#events = null;
constructor(baseUrl = window.location.origin) {
super(baseUrl);
this.#initCaches();
}
#initCaches() {
const authMeCache = createCache('GET /auth/me', () => this.#loadAuthMe());
const articleIndexCache = createCache('GET /articleIndex', () => this.#loadArticleIndex());
const brandCache = createCache('GET /brand', () => this.#loadBrands());
const unitCache = createCache('GET /unit', () => this.#loadUnits());
const companyCache = createCache('GET /company', () => this.#loadCompanies());
const categoryTreeCache = createCache('GET /category/tree', () => this.#loadCategoryTree());
const posterminalCache = createCache('GET /posterminal', () => this.#loadPosterminals());
Object.defineProperties(this, {
authMePromise: { get: () => authMeCache.promise },
updateAuthMe: { value: authMeCache.update },
articleIndexPromise: { get: () => articleIndexCache.promise },
updateArticleIndex: { value: articleIndexCache.update },
brandPromise: { get: () => brandCache.promise },
updateBrand: { value: brandCache.update },
unitPromise: { get: () => unitCache.promise },
updateUnit: { value: unitCache.update },
companyPromise: { get: () => companyCache.promise },
updateCompany: { value: companyCache.update },
categoryTreePromise: { get: () => categoryTreeCache.promise },
updateCategoryTree: { value: categoryTreeCache.update },
posterminalPromise: { get: () => posterminalCache.promise },
updatePosterminal: { value: posterminalCache.update },
/** Close and reopen GET /api/events (required after selected-company change). */
reconnectEvents: { value: () => this.#reconnectEvents() },
});
}
/**
* 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 event refresh failed', err);
});
});
es.addEventListener('articles', () => {
void this.updateArticleIndex().catch((err) => {
console.error('[storageMgr] articles event refresh failed', err);
});
});
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 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;