314 lines
8.1 KiB
JavaScript
314 lines
8.1 KiB
JavaScript
// 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 };
|