attachments
This commit is contained in:
@@ -6,7 +6,31 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const accountingService = require('../services/accounting-service');
|
||||
const multer = require('multer');
|
||||
|
||||
// Limit aus ENV, default 5 MB. Akzeptierte Werte z.B. "5", "10", "20"
|
||||
const ATTACHMENT_MAX_MB = parseInt(process.env.EXPENSE_ATTACHMENT_MAX_MB || '5', 10);
|
||||
const ATTACHMENT_MAX_BYTES = ATTACHMENT_MAX_MB * 1024 * 1024;
|
||||
|
||||
const ALLOWED_MIME = new Set([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/heic',
|
||||
'image/heif',
|
||||
'application/pdf'
|
||||
]);
|
||||
|
||||
const attachUpload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: ATTACHMENT_MAX_BYTES },
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (!ALLOWED_MIME.has(file.mimetype)) {
|
||||
return cb(new Error(`Unsupported file type: ${file.mimetype}. Allowed: PNG, JPG, HEIC, PDF`));
|
||||
}
|
||||
cb(null, true);
|
||||
}
|
||||
});
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
function handleQboError(err, res, context) {
|
||||
const statusCode = err.statusCode || 500;
|
||||
@@ -206,5 +230,50 @@ router.get('/expenses', async (req, res) => {
|
||||
res.json(expenses);
|
||||
} catch (err) { handleQboError(err, res, 'expense-list'); }
|
||||
});
|
||||
// ─── POST /api/accounting/expenses/:id/attach ───────────────────────
|
||||
// Hängt eine Datei (Bild oder PDF) an ein bestehendes QBO Purchase.
|
||||
// Body: multipart/form-data, field "file"
|
||||
router.post('/expenses/:id/attach', (req, res, next) => {
|
||||
// multer-Wrapper, damit wir Multer-Fehler hübsch zurückgeben können
|
||||
attachUpload.single('file')(req, res, (err) => {
|
||||
if (err) {
|
||||
// Spezifische Multer-Fehler übersetzen
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(400).json({
|
||||
error: `File too large. Max ${ATTACHMENT_MAX_MB} MB.`,
|
||||
context: 'attach'
|
||||
});
|
||||
}
|
||||
return res.status(400).json({ error: err.message, context: 'attach' });
|
||||
}
|
||||
next();
|
||||
});
|
||||
}, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const file = req.file;
|
||||
|
||||
if (!file) {
|
||||
return res.status(400).json({ error: 'No file uploaded (field name must be "file")' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await accountingService.attachFileToEntity({
|
||||
entityType: 'Purchase',
|
||||
entityId: id,
|
||||
fileBuffer: file.buffer,
|
||||
fileName: file.originalname,
|
||||
contentType: file.mimetype,
|
||||
note: req.body && req.body.note ? String(req.body.note).slice(0, 200) : undefined
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) { handleQboError(err, res, 'attach'); }
|
||||
});
|
||||
router.get('/attachments/limits', (req, res) => {
|
||||
res.json({
|
||||
maxBytes: ATTACHMENT_MAX_BYTES,
|
||||
maxMb: ATTACHMENT_MAX_MB,
|
||||
allowedMimeTypes: Array.from(ALLOWED_MIME),
|
||||
allowedExtensions: ['.png', '.jpg', '.jpeg', '.heic', '.heif', '.pdf']
|
||||
});
|
||||
});
|
||||
module.exports = router;
|
||||
@@ -981,7 +981,118 @@ function normalizePurchase(p) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hängt ein File an eine bestehende QBO-Transaktion (Purchase, Invoice, ...).
|
||||
*
|
||||
* QBO erwartet multipart/form-data mit zwei Parts:
|
||||
* - file_metadata_0 (JSON: Attachable-Object)
|
||||
* - file_content_0 (binary)
|
||||
*
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.entityType - 'Purchase' | 'Invoice' | etc.
|
||||
* @param {string} opts.entityId - QBO id der Transaktion
|
||||
* @param {Buffer} opts.fileBuffer - Binärdaten
|
||||
* @param {string} opts.fileName - z.B. "receipt.png"
|
||||
* @param {string} opts.contentType - z.B. "image/png", "application/pdf"
|
||||
* @param {string} [opts.note] - Anzeige-Text in QBO (default = fileName)
|
||||
* @returns {{ id, fileName }}
|
||||
*/
|
||||
async function attachFileToEntity({ entityType, entityId, fileBuffer, fileName, contentType, note }) {
|
||||
if (!entityType || !entityId) throw badRequest('entityType and entityId are required');
|
||||
if (!fileBuffer || !fileBuffer.length) throw badRequest('fileBuffer is empty');
|
||||
if (!fileName) throw badRequest('fileName is required');
|
||||
if (!contentType) throw badRequest('contentType is required');
|
||||
|
||||
const { companyId, baseUrl } = getClientInfo();
|
||||
|
||||
// Boundary für multipart
|
||||
const boundary = '----QBOAttachBoundary' + Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
|
||||
const CRLF = '\r\n';
|
||||
|
||||
const metadata = {
|
||||
AttachableRef: [{
|
||||
EntityRef: { type: entityType, value: String(entityId) }
|
||||
}],
|
||||
FileName: fileName,
|
||||
ContentType: contentType,
|
||||
Note: note || fileName
|
||||
};
|
||||
|
||||
// Multipart body als Buffer (Header + Metadata-JSON + File-Bytes + Footer)
|
||||
const head = Buffer.from(
|
||||
`--${boundary}${CRLF}` +
|
||||
`Content-Disposition: form-data; name="file_metadata_0"${CRLF}` +
|
||||
`Content-Type: application/json${CRLF}${CRLF}` +
|
||||
JSON.stringify(metadata) + CRLF +
|
||||
`--${boundary}${CRLF}` +
|
||||
`Content-Disposition: form-data; name="file_content_0"; filename="${fileName}"${CRLF}` +
|
||||
`Content-Type: ${contentType}${CRLF}${CRLF}`,
|
||||
'utf8'
|
||||
);
|
||||
const tail = Buffer.from(`${CRLF}--${boundary}--${CRLF}`, 'utf8');
|
||||
const body = Buffer.concat([head, fileBuffer, tail]);
|
||||
|
||||
const url = withMinorVersion(`${baseUrl}/v3/company/${companyId}/upload`);
|
||||
|
||||
let qboResponse;
|
||||
try {
|
||||
const response = await makeQboApiCall({
|
||||
url,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body
|
||||
});
|
||||
qboResponse = getJson(response);
|
||||
} catch (err) {
|
||||
await writeAuditLog({
|
||||
action: 'attachment.upload',
|
||||
entityType,
|
||||
entityQboId: String(entityId),
|
||||
status: 'error',
|
||||
requestExcerpt: `${fileName} (${contentType}, ${fileBuffer.length} bytes)`,
|
||||
responseExcerpt: err.message
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
// QBO antwortet mit AttachableResponse[0].Attachable bei Erfolg,
|
||||
// oder AttachableResponse[0].Fault bei Fehler.
|
||||
const responses = qboResponse.AttachableResponse || [];
|
||||
const first = responses[0] || {};
|
||||
|
||||
if (first.Fault) {
|
||||
const msg = (first.Fault.Error || []).map(e => `${e.code}: ${e.Message}`).join('; ');
|
||||
await writeAuditLog({
|
||||
action: 'attachment.upload',
|
||||
entityType,
|
||||
entityQboId: String(entityId),
|
||||
status: 'error',
|
||||
requestExcerpt: `${fileName} (${contentType}, ${fileBuffer.length} bytes)`,
|
||||
responseExcerpt: msg
|
||||
});
|
||||
const err = new Error('QBO attach failed: ' + msg);
|
||||
err.qboFault = first.Fault;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const att = first.Attachable;
|
||||
if (!att || !att.Id) throw new Error('QBO returned no attachable id');
|
||||
|
||||
await writeAuditLog({
|
||||
action: 'attachment.upload',
|
||||
entityType,
|
||||
entityQboId: String(entityId),
|
||||
status: 'success',
|
||||
requestExcerpt: `${fileName} (${contentType}, ${fileBuffer.length} bytes)`,
|
||||
responseExcerpt: `Attachable ${att.Id}`
|
||||
});
|
||||
|
||||
console.log(`📎 QBO Attachable created: ${att.Id} (${fileName}) → ${entityType} ${entityId}`);
|
||||
return { id: att.Id, fileName };
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// Exports
|
||||
@@ -1012,6 +1123,9 @@ module.exports = {
|
||||
createExpense,
|
||||
listExpenses,
|
||||
|
||||
// Phase 2 Lieferung 3
|
||||
attachFileToEntity,
|
||||
|
||||
// Audit
|
||||
writeAuditLog
|
||||
};
|
||||
Reference in New Issue
Block a user