u
This commit is contained in:
@@ -23,6 +23,14 @@ Default port: `4208`.
|
||||
|
||||
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
|
||||
|
||||
Manage the email allowlist:
|
||||
|
||||
@@ -26,18 +26,28 @@ Sessions use an HTTP-only `token` cookie (`Secure`, `SameSite=Strict`, 24h). The
|
||||
|
||||
## 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 |
|
||||
|------|----------|
|
||||
| [`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/logout.md`](routes/auth/logout.md) | `POST /auth/logout` |
|
||||
| [`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/article/search.md`](routes/article/search.md) | `GET /article/search` |
|
||||
| [`routes/article/get.md`](routes/article/get.md) | `GET /article/get` |
|
||||
| [`routes/misc/getSteuer.md`](routes/misc/getSteuer.md) | `GET /misc/getSteuer` |
|
||||
| [`routes/misc/getCategories.md`](routes/misc/getCategories.md) | `GET /misc/getCategories` |
|
||||
|
||||
Machine-readable spec: [`openapi.json`](openapi.json).
|
||||
|
||||
## DESIGN
|
||||
|
||||
@@ -45,4 +55,5 @@ Mirrors [`src/api/routes/`](../../src/api/routes/).
|
||||
- that should loosly bound to services in /src/services
|
||||
- for every service we have a CLI tester in /scripts/
|
||||
- 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
1384
docs/api/openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
39
docs/api/routes/article/create.md
Normal file
39
docs/api/routes/article/create.md
Normal 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`._
|
||||
@@ -1,121 +1,36 @@
|
||||
# `GET /article/get`
|
||||
# `GET /article/{sku}`
|
||||
|
||||
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 |
|
||||
|-------|----------|-------------|
|
||||
| `target` | yes | Exact article number (`cArtNr`), packaging EAN (`tGebinde.cEAN`), or barcode (`cBarcode`). |
|
||||
## Path parameters
|
||||
|
||||
| Param | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `sku` | yes | string | Article SKU |
|
||||
|
||||
## Response `200`
|
||||
|
||||
Article detail
|
||||
|
||||
```json
|
||||
{
|
||||
"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": []
|
||||
}
|
||||
"article": { … }
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
| Status | Body |
|
||||
|--------|------|
|
||||
| 400 | `{ "error": "Query parameter \"target\" is required" }` |
|
||||
| 401 | `{ "error": "Unauthorized" }` |
|
||||
| 404 | `{ "error": "Article not found" }` |
|
||||
| 500 | `{ "error": "..." }` |
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Missing sku — `{"error":"…"}` |
|
||||
| 401 | Unauthorized — `{"error":"…"}` |
|
||||
| 403 | Not authorized — `{"error":"…"}` |
|
||||
| 404 | Article not found — `{"error":"…"}` |
|
||||
|
||||
## Example
|
||||
---
|
||||
|
||||
```js
|
||||
// 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();
|
||||
```
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
|
||||
@@ -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();
|
||||
```
|
||||
44
docs/api/routes/article/update.md
Normal file
44
docs/api/routes/article/update.md
Normal 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`._
|
||||
@@ -2,25 +2,38 @@
|
||||
|
||||
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
|
||||
{ "idToken": "<google-id-token>" }
|
||||
{
|
||||
"idToken": "<google-id-token>"
|
||||
}
|
||||
```
|
||||
|
||||
**200**
|
||||
## Response `200`
|
||||
|
||||
Login successful; `token` cookie set
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Login successful",
|
||||
"user": { "id": 1, "email": "you@example.com", "picture": "https://lh3.googleusercontent.com/..." }
|
||||
"user": { … }
|
||||
}
|
||||
```
|
||||
|
||||
**Errors**
|
||||
## Errors
|
||||
|
||||
| Status | Body |
|
||||
|--------|------|
|
||||
| 400 | `{ "error": "idToken is required" }` or `{ "error": "GOOGLE_CLIENT_ID is not configured" }` |
|
||||
| 401 | `{ "error": "Invalid Google token" }` or `{ "error": "Invalid Google account" }` |
|
||||
| 403 | `{ "error": "Email is not authorized" }` |
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 400 | Missing token or Google client not configured — `{"error":"…"}` |
|
||||
| 401 | Invalid Google token or account — `{"error":"…"}` |
|
||||
| 403 | Email not on allowlist — `{"error":"…"}` |
|
||||
| 500 | Server error — `{"error":"…"}` |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
# `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
|
||||
{ "message": "Logged out successfully" }
|
||||
{
|
||||
"message": "Logged out successfully"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
# `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
|
||||
{
|
||||
"user": { "id": 1, "email": "you@example.com", "picture": "https://lh3.googleusercontent.com/..." },
|
||||
"googleClientId": "your-client-id.apps.googleusercontent.com"
|
||||
"user": { … },
|
||||
"googleClientId": "…"
|
||||
}
|
||||
```
|
||||
|
||||
**Errors**
|
||||
## Errors
|
||||
|
||||
| Status | Body |
|
||||
|--------|------|
|
||||
| 401 | `{ "error": "Unauthorized", "googleClientId": "..." }` or `{ "error": "Invalid token", "googleClientId": "..." }` |
|
||||
| 403 | `{ "error": "User is no longer authorized", "googleClientId": "..." }` |
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 401 | Unauthorized — still includes `googleClientId` for the frontend |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
|
||||
37
docs/api/routes/auth/selected-company.md
Normal file
37
docs/api/routes/auth/selected-company.md
Normal 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`._
|
||||
35
docs/api/routes/category/articles.md
Normal file
35
docs/api/routes/category/articles.md
Normal 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`._
|
||||
39
docs/api/routes/category/create.md
Normal file
39
docs/api/routes/category/create.md
Normal 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`._
|
||||
31
docs/api/routes/category/delete.md
Normal file
31
docs/api/routes/category/delete.md
Normal 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`._
|
||||
32
docs/api/routes/category/tree.md
Normal file
32
docs/api/routes/category/tree.md
Normal 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`._
|
||||
41
docs/api/routes/company/create.md
Normal file
41
docs/api/routes/company/create.md
Normal 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`._
|
||||
31
docs/api/routes/company/delete.md
Normal file
31
docs/api/routes/company/delete.md
Normal 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`._
|
||||
28
docs/api/routes/company/list.md
Normal file
28
docs/api/routes/company/list.md
Normal 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`._
|
||||
47
docs/api/routes/company/update.md
Normal file
47
docs/api/routes/company/update.md
Normal 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`._
|
||||
@@ -2,14 +2,26 @@
|
||||
|
||||
Public. No cookie required.
|
||||
|
||||
**200**
|
||||
Database health check
|
||||
|
||||
Verifies the database client can establish a connection.
|
||||
|
||||
## Response `200`
|
||||
|
||||
Database reachable
|
||||
|
||||
```json
|
||||
{ "ok": true }
|
||||
{
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
**Errors**
|
||||
## Errors
|
||||
|
||||
| Status | Body |
|
||||
|--------|------|
|
||||
| 503 | `{ "ok": false, "error": "..." }` |
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 503 | Database unreachable |
|
||||
|
||||
---
|
||||
|
||||
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||
|
||||
@@ -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])
|
||||
);
|
||||
```
|
||||
@@ -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();
|
||||
```
|
||||
@@ -10,6 +10,7 @@
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch-path=./src --watch-path=./.env src/index.js",
|
||||
"manage-users": "node scripts/manageUsers.js",
|
||||
"docs:api": "node scripts/generateApiDocs.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
313
scripts/generateApiDocs.js
Normal file
313
scripts/generateApiDocs.js
Normal 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
77
src/api/openapi.js
Normal 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 {};
|
||||
@@ -23,11 +23,65 @@ function articleErrorStatus(error) {
|
||||
*/
|
||||
export function register(app, basePath = '') {
|
||||
/**
|
||||
* @route POST /article
|
||||
* @description Creates an article under an authorized category (`parentId`).
|
||||
* @body {number} parentId - Category id
|
||||
* @body {string} name
|
||||
* @returns {Object} 201 - `{ article: { id, parentId, name, sku } }`
|
||||
* @openapi
|
||||
* /article:
|
||||
* post:
|
||||
* tags: [Article]
|
||||
* 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) => {
|
||||
const parent = parsePositiveInt(req.body?.parentId, 'parentId');
|
||||
@@ -55,9 +109,116 @@ export function register(app, basePath = '') {
|
||||
});
|
||||
|
||||
/**
|
||||
* @route GET /article/:sku
|
||||
* @description Returns article detail by SKU if the user may access its category.
|
||||
* @returns {Object} 200 - `{ article }` detail shape (`unit`, `brand`, `categoryIds`, `photos`, `eans`, `prices`, …)
|
||||
* @openapi
|
||||
* /article/{sku}:
|
||||
* 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) => {
|
||||
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) => {
|
||||
const sku = typeof req.params.sku === 'string' ? req.params.sku.trim() : '';
|
||||
if (!sku) {
|
||||
|
||||
@@ -12,14 +12,64 @@ const googleClient = new OAuth2Client(env.googleClientId);
|
||||
*/
|
||||
export function register(app, basePath = '') {
|
||||
/**
|
||||
* @route POST /auth/login
|
||||
* @description Verifies a Google ID token, checks allowlist (or creates entry when `IMPLICIT_CREATE=true`), and sets an HTTP-only `token` cookie.
|
||||
* @body {string} idToken - Google ID token from Google Identity Services
|
||||
* @returns {Object} 200 - `{ message, user }` with `{ id, email, selectedCompanyId, picture? }` in `user`
|
||||
* @returns {Object} 400 - Missing token or Google client not configured
|
||||
* @returns {Object} 401 - Invalid Google token
|
||||
* @returns {Object} 403 - Email not on allowlist
|
||||
* @returns {Object} 500 - Server error
|
||||
* @openapi
|
||||
* /auth/login:
|
||||
* post:
|
||||
* tags: [Auth]
|
||||
* summary: Login with Google ID token
|
||||
* description: Verifies a Google ID token, checks allowlist (or creates entry when `IMPLICIT_CREATE=true`), and sets an HTTP-only `token` cookie.
|
||||
* security: []
|
||||
* 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) => {
|
||||
try {
|
||||
|
||||
@@ -9,9 +9,25 @@ import { clearCachedUser, clearTokenCookie } from '../../middleware/auth.js';
|
||||
*/
|
||||
export function register(app, basePath = '') {
|
||||
/**
|
||||
* @route POST /auth/logout
|
||||
* @description Clears the HTTP-only `token` cookie set at login.
|
||||
* @returns {Object} 200 - `{ message }` confirming logout
|
||||
* @openapi
|
||||
* /auth/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) => {
|
||||
const token = req.cookies.token;
|
||||
|
||||
@@ -7,9 +7,38 @@ import { meJson } from '../../auth/meJson.js';
|
||||
*/
|
||||
export function register(app, basePath = '') {
|
||||
/**
|
||||
* @route GET /auth/me
|
||||
* @description Returns the authenticated user from `req.user` and the Google client ID.
|
||||
* @returns {Object} 200 - `{ user: { id, email, selectedCompanyId, picture? }, googleClientId }`
|
||||
* @openapi
|
||||
* /auth/me:
|
||||
* 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) => {
|
||||
const { id, email, selectedCompanyId, picture } = req.user;
|
||||
|
||||
@@ -8,10 +8,56 @@ import { clearCachedUser } from '../../middleware/auth.js';
|
||||
*/
|
||||
export function register(app, basePath = '') {
|
||||
/**
|
||||
* @route PUT /auth/selected-company
|
||||
* @description Sets the authenticated user's selected company.
|
||||
* @body {number} companyId
|
||||
* @returns {Object} 200 - `{ user: { id, email, selectedCompanyId, picture? } }`
|
||||
* @openapi
|
||||
* /auth/selected-company:
|
||||
* put:
|
||||
* 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) => {
|
||||
const companyId = Number(req.body?.companyId);
|
||||
|
||||
@@ -23,9 +23,59 @@ function categoryErrorStatus(error) {
|
||||
*/
|
||||
export function register(app, basePath = '') {
|
||||
/**
|
||||
* @route GET /category/tree
|
||||
* @description Returns the category tree for the authenticated user's selected company.
|
||||
* @returns {Object} 200 - `{ languageId, tree, articles }`
|
||||
* @openapi
|
||||
* /category/tree:
|
||||
* 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) => {
|
||||
const companyId = req.user.selectedCompanyId;
|
||||
@@ -44,11 +94,64 @@ export function register(app, basePath = '') {
|
||||
});
|
||||
|
||||
/**
|
||||
* @route POST /category
|
||||
* @description Creates a category under an authorized parent.
|
||||
* @body {number} parentId
|
||||
* @body {string} name
|
||||
* @returns {Object} 201 - `{ category: { id, parentId, sort, name } }`
|
||||
* @openapi
|
||||
* /category:
|
||||
* post:
|
||||
* tags: [Category]
|
||||
* 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) => {
|
||||
const parent = parsePositiveInt(req.body?.parentId, 'parentId');
|
||||
@@ -76,9 +179,53 @@ export function register(app, basePath = '') {
|
||||
});
|
||||
|
||||
/**
|
||||
* @route GET /category/:id/articles
|
||||
* @description Returns articles in a category the user is authorized for.
|
||||
* @returns {Object} 200 - `{ articles }`
|
||||
* @openapi
|
||||
* /category/{id}/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) => {
|
||||
const parsed = parsePositiveInt(req.params.id, 'id');
|
||||
@@ -97,9 +244,43 @@ export function register(app, basePath = '') {
|
||||
});
|
||||
|
||||
/**
|
||||
* @route DELETE /category/:id
|
||||
* @description Deletes a category the user is authorized for (not the company root).
|
||||
* @returns {Object} 204 - No content
|
||||
* @openapi
|
||||
* /category/{id}:
|
||||
* 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) => {
|
||||
const parsed = parsePositiveInt(req.params.id, 'id');
|
||||
|
||||
@@ -40,9 +40,97 @@ function companyErrorStatus(error) {
|
||||
*/
|
||||
export function register(app, basePath = '') {
|
||||
/**
|
||||
* @route GET /company
|
||||
* @description Lists companies for the authenticated user.
|
||||
* @returns {Object} 200 - `{ companies }`
|
||||
* @openapi
|
||||
* /company:
|
||||
* 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) => {
|
||||
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) => {
|
||||
const body = parseCompanyBody(req.body);
|
||||
if (body.error) {
|
||||
@@ -83,9 +162,105 @@ export function register(app, basePath = '') {
|
||||
});
|
||||
|
||||
/**
|
||||
* @route PUT /company/:id
|
||||
* @description Updates a company.
|
||||
* @returns {Object} 200 - `{ company }`
|
||||
* @openapi
|
||||
* /company/{id}:
|
||||
* 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) => {
|
||||
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) => {
|
||||
const parsedId = parseCompanyId(req.params.id);
|
||||
if (parsedId.error) {
|
||||
|
||||
@@ -7,10 +7,37 @@ import { ensureConnection } from '../../db/store.js';
|
||||
*/
|
||||
export function register(app, basePath = '') {
|
||||
/**
|
||||
* @route GET /health
|
||||
* @description Verifies the database client can establish a connection.
|
||||
* @returns {Object} 200 - `{ ok: true }` when the database is reachable
|
||||
* @returns {Object} 503 - `{ ok: false, error }` when the check fails
|
||||
* @openapi
|
||||
* /health:
|
||||
* get:
|
||||
* 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) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user