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, ''');
}
_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 '
-
';
const rows = breakdown.map((item) => {
const title = this._escapeHtml(item?.coffee_title || item?.title || 'Coffee');
const packs = Number(item?.packs ?? item?.quantity ?? 0);
return `${title} — ${Number.isFinite(packs) ? packs : 0} pack(s)`;
}).join('');
return ``;
}
_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 `
`;
}
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 = `
${isDe ? 'Hallo' : 'Hi'},
${isDe ? 'Ihr unterschriebener Vertrag ist als PDF im Anhang.' : 'Your signed contract is attached as a PDF.'}
${isDe ? 'Vertragsnummer' : 'Contract number'}: ${this._escapeHtml(displayContractNumber)}
${isDe ? 'Viele Grüße' : 'Best regards'},
ProfitPlanet
`;
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;