This commit is contained in:
seb
2026-10-09 16:44:18 +02:00
parent 56274ecb1d
commit 5e62be468f
9 changed files with 175 additions and 71 deletions

View File

@@ -21,25 +21,39 @@ function readableHeaders(headers) {
return out; 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 } = {}) { async function http(path, { method = 'GET', body } = {}) {
const isFormData = typeof FormData !== 'undefined' && body instanceof FormData; const isFormData = typeof FormData !== 'undefined' && body instanceof FormData;
const headers = {}; const headers = {};
if (!isFormData) { if (!isFormData) {
headers['Content-Type'] = 'application/json'; headers['Content-Type'] = 'application/json';
} }
if (isMutatingMethod(method)) {
Object.assign(headers, clientIdHeaders());
}
const url = new URL(path, window.location.origin).href; 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, { const res = await fetch(path, {
method, method,
headers, headers,
@@ -47,18 +61,6 @@ async function http(path, { method = 'GET', body } = {}) {
body: isFormData ? body : (body ? JSON.stringify(body) : undefined), 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) { if (!res.ok) {
let message = res.statusText; let message = res.statusText;
let data; let data;
@@ -78,12 +80,6 @@ async function http(path, { method = 'GET', body } = {}) {
} }
if (res.status === 204) return null; if (res.status === 204) return null;
const json = await res.json(); 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; return json;
} }
@@ -102,7 +98,7 @@ async function httpBinary(path, { method = 'POST', body, contentType, headers: e
const res = await fetch(path, { const res = await fetch(path, {
method, method,
headers: { 'Content-Type': contentType, ...(extraHeaders || {}) }, headers: { 'Content-Type': contentType, ...clientIdHeaders(), ...(extraHeaders || {}) },
credentials: 'include', credentials: 'include',
body, body,
}); });
@@ -851,13 +847,22 @@ export const brand = {
}, },
/** /**
* PUT /api/brand * POST /api/brand
* Body: { name, gsprpinfo? } — upsert by name for the selected company. * Body: { name, gsprpinfo? } — insert for the selected company.
* Same name ⇒ update, otherwise create. Empty name ⇒ 400 Invalid brand. * Empty name ⇒ 400 Invalid brand.
* → 200 { brand: Brand } * → 201 { brand: Brand } · 409 Brand already exists
*/ */
upsert(body) { create(body) {
return http('/api/brand', { method: 'PUT', 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); formData.append('file', file, file.name);
const res = await fetch('/api/delivery/parse', { const res = await fetch('/api/delivery/parse', {
method: 'POST', method: 'POST',
headers: { ...clientIdHeaders() },
credentials: 'include', credentials: 'include',
body: formData, body: formData,
signal, signal,
@@ -1264,6 +1270,7 @@ export const serper = {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...clientIdHeaders(),
}, },
credentials: 'include', credentials: 'include',
body: JSON.stringify({ q, gl, hl, num, preferSource }), body: JSON.stringify({ q, gl, hl, num, preferSource }),
@@ -1355,6 +1362,7 @@ export const serper = {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...clientIdHeaders(),
}, },
credentials: 'include', credentials: 'include',
body: JSON.stringify({ url }), body: JSON.stringify({ url }),
@@ -2165,6 +2173,7 @@ export const vfs = {
} }
const res = await fetch('/api/vfs/upload', { const res = await fetch('/api/vfs/upload', {
method: 'POST', method: 'POST',
headers: { ...clientIdHeaders() },
credentials: 'include', credentials: 'include',
body: formData, body: formData,
}); });

View File

@@ -5,7 +5,7 @@
* Upstream Triggers: * Upstream Triggers:
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on * - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
* `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`. * `'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'; 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 * @param {{ name: string, gsprpinfo?: string }} body
*/ */
async upsert(body) { async create(body) {
const res = await brandApi.upsert(body); const res = await brandApi.create(body);
const row = res.brand; const row = res.brand;
const brands = sortBrands([ const brands = sortBrands([
...brandAutomat.state.brands.filter((b) => b.id !== row.id), ...brandAutomat.state.brands.filter((b) => b.id !== row.id),
@@ -64,6 +64,21 @@ const actions = {
return row; 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. * Delete a brand by id.
* @param {number} id * @param {number} id

View File

@@ -16,6 +16,7 @@
import { Automat } from 'react-automat'; import { Automat } from 'react-automat';
import { modelconversation as modelconversationApi } from '../api'; import { modelconversation as modelconversationApi } from '../api';
import authAutomat from './authAutomat'; import authAutomat from './authAutomat';
import { getSseClientId } from './sseAutomat';
import { import {
attachUserEnv, attachUserEnv,
getCurrentRoute, getCurrentRoute,
@@ -110,6 +111,23 @@ let companyReconnectTimer = null;
let conversationStream = null; let conversationStream = null;
let activeStreamConversationId = 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. * Connect to company-wide SSE stream (/api/events) to listen for 'modelconversation' events.
*/ */
@@ -128,7 +146,10 @@ function ensureCompanyEventsConnected() {
companyReconnectTimer = null; 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; companyEventSource = es;
es.addEventListener('modelconversation', () => { es.addEventListener('modelconversation', () => {
@@ -1362,28 +1383,24 @@ const actions = {
*/ */
reset() { reset() {
closeConversationStream(); closeConversationStream();
if (companyEventSource) { disconnectCompanyEvents();
try {
companyEventSource.close();
} catch {}
companyEventSource = null;
}
if (companyReconnectTimer) {
clearTimeout(companyReconnectTimer);
companyReconnectTimer = null;
}
chatAutomat.setState(initialState); chatAutomat.setState(initialState);
}, },
}; };
chatAutomat = new Automat(initialState, actions, { name: 'chat' }); 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) => { authAutomat.subscribe((auth) => {
const companyId = auth.user?.selectedCompanyId; const companyId = auth.user?.selectedCompanyId;
const isAuthed = auth.status === 'authed' && companyId != null;
const relogin = isAuthed && !chatWasAuthed;
chatWasAuthed = isAuthed;
if (auth.status === 'anon' || !companyId) { if (auth.status === 'anon' || !companyId) {
disconnectCompanyEvents();
chatAutomat.actions.reset(); chatAutomat.actions.reset();
} else if (auth.status === 'authed' && companyId) { } else if (relogin || (auth.status === 'authed' && companyId)) {
if (chatAutomat.state.sseEstablished) { if (chatAutomat.state.sseEstablished) {
ensureCompanyEventsConnected(); ensureCompanyEventsConnected();
void chatAutomat.actions.loadConversations(true); void chatAutomat.actions.loadConversations(true);

View File

@@ -37,15 +37,51 @@ import supplierAutomat from './supplierAutomat';
* @typedef {Object} SseState * @typedef {Object} SseState
* @property {'disconnected'|'connecting'|'connected'|'error'} status Connection state. * @property {'disconnected'|'connecting'|'connected'|'error'} status Connection state.
* @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 {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.
*/ */
const EVENT_HISTORY_LIMIT = 200; 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} */ /** @type {SseState} */
const initialState = { const initialState = {
status: 'disconnected', status: 'disconnected',
companyId: null, companyId: null,
clientId: sseClientId,
events: [], events: [],
}; };
@@ -97,10 +133,16 @@ function addTrackedListener(es, type, handler) {
} }
function disconnect() { function disconnect() {
if (!eventSource) return; if (eventSource) {
eventSource.close(); console.log('[sse] disconnect');
eventSource = null; try {
sseAutomat.setState({ status: 'disconnected' }); eventSource.close();
} catch {}
eventSource = null;
}
if (sseAutomat.state.status !== 'disconnected') {
sseAutomat.setState({ status: 'disconnected' });
}
} }
/** /**
@@ -147,10 +189,12 @@ function connect() {
return; 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) }); 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. // Category tree changed: refresh the tree and every open category view.
addTrackedListener(es, 'category', () => { addTrackedListener(es, 'category', () => {
@@ -314,12 +358,19 @@ export function invalidateArticle(sku) {
} }
// Reconnect when the selected company changes; disconnect on logout. // 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) => { authAutomat.subscribe((auth) => {
const companyId = const companyId =
auth.status === 'authed' && auth.user?.selectedCompanyId != null auth.status === 'authed' && auth.user?.selectedCompanyId != null
? String(auth.user.selectedCompanyId) ? String(auth.user.selectedCompanyId)
: null; : 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) { if (companyId == null) {
disconnect(); disconnect();
@@ -327,7 +378,7 @@ authAutomat.subscribe((auth) => {
return; return;
} }
// A different company means a different stream scope. // A different company (or a fresh login) means a different stream scope.
disconnect(); disconnect();
sseAutomat.setState({ companyId }); sseAutomat.setState({ companyId });
connect(); connect();

View File

@@ -168,7 +168,7 @@ export default class ArticleViewBrand extends PureComponent {
if (existing) { if (existing) {
brandId = existing.id; brandId = existing.id;
} else { } else {
await brandAutomat.actions.upsert({ name }); await brandAutomat.actions.create({ name });
const nextBrands = await brandAutomat.actions.load(); const nextBrands = await brandAutomat.actions.load();
this.setState({ brands: nextBrands }); this.setState({ brands: nextBrands });
brandId = nextBrands.find( brandId = nextBrands.find(

View File

@@ -98,7 +98,7 @@ class ArtikelMarkenPage extends PureComponent {
this.setState({ busyKey: 'new' }); this.setState({ busyKey: 'new' });
try { try {
const row = await brandAutomat.actions.upsert({ name }); const row = await brandAutomat.actions.create({ name });
if (this._cancelled) return; if (this._cancelled) return;
this.setState({ newName: '', busyKey: null }); this.setState({ newName: '', busyKey: null });
enqueueSnackbar(`Marke „${row.name || name}" angelegt.`, { variant: 'success' }); enqueueSnackbar(`Marke „${row.name || name}" angelegt.`, { variant: 'success' });
@@ -115,7 +115,7 @@ class ArtikelMarkenPage extends PureComponent {
this.setState({ busyKey: brand.id }); this.setState({ busyKey: brand.id });
try { try {
await brandAutomat.actions.upsert({ name: nextName }); await brandAutomat.actions.rename(brand.id, { name: nextName });
if (this._cancelled) return; if (this._cancelled) return;
this.setState({ busyKey: null }); this.setState({ busyKey: null });
enqueueSnackbar('Marke gespeichert.', { variant: 'success' }); enqueueSnackbar('Marke gespeichert.', { variant: 'success' });

View File

@@ -9,6 +9,15 @@ const WHITE_THRESHOLD = 240; // Pixels with R,G,B >= 240 are considered near-whi
let encodeAvif; let encodeAvif;
function clientIdHeaders() {
try {
const cid = window.sessionStorage.getItem('sse.cid');
return cid ? { 'X-Client-Id': cid } : {};
} catch {
return {};
}
}
async function getAvifEncoder() { async function getAvifEncoder() {
if (!encodeAvif) { if (!encodeAvif) {
const { default: encode } = await import('@jsquash/avif/encode'); const { default: encode } = await import('@jsquash/avif/encode');
@@ -319,7 +328,10 @@ export async function downloadAndPrepareLogoAvif(url, fallbackDataUrl) {
try { try {
const resp = await fetch('/api/serper/image', { const resp = await fetch('/api/serper/image', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: {
'Content-Type': 'application/json',
...clientIdHeaders(),
},
credentials: 'include', credentials: 'include',
body: JSON.stringify({ url }), body: JSON.stringify({ url }),
}); });

View File

@@ -5,7 +5,7 @@
* *
* Everything is created through the existing dashback API: * Everything is created through the existing dashback API:
* - categories: POST /api/category ({ parentId, name }) * - 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 } } * - articles: POST /api/article ({ categoryId, name }) → { article: { sku } }
* - price: PUT /api/article/:sku/prices ({ prices: [{ price, tax }] }) * - price: PUT /api/article/:sku/prices ({ prices: [{ price, tax }] })
* - brand: PUT /api/article/:sku/brand ({ brandId }) * - brand: PUT /api/article/:sku/brand ({ brandId })
@@ -111,8 +111,8 @@ function createEnsureCategory(cache) {
} }
/** /**
* Upsert a brand by name → brandId. * Ensure a brand by name → brandId.
* PUT /api/brand upserts by name (same name ⇒ update, otherwise create). * POST /api/brand inserts (409 Brand already exists on duplicate).
*/ */
function createEnsureBrand(cache) { function createEnsureBrand(cache) {
return async function ensureBrand(name) { return async function ensureBrand(name) {
@@ -121,8 +121,8 @@ function createEnsureBrand(cache) {
const key = clean.toLowerCase(); const key = clean.toLowerCase();
if (cache.brandByKey.has(key)) return cache.brandByKey.get(key); if (cache.brandByKey.has(key)) return cache.brandByKey.get(key);
const res = await brand.upsert({ name: clean }); const res = await brand.create({ name: clean });
requireObject(res, 'PUT /api/brand'); requireObject(res, 'POST /api/brand');
requireObject(res.brand, 'brand'); requireObject(res.brand, 'brand');
requireDefined(res.brand.id, 'brand.id'); requireDefined(res.brand.id, 'brand.id');
cache.brandByKey.set(key, res.brand.id); cache.brandByKey.set(key, res.brand.id);

View File

@@ -7,7 +7,7 @@
* *
* All work is done through the existing dashback API: * All work is done through the existing dashback API:
* - categories → POST /api/category * - categories → POST /api/category
* - brands → PUT /api/brand * - brands → POST /api/brand
* - articles → POST /api/article (+ price / brand / ean / categories PUTs) * - articles → POST /api/article (+ price / brand / ean / categories PUTs)
*/ */