// Single source of truth for all backend communication. /** WebView consoles stringify objects as [object Object]; log JSON text. */ function logApi(tag, details) { let text; try { text = JSON.stringify(details, null, 2); } catch { text = String(details); } console[tag === 'error-body' ? 'error' : 'log'](`[api] ${tag} ${text}`); } function readableHeaders(headers) { const out = {}; headers.forEach((value, key) => { out[key] = value; }); return out; } async function http(path, { method = 'GET', body } = {}) { const headers = { 'Content-Type': 'application/json' }; 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, credentials: 'include', 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; try { data = await res.json(); message = data.error || data.message || message; } catch { message = (await res.text().catch(() => message)) || message; } logApi('error-body', { method, path, status: res.status, message, data }); const err = new Error(message || `Request failed (${res.status})`); err.status = res.status; err.path = path; err.method = method; err.data = data; throw err; } 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; } async function httpBinary(path, { method = 'POST', body, contentType } = {}) { const url = new URL(path, window.location.origin).href; logApi('request', { method, path, url, origin: window.location.origin, credentials: 'include', contentType, bodyBytes: body?.byteLength ?? body?.size ?? null, }); const res = await fetch(path, { method, headers: { 'Content-Type': contentType }, credentials: 'include', body, }); const responseHeaders = readableHeaders(res.headers); logApi('response', { method, path, status: res.status, statusText: res.statusText, ok: res.ok, responseHeaders, }); if (!res.ok) { let message = res.statusText; let data; try { data = await res.json(); message = data.error || data.message || message; } catch { message = (await res.text().catch(() => message)) || message; } logApi('error-body', { method, path, status: res.status, message, data }); const err = new Error(message || `Request failed (${res.status})`); err.status = res.status; err.path = path; err.method = method; err.data = data; throw err; } 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, hasArticle: Boolean(json?.article), }); return json; } export const article = { search({ target, filter, offset, maxCount } = {}) { const params = new URLSearchParams({ target }); if (filter) params.set('filter', filter); if (offset) params.set('offset', String(offset)); if (maxCount != null) params.set('maxCount', String(maxCount)); return http(`/api/article/search?${params}`); }, /** * GET /api/article/:sku → { article } * Detail: prices[], photos[], eans[], brand, unit, categoryIds, … * (Flat net/taxRate/fullHash/thumb* only on category tree / getCategoryArticles.) */ getBySku(sku) { return http(`/api/article/${encodeURIComponent(sku)}`); }, /** POST /api/article → 201 { article } */ create({ parentId, name }) { return http('/api/article', { method: 'POST', body: { parentId, name }, }); }, /** * PATCH /api/article/:sku * Body: at least one of { name, unitamount, unitSi } * unitSi must exist for selected company → 404 Unit not found * → 200 { article } (same detail shape as GET) */ update(sku, body) { return http(`/api/article/${encodeURIComponent(sku)}`, { method: 'PATCH', body, }); }, /** * DELETE /api/article/:sku → 204 * Errors: 400 Article has children, 401, 403, 404 Article not found */ delete(sku) { return http(`/api/article/${encodeURIComponent(sku)}`, { method: 'DELETE', }); }, /** * POST /api/article/:sku/image * Body: raw AVIF bytes (Content-Type: image/avif, max 1000×1000, prepared client-side via WASM) * → 201 { image: { picId, sort, full, thumb, fullHash, thumbHash } } */ uploadImage(sku, file) { return httpBinary(`/api/article/${encodeURIComponent(sku)}/image`, { method: 'POST', contentType: 'image/avif', body: file, }); }, /** * DELETE /api/article/:sku/image/:fullHash → 204 */ deleteImage(sku, fullHash) { return http( `/api/article/${encodeURIComponent(sku)}/image/${encodeURIComponent(fullHash)}`, { method: 'DELETE' }, ); }, /** * PUT /api/article/:sku/images/order * Body: { fullHashes: string[] } (every image exactly once) * → 200 { fullHashes: string[] } */ orderImages(sku, fullHashes) { return http(`/api/article/${encodeURIComponent(sku)}/images/order`, { method: 'PUT', body: { fullHashes }, }); }, /** * PUT /api/article/:sku/prices * Body: { prices: [{ price (net), tax (rate), minmultiplier? }] } * Replace-all POS customer group prices. Empty prices clears all POS prices. * → 200 { article } (same shape as GET by SKU) */ setPrices(sku, prices) { return http(`/api/article/${encodeURIComponent(sku)}/prices`, { method: 'PUT', body: { prices }, }); }, /** * PUT /api/article/:sku/brand * Body: { brandId: number | null } — null clears the brand. * brandId must belong to the article's company → 404 Article or brand not found. * → 200 { article } (same shape as GET by SKU) */ setBrandIdBySku(sku, brandId) { return http(`/api/article/${encodeURIComponent(sku)}/brand`, { method: 'PUT', body: { brandId }, }); }, /** * PUT /api/article/:sku/eans * Body: { eans: string[] } — replace-all. Empty array clears all. * EANs must be non-empty unique strings → 400 Invalid eans. * → 200 { article } (same shape as GET by SKU) */ setEansBySku(sku, eans) { return http(`/api/article/${encodeURIComponent(sku)}/eans`, { method: 'PUT', body: { eans }, }); }, /** * PUT /api/article/:sku/categories * Body: { categoryIds: number[] } — replace-all. Empty array clears all. * Categories must belong to the article's company; ids must be unique. * → 200 { article } (same shape as GET by SKU) */ setCategories(sku, categoryIds) { return http(`/api/article/${encodeURIComponent(sku)}/categories`, { method: 'PUT', body: { categoryIds }, }); }, }; /** * Content-addressed thumb AVIF by fullHash (SHA-256 hex of full image bytes). * Auth: session cookie. Browser-cacheable (private, immutable, ETag). */ export const thumb = { /** * GET /api/thumb/:fullHash → image/avif (thumb bytes) * Use as (same-origin; cookies sent). */ url(fullHash) { return `/api/thumb/${encodeURIComponent(fullHash)}`; }, }; /** * Company-scoped units (selectedCompanyId from session). * Shape: { si, basesi, basesifactor, symbol, name } */ export const unit = { /** GET /api/unit → { units: [...] } · 400 No company selected */ list() { return http('/api/unit'); }, /** * PUT /api/unit * Body: { si, basesi, basesifactor, symbol, name } * → 200 { unit } */ upsert(body) { return http('/api/unit', { method: 'PUT', body }); }, /** * DELETE /api/unit?si=… → 204 * 409 Unit in use */ delete(si) { const params = new URLSearchParams({ si }); return http(`/api/unit?${params}`, { method: 'DELETE' }); }, }; export const category = { /** GET /api/category/tree → { languageId, tree, articles } (company from session) */ getTree() { return http('/api/category/tree'); }, /** * GET /api/category/:id/articles → { articles: [...] } * Article rows: sku, name, net, taxRate, fullHash, thumbW, thumbH, thumbHash, … */ getArticles(categoryId) { return http(`/api/category/${categoryId}/articles`); }, /** POST /api/category → 201 { category } */ create({ parentId, name }) { return http('/api/category', { method: 'POST', body: { parentId, name }, }); }, /** DELETE /api/category/:id → 204 */ delete(categoryId) { return http(`/api/category/${categoryId}`, { method: 'DELETE' }); }, /** * PATCH /api/category/:id * Body: { name } — renames and re-slugifies in the company default language. * Empty name → 400 name is required. * → 200 { category: { id, name, slug } } */ updateName(categoryId, name) { return http(`/api/category/${categoryId}`, { method: 'PATCH', body: { name }, }); }, /** * PUT /api/category/order * Body: { parentId, categoryIds: number[] } — sibling order of the children * under `parentId` (top-down). categoryIds must be exactly the current child * set of parentId (same count, no foreign/duplicate ids) → 400 Invalid category order. * → 200 { categoryIds: number[] } */ reorder(parentId, categoryIds) { return http('/api/category/order', { method: 'PUT', body: { parentId, categoryIds }, }); }, /** * PATCH /api/category/:id * Body: { parentId } — moves the category under a new parent. * → 200 { category: { id, parentId, sort } } (kein name/slug im Response) */ move(categoryId, parentId) { return http(`/api/category/${categoryId}`, { method: 'PATCH', body: { parentId }, }); }, /** * PATCH /api/category/:id * Body: { pos: boolean } — toggles the POS (Point of Sale) flag. * → 200 { category: { id, pos } } * * PROPOSED — backend needs to implement this field. */ updatePos(categoryId, pos) { return http(`/api/category/${categoryId}`, { method: 'PATCH', body: { pos }, }); }, /** * POST /api/category/:id/image * Body: raw AVIF bytes (Content-Type: image/avif) * → 201 { category: { id, fullHash, thumbW, thumbH, thumbHash } } */ uploadImage(categoryId, file) { return httpBinary(`/api/category/${categoryId}/image`, { method: 'POST', contentType: 'image/avif', body: file, }); }, /** * DELETE /api/category/:id/image * → 200 { category: { id, thumbW: null, thumbH: null, thumbHash: null } } */ deleteImage(categoryId) { return http(`/api/category/${categoryId}/image`, { method: 'DELETE' }); }, }; /** * Company-scoped brands (selectedCompanyId from session). * Shape: Brand = { id, name, gsprpinfo | null } */ export const brand = { /** GET /api/brand → { brands: Brand[] } · 400 No company selected */ list() { return http('/api/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 } */ upsert(body) { return http('/api/brand', { method: 'PUT', body }); }, }; export const company = { async list() { try { return await http('/api/company'); } catch { return { companies: [] }; } }, /** Uses GET /api/company until a dedicated endpoint exists. */ async getById(companyId) { const data = await http('/api/company'); const found = data.companies?.find((item) => item.id === companyId); if (!found) throw new Error('Unternehmen nicht gefunden.'); return { company: found }; }, create({ name, iso639, taxrate, reducedtaxrate }) { return http('/api/company', { method: 'POST', body: { name, iso639, taxrate, reducedtaxrate }, }); }, /** * PUT /api/company/:companyId * Body: { name, iso639, taxrate, reducedtaxrate } */ update(companyId, { name, iso639, taxrate, reducedtaxrate }) { return http(`/api/company/${companyId}`, { method: 'PUT', body: { name, iso639, taxrate, reducedtaxrate }, }); }, select(companyId) { return http('/api/auth/selected-company', { method: 'PUT', body: { companyId }, }); }, }; export const posterminal = { /** * GET /api/posterminal → { posterminals: [{ id, name, token, pairingpin }] } * List all terminals for the selected company. */ list() { return http('/api/posterminal'); }, /** * PUT /api/posterminal * Body: { name: string } * Creates a new terminal (auto-generates 6-digit pin). * → 200 { posterminal: { id, name, token, pairingpin } } */ create({ name }) { return http('/api/posterminal', { method: 'PUT', body: { name }, }); }, /** * DELETE /api/posterminal/:id → 204 * Deletes a terminal. */ delete(terminalId) { return http(`/api/posterminal/${terminalId}`, { method: 'DELETE', }); }, /** * PUT /api/posterminal/:id/category/:categoryId * Enable a category on a terminal. * → 200 { id: number, categoryIds: number[] } */ enableCategory(terminalId, categoryId) { return http(`/api/posterminal/${terminalId}/category/${categoryId}`, { method: 'PUT', }); }, /** * DELETE /api/posterminal/:id/category/:categoryId * Disable a category on a terminal. * → 200 { id: number, categoryIds: number[] } */ disableCategory(terminalId, categoryId) { return http(`/api/posterminal/${terminalId}/category/${categoryId}`, { method: 'DELETE', }); }, /** * PUT /api/posterminal/:id/all * Enable all categories on a terminal. * → 200 { id: number, categoryIds: number[] } */ enableAllCategories(terminalId) { return http(`/api/posterminal/${terminalId}/all`, { method: 'PUT', }); }, /** * DELETE /api/posterminal/:id/all * Disable all categories on a terminal. * → 200 { id: number, categoryIds: number[] } */ disableAllCategories(terminalId) { return http(`/api/posterminal/${terminalId}/all`, { method: 'DELETE', }); }, }; export const auth = { async me() { try { const data = await http('/api/auth/me'); return { user: data.user ?? null, googleClientId: data.googleClientId ?? null, }; } catch (err) { if (err.status === 401 || err.status === 403) { return { user: null, googleClientId: err.data?.googleClientId ?? null, forbidden: err.status === 403, message: err.message, }; } throw err; } }, async loginWithGoogle(idToken) { logApi('loginWithGoogle', { idTokenLength: idToken?.length ?? 0, idTokenPrefix: idToken ? `${idToken.slice(0, 12)}…` : null, }); const data = await http('/api/auth/login', { method: 'POST', body: { idToken }, }); // Confirm whether the session cookie actually works (not just login JSON). const session = await auth.me(); logApi('session-after-login', { loginUser: data.user?.email ?? data.user?.sub ?? data.user ?? null, meUser: session.user?.email ?? session.user?.sub ?? session.user ?? null, meForbidden: session.forbidden ?? false, meMessage: session.message ?? null, sessionOk: Boolean(session.user), }); return { user: data.user, session }; }, async logout() { try { await http('/api/auth/logout', { method: 'POST' }); } catch (err) { if (err.status !== 401) throw err; } }, }; export default { auth, article, brand, category, company, posterminal, thumb, unit };