- Install chromium in Alpine Dockerfile - Add PUPPETEER_EXECUTABLE_PATH env var support - Fallback to system Chrome paths if bundled Chrome not found Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
413 lines
12 KiB
JavaScript
413 lines
12 KiB
JavaScript
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,
|
|
};
|
|
};
|