Files
dashfront/src/api/index.js
2026-07-31 20:37:43 +02:00

314 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 };