feat: add article search index endpoint and category export.zip

- Add GET /api/articleIndex returning the article search index (listSearchIndex)
- Add GET /api/category/export.zip returning a kategorien.zip with a
  Windows-1252 CSV (depth-first, Ebene N columns) plus one AVIF per category image
  (content-addressed, deduplicated) using jszip
This commit is contained in:
seb
2026-08-04 00:30:32 +02:00
parent bfcb073e32
commit c6cb1bc229
19 changed files with 1718 additions and 1 deletions

View File

@@ -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` |

View File

@@ -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 `<fullHash>.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": [

View File

@@ -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`._

View File

@@ -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`._

View File

@@ -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`._

View File

@@ -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 `<fullHash>.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`._

View File

@@ -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`._

View File

@@ -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`._

View File

@@ -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`._

View File

@@ -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`._

View File

@@ -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`._

View File

@@ -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`._

View File

@@ -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`._

98
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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 });
}
});
}

View File

@@ -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 `<fullHash>.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:

View File

@@ -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);
}

76
src/util/kategorienCsv.js Normal file
View File

@@ -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;
}