diff --git a/CLEAN_START_GUIDE.md b/CLEAN_START_GUIDE.md deleted file mode 100644 index f7f1ab5..0000000 --- a/CLEAN_START_GUIDE.md +++ /dev/null @@ -1,125 +0,0 @@ -# CLEAN START - Kompletný Reset CRM Systému - -## Problém -- Staré/archivované emaily sa syncujú aj keď nie sú v Thunderbirde -- Emaily majú zlý contactId -- Kontakty sa nedajú vymazať cez UI - -## Riešenie: KOMPLETNÝ RESET - -### Krok 1: Vyčisti databázu - -```bash -node src/scripts/CLEAN_EVERYTHING.js -``` - -Toto vymaže: -- ✅ Všetky emaily -- ✅ Všetky kontakty - -### Krok 2: Teraz môžeš pridať kontakty NANOVO - -Nový sync bude: -- ✅ IBA z Inboxu a Sent foldra (nie Archive, Trash, Drafts) -- ✅ IBA emaily z posledných 30 dní -- ✅ IBA emaily FROM alebo TO daný kontakt - -### Krok 3: Ako to funguje teraz - -Keď pridáš kontakt (napr. `martin@slovensko.ai`): - -**SYNCNE:** -- ✅ Emaily FROM martin@slovensko.ai → riso@slovensko.ai (prijaté) -- ✅ Emaily FROM riso@slovensko.ai → martin@slovensko.ai (odoslané) -- ✅ Iba z Inbox a Sent -- ✅ Iba z posledných 30 dní - -**NÉSYNCNE:** -- ❌ Emaily od iných ľudí -- ❌ Staré archivované emaily -- ❌ Emaily z Trash/Archive/Drafts -- ❌ Emaily staršie ako 30 dní - -## Prečo sa fetchovali staré emaily? - -JMAP API fetchuje **zo SERVERA, nie z Thunderbirdu**! - -- Thunderbird je len klient -- Server (truemail.sk) má všetky emaily (aj zmazané, archivované) -- Starý buggy kód fetchoval zo všetkých mailboxov bez filtra - -## Nové nastavenia syncу - -Môžeš zmeniť v kóde: - -```javascript -// V contact.service.js, line 56 -await syncEmailsFromSender(jmapConfig, emailAccountId, newContact.id, email, { - limit: 50, // Max počet emailov - daysBack: 30 // Dni späť (30 = posledný mesiac) -}); -``` - -**Zmeniť na:** -- `daysBack: 7` - Iba posledný týždeň -- `daysBack: 90` - Posledné 3 mesiace -- `limit: 20` - Max 20 emailov - -## Alternatíva: Manuálne vyčistenie cez DB - -Ak nechceš používať script: - -```sql --- Vymaž všetky emaily -DELETE FROM emails; - --- Vymaž všetky kontakty -DELETE FROM contacts; -``` - -## Po vyčistení - -1. **Refreshni frontend** (F5) -2. **Pridaj kontakty** cez Inbox page -3. **Počkaj** kým sa syncnú (vidíš loading) -4. **Skontroluj** že sú iba správne emaily - -## Ak sa stále fetchujú zlé emaily - -Znamená to že sú SKUTOČNE v Inbox/Sent na serveri. - -Choď do Thunderbirdu a: -1. Presuň nechcené emaily do Archive -2. Alebo ich TRVALO vymaž (Shift+Delete) -3. WAIT 5 min (server sa musí syncnúť) -4. Potom spusti CLEAN_EVERYTHING.js -5. A pridaj kontakty znova - -## Výhody nového systému - -✅ Žiadne staré smeti -✅ Iba aktuálna komunikácia -✅ Správne groupovanie -✅ Rýchlejší sync -✅ Menšia databáza - -## Testing - -Po resete otestuj: - -1. **Pridaj kontakt** (napr. foxerdxd@gmail.com) -2. **Skontroluj koľko emailov** sa synclo -3. **Over že sú správne** (FROM alebo TO ten kontakt) -4. **Pošli nový email** cez CRM -5. **Over že sa zobrazí** v konverzácii - -## Ak niečo nejde - -Check logy pri sync: -``` -Filtering: last 30 days, from Inbox/Sent only -Found X emails with contact@email.com -``` - -Ak je X príliš veľa → zníž `daysBack` -Ak je X 0 → zväčši `daysBack` alebo over že Inbox má emaily diff --git a/DOKUMENTACIA.md b/DOKUMENTACIA.md new file mode 100644 index 0000000..9c9e30c --- /dev/null +++ b/DOKUMENTACIA.md @@ -0,0 +1,1737 @@ +# 📚 CRM SERVER - KOMPLETNÁ DOKUMENTÁCIA + +> Vytvorené: 2025-11-21 +> Verzia: 1.0 +> 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 + +### Databázové Tabuľky +``` +users → userEmailAccounts ← emailAccounts + ↓ ↓ + | contacts → emails + | + └─→ projectUsers ←─┐ + ↓ | +companies → projects → todos → notes + ↓ + timesheets +``` + +**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) + +--- + +## 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. 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() +``` + +--- + +### 📊 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) + +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 + +**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 +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 + +--- + +## ⚠️ 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' +``` + +--- + +## 🔍 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-21 +**Autor:** CRM Server Team +**Kontakt:** crm-server documentation diff --git a/FIX_SUMMARY.md b/FIX_SUMMARY.md deleted file mode 100644 index 1c3c5a0..0000000 --- a/FIX_SUMMARY.md +++ /dev/null @@ -1,198 +0,0 @@ -# CRM Email System - Bug Fixes Summary - -## Problems Identified - -### 1. **Emails Scattered and Showing Under Wrong Contacts** -- **Root Cause**: Email syncing was changed from bidirectional (both FROM and TO contact) to unidirectional (only FROM contact) -- **Impact**: When you sent emails from foxerdxd@gmail.com, they weren't being synced into your CRM database -- **Symptom**: Emails appeared under the wrong contacts because conversations were incomplete - -### 2. **Sent Emails from CRM Not Appearing** -- **Root Cause**: Sent emails were saved with `contactId: null` and never linked to contacts -- **Impact**: All emails sent from the CRM weren't showing up in the conversation view -- **Symptom**: You could see received emails but not your replies - -### 3. **Inbox-Only Syncing** -- **Root Cause**: Sync was restricted to only Inbox mailbox -- **Impact**: Sent emails in the Sent folder weren't being synced -- **Symptom**: Incomplete conversation history - -## Fixes Applied - -### src/services/jmap.service.js - -#### 1. Fixed `syncEmailsFromSender()` (lines 344-477) -**Before:** -```javascript -filter: { - operator: 'AND', - conditions: [ - { inMailbox: inboxMailbox.id }, // ONLY from Inbox - { from: senderEmail }, // ONLY from this sender - ], -} -``` - -**After:** -```javascript -filter: { - operator: 'OR', - conditions: [ - { from: senderEmail }, // Emails FROM contact - { to: senderEmail }, // Emails TO contact (our sent emails) - ], -} -``` - -**Changes:** -- ✅ Removed inbox-only filter (now syncs from ALL mailboxes) -- ✅ Changed from unidirectional to bidirectional sync -- ✅ Now captures complete conversation history - -#### 2. Fixed `sendEmail()` (lines 557-681) -**Added:** -```javascript -// Find contact by recipient email address to properly link the sent email -const [recipientContact] = await db - .select() - .from(contacts) - .where( - and( - eq(contacts.emailAccountId, emailAccountId), - eq(contacts.email, to) - ) - ) - .limit(1); - -await db.insert(emails).values({ - ... - contactId: recipientContact?.id || null, // Link to contact if recipient is in contacts - ... -}); -``` - -**Changes:** -- ✅ Now looks up the recipient in contacts before saving -- ✅ Links sent emails to the correct contact -- ✅ Sent emails now appear in conversation view - -## Database Cleanup Scripts Created - -### 1. `src/scripts/fix-sent-emails-contactid.js` -**Purpose**: Links existing orphaned sent emails to their contacts - -**What it does:** -- Finds all emails with `contactId: null` -- For sent emails (where `sentByUserId` is not null), matches by the `to` field -- For received emails, matches by the `from` field -- Updates the `contactId` to link them properly - -**Run with:** -```bash -node src/scripts/fix-sent-emails-contactid.js -``` - -### 2. `src/scripts/resync-all-contacts.js` -**Purpose**: Re-syncs all contacts with bidirectional sync to fetch missing emails - -**What it does:** -- Fetches all contacts from the database -- Re-syncs each contact using the new bidirectional sync logic -- Pulls in any missing sent emails that weren't synced before - -**Run with:** -```bash -node src/scripts/resync-all-contacts.js -``` - -## About the Database Schema - -### The `sentByUserId` Column -- **Location**: `emails` table, line 93 in `src/db/schema.js` -- **Purpose**: Tracks which user sent the email from the CRM -- **Status**: ✅ This column EXISTS and IS being used correctly -- **Note**: It's NOT `set_by_user_id` (that was a typo in your question) - -### Current Architecture (Refactor Branch) -- Email accounts can be **shared** between multiple users via `userEmailAccounts` table -- Contacts belong to email accounts (not individual users) -- Multiple users can manage the same email account -- This design supports your requirement: "chcem mat jeden email account linknuty kludne aj na dvoch crm uctoch" - -## How Email Filtering Works Now - -### Backend (src/services/crm-email.service.js:9-38) -```javascript -// INNER JOIN with contacts table -.innerJoin(contacts, eq(emails.contactId, contacts.id)) -``` -- ✅ Only shows emails from added contacts (as requested) -- ✅ No spam or unwanted emails - -### Frontend (src/pages/Emails/EmailsPage.jsx:72-113) -```javascript -// Group by contact email -const contactEmail = email.contact.email; -``` -- ✅ Groups emails by contact -- ✅ Shows complete conversation threads - -## Testing Recommendations - -1. **Test New Email Sync:** - ```bash - # Add a new contact via Inbox - # Check that both received AND sent emails appear - ``` - -2. **Test Sending Emails:** - ```bash - # Send an email to a contact from CRM - # Verify it appears in the conversation immediately - ``` - -3. **Run Cleanup Scripts:** - ```bash - # Fix existing data - node src/scripts/fix-sent-emails-contactid.js - - # Re-sync all contacts to get complete history - node src/scripts/resync-all-contacts.js - ``` - -4. **Verify Shared Email Accounts:** - ```bash - # Add same email account to two different users - # Verify both users see the same contacts and emails - ``` - -## What's Fixed - -✅ Emails now sync bidirectionally (FROM and TO contact) -✅ Sent emails from CRM are properly linked to contacts -✅ Complete conversation history is preserved -✅ All emails from all mailboxes are synced (not just Inbox) -✅ Email grouping works correctly by contact -✅ Only emails from added contacts are shown -✅ Shared email accounts work correctly - -## Migration Path - -1. **Apply the code changes** (already done in `src/services/jmap.service.js`) -2. **Run fix script** to link existing orphaned emails: - ```bash - node src/scripts/fix-sent-emails-contactid.js - ``` -3. **Run resync script** to fetch missing emails: - ```bash - node src/scripts/resync-all-contacts.js - ``` -4. **Test thoroughly** with your email accounts -5. **Delete old migration files** from `MIGRATION_GUIDE.md` if no longer needed - -## Notes - -- No frontend changes were needed (FE code was already correct) -- No database schema changes needed -- The `sentByUserId` column is working as designed -- The refactor branch architecture supports shared email accounts as intended diff --git a/FRESH_START_README.md b/FRESH_START_README.md deleted file mode 100644 index 6f7b164..0000000 --- a/FRESH_START_README.md +++ /dev/null @@ -1,296 +0,0 @@ -# Fresh Start - Nový CRM systém so zdieľanými email účtami - -## 🎯 Čo je nové? - -Kompletne refaktorovaný CRM systém s **many-to-many** vzťahom medzi používateľmi a emailovými účtami. - -### Hlavné vylepšenia: - -✅ **Zdieľané emailové účty** - Viacero používateľov môže mať prístup k jednému email účtu -✅ **Jednoduchý dizajn** - Žiadne zbytočné role (owner/member) -✅ **Izolované kontakty** - Kontakty patria k email účtu, nie k používateľovi -✅ **Tracking odpovedí** - Vidíte kto poslal odpoveď (sentByUserId) - ---- - -## 🚀 Rýchly štart (Fresh Database) - -### 1. Drop a vytvor databázu od nula - -```bash -cd /home/richardtekula/Documents/WORK/crm-server -node src/scripts/fresh-database.js -``` - -**Čo tento script robí:** -- Vymaže všetky existujúce tabuľky -- Vytvorí nové tabuľky s many-to-many vzťahmi -- Vytvorí potrebné indexy - -### 2. Vytvor admin používateľa - -```bash -node src/scripts/seed-admin.js -``` - -**Admin credentials:** -- Username: `admin` -- Password: `admin123` - -⚠️ **DÔLEŽITÉ:** Zmeň heslo po prvom prihlásení! - -### 3. Spusť server - -```bash -npm run dev -``` - -### 4. Prihlás sa a začni používať - -1. Otvor frontend aplikáciu -2. Prihlás sa ako admin (admin / admin123) -3. Vytvor používateľov -4. Pridaj emailové účty - ---- - -## 📊 Databázová schéma - -### users -Používatelia systému (admini a členovia). - -### email_accounts -Emailové účty - môžu byť zdieľané medzi viacerými používateľmi. -- `email` - UNIQUE (jeden email = jeden záznam v systéme) - -### user_email_accounts -Junction table - many-to-many medzi users a email_accounts. -- `user_id` + `email_account_id` - UNIQUE (každý user môže mať account len raz) -- `is_primary` - primárny účet pre daného usera - -### contacts -Kontakty patriace k emailovému účtu (nie k používateľovi!). -- `email_account_id` + `email` - UNIQUE -- `added_by` - kto pridal kontakt (nullable) - -### emails -Emaily patriace k emailovému účtu. -- `email_account_id` - k akému accountu patrí -- `contact_id` - od ktorého kontaktu -- `sent_by_user_id` - kto poslal odpoveď (null ak prijatý email) - ---- - -## 🔄 Ako fungujú zdieľané účty? - -### Scenár 1: Dva admini, jeden email account - -``` -Admin 1: richardtekula@example.com -Admin 2: peterkolar@example.com - -Oba majú prístup k: firma@example.com -``` - -1. Admin 1 pridá `firma@example.com` → Vytvorí sa nový email account -2. Admin 2 pridá `firma@example.com` (s rovnakým heslom) → Automaticky sa pridá k existujúcemu accountu -3. Obaja vidia rovnaké kontakty a emaily pre `firma@example.com` -4. Keď Admin 1 odpovie na email, Admin 2 vidí že odpoveď poslal Admin 1 (`sentByUserId`) - -### Scenár 2: Admin vytvorí používateľa s emailom - -``` -Admin vytvorí usera "jan" s emailom firma@example.com -``` - -- Ak `firma@example.com` už existuje → jan sa automaticky pripojí k existujúcemu accountu (zdieľanie) -- Ak neexistuje → vytvorí sa nový account a jan je prvý používateľ - ---- - -## 🎨 API zmeny - -### Email Accounts - -```javascript -// Get user's email accounts -GET /api/email-accounts -// Returns: [{ id, email, jmapAccountId, isActive, isPrimary, addedAt }] - -// Add email account -POST /api/email-accounts -Body: { email, emailPassword } -// Automaticky vytvorí many-to-many link -// Ak account už existuje, pripojí usera k existujúcemu - -// Remove email account -DELETE /api/email-accounts/:accountId -// Odstráni link pre daného usera -// Ak je to posledný user, vymaže aj samotný email account - -// Set primary account -POST /api/email-accounts/:accountId/set-primary -``` - -### Contacts - -```javascript -// Get contacts for email account -GET /api/contacts?accountId=xxx (REQUIRED) - -// Add contact -POST /api/contacts -Body: { email, name, notes, accountId } - -// Remove contact -DELETE /api/contacts/:contactId?accountId=xxx (REQUIRED) - -// Update contact -PATCH /api/contacts/:contactId?accountId=xxx (REQUIRED) -Body: { name, notes } -``` - -**DÔLEŽITÉ:** `accountId` je teraz **povinný parameter** pre všetky contact operácie! - -### Users (Admin) - -```javascript -// Create user with email -POST /api/admin/users -Body: { - username, - firstName, - lastName, - email, // Optional - emailPassword // Optional -} -// Ak email existuje, user sa automaticky pripojí k existujúcemu accountu -``` - ---- - -## 🐛 Opravené bugy - -✅ **Bug č.1:** Keď admin vytvoril používateľa s emailom, credentials išli do `users` tabuľky namiesto `email_accounts` - - **Opravené:** Používa `emailAccountService.createEmailAccount` s many-to-many - -✅ **Bug č.2:** Duplikátne kontakty keď viacero používateľov pridalo rovnaký email - - **Opravené:** Kontakty patria k `email_account`, nie k jednotlivým používateľom - - UNIQUE constraint: `(email_account_id, email)` - -✅ **Bug č.3:** Chýbajúci "mark as read" button - - **Funguje:** Frontend dostáva správne `accountId` parameter - -✅ **Bug č.4:** Nepoužívané stĺpce v `users` tabuľke - - **Opravené:** `email`, `emailPassword`, `jmapAccountId` odstránené z users tabuľky - ---- - -## 🧪 Testovanie - -### Test 1: Vytvorenie používateľa s emailom - -```bash -# Ako admin: -1. Vytvor usera "jan" s emailom jan@firma.sk -2. Overiť že email account bol vytvorený -3. Prihlás sa ako jan -4. Overiť že vidíš jan@firma.sk v sidebari -``` - -### Test 2: Zdieľanie email accountu - -```bash -# Ako admin: -1. Vytvor usera "peter" s emailom jan@firma.sk (rovnaký ako vyššie!) -2. Overiť že peter sa pripojil k existujúcemu accountu -3. Prihlás sa ako peter -4. Overiť že peter vidí rovnaké kontakty ako jan -5. Peter pridá nový kontakt -6. Prihlás sa ako jan → overiť že jan vidí nový kontakt -``` - -### Test 3: Odpoveď na email - -```bash -1. Jan odpovie na email od kontaktu -2. Prihlás sa ako peter -3. Overiť že peter vidí odpoveď -4. Overiť že vidíš indikáciu "Sent by Jan" (sentByUserId) -``` - ---- - -## 📝 Poznámky pre vývojárov - -### Many-to-many vzťah - -``` -users ←→ user_email_accounts ←→ email_accounts -``` - -- Jeden user môže mať viacero email accounts -- Jeden email account môže patriť viacerým userom - -### Kontakty a emaily patria k email accountu - -``` -email_accounts → contacts → emails -``` - -- Kontakty sú **zdieľané** medzi všetkými usermi s prístupom k danému email accountu -- Emaily sú **zdieľané** rovnako - -### Tracking odpovedí - -- `emails.sent_by_user_id` - kto poslal odpoveď -- NULL = prijatý email (nie odpoveď) -- UUID = user, ktorý poslal odpoveď - ---- - -## 🔒 Bezpečnosť - -- Email heslá sú **encrypted** (nie hashed), lebo potrebujeme decryption pre JMAP -- Používateľské heslá sú **bcrypt hashed** -- Access control: User môže pristupovať len k email accountom, kde má link v `user_email_accounts` - ---- - -## 🚨 Troubleshooting - -### Problem: "Email účet nenájdený" - -**Možné príčiny:** -1. User nemá link v `user_email_accounts` tabuľke -2. Email account neexistuje v `email_accounts` tabuľke - -**Riešenie:** -```sql --- Check links -SELECT * FROM user_email_accounts WHERE user_id = 'your-user-id'; - --- Check accounts -SELECT * FROM email_accounts; -``` - -### Problem: "Kontakt už existuje" - -**Príčina:** Kontakt s týmto emailom už existuje pre daný email account. - -**Riešenie:** Kontakty sú zdieľané medzi všetkými usermi. Jeden email môže byť len raz per email account. - ---- - -## 📞 Podpora - -Ak narazíte na problémy: -1. Skontrolujte logs: `tail -f logs/server.log` -2. Skontrolujte databázu: `psql -U username -d database` -3. Re-run fresh database script - ---- - -Vytvoril: Claude Code -Dátum: 2025-11-20 -Verzia: 2.0 (Many-to-many) diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md deleted file mode 100644 index fa6718d..0000000 --- a/MIGRATION_GUIDE.md +++ /dev/null @@ -1,445 +0,0 @@ -# Migračný návod - Refaktorizácia emailových účtov - -## Prehľad zmien - -Tento refaktoring rieši nasledujúce problémy: - -### 🐛 Opravené bugy: - -1. **Bug s vytváraním používateľov** - Email credentials sa teraz ukladajú do `emailAccounts` tabuľky namiesto `users` tabuľky -2. **Bug s duplikátnymi kontaktami** - Pridaný composite unique constraint zabezpečuje správnu izoláciu kontaktov -3. **Bug s linkovaním emailov** - `linkEmail` funkcia teraz používa `emailAccounts` tabuľku -4. **Nepoužívané stĺpce** - Odstránené `email`, `email_password`, `jmap_account_id` z `users` tabuľky - -### ✨ Vylepšenia: - -- Čistejšia separácia concerns (users vs email accounts) -- Podpora pre viacero emailových účtov na používateľa -- Pripravené na zdieľané emailové účty (many-to-many) - ---- - -## 📋 Migračný proces - -### Krok 1: Backup databázy - -```bash -# PostgreSQL backup -pg_dump -U your_username -d your_database > backup_$(date +%Y%m%d).sql -``` - -### Krok 2: Migrovať existujúce email credentials - -Tento script presunie email credentials z `users` tabuľky do `emailAccounts` tabuľky: - -```bash -cd /home/richardtekula/Documents/WORK/crm-server -node src/scripts/migrate-users-to-email-accounts.js -``` - -**Čo tento script robí:** -- Nájde všetkých používateľov s `email`, `emailPassword`, `jmapAccountId` -- Vytvorí zodpovedajúce záznamy v `emailAccounts` tabuľke -- Nastaví prvý účet ako primárny (`isPrimary = true`) -- Zachová originálne dáta v `users` tabuľke (pre bezpečnosť) - -**Výstup:** -``` -🔄 Starting migration from users table to emailAccounts table... -Found 5 users with email credentials - -📧 Processing user: admin (admin@example.com) - ✅ Created email account: admin@example.com - - Account ID: abc-123-def - - Is Primary: true - -✅ Migration complete! - - Migrated: 5 accounts - - Skipped: 0 accounts (already exist) -``` - -### Krok 3: Opraviť duplikátne kontakty - -Ak máte duplikátne kontakty (ten istý email viackrát pre jedného usera), spustite: - -```bash -node src/scripts/fix-duplicate-contacts.js -``` - -**Čo tento script robí:** -- Nájde duplikátne kontakty (rovnaký `userId` + `email`) -- Zachová najnovší kontakt -- Premapuje všetky emaily na najnovší kontakt -- Vymaže staré duplikáty - -### Krok 4: Pridať unique constraint na contacts - -```bash -node src/scripts/add-contacts-unique-constraint.js -``` - -**Čo tento script robí:** -- Pridá composite unique constraint: `(user_id, email_account_id, email)` -- Zabráni vzniku nových duplikátov - -**Poznámka:** Ak tento krok zlyhá kvôli existujúcim duplikátom, najprv spustite Krok 3. - -### Krok 5: Aktualizovať kód aplikácie - -Kód je už aktualizovaný v týchto súboroch: - -**Backend:** -- ✅ `src/db/schema.js` - odstránené staré stĺpce z users tabuľky -- ✅ `src/controllers/admin.controller.js` - `createUser` používa emailAccounts -- ✅ `src/services/auth.service.js` - `linkEmail` používa emailAccounts -- ✅ `src/services/auth.service.js` - `getUserById` vracia emailAccounts -- ✅ `src/controllers/admin.controller.js` - `getAllUsers` nevracia staré stĺpce - -**Žiadne zmeny nie sú potrebné na frontende** - API rozhranie zostáva kompatibilné. - -### Krok 6: Reštartovať aplikáciu - -```bash -# Zastaviť aplikáciu -pm2 stop crm-server - -# Reštartovať aplikáciu -pm2 start crm-server - -# Alebo pomocou npm -npm run dev -``` - -### Krok 7: Overiť funkčnosť - -1. **Prihlásenie:** - - Skúste sa prihlásiť s existujúcim používateľom - - Overte že vidíte svoje email účty v sidebari - -2. **Vytvorenie nového používateľa:** - ```bash - # Admin vytvorí nového používateľa s emailom - # V Profile -> Create User -> vyplniť email + heslo - ``` - - Overte že email účet sa objavil v `emailAccounts` tabuľke - - Overte že používateľ sa môže prihlásiť a vidí svoj email účet - -3. **Pridanie kontaktu:** - - Prejdite do Inbox - - Discover kontakty - - Pridajte nový kontakt - - Overte že kontakt sa zobrazuje len pre daný email účet - -4. **Označenie emailov ako prečítané:** - - Otvorte Email Conversations - - Kliknite na "Mark all as read" button na kontakte - - Overte že všetky emaily od daného kontaktu sú označené ako prečítané - -### Krok 8: Vyčistiť staré dáta (VOLITEĽNÉ) - -**⚠️ POZOR:** Tento krok je nevratný! Spustite ho len po dôkladnom overení funkčnosti. - -```bash -# Odstráni staré stĺpce z users tabuľky -node src/scripts/remove-old-user-email-columns.js -``` - -Tento script odstráni: -- `users.email` -- `users.email_password` -- `users.jmap_account_id` - -**Po spustení tohto scriptu nie je možné sa vrátiť k starej verzii kódu!** - ---- - -## 🔮 Budúce vylepšenia: Zdieľané emailové účty - -### Súčasný stav: -- Jeden emailový účet patrí jednému používateľovi -- `emailAccounts.userId` je foreign key - -### Plánovaný stav (many-to-many): -- Jeden emailový účet môže byť zdieľaný medzi viacerými používateľmi -- Dvaja admini môžu mať linknutý ten istý email account -- Keď jeden odpíše, druhý to vidí - -### Návrh implementácie: - -#### 1. Nová junction table: - -```sql -CREATE TABLE user_email_accounts ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - email_account_id UUID NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE, - role TEXT NOT NULL DEFAULT 'member', -- 'owner' | 'member' - can_send BOOLEAN NOT NULL DEFAULT true, - can_read BOOLEAN NOT NULL DEFAULT true, - added_at TIMESTAMP NOT NULL DEFAULT NOW(), - added_by UUID REFERENCES users(id), - UNIQUE(user_id, email_account_id) -); -``` - -#### 2. Zmeny v existujúcich tabuľkách: - -**emailAccounts:** -- Odstráni `userId` foreign key -- Pridá `ownerId` (prvý používateľ, ktorý vytvoril účet) -- Pridá `shared` boolean flag - -**contacts:** -- Zostáva `emailAccountId` (kontakty patria k email účtu, nie k používateľovi) -- Odstráni `userId` (alebo ho ponechá ako "created_by") - -**emails:** -- Zostáva `emailAccountId` -- Pridá `sent_by_user_id` (kto poslal odpoveď) - -#### 3. Migračný script: - -```javascript -// Presunie existujúce väzby do user_email_accounts -async function migrateToSharedAccounts() { - const accounts = await db.select().from(emailAccounts); - - for (const account of accounts) { - await db.insert(userEmailAccounts).values({ - userId: account.userId, - emailAccountId: account.id, - role: 'owner', - canSend: true, - canRead: true, - }); - } - - // Alter table to remove userId, add ownerId - await db.execute(sql` - ALTER TABLE email_accounts - DROP COLUMN user_id, - ADD COLUMN owner_id UUID REFERENCES users(id), - ADD COLUMN shared BOOLEAN DEFAULT false - `); -} -``` - -#### 4. API zmeny: - -**Nové endpointy:** -```javascript -// Zdieľať email account s iným používateľom -POST /api/email-accounts/:accountId/share -Body: { userId, role: 'member', canSend: true, canRead: true } - -// Odobrať prístup -DELETE /api/email-accounts/:accountId/share/:userId - -// Získať používateľov s prístupom k účtu -GET /api/email-accounts/:accountId/users -``` - -#### 5. Frontend zmeny: - -- Zobrazenie ikony "zdieľané" pri zdieľaných účtoch -- UI pre zdieľanie účtov (modal s výberom používateľov) -- Zobrazenie "sent by User X" pri odpovediach - ---- - -## 🐛 Riešenie problémov - -### Problém: Script fail kvôli duplikátom - -**Chyba:** -``` -could not create unique index "user_account_email_unique" -Key (user_id, email_account_id, email)=(...) already exists. -``` - -**Riešenie:** -```bash -# Najprv opravte duplikáty -node src/scripts/fix-duplicate-contacts.js - -# Potom pridajte constraint -node src/scripts/add-contacts-unique-constraint.js -``` - -### Problém: Používateľ nevidí email účty po migrácii - -**Kontrola:** -```sql -SELECT * FROM email_accounts WHERE user_id = 'user-id-here'; -``` - -**Riešenie:** -- Skontrolujte či migračný script úspešne bežal -- Skontrolujte logy servera -- Reštartujte server - -### Problém: "Mark as read" button nefunguje - -**Možné príčiny:** -1. Backend endpoint `/emails/contact/:contactId/read` neexistuje -2. `contactId` je null alebo nesprávne -3. Email account nie je správne linknutý - -**Kontrola:** -```bash -# Skontrolujte logs -tail -f logs/server.log - -# Test endpoint -curl -X POST http://localhost:3000/api/emails/contact//read \ - -H "Authorization: Bearer " -``` - ---- - -## 📊 Databázová schéma (po migrácii) - -### users -``` -id | UUID | PRIMARY KEY -username | TEXT | UNIQUE NOT NULL -first_name | TEXT | -last_name | TEXT | -password | TEXT | bcrypt hash -temp_password | TEXT | bcrypt hash -changed_password| BOOLEAN | DEFAULT false -role | ENUM | 'admin' | 'member' -last_login | TIMESTAMP | -created_at | TIMESTAMP | -updated_at | TIMESTAMP | -``` - -### email_accounts -``` -id | UUID | PRIMARY KEY -user_id | UUID | REFERENCES users(id) CASCADE -email | TEXT | NOT NULL -email_password | TEXT | NOT NULL (encrypted) -jmap_account_id | TEXT | NOT NULL -is_primary | BOOLEAN | DEFAULT false -is_active | BOOLEAN | DEFAULT true -created_at | TIMESTAMP | -updated_at | TIMESTAMP | -``` - -### contacts -``` -id | UUID | PRIMARY KEY -user_id | UUID | REFERENCES users(id) CASCADE -email_account_id| UUID | REFERENCES email_accounts(id) CASCADE -email | TEXT | NOT NULL -name | TEXT | -notes | TEXT | -added_at | TIMESTAMP | -created_at | TIMESTAMP | -updated_at | TIMESTAMP | - -UNIQUE (user_id, email_account_id, email) -``` - -### emails -``` -id | UUID | PRIMARY KEY -user_id | UUID | REFERENCES users(id) CASCADE -email_account_id| UUID | REFERENCES email_accounts(id) CASCADE -contact_id | UUID | REFERENCES contacts(id) CASCADE -jmap_id | TEXT | UNIQUE -message_id | TEXT | UNIQUE -thread_id | TEXT | -in_reply_to | TEXT | -from | TEXT | -to | TEXT | -subject | TEXT | -body | TEXT | -is_read | BOOLEAN | DEFAULT false -date | TIMESTAMP | -created_at | TIMESTAMP | -updated_at | TIMESTAMP | -``` - ---- - -## ✅ Checklist - -- [ ] Backup databázy vytvorený -- [ ] Migračný script 1: `migrate-users-to-email-accounts.js` spustený -- [ ] Migračný script 2: `fix-duplicate-contacts.js` spustený -- [ ] Migračný script 3: `add-contacts-unique-constraint.js` spustený -- [ ] Backend kód aktualizovaný (už hotové) -- [ ] Aplikácia reštartovaná -- [ ] Funkčnosť otestovaná: - - [ ] Prihlásenie existujúceho používateľa - - [ ] Vytvorenie nového používateľa s emailom - - [ ] Pridanie nového email účtu - - [ ] Pridanie kontaktu - - [ ] Označenie emailov ako prečítané - - [ ] Odpoveď na email -- [ ] (Voliteľné) Vyčistenie starých stĺpcov: `remove-old-user-email-columns.js` spustené - ---- - -## 📞 Podpora - -Ak narazíte na problémy: - -1. Skontrolujte logs: `tail -f logs/server.log` -2. Skontrolujte databázu: `psql -U username -d database` -3. Obnovte backup ak je potrebné - ---- - -## 📝 Poznámky pre vývojárov - -### Dôležité zmeny v API: - -**❌ Staré (nepoužívať):** -```javascript -// users tabuľka obsahovala email credentials -const user = await db.select().from(users).where(eq(users.email, email)); -``` - -**✅ Nové (použiť):** -```javascript -// email credentials sú v emailAccounts tabuľke -const accounts = await db.select() - .from(emailAccounts) - .where(eq(emailAccounts.userId, userId)); -``` - -### Pri vytváraní nového používateľa: - -**❌ Staré:** -```javascript -await db.insert(users).values({ - username, - email, - emailPassword, - jmapAccountId, -}); -``` - -**✅ Nové:** -```javascript -// 1. Vytvor usera -const [user] = await db.insert(users).values({ username }).returning(); - -// 2. Vytvor email account -await db.insert(emailAccounts).values({ - userId: user.id, - email, - emailPassword, - jmapAccountId, - isPrimary: true, -}); -``` - ---- - -Vytvoril: Claude Code -Dátum: 2025-11-20 -Verzia: 1.0 diff --git a/run-migration.js b/run-migration.js new file mode 100644 index 0000000..15eb5ef --- /dev/null +++ b/run-migration.js @@ -0,0 +1,32 @@ +import pkg from 'pg'; +const { Pool } = pkg; +import dotenv from 'dotenv'; +import { readFileSync } from 'fs'; + +dotenv.config(); + +const pool = new Pool({ + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT || '5432'), + user: process.env.DB_USER || 'admin', + password: process.env.DB_PASSWORD || 'heslo123', + database: process.env.DB_NAME || 'crm', +}); + +async function runMigration() { + console.log('⏳ Running project_users migration...'); + + try { + const sql = readFileSync('./src/db/migrations/add_project_users.sql', 'utf8'); + await pool.query(sql); + console.log('✅ Migration completed successfully'); + process.exit(0); + } catch (error) { + console.error('❌ Migration failed:', error.message); + process.exit(1); + } finally { + await pool.end(); + } +} + +runMigration(); diff --git a/src/app.js b/src/app.js index 094fcea..3005000 100644 --- a/src/app.js +++ b/src/app.js @@ -18,6 +18,10 @@ import contactRoutes from './routes/contact.routes.js'; import crmEmailRoutes from './routes/crm-email.routes.js'; import emailAccountRoutes from './routes/email-account.routes.js'; import timesheetRoutes from './routes/timesheet.routes.js'; +import companyRoutes from './routes/company.routes.js'; +import projectRoutes from './routes/project.routes.js'; +import todoRoutes from './routes/todo.routes.js'; +import noteRoutes from './routes/note.routes.js'; const app = express(); @@ -74,6 +78,10 @@ app.use('/api/contacts', contactRoutes); app.use('/api/emails', crmEmailRoutes); app.use('/api/email-accounts', emailAccountRoutes); app.use('/api/timesheets', timesheetRoutes); +app.use('/api/companies', companyRoutes); +app.use('/api/projects', projectRoutes); +app.use('/api/todos', todoRoutes); +app.use('/api/notes', noteRoutes); // Basic route app.get('/', (req, res) => { diff --git a/src/controllers/company.controller.js b/src/controllers/company.controller.js new file mode 100644 index 0000000..b10b138 --- /dev/null +++ b/src/controllers/company.controller.js @@ -0,0 +1,223 @@ +import * as companyService from '../services/company.service.js'; +import * as noteService from '../services/note.service.js'; +import { formatErrorResponse } from '../utils/errors.js'; + +/** + * Get all companies + * GET /api/companies?search=query + */ +export const getAllCompanies = async (req, res) => { + try { + const { search } = req.query; + + const companies = await companyService.getAllCompanies(search); + + res.status(200).json({ + success: true, + count: companies.length, + data: companies, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Get company by ID + * GET /api/companies/:companyId + */ +export const getCompanyById = async (req, res) => { + try { + const { companyId } = req.params; + + const company = await companyService.getCompanyById(companyId); + + res.status(200).json({ + success: true, + data: company, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Get company with relations (projects, todos, notes) + * GET /api/companies/:companyId/details + */ +export const getCompanyWithRelations = async (req, res) => { + try { + const { companyId } = req.params; + + const company = await companyService.getCompanyWithRelations(companyId); + + res.status(200).json({ + success: true, + data: company, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Create new company + * POST /api/companies + * Body: { name, description, address, city, country, phone, email, website } + */ +export const createCompany = async (req, res) => { + try { + const userId = req.userId; + const data = req.body; + + const company = await companyService.createCompany(userId, data); + + res.status(201).json({ + success: true, + data: company, + message: 'Firma bola vytvorená', + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Update company + * PATCH /api/companies/:companyId + * Body: { name, description, address, city, country, phone, email, website } + */ +export const updateCompany = async (req, res) => { + try { + const { companyId } = req.params; + const data = req.body; + + const company = await companyService.updateCompany(companyId, data); + + res.status(200).json({ + success: true, + data: company, + message: 'Firma bola aktualizovaná', + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Delete company + * DELETE /api/companies/:companyId + */ +export const deleteCompany = async (req, res) => { + try { + const { companyId } = req.params; + + const result = await companyService.deleteCompany(companyId); + + res.status(200).json({ + success: true, + message: result.message, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Get company notes + * GET /api/companies/:companyId/notes + */ +export const getCompanyNotes = async (req, res) => { + try { + const { companyId } = req.params; + + const notes = await noteService.getNotesByCompanyId(companyId); + + res.status(200).json({ + success: true, + count: notes.length, + data: notes, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Add company note + * POST /api/companies/:companyId/notes + */ +export const addCompanyNote = async (req, res) => { + try { + const userId = req.userId; + const { companyId } = req.params; + const { content, reminderAt } = req.body; + + const note = await noteService.createNote(userId, { + content, + companyId, + reminderDate: reminderAt, // Map reminderAt to reminderDate + }); + + res.status(201).json({ + success: true, + data: note, + message: 'Poznámka bola pridaná', + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Update company note + * PATCH /api/companies/:companyId/notes/:noteId + */ +export const updateCompanyNote = async (req, res) => { + try { + const { noteId } = req.params; + const { content, reminderAt } = req.body; + + const note = await noteService.updateNote(noteId, { + content, + reminderDate: reminderAt, // Map reminderAt to reminderDate + }); + + res.status(200).json({ + success: true, + data: note, + message: 'Poznámka bola aktualizovaná', + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Delete company note + * DELETE /api/companies/:companyId/notes/:noteId + */ +export const deleteCompanyNote = async (req, res) => { + try { + const { noteId } = req.params; + + const result = await noteService.deleteNote(noteId); + + res.status(200).json({ + success: true, + message: result.message, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; diff --git a/src/controllers/contact.controller.js b/src/controllers/contact.controller.js index 76d3e90..5c34e01 100644 --- a/src/controllers/contact.controller.js +++ b/src/controllers/contact.controller.js @@ -238,3 +238,125 @@ export const updateContact = async (req, res) => { res.status(error.statusCode || 500).json(errorResponse); } }; + +/** + * Link company to contact + * POST /api/contacts/:contactId/link-company?accountId=xxx + * Body: { companyId } + */ +export const linkCompanyToContact = async (req, res) => { + try { + const userId = req.userId; + const { contactId } = req.params; + const { accountId } = req.query; + const { companyId } = req.body; + + if (!accountId) { + return res.status(400).json({ + success: false, + error: { + message: 'accountId je povinný parameter', + statusCode: 400, + }, + }); + } + + if (!companyId) { + return res.status(400).json({ + success: false, + error: { + message: 'companyId je povinný', + statusCode: 400, + }, + }); + } + + // Verify user has access to this email account + await emailAccountService.getEmailAccountById(accountId, userId); + + const updated = await contactService.linkCompanyToContact(contactId, accountId, companyId); + + res.status(200).json({ + success: true, + data: updated, + message: 'Firma bola linknutá ku kontaktu', + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Unlink company from contact + * POST /api/contacts/:contactId/unlink-company?accountId=xxx + */ +export const unlinkCompanyFromContact = async (req, res) => { + try { + const userId = req.userId; + const { contactId } = req.params; + const { accountId } = req.query; + + if (!accountId) { + return res.status(400).json({ + success: false, + error: { + message: 'accountId je povinný parameter', + statusCode: 400, + }, + }); + } + + // Verify user has access to this email account + await emailAccountService.getEmailAccountById(accountId, userId); + + const updated = await contactService.unlinkCompanyFromContact(contactId, accountId); + + res.status(200).json({ + success: true, + data: updated, + message: 'Firma bola odlinknutá od kontaktu', + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Create company from contact + * POST /api/contacts/:contactId/create-company?accountId=xxx + * Body: { name, email, phone, address, city, country, website, description } (all optional, uses contact data as defaults) + */ +export const createCompanyFromContact = async (req, res) => { + try { + const userId = req.userId; + const { contactId } = req.params; + const { accountId } = req.query; + const companyData = req.body; + + if (!accountId) { + return res.status(400).json({ + success: false, + error: { + message: 'accountId je povinný parameter', + statusCode: 400, + }, + }); + } + + // Verify user has access to this email account + await emailAccountService.getEmailAccountById(accountId, userId); + + const result = await contactService.createCompanyFromContact(contactId, accountId, userId, companyData); + + res.status(201).json({ + success: true, + data: result, + message: 'Firma bola vytvorená z kontaktu', + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; diff --git a/src/controllers/note.controller.js b/src/controllers/note.controller.js new file mode 100644 index 0000000..ee6f4c9 --- /dev/null +++ b/src/controllers/note.controller.js @@ -0,0 +1,159 @@ +import * as noteService from '../services/note.service.js'; +import { formatErrorResponse } from '../utils/errors.js'; + +/** + * Get all notes + * GET /api/notes?search=query&companyId=xxx&projectId=xxx&todoId=xxx&contactId=xxx + */ +export const getAllNotes = async (req, res) => { + try { + const { search, companyId, projectId, todoId, contactId } = req.query; + + const filters = { + searchTerm: search, + companyId, + projectId, + todoId, + contactId, + }; + + const notes = await noteService.getAllNotes(filters); + + res.status(200).json({ + success: true, + count: notes.length, + data: notes, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Get note by ID + * GET /api/notes/:noteId + */ +export const getNoteById = async (req, res) => { + try { + const { noteId } = req.params; + + const note = await noteService.getNoteById(noteId); + + res.status(200).json({ + success: true, + data: note, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Create new note + * POST /api/notes + * Body: { title, content, companyId, projectId, todoId, contactId } + */ +export const createNote = async (req, res) => { + try { + const userId = req.userId; + const data = req.body; + + const note = await noteService.createNote(userId, data); + + res.status(201).json({ + success: true, + data: note, + message: 'Poznámka bola vytvorená', + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Update note + * PATCH /api/notes/:noteId + * Body: { title, content, companyId, projectId, todoId, contactId } + */ +export const updateNote = async (req, res) => { + try { + const { noteId } = req.params; + const data = req.body; + + const note = await noteService.updateNote(noteId, data); + + res.status(200).json({ + success: true, + data: note, + message: 'Poznámka bola aktualizovaná', + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Delete note + * DELETE /api/notes/:noteId + */ +export const deleteNote = async (req, res) => { + try { + const { noteId } = req.params; + + const result = await noteService.deleteNote(noteId); + + res.status(200).json({ + success: true, + message: result.message, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Get upcoming reminders for current user + * GET /api/notes/my-reminders + */ +export const getMyReminders = async (req, res) => { + try { + const userId = req.userId; + + const reminders = await noteService.getUpcomingRemindersForUser(userId); + + res.status(200).json({ + success: true, + count: reminders.length, + data: reminders, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Mark reminder as sent + * POST /api/notes/:noteId/mark-reminder-sent + */ +export const markReminderSent = async (req, res) => { + try { + const { noteId } = req.params; + + const updated = await noteService.markReminderAsSent(noteId); + + res.status(200).json({ + success: true, + data: updated, + message: 'Reminder označený ako odoslaný', + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; diff --git a/src/controllers/project.controller.js b/src/controllers/project.controller.js new file mode 100644 index 0000000..3840937 --- /dev/null +++ b/src/controllers/project.controller.js @@ -0,0 +1,316 @@ +import * as projectService from '../services/project.service.js'; +import * as noteService from '../services/note.service.js'; +import { formatErrorResponse } from '../utils/errors.js'; + +/** + * Get all projects + * GET /api/projects?search=query&companyId=xxx + */ +export const getAllProjects = async (req, res) => { + try { + const { search, companyId } = req.query; + + const projects = await projectService.getAllProjects(search, companyId); + + res.status(200).json({ + success: true, + count: projects.length, + data: projects, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Get project by ID + * GET /api/projects/:projectId + */ +export const getProjectById = async (req, res) => { + try { + const { projectId } = req.params; + + const project = await projectService.getProjectById(projectId); + + res.status(200).json({ + success: true, + data: project, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Get project with relations (company, todos, notes, timesheets) + * GET /api/projects/:projectId/details + */ +export const getProjectWithRelations = async (req, res) => { + try { + const { projectId } = req.params; + + const project = await projectService.getProjectWithRelations(projectId); + + res.status(200).json({ + success: true, + data: project, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Create new project + * POST /api/projects + * Body: { name, description, companyId, status, startDate, endDate } + */ +export const createProject = async (req, res) => { + try { + const userId = req.userId; + const data = req.body; + + const project = await projectService.createProject(userId, data); + + res.status(201).json({ + success: true, + data: project, + message: 'Projekt bol vytvorený', + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Update project + * PATCH /api/projects/:projectId + * Body: { name, description, companyId, status, startDate, endDate } + */ +export const updateProject = async (req, res) => { + try { + const { projectId } = req.params; + const data = req.body; + + const project = await projectService.updateProject(projectId, data); + + res.status(200).json({ + success: true, + data: project, + message: 'Projekt bol aktualizovaný', + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Delete project + * DELETE /api/projects/:projectId + */ +export const deleteProject = async (req, res) => { + try { + const { projectId } = req.params; + + const result = await projectService.deleteProject(projectId); + + res.status(200).json({ + success: true, + message: result.message, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Get project notes + * GET /api/projects/:projectId/notes + */ +export const getProjectNotes = async (req, res) => { + try { + const { projectId } = req.params; + + const notes = await noteService.getNotesByProjectId(projectId); + + res.status(200).json({ + success: true, + count: notes.length, + data: notes, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Add project note + * POST /api/projects/:projectId/notes + */ +export const addProjectNote = async (req, res) => { + try { + const userId = req.userId; + const { projectId } = req.params; + const { content, reminderAt } = req.body; + + const note = await noteService.createNote(userId, { + content, + projectId, + reminderDate: reminderAt, // Map reminderAt to reminderDate + }); + + res.status(201).json({ + success: true, + data: note, + message: 'Poznámka bola pridaná', + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Update project note + * PATCH /api/projects/:projectId/notes/:noteId + */ +export const updateProjectNote = async (req, res) => { + try { + const { noteId } = req.params; + const { content, reminderAt } = req.body; + + const note = await noteService.updateNote(noteId, { + content, + reminderDate: reminderAt, // Map reminderAt to reminderDate + }); + + res.status(200).json({ + success: true, + data: note, + message: 'Poznámka bola aktualizovaná', + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Delete project note + * DELETE /api/projects/:projectId/notes/:noteId + */ +export const deleteProjectNote = async (req, res) => { + try { + const { noteId } = req.params; + + const result = await noteService.deleteNote(noteId); + + res.status(200).json({ + success: true, + message: result.message, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Get project users (team members) + * GET /api/projects/:projectId/users + */ +export const getProjectUsers = async (req, res) => { + try { + const { projectId } = req.params; + + const users = await projectService.getProjectUsers(projectId); + + res.status(200).json({ + success: true, + count: users.length, + data: users, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Assign user to project + * POST /api/projects/:projectId/users + * Body: { userId, role } + */ +export const assignUserToProject = async (req, res) => { + try { + const currentUserId = req.userId; + const { projectId } = req.params; + const { userId, role } = req.body; + + const assignment = await projectService.assignUserToProject( + projectId, + userId, + currentUserId, + role + ); + + res.status(201).json({ + success: true, + data: assignment, + message: 'Používateľ bol priradený k projektu', + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Remove user from project + * DELETE /api/projects/:projectId/users/:userId + */ +export const removeUserFromProject = async (req, res) => { + try { + const { projectId, userId } = req.params; + + const result = await projectService.removeUserFromProject(projectId, userId); + + res.status(200).json({ + success: true, + message: result.message, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Update user role on project + * PATCH /api/projects/:projectId/users/:userId + * Body: { role } + */ +export const updateUserRoleOnProject = async (req, res) => { + try { + const { projectId, userId } = req.params; + const { role } = req.body; + + const updated = await projectService.updateUserRoleOnProject(projectId, userId, role); + + res.status(200).json({ + success: true, + data: updated, + message: 'Rola používateľa bola aktualizovaná', + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; diff --git a/src/controllers/todo.controller.js b/src/controllers/todo.controller.js new file mode 100644 index 0000000..8724fd4 --- /dev/null +++ b/src/controllers/todo.controller.js @@ -0,0 +1,191 @@ +import * as todoService from '../services/todo.service.js'; +import { formatErrorResponse } from '../utils/errors.js'; + +/** + * Get all todos + * GET /api/todos?search=query&projectId=xxx&companyId=xxx&assignedTo=xxx&status=xxx + */ +export const getAllTodos = async (req, res) => { + try { + const { search, projectId, companyId, assignedTo, status } = req.query; + + const filters = { + searchTerm: search, + projectId, + companyId, + assignedTo, + status, + }; + + const todos = await todoService.getAllTodos(filters); + + res.status(200).json({ + success: true, + count: todos.length, + data: todos, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Get my todos (assigned to current user) + * GET /api/todos/my?status=xxx + */ +export const getMyTodos = async (req, res) => { + try { + const userId = req.userId; + const { status } = req.query; + + const filters = { + assignedTo: userId, + status, + }; + + const todos = await todoService.getAllTodos(filters); + + res.status(200).json({ + success: true, + count: todos.length, + data: todos, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Get todo by ID + * GET /api/todos/:todoId + */ +export const getTodoById = async (req, res) => { + try { + const { todoId } = req.params; + + const todo = await todoService.getTodoById(todoId); + + res.status(200).json({ + success: true, + data: todo, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Get todo with relations (project, company, assigned user, notes) + * GET /api/todos/:todoId/details + */ +export const getTodoWithRelations = async (req, res) => { + try { + const { todoId } = req.params; + + const todo = await todoService.getTodoWithRelations(todoId); + + res.status(200).json({ + success: true, + data: todo, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Create new todo + * POST /api/todos + * Body: { title, description, projectId, companyId, assignedTo, status, priority, dueDate } + */ +export const createTodo = async (req, res) => { + try { + const userId = req.userId; + const data = req.body; + + const todo = await todoService.createTodo(userId, data); + + res.status(201).json({ + success: true, + data: todo, + message: 'Todo bolo vytvorené', + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Update todo + * PATCH /api/todos/:todoId + * Body: { title, description, projectId, companyId, assignedTo, status, priority, dueDate } + */ +export const updateTodo = async (req, res) => { + try { + const { todoId } = req.params; + const data = req.body; + + const todo = await todoService.updateTodo(todoId, data); + + res.status(200).json({ + success: true, + data: todo, + message: 'Todo bolo aktualizované', + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Delete todo + * DELETE /api/todos/:todoId + */ +export const deleteTodo = async (req, res) => { + try { + const { todoId } = req.params; + + const result = await todoService.deleteTodo(todoId); + + res.status(200).json({ + success: true, + message: result.message, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Toggle todo completion status + * PATCH /api/todos/:todoId/toggle + */ +export const toggleTodo = async (req, res) => { + try { + const { todoId } = req.params; + + // Get current todo + const todo = await todoService.getTodoById(todoId); + + // Toggle completed status + const updated = await todoService.updateTodo(todoId, { + status: todo.status === 'completed' ? 'pending' : 'completed', + }); + + res.status(200).json({ + success: true, + data: updated, + message: 'Todo status aktualizovaný', + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; diff --git a/src/db/create_project_users_table.js b/src/db/create_project_users_table.js new file mode 100644 index 0000000..541af9b --- /dev/null +++ b/src/db/create_project_users_table.js @@ -0,0 +1,54 @@ +import { db } from '../config/database.js'; +import { sql } from 'drizzle-orm'; + +async function createProjectUsersTable() { + console.log('⏳ Creating project_users table...'); + + try { + // Check if table exists + const result = await db.execute(sql` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'project_users' + ); + `); + + const tableExists = result.rows[0]?.exists; + + if (tableExists) { + console.log('✅ project_users table already exists'); + process.exit(0); + } + + // Create the table + await db.execute(sql` + CREATE TABLE IF NOT EXISTS project_users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT, + added_by UUID REFERENCES users(id) ON DELETE SET NULL, + added_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT project_user_unique UNIQUE(project_id, user_id) + ); + `); + + // Create indexes + await db.execute(sql` + CREATE INDEX IF NOT EXISTS idx_project_users_project_id ON project_users(project_id); + `); + + await db.execute(sql` + CREATE INDEX IF NOT EXISTS idx_project_users_user_id ON project_users(user_id); + `); + + console.log('✅ project_users table created successfully'); + process.exit(0); + } catch (error) { + console.error('❌ Failed to create table:', error); + process.exit(1); + } +} + +createProjectUsersTable(); diff --git a/src/db/migrations/add_company_link_and_reminders.sql b/src/db/migrations/add_company_link_and_reminders.sql new file mode 100644 index 0000000..c07cd02 --- /dev/null +++ b/src/db/migrations/add_company_link_and_reminders.sql @@ -0,0 +1,31 @@ +-- Add company_id to contacts table +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name='contacts' AND column_name='company_id' + ) THEN + ALTER TABLE contacts ADD COLUMN company_id UUID REFERENCES companies(id) ON DELETE SET NULL; + CREATE INDEX idx_contacts_company_id ON contacts(company_id); + END IF; +END $$; + +-- Add reminder fields to notes table +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name='notes' AND column_name='reminder_date' + ) THEN + ALTER TABLE notes ADD COLUMN reminder_date TIMESTAMP; + CREATE INDEX idx_notes_reminder_date ON notes(reminder_date) WHERE reminder_date IS NOT NULL; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name='notes' AND column_name='reminder_sent' + ) THEN + ALTER TABLE notes ADD COLUMN reminder_sent BOOLEAN NOT NULL DEFAULT false; + CREATE INDEX idx_notes_reminder_pending ON notes(reminder_date, reminder_sent) WHERE reminder_date IS NOT NULL AND reminder_sent = false; + END IF; +END $$; diff --git a/src/db/migrations/add_crm_tables.sql b/src/db/migrations/add_crm_tables.sql new file mode 100644 index 0000000..8d836ee --- /dev/null +++ b/src/db/migrations/add_crm_tables.sql @@ -0,0 +1,107 @@ +-- Add new enum types +DO $$ BEGIN + CREATE TYPE project_status AS ENUM('active', 'completed', 'on_hold', 'cancelled'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +DO $$ BEGIN + CREATE TYPE todo_status AS ENUM('pending', 'in_progress', 'completed', 'cancelled'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +DO $$ BEGIN + CREATE TYPE todo_priority AS ENUM('low', 'medium', 'high', 'urgent'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Create companies table +CREATE TABLE IF NOT EXISTS companies ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + description TEXT, + address TEXT, + city TEXT, + country TEXT, + phone TEXT, + email TEXT, + website TEXT, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- Create projects table +CREATE TABLE IF NOT EXISTS projects ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + description TEXT, + company_id UUID REFERENCES companies(id) ON DELETE CASCADE, + status project_status NOT NULL DEFAULT 'active', + start_date TIMESTAMP, + end_date TIMESTAMP, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- Create todos table +CREATE TABLE IF NOT EXISTS todos ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title TEXT NOT NULL, + description TEXT, + project_id UUID REFERENCES projects(id) ON DELETE CASCADE, + company_id UUID REFERENCES companies(id) ON DELETE CASCADE, + assigned_to UUID REFERENCES users(id) ON DELETE SET NULL, + status todo_status NOT NULL DEFAULT 'pending', + priority todo_priority NOT NULL DEFAULT 'medium', + due_date TIMESTAMP, + completed_at TIMESTAMP, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- Create notes table +CREATE TABLE IF NOT EXISTS notes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title TEXT, + content TEXT NOT NULL, + company_id UUID REFERENCES companies(id) ON DELETE CASCADE, + project_id UUID REFERENCES projects(id) ON DELETE CASCADE, + todo_id UUID REFERENCES todos(id) ON DELETE CASCADE, + contact_id UUID REFERENCES contacts(id) ON DELETE CASCADE, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- Add project_id to timesheets table if not exists +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name='timesheets' AND column_name='project_id' + ) THEN + ALTER TABLE timesheets ADD COLUMN project_id UUID REFERENCES projects(id) ON DELETE SET NULL; + END IF; +END $$; + +-- Create indexes for better query performance +CREATE INDEX IF NOT EXISTS idx_companies_created_at ON companies(created_at); +CREATE INDEX IF NOT EXISTS idx_projects_company_id ON projects(company_id); +CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status); +CREATE INDEX IF NOT EXISTS idx_projects_created_at ON projects(created_at); +CREATE INDEX IF NOT EXISTS idx_todos_project_id ON todos(project_id); +CREATE INDEX IF NOT EXISTS idx_todos_company_id ON todos(company_id); +CREATE INDEX IF NOT EXISTS idx_todos_assigned_to ON todos(assigned_to); +CREATE INDEX IF NOT EXISTS idx_todos_status ON todos(status); +CREATE INDEX IF NOT EXISTS idx_todos_created_at ON todos(created_at); +CREATE INDEX IF NOT EXISTS idx_notes_company_id ON notes(company_id); +CREATE INDEX IF NOT EXISTS idx_notes_project_id ON notes(project_id); +CREATE INDEX IF NOT EXISTS idx_notes_todo_id ON notes(todo_id); +CREATE INDEX IF NOT EXISTS idx_notes_contact_id ON notes(contact_id); +CREATE INDEX IF NOT EXISTS idx_notes_created_at ON notes(created_at); +CREATE INDEX IF NOT EXISTS idx_timesheets_project_id ON timesheets(project_id); diff --git a/src/db/migrations/add_project_users.sql b/src/db/migrations/add_project_users.sql new file mode 100644 index 0000000..f46901b --- /dev/null +++ b/src/db/migrations/add_project_users.sql @@ -0,0 +1,21 @@ +-- Migration: Add project_users junction table for project team management +-- Created: 2025-11-21 +-- Description: Allows many-to-many relationship between projects and users + +-- Create project_users junction table +CREATE TABLE IF NOT EXISTS project_users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT, + added_by UUID REFERENCES users(id) ON DELETE SET NULL, + added_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT project_user_unique UNIQUE(project_id, user_id) +); + +-- Create indexes for better query performance +CREATE INDEX IF NOT EXISTS idx_project_users_project_id ON project_users(project_id); +CREATE INDEX IF NOT EXISTS idx_project_users_user_id ON project_users(user_id); + +-- Add comment +COMMENT ON TABLE project_users IS 'Junction table for many-to-many relationship between projects and users (project team members)'; diff --git a/src/db/schema.js b/src/db/schema.js index 4ba1990..f5c19ec 100644 --- a/src/db/schema.js +++ b/src/db/schema.js @@ -1,7 +1,10 @@ import { pgTable, text, timestamp, boolean, uuid, pgEnum, unique, integer } from 'drizzle-orm/pg-core'; -// Role enum +// Enums export const roleEnum = pgEnum('role', ['admin', 'member']); +export const projectStatusEnum = pgEnum('project_status', ['active', 'completed', 'on_hold', 'cancelled']); +export const todoStatusEnum = pgEnum('todo_status', ['pending', 'in_progress', 'completed', 'cancelled']); +export const todoPriorityEnum = pgEnum('todo_priority', ['low', 'medium', 'high', 'urgent']); // Users table - používatelia systému export const users = pgTable('users', { @@ -63,6 +66,7 @@ export const auditLogs = pgTable('audit_logs', { export const contacts = pgTable('contacts', { id: uuid('id').primaryKey().defaultRandom(), emailAccountId: uuid('email_account_id').references(() => emailAccounts.id, { onDelete: 'cascade' }).notNull(), + companyId: uuid('company_id').references(() => companies.id, { onDelete: 'set null' }), // kontakt môže byť linknutý k firme email: text('email').notNull(), name: text('name'), notes: text('notes'), @@ -96,10 +100,86 @@ export const emails = pgTable('emails', { updatedAt: timestamp('updated_at').defaultNow().notNull(), }); +// Companies table - firmy/spoločnosti +export const companies = pgTable('companies', { + id: uuid('id').primaryKey().defaultRandom(), + name: text('name').notNull(), + description: text('description'), + address: text('address'), + city: text('city'), + country: text('country'), + phone: text('phone'), + email: text('email'), + website: text('website'), + createdBy: uuid('created_by').references(() => users.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + +// Projects table - projekty +export const projects = pgTable('projects', { + id: uuid('id').primaryKey().defaultRandom(), + name: text('name').notNull(), + description: text('description'), + companyId: uuid('company_id').references(() => companies.id, { onDelete: 'cascade' }), // projekt môže patriť firme + status: projectStatusEnum('status').default('active').notNull(), + startDate: timestamp('start_date'), + endDate: timestamp('end_date'), + createdBy: uuid('created_by').references(() => users.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + +// Project Users - many-to-many medzi projects a users (tím projektu) +export const projectUsers = pgTable('project_users', { + id: uuid('id').primaryKey().defaultRandom(), + projectId: uuid('project_id').references(() => projects.id, { onDelete: 'cascade' }).notNull(), + userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), + role: text('role'), // napr. 'lead', 'member', 'viewer' - voliteľné + addedBy: uuid('added_by').references(() => users.id, { onDelete: 'set null' }), // kto pridal používateľa do projektu + addedAt: timestamp('added_at').defaultNow().notNull(), +}, (table) => ({ + projectUserUnique: unique('project_user_unique').on(table.projectId, table.userId), +})); + +// Todos table - úlohy/tasky +export const todos = pgTable('todos', { + id: uuid('id').primaryKey().defaultRandom(), + title: text('title').notNull(), + description: text('description'), + projectId: uuid('project_id').references(() => projects.id, { onDelete: 'cascade' }), // todo môže patriť projektu + companyId: uuid('company_id').references(() => companies.id, { onDelete: 'cascade' }), // alebo firme + assignedTo: uuid('assigned_to').references(() => users.id, { onDelete: 'set null' }), // komu je priradené + status: todoStatusEnum('status').default('pending').notNull(), + priority: todoPriorityEnum('priority').default('medium').notNull(), + dueDate: timestamp('due_date'), + completedAt: timestamp('completed_at'), + createdBy: uuid('created_by').references(() => users.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + +// Notes table - poznámky +export const notes = pgTable('notes', { + id: uuid('id').primaryKey().defaultRandom(), + title: text('title'), + content: text('content').notNull(), + companyId: uuid('company_id').references(() => companies.id, { onDelete: 'cascade' }), // poznámka k firme + projectId: uuid('project_id').references(() => projects.id, { onDelete: 'cascade' }), // alebo projektu + todoId: uuid('todo_id').references(() => todos.id, { onDelete: 'cascade' }), // alebo todo + contactId: uuid('contact_id').references(() => contacts.id, { onDelete: 'cascade' }), // alebo kontaktu + reminderDate: timestamp('reminder_date'), // dátum a čas pre reminder + reminderSent: boolean('reminder_sent').default(false).notNull(), // či už bol reminder odoslaný + createdBy: uuid('created_by').references(() => users.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + // Timesheets table - nahrané timesheets od používateľov export const timesheets = pgTable('timesheets', { id: uuid('id').primaryKey().defaultRandom(), userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), // kto nahral timesheet + projectId: uuid('project_id').references(() => projects.id, { onDelete: 'set null' }), // projekt ku ktorému patrí timesheet fileName: text('file_name').notNull(), // originálny názov súboru filePath: text('file_path').notNull(), // cesta k súboru na serveri fileType: text('file_type').notNull(), // 'pdf' alebo 'xlsx' diff --git a/src/routes/company.routes.js b/src/routes/company.routes.js new file mode 100644 index 0000000..480b5b5 --- /dev/null +++ b/src/routes/company.routes.js @@ -0,0 +1,95 @@ +import express from 'express'; +import * as companyController from '../controllers/company.controller.js'; +import { authenticate } from '../middlewares/auth/authMiddleware.js'; +import { validateBody, validateParams } from '../middlewares/security/validateInput.js'; +import { createCompanySchema, updateCompanySchema } from '../validators/crm.validators.js'; +import { z } from 'zod'; + +const router = express.Router(); + +// All company routes require authentication +router.use(authenticate); + +/** + * Company management + */ + +// Get all companies +router.get('/', companyController.getAllCompanies); + +// Get company by ID +router.get( + '/:companyId', + validateParams(z.object({ companyId: z.string().uuid() })), + companyController.getCompanyById +); + +// Get company with relations (projects, todos, notes) +router.get( + '/:companyId/details', + validateParams(z.object({ companyId: z.string().uuid() })), + companyController.getCompanyWithRelations +); + +// Create new company +router.post( + '/', + validateBody(createCompanySchema), + companyController.createCompany +); + +// Update company +router.patch( + '/:companyId', + validateParams(z.object({ companyId: z.string().uuid() })), + validateBody(updateCompanySchema), + companyController.updateCompany +); + +// Delete company +router.delete( + '/:companyId', + validateParams(z.object({ companyId: z.string().uuid() })), + companyController.deleteCompany +); + +// Company Notes (nested resources) +router.get( + '/:companyId/notes', + validateParams(z.object({ companyId: z.string().uuid() })), + companyController.getCompanyNotes +); + +router.post( + '/:companyId/notes', + validateParams(z.object({ companyId: z.string().uuid() })), + validateBody(z.object({ + content: z.string().min(1), + reminderAt: z.string().optional().or(z.literal('')), + })), + companyController.addCompanyNote +); + +router.patch( + '/:companyId/notes/:noteId', + validateParams(z.object({ + companyId: z.string().uuid(), + noteId: z.string().uuid() + })), + validateBody(z.object({ + content: z.string().min(1).optional(), + reminderAt: z.string().optional().or(z.literal('').or(z.null())), + })), + companyController.updateCompanyNote +); + +router.delete( + '/:companyId/notes/:noteId', + validateParams(z.object({ + companyId: z.string().uuid(), + noteId: z.string().uuid() + })), + companyController.deleteCompanyNote +); + +export default router; diff --git a/src/routes/contact.routes.js b/src/routes/contact.routes.js index 2f04422..f6b3d4e 100644 --- a/src/routes/contact.routes.js +++ b/src/routes/contact.routes.js @@ -53,4 +53,38 @@ router.delete( contactController.removeContact ); +// Link company to contact +router.post( + '/:contactId/link-company', + validateParams(z.object({ contactId: z.string().uuid() })), + validateBody(z.object({ companyId: z.string().uuid() })), + contactController.linkCompanyToContact +); + +// Unlink company from contact +router.post( + '/:contactId/unlink-company', + validateParams(z.object({ contactId: z.string().uuid() })), + contactController.unlinkCompanyFromContact +); + +// Create company from contact +router.post( + '/:contactId/create-company', + validateParams(z.object({ contactId: z.string().uuid() })), + validateBody( + z.object({ + name: z.string().optional(), + email: z.string().email().optional(), + phone: z.string().optional(), + address: z.string().optional(), + city: z.string().optional(), + country: z.string().optional(), + website: z.string().url().optional(), + description: z.string().optional(), + }) + ), + contactController.createCompanyFromContact +); + export default router; diff --git a/src/routes/note.routes.js b/src/routes/note.routes.js new file mode 100644 index 0000000..9f128bc --- /dev/null +++ b/src/routes/note.routes.js @@ -0,0 +1,59 @@ +import express from 'express'; +import * as noteController from '../controllers/note.controller.js'; +import { authenticate } from '../middlewares/auth/authMiddleware.js'; +import { validateBody, validateParams } from '../middlewares/security/validateInput.js'; +import { createNoteSchema, updateNoteSchema } from '../validators/crm.validators.js'; +import { z } from 'zod'; + +const router = express.Router(); + +// All note routes require authentication +router.use(authenticate); + +/** + * Note management + */ + +// Get all notes +router.get('/', noteController.getAllNotes); + +// Get my reminders (must be before /:noteId to avoid route conflict) +router.get('/my-reminders', noteController.getMyReminders); + +// Get note by ID +router.get( + '/:noteId', + validateParams(z.object({ noteId: z.string().uuid() })), + noteController.getNoteById +); + +// Create new note +router.post( + '/', + validateBody(createNoteSchema), + noteController.createNote +); + +// Update note +router.patch( + '/:noteId', + validateParams(z.object({ noteId: z.string().uuid() })), + validateBody(updateNoteSchema), + noteController.updateNote +); + +// Delete note +router.delete( + '/:noteId', + validateParams(z.object({ noteId: z.string().uuid() })), + noteController.deleteNote +); + +// Mark reminder as sent +router.post( + '/:noteId/mark-reminder-sent', + validateParams(z.object({ noteId: z.string().uuid() })), + noteController.markReminderSent +); + +export default router; diff --git a/src/routes/project.routes.js b/src/routes/project.routes.js new file mode 100644 index 0000000..829d726 --- /dev/null +++ b/src/routes/project.routes.js @@ -0,0 +1,133 @@ +import express from 'express'; +import * as projectController from '../controllers/project.controller.js'; +import { authenticate } from '../middlewares/auth/authMiddleware.js'; +import { validateBody, validateParams } from '../middlewares/security/validateInput.js'; +import { createProjectSchema, updateProjectSchema } from '../validators/crm.validators.js'; +import { z } from 'zod'; + +const router = express.Router(); + +// All project routes require authentication +router.use(authenticate); + +/** + * Project management + */ + +// Get all projects +router.get('/', projectController.getAllProjects); + +// Get project by ID +router.get( + '/:projectId', + validateParams(z.object({ projectId: z.string().uuid() })), + projectController.getProjectById +); + +// Get project with relations (company, todos, notes, timesheets) +router.get( + '/:projectId/details', + validateParams(z.object({ projectId: z.string().uuid() })), + projectController.getProjectWithRelations +); + +// Create new project +router.post( + '/', + validateBody(createProjectSchema), + projectController.createProject +); + +// Update project +router.patch( + '/:projectId', + validateParams(z.object({ projectId: z.string().uuid() })), + validateBody(updateProjectSchema), + projectController.updateProject +); + +// Delete project +router.delete( + '/:projectId', + validateParams(z.object({ projectId: z.string().uuid() })), + projectController.deleteProject +); + +// Project Notes (nested resources) +router.get( + '/:projectId/notes', + validateParams(z.object({ projectId: z.string().uuid() })), + projectController.getProjectNotes +); + +router.post( + '/:projectId/notes', + validateParams(z.object({ projectId: z.string().uuid() })), + validateBody(z.object({ + content: z.string().min(1), + reminderAt: z.string().optional().or(z.literal('')), + })), + projectController.addProjectNote +); + +router.patch( + '/:projectId/notes/:noteId', + validateParams(z.object({ + projectId: z.string().uuid(), + noteId: z.string().uuid() + })), + validateBody(z.object({ + content: z.string().min(1).optional(), + reminderAt: z.string().optional().or(z.literal('').or(z.null())), + })), + projectController.updateProjectNote +); + +router.delete( + '/:projectId/notes/:noteId', + validateParams(z.object({ + projectId: z.string().uuid(), + noteId: z.string().uuid() + })), + projectController.deleteProjectNote +); + +// Project Users (team members) +router.get( + '/:projectId/users', + validateParams(z.object({ projectId: z.string().uuid() })), + projectController.getProjectUsers +); + +router.post( + '/:projectId/users', + validateParams(z.object({ projectId: z.string().uuid() })), + validateBody(z.object({ + userId: z.string().uuid('Neplatný formát user ID'), + role: z.string().max(50).optional().or(z.literal('')), + })), + projectController.assignUserToProject +); + +router.patch( + '/:projectId/users/:userId', + validateParams(z.object({ + projectId: z.string().uuid(), + userId: z.string().uuid() + })), + validateBody(z.object({ + role: z.string().max(50).optional().or(z.literal('').or(z.null())), + })), + projectController.updateUserRoleOnProject +); + +router.delete( + '/:projectId/users/:userId', + validateParams(z.object({ + projectId: z.string().uuid(), + userId: z.string().uuid() + })), + projectController.removeUserFromProject +); + +export default router; diff --git a/src/routes/todo.routes.js b/src/routes/todo.routes.js new file mode 100644 index 0000000..6321dac --- /dev/null +++ b/src/routes/todo.routes.js @@ -0,0 +1,66 @@ +import express from 'express'; +import * as todoController from '../controllers/todo.controller.js'; +import { authenticate } from '../middlewares/auth/authMiddleware.js'; +import { validateBody, validateParams } from '../middlewares/security/validateInput.js'; +import { createTodoSchema, updateTodoSchema } from '../validators/crm.validators.js'; +import { z } from 'zod'; + +const router = express.Router(); + +// All todo routes require authentication +router.use(authenticate); + +/** + * Todo management + */ + +// Get all todos +router.get('/', todoController.getAllTodos); + +// Get my todos (assigned to current user) +router.get('/my', todoController.getMyTodos); + +// Get todo by ID +router.get( + '/:todoId', + validateParams(z.object({ todoId: z.string().uuid() })), + todoController.getTodoById +); + +// Get todo with relations (project, company, assigned user, notes) +router.get( + '/:todoId/details', + validateParams(z.object({ todoId: z.string().uuid() })), + todoController.getTodoWithRelations +); + +// Create new todo +router.post( + '/', + validateBody(createTodoSchema), + todoController.createTodo +); + +// Update todo +router.patch( + '/:todoId', + validateParams(z.object({ todoId: z.string().uuid() })), + validateBody(updateTodoSchema), + todoController.updateTodo +); + +// Delete todo +router.delete( + '/:todoId', + validateParams(z.object({ todoId: z.string().uuid() })), + todoController.deleteTodo +); + +// Toggle todo completion status +router.patch( + '/:todoId/toggle', + validateParams(z.object({ todoId: z.string().uuid() })), + todoController.toggleTodo +); + +export default router; diff --git a/src/services/company.service.js b/src/services/company.service.js new file mode 100644 index 0000000..a35b4cb --- /dev/null +++ b/src/services/company.service.js @@ -0,0 +1,163 @@ +import { db } from '../config/database.js'; +import { companies, projects, todos, notes } from '../db/schema.js'; +import { eq, desc, ilike, or, and } from 'drizzle-orm'; +import { NotFoundError, ConflictError } from '../utils/errors.js'; + +/** + * Get all companies + * Optionally filter by search term + */ +export const getAllCompanies = async (searchTerm = null) => { + let query = db.select().from(companies); + + if (searchTerm) { + query = query.where( + or( + ilike(companies.name, `%${searchTerm}%`), + ilike(companies.email, `%${searchTerm}%`), + ilike(companies.city, `%${searchTerm}%`) + ) + ); + } + + const result = await query.orderBy(desc(companies.createdAt)); + return result; +}; + +/** + * Get company by ID + */ +export const getCompanyById = async (companyId) => { + const [company] = await db + .select() + .from(companies) + .where(eq(companies.id, companyId)) + .limit(1); + + if (!company) { + throw new NotFoundError('Firma nenájdená'); + } + + return company; +}; + +/** + * Create new company + */ +export const createCompany = async (userId, data) => { + const { name, description, address, city, country, phone, email, website } = data; + + // Check if company with same name already exists + const [existing] = await db + .select() + .from(companies) + .where(eq(companies.name, name)) + .limit(1); + + if (existing) { + throw new ConflictError('Firma s týmto názvom už existuje'); + } + + const [newCompany] = await db + .insert(companies) + .values({ + name, + description: description || null, + address: address || null, + city: city || null, + country: country || null, + phone: phone || null, + email: email || null, + website: website || null, + createdBy: userId, + }) + .returning(); + + return newCompany; +}; + +/** + * Update company + */ +export const updateCompany = async (companyId, data) => { + const company = await getCompanyById(companyId); + + const { name, description, address, city, country, phone, email, website } = data; + + // If name is being changed, check for duplicates + if (name && name !== company.name) { + const [existing] = await db + .select() + .from(companies) + .where(and(eq(companies.name, name), eq(companies.id, companyId))) + .limit(1); + + if (existing && existing.id !== companyId) { + throw new ConflictError('Firma s týmto názvom už existuje'); + } + } + + const [updated] = await db + .update(companies) + .set({ + name: name !== undefined ? name : company.name, + description: description !== undefined ? description : company.description, + address: address !== undefined ? address : company.address, + city: city !== undefined ? city : company.city, + country: country !== undefined ? country : company.country, + phone: phone !== undefined ? phone : company.phone, + email: email !== undefined ? email : company.email, + website: website !== undefined ? website : company.website, + updatedAt: new Date(), + }) + .where(eq(companies.id, companyId)) + .returning(); + + return updated; +}; + +/** + * Delete company + */ +export const deleteCompany = async (companyId) => { + await getCompanyById(companyId); // Check if exists + + await db.delete(companies).where(eq(companies.id, companyId)); + + return { success: true, message: 'Firma bola odstránená' }; +}; + +/** + * Get company with related data (projects, todos, notes) + */ +export const getCompanyWithRelations = async (companyId) => { + const company = await getCompanyById(companyId); + + // Get related projects + const companyProjects = await db + .select() + .from(projects) + .where(eq(projects.companyId, companyId)) + .orderBy(desc(projects.createdAt)); + + // Get related todos + const companyTodos = await db + .select() + .from(todos) + .where(eq(todos.companyId, companyId)) + .orderBy(desc(todos.createdAt)); + + // Get related notes + const companyNotes = await db + .select() + .from(notes) + .where(eq(notes.companyId, companyId)) + .orderBy(desc(notes.createdAt)); + + return { + ...company, + projects: companyProjects, + todos: companyTodos, + notes: companyNotes, + }; +}; diff --git a/src/services/contact.service.js b/src/services/contact.service.js index 25aa2d8..27d1efe 100644 --- a/src/services/contact.service.js +++ b/src/services/contact.service.js @@ -1,5 +1,5 @@ import { db } from '../config/database.js'; -import { contacts, emails } from '../db/schema.js'; +import { contacts, emails, companies } from '../db/schema.js'; import { eq, and, desc, or, ne } from 'drizzle-orm'; import { NotFoundError, ConflictError } from '../utils/errors.js'; import { syncEmailsFromSender } from './jmap.service.js'; @@ -156,3 +156,91 @@ export const updateContact = async (contactId, emailAccountId, { name, notes }) return updated; }; + +/** + * Link company to contact + */ +export const linkCompanyToContact = async (contactId, emailAccountId, companyId) => { + const contact = await getContactById(contactId, emailAccountId); + + const [updated] = await db + .update(contacts) + .set({ + companyId, + updatedAt: new Date(), + }) + .where(eq(contacts.id, contactId)) + .returning(); + + return updated; +}; + +/** + * Unlink company from contact + */ +export const unlinkCompanyFromContact = async (contactId, emailAccountId) => { + const contact = await getContactById(contactId, emailAccountId); + + const [updated] = await db + .update(contacts) + .set({ + companyId: null, + updatedAt: new Date(), + }) + .where(eq(contacts.id, contactId)) + .returning(); + + return updated; +}; + +/** + * Create company from contact + * Creates a new company using contact's information and links it + */ +export const createCompanyFromContact = async (contactId, emailAccountId, userId, companyData = {}) => { + const contact = await getContactById(contactId, emailAccountId); + + // Check if company with same name already exists + if (companyData.name) { + const [existing] = await db + .select() + .from(companies) + .where(eq(companies.name, companyData.name)) + .limit(1); + + if (existing) { + throw new ConflictError('Firma s týmto názvom už existuje'); + } + } + + // Create company with contact's data as defaults + const [newCompany] = await db + .insert(companies) + .values({ + name: companyData.name || contact.name || contact.email.split('@')[0], + email: companyData.email || contact.email, + phone: companyData.phone || null, + address: companyData.address || null, + city: companyData.city || null, + country: companyData.country || null, + website: companyData.website || null, + description: companyData.description || null, + createdBy: userId, + }) + .returning(); + + // Link contact to newly created company + const [updatedContact] = await db + .update(contacts) + .set({ + companyId: newCompany.id, + updatedAt: new Date(), + }) + .where(eq(contacts.id, contactId)) + .returning(); + + return { + company: newCompany, + contact: updatedContact, + }; +}; diff --git a/src/services/note.service.js b/src/services/note.service.js new file mode 100644 index 0000000..4bfa093 --- /dev/null +++ b/src/services/note.service.js @@ -0,0 +1,349 @@ +import { db } from '../config/database.js'; +import { notes, companies, projects, todos, contacts } from '../db/schema.js'; +import { eq, desc, ilike, or, and, lte, isNull, not } from 'drizzle-orm'; +import { NotFoundError } from '../utils/errors.js'; + +/** + * Map note fields for frontend compatibility + * reminderDate → reminderAt + */ +const mapNoteForFrontend = (note) => { + if (!note) return note; + const { reminderDate, ...rest } = note; + return { + ...rest, + reminderAt: reminderDate, + }; +}; + +/** + * Get all notes + * Optionally filter by search, company, project, todo, or contact + */ +export const getAllNotes = async (filters = {}) => { + const { searchTerm, companyId, projectId, todoId, contactId } = filters; + + let query = db.select().from(notes); + + const conditions = []; + + if (searchTerm) { + conditions.push( + or( + ilike(notes.title, `%${searchTerm}%`), + ilike(notes.content, `%${searchTerm}%`) + ) + ); + } + + if (companyId) { + conditions.push(eq(notes.companyId, companyId)); + } + + if (projectId) { + conditions.push(eq(notes.projectId, projectId)); + } + + if (todoId) { + conditions.push(eq(notes.todoId, todoId)); + } + + if (contactId) { + conditions.push(eq(notes.contactId, contactId)); + } + + if (conditions.length > 0) { + query = query.where(and(...conditions)); + } + + const result = await query.orderBy(desc(notes.createdAt)); + return result.map(mapNoteForFrontend); +}; + +/** + * Get note by ID + */ +export const getNoteById = async (noteId) => { + const [note] = await db + .select() + .from(notes) + .where(eq(notes.id, noteId)) + .limit(1); + + if (!note) { + throw new NotFoundError('Poznámka nenájdená'); + } + + return mapNoteForFrontend(note); +}; + +/** + * Create new note + */ +export const createNote = async (userId, data) => { + const { title, content, companyId, projectId, todoId, contactId, reminderDate } = data; + + // Verify company exists if provided + if (companyId) { + const [company] = await db + .select() + .from(companies) + .where(eq(companies.id, companyId)) + .limit(1); + + if (!company) { + throw new NotFoundError('Firma nenájdená'); + } + } + + // Verify project exists if provided + if (projectId) { + const [project] = await db + .select() + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1); + + if (!project) { + throw new NotFoundError('Projekt nenájdený'); + } + } + + // Verify todo exists if provided + if (todoId) { + const [todo] = await db + .select() + .from(todos) + .where(eq(todos.id, todoId)) + .limit(1); + + if (!todo) { + throw new NotFoundError('Todo nenájdené'); + } + } + + // Verify contact exists if provided + if (contactId) { + const [contact] = await db + .select() + .from(contacts) + .where(eq(contacts.id, contactId)) + .limit(1); + + if (!contact) { + throw new NotFoundError('Kontakt nenájdený'); + } + } + + const [newNote] = await db + .insert(notes) + .values({ + title: title || null, + content, + companyId: companyId || null, + projectId: projectId || null, + todoId: todoId || null, + contactId: contactId || null, + reminderDate: reminderDate ? new Date(reminderDate) : null, + reminderSent: false, + createdBy: userId, + }) + .returning(); + + return mapNoteForFrontend(newNote); +}; + +/** + * Update note + */ +export const updateNote = async (noteId, data) => { + const note = await getNoteById(noteId); + + const { title, content, companyId, projectId, todoId, contactId, reminderDate } = data; + + // Verify company exists if being changed + if (companyId !== undefined && companyId !== null && companyId !== note.companyId) { + const [company] = await db + .select() + .from(companies) + .where(eq(companies.id, companyId)) + .limit(1); + + if (!company) { + throw new NotFoundError('Firma nenájdená'); + } + } + + // Verify project exists if being changed + if (projectId !== undefined && projectId !== null && projectId !== note.projectId) { + const [project] = await db + .select() + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1); + + if (!project) { + throw new NotFoundError('Projekt nenájdený'); + } + } + + // Verify todo exists if being changed + if (todoId !== undefined && todoId !== null && todoId !== note.todoId) { + const [todo] = await db + .select() + .from(todos) + .where(eq(todos.id, todoId)) + .limit(1); + + if (!todo) { + throw new NotFoundError('Todo nenájdené'); + } + } + + // Verify contact exists if being changed + if (contactId !== undefined && contactId !== null && contactId !== note.contactId) { + const [contact] = await db + .select() + .from(contacts) + .where(eq(contacts.id, contactId)) + .limit(1); + + if (!contact) { + throw new NotFoundError('Kontakt nenájdený'); + } + } + + const [updated] = await db + .update(notes) + .set({ + title: title !== undefined ? title : note.title, + content: content !== undefined ? content : note.content, + companyId: companyId !== undefined ? companyId : note.companyId, + projectId: projectId !== undefined ? projectId : note.projectId, + todoId: todoId !== undefined ? todoId : note.todoId, + contactId: contactId !== undefined ? contactId : note.contactId, + reminderDate: reminderDate !== undefined ? (reminderDate ? new Date(reminderDate) : null) : note.reminderDate, + reminderSent: reminderDate !== undefined ? false : note.reminderSent, // Reset reminderSent if reminderDate changes + updatedAt: new Date(), + }) + .where(eq(notes.id, noteId)) + .returning(); + + return mapNoteForFrontend(updated); +}; + +/** + * Delete note + */ +export const deleteNote = async (noteId) => { + await getNoteById(noteId); // Check if exists + + await db.delete(notes).where(eq(notes.id, noteId)); + + return { success: true, message: 'Poznámka bola odstránená' }; +}; + +/** + * Get notes by company ID + */ +export const getNotesByCompanyId = async (companyId) => { + const result = await db + .select() + .from(notes) + .where(eq(notes.companyId, companyId)) + .orderBy(desc(notes.createdAt)); + return result.map(mapNoteForFrontend); +}; + +/** + * Get notes by project ID + */ +export const getNotesByProjectId = async (projectId) => { + const result = await db + .select() + .from(notes) + .where(eq(notes.projectId, projectId)) + .orderBy(desc(notes.createdAt)); + return result.map(mapNoteForFrontend); +}; + +/** + * Get notes by todo ID + */ +export const getNotesByTodoId = async (todoId) => { + const result = await db + .select() + .from(notes) + .where(eq(notes.todoId, todoId)) + .orderBy(desc(notes.createdAt)); + return result.map(mapNoteForFrontend); +}; + +/** + * Get notes by contact ID + */ +export const getNotesByContactId = async (contactId) => { + const result = await db + .select() + .from(notes) + .where(eq(notes.contactId, contactId)) + .orderBy(desc(notes.createdAt)); + return result.map(mapNoteForFrontend); +}; + +/** + * Get pending reminders (reminders that are due and not sent) + */ +export const getPendingReminders = async () => { + const now = new Date(); + + const result = await db + .select() + .from(notes) + .where( + and( + not(isNull(notes.reminderDate)), + lte(notes.reminderDate, now), + eq(notes.reminderSent, false) + ) + ) + .orderBy(notes.reminderDate); + return result.map(mapNoteForFrontend); +}; + +/** + * Mark reminder as sent + */ +export const markReminderAsSent = async (noteId) => { + const [updated] = await db + .update(notes) + .set({ + reminderSent: true, + updatedAt: new Date(), + }) + .where(eq(notes.id, noteId)) + .returning(); + + return mapNoteForFrontend(updated); +}; + +/** + * Get upcoming reminders for a user (created by user, not sent yet) + */ +export const getUpcomingRemindersForUser = async (userId) => { + const now = new Date(); + + const result = await db + .select() + .from(notes) + .where( + and( + eq(notes.createdBy, userId), + not(isNull(notes.reminderDate)), + lte(notes.reminderDate, now), + eq(notes.reminderSent, false) + ) + ) + .orderBy(notes.reminderDate); + return result.map(mapNoteForFrontend); +}; diff --git a/src/services/project.service.js b/src/services/project.service.js new file mode 100644 index 0000000..63c85b3 --- /dev/null +++ b/src/services/project.service.js @@ -0,0 +1,379 @@ +import { db } from '../config/database.js'; +import { projects, todos, notes, timesheets, companies, projectUsers, users } from '../db/schema.js'; +import { eq, desc, ilike, or, and } from 'drizzle-orm'; +import { NotFoundError, ConflictError } from '../utils/errors.js'; + +/** + * Get all projects + * Optionally filter by search term or company + */ +export const getAllProjects = async (searchTerm = null, companyId = null) => { + let query = db.select().from(projects); + + const conditions = []; + + if (searchTerm) { + conditions.push( + or( + ilike(projects.name, `%${searchTerm}%`), + ilike(projects.description, `%${searchTerm}%`) + ) + ); + } + + if (companyId) { + conditions.push(eq(projects.companyId, companyId)); + } + + if (conditions.length > 0) { + query = query.where(and(...conditions)); + } + + const result = await query.orderBy(desc(projects.createdAt)); + return result; +}; + +/** + * Get project by ID + */ +export const getProjectById = async (projectId) => { + const [project] = await db + .select() + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1); + + if (!project) { + throw new NotFoundError('Projekt nenájdený'); + } + + return project; +}; + +/** + * Create new project + */ +export const createProject = async (userId, data) => { + const { name, description, companyId, status, startDate, endDate } = data; + + // If companyId is provided, verify company exists + if (companyId) { + const [company] = await db + .select() + .from(companies) + .where(eq(companies.id, companyId)) + .limit(1); + + if (!company) { + throw new NotFoundError('Firma nenájdená'); + } + } + + const [newProject] = await db + .insert(projects) + .values({ + name, + description: description || null, + companyId: companyId || null, + status: status || 'active', + startDate: startDate ? new Date(startDate) : null, + endDate: endDate ? new Date(endDate) : null, + createdBy: userId, + }) + .returning(); + + return newProject; +}; + +/** + * Update project + */ +export const updateProject = async (projectId, data) => { + const project = await getProjectById(projectId); + + const { name, description, companyId, status, startDate, endDate } = data; + + // If companyId is being changed, verify new company exists + if (companyId !== undefined && companyId !== null && companyId !== project.companyId) { + const [company] = await db + .select() + .from(companies) + .where(eq(companies.id, companyId)) + .limit(1); + + if (!company) { + throw new NotFoundError('Firma nenájdená'); + } + } + + const [updated] = await db + .update(projects) + .set({ + name: name !== undefined ? name : project.name, + description: description !== undefined ? description : project.description, + companyId: companyId !== undefined ? companyId : project.companyId, + status: status !== undefined ? status : project.status, + startDate: startDate !== undefined ? (startDate ? new Date(startDate) : null) : project.startDate, + endDate: endDate !== undefined ? (endDate ? new Date(endDate) : null) : project.endDate, + updatedAt: new Date(), + }) + .where(eq(projects.id, projectId)) + .returning(); + + return updated; +}; + +/** + * Delete project + */ +export const deleteProject = async (projectId) => { + await getProjectById(projectId); // Check if exists + + await db.delete(projects).where(eq(projects.id, projectId)); + + return { success: true, message: 'Projekt bol odstránený' }; +}; + +/** + * Get project with related data (todos, notes, timesheets) + */ +export const getProjectWithRelations = async (projectId) => { + const project = await getProjectById(projectId); + + // Get company if exists + let company = null; + if (project.companyId) { + [company] = await db + .select() + .from(companies) + .where(eq(companies.id, project.companyId)) + .limit(1); + } + + // Get related todos + const projectTodos = await db + .select() + .from(todos) + .where(eq(todos.projectId, projectId)) + .orderBy(desc(todos.createdAt)); + + // Get related notes + const projectNotes = await db + .select() + .from(notes) + .where(eq(notes.projectId, projectId)) + .orderBy(desc(notes.createdAt)); + + // Get related timesheets + const projectTimesheets = await db + .select() + .from(timesheets) + .where(eq(timesheets.projectId, projectId)) + .orderBy(desc(timesheets.uploadedAt)); + + // Get assigned users (team members) + const rawUsers = await db + .select() + .from(projectUsers) + .leftJoin(users, eq(projectUsers.userId, users.id)) + .where(eq(projectUsers.projectId, projectId)) + .orderBy(desc(projectUsers.addedAt)); + + const assignedUsers = rawUsers.map((row) => ({ + id: row.project_users.id, + userId: row.project_users.userId, + role: row.project_users.role, + addedBy: row.project_users.addedBy, + addedAt: row.project_users.addedAt, + user: row.users ? { + id: row.users.id, + username: row.users.username, + email: row.users.email, + role: row.users.role, + } : null, + })); + + return { + ...project, + company, + todos: projectTodos, + notes: projectNotes, + timesheets: projectTimesheets, + assignedUsers, + }; +}; + +/** + * Get projects by company ID + */ +export const getProjectsByCompanyId = async (companyId) => { + return await db + .select() + .from(projects) + .where(eq(projects.companyId, companyId)) + .orderBy(desc(projects.createdAt)); +}; + +/** + * Get project users (team members) + */ +export const getProjectUsers = async (projectId) => { + await getProjectById(projectId); // Verify project exists + + const rawResults = await db + .select() + .from(projectUsers) + .leftJoin(users, eq(projectUsers.userId, users.id)) + .where(eq(projectUsers.projectId, projectId)) + .orderBy(desc(projectUsers.addedAt)); + + const assignedUsers = rawResults.map((row) => ({ + id: row.project_users.id, + userId: row.project_users.userId, + role: row.project_users.role, + addedBy: row.project_users.addedBy, + addedAt: row.project_users.addedAt, + user: row.users ? { + id: row.users.id, + username: row.users.username, + email: row.users.email, + role: row.users.role, + } : null, + })); + + return assignedUsers; +}; + +/** + * Assign user to project + */ +export const assignUserToProject = async (projectId, userId, addedByUserId, role = null) => { + await getProjectById(projectId); // Verify project exists + + // Verify user exists + const [user] = await db + .select() + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + if (!user) { + throw new NotFoundError('Používateľ nenájdený'); + } + + // Check if user is already assigned + const [existing] = await db + .select() + .from(projectUsers) + .where(and(eq(projectUsers.projectId, projectId), eq(projectUsers.userId, userId))) + .limit(1); + + if (existing) { + throw new ConflictError('Používateľ je už priradený k projektu'); + } + + // Assign user to project + const [assignment] = await db + .insert(projectUsers) + .values({ + projectId, + userId, + role: role || null, + addedBy: addedByUserId, + }) + .returning(); + + // Return with user details + const [row] = await db + .select() + .from(projectUsers) + .leftJoin(users, eq(projectUsers.userId, users.id)) + .where(eq(projectUsers.id, assignment.id)) + .limit(1); + + return { + id: row.project_users.id, + userId: row.project_users.userId, + role: row.project_users.role, + addedBy: row.project_users.addedBy, + addedAt: row.project_users.addedAt, + user: row.users ? { + id: row.users.id, + username: row.users.username, + email: row.users.email, + role: row.users.role, + } : null, + }; +}; + +/** + * Remove user from project + */ +export const removeUserFromProject = async (projectId, userId) => { + await getProjectById(projectId); // Verify project exists + + // Check if user is assigned + const [existing] = await db + .select() + .from(projectUsers) + .where(and(eq(projectUsers.projectId, projectId), eq(projectUsers.userId, userId))) + .limit(1); + + if (!existing) { + throw new NotFoundError('Používateľ nie je priradený k projektu'); + } + + // Remove assignment + await db + .delete(projectUsers) + .where(and(eq(projectUsers.projectId, projectId), eq(projectUsers.userId, userId))); + + return { success: true, message: 'Používateľ bol odstránený z projektu' }; +}; + +/** + * Update user role on project + */ +export const updateUserRoleOnProject = async (projectId, userId, role) => { + await getProjectById(projectId); // Verify project exists + + // Check if user is assigned + const [existing] = await db + .select() + .from(projectUsers) + .where(and(eq(projectUsers.projectId, projectId), eq(projectUsers.userId, userId))) + .limit(1); + + if (!existing) { + throw new NotFoundError('Používateľ nie je priradený k projektu'); + } + + // Update role + const [updated] = await db + .update(projectUsers) + .set({ role: role || null }) + .where(and(eq(projectUsers.projectId, projectId), eq(projectUsers.userId, userId))) + .returning(); + + // Return with user details + const [row] = await db + .select() + .from(projectUsers) + .leftJoin(users, eq(projectUsers.userId, users.id)) + .where(eq(projectUsers.id, updated.id)) + .limit(1); + + return { + id: row.project_users.id, + userId: row.project_users.userId, + role: row.project_users.role, + addedBy: row.project_users.addedBy, + addedAt: row.project_users.addedAt, + user: row.users ? { + id: row.users.id, + username: row.users.username, + email: row.users.email, + role: row.users.role, + } : null, + }; +}; diff --git a/src/services/todo.service.js b/src/services/todo.service.js new file mode 100644 index 0000000..ff98292 --- /dev/null +++ b/src/services/todo.service.js @@ -0,0 +1,304 @@ +import { db } from '../config/database.js'; +import { todos, notes, projects, companies, users } from '../db/schema.js'; +import { eq, desc, ilike, or, and } from 'drizzle-orm'; +import { NotFoundError } from '../utils/errors.js'; + +/** + * Get all todos + * Optionally filter by search, project, company, assigned user, or status + */ +export const getAllTodos = async (filters = {}) => { + const { searchTerm, projectId, companyId, assignedTo, status } = filters; + + let query = db.select().from(todos); + + const conditions = []; + + if (searchTerm) { + conditions.push( + or( + ilike(todos.title, `%${searchTerm}%`), + ilike(todos.description, `%${searchTerm}%`) + ) + ); + } + + if (projectId) { + conditions.push(eq(todos.projectId, projectId)); + } + + if (companyId) { + conditions.push(eq(todos.companyId, companyId)); + } + + if (assignedTo) { + conditions.push(eq(todos.assignedTo, assignedTo)); + } + + if (status) { + conditions.push(eq(todos.status, status)); + } + + if (conditions.length > 0) { + query = query.where(and(...conditions)); + } + + const result = await query.orderBy(desc(todos.createdAt)); + return result; +}; + +/** + * Get todo by ID + */ +export const getTodoById = async (todoId) => { + const [todo] = await db + .select() + .from(todos) + .where(eq(todos.id, todoId)) + .limit(1); + + if (!todo) { + throw new NotFoundError('Todo nenájdené'); + } + + return todo; +}; + +/** + * Create new todo + */ +export const createTodo = async (userId, data) => { + const { title, description, projectId, companyId, assignedTo, status, priority, dueDate } = data; + + // Verify project exists if provided + if (projectId) { + const [project] = await db + .select() + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1); + + if (!project) { + throw new NotFoundError('Projekt nenájdený'); + } + } + + // Verify company exists if provided + if (companyId) { + const [company] = await db + .select() + .from(companies) + .where(eq(companies.id, companyId)) + .limit(1); + + if (!company) { + throw new NotFoundError('Firma nenájdená'); + } + } + + // Verify assigned user exists if provided + if (assignedTo) { + const [user] = await db + .select() + .from(users) + .where(eq(users.id, assignedTo)) + .limit(1); + + if (!user) { + throw new NotFoundError('Používateľ nenájdený'); + } + } + + const [newTodo] = await db + .insert(todos) + .values({ + title, + description: description || null, + projectId: projectId || null, + companyId: companyId || null, + assignedTo: assignedTo || null, + status: status || 'pending', + priority: priority || 'medium', + dueDate: dueDate ? new Date(dueDate) : null, + createdBy: userId, + }) + .returning(); + + return newTodo; +}; + +/** + * Update todo + */ +export const updateTodo = async (todoId, data) => { + const todo = await getTodoById(todoId); + + const { title, description, projectId, companyId, assignedTo, status, priority, dueDate } = data; + + // Verify project exists if being changed + if (projectId !== undefined && projectId !== null && projectId !== todo.projectId) { + const [project] = await db + .select() + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1); + + if (!project) { + throw new NotFoundError('Projekt nenájdený'); + } + } + + // Verify company exists if being changed + if (companyId !== undefined && companyId !== null && companyId !== todo.companyId) { + const [company] = await db + .select() + .from(companies) + .where(eq(companies.id, companyId)) + .limit(1); + + if (!company) { + throw new NotFoundError('Firma nenájdená'); + } + } + + // Verify assigned user exists if being changed + if (assignedTo !== undefined && assignedTo !== null && assignedTo !== todo.assignedTo) { + const [user] = await db + .select() + .from(users) + .where(eq(users.id, assignedTo)) + .limit(1); + + if (!user) { + throw new NotFoundError('Používateľ nenájdený'); + } + } + + // Set completedAt when status is changed to 'completed' + let completedAt = todo.completedAt; + if (status === 'completed' && todo.status !== 'completed') { + completedAt = new Date(); + } else if (status && status !== 'completed') { + completedAt = null; + } + + const [updated] = await db + .update(todos) + .set({ + title: title !== undefined ? title : todo.title, + description: description !== undefined ? description : todo.description, + projectId: projectId !== undefined ? projectId : todo.projectId, + companyId: companyId !== undefined ? companyId : todo.companyId, + assignedTo: assignedTo !== undefined ? assignedTo : todo.assignedTo, + status: status !== undefined ? status : todo.status, + priority: priority !== undefined ? priority : todo.priority, + dueDate: dueDate !== undefined ? (dueDate ? new Date(dueDate) : null) : todo.dueDate, + completedAt, + updatedAt: new Date(), + }) + .where(eq(todos.id, todoId)) + .returning(); + + return updated; +}; + +/** + * Delete todo + */ +export const deleteTodo = async (todoId) => { + await getTodoById(todoId); // Check if exists + + await db.delete(todos).where(eq(todos.id, todoId)); + + return { success: true, message: 'Todo bolo odstránené' }; +}; + +/** + * Get todo with related data (notes, project, company, assigned user) + */ +export const getTodoWithRelations = async (todoId) => { + const todo = await getTodoById(todoId); + + // Get project if exists + let project = null; + if (todo.projectId) { + [project] = await db + .select() + .from(projects) + .where(eq(projects.id, todo.projectId)) + .limit(1); + } + + // Get company if exists + let company = null; + if (todo.companyId) { + [company] = await db + .select() + .from(companies) + .where(eq(companies.id, todo.companyId)) + .limit(1); + } + + // Get assigned user if exists + let assignedUser = null; + if (todo.assignedTo) { + [assignedUser] = await db + .select({ + id: users.id, + username: users.username, + firstName: users.firstName, + lastName: users.lastName, + }) + .from(users) + .where(eq(users.id, todo.assignedTo)) + .limit(1); + } + + // Get related notes + const todoNotes = await db + .select() + .from(notes) + .where(eq(notes.todoId, todoId)) + .orderBy(desc(notes.createdAt)); + + return { + ...todo, + project, + company, + assignedUser, + notes: todoNotes, + }; +}; + +/** + * Get todos by project ID + */ +export const getTodosByProjectId = async (projectId) => { + return await db + .select() + .from(todos) + .where(eq(todos.projectId, projectId)) + .orderBy(desc(todos.createdAt)); +}; + +/** + * Get todos by company ID + */ +export const getTodosByCompanyId = async (companyId) => { + return await db + .select() + .from(todos) + .where(eq(todos.companyId, companyId)) + .orderBy(desc(todos.createdAt)); +}; + +/** + * Get todos assigned to a user + */ +export const getTodosByUserId = async (userId) => { + return await db + .select() + .from(todos) + .where(eq(todos.assignedTo, userId)) + .orderBy(desc(todos.createdAt)); +}; diff --git a/src/validators/crm.validators.js b/src/validators/crm.validators.js new file mode 100644 index 0000000..ba3e251 --- /dev/null +++ b/src/validators/crm.validators.js @@ -0,0 +1,107 @@ +import { z } from 'zod'; + +// Company validators +export const createCompanySchema = z.object({ + name: z + .string({ + required_error: 'Názov firmy je povinný', + }) + .min(1, 'Názov firmy nemôže byť prázdny') + .max(255, 'Názov firmy môže mať maximálne 255 znakov'), + description: z.string().max(1000).optional(), + address: z.string().max(255).optional(), + city: z.string().max(100).optional(), + country: z.string().max(100).optional(), + phone: z.string().max(50).optional(), + email: z.string().email('Neplatný formát emailu').max(255).optional().or(z.literal('')), + website: z.string().url('Neplatný formát URL').max(255).optional().or(z.literal('')), +}); + +export const updateCompanySchema = z.object({ + name: z.string().min(1).max(255).optional(), + description: z.string().max(1000).optional(), + address: z.string().max(255).optional(), + city: z.string().max(100).optional(), + country: z.string().max(100).optional(), + phone: z.string().max(50).optional(), + email: z.string().email('Neplatný formát emailu').max(255).optional().or(z.literal('')), + website: z.string().url('Neplatný formát URL').max(255).optional().or(z.literal('')), +}); + +// Project validators +export const createProjectSchema = z.object({ + name: z + .string({ + required_error: 'Názov projektu je povinný', + }) + .min(1, 'Názov projektu nemôže byť prázdny') + .max(255, 'Názov projektu môže mať maximálne 255 znakov'), + description: z.string().max(1000).optional(), + companyId: z.string().uuid('Neplatný formát company ID').optional().or(z.literal('')), + status: z.enum(['active', 'completed', 'on_hold', 'cancelled']).optional(), + startDate: z.string().optional().or(z.literal('')), + endDate: z.string().optional().or(z.literal('')), +}); + +export const updateProjectSchema = z.object({ + name: z.string().min(1).max(255).optional(), + description: z.string().max(1000).optional(), + companyId: z.string().uuid('Neplatný formát company ID').optional().or(z.literal('').or(z.null())), + status: z.enum(['active', 'completed', 'on_hold', 'cancelled']).optional(), + startDate: z.string().optional().or(z.literal('').or(z.null())), + endDate: z.string().optional().or(z.literal('').or(z.null())), +}); + +// Todo validators +export const createTodoSchema = z.object({ + title: z + .string({ + required_error: 'Názov todo je povinný', + }) + .min(1, 'Názov todo nemôže byť prázdny') + .max(255, 'Názov todo môže mať maximálne 255 znakov'), + description: z.string().max(1000).optional(), + projectId: z.string().uuid('Neplatný formát project ID').optional().or(z.literal('')), + companyId: z.string().uuid('Neplatný formát company ID').optional().or(z.literal('')), + assignedTo: z.string().uuid('Neplatný formát user ID').optional().or(z.literal('')), + status: z.enum(['pending', 'in_progress', 'completed', 'cancelled']).optional(), + priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(), + dueDate: z.string().optional().or(z.literal('')), +}); + +export const updateTodoSchema = z.object({ + title: z.string().min(1).max(255).optional(), + description: z.string().max(1000).optional(), + projectId: z.string().uuid('Neplatný formát project ID').optional().or(z.literal('').or(z.null())), + companyId: z.string().uuid('Neplatný formát company ID').optional().or(z.literal('').or(z.null())), + assignedTo: z.string().uuid('Neplatný formát user ID').optional().or(z.literal('').or(z.null())), + status: z.enum(['pending', 'in_progress', 'completed', 'cancelled']).optional(), + priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(), + dueDate: z.string().optional().or(z.literal('').or(z.null())), +}); + +// Note validators +export const createNoteSchema = z.object({ + title: z.string().max(255).optional(), + content: z + .string({ + required_error: 'Obsah poznámky je povinný', + }) + .min(1, 'Obsah poznámky nemôže byť prázdny') + .max(5000, 'Obsah poznámky môže mať maximálne 5000 znakov'), + companyId: z.string().uuid('Neplatný formát company ID').optional().or(z.literal('')), + projectId: z.string().uuid('Neplatný formát project ID').optional().or(z.literal('')), + todoId: z.string().uuid('Neplatný formát todo ID').optional().or(z.literal('')), + contactId: z.string().uuid('Neplatný formát contact ID').optional().or(z.literal('')), + reminderDate: z.string().optional().or(z.literal('')), +}); + +export const updateNoteSchema = z.object({ + title: z.string().max(255).optional().or(z.literal('').or(z.null())), + content: z.string().min(1).max(5000).optional(), + companyId: z.string().uuid('Neplatný formát company ID').optional().or(z.literal('').or(z.null())), + projectId: z.string().uuid('Neplatný formát project ID').optional().or(z.literal('').or(z.null())), + todoId: z.string().uuid('Neplatný formát todo ID').optional().or(z.literal('').or(z.null())), + contactId: z.string().uuid('Neplatný formát contact ID').optional().or(z.literal('').or(z.null())), + reminderDate: z.string().optional().or(z.literal('').or(z.null())), +}); diff --git a/uploads/timesheets/68927352-725c-4e95-adb6-d4002b22bef5/2024/11/Clockify_Time_Report_Weekly_03_11_2025-09_11_2025-1763709566832-767505558.pdf b/uploads/timesheets/68927352-725c-4e95-adb6-d4002b22bef5/2024/11/Clockify_Time_Report_Weekly_03_11_2025-09_11_2025-1763709566832-767505558.pdf deleted file mode 100644 index ad69b25..0000000 Binary files a/uploads/timesheets/68927352-725c-4e95-adb6-d4002b22bef5/2024/11/Clockify_Time_Report_Weekly_03_11_2025-09_11_2025-1763709566832-767505558.pdf and /dev/null differ diff --git a/uploads/timesheets/68927352-725c-4e95-adb6-d4002b22bef5/2024/11/demo (1)-1763709519358-520861577.xlsx b/uploads/timesheets/68927352-725c-4e95-adb6-d4002b22bef5/2024/11/demo (1)-1763709519358-520861577.xlsx deleted file mode 100644 index 9885400..0000000 Binary files a/uploads/timesheets/68927352-725c-4e95-adb6-d4002b22bef5/2024/11/demo (1)-1763709519358-520861577.xlsx and /dev/null differ