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)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()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" } }
Success (unauthenticated):
{ "user": null }
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_BRANCHValidation:
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:
derzeit habe ich eine RTX4060ti eingebaut. derzeit spiele ich auch viel auf der ps5 aber ich moechte in zukunft komplett umsteigen auf pc.
was brauche ich um spiele wie eafc, nba2k, anno, gta etc auf 4k mit hoher frame zu spielen?