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:
BIN
src/assets/certificate/background.jpeg
Normal file
BIN
src/assets/certificate/background.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 621 KiB |
BIN
src/assets/certificate/signature-gablasova.png
Normal file
BIN
src/assets/certificate/signature-gablasova.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
src/assets/certificate/signature-zdarilek.png
Normal file
BIN
src/assets/certificate/signature-zdarilek.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -231,6 +231,28 @@ export const deletePriloha = async (req, res, next) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const downloadPriloha = async (req, res, next) => {
|
||||
try {
|
||||
const { prilohaId } = req.params;
|
||||
const priloha = await aiKurzyService.getPrilohaById(parseInt(prilohaId));
|
||||
|
||||
if (!priloha) {
|
||||
return res.status(404).json({ success: false, error: { message: 'Príloha nenájdená' } });
|
||||
}
|
||||
|
||||
const fs = await import('fs/promises');
|
||||
try {
|
||||
await fs.access(priloha.cestaKSuboru);
|
||||
} catch {
|
||||
return res.status(404).json({ success: false, error: { message: 'Súbor nenájdený na serveri' } });
|
||||
}
|
||||
|
||||
res.download(priloha.cestaKSuboru, priloha.nazovSuboru);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== STATISTICS ====================
|
||||
|
||||
export const getStats = async (req, res, next) => {
|
||||
@@ -241,3 +263,21 @@ export const getStats = async (req, res, next) => {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== CERTIFICATE ====================
|
||||
|
||||
export const generateCertificate = async (req, res, next) => {
|
||||
try {
|
||||
const { registraciaId } = req.params;
|
||||
const { generateCertificate: generateCert } = await import('../services/ai-kurzy/certificate.service.js');
|
||||
|
||||
const result = await generateCert(parseInt(registraciaId));
|
||||
|
||||
res.status(201).json({
|
||||
data: result,
|
||||
message: 'Certifikát bol vygenerovaný',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -168,7 +168,8 @@ export const getMonthlyTimeEntries = async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
const userRole = req.user.role;
|
||||
const targetUserId = userRole === 'admin' && req.query.userId ? req.query.userId : userId;
|
||||
const hasFullAccess = userRole === 'admin' || userRole === 'team_leader';
|
||||
const targetUserId = hasFullAccess && req.query.userId ? req.query.userId : userId;
|
||||
const { year, month } = req.params;
|
||||
|
||||
const entries = await timeTrackingService.getMonthlyTimeEntries(
|
||||
@@ -195,7 +196,8 @@ export const generateMonthlyTimesheet = async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
const userRole = req.user.role;
|
||||
const targetUserId = userRole === 'admin' && req.query.userId ? req.query.userId : userId;
|
||||
const hasFullAccess = userRole === 'admin' || userRole === 'team_leader';
|
||||
const targetUserId = hasFullAccess && req.query.userId ? req.query.userId : userId;
|
||||
const { year, month } = req.params;
|
||||
|
||||
const result = await timeTrackingService.generateMonthlyTimesheet(
|
||||
@@ -312,7 +314,8 @@ export const getMonthlyStats = async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
const userRole = req.user.role;
|
||||
const targetUserId = userRole === 'admin' && req.query.userId ? req.query.userId : userId;
|
||||
const hasFullAccess = userRole === 'admin' || userRole === 'team_leader';
|
||||
const targetUserId = hasFullAccess && req.query.userId ? req.query.userId : userId;
|
||||
const { year, month } = req.params;
|
||||
|
||||
const stats = await timeTrackingService.getMonthlyStats(
|
||||
|
||||
@@ -10,12 +10,13 @@ export const uploadTimesheet = async (req, res, next) => {
|
||||
const { year, month, userId: requestUserId } = req.body;
|
||||
|
||||
// Determine target userId:
|
||||
// - If requestUserId is provided and user is admin, use requestUserId
|
||||
// - If requestUserId is provided and user is admin/team_leader, use requestUserId
|
||||
// - Otherwise, use req.userId (upload for themselves)
|
||||
let targetUserId = req.userId;
|
||||
const hasFullAccess = req.user.role === 'admin' || req.user.role === 'team_leader';
|
||||
if (requestUserId) {
|
||||
if (req.user.role !== 'admin') {
|
||||
throw new ForbiddenError('Iba admin môže nahrávať timesheets za iných používateľov');
|
||||
if (!hasFullAccess) {
|
||||
throw new ForbiddenError('Iba admin alebo team leader môže nahrávať timesheets za iných používateľov');
|
||||
}
|
||||
targetUserId = requestUserId;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import express from 'express';
|
||||
import * as adminController from '../controllers/admin.controller.js';
|
||||
import { authenticate } from '../middlewares/auth/authMiddleware.js';
|
||||
import { requireAdmin } from '../middlewares/auth/roleMiddleware.js';
|
||||
import { requireAdmin, requireTeamLeaderOrAdmin } from '../middlewares/auth/roleMiddleware.js';
|
||||
import { validateBody, validateParams } from '../middlewares/security/validateInput.js';
|
||||
import { createUserSchema, changeRoleSchema } from '../validators/auth.validators.js';
|
||||
import { z } from 'zod';
|
||||
@@ -9,13 +9,15 @@ import { z } from 'zod';
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* All admin routes require authentication and admin role
|
||||
* All admin routes require authentication
|
||||
*/
|
||||
router.use(authenticate);
|
||||
router.use(requireAdmin);
|
||||
|
||||
// Zoznam všetkých userov (admin only)
|
||||
router.get('/users', adminController.getAllUsers);
|
||||
// Zoznam všetkých userov (admin + team_leader)
|
||||
router.get('/users', requireTeamLeaderOrAdmin, adminController.getAllUsers);
|
||||
|
||||
// All other routes require admin role
|
||||
router.use(requireAdmin);
|
||||
|
||||
/**
|
||||
* User management
|
||||
@@ -46,7 +48,7 @@ router.patch(
|
||||
router.patch(
|
||||
'/users/:userId/role',
|
||||
validateParams(z.object({ userId: z.string().uuid() })),
|
||||
validateBody(z.object({ role: z.enum(['admin', 'member']) })),
|
||||
validateBody(z.object({ role: z.enum(['admin', 'team_leader', 'member']) })),
|
||||
adminController.changeUserRole
|
||||
);
|
||||
|
||||
|
||||
@@ -160,4 +160,18 @@ router.delete(
|
||||
aiKurzyController.deletePriloha
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/prilohy/:prilohaId/download',
|
||||
validateParams(prilohaIdSchema),
|
||||
aiKurzyController.downloadPriloha
|
||||
);
|
||||
|
||||
// ==================== CERTIFICATE ====================
|
||||
|
||||
router.post(
|
||||
'/registracie/:registraciaId/certificate',
|
||||
validateParams(registraciaIdSchema),
|
||||
aiKurzyController.generateCertificate
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -33,8 +33,9 @@ export const createUser = async (username, firstName, lastName, role, email, ema
|
||||
const tempPassword = generateTempPassword(12);
|
||||
const hashedTempPassword = await hashPassword(tempPassword);
|
||||
|
||||
// Validuj role - iba 'admin' alebo 'member'
|
||||
const validRole = role === 'admin' ? 'admin' : 'member';
|
||||
// Validuj role - 'admin', 'team_leader' alebo 'member'
|
||||
const validRoles = ['admin', 'team_leader', 'member'];
|
||||
const validRole = validRoles.includes(role) ? role : 'member';
|
||||
|
||||
// Vytvor usera
|
||||
const [newUser] = await db
|
||||
|
||||
@@ -3,5 +3,5 @@ export { getAllUcastnici, getUcastnikById, createUcastnik, updateUcastnik, delet
|
||||
export {
|
||||
getAllRegistracie, getRegistraciaById, createRegistracia, updateRegistracia, deleteRegistracia,
|
||||
getCombinedTableData, updateField,
|
||||
getPrilohyByRegistracia, createPriloha, deletePriloha,
|
||||
getPrilohyByRegistracia, createPriloha, deletePriloha, getPrilohaById,
|
||||
} from './ai-kurzy/registracie.service.js';
|
||||
|
||||
451
src/services/ai-kurzy/certificate.service.js
Normal file
451
src/services/ai-kurzy/certificate.service.js
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -233,3 +233,13 @@ export const deletePriloha = async (id) => {
|
||||
await db.delete(prilohy).where(eq(prilohy.id, id));
|
||||
return { success: true, filePath: priloha.cestaKSuboru };
|
||||
};
|
||||
|
||||
export const getPrilohaById = async (id) => {
|
||||
const [priloha] = await db
|
||||
.select()
|
||||
.from(prilohy)
|
||||
.where(eq(prilohy.id, id))
|
||||
.limit(1);
|
||||
|
||||
return priloha || null;
|
||||
};
|
||||
|
||||
@@ -481,9 +481,51 @@ export const getTodayCalendarCount = async (userId, isAdmin) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Get reminder count for today (uncompleted reminders with dueDate today)
|
||||
let reminderCount;
|
||||
if (isAdmin) {
|
||||
const reminderResult = await db
|
||||
.select({ count: sql`count(*)::int` })
|
||||
.from(companyReminders)
|
||||
.where(
|
||||
and(
|
||||
gte(companyReminders.dueDate, startOfDay),
|
||||
lt(companyReminders.dueDate, endOfDay),
|
||||
eq(companyReminders.isChecked, false)
|
||||
)
|
||||
);
|
||||
reminderCount = reminderResult[0]?.count || 0;
|
||||
} else {
|
||||
// For non-admin users, only count reminders for companies they have access to
|
||||
const userCompanyIds = await db
|
||||
.select({ companyId: companyUsers.companyId })
|
||||
.from(companyUsers)
|
||||
.where(eq(companyUsers.userId, userId));
|
||||
|
||||
const companyIds = userCompanyIds.map((row) => row.companyId);
|
||||
|
||||
if (companyIds.length === 0) {
|
||||
reminderCount = 0;
|
||||
} else {
|
||||
const reminderResult = await db
|
||||
.select({ count: sql`count(*)::int` })
|
||||
.from(companyReminders)
|
||||
.where(
|
||||
and(
|
||||
gte(companyReminders.dueDate, startOfDay),
|
||||
lt(companyReminders.dueDate, endOfDay),
|
||||
eq(companyReminders.isChecked, false),
|
||||
inArray(companyReminders.companyId, companyIds)
|
||||
)
|
||||
);
|
||||
reminderCount = reminderResult[0]?.count || 0;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
eventCount,
|
||||
todoCount,
|
||||
totalCount: eventCount + todoCount,
|
||||
reminderCount,
|
||||
totalCount: eventCount + todoCount + reminderCount,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -66,6 +66,7 @@ export const getConversations = async (userId) => {
|
||||
username: users.username,
|
||||
firstName: users.firstName,
|
||||
lastName: users.lastName,
|
||||
lastSeen: users.lastSeen,
|
||||
})
|
||||
.from(users)
|
||||
.where(sql`${users.id} IN ${partnerIds}`);
|
||||
|
||||
@@ -627,7 +627,8 @@ export const updateTimeEntry = async (entryId, actor, data, auditContext = null)
|
||||
const { userId, role } = actor;
|
||||
const entry = await getTimeEntryById(entryId);
|
||||
|
||||
if (entry.userId !== userId && role !== 'admin') {
|
||||
const hasFullAccess = role === 'admin' || role === 'team_leader';
|
||||
if (entry.userId !== userId && !hasFullAccess) {
|
||||
throw new ForbiddenError('Nemáte oprávnenie upraviť tento záznam');
|
||||
}
|
||||
|
||||
@@ -686,7 +687,8 @@ export const deleteTimeEntry = async (entryId, actor, auditContext = null) => {
|
||||
const { userId, role } = actor;
|
||||
const entry = await getTimeEntryById(entryId);
|
||||
|
||||
if (entry.userId !== userId && role !== 'admin') {
|
||||
const hasFullAccess = role === 'admin' || role === 'team_leader';
|
||||
if (entry.userId !== userId && !hasFullAccess) {
|
||||
throw new ForbiddenError('Nemáte oprávnenie odstrániť tento záznam');
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,8 @@ export const getTimesheetById = async (timesheetId) => {
|
||||
};
|
||||
|
||||
const assertAccess = (timesheet, { userId, role }) => {
|
||||
if (role !== 'admin' && timesheet.userId !== userId) {
|
||||
const hasFullAccess = role === 'admin' || role === 'team_leader';
|
||||
if (!hasFullAccess && timesheet.userId !== userId) {
|
||||
throw new ForbiddenError('Nemáte oprávnenie k tomuto timesheetu');
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user