import puppeteer from 'puppeteer'; import path from 'path'; import fs from 'fs/promises'; import crypto from 'crypto'; import { db } from '../../config/database.js'; import { registracie, ucastnici, kurzy, prilohy } from '../../db/schema.js'; import { eq, and } from 'drizzle-orm'; import { NotFoundError } from '../../utils/errors.js'; import { logger } from '../../utils/logger.js'; const UPLOAD_DIR = path.join(process.cwd(), 'uploads', 'ai-kurzy', 'certificates'); const ASSETS_DIR = path.join(process.cwd(), 'src', 'assets', 'certificate'); const TEMPLATES_DIR = path.join(process.cwd(), 'src', 'templates', 'certificates'); /** * Available certificate templates */ export const CERTIFICATE_TEMPLATES = { // AI certifikáty s dynamickým názvom kurzu a rôznymi lektormi AIcertifikat: { name: 'AI Certifikát (Zdarílek)', file: 'AIcertifikat.html', description: 'AI kurz - lektor Zdarílek + Gablasová', background: 'background.jpeg', }, AIcertifikatGablas: { name: 'AI Certifikát (Gablas)', file: 'AIcertifikatGablas.html', description: 'AI kurz - lektor Gablas + Gablasová', background: 'background-blue.jpeg', }, AIcertifikatPatrik: { name: 'AI Certifikát (Patrik)', file: 'AIcertifikatPatrik.html', description: 'AI kurz - lektor Patrik + Gablasová', background: 'background.jpeg', }, // Scrum certifikáty - fixný názov kurzu, podpisy v pozadí ScrumMaster: { name: 'Scrum Master', file: 'ScrumMaster.html', description: 'Scrum Master - Gablas + Gablasová', background: 'background-blue.jpeg', }, ScrumProductOwner: { name: 'Scrum Product Owner', file: 'ScrumProductOwner.html', description: 'Scrum Product Owner - Gablas + Gablasová', background: 'background-blue.jpeg', }, // ITIL certifikát - fixný názov kurzu, podpisy v pozadí ITILFoundation: { name: 'ITIL® 4 Foundation', file: 'ITILFoundation.html', description: 'ITIL Foundation - Husam + Gablasová', background: 'background-green.jpeg', }, // PRINCE2 certifikáty - fixný názov kurzu, podpisy v pozadí PRINCE2Foundation: { name: 'PRINCE2® Foundation', file: 'PRINCE2Foundation.html', description: 'PRINCE2 Foundation - Gablas + Gablasová', background: 'background-orange.jpeg', }, PRINCE2Practitioner: { name: 'PRINCE2® Practitioner', file: 'PRINCE2Practitioner.html', description: 'PRINCE2 Practitioner - Gablas + Gablasová', background: 'background-orange.jpeg', }, }; /** * Format date to Slovak format (DD.MM.YYYY) */ const formatDate = (date) => { if (!date) return ''; const d = new Date(date); return d.toLocaleDateString('sk-SK', { day: '2-digit', month: '2-digit', year: 'numeric', }); }; /** * Generate unique certificate ID */ const generateCertificateId = (registraciaId) => { const hash = crypto.createHash('sha256') .update(`${registraciaId}-${Date.now()}`) .digest('hex') .substring(0, 8) .toUpperCase(); return `CERT-${hash}`; }; /** * Load assets as base64 for embedding in HTML */ const loadAssets = async (templateName) => { const template = CERTIFICATE_TEMPLATES[templateName]; const backgroundFile = template?.background || 'background.jpeg'; const [background, signatureGablasova, signatureZdarilek, signatureGablas, signaturePatrik] = await Promise.all([ fs.readFile(path.join(ASSETS_DIR, backgroundFile)), fs.readFile(path.join(ASSETS_DIR, 'signature-gablasova.png')), fs.readFile(path.join(ASSETS_DIR, 'signature-zdarilek.png')), fs.readFile(path.join(ASSETS_DIR, 'signature-gablas.png')), fs.readFile(path.join(ASSETS_DIR, 'signature-patrik.png')), ]); return { backgroundImage: `data:image/jpeg;base64,${background.toString('base64')}`, signatureGablasova: `data:image/png;base64,${signatureGablasova.toString('base64')}`, signatureZdarilek: `data:image/png;base64,${signatureZdarilek.toString('base64')}`, signatureGablas: `data:image/png;base64,${signatureGablas.toString('base64')}`, signaturePatrik: `data:image/png;base64,${signaturePatrik.toString('base64')}`, }; }; /** * Load HTML template from file */ const loadTemplate = async (templateName) => { const template = CERTIFICATE_TEMPLATES[templateName]; if (!template) { throw new NotFoundError(`Šablóna "${templateName}" neexistuje`); } const templatePath = path.join(TEMPLATES_DIR, template.file); try { return await fs.readFile(templatePath, 'utf-8'); } catch (error) { throw new NotFoundError(`Súbor šablóny "${template.file}" nenájdený`); } }; /** * Replace template placeholders with actual values * Supports {{variable}} and {{#if variable}}...{{/if}} syntax */ const renderTemplate = (template, data) => { let html = template; // Handle conditional blocks {{#if variable}}...{{/if}} html = html.replace(/\{\{#if\s+(\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g, (match, variable, content) => { return data[variable] ? content : ''; }); // Replace simple placeholders {{variable}} html = html.replace(/\{\{(\w+)\}\}/g, (match, variable) => { return data[variable] !== undefined ? data[variable] : ''; }); return html; }; /** * Get list of available templates */ export const getAvailableTemplates = () => { return Object.entries(CERTIFICATE_TEMPLATES).map(([key, value]) => ({ id: key, name: value.name, description: value.description, })); }; /** * Check if certificate already exists for registration */ export const hasCertificate = async (registraciaId) => { const [existing] = await db .select({ id: prilohy.id }) .from(prilohy) .where(and( eq(prilohy.registraciaId, registraciaId), eq(prilohy.typPrilohy, 'certifikat') )) .limit(1); return !!existing; }; /** * Generate certificate PDF for a registration * @param {number} registraciaId - Registration ID * @param {string} templateName - Template name (default: 'AIcertifikat') */ export const generateCertificate = async (registraciaId, templateName = 'AIcertifikat') => { // Check if certificate already exists const exists = await hasCertificate(registraciaId); if (exists) { throw new Error('Certifikát pre túto registráciu už existuje'); } // Fetch registration with participant and course data const [registration] = await db .select({ id: registracie.id, ucastnikId: registracie.ucastnikId, kurzId: registracie.kurzId, }) .from(registracie) .where(eq(registracie.id, registraciaId)) .limit(1); if (!registration) { throw new NotFoundError('Registrácia nenájdená'); } // Fetch participant const [participant] = await db .select({ titul: ucastnici.titul, meno: ucastnici.meno, priezvisko: ucastnici.priezvisko, }) .from(ucastnici) .where(eq(ucastnici.id, registration.ucastnikId)) .limit(1); if (!participant) { throw new NotFoundError('Účastník nenájdený'); } // Fetch course const [course] = await db .select({ nazov: kurzy.nazov, popis: kurzy.popis, datumOd: kurzy.datumOd, datumDo: kurzy.datumDo, }) .from(kurzy) .where(eq(kurzy.id, registration.kurzId)) .limit(1); if (!course) { throw new NotFoundError('Kurz nenájdený'); } // Load template and assets const [templateHtml, assets] = await Promise.all([ loadTemplate(templateName), loadAssets(templateName), ]); // Generate certificate data const certificateId = generateCertificateId(registraciaId); const participantName = [participant.titul, participant.meno, participant.priezvisko] .filter(Boolean) .join(' '); const issueDate = course.datumDo || course.datumOd || new Date(); // Format date range let dateRange = ''; if (course.datumOd && course.datumDo) { dateRange = `${formatDate(course.datumOd)} - ${formatDate(course.datumDo)}`; } else if (course.datumDo) { dateRange = formatDate(course.datumDo); } else if (course.datumOd) { dateRange = formatDate(course.datumOd); } // Prepare template data const templateData = { participantName, courseTitle: course.nazov, courseModules: course.popis || '', dateRange, issueDate: formatDate(issueDate), certificateId, ...assets, }; // Render template with data const html = renderTemplate(templateHtml, templateData); // Ensure upload directory exists await fs.mkdir(UPLOAD_DIR, { recursive: true }); // Generate filename const safeName = participantName .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/[^a-zA-Z0-9 ]/g, '') .replace(/\s+/g, '_'); const fileName = `certifikat_${safeName}_${certificateId}.pdf`; const filePath = path.join(UPLOAD_DIR, fileName); // Launch Puppeteer and generate PDF let browser; try { const launchOptions = { headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], }; // Use PUPPETEER_EXECUTABLE_PATH env var if set (for Docker), otherwise try system paths const executablePath = process.env.PUPPETEER_EXECUTABLE_PATH; if (executablePath) { launchOptions.executablePath = executablePath; logger.info(`Using Chrome from PUPPETEER_EXECUTABLE_PATH: ${executablePath}`); } try { browser = await puppeteer.launch(launchOptions); } catch (launchError) { // If no env var set, try common system Chrome paths if (!executablePath) { const systemChromePaths = [ '/usr/bin/chromium-browser', '/usr/bin/chromium', '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', ]; for (const chromePath of systemChromePaths) { try { await fs.access(chromePath); logger.info(`Using system Chrome at: ${chromePath}`); browser = await puppeteer.launch({ ...launchOptions, executablePath: chromePath, }); break; } catch { // Continue to next path } } } if (!browser) { throw new Error('Chrome nie je nainštalovaný. Pridajte do Dockerfile: RUN apk add --no-cache chromium && ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser'); } } const page = await browser.newPage(); await page.setContent(html, { waitUntil: 'networkidle0' }); await page.pdf({ path: filePath, width: '297mm', height: '210mm', printBackground: true, margin: { top: 0, right: 0, bottom: 0, left: 0 }, }); logger.info(`Certificate generated: ${fileName} (template: ${templateName})`); } finally { if (browser) { await browser.close(); } } // Get file stats const stats = await fs.stat(filePath); // Save as priloha (attachment) const [attachment] = await db .insert(prilohy) .values({ registraciaId, nazovSuboru: fileName, typPrilohy: 'certifikat', cestaKSuboru: filePath, mimeType: 'application/pdf', velkostSuboru: stats.size, popis: `Certifikát vygenerovaný ${formatDate(new Date())} - ${certificateId} (šablóna: ${templateName})`, }) .returning(); return { id: attachment.id, fileName, filePath, certificateId, participantName, courseTitle: course.nazov, templateUsed: templateName, }; }; /** * Get certificate download info */ export const getCertificateDownloadInfo = async (prilohaId) => { const [attachment] = await db .select() .from(prilohy) .where(eq(prilohy.id, prilohaId)) .limit(1); if (!attachment) { throw new NotFoundError('Certifikát nenájdený'); } // Check if file exists try { await fs.access(attachment.cestaKSuboru); } catch { throw new NotFoundError('Súbor certifikátu nenájdený na serveri'); } return { filePath: attachment.cestaKSuboru, fileName: attachment.nazovSuboru, mimeType: attachment.mimeType, }; };