diff --git a/CLEAN_START_GUIDE.md b/CLEAN_START_GUIDE.md new file mode 100644 index 0000000..f7f1ab5 --- /dev/null +++ b/CLEAN_START_GUIDE.md @@ -0,0 +1,125 @@ +# 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/FIX_SUMMARY.md b/FIX_SUMMARY.md new file mode 100644 index 0000000..1c3c5a0 --- /dev/null +++ b/FIX_SUMMARY.md @@ -0,0 +1,198 @@ +# 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 new file mode 100644 index 0000000..6f7b164 --- /dev/null +++ b/FRESH_START_README.md @@ -0,0 +1,296 @@ +# 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 new file mode 100644 index 0000000..fa6718d --- /dev/null +++ b/MIGRATION_GUIDE.md @@ -0,0 +1,445 @@ +# 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/package-lock.json b/package-lock.json index 435ae9a..e98e451 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "helmet": "^8.0.0", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", + "multer": "^2.0.2", "pg": "^8.16.3", "uuid": "^13.0.0", "xss-clean": "^0.1.4", @@ -2655,6 +2656,12 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -3068,9 +3075,19 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, "license": "MIT" }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -3320,6 +3337,21 @@ "dev": true, "license": "MIT" }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -6033,6 +6065,27 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/morgan": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", @@ -6082,6 +6135,24 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, "node_modules/nanostores": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.0.1.tgz", @@ -6823,6 +6894,20 @@ "dev": true, "license": "MIT" }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -7257,6 +7342,23 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -7549,6 +7651,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/uncrypto": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", @@ -7619,6 +7727,12 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", diff --git a/package.json b/package.json index df74714..bfd74fd 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "helmet": "^8.0.0", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", + "multer": "^2.0.2", "pg": "^8.16.3", "uuid": "^13.0.0", "xss-clean": "^0.1.4", diff --git a/src/app.js b/src/app.js index 299a585..094fcea 100644 --- a/src/app.js +++ b/src/app.js @@ -17,6 +17,7 @@ import adminRoutes from './routes/admin.routes.js'; 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'; const app = express(); @@ -72,6 +73,7 @@ app.use('/api/admin', adminRoutes); app.use('/api/contacts', contactRoutes); app.use('/api/emails', crmEmailRoutes); app.use('/api/email-accounts', emailAccountRoutes); +app.use('/api/timesheets', timesheetRoutes); // Basic route app.get('/', (req, res) => { diff --git a/src/controllers/admin.controller.js b/src/controllers/admin.controller.js index 3ad0477..1c2a2a3 100644 --- a/src/controllers/admin.controller.js +++ b/src/controllers/admin.controller.js @@ -1,10 +1,10 @@ import { db } from '../config/database.js'; import { users } from '../db/schema.js'; import { eq } from 'drizzle-orm'; -import { hashPassword, generateTempPassword, encryptPassword } from '../utils/password.js'; +import { hashPassword, generateTempPassword } from '../utils/password.js'; import { logUserCreation, logRoleChange } from '../services/audit.service.js'; import { formatErrorResponse, ConflictError, NotFoundError } from '../utils/errors.js'; -import { validateJmapCredentials } from '../services/email.service.js'; +import * as emailAccountService from '../services/email-account.service.js'; /** * Vytvorenie nového usera s automatic temporary password (admin only) @@ -33,28 +33,11 @@ export const createUser = async (req, res) => { const tempPassword = generateTempPassword(12); const hashedTempPassword = await hashPassword(tempPassword); - // Ak sú poskytnuté email credentials, validuj ich a získaj JMAP account ID - let jmapAccountId = null; - let encryptedEmailPassword = null; - - if (email && emailPassword) { - try { - const { accountId } = await validateJmapCredentials(email, emailPassword); - jmapAccountId = accountId; - encryptedEmailPassword = encryptPassword(emailPassword); - } catch (emailError) { - throw new ConflictError(`Nepodarilo sa overiť emailový účet: ${emailError.message}`); - } - } - // Vytvor usera const [newUser] = await db .insert(users) .values({ username, - email: email || null, - emailPassword: encryptedEmailPassword, - jmapAccountId, tempPassword: hashedTempPassword, role: 'member', // Vždy member, nie admin firstName: firstName || null, @@ -63,6 +46,33 @@ export const createUser = async (req, res) => { }) .returning(); + // Ak sú poskytnuté email credentials, vytvor email account (many-to-many) + let emailAccountCreated = false; + let emailAccountData = null; + + if (email && emailPassword) { + try { + // Použij emailAccountService ktorý automaticky vytvorí many-to-many link + const newEmailAccount = await emailAccountService.createEmailAccount( + newUser.id, + email, + emailPassword + ); + + emailAccountCreated = true; + emailAccountData = { + id: newEmailAccount.id, + email: newEmailAccount.email, + jmapAccountId: newEmailAccount.jmapAccountId, + shared: newEmailAccount.shared, + }; + } catch (emailError) { + // Email account sa nepodarilo vytvoriť, ale user bol vytvorený + // Admin môže pridať email account neskôr + console.error('Failed to create email account:', emailError); + } + } + // Log user creation await logUserCreation(adminId, newUser.id, username, 'member', ipAddress, userAgent); @@ -72,17 +82,18 @@ export const createUser = async (req, res) => { user: { id: newUser.id, username: newUser.username, - email: newUser.email, firstName: newUser.firstName, lastName: newUser.lastName, role: newUser.role, - jmapAccountId: newUser.jmapAccountId, - emailSetup: !!newUser.jmapAccountId, + emailSetup: emailAccountCreated, + emailAccount: emailAccountData, tempPassword: tempPassword, // Vráti plain text password pre admina aby ho mohol poslať userovi }, }, - message: newUser.jmapAccountId - ? 'Používateľ úspešne vytvorený s emailovým účtom.' + message: emailAccountCreated + ? emailAccountData.shared + ? 'Používateľ vytvorený a pripojený k existujúcemu zdieľanému email účtu.' + : 'Používateľ úspešne vytvorený s novým emailovým účtom.' : 'Používateľ úspešne vytvorený. Email môže byť nastavený neskôr.', }); } catch (error) { @@ -101,7 +112,6 @@ export const getAllUsers = async (req, res) => { .select({ id: users.id, username: users.username, - email: users.email, firstName: users.firstName, lastName: users.lastName, role: users.role, @@ -136,7 +146,6 @@ export const getUser = async (req, res) => { .select({ id: users.id, username: users.username, - email: users.email, firstName: users.firstName, lastName: users.lastName, role: users.role, @@ -153,9 +162,17 @@ export const getUser = async (req, res) => { throw new NotFoundError('Používateľ nenájdený'); } + // Get user's email accounts (cez many-to-many) + const userEmailAccounts = await emailAccountService.getUserEmailAccounts(userId); + res.status(200).json({ success: true, - data: { user }, + data: { + user: { + ...user, + emailAccounts: userEmailAccounts, + }, + }, }); } catch (error) { const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); diff --git a/src/controllers/contact.controller.js b/src/controllers/contact.controller.js index 2cce4a5..76d3e90 100644 --- a/src/controllers/contact.controller.js +++ b/src/controllers/contact.controller.js @@ -5,15 +5,29 @@ import * as emailAccountService from '../services/email-account.service.js'; import { logger } from '../utils/logger.js'; /** - * Get all contacts for authenticated user - * GET /api/contacts?accountId=xxx (optional) + * Get all contacts for an email account + * GET /api/contacts?accountId=xxx (required) */ export const getContacts = async (req, res) => { try { const userId = req.userId; const { accountId } = req.query; - const contacts = await contactService.getUserContacts(userId, accountId || null); + 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); + + // Get contacts for email account + const contacts = await contactService.getContactsForEmailAccount(accountId); res.status(200).json({ success: true, @@ -70,7 +84,7 @@ export const discoverContacts = async (req, res) => { const potentialContacts = await discoverContactsFromJMAP( jmapConfig, - userId, + emailAccount.id, // emailAccountId search, parseInt(limit) ); @@ -134,12 +148,12 @@ export const addContact = async (req, res) => { const jmapConfig = getJmapConfigFromAccount(emailAccount); const contact = await contactService.addContact( - userId, emailAccount.id, jmapConfig, email, name, - notes + notes, + userId // addedByUserId ); res.status(201).json({ @@ -155,14 +169,28 @@ export const addContact = async (req, res) => { /** * Remove a contact - * DELETE /api/contacts/:contactId + * DELETE /api/contacts/:contactId?accountId=xxx */ export const removeContact = async (req, res) => { try { const userId = req.userId; const { contactId } = req.params; + const { accountId } = req.query; - const result = await contactService.removeContact(userId, contactId); + 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.removeContact(contactId, accountId); res.status(200).json({ success: true, @@ -176,15 +204,29 @@ export const removeContact = async (req, res) => { /** * Update a contact - * PATCH /api/contacts/:contactId + * PATCH /api/contacts/:contactId?accountId=xxx */ export const updateContact = async (req, res) => { try { const userId = req.userId; const { contactId } = req.params; + const { accountId } = req.query; const { name, notes } = req.body; - const updated = await contactService.updateContact(userId, contactId, { name, notes }); + 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.updateContact(contactId, accountId, { name, notes }); res.status(200).json({ success: true, diff --git a/src/controllers/crm-email.controller.js b/src/controllers/crm-email.controller.js index 8e41ec2..159e767 100644 --- a/src/controllers/crm-email.controller.js +++ b/src/controllers/crm-email.controller.js @@ -8,14 +8,27 @@ import { logger } from '../utils/logger.js'; /** * Get all emails for authenticated user - * GET /api/emails?accountId=xxx (optional) + * GET /api/emails?accountId=xxx (REQUIRED) */ export const getEmails = async (req, res) => { try { const userId = req.userId; const { accountId } = req.query; - const emails = await crmEmailService.getUserEmails(userId, accountId || null); + 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 emails = await crmEmailService.getEmailsForAccount(accountId); res.status(200).json({ success: true, @@ -30,14 +43,28 @@ export const getEmails = async (req, res) => { /** * Get emails by thread (conversation) - * GET /api/emails/thread/:threadId + * GET /api/emails/thread/:threadId?accountId=xxx (accountId required) */ export const getThread = async (req, res) => { try { const userId = req.userId; const { threadId } = req.params; + const { accountId } = req.query; - const thread = await crmEmailService.getEmailThread(userId, threadId); + 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 thread = await crmEmailService.getEmailThread(accountId, threadId); res.status(200).json({ success: true, @@ -52,14 +79,27 @@ export const getThread = async (req, res) => { /** * Search emails - * GET /api/emails/search?q=query&accountId=xxx (accountId optional) + * GET /api/emails/search?q=query&accountId=xxx (accountId required) */ export const searchEmails = async (req, res) => { try { const userId = req.userId; const { q, accountId } = req.query; - const results = await crmEmailService.searchEmails(userId, q, accountId || null); + 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 results = await crmEmailService.searchEmails(accountId, q); res.status(200).json({ success: true, @@ -80,14 +120,20 @@ export const searchEmails = async (req, res) => { export const getUnreadCount = async (req, res) => { try { const userId = req.userId; - const unreadData = await crmEmailService.getUnreadCount(userId); + + // Get all user's email accounts + const userAccounts = await emailAccountService.getUserEmailAccounts(userId); + const emailAccountIds = userAccounts.map((account) => account.id); + + // Get unread count summary for all accounts + const unreadData = await crmEmailService.getUnreadCountSummary(emailAccountIds); res.status(200).json({ success: true, data: { count: unreadData.totalUnread, totalUnread: unreadData.totalUnread, - accounts: unreadData.accounts, + accounts: unreadData.byAccount, lastUpdatedAt: new Date().toISOString(), }, }); @@ -127,7 +173,7 @@ export const syncEmails = async (req, res) => { } // Get contacts for this email account - const contacts = await contactService.getUserContacts(userId, emailAccount.id); + const contacts = await contactService.getContactsForEmailAccount(emailAccount.id); if (!contacts.length) { return res.status(200).json({ @@ -145,7 +191,6 @@ export const syncEmails = async (req, res) => { try { const { total, saved } = await syncEmailsFromSender( jmapConfig, - userId, emailAccount.id, contact.id, contact.email, @@ -175,17 +220,27 @@ export const syncEmails = async (req, res) => { /** * Mark email as read/unread - * PATCH /api/emails/:jmapId/read + * PATCH /api/emails/:jmapId/read?accountId=xxx */ export const markAsRead = async (req, res) => { try { const userId = req.userId; const { jmapId } = req.params; - const { isRead } = req.body; + const { isRead, accountId } = req.body; - // Get user to access JMAP config - const user = await getUserById(userId); - const jmapConfig = getJmapConfig(user); + 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 + const emailAccount = await emailAccountService.getEmailAccountWithCredentials(accountId, userId); + const jmapConfig = getJmapConfigFromAccount(emailAccount); await markEmailAsRead(jmapConfig, userId, jmapId, isRead); @@ -201,35 +256,40 @@ export const markAsRead = async (req, res) => { /** * Mark all emails from contact as read - * POST /api/emails/contact/:contactId/read + * POST /api/emails/contact/:contactId/read?accountId=xxx */ export const markContactEmailsRead = async (req, res) => { try { const userId = req.userId; const { contactId } = req.params; + const { accountId } = req.query; - // Get contact to find which email account it belongs to - const contact = await contactService.getContactById(contactId, userId); - if (!contact) { - return res.status(404).json({ + if (!accountId) { + return res.status(400).json({ success: false, - error: { message: 'Kontakt nenájdený', statusCode: 404 }, + error: { + message: 'accountId je povinný parameter', + statusCode: 400, + }, }); } - // Get email account with credentials - const emailAccount = await emailAccountService.getEmailAccountWithCredentials(contact.emailAccountId, userId); + // Verify user has access to this email account + const emailAccount = await emailAccountService.getEmailAccountWithCredentials(accountId, userId); const jmapConfig = getJmapConfigFromAccount(emailAccount); - // Mark emails as read in database and get the updated emails - const result = await crmEmailService.markContactEmailsAsRead(userId, contactId); + // Mark emails as read in database + const count = await crmEmailService.markContactEmailsAsRead(contactId, accountId); + + // Get the emails that were marked as read to sync with JMAP + const contactEmails = await crmEmailService.getContactEmailsWithUnread(accountId, contactId); // Also mark emails as read on JMAP server - if (result.emails && result.emails.length > 0) { - logger.info(`Marking ${result.emails.length} emails as read on JMAP server`); + if (contactEmails && contactEmails.length > 0) { + logger.info(`Marking ${contactEmails.length} emails as read on JMAP server`); - for (const email of result.emails) { - if (!email.jmapId) { + for (const email of contactEmails) { + if (!email.jmapId || email.isRead) { continue; } try { @@ -243,8 +303,8 @@ export const markContactEmailsRead = async (req, res) => { res.status(200).json({ success: true, - message: `Označených ${result.count} emailov ako prečítaných`, - data: result, + message: `Označených ${count} emailov ako prečítaných`, + data: { count }, }); } catch (error) { logger.error('ERROR in markContactEmailsRead', { error: error.message }); @@ -255,41 +315,50 @@ export const markContactEmailsRead = async (req, res) => { /** * Mark entire thread as read - * POST /api/emails/thread/:threadId/read + * POST /api/emails/thread/:threadId/read?accountId=xxx */ export const markThreadRead = async (req, res) => { try { const userId = req.userId; const { threadId } = req.params; + const { accountId } = req.query; - const user = await getUserById(userId); - const threadEmails = await crmEmailService.getEmailThread(userId, threadId); - const unreadEmails = threadEmails.filter((email) => !email.isRead); - - let jmapConfig = null; - if (user?.email && user?.emailPassword && user?.jmapAccountId) { - jmapConfig = getJmapConfig(user); + if (!accountId) { + return res.status(400).json({ + success: false, + error: { + message: 'accountId je povinný parameter', + statusCode: 400, + }, + }); } - if (jmapConfig) { - for (const email of unreadEmails) { - if (!email.jmapId) { - continue; - } - try { - await markEmailAsRead(jmapConfig, userId, email.jmapId, true); - } catch (jmapError) { - logger.error('Failed to mark JMAP email as read', { jmapId: email.jmapId, error: jmapError.message }); - } + // Verify user has access to this email account + const emailAccount = await emailAccountService.getEmailAccountWithCredentials(accountId, userId); + const jmapConfig = getJmapConfigFromAccount(emailAccount); + + const threadEmails = await crmEmailService.getEmailThread(accountId, threadId); + const unreadEmails = threadEmails.filter((email) => !email.isRead); + + // Mark emails as read on JMAP server + for (const email of unreadEmails) { + if (!email.jmapId) { + continue; + } + try { + await markEmailAsRead(jmapConfig, userId, email.jmapId, true); + } catch (jmapError) { + logger.error('Failed to mark JMAP email as read', { jmapId: email.jmapId, error: jmapError.message }); } } - await crmEmailService.markThreadAsRead(userId, threadId); + // Mark thread as read in database + const count = await crmEmailService.markThreadAsRead(accountId, threadId); res.status(200).json({ success: true, message: 'Konverzácia označená ako prečítaná', - count: unreadEmails.length, + count, }); } catch (error) { const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); @@ -352,14 +421,28 @@ export const replyToEmail = async (req, res) => { /** * Get emails for a specific contact - * GET /api/emails/contact/:contactId + * GET /api/emails/contact/:contactId?accountId=xxx */ export const getContactEmails = async (req, res) => { try { const userId = req.userId; const { contactId } = req.params; + const { accountId } = req.query; - const emails = await crmEmailService.getContactEmails(userId, contactId); + 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 emails = await crmEmailService.getContactEmailsWithUnread(accountId, contactId); res.status(200).json({ success: true, @@ -409,7 +492,7 @@ export const searchEmailsJMAP = async (req, res) => { const results = await searchEmailsJMAPService( jmapConfig, - userId, + emailAccount.id, query, parseInt(limit), parseInt(offset) diff --git a/src/controllers/timesheet.controller.js b/src/controllers/timesheet.controller.js new file mode 100644 index 0000000..f78b04a --- /dev/null +++ b/src/controllers/timesheet.controller.js @@ -0,0 +1,282 @@ +import { db } from '../config/database.js'; +import { timesheets, users } from '../db/schema.js'; +import { eq, and, desc } from 'drizzle-orm'; +import { formatErrorResponse, NotFoundError, BadRequestError, ForbiddenError } from '../utils/errors.js'; +import fs from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** + * Upload timesheet + * POST /api/timesheets/upload + */ +export const uploadTimesheet = async (req, res) => { + const { year, month } = req.body; + const userId = req.userId; + const file = req.file; + + let savedFilePath = null; + + try { + if (!file) { + throw new BadRequestError('Súbor nebol nahraný'); + } + + // Validate file type + const allowedTypes = ['application/pdf', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-excel']; + if (!allowedTypes.includes(file.mimetype)) { + throw new BadRequestError('Neplatný typ súboru. Povolené sú iba PDF a Excel súbory.'); + } + + // Determine file type + let fileType = 'pdf'; + if (file.mimetype.includes('sheet') || file.mimetype.includes('excel')) { + fileType = 'xlsx'; + } + + // Create directory structure: uploads/timesheets/{userId}/{year}/{month} + const uploadsDir = path.join(process.cwd(), 'uploads', 'timesheets'); + const userDir = path.join(uploadsDir, userId, year.toString(), month.toString()); + await fs.mkdir(userDir, { recursive: true }); + + // Generate unique filename + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); + const ext = path.extname(file.originalname); + const name = path.basename(file.originalname, ext); + const filename = `${name}-${uniqueSuffix}${ext}`; + savedFilePath = path.join(userDir, filename); + + // Save file from memory buffer to disk + await fs.writeFile(savedFilePath, file.buffer); + + // Create timesheet record + const [newTimesheet] = await db + .insert(timesheets) + .values({ + userId, + fileName: file.originalname, + filePath: savedFilePath, + fileType, + fileSize: file.size, + year: parseInt(year), + month: parseInt(month), + }) + .returning(); + + res.status(201).json({ + success: true, + data: { + timesheet: { + id: newTimesheet.id, + fileName: newTimesheet.fileName, + fileType: newTimesheet.fileType, + fileSize: newTimesheet.fileSize, + year: newTimesheet.year, + month: newTimesheet.month, + uploadedAt: newTimesheet.uploadedAt, + }, + }, + message: 'Timesheet bol úspešne nahraný', + }); + } catch (error) { + // If error occurs and file was saved, delete it + if (savedFilePath) { + try { + await fs.unlink(savedFilePath); + } catch (unlinkError) { + console.error('Failed to delete file:', unlinkError); + } + } + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Get user's timesheets (with optional filters) + * GET /api/timesheets/my + */ +export const getMyTimesheets = async (req, res) => { + const userId = req.userId; + const { year, month } = req.query; + + try { + let conditions = [eq(timesheets.userId, userId)]; + + if (year) { + conditions.push(eq(timesheets.year, parseInt(year))); + } + + if (month) { + conditions.push(eq(timesheets.month, parseInt(month))); + } + + const userTimesheets = await db + .select({ + id: timesheets.id, + fileName: timesheets.fileName, + fileType: timesheets.fileType, + fileSize: timesheets.fileSize, + year: timesheets.year, + month: timesheets.month, + uploadedAt: timesheets.uploadedAt, + }) + .from(timesheets) + .where(and(...conditions)) + .orderBy(desc(timesheets.uploadedAt)); + + res.status(200).json({ + success: true, + data: { + timesheets: userTimesheets, + count: userTimesheets.length, + }, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Get all users' timesheets (admin only) - grouped by user + * GET /api/timesheets/all + */ +export const getAllTimesheets = async (req, res) => { + const { userId: filterUserId, year, month } = req.query; + + try { + let conditions = []; + + if (filterUserId) { + conditions.push(eq(timesheets.userId, filterUserId)); + } + + if (year) { + conditions.push(eq(timesheets.year, parseInt(year))); + } + + if (month) { + conditions.push(eq(timesheets.month, parseInt(month))); + } + + const allTimesheets = await db + .select({ + id: timesheets.id, + fileName: timesheets.fileName, + fileType: timesheets.fileType, + fileSize: timesheets.fileSize, + year: timesheets.year, + month: timesheets.month, + uploadedAt: timesheets.uploadedAt, + userId: timesheets.userId, + username: users.username, + firstName: users.firstName, + lastName: users.lastName, + }) + .from(timesheets) + .leftJoin(users, eq(timesheets.userId, users.id)) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(desc(timesheets.uploadedAt)); + + res.status(200).json({ + success: true, + data: { + timesheets: allTimesheets, + count: allTimesheets.length, + }, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Download timesheet file + * GET /api/timesheets/:timesheetId/download + */ +export const downloadTimesheet = async (req, res) => { + const { timesheetId } = req.params; + const userId = req.userId; + const userRole = req.user.role; // Fix: use req.user.role instead of req.userRole + + try { + const [timesheet] = await db + .select() + .from(timesheets) + .where(eq(timesheets.id, timesheetId)) + .limit(1); + + if (!timesheet) { + throw new NotFoundError('Timesheet nenájdený'); + } + + // Check permissions: user can only download their own timesheets, admin can download all + if (userRole !== 'admin' && timesheet.userId !== userId) { + throw new ForbiddenError('Nemáte oprávnenie stiahnuť tento timesheet'); + } + + // Check if file exists + try { + await fs.access(timesheet.filePath); + } catch { + throw new NotFoundError('Súbor nebol nájdený na serveri'); + } + + // Send file + res.download(timesheet.filePath, timesheet.fileName); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Delete timesheet + * DELETE /api/timesheets/:timesheetId + */ +export const deleteTimesheet = async (req, res) => { + const { timesheetId } = req.params; + const userId = req.userId; + const userRole = req.user.role; // Fix: use req.user.role instead of req.userRole + + try { + const [timesheet] = await db + .select() + .from(timesheets) + .where(eq(timesheets.id, timesheetId)) + .limit(1); + + if (!timesheet) { + throw new NotFoundError('Timesheet nenájdený'); + } + + // Check permissions: user can only delete their own timesheets, admin can delete all + if (userRole !== 'admin' && timesheet.userId !== userId) { + throw new ForbiddenError('Nemáte oprávnenie zmazať tento timesheet'); + } + + // Delete file from filesystem + try { + await fs.unlink(timesheet.filePath); + } catch (unlinkError) { + console.error('Failed to delete file from filesystem:', unlinkError); + // Continue with database deletion even if file deletion fails + } + + // Delete from database + await db.delete(timesheets).where(eq(timesheets.id, timesheetId)); + + res.status(200).json({ + success: true, + message: 'Timesheet bol zmazaný', + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; diff --git a/src/db/migrations/0003_add_timesheets_table.sql b/src/db/migrations/0003_add_timesheets_table.sql new file mode 100644 index 0000000..94add12 --- /dev/null +++ b/src/db/migrations/0003_add_timesheets_table.sql @@ -0,0 +1,15 @@ +CREATE TABLE "timesheets" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "file_name" text NOT NULL, + "file_path" text NOT NULL, + "file_type" text NOT NULL, + "file_size" integer NOT NULL, + "year" integer NOT NULL, + "month" integer NOT NULL, + "uploaded_at" timestamp DEFAULT now() NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "timesheets" ADD CONSTRAINT "timesheets_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; diff --git a/src/db/schema.js b/src/db/schema.js index 19f628a..4ba1990 100644 --- a/src/db/schema.js +++ b/src/db/schema.js @@ -1,39 +1,47 @@ -import { pgTable, text, timestamp, boolean, uuid, pgEnum } from 'drizzle-orm/pg-core'; +import { pgTable, text, timestamp, boolean, uuid, pgEnum, unique, integer } from 'drizzle-orm/pg-core'; // Role enum export const roleEnum = pgEnum('role', ['admin', 'member']); -// Users table - hlavná tabuľka používateľov +// Users table - používatelia systému export const users = pgTable('users', { id: uuid('id').primaryKey().defaultRandom(), username: text('username').notNull().unique(), - email: text('email').unique(), - emailPassword: text('email_password'), // Heslo k emailovému účtu (encrypted) - jmapAccountId: text('jmap_account_id'), // JMAP account ID z truemail firstName: text('first_name'), lastName: text('last_name'), password: text('password'), // bcrypt hash (null ak ešte nenastavené) tempPassword: text('temp_password'), // dočasné heslo (bcrypt hash) - changedPassword: boolean('changed_password').default(false), // či si užívateľ zmenil heslo + changedPassword: boolean('changed_password').default(false), role: roleEnum('role').default('member').notNull(), lastLogin: timestamp('last_login'), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), }); -// Email Accounts table - viacero emailových účtov pre jedného usera +// Email Accounts table - emailové účty (môžu byť zdieľané medzi viacerými používateľmi) export const emailAccounts = pgTable('email_accounts', { id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), - email: text('email').notNull(), + email: text('email').notNull().unique(), // Email adresa emailPassword: text('email_password').notNull(), // Heslo k emailovému účtu (encrypted) jmapAccountId: text('jmap_account_id').notNull(), // JMAP account ID z truemail - isPrimary: boolean('is_primary').default(false).notNull(), // primárny email účet isActive: boolean('is_active').default(true).notNull(), // či je účet aktívny createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), }); +// User Email Accounts - many-to-many medzi users a emailAccounts +// Umožňuje zdieľať email účty medzi viacerými používateľmi +export const userEmailAccounts = pgTable('user_email_accounts', { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), + emailAccountId: uuid('email_account_id').references(() => emailAccounts.id, { onDelete: 'cascade' }).notNull(), + isPrimary: boolean('is_primary').default(false).notNull(), // primárny email účet pre daného usera + addedAt: timestamp('added_at').defaultNow().notNull(), +}, (table) => ({ + // Jeden user môže mať email account len raz + userEmailUnique: unique('user_email_unique').on(table.userId, table.emailAccountId), +})); + // Audit logs - kompletný audit trail všetkých akcií export const auditLogs = pgTable('audit_logs', { id: uuid('id').primaryKey().defaultRandom(), @@ -50,23 +58,27 @@ export const auditLogs = pgTable('audit_logs', { createdAt: timestamp('created_at').defaultNow().notNull(), }); -// Contacts table - ľudia s ktorými komunikujeme cez email +// Contacts table - kontakty patriace k emailovému účtu +// Kontakty sú zdieľané medzi všetkými používateľmi, ktorí majú prístup k danému email accountu export const contacts = pgTable('contacts', { id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), emailAccountId: uuid('email_account_id').references(() => emailAccounts.id, { onDelete: 'cascade' }).notNull(), email: text('email').notNull(), name: text('name'), notes: text('notes'), + addedBy: uuid('added_by').references(() => users.id, { onDelete: 'set null' }), // kto pridal kontakt addedAt: timestamp('added_at').defaultNow().notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), -}); +}, (table) => ({ + // Unique constraint: jeden email môže byť len raz v rámci email accountu + accountEmailUnique: unique('account_email_unique').on(table.emailAccountId, table.email), +})); -// Emails table - uložené emaily z JMAP (iba pre pridané kontakty) +// Emails table - uložené emaily z JMAP +// Emaily patria k email accountu a sú zdieľané medzi všetkými používateľmi s prístupom export const emails = pgTable('emails', { id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), emailAccountId: uuid('email_account_id').references(() => emailAccounts.id, { onDelete: 'cascade' }).notNull(), contactId: uuid('contact_id').references(() => contacts.id, { onDelete: 'cascade' }), jmapId: text('jmap_id').unique(), @@ -78,7 +90,23 @@ export const emails = pgTable('emails', { subject: text('subject'), body: text('body'), isRead: boolean('is_read').default(false).notNull(), + sentByUserId: uuid('sent_by_user_id').references(() => users.id, { onDelete: 'set null' }), // kto poslal odpoveď (null ak prijatý email) date: timestamp('date'), 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 + 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' + fileSize: integer('file_size').notNull(), // veľkosť súboru v bytoch + year: integer('year').notNull(), // rok (napr. 2024) + month: integer('month').notNull(), // mesiac (1-12) + uploadedAt: timestamp('uploaded_at').defaultNow().notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); diff --git a/src/routes/timesheet.routes.js b/src/routes/timesheet.routes.js new file mode 100644 index 0000000..0da4de4 --- /dev/null +++ b/src/routes/timesheet.routes.js @@ -0,0 +1,88 @@ +import express from 'express'; +import multer from 'multer'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import * as timesheetController from '../controllers/timesheet.controller.js'; +import { authenticate } from '../middlewares/auth/authMiddleware.js'; +import { requireAdmin } from '../middlewares/auth/roleMiddleware.js'; +import { validateBody, validateParams } from '../middlewares/security/validateInput.js'; +import { z } from 'zod'; +import fs from 'fs/promises'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const router = express.Router(); + +// Create uploads directory if it doesn't exist +const uploadsDir = path.join(process.cwd(), 'uploads', 'timesheets'); +await fs.mkdir(uploadsDir, { recursive: true }); + +// Configure multer for file uploads - use memory storage first +const upload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: 10 * 1024 * 1024, // 10MB limit + }, + fileFilter: (req, file, cb) => { + const allowedTypes = ['application/pdf', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-excel']; + if (allowedTypes.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error('Neplatný typ súboru. Povolené sú iba PDF a Excel súbory.'), false); + } + } +}); + +/** + * All timesheet routes require authentication + */ +router.use(authenticate); + +/** + * Upload timesheet + * POST /api/timesheets/upload + */ +router.post( + '/upload', + upload.single('file'), + validateBody(z.object({ + year: z.string().regex(/^\d{4}$/, 'Rok musí byť 4-miestne číslo'), + month: z.string().regex(/^([1-9]|1[0-2])$/, 'Mesiac musí byť číslo od 1 do 12'), + })), + timesheetController.uploadTimesheet +); + +/** + * Get user's timesheets + * GET /api/timesheets/my + */ +router.get('/my', timesheetController.getMyTimesheets); + +/** + * Get all timesheets (admin only) + * GET /api/timesheets/all + */ +router.get('/all', requireAdmin, timesheetController.getAllTimesheets); + +/** + * Download timesheet + * GET /api/timesheets/:timesheetId/download + */ +router.get( + '/:timesheetId/download', + validateParams(z.object({ timesheetId: z.string().uuid() })), + timesheetController.downloadTimesheet +); + +/** + * Delete timesheet + * DELETE /api/timesheets/:timesheetId + */ +router.delete( + '/:timesheetId', + validateParams(z.object({ timesheetId: z.string().uuid() })), + timesheetController.deleteTimesheet +); + +export default router; diff --git a/src/scripts/fix-duplicate-contacts.js b/src/scripts/fix-duplicate-contacts.js deleted file mode 100644 index 1bc9dc4..0000000 --- a/src/scripts/fix-duplicate-contacts.js +++ /dev/null @@ -1,90 +0,0 @@ -import { db } from '../config/database.js'; -import { contacts, emails } from '../db/schema.js'; -import { eq, and, sql } from 'drizzle-orm'; -import { logger } from '../utils/logger.js'; - -/** - * Fix duplicate contacts by merging them - * - Finds contacts with the same email address - * - Keeps the newest contact - * - Updates all emails to use the newest contact ID - * - Deletes old duplicate contacts - */ -async function fixDuplicateContacts() { - try { - logger.info('🔍 Finding duplicate contacts...'); - - // Find duplicate contacts (same userId + email) - const duplicates = await db - .select({ - userId: contacts.userId, - email: contacts.email, - count: sql`count(*)::int`, - ids: sql`array_agg(${contacts.id} ORDER BY ${contacts.createdAt} DESC)`, - }) - .from(contacts) - .groupBy(contacts.userId, contacts.email) - .having(sql`count(*) > 1`); - - if (duplicates.length === 0) { - logger.success('✅ No duplicate contacts found!'); - return; - } - - logger.info(`Found ${duplicates.length} sets of duplicate contacts`); - - let totalFixed = 0; - let totalDeleted = 0; - - for (const dup of duplicates) { - const contactIds = dup.ids; - const newestContactId = contactIds[0]; // First one (ordered by createdAt DESC) - const oldContactIds = contactIds.slice(1); // Rest are duplicates - - logger.info(`\n📧 Fixing duplicates for ${dup.email}:`); - logger.info(` - Keeping contact: ${newestContactId}`); - logger.info(` - Merging ${oldContactIds.length} duplicate(s): ${oldContactIds.join(', ')}`); - - // Update all emails from old contacts to use the newest contact ID - for (const oldContactId of oldContactIds) { - const updateResult = await db - .update(emails) - .set({ contactId: newestContactId, updatedAt: new Date() }) - .where(eq(emails.contactId, oldContactId)) - .returning(); - - if (updateResult.length > 0) { - logger.success(` ✅ Updated ${updateResult.length} emails from ${oldContactId} → ${newestContactId}`); - totalFixed += updateResult.length; - } - - // Delete the old duplicate contact - await db - .delete(contacts) - .where(eq(contacts.id, oldContactId)); - - logger.success(` 🗑️ Deleted duplicate contact: ${oldContactId}`); - totalDeleted++; - } - } - - logger.success(`\n✅ Cleanup complete!`); - logger.success(` - Fixed ${totalFixed} emails`); - logger.success(` - Deleted ${totalDeleted} duplicate contacts`); - - } catch (error) { - logger.error('❌ Error fixing duplicate contacts:', error); - throw error; - } -} - -// Run the script -fixDuplicateContacts() - .then(() => { - logger.success('🎉 Script completed successfully!'); - process.exit(0); - }) - .catch((error) => { - logger.error('💥 Script failed:', error); - process.exit(1); - }); diff --git a/src/scripts/fix-wrong-contact-associations.js b/src/scripts/fix-wrong-contact-associations.js new file mode 100644 index 0000000..6141985 --- /dev/null +++ b/src/scripts/fix-wrong-contact-associations.js @@ -0,0 +1,104 @@ +import { db } from '../config/database.js'; +import { emails, contacts } from '../db/schema.js'; +import { eq, and, or, ne, isNotNull } from 'drizzle-orm'; +import { logger } from '../utils/logger.js'; + +/** + * Fix emails that have wrong contactId + * + * This script finds emails where the contactId doesn't match the actual contact + * based on the from/to fields, and updates them to the correct contactId. + * + * Run with: node src/scripts/fix-wrong-contact-associations.js + */ + +async function fixWrongContactAssociations() { + try { + logger.info('🔧 Starting to fix wrong contact associations...'); + + // Get all contacts grouped by email account + const allContacts = await db + .select() + .from(contacts) + .orderBy(contacts.emailAccountId, contacts.email); + + logger.info(`Found ${allContacts.length} contacts to process`); + + let totalFixed = 0; + let totalChecked = 0; + + // Process each contact + for (const contact of allContacts) { + logger.info(`\n📧 Processing contact: ${contact.email} (${contact.name})`); + + // Find emails that belong to this contact but have wrong contactId + // Email belongs to contact if from === contact.email OR to === contact.email + const wrongEmails = await db + .select() + .from(emails) + .where( + and( + eq(emails.emailAccountId, contact.emailAccountId), + ne(emails.contactId, contact.id), // Has different contactId + or( + eq(emails.from, contact.email), + eq(emails.to, contact.email) + ) + ) + ); + + totalChecked += wrongEmails.length; + + if (wrongEmails.length > 0) { + logger.info(` ⚠️ Found ${wrongEmails.length} emails with wrong contactId`); + + for (const email of wrongEmails) { + // Get old contact name for logging + const [oldContact] = await db + .select() + .from(contacts) + .where(eq(contacts.id, email.contactId)) + .limit(1); + + logger.info(` 📬 Fixing email "${email.subject}"`); + logger.info(` From: ${email.from} → To: ${email.to}`); + logger.info(` Old contact: ${oldContact?.email || 'unknown'} → New contact: ${contact.email}`); + + // Update to correct contactId + await db + .update(emails) + .set({ contactId: contact.id }) + .where(eq(emails.id, email.id)); + + totalFixed++; + } + + logger.success(` ✅ Fixed ${wrongEmails.length} emails for ${contact.email}`); + } else { + logger.info(` ✅ No wrong associations found for ${contact.email}`); + } + } + + logger.success(`\n✅ Fix completed! + - Total contacts checked: ${allContacts.length} + - Total wrong emails found: ${totalChecked} + - Total emails fixed: ${totalFixed} +`); + + return { totalContacts: allContacts.length, totalChecked, totalFixed }; + } catch (error) { + logger.error('Error fixing contact associations', error); + throw error; + } +} + +// Run the script +fixWrongContactAssociations() + .then((result) => { + logger.success('Script finished successfully', result); + process.exit(0); + }) + .catch((error) => { + logger.error('Script failed', error); + process.exit(1); + }); diff --git a/src/scripts/fresh-database.js b/src/scripts/fresh-database.js new file mode 100644 index 0000000..e9a74e7 --- /dev/null +++ b/src/scripts/fresh-database.js @@ -0,0 +1,189 @@ +import { db } from '../config/database.js'; +import { sql } from 'drizzle-orm'; +import { logger } from '../utils/logger.js'; + +/** + * Fresh database - vymaže všetky tabuľky a vytvorí ich znova + * + * ⚠️ POZOR: Tento script vymaže všetky dáta! + * Použite len na development alebo pri začiatku s novými dátami. + */ +async function freshDatabase() { + try { + logger.warn('\n⚠️ POZOR: Tento script vymaže všetky dáta!'); + logger.warn(' Čaká sa 5 sekúnd... Stlač Ctrl+C na zrušenie.\n'); + + // Wait 5 seconds + await new Promise(resolve => setTimeout(resolve, 5000)); + + logger.info('🔄 Dropping all tables...'); + + // Drop all tables in correct order (reverse of dependencies) + await db.execute(sql`DROP TABLE IF EXISTS emails CASCADE`); + logger.success(' ✅ Dropped table: emails'); + + await db.execute(sql`DROP TABLE IF EXISTS contacts CASCADE`); + logger.success(' ✅ Dropped table: contacts'); + + await db.execute(sql`DROP TABLE IF EXISTS user_email_accounts CASCADE`); + logger.success(' ✅ Dropped table: user_email_accounts'); + + await db.execute(sql`DROP TABLE IF EXISTS email_accounts CASCADE`); + logger.success(' ✅ Dropped table: email_accounts'); + + await db.execute(sql`DROP TABLE IF EXISTS audit_logs CASCADE`); + logger.success(' ✅ Dropped table: audit_logs'); + + await db.execute(sql`DROP TABLE IF EXISTS users CASCADE`); + logger.success(' ✅ Dropped table: users'); + + await db.execute(sql`DROP TYPE IF EXISTS role CASCADE`); + logger.success(' ✅ Dropped type: role'); + + logger.info('\n🔨 Creating all tables...'); + + // Create role enum + await db.execute(sql`CREATE TYPE role AS ENUM ('admin', 'member')`); + logger.success(' ✅ Created type: role'); + + // Create users table + await db.execute(sql` + CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username TEXT NOT NULL UNIQUE, + first_name TEXT, + last_name TEXT, + password TEXT, + temp_password TEXT, + changed_password BOOLEAN NOT NULL DEFAULT false, + role role NOT NULL DEFAULT 'member', + last_login TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() + ) + `); + logger.success(' ✅ Created table: users'); + + // Create email_accounts table + await db.execute(sql` + CREATE TABLE email_accounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL UNIQUE, + email_password TEXT NOT NULL, + jmap_account_id TEXT NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() + ) + `); + logger.success(' ✅ Created table: email_accounts'); + + // Create user_email_accounts junction table + await db.execute(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, + is_primary BOOLEAN NOT NULL DEFAULT false, + added_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE(user_id, email_account_id) + ) + `); + logger.success(' ✅ Created table: user_email_accounts'); + + // Create contacts table + await db.execute(sql` + CREATE TABLE contacts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email_account_id UUID NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE, + email TEXT NOT NULL, + name TEXT, + notes TEXT, + added_by UUID REFERENCES users(id) ON DELETE SET NULL, + added_at TIMESTAMP NOT NULL DEFAULT NOW(), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE(email_account_id, email) + ) + `); + logger.success(' ✅ Created table: contacts'); + + // Create emails table + await db.execute(sql` + CREATE TABLE emails ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email_account_id UUID NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE, + contact_id UUID REFERENCES contacts(id) ON DELETE 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 NOT NULL DEFAULT false, + sent_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + date TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() + ) + `); + logger.success(' ✅ Created table: emails'); + + // Create audit_logs table + await db.execute(sql` + CREATE TABLE audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + action TEXT NOT NULL, + resource TEXT NOT NULL, + resource_id TEXT, + old_value TEXT, + new_value TEXT, + ip_address TEXT, + user_agent TEXT, + success BOOLEAN NOT NULL DEFAULT true, + error_message TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW() + ) + `); + logger.success(' ✅ Created table: audit_logs'); + + // Create indexes + logger.info('\n📊 Creating indexes...'); + + await db.execute(sql`CREATE INDEX idx_user_email_accounts_user ON user_email_accounts(user_id)`); + await db.execute(sql`CREATE INDEX idx_user_email_accounts_account ON user_email_accounts(email_account_id)`); + await db.execute(sql`CREATE INDEX idx_contacts_account ON contacts(email_account_id)`); + await db.execute(sql`CREATE INDEX idx_contacts_email ON contacts(email)`); + await db.execute(sql`CREATE INDEX idx_emails_account ON emails(email_account_id)`); + await db.execute(sql`CREATE INDEX idx_emails_contact ON emails(contact_id)`); + await db.execute(sql`CREATE INDEX idx_emails_thread ON emails(thread_id)`); + await db.execute(sql`CREATE INDEX idx_emails_date ON emails(date DESC)`); + + logger.success(' ✅ Created all indexes'); + + logger.success('\n✅ Fresh database created successfully!'); + logger.info('\n📝 Next steps:'); + logger.info(' 1. Run seed script to create admin user:'); + logger.info(' node src/scripts/seed-admin.js'); + logger.info(' 2. Start the server:'); + logger.info(' npm run dev'); + + } catch (error) { + logger.error('❌ Failed to create fresh database:', error); + throw error; + } +} + +// Run the script +freshDatabase() + .then(() => { + logger.success('🎉 Script completed!'); + process.exit(0); + }) + .catch((error) => { + logger.error('💥 Script failed:', error); + process.exit(1); + }); diff --git a/src/scripts/seed-admin.js b/src/scripts/seed-admin.js new file mode 100644 index 0000000..873964a --- /dev/null +++ b/src/scripts/seed-admin.js @@ -0,0 +1,75 @@ +import { db } from '../config/database.js'; +import { users } from '../db/schema.js'; +import { eq } from 'drizzle-orm'; +import { hashPassword } from '../utils/password.js'; +import { logger } from '../utils/logger.js'; + +/** + * Seed admin user + * + * Vytvorí admin používateľa s credentials: + * - username: admin + * - password: admin123 + * + * ⚠️ DÔLEŽITÉ: Zmeňte heslo po prvom prihlásení! + */ +async function seedAdmin() { + try { + logger.info('🌱 Creating admin user...'); + + const username = 'admin'; + const password = 'admin123'; + const hashedPassword = await hashPassword(password); + + // Check if admin already exists + const existingAdmins = await db.select().from(users).where(eq(users.username, username)); + + if (existingAdmins.length > 0) { + logger.warn('⚠️ Admin user already exists, skipping...'); + logger.info('\nAdmin credentials:'); + logger.info(' Username: admin'); + logger.info(' Password: (unchanged)'); + return; + } + + // Create admin user + const [admin] = await db + .insert(users) + .values({ + username, + password: hashedPassword, + firstName: 'Admin', + lastName: 'User', + role: 'admin', + changedPassword: true, // Admin už má nastavené heslo + }) + .returning(); + + logger.success('✅ Admin user created successfully!'); + logger.info('\n📋 Admin credentials:'); + logger.info(` Username: ${username}`); + logger.info(` Password: ${password}`); + logger.warn('\n⚠️ DÔLEŽITÉ: Zmeňte heslo po prvom prihlásení!'); + + logger.info('\n📝 Next steps:'); + logger.info(' 1. Start the server:'); + logger.info(' npm run dev'); + logger.info(' 2. Login as admin'); + logger.info(' 3. Create users and add email accounts'); + + } catch (error) { + logger.error('❌ Failed to seed admin user:', error); + throw error; + } +} + +// Run the script +seedAdmin() + .then(() => { + logger.success('🎉 Seed completed!'); + process.exit(0); + }) + .catch((error) => { + logger.error('💥 Seed failed:', error); + process.exit(1); + }); diff --git a/src/services/auth.service.js b/src/services/auth.service.js index 2de7eb1..715fec3 100644 --- a/src/services/auth.service.js +++ b/src/services/auth.service.js @@ -1,14 +1,12 @@ import { eq } from 'drizzle-orm'; import { db } from '../config/database.js'; import { users } from '../db/schema.js'; -import { hashPassword, comparePassword, encryptPassword, decryptPassword } from '../utils/password.js'; +import { hashPassword, comparePassword } from '../utils/password.js'; import { generateTokenPair } from '../utils/jwt.js'; -import { validateJmapCredentials } from './email.service.js'; +import * as emailAccountService from './email-account.service.js'; import { AuthenticationError, - ConflictError, NotFoundError, - ValidationError, } from '../utils/errors.js'; /** @@ -55,11 +53,13 @@ export const loginWithTempPassword = async (username, password, ipAddress, userA // Generuj JWT tokeny const tokens = generateTokenPair(user); + // Check if user has email accounts (many-to-many) + const userEmailAccounts = await emailAccountService.getUserEmailAccounts(user.id); + return { user: { id: user.id, username: user.username, - email: user.email, firstName: user.firstName, lastName: user.lastName, role: user.role, @@ -67,7 +67,7 @@ export const loginWithTempPassword = async (username, password, ipAddress, userA }, tokens, needsPasswordChange: !user.changedPassword, - needsEmailSetup: !user.email, + needsEmailSetup: userEmailAccounts.length === 0, }; }; @@ -112,6 +112,7 @@ export const setNewPassword = async (userId, newPassword) => { /** * KROK 3: Pripojenie emailu s JMAP validáciou + * Používa many-to-many vzťah cez userEmailAccounts */ export const linkEmail = async (userId, email, emailPassword) => { const [user] = await db @@ -124,38 +125,22 @@ export const linkEmail = async (userId, email, emailPassword) => { throw new NotFoundError('Používateľ nenájdený'); } - // Skontroluj či email už nie je použitý - const [existingUser] = await db - .select() - .from(users) - .where(eq(users.email, email)) - .limit(1); - - if (existingUser) { - throw new ConflictError('Email už je použitý'); - } - - // Validuj JMAP credentials a získaj account ID - const { accountId } = await validateJmapCredentials(email, emailPassword); - - // Encrypt email password pre bezpečné uloženie (nie hash, lebo potrebujeme decryption pre JMAP) - const encryptedEmailPassword = encryptPassword(emailPassword); - - // Update user s email credentials - await db - .update(users) - .set({ - email, - emailPassword: encryptedEmailPassword, - jmapAccountId: accountId, - updatedAt: new Date(), - }) - .where(eq(users.id, userId)); + // Použij emailAccountService ktorý automaticky vytvorí many-to-many link + const newEmailAccount = await emailAccountService.createEmailAccount( + userId, + email, + emailPassword + ); return { success: true, - accountId, - message: 'Email účet úspešne pripojený', + accountId: newEmailAccount.jmapAccountId, + emailAccountId: newEmailAccount.id, + isPrimary: newEmailAccount.isPrimary, + shared: newEmailAccount.shared, + message: newEmailAccount.shared + ? 'Email účet už existoval a bol zdieľaný s vami' + : 'Nový email účet úspešne vytvorený a pripojený', }; }; @@ -191,15 +176,13 @@ export const logout = async () => { /** * Get user by ID + * Email credentials sú v emailAccounts tabuľke (many-to-many) */ export const getUserById = async (userId) => { const [user] = await db .select({ id: users.id, username: users.username, - email: users.email, - emailPassword: users.emailPassword, - jmapAccountId: users.jmapAccountId, firstName: users.firstName, lastName: users.lastName, role: users.role, @@ -215,5 +198,11 @@ export const getUserById = async (userId) => { throw new NotFoundError('Používateľ nenájdený'); } - return user; + // Get user's email accounts (many-to-many) + const userEmailAccounts = await emailAccountService.getUserEmailAccounts(userId); + + return { + ...user, + emailAccounts: userEmailAccounts, + }; }; diff --git a/src/services/contact.service.js b/src/services/contact.service.js index 288f2ce..25aa2d8 100644 --- a/src/services/contact.service.js +++ b/src/services/contact.service.js @@ -1,41 +1,34 @@ import { db } from '../config/database.js'; import { contacts, emails } from '../db/schema.js'; -import { eq, and, desc } from 'drizzle-orm'; +import { eq, and, desc, or, ne } from 'drizzle-orm'; import { NotFoundError, ConflictError } from '../utils/errors.js'; import { syncEmailsFromSender } from './jmap.service.js'; import { logger } from '../utils/logger.js'; /** - * Get all contacts for a user - * If emailAccountId is provided, filter by that account, otherwise return all + * Get all contacts for an email account + * Kontakty patria k email accountu, nie k jednotlivým používateľom */ -export const getUserContacts = async (userId, emailAccountId = null) => { - const conditions = [eq(contacts.userId, userId)]; - - if (emailAccountId) { - conditions.push(eq(contacts.emailAccountId, emailAccountId)); - } - - const userContacts = await db +export const getContactsForEmailAccount = async (emailAccountId) => { + const accountContacts = await db .select() .from(contacts) - .where(and(...conditions)) + .where(eq(contacts.emailAccountId, emailAccountId)) .orderBy(desc(contacts.addedAt)); - return userContacts; + return accountContacts; }; /** - * Add a new contact and sync their emails + * Add a new contact to an email account */ -export const addContact = async (userId, emailAccountId, jmapConfig, email, name = '', notes = '') => { +export const addContact = async (emailAccountId, jmapConfig, email, name = '', notes = '', addedByUserId = null) => { // Check if contact already exists for this email account const [existing] = await db .select() .from(contacts) .where( and( - eq(contacts.userId, userId), eq(contacts.emailAccountId, emailAccountId), eq(contacts.email, email) ) @@ -50,33 +43,80 @@ export const addContact = async (userId, emailAccountId, jmapConfig, email, name const [newContact] = await db .insert(contacts) .values({ - userId, emailAccountId, email, name: name || email.split('@')[0], notes: notes || null, + addedBy: addedByUserId, }) .returning(); // Sync emails from this sender try { - await syncEmailsFromSender(jmapConfig, userId, emailAccountId, newContact.id, email); + await syncEmailsFromSender(jmapConfig, emailAccountId, newContact.id, email); } catch (error) { logger.error('Failed to sync emails for new contact', { error: error.message }); // Don't throw - contact was created successfully } + // REASSIGN: Fix any existing emails that belong to this contact but have wrong contactId + try { + logger.info(`Checking for emails to reassign to contact ${email}...`); + + // Find emails where: + // - from === newContact.email OR to === newContact.email + // - contactId !== newContact.id (belongs to different contact) + // - emailAccountId matches + const emailsToReassign = await db + .select() + .from(emails) + .where( + and( + eq(emails.emailAccountId, emailAccountId), + ne(emails.contactId, newContact.id), + or( + eq(emails.from, email), + eq(emails.to, email) + ) + ) + ); + + if (emailsToReassign.length > 0) { + logger.info(`Found ${emailsToReassign.length} emails to reassign to contact ${email}`); + + // Update contactId for these emails + for (const emailToReassign of emailsToReassign) { + await db + .update(emails) + .set({ contactId: newContact.id }) + .where(eq(emails.id, emailToReassign.id)); + } + + logger.success(`Reassigned ${emailsToReassign.length} emails to contact ${email}`); + } else { + logger.info(`No emails to reassign for contact ${email}`); + } + } catch (error) { + logger.error('Failed to reassign emails', { error: error.message }); + // Don't throw - contact was created successfully + } + return newContact; }; /** - * Get a contact by ID + * Get a contact by ID (check it belongs to the email account) */ -export const getContactById = async (contactId, userId) => { +export const getContactById = async (contactId, emailAccountId) => { const [contact] = await db .select() .from(contacts) - .where(and(eq(contacts.id, contactId), eq(contacts.userId, userId))) + .where( + and( + eq(contacts.id, contactId), + eq(contacts.emailAccountId, emailAccountId) + ) + ) .limit(1); if (!contact) { @@ -89,16 +129,8 @@ export const getContactById = async (contactId, userId) => { /** * Remove a contact */ -export const removeContact = async (userId, contactId) => { - const [contact] = await db - .select() - .from(contacts) - .where(and(eq(contacts.id, contactId), eq(contacts.userId, userId))) - .limit(1); - - if (!contact) { - throw new NotFoundError('Kontakt nenájdený'); - } +export const removeContact = async (contactId, emailAccountId) => { + const contact = await getContactById(contactId, emailAccountId); // Delete contact (emails will be cascade deleted) await db.delete(contacts).where(eq(contacts.id, contactId)); @@ -109,16 +141,8 @@ export const removeContact = async (userId, contactId) => { /** * Update contact */ -export const updateContact = async (userId, contactId, { name, notes }) => { - const [contact] = await db - .select() - .from(contacts) - .where(and(eq(contacts.id, contactId), eq(contacts.userId, userId))) - .limit(1); - - if (!contact) { - throw new NotFoundError('Kontakt nenájdený'); - } +export const updateContact = async (contactId, emailAccountId, { name, notes }) => { + const contact = await getContactById(contactId, emailAccountId); const [updated] = await db .update(contacts) diff --git a/src/services/crm-email.service.js b/src/services/crm-email.service.js index 31081cd..cce9231 100644 --- a/src/services/crm-email.service.js +++ b/src/services/crm-email.service.js @@ -1,20 +1,13 @@ import { db } from '../config/database.js'; import { emails, contacts } from '../db/schema.js'; -import { eq, and, or, desc, like, sql } from 'drizzle-orm'; +import { eq, and, or, desc, like, sql, inArray } from 'drizzle-orm'; import { NotFoundError } from '../utils/errors.js'; /** - * Get all emails for a user (only from added contacts) - * If emailAccountId is provided, filter by that account + * Get all emails for an email account (only from added contacts) */ -export const getUserEmails = async (userId, emailAccountId = null) => { - const conditions = [eq(emails.userId, userId)]; - - if (emailAccountId) { - conditions.push(eq(emails.emailAccountId, emailAccountId)); - } - - const userEmails = await db +export const getEmailsForAccount = async (emailAccountId) => { + const accountEmails = await db .select({ id: emails.id, jmapId: emails.jmapId, @@ -26,6 +19,7 @@ export const getUserEmails = async (userId, emailAccountId = null) => { subject: emails.subject, body: emails.body, isRead: emails.isRead, + sentByUserId: emails.sentByUserId, date: emails.date, createdAt: emails.createdAt, emailAccountId: emails.emailAccountId, @@ -36,21 +30,26 @@ export const getUserEmails = async (userId, emailAccountId = null) => { }, }) .from(emails) - .leftJoin(contacts, eq(emails.contactId, contacts.id)) - .where(and(...conditions)) + .innerJoin(contacts, eq(emails.contactId, contacts.id)) + .where(eq(emails.emailAccountId, emailAccountId)) .orderBy(desc(emails.date)); - return userEmails; + return accountEmails; }; /** * Get emails by thread ID */ -export const getEmailThread = async (userId, threadId) => { +export const getEmailThread = async (emailAccountId, threadId) => { const thread = await db .select() .from(emails) - .where(and(eq(emails.userId, userId), eq(emails.threadId, threadId))) + .where( + and( + eq(emails.emailAccountId, emailAccountId), + eq(emails.threadId, threadId) + ) + ) .orderBy(emails.date); if (thread.length === 0) { @@ -62,16 +61,15 @@ export const getEmailThread = async (userId, threadId) => { /** * Search emails (from, to, subject) - * If emailAccountId is provided, filter by that account */ -export const searchEmails = async (userId, query, emailAccountId = null) => { +export const searchEmails = async (emailAccountId, query) => { if (!query || query.trim().length < 2) { throw new Error('Search term must be at least 2 characters'); } const searchPattern = `%${query}%`; const conditions = [ - eq(emails.userId, userId), + eq(emails.emailAccountId, emailAccountId), or( like(emails.from, searchPattern), like(emails.to, searchPattern), @@ -79,10 +77,6 @@ export const searchEmails = async (userId, query, emailAccountId = null) => { ), ]; - if (emailAccountId) { - conditions.push(eq(emails.emailAccountId, emailAccountId)); - } - const results = await db .select() .from(emails) @@ -94,173 +88,150 @@ export const searchEmails = async (userId, query, emailAccountId = null) => { }; /** - * Get unread email count - * Returns total count and counts per email account + * Get unread email count for an email account (only from added contacts) */ -export const getUnreadCount = async (userId) => { - // Get total unread count - const totalResult = await db +export const getUnreadCountForAccount = async (emailAccountId) => { + const result = await db .select({ count: sql`count(*)::int` }) .from(emails) - .where(and(eq(emails.userId, userId), eq(emails.isRead, false))); + .innerJoin(contacts, eq(emails.contactId, contacts.id)) + .where( + and( + eq(emails.emailAccountId, emailAccountId), + eq(emails.isRead, false) + ) + ); - const totalUnread = totalResult[0]?.count || 0; + return result[0]?.count || 0; +}; - // Get unread count per email account +/** + * Get unread email count summary for all user's email accounts (only from added contacts) + */ +export const getUnreadCountSummary = async (emailAccountIds) => { + if (!emailAccountIds || emailAccountIds.length === 0) { + return { + totalUnread: 0, + byAccount: [], + }; + } + + // Get unread count per email account (only from added contacts) const accountCounts = await db .select({ emailAccountId: emails.emailAccountId, count: sql`count(*)::int`, }) .from(emails) - .where(and(eq(emails.userId, userId), eq(emails.isRead, false))) + .innerJoin(contacts, eq(emails.contactId, contacts.id)) + .where( + and( + inArray(emails.emailAccountId, emailAccountIds), + eq(emails.isRead, false) + ) + ) .groupBy(emails.emailAccountId); + const totalUnread = accountCounts.reduce((sum, acc) => sum + acc.count, 0); + return { totalUnread, - accounts: accountCounts.map((ac) => ({ - emailAccountId: ac.emailAccountId, - unreadCount: ac.count, - })), + byAccount: accountCounts, }; }; +/** + * Get unread count by contact for an email account + */ +export const getUnreadCountByContact = async (emailAccountId) => { + const contactCounts = await db + .select({ + contactId: emails.contactId, + contactEmail: contacts.email, + contactName: contacts.name, + count: sql`count(*)::int`, + }) + .from(emails) + .leftJoin(contacts, eq(emails.contactId, contacts.id)) + .where( + and( + eq(emails.emailAccountId, emailAccountId), + eq(emails.isRead, false) + ) + ) + .groupBy(emails.contactId, contacts.email, contacts.name); + + return contactCounts; +}; + /** * Mark thread as read */ -export const markThreadAsRead = async (userId, threadId) => { - console.log('🟦 markThreadAsRead called:', { userId, threadId }); - - const result = await db +export const markThreadAsRead = async (emailAccountId, threadId) => { + const updated = await db .update(emails) .set({ isRead: true, updatedAt: new Date() }) - .where(and(eq(emails.userId, userId), eq(emails.threadId, threadId), eq(emails.isRead, false))) + .where( + and( + eq(emails.emailAccountId, emailAccountId), + eq(emails.threadId, threadId), + eq(emails.isRead, false) + ) + ) .returning(); - console.log('✅ markThreadAsRead result:', { count: result.length, threadId }); - - return { success: true, count: result.length }; + return updated.length; }; /** * Mark all emails from a contact as read */ -export const markContactEmailsAsRead = async (userId, contactId) => { - console.log('🟦 markContactEmailsAsRead called:', { userId, contactId }); +export const markContactEmailsAsRead = async (contactId, emailAccountId) => { + const updated = await db + .update(emails) + .set({ isRead: true, updatedAt: new Date() }) + .where( + and( + eq(emails.emailAccountId, emailAccountId), + eq(emails.contactId, contactId) + ) + ) + .returning(); - // Get the contact info first - const [contact] = await db - .select() - .from(contacts) - .where(eq(contacts.id, contactId)) - .limit(1); - - console.log('👤 Contact info:', { - id: contact?.id, - email: contact?.email, - name: contact?.name, - }); - - // First, check what emails exist for this contact (including already read ones) - const allContactEmails = await db - .select({ - id: emails.id, - contactId: emails.contactId, - isRead: emails.isRead, - from: emails.from, - subject: emails.subject, - }) - .from(emails) - .where(and(eq(emails.userId, userId), eq(emails.contactId, contactId))); - - console.log('📧 All emails for this contact (by contactId):', { - total: allContactEmails.length, - unread: allContactEmails.filter(e => !e.isRead).length, - read: allContactEmails.filter(e => e.isRead).length, - sampleEmails: allContactEmails.slice(0, 3), - }); - - // Check if there are emails from this sender but with NULL or different contactId - if (contact) { - const emailsFromSender = await db - .select({ - id: emails.id, - contactId: emails.contactId, - isRead: emails.isRead, - from: emails.from, - subject: emails.subject, - }) - .from(emails) - .where(and( - eq(emails.userId, userId), - or( - eq(emails.from, contact.email), - like(emails.from, `%<${contact.email}>%`) - ) - )) - .limit(10); - - console.log('📨 Emails from this sender email (any contactId):', { - total: emailsFromSender.length, - withContactId: emailsFromSender.filter(e => e.contactId === contactId).length, - withNullContactId: emailsFromSender.filter(e => e.contactId === null).length, - withDifferentContactId: emailsFromSender.filter(e => e.contactId && e.contactId !== contactId).length, - sampleEmails: emailsFromSender.slice(0, 3), - }); - } - - // FIX: Mark emails as read by matching sender EMAIL, not just contactId - // This fixes the issue with duplicate contacts having different IDs - let result = []; - - if (contact) { - // Update ALL emails from this sender's email address: - // 1. Set the correct contactId (fixes duplicate contact issue) - // 2. Mark as read - result = await db - .update(emails) - .set({ - contactId: contactId, // Fix contactId for duplicate contacts - isRead: true, - updatedAt: new Date() - }) - .where(and( - eq(emails.userId, userId), - or( - eq(emails.from, contact.email), - like(emails.from, `%<${contact.email}>%`) - ), - eq(emails.isRead, false) - )) - .returning(); - - console.log('🔧 Fixed contactId and marked as read for emails from:', contact.email); - } else { - // Fallback: use old method if contact not found - result = await db - .update(emails) - .set({ isRead: true, updatedAt: new Date() }) - .where(and(eq(emails.userId, userId), eq(emails.contactId, contactId), eq(emails.isRead, false))) - .returning(); - } - - console.log('✅ markContactEmailsAsRead result:', { count: result.length, contactId }); - - return { - success: true, - count: result.length, - emails: result, // Return the emails so controller can mark them on JMAP server - }; + return updated.length; }; /** - * Get emails for a specific contact + * Get contact by email address for specific email account */ -export const getContactEmails = async (userId, contactId) => { +export const getContactByEmail = async (emailAccountId, contactEmail) => { + const [contact] = await db + .select() + .from(contacts) + .where( + and( + eq(contacts.emailAccountId, emailAccountId), + eq(contacts.email, contactEmail) + ) + ) + .limit(1); + + return contact || null; +}; + +/** + * Get contact emails with unread count + */ +export const getContactEmailsWithUnread = async (emailAccountId, contactId) => { const contactEmails = await db .select() .from(emails) - .where(and(eq(emails.userId, userId), eq(emails.contactId, contactId))) + .where( + and( + eq(emails.emailAccountId, emailAccountId), + eq(emails.contactId, contactId) + ) + ) .orderBy(desc(emails.date)); return contactEmails; diff --git a/src/services/email-account.service.js b/src/services/email-account.service.js index 1816505..3b853b3 100644 --- a/src/services/email-account.service.js +++ b/src/services/email-account.service.js @@ -1,6 +1,6 @@ -import { eq, and } from 'drizzle-orm'; +import { eq, and, sql } from 'drizzle-orm'; import { db } from '../config/database.js'; -import { emailAccounts, users } from '../db/schema.js'; +import { emailAccounts, userEmailAccounts, users } from '../db/schema.js'; import { encryptPassword, decryptPassword } from '../utils/password.js'; import { validateJmapCredentials } from './email.service.js'; import { @@ -12,40 +12,50 @@ import { import { logger } from '../utils/logger.js'; /** - * Get all email accounts for a user + * Get all email accounts for a user (cez many-to-many) */ export const getUserEmailAccounts = async (userId) => { const accounts = await db .select({ id: emailAccounts.id, - userId: emailAccounts.userId, email: emailAccounts.email, jmapAccountId: emailAccounts.jmapAccountId, - isPrimary: emailAccounts.isPrimary, isActive: emailAccounts.isActive, + isPrimary: userEmailAccounts.isPrimary, + addedAt: userEmailAccounts.addedAt, createdAt: emailAccounts.createdAt, - updatedAt: emailAccounts.updatedAt, }) - .from(emailAccounts) - .where(eq(emailAccounts.userId, userId)) - .orderBy(emailAccounts.isPrimary, emailAccounts.createdAt); + .from(userEmailAccounts) + .innerJoin(emailAccounts, eq(userEmailAccounts.emailAccountId, emailAccounts.id)) + .where(eq(userEmailAccounts.userId, userId)) + .orderBy(userEmailAccounts.isPrimary, emailAccounts.createdAt); return accounts; }; /** - * Get a specific email account by ID + * Get a specific email account by ID (check user has access) */ export const getEmailAccountById = async (accountId, userId) => { + const [link] = await db + .select() + .from(userEmailAccounts) + .where( + and( + eq(userEmailAccounts.emailAccountId, accountId), + eq(userEmailAccounts.userId, userId) + ) + ) + .limit(1); + + if (!link) { + throw new NotFoundError('Email účet nenájdený alebo nemáte k nemu prístup'); + } + const [account] = await db .select() .from(emailAccounts) - .where( - and( - eq(emailAccounts.id, accountId), - eq(emailAccounts.userId, userId) - ) - ) + .where(eq(emailAccounts.id, accountId)) .limit(1); if (!account) { @@ -59,40 +69,85 @@ export const getEmailAccountById = async (accountId, userId) => { * Get user's primary email account */ export const getPrimaryEmailAccount = async (userId) => { - const [account] = await db - .select() - .from(emailAccounts) + const [result] = await db + .select({ + id: emailAccounts.id, + email: emailAccounts.email, + jmapAccountId: emailAccounts.jmapAccountId, + isActive: emailAccounts.isActive, + }) + .from(userEmailAccounts) + .innerJoin(emailAccounts, eq(userEmailAccounts.emailAccountId, emailAccounts.id)) .where( and( - eq(emailAccounts.userId, userId), - eq(emailAccounts.isPrimary, true) + eq(userEmailAccounts.userId, userId), + eq(userEmailAccounts.isPrimary, true) ) ) .limit(1); - return account || null; + return result || null; }; /** * Create a new email account with JMAP validation + * Automatic many-to-many link vytvorí pre používateľa, ktorý účet vytvoril */ export const createEmailAccount = async (userId, email, emailPassword) => { - // Check if email already exists for this user + // Check if email account already exists (globally) const [existing] = await db .select() .from(emailAccounts) - .where( - and( - eq(emailAccounts.userId, userId), - eq(emailAccounts.email, email) - ) - ) + .where(eq(emailAccounts.email, email)) .limit(1); if (existing) { - throw new ConflictError('Tento email účet už je pripojený'); + // Email account už existuje - skontroluj či user už má prístup + const [userLink] = await db + .select() + .from(userEmailAccounts) + .where( + and( + eq(userEmailAccounts.userId, userId), + eq(userEmailAccounts.emailAccountId, existing.id) + ) + ) + .limit(1); + + if (userLink) { + throw new ConflictError('Tento email účet už máte pripojený'); + } + + // Email account existuje, ale user ho nemá - môžeme ho zdieľať + // Ale najprv overíme heslo + try { + await validateJmapCredentials(email, emailPassword); + } catch (error) { + throw new AuthenticationError('Nesprávne heslo k existujúcemu email účtu'); + } + + // Check if this is the first email account for this user + const existingAccounts = await getUserEmailAccounts(userId); + const isFirst = existingAccounts.length === 0; + + // Link user k existujúcemu accountu + await db.insert(userEmailAccounts).values({ + userId, + emailAccountId: existing.id, + isPrimary: isFirst, + }); + + return { + id: existing.id, + email: existing.email, + jmapAccountId: existing.jmapAccountId, + isPrimary: isFirst, + isActive: existing.isActive, + shared: true, // Indikuje že ide o zdieľaný account + }; } + // Email account neexistuje - vytvoríme nový // Validate JMAP credentials and get account ID let jmapAccountId; try { @@ -111,26 +166,36 @@ export const createEmailAccount = async (userId, email, emailPassword) => { const existingAccounts = await getUserEmailAccounts(userId); const isFirst = existingAccounts.length === 0; - // Create email account - const [newAccount] = await db - .insert(emailAccounts) - .values({ + // Use transaction to create email account and link it to user + const result = await db.transaction(async (tx) => { + // Create email account + const [newAccount] = await tx + .insert(emailAccounts) + .values({ + email, + emailPassword: encryptedPassword, + jmapAccountId, + isActive: true, + }) + .returning(); + + // Link user to email account + await tx.insert(userEmailAccounts).values({ userId, - email, - emailPassword: encryptedPassword, - jmapAccountId, + emailAccountId: newAccount.id, isPrimary: isFirst, // First account is automatically primary - isActive: true, - }) - .returning(); + }); + + return newAccount; + }); return { - id: newAccount.id, - email: newAccount.email, - jmapAccountId: newAccount.jmapAccountId, - isPrimary: newAccount.isPrimary, - isActive: newAccount.isActive, - createdAt: newAccount.createdAt, + id: result.id, + email: result.email, + jmapAccountId: result.jmapAccountId, + isPrimary: isFirst, + isActive: result.isActive, + shared: false, }; }; @@ -138,9 +203,16 @@ export const createEmailAccount = async (userId, email, emailPassword) => { * Update email account password */ export const updateEmailAccountPassword = async (accountId, userId, newPassword) => { - const account = await getEmailAccountById(accountId, userId); + // Check user has access + await getEmailAccountById(accountId, userId); // Validate new JMAP credentials + const [account] = await db + .select() + .from(emailAccounts) + .where(eq(emailAccounts.id, accountId)) + .limit(1); + try { await validateJmapCredentials(account.email, newPassword); } catch (error) { @@ -173,10 +245,23 @@ export const updateEmailAccountPassword = async (accountId, userId, newPassword) * Toggle email account active status */ export const toggleEmailAccountStatus = async (accountId, userId, isActive) => { - const account = await getEmailAccountById(accountId, userId); + // Check user has access + await getEmailAccountById(accountId, userId); + + // Get user's link to check if it's primary + const [link] = await db + .select() + .from(userEmailAccounts) + .where( + and( + eq(userEmailAccounts.userId, userId), + eq(userEmailAccounts.emailAccountId, accountId) + ) + ) + .limit(1); // Cannot deactivate primary account - if (account.isPrimary && !isActive) { + if (link.isPrimary && !isActive) { throw new ValidationError('Nemôžete deaktivovať primárny email účet'); } @@ -196,68 +281,126 @@ export const toggleEmailAccountStatus = async (accountId, userId, isActive) => { }; /** - * Set an email account as primary + * Set an email account as primary FOR SPECIFIC USER */ export const setPrimaryEmailAccount = async (accountId, userId) => { - const account = await getEmailAccountById(accountId, userId); + // Check user has access + await getEmailAccountById(accountId, userId); // Use transaction to prevent race conditions - const updated = await db.transaction(async (tx) => { - // Remove primary flag from all accounts + await db.transaction(async (tx) => { + // Remove primary flag from all user's accounts await tx - .update(emailAccounts) - .set({ isPrimary: false, updatedAt: new Date() }) - .where(eq(emailAccounts.userId, userId)); + .update(userEmailAccounts) + .set({ isPrimary: false }) + .where(eq(userEmailAccounts.userId, userId)); // Set new primary account - const [updatedAccount] = await tx + await tx + .update(userEmailAccounts) + .set({ isPrimary: true }) + .where( + and( + eq(userEmailAccounts.userId, userId), + eq(userEmailAccounts.emailAccountId, accountId) + ) + ); + + // Make sure email account is active + await tx .update(emailAccounts) .set({ - isPrimary: true, - isActive: true, // Primary account must be active + isActive: true, updatedAt: new Date(), }) - .where(eq(emailAccounts.id, accountId)) - .returning(); - - return updatedAccount; + .where(eq(emailAccounts.id, accountId)); }); + const [account] = await db + .select() + .from(emailAccounts) + .where(eq(emailAccounts.id, accountId)) + .limit(1); + return { - id: updated.id, - email: updated.email, - isPrimary: updated.isPrimary, + id: account.id, + email: account.email, + isPrimary: true, }; }; /** - * Delete an email account - * NOTE: This will cascade delete all associated contacts and emails + * Remove user's access to email account + * Ak je to posledný používateľ, vymaže aj samotný email account */ -export const deleteEmailAccount = async (accountId, userId) => { - const account = await getEmailAccountById(accountId, userId); +export const removeUserFromEmailAccount = async (accountId, userId) => { + // Check user has access + await getEmailAccountById(accountId, userId); - // Cannot delete primary account if it's the only one - if (account.isPrimary) { - const allAccounts = await getUserEmailAccounts(userId); - if (allAccounts.length === 1) { - throw new ValidationError('Nemôžete zmazať posledný email účet'); + // Get user's link + const [link] = await db + .select() + .from(userEmailAccounts) + .where( + and( + eq(userEmailAccounts.userId, userId), + eq(userEmailAccounts.emailAccountId, accountId) + ) + ) + .limit(1); + + // If this is user's primary account, make sure they have another one to set as primary + if (link.isPrimary) { + const allUserAccounts = await getUserEmailAccounts(userId); + if (allUserAccounts.length === 1) { + throw new ValidationError('Nemôžete odstrániť posledný email účet'); } - // If deleting primary account, make another account primary - const otherAccount = allAccounts.find(acc => acc.id !== accountId); + // Set another account as primary + const otherAccount = allUserAccounts.find(acc => acc.id !== accountId); if (otherAccount) { - await setPrimaryEmailAccount(otherAccount.id, userId); + await db + .update(userEmailAccounts) + .set({ isPrimary: true }) + .where( + and( + eq(userEmailAccounts.userId, userId), + eq(userEmailAccounts.emailAccountId, otherAccount.id) + ) + ); } } - // Delete account (will cascade to contacts and emails) + // Remove user's link await db - .delete(emailAccounts) - .where(eq(emailAccounts.id, accountId)); + .delete(userEmailAccounts) + .where( + and( + eq(userEmailAccounts.userId, userId), + eq(userEmailAccounts.emailAccountId, accountId) + ) + ); + + // Check if this was the last user with access to this email account + const [remainingLinks] = await db + .select({ count: sql`count(*)::int` }) + .from(userEmailAccounts) + .where(eq(userEmailAccounts.emailAccountId, accountId)); + + // If no users have access anymore, delete the email account itself + if (remainingLinks.count === 0) { + await db + .delete(emailAccounts) + .where(eq(emailAccounts.id, accountId)); + + return { + message: 'Email účet bol úspešne odstránený (posledný používateľ)', + deletedAccountId: accountId, + }; + } return { - message: 'Email účet bol úspešne odstránený', + message: 'Prístup k email účtu bol odobraný', deletedAccountId: accountId, }; }; @@ -267,7 +410,9 @@ export const deleteEmailAccount = async (accountId, userId) => { */ export const getEmailAccountWithCredentials = async (accountId, userId) => { logger.debug('getEmailAccountWithCredentials called', { accountId, userId }); + const account = await getEmailAccountById(accountId, userId); + logger.debug('Account retrieved', { id: account.id, email: account.email, @@ -286,3 +431,86 @@ export const getEmailAccountWithCredentials = async (accountId, userId) => { isActive: account.isActive, }; }; + +/** + * Get all users with access to an email account + */ +export const getUsersWithAccessToAccount = async (accountId) => { + const usersWithAccess = await db + .select({ + userId: users.id, + username: users.username, + firstName: users.firstName, + lastName: users.lastName, + isPrimary: userEmailAccounts.isPrimary, + addedAt: userEmailAccounts.addedAt, + }) + .from(userEmailAccounts) + .innerJoin(users, eq(userEmailAccounts.userId, users.id)) + .where(eq(userEmailAccounts.emailAccountId, accountId)); + + return usersWithAccess; +}; + +/** + * Share email account with another user + */ +export const shareEmailAccountWithUser = async (accountId, ownerId, targetUserId) => { + // Check owner has access + await getEmailAccountById(accountId, ownerId); + + // Check target user exists + const [targetUser] = await db + .select() + .from(users) + .where(eq(users.id, targetUserId)) + .limit(1); + + if (!targetUser) { + throw new NotFoundError('Cieľový používateľ nenájdený'); + } + + // Check if target user already has access + const [existingLink] = await db + .select() + .from(userEmailAccounts) + .where( + and( + eq(userEmailAccounts.userId, targetUserId), + eq(userEmailAccounts.emailAccountId, accountId) + ) + ) + .limit(1); + + if (existingLink) { + throw new ConflictError('Používateľ už má prístup k tomuto email účtu'); + } + + // Check if this is the first email account for target user + const targetUserAccounts = await getUserEmailAccounts(targetUserId); + const isFirst = targetUserAccounts.length === 0; + + // Create link + await db.insert(userEmailAccounts).values({ + userId: targetUserId, + emailAccountId: accountId, + isPrimary: isFirst, + }); + + const [account] = await db + .select() + .from(emailAccounts) + .where(eq(emailAccounts.id, accountId)) + .limit(1); + + return { + success: true, + message: `Email účet ${account.email} bol zdieľaný s používateľom ${targetUser.username}`, + sharedWith: { + userId: targetUser.id, + username: targetUser.username, + firstName: targetUser.firstName, + lastName: targetUser.lastName, + }, + }; +}; diff --git a/src/services/jmap.service.js b/src/services/jmap.service.js index 1aca275..0879993 100644 --- a/src/services/jmap.service.js +++ b/src/services/jmap.service.js @@ -123,7 +123,7 @@ export const getIdentities = async (jmapConfig) => { * Discover potential contacts from JMAP (no DB storage) * Returns list of unique senders */ -export const discoverContactsFromJMAP = async (jmapConfig, userId, searchTerm = '', limit = 50) => { +export const discoverContactsFromJMAP = async (jmapConfig, emailAccountId, searchTerm = '', limit = 50) => { try { logger.info(`Discovering contacts from JMAP (search: "${searchTerm}")`); @@ -167,11 +167,11 @@ export const discoverContactsFromJMAP = async (jmapConfig, userId, searchTerm = const emailsList = getResponse.methodResponses[0][1].list; - // Get existing contacts for this user + // Get existing contacts for this email account const existingContacts = await db .select() .from(contacts) - .where(eq(contacts.userId, userId)); + .where(eq(contacts.emailAccountId, emailAccountId)); const contactEmailsSet = new Set(existingContacts.map((c) => c.email.toLowerCase())); @@ -216,7 +216,7 @@ export const discoverContactsFromJMAP = async (jmapConfig, userId, searchTerm = * Searches in: from, to, subject, and email body * Returns list of unique senders grouped by email address */ -export const searchEmailsJMAP = async (jmapConfig, userId, query, limit = 50, offset = 0) => { +export const searchEmailsJMAP = async (jmapConfig, emailAccountId, query, limit = 50, offset = 0) => { try { logger.info(`Searching emails in JMAP (query: "${query}", limit: ${limit}, offset: ${offset})`); @@ -296,11 +296,11 @@ export const searchEmailsJMAP = async (jmapConfig, userId, query, limit = 50, of const emailsList = getResponse.methodResponses[0][1].list; - // Get existing contacts for this user + // Get existing contacts for this email account const existingContacts = await db .select() .from(contacts) - .where(eq(contacts.userId, userId)); + .where(eq(contacts.emailAccountId, emailAccountId)); const contactEmailsSet = new Set(existingContacts.map((c) => c.email.toLowerCase())); @@ -342,30 +342,54 @@ export const searchEmailsJMAP = async (jmapConfig, userId, query, limit = 50, of }; /** - * Sync emails from a specific sender (when adding as contact) + * Sync emails with a specific contact (bidirectional: both from and to) + * ONLY syncs from Inbox and Sent mailboxes (no Archive, Trash, Drafts) + * ONLY syncs recent emails (last 30 days by default) */ export const syncEmailsFromSender = async ( jmapConfig, - userId, emailAccountId, contactId, senderEmail, options = {} ) => { - const { limit = 500 } = options; + const { limit = 50, daysBack = 30 } = options; try { - logger.info(`Syncing emails from sender: ${senderEmail} for account ${emailAccountId}`); + logger.info(`Syncing emails with contact: ${senderEmail} for account ${emailAccountId}`); - // Query all emails from this sender - const queryResponse = await jmapRequest(jmapConfig, [ + // Get Inbox and Sent mailboxes ONLY + const mailboxes = await getMailboxes(jmapConfig); + const inboxMailbox = mailboxes.find(m => m.role === 'inbox' || m.name === 'Inbox' || m.name === 'INBOX'); + const sentMailbox = mailboxes.find(m => m.role === 'sent' || m.name === 'Sent'); + + if (!inboxMailbox) { + logger.error('Inbox mailbox not found'); + throw new Error('Inbox mailbox not found'); + } + + logger.info(`Using mailboxes: Inbox (${inboxMailbox.id})${sentMailbox ? `, Sent (${sentMailbox.id})` : ''}`); + + // Calculate date threshold (only emails from last X days) + const dateThreshold = new Date(); + dateThreshold.setDate(dateThreshold.getDate() - daysBack); + const dateThresholdISO = dateThreshold.toISOString(); + + logger.info(`Filtering: last ${daysBack} days, from Inbox/Sent only, for ${senderEmail}`); + + // Query emails FROM the contact + const queryFromResponse = await jmapRequest(jmapConfig, [ [ 'Email/query', { accountId: jmapConfig.accountId, filter: { - operator: 'OR', - conditions: [{ from: senderEmail }, { to: senderEmail }], + operator: 'AND', + conditions: [ + { inMailbox: inboxMailbox.id }, + { from: senderEmail }, + { after: dateThresholdISO } + ] }, sort: [{ property: 'receivedAt', isAscending: false }], limit, @@ -374,8 +398,39 @@ export const syncEmailsFromSender = async ( ], ]); - const emailIds = queryResponse.methodResponses[0][1].ids; - logger.info(`Found ${emailIds.length} emails from ${senderEmail}`); + const fromEmailIds = queryFromResponse.methodResponses[0][1].ids || []; + logger.info(`Found ${fromEmailIds.length} emails FROM ${senderEmail}`); + + // Query emails TO the contact (from Sent folder if it exists) + let toEmailIds = []; + if (sentMailbox) { + const queryToResponse = await jmapRequest(jmapConfig, [ + [ + 'Email/query', + { + accountId: jmapConfig.accountId, + filter: { + operator: 'AND', + conditions: [ + { inMailbox: sentMailbox.id }, + { to: senderEmail }, + { after: dateThresholdISO } + ] + }, + sort: [{ property: 'receivedAt', isAscending: false }], + limit, + }, + 'query2', + ], + ]); + + toEmailIds = queryToResponse.methodResponses[0][1].ids || []; + logger.info(`Found ${toEmailIds.length} emails TO ${senderEmail}`); + } + + // Combine and deduplicate + const emailIds = [...new Set([...fromEmailIds, ...toEmailIds])]; + logger.info(`Total unique emails: ${emailIds.length}`); if (emailIds.length === 0) { return { total: 0, saved: 0 }; @@ -427,6 +482,19 @@ export const syncEmailsFromSender = async ( continue; } + // VALIDATION: Email must belong to this contact + // Email belongs to contact if: + // - from === senderEmail (received email FROM contact) + // - to === senderEmail (sent email TO contact) + const belongsToContact = + fromEmail?.toLowerCase() === senderEmail.toLowerCase() || + toEmail?.toLowerCase() === senderEmail.toLowerCase(); + + if (!belongsToContact) { + logger.warn(`Skipping email ${messageId} - does not belong to contact ${senderEmail} (from: ${fromEmail}, to: ${toEmail})`); + continue; + } + // Skip if already exists const [existing] = await db .select() @@ -440,7 +508,6 @@ export const syncEmailsFromSender = async ( // Save email await db.insert(emails).values({ - userId, emailAccountId, contactId, jmapId: email.id, @@ -456,6 +523,7 @@ export const syncEmailsFromSender = async ( '(Empty message)', date: email.receivedAt ? new Date(email.receivedAt) : new Date(), isRead, + sentByUserId: null, // Prijatý email, nie odpoveď }); savedCount++; @@ -464,10 +532,10 @@ export const syncEmailsFromSender = async ( } } - logger.success(`Synced ${savedCount} new emails from ${senderEmail}`); + logger.success(`Synced ${savedCount} new emails with ${senderEmail}`); return { total: emailsList.length, saved: savedCount }; } catch (error) { - logger.error('Failed to sync emails from sender', error); + logger.error('Failed to sync emails with contact', error); throw error; } }; @@ -636,13 +704,24 @@ export const sendEmail = async (jmapConfig, userId, emailAccountId, to, subject, logger.success(`Email sent successfully to ${to}`); + // 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); + // Save sent email to database const messageId = `<${Date.now()}.${Math.random().toString(36).substr(2, 9)}@${jmapConfig.username.split('@')[1]}>`; await db.insert(emails).values({ - userId, emailAccountId, - contactId: null, // Will be linked later if recipient is a contact + contactId: recipientContact?.id || null, // Link to contact if recipient is in contacts jmapId: createdEmailId, messageId, threadId: threadId || messageId, @@ -653,8 +732,11 @@ export const sendEmail = async (jmapConfig, userId, emailAccountId, to, subject, body, date: new Date(), isRead: true, // Sent emails are always read + sentByUserId: userId, // Track who sent this email }); + logger.info(`Sent email linked to contact: ${recipientContact ? recipientContact.id : 'none (not in contacts)'}`); + return { success: true, messageId }; } catch (error) { logger.error('Error sending email', error); diff --git a/src/utils/errors.js b/src/utils/errors.js index 0cced9b..bc87700 100644 --- a/src/utils/errors.js +++ b/src/utils/errors.js @@ -20,6 +20,13 @@ export class ValidationError extends AppError { } } +export class BadRequestError extends AppError { + constructor(message = 'Zlá požiadavka') { + super(message, 400); + this.name = 'BadRequestError'; + } +} + export class AuthenticationError extends AppError { constructor(message = 'Neautorizovaný prístup') { super(message, 401); 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 new file mode 100644 index 0000000..ad69b25 Binary files /dev/null and b/uploads/timesheets/68927352-725c-4e95-adb6-d4002b22bef5/2024/11/Clockify_Time_Report_Weekly_03_11_2025-09_11_2025-1763709566832-767505558.pdf 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 new file mode 100644 index 0000000..9885400 Binary files /dev/null and b/uploads/timesheets/68927352-725c-4e95-adb6-d4002b22bef5/2024/11/demo (1)-1763709519358-520861577.xlsx differ diff --git a/uploads/timesheets/68927352-725c-4e95-adb6-d4002b22bef5/2024/12/Richard-Tekula (1)-1763707607012-681244677.pdf b/uploads/timesheets/68927352-725c-4e95-adb6-d4002b22bef5/2024/12/Richard-Tekula (1)-1763707607012-681244677.pdf new file mode 100644 index 0000000..a272e43 Binary files /dev/null and b/uploads/timesheets/68927352-725c-4e95-adb6-d4002b22bef5/2024/12/Richard-Tekula (1)-1763707607012-681244677.pdf differ