Files
crm-server/DOKUMENTACIA.md
2025-11-24 10:18:28 +01:00

47 KiB

📚 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
  2. Services - Biznis Logika
  3. Controllers - Request Handling
  4. Routes - API Endpointy
  5. Utils - Pomocné Funkcie
  6. Kompletný Zoznam API
  7. 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)
  • companiesprojects: One-to-many (company môže mať viac projektov)
  • projectstodos, notes, timesheets: One-to-many
  • userstodos.assignedTo: One-to-many (user má assigned úlohy)
  • userstimeEntries: One-to-many (user má záznamy času)
  • timeEntriesprojects, 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:

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:

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:

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:

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:  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:

// ✅ 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:

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:

// Frontend Mapping Helper
mapNoteForFrontend(note)
   Konvertuje: reminderDate  reminderAt
   Frontend používa "reminderAt", DB  "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:

getUserEmailAccounts(userId)
   SELECT emailAccounts
   JOIN userEmailAccounts
   WHERE userId

createEmailAccount(userId, email, emailPassword)
   Volá: email.service.validateJmapCredentials()
   Encrypt password: password.encryptPassword()
   Kontrola: email  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  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:

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:

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:

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:

startTimeEntry(userId, data)
   Validate: project/todo/company exists (ak  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:

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:

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):

{
  "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:

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:

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:

formatErrorResponse(error, includeStack)
   Formátuje error pre API response
   V dev mode: vracia stack trace
   V prod mode: iba message

Použitie:

throw new NotFoundError('Company not found')
throw new ConflictError('Email already exists')

2. jwt.js

Účel: JWT token management

Funkcie:

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:

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:

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:

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:

// 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()

// ❌ 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:

status: pgEnum('todo_status', ['pending', 'in_progress', 'completed', 'cancelled'])

SPRÁVNE použitie vo frontende:

// ✅ SPRÁVNE
const isCompleted = todo.status === 'completed';

// ❌ NESPRÁVNE - toto pole neexistuje!
const isCompleted = todo.completed;

Toggle endpoint:

// 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:

# 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:

// Backend vracia:
{ users: [...], count: 10 }

// Frontend musí extrahovať:
const usersList = Array.isArray(data) ? data : (data?.users || [])

Project API - Get Team Members:

// 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:

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:

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:

router.use(authenticate);  // Global middleware na route file

JWT token sa číta z:

  1. httpOnly cookie accessToken (preferované)
  2. Authorization header Bearer <token> (fallback)

User info v request:

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:

// 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á:

// ✅ 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

# Check running processes
ps aux | grep node

# Kill all and restart
pkill -9 node && npm run dev

2. Database query debugging

// Drizzle query debugging
const result = await db.select()...
console.log('[DEBUG] Raw DB result:', JSON.stringify(result, null, 2));

3. Frontend API debugging

// V API function
console.log('[DEBUG] API Response:', data);
console.log('[DEBUG] Extracted users:', usersList);

4. JMAP email issues

// 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