This commit is contained in:
seb
2026-09-20 13:14:55 +02:00
parent dbabce2a2d
commit dff254e923
5 changed files with 203 additions and 7 deletions

2
.gitignore vendored
View File

@@ -1,3 +1,3 @@
node_modules/
.env
data/
data/logs/

View File

@@ -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();

View File

@@ -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 });

View File

@@ -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;
}
};
}

55
src/util/errorLog.js Normal file
View File

@@ -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<string, unknown>} 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;
}
}