u
This commit is contained in:
208
src/api/StorageManager.js
Normal file
208
src/api/StorageManager.js
Normal 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;
|
||||
Reference in New Issue
Block a user