diff --git a/docs/api/README.md b/docs/api/README.md index 678081f..635bfbf 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -42,6 +42,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/article/order-images.md`](routes/article/order-images.md) | `PUT /article/{sku}/images/order` | | [`routes/article/set-prices.md`](routes/article/set-prices.md) | `PUT /article/{sku}/prices` | | [`routes/article/set-weight.md`](routes/article/set-weight.md) | `PUT /article/{sku}/weight` | +| [`routes/articleIndex.md`](routes/articleIndex.md) | `GET /articleIndex` | | [`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` | @@ -50,17 +51,27 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/brand/upsert.md`](routes/brand/upsert.md) | `PUT /brand` | | [`routes/brand/delete.md`](routes/brand/delete.md) | `DELETE /brand/{id}` | | [`routes/category/create.md`](routes/category/create.md) | `POST /category` | +| [`routes/category/export.zip.md`](routes/category/export.zip.md) | `GET /category/export.zip` | | [`routes/category/order.md`](routes/category/order.md) | `PUT /category/order` | | [`routes/category/tree.md`](routes/category/tree.md) | `GET /category/tree` | | [`routes/category/delete.md`](routes/category/delete.md) | `DELETE /category/{id}` | | [`routes/category/update.md`](routes/category/update.md) | `PATCH /category/{id}` | | [`routes/category/articles.md`](routes/category/articles.md) | `GET /category/{id}/articles` | +| [`routes/category/delete-image.md`](routes/category/delete-image.md) | `DELETE /category/{id}/image` | +| [`routes/category/add-image.md`](routes/category/add-image.md) | `POST /category/{id}/image` | | [`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/image/get.md`](routes/image/get.md) | `GET /image/{fullHash}` | +| [`routes/posterminal/list.md`](routes/posterminal/list.md) | `GET /posterminal` | +| [`routes/posterminal/upsert.md`](routes/posterminal/upsert.md) | `PUT /posterminal` | +| [`routes/posterminal/delete.md`](routes/posterminal/delete.md) | `DELETE /posterminal/{id}` | +| [`routes/posterminal/setOffAll.md`](routes/posterminal/setOffAll.md) | `DELETE /posterminal/{id}/all` | +| [`routes/posterminal/setOnAll.md`](routes/posterminal/setOnAll.md) | `PUT /posterminal/{id}/all` | +| [`routes/posterminal/setOff.md`](routes/posterminal/setOff.md) | `DELETE /posterminal/{id}/category/{categoryId}` | +| [`routes/posterminal/setOn.md`](routes/posterminal/setOn.md) | `PUT /posterminal/{id}/category/{categoryId}` | | [`routes/thumb/get.md`](routes/thumb/get.md) | `GET /thumb/{fullHash}` | | [`routes/unit/delete.md`](routes/unit/delete.md) | `DELETE /unit` | | [`routes/unit/list.md`](routes/unit/list.md) | `GET /unit` | diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 536db15..5abc516 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -310,6 +310,606 @@ } } }, + "/posterminal": { + "get": { + "tags": [ + "Posterminal" + ], + "summary": "List POS terminals", + "description": "Lists POS terminals for the authenticated user's selected company.", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "posterminal/list.md", + "responses": { + "200": { + "description": "Posterminal list", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "posterminals": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Posterminal" + } + } + } + } + } + } + }, + "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" + } + } + } + } + } + }, + "put": { + "tags": [ + "Posterminal" + ], + "summary": "Create POS terminal", + "description": "Creates a new POS terminal for the selected company.\nThe pairingpin is randomly generated server-side and returned in the response.\n", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "posterminal/upsert.md", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + }, + "example": { + "name": "Kassa 1" + } + } + } + } + }, + "responses": { + "200": { + "description": "Created posterminal with generated pairingpin", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "posterminal": { + "$ref": "#/components/schemas/Posterminal" + } + } + } + } + } + }, + "400": { + "description": "Invalid posterminal or 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" + } + } + } + } + } + } + }, + "/posterminal/{id}": { + "delete": { + "tags": [ + "Posterminal" + ], + "summary": "Delete POS terminal", + "description": "Deletes a POS terminal for the selected company.", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "posterminal/delete.md", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "204": { + "description": "Deleted" + }, + "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": "Posterminal not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/posterminal/{id}/category/{categoryId}": { + "put": { + "tags": [ + "Posterminal" + ], + "summary": "Enable category on terminal", + "description": "Toggles a single category's visibility on a terminal (idempotent).", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "posterminal/setOn.md", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "in": "path", + "name": "categoryId", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Updated category IDs for this terminal", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "categoryIds": { + "type": "array", + "items": { + "type": "integer" + } + } + } + } + } + } + }, + "400": { + "description": "No company selected or invalid terminal/category", + "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": "Posterminal not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Posterminal" + ], + "summary": "Disable category on terminal", + "description": "Removes a single category's visibility on a terminal (idempotent).", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "posterminal/setOff.md", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "in": "path", + "name": "categoryId", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Updated category IDs for this terminal", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "categoryIds": { + "type": "array", + "items": { + "type": "integer" + } + } + } + } + } + } + }, + "400": { + "description": "No company selected or invalid terminal/category", + "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": "Posterminal not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/posterminal/{id}/all": { + "put": { + "tags": [ + "Posterminal" + ], + "summary": "Enable all categories on terminal", + "description": "Walks the company's category tree and inserts any missing links\nfor this terminal. Returns the updated category IDs.\n", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "posterminal/setOnAll.md", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Updated category IDs for this terminal", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "categoryIds": { + "type": "array", + "items": { + "type": "integer" + } + } + } + } + } + } + }, + "400": { + "description": "No company selected or invalid terminal", + "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": "Posterminal not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Posterminal" + ], + "summary": "Disable all categories on terminal", + "description": "Removes all category links for this terminal.", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "posterminal/setOffAll.md", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Updated category IDs for this terminal", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "categoryIds": { + "type": "array", + "items": { + "type": "integer" + } + } + } + } + } + } + }, + "400": { + "description": "No company selected or invalid terminal", + "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": "Posterminal not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, "/image/{fullHash}": { "get": { "tags": [ @@ -960,6 +1560,73 @@ } } }, + "/category/export.zip": { + "get": { + "tags": [ + "Category" + ], + "summary": "Export categories as ZIP", + "description": "Builds a `kategorien.zip` for the authenticated user's selected company.\nThe ZIP contains `kategorien.csv` (semicolon-separated, Windows-1252,\ndepth-first in sort order with an `Ebene N` name column per tree depth)\nplus one AVIF entry per category image, named `.avif`\n(content-addressed, so shared hashes are deduplicated).\n", + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "description": "ZIP archive", + "content": { + "application/zip": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "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": [ @@ -1433,6 +2100,202 @@ } } }, + "/category/{id}/image": { + "post": { + "tags": [ + "Category" + ], + "summary": "Add category image", + "description": "Accepts a full-size AVIF body (`Content-Type: image/avif`).\nFull: stored as-is when already AVIF and ≤1000×1000; otherwise scaled to max 1000×1000.\nThumb always scaled to max 200×200. Stores hash/thumbhash, links `categorypic`.\nAuth like GET /category/tree — user must be authorized for the category's company.\n", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "category/add-image.md", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Category id" + } + ], + "requestBody": { + "required": true, + "content": { + "image/avif": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "responses": { + "201": { + "description": "Image stored and linked", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "category": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "thumbW": { + "type": "integer" + }, + "thumbH": { + "type": "integer" + }, + "thumbHash": { + "type": "string" + } + } + } + } + } + } + } + }, + "400": { + "description": "Missing body or invalid image", + "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": "Category not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Category" + ], + "summary": "Delete category image", + "description": "Removes the category's image. Sets thumbW, thumbH, thumbHash back to null.\nAuth like GET /category/tree — user must be authorized for the category's company.\n", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "category/delete-image.md", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Category id" + } + ], + "responses": { + "200": { + "description": "Image removed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "category": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "thumbW": { + "type": "integer", + "nullable": true + }, + "thumbH": { + "type": "integer", + "nullable": true + }, + "thumbHash": { + "type": "string", + "nullable": true + } + } + } + } + } + } + } + }, + "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": "Category or image not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, "/brand": { "get": { "tags": [ @@ -2886,6 +3749,104 @@ } } }, + "/articleIndex": { + "get": { + "tags": [ + "Article" + ], + "summary": "List article search index", + "description": "Returns every article reachable via the company's category tree.\nDesigned for client-side search / autocomplete preload.\nAuth via company access.\n", + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "description": "Search index", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "articles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "sku": { + "type": "string" + }, + "name": { + "type": "string", + "nullable": true + }, + "eans": { + "type": "array", + "items": { + "type": "string" + } + }, + "categoryIds": { + "type": "array", + "items": { + "type": "integer" + } + } + } + } + } + } + } + } + } + }, + "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" + } + } + } + } + } + } + }, "/auth/selected-company": { "put": { "tags": [ diff --git a/docs/api/routes/articleIndex.md b/docs/api/routes/articleIndex.md new file mode 100644 index 0000000..a191958 --- /dev/null +++ b/docs/api/routes/articleIndex.md @@ -0,0 +1,33 @@ +# `GET /articleIndex` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +List article search index + +Returns every article reachable via the company's category tree. +Designed for client-side search / autocomplete preload. +Auth via company access. + + +## Response `200` + +Search index + +```json +{ + "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/category/add-image.md b/docs/api/routes/category/add-image.md new file mode 100644 index 0000000..1536c61 --- /dev/null +++ b/docs/api/routes/category/add-image.md @@ -0,0 +1,44 @@ +# `POST /category/{id}/image` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Add category image + +Accepts a full-size AVIF body (`Content-Type: image/avif`). +Full: stored as-is when already AVIF and ≤1000×1000; otherwise scaled to max 1000×1000. +Thumb always scaled to max 200×200. Stores hash/thumbhash, links `categorypic`. +Auth like GET /category/tree — user must be authorized for the category's company. + + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | Category id | + +## Body + +Raw binary (`image/avif`). + +## Response `201` + +Image stored and linked + +```json +{ + "category": { … } +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Missing body or invalid image — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Category 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-image.md b/docs/api/routes/category/delete-image.md new file mode 100644 index 0000000..995ba7d --- /dev/null +++ b/docs/api/routes/category/delete-image.md @@ -0,0 +1,37 @@ +# `DELETE /category/{id}/image` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Delete category image + +Removes the category's image. Sets thumbW, thumbH, thumbHash back to null. +Auth like GET /category/tree — user must be authorized for the category's company. + + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | Category id | + +## Response `200` + +Image removed + +```json +{ + "category": { … } +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Category or image not found — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/category/export.zip.md b/docs/api/routes/category/export.zip.md new file mode 100644 index 0000000..e11dc8c --- /dev/null +++ b/docs/api/routes/category/export.zip.md @@ -0,0 +1,29 @@ +# `GET /category/export.zip` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Export categories as ZIP + +Builds a `kategorien.zip` for the authenticated user's selected company. +The ZIP contains `kategorien.csv` (semicolon-separated, Windows-1252, +depth-first in sort order with an `Ebene N` name column per tree depth) +plus one AVIF entry per category image, named `.avif` +(content-addressed, so shared hashes are deduplicated). + + +## Response `200` + +ZIP archive + +## 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/posterminal/delete.md b/docs/api/routes/posterminal/delete.md new file mode 100644 index 0000000..33b78ce --- /dev/null +++ b/docs/api/routes/posterminal/delete.md @@ -0,0 +1,32 @@ +# `DELETE /posterminal/{id}` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Delete POS terminal + +Deletes a POS terminal for the selected company. + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | | + +## Response `204` + +Deleted + +_No content_ + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | No company selected — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Posterminal not found — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/posterminal/list.md b/docs/api/routes/posterminal/list.md new file mode 100644 index 0000000..ade516c --- /dev/null +++ b/docs/api/routes/posterminal/list.md @@ -0,0 +1,30 @@ +# `GET /posterminal` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +List POS terminals + +Lists POS terminals for the authenticated user's selected company. + +## Response `200` + +Posterminal list + +```json +{ + "posterminals": [] +} +``` + +## 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/posterminal/setOff.md b/docs/api/routes/posterminal/setOff.md new file mode 100644 index 0000000..1decee9 --- /dev/null +++ b/docs/api/routes/posterminal/setOff.md @@ -0,0 +1,38 @@ +# `DELETE /posterminal/{id}/category/{categoryId}` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Disable category on terminal + +Removes a single category's visibility on a terminal (idempotent). + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | | +| `categoryId` | yes | integer | | + +## Response `200` + +Updated category IDs for this terminal + +```json +{ + "id": 1, + "categoryIds": [] +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | No company selected or invalid terminal/category — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Posterminal not found — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/posterminal/setOffAll.md b/docs/api/routes/posterminal/setOffAll.md new file mode 100644 index 0000000..261c47d --- /dev/null +++ b/docs/api/routes/posterminal/setOffAll.md @@ -0,0 +1,37 @@ +# `DELETE /posterminal/{id}/all` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Disable all categories on terminal + +Removes all category links for this terminal. + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | | + +## Response `200` + +Updated category IDs for this terminal + +```json +{ + "id": 1, + "categoryIds": [] +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | No company selected or invalid terminal — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Posterminal not found — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/posterminal/setOn.md b/docs/api/routes/posterminal/setOn.md new file mode 100644 index 0000000..4a9578c --- /dev/null +++ b/docs/api/routes/posterminal/setOn.md @@ -0,0 +1,38 @@ +# `PUT /posterminal/{id}/category/{categoryId}` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Enable category on terminal + +Toggles a single category's visibility on a terminal (idempotent). + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | | +| `categoryId` | yes | integer | | + +## Response `200` + +Updated category IDs for this terminal + +```json +{ + "id": 1, + "categoryIds": [] +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | No company selected or invalid terminal/category — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Posterminal not found — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/posterminal/setOnAll.md b/docs/api/routes/posterminal/setOnAll.md new file mode 100644 index 0000000..6f2577d --- /dev/null +++ b/docs/api/routes/posterminal/setOnAll.md @@ -0,0 +1,39 @@ +# `PUT /posterminal/{id}/all` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Enable all categories on terminal + +Walks the company's category tree and inserts any missing links +for this terminal. Returns the updated category IDs. + + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | | + +## Response `200` + +Updated category IDs for this terminal + +```json +{ + "id": 1, + "categoryIds": [] +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | No company selected or invalid terminal — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Posterminal not found — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/posterminal/upsert.md b/docs/api/routes/posterminal/upsert.md new file mode 100644 index 0000000..13d147a --- /dev/null +++ b/docs/api/routes/posterminal/upsert.md @@ -0,0 +1,40 @@ +# `PUT /posterminal` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Create POS terminal + +Creates a new POS terminal for the selected company. +The pairingpin is randomly generated server-side and returned in the response. + + +## Body (JSON) + +```json +{ + "name": "Kassa 1" +} +``` + +## Response `200` + +Created posterminal with generated pairingpin + +```json +{ + "posterminal": { … } +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Invalid posterminal or 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/package-lock.json b/package-lock.json index bec5ae6..d9e9ede 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "ink": "^7.1.0", "ink-text-input": "^6.0.0", "jsonwebtoken": "^9.0.3", + "jszip": "^3.10.1", "libdashstore": "file:../dashdbschema", "mssql": "^12.6.0", "react": "^19.2.7", @@ -36,7 +37,9 @@ "@clack/prompts": "^1.7.0", "@hpcc-js/wasm-graphviz": "^1.28.0", "chalk": "^6.0.0", - "dotenv": "^17.4.2" + "dotenv": "^17.4.2", + "sharp": "^0.35.3", + "thumbhash": "^0.1.1" }, "devDependencies": { "mssql": "^12.7.0" @@ -1391,6 +1394,12 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cross-env": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", @@ -2121,6 +2130,12 @@ ], "license": "BSD-3-Clause" }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/indent-string": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", @@ -2310,6 +2325,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2399,6 +2420,18 @@ "npm": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -2424,6 +2457,15 @@ "resolved": "../dashdbschema", "link": true }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -2732,6 +2774,12 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2795,6 +2843,12 @@ "node": ">= 0.6.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2871,6 +2925,36 @@ "react": "^19.2.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/readable-stream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -3014,6 +3098,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -3459,6 +3549,12 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index 908ea34..471b4d3 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "ink": "^7.1.0", "ink-text-input": "^6.0.0", "jsonwebtoken": "^9.0.3", + "jszip": "^3.10.1", "libdashstore": "file:../dashdbschema", "mssql": "^12.6.0", "react": "^19.2.7", diff --git a/src/api/routes/article.js b/src/api/routes/article.js index 2f6e022..fa9e85b 100644 --- a/src/api/routes/article.js +++ b/src/api/routes/article.js @@ -39,6 +39,7 @@ function articleErrorStatus(error) { if (error.message === 'Invalid unitamount') return 400; if (error.message === 'Unit not found') return 404; if (error.message === 'Invalid eans') return 400; + if (error.message === 'Company not found') return 404; if (error.message === 'Brand not found') return 404; if (error.message === 'Invalid weight') return 400; if (error.message === 'Invalid categoryIds') return 400; @@ -1074,4 +1075,85 @@ export function register(app, basePath = '') { res.status(status).json({ error: error.message }); } }); + + /** + * @openapi + * /articleIndex: + * get: + * tags: [Article] + * summary: List article search index + * description: | + * Returns every article reachable via the company's category tree. + * Designed for client-side search / autocomplete preload. + * Auth via company access. + * security: + * - cookieAuth: [] + * responses: + * 200: + * description: Search index + * content: + * application/json: + * schema: + * type: object + * properties: + * articles: + * type: array + * items: + * type: object + * properties: + * id: + * type: integer + * sku: + * type: string + * name: + * type: string + * nullable: true + * eans: + * type: array + * items: + * type: string + * categoryIds: + * type: array + * items: + * type: integer + * 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}/articleIndex`, async (req, res) => { + const companyId = req.user.selectedCompanyId; + if (companyId == null) { + return res.status(400).json({ error: 'No company selected' }); + } + + try { + const articles = await db.listSearchIndex(req.user.id, companyId); + res.status(200).json({ articles }); + } catch (error) { + const status = articleErrorStatus(error); + if (status === 500) console.error('List article search index failed:', error); + res.status(status).json({ error: error.message }); + } + }); } diff --git a/src/api/routes/category.js b/src/api/routes/category.js index bc5b445..2c72a4c 100644 --- a/src/api/routes/category.js +++ b/src/api/routes/category.js @@ -1,5 +1,8 @@ import express from 'express'; +import JSZip from 'jszip'; import * as db from '../../db/category.js'; +import * as picDb from '../../db/pic.js'; +import { buildKategorienCsv, toWindows1252 } from '../../util/kategorienCsv.js'; const AVIF_BODY_LIMIT = '20mb'; @@ -101,6 +104,91 @@ export function register(app, basePath = '') { } }); + /** + * @openapi + * /category/export.zip: + * get: + * tags: [Category] + * summary: Export categories as ZIP + * description: | + * Builds a `kategorien.zip` for the authenticated user's selected company. + * The ZIP contains `kategorien.csv` (semicolon-separated, Windows-1252, + * depth-first in sort order with an `Ebene N` name column per tree depth) + * plus one AVIF entry per category image, named `.avif` + * (content-addressed, so shared hashes are deduplicated). + * security: + * - cookieAuth: [] + * responses: + * 200: + * description: ZIP archive + * content: + * application/zip: + * schema: + * type: string + * format: binary + * 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/export.zip`, async (req, res) => { + const companyId = req.user.selectedCompanyId; + if (companyId == null) { + return res.status(400).json({ error: 'No company selected' }); + } + + try { + const { tree } = await db.getTreeForCompany(req.user.id, companyId); + + const csv = buildKategorienCsv(tree, tree.find((n) => n.parentId == null)?.id ?? null); + + const zip = new JSZip(); + zip.file('kategorien.csv', toWindows1252(csv)); + + const seen = new Set(); + for (const node of tree) { + const fullHash = node.fullHash; + if (!fullHash || seen.has(fullHash)) continue; + seen.add(fullHash); + try { + const { buffer } = await picDb.getImageByHash(req.user.id, fullHash); + zip.file(`${fullHash}.avif`, buffer); + } catch (error) { + // Skip images this user cannot access; CSV already carries the hash. + } + } + + const content = await zip.generateAsync({ type: 'nodebuffer' }); + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', 'attachment; filename="kategorien.zip"'); + res.status(200).send(content); + } catch (error) { + const status = categoryErrorStatus(error); + if (status === 500) console.error('Export categories failed:', error); + res.status(status).json({ error: error.message }); + } + }); + /** * @openapi * /category: diff --git a/src/db/article.js b/src/db/article.js index a51b9f5..6ce5dc0 100644 --- a/src/db/article.js +++ b/src/db/article.js @@ -59,3 +59,8 @@ export async function setCategoryIdsBySku(googleAuthId, sku, categoryIds) { const store = await getStore(); return store.article.setCategoryIdsBySku(googleAuthId, sku, categoryIds); } + +export async function listSearchIndex(googleAuthId, companyId) { + const store = await getStore(); + return store.article.listSearchIndex(googleAuthId, companyId); +} diff --git a/src/util/kategorienCsv.js b/src/util/kategorienCsv.js new file mode 100644 index 0000000..a756834 --- /dev/null +++ b/src/util/kategorienCsv.js @@ -0,0 +1,76 @@ +export const ROOT_CATEGORY_NAME = 'Home'; + +/** Escape a value for a CSV field ("..." double-quoting per RFC 4180). */ +function csvField(value) { + const text = value == null ? '' : String(value); + return /[";\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; +} + +function categoryDisplayName(name, categoryId, rootCategoryId) { + if (rootCategoryId != null && String(categoryId) === String(rootCategoryId)) { + return ROOT_CATEGORY_NAME; + } + return (name ?? '').trim(); +} + +/** + * Build the kategorien.csv string from the flattened category tree returned by + * `getTreeForCompany`. Each row carries FullHash;Id and one name cell in the + * `Ebene N` column matching the category's tree depth (root = Ebene 1). + * Mirrors the frontend's client-side export format exactly. + */ +export function buildKategorienCsv(tree, rootCategoryId) { + if (!Array.isArray(tree) || tree.length === 0) return ''; + + const children = new Map(); + const keyOf = (parentId) => + parentId == null || parentId === 0 ? 'ROOT' : String(parentId); + for (const node of tree) { + const key = keyOf(node.parentId); + if (!children.has(key)) children.set(key, []); + children.get(key).push(node); + } + children.forEach((arr) => + arr.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0) || a.id - b.id), + ); + + const root = tree.find((n) => keyOf(n.parentId) === 'ROOT'); + const ordered = []; + const visited = new Set(); + const walk = (node, depth) => { + if (node == null || visited.has(String(node.id))) return; + visited.add(String(node.id)); + ordered.push({ node, depth }); + (children.get(String(node.id)) ?? []).forEach((kid) => walk(kid, depth + 1)); + }; + walk(root, 0); + + const maxDepth = ordered.reduce((m, item) => Math.max(m, item.depth), 0); + const levelHeaders = Array.from({ length: maxDepth + 1 }, (_, i) => `Ebene ${i + 1}`); + const headers = ['FullHash', 'Id', ...levelHeaders]; + + const lines = [ + 'sep=;', + headers.map(csvField).join(';'), + ...ordered.map(({ node, depth }) => { + const name = categoryDisplayName(node.name, node.id, rootCategoryId); + return [ + csvField(node.fullHash ?? ''), + csvField(node.id), + ...levelHeaders.map((_, index) => (index === depth ? csvField(name) : '')), + ].join(';'); + }), + ]; + + return lines.join('\r\n'); +} + +/** Encode a string as Windows-1252 bytes (single-byte; out-of-range → '?'). */ +export function toWindows1252(str) { + const bytes = Buffer.alloc(str.length); + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i); + bytes[i] = code <= 0xff ? code : 0x3f; // '?' + } + return bytes; +}