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:
- 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
+197 -44
View File
@@ -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;
+17 -14
View File
@@ -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);
}
+4 -1
View File
@@ -21,7 +21,10 @@ export function renderLoginPage() {
<form class="vstack gap-3" @submit.prevent="submit()">
<div>
<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>
<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()">
<div>
<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>
<label class="form-label">Database name</label>
@@ -36,6 +39,7 @@ export function renderSettingsPage() {
<h2 class="h5">Integration notes</h2>
<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>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>Label preview accepts image blobs, image URLs, or SVG payloads.</li>
</ul>
+1 -1
View File
@@ -687,7 +687,7 @@ export function labelCreatePageData(store) {
});
saveStoredValue(STORAGE_KEYS.labelDraft, buildDraftPayload(this.form));
} catch (error) {
this.fieldErrors = normalizeValidationError(error.cause);
this.fieldErrors = normalizeValidationError(error);
this.submitError = error.message;
throw error;
}
+9 -2
View File
@@ -3,8 +3,15 @@ export function fieldError(errors, field) {
}
export function normalizeValidationError(error) {
if (error?.details && typeof error.details === 'object') {
return error.details;
const details =
error?.details ||
error?.cause?.details ||
error?.payload?.errors ||
error?.payload?.details ||
null;
if (details && typeof details === 'object') {
return details;
}
return {};
+120 -35
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 { createAsyncState, runAsyncState } from '../shared/ui-state.js';
import { formatDate } from '../shared/date-utils.js';
@@ -38,11 +42,11 @@ export function renderStockDetailPage() {
<dt class="col-5">Quantity</dt>
<dd class="col-7" x-text="formatQuantity(entry)"></dd>
<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>
<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>
<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>
<dd class="col-7" x-text="entry.stock_type || 'Stored'"></dd>
</dl>
@@ -56,34 +60,78 @@ export function renderStockDetailPage() {
<p class="eyebrow mb-2">Adjustment</p>
<h2 class="h5 mb-3">Update current stock level</h2>
<form class="vstack gap-3" @submit.prevent="submitAdjustment()">
<div>
<label class="form-label">Adjustment mode</label>
<select class="form-select" x-model="adjustment.mode">
<option value="increment">Add quantity</option>
<option value="decrement">Subtract quantity</option>
<option value="set">Set exact quantity</option>
</select>
</div>
<div>
<label class="form-label">Quantity</label>
<input class="form-control" type="number" min="0" step="0.01" x-model="adjustment.quantity" required />
</div>
<div class="d-flex flex-wrap gap-2">
<button type="button" class="btn btn-outline-secondary" @click="quickAdjust(1)">+1</button>
<button type="button" class="btn btn-outline-secondary" @click="quickAdjust(-1)">-1</button>
<button type="button" class="btn btn-outline-secondary" @click="quickAdjust(0.5)">+0.5</button>
</div>
<template x-if="entry.stock_type === 'measured'">
<form class="vstack gap-3" @submit.prevent="submitMeasuredAdjustment()">
<div>
<label class="form-label">Adjustment mode</label>
<select class="form-select" x-model="adjustment.mode">
<option value="increment">Add quantity</option>
<option value="decrement">Subtract quantity</option>
<option value="set">Set exact quantity</option>
</select>
</div>
<div>
<label class="form-label">Quantity</label>
<input class="form-control" type="number" min="0" step="0.01" x-model="adjustment.quantity" required />
</div>
<div class="d-flex flex-wrap gap-2">
<button type="button" class="btn btn-outline-secondary" @click="quickAdjust(1)">+1</button>
<button type="button" class="btn btn-outline-secondary" @click="quickAdjust(-1)">-1</button>
<button type="button" class="btn btn-outline-secondary" @click="quickAdjust(0.5)">+0.5</button>
</div>
<template x-if="adjustmentState.error">
<div class="alert alert-danger mb-0" x-text="adjustmentState.error"></div>
</template>
<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 adjustment</span>
<span x-show="adjustmentState.isLoading">Saving...</span>
</button>
</form>
<button class="btn btn-primary" type="submit" :disabled="adjustmentState.isLoading">
<span x-show="!adjustmentState.isLoading">Save quantity</span>
<span x-show="adjustmentState.isLoading">Saving...</span>
</button>
</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>
@@ -101,33 +149,70 @@ export function stockDetailPageData(store) {
adjustment: {
mode: 'increment',
quantity: '1',
level: 'plenty',
},
async init() {
const { params } = getRouteContext();
await runAsyncState(this.state, async () => {
this.entry = await getStockEntry(store, params.id);
this.adjustment.level = this.entry?.level || 'plenty';
}).catch(() => {});
},
async submitAdjustment() {
async submitMeasuredAdjustment() {
if (!this.entry) {
return;
}
await runAsyncState(this.adjustmentState, async () => {
this.entry = await adjustStockEntry(store, this.entry.id, {
mode: this.adjustment.mode,
quantity: Number(this.adjustment.quantity),
const requestedQuantity = Number(this.adjustment.quantity);
if (Number.isNaN(requestedQuantity) || requestedQuantity < 0) {
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.' });
}).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) {
const current = Number(this.adjustment.quantity || 0);
this.adjustment.quantity = String(Math.max(current + step, 0));
},
formatDate,
formatQuantity(entry) {
return `${entry.quantity ?? 0} ${entry.uom || ''}`.trim();
return `${entry.quantity ?? 0} ${entry.uom_symbol || ''}`.trim();
},
};
}