This commit is contained in:
seb
2026-07-31 18:25:42 +02:00
parent 1a05a5b7d6
commit 6074aa7304
33 changed files with 3041 additions and 470 deletions

View File

@@ -23,6 +23,14 @@ Default port: `4208`.
See [docs/api](docs/api/README.md) for endpoints, auth flow, and frontend integration. See [docs/api](docs/api/README.md) for endpoints, auth flow, and frontend integration.
Docs are generated from `@openapi` JSDoc on route handlers:
```bash
npm run docs:api
```
That writes `docs/api/openapi.json` and per-route Markdown under `docs/api/routes/`.
## Users ## Users
Manage the email allowlist: Manage the email allowlist:

View File

@@ -26,18 +26,28 @@ Sessions use an HTTP-only `token` cookie (`Secure`, `SameSite=Strict`, 24h). The
## Routes ## Routes
Mirrors [`src/api/routes/`](../../src/api/routes/). Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/routes/). Source of truth is the code; regenerate with `npm run docs:api`.
| File | Endpoint | | File | Endpoint |
|------|----------| |------|----------|
| [`routes/article/create.md`](routes/article/create.md) | `POST /article` |
| [`routes/article/get.md`](routes/article/get.md) | `GET /article/{sku}` |
| [`routes/article/update.md`](routes/article/update.md) | `PATCH /article/{sku}` |
| [`routes/auth/login.md`](routes/auth/login.md) | `POST /auth/login` | | [`routes/auth/login.md`](routes/auth/login.md) | `POST /auth/login` |
| [`routes/auth/logout.md`](routes/auth/logout.md) | `POST /auth/logout` | | [`routes/auth/logout.md`](routes/auth/logout.md) | `POST /auth/logout` |
| [`routes/auth/me.md`](routes/auth/me.md) | `GET /auth/me` | | [`routes/auth/me.md`](routes/auth/me.md) | `GET /auth/me` |
| [`routes/auth/selected-company.md`](routes/auth/selected-company.md) | `PUT /auth/selected-company` |
| [`routes/category/create.md`](routes/category/create.md) | `POST /category` |
| [`routes/category/tree.md`](routes/category/tree.md) | `GET /category/tree` |
| [`routes/category/delete.md`](routes/category/delete.md) | `DELETE /category/{id}` |
| [`routes/category/articles.md`](routes/category/articles.md) | `GET /category/{id}/articles` |
| [`routes/company/list.md`](routes/company/list.md) | `GET /company` |
| [`routes/company/create.md`](routes/company/create.md) | `POST /company` |
| [`routes/company/delete.md`](routes/company/delete.md) | `DELETE /company/{id}` |
| [`routes/company/update.md`](routes/company/update.md) | `PUT /company/{id}` |
| [`routes/health.md`](routes/health.md) | `GET /health` | | [`routes/health.md`](routes/health.md) | `GET /health` |
| [`routes/article/search.md`](routes/article/search.md) | `GET /article/search` |
| [`routes/article/get.md`](routes/article/get.md) | `GET /article/get` | Machine-readable spec: [`openapi.json`](openapi.json).
| [`routes/misc/getSteuer.md`](routes/misc/getSteuer.md) | `GET /misc/getSteuer` |
| [`routes/misc/getCategories.md`](routes/misc/getCategories.md) | `GET /misc/getCategories` |
## DESIGN ## DESIGN
@@ -45,4 +55,5 @@ Mirrors [`src/api/routes/`](../../src/api/routes/).
- that should loosly bound to services in /src/services - that should loosly bound to services in /src/services
- for every service we have a CLI tester in /scripts/ - for every service we have a CLI tester in /scripts/
- all DB SQL code shall be in /src/db - all DB SQL code shall be in /src/db
- with mssql staements in /src/db/mssql/ - with mssql staements in /src/db/mssql/
- API docs: `@openapi` JSDoc on handlers → `npm run docs:api` → `docs/api/`

1384
docs/api/openapi.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
# `POST /article`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Create article
Creates an article under an authorized category (`parentId`).
## Body (JSON)
```json
{
"parentId": 10,
"name": "New article"
}
```
## Response `201`
Created
```json
{
"article": { … }
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Validation error — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized for parent category — `{"error":"…"}` |
| 404 | Parent not found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -1,121 +1,36 @@
# `GET /article/get` # `GET /article/{sku}`
Protected. Requires a valid session cookie (`credentials: 'include'`). Protected. Requires a valid session cookie (`credentials: 'include'`).
Full article detail by **exact** match. Use after the user picks a row from [`GET /article/search`](search.md). Get article by SKU
## Query parameters Returns article detail by SKU if the user may access its category.
| Param | Required | Description | ## Path parameters
|-------|----------|-------------|
| `target` | yes | Exact article number (`cArtNr`), packaging EAN (`tGebinde.cEAN`), or barcode (`cBarcode`). | | Param | Required | Type | Description |
|-------|----------|------|-------------|
| `sku` | yes | string | Article SKU |
## Response `200` ## Response `200`
Article detail
```json ```json
{ {
"article": { "article": { … }
"kArtikel": 49,
"cArtNr": "42000044",
"cName": "BioBizz AllMix Peat Free 20L",
"cAktiv": "Y",
"fVKNEtto": 11.3445378151261,
"kSteuerklasse": 1,
"fBestand": null,
"cHersteller": null,
"cBarcode": null,
"KATs": [{ "kKategorie": 16 }, { "kKategorie": 39 }],
"lief": [],
"staffel": [],
"EANs": [],
"merkmale": [],
"attribute": []
}
} }
``` ```
The object is enriched with related data in parallel. Fields below are always present on success; arrays may be empty.
### Core fields
| Field | Type | Notes |
|-------|------|-------|
| `kArtikel` | number | Internal ID |
| `cArtNr` | string | Article number |
| `cName` | string | Name |
| `cBeschreibung` | string | Long description (HTML) |
| `cKurzBeschreibung` | string | Short description |
| `cAktiv` | string | `"Y"` or `"N"` |
| `fVKNEtto` | number | Net sales price |
| `kSteuerklasse` | number | `1` / `2` / `3` |
| `fUVP` | number | RRP |
| `fBestand` | number \| null | Stock warehouse 1 |
| `fGewicht` | number | Weight |
| `cBarcode` | string \| null | |
| `cHersteller` | string \| null | Manufacturer name |
| `cAnmerkung` | string | Internal note |
| `kVersandklasse` | number | Shipping class ID |
| `tVersandklassecName` | string | Shipping class name |
| `nIstVater` | number | `1` if parent article with variants |
| `kVaterArtikel` | number | Parent article ID (variants) |
| `kStueckliste` | number | Bill-of-materials ID; `0` if none |
| `kEigenschaftKombi` | number | Variant combination |
| `cEigenschaftName` | string \| null | Variant property name |
| `cEigenschaftWertName` | string \| null | Variant value name |
| `cLiefArtNr96` | string \| null | Supplier 96 article number |
| `nVPE` | number | Sales unit flag |
| `kMassEinheit` | number \| null | Unit of measure |
| `fMassMenge` | number | Unit quantity |
| `shops` | number \| null | Shop bitmask sum |
| `garage` | number \| undefined | Extra storage slot 0 qty |
| `keller` | number \| undefined | Extra storage slot 1 qty |
| `cHerstellerGPSR` | string \| null | Manufacturer GPSR text |
| `herstellerBild` | string \| null | Manufacturer logo, base64 PNG |
### Related arrays / objects
| Field | Description |
|-------|-------------|
| `children` | Variant rows (when `nIstVater === 1`) |
| `vater` | Parent `{ cArtNr }` (when variant) |
| `liste` | Stückliste / BOM components |
| `fAndereLager` | Stock in other warehouses `[{ fSumme }]` |
| `EANs` | `[{ cEAN }]` |
| `KATs` | `[{ kKategorie }]` |
| `lief` | Suppliers; `cAnmerkung` may be parsed into `anmerkung` |
| `incoming` | Open supplier purchase orders |
| `orders` | Open incidents (`order_count`, `order_label`, `order_free`) |
| `merkmale` | Features `[{ kMerkmal, cName, cWert, … }]` |
| `attribute` | Custom attributes |
| `staffel` | Tier prices `[{ menge, fVKBrutto }]` — brutto pre-formatted string |
### Supplier row (`lief[]`)
| Field | Notes |
|-------|-------|
| `kLieferant`, `cFirma` | Supplier |
| `cLiefArtNr` | Supplier article number |
| `nStandard` | Preferred supplier flag |
| `fEKNetto`, `fMwSt` | Purchase price / VAT |
| `fLagerbestand` | Supplier stock |
| `cEmail`, `cAnmerkung` | Contact / note |
## Errors ## Errors
| Status | Body | | Status | Description |
|--------|------| |--------|-------------|
| 400 | `{ "error": "Query parameter \"target\" is required" }` | | 400 | Missing sku — `{"error":"…"}` |
| 401 | `{ "error": "Unauthorized" }` | | 401 | Unauthorized — `{"error":"…"}` |
| 404 | `{ "error": "Article not found" }` | | 403 | Not authorized — `{"error":"…"}` |
| 500 | `{ "error": "..." }` | | 404 | Article not found — `{"error":"…"}` |
## Example ---
```js _Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
// User selected cArtNr from search results
const res = await fetch(
'http://localhost:4208/api/article/get?target=42000044',
{ credentials: 'include' }
);
const { article } = await res.json();
```

View File

@@ -1,121 +0,0 @@
# `GET /article/search`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Fuzzy article search. Use this to build a search box / result list with category facets.
## Query parameters
| Param | Required | Description |
|-------|----------|-------------|
| `target` | yes | Search string. Split on spaces; each word must match (AND). Each word is matched with `LIKE %word%` against EAN, supplier article number, name, article number, or barcode. |
| `filter` | no | Comma-separated category IDs. Omit or leave empty to search all categories. See [Category filter](#category-filter). |
| `offset` | no | Pagination offset (default `0`). |
| `maxCount` | no | Page size (default `10`). |
## Search behaviour
- Multiple spaces are collapsed to one.
- Example: `target=bizz allmix` → both `bizz` **and** `allmix` must match somewhere on the article.
- By default only **active** articles (`cAktiv = 'Y'`) are returned.
- Without a category filter, at most **2000** rows are returned (before pagination).
- Results are ordered by product name ascending.
- `length` is the total match count (before `offset` / `maxCount`); `recordset` is the current page.
## Category filter
| `filter` value | Effect |
|----------------|--------|
| *(omitted or empty)* | All categories; `TOP 2000` |
| `751` | Include inactive articles (no `cAktiv = 'Y'` restriction). Still uses category branch logic if other IDs are present. |
| `16` or `16,39` | Restrict to articles in those categories (OR). No row limit. |
Pass category IDs as a comma-separated list: `filter=16,39`.
Whenever `filter` is present, **`categories` always includes every id from `filter` and its full ancestor chain** (parent → … → root), even when `length` is `0` or a filtered id has no hits. Those entries use master-data `name` / `parent` and may have `count: 0`. Other facet categories with hits are still returned as usual.
## Response `200`
```json
{
"length": 42,
"recordset": [
{
"categories": "16,39",
"available": 0,
"id": 49,
"stock": null,
"sku": "42000044",
"name": "BioBizz AllMix Peat Free 20L",
"price": 13.5,
"tax": 19
}
],
"categories": {
"16": { "name": "Dünger", "count": 42, "parent": 0 },
"39": { "name": "Erde", "count": 18, "parent": 16 }
}
}
```
Empty result with a nested filter still returns the leaf and its ancestors:
```json
{
"length": 0,
"recordset": [],
"categories": {
"16": { "name": "Dünger", "count": 0, "parent": 0 },
"39": { "name": "Erde", "count": 0, "parent": 16 }
}
}
```
### Top-level fields
| Field | Type | Notes |
|-------|------|-------|
| `length` | number | Total matching articles (unpaginated). |
| `recordset` | array | Current page of shaped article rows. |
| `categories` | object | Map keyed by category id (string). Facet counts for the current search (+ filters). |
### `recordset[]` fields
| Field | Type | Notes |
|-------|------|-------|
| `id` | number | Internal article ID (`kArtikel`) |
| `sku` | string | Article number — pass to [`GET /article/get`](get.md) |
| `name` | string | Display name |
| `stock` | number \| null | Stock in warehouse 1 |
| `available` | number \| null | Available stock |
| `price` | number \| null | Brutto VK, rounded to 1 decimal |
| `tax` | number \| null | Tax rate percent for the article’s Steuerklasse |
| `categories` | string \| null | Comma-separated category IDs on the article |
### `categories` map entries
| Field | Type | Notes |
|-------|------|-------|
| `name` | string \| null | Display label from category master data |
| `count` | number | Hit count under the current search (+ other filters); may be `0` |
| `parent` | number \| null | Parent category id, or `0` if root |
Counts include parent categories rolled up from each article’s category path. Use for category sidebar / facet UI. Resolve labels via `categories[id].name` (skip ids without a name).
## Errors
| Status | Body |
|--------|------|
| 400 | `{ "error": "Query parameter \"target\" is required" }` |
| 401 | `{ "error": "Unauthorized" }` |
| 500 | `{ "error": "..." }` |
## Example
```js
const res = await fetch(
'http://localhost:4208/api/article/search?target=bizz%20allmix&filter=16&maxCount=10',
{ credentials: 'include' }
);
const { categories, length, recordset } = await res.json();
```

View File

@@ -0,0 +1,44 @@
# `PATCH /article/{sku}`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Update article by SKU
Updates an article name by SKU; returns the same detail shape as GET.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `sku` | yes | string | Article SKU |
## Body (JSON)
```json
{
"name": "Updated name"
}
```
## Response `200`
Updated article detail
```json
{
"article": { … }
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Validation error — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
| 404 | Article not found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -2,25 +2,38 @@
Public. No cookie required. Public. No cookie required.
**Body (JSON)** Login with Google ID token
Verifies a Google ID token, checks allowlist (or creates entry when `IMPLICIT_CREATE=true`), and sets an HTTP-only `token` cookie.
## Body (JSON)
```json ```json
{ "idToken": "<google-id-token>" } {
"idToken": "<google-id-token>"
}
``` ```
**200** ## Response `200`
Login successful; `token` cookie set
```json ```json
{ {
"message": "Login successful", "message": "Login successful",
"user": { "id": 1, "email": "you@example.com", "picture": "https://lh3.googleusercontent.com/..." } "user": { … }
} }
``` ```
**Errors** ## Errors
| Status | Body | | Status | Description |
|--------|------| |--------|-------------|
| 400 | `{ "error": "idToken is required" }` or `{ "error": "GOOGLE_CLIENT_ID is not configured" }` | | 400 | Missing token or Google client not configured — `{"error":"…"}` |
| 401 | `{ "error": "Invalid Google token" }` or `{ "error": "Invalid Google account" }` | | 401 | Invalid Google token or account — `{"error":"…"}` |
| 403 | `{ "error": "Email is not authorized" }` | | 403 | Email not on allowlist — `{"error":"…"}` |
| 500 | Server error — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -1,9 +1,21 @@
# `POST /auth/logout` # `POST /auth/logout`
Public. Clears the session cookie. Public. No cookie required.
**200** Logout
Clears the HTTP-only `token` cookie set at login.
## Response `200`
Logged out
```json ```json
{ "message": "Logged out successfully" } {
"message": "Logged out successfully"
}
``` ```
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -1,19 +1,28 @@
# `GET /auth/me` # `GET /auth/me`
Protected. Requires a valid session cookie. Every response includes `googleClientId`. Protected. Requires a valid session cookie (`credentials: 'include'`).
**200** Current user
Returns the authenticated user from the session cookie and the Google client ID. Unauthenticated calls still receive `googleClientId` with 401.
## Response `200`
Authenticated
```json ```json
{ {
"user": { "id": 1, "email": "you@example.com", "picture": "https://lh3.googleusercontent.com/..." }, "user": { … },
"googleClientId": "your-client-id.apps.googleusercontent.com" "googleClientId": "…"
} }
``` ```
**Errors** ## Errors
| Status | Body | | Status | Description |
|--------|------| |--------|-------------|
| 401 | `{ "error": "Unauthorized", "googleClientId": "..." }` or `{ "error": "Invalid token", "googleClientId": "..." }` | | 401 | Unauthorized — still includes `googleClientId` for the frontend |
| 403 | `{ "error": "User is no longer authorized", "googleClientId": "..." }` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,37 @@
# `PUT /auth/selected-company`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Set selected company
Sets the authenticated user's selected company.
## Body (JSON)
```json
{
"companyId": 1
}
```
## Response `200`
Updated user
```json
{
"user": { … }
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid companyId — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 500 | Server error — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,35 @@
# `GET /category/{id}/articles`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Articles in category
Returns articles in a category the user is authorized for.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | Category id |
## Response `200`
Articles list
```json
{
"articles": []
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid id — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,39 @@
# `POST /category`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Create category
Creates a category under an authorized parent.
## Body (JSON)
```json
{
"parentId": 1,
"name": "New category"
}
```
## Response `201`
Created
```json
{
"category": { … }
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Validation error — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
| 404 | Parent not found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,31 @@
# `DELETE /category/{id}`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Delete category
Deletes a category the user is authorized for (not the company root).
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | Category id |
## Response `204`
Deleted
_No content_
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid id or cannot delete root — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,32 @@
# `GET /category/tree`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Category tree
Returns the category tree for the authenticated user's selected company.
## Response `200`
Tree and articles for the selected company
```json
{
"languageId": 1,
"tree": [],
"articles": []
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | No company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
| 404 | Company not found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,41 @@
# `POST /company`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Create company
Creates a company and selects it for the current user.
## Body (JSON)
```json
{
"iso639": "de",
"name": "Acme GmbH",
"taxrate": 19,
"reducedtaxrate": 7
}
```
## Response `201`
Created; selected company updated
```json
{
"company": { … },
"user": { … }
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Validation error — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 409 | Company name not available — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,31 @@
# `DELETE /company/{id}`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Delete company
Deletes a company.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | |
## Response `204`
Deleted
_No content_
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid id — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 500 | Server error — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,28 @@
# `GET /company`
Protected. Requires a valid session cookie (`credentials: 'include'`).
List companies
Lists companies for the authenticated user.
## Response `200`
Company list
```json
{
"companies": []
}
```
## Errors
| Status | Description |
|--------|-------------|
| 401 | Unauthorized — `{"error":"…"}` |
| 500 | Server error — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,47 @@
# `PUT /company/{id}`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Update company
Updates a company.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | |
## Body (JSON)
```json
{
"iso639": "…",
"name": "…",
"taxrate": 1,
"reducedtaxrate": 1
}
```
## Response `200`
Updated
```json
{
"company": { … }
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Validation error — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 404 | Company not found — `{"error":"…"}` |
| 409 | Company name not available — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -2,14 +2,26 @@
Public. No cookie required. Public. No cookie required.
**200** Database health check
Verifies the database client can establish a connection.
## Response `200`
Database reachable
```json ```json
{ "ok": true } {
"ok": true
}
``` ```
**Errors** ## Errors
| Status | Body | | Status | Description |
|--------|------| |--------|-------------|
| 503 | `{ "ok": false, "error": "..." }` | | 503 | Database unreachable |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -1,78 +0,0 @@
# `GET /misc/getCategories`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Returns the full JTL category list and a nested tree. Same data source as the legacy `getKat` socket handler in dashApp.
## Response `200`
```json
{
"categories": [
{
"kKategorie": 16,
"kOberKategorie": 0,
"nSort": 10,
"cName": "Dünger",
"count": 142
}
],
"tree": [
{
"kKategorie": 16,
"kOberKategorie": 0,
"nSort": 10,
"cName": "Dünger",
"count": 142,
"children": [
{
"kKategorie": 39,
"kOberKategorie": 16,
"nSort": 0,
"cName": "Bio",
"count": 47,
"children": []
}
]
}
]
}
```
### `categories[]`
Flat list — useful for lookups by ID (`katNames`-style maps).
| Field | Type | Description |
|-------|------|-------------|
| `kKategorie` | number | Category ID |
| `kOberKategorie` | number | Parent category ID (`0` = root) |
| `nSort` | number | Sort order among siblings |
| `cName` | string \| null | German display name (Sprache 1, Plattform 1, Shop 0) |
| `count` | number | Active articles directly in this category |
### `tree[]`
Nested hierarchy rooted at `kOberKategorie === 0` (or orphaned nodes). Each node has the same fields plus `children[]`, sorted by `nSort` then `cName`.
Use `tree` for sidebar navigation; use `categories` for ID → name maps or [`GET /article/search`](../article/search.md) `filter` param.
## Errors
| Status | Body |
|--------|------|
| 401 | `{ "error": "Unauthorized" }` |
| 500 | `{ "error": "..." }` |
## Example
```js
const res = await fetch('http://localhost:4208/api/misc/getCategories', {
credentials: 'include',
});
const { categories, tree } = await res.json();
const katNames = Object.fromEntries(
categories.map((kat) => [kat.kKategorie, kat.cName])
);
```

View File

@@ -1,63 +0,0 @@
# `GET /misc/getSteuer`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Returns tax classes and their rates from JTL-Wawi (`tSteuerklasse` + `tSteuersatz`, domestic zone `kSteuerzone = 1`).
## Response `200`
```json
{
"steuerklassen": [
{
"kSteuerklasse": 1,
"cName": "Normaler Steuersatz",
"nStandard": 1,
"fSteuersatz": 19
},
{
"kSteuerklasse": 2,
"cName": "Ermäßigter Steuersatz",
"nStandard": 0,
"fSteuersatz": 7
}
]
}
```
| Field | Description |
|-------|-------------|
| `kSteuerklasse` | Tax class ID (used on articles as `kSteuerklasse`) |
| `cName` | Display name |
| `nStandard` | `1` if this is the default tax class |
| `fSteuersatz` | VAT rate in percent |
### Brutto calculation
Look up the article's `kSteuerklasse` in `steuerklassen`, then:
```js
function getBrutto(netto, kSteuerklasse, steuerklassen) {
const row = steuerklassen.find((s) => s.kSteuerklasse === kSteuerklasse);
if (!row) return null;
return netto * (1 + row.fSteuersatz / 100);
}
```
Load once on app init and reuse for search results and article detail.
## Errors
| Status | Body |
|--------|------|
| 401 | `{ "error": "Unauthorized" }` |
| 500 | `{ "error": "..." }` |
## Example
```js
const res = await fetch('http://localhost:4208/api/misc/getSteuer', {
credentials: 'include',
});
const { steuerklassen } = await res.json();
```

View File

@@ -10,6 +10,7 @@
"start": "node src/index.js", "start": "node src/index.js",
"dev": "node --watch-path=./src --watch-path=./.env src/index.js", "dev": "node --watch-path=./src --watch-path=./.env src/index.js",
"manage-users": "node scripts/manageUsers.js", "manage-users": "node scripts/manageUsers.js",
"docs:api": "node scripts/generateApiDocs.js",
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1"
}, },
"dependencies": { "dependencies": {

313
scripts/generateApiDocs.js Normal file
View File

@@ -0,0 +1,313 @@
/**
* Code-first API docs: scan `@openapi` JSDoc in routes → OpenAPI JSON + Markdown under docs/api/.
*
* Usage: npm run docs:api
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import swaggerJsdoc from 'swagger-jsdoc';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
const docsDir = path.join(root, 'docs', 'api');
const routesDocsDir = path.join(docsDir, 'routes');
const PUBLIC_PATHS = new Set(['/auth/login', '/auth/logout', '/health']);
const options = {
definition: {
openapi: '3.0.3',
info: {
title: 'Dash API',
version: '1.0.0',
description:
'Backend API for Dash — Google SSO auth, MSSQL, session cookies.\n\n' +
'Browser clients must send cookies (`credentials: \'include\'`). ' +
'Sessions use an HTTP-only `token` cookie (`Secure`, `SameSite=Strict`, 24h); HTTPS is required for login.',
},
servers: [{ url: 'http://localhost:4208/api', description: 'Local default (PORT / .env)' }],
tags: [
{ name: 'Auth', description: 'Google SSO and session' },
{ name: 'Health', description: 'Connectivity checks' },
{ name: 'Article', description: 'Articles under categories' },
{ name: 'Category', description: 'Category tree and membership' },
{ name: 'Company', description: 'Company CRUD' },
],
},
apis: [
path.join(root, 'src/api/openapi.js'),
path.join(root, 'src/api/routes/**/*.js'),
],
};
const spec = swaggerJsdoc(options);
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
function rmDirContents(dir) {
if (!fs.existsSync(dir)) return;
for (const name of fs.readdirSync(dir)) {
fs.rmSync(path.join(dir, name), { recursive: true, force: true });
}
}
function schemaRefName(schema) {
if (!schema) return null;
if (schema.$ref) return schema.$ref.split('/').pop();
return null;
}
function formatSchema(schema, components, indent = 0) {
if (!schema) return '_none_';
if (schema.$ref) {
const name = schemaRefName(schema);
const resolved = components?.schemas?.[name];
if (resolved) {
return formatSchema({ ...resolved, title: name }, components, indent);
}
return `\`${name}\``;
}
if (schema.example !== undefined) {
return '```json\n' + JSON.stringify(schema.example, null, 2) + '\n```';
}
if (schema.type === 'object' && schema.properties) {
const lines = ['```json', '{'];
const keys = Object.keys(schema.properties);
keys.forEach((key, i) => {
const prop = schema.properties[key];
const comma = i < keys.length - 1 ? ',' : '';
let value = '…';
if (prop.type === 'string') value = prop.format === 'email' ? '"user@example.com"' : '"…"';
else if (prop.type === 'integer' || prop.type === 'number') value = prop.nullable ? 'null' : '1';
else if (prop.type === 'boolean') value = 'true';
else if (prop.type === 'array') value = '[]';
else if (prop.type === 'object' || prop.$ref) value = '{ … }';
if (prop.example !== undefined) value = JSON.stringify(prop.example);
lines.push(` "${key}": ${value}${comma}`);
});
lines.push('}', '```');
return lines.join('\n');
}
if (schema.type === 'array') {
return '```json\n[]\n```';
}
return '```json\n' + JSON.stringify(schema, null, 2) + '\n```';
}
function contentSchema(content) {
if (!content) return null;
const json = content['application/json'];
return json?.schema ?? null;
}
function docsFileFor(apiPath, method) {
const custom = spec.paths[apiPath]?.[method]?.['x-markdown'];
if (custom) return custom;
const cleaned = apiPath
.replace(/^\//, '')
.replace(/\{([^}]+)\}/g, ':$1');
const segments = cleaned.split('/').filter(Boolean);
if (segments.length === 0) return `${method}.md`;
const fileBase = segments.pop();
const dir = segments.join('/');
const methodsOnPath = Object.keys(spec.paths[apiPath]).filter((m) => !m.startsWith('x-'));
const name =
methodsOnPath.length > 1 ? `${fileBase}.${method}.md` : `${fileBase}.md`;
return dir ? path.join(dir, name) : name;
}
function renderOperation(apiPath, method, operation) {
const isPublic = PUBLIC_PATHS.has(apiPath) || operation.security?.length === 0;
const lines = [];
lines.push(`# \`${method.toUpperCase()} ${apiPath}\``);
lines.push('');
if (isPublic) {
lines.push('Public. No cookie required.');
} else {
lines.push('Protected. Requires a valid session cookie (`credentials: \'include\'`).');
}
lines.push('');
if (operation.summary) {
lines.push(operation.summary);
lines.push('');
}
if (operation.description && operation.description !== operation.summary) {
lines.push(operation.description);
lines.push('');
}
const params = operation.parameters || [];
const pathParams = params.filter((p) => p.in === 'path');
const queryParams = params.filter((p) => p.in === 'query');
if (pathParams.length) {
lines.push('## Path parameters');
lines.push('');
lines.push('| Param | Required | Type | Description |');
lines.push('|-------|----------|------|-------------|');
for (const p of pathParams) {
lines.push(
`| \`${p.name}\` | ${p.required ? 'yes' : 'no'} | ${p.schema?.type || ''} | ${p.description || ''} |`,
);
}
lines.push('');
}
if (queryParams.length) {
lines.push('## Query parameters');
lines.push('');
lines.push('| Param | Required | Type | Description |');
lines.push('|-------|----------|------|-------------|');
for (const p of queryParams) {
lines.push(
`| \`${p.name}\` | ${p.required ? 'yes' : 'no'} | ${p.schema?.type || ''} | ${p.description || ''} |`,
);
}
lines.push('');
}
if (operation.requestBody) {
const schema = contentSchema(operation.requestBody.content);
lines.push('## Body (JSON)');
lines.push('');
lines.push(formatSchema(schema, spec.components));
lines.push('');
}
const responses = operation.responses || {};
const successCodes = Object.keys(responses)
.filter((c) => c.startsWith('2'))
.sort();
const errorCodes = Object.keys(responses)
.filter((c) => !c.startsWith('2'))
.sort();
for (const code of successCodes) {
const resp = responses[code];
lines.push(`## Response \`${code}\``);
lines.push('');
if (resp.description) {
lines.push(resp.description);
lines.push('');
}
const schema = contentSchema(resp.content);
if (schema) {
lines.push(formatSchema(schema, spec.components));
lines.push('');
} else if (code === '204') {
lines.push('_No content_');
lines.push('');
}
}
if (errorCodes.length) {
lines.push('## Errors');
lines.push('');
lines.push('| Status | Description |');
lines.push('|--------|-------------|');
for (const code of errorCodes) {
const resp = responses[code];
const schema = contentSchema(resp.content);
let detail = resp.description || '';
if (schema?.example) {
detail += (detail ? ' — ' : '') + `\`${JSON.stringify(schema.example)}\``;
} else if (schemaRefName(schema) === 'Error') {
detail += (detail ? ' — ' : '') + '`' + '{"error":"…"}' + '`';
}
lines.push(`| ${code} | ${detail} |`);
}
lines.push('');
}
lines.push('---');
lines.push('');
lines.push('_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._');
lines.push('');
return lines.join('\n');
}
function renderReadme(operations) {
const lines = [];
lines.push('# Dash API');
lines.push('');
lines.push('```');
lines.push('http://localhost:4208/api');
lines.push('```');
lines.push('');
lines.push('## CORS & cookies');
lines.push('');
lines.push('All browser requests that need auth must send cookies:');
lines.push('');
lines.push('```js');
lines.push("fetch('http://localhost:4208/api/auth/me', {");
lines.push(" credentials: 'include',");
lines.push('});');
lines.push('```');
lines.push('');
lines.push(
'Sessions use an HTTP-only `token` cookie (`Secure`, `SameSite=Strict`, 24h). The browser must talk to the API over **HTTPS** for login to work — `Secure` cookies are not sent on plain HTTP.',
);
lines.push('');
lines.push('## Auth flow');
lines.push('');
lines.push('1. Call [`GET /auth/me`](routes/auth/me.md) to get `googleClientId` (included in 200 and 401 responses).');
lines.push('2. Sign in with Google on the frontend (Google Identity Services / One Tap).');
lines.push('3. POST the Google ID token to [`POST /auth/login`](routes/auth/login.md).');
lines.push('4. The API sets the session cookie.');
lines.push("5. Call protected routes with `credentials: 'include'`. The cookie is refreshed automatically when it has less than 2 hours left.");
lines.push('');
lines.push('## Routes');
lines.push('');
lines.push('Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/routes/). Source of truth is the code; regenerate with `npm run docs:api`.');
lines.push('');
lines.push('| File | Endpoint |');
lines.push('|------|----------|');
for (const op of operations) {
lines.push(`| [\`routes/${op.file}\`](routes/${op.file}) | \`${op.method.toUpperCase()} ${op.path}\` |`);
}
lines.push('');
lines.push('Machine-readable spec: [`openapi.json`](openapi.json).');
lines.push('');
lines.push('## DESIGN');
lines.push('');
lines.push('- for every route we have a file in /src/api/routes');
lines.push('- that should loosly bound to services in /src/services');
lines.push('- for every service we have a CLI tester in /scripts/');
lines.push('- all DB SQL code shall be in /src/db');
lines.push('- with mssql staements in /src/db/mssql/');
lines.push('- API docs: `@openapi` JSDoc on handlers → `npm run docs:api` → `docs/api/`');
lines.push('');
return lines.join('\n');
}
// --- generate ---
ensureDir(docsDir);
rmDirContents(routesDocsDir);
ensureDir(routesDocsDir);
fs.writeFileSync(path.join(docsDir, 'openapi.json'), JSON.stringify(spec, null, 2) + '\n');
const operations = [];
const paths = Object.keys(spec.paths || {}).sort();
for (const apiPath of paths) {
const item = spec.paths[apiPath];
for (const method of Object.keys(item).sort()) {
if (method.startsWith('x-')) continue;
const operation = item[method];
const relFile = docsFileFor(apiPath, method);
const absFile = path.join(routesDocsDir, relFile);
ensureDir(path.dirname(absFile));
fs.writeFileSync(absFile, renderOperation(apiPath, method, operation));
operations.push({ path: apiPath, method, file: relFile.replace(/\\/g, '/') });
}
}
fs.writeFileSync(path.join(docsDir, 'README.md'), renderReadme(operations));
console.log(`Wrote ${operations.length} route docs + openapi.json → docs/api/`);

77
src/api/openapi.js Normal file
View File

@@ -0,0 +1,77 @@
/**
* Shared OpenAPI components scanned by `scripts/generateApiDocs.js`.
* Route-specific paths live as `@openapi` blocks next to each handler.
*
* @openapi
* components:
* securitySchemes:
* cookieAuth:
* type: apiKey
* in: cookie
* name: token
* description: HTTP-only session cookie set by POST /auth/login
* schemas:
* Error:
* type: object
* required: [error]
* properties:
* error:
* type: string
* User:
* type: object
* required: [id, email, selectedCompanyId]
* properties:
* id:
* type: integer
* email:
* type: string
* format: email
* selectedCompanyId:
* type: integer
* nullable: true
* picture:
* type: string
* format: uri
* Company:
* type: object
* properties:
* id:
* type: integer
* iso639:
* type: string
* name:
* type: string
* taxrate:
* type: number
* reducedtaxrate:
* type: number
* Category:
* type: object
* properties:
* id:
* type: integer
* parentId:
* type: integer
* nullable: true
* sort:
* type: integer
* name:
* type: string
* ArticleSummary:
* type: object
* properties:
* id:
* type: integer
* parentId:
* type: integer
* name:
* type: string
* sku:
* type: string
* ArticleDetail:
* type: object
* description: Full article payload (unit, brand, categoryIds, photos, eans, prices, …)
* additionalProperties: true
*/
export {};

View File

@@ -23,11 +23,65 @@ function articleErrorStatus(error) {
*/ */
export function register(app, basePath = '') { export function register(app, basePath = '') {
/** /**
* @route POST /article * @openapi
* @description Creates an article under an authorized category (`parentId`). * /article:
* @body {number} parentId - Category id * post:
* @body {string} name * tags: [Article]
* @returns {Object} 201 - `{ article: { id, parentId, name, sku } }` * summary: Create article
* description: Creates an article under an authorized category (`parentId`).
* security:
* - cookieAuth: []
* x-markdown: article/create.md
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [parentId, name]
* properties:
* parentId:
* type: integer
* description: Category id
* name:
* type: string
* example:
* parentId: 10
* name: New article
* responses:
* 201:
* description: Created
* content:
* application/json:
* schema:
* type: object
* properties:
* article:
* $ref: '#/components/schemas/ArticleSummary'
* 400:
* description: Validation error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized for parent category
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Parent not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/ */
app.post(`${basePath}/article`, async (req, res) => { app.post(`${basePath}/article`, async (req, res) => {
const parent = parsePositiveInt(req.body?.parentId, 'parentId'); const parent = parsePositiveInt(req.body?.parentId, 'parentId');
@@ -55,9 +109,116 @@ export function register(app, basePath = '') {
}); });
/** /**
* @route GET /article/:sku * @openapi
* @description Returns article detail by SKU if the user may access its category. * /article/{sku}:
* @returns {Object} 200 - `{ article }` detail shape (`unit`, `brand`, `categoryIds`, `photos`, `eans`, `prices`, …) * get:
* tags: [Article]
* summary: Get article by SKU
* description: Returns article detail by SKU if the user may access its category.
* security:
* - cookieAuth: []
* x-markdown: article/get.md
* parameters:
* - in: path
* name: sku
* required: true
* schema:
* type: string
* description: Article SKU
* responses:
* 200:
* description: Article detail
* content:
* application/json:
* schema:
* type: object
* properties:
* article:
* $ref: '#/components/schemas/ArticleDetail'
* 400:
* description: Missing sku
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Article not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* patch:
* tags: [Article]
* summary: Update article by SKU
* description: Updates an article name by SKU; returns the same detail shape as GET.
* security:
* - cookieAuth: []
* x-markdown: article/update.md
* parameters:
* - in: path
* name: sku
* required: true
* schema:
* type: string
* description: Article SKU
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [name]
* properties:
* name:
* type: string
* example:
* name: Updated name
* responses:
* 200:
* description: Updated article detail
* content:
* application/json:
* schema:
* type: object
* properties:
* article:
* $ref: '#/components/schemas/ArticleDetail'
* 400:
* description: Validation error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Article not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/ */
app.get(`${basePath}/article/:sku`, async (req, res) => { app.get(`${basePath}/article/:sku`, async (req, res) => {
const sku = typeof req.params.sku === 'string' ? req.params.sku.trim() : ''; const sku = typeof req.params.sku === 'string' ? req.params.sku.trim() : '';
@@ -75,12 +236,6 @@ export function register(app, basePath = '') {
} }
}); });
/**
* @route PATCH /article/:sku
* @description Updates an article name by SKU; returns the same detail shape as GET.
* @body {string} name
* @returns {Object} 200 - `{ article }` detail shape
*/
app.patch(`${basePath}/article/:sku`, async (req, res) => { app.patch(`${basePath}/article/:sku`, async (req, res) => {
const sku = typeof req.params.sku === 'string' ? req.params.sku.trim() : ''; const sku = typeof req.params.sku === 'string' ? req.params.sku.trim() : '';
if (!sku) { if (!sku) {

View File

@@ -12,14 +12,64 @@ const googleClient = new OAuth2Client(env.googleClientId);
*/ */
export function register(app, basePath = '') { export function register(app, basePath = '') {
/** /**
* @route POST /auth/login * @openapi
* @description Verifies a Google ID token, checks allowlist (or creates entry when `IMPLICIT_CREATE=true`), and sets an HTTP-only `token` cookie. * /auth/login:
* @body {string} idToken - Google ID token from Google Identity Services * post:
* @returns {Object} 200 - `{ message, user }` with `{ id, email, selectedCompanyId, picture? }` in `user` * tags: [Auth]
* @returns {Object} 400 - Missing token or Google client not configured * summary: Login with Google ID token
* @returns {Object} 401 - Invalid Google token * description: Verifies a Google ID token, checks allowlist (or creates entry when `IMPLICIT_CREATE=true`), and sets an HTTP-only `token` cookie.
* @returns {Object} 403 - Email not on allowlist * security: []
* @returns {Object} 500 - Server error * x-markdown: auth/login.md
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [idToken]
* properties:
* idToken:
* type: string
* description: Google ID token from Google Identity Services
* example:
* idToken: "<google-id-token>"
* responses:
* 200:
* description: Login successful; `token` cookie set
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Login successful
* user:
* $ref: '#/components/schemas/User'
* 400:
* description: Missing token or Google client not configured
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Invalid Google token or account
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Email not on allowlist
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 500:
* description: Server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/ */
app.post(`${basePath}/auth/login`, async (req, res) => { app.post(`${basePath}/auth/login`, async (req, res) => {
try { try {

View File

@@ -9,9 +9,25 @@ import { clearCachedUser, clearTokenCookie } from '../../middleware/auth.js';
*/ */
export function register(app, basePath = '') { export function register(app, basePath = '') {
/** /**
* @route POST /auth/logout * @openapi
* @description Clears the HTTP-only `token` cookie set at login. * /auth/logout:
* @returns {Object} 200 - `{ message }` confirming logout * post:
* tags: [Auth]
* summary: Logout
* description: Clears the HTTP-only `token` cookie set at login.
* security: []
* x-markdown: auth/logout.md
* responses:
* 200:
* description: Logged out
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Logged out successfully
*/ */
app.post(`${basePath}/auth/logout`, (req, res) => { app.post(`${basePath}/auth/logout`, (req, res) => {
const token = req.cookies.token; const token = req.cookies.token;

View File

@@ -7,9 +7,38 @@ import { meJson } from '../../auth/meJson.js';
*/ */
export function register(app, basePath = '') { export function register(app, basePath = '') {
/** /**
* @route GET /auth/me * @openapi
* @description Returns the authenticated user from `req.user` and the Google client ID. * /auth/me:
* @returns {Object} 200 - `{ user: { id, email, selectedCompanyId, picture? }, googleClientId }` * get:
* tags: [Auth]
* summary: Current user
* description: Returns the authenticated user from the session cookie and the Google client ID. Unauthenticated calls still receive `googleClientId` with 401.
* security:
* - cookieAuth: []
* x-markdown: auth/me.md
* responses:
* 200:
* description: Authenticated
* content:
* application/json:
* schema:
* type: object
* properties:
* user:
* $ref: '#/components/schemas/User'
* googleClientId:
* type: string
* 401:
* description: Unauthorized — still includes `googleClientId` for the frontend
* content:
* application/json:
* schema:
* type: object
* properties:
* error:
* type: string
* googleClientId:
* type: string
*/ */
app.get(`${basePath}/auth/me`, (req, res) => { app.get(`${basePath}/auth/me`, (req, res) => {
const { id, email, selectedCompanyId, picture } = req.user; const { id, email, selectedCompanyId, picture } = req.user;

View File

@@ -8,10 +8,56 @@ import { clearCachedUser } from '../../middleware/auth.js';
*/ */
export function register(app, basePath = '') { export function register(app, basePath = '') {
/** /**
* @route PUT /auth/selected-company * @openapi
* @description Sets the authenticated user's selected company. * /auth/selected-company:
* @body {number} companyId * put:
* @returns {Object} 200 - `{ user: { id, email, selectedCompanyId, picture? } }` * tags: [Auth]
* summary: Set selected company
* description: Sets the authenticated user's selected company.
* security:
* - cookieAuth: []
* x-markdown: auth/selected-company.md
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [companyId]
* properties:
* companyId:
* type: integer
* minimum: 1
* example:
* companyId: 1
* responses:
* 200:
* description: Updated user
* content:
* application/json:
* schema:
* type: object
* properties:
* user:
* $ref: '#/components/schemas/User'
* 400:
* description: Invalid companyId
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 500:
* description: Server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/ */
app.put(`${basePath}/auth/selected-company`, async (req, res) => { app.put(`${basePath}/auth/selected-company`, async (req, res) => {
const companyId = Number(req.body?.companyId); const companyId = Number(req.body?.companyId);

View File

@@ -23,9 +23,59 @@ function categoryErrorStatus(error) {
*/ */
export function register(app, basePath = '') { export function register(app, basePath = '') {
/** /**
* @route GET /category/tree * @openapi
* @description Returns the category tree for the authenticated user's selected company. * /category/tree:
* @returns {Object} 200 - `{ languageId, tree, articles }` * get:
* tags: [Category]
* summary: Category tree
* description: Returns the category tree for the authenticated user's selected company.
* security:
* - cookieAuth: []
* x-markdown: category/tree.md
* responses:
* 200:
* description: Tree and articles for the selected company
* content:
* application/json:
* schema:
* type: object
* properties:
* languageId:
* type: integer
* tree:
* type: array
* items:
* type: object
* additionalProperties: true
* articles:
* type: array
* items:
* type: object
* additionalProperties: true
* 400:
* description: No company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Company not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/ */
app.get(`${basePath}/category/tree`, async (req, res) => { app.get(`${basePath}/category/tree`, async (req, res) => {
const companyId = req.user.selectedCompanyId; const companyId = req.user.selectedCompanyId;
@@ -44,11 +94,64 @@ export function register(app, basePath = '') {
}); });
/** /**
* @route POST /category * @openapi
* @description Creates a category under an authorized parent. * /category:
* @body {number} parentId * post:
* @body {string} name * tags: [Category]
* @returns {Object} 201 - `{ category: { id, parentId, sort, name } }` * summary: Create category
* description: Creates a category under an authorized parent.
* security:
* - cookieAuth: []
* x-markdown: category/create.md
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [parentId, name]
* properties:
* parentId:
* type: integer
* name:
* type: string
* example:
* parentId: 1
* name: New category
* responses:
* 201:
* description: Created
* content:
* application/json:
* schema:
* type: object
* properties:
* category:
* $ref: '#/components/schemas/Category'
* 400:
* description: Validation error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Parent not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/ */
app.post(`${basePath}/category`, async (req, res) => { app.post(`${basePath}/category`, async (req, res) => {
const parent = parsePositiveInt(req.body?.parentId, 'parentId'); const parent = parsePositiveInt(req.body?.parentId, 'parentId');
@@ -76,9 +179,53 @@ export function register(app, basePath = '') {
}); });
/** /**
* @route GET /category/:id/articles * @openapi
* @description Returns articles in a category the user is authorized for. * /category/{id}/articles:
* @returns {Object} 200 - `{ articles }` * get:
* tags: [Category]
* summary: Articles in category
* description: Returns articles in a category the user is authorized for.
* security:
* - cookieAuth: []
* x-markdown: category/articles.md
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: Category id
* responses:
* 200:
* description: Articles list
* content:
* application/json:
* schema:
* type: object
* properties:
* articles:
* type: array
* items:
* type: object
* additionalProperties: true
* 400:
* description: Invalid id
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/ */
app.get(`${basePath}/category/:id/articles`, async (req, res) => { app.get(`${basePath}/category/:id/articles`, async (req, res) => {
const parsed = parsePositiveInt(req.params.id, 'id'); const parsed = parsePositiveInt(req.params.id, 'id');
@@ -97,9 +244,43 @@ export function register(app, basePath = '') {
}); });
/** /**
* @route DELETE /category/:id * @openapi
* @description Deletes a category the user is authorized for (not the company root). * /category/{id}:
* @returns {Object} 204 - No content * delete:
* tags: [Category]
* summary: Delete category
* description: Deletes a category the user is authorized for (not the company root).
* security:
* - cookieAuth: []
* x-markdown: category/delete.md
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: Category id
* responses:
* 204:
* description: Deleted
* 400:
* description: Invalid id or cannot delete root
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/ */
app.delete(`${basePath}/category/:id`, async (req, res) => { app.delete(`${basePath}/category/:id`, async (req, res) => {
const parsed = parsePositiveInt(req.params.id, 'id'); const parsed = parsePositiveInt(req.params.id, 'id');

View File

@@ -40,9 +40,97 @@ function companyErrorStatus(error) {
*/ */
export function register(app, basePath = '') { export function register(app, basePath = '') {
/** /**
* @route GET /company * @openapi
* @description Lists companies for the authenticated user. * /company:
* @returns {Object} 200 - `{ companies }` * get:
* tags: [Company]
* summary: List companies
* description: Lists companies for the authenticated user.
* security:
* - cookieAuth: []
* x-markdown: company/list.md
* responses:
* 200:
* description: Company list
* content:
* application/json:
* schema:
* type: object
* properties:
* companies:
* type: array
* items:
* $ref: '#/components/schemas/Company'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 500:
* description: Server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* post:
* tags: [Company]
* summary: Create company
* description: Creates a company and selects it for the current user.
* security:
* - cookieAuth: []
* x-markdown: company/create.md
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [iso639, name, taxrate, reducedtaxrate]
* properties:
* iso639:
* type: string
* name:
* type: string
* taxrate:
* type: number
* reducedtaxrate:
* type: number
* example:
* iso639: de
* name: Acme GmbH
* taxrate: 19
* reducedtaxrate: 7
* responses:
* 201:
* description: Created; selected company updated
* content:
* application/json:
* schema:
* type: object
* properties:
* company:
* $ref: '#/components/schemas/Company'
* user:
* $ref: '#/components/schemas/User'
* 400:
* description: Validation error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 409:
* description: Company name not available
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/ */
app.get(`${basePath}/company`, async (req, res) => { app.get(`${basePath}/company`, async (req, res) => {
try { try {
@@ -54,15 +142,6 @@ export function register(app, basePath = '') {
} }
}); });
/**
* @route POST /company
* @description Creates a company.
* @body {string} iso639
* @body {string} name
* @body {number} taxrate
* @body {number} reducedtaxrate
* @returns {Object} 201 - `{ company }`
*/
app.post(`${basePath}/company`, async (req, res) => { app.post(`${basePath}/company`, async (req, res) => {
const body = parseCompanyBody(req.body); const body = parseCompanyBody(req.body);
if (body.error) { if (body.error) {
@@ -83,9 +162,105 @@ export function register(app, basePath = '') {
}); });
/** /**
* @route PUT /company/:id * @openapi
* @description Updates a company. * /company/{id}:
* @returns {Object} 200 - `{ company }` * put:
* tags: [Company]
* summary: Update company
* description: Updates a company.
* security:
* - cookieAuth: []
* x-markdown: company/update.md
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [iso639, name, taxrate, reducedtaxrate]
* properties:
* iso639:
* type: string
* name:
* type: string
* taxrate:
* type: number
* reducedtaxrate:
* type: number
* responses:
* 200:
* description: Updated
* content:
* application/json:
* schema:
* type: object
* properties:
* company:
* $ref: '#/components/schemas/Company'
* 400:
* description: Validation error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Company not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 409:
* description: Company name not available
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* delete:
* tags: [Company]
* summary: Delete company
* description: Deletes a company.
* security:
* - cookieAuth: []
* x-markdown: company/delete.md
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 204:
* description: Deleted
* 400:
* description: Invalid id
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 500:
* description: Server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/ */
app.put(`${basePath}/company/:id`, async (req, res) => { app.put(`${basePath}/company/:id`, async (req, res) => {
const parsedId = parseCompanyId(req.params.id); const parsedId = parseCompanyId(req.params.id);
@@ -108,11 +283,6 @@ export function register(app, basePath = '') {
} }
}); });
/**
* @route DELETE /company/:id
* @description Deletes a company.
* @returns {Object} 204 - No content
*/
app.delete(`${basePath}/company/:id`, async (req, res) => { app.delete(`${basePath}/company/:id`, async (req, res) => {
const parsedId = parseCompanyId(req.params.id); const parsedId = parseCompanyId(req.params.id);
if (parsedId.error) { if (parsedId.error) {

View File

@@ -7,10 +7,37 @@ import { ensureConnection } from '../../db/store.js';
*/ */
export function register(app, basePath = '') { export function register(app, basePath = '') {
/** /**
* @route GET /health * @openapi
* @description Verifies the database client can establish a connection. * /health:
* @returns {Object} 200 - `{ ok: true }` when the database is reachable * get:
* @returns {Object} 503 - `{ ok: false, error }` when the check fails * tags: [Health]
* summary: Database health check
* description: Verifies the database client can establish a connection.
* security: []
* x-markdown: health.md
* responses:
* 200:
* description: Database reachable
* content:
* application/json:
* schema:
* type: object
* properties:
* ok:
* type: boolean
* example: true
* 503:
* description: Database unreachable
* content:
* application/json:
* schema:
* type: object
* properties:
* ok:
* type: boolean
* example: false
* error:
* type: string
*/ */
app.get(`${basePath}/health`, async (_request, response) => { app.get(`${basePath}/health`, async (_request, response) => {
try { try {