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 `Signature`; } 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;