Files
dashback/src/api/routes/delivery.js
2026-10-14 23:26:09 +02:00

1085 lines
40 KiB
JavaScript

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 === '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 === '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.
* security:
* - cookieAuth: []
* x-markdown: delivery/list.md
* responses:
* 200:
* description: Delivery list
* content:
* application/json:
* schema:
* type: object
* properties:
* deliveries:
* type: array
* items:
* $ref: '#/components/schemas/DeliverySummary'
* 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: '<base64 encoded 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 });
}
try {
const deliveries = await db.list(req.user.id);
res.status(200).json({ 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 });
}
});
// #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
}