686 lines
18 KiB
JavaScript
686 lines
18 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;
|
||
}
|
||
|
||
/** 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;
|
||
}
|
||
|
||
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 `<fullHash>.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 <img src={thumb.url(fullHash)} /> (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 `<fullHash>.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 });
|
||
},
|
||
};
|
||
|
||
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 };
|