// 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; } /** GET a file (e.g. a ZIP download). Returns a Blob on success; JSON errors on failure. */ async function httpGetBinary(path) { logApi('request', { method: 'GET', path, url: new URL(path, window.location.origin).href, origin: window.location.origin, credentials: 'include', }); const res = await fetch(path, { method: 'GET', credentials: 'include' }); logApi('response', { method: 'GET', path, status: res.status, statusText: res.statusText, ok: res.ok, contentType: res.headers.get('content-type'), responseHeaders: readableHeaders(res.headers), }); 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: 'GET', path, status: res.status, message, data }); const err = new Error(message || `Request failed (${res.status})`); err.status = res.status; err.path = path; err.method = 'GET'; err.data = data; throw err; } const blob = await res.blob(); return blob; } /** * Company-scoped article search index (selectedCompanyId from session). * GET /api/articleIndex → { articles: [...] } */ export const articleIndex = { list() { return http('/api/articleIndex'); }, }; 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 }, }); }, /** * GET /api/article/export.zip → application/zip (binary download) * `artikel.zip` = artikel.csv (semicolon-separated, Windows-1252) + * one `.avif` per article image (content-addressed, deduplicated). * Optional `categoryId` restricts the export to that category and below. * Errors: 400 No company selected, 401, 403, 404 (JSON { error }). */ exportZip(categoryId) { const params = new URLSearchParams(); if (categoryId != null) params.set('categoryId', String(categoryId)); const qs = params.toString(); return httpGetBinary(`/api/article/export.zip${qs ? `?${qs}` : ''}`); }, }; /** * 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' }); }, /** * GET /api/category/export.zip → application/zip (binary download) * `kategorien.zip` = kategorien.csv (semicolon-separated, Windows-1252, * depth-first, `Ebene N` name column per depth) + one `.avif` * entry per category image (content-addressed, deduplicated). * Errors: 400 No company selected, 401, 403, 404 (JSON { error }). */ exportZip() { return httpGetBinary('/api/category/export.zip'); }, }; /** * 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 }); }, }; /** * Company-scoped suppliers / Lieferanten (selectedCompanyId from session). * Shape: Supplier = { id, name, createdAt, updatedAt } */ export const supplier = { /** GET /api/supplier → { suppliers: Supplier[] } */ list() { return http('/api/supplier'); }, /** * POST /api/supplier * Body: { name } — empty name ⇒ 400 Name required. * → 201 { supplier: Supplier } */ create(body) { return http('/api/supplier', { method: 'POST', body }); }, /** * PATCH /api/supplier/:id * Body: { name } — empty name ⇒ 400 Name required. * → 200 { supplier: Supplier } */ update(id, body) { return http(`/api/supplier/${id}`, { method: 'PATCH', body }); }, /** DELETE /api/supplier/:id → 204 */ delete(id) { return http(`/api/supplier/${id}`, { method: 'DELETE' }); }, }; /** * Company-scoped goods receipts (manifest type = delivery). * DeliverySummary = { id, number, type: 'delivery', supplierId, date } * DeliveryLine = { id, articleId, sku, amount, net, transactedAmount, rebate, tax, comment } * Delivery = DeliverySummary & { lines: DeliveryLine[] } * DeliveryLineInput = { sku, amount, net, tax, rebate?, transactedAmount?, comment? } */ export const delivery = { /** GET /api/delivery → { deliveries: DeliverySummary[] } */ list() { return http('/api/delivery'); }, /** GET /api/delivery/:id → { delivery: Delivery } */ get(id) { return http(`/api/delivery/${id}`); }, /** * POST /api/delivery * Body: { number, date, supplierId?, lines: DeliveryLineInput[] } * Line requires sku, amount, net, tax. rebate→0, transactedAmount→amount. * → 201 { delivery: Delivery } */ create(body) { return http('/api/delivery', { method: 'POST', body }); }, /** * PATCH /api/delivery/:id * Body: partial { number?, date?, supplierId?, lines? }. * If lines present, replaces all lines; if omitted, lines unchanged. * → 200 { delivery: Delivery } */ update(id, body) { return http(`/api/delivery/${id}`, { method: 'PATCH', body }); }, /** DELETE /api/delivery/:id → 204 */ delete(id) { return http(`/api/delivery/${id}`, { method: 'DELETE' }); }, }; /** * Sales quotes (manifest type = quote). * Summary = { id, number, type: 'quote', entityId, date } * Line = { id, articleId, sku, amount, net, rebate, tax, comment } — no transactedAmount * LineInput = { sku, amount, net, tax, rebate?, comment? } */ export const quote = { /** GET /api/quote → { quotes: QuoteSummary[] } */ list() { return http('/api/quote'); }, /** GET /api/quote/:id → { quote: Quote } */ get(id) { return http(`/api/quote/${id}`); }, /** * POST /api/quote * Body: { number, date, entityId?, lines } * → 201 { quote: Quote } */ create(body) { return http('/api/quote', { method: 'POST', body }); }, /** * PATCH /api/quote/:id * Body: partial { number?, date?, entityId?, lines? } * → 200 { quote: Quote } */ update(id, body) { return http(`/api/quote/${id}`, { method: 'PATCH', body }); }, /** DELETE /api/quote/:id → 204 */ delete(id) { return http(`/api/quote/${id}`, { method: 'DELETE' }); }, }; /** * Sales orders (manifest type = order). * Summary = { id, number, type: 'order', entityId, date } * Line shape same as quote (no transactedAmount). */ export const order = { /** GET /api/order → { orders: OrderSummary[] } */ list() { return http('/api/order'); }, /** GET /api/order/:id → { order: Order } */ get(id) { return http(`/api/order/${id}`); }, /** * POST /api/order * Body: { number, date, entityId?, lines } * → 201 { order: Order } */ create(body) { return http('/api/order', { method: 'POST', body }); }, /** * PATCH /api/order/:id * Body: partial { number?, date?, entityId?, lines? } * → 200 { order: Order } */ update(id, body) { return http(`/api/order/${id}`, { method: 'PATCH', body }); }, /** DELETE /api/order/:id → 204 */ delete(id) { return http(`/api/order/${id}`, { method: 'DELETE' }); }, }; /** * Company-scoped customer groups. * Shape: CustomerGroup = { id, name } */ export const customergroup = { /** GET /api/customergroup → { customergroups: CustomerGroup[] } */ list() { return http('/api/customergroup'); }, /** * POST /api/customergroup * Body: { name } — empty name ⇒ 400 Name required. * → 201 { customergroup: CustomerGroup } */ create(body) { return http('/api/customergroup', { method: 'POST', body }); }, /** * PATCH /api/customergroup/:id * Body: { name } * → 200 { customergroup: CustomerGroup } */ update(id, body) { return http(`/api/customergroup/${id}`, { method: 'PATCH', body }); }, /** DELETE /api/customergroup/:id → 204 · 409 Customer group in use */ delete(id) { return http(`/api/customergroup/${id}`, { method: 'DELETE' }); }, }; /** * Company-scoped entities (customers / contacts in a customergroup). * Shape: Entity = { id, customergroupId, firstName, name, createdAt, updatedAt } */ export const entity = { /** * GET /api/entity → { entities: Entity[] } * Optional query: customergroupId */ list({ customergroupId } = {}) { const qs = customergroupId != null ? `?customergroupId=${encodeURIComponent(customergroupId)}` : ''; return http(`/api/entity${qs}`); }, /** GET /api/entity/:id → { entity: Entity } */ get(id) { return http(`/api/entity/${id}`); }, /** * POST /api/entity * Body: { customergroupId, name, firstName? } * → 201 { entity: Entity } */ create(body) { return http('/api/entity', { method: 'POST', body }); }, /** * PATCH /api/entity/:id * Body: { name?, firstName? } * → 200 { entity: Entity } */ update(id, body) { return http(`/api/entity/${id}`, { method: 'PATCH', body }); }, /** DELETE /api/entity/:id → 204 */ delete(id) { return http(`/api/entity/${id}`, { method: 'DELETE' }); }, /** GET /api/entity/:entityId/address → { addresses: EntityAddress[] } */ listAddresses(entityId) { return http(`/api/entity/${entityId}/address`); }, /** POST /api/entity/:entityId/address → 201 { address } */ createAddress(entityId, body) { return http(`/api/entity/${entityId}/address`, { method: 'POST', body }); }, /** PATCH /api/entity/:entityId/address/:id → { address } */ updateAddress(entityId, id, body) { return http(`/api/entity/${entityId}/address/${id}`, { method: 'PATCH', body, }); }, /** DELETE /api/entity/:entityId/address/:id → 204 */ deleteAddress(entityId, id) { return http(`/api/entity/${entityId}/address/${id}`, { method: 'DELETE' }); }, /** GET /api/entity/:entityId/contact → { contacts: EntityContact[] } */ listContacts(entityId) { return http(`/api/entity/${entityId}/contact`); }, /** POST /api/entity/:entityId/contact → 201 { contact } */ createContact(entityId, body) { return http(`/api/entity/${entityId}/contact`, { method: 'POST', body }); }, /** PATCH /api/entity/:entityId/contact/:id → { contact } */ updateContact(entityId, id, body) { return http(`/api/entity/${entityId}/contact/${id}`, { method: 'PATCH', body, }); }, /** DELETE /api/entity/:entityId/contact/:id → 204 */ deleteContact(entityId, id) { return http(`/api/entity/${entityId}/contact/${id}`, { method: 'DELETE' }); }, }; export const company = { /** GET /api/company → { companies: [...] } */ list() { return http('/api/company'); }, /** 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, articleIndex, brand, category, company, customergroup, delivery, entity, order, posterminal, quote, supplier, thumb, unit, };