Compare commits

...

4 Commits

Author SHA1 Message Date
seb
369fbb2a70 u 2026-10-24 06:19:34 +02:00
seb
464021318a u 2026-10-24 05:13:13 +02:00
seb
9929e322a3 u 2026-10-24 02:57:32 +02:00
seb
ebbeb6437a u 2026-10-24 02:31:35 +02:00
12 changed files with 446 additions and 39 deletions

View File

@@ -115,6 +115,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/category/_id/attributevalue.put.md`](routes/category/_id/attributevalue.put.md) | `PUT /category/{id}/attributevalue` |
| [`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/category/_id/image/generate.md`](routes/category/_id/image/generate.md) | `POST /category/{id}/image/generate` |
| [`routes/category/_id/property.get.md`](routes/category/_id/property.get.md) | `GET /category/{id}/property` |
| [`routes/category/_id/property.put.md`](routes/category/_id/property.put.md) | `PUT /category/{id}/property` |
| [`routes/category/_id/property/_propertyId.md`](routes/category/_id/property/_propertyId.md) | `DELETE /category/{id}/property/{propertyId}` |
@@ -275,6 +276,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/unit/list.md`](routes/unit/list.md) | `GET /unit` |
| [`routes/unit/upsert.md`](routes/unit/upsert.md) | `PUT /unit` |
| [`routes/v1/category.md`](routes/v1/category.md) | `GET /v1/category` |
| [`routes/pos/cimage.md`](routes/pos/cimage.md) | `GET /v1/cimage` |
| [`routes/pos/client.md`](routes/pos/client.md) | `GET /v1/client` |
| [`routes/v1/customer.md`](routes/v1/customer.md) | `GET /v1/customer` |
| [`routes/v1/customergroup.md`](routes/v1/customergroup.md) | `GET /v1/customergroup` |
@@ -314,7 +316,7 @@ Clients subscribe via [`GET /events`](routes/events.md) (`text/event-stream`). A
| `attributename` | `POST /attributename`, `PUT /attributename/order`, `DELETE /attributename/{id}`, `PATCH /attributename/{id}`, `DELETE /attributevalue/{id}/image`, `POST /attributevalue/{id}/image` |
| `brand` | `POST /brand`, `DELETE /brand/{id}`, `PATCH /brand/{id}`, `DELETE /brand/{id}/image`, `POST /brand/{id}/image` |
| `carrier` | `POST /carrier`, `PUT /carrier/order`, `DELETE /carrier/{id}`, `PATCH /carrier/{id}` |
| `category` | `POST /category`, `PUT /category/order`, `DELETE /category/{id}`, `PATCH /category/{id}`, `DELETE /category/{id}/image`, `POST /category/{id}/image` |
| `category` | `POST /category`, `PUT /category/order`, `DELETE /category/{id}`, `PATCH /category/{id}`, `DELETE /category/{id}/image`, `POST /category/{id}/image`, `POST /category/{id}/image/generate` |
| `delivery` | `DELETE /delivery/{id}/file/{fileId}`, `POST /delivery/{id}/pdf`, `POST /stock/accept` |
| `labeltemplate` | `POST /labeltemplate`, `DELETE /labeltemplate/{id}`, `PATCH /labeltemplate/{id}`, `PUT /labeltemplate/{id}` |
| `ledger` | `DELETE /shipment/{id}`, `POST /stock/accept`, `POST /stock/adjust`, `POST /stock/ship` |

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview (All)
Overview of all 268 API endpoints across all modules.
Overview of all 270 API endpoints across all modules.
### Notation
@@ -329,6 +329,9 @@ _none_
#### Add category image — `POST /category/{id}/image`
- Params: `id: int, image/avif`
- Returns: `201 { category: { id: int, thumbW: int, thumbH: int, thumbHash: str } }`
#### Generate category mosaic image from contents — `POST /category/{id}/image/generate`
- Params: `id: int, body?: { size?: int }`
- Returns: `200 { category: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, size: int, grid: str, articleCount: int } }`
#### List category properties — `GET /category/{id}/property`
- Params: `id: int`
- Returns: `200 { properties: { id: int, propertyId: int, name: str, exclusive: bool, value: str, sort: int, categoryId?: int }[] }`
@@ -924,6 +927,9 @@ _none_
#### JTL-POS category synchronization — `GET /v1/category`
- Params: `query: lastChangedCategory?: str, limit?: int`
- Returns: `200 {}[]`
#### POS category image delivery — `GET /v1/cimage`
- Params: `query: path: str, size?: int, w?: int, h?: int, format?: str`
- Returns: `200 jpeg`
#### POS discovery and pairing — `GET /v1/client`
- Params: `query: authCode: str, name?: str`
- Returns: `200 { authCode: str|null, authToken: str|null, certificateFingerprint: str, certificateSerialNumber: str, mandantId: str, mandantName: str|null, mandantDatabase: str|null, serverFingerprint: str|null, name: str|null, serverTimestamp: str }`
@@ -943,7 +949,7 @@ _none_
- Params: `query: mandantId?: str, body: { orders?: {}[] }`
- Returns: `200 { status: str, externalOrderId: str, message: str }[]`
#### POS product image delivery — `GET /v1/pimage`
- Params: `query: path: str, w?: int, h?: int, format?: str`
- Params: `query: path: str, size?: int, w?: int, h?: int, format?: str`
- Returns: `200 jpeg`
#### JTL-POS product catalog synchronization — `GET /v1/product`
- Params: `query: lastChangedProduct?: str, limit?: int`

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview
Overview of all 189 API endpoints across all modules.
Overview of all 190 API endpoints across all modules.
### Notation
@@ -253,6 +253,9 @@ _none_
#### Delete category image — `DELETE /category/{id}/image`
- Params: `id: int`
- Returns: `200 { category: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null } }`
#### Generate category mosaic image from contents — `POST /category/{id}/image/generate`
- Params: `id: int, body?: { size?: int }`
- Returns: `200 { category: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, size: int, grid: str, articleCount: int } }`
#### List category properties — `GET /category/{id}/property`
- Params: `id: int`
- Returns: `200 { properties: { id: int, propertyId: int, name: str, exclusive: bool, value: str, sort: int, categoryId?: int }[] }`

View File

@@ -0,0 +1,64 @@
# `POST /category/{id}/image/generate`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Generate category mosaic image from contents
Generates a 2x2, 3x3, or 4x4 mosaic image from the category's and its subcategories'
articles (grouped by subcategory) and sets it as the new main picture for the category.
If size is not specified, defaults to 3 (3x3), falling back to 2 (2x2) if fewer than 7 images are available.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | Category id |
## Body (JSON) — _optional_
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `size` | no | integer | Mosaic grid size (2 for 2x2, 3 for 3x3, 4 for 4x4). Defaults to 3x3 (or 2x2 if < 7 images). |
```json
{
"size": 1
}
```
## Signals
On success, emits SSE signal: `category` (see [`GET /events`](../events.md)).
## Response `200`
Mosaic image generated and linked as category main picture
```json
{
"category": {
"id": 1,
"fullHash": "…",
"thumbW": 1,
"thumbH": 1,
"thumbHash": "…",
"size": 1,
"grid": "…",
"articleCount": 1
}
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid category ID or size — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
| 404 | Category not found or no article images found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,34 @@
# `GET /v1/cimage`
Protected. Requires a valid session cookie (`credentials: 'include'`).
POS category image delivery
Returns category image by hash (`path` query param) scaled to preview size (padded to 1:1 with adaptive aspect-ratio borders: thinner borders for vertical images, wider borders for horizontal images).
## Query parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `path` | yes | string | SHA-256 hash of the category image |
| `size` | no | integer | Max dimension (width & height) |
| `w` | no | integer | Max width |
| `h` | no | integer | Max height |
| `format` | no | string | Optional target format (defaults to jpeg/png or client Accept preference) |
## Response `200`
Resized image binary
## Errors
| Status | Description |
|--------|-------------|
| 304 | Image not modified |
| 400 | Missing required query parameter 'path' |
| 404 | Image not found |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -12,6 +12,7 @@ Returns product image by hash (`path` query param) scaled to preview size.
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `path` | yes | string | SHA-256 hash of the product image |
| `size` | no | integer | Max dimension (width & height) |
| `w` | no | integer | Max width |
| `h` | no | integer | Max height |
| `format` | no | string | Optional target format (defaults to jpeg/png or client Accept preference) |

BIN
scratch/curl-pimage.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -41,6 +41,7 @@ const OMITTED_ROUTES = new Set([
'GET /shipment/{id}/file/{fileId}',
'GET /thumb/{fullHash}',
'GET /v1/category',
'GET /v1/cimage',
'GET /v1/client',
'GET /v1/customer',
'GET /v1/customergroup',

View File

@@ -907,4 +907,122 @@ export function register(app, basePath = '') {
});
// #endregion
/**
* @openapi
* /category/{id}/image/generate:
* post:
* tags: [Category]
* summary: Generate category mosaic image from contents
* description: |
* Generates a 2x2, 3x3, or 4x4 mosaic image from the category's and its subcategories'
* articles (grouped by subcategory) and sets it as the new main picture for the category.
* If size is not specified, defaults to 3 (3x3), falling back to 2 (2x2) if fewer than 7 images are available.
* security:
* - cookieAuth: []
* x-signals: [category]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: Category id
* requestBody:
* required: false
* content:
* application/json:
* schema:
* type: object
* properties:
* size:
* type: integer
* enum: [2, 3, 4]
* description: Mosaic grid size (2 for 2x2, 3 for 3x3, 4 for 4x4). Defaults to 3x3 (or 2x2 if < 7 images).
* responses:
* 200:
* description: Mosaic image generated and linked as category main picture
* content:
* application/json:
* schema:
* type: object
* properties:
* category:
* type: object
* properties:
* id:
* type: integer
* fullHash:
* type: string
* thumbW:
* type: integer
* thumbH:
* type: integer
* thumbHash:
* type: string
* size:
* type: integer
* grid:
* type: string
* articleCount:
* type: integer
* 400:
* description: Invalid category ID or size
* 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 or no article images found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region POST /category/:id/image/generate
app.post(
`${basePath}/category/:id/image/generate`,
express.json(),
async (req, res) => {
const parsed = parsePositiveInt(req.params.id, 'id');
if (parsed.error) {
return res.status(400).json({ error: parsed.error });
}
const rawSize = req.body?.size;
let size = undefined;
if (rawSize !== undefined && rawSize !== null && rawSize !== '') {
size = Number(rawSize);
if (![2, 3, 4].includes(size)) {
return res.status(400).json({ error: 'Invalid size, must be 2, 3, or 4' });
}
}
try {
const category = await db.generateImage(req.user.id, parsed.id, { size });
emitCategorySignal(req);
res.status(200).json({ category });
} catch (error) {
const status = categoryErrorStatus(error);
if (status === 500) console.error('Generate category image failed:', error);
res.status(status).json({ error: error.message });
}
},
);
// #endregion
}

View File

@@ -44,3 +44,9 @@ export async function deleteImage(googleAuthId, categoryId) {
const store = await getStore();
return store.category.deleteImage(googleAuthId, categoryId);
}
export async function generateImage(googleAuthId, categoryId, options) {
const store = await getStore();
return store.category.generateImage(googleAuthId, categoryId, options);
}

View File

@@ -7,7 +7,7 @@ import { handleCustomerGroup } from './routes/customergroup.js';
import { handleCustomer } from './routes/customer.js';
import { handleProduct } from './routes/product.js';
import { handleDeletedEntity } from './routes/deletedentity.js';
import { handlePimage } from './routes/pimage.js';
import { handlePimage, handleCimage } from './routes/pimage.js';
import { handleOrder } from './routes/order.js';
const router = Router();
@@ -30,7 +30,7 @@ router.post('/order', posAuthMiddleware, handleOrder);
// POS image delivery
router.get('/pimage', handlePimage);
router.get('/cimage', handlePimage);
router.get('/cimage', handleCimage);
export default router;

View File

@@ -1,7 +1,10 @@
import sharp from 'sharp';
import * as db from '../../db/posterminal.js';
const MAX_DIMENSION = 200;
const PIMAGE_DEFAULT_W = 200;
const PIMAGE_DEFAULT_H = 200;
const CIMAGE_DEFAULT_W = 200;
const CIMAGE_DEFAULT_H = 200;
/**
* @openapi
@@ -21,6 +24,13 @@ const MAX_DIMENSION = 200;
* description: SHA-256 hash of the product image
* example: "71de4085ce2b1a53317832421009d844eec0c36a3ec071a31661cc2a212e15e6"
* - in: query
* name: size
* required: false
* schema:
* type: integer
* default: 200
* description: Max dimension (width & height)
* - in: query
* name: w
* required: false
* schema:
@@ -85,16 +95,142 @@ const MAX_DIMENSION = 200;
* example: "No image was found for path 'abc'."
*/
export async function handlePimage(req, res) {
const isCimage = req.path?.includes('cimage') || req.originalUrl?.includes('/cimage');
return servePosImage(req, res, { isCimage });
}
/**
* @openapi
* /v1/cimage:
* get:
* tags: [POS]
* summary: POS category image delivery
* description: |
* Returns category image by hash (`path` query param) scaled to preview size (padded to 1:1 with adaptive aspect-ratio borders: thinner borders for vertical images, wider borders for horizontal images).
* x-markdown: pos/cimage.md
* parameters:
* - in: query
* name: path
* required: true
* schema:
* type: string
* description: SHA-256 hash of the category image
* example: "71de4085ce2b1a53317832421009d844eec0c36a3ec071a31661cc2a212e15e6"
* - in: query
* name: size
* required: false
* schema:
* type: integer
* default: 200
* description: Max dimension (width & height)
* - in: query
* name: w
* required: false
* schema:
* type: integer
* default: 200
* description: Max width
* - in: query
* name: h
* required: false
* schema:
* type: integer
* default: 200
* description: Max height
* - in: query
* name: format
* required: false
* schema:
* type: string
* enum: [jpeg, png, webp, avif]
* description: Optional target format (defaults to jpeg/png or client Accept preference)
* responses:
* 200:
* description: Resized image binary
* content:
* image/jpeg:
* schema:
* type: string
* format: binary
* image/png:
* schema:
* type: string
* format: binary
* image/webp:
* schema:
* type: string
* format: binary
* image/avif:
* schema:
* type: string
* format: binary
* 304:
* description: Image not modified
* 400:
* description: Missing required query parameter 'path'
* content:
* application/json:
* schema:
* type: object
* properties:
* Message:
* type: string
* example: "Missing required query parameter 'path'."
* 404:
* description: Image not found
* content:
* application/json:
* schema:
* type: object
* properties:
* Message:
* type: string
* example: "No image was found for path 'abc'."
*/
export async function handleCimage(req, res) {
return servePosImage(req, res, { isCimage: true });
}
async function servePosImage(req, res, { isCimage = false } = {}) {
const rawPath = typeof req.query.path === 'string' ? req.query.path.trim() : '';
if (!rawPath) {
return res.status(400).json({ Message: "Missing required query parameter 'path'." });
}
const widthParam = req.query.w !== undefined ? Number(req.query.w) : MAX_DIMENSION;
const heightParam = req.query.h !== undefined ? Number(req.query.h) : MAX_DIMENSION;
const targetW = Number.isFinite(widthParam) && widthParam > 0 ? Math.min(Math.round(widthParam), 2000) : MAX_DIMENSION;
const targetH = Number.isFinite(heightParam) && heightParam > 0 ? Math.min(Math.round(heightParam), 2000) : MAX_DIMENSION;
const hasSize = req.query.size !== undefined && Number.isFinite(Number(req.query.size)) && Number(req.query.size) > 0;
const hasW = req.query.w !== undefined && Number.isFinite(Number(req.query.w)) && Number(req.query.w) > 0;
const hasH = req.query.h !== undefined && Number.isFinite(Number(req.query.h)) && Number(req.query.h) > 0;
let targetW;
let targetH;
if (isCimage) {
if (hasW && !hasH) {
targetW = Math.min(Math.round(Number(req.query.w)), 2000);
targetH = targetW;
} else if (hasH && !hasW) {
targetH = Math.min(Math.round(Number(req.query.h)), 2000);
targetW = targetH;
} else if (hasW && hasH) {
targetW = Math.min(Math.round(Number(req.query.w)), 2000);
targetH = Math.min(Math.round(Number(req.query.h)), 2000);
} else if (hasSize) {
const s = Math.min(Math.round(Number(req.query.size)), 2000);
targetW = s;
targetH = s;
} else {
targetW = CIMAGE_DEFAULT_W;
targetH = CIMAGE_DEFAULT_H;
}
} else {
const defaultW = hasSize ? Number(req.query.size) : PIMAGE_DEFAULT_W;
const defaultH = hasSize ? Number(req.query.size) : PIMAGE_DEFAULT_H;
const widthParam = hasW ? Number(req.query.w) : defaultW;
const heightParam = hasH ? Number(req.query.h) : defaultH;
targetW = Math.min(Math.round(widthParam), 2000);
targetH = Math.min(Math.round(heightParam), 2000);
}
try {
const image = await db.getPosImageByHash(rawPath, { width: targetW, height: targetH });
@@ -102,7 +238,7 @@ export async function handlePimage(req, res) {
return res.status(404).json({ Message: `No image was found for path '${rawPath}'.` });
}
const etag = `"${image.fullHash}-${targetW}x${targetH}"`;
const etag = `"${image.fullHash}-${targetW}x${targetH}${isCimage ? '-c-v5' : ''}"`;
const ifNoneMatch = req.headers['if-none-match'];
if (ifNoneMatch === etag || ifNoneMatch === image.fullHash || ifNoneMatch === `"${image.fullHash}"`) {
res.setHeader('ETag', etag);
@@ -110,47 +246,83 @@ export async function handlePimage(req, res) {
return res.status(304).end();
}
let buffer = image.buffer;
let contentType = 'image/jpeg';
const imageSharp = sharp(image.buffer);
try {
const imageSharp = sharp(buffer);
let pipeline;
if (isCimage) {
const meta = await imageSharp.metadata();
const origW = meta.width || targetW;
const origH = meta.height || targetH;
const r = origW / origH;
let pipeline = imageSharp.resize(targetW, targetH, {
fit: 'inside',
withoutEnlargement: true,
});
const accept = String(req.headers.accept || '');
const formatParam = typeof req.query.format === 'string' ? req.query.format.toLowerCase() : '';
if (formatParam === 'avif' || (!formatParam && accept.includes('image/avif'))) {
pipeline = pipeline.avif({ effort: 2 });
contentType = 'image/avif';
} else if (formatParam === 'webp' || (!formatParam && accept.includes('image/webp'))) {
pipeline = pipeline.webp({ quality: 85 });
contentType = 'image/webp';
} else if (formatParam === 'png' || (!formatParam && meta.hasAlpha)) {
pipeline = pipeline.png();
contentType = 'image/png';
// Adaptive fill ratio:
// Vertical images (r < 1) get much less border (height fills up to ~94% of tile).
// Horizontal images (r > 1) get a bit more border (width fills down to ~65% of tile).
// Square images (r = 1) get ~75% fill (~12.5% border on all sides).
let fillRatio;
if (r < 1.0) {
const t = Math.max(0, Math.min(1, (1.0 - r) / 0.5));
fillRatio = 0.75 + t * (0.94 - 0.75);
} else {
pipeline = pipeline.jpeg({ quality: 85 });
contentType = 'image/jpeg';
const t = Math.max(0, Math.min(1, (r - 1.0) / 0.8));
fillRatio = 0.75 - t * (0.75 - 0.65);
}
buffer = await pipeline.toBuffer();
} catch (resizeErr) {
console.warn(`[pimage] resize/convert fallback for ${rawPath}:`, resizeErr.message);
const innerW = Math.max(1, Math.round(targetW * fillRatio));
const innerH = Math.max(1, Math.round(targetH * fillRatio));
const intermediateBuffer = await imageSharp
.resize(innerW, innerH, {
fit: 'contain',
background: '#ffffff',
withoutEnlargement: true,
})
.flatten({ background: '#ffffff' })
.toBuffer();
pipeline = sharp(intermediateBuffer)
.resize(targetW, targetH, {
fit: 'contain',
background: '#ffffff',
withoutEnlargement: true,
})
.flatten({ background: '#ffffff' });
} else {
pipeline = imageSharp
.resize(targetW, targetH, {
fit: 'inside',
withoutEnlargement: true,
})
.flatten({ background: '#ffffff' });
}
const accept = String(req.headers.accept || '');
const formatParam = typeof req.query.format === 'string' ? req.query.format.toLowerCase() : '';
let contentType = 'image/jpeg';
if (formatParam === 'avif' || (!formatParam && accept.includes('image/avif'))) {
pipeline = pipeline.avif({ effort: 2 });
contentType = 'image/avif';
} else if (formatParam === 'webp' || (!formatParam && accept.includes('image/webp'))) {
pipeline = pipeline.webp({ quality: 85 });
contentType = 'image/webp';
} else if (formatParam === 'png') {
pipeline = pipeline.png();
contentType = 'image/png';
} else {
pipeline = pipeline.jpeg({ quality: 85 });
contentType = 'image/jpeg';
}
const buffer = await pipeline.toBuffer();
res.setHeader('Content-Type', contentType);
res.setHeader('Content-Length', String(buffer.length));
res.setHeader('Cache-Control', 'public, max-age=86400');
res.setHeader('ETag', etag);
return res.status(200).send(buffer);
} catch (err) {
console.error('POS /v1/pimage error:', err);
console.error(`POS /v1/${isCimage ? 'cimage' : 'pimage'} error:`, err);
return res.status(500).json({ Message: 'Internal server error' });
}
}