diff --git a/README.md b/README.md index 3adf2c8..7db76f4 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Then configure your web server to serve `/var/www/lonc` as a static site. The application does not require build-time environment variables for the Tryton connection. Users configure the following in the login screen: -- Tryton server base URL +- Tryton server base URL (optional, leave empty for same-origin deployment) - database name - user login @@ -77,6 +77,8 @@ Authentication is done with Tryton user application keys for the `kitchen` appli If the frontend and Tryton backend are served from different origins, the Tryton server must allow cross-origin requests from the frontend origin. +If Lonc is served by the same nginx origin as the API, leave the server URL empty in the app settings so requests stay same-origin and avoid unnecessary browser CORS checks. + At minimum, production should ensure: - `Authorization` headers are accepted for API requests @@ -143,8 +145,6 @@ Expected shapes today: `/{database}/kitchen/{kitchen_id}/{resource}` - User application key management uses: `/{database}/user/application/` -- Non-kitchen-scoped authenticated resources currently assume: - `/{database}/{resource}` - `POST /{database}/user/application/` Sends `{ user, application: "kitchen" }` and returns the application key as a JSON string. @@ -155,14 +155,20 @@ Expected shapes today: Returns `{ data: [...] }` or `{ kitchens: [...] }`. - `GET /{database}/kitchen/items?search_name=...` Returns item definitions for autocomplete. +- `GET /{database}/kitchen/items` + Returns the current stock review list. +- `GET /{database}/kitchen/items/{uuid_b64}` + Returns one item detail payload. - `POST /{database}/kitchen/items?label=1` Creates a stock item plus label-related output on the backend side. - `POST /{database}/kitchen/items?label=1&preview=1` Returns an image blob, `{ imageUrl }`, or `{ imageSvg }` for in-browser preview. +- `POST /{database}/kitchen/items/{uuid_b64}/stock` + Updates measured or descriptive stock state using `{ quantity }` or `{ level }`. +- `DELETE /{database}/kitchen/items/{uuid_b64}` + Marks an individual stock item gone. - `GET /{database}/kitchen/locations` Returns a nested location tree. -- `GET /{database}/kitchen/{kitchen_id}/stock`, `GET /{database}/kitchen/{kitchen_id}/stock/:id`, `POST /{database}/kitchen/{kitchen_id}/stock/:id/adjust` - Back the stock overview, creation, and adjustment workflows. ## Notes diff --git a/src/api/client.js b/src/api/client.js index 09b241d..06964a2 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -1,70 +1,202 @@ import { API_PATHS } from '../app/config.js'; function normalizeBaseUrl(baseUrl) { - return baseUrl.trim().replace(/\/+$/, ''); + return String(baseUrl || '').trim().replace(/\/+$/, ''); +} + +function isSameOriginBaseUrl(baseUrl) { + if (!baseUrl) { + return true; + } + + if (typeof window === 'undefined') { + return false; + } + + try { + return new URL(baseUrl, window.location.origin).origin === window.location.origin; + } catch { + return false; + } +} + +function buildPathname({ database, kitchenId, path, includeKitchen = true }) { + const encodedDatabase = encodeURIComponent(database); + const encodedPathSegments = String(path || '') + .replace(/^\/+/, '') + .split('/') + .filter(Boolean) + .map((segment) => encodeURIComponent(segment)); + const segments = [encodedDatabase]; + + if (includeKitchen && kitchenId) { + segments.push('kitchen', encodeURIComponent(String(kitchenId))); + } + + segments.push(...encodedPathSegments); + + return `/${segments.join('/')}`; } function buildUrl({ baseUrl, database, kitchenId, path, query = {}, includeKitchen = true }) { const cleanBaseUrl = normalizeBaseUrl(baseUrl); - const encodedDatabase = encodeURIComponent(database); - const encodedPath = path.replace(/^\/+/, ''); - const kitchenSegment = - includeKitchen && kitchenId - ? `/kitchen/${encodeURIComponent(String(kitchenId))}` - : ''; - - const url = new URL( - `${cleanBaseUrl}/${encodedDatabase}${kitchenSegment}/${encodedPath}`, - ); + const pathname = buildPathname({ + database, + kitchenId, + path, + includeKitchen, + }); + const searchParams = new URLSearchParams(); Object.entries(query).forEach(([key, value]) => { if (value !== undefined && value !== null && value !== '') { - url.searchParams.set(key, String(value)); + searchParams.set(key, String(value)); } }); - return url.toString(); + const queryString = searchParams.toString(); + const relativeUrl = queryString ? `${pathname}?${queryString}` : pathname; + + if (!cleanBaseUrl || isSameOriginBaseUrl(cleanBaseUrl)) { + return relativeUrl; + } + + return new URL(relativeUrl, `${cleanBaseUrl}/`).toString(); +} + +async function parseJsonResponse(response) { + const text = await response.text(); + if (!text) { + return null; + } + + try { + return JSON.parse(text); + } catch { + return text; + } } async function parseResponse(response) { + if (response.status === 204) { + return null; + } + const contentType = response.headers.get('content-type') || ''; if (contentType.includes('application/json')) { - return response.json(); + return parseJsonResponse(response); } if (contentType.includes('image/')) { return response.blob(); } - if (response.status === 204) { + const text = await response.text(); + if (!text) { return null; } - return response.text(); + try { + return JSON.parse(text); + } catch { + return text; + } } -function normalizeError(response, payload) { - const message = - payload?.message || - payload?.error || - `Request failed with status ${response.status}.`; +function flattenErrorObject(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } - return new Error(message, { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => { + if (Array.isArray(entry)) { + return [key, entry.join(', ')]; + } + + return [key, String(entry)]; + }), + ); +} + +function extractErrorMessage(response, payload) { + if (typeof payload === 'string' && payload.trim()) { + return payload; + } + + if (Array.isArray(payload) && payload.length) { + return payload.map((entry) => String(entry)).join(', '); + } + + if (payload && typeof payload === 'object') { + const directMessage = + payload.message || + payload.error || + payload.detail || + payload.description || + payload.title; + + if (typeof directMessage === 'string' && directMessage.trim()) { + return directMessage; + } + + const fieldErrors = flattenErrorObject(payload.errors || payload.details); + if (fieldErrors) { + return Object.entries(fieldErrors) + .map(([field, message]) => `${field}: ${message}`) + .join(' | '); + } + } + + return `Request failed with status ${response.status}.`; +} + +function normalizeError(response, payload, request) { + const details = flattenErrorObject(payload?.errors || payload?.details) || null; + const message = + extractErrorMessage(response, payload); + + const error = new Error(message, { cause: { status: response.status, - details: payload?.errors || payload?.details || null, + details, + url: request.url, + method: request.method, + payload, }, }); + + error.name = 'ApiRequestError'; + error.status = response.status; + error.url = request.url; + error.method = request.method; + error.details = details; + error.payload = payload; + + return error; +} + +function logApiFailure(message, context) { + console.error(message, context); } export async function apiRequest(store, path, options = {}) { const { config, session, activeKitchen } = store; - if (!config.baseUrl || !config.database) { - throw new Error('Server URL and database name are required.'); + if (!config.database) { + throw new Error('Database name is required.'); } + const method = options.method || 'GET'; + const url = buildUrl({ + baseUrl: config.baseUrl, + database: config.database, + kitchenId: activeKitchen?.id, + path, + query: options.query, + includeKitchen: options.includeKitchen !== false, + }); const headers = new Headers(options.headers || {}); headers.set('Accept', options.accept || 'application/json'); @@ -76,28 +208,49 @@ export async function apiRequest(store, path, options = {}) { headers.set('Authorization', `Bearer ${session.applicationKey}`); } - const response = await fetch( - buildUrl({ - baseUrl: config.baseUrl, - database: config.database, - kitchenId: activeKitchen?.id, - path, - query: options.query, - includeKitchen: options.includeKitchen !== false, - }), - { - method: options.method || 'GET', - headers, - body: - options.body && !options.isFormData - ? JSON.stringify(options.body) - : options.body || undefined, - }, - ); + let response; + try { + response = await fetch(url, { + method, + headers, + credentials: options.credentials || 'same-origin', + body: + options.body && !options.isFormData + ? JSON.stringify(options.body) + : options.body || undefined, + }); + } catch (error) { + const networkError = new Error( + `Network request failed for ${method} ${url}. Check the server URL, nginx proxy, and CORS configuration.`, + { + cause: { + url, + method, + originalError: error, + }, + }, + ); + networkError.name = 'ApiNetworkError'; + networkError.url = url; + networkError.method = method; + logApiFailure('API network request failed', { + url, + method, + error, + }); + throw networkError; + } const payload = await parseResponse(response); if (!response.ok) { - throw normalizeError(response, payload); + const apiError = normalizeError(response, payload, { url, method }); + logApiFailure('API request returned an error response', { + url, + method, + status: response.status, + payload, + }); + throw apiError; } return payload; diff --git a/src/api/stock.js b/src/api/stock.js index 4ad38e3..4767e92 100644 --- a/src/api/stock.js +++ b/src/api/stock.js @@ -1,5 +1,9 @@ import { apiRequest, getPath } from './client.js'; +function unwrapEntryPayload(payload) { + return payload?.data || payload?.entry || payload?.item || payload; +} + export async function searchItemDefinitions(store, query) { if (query.trim().length <= 2) { return []; @@ -30,8 +34,10 @@ export async function listStockEntries(store, filters = {}) { } export async function getStockEntry(store, stockId) { - const payload = await apiRequest(store, `${getPath('stockEntries')}/${stockId}`); - return payload?.data || payload?.entry || payload; + const payload = await apiRequest(store, `${getPath('items')}/${stockId}`, { + includeKitchen: false, + }); + return unwrapEntryPayload(payload); } export async function createStockEntry(store, body) { @@ -41,7 +47,7 @@ export async function createStockEntry(store, body) { includeKitchen: false, query: { label: 1 }, }); - return payload?.data || payload?.entry || payload; + return unwrapEntryPayload(payload); } export async function updateStockItem(store, uuidB64, body) { @@ -50,7 +56,7 @@ export async function updateStockItem(store, uuidB64, body) { body, includeKitchen: false, }); - return payload?.data || payload?.entry || payload; + return unwrapEntryPayload(payload); } export async function deleteStockItem(store, uuidB64) { @@ -58,17 +64,14 @@ export async function deleteStockItem(store, uuidB64) { method: 'DELETE', includeKitchen: false, }); - return payload?.data || payload?.entry || payload; + return unwrapEntryPayload(payload); } export async function adjustStockEntry(store, stockId, body) { - const payload = await apiRequest( - store, - `${getPath('stockEntries')}/${stockId}/adjust`, - { - method: 'POST', - body, - }, - ); - return payload?.data || payload?.entry || payload; + const payload = await apiRequest(store, `${getPath('items')}/${stockId}/stock`, { + method: 'POST', + body, + includeKitchen: false, + }); + return unwrapEntryPayload(payload); } diff --git a/src/features/auth/login-page.js b/src/features/auth/login-page.js index ff2c1ca..6e40e56 100644 --- a/src/features/auth/login-page.js +++ b/src/features/auth/login-page.js @@ -21,7 +21,10 @@ export function renderLoginPage() {