This document is the frontend-facing single source of truth for consuming the RHL Lieferscheine backend APIs.
Scope:
apiClient helper layer (lib/frontend/apiClient.js).from / to) and shareable URL sync (RHL-025).UI developers: For the app shell layout and frontend route scaffold (public vs protected routes, placeholder pages), see
docs/frontend-ui.md.For UI navigation, prefer centralized route builders from
lib/frontend/routes.jsinstead of hardcoding path strings.
Non-goals:
Notes:
from / to) with a date range picker and presets (RHL-025).For the first UI, prefer calling the backend from browser/client-side code so the HTTP-only session cookie is naturally sent with requests.
Key fetch requirements:
credentials: "include"cache: "no-store"The project provides a tiny wrapper to enforce those defaults: lib/frontend/apiClient.js.
The core Explorer UI flow is a simple drill-down:
login({ username, password })getMe() (optional but recommended)getBranches()getYears(branch)getMonths(branch, year)getDays(branch, year, month)getFiles(branch, year, month, day)Optional (admin/dev):
GET /api/search) to implement cross-branch search UI (see section 4.4)from / to in YYYY-MM-DD format (RHL-025)Optional (authenticated users):
changePassword({ currentPassword, newPassword }) (RHL-009)import {
login,
getMe,
getBranches,
getYears,
getMonths,
getDays,
getFiles,
ApiClientError,
} from "@/lib/frontend/apiClient";
export async function runExampleFlow() {
try {
await login({ username: "nl01user", password: "secret" });
const me = await getMe();
if (!me.user) throw new Error("Expected to be logged in");
const { branches } = await getBranches();
const branch = branches[0];
const { years } = await getYears(branch);
const year = years[years.length - 1];
const { months } = await getMonths(branch, year);
const month = months[months.length - 1];
const { days } = await getDays(branch, year, month);
const day = days[days.length - 1];
const { files } = await getFiles(branch, year, month, day);
return { branch, year, month, day, files };
} catch (err) {
if (err instanceof ApiClientError) {
// Use err.code for UI decisions.
// Example: AUTH_UNAUTHENTICATED -> redirect to login
throw err;
}
throw err;
}
}
File:
lib/frontend/routes.jsPurpose:
Example:
import {
branchPath,
dayPath,
searchPath,
loginPath,
} from "@/lib/frontend/routes";
const loginUrl = loginPath();
const branchUrl = branchPath("NL01");
const dayUrl = dayPath("NL01", "2025", "12", "31");
const searchUrl = searchPath("NL01");
apiClient helperFile:
lib/frontend/apiClient.jsDesign goals:
credentials: "include" and cache: "no-store".ApiClientError.ApiClientErrorWhen the backend returns the standardized error payload:
{ "error": { "message": "...", "code": "...", "details": {} } }
…the client throws:
name = "ApiClientError"status (HTTP status)code (machine-readable error code)message (safe human-readable message)detailsAuth:
login({ username, password })logout()getMe()changePassword({ currentPassword, newPassword }) (RHL-009)Navigation:
getBranches()getYears(branch)getMonths(branch, year)getDays(branch, year, month)Files:
getFiles(branch, year, month, day)Search:
search({ q, branch, scope, branches, from, to, limit, cursor })Low-level:
apiFetch(path, options)Node’s fetch does not include a cookie jar automatically.
For manual verification we provide a script that includes a minimal cookie jar:
scripts/manual-api-client-flow.mjsFor server checks, run it inside the app container:
docker compose exec app node scripts/manual-api-client-flow.mjs \
--baseUrl=http://127.0.0.1:3000 \
--username=<user> \
--password=<pw> \
--branch=NL01
branch: NL01, NL02, ...year: "YYYY" (4 digits)month: "MM" (2 digits, 01–12)day: "DD" (2 digits, 01–31)Current server responses are sorted ascending:
NL01, NL02, ...)If the UI needs “newest first”, reverse the arrays in the UI.
getFiles() returns:
{
"files": [{ "name": "...pdf", "relativePath": "NL01/2025/12/19/...pdf" }]
}
Notes:
relativePath is relative to NAS_ROOT_PATH inside the container.branch/year/month/day/filename).files[].name as the canonical filename and build the stream URL from the current Explorer route segments.All routes are served under /api.
POST /api/auth/loginBody:
{ "username": "example.user", "password": "plain" }
Success:
{ "ok": true }
Errors:
400 VALIDATION_*401 AUTH_INVALID_CREDENTIALSGET /api/auth/logoutSuccess:
{ "ok": true }
GET /api/auth/meSuccess (authenticated):
{
"user": {
"userId": "...",
"role": "branch|admin|dev",
"branchId": "NL01",
"email": "nl01@example.com"
}
}
Notes:
email is optional and may be null.Success (unauthenticated):
{ "user": null }
POST /api/auth/change-password (RHL-009)Body:
{ "currentPassword": "<string>", "newPassword": "<string>" }
Success:
{ "ok": true }
Error codes (common):
401 AUTH_UNAUTHENTICATED (no/invalid session)401 AUTH_INVALID_CREDENTIALS (wrong current password)400 VALIDATION_MISSING_FIELD (missing currentPassword / newPassword)400 VALIDATION_WEAK_PASSWORD (policy violation)Weak password details:
details for VALIDATION_WEAK_PASSWORD, including minLength, requireLetter, requireNumber, disallowSameAsCurrent, and reasons.Example UI usage:
import { changePassword, ApiClientError } from "@/lib/frontend/apiClient";
export async function changePasswordExample() {
try {
await changePassword({
currentPassword: "old-password",
newPassword: "NewPassw0rd",
});
// Success UI: show a toast and clear form state
return { ok: true };
} catch (err) {
if (err instanceof ApiClientError) {
if (err.code === "AUTH_UNAUTHENTICATED") {
// redirect to /login?reason=expired&next=...
}
if (err.code === "AUTH_INVALID_CREDENTIALS") {
// show: “Current password is wrong”
}
if (err.code === "VALIDATION_WEAK_PASSWORD") {
// show hints based on err.details.reasons
// err.details.minLength etc.
}
}
throw err;
}
}
Security note:
All endpoints below require a valid session.
GET /api/branchesSuccess:
{ "branches": ["NL01", "NL02"] }
RBAC:
branch role: returns only its own branchadmin/dev: returns all branchesGET /api/branches/:branch/yearsSuccess:
{ "branch": "NL01", "years": ["2024", "2025"] }
GET /api/branches/:branch/:year/monthsSuccess:
{ "branch": "NL01", "year": "2025", "months": ["01", "02"] }
GET /api/branches/:branch/:year/:month/daysSuccess:
{ "branch": "NL01", "year": "2025", "month": "12", "days": ["18", "19"] }
GET /api/files?branch=&year=&month=&day=Success:
{
"branch": "NL01",
"year": "2025",
"month": "12",
"day": "19",
"files": [{ "name": "test.pdf", "relativePath": "NL01/2025/12/19/test.pdf" }]
}
GET /api/files/:branch/:year/:month/:day/:filenameThis endpoint returns binary PDF data on the happy path (not JSON).
Frontend rules:
Do not call this endpoint via apiClient.apiFetch().
apiClient is JSON-centric and will try to parse the response.Prefer opening the endpoint URL in a new tab so the browser handles PDF rendering.
File:
lib/frontend/explorer/pdfUrl.jsExports:
buildPdfUrl({ branch, year, month, day, filename })buildPdfDownloadUrl({ branch, year, month, day, filename }) (adds ?download=1)Why it exists:
filename segment is encoded correctly.In the Explorer (and Search results table), we open PDFs via navigation:
target="_blank".Example:
import { buildPdfUrl } from "@/lib/frontend/explorer/pdfUrl";
const href = buildPdfUrl({ branch, year, month, day, filename });
// In JSX:
// <a href={href} target="_blank" rel="noopener noreferrer">Öffnen</a>
Force download:
import { buildPdfDownloadUrl } from "@/lib/frontend/explorer/pdfUrl";
const href = buildPdfDownloadUrl({ branch, year, month, day, filename });
Use the exact files[].name returned by getFiles() (case-sensitive on Linux).
Filenames with special characters must be URL-encoded.
# must be encoded as %23.
Otherwise the browser treats it as a fragment and the server receives a truncated filename.Host consistency matters for cookies:
http://localhost:3000, also open the PDF on http://localhost:3000.http://127.0.0.1:3000 will not send the cookie (different host) and results in 401.GET /api/searchThis is a JSON endpoint.
In the frontend, prefer the dedicated wrapper:
apiClient.search(...)Query params:
q (optional)Optional filters:
scope: branch | all | multibranch: single branchbranches: comma-separated branch list (for scope=multi)from, to: YYYY-MM-DD (inclusive)limit: page size (default 100, allowed 50..200)cursor: pagination cursor returned by the previous responseFilter rule:
q OR from OR toIf all three are missing, the API returns:
400 VALIDATION_SEARCH_MISSING_FILTERResponse shape:
{
"items": [
{
"branch": "NL20",
"date": "2025-12-18",
"year": "2025",
"month": "12",
"day": "18",
"filename": "...pdf",
"relativePath": "NL20/2025/12/18/...pdf",
"snippet": "..."
}
],
"nextCursor": "<opaque>",
"total": 123
}
Notes:
nextCursor is null when there are no more results.total is the total number of matches for the current query and can be shown as “x of y loaded”.total may be null if the provider cannot provide a reliable total.The Search UI supports optional date filtering:
from / to are inclusive ISO date strings (YYYY-MM-DD).from === to is valid (single-day filter).from > to is invalid and must be rejected.Frontend responsibilities:
VALIDATION_SEARCH_DATE / VALIDATION_SEARCH_RANGE).Relevant frontend helpers:
lib/frontend/search/dateRange.js (pure ISO date helpers + German formatting)lib/frontend/search/searchDateValidation.js (canonical date-range validation)lib/frontend/search/dateFilterValidation.js (builds a local ApiClientError for UI rendering)For Search v1, the first-page identity is URL-driven (shareable).
q, scope, branches, limit, from, to are part of the shareable state.cursor is intentionally not part of the shareable URL and stays in client state.import { search, ApiClientError } from "@/lib/frontend/apiClient";
export async function searchDeliveryNotesExample() {
try {
const res = await search({
q: "bridgestone",
branch: "NL20",
from: "2025-12-01",
to: "2025-12-31",
limit: 100,
});
return {
items: res.items,
nextCursor: res.nextCursor,
total: res.total,
};
} catch (err) {
if (err instanceof ApiClientError) {
// Example:
// - AUTH_UNAUTHENTICATED -> redirect to login
// - AUTH_FORBIDDEN_BRANCH -> show forbidden
// - VALIDATION_* -> show a friendly input message
}
throw err;
}
}
Date-range-only example (no q):
from / to is provided.The current v1 UI intentionally requires q to trigger a search to avoid accidental broad queries.
import { search } from "@/lib/frontend/apiClient";
export async function searchByDateRangeOnly() {
const res = await search({
scope: "all",
from: "2025-12-01",
to: "2025-12-31",
limit: 100,
});
return res;
}
Using a hit to navigate/open:
Navigate to the day folder:
dayPath(hit.branch, hit.year, hit.month, hit.day)Open the PDF:
buildPdfUrl({ branch: hit.branch, year: hit.year, month: hit.month, day: hit.day, filename: hit.filename })Pagination:
nextCursor from the response.cursor= for the next page.RBAC note:
scope=all or scope=multi for cross-branch search.All JSON error responses use:
{
"error": {
"message": "Human readable message",
"code": "SOME_MACHINE_CODE",
"details": {}
}
}
Auth:
AUTH_UNAUTHENTICATEDAUTH_INVALID_CREDENTIALSAUTH_FORBIDDEN_BRANCHPassword management:
VALIDATION_WEAK_PASSWORDValidation:
VALIDATION_MISSING_PARAMVALIDATION_MISSING_QUERYVALIDATION_INVALID_JSONVALIDATION_INVALID_BODYVALIDATION_MISSING_FIELDSearch validation:
VALIDATION_SEARCH_SCOPEVALIDATION_SEARCH_BRANCHVALIDATION_SEARCH_BRANCHESVALIDATION_SEARCH_DATEVALIDATION_SEARCH_RANGEVALIDATION_SEARCH_LIMITVALIDATION_SEARCH_CURSORVALIDATION_SEARCH_MISSING_FILTERStorage:
FS_NOT_FOUNDFS_STORAGE_ERRORInternal:
INTERNAL_SERVER_ERRORThe backend reads from a NAS where new scans can appear at any time.
Backend rules:
dynamic = "force-dynamic".Cache-Control: no-store.lib/storage.js:
Frontend guidance:
credentials: "include" and cache: "no-store".The repository contains a manual smoke test script that exercises:
Script:
scripts/manual-api-client-flow.mjsLocal:
node scripts/manual-api-client-flow.mjs \
--baseUrl=http://localhost:3000 \
--username=<user> \
--password=<pw> \
--branch=NL01
Server (recommended from within container):
docker compose exec app node scripts/manual-api-client-flow.mjs \
--baseUrl=http://127.0.0.1:3000 \
--username=<user> \
--password=<pw> \
--branch=NL01
As of RHL-008, the endpoints and response shapes documented here are considered API v1.
Rules:
Avoid breaking changes to existing URLs, parameters, or response fields.
Prefer additive changes:
If a breaking change becomes necessary, introduce a new endpoint rather than modifying the existing contract.
Date-only Search UI mode (admin/dev):
q.q to trigger a search.Optional Search UX improvements:
Optional Explorer UX polish:
Password reset / recovery: