diff --git a/.gitignore b/.gitignore index 5563af0..f994e43 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ node_modules/ .env -data/ \ No newline at end of file +data/logs/ diff --git a/src/api/middleware/logger.js b/src/api/middleware/logger.js index 10508fe..e36a865 100644 --- a/src/api/middleware/logger.js +++ b/src/api/middleware/logger.js @@ -1,5 +1,6 @@ import chalk from 'chalk'; import { isEmpty, prettyJson } from '../../util/prettyJson.js'; +import { writeErrorLog } from '../../util/errorLog.js'; function logField(label, value, { skipEmpty = false } = {}) { if (skipEmpty && isEmpty(value)) return; @@ -56,6 +57,60 @@ export const loggerMiddleware = (req, res, next) => { } logField('body', req.body ?? {}, { skipEmpty: true }); logField('response', getBody()); + + // Dump full request/response/error details to a log file on 5xx. + const statusCode = res.statusCode; + if (statusCode >= 500) { + const body = getBody(); + writeErrorLog( + `${req.method} ${req.originalUrl} ${statusCode}`, + { + request: { + method: req.method, + url: req.originalUrl, + headers: req.headers, + params: req.params, + query: req.query, + }, + body: req.body, + files: Array.isArray(req.files) + ? req.files.map((f) => ({ + fieldname: f.fieldname, + originalname: f.originalname, + mimetype: f.mimetype, + size: f.size, + encoding: f.encoding, + bufferHeadHex: f.buffer + ? [...f.buffer.subarray(0, 32)] + .map((b) => b.toString(16).padStart(2, '0')) + .join(' ') + : null, + })) + : undefined, + response: { + status: statusCode, + body, + }, + error: res.locals?.error + ? { + message: res.locals.error.message, + code: res.locals.error.code, + number: res.locals.error.number, + state: res.locals.error.state, + class: res.locals.error.class, + lineNumber: res.locals.error.lineNumber, + procName: res.locals.error.procName, + stack: res.locals.error.stack, + precedingErrors: res.locals.error.precedingErrors?.map((e) => ({ + message: e.message, + number: e.number, + state: e.state, + })), + } + : undefined, + }, + ); + } }); next(); diff --git a/src/api/routes/delivery.js b/src/api/routes/delivery.js index e3f6169..689b082 100644 --- a/src/api/routes/delivery.js +++ b/src/api/routes/delivery.js @@ -1,5 +1,6 @@ import multer from 'multer'; import * as db from '../../db/delivery.js'; +import { writeErrorLog } from '../../util/errorLog.js'; // Shared memory storage for delivery attachments (max 50 MB) const multerUpload = multer({ @@ -105,6 +106,51 @@ function requireSelectedCompany(req) { 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 tedious/mssql 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; @@ -293,6 +339,7 @@ export function register(app, basePath = '') { 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: error.message }); @@ -307,6 +354,7 @@ export function register(app, basePath = '') { try { const { externalNumber, date, supplierId, lines, files } = parseDeliveryPayload(req); + logDeliveryFiles(files, 'create'); const delivery = await db.create(req.user.id, { externalNumber, date, @@ -316,8 +364,34 @@ export function register(app, basePath = '') { }); res.status(201).json({ delivery }); } catch (error) { + res.locals.error = error; const status = deliveryErrorStatus(error); - if (status === 500) console.error('Create delivery failed:', 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: error.message }); } }); @@ -526,6 +600,7 @@ export function register(app, basePath = '') { 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: error.message }); @@ -562,11 +637,15 @@ export function register(app, basePath = '') { 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) console.error('Update delivery failed:', error); + if (status === 500) { + logStoreError('Update delivery failed', error); + } res.status(status).json({ error: error.message }); } }); @@ -586,6 +665,7 @@ export function register(app, basePath = '') { 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); res.status(status).json({ error: error.message }); @@ -729,6 +809,7 @@ export function register(app, basePath = '') { 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: error.message }); diff --git a/src/db/store.js b/src/db/store.js index 0ed663b..444de2d 100644 --- a/src/db/store.js +++ b/src/db/store.js @@ -48,12 +48,17 @@ function wrapStoreWithTiming(rawStore) { api[method] = async (...args) => { const start = Date.now(); try { - return await fn(...args); - } finally { - const duration = Date.now() - start; + const result = await fn(...args); console.log( - `${chalk.magenta('libstore')} ${namespace}.${method} ${chalk.gray(`(${duration}ms)`)}` + `${chalk.magenta('libstore')} ${namespace}.${method} ${chalk.gray(`(${Date.now() - start}ms)`)}` ); + return result; + } catch (err) { + console.error( + `${chalk.magenta('libstore')} ${namespace}.${method} ${chalk.red('FAILED')} ` + + `${chalk.gray(`(${Date.now() - start}ms)`)} ${chalk.red(err.message)}` + ); + throw err; } }; } diff --git a/src/util/errorLog.js b/src/util/errorLog.js new file mode 100644 index 0000000..e48573c --- /dev/null +++ b/src/util/errorLog.js @@ -0,0 +1,55 @@ +import { appendFileSync, mkdirSync } from 'fs'; +import { dirname, resolve } from 'path'; +import { fileURLToPath } from 'url'; +import { prettyJson } from '../util/prettyJson.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const LOG_DIR = process.env.ERROR_LOG_DIR || resolve(__dirname, '../../logs/errors'); +const LOG_PREFIX = 'error-'; + +let dirEnsured = false; + +function ensureLogDir() { + if (dirEnsured) return; + mkdirSync(LOG_DIR, { recursive: true }); + dirEnsured = true; +} + +/** + * Writes a plain-text error log file (no ANSI colors) to the error log + * directory and returns the file path. Never throws. + * @param {string} label Short description, e.g. "POST /api/delivery 500" + * @param {Record} sections Named sections (request, body, response, error…) + * @returns {string | null} Path of the log file, or null on failure + */ +export function writeErrorLog(label, sections) { + try { + ensureLogDir(); + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + const safeLabel = label + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40); + const filePath = resolve(LOG_DIR, `${LOG_PREFIX}${stamp}-${safeLabel}.log`); + const divider = '='.repeat(80); + + const parts = [ + `Timestamp: ${new Date().toISOString()}`, + `Label: ${label}`, + divider, + ]; + + for (const [name, value] of Object.entries(sections)) { + parts.push(`--- ${name} ${'-'.repeat(Math.max(0, 74 - name.length))}`); + parts.push(prettyJson(value, { colors: false })); + } + + appendFileSync(filePath, `${parts.join('\n')}\n`); + console.error(`[error-log] Wrote ${filePath}`); + return filePath; + } catch (err) { + console.error('[error-log] Failed to write error log:', err.message); + return null; + } +}