# 📚 CRM SERVER - KOMPLETNÁ DOKUMENTÁCIA > Vytvorené: 2025-11-21 > Verzia: 1.1 > Backend: Node.js + Express + Drizzle ORM + PostgreSQL > Frontend: React --- ## 📑 OBSAH 1. [Architektúra Systému](#architektúra-systému) 2. [Services - Biznis Logika](#services) 3. [Controllers - Request Handling](#controllers) 4. [Routes - API Endpointy](#routes) 5. [Utils - Pomocné Funkcie](#utils) 6. [Kompletný Zoznam API](#kompletný-zoznam-api) 7. [Vzťahy medzi Službami](#vzťahy-medzi-službami) --- ## ARCHITEKTÚRA SYSTÉMU ### Stack - **Backend Framework:** Express.js - **Database:** PostgreSQL - **ORM:** Drizzle ORM - **Auth:** JWT (access + refresh tokens) - **Validation:** Zod schemas - **Email:** JMAP protocol (Truemail.sk) - **Encryption:** AES-256-GCM (email passwords) - **File Upload:** Multer - **Time Tracking:** Real-time timer s automatickým stop/start ### Databázové Tabuľky ``` users → userEmailAccounts ← emailAccounts ↓ ↓ | contacts → emails | └─→ projectUsers ←─┐ ↓ | companies → projects → todos → notes ↓ timesheets users → timeEntries → projects/todos/companies ``` **Vzťahy:** - `projectUsers`: Many-to-many junction table (users ↔ projects) - `companies` → `projects`: One-to-many (company môže mať viac projektov) - `projects` → `todos`, `notes`, `timesheets`: One-to-many - `users` → `todos.assignedTo`: One-to-many (user má assigned úlohy) - `users` → `timeEntries`: One-to-many (user má záznamy času) - `timeEntries` → `projects`, `todos`, `companies`: Optional Many-to-one --- ## SERVICES ### 1. auth.service.js **Účel:** Autentifikácia a onboarding používateľov **Databáza:** `users`, `userEmailAccounts` **Metódy:** ```javascript loginWithTempPassword(username, password, ipAddress, userAgent) → Overí credentials → Vráti JWT tokens → Kontroluje či treba zmeniť heslo / linknúť email setNewPassword(userId, newPassword) → Hashuje heslo (bcrypt) → Nastaví changedPassword = true → Volá: password.js (hashPassword) linkEmail(userId, email, emailPassword) → Volá: email.service.js (validateJmapCredentials) → Volá: emailAccountService.js (createEmailAccount) → Pripojí email k userovi skipEmailSetup(userId) → Umožní preskočiť email setup ``` **Volá:** - `emailAccountService.createEmailAccount()` - `email.service.validateJmapCredentials()` - `utils/password.hashPassword()` - `utils/jwt.generateTokenPair()` --- ### 2. company.service.js **Účel:** CRUD operácie pre firmy **Databáza:** `companies`, `projects`, `todos`, `notes` **Metódy:** ```javascript getAllCompanies(searchTerm) → SELECT * FROM companies → Filter: ILIKE na name, email, city → ORDER BY createdAt DESC getCompanyById(companyId) → SELECT * FROM companies WHERE id = ? → Throw NotFoundError ak neexistuje createCompany(userId, data) → Kontrola duplicity (name) → INSERT INTO companies → Nastaví createdBy = userId updateCompany(companyId, data) → Volá getCompanyById (check existencie) → UPDATE companies SET ... deleteCompany(companyId) → DELETE FROM companies WHERE id = ? → CASCADE delete: projects, todos, notes getCompanyWithRelations(companyId) → Volá getCompanyById() → LEFT JOIN projects WHERE companyId → LEFT JOIN todos WHERE companyId → LEFT JOIN notes WHERE companyId → Vráti všetko v jednom objekte ``` **Volá:** - Priamo databázu cez Drizzle ORM - `utils/errors.NotFoundError` - `utils/errors.ConflictError` --- ### 3. contact.service.js **Účel:** Správa email kontaktov (per email account) **Databáza:** `contacts`, `emails`, `companies` **Metódy:** ```javascript getContactsForEmailAccount(emailAccountId) → SELECT * FROM contacts WHERE emailAccountId addContact(emailAccountId, jmapConfig, email, name, notes, addedByUserId) → INSERT INTO contacts → Volá: jmap.service.syncEmailsFromSender() → Reassign: UPDATE emails SET contactId WHERE from = email getContactById(contactId, emailAccountId) → Verifikuje prístup k accountu linkCompanyToContact(contactId, emailAccountId, companyId) → UPDATE contacts SET companyId = ? createCompanyFromContact(contactId, emailAccountId, userId, companyData) → Volá: company.service.createCompany() → UPDATE contacts SET companyId = newCompany.id ``` **Volá:** - `jmap.service.syncEmailsFromSender()` - `company.service.createCompany()` - Databázu (contacts, emails, companies) --- ### 4. project.service.js **Účel:** Projekty a správa tímov **Databáza:** `projects`, `companies`, `todos`, `notes`, `timesheets`, `projectUsers`, `users` **Metódy:** ```javascript getAllProjects(searchTerm, companyId) → SELECT * FROM projects → Filter: search (name, desc) + companyId createProject(userId, data) → Validate: company exists (ak je companyId) → INSERT INTO projects getProjectWithRelations(projectId) → SELECT project → LEFT JOIN company → LEFT JOIN todos → LEFT JOIN notes → LEFT JOIN timesheets → LEFT JOIN projectUsers + users (tím) → Vráti všetko naraz // Team Management getProjectUsers(projectId) → SELECT * FROM projectUsers → LEFT JOIN users → WHERE projectId → Vráti: Array<{ id, userId, role, addedBy, addedAt, user: {...} }> → DRIZZLE PATTERN: Používa .select() bez params, pristupuje cez row.table_name.field assignUserToProject(projectId, userId, addedByUserId, role) → Validate: project exists (getProjectById) → Validate: user exists → Check: už nie je assigned (ConflictError) → INSERT INTO projectUsers → Vráti assignment s user details → UNIQUE constraint: projectId + userId removeUserFromProject(projectId, userId) → Validate: project exists → Check: user je assigned (NotFoundError) → DELETE FROM projectUsers WHERE projectId AND userId updateUserRoleOnProject(projectId, userId, role) → Validate: project exists → Check: user je assigned → UPDATE projectUsers SET role → Vráti updated assignment s user details ``` **Volá:** - Databázu (projects, projectUsers, users) - `utils/errors.NotFoundError` - `utils/errors.ConflictError` **Dôležité - Drizzle ORM Query Pattern:** ```javascript // ✅ SPRÁVNE - Štandardný .select() pre joins const rawResults = await db .select() .from(projectUsers) .leftJoin(users, eq(projectUsers.userId, users.id)) .where(eq(projectUsers.projectId, projectId)) .orderBy(desc(projectUsers.addedAt)); // Potom mapovať výsledky: const assignedUsers = rawResults.map((row) => ({ id: row.project_users.id, userId: row.project_users.userId, user: row.users ? { id: row.users.id, username: row.users.username, } : null, })); // ❌ NESPRÁVNE - Nested object syntax NEFUNGUJE v Drizzle! const wrong = await db .select({ user: { id: users.id, username: users.username, } }) .from(projectUsers) // → TypeError: Cannot convert undefined or null to object ``` --- ### 5. todo.service.js **Účel:** Správa úloh (tasks) **Databáza:** `todos`, `projects`, `companies`, `users`, `notes` **Status Enum:** `['pending', 'in_progress', 'completed', 'cancelled']` **Metódy:** ```javascript getAllTodos(filters) → Filtre: search, projectId, companyId, assignedTo, status → SELECT * FROM todos WHERE ... createTodo(userId, data) → Validate: project/company/assignedTo exists → INSERT INTO todos → Default status: 'pending' updateTodo(todoId, data) → Ak status = 'completed' → nastaví completedAt = NOW() → Ak status != 'completed' → zmaže completedAt → UPDATE todos toggleTodoComplete(todoId) → Toggle: 'completed' ↔ 'pending' → Používa sa vo frontende pre checkbox toggle getTodoWithRelations(todoId) → LEFT JOIN project → LEFT JOIN company → LEFT JOIN assignedUser (users) → LEFT JOIN notes ``` **Volá:** - Databázu (todos, projects, companies, users) - `utils/errors.NotFoundError` **⚠️ DÔLEŽITÉ - Status Field:** - Databázové pole: `status` (enum) - **NIE** `completed` (boolean)! - Frontend check: `todo.status === 'completed'` ✅ - Frontend check: `todo.completed` ❌ (toto pole neexistuje!) --- ### 6. note.service.js **Účel:** Poznámky s remindermi **Databáza:** `notes`, `companies`, `projects`, `todos`, `contacts` **Metódy:** ```javascript // Frontend Mapping Helper mapNoteForFrontend(note) → Konvertuje: reminderDate → reminderAt → Frontend používa "reminderAt", DB má "reminderDate" getAllNotes(filters) → Filter: search, companyId, projectId, todoId, contactId → Volá mapNoteForFrontend() na výsledky createNote(userId, data) → Validate: company/project/todo/contact exists → INSERT INTO notes → reminderSent = false → Volá mapNoteForFrontend() updateNote(noteId, data) → Ak sa zmení reminderDate → reset reminderSent = false → Volá mapNoteForFrontend() // Reminder Management getPendingReminders() → SELECT * WHERE reminderDate <= NOW() AND reminderSent = false markReminderAsSent(noteId) → UPDATE notes SET reminderSent = true getUpcomingRemindersForUser(userId) → WHERE createdBy = userId AND pending reminders ``` **Volá:** - Databázu (notes, companies, projects, todos, contacts) - `mapNoteForFrontend()` - internal helper --- ### 7. email-account.service.js **Účel:** Správa email účtov (many-to-many sharing) **Databáza:** `emailAccounts`, `userEmailAccounts`, `users` **Metódy:** ```javascript getUserEmailAccounts(userId) → SELECT emailAccounts → JOIN userEmailAccounts → WHERE userId createEmailAccount(userId, email, emailPassword) → Volá: email.service.validateJmapCredentials() → Encrypt password: password.encryptPassword() → Kontrola: email už existuje? - Áno → link existujúci account (shared = true) - Nie → vytvor nový account → INSERT INTO userEmailAccounts → Ak prvý account → set isPrimary = true → Volá: contact.service.syncContactsForAccount() getEmailAccountWithCredentials(accountId, userId) → SELECT emailAccount → Decrypt password: password.decryptPassword() → Vráti s plaintext password (pre JMAP) setPrimaryEmailAccount(accountId, userId) → UPDATE userEmailAccounts SET isPrimary = false (všetky) → UPDATE userEmailAccounts SET isPrimary = true (tento) removeUserFromEmailAccount(accountId, userId) → DELETE FROM userEmailAccounts → Ak posledný user → CASCADE delete emailAccount + data shareEmailAccountWithUser(accountId, ownerId, targetUserId) → Kontrola: owner má prístup → INSERT INTO userEmailAccounts (link target user) ``` **Volá:** - `email.service.validateJmapCredentials()` - `password.encryptPassword()` - `password.decryptPassword()` - `contact.service.syncContactsForAccount()` - Databázu (emailAccounts, userEmailAccounts) --- ### 8. crm-email.service.js **Účel:** Správa emailov (read status, search) **Databáza:** `emails`, `contacts` **Metódy:** ```javascript getEmailsForAccount(emailAccountId) → SELECT emails → LEFT JOIN contacts → WHERE emailAccountId → Iba emaily od pridaných kontaktov getEmailThread(emailAccountId, threadId) → SELECT emails WHERE threadId → ORDER BY date ASC (konverzácia) searchEmails(emailAccountId, query) → DB search: ILIKE na subject, body, from → LIMIT 50 getUnreadCountForAccount(emailAccountId) → SELECT COUNT WHERE isRead = false → Iba od added contacts markThreadAsRead(emailAccountId, threadId) → UPDATE emails SET isRead = true WHERE threadId markContactEmailsAsRead(contactId, emailAccountId) → Volá: getContactByEmail() → UPDATE emails SET isRead = true WHERE contactId ``` **Volá:** - Databázu (emails, contacts) - `getContactByEmail()` - internal --- ### 9. jmap.service.js **Účel:** JMAP protokol integrácia **JMAP Server:** `https://mail.truemail.sk/jmap/` **Metódy:** ```javascript jmapRequest(jmapConfig, methodCalls) → HTTP POST na JMAP server → Authorization: Basic + password → Vráti raw JMAP response getMailboxes(jmapConfig) → JMAP call: "Mailbox/get" → Vráti Inbox, Sent, Trash, atď. syncEmailsFromSender(jmapConfig, emailAccountId, contactId, email) → JMAP query: "Email/query" WHERE from = email → Fetch email details: "Email/get" → INSERT INTO emails (bulk) → Volá: markEmailAsRead() na serveri searchEmailsJMAP(jmapConfig, emailAccountId, query) → JMAP full-text search → Neukláda do DB, iba vráti results markEmailAsRead(jmapConfig, jmapId, isRead) → JMAP: "Email/set" update \Seen flag → Sync: UPDATE local DB sendEmail(jmapConfig, to, subject, body, inReplyTo, threadId) → JMAP: "EmailSubmission/set" → Pošle email cez JMAP server → INSERT do local DB (sentByUserId) discoverContactsFromJMAP(jmapConfig, emailAccountId, search, limit) → JMAP: "Email/query" group by sender → Vráti unique senderi (potenciálne kontakty) ``` **Volá:** - JMAP server API (HTTP POST) - Databázu (emails) pre sync --- ### 10. email.service.js **Účel:** Validácia JMAP credentials **Metódy:** ```javascript validateJmapCredentials(email, password) → HTTP GET na JMAP server /.well-known/jmap → Authorization: Basic email:password → Ak 200 → credentials OK → Ak 401 → invalid credentials → Vráti accountId a session data ``` **Volá:** - JMAP server (validácia) --- ### 11. time-tracking.service.js **Účel:** Sledovanie odpracovaného času **Databáza:** `timeEntries`, `projects`, `todos`, `companies`, `users` **Metódy:** ```javascript startTimeEntry(userId, data) → Validate: project/todo/company exists (ak sú poskytnuté) → Check: existujúci bežiaci časovač → Ak beží → automaticky ho zastaví → INSERT INTO timeEntries → isRunning = true, startTime = NOW() stopTimeEntry(entryId, userId, data) → Validate: ownership, isRunning = true → Počíta duration v minútach → UPDATE timeEntries SET endTime, duration, isRunning = false → Optional: update projectId, todoId, companyId, description getRunningTimeEntry(userId) → SELECT * WHERE userId AND isRunning = true → Vráti aktuálny bežiaci časovač (alebo null) getAllTimeEntries(userId, filters) → Filter: projectId, todoId, companyId, startDate, endDate → SELECT * FROM timeEntries WHERE userId → ORDER BY startTime DESC getMonthlyTimeEntries(userId, year, month) → SELECT * WHERE userId AND startTime BETWEEN month range → Používa sa pre mesačný prehľad getTimeEntryById(entryId) → SELECT * WHERE id → Throw NotFoundError ak neexistuje getTimeEntryWithRelations(entryId) → LEFT JOIN project, todo, company → Vráti všetko v jednom objekte updateTimeEntry(entryId, userId, data) → Validate: ownership, NOT isRunning (nemožno upraviť bežiaci) → Update: startTime, endTime, projectId, todoId, companyId, description → Prepočíta duration ak sa zmení čas → Set isEdited = true deleteTimeEntry(entryId, userId) → Validate: ownership, NOT isRunning → DELETE FROM timeEntries getMonthlyStats(userId, year, month) → Volá getMonthlyTimeEntries() → Počíta štatistiky: - totalMinutes / totalHours - daysWorked (unique days) - averagePerDay - byProject (čas per projekt) - byCompany (čas per firma) ``` **Volá:** - Databázu (timeEntries, projects, todos, companies) - `utils/errors.NotFoundError` - `utils/errors.BadRequestError` **Dôležité poznámky:** - **Auto-stop:** Pri štarte nového časovača sa automaticky zastaví predchádzajúci - **Duration:** Ukladá sa v minútach (Math.round) - **isEdited flag:** Označuje manuálne upravené záznamy - **isRunning:** Iba jeden časovač môže byť aktívny pre usera --- ### 12. audit.service.js **Účel:** Audit logging pre compliance **Databáza:** `auditLogs` **Metódy:** ```javascript logAuditEvent(userId, action, resourceType, resourceId, details, ip, userAgent) → INSERT INTO auditLogs → Ukladá všetky parametre // Špecifické loggery logLoginAttempt(username, success, ip, userAgent, error) → action = "login_attempt" logPasswordChange(userId, ip, userAgent) → action = "password_change" logEmailLink(userId, email, ip, userAgent) → action = "email_linked" logRoleChange(adminId, userId, oldRole, newRole, ip, userAgent) → action = "role_change" logUserCreation(adminId, newUserId, username, role, ip, userAgent) → action = "user_created" ``` **Volá:** - Iba databázu (write-only) --- ## CONTROLLERS **Účel:** Spracovanie HTTP requestov, volanie services, vracanie responses ### Štruktúra každého controllera: ```javascript export const methodName = async (req, res) => { try { // 1. Extract params/body const { param } = req.params const data = req.body const userId = req.userId // z authenticate middleware // 2. Volaj service const result = await service.method(param, data) // 3. Vráť response res.status(200).json({ success: true, data: result, message: 'Success message' }) } catch (error) { // 4. Error handling const errorResponse = formatErrorResponse(error, isDev) res.status(error.statusCode || 500).json(errorResponse) } } ``` ### Response Format (štandard): ```json { "success": true/false, "data": { ... }, "count": 10, // optional (pre lists) "message": "Success/Error message" } ``` --- ## ROUTES **Účel:** Definícia endpointov, middleware, validácia ### Štruktúra route file: ```javascript import express from 'express' import * as controller from '../controllers/xxx.controller.js' import { authenticate } from '../middlewares/auth/authMiddleware.js' import { validateBody, validateParams } from '../middlewares/security/validateInput.js' import { schema } from '../validators/xxx.validators.js' const router = express.Router() // Global middleware (pre všetky routes) router.use(authenticate) // Route definition router.get('/', controller.getAll ) router.post('/', validateBody(schema), // Zod validation controller.create ) router.patch('/:id', validateParams(z.object({ id: z.string().uuid() })), validateBody(updateSchema), controller.update ) export default router ``` ### Middleware poradie: 1. `authenticate` - JWT validácia 2. `validateParams` - Path params validácia (Zod) 3. `validateBody` - Request body validácia (Zod) 4. `controller.method` - Controller handler --- ## UTILS ### 1. errors.js **Účel:** Custom error classy a formatting **Error Classes:** ```javascript class AppError extends Error { constructor(message, statusCode) { this.statusCode = statusCode this.isOperational = true } } class ValidationError extends AppError { statusCode = 400 } class BadRequestError extends AppError { statusCode = 400 } class AuthenticationError extends AppError { statusCode = 401 } class ForbiddenError extends AppError { statusCode = 403 } class NotFoundError extends AppError { statusCode = 404 } class ConflictError extends AppError { statusCode = 409 } class RateLimitError extends AppError { statusCode = 429 } ``` **Funkcie:** ```javascript formatErrorResponse(error, includeStack) → Formátuje error pre API response → V dev mode: vracia stack trace → V prod mode: iba message ``` **Použitie:** ```javascript throw new NotFoundError('Company not found') throw new ConflictError('Email already exists') ``` --- ### 2. jwt.js **Účel:** JWT token management **Funkcie:** ```javascript generateAccessToken(payload) → jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' }) generateRefreshToken(payload) → jwt.sign(payload, JWT_REFRESH_SECRET, { expiresIn: '7d' }) verifyAccessToken(token) → jwt.verify(token, JWT_SECRET) → Vráti decoded payload alebo throw error generateTokenPair(user) → Vytvorí obe tokens naraz → Payload: { userId, username, role } ``` **Environment Variables:** ```bash JWT_SECRET=xxx JWT_REFRESH_SECRET=xxx JWT_EXPIRES_IN=1h JWT_REFRESH_EXPIRES_IN=7d ``` --- ### 3. logger.js **Účel:** Farebné console logging **Metódy:** ```javascript logger.info(message, ...args) // 🔵 Blue logger.success(message, ...args) // 🟢 Green logger.warn(message, ...args) // 🟡 Yellow logger.error(message, error) // 🔴 Red logger.debug(message, ...args) // 🔷 Cyan (dev only) logger.audit(message, ...args) // 🟣 Magenta ``` **Použitie:** ```javascript logger.info('Server started', { port: 5000 }) logger.success('User created', { userId }) logger.error('Database error', error) ``` --- ### 4. password.js **Účel:** Password hashing, encryption, generation **Funkcie:** ```javascript // Bcrypt (pre user passwords) hashPassword(password) → bcrypt.hash(password, BCRYPT_ROUNDS) comparePassword(password, hash) → bcrypt.compare(password, hash) // AES-256-GCM (pre email passwords) encryptPassword(text) → crypto.randomBytes(16) // IV → crypto.scrypt(JWT_SECRET) // Key derivation → cipher = crypto.createCipheriv('aes-256-gcm') → Vráti: "iv:authTag:encrypted" (base64) decryptPassword(encryptedText) → Split "iv:authTag:encrypted" → decipher = crypto.createDecipheriv('aes-256-gcm') → Vráti plaintext // Temp password generation generateTempPassword(length = 12) → Random: uppercase + lowercase + numbers + special chars → Použitie: admin vytvára usera ``` **Prečo 2 typy encryption?** - **Bcrypt (user passwords):** One-way hash, nemožné dekryptovať - **AES-256 (email passwords):** Reversible, treba plaintext pre JMAP login --- ## KOMPLETNÝ ZOZNAM API ### 🔐 AUTHENTICATION #### POST /api/auth/login ``` Účel: Prihlásenie používateľa Body: { username, password } Response: { user, tokens, needsPasswordChange, needsEmailSetup } Rate Limit: Áno ``` #### POST /api/auth/set-password ``` Účel: Zmena hesla pri prvom prihlásení Body: { newPassword } Auth: Áno Rate Limit: Áno ``` #### POST /api/auth/link-email ``` Účel: Pripojenie email účtu Body: { email, emailPassword } Auth: Áno Volá: email.service, emailAccountService ``` #### POST /api/auth/skip-email ``` Účel: Preskočiť email setup Auth: Áno ``` #### POST /api/auth/logout ``` Účel: Odhlásenie (clear cookies) Auth: Áno Response: Clear accessToken & refreshToken cookies ``` #### GET /api/auth/session ``` Účel: Získať info o aktuálnej session Auth: Áno Response: { user, authenticated: true } ``` #### GET /api/auth/me ``` Účel: Profil aktuálneho usera Auth: Áno Response: { user with emailAccounts } ``` --- ### 👥 ADMIN (User Management) #### POST /api/admin/users ``` Účel: Vytvoriť nového usera Body: { username, email?, emailPassword?, firstName?, lastName? } Auth: Áno (admin only) Response: { user with tempPassword } Efekt: Vygeneruje temp password, user musí zmeniť pri login ``` #### GET /api/admin/users ``` Účel: Zoznam všetkých userov Auth: Áno (admin only) Response: Array of users ``` #### GET /api/admin/users/:userId ``` Účel: Detail usera Auth: Áno (admin only) Response: { user with emailAccounts } ``` #### PATCH /api/admin/users/:userId/role ``` Účel: Zmeniť rolu usera Body: { role: "admin" | "member" } Auth: Áno (admin only) ``` #### DELETE /api/admin/users/:userId ``` Účel: Zmazať usera Auth: Áno (admin only) Efekt: CASCADE delete všetkých dát usera ``` --- ### 🏢 COMPANIES #### GET /api/companies?search=query ``` Účel: Zoznam firiem Query: search (optional) - hľadá v name, email, city Auth: Áno Response: Array of companies ``` #### GET /api/companies/:companyId ``` Účel: Detail firmy Auth: Áno Response: Company object ``` #### GET /api/companies/:companyId/details ``` Účel: Firma s všetkými reláciami Auth: Áno Response: { ...company, projects: [], todos: [], notes: [] } Poznámka: NEVYUŽÍVA SA vo frontende (robí sa 3 samostatné cally) ``` #### POST /api/companies ``` Účel: Vytvoriť firmu Body: { name*, description, address, city, country, phone, email, website } Auth: Áno Validation: createCompanySchema ``` #### PATCH /api/companies/:companyId ``` Účel: Upraviť firmu Body: (all optional) { name, description, ... } Auth: Áno Validation: updateCompanySchema ``` #### DELETE /api/companies/:companyId ``` Účel: Zmazať firmu Auth: Áno Efekt: CASCADE delete projects, todos, notes ``` #### GET /api/companies/:companyId/notes ``` Účel: Poznámky firmy Auth: Áno Response: Array of notes (mapped reminderDate→reminderAt) ``` #### POST /api/companies/:companyId/notes ``` Účel: Pridať poznámku k firme Body: { content*, reminderAt? } Auth: Áno ``` #### PATCH /api/companies/:companyId/notes/:noteId ``` Účel: Upraviť poznámku firmy Body: { content?, reminderAt? } Auth: Áno ``` #### DELETE /api/companies/:companyId/notes/:noteId ``` Účel: Zmazať poznámku firmy Auth: Áno ``` --- ### 📁 PROJECTS #### GET /api/projects?search=query&companyId=uuid ``` Účel: Zoznam projektov Query: search, companyId (both optional) Auth: Áno ``` #### GET /api/projects/:projectId ``` Účel: Detail projektu Auth: Áno ``` #### GET /api/projects/:projectId/details ``` Účel: Projekt s reláciami Auth: Áno Response: { ...project, company, todos, notes, timesheets, assignedUsers } Poznámka: NEVYUŽÍVA SA vo frontende ``` #### POST /api/projects ``` Účel: Vytvoriť projekt Body: { name*, description, companyId, status, startDate, endDate } Auth: Áno Validation: createProjectSchema ``` #### PATCH /api/projects/:projectId ``` Účel: Upraviť projekt Body: (all optional) Auth: Áno Validation: updateProjectSchema ``` #### DELETE /api/projects/:projectId ``` Účel: Zmazať projekt Auth: Áno Efekt: CASCADE delete todos, notes, projectUsers ``` #### GET /api/projects/:projectId/notes ``` Účel: Poznámky projektu Auth: Áno ``` #### POST /api/projects/:projectId/notes ``` Účel: Pridať poznámku k projektu Body: { content*, reminderAt? } Auth: Áno ``` #### PATCH /api/projects/:projectId/notes/:noteId ``` Účel: Upraviť poznámku projektu Auth: Áno ``` #### DELETE /api/projects/:projectId/notes/:noteId ``` Účel: Zmazať poznámku projektu Auth: Áno ``` #### GET /api/projects/:projectId/users ``` Účel: Členovia tímu projektu (many-to-many relationship) Auth: Áno Response: Array of { id, userId, role, addedBy, addedAt, user: { id, username, email, role } } Volá: project.service.getProjectUsers() ``` #### POST /api/projects/:projectId/users ``` Účel: Priradiť usera k projektu Body: { userId*, role? } Auth: Áno Validation: - userId musí byť UUID - role je optional (text) - User nesmie byť už assigned (UNIQUE constraint) Response: Assignment with user details Volá: project.service.assignUserToProject() Errors: - 404: Project alebo User nenájdený - 409: User už je assigned k projektu ``` #### PATCH /api/projects/:projectId/users/:userId ``` Účel: Zmeniť rolu usera na projekte Params: projectId (UUID), userId (UUID) Body: { role? } Auth: Áno Poznámka: Momentálne sa NEVYUŽÍVA vo frontende (role field removed) Volá: project.service.updateUserRoleOnProject() ``` #### DELETE /api/projects/:projectId/users/:userId ``` Účel: Odstrániť usera z projektu Params: projectId (UUID), userId (UUID) Auth: Áno Response: { success: true, message: 'Používateľ bol odstránený z projektu' } Volá: project.service.removeUserFromProject() Errors: - 404: User nie je assigned k projektu ``` --- ### ✅ TODOS #### GET /api/todos?search=&projectId=&companyId=&assignedTo=&status= ``` Účel: Zoznam úloh Query: všetky parametre optional Auth: Áno ``` #### GET /api/todos/my?status= ``` Účel: Moje úlohy (assigned to current user) Auth: Áno Poznámka: NEVYUŽÍVA SA vo frontende ``` #### GET /api/todos/:todoId ``` Účel: Detail todo Auth: Áno ``` #### GET /api/todos/:todoId/details ``` Účel: Todo s reláciami Auth: Áno Response: { ...todo, project, company, assignedUser, notes } Poznámka: NEVYUŽÍVA SA vo frontende ``` #### POST /api/todos ``` Účel: Vytvoriť todo Body: { title*, description, projectId, companyId, assignedTo, status, priority, dueDate } Auth: Áno Validation: createTodoSchema ``` #### PATCH /api/todos/:todoId ``` Účel: Upraviť todo Body: (all optional) Auth: Áno Efekt: Ak status=completed → nastaví completedAt ``` #### DELETE /api/todos/:todoId ``` Účel: Zmazať todo Auth: Áno ``` #### PATCH /api/todos/:todoId/toggle ``` Účel: Toggle completed status Auth: Áno Efekt: status 'completed' ↔ 'pending' Poznámka: Používa sa vo frontende pre checkbox toggle Response: Updated todo ``` --- ### 📝 NOTES (Standalone) **POZNÁMKA:** Všetky standalone note routes sú **NEVYUŽITÉ** vo frontende. Notes sa používajú iba cez nested routes (companies/:id/notes, projects/:id/notes). #### GET /api/notes?search=&companyId=&projectId=&todoId=&contactId= ``` Účel: Zoznam všetkých poznámok Poznámka: NEVYUŽÍVA SA ``` #### GET /api/notes/my-reminders ``` Účel: Moje pending reminders Poznámka: NEVYUŽÍVA SA (mohlo by byť užitočné!) ``` #### GET /api/notes/:noteId ``` Účel: Detail poznámky Poznámka: NEVYUŽÍVA SA ``` #### POST /api/notes ``` Účel: Vytvoriť standalone poznámku Poznámka: NEVYUŽÍVA SA ``` #### PATCH /api/notes/:noteId ``` Účel: Upraviť poznámku Poznámka: NEVYUŽÍVA SA ``` #### DELETE /api/notes/:noteId ``` Účel: Zmazať poznámku Poznámka: NEVYUŽÍVA SA ``` #### POST /api/notes/:noteId/mark-reminder-sent ``` Účel: Označiť reminder ako odoslaný Poznámka: NEVYUŽÍVA SA ``` --- ### 👤 CONTACTS #### GET /api/contacts?accountId=uuid ``` Účel: Zoznam kontaktov pre email account Query: accountId (required) Auth: Áno ``` #### GET /api/contacts/discover?accountId=&search=&limit= ``` Účel: Objaviť potenciálne kontakty z emailov Query: accountId (optional, uses primary), search, limit Auth: Áno Volá: jmap.service.discoverContactsFromJMAP() ``` #### POST /api/contacts ``` Účel: Pridať kontakt Body: { email*, name, notes, accountId } Auth: Áno Efekt: Sync emails from sender, reassign existing emails Volá: jmap.service.syncEmailsFromSender() ``` #### PATCH /api/contacts/:contactId?accountId=uuid ``` Účel: Upraviť kontakt Body: { name, notes } Query: accountId (required) Auth: Áno ``` #### DELETE /api/contacts/:contactId?accountId=uuid ``` Účel: Zmazať kontakt Query: accountId (required) Auth: Áno Efekt: CASCADE delete emails ``` #### POST /api/contacts/:contactId/link-company?accountId=uuid ``` Účel: Linknúť firmu k kontaktu Body: { companyId* } Poznámka: NEVYUŽÍVA SA vo frontende ``` #### POST /api/contacts/:contactId/unlink-company?accountId=uuid ``` Účel: Odlinkovať firmu od kontaktu Poznámka: NEVYUŽÍVA SA vo frontende ``` #### POST /api/contacts/:contactId/create-company?accountId=uuid ``` Účel: Vytvoriť firmu z kontaktu Body: (optional) { name, email, phone, ... } Poznámka: NEVYUŽÍVA SA vo frontende Efekt: Vytvorí company, nastaví contact.companyId ``` --- ### 📧 EMAIL ACCOUNTS #### GET /api/email-accounts ``` Účel: Zoznam mojich email účtov Auth: Áno Response: Array of accounts (bez passwords!) ``` #### GET /api/email-accounts/:id ``` Účel: Detail email accountu Auth: Áno Poznámka: NEVYUŽÍVA SA vo frontende ``` #### POST /api/email-accounts ``` Účel: Pripojiť email account Body: { email*, emailPassword* } Auth: Áno Rate Limit: Áno Efekt: - Validate credentials (JMAP) - Encrypt password (AES-256-GCM) - Ak existuje → share (shared=true) - Ak prvý → set primary - Sync contacts Volá: email.service, password.encryptPassword() ``` #### PATCH /api/email-accounts/:id/password ``` Účel: Zmeniť heslo k emailu Body: { emailPassword* } Auth: Áno Poznámka: NEVYUŽÍVA SA vo frontende ``` #### PATCH /api/email-accounts/:id/status ``` Účel: Aktivovať/deaktivovať email account Body: { isActive* } Auth: Áno Poznámka: NEVYUŽÍVA SA vo frontende ``` #### POST /api/email-accounts/:id/set-primary ``` Účel: Nastaviť ako primárny email Auth: Áno Efekt: Ostatné accounts → isPrimary = false ``` #### DELETE /api/email-accounts/:id ``` Účel: Odstrániť prístup k emailu Auth: Áno Rate Limit: Áno Efekt: - Ak posledný user → delete account + data - Ak shared → iba unlink ``` --- ### ✉️ EMAILS (CRM Email Management) #### GET /api/emails?accountId=uuid ``` Účel: Zoznam emailov Query: accountId (required) Auth: Áno Response: Iba emaily od pridaných kontaktov! ``` #### GET /api/emails/thread/:threadId?accountId=uuid ``` Účel: Email thread (konverzácia) Auth: Áno Response: Všetky emaily v threade, sorted by date ``` #### GET /api/emails/search?accountId=&query= ``` Účel: Search v uložených emailoch (DB) Query: accountId, query (min 2 chars) Auth: Áno Limit: 50 results ``` #### GET /api/emails/search-jmap?accountId=&query= ``` Účel: Full-text search cez JMAP server Query: accountId, query Auth: Áno Volá: jmap.service.searchEmailsJMAP() Poznámka: Hľadá vo VŠETKÝCH emailoch, nie len v DB ``` #### GET /api/emails/unread-count?accountId=uuid ``` Účel: Počet neprečítaných emailov Query: accountId (required) Auth: Áno Response: { totalUnread, byAccount: [...] } ``` #### POST /api/emails/sync?accountId=uuid ``` Účel: Synchronizovať najnovšie emaily z JMAP Query: accountId (required) Auth: Áno Efekt: Fetch latest emails, store to DB Volá: jmap.service.syncEmails() ``` #### POST /api/emails/thread/:threadId/read?accountId=uuid ``` Účel: Označiť thread ako prečítaný Auth: Áno Efekt: UPDATE emails SET isRead = true + sync JMAP ``` #### GET /api/emails/contact/:contactId?accountId=uuid ``` Účel: Emaily od konkrétneho kontaktu Poznámka: NEVYUŽÍVA SA vo frontende ``` #### POST /api/emails/contact/:contactId/read?accountId=uuid ``` Účel: Označiť všetky emaily kontaktu ako prečítané Auth: Áno ``` #### PATCH /api/emails/:jmapId/read?accountId=uuid ``` Účel: Označiť jeden email ako read/unread Body: { isRead* } Auth: Áno Poznámka: NEVYUŽÍVA SA vo frontende ``` #### POST /api/emails/reply ``` Účel: Odpovedať na email / poslať nový Body: { to*, subject*, body*, inReplyTo?, threadId? } Auth: Áno Efekt: Send via JMAP, store to DB Volá: jmap.service.sendEmail() ``` --- ### ⏱️ TIME TRACKING #### POST /api/time-tracking/start ``` Účel: Spustiť nový časovač Body: { projectId?, todoId?, companyId?, description? } Auth: Áno Response: { entry with isRunning: true } Efekt: - Automaticky zastaví predchádzajúci bežiaci časovač (ak existuje) - Vytvorí nový time entry s startTime = NOW() ``` #### POST /api/time-tracking/:entryId/stop ``` Účel: Zastaviť bežiaci časovač Params: entryId (UUID) Body: { projectId?, todoId?, companyId?, description? } Auth: Áno Response: { entry with endTime, duration, isRunning: false } Efekt: - Nastaví endTime = NOW() - Vypočíta duration v minútach - Optional: update projektId/todoId/companyId/description ``` #### GET /api/time-tracking/running ``` Účel: Získať aktuálny bežiaci časovač Auth: Áno Response: { entry } alebo null ``` #### GET /api/time-tracking?projectId=&todoId=&companyId=&startDate=&endDate= ``` Účel: Zoznam všetkých time entries s filtrami Query: projectId, todoId, companyId, startDate, endDate (all optional) Auth: Áno Response: Array of time entries Order: DESC by startTime ``` #### GET /api/time-tracking/month/:year/:month ``` Účel: Mesačný prehľad time entries Params: year (YYYY), month (1-12) Auth: Áno Response: Array of entries pre daný mesiac ``` #### GET /api/time-tracking/stats/monthly/:year/:month ``` Účel: Mesačné štatistiky Params: year (YYYY), month (1-12) Auth: Áno Response: { totalMinutes, totalHours, remainingMinutes, daysWorked, averagePerDay, entriesCount, byProject: { projectId: minutes }, byCompany: { companyId: minutes } } ``` #### GET /api/time-tracking/:entryId ``` Účel: Detail time entry Params: entryId (UUID) Auth: Áno Response: Single time entry ``` #### GET /api/time-tracking/:entryId/details ``` Účel: Detail time entry s reláciami Params: entryId (UUID) Auth: Áno Response: { ...entry, project, todo, company } ``` #### PATCH /api/time-tracking/:entryId ``` Účel: Upraviť time entry Params: entryId (UUID) Body: { startTime?, endTime?, projectId?, todoId?, companyId?, description? } Auth: Áno Validation: - Entry nesmie byť isRunning (nemožno upraviť bežiaci časovač) - User musí byť owner - Pri zmene času sa prepočíta duration Response: Updated entry with isEdited: true ``` #### DELETE /api/time-tracking/:entryId ``` Účel: Zmazať time entry Params: entryId (UUID) Auth: Áno Validation: - Entry nesmie byť isRunning - User musí byť owner ``` --- ### 📊 TIMESHEETS #### POST /api/timesheets/upload ``` Účel: Upload timesheet file Content-Type: multipart/form-data Form: file (PDF/Excel), year (YYYY), month (1-12) Auth: Áno Validation: Max 10MB ``` #### GET /api/timesheets/my ``` Účel: Moje timesheets Auth: Áno ``` #### GET /api/timesheets/all ``` Účel: Všetky timesheets (admin) Auth: Áno (admin only) ``` #### GET /api/timesheets/:timesheetId/download ``` Účel: Stiahnuť timesheet file Auth: Áno Response: File download ``` #### DELETE /api/timesheets/:timesheetId ``` Účel: Zmazať timesheet Auth: Áno Efekt: Delete file + DB record ``` --- ## VZŤAHY MEDZI SLUŽBAMI ### Call Graph (kto volá koho) ``` AUTH FLOW: auth.controller → auth.service → emailAccountService.createEmailAccount() → email.service.validateJmapCredentials() → password.encryptPassword() → contact.service.syncContactsForAccount() → jmap.service.discoverContactsFromJMAP() → password.hashPassword() → jwt.generateTokenPair() CONTACT CREATION: contact.controller → contact.service.addContact() → jmap.service.syncEmailsFromSender() → jmapRequest() → JMAP Server → INSERT emails to DB COMPANY FROM CONTACT: contact.controller → contact.service.createCompanyFromContact() → company.service.createCompany() → UPDATE contact.companyId PROJECT TEAM: project.controller → project.service.assignUserToProject() → Validate user exists (users table) → INSERT projectUsers EMAIL SEND: email.controller → jmap.service.sendEmail() → jmapRequest() → JMAP Server → INSERT email to DB (sent) TIME TRACKING: time-tracking.controller → time-tracking.service.startTimeEntry() → Check running entry (auto-stop if exists) → Validate project/todo/company → INSERT timeEntries → time-tracking.service.stopTimeEntry() → Calculate duration → UPDATE timeEntries (set endTime, isRunning = false) → time-tracking.service.getMonthlyStats() → Aggregate statistics by project/company AUDIT: Rôzne controllers → audit.service.logAuditEvent() → INSERT auditLogs ``` ### Service Dependencies **Tier 1 (No dependencies):** - `password.js` (util) - `jwt.js` (util) - `logger.js` (util) - `errors.js` (util) **Tier 2 (Only utils):** - `email.service` → (iba HTTP call) - `audit.service` → (iba DB) **Tier 3 (Utils + Tier 2):** - `jmap.service` → database, HTTP - `company.service` → database, errors - `todo.service` → database, errors - `note.service` → database, errors - `time-tracking.service` → database, errors **Tier 4 (Multiple services):** - `contact.service` → company.service, jmap.service - `emailAccountService` → email.service, password, contact.service - `project.service` → database, errors **Tier 5 (Highest level):** - `auth.service` → emailAccountService, password, jwt --- ## POZNÁMKY ### Bezpečnosť - **User passwords:** bcrypt (12 rounds, one-way hash) - **Email passwords:** AES-256-GCM (reversible, pre JMAP login) - **JWT tokens:** HS256, httpOnly cookies - **Rate limiting:** Login, password change, email operations ### Performance - **Database indexy:** Na všetkých foreign keys - **Eager loading:** `getWithRelations()` metódy použiť iba ak treba všetko - **Pagination:** Momentálne nie je, odporúčam pridať na lists ### Maintenance - **Error handling:** Centralizované cez `formatErrorResponse()` - **Logging:** Štruktúrované cez `logger` - **Audit trail:** Všetky kritické akcie logované ### Možné zlepšenia 1. Pagination na všetkých list endpointoch 2. WebSocket pre real-time notifications (time tracking updates, email sync) 3. Background jobs pre email sync (Bull/Redis) 4. Cache layer (Redis) pre často čítané dáta 5. API versioning (/api/v1/) 6. GraphQL ako alternatíva k REST 7. Implement standalone Notes UI alebo vymazať routes 8. Time tracking export do CSV/Excel pre reporting 9. Team time tracking dashboard (admin view všetkých userov) --- ## ⚠️ TECHNICKÉ POZNÁMKY A GOTCHAS ### 1. Drizzle ORM - Join Query Pattern **PROBLÉM:** Drizzle ORM nepodporuje nested object syntax v `.select()` ```javascript // ❌ NEFUNGUJE - TypeError: Cannot convert undefined or null to object const wrong = await db .select({ id: projectUsers.id, user: { id: users.id, username: users.username, } }) .from(projectUsers) .leftJoin(users, eq(projectUsers.userId, users.id)); // ✅ SPRÁVNE RIEŠENIE const rawResults = await db .select() // Bez parametrov! .from(projectUsers) .leftJoin(users, eq(projectUsers.userId, users.id)) .where(eq(projectUsers.projectId, projectId)) .orderBy(desc(projectUsers.addedAt)); // Následne mapovať výsledky: const assignedUsers = rawResults.map((row) => ({ id: row.project_users.id, // snake_case table name userId: row.project_users.userId, role: row.project_users.role, user: row.users ? { // null check pre LEFT JOIN id: row.users.id, username: row.users.username, email: row.users.email, } : null, })); ``` **Kde sa to používa:** - `project.service.js`: `getProjectUsers()`, `assignUserToProject()`, `updateUserRoleOnProject()`, `getProjectWithRelations()` - Všade kde robíme LEFT JOIN s users, companies, projects, atď. --- ### 2. Todo Status - Enum vs Boolean **PROBLÉM:** Frontend pôvodne checkoval `todo.completed` (boolean), ale databáza má `status` enum. **Databázová schéma:** ```javascript status: pgEnum('todo_status', ['pending', 'in_progress', 'completed', 'cancelled']) ``` **SPRÁVNE použitie vo frontende:** ```javascript // ✅ SPRÁVNE const isCompleted = todo.status === 'completed'; // ❌ NESPRÁVNE - toto pole neexistuje! const isCompleted = todo.completed; ``` **Toggle endpoint:** ```javascript // Backend: /api/todos/:todoId/toggle // Toggleuje medzi: 'completed' ↔ 'pending' const newStatus = todo.status === 'completed' ? 'pending' : 'completed'; ``` **Kde sa to používa:** - Frontend: `TodoItem.jsx`, `TodosPage.jsx`, `ProjectNotesModal.jsx` - Backend: `todo.service.js` - `toggleTodoComplete()` --- ### 3. Node.js Module Caching **PROBLÉM:** Po zmene kódu server niekedy beží stále starý kód, aj keď nodemon reštartoval. **PRÍČINY:** - Viacero nodemon procesov naraz (v separátnych termináloch) - ESM module caching - Neukončené background procesy **RIEŠENIE:** ```bash # Zabij všetky node/nodemon procesy ps aux | grep -E "node|nodemon" | grep -v grep | awk '{print $2}' | xargs kill -9 # Alebo len na porte 5000 lsof -i:5000 | grep LISTEN | awk '{print $2}' | xargs kill -9 # Clear cache a reštart rm -rf node_modules/.cache npm run dev ``` **PREVENCIA:** - Nespúšťať server v background mode počas developmentu - Používať iba jeden terminál pre server - Check procesy: `ps aux | grep node` --- ### 4. Frontend API Response Parsing **PROBLÉM:** API môže vracať rôzne formáty odpovede. **Admin API - Get All Users:** ```javascript // Backend vracia: { users: [...], count: 10 } // Frontend musí extrahovať: const usersList = Array.isArray(data) ? data : (data?.users || []) ``` **Project API - Get Team Members:** ```javascript // Backend vracia priamo array: [{ id, userId, user: {...} }] // Frontend očakáva array: setTeamMembers(data || []) ``` **BEST PRACTICE:** - Vždy checkuj `Array.isArray()` - Fallback na prázdne array: `data || []` - Console.log response v dev mode pre debug --- ### 5. Database Constraints **projectUsers table - UNIQUE constraint:** ```javascript unique('project_user_unique').on(table.projectId, table.userId) ``` **Význam:** - User môže byť assigned k projektu **iba raz** - Duplicate assignment → `409 ConflictError` **Cascade deletes:** ```javascript onDelete: 'cascade' // Ak sa zmaže project/user → zmaže sa assignment ``` **Kde sa používa:** - Projects: CASCADE delete na todos, notes, timesheets, projectUsers - Companies: CASCADE delete na projects (a všetko pod nimi) - Email accounts: CASCADE delete na contacts, emails --- ### 6. Authentication Middleware **Každá route (okrem /api/auth/login) vyžaduje authentication:** ```javascript router.use(authenticate); // Global middleware na route file ``` **JWT token sa číta z:** 1. httpOnly cookie `accessToken` (preferované) 2. Authorization header `Bearer ` (fallback) **User info v request:** ```javascript req.userId // UUID req.username // String req.userRole // 'admin' | 'member' ``` --- ### 7. Time Tracking - Auto-stop Behavior **PROBLÉM:** User môže mať spustený iba jeden časovač naraz. **RIEŠENIE - Automatický stop:** ```javascript // Pri štarte nového časovača if (existingRunning) { // Automaticky zastaví predchádzajúci časovač const endTime = new Date(); const duration = Math.round((endTime - startTime) / 60000); // minúty await db.update(timeEntries) .set({ endTime, duration, isRunning: false }) .where(eq(timeEntries.id, existingRunning.id)); } ``` **Validačné pravidlá:** ```javascript // ✅ POVOLENÉ - Spustiť nový časovač (auto-stop predchádzajúceho) - Upraviť zastavený časovač - Zmazať zastavený časovač // ❌ ZAKÁZANÉ - Upraviť bežiaci časovač (musí sa najprv zastaviť) - Zmazať bežiaci časovač (musí sa najprv zastaviť) - Zastaviť časovač iného usera ``` **Duration calculation:** - Ukladá sa v **minútach** (Math.round) - Počíta sa z rozdelu: `(endTime - startTime) / 60000` - Frontend zobrazuje: hodiny + minúty (napr. "2h 35m") **isEdited flag:** - Automaticky nastavený pri manuálnej úprave - Indikuje, že čas bol zmenený používateľom (nie auto-tracked) **Kde sa používa:** - `time-tracking.service.js`: `startTimeEntry()`, `updateTimeEntry()` - Frontend: Time tracking komponenty s real-time countdown --- ## 🔍 DEBUGGING TIPS ### 1. Server beží starý kód ```bash # Check running processes ps aux | grep node # Kill all and restart pkill -9 node && npm run dev ``` ### 2. Database query debugging ```javascript // Drizzle query debugging const result = await db.select()... console.log('[DEBUG] Raw DB result:', JSON.stringify(result, null, 2)); ``` ### 3. Frontend API debugging ```javascript // V API function console.log('[DEBUG] API Response:', data); console.log('[DEBUG] Extracted users:', usersList); ``` ### 4. JMAP email issues ```javascript // Check JMAP credentials const valid = await validateJmapCredentials(email, password); console.log('[DEBUG] JMAP validation:', valid); ``` --- **Vytvorené:** 2025-11-21 **Posledná aktualizácia:** 2025-11-24 **Autor:** CRM Server Team **Kontakt:** crm-server documentation