diff --git a/src/automats/articleIndexAutomat.js b/src/automats/articleIndexAutomat.js index 8f0a00b..8c10616 100644 --- a/src/automats/articleIndexAutomat.js +++ b/src/automats/articleIndexAutomat.js @@ -29,12 +29,17 @@ */ import { Automat } from 'react-automat'; +import { article as articleApi } from '../api'; import authAutomat from './authAutomat'; import categoryAutomat, { whenCategoryTreeReady } from './categoryAutomat'; +import sseAutomat from './sseAutomat'; import { categoryViews, collectLoadedCategoryViewData, loadAllCategoryViews, + invalidateAllCategoryViews, + updateArticleInCategoryViews, + removeArticleBySku, } from './categoryViewAutomat'; /** @@ -98,7 +103,7 @@ function scheduleRebuild() { function toIndexRecord(article, categoryId) { const eans = article.eans ?? article.eanCodes ?? article.ean ?? null; 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 trackStock = article.track_stock ?? article.trackStock; const stock = trackStock === false @@ -111,6 +116,9 @@ function toIndexRecord(article, categoryId) { const available = article.available != null && !Number.isNaN(Number(article.available)) ? Number(article.available) : stock; + const categories = categoryId != null + ? String(categoryId) + : (Array.isArray(article.categoryIds) ? article.categoryIds.join(',') : (article.categories ?? null)); return { ...article, @@ -126,7 +134,7 @@ function toIndexRecord(article, categoryId) { ? article.articlewarehousesummary : [], 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)), childCount, }; @@ -258,6 +266,32 @@ const actions = { return items; }, + /** + * Refetches and updates a single article in the index and category views by SKU. + * + * @param {string|number} sku Article SKU. + * @returns {Promise} 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() { inflightEnsure = null; @@ -294,6 +328,72 @@ authAutomat.subscribe((auth) => { 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|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). * diff --git a/src/automats/artikelAutomat.js b/src/automats/artikelAutomat.js index 6611b3c..ebf5f84 100644 --- a/src/automats/artikelAutomat.js +++ b/src/automats/artikelAutomat.js @@ -20,7 +20,12 @@ import { Automat } from 'react-automat'; import { article as articleApi } from '../api'; import { requireDefined, requireObject } from '../utils/apiShape'; import authAutomat from './authAutomat'; -import articleIndexAutomat, { setArticleIndexName, setArticleIndexStock } from './articleIndexAutomat'; +import articleIndexAutomat, { + setArticleIndexName, + setArticleIndexStock, + updateArticleIndexRecord, +} from './articleIndexAutomat'; +import sseAutomat from './sseAutomat'; /** * @typedef {Object} ArtikelState @@ -113,12 +118,7 @@ const actions = { artikelAutomat.setState({ bySku: { ...artikelAutomat.state.bySku, [key]: article }, }); - if (article.warehouses || article.stock != null) { - setArticleIndexStock(article.sku, { - stock: article.stock, - warehouses: article.warehouses, - }); - } + updateArticleIndexRecord(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; diff --git a/src/automats/categoryAutomat.js b/src/automats/categoryAutomat.js index 5bb7dcc..80e5ab5 100644 --- a/src/automats/categoryAutomat.js +++ b/src/automats/categoryAutomat.js @@ -30,6 +30,7 @@ import { Automat } from 'react-automat'; import { category as categoryApi, article as articleApi } from '../api'; import authAutomat from './authAutomat'; import companyAutomat from './companyAutomat'; +import sseAutomat from './sseAutomat'; import { attachCategoryTree, categoryViews, @@ -37,6 +38,7 @@ import { disposeAllCategoryViewAutomats, refreshCategoryViewChildren, appendArticlesToCategoryViews, + updateArticleInCategoryViews, removeArticleBySku, waitForCategoryTree, } 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 export { ROOT_CATEGORY_NAME, @@ -532,6 +542,7 @@ export { disposeAllCategoryViewAutomats, loadAllCategoryViews, collectLoadedCategoryViewData, + updateArticleInCategoryViews, } from './categoryViewAutomat'; export default categoryAutomat; diff --git a/src/automats/categoryViewAutomat.js b/src/automats/categoryViewAutomat.js index e99ebcb..b2308ea 100644 --- a/src/automats/categoryViewAutomat.js +++ b/src/automats/categoryViewAutomat.js @@ -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. * diff --git a/src/automats/index.js b/src/automats/index.js index 80fec28..c454244 100644 --- a/src/automats/index.js +++ b/src/automats/index.js @@ -56,6 +56,7 @@ export { default as articleIndexAutomat, setArticleIndexName, setArticleIndexStock, + updateArticleIndexRecord, buildArticleIndex, selectArticleIndexItems, } from './articleIndexAutomat'; diff --git a/src/automats/sseAutomat.js b/src/automats/sseAutomat.js index 60b3c6a..805fbc7 100644 --- a/src/automats/sseAutomat.js +++ b/src/automats/sseAutomat.js @@ -28,6 +28,7 @@ import authAutomat from './authAutomat'; * @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 {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; @@ -68,6 +69,7 @@ const initialState = { companyId: null, clientId: sseClientId, events: [], + lastEvent: null, }; /** Event name prefix for per-article signals. */ @@ -76,18 +78,20 @@ const ARTICLE_EVENT_PREFIX = 'article-'; let sseAutomat; /** @type {EventSource | null} */ let eventSource = null; +/** @type {AbortController | null} */ +let streamAbortController = null; +let reconnectTimer = null; /** - * Handlers for statically named events (`category`, `articles`, …). - * @type {Map void, detach: (() => void)|null}>>} + * Handlers for event subscriptions (`category`, `articles`, `article-*`, …). + * @type {Map void)|null}>>} */ const handlers = new Map(); let nextEventId = 0; const seenEvents = new WeakSet(); /** - * Handlers per watched SKU, so one `article-` listener is attached while - * at least one view is open on that SKU. + * Handlers per watched SKU. * @type {Map void>>} */ const articleWatchers = new Map(); @@ -99,25 +103,41 @@ function recordEvent(event) { const entry = { id: ++nextEventId, 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 ?? '', time: Date.now(), }; sseAutomat.setState((state) => ({ events: [...state.events.slice(-(EVENT_HISTORY_LIMIT - 1)), entry], + lastEvent: entry, })); } -function addTrackedListener(es, type, handler) { - const listener = (event) => { - recordEvent(event); - handler(event); - }; - es.addEventListener(type, listener); - return () => es.removeEventListener(type, listener); +function clearReconnectTimer() { + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } +} + +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() { + clearReconnectTimer(); + if (streamAbortController) { + streamAbortController.abort(); + streamAbortController = null; + } if (eventSource) { console.log('[sse] disconnect'); try { @@ -125,21 +145,136 @@ function disconnect() { } catch {} eventSource = null; } - if (sseAutomat.state.status !== 'disconnected') { + if (sseAutomat && sseAutomat.state.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. - * @returns {void} + * @param {{ type: string, data: any, lastEventId?: string }} event */ +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) { for (const [event, set] of handlers) { + if (event === '*' || event === 'article-*') continue; 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) { @@ -147,25 +282,127 @@ function attachHandlers(es) { } } -/** - * Binds the single `article-` listener for a watched SKU. - * - * @param {string} sku Article SKU. - * @returns {void} - */ function attachArticleListener(sku) { if (!eventSource) return; 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); - 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 -// MARK: opens GET /api/events EventSource when hasAuth is true +// MARK: opens GET /api/events stream when hasAuth is true function connect() { + clearReconnectTimer(); const auth = authAutomat.state; const hasAuth = auth.hasAuth ?? (auth.status === 'authed' && Boolean(auth.user)); if (!hasAuth) { @@ -180,196 +417,11 @@ function connect() { console.log('[sse] connecting'); sseAutomat.setState({ status: 'connecting', companyId }); - const es = new EventSource(url.href, { withCredentials: true }); - - addTrackedListener(es, 'user', (event) => { - try { - 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); + if (typeof fetch === 'function' && typeof ReadableStream !== 'undefined') { + void startFetchStream(url, companyId); + } else { + startEventSource(url, companyId); + } } // #endregion @@ -390,7 +442,7 @@ function handleAuthSync(auth) { return; } - if (!relogin && eventSource && companyId === sseAutomat.state.companyId) { + if (!relogin && (streamAbortController || eventSource) && companyId === sseAutomat.state.companyId) { return; } @@ -420,14 +472,15 @@ const actions = { }, 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 {(event: MessageEvent) => void} handler Signal handler. + * @param {string} event Event name or pattern. + * @param {Function} handler Signal handler. * @returns {() => void} Unsubscribe callback. */ on(event, handler) { @@ -440,7 +493,9 @@ const actions = { if (!entry) { entry = { handler, detach: null }; set.add(entry); - if (eventSource) entry.detach = addTrackedListener(eventSource, event, handler); + if (eventSource && event !== '*' && event !== 'article-*') { + entry.detach = addTrackedListener(eventSource, event, handler); + } } return () => { entry.detach?.(); @@ -450,7 +505,17 @@ const actions = { }, /** - * Subscribes to `article-` signals for one SKU. + * Subscribes to any article mutation event (`article-`). + * + * @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-` signals for one specific SKU. * * @param {string} sku Article SKU to watch. * @param {(sku: string) => void} handler Called with the changed SKU. @@ -473,6 +538,22 @@ const actions = { 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, + }); + }, }; /**