Refactor API client and stock management logic for improved clarity, error handling, and support for additional stock types.

This commit is contained in:
2026-04-06 10:30:37 +02:00
parent 929ee6557a
commit 155c7a65d6
8 changed files with 364 additions and 103 deletions
+11 -5
View File
@@ -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: 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 - database name
- user login - 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 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: At minimum, production should ensure:
- `Authorization` headers are accepted for API requests - `Authorization` headers are accepted for API requests
@@ -143,8 +145,6 @@ Expected shapes today:
`/{database}/kitchen/{kitchen_id}/{resource}` `/{database}/kitchen/{kitchen_id}/{resource}`
- User application key management uses: - User application key management uses:
`/{database}/user/application/` `/{database}/user/application/`
- Non-kitchen-scoped authenticated resources currently assume:
`/{database}/{resource}`
- `POST /{database}/user/application/` - `POST /{database}/user/application/`
Sends `{ user, application: "kitchen" }` and returns the application key as a JSON string. Sends `{ user, application: "kitchen" }` and returns the application key as a JSON string.
@@ -155,14 +155,20 @@ Expected shapes today:
Returns `{ data: [...] }` or `{ kitchens: [...] }`. Returns `{ data: [...] }` or `{ kitchens: [...] }`.
- `GET /{database}/kitchen/items?search_name=...` - `GET /{database}/kitchen/items?search_name=...`
Returns item definitions for autocomplete. 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` - `POST /{database}/kitchen/items?label=1`
Creates a stock item plus label-related output on the backend side. Creates a stock item plus label-related output on the backend side.
- `POST /{database}/kitchen/items?label=1&preview=1` - `POST /{database}/kitchen/items?label=1&preview=1`
Returns an image blob, `{ imageUrl }`, or `{ imageSvg }` for in-browser preview. 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` - `GET /{database}/kitchen/locations`
Returns a nested location tree. 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 ## Notes
+190 -37
View File
@@ -1,70 +1,202 @@
import { API_PATHS } from '../app/config.js'; import { API_PATHS } from '../app/config.js';
function normalizeBaseUrl(baseUrl) { 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 }) { function buildUrl({ baseUrl, database, kitchenId, path, query = {}, includeKitchen = true }) {
const cleanBaseUrl = normalizeBaseUrl(baseUrl); const cleanBaseUrl = normalizeBaseUrl(baseUrl);
const encodedDatabase = encodeURIComponent(database); const pathname = buildPathname({
const encodedPath = path.replace(/^\/+/, ''); database,
const kitchenSegment = kitchenId,
includeKitchen && kitchenId path,
? `/kitchen/${encodeURIComponent(String(kitchenId))}` includeKitchen,
: ''; });
const searchParams = new URLSearchParams();
const url = new URL(
`${cleanBaseUrl}/${encodedDatabase}${kitchenSegment}/${encodedPath}`,
);
Object.entries(query).forEach(([key, value]) => { Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== null && 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) { async function parseResponse(response) {
if (response.status === 204) {
return null;
}
const contentType = response.headers.get('content-type') || ''; const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) { if (contentType.includes('application/json')) {
return response.json(); return parseJsonResponse(response);
} }
if (contentType.includes('image/')) { if (contentType.includes('image/')) {
return response.blob(); return response.blob();
} }
if (response.status === 204) { const text = await response.text();
if (!text) {
return null; return null;
} }
return response.text(); try {
return JSON.parse(text);
} catch {
return text;
}
} }
function normalizeError(response, payload) { function flattenErrorObject(value) {
const message = if (!value || typeof value !== 'object' || Array.isArray(value)) {
payload?.message || return null;
payload?.error || }
`Request failed with status ${response.status}.`;
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: { cause: {
status: response.status, 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 = {}) { export async function apiRequest(store, path, options = {}) {
const { config, session, activeKitchen } = store; const { config, session, activeKitchen } = store;
if (!config.baseUrl || !config.database) { if (!config.database) {
throw new Error('Server URL and database name are required.'); 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 || {}); const headers = new Headers(options.headers || {});
headers.set('Accept', options.accept || 'application/json'); headers.set('Accept', options.accept || 'application/json');
@@ -76,28 +208,49 @@ export async function apiRequest(store, path, options = {}) {
headers.set('Authorization', `Bearer ${session.applicationKey}`); headers.set('Authorization', `Bearer ${session.applicationKey}`);
} }
const response = await fetch( let response;
buildUrl({ try {
baseUrl: config.baseUrl, response = await fetch(url, {
database: config.database, method,
kitchenId: activeKitchen?.id,
path,
query: options.query,
includeKitchen: options.includeKitchen !== false,
}),
{
method: options.method || 'GET',
headers, headers,
credentials: options.credentials || 'same-origin',
body: body:
options.body && !options.isFormData options.body && !options.isFormData
? JSON.stringify(options.body) ? JSON.stringify(options.body)
: options.body || undefined, : 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); const payload = await parseResponse(response);
if (!response.ok) { 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; return payload;
+15 -12
View File
@@ -1,5 +1,9 @@
import { apiRequest, getPath } from './client.js'; import { apiRequest, getPath } from './client.js';
function unwrapEntryPayload(payload) {
return payload?.data || payload?.entry || payload?.item || payload;
}
export async function searchItemDefinitions(store, query) { export async function searchItemDefinitions(store, query) {
if (query.trim().length <= 2) { if (query.trim().length <= 2) {
return []; return [];
@@ -30,8 +34,10 @@ export async function listStockEntries(store, filters = {}) {
} }
export async function getStockEntry(store, stockId) { export async function getStockEntry(store, stockId) {
const payload = await apiRequest(store, `${getPath('stockEntries')}/${stockId}`); const payload = await apiRequest(store, `${getPath('items')}/${stockId}`, {
return payload?.data || payload?.entry || payload; includeKitchen: false,
});
return unwrapEntryPayload(payload);
} }
export async function createStockEntry(store, body) { export async function createStockEntry(store, body) {
@@ -41,7 +47,7 @@ export async function createStockEntry(store, body) {
includeKitchen: false, includeKitchen: false,
query: { label: 1 }, query: { label: 1 },
}); });
return payload?.data || payload?.entry || payload; return unwrapEntryPayload(payload);
} }
export async function updateStockItem(store, uuidB64, body) { export async function updateStockItem(store, uuidB64, body) {
@@ -50,7 +56,7 @@ export async function updateStockItem(store, uuidB64, body) {
body, body,
includeKitchen: false, includeKitchen: false,
}); });
return payload?.data || payload?.entry || payload; return unwrapEntryPayload(payload);
} }
export async function deleteStockItem(store, uuidB64) { export async function deleteStockItem(store, uuidB64) {
@@ -58,17 +64,14 @@ export async function deleteStockItem(store, uuidB64) {
method: 'DELETE', method: 'DELETE',
includeKitchen: false, includeKitchen: false,
}); });
return payload?.data || payload?.entry || payload; return unwrapEntryPayload(payload);
} }
export async function adjustStockEntry(store, stockId, body) { export async function adjustStockEntry(store, stockId, body) {
const payload = await apiRequest( const payload = await apiRequest(store, `${getPath('items')}/${stockId}/stock`, {
store,
`${getPath('stockEntries')}/${stockId}/adjust`,
{
method: 'POST', method: 'POST',
body, body,
}, includeKitchen: false,
); });
return payload?.data || payload?.entry || payload; return unwrapEntryPayload(payload);
} }
+4 -1
View File
@@ -21,7 +21,10 @@ export function renderLoginPage() {
<form class="vstack gap-3" @submit.prevent="submit()"> <form class="vstack gap-3" @submit.prevent="submit()">
<div> <div>
<label class="form-label" for="base-url">Tryton server URL</label> <label class="form-label" for="base-url">Tryton server URL</label>
<input id="base-url" class="form-control" type="url" x-model="form.baseUrl" placeholder="https://tryton.example.com" required /> <input id="base-url" class="form-control" type="url" x-model="form.baseUrl" placeholder="https://tryton.example.com" />
<div class="form-text">
Leave this empty to use the same origin as the current Lonc page.
</div>
</div> </div>
<div> <div>
<label class="form-label" for="database">Database name</label> <label class="form-label" for="database">Database name</label>
+5 -1
View File
@@ -18,7 +18,10 @@ export function renderSettingsPage() {
<form class="vstack gap-3" @submit.prevent="save()"> <form class="vstack gap-3" @submit.prevent="save()">
<div> <div>
<label class="form-label">Tryton server URL</label> <label class="form-label">Tryton server URL</label>
<input class="form-control" type="url" x-model="form.baseUrl" required /> <input class="form-control" type="url" x-model="form.baseUrl" />
<div class="form-text">
Leave empty to use the same origin as this deployed Lonc frontend.
</div>
</div> </div>
<div> <div>
<label class="form-label">Database name</label> <label class="form-label">Database name</label>
@@ -36,6 +39,7 @@ export function renderSettingsPage() {
<h2 class="h5">Integration notes</h2> <h2 class="h5">Integration notes</h2>
<ul class="text-body-secondary small ps-3 mb-0"> <ul class="text-body-secondary small ps-3 mb-0">
<li>Connection uses Tryton user application keys for the <code>kitchen</code> application.</li> <li>Connection uses Tryton user application keys for the <code>kitchen</code> application.</li>
<li>Leaving the server URL empty makes API calls use same-origin relative paths.</li>
<li>Kitchen-scoped requests are built as <code>/{database}/kitchen/{kitchenId}/...</code>.</li> <li>Kitchen-scoped requests are built as <code>/{database}/kitchen/{kitchenId}/...</code>.</li>
<li>Label preview accepts image blobs, image URLs, or SVG payloads.</li> <li>Label preview accepts image blobs, image URLs, or SVG payloads.</li>
</ul> </ul>
+1 -1
View File
@@ -687,7 +687,7 @@ export function labelCreatePageData(store) {
}); });
saveStoredValue(STORAGE_KEYS.labelDraft, buildDraftPayload(this.form)); saveStoredValue(STORAGE_KEYS.labelDraft, buildDraftPayload(this.form));
} catch (error) { } catch (error) {
this.fieldErrors = normalizeValidationError(error.cause); this.fieldErrors = normalizeValidationError(error);
this.submitError = error.message; this.submitError = error.message;
throw error; throw error;
} }
+9 -2
View File
@@ -3,8 +3,15 @@ export function fieldError(errors, field) {
} }
export function normalizeValidationError(error) { export function normalizeValidationError(error) {
if (error?.details && typeof error.details === 'object') { const details =
return error.details; error?.details ||
error?.cause?.details ||
error?.payload?.errors ||
error?.payload?.details ||
null;
if (details && typeof details === 'object') {
return details;
} }
return {}; return {};
+96 -11
View File
@@ -1,4 +1,8 @@
import { adjustStockEntry, getStockEntry } from '../../api/stock.js'; import {
adjustStockEntry,
deleteStockItem,
getStockEntry,
} from '../../api/stock.js';
import { getRouteContext } from '../../app/router.js'; import { getRouteContext } from '../../app/router.js';
import { createAsyncState, runAsyncState } from '../shared/ui-state.js'; import { createAsyncState, runAsyncState } from '../shared/ui-state.js';
import { formatDate } from '../shared/date-utils.js'; import { formatDate } from '../shared/date-utils.js';
@@ -38,11 +42,11 @@ export function renderStockDetailPage() {
<dt class="col-5">Quantity</dt> <dt class="col-5">Quantity</dt>
<dd class="col-7" x-text="formatQuantity(entry)"></dd> <dd class="col-7" x-text="formatQuantity(entry)"></dd>
<dt class="col-5">Location</dt> <dt class="col-5">Location</dt>
<dd class="col-7" x-text="entry.location_name || 'Unassigned'"></dd> <dd class="col-7" x-text="entry.location_initial_uuid_b64 || 'Unassigned'"></dd>
<dt class="col-5">Production date</dt> <dt class="col-5">Production date</dt>
<dd class="col-7" x-text="formatDate(entry.production_date)"></dd> <dd class="col-7" x-text="formatDate(entry.date)"></dd>
<dt class="col-5">Expiration date</dt> <dt class="col-5">Expiration date</dt>
<dd class="col-7" x-text="formatDate(entry.expiration_date)"></dd> <dd class="col-7" x-text="formatDate(entry.expire_date)"></dd>
<dt class="col-5">Stock type</dt> <dt class="col-5">Stock type</dt>
<dd class="col-7" x-text="entry.stock_type || 'Stored'"></dd> <dd class="col-7" x-text="entry.stock_type || 'Stored'"></dd>
</dl> </dl>
@@ -56,7 +60,8 @@ export function renderStockDetailPage() {
<p class="eyebrow mb-2">Adjustment</p> <p class="eyebrow mb-2">Adjustment</p>
<h2 class="h5 mb-3">Update current stock level</h2> <h2 class="h5 mb-3">Update current stock level</h2>
<form class="vstack gap-3" @submit.prevent="submitAdjustment()"> <template x-if="entry.stock_type === 'measured'">
<form class="vstack gap-3" @submit.prevent="submitMeasuredAdjustment()">
<div> <div>
<label class="form-label">Adjustment mode</label> <label class="form-label">Adjustment mode</label>
<select class="form-select" x-model="adjustment.mode"> <select class="form-select" x-model="adjustment.mode">
@@ -80,10 +85,53 @@ export function renderStockDetailPage() {
</template> </template>
<button class="btn btn-primary" type="submit" :disabled="adjustmentState.isLoading"> <button class="btn btn-primary" type="submit" :disabled="adjustmentState.isLoading">
<span x-show="!adjustmentState.isLoading">Save adjustment</span> <span x-show="!adjustmentState.isLoading">Save quantity</span>
<span x-show="adjustmentState.isLoading">Saving...</span> <span x-show="adjustmentState.isLoading">Saving...</span>
</button> </button>
</form> </form>
</template>
<template x-if="entry.stock_type === 'descriptive'">
<form class="vstack gap-3" @submit.prevent="submitLevelAdjustment()">
<div>
<label class="form-label">Stock level</label>
<select class="form-select" x-model="adjustment.level">
<option value="plenty">Plenty</option>
<option value="good">Good</option>
<option value="some">Some</option>
<option value="low">Low</option>
<option value="trace">Trace</option>
<option value="gone">Gone</option>
</select>
</div>
<template x-if="adjustmentState.error">
<div class="alert alert-danger mb-0" x-text="adjustmentState.error"></div>
</template>
<button class="btn btn-primary" type="submit" :disabled="adjustmentState.isLoading">
<span x-show="!adjustmentState.isLoading">Save stock level</span>
<span x-show="adjustmentState.isLoading">Saving...</span>
</button>
</form>
</template>
<template x-if="entry.stock_type === 'binary'">
<div class="vstack gap-3">
<p class="text-body-secondary mb-0">
Binary stock items can be marked gone from this screen.
</p>
<template x-if="adjustmentState.error">
<div class="alert alert-danger mb-0" x-text="adjustmentState.error"></div>
</template>
<button class="btn btn-outline-danger align-self-start" type="button" @click="markGone()" :disabled="adjustmentState.isLoading">
<span x-show="!adjustmentState.isLoading">Mark gone</span>
<span x-show="adjustmentState.isLoading">Removing...</span>
</button>
</div>
</template>
</div> </div>
</div> </div>
</div> </div>
@@ -101,33 +149,70 @@ export function stockDetailPageData(store) {
adjustment: { adjustment: {
mode: 'increment', mode: 'increment',
quantity: '1', quantity: '1',
level: 'plenty',
}, },
async init() { async init() {
const { params } = getRouteContext(); const { params } = getRouteContext();
await runAsyncState(this.state, async () => { await runAsyncState(this.state, async () => {
this.entry = await getStockEntry(store, params.id); this.entry = await getStockEntry(store, params.id);
this.adjustment.level = this.entry?.level || 'plenty';
}).catch(() => {}); }).catch(() => {});
}, },
async submitAdjustment() { async submitMeasuredAdjustment() {
if (!this.entry) { if (!this.entry) {
return; return;
} }
await runAsyncState(this.adjustmentState, async () => { await runAsyncState(this.adjustmentState, async () => {
this.entry = await adjustStockEntry(store, this.entry.id, { const requestedQuantity = Number(this.adjustment.quantity);
mode: this.adjustment.mode, if (Number.isNaN(requestedQuantity) || requestedQuantity < 0) {
quantity: Number(this.adjustment.quantity), throw new Error('Enter a valid quantity first.');
}
const currentQuantity = Number(this.entry.quantity || 0);
const exactQuantity =
this.adjustment.mode === 'increment'
? currentQuantity + requestedQuantity
: this.adjustment.mode === 'decrement'
? Math.max(currentQuantity - requestedQuantity, 0)
: requestedQuantity;
this.entry = await adjustStockEntry(store, this.entry.uuid_b64, {
quantity: exactQuantity,
}); });
store.addAlert({ type: 'success', message: 'Stock quantity updated.' }); store.addAlert({ type: 'success', message: 'Stock quantity updated.' });
}).catch(() => {}); }).catch(() => {});
}, },
async submitLevelAdjustment() {
if (!this.entry) {
return;
}
await runAsyncState(this.adjustmentState, async () => {
this.entry = await adjustStockEntry(store, this.entry.uuid_b64, {
level: this.adjustment.level,
});
store.addAlert({ type: 'success', message: 'Stock level updated.' });
}).catch(() => {});
},
async markGone() {
if (!this.entry) {
return;
}
await runAsyncState(this.adjustmentState, async () => {
await deleteStockItem(store, this.entry.uuid_b64);
store.addAlert({ type: 'success', message: `${this.entry.name} was marked gone.` });
window.__loncApp.navigate('/stock');
}).catch(() => {});
},
quickAdjust(step) { quickAdjust(step) {
const current = Number(this.adjustment.quantity || 0); const current = Number(this.adjustment.quantity || 0);
this.adjustment.quantity = String(Math.max(current + step, 0)); this.adjustment.quantity = String(Math.max(current + step, 0));
}, },
formatDate, formatDate,
formatQuantity(entry) { formatQuantity(entry) {
return `${entry.quantity ?? 0} ${entry.uom || ''}`.trim(); return `${entry.quantity ?? 0} ${entry.uom_symbol || ''}`.trim();
}, },
}; };
} }