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.
|
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:
|
||||||
|
|||||||
@@ -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
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'`).
|
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();
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -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.
|
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`._
|
||||||
|
|||||||
@@ -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`._
|
||||||
|
|||||||
@@ -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`._
|
||||||
|
|||||||
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.
|
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`._
|
||||||
|
|||||||
@@ -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",
|
"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
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 = '') {
|
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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user