refactor: Extract certificate template to separate file with template selection

- Move HTML template to src/templates/certificates/AIcertifikat.html
- Add template rendering with {{variable}} and {{#if}} syntax
- Add CERTIFICATE_TEMPLATES registry for multiple templates
- Accept templateName parameter in generateCertificate
- Add hasCertificate check to prevent duplicate certificates
- Add hasCertificate field to getCombinedTableData query

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
richardtekula
2026-01-29 11:24:14 +01:00
parent 225936e64a
commit 2fee1f39bc
4 changed files with 323 additions and 232 deletions

View File

@@ -269,9 +269,10 @@ export const getStats = async (req, res, next) => {
export const generateCertificate = async (req, res, next) => { export const generateCertificate = async (req, res, next) => {
try { try {
const { registraciaId } = req.params; const { registraciaId } = req.params;
const { templateName } = req.body;
const { generateCertificate: generateCert } = await import('../services/ai-kurzy/certificate.service.js'); const { generateCertificate: generateCert } = await import('../services/ai-kurzy/certificate.service.js');
const result = await generateCert(parseInt(registraciaId)); const result = await generateCert(parseInt(registraciaId), templateName || 'AIcertifikat');
res.status(201).json({ res.status(201).json({
data: result, data: result,

View File

@@ -4,12 +4,30 @@ import fs from 'fs/promises';
import crypto from 'crypto'; import crypto from 'crypto';
import { db } from '../../config/database.js'; import { db } from '../../config/database.js';
import { registracie, ucastnici, kurzy, prilohy } from '../../db/schema.js'; import { registracie, ucastnici, kurzy, prilohy } from '../../db/schema.js';
import { eq } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import { NotFoundError } from '../../utils/errors.js'; import { NotFoundError } from '../../utils/errors.js';
import { logger } from '../../utils/logger.js'; import { logger } from '../../utils/logger.js';
const UPLOAD_DIR = path.join(process.cwd(), 'uploads', 'ai-kurzy', 'certificates'); const UPLOAD_DIR = path.join(process.cwd(), 'uploads', 'ai-kurzy', 'certificates');
const ASSETS_DIR = path.join(process.cwd(), 'src', 'assets', 'certificate'); 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 = {
AIcertifikat: {
name: 'AI Certifikát',
file: 'AIcertifikat.html',
description: 'Osvedčenie o absolvovaní AI kurzu',
},
// Add more templates here in the future
// participation: {
// name: 'Potvrdenie o účasti',
// file: 'participation.html',
// description: 'Potvrdenie o účasti na školení',
// },
};
/** /**
* Format date to Slovak format (DD.MM.YYYY) * Format date to Slovak format (DD.MM.YYYY)
@@ -47,246 +65,88 @@ const loadAssets = async () => {
]); ]);
return { return {
background: `data:image/jpeg;base64,${background.toString('base64')}`, backgroundImage: `data:image/jpeg;base64,${background.toString('base64')}`,
signatureGablasova: `data:image/png;base64,${signatureGablasova.toString('base64')}`, signatureGablasova: `data:image/png;base64,${signatureGablasova.toString('base64')}`,
signatureZdarilek: `data:image/png;base64,${signatureZdarilek.toString('base64')}`, signatureZdarilek: `data:image/png;base64,${signatureZdarilek.toString('base64')}`,
}; };
}; };
/** /**
* Generate HTML template for certificate * Load HTML template from file
*/ */
const generateCertificateHtml = (data, assets) => { const loadTemplate = async (templateName) => {
const { participantName, courseTitle, courseModules, dateFrom, dateTo, issueDate, certificateId } = data; const template = CERTIFICATE_TEMPLATES[templateName];
if (!template) {
// Format date range throw new NotFoundError(`Šablóna "${templateName}" neexistuje`);
let dateRangeText = '';
if (dateFrom && dateTo) {
dateRangeText = `${formatDate(dateFrom)} - ${formatDate(dateTo)}`;
} else if (dateTo) {
dateRangeText = formatDate(dateTo);
} else if (dateFrom) {
dateRangeText = formatDate(dateFrom);
} }
return ` const templatePath = path.join(TEMPLATES_DIR, template.file);
<!DOCTYPE html> try {
<html lang="sk"> return await fs.readFile(templatePath, 'utf-8');
<head> } catch (error) {
<meta charset="UTF-8"> throw new NotFoundError(`Súbor šablóny "${template.file}" nenájdený`);
<meta name="viewport" content="width=device-width, initial-scale=1.0"> }
<title>Osvedčenie - ${participantName}</title> };
<style>
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700&family=Open+Sans:wght@300;400;500;600&display=swap');
* { /**
margin: 0; * Replace template placeholders with actual values
padding: 0; * Supports {{variable}} and {{#if variable}}...{{/if}} syntax
box-sizing: border-box; */
} const renderTemplate = (template, data) => {
let html = template;
@page { // Handle conditional blocks {{#if variable}}...{{/if}}
size: A4 landscape; html = html.replace(/\{\{#if\s+(\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g, (match, variable, content) => {
margin: 0; return data[variable] ? content : '';
} });
html, body { // Replace simple placeholders {{variable}}
width: 297mm; html = html.replace(/\{\{(\w+)\}\}/g, (match, variable) => {
height: 210mm; return data[variable] !== undefined ? data[variable] : '';
overflow: hidden; });
}
body { return html;
font-family: 'Open Sans', Arial, sans-serif; };
background: #fff;
}
.certificate { /**
width: 297mm; * Get list of available templates
height: 210mm; */
position: relative; export const getAvailableTemplates = () => {
background-image: url('${assets.background}'); return Object.entries(CERTIFICATE_TEMPLATES).map(([key, value]) => ({
background-size: cover; id: key,
background-position: center; name: value.name,
background-repeat: no-repeat; description: value.description,
} }));
};
/* Main content area - positioned in the white space */ /**
.content { * Check if certificate already exists for registration
position: absolute; */
top: 28mm; export const hasCertificate = async (registraciaId) => {
left: 0; const [existing] = await db
right: 0; .select({ id: prilohy.id })
text-align: center; .from(prilohy)
} .where(and(
eq(prilohy.registraciaId, registraciaId),
eq(prilohy.typPrilohy, 'certifikat')
))
.limit(1);
.main-title { return !!existing;
font-family: 'Playfair Display', Georgia, serif;
font-size: 52pt;
font-weight: 700;
letter-spacing: 14px;
color: #1a1a1a;
text-transform: uppercase;
margin-bottom: 2mm;
}
.subtitle {
font-family: 'Open Sans', Arial, sans-serif;
font-size: 18pt;
font-weight: 400;
color: #333;
letter-spacing: 1px;
margin-bottom: 18mm;
}
.course-section {
margin-top: 5mm;
}
.course-title {
font-family: 'Playfair Display', Georgia, serif;
font-size: 32pt;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 3mm;
}
.course-modules {
font-family: 'Open Sans', Arial, sans-serif;
font-size: 13pt;
font-weight: 500;
color: #333;
line-height: 1.5;
margin-bottom: 2mm;
}
.course-dates {
font-family: 'Open Sans', Arial, sans-serif;
font-size: 12pt;
font-weight: 400;
color: #444;
}
.participant-name {
font-family: 'Playfair Display', Georgia, serif;
font-size: 36pt;
font-weight: 600;
color: #1a1a1a;
margin-top: 14mm;
}
/* Footer section with signatures */
.footer-section {
position: absolute;
bottom: 28mm;
left: 25mm;
right: 25mm;
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.date-location {
font-family: 'Open Sans', Arial, sans-serif;
font-size: 11pt;
color: #333;
}
.signatures {
display: flex;
gap: 25mm;
}
.signature {
text-align: center;
min-width: 55mm;
}
.signature-image {
height: 18mm;
margin-bottom: 1mm;
object-fit: contain;
}
.signature-line {
width: 55mm;
height: 0.4mm;
background: #333;
margin: 0 auto 2mm auto;
}
.signature-name {
font-family: 'Open Sans', Arial, sans-serif;
font-size: 10pt;
font-weight: 600;
color: #1a1a1a;
}
.signature-title {
font-family: 'Open Sans', Arial, sans-serif;
font-size: 9pt;
font-weight: 400;
color: #555;
}
.certificate-id {
position: absolute;
bottom: 20mm;
right: 25mm;
font-family: 'Open Sans', Arial, sans-serif;
font-size: 7pt;
color: #999;
letter-spacing: 0.5px;
}
</style>
</head>
<body>
<div class="certificate">
<div class="content">
<h1 class="main-title">O S V E D Č E N I E</h1>
<p class="subtitle">o absolvovaní kurzu</p>
<div class="course-section">
<h2 class="course-title">${courseTitle}</h2>
${courseModules ? `<p class="course-modules">${courseModules}</p>` : ''}
${dateRangeText ? `<p class="course-dates">${dateRangeText}</p>` : ''}
</div>
<h3 class="participant-name">${participantName}</h3>
</div>
<div class="footer-section">
<div class="date-location">
V Bratislave ${formatDate(issueDate)}
</div>
<div class="signatures">
<div class="signature">
<img src="${assets.signatureZdarilek}" alt="Podpis" class="signature-image" />
<div class="signature-line"></div>
<p class="signature-name">Mgr. Tomáš Zdarílek</p>
<p class="signature-title">lektor</p>
</div>
<div class="signature">
<img src="${assets.signatureGablasova}" alt="Podpis" class="signature-image" />
<div class="signature-line"></div>
<p class="signature-name">Ing. Jana Gablasová</p>
<p class="signature-title">konateľ</p>
</div>
</div>
</div>
<div class="certificate-id">ID: ${certificateId}</div>
</div>
</body>
</html>
`;
}; };
/** /**
* Generate certificate PDF for a registration * Generate certificate PDF for a registration
* @param {number} registraciaId - Registration ID
* @param {string} templateName - Template name (default: 'AIcertifikat')
*/ */
export const generateCertificate = async (registraciaId) => { 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 // Fetch registration with participant and course data
const [registration] = await db const [registration] = await db
.select({ .select({
@@ -333,8 +193,11 @@ export const generateCertificate = async (registraciaId) => {
throw new NotFoundError('Kurz nenájdený'); throw new NotFoundError('Kurz nenájdený');
} }
// Load assets // Load template and assets
const assets = await loadAssets(); const [templateHtml, assets] = await Promise.all([
loadTemplate(templateName),
loadAssets(),
]);
// Generate certificate data // Generate certificate data
const certificateId = generateCertificateId(registraciaId); const certificateId = generateCertificateId(registraciaId);
@@ -344,18 +207,29 @@ export const generateCertificate = async (registraciaId) => {
const issueDate = registration.datumDo || registration.datumOd || new Date(); const issueDate = registration.datumDo || registration.datumOd || new Date();
const certificateData = { // Format date range
let dateRange = '';
if (registration.datumOd && registration.datumDo) {
dateRange = `${formatDate(registration.datumOd)} - ${formatDate(registration.datumDo)}`;
} else if (registration.datumDo) {
dateRange = formatDate(registration.datumDo);
} else if (registration.datumOd) {
dateRange = formatDate(registration.datumOd);
}
// Prepare template data
const templateData = {
participantName, participantName,
courseTitle: course.nazov, courseTitle: course.nazov,
courseModules: course.popis || null, courseModules: course.popis || '',
dateFrom: registration.datumOd, dateRange,
dateTo: registration.datumDo, issueDate: formatDate(issueDate),
issueDate,
certificateId, certificateId,
...assets,
}; };
// Generate HTML // Render template with data
const html = generateCertificateHtml(certificateData, assets); const html = renderTemplate(templateHtml, templateData);
// Ensure upload directory exists // Ensure upload directory exists
await fs.mkdir(UPLOAD_DIR, { recursive: true }); await fs.mkdir(UPLOAD_DIR, { recursive: true });
@@ -388,7 +262,7 @@ export const generateCertificate = async (registraciaId) => {
margin: { top: 0, right: 0, bottom: 0, left: 0 }, margin: { top: 0, right: 0, bottom: 0, left: 0 },
}); });
logger.info(`Certificate generated: ${fileName}`); logger.info(`Certificate generated: ${fileName} (template: ${templateName})`);
} finally { } finally {
if (browser) { if (browser) {
await browser.close(); await browser.close();
@@ -408,7 +282,7 @@ export const generateCertificate = async (registraciaId) => {
cestaKSuboru: filePath, cestaKSuboru: filePath,
mimeType: 'application/pdf', mimeType: 'application/pdf',
velkostSuboru: stats.size, velkostSuboru: stats.size,
popis: `Certifikát vygenerovaný ${formatDate(new Date())} - ${certificateId}`, popis: `Certifikát vygenerovaný ${formatDate(new Date())} - ${certificateId} (šablóna: ${templateName})`,
}) })
.returning(); .returning();
@@ -419,6 +293,7 @@ export const generateCertificate = async (registraciaId) => {
certificateId, certificateId,
participantName, participantName,
courseTitle: course.nazov, courseTitle: course.nazov,
templateUsed: templateName,
}; };
}; };

View File

@@ -146,6 +146,7 @@ export const getCombinedTableData = async () => {
poznamka: registracie.poznamka, poznamka: registracie.poznamka,
createdAt: registracie.createdAt, createdAt: registracie.createdAt,
dokumentyCount: sql`(SELECT COUNT(*) FROM prilohy WHERE registracia_id = ${registracie.id})::int`, dokumentyCount: sql`(SELECT COUNT(*) FROM prilohy WHERE registracia_id = ${registracie.id})::int`,
hasCertificate: sql`(SELECT COUNT(*) > 0 FROM prilohy WHERE registracia_id = ${registracie.id} AND typ_prilohy = 'certifikat')::boolean`,
}) })
.from(registracie) .from(registracie)
.innerJoin(ucastnici, eq(registracie.ucastnikId, ucastnici.id)) .innerJoin(ucastnici, eq(registracie.ucastnikId, ucastnici.id))

View File

@@ -0,0 +1,214 @@
<!DOCTYPE html>
<html lang="sk">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Osvedčenie - {{participantName}}</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700&family=Open+Sans:wght@300;400;500;600&display=swap');
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
@page {
size: A4 landscape;
margin: 0;
}
html, body {
width: 297mm;
height: 210mm;
overflow: hidden;
}
body {
font-family: 'Open Sans', Arial, sans-serif;
background: #fff;
}
.certificate {
width: 297mm;
height: 210mm;
position: relative;
background-image: url('{{backgroundImage}}');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
/* Main content area - positioned in the white space */
.content {
position: absolute;
top: 28mm;
left: 0;
right: 0;
text-align: center;
}
.main-title {
font-family: 'Playfair Display', Georgia, serif;
font-size: 52pt;
font-weight: 700;
letter-spacing: 14px;
color: #1a1a1a;
text-transform: uppercase;
margin-bottom: 2mm;
}
.subtitle {
font-family: 'Open Sans', Arial, sans-serif;
font-size: 18pt;
font-weight: 400;
color: #333;
letter-spacing: 1px;
margin-bottom: 18mm;
}
.course-section {
margin-top: 5mm;
}
.course-title {
font-family: 'Playfair Display', Georgia, serif;
font-size: 32pt;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 3mm;
}
.course-modules {
font-family: 'Open Sans', Arial, sans-serif;
font-size: 13pt;
font-weight: 500;
color: #333;
line-height: 1.5;
margin-bottom: 2mm;
}
.course-dates {
font-family: 'Open Sans', Arial, sans-serif;
font-size: 12pt;
font-weight: 400;
color: #444;
}
.participant-name {
font-family: 'Playfair Display', Georgia, serif;
font-size: 36pt;
font-weight: 600;
color: #1a1a1a;
margin-top: 14mm;
}
/* Footer section with signatures */
.footer-section {
position: absolute;
bottom: 28mm;
left: 25mm;
right: 25mm;
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.date-location {
font-family: 'Open Sans', Arial, sans-serif;
font-size: 11pt;
color: #333;
}
.signatures {
display: flex;
gap: 25mm;
}
.signature {
text-align: center;
min-width: 55mm;
}
.signature-image {
height: 18mm;
margin-bottom: 1mm;
object-fit: contain;
}
.signature-line {
width: 55mm;
height: 0.4mm;
background: #333;
margin: 0 auto 2mm auto;
}
.signature-name {
font-family: 'Open Sans', Arial, sans-serif;
font-size: 10pt;
font-weight: 600;
color: #1a1a1a;
}
.signature-title {
font-family: 'Open Sans', Arial, sans-serif;
font-size: 9pt;
font-weight: 400;
color: #555;
}
.certificate-id {
position: absolute;
bottom: 20mm;
right: 25mm;
font-family: 'Open Sans', Arial, sans-serif;
font-size: 7pt;
color: #999;
letter-spacing: 0.5px;
}
</style>
</head>
<body>
<div class="certificate">
<div class="content">
<h1 class="main-title">O S V E D Č E N I E</h1>
<p class="subtitle">o absolvovaní kurzu</p>
<div class="course-section">
<h2 class="course-title">{{courseTitle}}</h2>
{{#if courseModules}}
<p class="course-modules">{{courseModules}}</p>
{{/if}}
{{#if dateRange}}
<p class="course-dates">{{dateRange}}</p>
{{/if}}
</div>
<h3 class="participant-name">{{participantName}}</h3>
</div>
<div class="footer-section">
<div class="date-location">
V Bratislave {{issueDate}}
</div>
<div class="signatures">
<div class="signature">
<img src="{{signatureZdarilek}}" alt="Podpis" class="signature-image" />
<div class="signature-line"></div>
<p class="signature-name">Mgr. Tomáš Zdarílek</p>
<p class="signature-title">lektor</p>
</div>
<div class="signature">
<img src="{{signatureGablasova}}" alt="Podpis" class="signature-image" />
<div class="signature-line"></div>
<p class="signature-name">Ing. Jana Gablasová</p>
<p class="signature-title">konateľ</p>
</div>
</div>
</div>
<div class="certificate-id">ID: {{certificateId}}</div>
</div>
</body>
</html>