# CRM Server Node.js/Express backend for a full-featured CRM system with company/project management, time tracking, email integration (JMAP), internal messaging, calendar events, push notifications, and an AI courses module. ## Tech Stack - **Runtime:** Node.js with ES modules - **Framework:** Express.js - **Database:** PostgreSQL via Drizzle ORM - **Auth:** JWT (access + refresh tokens), bcrypt passwords - **Email:** JMAP protocol - **Files:** Multer (disk + memory storage) - **Validation:** Zod schemas - **Notifications:** Web Push (VAPID) - **Exports:** ExcelJS for XLSX timesheets - **Cron:** node-cron for scheduled tasks - **Logging:** Winston ## Project Structure ``` src/ config/ # Database, env, upload configs controllers/ # HTTP request handlers cron/ # Scheduled jobs db/ # Schema definitions, migrations, seeds middlewares/ # Auth, security, validation middleware routes/ # Express route definitions services/ # Business logic layer utils/ # Shared utilities validators/ # Zod validation schemas ``` --- ## Config (`src/config/`) | File | Exports | |------|---------| | `database.js` | `db` (Drizzle instance), `pool` (pg connection pool) | | `env.js` | `env` object — validated env vars (DATABASE_URL, JWT_SECRET, JWT_REFRESH_SECRET, JMAP_URL, ENCRYPTION_KEY, VAPID keys, PORT) | | `upload.js` | `createUpload()` factory for multer configs, `ALLOWED_FILE_TYPES` constant. Pre-configured exports: `upload`, `timesheetUpload`, `companyDocumentUpload`, `projectDocumentUpload`, `serviceDocumentUpload`, `aiKurzyUpload` | --- ## Middlewares (`src/middlewares/`) ### Auth | Function | Description | |----------|-------------| | `authenticate` | Verify JWT from cookie or Authorization header, attach user to `req` | | `optionalAuthenticate` | Same but doesn't fail if no token present | | `requireAdmin` | Require admin role | | `requireMember` | Require member role (or admin) | | `checkResourceAccess(type)` | Check user has access to company/project/todo | | `getAccessibleResourceIds(type, userId)` | Get IDs of resources a user can access | ### Security | Function | Description | |----------|-------------| | `apiLimiter` | Rate limit: 100 req/15min | | `authLimiter` | Rate limit: 5 req/15min (login) | | `requireAccountId` | Validate `accountId` query param | | `sanitizeInput(input)` | Strip XSS from user input | ### Global | Function | Description | |----------|-------------| | `validateBody(schema)` | Validate `req.body` against Zod schema | | `errorHandler` | Global error handler, formats errors consistently | | `notFound` | 404 handler for unknown routes | --- ## Utils (`src/utils/`) | File | Exports | Description | |------|---------|-------------| | `jwt.js` | `generateAccessToken`, `generateRefreshToken`, `generateTokenPair`, `verifyAccessToken`, `verifyRefreshToken` | JWT token operations (1h access, 7d refresh) | | `password.js` | `hashPassword`, `comparePassword`, `generateTempPassword` | bcrypt hashing + temp password generation | | `logger.js` | `logger` | Winston logger (file + console) | | `errors.js` | `AppError`, `NotFoundError`, `ValidationError`, `AuthenticationError`, `ForbiddenError`, `ConflictError` | Custom error classes with HTTP status codes | | `emailAccountHelper.js` | `encryptPassword`, `decryptPassword` | AES-256 encryption for stored email passwords | --- ## Validators (`src/validators/`) | File | Schemas | |------|---------| | `auth.validators.js` | `loginSchema`, `setPasswordSchema`, `linkEmailSchema`, `createUserSchema` | | `crm.validators.js` | `createCompanySchema`, `updateCompanySchema`, `createProjectSchema`, `updateProjectSchema`, `createTodoSchema`, `updateTodoSchema`, `createNoteSchema`, `updateNoteSchema`, `createReminderSchema`, `updateReminderSchema`, `startTimeEntrySchema`, `stopTimeEntrySchema`, `updateTimeEntrySchema`, `createContactSchema` | | `email-account.validators.js` | `createEmailAccountSchema`, `updatePasswordSchema`, `toggleStatusSchema`, `signatureSchema`, `toggleSchema` | | `service.validators.js` | `serviceIdSchema`, `folderIdSchema`, `folderDocumentIdSchema`, `createFolderSchema`, `updateFolderSchema` | | `ai-kurzy.validators.js` | `createKurzSchema`, `updateKurzSchema`, `createUcastnikSchema`, `updateUcastnikSchema`, `createRegistraciaSchema`, `updateRegistraciaSchema`, `registracieQuerySchema`, `updateFieldSchema`, `prilohaIdSchema` | --- ## Services (`src/services/`) ### auth.service.js | Function | Description | |----------|-------------| | `loginWithTempPassword(username, password, ip, ua)` | Authenticate with temp or permanent password, return JWT pair | | `setNewPassword(userId, newPassword, auditCtx)` | Set permanent password, mark as changed | | `linkEmail(userId, email, password, auditCtx)` | Link email account via JMAP validation | | `skipEmailSetup(userId)` | Skip email onboarding step | | `logout(auditCtx)` | Log logout to audit trail | | `getUserById(userId)` | Get user with email accounts | ### admin.service.js | Function | Description | |----------|-------------| | `checkUsernameExists(username)` | Check if username is taken | | `createUser(username, firstName, lastName, role, email, password, auditCtx)` | Create user with auto-generated temp password | | `getAllUsers()` | List all users (no passwords) | | `getUserById(userId)` | Get user with email accounts | | `changeUserRole(userId, newRole, auditCtx)` | Change role (admin/member) | | `updateUser(userId, data)` | Update user name fields | | `resetUserPassword(userId)` | Generate new temp password | | `deleteUser(userId, auditCtx)` | Delete user and orphaned email accounts | ### company.service.js | Function | Description | |----------|-------------| | `getAllCompanies(search, userId, role)` | Get companies (members see only assigned) | | `getCompanyById(id)` | Get company with creator info | | `createCompany(userId, data, auditCtx)` | Create company, auto-assign creator | | `updateCompany(id, data, auditCtx)` | Update company | | `deleteCompany(id, auditCtx)` | Delete company | | `getCompanyWithRelations(id)` | Get with projects, todos, notes, reminders | | `getCompanyUsers(id)` | Get assigned users | | `assignUserToCompany(companyId, userId, addedBy, role, auditCtx)` | Add user to team | | `removeUserFromCompany(companyId, userId, auditCtx)` | Remove from team | | `updateUserRoleOnCompany(companyId, userId, role)` | Update team role | ### company-email.service.js | Function | Description | |----------|-------------| | `getCompanyEmailThreads(companyId, userId)` | Get email threads grouped by contact | | `getCompanyUnreadCounts(userId)` | Unread counts per company | ### company-document.service.js | Function | Description | |----------|-------------| | `getDocumentsByCompanyId(id)` | List company documents | | `uploadDocument({ companyId, userId, file, description })` | Upload document | | `getDocumentForDownload(companyId, docId)` | Get file path for download | | `deleteDocument(companyId, docId)` | Delete document and file | ### company-reminder.service.js | Function | Description | |----------|-------------| | `getRemindersByCompanyId(id)` | Get reminders for company | | `createReminder(companyId, data, auditCtx)` | Create reminder | | `updateReminder(companyId, id, data, auditCtx)` | Update reminder | | `deleteReminder(companyId, id, auditCtx)` | Delete reminder | | `getReminderSummary(userId, role)` | Checked/unchecked stats | | `getReminderCountsByCompany(userId, role)` | Counts grouped by company | | `getUpcomingReminders(userId, role)` | Upcoming with due dates | ### project.service.js | Function | Description | |----------|-------------| | `getAllProjects(search, companyId, userId, role)` | List with filters (members see assigned only) | | `getProjectById(id)` | Get with creator info | | `createProject(userId, data, auditCtx)` | Create and auto-assign creator | | `updateProject(id, data, auditCtx)` | Update project | | `deleteProject(id, auditCtx)` | Delete project | | `getProjectWithRelations(id)` | Get with todos, notes, timesheets, team | | `getProjectUsers(id)` | Get team members | | `assignUserToProject(projectId, userId, addedBy, role, auditCtx)` | Add to team | | `removeUserFromProject(projectId, userId, auditCtx)` | Remove from team | | `updateUserRoleOnProject(projectId, userId, role)` | Update role | ### project-document.service.js Same pattern as company-document: `getDocumentsByProjectId`, `uploadDocument`, `getDocumentForDownload`, `deleteDocument`. ### todo.service.js | Function | Description | |----------|-------------| | `getAllTodos(filters, userId, role)` | Get with filters (project, company, status, priority, assignedTo) | | `getTodoById(id)` | Get todo | | `getTodoWithRelations(id)` | Get with project, company, users, notes | | `createTodo(userId, data, auditCtx)` | Create and assign users (triggers push) | | `updateTodo(id, data, userId, auditCtx)` | Update and manage assignments | | `deleteTodo(id, auditCtx)` | Delete todo | | `getOverdueCount(userId, role)` | Count overdue | | `getCompletedByMeCount(userId)` | Count completed by user | | `getTodoCounts(userId, role)` | Combined counts for sidebar badges | | `markCompletedAsNotified(userId)` | Mark completed as notified | ### todo-notification.service.js | Function | Description | |----------|-------------| | `notifyNewTodoAssignment(userIds, title, todoId, excludeId)` | Push notify assigned users | | `notifyUpdatedTodoAssignment(userIds, title, todoId, excludeId)` | Push notify newly assigned users | ### note.service.js | Function | Description | |----------|-------------| | `getAllNotes(filters)` | Get with filters (company, project, todo, contact) | | `getNoteById(id)` | Get note | | `getNotesByCompanyId(id)` | Notes for company | | `getNotesByProjectId(id)` | Notes for project | | `createNote(userId, data, auditCtx)` | Create note | | `updateNote(id, data, auditCtx)` | Update note | | `deleteNote(id, auditCtx)` | Delete note | | `getUpcomingRemindersForUser(userId)` | Upcoming note reminders | | `markReminderAsSent(id)` | Mark reminder sent | | `searchNotes(term)` | Search with company/project info | ### time-tracking.service.js | Function | Description | |----------|-------------| | `startTimeEntry(userId, data, auditCtx)` | Start timer (auto-stops any running entry) | | `stopTimeEntry(entryId, userId, data, auditCtx)` | Stop and calculate duration | | `pauseTimeEntry(entryId, userId, auditCtx)` | Pause running entry | | `resumeTimeEntry(entryId, userId, auditCtx)` | Resume paused entry | | `getRunningTimeEntry(userId)` | Get active entry for user | | `getAllRunningTimeEntries()` | All active entries (admin) | | `getAllTimeEntries(userId, filters)` | Filtered time entries | | `getMonthlyTimeEntries(userId, year, month)` | Entries for month | | `generateMonthlyTimesheet(userId, year, month)` | Generate XLSX timesheet | | `generateCompanyTimesheet(userId, year, month, companyId)` | XLSX filtered by company | | `getTimeEntryById(id)` | Get single entry | | `getTimeEntryWithRelations(id)` | Entry with project/todo/company/user | | `updateTimeEntry(id, userCtx, data, auditCtx)` | Update entry | | `deleteTimeEntry(id, userCtx, auditCtx)` | Delete entry | | `getMonthlyStats(userId, year, month)` | Monthly stats | ### timesheet.service.js | Function | Description | |----------|-------------| | `uploadTimesheet({ userId, year, month, file, auditCtx })` | Upload timesheet file | | `getTimesheetsForUser(userId, filters)` | User's timesheets | | `getAllTimesheets(filters)` | All timesheets (admin) | | `getDownloadInfo(id, userCtx)` | File path with access check | | `deleteTimesheet(id, userCtx, auditCtx)` | Delete timesheet and file | ### contact.service.js | Function | Description | |----------|-------------| | `getContactsForEmailAccount(accountId)` | Get contacts for email account | | `addContact(accountId, jmapConfig, email, name, notes, userId)` | Add contact and sync JMAP emails | | `removeContact(contactId, accountId)` | Remove contact | | `updateContact(contactId, accountId, data)` | Update contact | | `linkCompanyToContact(contactId, accountId, companyId, auditCtx)` | Link company | | `unlinkCompanyFromContact(contactId, accountId)` | Unlink company | | `createCompanyFromContact(contactId, accountId, userId, data, auditCtx)` | Create company and link | ### personal-contact.service.js | Function | Description | |----------|-------------| | `listPersonalContacts(userId)` | List user's personal contacts | | `createPersonalContact(userId, data)` | Create contact | | `updatePersonalContact(id, userId, data)` | Update contact | | `deletePersonalContact(id, userId)` | Delete contact | | `getContactsByCompanyId(companyId)` | Contacts by company | ### crm-email.service.js | Function | Description | |----------|-------------| | `getEmailsForAccount(accountId)` | All emails for account | | `getEmailThread(accountId, threadId)` | Thread emails | | `searchEmails(accountId, term)` | Search by subject/from/to/body | | `getUnreadCountSummary(accountIds)` | Unread counts | | `markContactEmailsAsRead(contactId, accountId)` | Mark all read for contact | | `getContactEmailsWithUnread(accountId, contactId)` | Contact emails with status | | `markThreadAsRead(accountId, threadId)` | Mark thread read | ### email-account.service.js | Function | Description | |----------|-------------| | `getUserEmailAccounts(userId)` | Get accounts via many-to-many | | `getEmailAccountById(accountId, userId)` | Get with access check | | `getEmailAccountWithCredentials(accountId, userId)` | Get with decrypted password | | `getPrimaryEmailAccount(userId)` | Get primary account | | `createEmailAccount(userId, email, password)` | Create/link via JMAP discovery | | `updateEmailAccountPassword(accountId, userId, password)` | Update password | | `toggleEmailAccountStatus(accountId, userId, isActive)` | Enable/disable | | `setPrimaryEmailAccount(accountId, userId)` | Set as primary | | `removeUserFromEmailAccount(accountId, userId)` | Remove user access | ### email-signature.service.js `getSignature`, `upsertSignature`, `toggleSignature`, `formatSignatureText`, `deleteSignature` — manage per-user email signatures. ### message.service.js | Function | Description | |----------|-------------| | `getConversations(userId)` | All 1-on-1 conversations with unread counts | | `getConversation(userId, partnerId)` | Messages with user, mark as read | | `sendMessage(senderId, receiverId, content)` | Send direct message | | `deleteConversation(userId, partnerId)` | Soft delete conversation | | `getUnreadCount(userId)` | Total unread count | | `getChatUsers(userId)` | All users except self | ### group.service.js | Function | Description | |----------|-------------| | `createGroup(name, createdById, memberIds)` | Create group chat | | `getUserGroups(userId)` | Groups with unread counts | | `getGroupDetails(groupId, userId)` | Group with members | | `getGroupMessages(groupId, userId)` | Messages, update last read | | `sendGroupMessage(groupId, senderId, content)` | Send to group | | `addGroupMember(groupId, userId, requesterId)` | Add member | | `removeGroupMember(groupId, userId, requesterId)` | Remove member | | `updateGroupName(groupId, name, userId)` | Rename group | | `deleteGroup(groupId, userId)` | Delete (creator only) | ### push.service.js | Function | Description | |----------|-------------| | `getVapidPublicKey()` | Get VAPID public key | | `saveSubscription(userId, sub)` | Save push subscription | | `removeSubscription(userId, endpoint)` | Remove subscription | | `removeAllSubscriptions(userId)` | Remove all for user | | `hasActiveSubscription(userId)` | Check if subscribed | | `sendPushNotification(userId, payload)` | Send push to user | ### event.service.js | Function | Description | |----------|-------------| | `getCalendarData(year, month, userId, isAdmin)` | Calendar events + todos | | `getEventById(eventId, userId, isAdmin)` | Event with access check | | `createEvent(userId, data)` | Create and assign users | | `updateEvent(eventId, data)` | Update and reassign users | | `deleteEvent(eventId)` | Delete event | ### audit.service.js Provides `logLogin`, `logLogout`, `logPasswordChange`, `logEmailLink`, `logUserCreation`, `logRoleChange`, `logUserDeleted`, `logCompanyCreated`, `logCompanyUpdated`, `logCompanyDeleted`, `logCompanyUserAssigned`, `logCompanyUserRemoved`, `logProjectCreated`, `logProjectUpdated`, `logProjectDeleted`, `logProjectUserAssigned`, `logProjectUserRemoved`, and more. Each function records action, resource, details, IP, and user agent to the `auditLogs` table. ### service.service.js / service-folder.service.js / service-document.service.js Standard CRUD for services, folders, and folder documents. Same pattern: `getAll`, `getById`, `create`, `update`, `delete`. ### ai-kurzy/ (barrel exported from ai-kurzy.service.js) **kurzy.service.js** — `getAllKurzy`, `getKurzById`, `createKurz`, `updateKurz`, `deleteKurz`, `getKurzyStats` (course CRUD + statistics) **ucastnici.service.js** — `getAllUcastnici`, `getUcastnikById`, `createUcastnik`, `updateUcastnik`, `deleteUcastnik` (participant CRUD) **registracie.service.js** — `getAllRegistracie`, `getRegistraciaById`, `createRegistracia`, `updateRegistracia`, `deleteRegistracia`, `getCombinedTableData`, `updateField`, `getPrilohyByRegistracia`, `createPriloha`, `deletePriloha` (registrations + combined table view + inline field editing + attachments) ### jmap/ (email protocol integration) | Function | Description | |----------|-------------| | `getJmapConfig(userId)` | Get JMAP config from user's primary email | | `getJmapConfigFromAccount(account)` | Build JMAP config from account object | | `discoverContactsFromJMAP(config, accountId, search, limit)` | Search and discover contacts | | `syncEmailsFromSender(config, accountId, contactId, email, opts)` | Sync emails from sender | | `markEmailAsRead(config, userId, jmapId, isRead)` | Mark read/unread on JMAP | | `sendEmail(config, userId, accountId, to, subject, body, inReplyTo, threadId)` | Send via JMAP | | `searchEmailsJMAP(config, accountId, query, limit, offset)` | Full-text JMAP search | ### status.service.js `getServerStatus()` — returns uptime, memory usage, CPU, database connection info. --- ## Controllers (`src/controllers/`) Controllers are thin HTTP handlers. Each extracts params/body from `req`, calls the corresponding service, and returns JSON. After the refactoring, audit logging is handled inside services via the `auditContext` parameter (`{ userId, ipAddress, userAgent }`). | Controller | Handles | |-----------|---------| | `auth.controller.js` | Login, logout, refresh token, set password, link email, session | | `admin.controller.js` | User CRUD (admin only), server status, trigger notifications | | `company.controller.js` | Company CRUD, email threads, unread counts | | `company-team.controller.js` | Company user assignments | | `company-note.controller.js` | Company notes CRUD | | `company-reminder.controller.js` | Company reminders CRUD + summary/upcoming | | `company-document.controller.js` | Company document upload/download/delete | | `project.controller.js` | Project CRUD, notes, team, documents | | `project-document.controller.js` | Project document upload/download/delete | | `todo.controller.js` | Todo CRUD, toggle, counts, overdue, my todos | | `note.controller.js` | Note CRUD, search, reminders | | `time-tracking.controller.js` | Start/stop/pause/resume timer, entries, stats, timesheet generation | | `timesheet.controller.js` | Timesheet upload/download/delete | | `contact.controller.js` | Email contacts, discover, link/create company | | `personal-contact.controller.js` | Personal contacts CRUD | | `crm-email.controller.js` | Email list, threads, search, sync, reply, read status | | `email-account.controller.js` | Email account CRUD, password, status, primary | | `email-signature.controller.js` | Signature CRUD, toggle, formatted output | | `message.controller.js` | 1-on-1 messaging, conversations, unread count | | `group.controller.js` | Group chat CRUD, messages, members | | `push.controller.js` | Push subscription, VAPID key, test push | | `event.controller.js` | Calendar events CRUD, notifications | | `audit.controller.js` | Audit log listing and filters (admin) | | `service.controller.js` | Services CRUD | | `service-folder.controller.js` | Service folders CRUD | | `service-document.controller.js` | Folder documents upload/download/delete | | `ai-kurzy.controller.js` | Courses, participants, registrations, combined table, attachments, stats | --- ## Routes (`src/routes/`) All routes are prefixed with `/api`. Authentication is required unless noted. | Prefix | Methods | Description | |--------|---------|-------------| | `/api/auth` | POST login/logout/refresh/set-password/link-email/skip-email, GET session/me | Auth flow | | `/api/admin` | GET/POST/PATCH/DELETE users, GET server-status, POST trigger-notifications | Admin only | | `/api/companies` | Full CRUD + nested notes, reminders, users, documents, email-threads | Company management | | `/api/projects` | Full CRUD + nested notes, users, documents | Project management | | `/api/todos` | Full CRUD + /my, /counts, /toggle, /overdue-count | Task management | | `/api/notes` | CRUD + /search, /my-reminders | Notes with reminders | | `/api/time-tracking` | start/stop/pause/resume, entries, monthly, generate timesheets | Time tracking | | `/api/timesheets` | Upload/download/delete, /my, /all | Timesheet files | | `/api/contacts` | CRUD + /discover, link/unlink/create company | Email contacts | | `/api/personal-contacts` | CRUD + /company/:companyId | Personal contacts | | `/api/emails` | List, search, sync, reply, threads, read status | Email operations | | `/api/email-accounts` | CRUD + password, status toggle, set-primary | Email accounts | | `/api/email-signature` | CRUD + toggle, formatted | Email signatures | | `/api/messages` | Conversations, send, unread, users | Direct messages | | `/api/groups` | CRUD + messages, members | Group chat | | `/api/push` | Subscribe/unsubscribe, VAPID key, status, test | Push notifications | | `/api/events` | CRUD + /notify | Calendar events (admin-managed) | | `/api/audit` | GET logs, actions, resources | Audit trail (admin) | | `/api/services` | CRUD + folders + folder documents | Service catalog | | `/api/ai-kurzy` | Courses, participants, registrations, combined table, attachments, stats | AI courses module | | `/api/users` | GET all users | Basic user list | --- ## Cron Jobs (`src/cron/`) | Job | Schedule | Description | |-----|----------|-------------| | `notifyUpcomingEvents` | Every 15 min | Push notifications for events starting within 30 min | | `cleanupOldAuditLogs` | Daily 2:00 AM | Delete audit logs older than 90 days | Manual triggers available via admin endpoints: `triggerEventNotifications()`, `triggerSingleEventNotification(eventId, adminUserId)`. --- ## Database Schema (`src/db/schema.js`) 34 tables organized by domain: **Auth & Users:** `users`, `emailAccounts`, `userEmailAccounts`, `auditLogs` **CRM Core:** `companies`, `companyUsers`, `companyReminders`, `companyDocuments`, `projects`, `projectUsers`, `projectDocuments`, `todos`, `todoUsers`, `notes` **Time Tracking:** `timeEntries`, `timesheets` **Email:** `contacts`, `personalContacts`, `emails`, `emailSignatures` **Messaging:** `messages`, `chatGroups`, `chatGroupMembers`, `groupMessages` **Calendar:** `events`, `eventUsers` **Push:** `pushSubscriptions` **Services:** `services`, `serviceFolders`, `serviceDocuments` **AI Courses:** `kurzy`, `ucastnici`, `registracie`, `prilohy` ### Key Enums - `role`: admin, member - `projectStatus`: active, completed, on_hold, cancelled - `todoStatus`: pending, in_progress, completed, cancelled - `todoPriority`: low, medium, high, urgent - `companyStatus`: registered, lead, customer, inactive - `formaKurzu`: prezencne, online, hybridne - `stavRegistracie`: potencialny, registrovany, potvrdeny, absolvoval, zruseny - `typPrilohy`: certifikat, faktura, prihlaska, doklad_o_platbe, ine --- ## Access Control - **Admin** — full access to all resources, user management, server status - **Member** — sees only companies/projects they are assigned to; todos filtered accordingly - Resource access checked via `checkResourceAccess` middleware and `getAccessibleResourceIds` helper - Creator is auto-assigned to companies and projects on creation ## Audit Logging All write operations log to `auditLogs` via the `auditContext` pattern. Services receive `{ userId, ipAddress, userAgent }` and call the audit service internally. Tracked: logins, logouts, password changes, CRUD on companies/projects/todos/notes/timesheets, user assignments, admin actions. --- ## PDF Certificate Generation The AI Kurzy module can generate PDF certificates for course participants using Puppeteer. **Services:** - `certificate.service.js` — `generateCertificate(registraciaId, templateName)`, `getCertificateDownloadInfo(prilohaId)`, `getAvailableTemplates()`, `hasCertificate(registraciaId)` - `certificate-email.service.js` — `sendCertificateEmail(registraciaId, prilohaId)` — sends certificate via JMAP **Available Templates:** - `AIcertifikat` — AI kurz (Zdarílek + Gablasová) - `AIcertifikatGablas` — AI kurz (Gablas + Gablasová) - `AIcertifikatPatrik` — AI kurz (Patrik + Gablasová) - `ScrumMaster`, `ScrumProductOwner` — Scrum certifications - `ITILFoundation` — ITIL® 4 Foundation - `PRINCE2Foundation`, `PRINCE2Practitioner` — PRINCE2® certifications --- ## Docker Deployment ```dockerfile FROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm ci --only=production # Required for PDF certificate generation RUN apk add --no-cache chromium ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true COPY src ./src COPY drizzle.config.js ./ RUN mkdir -p uploads RUN addgroup -g 1001 -S nodejs && \ adduser -S nodejs -u 1001 && \ chown -R nodejs:nodejs /app USER nodejs EXPOSE 5000 CMD ["node", "src/index.js"] ``` **Environment Variables:** ```env NODE_ENV=production DATABASE_URL=postgres://user:pass@host:5432/db JWT_SECRET=your-secret JWT_REFRESH_SECRET=your-refresh-secret ENCRYPTION_KEY=32-char-key-for-aes JMAP_URL=https://mail.truemail.sk/jmap/ VAPID_PUBLIC_KEY=... VAPID_PRIVATE_KEY=... CORS_ORIGIN=https://your-frontend.com ```