import multer from 'multer'; import * as db from '../../db/delivery.js'; import { createAndAttachManifestPdf } from '../../services/manifestPdf.js'; import { writeErrorLog } from '../../util/errorLog.js'; import { emit } from '../sse.js'; // #region Helpers // Shared memory storage for delivery attachments (max 50 MB) const multerUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 }, fileFilter: (_req, file, cb) => { const name = file.originalname?.toLowerCase() || ''; const isPdf = file.mimetype === 'application/pdf' || name.endsWith('.pdf'); const isText = file.mimetype.startsWith('text/') || /\.(txt|text|md|csv)$/.test(name); if (isPdf || isText) { cb(null, true); } else { cb(new Error('Only PDF or text files are accepted as attachments')); } }, }).array('files'); function uploadDeliveryFiles(req, res, next) { multerUpload(req, res, (err) => { if (err) { if (err.code === 'LIMIT_FILE_SIZE') { return res.status(413).json({ error: 'File too large (max 50 MB)' }); } return res.status(400).json({ error: err.message }); } next(); }); } /** * Helper to normalize payload from either multipart/form-data or application/json */ function parseDeliveryPayload(req) { let payload = req.body ?? {}; // If sent as multipart/form-data with a JSON `data` field: if (typeof payload.data === 'string') { try { payload = JSON.parse(payload.data); } catch { throw new Error('Invalid JSON in "data" field'); } } if (typeof payload !== 'object' || payload === null) { throw new Error('Invalid delivery'); } const { externalNumber, date, createdAt, supplierId, lines } = payload; if (supplierId !== undefined && supplierId !== null && (typeof supplierId !== 'number' || !Number.isInteger(supplierId) || supplierId <= 0)) { throw new Error('Invalid supplierId'); } let files = payload.files; // If binary files were uploaded via multer: if (Array.isArray(req.files) && req.files.length > 0) { const filesMeta = Array.isArray(payload.filesMeta) ? payload.filesMeta : (Array.isArray(payload.files) ? payload.files : []); const metaByName = new Map( filesMeta.filter((m) => m?.name).map((m) => [m.name, m]) ); files = req.files.map((f, idx) => { const metaObj = metaByName.get(f.originalname) || filesMeta[idx] || {}; const name = f.originalname || ''; const isPdf = f.mimetype === 'application/pdf' || name.toLowerCase().endsWith('.pdf'); const mimetype = isPdf ? 'application/pdf' : 'text/plain'; console.log(`[delivery upload] Received multipart file "${name}": ${f.size} bytes, mimetype=${mimetype}`); return { mimetype, name: f.originalname, content: f.buffer, // Raw Buffer directly accepted by libdashstore/attachments.js size: f.size, thumbavif: metaObj.thumbavif ?? null, meta: metaObj.meta ?? null, }; }); } else if (Array.isArray(files) && files.length > 0) { files = files.map((f) => { let content = f.content; if (typeof content === 'string') { const trimmed = content.trim(); if (f.mimetype === 'application/pdf' || trimmed.startsWith('JVBER') || /^[A-Za-z0-9+/=]+$/.test(trimmed.slice(0, 100))) { try { content = Buffer.from(trimmed, 'base64'); } catch { content = Buffer.from(content, 'utf8'); } } else { content = Buffer.from(content, 'utf8'); } } const size = Buffer.isBuffer(content) ? content.length : (f.size ?? 0); console.log(`[delivery upload] Received JSON file "${f.name}": ${size} bytes, mimetype=${f.mimetype}`); return { ...f, content, size, }; }); } return { payload, externalNumber, date, createdAt, supplierId, lines, files }; } function requireSelectedCompany(req) { const companyId = req.user.selectedCompanyId; if (companyId == null) { return { error: 'No company selected' }; } return { companyId }; } /** * Debug logging: dump per-file details before handing them to the store. * Needed to diagnose MSSQL TDS errors on @content ("Data type 0xA5 has an * invalid data length or metadata length"). */ function logDeliveryFiles(files, label) { if (!Array.isArray(files) || files.length === 0) { console.log(`[delivery diag] ${label}: no files`); return; } files.forEach((f, idx) => { const content = f?.content; const isBuf = Buffer.isBuffer(content); const hex = isBuf ? [...content.subarray(0, 8)].map((b) => b.toString(16).padStart(2, '0')).join(' ') : null; console.log( `[delivery diag] ${label} file[${idx}]:` + ` name=${JSON.stringify(f?.name)}` + ` mimetype=${JSON.stringify(f?.mimetype)}` + ` size=${JSON.stringify(f?.size)}` + ` contentType=${isBuf ? 'Buffer' : typeof content}` + ` contentLength=${isBuf ? content.length : typeof content === 'string' ? content.length : 'n/a'}` + (hex !== null ? ` headHex=${hex}` : '') + (typeof content === 'string' ? ` head=${JSON.stringify(content.slice(0, 16))}` : '') ); }); } /** * Debug logging: dump everything the SQL driver puts on a SQL error. */ function logStoreError(label, error) { console.error(`[delivery diag] ${label} store error:`); console.error(` message: ${error.message}`); console.error(` code: ${error.code} number: ${error.number} state: ${error.state} class: ${error.class}`); console.error(` lineNumber: ${error.lineNumber} serverName: ${error.serverName} procName: ${error.procName}`); if (error.precedingErrors?.length) { error.precedingErrors.forEach((e, i) => { console.error(` precedingError[${i}]: ${e.message} (number=${e.number} state=${e.state})`); }); } console.error(error.stack); } function deliveryErrorStatus(error) { if (error.message === 'No company selected') return 400; if (error.message === 'Not authorized') return 403; if (error.message === 'Company not found') return 404; if (error.message === 'Delivery not found') return 404; if (error.message === 'Manifest not found') return 404; if (error.message === 'Attachment not found') return 404; if (error.message === 'File not found') return 404; if (error.message === 'Supplier not found') return 404; if (error.message === 'Warehouse not found') return 404; if (error.message === 'Invalid supplierId') return 400; if (error.message === 'Article not found') return 404; if (error.message === 'Invalid delivery') return 400; if (error.message === 'Invalid lines') return 400; if (error.message === 'Delivery has no lines') return 400; if (error.message === 'Invalid file') return 400; if (error.message === 'Invalid JSON in "data" field') return 400; if (error.message === 'Only PDF or text files are accepted as attachments') return 400; if (error.message?.includes('parent article') || error.message?.includes('A parent article')) return 400; if ( error.message === 'Delivery in use in ledger' || error.message === 'Delivery lines already booked on ledger' || error.message?.includes('FOREIGN KEY constraint failed') || error.message?.includes('foreign key constraint') || error.message?.includes('REFERENCE constraint') || error.code === 'SQLITE_CONSTRAINT_FOREIGNKEY' || error.number === 547 ) return 409; return 500; } // #endregion /** * Registers delivery (goods receipt) routes for the authenticated user's selected company. * @param {import('express').Application} app * @param {string} [basePath=''] */ export function register(app, basePath = '') { /** * @openapi * /delivery: * get: * tags: [Delivery] * summary: List deliveries * description: | * Lists goods receipts (`manifest` rows with `type = 'delivery'`) * for the authenticated user's selected company. Supports pagination and filtering. * security: * - cookieAuth: [] * x-markdown: delivery/list.md * parameters: * - name: offset * in: query * schema: * type: integer * default: 0 * description: Number of records to skip * - name: limit * in: query * schema: * type: integer * default: 50 * description: Number of records to return (1-1000) * - name: search * in: query * schema: * type: string * description: Substring match for delivery number, external number, or supplier name * - name: onlyUnbooked * in: query * schema: * type: boolean * description: When true, returns only deliveries with unbooked stock items * - name: supplierId * in: query * schema: * type: integer * description: Filter by supplier entity id * - name: seekMonths * in: query * schema: * type: integer * description: Negative integer cutoff months ago (e.g. -1) * responses: * 200: * description: Delivery list (windowed when pagination/filters are used) * content: * application/json: * schema: * type: object * properties: * deliveries: * type: array * items: * $ref: '#/components/schemas/DeliverySummary' * total: * type: integer * unfilteredTotal: * type: integer * offset: * type: integer * limit: * 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' * post: * tags: [Delivery] * summary: Create delivery * description: | * Creates a goods receipt with header and lines in one transaction. * The manifest `number` is allocated automatically via * `nextNumber('delivery')`; the user-supplied reference is stored as * `externalNumber`. Each line requires `sku` or `name`, `amount`, `net`, and * `taxrate` (rate). `pu` defaults to `1`; `rebate` defaults to `0`. * security: * - cookieAuth: [] * x-markdown: delivery/create.md * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [externalNumber, date, lines] * properties: * externalNumber: * type: string * description: User-supplied reference number * date: * type: string * format: date-time * supplierId: * type: integer * nullable: true * lines: * type: array * items: * $ref: '#/components/schemas/DeliveryLineInput' * files: * type: array * description: | * Attached documents (pdf or txt) stored with the delivery * and returned in `files` on every delivery response. * Thumbnails (AVIF) are generated server-side. * items: * $ref: '#/components/schemas/DeliveryFileInput' * example: * externalNumber: DHL-123 * date: '2026-08-10' * supplierId: 1 * lines: * - sku: '1' * name: 'Sample Article' * pu: 1 * amount: 10 * net: 99.5 * taxrate: 19 * files: * - mimetype: application/pdf * name: delivery-note.pdf * content: '' * multipart/form-data: * schema: * type: object * required: [data] * properties: * data: * type: string * description: | * JSON string containing manifest header and line items ({ externalNumber, date, supplierId, lines, filesMeta? }). * files: * type: array * items: * type: string * format: binary * description: Raw binary PDF or text files attached natively via standard HTTP multipart upload. * responses: * 201: * description: Created delivery * content: * application/json: * schema: * type: object * properties: * delivery: * $ref: '#/components/schemas/Delivery' * 400: * description: Invalid delivery, invalid lines or files, 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 supplier not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region GET /delivery app.get(`${basePath}/delivery`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const rawOffset = req.query.offset; const rawLimit = req.query.limit; let offset = 0; let limit = 50; if (rawOffset !== undefined) { offset = parseInt(rawOffset, 10); if (!Number.isInteger(offset) || offset < 0) { return res.status(400).json({ error: 'offset must be an integer >= 0' }); } } if (rawLimit !== undefined) { limit = parseInt(rawLimit, 10); if (!Number.isInteger(limit) || limit < 1 || limit > 1000) { return res.status(400).json({ error: 'limit must be an integer between 1 and 1000' }); } } const search = typeof req.query.search === 'string' && req.query.search.trim() !== '' ? req.query.search.trim() : undefined; const onlyUnbooked = req.query.onlyUnbooked === 'true'; let supplierId = undefined; if (req.query.supplierId !== undefined && req.query.supplierId !== '') { const parsedSupplier = parseInt(req.query.supplierId, 10); if (!Number.isInteger(parsedSupplier) || parsedSupplier <= 0) { return res.status(400).json({ error: 'supplierId must be a positive integer' }); } supplierId = parsedSupplier; } let seekMonths = undefined; if (req.query.seekMonths !== undefined && req.query.seekMonths !== '') { const parsedSeek = parseInt(req.query.seekMonths, 10); if (!Number.isInteger(parsedSeek)) { return res.status(400).json({ error: 'seekMonths must be an integer' }); } seekMonths = parsedSeek; } const isPaged = rawOffset !== undefined || rawLimit !== undefined || search !== undefined || onlyUnbooked || supplierId !== undefined || seekMonths !== undefined; try { if (isPaged) { const result = await db.list(req.user.id, { offset, limit, search, onlyUnbooked: onlyUnbooked ? true : undefined, supplierId, seekMonths, }); res.status(200).json(result); } else { const deliveries = await db.list(req.user.id); res.status(200).json({ deliveries: Array.isArray(deliveries) ? deliveries : (deliveries.deliveries || []) }); } } catch (error) { res.locals.error = error; const status = deliveryErrorStatus(error); if (status === 500) console.error('List deliveries failed:', error); res.status(status).json({ error: status === 500 ? 'Internal server error' : error.message }); } }); app.post(`${basePath}/delivery/rebuild-summaries`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } try { const result = await db.rebuildSummaries(req.user.id); res.status(200).json(result); } catch (error) { const status = deliveryErrorStatus(error); if (status === 500) console.error('Rebuild delivery summaries failed:', error); res.status(status).json({ error: status === 500 ? 'Internal server error' : error.message }); } }); // #endregion // #region POST /delivery app.post(`${basePath}/delivery`, uploadDeliveryFiles, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } try { const { externalNumber, date, createdAt, supplierId, lines, files } = parseDeliveryPayload(req); logDeliveryFiles(files, 'create'); const delivery = await db.create(req.user.id, { externalNumber, date, createdAt, supplierId, lines, files, }); res.status(201).json({ delivery }); } catch (error) { res.locals.error = error; const status = deliveryErrorStatus(error); if (status === 500) { logStoreError('Create delivery failed', error); // Dump the full parsed payload (including file metadata) to a log file. try { const { externalNumber, date, supplierId, lines, files } = parseDeliveryPayload(req); writeErrorLog(`POST ${basePath}/delivery 500 (create delivery)`, { payload: { externalNumber, date, supplierId, lines }, files: files?.map((f) => ({ name: f.name, mimetype: f.mimetype, size: f.size, meta: f.meta, })), error: { message: error.message, code: error.code, number: error.number, state: error.state, class: error.class, stack: error.stack, }, }); } catch (logErr) { console.error('[delivery diag] Failed to dump payload:', logErr.message); } } res.status(status).json({ error: status === 500 ? 'Internal server error' : error.message }); } }); // #endregion /** * @openapi * /delivery/{id}: * get: * tags: [Delivery] * summary: Get delivery * description: Returns a goods receipt with lines for the selected company. * security: * - cookieAuth: [] * x-markdown: delivery/get.md * parameters: * - in: path * name: id * required: true * schema: * type: integer * responses: * 200: * description: Delivery * content: * application/json: * schema: * type: object * properties: * delivery: * $ref: '#/components/schemas/Delivery' * 400: * description: No company selected or 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' * 404: * description: Delivery or company not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * patch: * tags: [Delivery] * summary: Update delivery * description: | * Patches header fields that are present. The manifest `number` is * auto-allocated and cannot be patched; only the user-supplied * `externalNumber` reference is editable. If `lines` is present, * replaces all lines; if omitted, leaves lines unchanged. * security: * - cookieAuth: [] * x-markdown: delivery/update.md * parameters: * - in: path * name: id * required: true * schema: * type: integer * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * externalNumber: * type: string * nullable: true * description: User-supplied reference number (empty/null clears it) * date: * type: string * format: date-time * supplierId: * type: integer * nullable: true * lines: * type: array * items: * $ref: '#/components/schemas/DeliveryLineInput' * files: * type: array * description: | * Appends attachments (pdf or txt) to the delivery. * Omit to leave existing files unchanged. * Thumbnails (AVIF) are generated server-side. * items: * $ref: '#/components/schemas/DeliveryFileInput' * example: * externalNumber: DHL-124 * lines: [] * multipart/form-data: * schema: * type: object * properties: * data: * type: string * description: | * JSON string containing patch fields ({ externalNumber, date, supplierId, lines, filesMeta? }). * files: * type: array * items: * type: string * format: binary * description: Raw binary PDF or text files to append to the delivery. * responses: * 200: * description: Updated delivery * content: * application/json: * schema: * type: object * properties: * delivery: * $ref: '#/components/schemas/Delivery' * 400: * description: Invalid delivery, invalid lines, invalid id, 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: Delivery, supplier, or company not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 409: * description: Delivery lines already booked on ledger * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * delete: * tags: [Delivery] * summary: Delete delivery * description: | * Deletes the goods receipt (`manifestarticle` rows cascade). * security: * - cookieAuth: [] * x-markdown: delivery/delete.md * parameters: * - in: path * name: id * required: true * schema: * type: integer * responses: * 204: * description: Deleted * 400: * description: No company selected or 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' * 404: * description: Delivery not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 409: * description: Delivery in use in ledger * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region GET /delivery/:id app.get(`${basePath}/delivery/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const id = Number(req.params.id); if (!Number.isInteger(id) || id <= 0) { return res.status(400).json({ error: 'id is required' }); } try { const delivery = await db.getById(req.user.id, id); res.status(200).json({ delivery }); } catch (error) { res.locals.error = error; const status = deliveryErrorStatus(error); if (status === 500) console.error('Get delivery failed:', error); res.status(status).json({ error: status === 500 ? 'Internal server error' : error.message }); } }); // #endregion // #region PATCH /delivery/:id app.patch(`${basePath}/delivery/:id`, uploadDeliveryFiles, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const id = Number(req.params.id); if (!Number.isInteger(id) || id <= 0) { return res.status(400).json({ error: 'id is required' }); } try { const parsed = parseDeliveryPayload(req); const patch = {}; if (Object.prototype.hasOwnProperty.call(parsed.payload, 'externalNumber')) { patch.externalNumber = parsed.externalNumber; } if (Object.prototype.hasOwnProperty.call(parsed.payload, 'date')) { patch.date = parsed.date; } if (Object.prototype.hasOwnProperty.call(parsed.payload, 'createdAt')) { patch.createdAt = parsed.createdAt; } if (Object.prototype.hasOwnProperty.call(parsed.payload, 'supplierId')) { patch.supplierId = parsed.supplierId; } if (Object.prototype.hasOwnProperty.call(parsed.payload, 'lines')) { patch.lines = parsed.lines; } if (parsed.files !== undefined) { patch.files = parsed.files; } logDeliveryFiles(patch.files, `update ${id}`); const delivery = await db.update(req.user.id, id, patch); res.status(200).json({ delivery }); } catch (error) { res.locals.error = error; const status = deliveryErrorStatus(error); if (status === 500) { logStoreError('Update delivery failed', error); } let errorMessage = error.message; if (status === 500) { errorMessage = 'Internal server error'; } else if ( status === 409 || error.message === 'Delivery lines already booked on ledger' || error.message?.includes('FOREIGN KEY constraint failed') || error.message?.includes('foreign key constraint') || error.message?.includes('REFERENCE constraint') || error.code === 'SQLITE_CONSTRAINT_FOREIGNKEY' || error.number === 547 ) { errorMessage = 'Delivery lines are already booked on the ledger and cannot be modified'; } res.status(status).json({ error: errorMessage }); } }); // #endregion // #region DELETE /delivery/:id app.delete(`${basePath}/delivery/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const id = Number(req.params.id); if (!Number.isInteger(id) || id <= 0) { return res.status(400).json({ error: 'id is required' }); } try { await db.remove(req.user.id, id); res.sendStatus(204); } catch (error) { res.locals.error = error; const status = deliveryErrorStatus(error); if (status === 500) console.error('Delete delivery failed:', error); let errorMessage = error.message; if (status === 500) { errorMessage = 'Internal server error'; } else if (status === 409) { errorMessage = 'Delivery in use in ledger'; } res.status(status).json({ error: errorMessage }); } }); // #endregion /** * @openapi * /delivery/{id}/file/{fileId}: * get: * tags: [Delivery] * summary: Get delivery attachment file content * description: | * Returns the full binary content of an attached file (e.g. PDF or text document) * for the specified delivery. Supports HTTP caching via ETag (SHA-256 hash) and 304 Not Modified. * security: * - cookieAuth: [] * x-markdown: delivery/file.md * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: Delivery (manifest) ID * - in: path * name: fileId * required: true * schema: * type: integer * description: File ID or attachment ID * - in: query * name: download * required: false * schema: * type: boolean * description: When true, sets Content-Disposition to attachment instead of inline * responses: * 200: * description: Full binary file content * headers: * Content-Type: * schema: * type: string * example: application/pdf * Content-Disposition: * schema: * type: string * example: inline; filename="document.pdf" * ETag: * schema: * type: string * Cache-Control: * schema: * type: string * example: private, max-age=86400 * content: * application/pdf: * schema: * type: string * format: binary * text/plain: * schema: * type: string * format: binary * application/octet-stream: * schema: * type: string * format: binary * 304: * description: Not modified (If-None-Match matched ETag) * 400: * description: Invalid id, invalid fileId, 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: Delivery or file not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ const handleGetDeliveryFile = async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const id = Number(req.params.id); if (!Number.isInteger(id) || id <= 0) { return res.status(400).json({ error: 'id is required' }); } const fileId = Number(req.params.fileId); if (!Number.isInteger(fileId) || fileId <= 0) { return res.status(400).json({ error: 'fileId is required' }); } try { const attachment = await db.getFile(req.user.id, id, fileId); if (attachment.hash) { const etag = `"${attachment.hash}"`; res.setHeader('ETag', etag); const ifNoneMatch = req.headers['if-none-match']; if (ifNoneMatch === etag || ifNoneMatch === attachment.hash) { return res.status(304).end(); } } const isDownload = req.query.download === '1' || req.query.download === 'true'; const dispositionType = isDownload ? 'attachment' : 'inline'; const filename = attachment.name || `file-${attachment.fileId}`; const asciiName = filename.replace(/["\r\n\\]/g, '_'); const encodedName = encodeURIComponent(filename).replace(/['()]/g, escape); res.setHeader('Content-Type', attachment.mimetype || 'application/octet-stream'); res.setHeader('Content-Disposition', `${dispositionType}; filename="${asciiName}"; filename*=UTF-8''${encodedName}`); res.setHeader('Cache-Control', 'private, max-age=86400'); if (attachment.content && Buffer.isBuffer(attachment.content)) { res.setHeader('Content-Length', String(attachment.content.length)); return res.status(200).send(attachment.content); } else { const buffer = Buffer.from(attachment.content || ''); res.setHeader('Content-Length', String(buffer.length)); return res.status(200).send(buffer); } } catch (error) { res.locals.error = error; const status = deliveryErrorStatus(error); if (status === 500) console.error('Get delivery file failed:', error); res.status(status).json({ error: status === 500 ? 'Internal server error' : error.message }); } }; // #region GET /delivery/:id/file/:fileId app.get(`${basePath}/delivery/:id/file/:fileId`, handleGetDeliveryFile); // #endregion // #region GET /delivery/:id/files/:fileId app.get(`${basePath}/delivery/:id/files/:fileId`, handleGetDeliveryFile); // #endregion /** * @openapi * /delivery/{id}/pdf: * post: * tags: [Delivery] * summary: Generate and attach delivery note PDF (goods receipt) * description: | * Generates an A4 PDF document for the goods receipt / delivery note with line items and quantities. * Attaches the PDF as a `manifestattachment` to the delivery. * security: * - cookieAuth: [] * x-signals: [delivery] * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: Delivery ID * responses: * 201: * description: PDF generated and attached to delivery * content: * application/json: * schema: * type: object * required: [attachment, fileUrl] * properties: * attachment: * $ref: '#/components/schemas/DeliveryFile' * fileUrl: * type: string * example: /delivery/1/file/5 * 400: * description: Invalid ID 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: Delivery not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region POST /delivery/:id/pdf app.post(`${basePath}/delivery/:id/pdf`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const id = Number(req.params.id); if (!Number.isInteger(id) || id <= 0) { return res.status(400).json({ error: 'id is required' }); } try { const { attachment } = await createAndAttachManifestPdf( req.user.id, selected.companyId, id, 'delivery' ); res.status(201).json({ attachment, fileUrl: `/delivery/${id}/file/${attachment.fileId}`, }); } catch (error) { const status = deliveryErrorStatus(error); if (status === 500) console.error('Create delivery PDF failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /delivery/{id}/file/{fileId}: * delete: * tags: [Delivery] * summary: Delete delivery file attachment * description: | * Removes a file attachment from the delivery and deletes the underlying file. * security: * - cookieAuth: [] * x-signals: [delivery] * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: Delivery ID * - in: path * name: fileId * required: true * schema: * type: integer * description: File ID or attachment ID * responses: * 204: * description: Attachment deleted * 400: * description: Invalid parameters 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: Delivery or attachment not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region DELETE /delivery/:id/file/:fileId app.delete(`${basePath}/delivery/:id/file/:fileId`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const id = Number(req.params.id); if (!Number.isInteger(id) || id <= 0) { return res.status(400).json({ error: 'id is required' }); } const fileId = Number(req.params.fileId); if (!Number.isInteger(fileId) || fileId <= 0) { return res.status(400).json({ error: 'fileId is required' }); } try { await db.removeAttachment(req.user.id, id, fileId); emit('delivery', { companyId: selected.companyId }); res.sendStatus(204); } catch (error) { const status = deliveryErrorStatus(error); if (status === 500) console.error('Delete delivery attachment failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion }