diff --git a/src/api/index.js b/src/api/index.js index eb61375..b4d9817 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -21,25 +21,39 @@ function readableHeaders(headers) { return out; } +const SSE_CLIENT_ID_KEY = 'sse.cid'; +const CLIENT_ID_HEADER = 'X-Client-Id'; + +function getStoredClientId() { + try { + if (typeof window === 'undefined') return null; + return window.sessionStorage.getItem(SSE_CLIENT_ID_KEY); + } catch { + return null; + } +} + +function clientIdHeaders() { + const cid = getStoredClientId(); + return cid ? { [CLIENT_ID_HEADER]: cid } : {}; +} + +function isMutatingMethod(method) { + const upper = String(method || 'GET').toUpperCase(); + return upper !== 'GET' && upper !== 'HEAD'; +} + async function http(path, { method = 'GET', body } = {}) { const isFormData = typeof FormData !== 'undefined' && body instanceof FormData; const headers = {}; if (!isFormData) { headers['Content-Type'] = 'application/json'; } + if (isMutatingMethod(method)) { + Object.assign(headers, clientIdHeaders()); + } const url = new URL(path, window.location.origin).href; - logApi('request', { - method, - path, - url, - origin: window.location.origin, - credentials: 'include', - // HttpOnly cookies are invisible here; this only shows JS-readable cookies. - documentCookie: document.cookie || '(empty)', - hasBody: Boolean(body), - }); - const res = await fetch(path, { method, headers, @@ -47,18 +61,6 @@ async function http(path, { method = 'GET', body } = {}) { body: isFormData ? body : (body ? JSON.stringify(body) : undefined), }); - const responseHeaders = readableHeaders(res.headers); - // Browsers hide Set-Cookie from JS; if it's missing here that proves nothing. - logApi('response', { - method, - path, - status: res.status, - statusText: res.statusText, - ok: res.ok, - responseHeaders, - documentCookieAfter: document.cookie || '(empty)', - }); - if (!res.ok) { let message = res.statusText; let data; @@ -78,12 +80,6 @@ async function http(path, { method = 'GET', body } = {}) { } if (res.status === 204) return null; const json = await res.json(); - logApi('ok-body', { - method, - path, - keys: json && typeof json === 'object' ? Object.keys(json) : typeof json, - hasUser: Boolean(json?.user), - }); return json; } @@ -102,7 +98,7 @@ async function httpBinary(path, { method = 'POST', body, contentType, headers: e const res = await fetch(path, { method, - headers: { 'Content-Type': contentType, ...(extraHeaders || {}) }, + headers: { 'Content-Type': contentType, ...clientIdHeaders(), ...(extraHeaders || {}) }, credentials: 'include', body, }); @@ -851,13 +847,22 @@ export const brand = { }, /** - * PUT /api/brand - * Body: { name, gsprpinfo? } — upsert by name for the selected company. - * Same name ⇒ update, otherwise create. Empty name ⇒ 400 Invalid brand. - * → 200 { brand: Brand } + * POST /api/brand + * Body: { name, gsprpinfo? } — insert for the selected company. + * Empty name ⇒ 400 Invalid brand. + * → 201 { brand: Brand } · 409 Brand already exists */ - upsert(body) { - return http('/api/brand', { method: 'PUT', body }); + create(body) { + return http('/api/brand', { method: 'POST', body }); + }, + + /** + * PATCH /api/brand/:id + * Body: { name, gsprpinfo? } — rename / update info. + * → 200 { id, name, gsprpinfo, touchedArticleCount } · 404 Brand not found + */ + rename(id, body) { + return http(`/api/brand/${encodeURIComponent(id)}`, { method: 'PATCH', body }); }, /** @@ -1170,6 +1175,7 @@ export const delivery = { formData.append('file', file, file.name); const res = await fetch('/api/delivery/parse', { method: 'POST', + headers: { ...clientIdHeaders() }, credentials: 'include', body: formData, signal, @@ -1264,6 +1270,7 @@ export const serper = { method: 'POST', headers: { 'Content-Type': 'application/json', + ...clientIdHeaders(), }, credentials: 'include', body: JSON.stringify({ q, gl, hl, num, preferSource }), @@ -1355,6 +1362,7 @@ export const serper = { method: 'POST', headers: { 'Content-Type': 'application/json', + ...clientIdHeaders(), }, credentials: 'include', body: JSON.stringify({ url }), @@ -2165,6 +2173,7 @@ export const vfs = { } const res = await fetch('/api/vfs/upload', { method: 'POST', + headers: { ...clientIdHeaders() }, credentials: 'include', body: formData, }); diff --git a/src/automats/brandAutomat.js b/src/automats/brandAutomat.js index 648ce11..4510fbf 100644 --- a/src/automats/brandAutomat.js +++ b/src/automats/brandAutomat.js @@ -5,7 +5,7 @@ * Upstream Triggers: * - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on * `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`. - * - UI / Settings: Mutating actions (`upsert`, `remove`, `addImage`, `deleteImage`). + * - UI / Settings: Mutating actions (`create`, `rename`, `remove`, `addImage`, `deleteImage`). */ import { Automat } from 'react-automat'; @@ -50,11 +50,11 @@ const actions = { }, /** - * Upsert a brand (create or update by name). + * Create a brand (insert). 409 Brand already exists on duplicate name. * @param {{ name: string, gsprpinfo?: string }} body */ - async upsert(body) { - const res = await brandApi.upsert(body); + async create(body) { + const res = await brandApi.create(body); const row = res.brand; const brands = sortBrands([ ...brandAutomat.state.brands.filter((b) => b.id !== row.id), @@ -64,6 +64,21 @@ const actions = { return row; }, + /** + * Rename a brand. Returns `{ id, name, gsprpinfo, touchedArticleCount }`. + * @param {number} id + * @param {{ name?: string, gsprpinfo?: string }} body + */ + async rename(id, body) { + const res = await brandApi.rename(id, body); + const updated = res.brand ?? { id: res.id, name: res.name, gsprpinfo: res.gsprpinfo }; + const brands = sortBrands( + brandAutomat.state.brands.map((b) => (b.id === id ? { ...b, ...updated } : b)), + ); + brandAutomat.setState({ brands }); + return res; + }, + /** * Delete a brand by id. * @param {number} id diff --git a/src/automats/chatAutomat.js b/src/automats/chatAutomat.js index ce628fd..ff328a7 100644 --- a/src/automats/chatAutomat.js +++ b/src/automats/chatAutomat.js @@ -16,6 +16,7 @@ import { Automat } from 'react-automat'; import { modelconversation as modelconversationApi } from '../api'; import authAutomat from './authAutomat'; +import { getSseClientId } from './sseAutomat'; import { attachUserEnv, getCurrentRoute, @@ -110,6 +111,23 @@ let companyReconnectTimer = null; let conversationStream = null; let activeStreamConversationId = null; +/** + * Close the company-wide SSE stream (/api/events). Called on logout so no + * stale stream survives; reconnected on relogin via `ensureCompanyEventsConnected()`. + */ +function disconnectCompanyEvents() { + if (companyReconnectTimer) { + clearTimeout(companyReconnectTimer); + companyReconnectTimer = null; + } + if (companyEventSource) { + try { + companyEventSource.close(); + } catch {} + companyEventSource = null; + } +} + /** * Connect to company-wide SSE stream (/api/events) to listen for 'modelconversation' events. */ @@ -128,7 +146,10 @@ function ensureCompanyEventsConnected() { companyReconnectTimer = null; } - const es = new EventSource('/api/events', { withCredentials: true }); + const url = new URL('/api/events', window.location.origin); + const cid = getSseClientId(); + if (cid) url.searchParams.set('cid', cid); + const es = new EventSource(url.href, { withCredentials: true }); companyEventSource = es; es.addEventListener('modelconversation', () => { @@ -1362,28 +1383,24 @@ const actions = { */ reset() { closeConversationStream(); - if (companyEventSource) { - try { - companyEventSource.close(); - } catch {} - companyEventSource = null; - } - if (companyReconnectTimer) { - clearTimeout(companyReconnectTimer); - companyReconnectTimer = null; - } + disconnectCompanyEvents(); chatAutomat.setState(initialState); }, }; chatAutomat = new Automat(initialState, actions, { name: 'chat' }); -// Listen to auth changes: reset on logout or company change +// Listen to auth changes: disconnect on logout, reconnect on relogin/company change. +let chatWasAuthed = false; authAutomat.subscribe((auth) => { const companyId = auth.user?.selectedCompanyId; + const isAuthed = auth.status === 'authed' && companyId != null; + const relogin = isAuthed && !chatWasAuthed; + chatWasAuthed = isAuthed; if (auth.status === 'anon' || !companyId) { + disconnectCompanyEvents(); chatAutomat.actions.reset(); - } else if (auth.status === 'authed' && companyId) { + } else if (relogin || (auth.status === 'authed' && companyId)) { if (chatAutomat.state.sseEstablished) { ensureCompanyEventsConnected(); void chatAutomat.actions.loadConversations(true); diff --git a/src/automats/sseAutomat.js b/src/automats/sseAutomat.js index 1fc1560..420fa9a 100644 --- a/src/automats/sseAutomat.js +++ b/src/automats/sseAutomat.js @@ -37,15 +37,51 @@ import supplierAutomat from './supplierAutomat'; * @typedef {Object} SseState * @property {'disconnected'|'connecting'|'connected'|'error'} status Connection state. * @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. */ const EVENT_HISTORY_LIMIT = 200; +const SSE_CLIENT_ID_KEY = 'sse.cid'; + +function createClientId() { + try { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + } catch {} + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}${Math.random().toString(36).slice(2, 10)}`; +} + +/** + * Returns the tab-scoped SSE client id, creating and persisting it on first use. + * Created when the automat module is evaluated so every `EventSource` in this + * tab shares the same `cid`. + * + * @returns {string} Client id. + */ +export function getSseClientId() { + try { + const existing = window.sessionStorage.getItem(SSE_CLIENT_ID_KEY); + if (existing) return existing; + const id = createClientId(); + window.sessionStorage.setItem(SSE_CLIENT_ID_KEY, id); + return id; + } catch { + if (!getSseClientId.fallback) getSseClientId.fallback = createClientId(); + return getSseClientId.fallback; + } +} + +// Created once per tab at automat creation time and stored to sessionStorage. +const sseClientId = typeof window !== 'undefined' ? getSseClientId() : null; + /** @type {SseState} */ const initialState = { status: 'disconnected', companyId: null, + clientId: sseClientId, events: [], }; @@ -97,10 +133,16 @@ function addTrackedListener(es, type, handler) { } function disconnect() { - if (!eventSource) return; - eventSource.close(); - eventSource = null; - sseAutomat.setState({ status: 'disconnected' }); + if (eventSource) { + console.log('[sse] disconnect'); + try { + eventSource.close(); + } catch {} + eventSource = null; + } + if (sseAutomat.state.status !== 'disconnected') { + sseAutomat.setState({ status: 'disconnected' }); + } } /** @@ -147,10 +189,12 @@ function connect() { return; } - const url = new URL('/api/events', window.location.origin).href; + const url = new URL('/api/events', window.location.origin); + if (sseClientId) url.searchParams.set('cid', sseClientId); + console.log(`[sse] connecting`); sseAutomat.setState({ status: 'connecting', companyId: String(companyId) }); - const es = new EventSource(url, { withCredentials: true }); + const es = new EventSource(url.href, { withCredentials: true }); // Category tree changed: refresh the tree and every open category view. addTrackedListener(es, 'category', () => { @@ -314,12 +358,19 @@ export function invalidateArticle(sku) { } // Reconnect when the selected company changes; disconnect on logout. +// On logout the stream must close; on relogin (anon -> authed with a company) +// the stream is re-established even if the company id matches the previous one. +let wasAuthed = false; authAutomat.subscribe((auth) => { const companyId = auth.status === 'authed' && auth.user?.selectedCompanyId != null ? String(auth.user.selectedCompanyId) : null; - if (companyId === sseAutomat.state.companyId) return; + const isAuthed = companyId != null; + const relogin = isAuthed && !wasAuthed; + wasAuthed = isAuthed; + + if (!relogin && companyId === sseAutomat.state.companyId) return; if (companyId == null) { disconnect(); @@ -327,7 +378,7 @@ authAutomat.subscribe((auth) => { return; } - // A different company means a different stream scope. + // A different company (or a fresh login) means a different stream scope. disconnect(); sseAutomat.setState({ companyId }); connect(); diff --git a/src/components/article/boxes/ArticleViewBrand.jsx b/src/components/article/boxes/ArticleViewBrand.jsx index d052ea2..9d9bc3b 100644 --- a/src/components/article/boxes/ArticleViewBrand.jsx +++ b/src/components/article/boxes/ArticleViewBrand.jsx @@ -168,7 +168,7 @@ export default class ArticleViewBrand extends PureComponent { if (existing) { brandId = existing.id; } else { - await brandAutomat.actions.upsert({ name }); + await brandAutomat.actions.create({ name }); const nextBrands = await brandAutomat.actions.load(); this.setState({ brands: nextBrands }); brandId = nextBrands.find( diff --git a/src/routes/artikel/marken.jsx b/src/routes/artikel/marken.jsx index ea729df..c48a985 100644 --- a/src/routes/artikel/marken.jsx +++ b/src/routes/artikel/marken.jsx @@ -98,7 +98,7 @@ class ArtikelMarkenPage extends PureComponent { this.setState({ busyKey: 'new' }); try { - const row = await brandAutomat.actions.upsert({ name }); + const row = await brandAutomat.actions.create({ name }); if (this._cancelled) return; this.setState({ newName: '', busyKey: null }); enqueueSnackbar(`Marke „${row.name || name}" angelegt.`, { variant: 'success' }); @@ -115,7 +115,7 @@ class ArtikelMarkenPage extends PureComponent { this.setState({ busyKey: brand.id }); try { - await brandAutomat.actions.upsert({ name: nextName }); + await brandAutomat.actions.rename(brand.id, { name: nextName }); if (this._cancelled) return; this.setState({ busyKey: null }); enqueueSnackbar('Marke gespeichert.', { variant: 'success' }); diff --git a/src/utils/cropLogo.js b/src/utils/cropLogo.js index 0f35f62..c89bba9 100644 --- a/src/utils/cropLogo.js +++ b/src/utils/cropLogo.js @@ -9,6 +9,15 @@ const WHITE_THRESHOLD = 240; // Pixels with R,G,B >= 240 are considered near-whi let encodeAvif; +function clientIdHeaders() { + try { + const cid = window.sessionStorage.getItem('sse.cid'); + return cid ? { 'X-Client-Id': cid } : {}; + } catch { + return {}; + } +} + async function getAvifEncoder() { if (!encodeAvif) { const { default: encode } = await import('@jsquash/avif/encode'); @@ -319,7 +328,10 @@ export async function downloadAndPrepareLogoAvif(url, fallbackDataUrl) { try { const resp = await fetch('/api/serper/image', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...clientIdHeaders(), + }, credentials: 'include', body: JSON.stringify({ url }), }); diff --git a/src/utils/importArtikel.js b/src/utils/importArtikel.js index 25f4c78..115b611 100644 --- a/src/utils/importArtikel.js +++ b/src/utils/importArtikel.js @@ -5,7 +5,7 @@ * * Everything is created through the existing dashback API: * - categories: POST /api/category ({ parentId, name }) - * - brands: PUT /api/brand ({ name }) → { brand: { id } } + * - brands: POST /api/brand ({ name }) → { brand: { id } } * - articles: POST /api/article ({ categoryId, name }) → { article: { sku } } * - price: PUT /api/article/:sku/prices ({ prices: [{ price, tax }] }) * - brand: PUT /api/article/:sku/brand ({ brandId }) @@ -111,8 +111,8 @@ function createEnsureCategory(cache) { } /** - * Upsert a brand by name → brandId. - * PUT /api/brand upserts by name (same name ⇒ update, otherwise create). + * Ensure a brand by name → brandId. + * POST /api/brand inserts (409 Brand already exists on duplicate). */ function createEnsureBrand(cache) { return async function ensureBrand(name) { @@ -121,8 +121,8 @@ function createEnsureBrand(cache) { const key = clean.toLowerCase(); if (cache.brandByKey.has(key)) return cache.brandByKey.get(key); - const res = await brand.upsert({ name: clean }); - requireObject(res, 'PUT /api/brand'); + const res = await brand.create({ name: clean }); + requireObject(res, 'POST /api/brand'); requireObject(res.brand, 'brand'); requireDefined(res.brand.id, 'brand.id'); cache.brandByKey.set(key, res.brand.id); diff --git a/src/utils/importArtikelCsv.js b/src/utils/importArtikelCsv.js index b925059..c592429 100644 --- a/src/utils/importArtikelCsv.js +++ b/src/utils/importArtikelCsv.js @@ -7,7 +7,7 @@ * * All work is done through the existing dashback API: * - categories → POST /api/category - * - brands → PUT /api/brand + * - brands → POST /api/brand * - articles → POST /api/article (+ price / brand / ean / categories PUTs) */