{ "openapi": "3.0.3", "info": { "title": "Dash API", "version": "1.0.0", "description": "Backend API for Dash — Google SSO auth, MSSQL, session cookies.\n\nBrowser clients must send cookies (`credentials: 'include'`). Sessions use an HTTP-only `token` cookie (`Secure`, `SameSite=Strict`, 24h); HTTPS is required for login." }, "servers": [ { "url": "http://localhost:4208/api", "description": "Local default (PORT / .env)" } ], "tags": [ { "name": "Auth", "description": "Google SSO and session" }, { "name": "Health", "description": "Connectivity checks" }, { "name": "Article", "description": "Articles under categories" }, { "name": "Category", "description": "Category tree and membership" }, { "name": "Company", "description": "Company CRUD" }, { "name": "Unit", "description": "Company-scoped units of measure" }, { "name": "Brand", "description": "Company-scoped brands" }, { "name": "Image", "description": "Content-addressed AVIF by fullHash" } ], "paths": { "/unit": { "get": { "tags": [ "Unit" ], "summary": "List units", "description": "Lists units for the authenticated user's selected company (default-language symbol/name).", "security": [ { "cookieAuth": [] } ], "x-markdown": "unit/list.md", "responses": { "200": { "description": "Unit list", "content": { "application/json": { "schema": { "type": "object", "properties": { "units": { "type": "array", "items": { "$ref": "#/components/schemas/Unit" } } } } } } }, "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": [ "Unit" ], "summary": "Upsert unit", "description": "Insert or update unit by `si` for the selected company. Upserts `unittext` for the company language.\n`basesifactor` must be finite and `> 0`.\n", "security": [ { "cookieAuth": [] } ], "x-markdown": "unit/upsert.md", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "si", "basesi", "basesifactor", "symbol", "name" ], "properties": { "si": { "type": "string" }, "basesi": { "type": "string" }, "basesifactor": { "type": "number" }, "symbol": { "type": "string" }, "name": { "type": "string" } }, "example": { "si": "l", "basesi": "l", "basesifactor": 1, "symbol": "L", "name": "Liter" } } } } }, "responses": { "200": { "description": "Upserted unit", "content": { "application/json": { "schema": { "type": "object", "properties": { "unit": { "$ref": "#/components/schemas/Unit" } } } } } }, "400": { "description": "Invalid unit 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" } } } } } }, "delete": { "tags": [ "Unit" ], "summary": "Delete unit", "description": "Deletes `si` for the selected company. Fails with `Unit in use` if articles still reference it.", "security": [ { "cookieAuth": [] } ], "x-markdown": "unit/delete.md", "parameters": [ { "in": "query", "name": "si", "required": true, "schema": { "type": "string" } } ], "responses": { "204": { "description": "Deleted" }, "400": { "description": "Missing si 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 or unit not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "Unit in use", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/image/{fullHash}": { "get": { "tags": [ "Image" ], "summary": "Full image by hash", "description": "Returns the full AVIF by `pic.hash` (SHA-256 hex).\nAuth via article category access. Browser-cacheable (`private`, `immutable`, `ETag`).\n", "security": [ { "cookieAuth": [] } ], "x-markdown": "image/get.md", "parameters": [ { "in": "path", "name": "fullHash", "required": true, "schema": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" }, "description": "SHA-256 hex of the full image bytes" } ], "responses": { "200": { "description": "AVIF image bytes", "headers": { "Cache-Control": { "schema": { "type": "string", "example": "private, max-age=31536000, immutable" } }, "ETag": { "schema": { "type": "string" } } }, "content": { "image/avif": { "schema": { "type": "string", "format": "binary" } } } }, "304": { "description": "Not modified (If-None-Match matched ETag)" }, "400": { "description": "Invalid hash", "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": "Image not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/thumb/{fullHash}": { "get": { "tags": [ "Image" ], "summary": "Thumb image by hash", "description": "Returns the thumb AVIF for `pic.hash` (fullHash — not thumbhash).\nAuth via article category access. Browser-cacheable (`private`, `immutable`, `ETag`).\n", "security": [ { "cookieAuth": [] } ], "x-markdown": "thumb/get.md", "parameters": [ { "in": "path", "name": "fullHash", "required": true, "schema": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" }, "description": "SHA-256 hex of the full image bytes" } ], "responses": { "200": { "description": "AVIF thumb bytes", "headers": { "Cache-Control": { "schema": { "type": "string", "example": "private, max-age=31536000, immutable" } }, "ETag": { "schema": { "type": "string" } } }, "content": { "image/avif": { "schema": { "type": "string", "format": "binary" } } } }, "304": { "description": "Not modified (If-None-Match matched ETag)" }, "400": { "description": "Invalid hash", "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": "Image not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/health": { "get": { "tags": [ "Health" ], "summary": "Database health check", "description": "Verifies the database client can establish a connection.", "security": [], "x-markdown": "health.md", "responses": { "200": { "description": "Database reachable", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean", "example": true } } } } } }, "503": { "description": "Database unreachable", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean", "example": false }, "error": { "type": "string" } } } } } } } } }, "/company": { "get": { "tags": [ "Company" ], "summary": "List companies", "description": "Lists companies for the authenticated user.", "security": [ { "cookieAuth": [] } ], "x-markdown": "company/list.md", "responses": { "200": { "description": "Company list", "content": { "application/json": { "schema": { "type": "object", "properties": { "companies": { "type": "array", "items": { "$ref": "#/components/schemas/Company" } } } } } } }, "401": { "description": "Unauthorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } }, "post": { "tags": [ "Company" ], "summary": "Create company", "description": "Creates a company and selects it for the current user.", "security": [ { "cookieAuth": [] } ], "x-markdown": "company/create.md", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "iso639", "name", "taxrate", "reducedtaxrate" ], "properties": { "iso639": { "type": "string" }, "name": { "type": "string" }, "taxrate": { "type": "number" }, "reducedtaxrate": { "type": "number" } }, "example": { "iso639": "de", "name": "Acme GmbH", "taxrate": 19, "reducedtaxrate": 7 } } } } }, "responses": { "201": { "description": "Created; selected company updated", "content": { "application/json": { "schema": { "type": "object", "properties": { "company": { "$ref": "#/components/schemas/Company" }, "user": { "$ref": "#/components/schemas/User" } } } } } }, "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Unauthorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "Company name not available", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/company/{id}": { "put": { "tags": [ "Company" ], "summary": "Update company", "description": "Updates a company.", "security": [ { "cookieAuth": [] } ], "x-markdown": "company/update.md", "parameters": [ { "in": "path", "name": "id", "required": true, "schema": { "type": "integer" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "iso639", "name", "taxrate", "reducedtaxrate" ], "properties": { "iso639": { "type": "string" }, "name": { "type": "string" }, "taxrate": { "type": "number" }, "reducedtaxrate": { "type": "number" } } } } } }, "responses": { "200": { "description": "Updated", "content": { "application/json": { "schema": { "type": "object", "properties": { "company": { "$ref": "#/components/schemas/Company" } } } } } }, "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Unauthorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Company not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "Company name not available", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } }, "delete": { "tags": [ "Company" ], "summary": "Delete company", "description": "Deletes a company.", "security": [ { "cookieAuth": [] } ], "x-markdown": "company/delete.md", "parameters": [ { "in": "path", "name": "id", "required": true, "schema": { "type": "integer" } } ], "responses": { "204": { "description": "Deleted" }, "400": { "description": "Invalid id", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Unauthorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/category/tree": { "get": { "tags": [ "Category" ], "summary": "Category tree", "description": "Returns the category tree for the authenticated user's selected company.", "security": [ { "cookieAuth": [] } ], "x-markdown": "category/tree.md", "responses": { "200": { "description": "Tree and articles for the selected company", "content": { "application/json": { "schema": { "type": "object", "properties": { "languageId": { "type": "integer" }, "tree": { "type": "array", "items": { "type": "object", "additionalProperties": true } }, "articles": { "type": "array", "items": { "type": "object", "additionalProperties": true } } } } } } }, "400": { "description": "No company selected", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Unauthorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Not authorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Company not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/category": { "post": { "tags": [ "Category" ], "summary": "Create category", "description": "Creates a category under an authorized parent.", "security": [ { "cookieAuth": [] } ], "x-markdown": "category/create.md", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "parentId", "name" ], "properties": { "parentId": { "type": "integer" }, "name": { "type": "string" } }, "example": { "parentId": 1, "name": "New category" } } } } }, "responses": { "201": { "description": "Created", "content": { "application/json": { "schema": { "type": "object", "properties": { "category": { "$ref": "#/components/schemas/Category" } } } } } }, "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Unauthorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Not authorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Parent not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/category/{id}/articles": { "get": { "tags": [ "Category" ], "summary": "Articles in category", "description": "Returns articles in a category the user is authorized for.", "security": [ { "cookieAuth": [] } ], "x-markdown": "category/articles.md", "parameters": [ { "in": "path", "name": "id", "required": true, "schema": { "type": "integer" }, "description": "Category id" } ], "responses": { "200": { "description": "Articles list", "content": { "application/json": { "schema": { "type": "object", "properties": { "articles": { "type": "array", "items": { "type": "object", "additionalProperties": true } } } } } } }, "400": { "description": "Invalid id", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Unauthorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Not authorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/category/{id}": { "patch": { "tags": [ "Category" ], "summary": "Update category (rename or move)", "description": "Updates a category the user is authorized for.\n`name` renames the category (re-slugifies in the company default language).\n`parentId` moves it under a new parent (cycle/self → `Invalid parent`).\nEither `name` or `parentId` (or both) is required.\n", "security": [ { "cookieAuth": [] } ], "x-markdown": "category/update.md", "parameters": [ { "in": "path", "name": "id", "required": true, "schema": { "type": "integer" }, "description": "Category id" } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "name": { "type": "string" }, "parentId": { "type": "integer", "description": "New parent category id" } }, "example": { "parentId": 3 } } } } }, "responses": { "200": { "description": "Updated category (renamed and/or moved)", "content": { "application/json": { "schema": { "type": "object", "properties": { "category": { "type": "object", "required": [ "id", "name", "slug", "parentId", "sort" ], "properties": { "id": { "type": "integer" }, "name": { "type": "string" }, "slug": { "type": "string" }, "parentId": { "type": "integer" }, "sort": { "type": "integer" } } } } } } } }, "400": { "description": "Validation error (name or parentId required, or invalid parent)", "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", "description": "Deletes a category the user is authorized for (not the company root).", "security": [ { "cookieAuth": [] } ], "x-markdown": "category/delete.md", "parameters": [ { "in": "path", "name": "id", "required": true, "schema": { "type": "integer" }, "description": "Category id" } ], "responses": { "204": { "description": "Deleted" }, "400": { "description": "Invalid id or cannot delete root", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Unauthorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Not authorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/category/order": { "put": { "tags": [ "Category" ], "summary": "Reorder sibling categories", "description": "Sets the sibling sort order of `categoryIds` under `parentId`.\n`categoryIds` must be exactly the current child set of `parentId`\n(same count, no foreign/duplicate ids). Each child's `sort` is set\nto its index `0..n-1` in one transaction.\n", "security": [ { "cookieAuth": [] } ], "x-markdown": "category/order.md", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "parentId", "categoryIds" ], "properties": { "parentId": { "type": "integer", "description": "Parent category under which sibling order applies" }, "categoryIds": { "type": "array", "items": { "type": "integer" } } }, "example": { "parentId": 1, "categoryIds": [ 2, 4, 3, 5 ] } } } } }, "responses": { "200": { "description": "New sibling order", "content": { "application/json": { "schema": { "type": "object", "properties": { "categoryIds": { "type": "array", "items": { "type": "integer" } } } } } } }, "400": { "description": "Invalid category order", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Unauthorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Not authorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Parent category not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/brand": { "get": { "tags": [ "Brand" ], "summary": "List brands", "description": "Lists brands for the authenticated user's selected company, sorted by name.", "security": [ { "cookieAuth": [] } ], "x-markdown": "brand/list.md", "responses": { "200": { "description": "Brand list", "content": { "application/json": { "schema": { "type": "object", "properties": { "brands": { "type": "array", "items": { "$ref": "#/components/schemas/Brand" } } } } } } }, "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": [ "Brand" ], "summary": "Upsert brand", "description": "Insert or update brand by `name` for the selected company. `gsprpinfo` optional.\n", "security": [ { "cookieAuth": [] } ], "x-markdown": "brand/upsert.md", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "name" ], "properties": { "name": { "type": "string" }, "gsprpinfo": { "type": "string" } }, "example": { "name": "Acme", "gsprpinfo": "Acme GmbH" } } } } }, "responses": { "200": { "description": "Upserted brand", "content": { "application/json": { "schema": { "type": "object", "properties": { "brand": { "$ref": "#/components/schemas/Brand" } } } } } }, "400": { "description": "Invalid brand 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" } } } } } } }, "/brand/{id}": { "delete": { "tags": [ "Brand" ], "summary": "Delete brand", "description": "Deletes the brand for the selected company. Fails with `Brand in use`\nif any article still references it.\n", "security": [ { "cookieAuth": [] } ], "x-markdown": "brand/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": "Brand not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "Brand in use", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/article": { "post": { "tags": [ "Article" ], "summary": "Create article", "description": "Creates an article under an authorized category (`parentId`).", "security": [ { "cookieAuth": [] } ], "x-markdown": "article/create.md", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "parentId", "name" ], "properties": { "parentId": { "type": "integer", "description": "Category id" }, "name": { "type": "string" } }, "example": { "parentId": 10, "name": "New article" } } } } }, "responses": { "201": { "description": "Created", "content": { "application/json": { "schema": { "type": "object", "properties": { "article": { "$ref": "#/components/schemas/ArticleSummary" } } } } } }, "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Unauthorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Not authorized for parent category", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Parent not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/article/{sku}": { "get": { "tags": [ "Article" ], "summary": "Get article by SKU", "description": "Returns article detail by SKU if the user may access its category.", "security": [ { "cookieAuth": [] } ], "x-markdown": "article/get.md", "parameters": [ { "in": "path", "name": "sku", "required": true, "schema": { "type": "string" }, "description": "Article SKU" } ], "responses": { "200": { "description": "Article detail", "content": { "application/json": { "schema": { "type": "object", "properties": { "article": { "$ref": "#/components/schemas/ArticleDetail" } } } } } }, "400": { "description": "Missing sku", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Unauthorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Not authorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Article not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } }, "patch": { "tags": [ "Article" ], "summary": "Update article by SKU", "description": "Patches article fields. At least one of `name`, `unitamount`, `unitSi` required.\n`unitSi` must already exist for the company. Returns the same detail shape as GET.\n", "security": [ { "cookieAuth": [] } ], "x-markdown": "article/update.md", "parameters": [ { "in": "path", "name": "sku", "required": true, "schema": { "type": "string" }, "description": "Article SKU" } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "name": { "type": "string" }, "unitamount": { "type": "number", "description": "Must be finite and `> 0`" }, "unitSi": { "type": "string", "description": "Existing company unit `si`" } }, "example": { "unitamount": 0.24, "unitSi": "l" } } } } }, "responses": { "200": { "description": "Updated article detail", "content": { "application/json": { "schema": { "type": "object", "properties": { "article": { "$ref": "#/components/schemas/ArticleDetail" } } } } } }, "400": { "description": "Validation error (Name required / Invalid unitamount)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Unauthorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Not authorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Article or unit not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } }, "delete": { "tags": [ "Article" ], "summary": "Delete article by SKU", "description": "Deletes the article when it has no child articles. Cascades related rows;\nnulls `manifestarticle.article_id` and appends a delete comment.\nAuth like GET by SKU.\n", "security": [ { "cookieAuth": [] } ], "x-markdown": "article/delete.md", "parameters": [ { "in": "path", "name": "sku", "required": true, "schema": { "type": "string" }, "description": "Article SKU" } ], "responses": { "204": { "description": "Deleted" }, "400": { "description": "Article has children", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Unauthorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Not authorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Article not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/article/{sku}/prices": { "put": { "tags": [ "Article" ], "summary": "Replace POS prices by SKU", "description": "Replace-all prices for the company POS customer group.\nEach entry: `price` (net), `tax` (rate, ensured via tax.ensure), optional `minmultiplier` (default 1).\nEmpty `prices` clears all POS prices. Returns the same shape as GET by SKU.\n", "security": [ { "cookieAuth": [] } ], "x-markdown": "article/set-prices.md", "parameters": [ { "in": "path", "name": "sku", "required": true, "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "prices" ], "properties": { "prices": { "type": "array", "items": { "type": "object", "required": [ "price", "tax" ], "properties": { "price": { "type": "number", "description": "Net price" }, "tax": { "type": "number", "description": "Tax rate (e.g. 19)" }, "minmultiplier": { "type": "number", "description": "Defaults to 1; must be unique per request" } } } } }, "example": { "prices": [ { "price": 10.5, "tax": 19 }, { "price": 9.9, "tax": 19, "minmultiplier": 10 } ] } } } } }, "responses": { "200": { "description": "Updated article detail", "content": { "application/json": { "schema": { "type": "object", "properties": { "article": { "$ref": "#/components/schemas/ArticleDetail" } } } } } }, "400": { "description": "Invalid prices or POS customer group required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Unauthorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Not authorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Article not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/article/{sku}/image": { "post": { "tags": [ "Article" ], "summary": "Add article 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 `articlepic`.\nAuth like GET by SKU.\n", "security": [ { "cookieAuth": [] } ], "x-markdown": "article/add-image.md", "parameters": [ { "in": "path", "name": "sku", "required": true, "schema": { "type": "string" }, "description": "Article SKU" } ], "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": { "image": { "$ref": "#/components/schemas/ArticleImage" } } } } } }, "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": "Article not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/article/{sku}/image/{fullHash}": { "delete": { "tags": [ "Article" ], "summary": "Delete article image", "description": "Removes `articlepic` and the `pic` row for this article (`fullHash`). Auth like GET by SKU.", "security": [ { "cookieAuth": [] } ], "x-markdown": "article/delete-image.md", "parameters": [ { "in": "path", "name": "sku", "required": true, "schema": { "type": "string" } }, { "in": "path", "name": "fullHash", "required": true, "schema": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" } } ], "responses": { "204": { "description": "Deleted" }, "400": { "description": "Invalid hash", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Unauthorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Not authorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Article or image not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/article/{sku}/images/order": { "put": { "tags": [ "Article" ], "summary": "Reorder article images", "description": "Sets `articlepic.sort` to `0..n-1` in the given order.\n`fullHashes` must list every image of the article exactly once.\n", "security": [ { "cookieAuth": [] } ], "x-markdown": "article/order-images.md", "parameters": [ { "in": "path", "name": "sku", "required": true, "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "fullHashes" ], "properties": { "fullHashes": { "type": "array", "items": { "type": "string", "pattern": "^[a-fA-F0-9]{64}$" } } }, "example": { "fullHashes": [ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" ] } } } } }, "responses": { "200": { "description": "New order", "content": { "application/json": { "schema": { "type": "object", "properties": { "fullHashes": { "type": "array", "items": { "type": "string" } } } } } } }, "400": { "description": "Invalid image order", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Unauthorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Not authorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Article not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/article/{sku}/eans": { "put": { "tags": [ "Article" ], "summary": "Replace article EANs by SKU", "description": "Replace-all EANs for the article. EANs must be non-empty unique strings;\nempty array clears all EANs. Returns the same shape as GET by SKU.\n", "security": [ { "cookieAuth": [] } ], "x-markdown": "article/set-eans.md", "parameters": [ { "in": "path", "name": "sku", "required": true, "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "eans" ], "properties": { "eans": { "type": "array", "items": { "type": "string" } } }, "example": { "eans": [ "4001234567890" ] } } } } }, "responses": { "200": { "description": "Updated article detail", "content": { "application/json": { "schema": { "type": "object", "properties": { "article": { "$ref": "#/components/schemas/ArticleDetail" } } } } } }, "400": { "description": "Invalid eans", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Unauthorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Not authorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Article not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/article/{sku}/brand": { "put": { "tags": [ "Article" ], "summary": "Set or clear article brand by SKU", "description": "Sets the article's `brandId` (must belong to the article's company).\nPass `null` to clear the brand. Returns the same shape as GET by SKU.\n", "security": [ { "cookieAuth": [] } ], "x-markdown": "article/set-brand.md", "parameters": [ { "in": "path", "name": "sku", "required": true, "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "brandId" ], "properties": { "brandId": { "type": "integer", "nullable": true } }, "example": { "brandId": 3 } } } } }, "responses": { "200": { "description": "Updated article detail", "content": { "application/json": { "schema": { "type": "object", "properties": { "article": { "$ref": "#/components/schemas/ArticleDetail" } } } } } }, "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Unauthorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Not authorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Article or brand not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/article/{sku}/weight": { "put": { "tags": [ "Article" ], "summary": "Set article weight by SKU", "description": "Sets the article's `weight`. Must be finite and `>= 0`.\nReturns the same shape as GET by SKU.\n", "security": [ { "cookieAuth": [] } ], "x-markdown": "article/set-weight.md", "parameters": [ { "in": "path", "name": "sku", "required": true, "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "weight" ], "properties": { "weight": { "type": "number" } }, "example": { "weight": 1.5 } } } } }, "responses": { "200": { "description": "Updated article detail", "content": { "application/json": { "schema": { "type": "object", "properties": { "article": { "$ref": "#/components/schemas/ArticleDetail" } } } } } }, "400": { "description": "Invalid weight", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Unauthorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Not authorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Article not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/article/{sku}/categories": { "put": { "tags": [ "Article" ], "summary": "Replace article categories by SKU", "description": "Replace-all the article's category list. Each category must belong to\nthe article's company; ids must be unique. Empty array removes the\narticle from all categories. Returns the same shape as GET by SKU.\n", "security": [ { "cookieAuth": [] } ], "x-markdown": "article/set-categories.md", "parameters": [ { "in": "path", "name": "sku", "required": true, "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "categoryIds" ], "properties": { "categoryIds": { "type": "array", "items": { "type": "integer" } } }, "example": { "categoryIds": [ 4, 7 ] } } } } }, "responses": { "200": { "description": "Updated article detail", "content": { "application/json": { "schema": { "type": "object", "properties": { "article": { "$ref": "#/components/schemas/ArticleDetail" } } } } } }, "400": { "description": "Invalid categoryIds", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Unauthorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Not authorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Article not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/auth/selected-company": { "put": { "tags": [ "Auth" ], "summary": "Set selected company", "description": "Sets the authenticated user's selected company.", "security": [ { "cookieAuth": [] } ], "x-markdown": "auth/selected-company.md", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "companyId" ], "properties": { "companyId": { "type": "integer", "minimum": 1 } }, "example": { "companyId": 1 } } } } }, "responses": { "200": { "description": "Updated user", "content": { "application/json": { "schema": { "type": "object", "properties": { "user": { "$ref": "#/components/schemas/User" } } } } } }, "400": { "description": "Invalid companyId", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Unauthorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/auth/me": { "get": { "tags": [ "Auth" ], "summary": "Current user", "description": "Returns the authenticated user from the session cookie and the Google client ID. Unauthenticated calls still receive `googleClientId` with 401.", "security": [ { "cookieAuth": [] } ], "x-markdown": "auth/me.md", "responses": { "200": { "description": "Authenticated", "content": { "application/json": { "schema": { "type": "object", "properties": { "user": { "$ref": "#/components/schemas/User" }, "googleClientId": { "type": "string" } } } } } }, "401": { "description": "Unauthorized — still includes `googleClientId` for the frontend", "content": { "application/json": { "schema": { "type": "object", "properties": { "error": { "type": "string" }, "googleClientId": { "type": "string" } } } } } } } } }, "/auth/logout": { "post": { "tags": [ "Auth" ], "summary": "Logout", "description": "Clears the HTTP-only `token` cookie set at login.", "security": [], "x-markdown": "auth/logout.md", "responses": { "200": { "description": "Logged out", "content": { "application/json": { "schema": { "type": "object", "properties": { "message": { "type": "string", "example": "Logged out successfully" } } } } } } } } }, "/auth/login": { "post": { "tags": [ "Auth" ], "summary": "Login with Google ID token", "description": "Verifies a Google ID token, checks allowlist (or creates entry when `IMPLICIT_CREATE=true`), and sets an HTTP-only `token` cookie.", "security": [], "x-markdown": "auth/login.md", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "idToken" ], "properties": { "idToken": { "type": "string", "description": "Google ID token from Google Identity Services" } }, "example": { "idToken": "" } } } } }, "responses": { "200": { "description": "Login successful; `token` cookie set", "content": { "application/json": { "schema": { "type": "object", "properties": { "message": { "type": "string", "example": "Login successful" }, "user": { "$ref": "#/components/schemas/User" } } } } } }, "400": { "description": "Missing token or Google client not configured", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Invalid Google token or account", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Email not on allowlist", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } } }, "components": { "securitySchemes": { "cookieAuth": { "type": "apiKey", "in": "cookie", "name": "token", "description": "HTTP-only session cookie set by POST /auth/login" } }, "schemas": { "Error": { "type": "object", "required": [ "error" ], "properties": { "error": { "type": "string" } } }, "User": { "type": "object", "required": [ "id", "email", "selectedCompanyId" ], "properties": { "id": { "type": "integer" }, "email": { "type": "string", "format": "email" }, "selectedCompanyId": { "type": "integer", "nullable": true }, "picture": { "type": "string", "format": "uri" } } }, "Company": { "type": "object", "properties": { "id": { "type": "integer" }, "iso639": { "type": "string" }, "name": { "type": "string" }, "taxrate": { "type": "number" }, "reducedtaxrate": { "type": "number" } } }, "Category": { "type": "object", "properties": { "id": { "type": "integer" }, "parentId": { "type": "integer", "nullable": true }, "sort": { "type": "integer" }, "name": { "type": "string" } } }, "Unit": { "type": "object", "required": [ "si", "basesi", "basesifactor" ], "properties": { "si": { "type": "string" }, "basesi": { "type": "string" }, "basesifactor": { "type": "number" }, "symbol": { "type": "string", "nullable": true }, "name": { "type": "string", "nullable": true } } }, "ArticleSummary": { "type": "object", "properties": { "id": { "type": "integer" }, "parentId": { "type": "integer" }, "name": { "type": "string" }, "sku": { "type": "string" } } }, "ArticleDetail": { "type": "object", "description": "Full article payload (unit, brand, categoryIds, photos, eans, prices, …)", "additionalProperties": true }, "Brand": { "type": "object", "required": [ "id", "name" ], "properties": { "id": { "type": "integer" }, "name": { "type": "string" }, "gsprpinfo": { "type": "string", "nullable": true } } }, "ArticleImage": { "type": "object", "required": [ "picId", "sort", "full", "thumb", "hash", "thumbHash" ], "properties": { "picId": { "type": "integer" }, "sort": { "type": "integer" }, "full": { "type": "object", "required": [ "w", "h", "size" ], "properties": { "w": { "type": "integer" }, "h": { "type": "integer" }, "size": { "type": "integer", "description": "Byte length of full AVIF (as-is when already AVIF ≤1000×1000)" } } }, "thumb": { "type": "object", "required": [ "w", "h" ], "properties": { "w": { "type": "integer" }, "h": { "type": "integer" } } }, "hash": { "type": "string", "description": "SHA-256 hex of full image bytes" }, "thumbHash": { "type": "string", "description": "Base64 thumbhash of thumb preview" } } } } } }