feat: Team leader role permissions, certificate generation, and bug fixes

- Add team_leader access to /admin/users endpoint for user list viewing
- Add PDF certificate generation for AI Kurzy with Puppeteer
- Add certificate assets (background, signatures)
- Add getPrilohaById and download endpoint for attachments
- Fix time tracking service permissions for team_leader
- Fix timesheet controller/service permissions for team_leader
- Fix calendar badge to include reminders in count
- Add lastSeen to message service for online indicator

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
richardtekula
2026-01-29 10:58:42 +01:00
parent a4a81ef88e
commit 225936e64a
18 changed files with 1229 additions and 54 deletions

View File

@@ -0,0 +1,451 @@
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 } 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');
/**
* 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 () => {
const [background, signatureGablasova, signatureZdarilek] = await Promise.all([
fs.readFile(path.join(ASSETS_DIR, 'background.jpeg')),
fs.readFile(path.join(ASSETS_DIR, 'signature-gablasova.png')),
fs.readFile(path.join(ASSETS_DIR, 'signature-zdarilek.png')),
]);
return {
background: `data:image/jpeg;base64,${background.toString('base64')}`,
signatureGablasova: `data:image/png;base64,${signatureGablasova.toString('base64')}`,
signatureZdarilek: `data:image/png;base64,${signatureZdarilek.toString('base64')}`,
};
};
/**
* Generate HTML template for certificate
*/
const generateCertificateHtml = (data, assets) => {
const { participantName, courseTitle, courseModules, dateFrom, dateTo, issueDate, certificateId } = data;
// Format date range
let dateRangeText = '';
if (dateFrom && dateTo) {
dateRangeText = `${formatDate(dateFrom)} - ${formatDate(dateTo)}`;
} else if (dateTo) {
dateRangeText = formatDate(dateTo);
} else if (dateFrom) {
dateRangeText = formatDate(dateFrom);
}
return `
<!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('${assets.background}');
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>
${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
*/
export const generateCertificate = async (registraciaId) => {
// Fetch registration with participant and course data
const [registration] = await db
.select({
id: registracie.id,
datumOd: registracie.datumOd,
datumDo: registracie.datumDo,
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,
})
.from(kurzy)
.where(eq(kurzy.id, registration.kurzId))
.limit(1);
if (!course) {
throw new NotFoundError('Kurz nenájdený');
}
// Load assets
const assets = await loadAssets();
// Generate certificate data
const certificateId = generateCertificateId(registraciaId);
const participantName = [participant.titul, participant.meno, participant.priezvisko]
.filter(Boolean)
.join(' ');
const issueDate = registration.datumDo || registration.datumOd || new Date();
const certificateData = {
participantName,
courseTitle: course.nazov,
courseModules: course.popis || null,
dateFrom: registration.datumOd,
dateTo: registration.datumDo,
issueDate,
certificateId,
};
// Generate HTML
const html = generateCertificateHtml(certificateData, assets);
// 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 {
browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
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}`);
} 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}`,
})
.returning();
return {
id: attachment.id,
fileName,
filePath,
certificateId,
participantName,
courseTitle: course.nazov,
};
};
/**
* 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,
};
};