188 lines
6.6 KiB
JavaScript
188 lines
6.6 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const puppeteer = require('puppeteer');
|
|
const { PutObjectCommand } = require('@aws-sdk/client-s3');
|
|
const { s3: sharedExoscaleClient } = require('../../utils/exoscaleUploader');
|
|
const DocumentTemplateService = require('../template/DocumentTemplateService');
|
|
const MailService = require('../email/MailService');
|
|
const { logger } = require('../../middleware/logger');
|
|
|
|
class AboContractService {
|
|
constructor() {
|
|
this.templatePath = path.join(__dirname, '..', '..', 'templates', 'abo', 'abo-contract-template.html');
|
|
}
|
|
|
|
_escapeHtml(value) {
|
|
return String(value ?? '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
_formatDateTime(d = new Date()) {
|
|
const pad = (n) => String(n).padStart(2, '0');
|
|
return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
}
|
|
|
|
_sanitizeKeyPart(input, fallback) {
|
|
const raw = String(input ?? '').trim();
|
|
const safe = raw.replace(/[^a-zA-Z0-9._-]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 80);
|
|
return safe || fallback;
|
|
}
|
|
|
|
_renderTemplate(template, variables, { rawKeys = new Set() } = {}) {
|
|
if (!template) return '';
|
|
return template.replace(/{{\s*([\w]+)\s*}}/g, (_, key) => {
|
|
const value = variables[key];
|
|
if (value === undefined || value === null) return '';
|
|
if (rawKeys.has(key)) return String(value);
|
|
return this._escapeHtml(String(value));
|
|
});
|
|
}
|
|
|
|
_buildSelectedProductsHtml(abonement) {
|
|
const breakdown = Array.isArray(abonement?.pack_breakdown) ? abonement.pack_breakdown : [];
|
|
if (!breakdown.length) return '<p>-</p>';
|
|
|
|
const rows = breakdown.map((item) => {
|
|
const title = this._escapeHtml(item?.coffee_title || item?.title || 'Coffee');
|
|
const packs = Number(item?.packs ?? item?.quantity ?? 0);
|
|
return `<li>${title} — ${Number.isFinite(packs) ? packs : 0} pack(s)</li>`;
|
|
}).join('');
|
|
|
|
return `<ul>${rows}</ul>`;
|
|
}
|
|
|
|
_buildSignatureImgHtml(signatureDataUrl) {
|
|
const s = typeof signatureDataUrl === 'string' ? signatureDataUrl.trim() : '';
|
|
if (!s) return '';
|
|
// Basic safety: only accept data:image/... urls
|
|
if (!/^data:image\//i.test(s)) return '';
|
|
const escaped = this._escapeHtml(s);
|
|
return `<img alt="Signature" src="${escaped}" style="max-width:280px;max-height:120px;" />`;
|
|
}
|
|
|
|
async _renderPdfFromHtml(html) {
|
|
const browser = await puppeteer.launch({
|
|
headless: 'new',
|
|
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
});
|
|
try {
|
|
const page = await browser.newPage();
|
|
await page.setContent(html, { waitUntil: 'networkidle0' });
|
|
const pdfBuffer = await page.pdf({
|
|
format: 'A4',
|
|
printBackground: true,
|
|
margin: { top: '16mm', right: '14mm', bottom: '16mm', left: '14mm' },
|
|
});
|
|
return Buffer.isBuffer(pdfBuffer) ? pdfBuffer : Buffer.from(pdfBuffer);
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
}
|
|
|
|
async generateUploadAndEmail({
|
|
abonement,
|
|
actorUser,
|
|
contractNumber,
|
|
signatureDataUrl,
|
|
lang = 'en',
|
|
}) {
|
|
if (!abonement?.id) throw new Error('abonement is required');
|
|
|
|
let template;
|
|
try {
|
|
template = fs.readFileSync(this.templatePath, 'utf8');
|
|
} catch (e) {
|
|
logger.error('AboContractService:template_missing', { templatePath: this.templatePath, message: e?.message });
|
|
throw new Error('ABO contract template missing');
|
|
}
|
|
|
|
const displayContractNumber = String(contractNumber || '').trim() || `ABO-${abonement.id}`;
|
|
const contractKeyPart = this._sanitizeKeyPart(displayContractNumber, `abo-${abonement.id}`);
|
|
|
|
let profitplanetSignature = '';
|
|
try {
|
|
const sig = await DocumentTemplateService.getProfitPlanetSignatureTag({ maxW: 300, maxH: 300 });
|
|
profitplanetSignature = sig?.tag || '';
|
|
} catch (e) {
|
|
logger.warn('AboContractService:getProfitPlanetSignatureTag_failed', { message: e?.message });
|
|
}
|
|
|
|
const variables = {
|
|
contractNumber: displayContractNumber,
|
|
currentDate: this._formatDateTime(new Date()),
|
|
firstName: abonement.first_name || '',
|
|
lastName: abonement.last_name || '',
|
|
email: abonement.email || '',
|
|
street: abonement.street || '',
|
|
postalCode: abonement.postal_code || '',
|
|
city: abonement.city || '',
|
|
country: abonement.country || '',
|
|
frequency: abonement.frequency || '',
|
|
price: abonement.price != null ? String(abonement.price) : '',
|
|
currency: abonement.currency || 'EUR',
|
|
selectedProductsHtml: this._buildSelectedProductsHtml(abonement),
|
|
profitplanetSignature,
|
|
signatureImage: this._buildSignatureImgHtml(signatureDataUrl),
|
|
};
|
|
|
|
const html = this._renderTemplate(template, variables, {
|
|
rawKeys: new Set(['selectedProductsHtml', 'profitplanetSignature', 'signatureImage']),
|
|
});
|
|
|
|
const pdfBuffer = await this._renderPdfFromHtml(html);
|
|
|
|
const key = `abo/${abonement.id}/${contractKeyPart}.pdf`;
|
|
await sharedExoscaleClient.send(new PutObjectCommand({
|
|
Bucket: process.env.EXOSCALE_BUCKET,
|
|
Key: key,
|
|
Body: pdfBuffer,
|
|
ContentType: 'application/pdf',
|
|
}));
|
|
|
|
const recipientEmail = abonement.email || actorUser?.email;
|
|
if (recipientEmail) {
|
|
const isDe = lang === 'de';
|
|
const subject = isDe
|
|
? `ProfitPlanet Vertrag ${displayContractNumber}`
|
|
: `ProfitPlanet Contract ${displayContractNumber}`;
|
|
|
|
const text = isDe
|
|
? 'Ihr unterschriebener Vertrag ist als PDF im Anhang.'
|
|
: 'Your signed contract is attached as a PDF.';
|
|
|
|
const mailHtml = `<!doctype html>
|
|
<html>
|
|
<head><meta charset="utf-8"></head>
|
|
<body style="font-family:Arial,sans-serif;">
|
|
<p>${isDe ? 'Hallo' : 'Hi'},</p>
|
|
<p>${isDe ? 'Ihr unterschriebener Vertrag ist als PDF im Anhang.' : 'Your signed contract is attached as a PDF.'}</p>
|
|
<p>${isDe ? 'Vertragsnummer' : 'Contract number'}: <strong>${this._escapeHtml(displayContractNumber)}</strong></p>
|
|
<p>${isDe ? 'Viele Grüße' : 'Best regards'},<br>ProfitPlanet</p>
|
|
</body>
|
|
</html>`;
|
|
|
|
await MailService.sendAboContractEmail({
|
|
email: recipientEmail,
|
|
subject,
|
|
text,
|
|
html: mailHtml,
|
|
lang,
|
|
attachments: [{
|
|
name: `${contractKeyPart}.pdf`,
|
|
content: pdfBuffer.toString('base64'),
|
|
}],
|
|
});
|
|
} else {
|
|
logger.warn('AboContractService:missing_recipient_email', { abonementId: abonement.id });
|
|
}
|
|
|
|
return { storageKey: key, contractNumber: displayContractNumber };
|
|
}
|
|
}
|
|
|
|
module.exports = AboContractService;
|