This commit is contained in:
seb
2026-10-28 21:44:52 +01:00
parent 003b198b86
commit 2e69f49e4d
6 changed files with 494 additions and 233 deletions

View File

@@ -29,12 +29,17 @@
*/ */
import { Automat } from 'react-automat'; import { Automat } from 'react-automat';
import { article as articleApi } from '../api';
import authAutomat from './authAutomat'; import authAutomat from './authAutomat';
import categoryAutomat, { whenCategoryTreeReady } from './categoryAutomat'; import categoryAutomat, { whenCategoryTreeReady } from './categoryAutomat';
import sseAutomat from './sseAutomat';
import { import {
categoryViews, categoryViews,
collectLoadedCategoryViewData, collectLoadedCategoryViewData,
loadAllCategoryViews, loadAllCategoryViews,
invalidateAllCategoryViews,
updateArticleInCategoryViews,
removeArticleBySku,
} from './categoryViewAutomat'; } from './categoryViewAutomat';
/** /**
@@ -98,7 +103,7 @@ function scheduleRebuild() {
function toIndexRecord(article, categoryId) { function toIndexRecord(article, categoryId) {
const eans = article.eans ?? article.eanCodes ?? article.ean ?? null; const eans = article.eans ?? article.eanCodes ?? article.ean ?? null;
const childCount = Number(article.childCount) || 0; const childCount = Number(article.childCount) || 0;
const price = article.price ?? article.net ?? article.vknetto ?? null; const price = article.price ?? article.net ?? article.vknetto ?? (Array.isArray(article.prices) ? (article.prices.find((p) => p.minmultiplier === 1)?.net ?? article.prices[0]?.net) : null);
const warehouses = Array.isArray(article.warehouses) ? article.warehouses : []; const warehouses = Array.isArray(article.warehouses) ? article.warehouses : [];
const trackStock = article.track_stock ?? article.trackStock; const trackStock = article.track_stock ?? article.trackStock;
const stock = trackStock === false const stock = trackStock === false
@@ -111,6 +116,9 @@ function toIndexRecord(article, categoryId) {
const available = article.available != null && !Number.isNaN(Number(article.available)) const available = article.available != null && !Number.isNaN(Number(article.available))
? Number(article.available) ? Number(article.available)
: stock; : stock;
const categories = categoryId != null
? String(categoryId)
: (Array.isArray(article.categoryIds) ? article.categoryIds.join(',') : (article.categories ?? null));
return { return {
...article, ...article,
@@ -126,7 +134,7 @@ function toIndexRecord(article, categoryId) {
? article.articlewarehousesummary ? article.articlewarehousesummary
: [], : [],
eans: Array.isArray(eans) ? eans.filter(Boolean) : eans ? [eans] : [], eans: Array.isArray(eans) ? eans.filter(Boolean) : eans ? [eans] : [],
categories: categoryId != null ? String(categoryId) : null, categories,
isParent: Boolean(article.isParent ?? article.is_parent ?? (childCount > 0)), isParent: Boolean(article.isParent ?? article.is_parent ?? (childCount > 0)),
childCount, childCount,
}; };
@@ -258,6 +266,32 @@ const actions = {
return items; return items;
}, },
/**
* Refetches and updates a single article in the index and category views by SKU.
*
* @param {string|number} sku Article SKU.
* @returns {Promise<object|null>} Fresh article or null.
*/
async updateArticleBySku(sku) {
const key = String(sku);
try {
const res = await articleApi.getBySku(key);
const article = res?.article;
if (article) {
updateArticleIndexRecord(article);
return article;
}
return null;
} catch (err) {
if (err?.status === 404 || err?.statusCode === 404) {
removeArticleBySku(key);
const nextItems = articleIndexAutomat.state.items.filter((r) => String(r.sku) !== key);
articleIndexAutomat.setState({ items: nextItems });
}
return null;
}
},
/** Reset to idle state (logout / company switch). */ /** Reset to idle state (logout / company switch). */
reset() { reset() {
inflightEnsure = null; inflightEnsure = null;
@@ -294,6 +328,72 @@ authAutomat.subscribe((auth) => {
if (auth.status === 'anon') actions.reset(); if (auth.status === 'anon') actions.reset();
}); });
// Upstream Trigger: sseAutomat
if (sseAutomat?.actions?.onArticle) {
sseAutomat.actions.onArticle(async (sku) => {
if (articleIndexAutomat.state.status === 'ready') {
await actions.updateArticleBySku(sku);
}
});
}
if (sseAutomat?.actions?.on) {
sseAutomat.actions.on('articles', () => {
invalidateAllCategoryViews();
void actions.reload().catch(() => {});
});
sseAutomat.actions.on('warehouse', () => {
invalidateAllCategoryViews();
void actions.reload().catch(() => {});
});
}
/**
* Updates a single article record in the derived index and loaded category views.
*
* @param {object} article Article detail or row.
* @returns {Array<ArticleIndexRecord>|undefined} Updated items array.
*/
export function updateArticleIndexRecord(article) {
if (!article || article.sku == null) return;
const key = String(article.sku);
updateArticleInCategoryViews(article);
const items = articleIndexAutomat.state.items;
if (!items || !items.length) return;
const existingIndex = items.findIndex((r) => String(r.sku) === key);
const categoryIds = Array.isArray(article.categoryIds)
? article.categoryIds.map(String)
: [];
const primaryCatId = categoryIds[0] ?? null;
const record = toIndexRecord(article, primaryCatId);
if (categoryIds.length > 0) {
record.categories = categoryIds.join(',');
}
let nextItems;
if (existingIndex >= 0) {
const existing = items[existingIndex];
nextItems = [...items];
nextItems[existingIndex] = {
...existing,
...record,
categories: record.categories ?? existing.categories,
smallThumbBinary: article.smallThumbBinary ?? article.photos?.[0]?.smallThumbBinary ?? existing.smallThumbBinary,
brand: article.brand ?? existing.brand,
brandName: article.brand?.name ?? article.brandName ?? existing.brandName,
};
} else {
nextItems = [...items, record];
}
articleIndexAutomat.setState({ items: nextItems });
return nextItems;
}
/** /**
* Patches a single record's name in the derived index (optimistic update). * Patches a single record's name in the derived index (optimistic update).
* *

View File

@@ -20,7 +20,12 @@ import { Automat } from 'react-automat';
import { article as articleApi } from '../api'; import { article as articleApi } from '../api';
import { requireDefined, requireObject } from '../utils/apiShape'; import { requireDefined, requireObject } from '../utils/apiShape';
import authAutomat from './authAutomat'; import authAutomat from './authAutomat';
import articleIndexAutomat, { setArticleIndexName, setArticleIndexStock } from './articleIndexAutomat'; import articleIndexAutomat, {
setArticleIndexName,
setArticleIndexStock,
updateArticleIndexRecord,
} from './articleIndexAutomat';
import sseAutomat from './sseAutomat';
/** /**
* @typedef {Object} ArtikelState * @typedef {Object} ArtikelState
@@ -113,12 +118,7 @@ const actions = {
artikelAutomat.setState({ artikelAutomat.setState({
bySku: { ...artikelAutomat.state.bySku, [key]: article }, bySku: { ...artikelAutomat.state.bySku, [key]: article },
}); });
if (article.warehouses || article.stock != null) { updateArticleIndexRecord(article);
setArticleIndexStock(article.sku, {
stock: article.stock,
warehouses: article.warehouses,
});
}
return article; return article;
}, },
@@ -325,4 +325,14 @@ authAutomat.subscribe((auth) => {
} }
}); });
// Upstream Trigger: sseAutomat
if (sseAutomat?.actions?.onArticle) {
sseAutomat.actions.onArticle((sku) => {
const key = String(sku);
if (key in artikelAutomat.state.bySku) {
void actions.reloadArticle(key).catch(() => {});
}
});
}
export default artikelAutomat; export default artikelAutomat;

View File

@@ -30,6 +30,7 @@ import { Automat } from 'react-automat';
import { category as categoryApi, article as articleApi } from '../api'; import { category as categoryApi, article as articleApi } from '../api';
import authAutomat from './authAutomat'; import authAutomat from './authAutomat';
import companyAutomat from './companyAutomat'; import companyAutomat from './companyAutomat';
import sseAutomat from './sseAutomat';
import { import {
attachCategoryTree, attachCategoryTree,
categoryViews, categoryViews,
@@ -37,6 +38,7 @@ import {
disposeAllCategoryViewAutomats, disposeAllCategoryViewAutomats,
refreshCategoryViewChildren, refreshCategoryViewChildren,
appendArticlesToCategoryViews, appendArticlesToCategoryViews,
updateArticleInCategoryViews,
removeArticleBySku, removeArticleBySku,
waitForCategoryTree, waitForCategoryTree,
} from './categoryViewAutomat'; } from './categoryViewAutomat';
@@ -512,6 +514,14 @@ authAutomat.subscribe((auth) => {
} }
}); });
// Upstream Trigger: sseAutomat
if (sseAutomat?.actions?.on) {
sseAutomat.actions.on('category', () => {
void categoryAutomat.actions.loadTree().catch(() => {});
invalidateAllCategoryViews();
});
}
// Re-export pure helpers for consumers that previously imported from categorySlice // Re-export pure helpers for consumers that previously imported from categorySlice
export { export {
ROOT_CATEGORY_NAME, ROOT_CATEGORY_NAME,
@@ -532,6 +542,7 @@ export {
disposeAllCategoryViewAutomats, disposeAllCategoryViewAutomats,
loadAllCategoryViews, loadAllCategoryViews,
collectLoadedCategoryViewData, collectLoadedCategoryViewData,
updateArticleInCategoryViews,
} from './categoryViewAutomat'; } from './categoryViewAutomat';
export default categoryAutomat; export default categoryAutomat;

View File

@@ -323,6 +323,64 @@ export function appendArticlesToCategoryViews({ categoryId, articles }) {
} }
} }
/**
* Updates an article in all loaded category views.
*
* @param {object} article Article detail or row with at least `sku`.
* @returns {void}
*/
export function updateArticleInCategoryViews(article) {
if (!article || article.sku == null) return;
const sku = String(article.sku);
const targetCategoryIds = Array.isArray(article.categoryIds)
? new Set(article.categoryIds.map(String))
: null;
for (const [slug, item] of categoryViews._items.entries()) {
if (!item?.data) continue;
const viewCatId = String(item.data.categoryId);
const existingIndex = (item.data.articles || []).findIndex((r) => String(r.sku) === sku);
if (existingIndex >= 0) {
if (targetCategoryIds && !targetCategoryIds.has(viewCatId)) {
// Article moved out of this category
categoryViews.key(slug).set({
...item.data,
articles: item.data.articles.filter((r) => String(r.sku) !== sku),
});
} else {
// Update in-place
const existing = item.data.articles[existingIndex];
const updatedArticles = [...item.data.articles];
updatedArticles[existingIndex] = {
...existing,
...article,
name: article.name ?? existing.name,
price: article.price ?? (Array.isArray(article.prices) ? article.prices[0]?.net : null) ?? article.net ?? existing.price,
net: article.net ?? article.price ?? (Array.isArray(article.prices) ? article.prices[0]?.net : null) ?? existing.net,
stock: article.stock ?? existing.stock,
available: article.available ?? existing.available,
warehouses: article.warehouses ?? existing.warehouses,
brand: article.brand ?? existing.brand,
brandName: article.brand?.name ?? article.brandName ?? existing.brandName,
childCount: article.childCount ?? existing.childCount,
isParent: Boolean(article.childCount > 0 || article.isParent || existing.isParent),
};
categoryViews.key(slug).set({
...item.data,
articles: updatedArticles,
});
}
} else if (targetCategoryIds && targetCategoryIds.has(viewCatId)) {
// Article added to this category
categoryViews.key(slug).set({
...item.data,
articles: [...(item.data.articles || []), article],
});
}
}
}
/** /**
* Removes a deleted article from every loaded view that contains it. * Removes a deleted article from every loaded view that contains it.
* *

View File

@@ -56,6 +56,7 @@ export {
default as articleIndexAutomat, default as articleIndexAutomat,
setArticleIndexName, setArticleIndexName,
setArticleIndexStock, setArticleIndexStock,
updateArticleIndexRecord,
buildArticleIndex, buildArticleIndex,
selectArticleIndexItems, selectArticleIndexItems,
} from './articleIndexAutomat'; } from './articleIndexAutomat';

View File

@@ -28,6 +28,7 @@ import authAutomat from './authAutomat';
* @property {string|null} companyId Company the stream is bound to. * @property {string|null} companyId Company the stream is bound to.
* @property {string|null} clientId Tab-scoped random session id sent as `?cid=` to `/api/events`. * @property {string|null} clientId Tab-scoped random session id sent as `?cid=` to `/api/events`.
* @property {Array<{id: number, type: string, data: string, lastEventId: string, time: number}>} events Recent stream events. * @property {Array<{id: number, type: string, data: string, lastEventId: string, time: number}>} events Recent stream events.
* @property {{id: number, type: string, data: string, lastEventId: string, time: number}|null} lastEvent Most recent event.
*/ */
const EVENT_HISTORY_LIMIT = 200; const EVENT_HISTORY_LIMIT = 200;
@@ -68,6 +69,7 @@ const initialState = {
companyId: null, companyId: null,
clientId: sseClientId, clientId: sseClientId,
events: [], events: [],
lastEvent: null,
}; };
/** Event name prefix for per-article signals. */ /** Event name prefix for per-article signals. */
@@ -76,18 +78,20 @@ const ARTICLE_EVENT_PREFIX = 'article-';
let sseAutomat; let sseAutomat;
/** @type {EventSource | null} */ /** @type {EventSource | null} */
let eventSource = null; let eventSource = null;
/** @type {AbortController | null} */
let streamAbortController = null;
let reconnectTimer = null;
/** /**
* Handlers for statically named events (`category`, `articles`, …). * Handlers for event subscriptions (`category`, `articles`, `article-*`, …).
* @type {Map<string, Set<{handler: (event: MessageEvent) => void, detach: (() => void)|null}>>} * @type {Map<string, Set<{handler: Function, detach: (() => void)|null}>>}
*/ */
const handlers = new Map(); const handlers = new Map();
let nextEventId = 0; let nextEventId = 0;
const seenEvents = new WeakSet(); const seenEvents = new WeakSet();
/** /**
* Handlers per watched SKU, so one `article-<sku>` listener is attached while * Handlers per watched SKU.
* at least one view is open on that SKU.
* @type {Map<string, Set<(sku: string) => void>>} * @type {Map<string, Set<(sku: string) => void>>}
*/ */
const articleWatchers = new Map(); const articleWatchers = new Map();
@@ -99,25 +103,41 @@ function recordEvent(event) {
const entry = { const entry = {
id: ++nextEventId, id: ++nextEventId,
type: event.type, type: event.type,
data: typeof event.data === 'string' ? event.data : '', data: typeof event.data === 'string' ? event.data : (event.data ? JSON.stringify(event.data) : ''),
lastEventId: event.lastEventId ?? '', lastEventId: event.lastEventId ?? '',
time: Date.now(), time: Date.now(),
}; };
sseAutomat.setState((state) => ({ sseAutomat.setState((state) => ({
events: [...state.events.slice(-(EVENT_HISTORY_LIMIT - 1)), entry], events: [...state.events.slice(-(EVENT_HISTORY_LIMIT - 1)), entry],
lastEvent: entry,
})); }));
} }
function addTrackedListener(es, type, handler) { function clearReconnectTimer() {
const listener = (event) => { if (reconnectTimer) {
recordEvent(event); clearTimeout(reconnectTimer);
handler(event); reconnectTimer = null;
}; }
es.addEventListener(type, listener); }
return () => es.removeEventListener(type, listener);
function scheduleReconnect() {
clearReconnectTimer();
const auth = authAutomat.state;
const hasAuth = auth.hasAuth ?? (auth.status === 'authed' && Boolean(auth.user));
if (!hasAuth) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, 1500);
} }
function disconnect() { function disconnect() {
clearReconnectTimer();
if (streamAbortController) {
streamAbortController.abort();
streamAbortController = null;
}
if (eventSource) { if (eventSource) {
console.log('[sse] disconnect'); console.log('[sse] disconnect');
try { try {
@@ -125,21 +145,136 @@ function disconnect() {
} catch {} } catch {}
eventSource = null; eventSource = null;
} }
if (sseAutomat.state.status !== 'disconnected') { if (sseAutomat && sseAutomat.state.status !== 'disconnected') {
sseAutomat.setState({ status: 'disconnected' }); sseAutomat.setState({ status: 'disconnected' });
} }
} }
/** /**
* Attaches every registered handler to a freshly opened stream. * Dispatches an incoming event through registered handlers and article watchers.
* *
* @param {EventSource} es Open event stream. * @param {{ type: string, data: any, lastEventId?: string }} event
* @returns {void}
*/ */
function dispatchSseEvent(event) {
recordEvent(event);
const eventType = String(event.type);
// 1. Built-in system handling (e.g. user / company switch sync)
if (eventType === 'user') {
try {
const payload = typeof event.data === 'string' ? JSON.parse(event.data || '{}') : (event.data || {});
const nextCompanyId = payload.selectedCompanyId ?? null;
if (authAutomat.state.user?.selectedCompanyId !== nextCompanyId) {
authAutomat.setState((prev) => ({
...prev,
user: prev.user ? { ...prev.user, selectedCompanyId: nextCompanyId } : prev.user,
hasCompany: Boolean((prev.hasAuth ?? (prev.status === 'authed' && prev.user)) && nextCompanyId != null),
}));
}
} catch (err) {
console.error('[sse] failed to handle user event', err);
}
}
// 2. Dispatch to exact handlers registered via on(eventType, handler)
const exactSet = handlers.get(eventType);
if (exactSet && exactSet.size > 0) {
for (const entry of exactSet) {
try {
entry.handler(event);
} catch (err) {
console.error(`[sse] error in handler for ${eventType}:`, err);
}
}
} else {
// Fallback direct dispatch if store didn't subscribe yet
if (eventType === 'articles') {
Automat.get('category')?.actions?.invalidateAllCategoryViews?.();
void Automat.get('articleIndex')?.actions?.reload?.().catch(() => {});
} else if (eventType === 'category') {
void Automat.get('category')?.actions?.loadTree?.().catch(() => {});
Automat.get('category')?.actions?.invalidateAllCategoryViews?.();
} else if (eventType === 'company') {
void Automat.get('company')?.actions?.reload?.().catch(() => {});
} else if (eventType === 'warehouse') {
void Automat.get('company')?.actions?.reloadWarehouses?.().catch(() => {});
Automat.get('category')?.actions?.invalidateAllCategoryViews?.();
void Automat.get('articleIndex')?.actions?.reload?.().catch(() => {});
} else if (eventType === 'order') {
void Automat.get('order')?.actions?.reload?.().catch(() => {});
void Automat.get('shipment')?.actions?.reload?.().catch(() => {});
} else if (eventType === 'delivery') {
void Automat.get('delivery')?.actions?.reload?.().catch(() => {});
} else if (eventType === 'retoure') {
void Automat.get('retoure')?.actions?.reload?.().catch(() => {});
} else if (eventType === 'quote') {
void Automat.get('quote')?.actions?.reload?.().catch(() => {});
} else if (eventType === 'shipment') {
void Automat.get('shipment')?.actions?.reload?.().catch(() => {});
}
}
// 3. Dispatch to article-specific handlers if it starts with article-
if (eventType.startsWith(ARTICLE_EVENT_PREFIX)) {
const sku = eventType.slice(ARTICLE_EVENT_PREFIX.length);
// Watchers registered via watchArticle(sku, handler)
const watchers = articleWatchers.get(sku);
if (watchers) {
for (const handler of watchers) {
try {
handler(sku);
} catch (err) {
console.error(`[sse] error in article watcher for ${sku}:`, err);
}
}
}
// Article wildcard handlers (registered via on('article-*', ...) or onArticle(...))
const articleWildcard = handlers.get('article-*');
if (articleWildcard) {
for (const entry of articleWildcard) {
try {
entry.handler(sku, event);
} catch (err) {
console.error('[sse] error in article-* handler:', err);
}
}
}
// Single article detail & history/supplier caches invalidation
invalidateArticle(sku);
}
// 4. Wildcard handlers registered via on('*', handler)
const allWildcard = handlers.get('*');
if (allWildcard) {
for (const entry of allWildcard) {
try {
entry.handler(event);
} catch (err) {
console.error('[sse] error in * handler:', err);
}
}
}
}
function addTrackedListener(es, type, handler) {
const listener = (event) => {
dispatchSseEvent(event);
handler(event);
};
es.addEventListener(type, listener);
return () => es.removeEventListener(type, listener);
}
function attachHandlers(es) { function attachHandlers(es) {
for (const [event, set] of handlers) { for (const [event, set] of handlers) {
if (event === '*' || event === 'article-*') continue;
for (const entry of set) { for (const entry of set) {
entry.detach = addTrackedListener(es, event, entry.handler); entry.detach = addTrackedListener(es, event, (ev) => dispatchSseEvent(ev));
} }
} }
for (const [sku] of articleWatchers) { for (const [sku] of articleWatchers) {
@@ -147,25 +282,127 @@ function attachHandlers(es) {
} }
} }
/**
* Binds the single `article-<sku>` listener for a watched SKU.
*
* @param {string} sku Article SKU.
* @returns {void}
*/
function attachArticleListener(sku) { function attachArticleListener(sku) {
if (!eventSource) return; if (!eventSource) return;
addTrackedListener(eventSource, `${ARTICLE_EVENT_PREFIX}${sku}`, (event) => { addTrackedListener(eventSource, `${ARTICLE_EVENT_PREFIX}${sku}`, (event) => {
const set = articleWatchers.get(sku);
if (!set || set.size === 0) return;
const changed = String(event.type).slice(ARTICLE_EVENT_PREFIX.length); const changed = String(event.type).slice(ARTICLE_EVENT_PREFIX.length);
for (const handler of set) handler(changed); const set = articleWatchers.get(sku);
if (set) {
for (const handler of set) handler(changed);
}
}); });
} }
async function startFetchStream(url, companyId) {
if (streamAbortController) {
streamAbortController.abort();
streamAbortController = null;
}
const controller = new AbortController();
streamAbortController = controller;
const signal = controller.signal;
try {
const res = await fetch(url.href, {
credentials: 'include',
headers: { Accept: 'text/event-stream' },
signal,
});
if (signal.aborted) return;
if (!res.ok) {
if (res.status === 401) {
sseAutomat.setState({ status: 'disconnected' });
return;
}
throw new Error(`SSE HTTP ${res.status}`);
}
if (!res.body) {
throw new Error('Response body is null');
}
sseAutomat.setState({ status: 'connected', companyId });
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let eventType = 'message';
let eventData = '';
let lastEventId = '';
while (!signal.aborted) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split(/\r\n|\r|\n/);
buffer = lines.pop() ?? '';
for (const line of lines) {
if (line === '') {
if (eventData || eventType !== 'message') {
dispatchSseEvent({
type: eventType,
data: eventData,
lastEventId,
});
}
eventType = 'message';
eventData = '';
} else if (line.startsWith(':')) {
continue;
} else if (line.startsWith('event:')) {
eventType = line.slice(6).trim();
} else if (line.startsWith('data:')) {
const chunk = line.slice(5).trim();
eventData = eventData ? `${eventData}\n${chunk}` : chunk;
} else if (line.startsWith('id:')) {
lastEventId = line.slice(3).trim();
}
}
}
} catch (err) {
if (signal.aborted) return;
console.error('[sse] stream error', err);
recordEvent({ type: 'error', data: err?.message || '', lastEventId: '' });
sseAutomat.setState({ status: 'error' });
} finally {
if (!signal.aborted) {
scheduleReconnect();
}
}
}
function startEventSource(url, companyId) {
const es = new EventSource(url.href, { withCredentials: true });
eventSource = es;
addTrackedListener(es, 'open', () => {
sseAutomat.setState({ status: 'connected', companyId });
});
addTrackedListener(es, 'user', (event) => {
dispatchSseEvent(event);
});
addTrackedListener(es, 'message', (event) => {
dispatchSseEvent(event);
});
es.onerror = (e) => {
console.error('[sse] event stream error', e);
recordEvent(e || { type: 'error', data: '', lastEventId: '' });
sseAutomat.setState({ status: 'error' });
};
attachHandlers(es);
}
// #region connect // #region connect
// MARK: opens GET /api/events EventSource when hasAuth is true // MARK: opens GET /api/events stream when hasAuth is true
function connect() { function connect() {
clearReconnectTimer();
const auth = authAutomat.state; const auth = authAutomat.state;
const hasAuth = auth.hasAuth ?? (auth.status === 'authed' && Boolean(auth.user)); const hasAuth = auth.hasAuth ?? (auth.status === 'authed' && Boolean(auth.user));
if (!hasAuth) { if (!hasAuth) {
@@ -180,196 +417,11 @@ function connect() {
console.log('[sse] connecting'); console.log('[sse] connecting');
sseAutomat.setState({ status: 'connecting', companyId }); sseAutomat.setState({ status: 'connecting', companyId });
const es = new EventSource(url.href, { withCredentials: true }); if (typeof fetch === 'function' && typeof ReadableStream !== 'undefined') {
void startFetchStream(url, companyId);
addTrackedListener(es, 'user', (event) => { } else {
try { startEventSource(url, companyId);
const payload = JSON.parse(event.data || '{}'); }
const nextCompanyId = payload.selectedCompanyId ?? null;
if (authAutomat.state.user?.selectedCompanyId !== nextCompanyId) {
authAutomat.setState((prev) => ({
...prev,
user: prev.user ? { ...prev.user, selectedCompanyId: nextCompanyId } : prev.user,
hasCompany: Boolean((prev.hasAuth ?? (prev.status === 'authed' && prev.user)) && nextCompanyId != null),
}));
}
} catch (err) {
console.error('[sse] failed to handle user event', err);
}
});
addTrackedListener(es, 'company', () => {
const comp = Automat.get('company');
if (comp?.actions?.reload) {
void comp.actions.reload().catch((err) => {
console.error('[sse] company refresh failed', err);
});
}
});
// Category tree changed: refresh the tree and every open category view.
addTrackedListener(es, 'category', () => {
const cat = Automat.get('category');
if (cat?.actions?.loadTree) {
void cat.actions.loadTree().catch((err) => {
console.error('[sse] category tree refresh failed', err);
});
}
cat?.actions?.invalidateAllCategoryViews?.();
});
// Article index changed: refresh the index and open category views.
addTrackedListener(es, 'articles', () => {
Automat.get('category')?.actions?.invalidateAllCategoryViews?.();
const idx = Automat.get('articleIndex');
if (idx?.actions?.reload) {
void idx.actions.reload().catch((err) => {
console.error('[sse] article index refresh failed', err);
});
}
});
addTrackedListener(es, 'warehouse', () => {
const comp = Automat.get('company');
if (comp?.actions?.reloadWarehouses) {
void comp.actions.reloadWarehouses().catch((err) => {
console.error('[sse] warehouse refresh failed', err);
});
}
Automat.get('category')?.actions?.invalidateAllCategoryViews?.();
const idx = Automat.get('articleIndex');
if (idx?.actions?.reload) {
void idx.actions.reload().catch((err) => {
console.error('[sse] article index refresh on warehouse event failed', err);
});
}
});
addTrackedListener(es, 'carrier', () => {
const comp = Automat.get('company');
if (comp?.actions?.reloadCarriers) {
void comp.actions.reloadCarriers().catch((err) => {
console.error('[sse] carrier refresh failed', err);
});
}
});
addTrackedListener(es, 'attributename', () => {
const comp = Automat.get('company');
if (comp?.actions?.loadAttributes) {
void comp.actions.loadAttributes().catch((err) => {
console.error('[sse] attributename refresh failed', err);
});
}
});
addTrackedListener(es, 'propertyname', () => {
const comp = Automat.get('company');
if (comp?.actions?.loadProperties) {
void comp.actions.loadProperties().catch((err) => {
console.error('[sse] propertyname refresh failed', err);
});
}
});
addTrackedListener(es, 'supplier', () => {
const comp = Automat.get('company');
if (comp?.actions?.reloadSuppliers) {
void comp.actions.reloadSuppliers().catch((err) => {
console.error('[sse] supplier refresh failed', err);
});
}
});
addTrackedListener(es, 'brand', (event) => {
try {
const payload = JSON.parse(event?.data ?? '{}');
if (payload?.sender && payload.sender === getSseClientId()) return;
} catch {}
const comp = Automat.get('company');
if (comp?.actions?.loadBrands) {
void comp.actions.loadBrands(true).catch(() => {});
}
});
addTrackedListener(es, 'order', () => {
const ord = Automat.get('order');
if (ord?.actions?.reload) {
void ord.actions.reload().catch((err) => {
console.error('[sse] order refresh failed', err);
});
}
const shp = Automat.get('shipment');
if (shp?.actions?.reload) {
void shp.actions.reload().catch((err) => {
console.error('[sse] shipment refresh failed', err);
});
}
});
addTrackedListener(es, 'quote', () => {
const q = Automat.get('quote');
if (q?.actions?.reload) {
void q.actions.reload().catch((err) => {
console.error('[sse] quote refresh failed', err);
});
}
});
addTrackedListener(es, 'shipment', () => {
const shp = Automat.get('shipment');
if (shp?.actions?.reload) {
void shp.actions.reload().catch((err) => {
console.error('[sse] shipment refresh failed', err);
});
}
const ord = Automat.get('order');
if (ord?.actions?.reload) {
void ord.actions.reload().catch((err) => {
console.error('[sse] order refresh failed', err);
});
}
});
addTrackedListener(es, 'delivery', () => {
const del = Automat.get('delivery');
if (del?.actions?.reload) {
void del.actions.reload().catch((err) => {
console.error('[sse] delivery refresh failed', err);
});
}
});
addTrackedListener(es, 'retoure', () => {
const ret = Automat.get('retoure');
if (ret?.actions?.reload) {
void ret.actions.reload().catch((err) => {
console.error('[sse] retoure refresh failed', err);
});
}
const shp = Automat.get('shipment');
if (shp?.actions?.reload) {
void shp.actions.reload().catch((err) => {
console.error('[sse] shipment refresh failed', err);
});
}
});
addTrackedListener(es, 'open', () => {
sseAutomat.setState({ status: 'connected' });
});
addTrackedListener(es, 'message', () => {});
es.onerror = (e) => {
console.error('[sse] event stream error', e);
recordEvent(e || { type: 'error', data: '', lastEventId: '' });
sseAutomat.setState({ status: 'error' });
};
eventSource = es;
attachHandlers(es);
} }
// #endregion // #endregion
@@ -390,7 +442,7 @@ function handleAuthSync(auth) {
return; return;
} }
if (!relogin && eventSource && companyId === sseAutomat.state.companyId) { if (!relogin && (streamAbortController || eventSource) && companyId === sseAutomat.state.companyId) {
return; return;
} }
@@ -420,14 +472,15 @@ const actions = {
}, },
clearEvents() { clearEvents() {
sseAutomat.setState({ events: [] }); sseAutomat.setState({ events: [], lastEvent: null });
}, },
/** /**
* Registers a handler for a statically named backend event. * Registers a handler for a named backend event or pattern.
* Supports exact event names (e.g. `category`, `articles`), `article-*` wildcard, or `*`.
* *
* @param {string} event Event name (e.g. `category`, `articles`). * @param {string} event Event name or pattern.
* @param {(event: MessageEvent) => void} handler Signal handler. * @param {Function} handler Signal handler.
* @returns {() => void} Unsubscribe callback. * @returns {() => void} Unsubscribe callback.
*/ */
on(event, handler) { on(event, handler) {
@@ -440,7 +493,9 @@ const actions = {
if (!entry) { if (!entry) {
entry = { handler, detach: null }; entry = { handler, detach: null };
set.add(entry); set.add(entry);
if (eventSource) entry.detach = addTrackedListener(eventSource, event, handler); if (eventSource && event !== '*' && event !== 'article-*') {
entry.detach = addTrackedListener(eventSource, event, handler);
}
} }
return () => { return () => {
entry.detach?.(); entry.detach?.();
@@ -450,7 +505,17 @@ const actions = {
}, },
/** /**
* Subscribes to `article-<sku>` signals for one SKU. * Subscribes to any article mutation event (`article-<sku>`).
*
* @param {(sku: string, event: object) => void} handler Called with changed SKU.
* @returns {() => void} Unsubscribe callback.
*/
onArticle(handler) {
return actions.on('article-*', (sku, event) => handler(sku, event));
},
/**
* Subscribes to `article-<sku>` signals for one specific SKU.
* *
* @param {string} sku Article SKU to watch. * @param {string} sku Article SKU to watch.
* @param {(sku: string) => void} handler Called with the changed SKU. * @param {(sku: string) => void} handler Called with the changed SKU.
@@ -473,6 +538,22 @@ const actions = {
if (current.size === 0) articleWatchers.delete(key); if (current.size === 0) articleWatchers.delete(key);
}; };
}, },
/**
* Programmatically dispatches an event through sseAutomat.
*
* @param {string} type Event type (e.g. 'article-ART-000001', 'articles').
* @param {any} [data] Event data payload.
* @param {string} [lastEventId] Optional event ID.
* @returns {void}
*/
dispatch(type, data = {}, lastEventId = '') {
dispatchSseEvent({
type,
data: typeof data === 'string' ? data : JSON.stringify(data),
lastEventId,
});
},
}; };
/** /**