From 6074aa73045b67e926704797da2d8733f0c09def Mon Sep 17 00:00:00 2001 From: seb Date: Fri, 31 Jul 2026 18:25:42 +0200 Subject: [PATCH] u --- README.md | 8 + docs/api/README.md | 23 +- docs/api/openapi.json | 1384 ++++++++++++++++++++++ docs/api/routes/article/create.md | 39 + docs/api/routes/article/get.md | 123 +- docs/api/routes/article/search.md | 121 -- docs/api/routes/article/update.md | 44 + docs/api/routes/auth/login.md | 33 +- docs/api/routes/auth/logout.md | 18 +- docs/api/routes/auth/me.md | 27 +- docs/api/routes/auth/selected-company.md | 37 + docs/api/routes/category/articles.md | 35 + docs/api/routes/category/create.md | 39 + docs/api/routes/category/delete.md | 31 + docs/api/routes/category/tree.md | 32 + docs/api/routes/company/create.md | 41 + docs/api/routes/company/delete.md | 31 + docs/api/routes/company/list.md | 28 + docs/api/routes/company/update.md | 47 + docs/api/routes/health.md | 24 +- docs/api/routes/misc/getCategories.md | 78 -- docs/api/routes/misc/getSteuer.md | 63 - package.json | 1 + scripts/generateApiDocs.js | 313 +++++ src/api/openapi.js | 77 ++ src/api/routes/article.js | 183 ++- src/api/routes/auth/login.js | 66 +- src/api/routes/auth/logout.js | 22 +- src/api/routes/auth/me.js | 35 +- src/api/routes/auth/selectedCompany.js | 54 +- src/api/routes/category.js | 209 +++- src/api/routes/company.js | 210 +++- src/api/routes/health.js | 35 +- 33 files changed, 3041 insertions(+), 470 deletions(-) create mode 100644 docs/api/openapi.json create mode 100644 docs/api/routes/article/create.md delete mode 100644 docs/api/routes/article/search.md create mode 100644 docs/api/routes/article/update.md create mode 100644 docs/api/routes/auth/selected-company.md create mode 100644 docs/api/routes/category/articles.md create mode 100644 docs/api/routes/category/create.md create mode 100644 docs/api/routes/category/delete.md create mode 100644 docs/api/routes/category/tree.md create mode 100644 docs/api/routes/company/create.md create mode 100644 docs/api/routes/company/delete.md create mode 100644 docs/api/routes/company/list.md create mode 100644 docs/api/routes/company/update.md delete mode 100644 docs/api/routes/misc/getCategories.md delete mode 100644 docs/api/routes/misc/getSteuer.md create mode 100644 scripts/generateApiDocs.js create mode 100644 src/api/openapi.js diff --git a/README.md b/README.md index 6537cc7..8b107da 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/docs/api/README.md b/docs/api/README.md index 265876f..31445f7 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -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/ \ No newline at end of file +- with mssql staements in /src/db/mssql/ +- API docs: `@openapi` JSDoc on handlers → `npm run docs:api` → `docs/api/` diff --git a/docs/api/openapi.json b/docs/api/openapi.json new file mode 100644 index 0000000..54ddd33 --- /dev/null +++ b/docs/api/openapi.json @@ -0,0 +1,1384 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Dash API", + "version": "1.0.0", + "description": "Backend API for Dash — Google SSO auth, MSSQL, session cookies.\n\nBrowser 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" + } + ], + "paths": { + "/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" + } + } + } + } + } + } + } + } + }, + "/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" + } + } + } + } + } + } + }, + "/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" + } + } + } + } + } + } + }, + "/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" + } + } + } + } + } + } + }, + "/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" + } + } + } + } + } + } + }, + "/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" + } + } + } + } + } + } + }, + "/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" + } + } + } + } + } + } + }, + "/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" + } + } + } + } + } + } + }, + "/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" + } + } + } + } + } + } + }, + "/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" + } + } + } + } + } + } + }, + "/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" + } + } + } + } + } + } + } + } + }, + "/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" + } + } + } + } + } + } + } + } + }, + "/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": "" + } + } + } + } + }, + "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" + } + } + } + } + } + } + } + }, + "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 + } + } + } +} diff --git a/docs/api/routes/article/create.md b/docs/api/routes/article/create.md new file mode 100644 index 0000000..858d802 --- /dev/null +++ b/docs/api/routes/article/create.md @@ -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`._ diff --git a/docs/api/routes/article/get.md b/docs/api/routes/article/get.md index d7895e5..3f4e82d 100644 --- a/docs/api/routes/article/get.md +++ b/docs/api/routes/article/get.md @@ -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`._ diff --git a/docs/api/routes/article/search.md b/docs/api/routes/article/search.md deleted file mode 100644 index 6cc1b79..0000000 --- a/docs/api/routes/article/search.md +++ /dev/null @@ -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(); -``` diff --git a/docs/api/routes/article/update.md b/docs/api/routes/article/update.md new file mode 100644 index 0000000..aa348c8 --- /dev/null +++ b/docs/api/routes/article/update.md @@ -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`._ diff --git a/docs/api/routes/auth/login.md b/docs/api/routes/auth/login.md index 4bff7e9..df5d12d 100644 --- a/docs/api/routes/auth/login.md +++ b/docs/api/routes/auth/login.md @@ -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": "" } +{ + "idToken": "" +} ``` -**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`._ diff --git a/docs/api/routes/auth/logout.md b/docs/api/routes/auth/logout.md index 5ad2558..22ce35f 100644 --- a/docs/api/routes/auth/logout.md +++ b/docs/api/routes/auth/logout.md @@ -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`._ diff --git a/docs/api/routes/auth/me.md b/docs/api/routes/auth/me.md index 3174e9a..a104563 100644 --- a/docs/api/routes/auth/me.md +++ b/docs/api/routes/auth/me.md @@ -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`._ diff --git a/docs/api/routes/auth/selected-company.md b/docs/api/routes/auth/selected-company.md new file mode 100644 index 0000000..a8e17b1 --- /dev/null +++ b/docs/api/routes/auth/selected-company.md @@ -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`._ diff --git a/docs/api/routes/category/articles.md b/docs/api/routes/category/articles.md new file mode 100644 index 0000000..d8ad6b1 --- /dev/null +++ b/docs/api/routes/category/articles.md @@ -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`._ diff --git a/docs/api/routes/category/create.md b/docs/api/routes/category/create.md new file mode 100644 index 0000000..c01aec1 --- /dev/null +++ b/docs/api/routes/category/create.md @@ -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`._ diff --git a/docs/api/routes/category/delete.md b/docs/api/routes/category/delete.md new file mode 100644 index 0000000..91e3a9b --- /dev/null +++ b/docs/api/routes/category/delete.md @@ -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`._ diff --git a/docs/api/routes/category/tree.md b/docs/api/routes/category/tree.md new file mode 100644 index 0000000..b31cfd8 --- /dev/null +++ b/docs/api/routes/category/tree.md @@ -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`._ diff --git a/docs/api/routes/company/create.md b/docs/api/routes/company/create.md new file mode 100644 index 0000000..6c81323 --- /dev/null +++ b/docs/api/routes/company/create.md @@ -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`._ diff --git a/docs/api/routes/company/delete.md b/docs/api/routes/company/delete.md new file mode 100644 index 0000000..21bfccd --- /dev/null +++ b/docs/api/routes/company/delete.md @@ -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`._ diff --git a/docs/api/routes/company/list.md b/docs/api/routes/company/list.md new file mode 100644 index 0000000..c375dd5 --- /dev/null +++ b/docs/api/routes/company/list.md @@ -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`._ diff --git a/docs/api/routes/company/update.md b/docs/api/routes/company/update.md new file mode 100644 index 0000000..0ab81ea --- /dev/null +++ b/docs/api/routes/company/update.md @@ -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`._ diff --git a/docs/api/routes/health.md b/docs/api/routes/health.md index 35b9155..206d369 100644 --- a/docs/api/routes/health.md +++ b/docs/api/routes/health.md @@ -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`._ diff --git a/docs/api/routes/misc/getCategories.md b/docs/api/routes/misc/getCategories.md deleted file mode 100644 index b494191..0000000 --- a/docs/api/routes/misc/getCategories.md +++ /dev/null @@ -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]) -); -``` diff --git a/docs/api/routes/misc/getSteuer.md b/docs/api/routes/misc/getSteuer.md deleted file mode 100644 index 80ae702..0000000 --- a/docs/api/routes/misc/getSteuer.md +++ /dev/null @@ -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(); -``` diff --git a/package.json b/package.json index 953409c..f5dd6d6 100644 --- a/package.json +++ b/package.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": { diff --git a/scripts/generateApiDocs.js b/scripts/generateApiDocs.js new file mode 100644 index 0000000..0ed5316 --- /dev/null +++ b/scripts/generateApiDocs.js @@ -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/`); diff --git a/src/api/openapi.js b/src/api/openapi.js new file mode 100644 index 0000000..0db4e5e --- /dev/null +++ b/src/api/openapi.js @@ -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 {}; diff --git a/src/api/routes/article.js b/src/api/routes/article.js index 836749a..2ae0bf0 100644 --- a/src/api/routes/article.js +++ b/src/api/routes/article.js @@ -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) { diff --git a/src/api/routes/auth/login.js b/src/api/routes/auth/login.js index b13ef38..1108348 100644 --- a/src/api/routes/auth/login.js +++ b/src/api/routes/auth/login.js @@ -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: "" + * 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 { diff --git a/src/api/routes/auth/logout.js b/src/api/routes/auth/logout.js index 9f7ac7a..5934fc9 100644 --- a/src/api/routes/auth/logout.js +++ b/src/api/routes/auth/logout.js @@ -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; diff --git a/src/api/routes/auth/me.js b/src/api/routes/auth/me.js index 62f94a1..525a634 100644 --- a/src/api/routes/auth/me.js +++ b/src/api/routes/auth/me.js @@ -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; diff --git a/src/api/routes/auth/selectedCompany.js b/src/api/routes/auth/selectedCompany.js index 5823018..d6a0cad 100644 --- a/src/api/routes/auth/selectedCompany.js +++ b/src/api/routes/auth/selectedCompany.js @@ -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); diff --git a/src/api/routes/category.js b/src/api/routes/category.js index 6422909..e971504 100644 --- a/src/api/routes/category.js +++ b/src/api/routes/category.js @@ -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'); diff --git a/src/api/routes/company.js b/src/api/routes/company.js index a0a9fc0..ffeaafa 100644 --- a/src/api/routes/company.js +++ b/src/api/routes/company.js @@ -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) { diff --git a/src/api/routes/health.js b/src/api/routes/health.js index 7bbdfeb..09caf8a 100644 --- a/src/api/routes/health.js +++ b/src/api/routes/health.js @@ -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 {