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:
@@ -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` |
|
||||
|
||||
@@ -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": [
|
||||
|
||||
33
docs/api/routes/articleIndex.md
Normal file
33
docs/api/routes/articleIndex.md
Normal 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`._
|
||||
44
docs/api/routes/category/add-image.md
Normal file
44
docs/api/routes/category/add-image.md
Normal 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`._
|
||||
37
docs/api/routes/category/delete-image.md
Normal file
37
docs/api/routes/category/delete-image.md
Normal 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`._
|
||||
29
docs/api/routes/category/export.zip.md
Normal file
29
docs/api/routes/category/export.zip.md
Normal 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`._
|
||||
32
docs/api/routes/posterminal/delete.md
Normal file
32
docs/api/routes/posterminal/delete.md
Normal 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`._
|
||||
30
docs/api/routes/posterminal/list.md
Normal file
30
docs/api/routes/posterminal/list.md
Normal 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`._
|
||||
38
docs/api/routes/posterminal/setOff.md
Normal file
38
docs/api/routes/posterminal/setOff.md
Normal 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`._
|
||||
37
docs/api/routes/posterminal/setOffAll.md
Normal file
37
docs/api/routes/posterminal/setOffAll.md
Normal 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`._
|
||||
38
docs/api/routes/posterminal/setOn.md
Normal file
38
docs/api/routes/posterminal/setOn.md
Normal 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`._
|
||||
39
docs/api/routes/posterminal/setOnAll.md
Normal file
39
docs/api/routes/posterminal/setOnAll.md
Normal 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`._
|
||||
40
docs/api/routes/posterminal/upsert.md
Normal file
40
docs/api/routes/posterminal/upsert.md
Normal 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
98
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
76
src/util/kategorienCsv.js
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user