From 8656fb1db07fd3efe7f63e4e4874419fbde66c3c Mon Sep 17 00:00:00 2001 From: richardtekula Date: Fri, 12 Dec 2025 07:41:57 +0100 Subject: [PATCH] feat: Add creator info, team management for companies, and member access control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add creator info (username) to companies, projects, and notes responses - Add company_users table for team management on companies - Add resourceAccessMiddleware for member access control - Members can only see resources they are directly assigned to - Companies, projects, and todos are now filtered by user assignments - Add personal contacts feature 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- DOCUMENTATION.md | 1392 +++++++++++++++++ src/controllers/company.controller.js | 107 +- src/controllers/project.controller.js | 5 +- src/controllers/todo.controller.js | 5 +- src/db/schema.js | 12 + .../auth/resourceAccessMiddleware.js | 160 ++ src/routes/company.routes.js | 47 + src/routes/project.routes.js | 4 + src/routes/todo.routes.js | 13 +- src/services/company-reminder.service.js | 73 +- src/services/company.service.js | 241 ++- src/services/note.service.js | 61 +- src/services/project.service.js | 64 +- src/services/todo.service.js | 116 +- 14 files changed, 2175 insertions(+), 125 deletions(-) create mode 100644 DOCUMENTATION.md create mode 100644 src/middlewares/auth/resourceAccessMiddleware.js diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md new file mode 100644 index 0000000..6be64e3 --- /dev/null +++ b/DOCUMENTATION.md @@ -0,0 +1,1392 @@ +# CRM Server - Kompletná Dokumentácia + +## Obsah + +1. [Prehľad projektu](#1-prehľad-projektu) +2. [Štruktúra projektu](#2-štruktúra-projektu) +3. [Databázová schéma](#3-databázová-schéma) +4. [API Endpoints](#4-api-endpoints) +5. [Middlewares](#5-middlewares) +6. [Services](#6-services) +7. [Controllers](#7-controllers) +8. [Utility funkcie](#8-utility-funkcie) +9. [Validačné schémy](#9-validačné-schémy) +10. [Konfigurácia](#10-konfigurácia) +11. [Autentizácia a bezpečnosť](#11-autentizácia-a-bezpečnosť) + +--- + +## 1. Prehľad projektu + +CRM Server je backend aplikácia postavená na: +- **Express.js** - Web framework +- **Drizzle ORM** - Databázový ORM +- **PostgreSQL** - Databáza +- **JWT** - Autentizácia +- **Zod** - Validácia +- **JMAP** - Email integrácia + +### Hlavné funkcie: +- Multi-user CRM s role-based access control (admin/member) +- Email integrácia cez JMAP API +- Time tracking s generovaním timesheetov (XLSX) +- Contact management s linkovaním na firmy +- Company/Project management s teamami +- Todo/Task management s priorítami +- Audit logging všetkých akcií +- Company reminders s due dates +- Meeting scheduling + +--- + +## 2. Štruktúra projektu + +``` +crm-server/ +├── src/ +│ ├── config/ +│ │ └── database.js # Drizzle ORM + PostgreSQL konfigurácia +│ │ +│ ├── db/ +│ │ ├── schema.js # Kompletná databázová schéma +│ │ └── seeds/ +│ │ ├── admin.seed.js # Seed pre admin usera +│ │ └── testuser.seed.js # Seed pre testovacieho usera +│ │ +│ ├── controllers/ +│ │ ├── auth.controller.js +│ │ ├── admin.controller.js +│ │ ├── contact.controller.js +│ │ ├── personal-contact.controller.js +│ │ ├── company.controller.js +│ │ ├── company-reminder.controller.js +│ │ ├── project.controller.js +│ │ ├── todo.controller.js +│ │ ├── time-tracking.controller.js +│ │ ├── timesheet.controller.js +│ │ ├── meeting.controller.js +│ │ ├── note.controller.js +│ │ ├── crm-email.controller.js +│ │ ├── email-account.controller.js +│ │ └── audit.controller.js +│ │ +│ ├── services/ +│ │ ├── auth.service.js +│ │ ├── admin.service.js +│ │ ├── contact.service.js +│ │ ├── personal-contact.service.js +│ │ ├── company.service.js +│ │ ├── company-reminder.service.js +│ │ ├── company-email.service.js +│ │ ├── project.service.js +│ │ ├── todo.service.js +│ │ ├── time-tracking.service.js +│ │ ├── timesheet.service.js +│ │ ├── meeting.service.js +│ │ ├── note.service.js +│ │ ├── crm-email.service.js +│ │ ├── email-account.service.js +│ │ ├── email.service.js +│ │ ├── audit.service.js +│ │ ├── status.service.js +│ │ └── jmap/ +│ │ ├── index.js # Hlavný JMAP export +│ │ ├── client.js # JMAP klient +│ │ ├── config.js # JMAP konfigurácia +│ │ ├── discovery.js # JMAP discovery +│ │ ├── operations.js # JMAP operácie +│ │ ├── search.js # JMAP vyhľadávanie +│ │ └── sync.js # JMAP synchronizácia +│ │ +│ ├── routes/ +│ │ ├── auth.routes.js +│ │ ├── admin.routes.js +│ │ ├── contact.routes.js +│ │ ├── personal-contact.routes.js +│ │ ├── company.routes.js +│ │ ├── project.routes.js +│ │ ├── todo.routes.js +│ │ ├── time-tracking.routes.js +│ │ ├── timesheet.routes.js +│ │ ├── meeting.routes.js +│ │ ├── note.routes.js +│ │ ├── crm-email.routes.js +│ │ ├── email-account.routes.js +│ │ └── audit.routes.js +│ │ +│ ├── middlewares/ +│ │ ├── auth/ +│ │ │ ├── authMiddleware.js # JWT overenie +│ │ │ ├── roleMiddleware.js # Role overenie +│ │ │ └── resourceAccessMiddleware.js # Resource access control +│ │ ├── security/ +│ │ │ ├── rateLimiter.js # Rate limiting +│ │ │ ├── validateInput.js # Input validácia +│ │ │ └── requireAccountId.js # Email account check +│ │ └── global/ +│ │ ├── errorHandler.js # Error handling +│ │ ├── validateBody.js # Body validation +│ │ └── notFound.js # 404 handler +│ │ +│ ├── utils/ +│ │ ├── errors.js # Custom error classes +│ │ ├── jwt.js # JWT management +│ │ ├── password.js # Password & encryption +│ │ └── logger.js # Logger utility +│ │ +│ ├── validators/ +│ │ ├── auth.validators.js # Auth schémy (Zod) +│ │ ├── crm.validators.js # CRM schémy (Zod) +│ │ └── email-account.validators.js +│ │ +│ ├── scripts/ +│ │ ├── seed-admin.js +│ │ ├── fresh-database.js +│ │ └── fix-wrong-contact-associations.js +│ │ +│ ├── app.js # Express aplikácia +│ └── index.js # Entry point +│ +├── uploads/ # Nahrané súbory (timesheets) +├── __tests__/ # Testy +├── package.json +├── drizzle.config.js +├── Dockerfile +└── .env +``` + +--- + +## 3. Databázová schéma + +### Enums + +```sql +-- Role používateľa +CREATE TYPE role AS ENUM ('admin', 'member'); + +-- Status projektu +CREATE TYPE project_status AS ENUM ('active', 'completed', 'on_hold', 'cancelled'); + +-- Status úlohy +CREATE TYPE todo_status AS ENUM ('pending', 'in_progress', 'completed', 'cancelled'); + +-- Priorita úlohy +CREATE TYPE todo_priority AS ENUM ('low', 'medium', 'high', 'urgent'); +``` + +### Tabuľky + +#### users +Používatelia systému. + +| Stĺpec | Typ | Popis | +|--------|-----|-------| +| id | UUID | Primárny kľúč | +| username | VARCHAR(50) | Unikátne užívateľské meno | +| firstName | VARCHAR(100) | Krstné meno | +| lastName | VARCHAR(100) | Priezvisko | +| password | TEXT | Hash hesla | +| tempPassword | TEXT | Dočasné heslo | +| changedPassword | BOOLEAN | Či si zmenil heslo | +| role | ENUM | 'admin' alebo 'member' | +| lastLogin | TIMESTAMP | Posledné prihlásenie | +| createdAt | TIMESTAMP | Dátum vytvorenia | +| updatedAt | TIMESTAMP | Dátum aktualizácie | + +#### email_accounts +Zdieľané email účty. + +| Stĺpec | Typ | Popis | +|--------|-----|-------| +| id | UUID | Primárny kľúč | +| email | VARCHAR(255) | Email adresa | +| emailPassword | TEXT | Šifrované heslo (AES-256-GCM) | +| jmapAccountId | TEXT | JMAP account ID | +| isActive | BOOLEAN | Či je účet aktívny | +| createdAt | TIMESTAMP | Dátum vytvorenia | +| updatedAt | TIMESTAMP | Dátum aktualizácie | + +#### user_email_accounts +M2M väzba: users ↔ email_accounts + +| Stĺpec | Typ | Popis | +|--------|-----|-------| +| id | UUID | Primárny kľúč | +| userId | UUID | FK → users.id | +| emailAccountId | UUID | FK → email_accounts.id | +| isPrimary | BOOLEAN | Či je primárny účet | +| addedAt | TIMESTAMP | Dátum pridania | + +#### companies +Firmy/spoločnosti. + +| Stĺpec | Typ | Popis | +|--------|-----|-------| +| id | UUID | Primárny kľúč | +| name | VARCHAR(255) | Názov firmy | +| description | TEXT | Popis | +| address | TEXT | Adresa | +| city | VARCHAR(100) | Mesto | +| country | VARCHAR(100) | Krajina | +| phone | VARCHAR(50) | Telefón | +| email | VARCHAR(255) | Email | +| website | VARCHAR(255) | Web stránka | +| isActive | BOOLEAN | Či je aktívna | +| createdBy | UUID | FK → users.id | +| createdAt | TIMESTAMP | Dátum vytvorenia | +| updatedAt | TIMESTAMP | Dátum aktualizácie | + +#### company_users +M2M väzba: companies ↔ users (tím firmy) + +| Stĺpec | Typ | Popis | +|--------|-----|-------| +| id | UUID | Primárny kľúč | +| companyId | UUID | FK → companies.id (CASCADE) | +| userId | UUID | FK → users.id (CASCADE) | +| role | TEXT | Rola v tíme | +| addedBy | UUID | FK → users.id (SET NULL) | +| addedAt | TIMESTAMP | Dátum pridania | + +**Unique constraint:** (companyId, userId) + +#### company_remind +Pripomienky firmy. + +| Stĺpec | Typ | Popis | +|--------|-----|-------| +| id | UUID | Primárny kľúč | +| companyId | UUID | FK → companies.id (CASCADE) | +| description | TEXT | Popis pripomienky | +| dueDate | TIMESTAMP | Termín | +| isChecked | BOOLEAN | Či je splnená | +| createdAt | TIMESTAMP | Dátum vytvorenia | +| updatedAt | TIMESTAMP | Dátum aktualizácie | + +#### projects +Projekty. + +| Stĺpec | Typ | Popis | +|--------|-----|-------| +| id | UUID | Primárny kľúč | +| name | VARCHAR(255) | Názov projektu | +| description | TEXT | Popis | +| companyId | UUID | FK → companies.id (SET NULL) | +| status | ENUM | Status projektu | +| startDate | DATE | Dátum začiatku | +| endDate | DATE | Dátum konca | +| createdBy | UUID | FK → users.id | +| createdAt | TIMESTAMP | Dátum vytvorenia | +| updatedAt | TIMESTAMP | Dátum aktualizácie | + +#### project_users +M2M väzba: projects ↔ users (tím projektu) + +| Stĺpec | Typ | Popis | +|--------|-----|-------| +| id | UUID | Primárny kľúč | +| projectId | UUID | FK → projects.id (CASCADE) | +| userId | UUID | FK → users.id (CASCADE) | +| role | TEXT | Rola v projekte | +| addedBy | UUID | FK → users.id (SET NULL) | +| addedAt | TIMESTAMP | Dátum pridania | + +**Unique constraint:** (projectId, userId) + +#### todos +Úlohy/tasky. + +| Stĺpec | Typ | Popis | +|--------|-----|-------| +| id | UUID | Primárny kľúč | +| title | VARCHAR(255) | Názov úlohy | +| description | TEXT | Popis | +| projectId | UUID | FK → projects.id (SET NULL) | +| companyId | UUID | FK → companies.id (SET NULL) | +| status | ENUM | Status úlohy | +| priority | ENUM | Priorita | +| dueDate | TIMESTAMP | Termín | +| completedAt | TIMESTAMP | Dátum dokončenia | +| createdBy | UUID | FK → users.id | +| createdAt | TIMESTAMP | Dátum vytvorenia | +| updatedAt | TIMESTAMP | Dátum aktualizácie | + +#### todo_users +M2M väzba: todos ↔ users (priradení k úlohe) + +| Stĺpec | Typ | Popis | +|--------|-----|-------| +| id | UUID | Primárny kľúč | +| todoId | UUID | FK → todos.id (CASCADE) | +| userId | UUID | FK → users.id (CASCADE) | +| assignedBy | UUID | FK → users.id (SET NULL) | +| assignedAt | TIMESTAMP | Dátum priradenia | + +**Unique constraint:** (todoId, userId) + +#### contacts +Kontakty (patriace email účtu). + +| Stĺpec | Typ | Popis | +|--------|-----|-------| +| id | UUID | Primárny kľúč | +| emailAccountId | UUID | FK → email_accounts.id (CASCADE) | +| companyId | UUID | FK → companies.id (SET NULL) | +| email | VARCHAR(255) | Email kontaktu | +| name | VARCHAR(255) | Meno kontaktu | +| notes | TEXT | Poznámky | +| addedBy | UUID | FK → users.id | +| addedAt | TIMESTAMP | Dátum pridania | +| createdAt | TIMESTAMP | Dátum vytvorenia | +| updatedAt | TIMESTAMP | Dátum aktualizácie | + +#### personal_contacts +Osobné kontakty používateľa. + +| Stĺpec | Typ | Popis | +|--------|-----|-------| +| id | UUID | Primárny kľúč | +| userId | UUID | FK → users.id (CASCADE) | +| firstName | VARCHAR(100) | Krstné meno | +| lastName | VARCHAR(100) | Priezvisko | +| phone | VARCHAR(50) | Telefón | +| email | VARCHAR(255) | Email | +| secondaryEmail | VARCHAR(255) | Sekundárny email | +| createdAt | TIMESTAMP | Dátum vytvorenia | +| updatedAt | TIMESTAMP | Dátum aktualizácie | + +#### emails +Uložené emaily z JMAP. + +| Stĺpec | Typ | Popis | +|--------|-----|-------| +| id | UUID | Primárny kľúč | +| emailAccountId | UUID | FK → email_accounts.id (CASCADE) | +| contactId | UUID | FK → contacts.id (SET NULL) | +| companyId | UUID | FK → companies.id (SET NULL) | +| jmapId | TEXT | JMAP ID emailu | +| messageId | TEXT | Message-ID header | +| threadId | TEXT | JMAP thread ID | +| inReplyTo | TEXT | In-Reply-To header | +| from | JSONB | Odosielateľ | +| to | JSONB | Príjemcovia | +| subject | TEXT | Predmet | +| body | TEXT | Telo emailu | +| isRead | BOOLEAN | Či je prečítaný | +| sentByUserId | UUID | FK → users.id (ak odoslané) | +| date | TIMESTAMP | Dátum emailu | +| createdAt | TIMESTAMP | Dátum vytvorenia | +| updatedAt | TIMESTAMP | Dátum aktualizácie | + +#### notes +Poznámky (pripojené k rôznym entitám). + +| Stĺpec | Typ | Popis | +|--------|-----|-------| +| id | UUID | Primárny kľúč | +| title | VARCHAR(255) | Titulok | +| content | TEXT | Obsah poznámky | +| companyId | UUID | FK → companies.id (SET NULL) | +| projectId | UUID | FK → projects.id (SET NULL) | +| todoId | UUID | FK → todos.id (SET NULL) | +| contactId | UUID | FK → contacts.id (SET NULL) | +| reminderAt | TIMESTAMP | Čas pripomienky | +| reminderSent | BOOLEAN | Či bola pripomienka odoslaná | +| createdBy | UUID | FK → users.id | +| createdAt | TIMESTAMP | Dátum vytvorenia | +| updatedAt | TIMESTAMP | Dátum aktualizácie | + +#### time_entries +Sledovanie času. + +| Stĺpec | Typ | Popis | +|--------|-----|-------| +| id | UUID | Primárny kľúč | +| userId | UUID | FK → users.id (CASCADE) | +| projectId | UUID | FK → projects.id (SET NULL) | +| todoId | UUID | FK → todos.id (SET NULL) | +| companyId | UUID | FK → companies.id (SET NULL) | +| startTime | TIMESTAMP | Čas začiatku | +| endTime | TIMESTAMP | Čas konca | +| duration | INTEGER | Trvanie v minútach | +| description | TEXT | Popis práce | +| isRunning | BOOLEAN | Či práve beží | +| isEdited | BOOLEAN | Či bol upravený | +| createdAt | TIMESTAMP | Dátum vytvorenia | +| updatedAt | TIMESTAMP | Dátum aktualizácie | + +#### timesheets +Nahrané timesheets. + +| Stĺpec | Typ | Popis | +|--------|-----|-------| +| id | UUID | Primárny kľúč | +| userId | UUID | FK → users.id (CASCADE) | +| projectId | UUID | FK → projects.id (SET NULL) | +| fileName | VARCHAR(255) | Názov súboru | +| filePath | TEXT | Cesta k súboru | +| fileType | VARCHAR(10) | 'pdf' alebo 'xlsx' | +| fileSize | INTEGER | Veľkosť v bytoch | +| year | INTEGER | Rok | +| month | INTEGER | Mesiac | +| isGenerated | BOOLEAN | Či bol generovaný | +| uploadedAt | TIMESTAMP | Dátum nahratia | +| createdAt | TIMESTAMP | Dátum vytvorenia | +| updatedAt | TIMESTAMP | Dátum aktualizácie | + +#### meetings +Stretnutia (iba admin). + +| Stĺpec | Typ | Popis | +|--------|-----|-------| +| id | UUID | Primárny kľúč | +| title | VARCHAR(255) | Názov stretnutia | +| description | TEXT | Popis | +| start | TIMESTAMP | Čas začiatku | +| end | TIMESTAMP | Čas konca | +| createdBy | UUID | FK → users.id | +| createdAt | TIMESTAMP | Dátum vytvorenia | +| updatedAt | TIMESTAMP | Dátum aktualizácie | + +#### audit_logs +Audit trail všetkých akcií. + +| Stĺpec | Typ | Popis | +|--------|-----|-------| +| id | UUID | Primárny kľúč | +| userId | UUID | FK → users.id (SET NULL) | +| action | VARCHAR(100) | Typ akcie | +| resource | VARCHAR(100) | Typ resource | +| resourceId | UUID | ID resource | +| oldValue | JSONB | Stará hodnota | +| newValue | JSONB | Nová hodnota | +| ipAddress | VARCHAR(45) | IP adresa | +| userAgent | TEXT | User agent | +| success | BOOLEAN | Či bola úspešná | +| errorMessage | TEXT | Chybová správa | +| createdAt | TIMESTAMP | Dátum vytvorenia | + +--- + +## 4. API Endpoints + +### Formát Response + +**Success:** +```json +{ + "success": true, + "data": { }, + "count": 10, + "message": "Akcia úspešná" +} +``` + +**Error:** +```json +{ + "success": false, + "error": { + "message": "Chybová správa", + "statusCode": 400, + "details": [ + { "field": "username", "message": "Povinné pole" } + ] + } +} +``` + +### Authentication (`/api/auth`) + +| Metóda | Endpoint | Popis | Auth | Rate Limit | +|--------|----------|-------|------|------------| +| POST | `/login` | Prihlásenie | Nie | 5/15min | +| POST | `/set-password` | Nastavenie hesla | Áno | - | +| POST | `/logout` | Odhlásenie | Áno | - | +| GET | `/session` | Aktuálna session | Áno | - | + +### Admin (`/api/admin`) + +| Metóda | Endpoint | Popis | Auth | Role | +|--------|----------|-------|------|------| +| GET | `/users` | Zoznam userov | Áno | Všetci | +| POST | `/users` | Vytvoriť usera | Áno | Admin | +| GET | `/users/:userId` | Detail usera | Áno | Admin | +| PATCH | `/users/:userId/role` | Zmeniť rolu | Áno | Admin | +| DELETE | `/users/:userId` | Zmazať usera | Áno | Admin | +| GET | `/server-status` | Server status | Áno | Admin | + +### Companies (`/api/companies`) + +| Metóda | Endpoint | Popis | Auth | Role | +|--------|----------|-------|------|------| +| GET | `/` | Zoznam firiem | Áno | Všetci* | +| GET | `/:companyId` | Detail firmy | Áno | Access | +| POST | `/` | Vytvoriť firmu | Áno | Admin | +| PATCH | `/:companyId` | Upraviť firmu | Áno | Admin | +| DELETE | `/:companyId` | Zmazať firmu | Áno | Admin | +| GET | `/reminders/summary` | Súhrn pripomienok | Áno | Všetci* | +| GET | `/reminders/counts` | Počty pripomienok | Áno | Všetci* | +| GET | `/reminders/upcoming` | Budúce pripomienky | Áno | Všetci* | +| GET | `/email-unread` | Neprečítané emaily | Áno | Všetci | +| GET | `/:companyId/email-threads` | Email vlákna | Áno | Access | + +**Company Notes:** + +| Metóda | Endpoint | Popis | Auth | Role | +|--------|----------|-------|------|------| +| GET | `/:companyId/notes` | Poznámky firmy | Áno | Access | +| POST | `/:companyId/notes` | Pridať poznámku | Áno | Admin | +| PATCH | `/:companyId/notes/:noteId` | Upraviť poznámku | Áno | Admin | +| DELETE | `/:companyId/notes/:noteId` | Zmazať poznámku | Áno | Admin | + +**Company Reminders:** + +| Metóda | Endpoint | Popis | Auth | Role | +|--------|----------|-------|------|------| +| GET | `/:companyId/reminders` | Pripomienky firmy | Áno | Access | +| POST | `/:companyId/reminders` | Vytvoriť | Áno | Admin | +| PATCH | `/:companyId/reminders/:reminderId` | Upraviť | Áno | Admin | +| DELETE | `/:companyId/reminders/:reminderId` | Zmazať | Áno | Admin | + +**Company Users (Team):** + +| Metóda | Endpoint | Popis | Auth | Role | +|--------|----------|-------|------|------| +| GET | `/:companyId/users` | Členovia tímu | Áno | Access | +| POST | `/:companyId/users` | Pridať člena | Áno | Admin | +| PATCH | `/:companyId/users/:userId` | Upraviť rolu | Áno | Admin | +| DELETE | `/:companyId/users/:userId` | Odstrániť člena | Áno | Admin | + +*\* Member vidí len firmy kde je priradený* + +### Projects (`/api/projects`) + +| Metóda | Endpoint | Popis | Auth | Role | +|--------|----------|-------|------|------| +| GET | `/` | Zoznam projektov | Áno | Všetci* | +| GET | `/:projectId` | Detail projektu | Áno | Access | +| POST | `/` | Vytvoriť projekt | Áno | Admin | +| PATCH | `/:projectId` | Upraviť projekt | Áno | Admin | +| DELETE | `/:projectId` | Zmazať projekt | Áno | Admin | + +**Project Notes:** + +| Metóda | Endpoint | Popis | Auth | Role | +|--------|----------|-------|------|------| +| GET | `/:projectId/notes` | Poznámky projektu | Áno | Access | +| POST | `/:projectId/notes` | Pridať poznámku | Áno | Admin | +| PATCH | `/:projectId/notes/:noteId` | Upraviť | Áno | Admin | +| DELETE | `/:projectId/notes/:noteId` | Zmazať | Áno | Admin | + +**Project Users (Team):** + +| Metóda | Endpoint | Popis | Auth | Role | +|--------|----------|-------|------|------| +| GET | `/:projectId/users` | Členovia projektu | Áno | Access | +| POST | `/:projectId/users` | Pridať člena | Áno | Admin | +| PATCH | `/:projectId/users/:userId` | Upraviť rolu | Áno | Admin | +| DELETE | `/:projectId/users/:userId` | Odstrániť člena | Áno | Admin | + +*\* Member vidí len projekty kde je priradený alebo projekty firiem kde je priradený* + +### Todos (`/api/todos`) + +| Metóda | Endpoint | Popis | Auth | Role | +|--------|----------|-------|------|------| +| GET | `/` | Zoznam úloh | Áno | Všetci* | +| GET | `/:todoId` | Detail úlohy | Áno | Access | +| POST | `/` | Vytvoriť úlohu | Áno | Admin | +| PATCH | `/:todoId` | Upraviť úlohu | Áno | Admin | +| DELETE | `/:todoId` | Zmazať úlohu | Áno | Admin | +| PATCH | `/:todoId/toggle` | Prepnúť stav | Áno | Access | + +*\* Member vidí len úlohy kde je priradený* + +**Query parametre pre GET /:** +- `search` - Vyhľadávanie v title/description +- `projectId` - Filter podľa projektu +- `companyId` - Filter podľa firmy +- `assignedTo` - Filter podľa priradeného usera +- `status` - Filter podľa statusu (pending/in_progress/completed/cancelled) +- `completed` - true/false (alternatíva k status) +- `priority` - Filter podľa priority (low/medium/high/urgent) + +### Contacts (`/api/contacts`) + +| Metóda | Endpoint | Popis | Auth | Role | +|--------|----------|-------|------|------| +| GET | `/` | Zoznam kontaktov | Áno | Všetci | +| GET | `/discover` | Objaviť z JMAP | Áno | Všetci | +| POST | `/` | Pridať kontakt | Áno | Všetci | +| PATCH | `/:contactId` | Upraviť kontakt | Áno | Všetci | +| POST | `/:contactId/link-company` | Pripojiť firmu | Áno | Všetci | +| POST | `/:contactId/unlink-company` | Odpojiť firmu | Áno | Všetci | +| POST | `/:contactId/create-company` | Vytvoriť firmu | Áno | Všetci | +| DELETE | `/:contactId` | Zmazať kontakt | Áno | Všetci | + +### Personal Contacts (`/api/personal-contacts`) + +| Metóda | Endpoint | Popis | Auth | Role | +|--------|----------|-------|------|------| +| GET | `/` | Moje osobné kontakty | Áno | Všetci | +| POST | `/` | Vytvoriť | Áno | Všetci | +| PUT | `/:contactId` | Upraviť | Áno | Všetci | +| DELETE | `/:contactId` | Zmazať | Áno | Všetci | + +### Time Tracking (`/api/time-tracking`) + +| Metóda | Endpoint | Popis | Auth | Role | +|--------|----------|-------|------|------| +| POST | `/start` | Spustiť tracking | Áno | Všetci | +| POST | `/:entryId/stop` | Zastaviť tracking | Áno | Všetci | +| GET | `/running` | Bežiaci entry | Áno | Všetci | +| GET | `/running-all` | Všetky bežiace | Áno | Všetci | +| GET | `/` | Zoznam entries | Áno | Všetci | +| GET | `/month/:year/:month` | Mesačné entries | Áno | Všetci | +| POST | `/month/:year/:month/generate` | Generovať XLSX | Áno | Všetci | +| GET | `/stats/monthly/:year/:month` | Mesačné štatistiky | Áno | Všetci | +| GET | `/:entryId` | Detail entry | Áno | Všetci | +| GET | `/:entryId/details` | Entry s reláciami | Áno | Všetci | +| PATCH | `/:entryId` | Upraviť entry | Áno | Všetci | +| DELETE | `/:entryId` | Zmazať entry | Áno | Všetci | + +### Timesheets (`/api/timesheets`) + +| Metóda | Endpoint | Popis | Auth | Role | +|--------|----------|-------|------|------| +| POST | `/upload` | Nahrať timesheet | Áno | Všetci | +| GET | `/my` | Moje timesheets | Áno | Všetci | +| GET | `/all` | Všetky timesheets | Áno | Admin | +| GET | `/:timesheetId/download` | Stiahnuť súbor | Áno | Všetci | +| DELETE | `/:timesheetId` | Zmazať | Áno | Všetci | + +### Meetings (`/api/meetings`) + +| Metóda | Endpoint | Popis | Auth | Role | +|--------|----------|-------|------|------| +| GET | `/` | Zoznam meetingov | Áno | Všetci | +| GET | `/:meetingId` | Detail meetingu | Áno | Všetci | +| POST | `/` | Vytvoriť meeting | Áno | Admin | +| PUT | `/:meetingId` | Upraviť meeting | Áno | Admin | +| DELETE | `/:meetingId` | Zmazať meeting | Áno | Admin | + +**Query parametre pre GET /:** +- `year` - Rok +- `month` - Mesiac + +### Notes (`/api/notes`) + +| Metóda | Endpoint | Popis | Auth | Role | +|--------|----------|-------|------|------| +| GET | `/` | Zoznam poznámok | Áno | Všetci | +| GET | `/my-reminders` | Moje pripomienky | Áno | Všetci | +| GET | `/:noteId` | Detail poznámky | Áno | Všetci | +| POST | `/` | Vytvoriť poznámku | Áno | Všetci | +| PATCH | `/:noteId` | Upraviť poznámku | Áno | Všetci | +| DELETE | `/:noteId` | Zmazať poznámku | Áno | Všetci | +| POST | `/:noteId/mark-reminder-sent` | Označiť odoslanú | Áno | Všetci | + +### CRM Emails (`/api/emails`) + +| Metóda | Endpoint | Popis | Auth | Role | +|--------|----------|-------|------|------| +| GET | `/` | Zoznam emailov | Áno | Všetci | +| GET | `/search` | Vyhľadávanie v DB | Áno | Všetci | +| GET | `/search-jmap` | JMAP full-text | Áno | Všetci | +| GET | `/unread-count` | Počet neprečítaných | Áno | Všetci | +| POST | `/sync` | Synchronizovať z JMAP | Áno | Všetci | +| GET | `/thread/:threadId` | Email vlákno | Áno | Všetci | +| POST | `/thread/:threadId/read` | Označiť prečítané | Áno | Všetci | +| POST | `/contact/:contactId/read` | Označiť od kontaktu | Áno | Všetci | +| POST | `/reply` | Odpovedať na email | Áno | Všetci | + +**Väčšina vyžaduje `accountId` v query parametroch.** + +### Email Accounts (`/api/email-accounts`) + +| Metóda | Endpoint | Popis | Auth | Role | Rate Limit | +|--------|----------|-------|------|------|------------| +| GET | `/` | Moje email účty | Áno | Všetci | - | +| POST | `/` | Vytvoriť účet | Áno | Všetci | Sensitive | +| POST | `/:id/set-primary` | Nastaviť primárny | Áno | Všetci | - | +| DELETE | `/:id` | Zmazať účet | Áno | Všetci | Sensitive | + +### Audit Logs (`/api/audit-logs`) + +| Metóda | Endpoint | Popis | Auth | Role | +|--------|----------|-------|------|------| +| GET | `/` | Nedávne audit logy | Áno | Všetci | + +### Health Check + +| Metóda | Endpoint | Popis | Auth | +|--------|----------|-------|------| +| GET | `/health` | Health check | Nie | +| GET | `/` | API info | Nie | + +--- + +## 5. Middlewares + +### Auth Middlewares (`/middlewares/auth/`) + +#### authMiddleware.js + +```javascript +authenticate(req, res, next) +``` +- Overí JWT token z Authorization header alebo cookies +- Načíta aktuálne user data z DB +- Pridá `req.user` a `req.userId` +- Vracia 401 ak token chýba/je neplatný/expiroval + +#### roleMiddleware.js + +```javascript +requireRole(...allowedRoles) +``` +- Overí či má user jednu z povolených rolí +- Vracia 403 ak nemá oprávnenie + +```javascript +requireAdmin +``` +- Skratka pre `requireRole('admin')` + +```javascript +requireOwnerOrAdmin(getResourceUserId) +``` +- Admin má vždy prístup +- Inak overí ownership cez callback funkciu + +#### resourceAccessMiddleware.js + +```javascript +checkResourceAccess(resourceType, paramName) +``` +- Univerzálny middleware pre kontrolu prístupu k resources +- Admin má prístup vždy +- Member len ak je priradený k resource + +```javascript +checkCompanyAccess // = checkResourceAccess('company', 'companyId') +checkProjectAccess // = checkResourceAccess('project', 'projectId') +checkTodoAccess // = checkResourceAccess('todo', 'todoId') +``` + +```javascript +getAccessibleResourceIds(resourceType, userId) +``` +- Vráti zoznam resource IDs ku ktorým má user prístup +- Použité v service vrstvách pre filtrovanie + +```javascript +hasAccessToResource(resourceType, userId, resourceId) +``` +- Skontroluje či user má prístup k danému resource + +```javascript +canAccessResource(resourceType, userId, resourceId, userRole) +``` +- Kombinácia role check + access check + +**Podporované resource typy:** +| Resource Type | Junction Table | Resource ID Column | +|--------------|----------------|-------------------| +| company | company_users | companyId | +| project | project_users | projectId | +| todo | todo_users | todoId | + +### Security Middlewares (`/middlewares/security/`) + +#### rateLimiter.js + +```javascript +loginRateLimiter +``` +- Login endpoint: 5 pokusov za 15 minút + +```javascript +apiRateLimiter +``` +- Všeobecné API: 1000 (dev) / 100 (prod) za 15 minút + +```javascript +sensitiveOperationLimiter +``` +- Citlivé operácie: 50 (dev) / 10 (prod) za 15 minút + +#### validateInput.js + +```javascript +validateBody(schema) +``` +- Validuje request body podľa Zod schémy + +```javascript +validateQuery(schema) +``` +- Validuje query parametre + +```javascript +validateParams(schema) +``` +- Validuje URL parametre + +Všetky vracajú 400 s detailmi chýb: +```json +{ + "success": false, + "error": { + "message": "Validačná chyba", + "statusCode": 400, + "details": [ + { "field": "email", "message": "Neplatný email" } + ] + } +} +``` + +#### requireAccountId.js + +```javascript +requireAccountId(req, res, next) +``` +- Kontroluje či má user `accountId` v query +- Potrebné pre email operácie + +### Global Middlewares (`/middlewares/global/`) + +#### errorHandler.js + +```javascript +errorHandler(err, req, res, next) +``` +- Globálny error handler (posledný middleware) +- Formátuje error response +- Loguje chyby +- Vracia príslušný status code + +#### validateBody.js + +```javascript +validateBodyMiddleware(req, res, next) +``` +- Overí či je request body valid JSON + +#### notFound.js + +```javascript +notFoundHandler(req, res) +``` +- Handler pre 404 (neexistujúce routes) + +--- + +## 6. Services + +### auth.service.js + +| Funkcia | Popis | +|---------|-------| +| `login(username, password, ip, userAgent)` | Prihlásenie s temp/permanent heslom | +| `setPassword(userId, newPassword)` | Nastavenie nového hesla | +| `logout(userId)` | Odhlásenie (audit log) | +| `getUserById(userId)` | Získať usera podľa ID | + +### admin.service.js + +| Funkcia | Popis | +|---------|-------| +| `getAllUsers()` | Zoznam všetkých userov | +| `getUserById(userId)` | Detail usera | +| `createUser(data)` | Vytvoriť usera s temp heslom | +| `updateUserRole(userId, role)` | Zmeniť rolu usera | +| `deleteUser(userId)` | Zmazať usera | + +### company.service.js + +| Funkcia | Popis | +|---------|-------| +| `getAllCompanies(searchTerm, userId, userRole)` | Zoznam firiem (filtrované pre membera) | +| `getCompanyById(companyId)` | Detail firmy | +| `createCompany(data, userId)` | Vytvoriť firmu | +| `updateCompany(companyId, data)` | Upraviť firmu | +| `deleteCompany(companyId)` | Zmazať firmu | +| `getCompanyUsers(companyId)` | Členovia tímu | +| `addUserToCompany(companyId, userId, role, addedBy)` | Pridať člena | +| `updateUserRoleInCompany(companyId, userId, role)` | Upraviť rolu | +| `removeUserFromCompany(companyId, userId)` | Odstrániť člena | + +### company-reminder.service.js + +| Funkcia | Popis | +|---------|-------| +| `getRemindersByCompanyId(companyId)` | Pripomienky firmy | +| `createReminder(companyId, data)` | Vytvoriť pripomienku | +| `updateReminder(reminderId, data)` | Upraviť pripomienku | +| `deleteReminder(reminderId)` | Zmazať pripomienku | +| `getReminderSummary(userId, userRole)` | Súhrn pripomienok (filtrované) | +| `getReminderCountsByCompany(userId, userRole)` | Počty podľa firmy (filtrované) | +| `getUpcomingReminders(limit, userId, userRole)` | Budúce pripomienky (filtrované) | + +### project.service.js + +| Funkcia | Popis | +|---------|-------| +| `getAllProjects(searchTerm, companyId, userId, userRole)` | Zoznam projektov (filtrované) | +| `getProjectById(projectId)` | Detail projektu | +| `createProject(data, userId)` | Vytvoriť projekt | +| `updateProject(projectId, data)` | Upraviť projekt | +| `deleteProject(projectId)` | Zmazať projekt | +| `getProjectUsers(projectId)` | Členovia projektu | +| `assignUserToProject(projectId, userId, role, addedBy)` | Pridať člena | +| `updateProjectUserRole(projectId, userId, role)` | Upraviť rolu | +| `removeUserFromProject(projectId, userId)` | Odstrániť člena | + +### todo.service.js + +| Funkcia | Popis | +|---------|-------| +| `getAllTodos(filters, userId, userRole)` | Zoznam úloh (filtrované) | +| `getTodoById(todoId)` | Detail úlohy | +| `createTodo(userId, data)` | Vytvoriť úlohu | +| `updateTodo(todoId, data)` | Upraviť úlohu | +| `deleteTodo(todoId)` | Zmazať úlohu | +| `getTodoWithRelations(todoId)` | Úloha s reláciami | +| `getTodosByProjectId(projectId)` | Úlohy projektu | +| `getTodosByCompanyId(companyId)` | Úlohy firmy | +| `getTodosByUserId(userId)` | Úlohy usera | + +### contact.service.js + +| Funkcia | Popis | +|---------|-------| +| `getAllContacts(accountId, search, companyId)` | Zoznam kontaktov | +| `getContactById(contactId)` | Detail kontaktu | +| `createContact(data)` | Vytvoriť kontakt | +| `updateContact(contactId, data)` | Upraviť kontakt | +| `deleteContact(contactId)` | Zmazať kontakt | +| `linkCompanyToContact(contactId, companyId)` | Pripojiť firmu | +| `unlinkCompanyFromContact(contactId)` | Odpojiť firmu | + +### personal-contact.service.js + +| Funkcia | Popis | +|---------|-------| +| `getPersonalContacts(userId)` | Osobné kontakty usera | +| `createPersonalContact(userId, data)` | Vytvoriť | +| `updatePersonalContact(contactId, userId, data)` | Upraviť | +| `deletePersonalContact(contactId, userId)` | Zmazať | + +### time-tracking.service.js + +| Funkcia | Popis | +|---------|-------| +| `startTimeEntry(userId, data)` | Spustiť tracking | +| `stopTimeEntry(entryId, endTime)` | Zastaviť tracking | +| `getRunningEntry(userId)` | Bežiaci entry usera | +| `getAllRunningEntries()` | Všetky bežiace entries | +| `getTimeEntries(filters)` | Zoznam entries | +| `getTimeEntryById(entryId)` | Detail entry | +| `getTimeEntryWithRelations(entryId)` | Entry s reláciami | +| `updateTimeEntry(entryId, data)` | Upraviť entry | +| `deleteTimeEntry(entryId)` | Zmazať entry | +| `getMonthlyEntries(userId, year, month)` | Mesačné entries | +| `getMonthlyStats(userId, year, month)` | Mesačné štatistiky | +| `generateMonthlyTimesheet(userId, year, month)` | Generovať XLSX | + +### timesheet.service.js + +| Funkcia | Popis | +|---------|-------| +| `uploadTimesheet(userId, file, year, month, projectId)` | Nahrať | +| `getMyTimesheets(userId)` | Moje timesheets | +| `getAllTimesheets()` | Všetky timesheets | +| `getTimesheetById(timesheetId)` | Detail | +| `deleteTimesheet(timesheetId, userId)` | Zmazať | + +### meeting.service.js + +| Funkcia | Popis | +|---------|-------| +| `getMeetingsByMonth(year, month)` | Meetingy v mesiaci | +| `getMeetingById(meetingId)` | Detail meetingu | +| `createMeeting(data, userId)` | Vytvoriť | +| `updateMeeting(meetingId, data)` | Upraviť | +| `deleteMeeting(meetingId)` | Zmazať | + +### note.service.js + +| Funkcia | Popis | +|---------|-------| +| `getAllNotes(filters)` | Zoznam poznámok | +| `getNoteById(noteId)` | Detail poznámky | +| `createNote(data, userId)` | Vytvoriť | +| `updateNote(noteId, data)` | Upraviť | +| `deleteNote(noteId)` | Zmazať | +| `getMyReminders(userId)` | Moje pripomienky | +| `markReminderSent(noteId)` | Označiť odoslanú | + +### crm-email.service.js + +| Funkcia | Popis | +|---------|-------| +| `getEmails(accountId, filters)` | Zoznam emailov | +| `searchEmails(accountId, query)` | Vyhľadávanie v DB | +| `searchEmailsJmap(accountId, query)` | JMAP full-text | +| `getUnreadCount(accountId)` | Počet neprečítaných | +| `syncEmails(accountId)` | Synchronizovať z JMAP | +| `getEmailThread(accountId, threadId)` | Email vlákno | +| `markThreadAsRead(accountId, threadId)` | Označiť prečítané | +| `markContactEmailsAsRead(accountId, contactId)` | Označiť od kontaktu | +| `replyToEmail(accountId, userId, data)` | Odpovedať | + +### email-account.service.js + +| Funkcia | Popis | +|---------|-------| +| `getUserEmailAccounts(userId)` | Email účty usera | +| `createEmailAccount(userId, data)` | Vytvoriť účet | +| `setPrimaryAccount(userId, accountId)` | Nastaviť primárny | +| `deleteEmailAccount(userId, accountId)` | Zmazať účet | + +### audit.service.js + +| Funkcia | Popis | +|---------|-------| +| `logAction(data)` | Zaznamenať akciu | +| `logLogin(userId, success, ip, userAgent, error)` | Login log | +| `logLogout(userId, ip, userAgent)` | Logout log | +| `logPasswordChange(userId, ip, userAgent)` | Password change log | +| `logTodoCreated(userId, todoId, title, ip, userAgent)` | Todo created log | +| `logTodoDeleted(userId, todoId, title, ip, userAgent)` | Todo deleted log | +| `logTodoCompleted(userId, todoId, title, ip, userAgent)` | Todo completed log | +| `getRecentLogs(limit)` | Nedávne logy | + +### status.service.js + +| Funkcia | Popis | +|---------|-------| +| `getServerStatus()` | CPU, RAM, Disk, Network, Uptime | + +### JMAP Services (`/services/jmap/`) + +| Súbor | Funkcie | +|-------|---------| +| `client.js` | JMAP HTTP klient | +| `config.js` | JMAP konfigurácia | +| `discovery.js` | JMAP discovery (.well-known) | +| `operations.js` | Email/set, Mailbox/get | +| `search.js` | Email/query s full-text | +| `sync.js` | Synchronizácia emailov | + +--- + +## 7. Controllers + +Každý controller: +1. Extrahuje dáta z `req` (params, query, body, user) +2. Volá príslušný service +3. Formátuje a vracia response +4. Zachytáva a propaguje chyby cez `next(error)` + +### Príklad controller funkcie: + +```javascript +export const getAllCompanies = async (req, res, next) => { + try { + const { search } = req.query; + const userId = req.user?.id; + const userRole = req.user?.role; + + const companies = await companyService.getAllCompanies(search, userId, userRole); + + res.status(200).json({ + success: true, + count: companies.length, + data: companies, + }); + } catch (error) { + next(error); + } +}; +``` + +--- + +## 8. Utility funkcie + +### errors.js + +```javascript +class AppError extends Error { + constructor(message, statusCode, details = null) +} + +class ValidationError extends AppError { + constructor(message, details = []) // 400 +} + +class BadRequestError extends AppError { + constructor(message) // 400 +} + +class AuthenticationError extends AppError { + constructor(message) // 401 +} + +class ForbiddenError extends AppError { + constructor(message) // 403 +} + +class NotFoundError extends AppError { + constructor(message) // 404 +} + +class ConflictError extends AppError { + constructor(message) // 409 +} + +class RateLimitError extends AppError { + constructor(message) // 429 +} + +formatErrorResponse(error, includeStack = false) +``` + +### jwt.js + +```javascript +generateAccessToken(payload) // Expires: 1h +generateRefreshToken(payload) // Expires: 7d +verifyAccessToken(token) // Verify + decode +verifyRefreshToken(token) // Verify + decode +generateTokenPair(user) // Both tokens +``` + +### password.js + +```javascript +hashPassword(password) // bcrypt, 12 rounds +comparePassword(password, hash) // Verify password +generateTempPassword(length = 12) // Random temp password +generateVerificationToken() // UUID token +encryptPassword(text) // AES-256-GCM encrypt +decryptPassword(encryptedText) // AES-256-GCM decrypt +``` + +### logger.js + +```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 +``` + +--- + +## 9. Validačné schémy + +### auth.validators.js + +```javascript +loginSchema = z.object({ + username: z.string().min(3).max(50), + password: z.string().min(1) +}) + +setPasswordSchema = z.object({ + newPassword: z.string().min(8).max(100).regex(/[A-Z]/).regex(/[a-z]/).regex(/[0-9]/), + confirmPassword: z.string() +}).refine(data => data.newPassword === data.confirmPassword) + +createUserSchema = z.object({ + username: z.string().min(3).max(50), + email: z.string().email().optional(), + firstName: z.string().min(1).max(100), + lastName: z.string().min(1).max(100), + role: z.enum(['admin', 'member']).default('member') +}) + +changeRoleSchema = z.object({ + role: z.enum(['admin', 'member']) +}) +``` + +### crm.validators.js + +```javascript +// Company +createCompanySchema = z.object({ + name: z.string().min(1).max(255), + description: z.string().optional(), + address: z.string().optional(), + city: z.string().max(100).optional(), + country: z.string().max(100).optional(), + phone: z.string().max(50).optional(), + email: z.string().email().optional(), + website: z.string().url().optional() +}) + +// Project +createProjectSchema = z.object({ + name: z.string().min(1).max(255), + description: z.string().optional(), + companyId: z.string().uuid().optional(), + status: z.enum(['active', 'completed', 'on_hold', 'cancelled']).default('active'), + startDate: z.string().optional(), + endDate: z.string().optional() +}) + +// Todo +createTodoSchema = z.object({ + title: z.string().min(1).max(255), + description: z.string().optional(), + projectId: z.string().uuid().optional(), + companyId: z.string().uuid().optional(), + assignedUserIds: z.array(z.string().uuid()).optional(), + status: z.enum(['pending', 'in_progress', 'completed', 'cancelled']).default('pending'), + priority: z.enum(['low', 'medium', 'high', 'urgent']).default('medium'), + dueDate: z.string().optional() +}) + +// Time Entry +startTimeEntrySchema = z.object({ + projectId: z.string().uuid().optional(), + todoId: z.string().uuid().optional(), + companyId: z.string().uuid().optional(), + description: z.string().optional() +}) + +// Company Reminder +createCompanyReminderSchema = z.object({ + description: z.string().min(1), + dueDate: z.string(), + isChecked: z.boolean().default(false) +}) +``` + +--- + +## 10. Konfigurácia + +### .env + +```bash +# Database +DB_HOST=localhost +DB_PORT=5432 +DB_USER=admin +DB_PASSWORD=heslo123 +DB_NAME=crm + +# JWT +JWT_SECRET=tajny_kluc_pre_access_token +JWT_REFRESH_SECRET=tajny_kluc_pre_refresh_token +JWT_EXPIRES_IN=1h +JWT_REFRESH_EXPIRES_IN=7d + +# Encryption +ENCRYPTION_SALT=salt_pre_enkrypciu_emailovych_hesiel +BCRYPT_ROUNDS=12 + +# CORS +CORS_ORIGIN=http://localhost:5173 + +# Rate Limiting +RATE_LIMIT_WINDOW_MS=900000 +RATE_LIMIT_LOGIN_MAX=5 +RATE_LIMIT_MAX_REQUESTS=100 + +# Server +NODE_ENV=development +PORT=5000 +``` + +### package.json scripts + +```bash +npm run dev # nodemon src/index.js +npm run start # node src/index.js +npm run test # Jest testy +npm run db:generate # drizzle-kit generate +npm run db:push # drizzle-kit push +npm run db:studio # drizzle-kit studio +npm run db:seed # Admin seed +npm run db:seed:testuser # Test user seed +``` + +### drizzle.config.js + +```javascript +export default { + schema: './src/db/schema.js', + out: './drizzle', + dialect: 'postgresql', + dbCredentials: { + host: process.env.DB_HOST, + port: process.env.DB_PORT, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + }, +}; +``` + +--- + +## 11. Autentizácia a bezpečnosť + +### Autentizačný flow + +1. **Login** - User zadá username + temp heslo +2. **Validácia** - Kontrola temp/permanent hesla +3. **Token generovanie** - Access token (1h) + Refresh token (7d) +4. **Cookies** - httpOnly, secure, sameSite=strict +5. **Password change** - Povinný v prvej prihláške +6. **Email setup** - Nastavenie email accounts + +### Security features + +| Feature | Implementácia | +|---------|---------------| +| JWT tokeny | httpOnly cookies | +| Rate limiting | express-rate-limit | +| CORS | cors middleware | +| XSS protection | xss-clean | +| Security headers | Helmet.js | +| Password hashing | bcrypt (12 rounds) | +| Email password encryption | AES-256-GCM | +| Audit logging | Všetky akcie | +| RBAC | admin/member role | +| Resource access control | Junction tables | + +### Role-based access control + +| Akcia | Admin | Member | +|-------|-------|--------| +| Vytvoriť/upraviť/zmazať firmu | ✓ | ✗ | +| Vytvoriť/upraviť/zmazať projekt | ✓ | ✗ | +| Vytvoriť/upraviť/zmazať todo | ✓ | ✗ | +| Toggle todo (splnené) | ✓ | ✓ (ak priradený) | +| Zobraziť firmu | ✓ | ✓ (ak priradený) | +| Zobraziť projekt | ✓ | ✓ (ak priradený) | +| Zobraziť todo | ✓ | ✓ (ak priradený) | +| Spravovať tím | ✓ | ✗ | +| Server status | ✓ | ✗ | +| Vytvoriť/upraviť/zmazať meeting | ✓ | ✗ | +| Time tracking | ✓ | ✓ | +| Kontakty | ✓ | ✓ | +| Emaily | ✓ | ✓ | + +--- + +## Záver + +Táto dokumentácia poskytuje kompletný prehľad CRM Server backendu. Pre ďalšie informácie kontaktujte administrátora projektu. diff --git a/src/controllers/company.controller.js b/src/controllers/company.controller.js index 27be284..3dc7937 100644 --- a/src/controllers/company.controller.js +++ b/src/controllers/company.controller.js @@ -7,12 +7,15 @@ import { logCompanyCreated, logCompanyDeleted } from '../services/audit.service. /** * Get all companies * GET /api/companies?search=query + * Members only see companies they are assigned to */ export const getAllCompanies = async (req, res, next) => { try { const { search } = req.query; + const userId = req.user?.id; + const userRole = req.user?.role; - const companies = await companyService.getAllCompanies(search); + const companies = await companyService.getAllCompanies(search, userId, userRole); res.status(200).json({ success: true, @@ -333,9 +336,11 @@ export const deleteCompanyReminder = async (req, res, next) => { } }; -export const getReminderSummary = async (_req, res, next) => { +export const getReminderSummary = async (req, res, next) => { try { - const summary = await companyReminderService.getReminderSummary(); + const userId = req.user?.id; + const userRole = req.user?.role; + const summary = await companyReminderService.getReminderSummary(userId, userRole); res.status(200).json({ success: true, data: summary, @@ -345,9 +350,11 @@ export const getReminderSummary = async (_req, res, next) => { } }; -export const getReminderCountsByCompany = async (_req, res, next) => { +export const getReminderCountsByCompany = async (req, res, next) => { try { - const counts = await companyReminderService.getReminderCountsByCompany(); + const userId = req.user?.id; + const userRole = req.user?.role; + const counts = await companyReminderService.getReminderCountsByCompany(userId, userRole); res.status(200).json({ success: true, data: counts, @@ -357,9 +364,11 @@ export const getReminderCountsByCompany = async (_req, res, next) => { } }; -export const getUpcomingReminders = async (_req, res, next) => { +export const getUpcomingReminders = async (req, res, next) => { try { - const reminders = await companyReminderService.getUpcomingReminders(); + const userId = req.user?.id; + const userRole = req.user?.role; + const reminders = await companyReminderService.getUpcomingReminders(userId, userRole); res.status(200).json({ success: true, count: reminders.length, @@ -369,3 +378,87 @@ export const getUpcomingReminders = async (_req, res, next) => { next(error); } }; + +/** + * Get company users (team members) + * GET /api/companies/:companyId/users + */ +export const getCompanyUsers = async (req, res, next) => { + try { + const { companyId } = req.params; + + const users = await companyService.getCompanyUsers(companyId); + + res.status(200).json({ + success: true, + count: users.length, + data: users, + }); + } catch (error) { + next(error); + } +}; + +/** + * Assign user to company + * POST /api/companies/:companyId/users + * Body: { userId, role } + */ +export const assignUserToCompany = async (req, res, next) => { + try { + const currentUserId = req.userId; + const { companyId } = req.params; + const { userId, role } = req.body; + + const assignment = await companyService.assignUserToCompany(companyId, userId, currentUserId, role); + + res.status(201).json({ + success: true, + data: assignment, + message: 'Používateľ bol priradený k firme', + }); + } catch (error) { + next(error); + } +}; + +/** + * Remove user from company + * DELETE /api/companies/:companyId/users/:userId + */ +export const removeUserFromCompany = async (req, res, next) => { + try { + const { companyId, userId } = req.params; + + const result = await companyService.removeUserFromCompany(companyId, userId); + + res.status(200).json({ + success: true, + message: result.message, + }); + } catch (error) { + next(error); + } +}; + +/** + * Update user role on company + * PATCH /api/companies/:companyId/users/:userId + * Body: { role } + */ +export const updateUserRoleOnCompany = async (req, res, next) => { + try { + const { companyId, userId } = req.params; + const { role } = req.body; + + const assignment = await companyService.updateUserRoleOnCompany(companyId, userId, role); + + res.status(200).json({ + success: true, + data: assignment, + message: 'Rola používateľa bola aktualizovaná', + }); + } catch (error) { + next(error); + } +}; diff --git a/src/controllers/project.controller.js b/src/controllers/project.controller.js index d79440f..ddc1d30 100644 --- a/src/controllers/project.controller.js +++ b/src/controllers/project.controller.js @@ -5,12 +5,15 @@ import { logProjectCreated, logProjectDeleted } from '../services/audit.service. /** * Get all projects * GET /api/projects?search=query&companyId=xxx + * Members only see projects they are assigned to or projects of companies they are assigned to */ export const getAllProjects = async (req, res, next) => { try { const { search, companyId } = req.query; + const userId = req.user?.id; + const userRole = req.user?.role; - const projects = await projectService.getAllProjects(search, companyId); + const projects = await projectService.getAllProjects(search, companyId, userId, userRole); res.status(200).json({ success: true, diff --git a/src/controllers/todo.controller.js b/src/controllers/todo.controller.js index f85eaea..5956e5e 100644 --- a/src/controllers/todo.controller.js +++ b/src/controllers/todo.controller.js @@ -4,10 +4,13 @@ import { logTodoCreated, logTodoDeleted, logTodoCompleted } from '../services/au /** * Get all todos * GET /api/todos?search=query&projectId=xxx&companyId=xxx&assignedTo=xxx&status=xxx + * Members only see todos they are assigned to */ export const getAllTodos = async (req, res, next) => { try { const { search, projectId, companyId, assignedTo, status, completed, priority } = req.query; + const userId = req.user?.id; + const userRole = req.user?.role; // Handle both 'status' and 'completed' query params let statusFilter = status; @@ -24,7 +27,7 @@ export const getAllTodos = async (req, res, next) => { priority, }; - const todos = await todoService.getAllTodos(filters); + const todos = await todoService.getAllTodos(filters, userId, userRole); res.status(200).json({ success: true, diff --git a/src/db/schema.js b/src/db/schema.js index b0ddac0..50c71e9 100644 --- a/src/db/schema.js +++ b/src/db/schema.js @@ -158,6 +158,18 @@ export const companyReminders = pgTable('company_remind', { updatedAt: timestamp('updated_at').defaultNow().notNull(), }); +// Company Users - many-to-many medzi companies a users (tím firmy) +export const companyUsers = pgTable('company_users', { + id: uuid('id').primaryKey().defaultRandom(), + companyId: uuid('company_id').references(() => companies.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 firmy + addedAt: timestamp('added_at').defaultNow().notNull(), +}, (table) => ({ + companyUserUnique: unique('company_user_unique').on(table.companyId, table.userId), +})); + // Project Users - many-to-many medzi projects a users (tím projektu) export const projectUsers = pgTable('project_users', { id: uuid('id').primaryKey().defaultRandom(), diff --git a/src/middlewares/auth/resourceAccessMiddleware.js b/src/middlewares/auth/resourceAccessMiddleware.js new file mode 100644 index 0000000..a3a1cd2 --- /dev/null +++ b/src/middlewares/auth/resourceAccessMiddleware.js @@ -0,0 +1,160 @@ +import { db } from '../../config/database.js'; +import { companyUsers, projectUsers, todoUsers } from '../../db/schema.js'; +import { eq, and } from 'drizzle-orm'; + +/** + * Univerzálny middleware pre kontrolu prístupu k resources + * Admin má prístup vždy, member len ak je priradený k resource + * + * Použitie: + * checkResourceAccess('company', 'companyId') // pre /companies/:companyId + * checkResourceAccess('project', 'projectId') // pre /projects/:projectId + * checkResourceAccess('todo', 'todoId') // pre /todos/:todoId + */ + +// Mapovanie resource typu na junction tabuľku a stĺpce +const resourceConfig = { + company: { + table: companyUsers, + resourceIdColumn: 'companyId', + }, + project: { + table: projectUsers, + resourceIdColumn: 'projectId', + }, + todo: { + table: todoUsers, + resourceIdColumn: 'todoId', + }, +}; + +/** + * Skontroluje či user má prístup k danému resource + * @param {string} resourceType - Typ resource ('company', 'project', atď.) + * @param {string} userId - ID používateľa + * @param {string} resourceId - ID resource + * @returns {Promise} + */ +export const hasAccessToResource = async (resourceType, userId, resourceId) => { + const config = resourceConfig[resourceType]; + if (!config) { + throw new Error(`Unknown resource type: ${resourceType}`); + } + + const { table, resourceIdColumn } = config; + + const [assignment] = await db + .select() + .from(table) + .where(and( + eq(table[resourceIdColumn], resourceId), + eq(table.userId, userId) + )) + .limit(1); + + return !!assignment; +}; + +/** + * Middleware factory pre kontrolu prístupu k resource + * @param {string} resourceType - Typ resource ('company', 'project') + * @param {string} paramName - Názov parametra v URL (napr. 'companyId', 'projectId') + */ +export const checkResourceAccess = (resourceType, paramName) => { + return async (req, res, next) => { + // Skontroluj či je user autentifikovaný + if (!req.user) { + return res.status(401).json({ + success: false, + error: { + message: 'Musíte byť prihlásený', + statusCode: 401, + }, + }); + } + + // Admin má prístup vždy + if (req.user.role === 'admin') { + return next(); + } + + const resourceId = req.params[paramName]; + if (!resourceId) { + return res.status(400).json({ + success: false, + error: { + message: `Chýba parameter ${paramName}`, + statusCode: 400, + }, + }); + } + + try { + const hasAccess = await hasAccessToResource(resourceType, req.user.id, resourceId); + + if (!hasAccess) { + return res.status(403).json({ + success: false, + error: { + message: 'Nemáte prístup k tomuto zdroju', + statusCode: 403, + }, + }); + } + + next(); + } catch (error) { + console.error('Resource access check error:', error); + return res.status(500).json({ + success: false, + error: { + message: 'Chyba pri overovaní prístupu', + statusCode: 500, + }, + }); + } + }; +}; + +/** + * Helper funkcie pre bežné prípady + */ +export const checkCompanyAccess = checkResourceAccess('company', 'companyId'); +export const checkProjectAccess = checkResourceAccess('project', 'projectId'); +export const checkTodoAccess = checkResourceAccess('todo', 'todoId'); + +/** + * Získa zoznam resource IDs ku ktorým má user prístup + * Užitočné pre filtrovanie v service vrstvách + * @param {string} resourceType - Typ resource ('company', 'project') + * @param {string} userId - ID používateľa + * @returns {Promise} - Zoznam resource IDs + */ +export const getAccessibleResourceIds = async (resourceType, userId) => { + const config = resourceConfig[resourceType]; + if (!config) { + throw new Error(`Unknown resource type: ${resourceType}`); + } + + const { table, resourceIdColumn } = config; + + const assignments = await db + .select({ resourceId: table[resourceIdColumn] }) + .from(table) + .where(eq(table.userId, userId)); + + return assignments.map(a => a.resourceId); +}; + +/** + * Skontroluje prístup a vráti boolean (pre použitie v services) + * @param {string} resourceType + * @param {string} userId + * @param {string} resourceId + * @param {string} userRole + * @returns {Promise} + */ +export const canAccessResource = async (resourceType, userId, resourceId, userRole) => { + if (userRole === 'admin') return true; + return hasAccessToResource(resourceType, userId, resourceId); +}; diff --git a/src/routes/company.routes.js b/src/routes/company.routes.js index 5d787e5..c3b8607 100644 --- a/src/routes/company.routes.js +++ b/src/routes/company.routes.js @@ -2,6 +2,7 @@ import express from 'express'; import * as companyController from '../controllers/company.controller.js'; import { authenticate } from '../middlewares/auth/authMiddleware.js'; import { requireAdmin } from '../middlewares/auth/roleMiddleware.js'; +import { checkCompanyAccess } from '../middlewares/auth/resourceAccessMiddleware.js'; import { validateBody, validateParams } from '../middlewares/security/validateInput.js'; import { createCompanySchema, updateCompanySchema, createCompanyReminderSchema, updateCompanyReminderSchema } from '../validators/crm.validators.js'; import { z } from 'zod'; @@ -23,6 +24,7 @@ router.get('/email-unread', companyController.getCompanyUnreadCounts); router.get( '/:companyId/email-threads', validateParams(z.object({ companyId: z.string().uuid() })), + checkCompanyAccess, companyController.getCompanyEmailThreads ); @@ -37,6 +39,7 @@ router.get('/', companyController.getAllCompanies); router.get( '/:companyId', validateParams(z.object({ companyId: z.string().uuid() })), + checkCompanyAccess, companyController.getCompanyById ); @@ -69,6 +72,7 @@ router.delete( router.get( '/:companyId/notes', validateParams(z.object({ companyId: z.string().uuid() })), + checkCompanyAccess, companyController.getCompanyNotes ); @@ -109,6 +113,7 @@ router.delete( router.get( '/:companyId/reminders', validateParams(z.object({ companyId: z.string().uuid() })), + checkCompanyAccess, companyController.getCompanyReminders ); @@ -141,4 +146,46 @@ router.delete( companyController.deleteCompanyReminder ); +// Company Users (Team Management) +router.get( + '/:companyId/users', + validateParams(z.object({ companyId: z.string().uuid() })), + checkCompanyAccess, + companyController.getCompanyUsers +); + +router.post( + '/:companyId/users', + requireAdmin, + validateParams(z.object({ companyId: z.string().uuid() })), + validateBody(z.object({ + userId: z.string().uuid(), + role: z.string().optional(), + })), + companyController.assignUserToCompany +); + +router.patch( + '/:companyId/users/:userId', + requireAdmin, + validateParams(z.object({ + companyId: z.string().uuid(), + userId: z.string().uuid() + })), + validateBody(z.object({ + role: z.string().optional(), + })), + companyController.updateUserRoleOnCompany +); + +router.delete( + '/:companyId/users/:userId', + requireAdmin, + validateParams(z.object({ + companyId: z.string().uuid(), + userId: z.string().uuid() + })), + companyController.removeUserFromCompany +); + export default router; diff --git a/src/routes/project.routes.js b/src/routes/project.routes.js index 2e89d63..f726e67 100644 --- a/src/routes/project.routes.js +++ b/src/routes/project.routes.js @@ -2,6 +2,7 @@ import express from 'express'; import * as projectController from '../controllers/project.controller.js'; import { authenticate } from '../middlewares/auth/authMiddleware.js'; import { requireAdmin } from '../middlewares/auth/roleMiddleware.js'; +import { checkProjectAccess } from '../middlewares/auth/resourceAccessMiddleware.js'; import { validateBody, validateParams } from '../middlewares/security/validateInput.js'; import { createProjectSchema, updateProjectSchema } from '../validators/crm.validators.js'; import { z } from 'zod'; @@ -22,6 +23,7 @@ router.get('/', projectController.getAllProjects); router.get( '/:projectId', validateParams(z.object({ projectId: z.string().uuid() })), + checkProjectAccess, projectController.getProjectById ); @@ -54,6 +56,7 @@ router.delete( router.get( '/:projectId/notes', validateParams(z.object({ projectId: z.string().uuid() })), + checkProjectAccess, projectController.getProjectNotes ); @@ -96,6 +99,7 @@ router.delete( router.get( '/:projectId/users', validateParams(z.object({ projectId: z.string().uuid() })), + checkProjectAccess, projectController.getProjectUsers ); diff --git a/src/routes/todo.routes.js b/src/routes/todo.routes.js index b4af5d7..4caadc3 100644 --- a/src/routes/todo.routes.js +++ b/src/routes/todo.routes.js @@ -1,6 +1,8 @@ import express from 'express'; import * as todoController from '../controllers/todo.controller.js'; import { authenticate } from '../middlewares/auth/authMiddleware.js'; +import { requireAdmin } from '../middlewares/auth/roleMiddleware.js'; +import { checkTodoAccess } from '../middlewares/auth/resourceAccessMiddleware.js'; import { validateBody, validateParams } from '../middlewares/security/validateInput.js'; import { createTodoSchema, updateTodoSchema } from '../validators/crm.validators.js'; import { z } from 'zod'; @@ -21,27 +23,31 @@ router.get('/', todoController.getAllTodos); router.get( '/:todoId', validateParams(z.object({ todoId: z.string().uuid() })), + checkTodoAccess, todoController.getTodoById ); -// Create new todo +// Create new todo (admin only) router.post( '/', + requireAdmin, validateBody(createTodoSchema), todoController.createTodo ); -// Update todo +// Update todo (admin only) router.patch( '/:todoId', + requireAdmin, validateParams(z.object({ todoId: z.string().uuid() })), validateBody(updateTodoSchema), todoController.updateTodo ); -// Delete todo +// Delete todo (admin only) router.delete( '/:todoId', + requireAdmin, validateParams(z.object({ todoId: z.string().uuid() })), todoController.deleteTodo ); @@ -50,6 +56,7 @@ router.delete( router.patch( '/:todoId/toggle', validateParams(z.object({ todoId: z.string().uuid() })), + checkTodoAccess, todoController.toggleTodo ); diff --git a/src/services/company-reminder.service.js b/src/services/company-reminder.service.js index 9b1453c..7651d70 100644 --- a/src/services/company-reminder.service.js +++ b/src/services/company-reminder.service.js @@ -1,7 +1,8 @@ import { db } from '../config/database.js'; import { companies, companyReminders } from '../db/schema.js'; -import { eq, desc, sql, and, lte, gte, isNull, or } from 'drizzle-orm'; +import { eq, desc, sql, and, lte, gte, isNull, or, inArray } from 'drizzle-orm'; import { NotFoundError, BadRequestError } from '../utils/errors.js'; +import { getAccessibleResourceIds } from '../middlewares/auth/resourceAccessMiddleware.js'; const ensureCompanyExists = async (companyId) => { const [company] = await db @@ -105,8 +106,17 @@ export const deleteReminder = async (companyId, reminderId) => { return { success: true, message: 'Reminder bol odstránený' }; }; -export const getReminderSummary = async () => { - const [row] = await db +export const getReminderSummary = async (userId = null, userRole = null) => { + // Pre membera filtruj len pristupne firmy + let accessibleCompanyIds = null; + if (userRole && userRole !== 'admin' && userId) { + accessibleCompanyIds = await getAccessibleResourceIds('company', userId); + if (accessibleCompanyIds.length === 0) { + return { total: 0, active: 0, completed: 0 }; + } + } + + let query = db .select({ total: sql`COUNT(*)::int`, active: sql`COUNT(*) FILTER (WHERE ${companyReminders.isChecked} = false)::int`, @@ -114,6 +124,12 @@ export const getReminderSummary = async () => { }) .from(companyReminders); + if (accessibleCompanyIds !== null) { + query = query.where(inArray(companyReminders.companyId, accessibleCompanyIds)); + } + + const [row] = await query; + return { total: row?.total ?? 0, active: row?.active ?? 0, @@ -121,15 +137,30 @@ export const getReminderSummary = async () => { }; }; -export const getReminderCountsByCompany = async () => { - const rows = await db +export const getReminderCountsByCompany = async (userId = null, userRole = null) => { + // Pre membera filtruj len pristupne firmy + let accessibleCompanyIds = null; + if (userRole && userRole !== 'admin' && userId) { + accessibleCompanyIds = await getAccessibleResourceIds('company', userId); + if (accessibleCompanyIds.length === 0) { + return []; + } + } + + let query = db .select({ companyId: companyReminders.companyId, total: sql`COUNT(*)::int`, active: sql`COUNT(*) FILTER (WHERE ${companyReminders.isChecked} = false)::int`, completed: sql`COUNT(*) FILTER (WHERE ${companyReminders.isChecked} = true)::int`, }) - .from(companyReminders) + .from(companyReminders); + + if (accessibleCompanyIds !== null) { + query = query.where(inArray(companyReminders.companyId, accessibleCompanyIds)); + } + + const rows = await query .groupBy(companyReminders.companyId) .orderBy(desc(companyReminders.companyId)); @@ -140,12 +171,32 @@ export const getReminderCountsByCompany = async () => { * Get upcoming reminders for dashboard * Returns reminders due within the next 5 days that are not checked * Includes company name for display + * For members: returns only reminders from companies they are assigned to */ -export const getUpcomingReminders = async () => { +export const getUpcomingReminders = async (userId = null, userRole = null) => { + // Pre membera filtruj len pristupne firmy + let accessibleCompanyIds = null; + if (userRole && userRole !== 'admin' && userId) { + accessibleCompanyIds = await getAccessibleResourceIds('company', userId); + if (accessibleCompanyIds.length === 0) { + return []; + } + } + const now = new Date(); const fiveDaysFromNow = new Date(); fiveDaysFromNow.setDate(fiveDaysFromNow.getDate() + 5); + const conditions = [ + eq(companyReminders.isChecked, false), + lte(companyReminders.dueDate, fiveDaysFromNow), + gte(companyReminders.dueDate, now) + ]; + + if (accessibleCompanyIds !== null) { + conditions.push(inArray(companyReminders.companyId, accessibleCompanyIds)); + } + const reminders = await db .select({ id: companyReminders.id, @@ -158,13 +209,7 @@ export const getUpcomingReminders = async () => { }) .from(companyReminders) .leftJoin(companies, eq(companyReminders.companyId, companies.id)) - .where( - and( - eq(companyReminders.isChecked, false), - lte(companyReminders.dueDate, fiveDaysFromNow), - gte(companyReminders.dueDate, now) - ) - ) + .where(and(...conditions)) .orderBy(companyReminders.dueDate); return reminders; diff --git a/src/services/company.service.js b/src/services/company.service.js index 1f067d5..c1317f9 100644 --- a/src/services/company.service.js +++ b/src/services/company.service.js @@ -1,17 +1,58 @@ import { db } from '../config/database.js'; -import { companies, projects, todos, notes, companyReminders } from '../db/schema.js'; -import { eq, desc, ilike, or, and } from 'drizzle-orm'; +import { companies, projects, todos, notes, companyReminders, companyUsers, users } from '../db/schema.js'; +import { eq, desc, ilike, or, and, inArray } from 'drizzle-orm'; import { NotFoundError, ConflictError } from '../utils/errors.js'; +import { getAccessibleResourceIds } from '../middlewares/auth/resourceAccessMiddleware.js'; /** * Get all companies * Optionally filter by search term + * Returns companies with creator info + * For members: returns only companies they are assigned to */ -export const getAllCompanies = async (searchTerm = null) => { - let query = db.select().from(companies); +export const getAllCompanies = async (searchTerm = null, userId = null, userRole = null) => { + // Pre membera najprv ziskaj pristupne company IDs + let accessibleCompanyIds = null; + if (userRole && userRole !== 'admin' && userId) { + accessibleCompanyIds = await getAccessibleResourceIds('company', userId); + // Ak member nema pristup k ziadnym firmam, vrat prazdne pole + if (accessibleCompanyIds.length === 0) { + return []; + } + } + + let query = db + .select({ + id: companies.id, + name: companies.name, + description: companies.description, + address: companies.address, + city: companies.city, + country: companies.country, + phone: companies.phone, + email: companies.email, + website: companies.website, + isActive: companies.isActive, + createdBy: companies.createdBy, + createdAt: companies.createdAt, + updatedAt: companies.updatedAt, + creator: { + id: users.id, + username: users.username, + }, + }) + .from(companies) + .leftJoin(users, eq(companies.createdBy, users.id)); + + // Aplikuj filtrovanie pre membera + const conditions = []; + + if (accessibleCompanyIds !== null) { + conditions.push(inArray(companies.id, accessibleCompanyIds)); + } if (searchTerm) { - query = query.where( + conditions.push( or( ilike(companies.name, `%${searchTerm}%`), ilike(companies.email, `%${searchTerm}%`), @@ -20,17 +61,41 @@ export const getAllCompanies = async (searchTerm = null) => { ); } + if (conditions.length > 0) { + query = query.where(and(...conditions)); + } + const result = await query.orderBy(desc(companies.createdAt)); return result; }; /** * Get company by ID + * Returns company with creator info */ export const getCompanyById = async (companyId) => { const [company] = await db - .select() + .select({ + id: companies.id, + name: companies.name, + description: companies.description, + address: companies.address, + city: companies.city, + country: companies.country, + phone: companies.phone, + email: companies.email, + website: companies.website, + isActive: companies.isActive, + createdBy: companies.createdBy, + createdAt: companies.createdAt, + updatedAt: companies.updatedAt, + creator: { + id: users.id, + username: users.username, + }, + }) .from(companies) + .leftJoin(users, eq(companies.createdBy, users.id)) .where(eq(companies.id, companyId)) .limit(1); @@ -169,3 +234,167 @@ export const getCompanyWithRelations = async (companyId) => { reminders: companyReminderList, }; }; + +/** + * Get all users assigned to a company + */ +export const getCompanyUsers = async (companyId) => { + await getCompanyById(companyId); // Verify company exists + + const rawResults = await db + .select() + .from(companyUsers) + .leftJoin(users, eq(companyUsers.userId, users.id)) + .where(eq(companyUsers.companyId, companyId)) + .orderBy(desc(companyUsers.addedAt)); + + const assignedUsers = rawResults.map((row) => ({ + id: row.company_users.id, + userId: row.company_users.userId, + role: row.company_users.role, + addedBy: row.company_users.addedBy, + addedAt: row.company_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 company + */ +export const assignUserToCompany = async (companyId, userId, addedByUserId, role = null) => { + await getCompanyById(companyId); // Verify company 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(companyUsers) + .where(and(eq(companyUsers.companyId, companyId), eq(companyUsers.userId, userId))) + .limit(1); + + if (existing) { + throw new ConflictError('Používateľ je už priradený k firme'); + } + + // Assign user to company + const [assignment] = await db + .insert(companyUsers) + .values({ + companyId, + userId, + role: role || null, + addedBy: addedByUserId, + }) + .returning(); + + // Return with user details + const [row] = await db + .select() + .from(companyUsers) + .leftJoin(users, eq(companyUsers.userId, users.id)) + .where(eq(companyUsers.id, assignment.id)) + .limit(1); + + return { + id: row.company_users.id, + userId: row.company_users.userId, + role: row.company_users.role, + addedBy: row.company_users.addedBy, + addedAt: row.company_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 company + */ +export const removeUserFromCompany = async (companyId, userId) => { + await getCompanyById(companyId); // Verify company exists + + // Check if user is assigned + const [existing] = await db + .select() + .from(companyUsers) + .where(and(eq(companyUsers.companyId, companyId), eq(companyUsers.userId, userId))) + .limit(1); + + if (!existing) { + throw new NotFoundError('Používateľ nie je priradený k firme'); + } + + // Remove assignment + await db + .delete(companyUsers) + .where(and(eq(companyUsers.companyId, companyId), eq(companyUsers.userId, userId))); + + return { success: true, message: 'Používateľ bol odstránený z firmy' }; +}; + +/** + * Update user role on company + */ +export const updateUserRoleOnCompany = async (companyId, userId, role) => { + await getCompanyById(companyId); // Verify company exists + + // Check if user is assigned + const [existing] = await db + .select() + .from(companyUsers) + .where(and(eq(companyUsers.companyId, companyId), eq(companyUsers.userId, userId))) + .limit(1); + + if (!existing) { + throw new NotFoundError('Používateľ nie je priradený k firme'); + } + + // Update role + const [updated] = await db + .update(companyUsers) + .set({ role: role || null }) + .where(and(eq(companyUsers.companyId, companyId), eq(companyUsers.userId, userId))) + .returning(); + + // Return with user details + const [row] = await db + .select() + .from(companyUsers) + .leftJoin(users, eq(companyUsers.userId, users.id)) + .where(eq(companyUsers.id, updated.id)) + .limit(1); + + return { + id: row.company_users.id, + userId: row.company_users.userId, + role: row.company_users.role, + addedBy: row.company_users.addedBy, + addedAt: row.company_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/note.service.js b/src/services/note.service.js index 0ca5bad..fd00d15 100644 --- a/src/services/note.service.js +++ b/src/services/note.service.js @@ -1,16 +1,35 @@ import { db } from '../config/database.js'; -import { notes, companies, projects, todos, contacts } from '../db/schema.js'; +import { notes, companies, projects, todos, contacts, users } from '../db/schema.js'; import { eq, desc, ilike, or, and } from 'drizzle-orm'; import { NotFoundError } from '../utils/errors.js'; /** * Get all notes * Optionally filter by search, company, project, todo, or contact + * Returns notes with creator info */ export const getAllNotes = async (filters = {}) => { const { searchTerm, companyId, projectId, todoId, contactId } = filters; - let query = db.select().from(notes); + let query = db + .select({ + id: notes.id, + title: notes.title, + content: notes.content, + companyId: notes.companyId, + projectId: notes.projectId, + todoId: notes.todoId, + contactId: notes.contactId, + createdBy: notes.createdBy, + createdAt: notes.createdAt, + updatedAt: notes.updatedAt, + creator: { + id: users.id, + username: users.username, + }, + }) + .from(notes) + .leftJoin(users, eq(notes.createdBy, users.id)); const conditions = []; @@ -227,22 +246,56 @@ export const deleteNote = async (noteId) => { /** * Get notes by company ID + * Returns notes with creator info */ export const getNotesByCompanyId = async (companyId) => { return await db - .select() + .select({ + id: notes.id, + title: notes.title, + content: notes.content, + companyId: notes.companyId, + projectId: notes.projectId, + todoId: notes.todoId, + contactId: notes.contactId, + createdBy: notes.createdBy, + createdAt: notes.createdAt, + updatedAt: notes.updatedAt, + creator: { + id: users.id, + username: users.username, + }, + }) .from(notes) + .leftJoin(users, eq(notes.createdBy, users.id)) .where(eq(notes.companyId, companyId)) .orderBy(desc(notes.createdAt)); }; /** * Get notes by project ID + * Returns notes with creator info */ export const getNotesByProjectId = async (projectId) => { return await db - .select() + .select({ + id: notes.id, + title: notes.title, + content: notes.content, + companyId: notes.companyId, + projectId: notes.projectId, + todoId: notes.todoId, + contactId: notes.contactId, + createdBy: notes.createdBy, + createdAt: notes.createdAt, + updatedAt: notes.updatedAt, + creator: { + id: users.id, + username: users.username, + }, + }) .from(notes) + .leftJoin(users, eq(notes.createdBy, users.id)) .where(eq(notes.projectId, projectId)) .orderBy(desc(notes.createdAt)); }; diff --git a/src/services/project.service.js b/src/services/project.service.js index 63c85b3..545becd 100644 --- a/src/services/project.service.js +++ b/src/services/project.service.js @@ -1,17 +1,56 @@ 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 { eq, desc, ilike, or, and, inArray } from 'drizzle-orm'; import { NotFoundError, ConflictError } from '../utils/errors.js'; +import { getAccessibleResourceIds } from '../middlewares/auth/resourceAccessMiddleware.js'; /** * Get all projects * Optionally filter by search term or company + * Returns projects with creator info + * For members: returns only projects they are directly assigned to */ -export const getAllProjects = async (searchTerm = null, companyId = null) => { - let query = db.select().from(projects); +export const getAllProjects = async (searchTerm = null, companyId = null, userId = null, userRole = null) => { + // Pre membera ziskaj pristupne project IDs + let accessibleProjectIds = null; + + if (userRole && userRole !== 'admin' && userId) { + // Ziskaj projekty kde je user priamo priradeny + accessibleProjectIds = await getAccessibleResourceIds('project', userId); + + // Ak member nema pristup k ziadnym projektom, vrat prazdne pole + if (accessibleProjectIds.length === 0) { + return []; + } + } + + let query = db + .select({ + id: projects.id, + name: projects.name, + description: projects.description, + companyId: projects.companyId, + status: projects.status, + startDate: projects.startDate, + endDate: projects.endDate, + createdBy: projects.createdBy, + createdAt: projects.createdAt, + updatedAt: projects.updatedAt, + creator: { + id: users.id, + username: users.username, + }, + }) + .from(projects) + .leftJoin(users, eq(projects.createdBy, users.id)); const conditions = []; + // Filtrovanie pre membera - projekt je pristupny iba ak je user priamo priradeny + if (accessibleProjectIds !== null) { + conditions.push(inArray(projects.id, accessibleProjectIds)); + } + if (searchTerm) { conditions.push( or( @@ -35,11 +74,28 @@ export const getAllProjects = async (searchTerm = null, companyId = null) => { /** * Get project by ID + * Returns project with creator info */ export const getProjectById = async (projectId) => { const [project] = await db - .select() + .select({ + id: projects.id, + name: projects.name, + description: projects.description, + companyId: projects.companyId, + status: projects.status, + startDate: projects.startDate, + endDate: projects.endDate, + createdBy: projects.createdBy, + createdAt: projects.createdAt, + updatedAt: projects.updatedAt, + creator: { + id: users.id, + username: users.username, + }, + }) .from(projects) + .leftJoin(users, eq(projects.createdBy, users.id)) .where(eq(projects.id, projectId)) .limit(1); diff --git a/src/services/todo.service.js b/src/services/todo.service.js index b07f085..9d8eae4 100644 --- a/src/services/todo.service.js +++ b/src/services/todo.service.js @@ -2,106 +2,50 @@ import { db } from '../config/database.js'; import { todos, todoUsers, notes, projects, companies, users } from '../db/schema.js'; import { eq, desc, ilike, or, and, inArray } from 'drizzle-orm'; import { NotFoundError } from '../utils/errors.js'; +import { getAccessibleResourceIds } from '../middlewares/auth/resourceAccessMiddleware.js'; /** * Get all todos * Optionally filter by search, project, company, assigned user, or status + * For members: returns only todos they are assigned to */ -export const getAllTodos = async (filters = {}) => { +export const getAllTodos = async (filters = {}, userId = null, userRole = null) => { const { searchTerm, projectId, companyId, assignedTo, status, priority } = filters; - // If filtering by assignedTo, we need to join with todo_users + // Pre membera filtruj len todos kde je priradeny + let accessibleTodoIds = null; + if (userRole && userRole !== 'admin' && userId) { + accessibleTodoIds = await getAccessibleResourceIds('todo', userId); + // Ak member nema pristup k ziadnym todos, vrat prazdne pole + if (accessibleTodoIds.length === 0) { + return []; + } + } + + // Build query conditions + const conditions = []; + + // Member access filter - only todos they are assigned to + if (accessibleTodoIds !== null) { + conditions.push(inArray(todos.id, accessibleTodoIds)); + } + + // If filtering by assignedTo, we need additional filter if (assignedTo) { const todoIdsWithUser = await db .select({ todoId: todoUsers.todoId }) .from(todoUsers) .where(eq(todoUsers.userId, assignedTo)); - const todoIds = todoIdsWithUser.map((row) => row.todoId); + const assignedTodoIds = todoIdsWithUser.map((row) => row.todoId); - if (todoIds.length === 0) { + if (assignedTodoIds.length === 0) { return []; } - let query = db.select().from(todos).where(inArray(todos.id, todoIds)); - - 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 (status) { - conditions.push(eq(todos.status, status)); - } - - if (priority) { - conditions.push(eq(todos.priority, priority)); - } - - if (conditions.length > 0) { - query = query.where(and(...conditions)); - } - - const result = await query.orderBy(desc(todos.createdAt)); - - // Fetch assigned users for all todos - if (result.length > 0) { - const todoIds = result.map(todo => todo.id); - const assignedUsersData = await db - .select({ - todoId: todoUsers.todoId, - userId: users.id, - username: users.username, - firstName: users.firstName, - lastName: users.lastName, - }) - .from(todoUsers) - .innerJoin(users, eq(todoUsers.userId, users.id)) - .where(inArray(todoUsers.todoId, todoIds)); - - // Group assigned users by todoId - const usersByTodoId = {}; - for (const row of assignedUsersData) { - if (!usersByTodoId[row.todoId]) { - usersByTodoId[row.todoId] = []; - } - usersByTodoId[row.todoId].push({ - id: row.userId, - username: row.username, - firstName: row.firstName, - lastName: row.lastName, - }); - } - - // Attach assigned users to each todo - return result.map(todo => ({ - ...todo, - assignedUsers: usersByTodoId[todo.id] || [], - })); - } - - return result; + conditions.push(inArray(todos.id, assignedTodoIds)); } - // No assignedTo filter - simple query - let query = db.select().from(todos); - - const conditions = []; - if (searchTerm) { conditions.push( or( @@ -127,6 +71,8 @@ export const getAllTodos = async (filters = {}) => { conditions.push(eq(todos.priority, priority)); } + let query = db.select().from(todos); + if (conditions.length > 0) { query = query.where(and(...conditions)); } @@ -135,18 +81,18 @@ export const getAllTodos = async (filters = {}) => { // Fetch assigned users for all todos if (result.length > 0) { - const todoIds = result.map(todo => todo.id); + const resultTodoIds = result.map(todo => todo.id); const assignedUsersData = await db .select({ todoId: todoUsers.todoId, - userId: users.id, + odUserId: users.id, username: users.username, firstName: users.firstName, lastName: users.lastName, }) .from(todoUsers) .innerJoin(users, eq(todoUsers.userId, users.id)) - .where(inArray(todoUsers.todoId, todoIds)); + .where(inArray(todoUsers.todoId, resultTodoIds)); // Group assigned users by todoId const usersByTodoId = {}; @@ -155,7 +101,7 @@ export const getAllTodos = async (filters = {}) => { usersByTodoId[row.todoId] = []; } usersByTodoId[row.todoId].push({ - id: row.userId, + id: row.odUserId, username: row.username, firstName: row.firstName, lastName: row.lastName,