// 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/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: partial fields, e.g. { name } * → 200 { article } */ update(sku, body) { return http(`/api/article/${encodeURIComponent(sku)}`, { method: 'PATCH', body, }); }, /** * POST /api/article/:sku/image * Body: raw AVIF bytes (Content-Type: image/avif, max 1000×1000, prepared client-side via WASM) * → 201 { article } */ uploadImage(sku, file) { return httpBinary(`/api/article/${encodeURIComponent(sku)}/image`, { method: 'POST', contentType: 'image/avif', body: file, }); }, }; 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: [...] } */ 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' }); }, }; 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 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, category, company };