Add Timesheets API with file upload and role-based access
Backend Features:
- Timesheets database table (id, userId, fileName, filePath, fileType, fileSize, year, month, timestamps)
- File upload with multer (memory storage, 10MB limit, PDF/Excel validation)
- Structured file storage: uploads/timesheets/{userId}/{year}/{month}/
- RESTful API endpoints:
* POST /api/timesheets/upload - Upload timesheet
* GET /api/timesheets/my - Get user's timesheets (with filters)
* GET /api/timesheets/all - Get all timesheets (admin only)
* GET /api/timesheets/:id/download - Download file
* DELETE /api/timesheets/:id - Delete timesheet
- Role-based permissions: users access own files, admins access all
- Proper error handling and file cleanup on errors
- Database migration for timesheets table
Technical:
- Uses req.user.role for permission checks
- Automatic directory creation for user/year/month structure
- Blob URL cleanup and proper file handling
- Integration with existing auth middleware
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
125
CLEAN_START_GUIDE.md
Normal file
125
CLEAN_START_GUIDE.md
Normal file
@@ -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
|
||||||
198
FIX_SUMMARY.md
Normal file
198
FIX_SUMMARY.md
Normal file
@@ -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
|
||||||
296
FRESH_START_README.md
Normal file
296
FRESH_START_README.md
Normal file
@@ -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)
|
||||||
445
MIGRATION_GUIDE.md
Normal file
445
MIGRATION_GUIDE.md
Normal file
@@ -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/<contactId>/read \
|
||||||
|
-H "Authorization: Bearer <token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 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
|
||||||
116
package-lock.json
generated
116
package-lock.json
generated
@@ -21,6 +21,7 @@
|
|||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
|
"multer": "^2.0.2",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"xss-clean": "^0.1.4",
|
"xss-clean": "^0.1.4",
|
||||||
@@ -2655,6 +2656,12 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/argparse": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
@@ -3068,9 +3075,19 @@
|
|||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -3320,6 +3337,21 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/content-disposition": {
|
||||||
"version": "0.5.4",
|
"version": "0.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
@@ -6033,6 +6065,27 @@
|
|||||||
"node": "*"
|
"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": {
|
"node_modules/morgan": {
|
||||||
"version": "1.10.1",
|
"version": "1.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
|
||||||
@@ -6082,6 +6135,24 @@
|
|||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/nanostores": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.0.1.tgz",
|
||||||
@@ -6823,6 +6894,20 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/readdirp": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||||
@@ -7257,6 +7342,23 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/string-length": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
|
||||||
@@ -7549,6 +7651,12 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/uncrypto": {
|
||||||
"version": "0.1.3",
|
"version": "0.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz",
|
||||||
@@ -7619,6 +7727,12 @@
|
|||||||
"punycode": "^2.1.0"
|
"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": {
|
"node_modules/utils-merge": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
|
"multer": "^2.0.2",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"xss-clean": "^0.1.4",
|
"xss-clean": "^0.1.4",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import adminRoutes from './routes/admin.routes.js';
|
|||||||
import contactRoutes from './routes/contact.routes.js';
|
import contactRoutes from './routes/contact.routes.js';
|
||||||
import crmEmailRoutes from './routes/crm-email.routes.js';
|
import crmEmailRoutes from './routes/crm-email.routes.js';
|
||||||
import emailAccountRoutes from './routes/email-account.routes.js';
|
import emailAccountRoutes from './routes/email-account.routes.js';
|
||||||
|
import timesheetRoutes from './routes/timesheet.routes.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -72,6 +73,7 @@ app.use('/api/admin', adminRoutes);
|
|||||||
app.use('/api/contacts', contactRoutes);
|
app.use('/api/contacts', contactRoutes);
|
||||||
app.use('/api/emails', crmEmailRoutes);
|
app.use('/api/emails', crmEmailRoutes);
|
||||||
app.use('/api/email-accounts', emailAccountRoutes);
|
app.use('/api/email-accounts', emailAccountRoutes);
|
||||||
|
app.use('/api/timesheets', timesheetRoutes);
|
||||||
|
|
||||||
// Basic route
|
// Basic route
|
||||||
app.get('/', (req, res) => {
|
app.get('/', (req, res) => {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { db } from '../config/database.js';
|
import { db } from '../config/database.js';
|
||||||
import { users } from '../db/schema.js';
|
import { users } from '../db/schema.js';
|
||||||
import { eq } from 'drizzle-orm';
|
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 { logUserCreation, logRoleChange } from '../services/audit.service.js';
|
||||||
import { formatErrorResponse, ConflictError, NotFoundError } from '../utils/errors.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)
|
* 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 tempPassword = generateTempPassword(12);
|
||||||
const hashedTempPassword = await hashPassword(tempPassword);
|
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
|
// Vytvor usera
|
||||||
const [newUser] = await db
|
const [newUser] = await db
|
||||||
.insert(users)
|
.insert(users)
|
||||||
.values({
|
.values({
|
||||||
username,
|
username,
|
||||||
email: email || null,
|
|
||||||
emailPassword: encryptedEmailPassword,
|
|
||||||
jmapAccountId,
|
|
||||||
tempPassword: hashedTempPassword,
|
tempPassword: hashedTempPassword,
|
||||||
role: 'member', // Vždy member, nie admin
|
role: 'member', // Vždy member, nie admin
|
||||||
firstName: firstName || null,
|
firstName: firstName || null,
|
||||||
@@ -63,6 +46,33 @@ export const createUser = async (req, res) => {
|
|||||||
})
|
})
|
||||||
.returning();
|
.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
|
// Log user creation
|
||||||
await logUserCreation(adminId, newUser.id, username, 'member', ipAddress, userAgent);
|
await logUserCreation(adminId, newUser.id, username, 'member', ipAddress, userAgent);
|
||||||
|
|
||||||
@@ -72,17 +82,18 @@ export const createUser = async (req, res) => {
|
|||||||
user: {
|
user: {
|
||||||
id: newUser.id,
|
id: newUser.id,
|
||||||
username: newUser.username,
|
username: newUser.username,
|
||||||
email: newUser.email,
|
|
||||||
firstName: newUser.firstName,
|
firstName: newUser.firstName,
|
||||||
lastName: newUser.lastName,
|
lastName: newUser.lastName,
|
||||||
role: newUser.role,
|
role: newUser.role,
|
||||||
jmapAccountId: newUser.jmapAccountId,
|
emailSetup: emailAccountCreated,
|
||||||
emailSetup: !!newUser.jmapAccountId,
|
emailAccount: emailAccountData,
|
||||||
tempPassword: tempPassword, // Vráti plain text password pre admina aby ho mohol poslať userovi
|
tempPassword: tempPassword, // Vráti plain text password pre admina aby ho mohol poslať userovi
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
message: newUser.jmapAccountId
|
message: emailAccountCreated
|
||||||
? 'Používateľ úspešne vytvorený s emailovým účtom.'
|
? 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.',
|
: 'Používateľ úspešne vytvorený. Email môže byť nastavený neskôr.',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -101,7 +112,6 @@ export const getAllUsers = async (req, res) => {
|
|||||||
.select({
|
.select({
|
||||||
id: users.id,
|
id: users.id,
|
||||||
username: users.username,
|
username: users.username,
|
||||||
email: users.email,
|
|
||||||
firstName: users.firstName,
|
firstName: users.firstName,
|
||||||
lastName: users.lastName,
|
lastName: users.lastName,
|
||||||
role: users.role,
|
role: users.role,
|
||||||
@@ -136,7 +146,6 @@ export const getUser = async (req, res) => {
|
|||||||
.select({
|
.select({
|
||||||
id: users.id,
|
id: users.id,
|
||||||
username: users.username,
|
username: users.username,
|
||||||
email: users.email,
|
|
||||||
firstName: users.firstName,
|
firstName: users.firstName,
|
||||||
lastName: users.lastName,
|
lastName: users.lastName,
|
||||||
role: users.role,
|
role: users.role,
|
||||||
@@ -153,9 +162,17 @@ export const getUser = async (req, res) => {
|
|||||||
throw new NotFoundError('Používateľ nenájdený');
|
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({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
data: { user },
|
data: {
|
||||||
|
user: {
|
||||||
|
...user,
|
||||||
|
emailAccounts: userEmailAccounts,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
|||||||
@@ -5,15 +5,29 @@ import * as emailAccountService from '../services/email-account.service.js';
|
|||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all contacts for authenticated user
|
* Get all contacts for an email account
|
||||||
* GET /api/contacts?accountId=xxx (optional)
|
* GET /api/contacts?accountId=xxx (required)
|
||||||
*/
|
*/
|
||||||
export const getContacts = async (req, res) => {
|
export const getContacts = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.userId;
|
const userId = req.userId;
|
||||||
const { accountId } = req.query;
|
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({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -70,7 +84,7 @@ export const discoverContacts = async (req, res) => {
|
|||||||
|
|
||||||
const potentialContacts = await discoverContactsFromJMAP(
|
const potentialContacts = await discoverContactsFromJMAP(
|
||||||
jmapConfig,
|
jmapConfig,
|
||||||
userId,
|
emailAccount.id, // emailAccountId
|
||||||
search,
|
search,
|
||||||
parseInt(limit)
|
parseInt(limit)
|
||||||
);
|
);
|
||||||
@@ -134,12 +148,12 @@ export const addContact = async (req, res) => {
|
|||||||
const jmapConfig = getJmapConfigFromAccount(emailAccount);
|
const jmapConfig = getJmapConfigFromAccount(emailAccount);
|
||||||
|
|
||||||
const contact = await contactService.addContact(
|
const contact = await contactService.addContact(
|
||||||
userId,
|
|
||||||
emailAccount.id,
|
emailAccount.id,
|
||||||
jmapConfig,
|
jmapConfig,
|
||||||
email,
|
email,
|
||||||
name,
|
name,
|
||||||
notes
|
notes,
|
||||||
|
userId // addedByUserId
|
||||||
);
|
);
|
||||||
|
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
@@ -155,14 +169,28 @@ export const addContact = async (req, res) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a contact
|
* Remove a contact
|
||||||
* DELETE /api/contacts/:contactId
|
* DELETE /api/contacts/:contactId?accountId=xxx
|
||||||
*/
|
*/
|
||||||
export const removeContact = async (req, res) => {
|
export const removeContact = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.userId;
|
const userId = req.userId;
|
||||||
const { contactId } = req.params;
|
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({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -176,15 +204,29 @@ export const removeContact = async (req, res) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a contact
|
* Update a contact
|
||||||
* PATCH /api/contacts/:contactId
|
* PATCH /api/contacts/:contactId?accountId=xxx
|
||||||
*/
|
*/
|
||||||
export const updateContact = async (req, res) => {
|
export const updateContact = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.userId;
|
const userId = req.userId;
|
||||||
const { contactId } = req.params;
|
const { contactId } = req.params;
|
||||||
|
const { accountId } = req.query;
|
||||||
const { name, notes } = req.body;
|
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({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -8,14 +8,27 @@ import { logger } from '../utils/logger.js';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all emails for authenticated user
|
* Get all emails for authenticated user
|
||||||
* GET /api/emails?accountId=xxx (optional)
|
* GET /api/emails?accountId=xxx (REQUIRED)
|
||||||
*/
|
*/
|
||||||
export const getEmails = async (req, res) => {
|
export const getEmails = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.userId;
|
const userId = req.userId;
|
||||||
const { accountId } = req.query;
|
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({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -30,14 +43,28 @@ export const getEmails = async (req, res) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get emails by thread (conversation)
|
* 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) => {
|
export const getThread = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.userId;
|
const userId = req.userId;
|
||||||
const { threadId } = req.params;
|
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({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -52,14 +79,27 @@ export const getThread = async (req, res) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Search emails
|
* 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) => {
|
export const searchEmails = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.userId;
|
const userId = req.userId;
|
||||||
const { q, accountId } = req.query;
|
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({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -80,14 +120,20 @@ export const searchEmails = async (req, res) => {
|
|||||||
export const getUnreadCount = async (req, res) => {
|
export const getUnreadCount = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.userId;
|
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({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
count: unreadData.totalUnread,
|
count: unreadData.totalUnread,
|
||||||
totalUnread: unreadData.totalUnread,
|
totalUnread: unreadData.totalUnread,
|
||||||
accounts: unreadData.accounts,
|
accounts: unreadData.byAccount,
|
||||||
lastUpdatedAt: new Date().toISOString(),
|
lastUpdatedAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -127,7 +173,7 @@ export const syncEmails = async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get contacts for this email account
|
// Get contacts for this email account
|
||||||
const contacts = await contactService.getUserContacts(userId, emailAccount.id);
|
const contacts = await contactService.getContactsForEmailAccount(emailAccount.id);
|
||||||
|
|
||||||
if (!contacts.length) {
|
if (!contacts.length) {
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
@@ -145,7 +191,6 @@ export const syncEmails = async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { total, saved } = await syncEmailsFromSender(
|
const { total, saved } = await syncEmailsFromSender(
|
||||||
jmapConfig,
|
jmapConfig,
|
||||||
userId,
|
|
||||||
emailAccount.id,
|
emailAccount.id,
|
||||||
contact.id,
|
contact.id,
|
||||||
contact.email,
|
contact.email,
|
||||||
@@ -175,17 +220,27 @@ export const syncEmails = async (req, res) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark email as read/unread
|
* Mark email as read/unread
|
||||||
* PATCH /api/emails/:jmapId/read
|
* PATCH /api/emails/:jmapId/read?accountId=xxx
|
||||||
*/
|
*/
|
||||||
export const markAsRead = async (req, res) => {
|
export const markAsRead = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.userId;
|
const userId = req.userId;
|
||||||
const { jmapId } = req.params;
|
const { jmapId } = req.params;
|
||||||
const { isRead } = req.body;
|
const { isRead, accountId } = req.body;
|
||||||
|
|
||||||
// Get user to access JMAP config
|
if (!accountId) {
|
||||||
const user = await getUserById(userId);
|
return res.status(400).json({
|
||||||
const jmapConfig = getJmapConfig(user);
|
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);
|
await markEmailAsRead(jmapConfig, userId, jmapId, isRead);
|
||||||
|
|
||||||
@@ -201,35 +256,40 @@ export const markAsRead = async (req, res) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark all emails from contact as read
|
* 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) => {
|
export const markContactEmailsRead = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.userId;
|
const userId = req.userId;
|
||||||
const { contactId } = req.params;
|
const { contactId } = req.params;
|
||||||
|
const { accountId } = req.query;
|
||||||
|
|
||||||
// Get contact to find which email account it belongs to
|
if (!accountId) {
|
||||||
const contact = await contactService.getContactById(contactId, userId);
|
return res.status(400).json({
|
||||||
if (!contact) {
|
|
||||||
return res.status(404).json({
|
|
||||||
success: false,
|
success: false,
|
||||||
error: { message: 'Kontakt nenájdený', statusCode: 404 },
|
error: {
|
||||||
|
message: 'accountId je povinný parameter',
|
||||||
|
statusCode: 400,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get email account with credentials
|
// Verify user has access to this email account
|
||||||
const emailAccount = await emailAccountService.getEmailAccountWithCredentials(contact.emailAccountId, userId);
|
const emailAccount = await emailAccountService.getEmailAccountWithCredentials(accountId, userId);
|
||||||
const jmapConfig = getJmapConfigFromAccount(emailAccount);
|
const jmapConfig = getJmapConfigFromAccount(emailAccount);
|
||||||
|
|
||||||
// Mark emails as read in database and get the updated emails
|
// Mark emails as read in database
|
||||||
const result = await crmEmailService.markContactEmailsAsRead(userId, contactId);
|
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
|
// Also mark emails as read on JMAP server
|
||||||
if (result.emails && result.emails.length > 0) {
|
if (contactEmails && contactEmails.length > 0) {
|
||||||
logger.info(`Marking ${result.emails.length} emails as read on JMAP server`);
|
logger.info(`Marking ${contactEmails.length} emails as read on JMAP server`);
|
||||||
|
|
||||||
for (const email of result.emails) {
|
for (const email of contactEmails) {
|
||||||
if (!email.jmapId) {
|
if (!email.jmapId || email.isRead) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -243,8 +303,8 @@ export const markContactEmailsRead = async (req, res) => {
|
|||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
message: `Označených ${result.count} emailov ako prečítaných`,
|
message: `Označených ${count} emailov ako prečítaných`,
|
||||||
data: result,
|
data: { count },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('ERROR in markContactEmailsRead', { error: error.message });
|
logger.error('ERROR in markContactEmailsRead', { error: error.message });
|
||||||
@@ -255,23 +315,32 @@ export const markContactEmailsRead = async (req, res) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark entire thread as read
|
* 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) => {
|
export const markThreadRead = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.userId;
|
const userId = req.userId;
|
||||||
const { threadId } = req.params;
|
const { threadId } = req.params;
|
||||||
|
const { accountId } = req.query;
|
||||||
|
|
||||||
const user = await getUserById(userId);
|
if (!accountId) {
|
||||||
const threadEmails = await crmEmailService.getEmailThread(userId, threadId);
|
return res.status(400).json({
|
||||||
const unreadEmails = threadEmails.filter((email) => !email.isRead);
|
success: false,
|
||||||
|
error: {
|
||||||
let jmapConfig = null;
|
message: 'accountId je povinný parameter',
|
||||||
if (user?.email && user?.emailPassword && user?.jmapAccountId) {
|
statusCode: 400,
|
||||||
jmapConfig = getJmapConfig(user);
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (jmapConfig) {
|
// 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) {
|
for (const email of unreadEmails) {
|
||||||
if (!email.jmapId) {
|
if (!email.jmapId) {
|
||||||
continue;
|
continue;
|
||||||
@@ -282,14 +351,14 @@ export const markThreadRead = async (req, res) => {
|
|||||||
logger.error('Failed to mark JMAP email as read', { jmapId: email.jmapId, error: jmapError.message });
|
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({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Konverzácia označená ako prečítaná',
|
message: 'Konverzácia označená ako prečítaná',
|
||||||
count: unreadEmails.length,
|
count,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
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 emails for a specific contact
|
||||||
* GET /api/emails/contact/:contactId
|
* GET /api/emails/contact/:contactId?accountId=xxx
|
||||||
*/
|
*/
|
||||||
export const getContactEmails = async (req, res) => {
|
export const getContactEmails = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.userId;
|
const userId = req.userId;
|
||||||
const { contactId } = req.params;
|
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({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -409,7 +492,7 @@ export const searchEmailsJMAP = async (req, res) => {
|
|||||||
|
|
||||||
const results = await searchEmailsJMAPService(
|
const results = await searchEmailsJMAPService(
|
||||||
jmapConfig,
|
jmapConfig,
|
||||||
userId,
|
emailAccount.id,
|
||||||
query,
|
query,
|
||||||
parseInt(limit),
|
parseInt(limit),
|
||||||
parseInt(offset)
|
parseInt(offset)
|
||||||
|
|||||||
282
src/controllers/timesheet.controller.js
Normal file
282
src/controllers/timesheet.controller.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
15
src/db/migrations/0003_add_timesheets_table.sql
Normal file
15
src/db/migrations/0003_add_timesheets_table.sql
Normal file
@@ -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;
|
||||||
@@ -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
|
// Role enum
|
||||||
export const roleEnum = pgEnum('role', ['admin', 'member']);
|
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', {
|
export const users = pgTable('users', {
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
username: text('username').notNull().unique(),
|
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'),
|
firstName: text('first_name'),
|
||||||
lastName: text('last_name'),
|
lastName: text('last_name'),
|
||||||
password: text('password'), // bcrypt hash (null ak ešte nenastavené)
|
password: text('password'), // bcrypt hash (null ak ešte nenastavené)
|
||||||
tempPassword: text('temp_password'), // dočasné heslo (bcrypt hash)
|
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(),
|
role: roleEnum('role').default('member').notNull(),
|
||||||
lastLogin: timestamp('last_login'),
|
lastLogin: timestamp('last_login'),
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_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', {
|
export const emailAccounts = pgTable('email_accounts', {
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
email: text('email').notNull().unique(), // Email adresa
|
||||||
email: text('email').notNull(),
|
|
||||||
emailPassword: text('email_password').notNull(), // Heslo k emailovému účtu (encrypted)
|
emailPassword: text('email_password').notNull(), // Heslo k emailovému účtu (encrypted)
|
||||||
jmapAccountId: text('jmap_account_id').notNull(), // JMAP account ID z truemail
|
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
|
isActive: boolean('is_active').default(true).notNull(), // či je účet aktívny
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_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í
|
// Audit logs - kompletný audit trail všetkých akcií
|
||||||
export const auditLogs = pgTable('audit_logs', {
|
export const auditLogs = pgTable('audit_logs', {
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
@@ -50,23 +58,27 @@ export const auditLogs = pgTable('audit_logs', {
|
|||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
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', {
|
export const contacts = pgTable('contacts', {
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
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(),
|
emailAccountId: uuid('email_account_id').references(() => emailAccounts.id, { onDelete: 'cascade' }).notNull(),
|
||||||
email: text('email').notNull(),
|
email: text('email').notNull(),
|
||||||
name: text('name'),
|
name: text('name'),
|
||||||
notes: text('notes'),
|
notes: text('notes'),
|
||||||
|
addedBy: uuid('added_by').references(() => users.id, { onDelete: 'set null' }), // kto pridal kontakt
|
||||||
addedAt: timestamp('added_at').defaultNow().notNull(),
|
addedAt: timestamp('added_at').defaultNow().notNull(),
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_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', {
|
export const emails = pgTable('emails', {
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
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(),
|
emailAccountId: uuid('email_account_id').references(() => emailAccounts.id, { onDelete: 'cascade' }).notNull(),
|
||||||
contactId: uuid('contact_id').references(() => contacts.id, { onDelete: 'cascade' }),
|
contactId: uuid('contact_id').references(() => contacts.id, { onDelete: 'cascade' }),
|
||||||
jmapId: text('jmap_id').unique(),
|
jmapId: text('jmap_id').unique(),
|
||||||
@@ -78,7 +90,23 @@ export const emails = pgTable('emails', {
|
|||||||
subject: text('subject'),
|
subject: text('subject'),
|
||||||
body: text('body'),
|
body: text('body'),
|
||||||
isRead: boolean('is_read').default(false).notNull(),
|
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'),
|
date: timestamp('date'),
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_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(),
|
||||||
|
});
|
||||||
|
|||||||
88
src/routes/timesheet.routes.js
Normal file
88
src/routes/timesheet.routes.js
Normal file
@@ -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;
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
104
src/scripts/fix-wrong-contact-associations.js
Normal file
104
src/scripts/fix-wrong-contact-associations.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
189
src/scripts/fresh-database.js
Normal file
189
src/scripts/fresh-database.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
75
src/scripts/seed-admin.js
Normal file
75
src/scripts/seed-admin.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { db } from '../config/database.js';
|
import { db } from '../config/database.js';
|
||||||
import { users } from '../db/schema.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 { generateTokenPair } from '../utils/jwt.js';
|
||||||
import { validateJmapCredentials } from './email.service.js';
|
import * as emailAccountService from './email-account.service.js';
|
||||||
import {
|
import {
|
||||||
AuthenticationError,
|
AuthenticationError,
|
||||||
ConflictError,
|
|
||||||
NotFoundError,
|
NotFoundError,
|
||||||
ValidationError,
|
|
||||||
} from '../utils/errors.js';
|
} from '../utils/errors.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,11 +53,13 @@ export const loginWithTempPassword = async (username, password, ipAddress, userA
|
|||||||
// Generuj JWT tokeny
|
// Generuj JWT tokeny
|
||||||
const tokens = generateTokenPair(user);
|
const tokens = generateTokenPair(user);
|
||||||
|
|
||||||
|
// Check if user has email accounts (many-to-many)
|
||||||
|
const userEmailAccounts = await emailAccountService.getUserEmailAccounts(user.id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
email: user.email,
|
|
||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
@@ -67,7 +67,7 @@ export const loginWithTempPassword = async (username, password, ipAddress, userA
|
|||||||
},
|
},
|
||||||
tokens,
|
tokens,
|
||||||
needsPasswordChange: !user.changedPassword,
|
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
|
* KROK 3: Pripojenie emailu s JMAP validáciou
|
||||||
|
* Používa many-to-many vzťah cez userEmailAccounts
|
||||||
*/
|
*/
|
||||||
export const linkEmail = async (userId, email, emailPassword) => {
|
export const linkEmail = async (userId, email, emailPassword) => {
|
||||||
const [user] = await db
|
const [user] = await db
|
||||||
@@ -124,38 +125,22 @@ export const linkEmail = async (userId, email, emailPassword) => {
|
|||||||
throw new NotFoundError('Používateľ nenájdený');
|
throw new NotFoundError('Používateľ nenájdený');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skontroluj či email už nie je použitý
|
// Použij emailAccountService ktorý automaticky vytvorí many-to-many link
|
||||||
const [existingUser] = await db
|
const newEmailAccount = await emailAccountService.createEmailAccount(
|
||||||
.select()
|
userId,
|
||||||
.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,
|
email,
|
||||||
emailPassword: encryptedEmailPassword,
|
emailPassword
|
||||||
jmapAccountId: accountId,
|
);
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(users.id, userId));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
accountId,
|
accountId: newEmailAccount.jmapAccountId,
|
||||||
message: 'Email účet úspešne pripojený',
|
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
|
* Get user by ID
|
||||||
|
* Email credentials sú v emailAccounts tabuľke (many-to-many)
|
||||||
*/
|
*/
|
||||||
export const getUserById = async (userId) => {
|
export const getUserById = async (userId) => {
|
||||||
const [user] = await db
|
const [user] = await db
|
||||||
.select({
|
.select({
|
||||||
id: users.id,
|
id: users.id,
|
||||||
username: users.username,
|
username: users.username,
|
||||||
email: users.email,
|
|
||||||
emailPassword: users.emailPassword,
|
|
||||||
jmapAccountId: users.jmapAccountId,
|
|
||||||
firstName: users.firstName,
|
firstName: users.firstName,
|
||||||
lastName: users.lastName,
|
lastName: users.lastName,
|
||||||
role: users.role,
|
role: users.role,
|
||||||
@@ -215,5 +198,11 @@ export const getUserById = async (userId) => {
|
|||||||
throw new NotFoundError('Používateľ nenájdený');
|
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,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,41 +1,34 @@
|
|||||||
import { db } from '../config/database.js';
|
import { db } from '../config/database.js';
|
||||||
import { contacts, emails } from '../db/schema.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 { NotFoundError, ConflictError } from '../utils/errors.js';
|
||||||
import { syncEmailsFromSender } from './jmap.service.js';
|
import { syncEmailsFromSender } from './jmap.service.js';
|
||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all contacts for a user
|
* Get all contacts for an email account
|
||||||
* If emailAccountId is provided, filter by that account, otherwise return all
|
* Kontakty patria k email accountu, nie k jednotlivým používateľom
|
||||||
*/
|
*/
|
||||||
export const getUserContacts = async (userId, emailAccountId = null) => {
|
export const getContactsForEmailAccount = async (emailAccountId) => {
|
||||||
const conditions = [eq(contacts.userId, userId)];
|
const accountContacts = await db
|
||||||
|
|
||||||
if (emailAccountId) {
|
|
||||||
conditions.push(eq(contacts.emailAccountId, emailAccountId));
|
|
||||||
}
|
|
||||||
|
|
||||||
const userContacts = await db
|
|
||||||
.select()
|
.select()
|
||||||
.from(contacts)
|
.from(contacts)
|
||||||
.where(and(...conditions))
|
.where(eq(contacts.emailAccountId, emailAccountId))
|
||||||
.orderBy(desc(contacts.addedAt));
|
.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
|
// Check if contact already exists for this email account
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(contacts)
|
.from(contacts)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(contacts.userId, userId),
|
|
||||||
eq(contacts.emailAccountId, emailAccountId),
|
eq(contacts.emailAccountId, emailAccountId),
|
||||||
eq(contacts.email, email)
|
eq(contacts.email, email)
|
||||||
)
|
)
|
||||||
@@ -50,33 +43,80 @@ export const addContact = async (userId, emailAccountId, jmapConfig, email, name
|
|||||||
const [newContact] = await db
|
const [newContact] = await db
|
||||||
.insert(contacts)
|
.insert(contacts)
|
||||||
.values({
|
.values({
|
||||||
userId,
|
|
||||||
emailAccountId,
|
emailAccountId,
|
||||||
email,
|
email,
|
||||||
name: name || email.split('@')[0],
|
name: name || email.split('@')[0],
|
||||||
notes: notes || null,
|
notes: notes || null,
|
||||||
|
addedBy: addedByUserId,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
// Sync emails from this sender
|
// Sync emails from this sender
|
||||||
try {
|
try {
|
||||||
await syncEmailsFromSender(jmapConfig, userId, emailAccountId, newContact.id, email);
|
await syncEmailsFromSender(jmapConfig, emailAccountId, newContact.id, email);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to sync emails for new contact', { error: error.message });
|
logger.error('Failed to sync emails for new contact', { error: error.message });
|
||||||
// Don't throw - contact was created successfully
|
// 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;
|
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
|
const [contact] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(contacts)
|
.from(contacts)
|
||||||
.where(and(eq(contacts.id, contactId), eq(contacts.userId, userId)))
|
.where(
|
||||||
|
and(
|
||||||
|
eq(contacts.id, contactId),
|
||||||
|
eq(contacts.emailAccountId, emailAccountId)
|
||||||
|
)
|
||||||
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!contact) {
|
if (!contact) {
|
||||||
@@ -89,16 +129,8 @@ export const getContactById = async (contactId, userId) => {
|
|||||||
/**
|
/**
|
||||||
* Remove a contact
|
* Remove a contact
|
||||||
*/
|
*/
|
||||||
export const removeContact = async (userId, contactId) => {
|
export const removeContact = async (contactId, emailAccountId) => {
|
||||||
const [contact] = await db
|
const contact = await getContactById(contactId, emailAccountId);
|
||||||
.select()
|
|
||||||
.from(contacts)
|
|
||||||
.where(and(eq(contacts.id, contactId), eq(contacts.userId, userId)))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!contact) {
|
|
||||||
throw new NotFoundError('Kontakt nenájdený');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete contact (emails will be cascade deleted)
|
// Delete contact (emails will be cascade deleted)
|
||||||
await db.delete(contacts).where(eq(contacts.id, contactId));
|
await db.delete(contacts).where(eq(contacts.id, contactId));
|
||||||
@@ -109,16 +141,8 @@ export const removeContact = async (userId, contactId) => {
|
|||||||
/**
|
/**
|
||||||
* Update contact
|
* Update contact
|
||||||
*/
|
*/
|
||||||
export const updateContact = async (userId, contactId, { name, notes }) => {
|
export const updateContact = async (contactId, emailAccountId, { name, notes }) => {
|
||||||
const [contact] = await db
|
const contact = await getContactById(contactId, emailAccountId);
|
||||||
.select()
|
|
||||||
.from(contacts)
|
|
||||||
.where(and(eq(contacts.id, contactId), eq(contacts.userId, userId)))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!contact) {
|
|
||||||
throw new NotFoundError('Kontakt nenájdený');
|
|
||||||
}
|
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(contacts)
|
.update(contacts)
|
||||||
|
|||||||
@@ -1,20 +1,13 @@
|
|||||||
import { db } from '../config/database.js';
|
import { db } from '../config/database.js';
|
||||||
import { emails, contacts } from '../db/schema.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';
|
import { NotFoundError } from '../utils/errors.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all emails for a user (only from added contacts)
|
* Get all emails for an email account (only from added contacts)
|
||||||
* If emailAccountId is provided, filter by that account
|
|
||||||
*/
|
*/
|
||||||
export const getUserEmails = async (userId, emailAccountId = null) => {
|
export const getEmailsForAccount = async (emailAccountId) => {
|
||||||
const conditions = [eq(emails.userId, userId)];
|
const accountEmails = await db
|
||||||
|
|
||||||
if (emailAccountId) {
|
|
||||||
conditions.push(eq(emails.emailAccountId, emailAccountId));
|
|
||||||
}
|
|
||||||
|
|
||||||
const userEmails = await db
|
|
||||||
.select({
|
.select({
|
||||||
id: emails.id,
|
id: emails.id,
|
||||||
jmapId: emails.jmapId,
|
jmapId: emails.jmapId,
|
||||||
@@ -26,6 +19,7 @@ export const getUserEmails = async (userId, emailAccountId = null) => {
|
|||||||
subject: emails.subject,
|
subject: emails.subject,
|
||||||
body: emails.body,
|
body: emails.body,
|
||||||
isRead: emails.isRead,
|
isRead: emails.isRead,
|
||||||
|
sentByUserId: emails.sentByUserId,
|
||||||
date: emails.date,
|
date: emails.date,
|
||||||
createdAt: emails.createdAt,
|
createdAt: emails.createdAt,
|
||||||
emailAccountId: emails.emailAccountId,
|
emailAccountId: emails.emailAccountId,
|
||||||
@@ -36,21 +30,26 @@ export const getUserEmails = async (userId, emailAccountId = null) => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
.from(emails)
|
.from(emails)
|
||||||
.leftJoin(contacts, eq(emails.contactId, contacts.id))
|
.innerJoin(contacts, eq(emails.contactId, contacts.id))
|
||||||
.where(and(...conditions))
|
.where(eq(emails.emailAccountId, emailAccountId))
|
||||||
.orderBy(desc(emails.date));
|
.orderBy(desc(emails.date));
|
||||||
|
|
||||||
return userEmails;
|
return accountEmails;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get emails by thread ID
|
* Get emails by thread ID
|
||||||
*/
|
*/
|
||||||
export const getEmailThread = async (userId, threadId) => {
|
export const getEmailThread = async (emailAccountId, threadId) => {
|
||||||
const thread = await db
|
const thread = await db
|
||||||
.select()
|
.select()
|
||||||
.from(emails)
|
.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);
|
.orderBy(emails.date);
|
||||||
|
|
||||||
if (thread.length === 0) {
|
if (thread.length === 0) {
|
||||||
@@ -62,16 +61,15 @@ export const getEmailThread = async (userId, threadId) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Search emails (from, to, subject)
|
* 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) {
|
if (!query || query.trim().length < 2) {
|
||||||
throw new Error('Search term must be at least 2 characters');
|
throw new Error('Search term must be at least 2 characters');
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchPattern = `%${query}%`;
|
const searchPattern = `%${query}%`;
|
||||||
const conditions = [
|
const conditions = [
|
||||||
eq(emails.userId, userId),
|
eq(emails.emailAccountId, emailAccountId),
|
||||||
or(
|
or(
|
||||||
like(emails.from, searchPattern),
|
like(emails.from, searchPattern),
|
||||||
like(emails.to, 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
|
const results = await db
|
||||||
.select()
|
.select()
|
||||||
.from(emails)
|
.from(emails)
|
||||||
@@ -94,173 +88,150 @@ export const searchEmails = async (userId, query, emailAccountId = null) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get unread email count
|
* Get unread email count for an email account (only from added contacts)
|
||||||
* Returns total count and counts per email account
|
|
||||||
*/
|
*/
|
||||||
export const getUnreadCount = async (userId) => {
|
export const getUnreadCountForAccount = async (emailAccountId) => {
|
||||||
// Get total unread count
|
const result = await db
|
||||||
const totalResult = await db
|
|
||||||
.select({ count: sql`count(*)::int` })
|
.select({ count: sql`count(*)::int` })
|
||||||
.from(emails)
|
.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
|
const accountCounts = await db
|
||||||
.select({
|
.select({
|
||||||
emailAccountId: emails.emailAccountId,
|
emailAccountId: emails.emailAccountId,
|
||||||
count: sql`count(*)::int`,
|
count: sql`count(*)::int`,
|
||||||
})
|
})
|
||||||
.from(emails)
|
.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);
|
.groupBy(emails.emailAccountId);
|
||||||
|
|
||||||
|
const totalUnread = accountCounts.reduce((sum, acc) => sum + acc.count, 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalUnread,
|
totalUnread,
|
||||||
accounts: accountCounts.map((ac) => ({
|
byAccount: accountCounts,
|
||||||
emailAccountId: ac.emailAccountId,
|
|
||||||
unreadCount: ac.count,
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* Mark thread as read
|
||||||
*/
|
*/
|
||||||
export const markThreadAsRead = async (userId, threadId) => {
|
export const markThreadAsRead = async (emailAccountId, threadId) => {
|
||||||
console.log('🟦 markThreadAsRead called:', { userId, threadId });
|
const updated = await db
|
||||||
|
|
||||||
const result = await db
|
|
||||||
.update(emails)
|
.update(emails)
|
||||||
.set({ isRead: true, updatedAt: new Date() })
|
.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();
|
.returning();
|
||||||
|
|
||||||
console.log('✅ markThreadAsRead result:', { count: result.length, threadId });
|
return updated.length;
|
||||||
|
|
||||||
return { success: true, count: result.length };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark all emails from a contact as read
|
* Mark all emails from a contact as read
|
||||||
*/
|
*/
|
||||||
export const markContactEmailsAsRead = async (userId, contactId) => {
|
export const markContactEmailsAsRead = async (contactId, emailAccountId) => {
|
||||||
console.log('🟦 markContactEmailsAsRead called:', { userId, contactId });
|
const updated = await db
|
||||||
|
|
||||||
// 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)
|
.update(emails)
|
||||||
.set({ isRead: true, updatedAt: new Date() })
|
.set({ isRead: true, updatedAt: new Date() })
|
||||||
.where(and(eq(emails.userId, userId), eq(emails.contactId, contactId), eq(emails.isRead, false)))
|
.where(
|
||||||
|
and(
|
||||||
|
eq(emails.emailAccountId, emailAccountId),
|
||||||
|
eq(emails.contactId, contactId)
|
||||||
|
)
|
||||||
|
)
|
||||||
.returning();
|
.returning();
|
||||||
}
|
|
||||||
|
|
||||||
console.log('✅ markContactEmailsAsRead result:', { count: result.length, contactId });
|
return updated.length;
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
count: result.length,
|
|
||||||
emails: result, // Return the emails so controller can mark them on JMAP server
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
const contactEmails = await db
|
||||||
.select()
|
.select()
|
||||||
.from(emails)
|
.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));
|
.orderBy(desc(emails.date));
|
||||||
|
|
||||||
return contactEmails;
|
return contactEmails;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and, sql } from 'drizzle-orm';
|
||||||
import { db } from '../config/database.js';
|
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 { encryptPassword, decryptPassword } from '../utils/password.js';
|
||||||
import { validateJmapCredentials } from './email.service.js';
|
import { validateJmapCredentials } from './email.service.js';
|
||||||
import {
|
import {
|
||||||
@@ -12,40 +12,50 @@ import {
|
|||||||
import { logger } from '../utils/logger.js';
|
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) => {
|
export const getUserEmailAccounts = async (userId) => {
|
||||||
const accounts = await db
|
const accounts = await db
|
||||||
.select({
|
.select({
|
||||||
id: emailAccounts.id,
|
id: emailAccounts.id,
|
||||||
userId: emailAccounts.userId,
|
|
||||||
email: emailAccounts.email,
|
email: emailAccounts.email,
|
||||||
jmapAccountId: emailAccounts.jmapAccountId,
|
jmapAccountId: emailAccounts.jmapAccountId,
|
||||||
isPrimary: emailAccounts.isPrimary,
|
|
||||||
isActive: emailAccounts.isActive,
|
isActive: emailAccounts.isActive,
|
||||||
|
isPrimary: userEmailAccounts.isPrimary,
|
||||||
|
addedAt: userEmailAccounts.addedAt,
|
||||||
createdAt: emailAccounts.createdAt,
|
createdAt: emailAccounts.createdAt,
|
||||||
updatedAt: emailAccounts.updatedAt,
|
|
||||||
})
|
})
|
||||||
.from(emailAccounts)
|
.from(userEmailAccounts)
|
||||||
.where(eq(emailAccounts.userId, userId))
|
.innerJoin(emailAccounts, eq(userEmailAccounts.emailAccountId, emailAccounts.id))
|
||||||
.orderBy(emailAccounts.isPrimary, emailAccounts.createdAt);
|
.where(eq(userEmailAccounts.userId, userId))
|
||||||
|
.orderBy(userEmailAccounts.isPrimary, emailAccounts.createdAt);
|
||||||
|
|
||||||
return accounts;
|
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) => {
|
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
|
const [account] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(emailAccounts)
|
.from(emailAccounts)
|
||||||
.where(
|
.where(eq(emailAccounts.id, accountId))
|
||||||
and(
|
|
||||||
eq(emailAccounts.id, accountId),
|
|
||||||
eq(emailAccounts.userId, userId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
@@ -59,40 +69,85 @@ export const getEmailAccountById = async (accountId, userId) => {
|
|||||||
* Get user's primary email account
|
* Get user's primary email account
|
||||||
*/
|
*/
|
||||||
export const getPrimaryEmailAccount = async (userId) => {
|
export const getPrimaryEmailAccount = async (userId) => {
|
||||||
const [account] = await db
|
const [result] = await db
|
||||||
.select()
|
.select({
|
||||||
.from(emailAccounts)
|
id: emailAccounts.id,
|
||||||
|
email: emailAccounts.email,
|
||||||
|
jmapAccountId: emailAccounts.jmapAccountId,
|
||||||
|
isActive: emailAccounts.isActive,
|
||||||
|
})
|
||||||
|
.from(userEmailAccounts)
|
||||||
|
.innerJoin(emailAccounts, eq(userEmailAccounts.emailAccountId, emailAccounts.id))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(emailAccounts.userId, userId),
|
eq(userEmailAccounts.userId, userId),
|
||||||
eq(emailAccounts.isPrimary, true)
|
eq(userEmailAccounts.isPrimary, true)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
return account || null;
|
return result || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new email account with JMAP validation
|
* 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) => {
|
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
|
const [existing] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(emailAccounts)
|
.from(emailAccounts)
|
||||||
|
.where(eq(emailAccounts.email, email))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Email account už existuje - skontroluj či user už má prístup
|
||||||
|
const [userLink] = await db
|
||||||
|
.select()
|
||||||
|
.from(userEmailAccounts)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(emailAccounts.userId, userId),
|
eq(userEmailAccounts.userId, userId),
|
||||||
eq(emailAccounts.email, email)
|
eq(userEmailAccounts.emailAccountId, existing.id)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (existing) {
|
if (userLink) {
|
||||||
throw new ConflictError('Tento email účet už je pripojený');
|
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
|
// Validate JMAP credentials and get account ID
|
||||||
let jmapAccountId;
|
let jmapAccountId;
|
||||||
try {
|
try {
|
||||||
@@ -111,26 +166,36 @@ export const createEmailAccount = async (userId, email, emailPassword) => {
|
|||||||
const existingAccounts = await getUserEmailAccounts(userId);
|
const existingAccounts = await getUserEmailAccounts(userId);
|
||||||
const isFirst = existingAccounts.length === 0;
|
const isFirst = existingAccounts.length === 0;
|
||||||
|
|
||||||
|
// Use transaction to create email account and link it to user
|
||||||
|
const result = await db.transaction(async (tx) => {
|
||||||
// Create email account
|
// Create email account
|
||||||
const [newAccount] = await db
|
const [newAccount] = await tx
|
||||||
.insert(emailAccounts)
|
.insert(emailAccounts)
|
||||||
.values({
|
.values({
|
||||||
userId,
|
|
||||||
email,
|
email,
|
||||||
emailPassword: encryptedPassword,
|
emailPassword: encryptedPassword,
|
||||||
jmapAccountId,
|
jmapAccountId,
|
||||||
isPrimary: isFirst, // First account is automatically primary
|
|
||||||
isActive: true,
|
isActive: true,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
// Link user to email account
|
||||||
|
await tx.insert(userEmailAccounts).values({
|
||||||
|
userId,
|
||||||
|
emailAccountId: newAccount.id,
|
||||||
|
isPrimary: isFirst, // First account is automatically primary
|
||||||
|
});
|
||||||
|
|
||||||
|
return newAccount;
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: newAccount.id,
|
id: result.id,
|
||||||
email: newAccount.email,
|
email: result.email,
|
||||||
jmapAccountId: newAccount.jmapAccountId,
|
jmapAccountId: result.jmapAccountId,
|
||||||
isPrimary: newAccount.isPrimary,
|
isPrimary: isFirst,
|
||||||
isActive: newAccount.isActive,
|
isActive: result.isActive,
|
||||||
createdAt: newAccount.createdAt,
|
shared: false,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -138,9 +203,16 @@ export const createEmailAccount = async (userId, email, emailPassword) => {
|
|||||||
* Update email account password
|
* Update email account password
|
||||||
*/
|
*/
|
||||||
export const updateEmailAccountPassword = async (accountId, userId, newPassword) => {
|
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
|
// Validate new JMAP credentials
|
||||||
|
const [account] = await db
|
||||||
|
.select()
|
||||||
|
.from(emailAccounts)
|
||||||
|
.where(eq(emailAccounts.id, accountId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await validateJmapCredentials(account.email, newPassword);
|
await validateJmapCredentials(account.email, newPassword);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -173,10 +245,23 @@ export const updateEmailAccountPassword = async (accountId, userId, newPassword)
|
|||||||
* Toggle email account active status
|
* Toggle email account active status
|
||||||
*/
|
*/
|
||||||
export const toggleEmailAccountStatus = async (accountId, userId, isActive) => {
|
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
|
// Cannot deactivate primary account
|
||||||
if (account.isPrimary && !isActive) {
|
if (link.isPrimary && !isActive) {
|
||||||
throw new ValidationError('Nemôžete deaktivovať primárny email účet');
|
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) => {
|
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
|
// Use transaction to prevent race conditions
|
||||||
const updated = await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
// Remove primary flag from all accounts
|
// Remove primary flag from all user's accounts
|
||||||
await tx
|
await tx
|
||||||
.update(emailAccounts)
|
.update(userEmailAccounts)
|
||||||
.set({ isPrimary: false, updatedAt: new Date() })
|
.set({ isPrimary: false })
|
||||||
.where(eq(emailAccounts.userId, userId));
|
.where(eq(userEmailAccounts.userId, userId));
|
||||||
|
|
||||||
// Set new primary account
|
// 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)
|
.update(emailAccounts)
|
||||||
.set({
|
.set({
|
||||||
isPrimary: true,
|
isActive: true,
|
||||||
isActive: true, // Primary account must be active
|
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(emailAccounts.id, accountId))
|
.where(eq(emailAccounts.id, accountId));
|
||||||
.returning();
|
|
||||||
|
|
||||||
return updatedAccount;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [account] = await db
|
||||||
|
.select()
|
||||||
|
.from(emailAccounts)
|
||||||
|
.where(eq(emailAccounts.id, accountId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: updated.id,
|
id: account.id,
|
||||||
email: updated.email,
|
email: account.email,
|
||||||
isPrimary: updated.isPrimary,
|
isPrimary: true,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete an email account
|
* Remove user's access to email account
|
||||||
* NOTE: This will cascade delete all associated contacts and emails
|
* Ak je to posledný používateľ, vymaže aj samotný email account
|
||||||
*/
|
*/
|
||||||
export const deleteEmailAccount = async (accountId, userId) => {
|
export const removeUserFromEmailAccount = async (accountId, userId) => {
|
||||||
const account = await getEmailAccountById(accountId, userId);
|
// Check user has access
|
||||||
|
await getEmailAccountById(accountId, userId);
|
||||||
|
|
||||||
// Cannot delete primary account if it's the only one
|
// Get user's link
|
||||||
if (account.isPrimary) {
|
const [link] = await db
|
||||||
const allAccounts = await getUserEmailAccounts(userId);
|
.select()
|
||||||
if (allAccounts.length === 1) {
|
.from(userEmailAccounts)
|
||||||
throw new ValidationError('Nemôžete zmazať posledný email účet');
|
.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
|
// Set another account as primary
|
||||||
const otherAccount = allAccounts.find(acc => acc.id !== accountId);
|
const otherAccount = allUserAccounts.find(acc => acc.id !== accountId);
|
||||||
if (otherAccount) {
|
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(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
|
await db
|
||||||
.delete(emailAccounts)
|
.delete(emailAccounts)
|
||||||
.where(eq(emailAccounts.id, accountId));
|
.where(eq(emailAccounts.id, accountId));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: 'Email účet bol úspešne odstránený',
|
message: 'Email účet bol úspešne odstránený (posledný používateľ)',
|
||||||
|
deletedAccountId: accountId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Prístup k email účtu bol odobraný',
|
||||||
deletedAccountId: accountId,
|
deletedAccountId: accountId,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -267,7 +410,9 @@ export const deleteEmailAccount = async (accountId, userId) => {
|
|||||||
*/
|
*/
|
||||||
export const getEmailAccountWithCredentials = async (accountId, userId) => {
|
export const getEmailAccountWithCredentials = async (accountId, userId) => {
|
||||||
logger.debug('getEmailAccountWithCredentials called', { accountId, userId });
|
logger.debug('getEmailAccountWithCredentials called', { accountId, userId });
|
||||||
|
|
||||||
const account = await getEmailAccountById(accountId, userId);
|
const account = await getEmailAccountById(accountId, userId);
|
||||||
|
|
||||||
logger.debug('Account retrieved', {
|
logger.debug('Account retrieved', {
|
||||||
id: account.id,
|
id: account.id,
|
||||||
email: account.email,
|
email: account.email,
|
||||||
@@ -286,3 +431,86 @@ export const getEmailAccountWithCredentials = async (accountId, userId) => {
|
|||||||
isActive: account.isActive,
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export const getIdentities = async (jmapConfig) => {
|
|||||||
* Discover potential contacts from JMAP (no DB storage)
|
* Discover potential contacts from JMAP (no DB storage)
|
||||||
* Returns list of unique senders
|
* Returns list of unique senders
|
||||||
*/
|
*/
|
||||||
export const discoverContactsFromJMAP = async (jmapConfig, userId, searchTerm = '', limit = 50) => {
|
export const discoverContactsFromJMAP = async (jmapConfig, emailAccountId, searchTerm = '', limit = 50) => {
|
||||||
try {
|
try {
|
||||||
logger.info(`Discovering contacts from JMAP (search: "${searchTerm}")`);
|
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;
|
const emailsList = getResponse.methodResponses[0][1].list;
|
||||||
|
|
||||||
// Get existing contacts for this user
|
// Get existing contacts for this email account
|
||||||
const existingContacts = await db
|
const existingContacts = await db
|
||||||
.select()
|
.select()
|
||||||
.from(contacts)
|
.from(contacts)
|
||||||
.where(eq(contacts.userId, userId));
|
.where(eq(contacts.emailAccountId, emailAccountId));
|
||||||
|
|
||||||
const contactEmailsSet = new Set(existingContacts.map((c) => c.email.toLowerCase()));
|
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
|
* Searches in: from, to, subject, and email body
|
||||||
* Returns list of unique senders grouped by email address
|
* 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 {
|
try {
|
||||||
logger.info(`Searching emails in JMAP (query: "${query}", limit: ${limit}, offset: ${offset})`);
|
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;
|
const emailsList = getResponse.methodResponses[0][1].list;
|
||||||
|
|
||||||
// Get existing contacts for this user
|
// Get existing contacts for this email account
|
||||||
const existingContacts = await db
|
const existingContacts = await db
|
||||||
.select()
|
.select()
|
||||||
.from(contacts)
|
.from(contacts)
|
||||||
.where(eq(contacts.userId, userId));
|
.where(eq(contacts.emailAccountId, emailAccountId));
|
||||||
|
|
||||||
const contactEmailsSet = new Set(existingContacts.map((c) => c.email.toLowerCase()));
|
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 (
|
export const syncEmailsFromSender = async (
|
||||||
jmapConfig,
|
jmapConfig,
|
||||||
userId,
|
|
||||||
emailAccountId,
|
emailAccountId,
|
||||||
contactId,
|
contactId,
|
||||||
senderEmail,
|
senderEmail,
|
||||||
options = {}
|
options = {}
|
||||||
) => {
|
) => {
|
||||||
const { limit = 500 } = options;
|
const { limit = 50, daysBack = 30 } = options;
|
||||||
|
|
||||||
try {
|
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
|
// Get Inbox and Sent mailboxes ONLY
|
||||||
const queryResponse = await jmapRequest(jmapConfig, [
|
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',
|
'Email/query',
|
||||||
{
|
{
|
||||||
accountId: jmapConfig.accountId,
|
accountId: jmapConfig.accountId,
|
||||||
filter: {
|
filter: {
|
||||||
operator: 'OR',
|
operator: 'AND',
|
||||||
conditions: [{ from: senderEmail }, { to: senderEmail }],
|
conditions: [
|
||||||
|
{ inMailbox: inboxMailbox.id },
|
||||||
|
{ from: senderEmail },
|
||||||
|
{ after: dateThresholdISO }
|
||||||
|
]
|
||||||
},
|
},
|
||||||
sort: [{ property: 'receivedAt', isAscending: false }],
|
sort: [{ property: 'receivedAt', isAscending: false }],
|
||||||
limit,
|
limit,
|
||||||
@@ -374,8 +398,39 @@ export const syncEmailsFromSender = async (
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const emailIds = queryResponse.methodResponses[0][1].ids;
|
const fromEmailIds = queryFromResponse.methodResponses[0][1].ids || [];
|
||||||
logger.info(`Found ${emailIds.length} emails from ${senderEmail}`);
|
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) {
|
if (emailIds.length === 0) {
|
||||||
return { total: 0, saved: 0 };
|
return { total: 0, saved: 0 };
|
||||||
@@ -427,6 +482,19 @@ export const syncEmailsFromSender = async (
|
|||||||
continue;
|
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
|
// Skip if already exists
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -440,7 +508,6 @@ export const syncEmailsFromSender = async (
|
|||||||
|
|
||||||
// Save email
|
// Save email
|
||||||
await db.insert(emails).values({
|
await db.insert(emails).values({
|
||||||
userId,
|
|
||||||
emailAccountId,
|
emailAccountId,
|
||||||
contactId,
|
contactId,
|
||||||
jmapId: email.id,
|
jmapId: email.id,
|
||||||
@@ -456,6 +523,7 @@ export const syncEmailsFromSender = async (
|
|||||||
'(Empty message)',
|
'(Empty message)',
|
||||||
date: email.receivedAt ? new Date(email.receivedAt) : new Date(),
|
date: email.receivedAt ? new Date(email.receivedAt) : new Date(),
|
||||||
isRead,
|
isRead,
|
||||||
|
sentByUserId: null, // Prijatý email, nie odpoveď
|
||||||
});
|
});
|
||||||
|
|
||||||
savedCount++;
|
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 };
|
return { total: emailsList.length, saved: savedCount };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to sync emails from sender', error);
|
logger.error('Failed to sync emails with contact', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -636,13 +704,24 @@ export const sendEmail = async (jmapConfig, userId, emailAccountId, to, subject,
|
|||||||
|
|
||||||
logger.success(`Email sent successfully to ${to}`);
|
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
|
// Save sent email to database
|
||||||
const messageId = `<${Date.now()}.${Math.random().toString(36).substr(2, 9)}@${jmapConfig.username.split('@')[1]}>`;
|
const messageId = `<${Date.now()}.${Math.random().toString(36).substr(2, 9)}@${jmapConfig.username.split('@')[1]}>`;
|
||||||
|
|
||||||
await db.insert(emails).values({
|
await db.insert(emails).values({
|
||||||
userId,
|
|
||||||
emailAccountId,
|
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,
|
jmapId: createdEmailId,
|
||||||
messageId,
|
messageId,
|
||||||
threadId: threadId || messageId,
|
threadId: threadId || messageId,
|
||||||
@@ -653,8 +732,11 @@ export const sendEmail = async (jmapConfig, userId, emailAccountId, to, subject,
|
|||||||
body,
|
body,
|
||||||
date: new Date(),
|
date: new Date(),
|
||||||
isRead: true, // Sent emails are always read
|
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 };
|
return { success: true, messageId };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error sending email', error);
|
logger.error('Error sending email', error);
|
||||||
|
|||||||
@@ -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 {
|
export class AuthenticationError extends AppError {
|
||||||
constructor(message = 'Neautorizovaný prístup') {
|
constructor(message = 'Neautorizovaný prístup') {
|
||||||
super(message, 401);
|
super(message, 401);
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user