diff --git a/docs/api/README.md b/docs/api/README.md index 9392b18..da84259 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -275,6 +275,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` | diff --git a/docs/api/routes-all.md b/docs/api/routes-all.md index 4f32368..28a39a2 100644 --- a/docs/api/routes-all.md +++ b/docs/api/routes-all.md @@ -1,6 +1,6 @@ # Dash API Routes Overview (All) -Overview of all 268 API endpoints across all modules. +Overview of all 269 API endpoints across all modules. ### Notation @@ -924,6 +924,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, 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 }` diff --git a/docs/api/routes/pos/cimage.md b/docs/api/routes/pos/cimage.md new file mode 100644 index 0000000..c946fdf --- /dev/null +++ b/docs/api/routes/pos/cimage.md @@ -0,0 +1,33 @@ +# `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 with 3:4 portrait ratio. + + +## Query parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `path` | yes | string | SHA-256 hash of the category image | +| `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`._ diff --git a/scripts/generateApiDocs.js b/scripts/generateApiDocs.js index 9443205..ccc6821 100644 --- a/scripts/generateApiDocs.js +++ b/scripts/generateApiDocs.js @@ -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', diff --git a/src/v1/index.js b/src/v1/index.js index 6ec6e45..ca0d999 100644 --- a/src/v1/index.js +++ b/src/v1/index.js @@ -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; diff --git a/src/v1/routes/pimage.js b/src/v1/routes/pimage.js index 5212194..1c7aaff 100644 --- a/src/v1/routes/pimage.js +++ b/src/v1/routes/pimage.js @@ -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 = 150; +const CIMAGE_DEFAULT_H = 200; /** * @openapi @@ -85,16 +88,128 @@ 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 with 3:4 portrait ratio. + * 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: w + * required: false + * schema: + * type: integer + * default: 150 + * 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 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 = Math.min(Math.round((targetW * 4) / 3), 2000); + } else if (hasH && !hasW) { + targetH = Math.min(Math.round(Number(req.query.h)), 2000); + targetW = Math.min(Math.round((targetH * 3) / 4), 2000); + } 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 { + targetW = CIMAGE_DEFAULT_W; + targetH = CIMAGE_DEFAULT_H; + } + } else { + const widthParam = hasW ? Number(req.query.w) : PIMAGE_DEFAULT_W; + const heightParam = hasH ? Number(req.query.h) : PIMAGE_DEFAULT_H; + 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 +217,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' : ''}"`; const ifNoneMatch = req.headers['if-none-match']; if (ifNoneMatch === etag || ifNoneMatch === image.fullHash || ifNoneMatch === `"${image.fullHash}"`) { res.setHeader('ETag', etag); @@ -113,11 +228,19 @@ export async function handlePimage(req, res) { const imageSharp = sharp(image.buffer); const meta = await imageSharp.metadata(); - let pipeline = imageSharp - .resize(targetW, targetH, { + const resizeOptions = isCimage + ? { + fit: 'contain', + background: '#ffffff', + withoutEnlargement: true, + } + : { fit: 'inside', withoutEnlargement: true, - }) + }; + + let pipeline = imageSharp + .resize(targetW, targetH, resizeOptions) .flatten({ background: '#ffffff' }); const accept = String(req.headers.accept || ''); @@ -146,7 +269,7 @@ export async function handlePimage(req, res) { 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' }); } }