This commit is contained in:
seb
2026-09-22 07:20:31 +02:00
parent 536e7b1e2a
commit 5e13b8f9cd
32 changed files with 2485 additions and 1908 deletions

View File

@@ -1,8 +1,14 @@
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
import { notifyError } from '../utils/notify';
import { parseCategoryTreeResponse } from '../store/categorySlice';
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()
@@ -172,20 +178,25 @@ export class StorageManager extends ApiClient {
* @returns {{ categories: object, rootCategoryId: unknown } | null}
*/
getCategoryState() {
const treeData = this.categoryTree;
const companies = this.companies;
const session = this.authMe;
if (treeData == null || companies == null || session == null) return null;
const companyId = session.user?.selectedCompanyId;
if (companyId == null) return null;
const company = companies.find((c) => c.id === companyId);
const company = this.company;
if (company == null) return null;
const parsed = parseCategoryTreeResponse(treeData, company.rootCategoryId);
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: parsed.categories,
rootCategoryId: parsed.rootCategoryId,
categories: catState.categories,
rootCategoryId: catState.rootCategoryId,
};
}
@@ -375,13 +386,6 @@ export class StorageManager extends ApiClient {
#initCaches() {
const authMeCache = createCache('GET /auth/me', () => this.#loadAuthMe());
this.#articleIndexCache = createCache('GET /articleIndex', () => this.#loadArticleIndex());
const articleIndexCache = this.#articleIndexCache;
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());
const deliveryCache = createCache('GET /delivery', () => this.#loadDeliveries());
const customergroupCache = createCache('GET /customergroup', () =>
this.#loadCustomergroups(),
@@ -391,6 +395,19 @@ export class StorageManager extends ApiClient {
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 },
@@ -400,29 +417,68 @@ export class StorageManager extends ApiClient {
articleIndexPromise: { get: () => articleIndexCache.promise },
updateArticleIndex: { value: articleIndexCache.update },
brandPromise: { get: () => brandCache.promise },
updateBrand: { value: brandCache.update },
/** Settled GET /brand list, or undefined. */
brands: { get: () => brandCache.value },
// ── 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() },
unitPromise: { get: () => unitCache.promise },
updateUnit: { value: unitCache.update },
/** Settled GET /unit list, or undefined. */
units: { get: () => unitCache.value },
/** 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() },
companyPromise: { get: () => companyCache.promise },
updateCompany: { value: companyCache.update },
/** Settled GET /company list, or undefined. */
companies: { get: () => companyCache.value },
/**
* 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 },
posterminalPromise: { get: () => posterminalCache.promise },
updatePosterminal: { value: posterminalCache.update },
// ── Remaining non-auth-gated caches ─────────────────────────────────────
deliveryPromise: { get: () => deliveryCache.promise },
updateDelivery: { value: deliveryCache.update },
/** Settled GET /delivery list, or undefined. */
@@ -456,8 +512,63 @@ export class StorageManager extends ApiClient {
/** 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.