initialize git, basic setup for crm
This commit is contained in:
34
.env.example
Normal file
34
.env.example
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
PORT=5000
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_USER=admin
|
||||||
|
DB_PASSWORD=heslo123
|
||||||
|
DB_NAME=crm
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
|
||||||
|
JWT_EXPIRES_IN=1h
|
||||||
|
JWT_REFRESH_SECRET=your-super-secret-refresh-key-change-this-in-production
|
||||||
|
JWT_REFRESH_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# Better Auth Configuration
|
||||||
|
BETTER_AUTH_SECRET=your-super-secret-better-auth-key-change-this-in-production
|
||||||
|
BETTER_AUTH_URL=http://localhost:5000
|
||||||
|
|
||||||
|
# JMAP Email Configuration
|
||||||
|
JMAP_SERVER=https://mail.truemail.sk/jmap/
|
||||||
|
JMAP_USERNAME=info1_test@truemail.sk
|
||||||
|
JMAP_PASSWORD=info1
|
||||||
|
JMAP_ACCOUNT_ID=ba
|
||||||
|
|
||||||
|
# Security
|
||||||
|
BCRYPT_ROUNDS=12
|
||||||
|
RATE_LIMIT_WINDOW_MS=900000
|
||||||
|
RATE_LIMIT_MAX_REQUESTS=100
|
||||||
|
RATE_LIMIT_LOGIN_MAX=5
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ORIGIN=http://localhost:3000
|
||||||
13
.eslintrc.json
Normal file
13
.eslintrc.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"node": true,
|
||||||
|
"es6": true
|
||||||
|
},
|
||||||
|
"extends": "eslint:recommended",
|
||||||
|
"parserOptions": {
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"no-console": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
47
.gitignore
vendored
Normal file
47
.gitignore
vendored
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# Database
|
||||||
|
postgres/
|
||||||
|
*.sqlite
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
|
||||||
|
# Build
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
*.tmp
|
||||||
|
cookies.txt
|
||||||
|
|
||||||
|
# Production
|
||||||
|
.env.production.local
|
||||||
381
README.md
Normal file
381
README.md
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
# CRM Server - Autentifikačný Systém
|
||||||
|
|
||||||
|
Backend API server pre CRM systém s pokročilým autentifikačným systémom, role-based access control a kompletnou bezpečnostnou vrstvou.
|
||||||
|
|
||||||
|
## Technológie
|
||||||
|
|
||||||
|
- **Node.js** + **Express.js** - Backend framework
|
||||||
|
- **PostgreSQL** - Databáza
|
||||||
|
- **Drizzle ORM** - Type-safe ORM
|
||||||
|
- **JWT** - JSON Web Tokens pre autentifikáciu
|
||||||
|
- **Bcrypt** - Password hashing
|
||||||
|
- **Zod** - Validácia vstupov
|
||||||
|
- **Helmet.js** - HTTP security headers
|
||||||
|
- **Express Rate Limit** - Rate limiting
|
||||||
|
- **JMAP** - Email posielanie (Truemail.sk)
|
||||||
|
|
||||||
|
## Funkcionality
|
||||||
|
|
||||||
|
### 3-Krokový Autentifikačný Flow
|
||||||
|
|
||||||
|
1. **Krok 1: Login s temporary password**
|
||||||
|
- Používateľ dostane username a dočasné heslo
|
||||||
|
- Prihlásenie vytvorí JWT tokens a session
|
||||||
|
|
||||||
|
2. **Krok 2: Nastavenie nového hesla**
|
||||||
|
- Po prvom prihlásení musí užívateľ zmeniť heslo
|
||||||
|
- Strong password policy (8+ znakov, uppercase, lowercase, čísla, špeciálne znaky)
|
||||||
|
|
||||||
|
3. **Krok 3: Email setup (voliteľný)**
|
||||||
|
- Pripojenie emailu s verifikačným linkom
|
||||||
|
- Možnosť preskočiť tento krok
|
||||||
|
|
||||||
|
### Role-Based Access Control (RBAC)
|
||||||
|
|
||||||
|
- **Admin** - Plný prístup, môže vytvárať používateľov, meniť role
|
||||||
|
- **Member** - Základný prístup (zatiaľ len vlastný profil)
|
||||||
|
|
||||||
|
### Bezpečnostné Vrstvy
|
||||||
|
|
||||||
|
- ✅ **Helmet.js** - HTTP headers security (CSP, HSTS)
|
||||||
|
- ✅ **CORS** - Whitelist configuration
|
||||||
|
- ✅ **Rate limiting** - Login (5/15min), API (100/15min)
|
||||||
|
- ✅ **Input validation** - Zod schemas
|
||||||
|
- ✅ **SQL injection protection** - Drizzle ORM parametrized queries
|
||||||
|
- ✅ **XSS protection** - Helmet + input sanitization
|
||||||
|
- ✅ **Password security** - Bcrypt (12 rounds)
|
||||||
|
- ✅ **JWT security** - HttpOnly cookies, short expiration
|
||||||
|
- ✅ **Audit logging** - Všetky dôležité akcie logované
|
||||||
|
- ✅ **Environment variables** - Citlivé dáta v .env
|
||||||
|
|
||||||
|
## Inštalácia
|
||||||
|
|
||||||
|
### 1. Klonovanie a Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Konfigurácia Environment Variables
|
||||||
|
|
||||||
|
Skopírujte `.env.example` do `.env` a upravte hodnoty:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Dôležité premenné:
|
||||||
|
- `JWT_SECRET` - Zmeňte v produkcii!
|
||||||
|
- `JWT_REFRESH_SECRET` - Zmeňte v produkcii!
|
||||||
|
- `BETTER_AUTH_SECRET` - Zmeňte v produkcii!
|
||||||
|
- `DB_*` - Databázové credentials
|
||||||
|
- `JMAP_*` - Email server credentials
|
||||||
|
|
||||||
|
### 3. Databáza Setup
|
||||||
|
|
||||||
|
Spustite PostgreSQL (Docker):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
Vygenerujte a aplikujte migrácie:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:generate
|
||||||
|
npm run db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Seed Admin Account
|
||||||
|
|
||||||
|
Vytvorte prvý admin účet:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:seed
|
||||||
|
```
|
||||||
|
|
||||||
|
**DÔLEŽITÉ:** Uložte si vygenerované temporary password!
|
||||||
|
|
||||||
|
### 5. Spustenie Servera
|
||||||
|
|
||||||
|
Development mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Production mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Server beží na `http://localhost:5000`
|
||||||
|
|
||||||
|
## API Endpointy
|
||||||
|
|
||||||
|
### Public Endpoints
|
||||||
|
|
||||||
|
#### Login
|
||||||
|
```http
|
||||||
|
POST /api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "temporary-password"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"user": { ... },
|
||||||
|
"tokens": {
|
||||||
|
"accessToken": "...",
|
||||||
|
"refreshToken": "..."
|
||||||
|
},
|
||||||
|
"needsPasswordChange": true,
|
||||||
|
"needsEmailSetup": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Protected Endpoints (vyžadujú JWT token)
|
||||||
|
|
||||||
|
#### Set New Password (Krok 2)
|
||||||
|
```http
|
||||||
|
POST /api/auth/set-password
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"newPassword": "NewSecurePass123!",
|
||||||
|
"confirmPassword": "NewSecurePass123!"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Link Email (Krok 3)
|
||||||
|
```http
|
||||||
|
POST /api/auth/link-email
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "user@example.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Skip Email Setup
|
||||||
|
```http
|
||||||
|
POST /api/auth/skip-email
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Get Current User
|
||||||
|
```http
|
||||||
|
GET /api/auth/me
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Logout
|
||||||
|
```http
|
||||||
|
POST /api/auth/logout
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Admin Endpoints (vyžadujú admin rolu)
|
||||||
|
|
||||||
|
#### Create User
|
||||||
|
```http
|
||||||
|
POST /api/admin/users
|
||||||
|
Authorization: Bearer <admin-token>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"username": "newuser",
|
||||||
|
"tempPassword": "TempPass123!",
|
||||||
|
"role": "member",
|
||||||
|
"firstName": "John",
|
||||||
|
"lastName": "Doe"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Get All Users
|
||||||
|
```http
|
||||||
|
GET /api/admin/users
|
||||||
|
Authorization: Bearer <admin-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Get User by ID
|
||||||
|
```http
|
||||||
|
GET /api/admin/users/:userId
|
||||||
|
Authorization: Bearer <admin-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Change User Role
|
||||||
|
```http
|
||||||
|
PATCH /api/admin/users/:userId/role
|
||||||
|
Authorization: Bearer <admin-token>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"role": "admin"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Delete User
|
||||||
|
```http
|
||||||
|
DELETE /api/admin/users/:userId
|
||||||
|
Authorization: Bearer <admin-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Databázová Schéma
|
||||||
|
|
||||||
|
### Users Table
|
||||||
|
```
|
||||||
|
- id (UUID)
|
||||||
|
- username (unique)
|
||||||
|
- email (unique, nullable)
|
||||||
|
- email_verified (boolean)
|
||||||
|
- first_name
|
||||||
|
- last_name
|
||||||
|
- password (bcrypt hash)
|
||||||
|
- temp_password (bcrypt hash)
|
||||||
|
- changed_password (boolean)
|
||||||
|
- role (enum: admin, member)
|
||||||
|
- last_login
|
||||||
|
- created_at
|
||||||
|
- updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sessions Table
|
||||||
|
```
|
||||||
|
- id (text)
|
||||||
|
- user_id (UUID, FK)
|
||||||
|
- expires_at
|
||||||
|
- ip_address
|
||||||
|
- user_agent
|
||||||
|
- created_at
|
||||||
|
```
|
||||||
|
|
||||||
|
### Audit Logs Table
|
||||||
|
```
|
||||||
|
- id (UUID)
|
||||||
|
- user_id (UUID, FK, nullable)
|
||||||
|
- action
|
||||||
|
- resource
|
||||||
|
- resource_id
|
||||||
|
- old_value (JSON)
|
||||||
|
- new_value (JSON)
|
||||||
|
- ip_address
|
||||||
|
- user_agent
|
||||||
|
- success (boolean)
|
||||||
|
- error_message
|
||||||
|
- created_at
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validačné Pravidlá
|
||||||
|
|
||||||
|
### Password Policy
|
||||||
|
- Minimálne 8 znakov
|
||||||
|
- Aspoň 1 veľké písmeno
|
||||||
|
- Aspoň 1 malé písmeno
|
||||||
|
- Aspoň 1 číslo
|
||||||
|
- Aspoň 1 špeciálny znak
|
||||||
|
|
||||||
|
### Username Policy
|
||||||
|
- 3-50 znakov
|
||||||
|
- Iba písmená, čísla, pomlčky a podčiarkovníky
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
- **Login endpoint:** 5 pokusov / 15 minút
|
||||||
|
- **API endpoints:** 100 requestov / 15 minút
|
||||||
|
- **Citlivé operácie:** 3 pokusy / 15 minút
|
||||||
|
|
||||||
|
## Audit Logging
|
||||||
|
|
||||||
|
Všetky dôležité akcie sú automaticky logované:
|
||||||
|
- Login attempts (úspešné aj neúspešné)
|
||||||
|
- Password changes
|
||||||
|
- Email linking & verification
|
||||||
|
- Role changes
|
||||||
|
- User creation
|
||||||
|
- A ďalšie...
|
||||||
|
|
||||||
|
## Security Best Practices
|
||||||
|
|
||||||
|
1. **HTTPS Only** - V produkcii vždy používajte HTTPS
|
||||||
|
2. **Strong Secrets** - Zmeňte všetky secret keys v `.env`
|
||||||
|
3. **Regular Updates** - Aktualizujte dependencies
|
||||||
|
4. **Monitor Logs** - Sledujte audit logs
|
||||||
|
5. **Backup Database** - Pravidelné zálohy PostgreSQL
|
||||||
|
|
||||||
|
## Database Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generovanie migrácií
|
||||||
|
npm run db:generate
|
||||||
|
|
||||||
|
# Aplikovanie migrácií
|
||||||
|
npm run db:migrate
|
||||||
|
|
||||||
|
# Push schema (alternative to migrations)
|
||||||
|
npm run db:push
|
||||||
|
|
||||||
|
# Drizzle Studio (GUI)
|
||||||
|
npm run db:studio
|
||||||
|
|
||||||
|
# Seed admin account
|
||||||
|
npm run db:seed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Spustenie testov:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Štruktúra Projektu
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── config/ # Database & auth config
|
||||||
|
├── db/
|
||||||
|
│ ├── schema.js # Drizzle schema
|
||||||
|
│ ├── migrations/ # Auto-generated migrations
|
||||||
|
│ └── seeds/ # Seed scripts
|
||||||
|
├── controllers/ # Request handlers
|
||||||
|
├── services/ # Business logic
|
||||||
|
├── middlewares/
|
||||||
|
│ ├── auth/ # Auth & role middleware
|
||||||
|
│ ├── security/ # Rate limiting, validation
|
||||||
|
│ └── global/ # Error handling
|
||||||
|
├── routes/ # API routes
|
||||||
|
├── validators/ # Zod schemas
|
||||||
|
├── utils/ # Helper functions
|
||||||
|
├── app.js # Express app
|
||||||
|
└── index.js # Server entry point
|
||||||
|
```
|
||||||
|
|
||||||
|
## Licencia
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
|
## Autor
|
||||||
|
|
||||||
|
Richard Tekula
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**POZNÁMKA:** Tento projekt je v aktívnom vývoji. Pre production nasadenie nezabudnite:
|
||||||
|
1. Zmeniť všetky secret keys v `.env`
|
||||||
|
2. Nastaviť HTTPS
|
||||||
|
3. Konfigurovať firewall
|
||||||
|
4. Nastaviť monitoring a alerting
|
||||||
|
5. Pravidelné security audity
|
||||||
13
docker-compose.yml
Normal file
13
docker-compose.yml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
container_name: postgres-db
|
||||||
|
restart: "no" # nebude sa spúšťať automaticky
|
||||||
|
environment:
|
||||||
|
POSTGRES_PASSWORD: heslo123
|
||||||
|
POSTGRES_DB: crm
|
||||||
|
POSTGRES_USER: admin
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- ./postgres:/var/lib/postgresql/data
|
||||||
20
drizzle.config.js
Normal file
20
drizzle.config.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineConfig } from 'drizzle-kit';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: './src/db/schema.js',
|
||||||
|
out: './src/db/migrations',
|
||||||
|
dialect: 'postgresql',
|
||||||
|
dbCredentials: {
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.DB_PORT || '5432'),
|
||||||
|
user: process.env.DB_USER || 'admin',
|
||||||
|
password: process.env.DB_PASSWORD || 'heslo123',
|
||||||
|
database: process.env.DB_NAME || 'crm',
|
||||||
|
ssl: false,
|
||||||
|
},
|
||||||
|
verbose: true,
|
||||||
|
strict: true,
|
||||||
|
});
|
||||||
7836
package-lock.json
generated
Normal file
7836
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
53
package.json
Normal file
53
package.json
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"name": "crm-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "basic express api backend",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nodemon src/index.js",
|
||||||
|
"start": "node src/index.js",
|
||||||
|
"test": "node --experimental-vm-modules node_modules/.bin/jest",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:migrate": "node src/db/migrate.js",
|
||||||
|
"db:push": "drizzle-kit push",
|
||||||
|
"db:studio": "drizzle-kit studio",
|
||||||
|
"db:seed": "node src/db/seeds/admin.seed.js",
|
||||||
|
"db:seed:testuser": "node src/db/seeds/testuser.seed.js"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "Richard Tekula",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"better-auth": "^1.3.34",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"drizzle-orm": "^0.44.7",
|
||||||
|
"express": "^4.21.1",
|
||||||
|
"express-rate-limit": "^8.2.1",
|
||||||
|
"helmet": "^8.0.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"morgan": "^1.10.0",
|
||||||
|
"pg": "^8.16.3",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
|
"xss-clean": "^0.1.4",
|
||||||
|
"zod": "^4.1.12"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/pg": "^8.15.6",
|
||||||
|
"drizzle-kit": "^0.31.7",
|
||||||
|
"eslint": "latest",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"nodemon": "^3.1.7",
|
||||||
|
"supertest": "^6.3.4"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"jest": {
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"transform": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
scripts/encrypt-password.js
Normal file
25
scripts/encrypt-password.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
const encryptPassword = (text) => {
|
||||||
|
const algorithm = 'aes-256-gcm';
|
||||||
|
const key = crypto.scryptSync(process.env.JWT_SECRET || 'your-super-secret-jwt-key-change-this-in-production', 'salt', 32);
|
||||||
|
const iv = crypto.randomBytes(16);
|
||||||
|
|
||||||
|
const cipher = crypto.createCipheriv(algorithm, key, iv);
|
||||||
|
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||||
|
encrypted += cipher.final('hex');
|
||||||
|
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Usage: node scripts/encrypt-password.js YOUR_PASSWORD
|
||||||
|
const password = process.argv[2];
|
||||||
|
if (!password) {
|
||||||
|
console.error('Usage: node scripts/encrypt-password.js YOUR_PASSWORD');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Encrypted password:');
|
||||||
|
console.log(encryptPassword(password));
|
||||||
87
src/app.js
Normal file
87
src/app.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import morgan from 'morgan';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import cors from 'cors';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
import { validateBody } from './middlewares/global/validateBody.js';
|
||||||
|
import { notFound } from './middlewares/global/notFound.js';
|
||||||
|
import { errorHandler } from './middlewares/global/errorHandler.js';
|
||||||
|
import { apiRateLimiter } from './middlewares/security/rateLimiter.js';
|
||||||
|
|
||||||
|
// Import routes
|
||||||
|
import authRoutes from './routes/auth.routes.js';
|
||||||
|
import adminRoutes from './routes/admin.routes.js';
|
||||||
|
import contactRoutes from './routes/contact.routes.js';
|
||||||
|
import crmEmailRoutes from './routes/crm-email.routes.js';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// Security middleware
|
||||||
|
app.use(morgan('dev'));
|
||||||
|
app.use(
|
||||||
|
helmet({
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
directives: {
|
||||||
|
defaultSrc: ["'self'"],
|
||||||
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hsts: {
|
||||||
|
maxAge: 31536000,
|
||||||
|
includeSubDomains: true,
|
||||||
|
preload: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// CORS configuration
|
||||||
|
const corsOptions = {
|
||||||
|
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||||
|
credentials: true,
|
||||||
|
optionsSuccessStatus: 200,
|
||||||
|
};
|
||||||
|
app.use(cors(corsOptions));
|
||||||
|
|
||||||
|
// Body parsing middleware
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
|
// Custom body validation middleware
|
||||||
|
app.use(validateBody);
|
||||||
|
|
||||||
|
// Rate limiting for all API routes
|
||||||
|
app.use('/api', apiRateLimiter);
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'CRM API is running',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API Routes
|
||||||
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/admin', adminRoutes);
|
||||||
|
app.use('/api/contacts', contactRoutes);
|
||||||
|
app.use('/api/emails', crmEmailRoutes);
|
||||||
|
|
||||||
|
// Basic route
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'CRM API Server',
|
||||||
|
version: '1.0.0',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global Middlewares (must be last)
|
||||||
|
app.use(notFound);
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
export default app;
|
||||||
32
src/config/database.js
Normal file
32
src/config/database.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||||
|
import pkg from 'pg';
|
||||||
|
const { Pool } = pkg;
|
||||||
|
import * as schema from '../db/schema.js';
|
||||||
|
|
||||||
|
// PostgreSQL connection pool
|
||||||
|
const pool = new Pool({
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.DB_PORT || '5432'),
|
||||||
|
user: process.env.DB_USER || 'admin',
|
||||||
|
password: process.env.DB_PASSWORD || 'heslo123',
|
||||||
|
database: process.env.DB_NAME || 'crm',
|
||||||
|
max: 20, // maximum number of connections in pool
|
||||||
|
idleTimeoutMillis: 30000,
|
||||||
|
connectionTimeoutMillis: 2000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test database connection
|
||||||
|
pool.on('connect', () => {
|
||||||
|
console.log('✅ Database connected successfully');
|
||||||
|
});
|
||||||
|
|
||||||
|
pool.on('error', (err) => {
|
||||||
|
console.error('❌ Unexpected database error:', err);
|
||||||
|
process.exit(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize Drizzle ORM with schema
|
||||||
|
export const db = drizzle(pool, { schema });
|
||||||
|
|
||||||
|
// Export pool for direct access if needed
|
||||||
|
export { pool };
|
||||||
231
src/controllers/admin.controller.js
Normal file
231
src/controllers/admin.controller.js
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import { db } from '../config/database.js';
|
||||||
|
import { users } from '../db/schema.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { hashPassword, generateTempPassword } from '../utils/password.js';
|
||||||
|
import { logUserCreation, logRoleChange } from '../services/audit.service.js';
|
||||||
|
import { formatErrorResponse, ConflictError, NotFoundError } from '../utils/errors.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vytvorenie nového usera s temporary password (admin only)
|
||||||
|
* POST /api/admin/users
|
||||||
|
*/
|
||||||
|
export const createUser = async (req, res) => {
|
||||||
|
const { username, tempPassword, role, firstName, lastName } = req.body;
|
||||||
|
const adminId = req.userId;
|
||||||
|
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||||
|
const userAgent = req.headers['user-agent'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Skontroluj či username už neexistuje
|
||||||
|
const [existingUser] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.username, username))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
throw new ConflictError('Username už existuje');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash temporary password
|
||||||
|
const hashedTempPassword = await hashPassword(tempPassword);
|
||||||
|
|
||||||
|
// Vytvor usera
|
||||||
|
const [newUser] = await db
|
||||||
|
.insert(users)
|
||||||
|
.values({
|
||||||
|
username,
|
||||||
|
tempPassword: hashedTempPassword,
|
||||||
|
role: role || 'member',
|
||||||
|
firstName: firstName || null,
|
||||||
|
lastName: lastName || null,
|
||||||
|
changedPassword: false,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Log user creation
|
||||||
|
await logUserCreation(adminId, newUser.id, username, role || 'member', ipAddress, userAgent);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
id: newUser.id,
|
||||||
|
username: newUser.username,
|
||||||
|
role: newUser.role,
|
||||||
|
tempPassword: tempPassword, // Vráti plain text password pre admina
|
||||||
|
},
|
||||||
|
},
|
||||||
|
message: 'Používateľ úspešne vytvorený',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zoznam všetkých userov (admin only)
|
||||||
|
* GET /api/admin/users
|
||||||
|
*/
|
||||||
|
export const getAllUsers = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const allUsers = await db
|
||||||
|
.select({
|
||||||
|
id: users.id,
|
||||||
|
username: users.username,
|
||||||
|
email: users.email,
|
||||||
|
firstName: users.firstName,
|
||||||
|
lastName: users.lastName,
|
||||||
|
role: users.role,
|
||||||
|
changedPassword: users.changedPassword,
|
||||||
|
lastLogin: users.lastLogin,
|
||||||
|
createdAt: users.createdAt,
|
||||||
|
})
|
||||||
|
.from(users);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
users: allUsers,
|
||||||
|
count: allUsers.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Získanie konkrétneho usera (admin only)
|
||||||
|
* GET /api/admin/users/:userId
|
||||||
|
*/
|
||||||
|
export const getUserById = async (req, res) => {
|
||||||
|
const { userId } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [user] = await db
|
||||||
|
.select({
|
||||||
|
id: users.id,
|
||||||
|
username: users.username,
|
||||||
|
email: users.email,
|
||||||
|
firstName: users.firstName,
|
||||||
|
lastName: users.lastName,
|
||||||
|
role: users.role,
|
||||||
|
changedPassword: users.changedPassword,
|
||||||
|
lastLogin: users.lastLogin,
|
||||||
|
createdAt: users.createdAt,
|
||||||
|
updatedAt: users.updatedAt,
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundError('Používateľ nenájdený');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: { user },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zmena role usera (admin only)
|
||||||
|
* PATCH /api/admin/users/:userId/role
|
||||||
|
*/
|
||||||
|
export const changeUserRole = async (req, res) => {
|
||||||
|
const { userId } = req.params;
|
||||||
|
const { role } = req.body;
|
||||||
|
const adminId = req.userId;
|
||||||
|
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||||
|
const userAgent = req.headers['user-agent'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Získaj starú rolu
|
||||||
|
const [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundError('Používateľ nenájdený');
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldRole = user.role;
|
||||||
|
|
||||||
|
// Update role
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
role,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(users.id, userId));
|
||||||
|
|
||||||
|
// Log role change
|
||||||
|
await logRoleChange(adminId, userId, oldRole, role, ipAddress, userAgent);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
oldRole,
|
||||||
|
newRole: role,
|
||||||
|
},
|
||||||
|
message: 'Rola používateľa bola zmenená',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zmazanie usera (admin only)
|
||||||
|
* DELETE /api/admin/users/:userId
|
||||||
|
*/
|
||||||
|
export const deleteUser = async (req, res) => {
|
||||||
|
const { userId } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundError('Používateľ nenájdený');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zabraň zmazaniu posledného admina
|
||||||
|
if (user.role === 'admin') {
|
||||||
|
const [adminCount] = await db
|
||||||
|
.select({ count: db.$count(users) })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.role, 'admin'));
|
||||||
|
|
||||||
|
if (adminCount.count <= 1) {
|
||||||
|
throw new ConflictError('Nemôžete zmazať posledného administrátora');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(users).where(eq(users.id, userId));
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Používateľ bol zmazaný',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
204
src/controllers/auth.controller.js
Normal file
204
src/controllers/auth.controller.js
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import * as authService from '../services/auth.service.js';
|
||||||
|
import {
|
||||||
|
logLoginAttempt,
|
||||||
|
logPasswordChange,
|
||||||
|
logEmailLink,
|
||||||
|
} from '../services/audit.service.js';
|
||||||
|
import { formatErrorResponse } from '../utils/errors.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KROK 1: Login s temporary password
|
||||||
|
* POST /api/auth/login
|
||||||
|
*/
|
||||||
|
export const login = async (req, res) => {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||||
|
const userAgent = req.headers['user-agent'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await authService.loginWithTempPassword(
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
ipAddress,
|
||||||
|
userAgent
|
||||||
|
);
|
||||||
|
|
||||||
|
// Log successful login
|
||||||
|
await logLoginAttempt(username, true, ipAddress, userAgent);
|
||||||
|
|
||||||
|
// Nastav cookie s access tokenom (httpOnly, secure)
|
||||||
|
res.cookie('accessToken', result.tokens.accessToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'strict',
|
||||||
|
maxAge: 60 * 60 * 1000, // 1 hodina
|
||||||
|
});
|
||||||
|
|
||||||
|
res.cookie('refreshToken', result.tokens.refreshToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'strict',
|
||||||
|
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 dní
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
user: result.user,
|
||||||
|
tokens: result.tokens,
|
||||||
|
needsPasswordChange: result.needsPasswordChange,
|
||||||
|
needsEmailSetup: result.needsEmailSetup,
|
||||||
|
},
|
||||||
|
message: 'Prihlásenie úspešné',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Log failed login
|
||||||
|
await logLoginAttempt(username, false, ipAddress, userAgent, error.message);
|
||||||
|
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KROK 2: Nastavenie nového hesla
|
||||||
|
* POST /api/auth/set-password
|
||||||
|
* Requires: authentication
|
||||||
|
*/
|
||||||
|
export const setPassword = async (req, res) => {
|
||||||
|
const { newPassword } = req.body;
|
||||||
|
const userId = req.userId;
|
||||||
|
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||||
|
const userAgent = req.headers['user-agent'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await authService.setNewPassword(userId, newPassword);
|
||||||
|
|
||||||
|
// Log password change
|
||||||
|
await logPasswordChange(userId, ipAddress, userAgent);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: 'Heslo úspešne nastavené',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KROK 3: Pripojenie emailu s JMAP validáciou
|
||||||
|
* POST /api/auth/link-email
|
||||||
|
* Requires: authentication
|
||||||
|
*/
|
||||||
|
export const linkEmail = async (req, res) => {
|
||||||
|
const { email, emailPassword } = req.body;
|
||||||
|
const userId = req.userId;
|
||||||
|
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||||
|
const userAgent = req.headers['user-agent'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await authService.linkEmail(userId, email, emailPassword);
|
||||||
|
|
||||||
|
// Log email link
|
||||||
|
await logEmailLink(userId, email, ipAddress, userAgent);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
accountId: result.accountId,
|
||||||
|
},
|
||||||
|
message: 'Email účet úspešne pripojený a overený',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KROK 3 (alternatíva): Skip email setup
|
||||||
|
* POST /api/auth/skip-email
|
||||||
|
* Requires: authentication
|
||||||
|
*/
|
||||||
|
export const skipEmail = async (req, res) => {
|
||||||
|
const userId = req.userId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await authService.skipEmailSetup(userId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: 'Email setup preskočený',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout
|
||||||
|
* POST /api/auth/logout
|
||||||
|
* Requires: authentication
|
||||||
|
*/
|
||||||
|
export const logout = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await authService.logout();
|
||||||
|
|
||||||
|
// Vymaž cookies
|
||||||
|
res.clearCookie('accessToken');
|
||||||
|
res.clearCookie('refreshToken');
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: result.message,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Získanie aktuálnej session info
|
||||||
|
* GET /api/auth/session
|
||||||
|
* Requires: authentication
|
||||||
|
*/
|
||||||
|
export const getSession = async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
user: req.user,
|
||||||
|
authenticated: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Profil aktuálneho usera
|
||||||
|
* GET /api/auth/me
|
||||||
|
* Requires: authentication
|
||||||
|
*/
|
||||||
|
export const getMe = async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
user: req.user,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
147
src/controllers/contact.controller.js
Normal file
147
src/controllers/contact.controller.js
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import * as contactService from '../services/contact.service.js';
|
||||||
|
import { discoverContactsFromJMAP, getJmapConfig } from '../services/jmap.service.js';
|
||||||
|
import { formatErrorResponse } from '../utils/errors.js';
|
||||||
|
import { getUserById } from '../services/auth.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all contacts for authenticated user
|
||||||
|
* GET /api/contacts
|
||||||
|
*/
|
||||||
|
export const getContacts = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId;
|
||||||
|
const contacts = await contactService.getUserContacts(userId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
count: contacts.length,
|
||||||
|
data: contacts,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover potential contacts from JMAP (email senders)
|
||||||
|
* GET /api/contacts/discover?search=query&limit=50
|
||||||
|
*/
|
||||||
|
export const discoverContacts = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId;
|
||||||
|
const { search = '', limit = 50 } = req.query;
|
||||||
|
|
||||||
|
// Get user to access JMAP config
|
||||||
|
const user = await getUserById(userId);
|
||||||
|
|
||||||
|
// Check if user has JMAP email configured
|
||||||
|
if (!user.email || !user.emailPassword || !user.jmapAccountId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Najprv musíš pripojiť email účet v Profile',
|
||||||
|
statusCode: 400,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const jmapConfig = getJmapConfig(user);
|
||||||
|
|
||||||
|
const potentialContacts = await discoverContactsFromJMAP(
|
||||||
|
jmapConfig,
|
||||||
|
userId,
|
||||||
|
search,
|
||||||
|
parseInt(limit)
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
count: potentialContacts.length,
|
||||||
|
data: potentialContacts,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new contact
|
||||||
|
* POST /api/contacts
|
||||||
|
*/
|
||||||
|
export const addContact = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId;
|
||||||
|
const { email, name = '', notes = '' } = req.body;
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Email je povinný',
|
||||||
|
statusCode: 400,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user to access JMAP config
|
||||||
|
const user = await getUserById(userId);
|
||||||
|
const jmapConfig = getJmapConfig(user);
|
||||||
|
|
||||||
|
const contact = await contactService.addContact(userId, jmapConfig, email, name, notes);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: contact,
|
||||||
|
message: 'Kontakt pridaný a emaily synchronizované',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a contact
|
||||||
|
* DELETE /api/contacts/:contactId
|
||||||
|
*/
|
||||||
|
export const removeContact = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId;
|
||||||
|
const { contactId } = req.params;
|
||||||
|
|
||||||
|
const result = await contactService.removeContact(userId, contactId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: result.message,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a contact
|
||||||
|
* PATCH /api/contacts/:contactId
|
||||||
|
*/
|
||||||
|
export const updateContact = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId;
|
||||||
|
const { contactId } = req.params;
|
||||||
|
const { name, notes } = req.body;
|
||||||
|
|
||||||
|
const updated = await contactService.updateContact(userId, contactId, { name, notes });
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: updated,
|
||||||
|
message: 'Kontakt aktualizovaný',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
193
src/controllers/crm-email.controller.js
Normal file
193
src/controllers/crm-email.controller.js
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import * as crmEmailService from '../services/crm-email.service.js';
|
||||||
|
import { markEmailAsRead, sendEmail, getJmapConfig } from '../services/jmap.service.js';
|
||||||
|
import { formatErrorResponse } from '../utils/errors.js';
|
||||||
|
import { getUserById } from '../services/auth.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all emails for authenticated user
|
||||||
|
* GET /api/emails
|
||||||
|
*/
|
||||||
|
export const getEmails = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId;
|
||||||
|
const emails = await crmEmailService.getUserEmails(userId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
count: emails.length,
|
||||||
|
data: emails,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get emails by thread (conversation)
|
||||||
|
* GET /api/emails/thread/:threadId
|
||||||
|
*/
|
||||||
|
export const getThread = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId;
|
||||||
|
const { threadId } = req.params;
|
||||||
|
|
||||||
|
const thread = await crmEmailService.getEmailThread(userId, threadId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
count: thread.length,
|
||||||
|
data: thread,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search emails
|
||||||
|
* GET /api/emails/search?q=query
|
||||||
|
*/
|
||||||
|
export const searchEmails = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId;
|
||||||
|
const { q } = req.query;
|
||||||
|
|
||||||
|
const results = await crmEmailService.searchEmails(userId, q);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
count: results.length,
|
||||||
|
data: results,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unread count
|
||||||
|
* GET /api/emails/unread-count
|
||||||
|
*/
|
||||||
|
export const getUnreadCount = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId;
|
||||||
|
const count = await crmEmailService.getUnreadCount(userId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: { count },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark email as read/unread
|
||||||
|
* PATCH /api/emails/:jmapId/read
|
||||||
|
*/
|
||||||
|
export const markAsRead = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId;
|
||||||
|
const { jmapId } = req.params;
|
||||||
|
const { isRead } = req.body;
|
||||||
|
|
||||||
|
// Get user to access JMAP config
|
||||||
|
const user = await getUserById(userId);
|
||||||
|
const jmapConfig = getJmapConfig(user);
|
||||||
|
|
||||||
|
await markEmailAsRead(jmapConfig, userId, jmapId, isRead);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: `Email označený ako ${isRead ? 'prečítaný' : 'neprečítaný'}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark entire thread as read
|
||||||
|
* POST /api/emails/thread/:threadId/read
|
||||||
|
*/
|
||||||
|
export const markThreadRead = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId;
|
||||||
|
const { threadId } = req.params;
|
||||||
|
|
||||||
|
const result = await crmEmailService.markThreadAsRead(userId, threadId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Konverzácia označená ako prečítaná',
|
||||||
|
count: result.count,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send email reply
|
||||||
|
* POST /api/emails/reply
|
||||||
|
*/
|
||||||
|
export const replyToEmail = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId;
|
||||||
|
const { to, subject, body, inReplyTo = null, threadId = null } = req.body;
|
||||||
|
|
||||||
|
if (!to || !subject || !body) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Chýbajúce povinné polia: to, subject, body',
|
||||||
|
statusCode: 400,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user to access JMAP config
|
||||||
|
const user = await getUserById(userId);
|
||||||
|
const jmapConfig = getJmapConfig(user);
|
||||||
|
|
||||||
|
const result = await sendEmail(jmapConfig, userId, to, subject, body, inReplyTo, threadId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Email odoslaný',
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get emails for a specific contact
|
||||||
|
* GET /api/emails/contact/:contactId
|
||||||
|
*/
|
||||||
|
export const getContactEmails = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId;
|
||||||
|
const { contactId } = req.params;
|
||||||
|
|
||||||
|
const emails = await crmEmailService.getContactEmails(userId, contactId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
count: emails.length,
|
||||||
|
data: emails,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
32
src/db/migrate.js
Normal file
32
src/db/migrate.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||||
|
import { migrate } from 'drizzle-orm/node-postgres/migrator';
|
||||||
|
import pkg from 'pg';
|
||||||
|
const { Pool } = pkg;
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.DB_PORT || '5432'),
|
||||||
|
user: process.env.DB_USER || 'admin',
|
||||||
|
password: process.env.DB_PASSWORD || 'heslo123',
|
||||||
|
database: process.env.DB_NAME || 'crm',
|
||||||
|
});
|
||||||
|
|
||||||
|
const db = drizzle(pool);
|
||||||
|
|
||||||
|
async function runMigrations() {
|
||||||
|
console.log('⏳ Running migrations...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await migrate(db, { migrationsFolder: './src/db/migrations' });
|
||||||
|
console.log('✅ Migrations completed successfully');
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Migration failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runMigrations();
|
||||||
36
src/db/migrations/0000_legal_karnak.sql
Normal file
36
src/db/migrations/0000_legal_karnak.sql
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
CREATE TYPE "public"."role" AS ENUM('admin', 'member');--> statement-breakpoint
|
||||||
|
CREATE TABLE "audit_logs" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid,
|
||||||
|
"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 DEFAULT true NOT NULL,
|
||||||
|
"error_message" text,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "users" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"username" text NOT NULL,
|
||||||
|
"email" text,
|
||||||
|
"email_password" text,
|
||||||
|
"jmap_account_id" text,
|
||||||
|
"first_name" text,
|
||||||
|
"last_name" text,
|
||||||
|
"password" text,
|
||||||
|
"temp_password" text,
|
||||||
|
"changed_password" boolean DEFAULT false,
|
||||||
|
"role" "role" DEFAULT 'member' NOT NULL,
|
||||||
|
"last_login" timestamp,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "users_username_unique" UNIQUE("username"),
|
||||||
|
CONSTRAINT "users_email_unique" UNIQUE("email")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||||
34
src/db/migrations/0001_slow_drax.sql
Normal file
34
src/db/migrations/0001_slow_drax.sql
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
CREATE TABLE "contacts" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"email" text NOT NULL,
|
||||||
|
"name" text,
|
||||||
|
"notes" text,
|
||||||
|
"added_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "emails" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"contact_id" uuid,
|
||||||
|
"jmap_id" text,
|
||||||
|
"message_id" text,
|
||||||
|
"thread_id" text,
|
||||||
|
"in_reply_to" text,
|
||||||
|
"from" text,
|
||||||
|
"to" text,
|
||||||
|
"subject" text,
|
||||||
|
"body" text,
|
||||||
|
"is_read" boolean DEFAULT false NOT NULL,
|
||||||
|
"date" timestamp,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "emails_jmap_id_unique" UNIQUE("jmap_id"),
|
||||||
|
CONSTRAINT "emails_message_id_unique" UNIQUE("message_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "contacts" ADD CONSTRAINT "contacts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "emails" ADD CONSTRAINT "emails_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "emails" ADD CONSTRAINT "emails_contact_id_contacts_id_fk" FOREIGN KEY ("contact_id") REFERENCES "public"."contacts"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
248
src/db/migrations/meta/0000_snapshot.json
Normal file
248
src/db/migrations/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
{
|
||||||
|
"id": "d81153f7-e0c6-4843-bee9-21a7129f7d01",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.audit_logs": {
|
||||||
|
"name": "audit_logs",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"name": "action",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"resource": {
|
||||||
|
"name": "resource",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"resource_id": {
|
||||||
|
"name": "resource_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"old_value": {
|
||||||
|
"name": "old_value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"new_value": {
|
||||||
|
"name": "new_value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"ip_address": {
|
||||||
|
"name": "ip_address",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_agent": {
|
||||||
|
"name": "user_agent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"name": "success",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"error_message": {
|
||||||
|
"name": "error_message",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"audit_logs_user_id_users_id_fk": {
|
||||||
|
"name": "audit_logs_user_id_users_id_fk",
|
||||||
|
"tableFrom": "audit_logs",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.users": {
|
||||||
|
"name": "users",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"email_password": {
|
||||||
|
"name": "email_password",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"jmap_account_id": {
|
||||||
|
"name": "jmap_account_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"first_name": {
|
||||||
|
"name": "first_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"last_name": {
|
||||||
|
"name": "last_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"name": "password",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"temp_password": {
|
||||||
|
"name": "temp_password",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"changed_password": {
|
||||||
|
"name": "changed_password",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "role",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'member'"
|
||||||
|
},
|
||||||
|
"last_login": {
|
||||||
|
"name": "last_login",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"users_username_unique": {
|
||||||
|
"name": "users_username_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"users_email_unique": {
|
||||||
|
"name": "users_email_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {
|
||||||
|
"public.role": {
|
||||||
|
"name": "role",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"admin",
|
||||||
|
"member"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
476
src/db/migrations/meta/0001_snapshot.json
Normal file
476
src/db/migrations/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,476 @@
|
|||||||
|
{
|
||||||
|
"id": "1b8c1e0f-8476-470c-a641-b3c350a2c1a4",
|
||||||
|
"prevId": "d81153f7-e0c6-4843-bee9-21a7129f7d01",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.audit_logs": {
|
||||||
|
"name": "audit_logs",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"name": "action",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"resource": {
|
||||||
|
"name": "resource",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"resource_id": {
|
||||||
|
"name": "resource_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"old_value": {
|
||||||
|
"name": "old_value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"new_value": {
|
||||||
|
"name": "new_value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"ip_address": {
|
||||||
|
"name": "ip_address",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_agent": {
|
||||||
|
"name": "user_agent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"name": "success",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"error_message": {
|
||||||
|
"name": "error_message",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"audit_logs_user_id_users_id_fk": {
|
||||||
|
"name": "audit_logs_user_id_users_id_fk",
|
||||||
|
"tableFrom": "audit_logs",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.contacts": {
|
||||||
|
"name": "contacts",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"name": "notes",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"added_at": {
|
||||||
|
"name": "added_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"contacts_user_id_users_id_fk": {
|
||||||
|
"name": "contacts_user_id_users_id_fk",
|
||||||
|
"tableFrom": "contacts",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.emails": {
|
||||||
|
"name": "emails",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"contact_id": {
|
||||||
|
"name": "contact_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"jmap_id": {
|
||||||
|
"name": "jmap_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"message_id": {
|
||||||
|
"name": "message_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"thread_id": {
|
||||||
|
"name": "thread_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"in_reply_to": {
|
||||||
|
"name": "in_reply_to",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"from": {
|
||||||
|
"name": "from",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"to": {
|
||||||
|
"name": "to",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"subject": {
|
||||||
|
"name": "subject",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"name": "body",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"is_read": {
|
||||||
|
"name": "is_read",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"name": "date",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"emails_user_id_users_id_fk": {
|
||||||
|
"name": "emails_user_id_users_id_fk",
|
||||||
|
"tableFrom": "emails",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"emails_contact_id_contacts_id_fk": {
|
||||||
|
"name": "emails_contact_id_contacts_id_fk",
|
||||||
|
"tableFrom": "emails",
|
||||||
|
"tableTo": "contacts",
|
||||||
|
"columnsFrom": [
|
||||||
|
"contact_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"emails_jmap_id_unique": {
|
||||||
|
"name": "emails_jmap_id_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"jmap_id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"emails_message_id_unique": {
|
||||||
|
"name": "emails_message_id_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"message_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.users": {
|
||||||
|
"name": "users",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"email_password": {
|
||||||
|
"name": "email_password",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"jmap_account_id": {
|
||||||
|
"name": "jmap_account_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"first_name": {
|
||||||
|
"name": "first_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"last_name": {
|
||||||
|
"name": "last_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"name": "password",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"temp_password": {
|
||||||
|
"name": "temp_password",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"changed_password": {
|
||||||
|
"name": "changed_password",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "role",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'member'"
|
||||||
|
},
|
||||||
|
"last_login": {
|
||||||
|
"name": "last_login",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"users_username_unique": {
|
||||||
|
"name": "users_username_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"users_email_unique": {
|
||||||
|
"name": "users_email_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {
|
||||||
|
"public.role": {
|
||||||
|
"name": "role",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"admin",
|
||||||
|
"member"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/db/migrations/meta/_journal.json
Normal file
20
src/db/migrations/meta/_journal.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1763450484405,
|
||||||
|
"tag": "0000_legal_karnak",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1763457837858,
|
||||||
|
"tag": "0001_slow_drax",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
69
src/db/schema.js
Normal file
69
src/db/schema.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { pgTable, text, timestamp, boolean, uuid, pgEnum } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
// Role enum
|
||||||
|
export const roleEnum = pgEnum('role', ['admin', 'member']);
|
||||||
|
|
||||||
|
// Users table - hlavná tabuľka používateľov
|
||||||
|
export const users = pgTable('users', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
username: text('username').notNull().unique(),
|
||||||
|
email: text('email').unique(),
|
||||||
|
emailPassword: text('email_password'), // Heslo k emailovému účtu (encrypted)
|
||||||
|
jmapAccountId: text('jmap_account_id'), // JMAP account ID z truemail
|
||||||
|
firstName: text('first_name'),
|
||||||
|
lastName: text('last_name'),
|
||||||
|
password: text('password'), // bcrypt hash (null ak ešte nenastavené)
|
||||||
|
tempPassword: text('temp_password'), // dočasné heslo (bcrypt hash)
|
||||||
|
changedPassword: boolean('changed_password').default(false), // či si užívateľ zmenil heslo
|
||||||
|
role: roleEnum('role').default('member').notNull(),
|
||||||
|
lastLogin: timestamp('last_login'),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Audit logs - kompletný audit trail všetkých akcií
|
||||||
|
export const auditLogs = pgTable('audit_logs', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
userId: uuid('user_id').references(() => users.id, { onDelete: 'set null' }),
|
||||||
|
action: text('action').notNull(), // 'login', 'password_change', 'email_linked', 'role_change', atď.
|
||||||
|
resource: text('resource').notNull(), // 'user', 'auth', atď.
|
||||||
|
resourceId: text('resource_id'), // ID ovplyvneného zdroja
|
||||||
|
oldValue: text('old_value'), // JSON string starých hodnôt
|
||||||
|
newValue: text('new_value'), // JSON string nových hodnôt
|
||||||
|
ipAddress: text('ip_address'),
|
||||||
|
userAgent: text('user_agent'),
|
||||||
|
success: boolean('success').default(true).notNull(),
|
||||||
|
errorMessage: text('error_message'),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Contacts table - ľudia s ktorými komunikujeme cez email
|
||||||
|
export const contacts = pgTable('contacts', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||||
|
email: text('email').notNull(),
|
||||||
|
name: text('name'),
|
||||||
|
notes: text('notes'),
|
||||||
|
addedAt: timestamp('added_at').defaultNow().notNull(),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emails table - uložené emaily z JMAP (iba pre pridané kontakty)
|
||||||
|
export const emails = pgTable('emails', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
|
||||||
|
contactId: uuid('contact_id').references(() => contacts.id, { onDelete: 'cascade' }),
|
||||||
|
jmapId: text('jmap_id').unique(),
|
||||||
|
messageId: text('message_id').unique(),
|
||||||
|
threadId: text('thread_id'),
|
||||||
|
inReplyTo: text('in_reply_to'),
|
||||||
|
from: text('from'),
|
||||||
|
to: text('to'),
|
||||||
|
subject: text('subject'),
|
||||||
|
body: text('body'),
|
||||||
|
isRead: boolean('is_read').default(false).notNull(),
|
||||||
|
date: timestamp('date'),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
67
src/db/seeds/admin.seed.js
Normal file
67
src/db/seeds/admin.seed.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { db } from '../../config/database.js';
|
||||||
|
import { users } from '../schema.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { hashPassword, generateTempPassword } from '../../utils/password.js';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed script pre vytvorenie admin účtu
|
||||||
|
*/
|
||||||
|
async function seedAdmin() {
|
||||||
|
try {
|
||||||
|
logger.info('Starting admin user seed...');
|
||||||
|
|
||||||
|
// Skontroluj či admin už existuje
|
||||||
|
const [existingAdmin] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.username, 'admin'))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingAdmin) {
|
||||||
|
logger.warn('Admin user already exists. Skipping seed.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vygeneruj temporary password
|
||||||
|
const tempPassword = generateTempPassword(16);
|
||||||
|
|
||||||
|
// Hash temporary password
|
||||||
|
const hashedTempPassword = await hashPassword(tempPassword);
|
||||||
|
|
||||||
|
// Vytvor admin usera
|
||||||
|
const [newAdmin] = await db
|
||||||
|
.insert(users)
|
||||||
|
.values({
|
||||||
|
username: 'admin',
|
||||||
|
tempPassword: hashedTempPassword,
|
||||||
|
role: 'admin',
|
||||||
|
changedPassword: false,
|
||||||
|
firstName: 'System',
|
||||||
|
lastName: 'Administrator',
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
logger.success('Admin user created successfully!');
|
||||||
|
logger.info('═══════════════════════════════════════════════════════');
|
||||||
|
logger.info(' ADMIN CREDENTIALS');
|
||||||
|
logger.info('═══════════════════════════════════════════════════════');
|
||||||
|
logger.info(` Username: admin`);
|
||||||
|
logger.info(` Temporary Password: ${tempPassword}`);
|
||||||
|
logger.info(` User ID: ${newAdmin.id}`);
|
||||||
|
logger.info('═══════════════════════════════════════════════════════');
|
||||||
|
logger.warn(' ⚠️ IMPORTANT: Save this password securely!');
|
||||||
|
logger.warn(' ⚠️ You will need to change it after first login.');
|
||||||
|
logger.info('═══════════════════════════════════════════════════════');
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to seed admin user', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seedAdmin();
|
||||||
62
src/db/seeds/testuser.seed.js
Normal file
62
src/db/seeds/testuser.seed.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { db } from '../../config/database.js';
|
||||||
|
import { users } from '../schema.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { hashPassword } from '../../utils/password.js';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed script pre vytvorenie test usera
|
||||||
|
*/
|
||||||
|
async function seedTestUser() {
|
||||||
|
try {
|
||||||
|
logger.info('Starting test user seed...');
|
||||||
|
|
||||||
|
// Skontroluj či testuser už existuje
|
||||||
|
const [existingUser] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.username, 'testuser'))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
logger.warn('Test user already exists. Deleting old one...');
|
||||||
|
await db.delete(users).where(eq(users.username, 'testuser'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempPassword = 'testuser123!';
|
||||||
|
const hashedTempPassword = await hashPassword(tempPassword);
|
||||||
|
|
||||||
|
// Vytvor test usera
|
||||||
|
const [newUser] = await db
|
||||||
|
.insert(users)
|
||||||
|
.values({
|
||||||
|
username: 'testuser',
|
||||||
|
tempPassword: hashedTempPassword,
|
||||||
|
role: 'member',
|
||||||
|
changedPassword: false,
|
||||||
|
firstName: 'Test',
|
||||||
|
lastName: 'User',
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
logger.success('Test user created successfully!');
|
||||||
|
logger.info('═══════════════════════════════════════════════════════');
|
||||||
|
logger.info(' TEST USER CREDENTIALS');
|
||||||
|
logger.info('═══════════════════════════════════════════════════════');
|
||||||
|
logger.info(` Username: testuser`);
|
||||||
|
logger.info(` Temporary Password: ${tempPassword}`);
|
||||||
|
logger.info(` User ID: ${newUser.id}`);
|
||||||
|
logger.info(` Role: ${newUser.role}`);
|
||||||
|
logger.info('═══════════════════════════════════════════════════════');
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to seed test user', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seedTestUser();
|
||||||
6
src/index.js
Normal file
6
src/index.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import app from './app.js';
|
||||||
|
|
||||||
|
const port = process.env.PORT || 5000;
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`🚀 Server running on http://localhost:${port}`);
|
||||||
|
});
|
||||||
90
src/middlewares/auth/authMiddleware.js
Normal file
90
src/middlewares/auth/authMiddleware.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { verifyAccessToken } from '../../utils/jwt.js';
|
||||||
|
import { AuthenticationError } from '../../utils/errors.js';
|
||||||
|
import { getUserById } from '../../services/auth.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware na overenie JWT tokenu
|
||||||
|
* Pridá user objekt do req.user ak je token validný
|
||||||
|
*/
|
||||||
|
export const authenticate = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
// Získaj token z Authorization header alebo cookies
|
||||||
|
let token = null;
|
||||||
|
|
||||||
|
// Skús Authorization header (Bearer token)
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||||
|
token = authHeader.substring(7);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ak nie je v header, skús cookies
|
||||||
|
if (!token && req.cookies && req.cookies.accessToken) {
|
||||||
|
token = req.cookies.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new AuthenticationError('Token nie je poskytnutý');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overenie tokenu
|
||||||
|
const decoded = verifyAccessToken(token);
|
||||||
|
|
||||||
|
// Načítaj aktuálne user data z DB
|
||||||
|
const user = await getUserById(decoded.id);
|
||||||
|
|
||||||
|
// Pridaj user do requestu
|
||||||
|
req.user = user;
|
||||||
|
req.userId = user.id;
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthenticationError) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: error.message,
|
||||||
|
statusCode: 401,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Neplatný alebo expirovaný token',
|
||||||
|
statusCode: 401,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional authentication - nepovinnné overenie
|
||||||
|
* Ak je token poskytnutý, overí ho, ale nehodí error ak nie je
|
||||||
|
*/
|
||||||
|
export const optionalAuthenticate = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
let token = null;
|
||||||
|
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||||
|
token = authHeader.substring(7);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token && req.cookies && req.cookies.accessToken) {
|
||||||
|
token = req.cookies.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
const decoded = verifyAccessToken(token);
|
||||||
|
const user = await getUserById(decoded.id);
|
||||||
|
req.user = user;
|
||||||
|
req.userId = user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
// Ignoruj chyby, len pokračuj bez user objektu
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
};
|
||||||
88
src/middlewares/auth/roleMiddleware.js
Normal file
88
src/middlewares/auth/roleMiddleware.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { ForbiddenError } from '../../utils/errors.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware na overenie role používateľa
|
||||||
|
* Musí byť použité PO authenticate middleware
|
||||||
|
* @param {...string} allowedRoles - Povolené role (napr. 'admin', 'member')
|
||||||
|
*/
|
||||||
|
export const requireRole = (...allowedRoles) => {
|
||||||
|
return (req, res, next) => {
|
||||||
|
// Skontroluj či je user autentifikovaný
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Musíte byť prihlásený',
|
||||||
|
statusCode: 401,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skontroluj či user má jednu z povolených rolí
|
||||||
|
if (!allowedRoles.includes(req.user.role)) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Nemáte oprávnenie na túto operáciu',
|
||||||
|
statusCode: 403,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware špecificky pre admin rolu
|
||||||
|
*/
|
||||||
|
export const requireAdmin = requireRole('admin');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware pre kontrolu či user môže upravovať resource
|
||||||
|
* Buď je to admin, alebo je to vlastník resource
|
||||||
|
* @param {function} getResourceUserId - Funkcia ktorá vráti userId vlastníka resource
|
||||||
|
*/
|
||||||
|
export const requireOwnerOrAdmin = (getResourceUserId) => {
|
||||||
|
return async (req, res, next) => {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Musíte byť prihlásený',
|
||||||
|
statusCode: 401,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin môže všetko
|
||||||
|
if (req.user.role === 'admin') {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inak skontroluj ownership
|
||||||
|
try {
|
||||||
|
const resourceUserId = await getResourceUserId(req);
|
||||||
|
|
||||||
|
if (req.user.id !== resourceUserId) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Nemáte oprávnenie na túto operáciu',
|
||||||
|
statusCode: 403,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Chyba pri overovaní oprávnenia',
|
||||||
|
statusCode: 500,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
15
src/middlewares/global/errorHandler.js
Normal file
15
src/middlewares/global/errorHandler.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { formatErrorResponse } from '../../utils/errors.js';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
|
||||||
|
export function errorHandler(err, req, res, next) {
|
||||||
|
// Log error
|
||||||
|
logger.error('Unhandled error', err);
|
||||||
|
|
||||||
|
// Get status code
|
||||||
|
const statusCode = err.statusCode || res.statusCode !== 200 ? res.statusCode : 500;
|
||||||
|
|
||||||
|
// Format error response
|
||||||
|
const errorResponse = formatErrorResponse(err, process.env.NODE_ENV === 'development');
|
||||||
|
|
||||||
|
res.status(statusCode).json(errorResponse);
|
||||||
|
}
|
||||||
5
src/middlewares/global/notFound.js
Normal file
5
src/middlewares/global/notFound.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export function notFound(req, res, next) {
|
||||||
|
res.status(404);
|
||||||
|
const error = new Error(`🔍 Not Found - ${req.originalUrl}`);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
18
src/middlewares/global/validateBody.js
Normal file
18
src/middlewares/global/validateBody.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export function validateBody(req, res, next) {
|
||||||
|
const data = JSON.stringify({ body: req.body, query: req.query, params: req.params });
|
||||||
|
const dangerousPatterns = [
|
||||||
|
/(\b(SELECT|INSERT|UPDATE|DELETE|DROP|TRUNCATE|ALTER|CREATE|EXEC|UNION|LOAD_FILE|OUTFILE)\b.*\b(FROM|INTO|TABLE|DATABASE)\b)/gi,
|
||||||
|
/\b(OR 1=1|AND 1=1|OR '1'='1'|--|#|\/\*|\*\/|;|\bUNION\b.*?\bSELECT\b)/gi,
|
||||||
|
/\b(\$where|\$ne|\$gt|\$lt|\$regex|\$exists|\$not|\$or|\$and)\b/gi,
|
||||||
|
/(<script|<\/script>|document\.cookie|eval\(|alert\(|javascript:|onerror=|onmouseover=)/gi,
|
||||||
|
/(\bexec\s*xp_cmdshell|\bshutdown\b|\bdrop\s+database|\bdelete\s+from)/gi,
|
||||||
|
/(\b(base64_decode|cmd|powershell|wget|curl|rm -rf|nc -e|perl -e|python -c)\b)/gi,
|
||||||
|
];
|
||||||
|
for (const pattern of dangerousPatterns) {
|
||||||
|
if (pattern.test(data)) {
|
||||||
|
console.warn(`❌ Suspicious input detected: ${data}`);
|
||||||
|
return res.status(400).json({ message: '🚨 Malicious content detected in request data' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
92
src/middlewares/security/rateLimiter.js
Normal file
92
src/middlewares/security/rateLimiter.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import { RateLimitError } from '../../utils/errors.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate limiter pre login endpoint
|
||||||
|
* Max 5 pokusov za 15 minút
|
||||||
|
*/
|
||||||
|
export const loginRateLimiter = rateLimit({
|
||||||
|
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000, // 15 minút
|
||||||
|
max: parseInt(process.env.RATE_LIMIT_LOGIN_MAX) || 5,
|
||||||
|
message: {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Príliš veľa prihlasovacích pokusov. Skúste znova o 15 minút.',
|
||||||
|
statusCode: 429,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
// Skip successful requests - len zlyhané pokusy sa počítajú
|
||||||
|
skip: (req, res) => {
|
||||||
|
// Ak je response success, nerátaj to do limitu
|
||||||
|
return res.statusCode < 400;
|
||||||
|
},
|
||||||
|
handler: (req, res) => {
|
||||||
|
res.status(429).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Príliš veľa prihlasovacích pokusov. Skúste znova o 15 minút.',
|
||||||
|
statusCode: 429,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate limiter pre všeobecné API endpointy
|
||||||
|
* Development: 1000 requestov za 15 minút (viac pre development)
|
||||||
|
* Production: 100 requestov za 15 minút
|
||||||
|
*/
|
||||||
|
export const apiRateLimiter = rateLimit({
|
||||||
|
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000,
|
||||||
|
max: process.env.NODE_ENV === 'production'
|
||||||
|
? parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 100
|
||||||
|
: 1000, // Higher limit for development
|
||||||
|
message: {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Príliš veľa požiadaviek. Skúste znova neskôr.',
|
||||||
|
statusCode: 429,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
handler: (req, res) => {
|
||||||
|
res.status(429).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Príliš veľa požiadaviek. Skúste znova neskôr.',
|
||||||
|
statusCode: 429,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate limiter pre citlivé operácie (password reset, email change, atď.)
|
||||||
|
* Development: 50 pokusov za 15 minút
|
||||||
|
* Production: 3 pokusy za 15 minút
|
||||||
|
*/
|
||||||
|
export const sensitiveOperationLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: process.env.NODE_ENV === 'production' ? 3 : 50,
|
||||||
|
message: {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Príliš veľa pokusov. Skúste znova o 15 minút.',
|
||||||
|
statusCode: 429,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
handler: (req, res) => {
|
||||||
|
res.status(429).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Príliš veľa pokusov. Skúste znova o 15 minút.',
|
||||||
|
statusCode: 429,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
128
src/middlewares/security/validateInput.js
Normal file
128
src/middlewares/security/validateInput.js
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { ZodError } from 'zod';
|
||||||
|
import { ValidationError } from '../../utils/errors.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware na validáciu request body pomocou Zod schema
|
||||||
|
* @param {ZodSchema} schema - Zod validačná schéma
|
||||||
|
*/
|
||||||
|
export const validateBody = (schema) => {
|
||||||
|
return async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
// Validuj request body
|
||||||
|
const validated = await schema.parseAsync(req.body);
|
||||||
|
|
||||||
|
// Nahraď body validovanými dátami
|
||||||
|
req.body = validated;
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ZodError && error.errors) {
|
||||||
|
// Zformátuj Zod chyby
|
||||||
|
const formattedErrors = error.errors.map((err) => ({
|
||||||
|
field: err.path.join('.'),
|
||||||
|
message: err.message,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Validačná chyba',
|
||||||
|
statusCode: 400,
|
||||||
|
details: formattedErrors,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log unexpected errors
|
||||||
|
console.error('Validation error:', error);
|
||||||
|
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: error?.message || 'Neplatné vstupné dáta',
|
||||||
|
statusCode: 400,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware na validáciu query parametrov
|
||||||
|
* @param {ZodSchema} schema - Zod validačná schéma
|
||||||
|
*/
|
||||||
|
export const validateQuery = (schema) => {
|
||||||
|
return async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const validated = await schema.parseAsync(req.query);
|
||||||
|
req.query = validated;
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ZodError && error.errors) {
|
||||||
|
const formattedErrors = error.errors.map((err) => ({
|
||||||
|
field: err.path.join('.'),
|
||||||
|
message: err.message,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Validačná chyba v query parametroch',
|
||||||
|
statusCode: 400,
|
||||||
|
details: formattedErrors,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Query validation error:', error);
|
||||||
|
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: error?.message || 'Neplatné query parametre',
|
||||||
|
statusCode: 400,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware na validáciu URL parametrov
|
||||||
|
* @param {ZodSchema} schema - Zod validačná schéma
|
||||||
|
*/
|
||||||
|
export const validateParams = (schema) => {
|
||||||
|
return async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const validated = await schema.parseAsync(req.params);
|
||||||
|
req.params = validated;
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ZodError && error.errors) {
|
||||||
|
const formattedErrors = error.errors.map((err) => ({
|
||||||
|
field: err.path.join('.'),
|
||||||
|
message: err.message,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Validačná chyba v URL parametroch',
|
||||||
|
statusCode: 400,
|
||||||
|
details: formattedErrors,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Params validation error:', error);
|
||||||
|
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: error?.message || 'Neplatné URL parametre',
|
||||||
|
statusCode: 400,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
49
src/routes/admin.routes.js
Normal file
49
src/routes/admin.routes.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import * as adminController from '../controllers/admin.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 { createUserSchema, changeRoleSchema } from '../validators/auth.validators.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Všetky admin routes vyžadujú autentifikáciu a admin rolu
|
||||||
|
*/
|
||||||
|
router.use(authenticate);
|
||||||
|
router.use(requireAdmin);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User management
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Vytvorenie nového usera
|
||||||
|
router.post('/users', validateBody(createUserSchema), adminController.createUser);
|
||||||
|
|
||||||
|
// Zoznam všetkých userov
|
||||||
|
router.get('/users', adminController.getAllUsers);
|
||||||
|
|
||||||
|
// Získanie konkrétneho usera
|
||||||
|
router.get(
|
||||||
|
'/users/:userId',
|
||||||
|
validateParams(z.object({ userId: z.string().uuid() })),
|
||||||
|
adminController.getUserById
|
||||||
|
);
|
||||||
|
|
||||||
|
// Zmena role usera
|
||||||
|
router.patch(
|
||||||
|
'/users/:userId/role',
|
||||||
|
validateParams(z.object({ userId: z.string().uuid() })),
|
||||||
|
validateBody(z.object({ role: z.enum(['admin', 'member']) })),
|
||||||
|
adminController.changeUserRole
|
||||||
|
);
|
||||||
|
|
||||||
|
// Zmazanie usera
|
||||||
|
router.delete(
|
||||||
|
'/users/:userId',
|
||||||
|
validateParams(z.object({ userId: z.string().uuid() })),
|
||||||
|
adminController.deleteUser
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
63
src/routes/auth.routes.js
Normal file
63
src/routes/auth.routes.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import * as authController from '../controllers/auth.controller.js';
|
||||||
|
import { authenticate } from '../middlewares/auth/authMiddleware.js';
|
||||||
|
import { validateBody } from '../middlewares/security/validateInput.js';
|
||||||
|
import {
|
||||||
|
loginSchema,
|
||||||
|
setPasswordSchema,
|
||||||
|
linkEmailSchema,
|
||||||
|
} from '../validators/auth.validators.js';
|
||||||
|
import {
|
||||||
|
loginRateLimiter,
|
||||||
|
sensitiveOperationLimiter,
|
||||||
|
} from '../middlewares/security/rateLimiter.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public routes (bez autentifikácie)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// KROK 1: Login s temporary password
|
||||||
|
router.post(
|
||||||
|
'/login',
|
||||||
|
loginRateLimiter,
|
||||||
|
validateBody(loginSchema),
|
||||||
|
authController.login
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Protected routes (vyžadujú autentifikáciu)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// KROK 2: Set new password
|
||||||
|
router.post(
|
||||||
|
'/set-password',
|
||||||
|
authenticate,
|
||||||
|
sensitiveOperationLimiter,
|
||||||
|
validateBody(setPasswordSchema),
|
||||||
|
authController.setPassword
|
||||||
|
);
|
||||||
|
|
||||||
|
// KROK 3: Link email
|
||||||
|
router.post(
|
||||||
|
'/link-email',
|
||||||
|
authenticate,
|
||||||
|
sensitiveOperationLimiter,
|
||||||
|
validateBody(linkEmailSchema),
|
||||||
|
authController.linkEmail
|
||||||
|
);
|
||||||
|
|
||||||
|
// KROK 3 (alternatíva): Skip email
|
||||||
|
router.post('/skip-email', authenticate, authController.skipEmail);
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
router.post('/logout', authenticate, authController.logout);
|
||||||
|
|
||||||
|
// Get current session
|
||||||
|
router.get('/session', authenticate, authController.getSession);
|
||||||
|
|
||||||
|
// Get current user profile
|
||||||
|
router.get('/me', authenticate, authController.getMe);
|
||||||
|
|
||||||
|
export default router;
|
||||||
55
src/routes/contact.routes.js
Normal file
55
src/routes/contact.routes.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import * as contactController from '../controllers/contact.controller.js';
|
||||||
|
import { authenticate } from '../middlewares/auth/authMiddleware.js';
|
||||||
|
import { validateBody, validateParams } from '../middlewares/security/validateInput.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// All contact routes require authentication
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contact management
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Get all contacts
|
||||||
|
router.get('/', contactController.getContacts);
|
||||||
|
|
||||||
|
// Discover potential contacts from JMAP
|
||||||
|
router.get('/discover', contactController.discoverContacts);
|
||||||
|
|
||||||
|
// Add a new contact
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
validateBody(
|
||||||
|
z.object({
|
||||||
|
email: z.string().email('Neplatný formát emailu'),
|
||||||
|
name: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
contactController.addContact
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update a contact
|
||||||
|
router.patch(
|
||||||
|
'/:contactId',
|
||||||
|
validateParams(z.object({ contactId: z.string().uuid() })),
|
||||||
|
validateBody(
|
||||||
|
z.object({
|
||||||
|
name: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
contactController.updateContact
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove a contact
|
||||||
|
router.delete(
|
||||||
|
'/:contactId',
|
||||||
|
validateParams(z.object({ contactId: z.string().uuid() })),
|
||||||
|
contactController.removeContact
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
69
src/routes/crm-email.routes.js
Normal file
69
src/routes/crm-email.routes.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import * as crmEmailController from '../controllers/crm-email.controller.js';
|
||||||
|
import { authenticate } from '../middlewares/auth/authMiddleware.js';
|
||||||
|
import { validateBody, validateParams } from '../middlewares/security/validateInput.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// All email routes require authentication
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email management
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Get all emails
|
||||||
|
router.get('/', crmEmailController.getEmails);
|
||||||
|
|
||||||
|
// Search emails
|
||||||
|
router.get('/search', crmEmailController.searchEmails);
|
||||||
|
|
||||||
|
// Get unread count
|
||||||
|
router.get('/unread-count', crmEmailController.getUnreadCount);
|
||||||
|
|
||||||
|
// Get email thread (conversation)
|
||||||
|
router.get(
|
||||||
|
'/thread/:threadId',
|
||||||
|
validateParams(z.object({ threadId: z.string() })),
|
||||||
|
crmEmailController.getThread
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark thread as read
|
||||||
|
router.post(
|
||||||
|
'/thread/:threadId/read',
|
||||||
|
validateParams(z.object({ threadId: z.string() })),
|
||||||
|
crmEmailController.markThreadRead
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get emails for a specific contact
|
||||||
|
router.get(
|
||||||
|
'/contact/:contactId',
|
||||||
|
validateParams(z.object({ contactId: z.string().uuid() })),
|
||||||
|
crmEmailController.getContactEmails
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark email as read/unread
|
||||||
|
router.patch(
|
||||||
|
'/:jmapId/read',
|
||||||
|
validateParams(z.object({ jmapId: z.string() })),
|
||||||
|
validateBody(z.object({ isRead: z.boolean() })),
|
||||||
|
crmEmailController.markAsRead
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send email reply
|
||||||
|
router.post(
|
||||||
|
'/reply',
|
||||||
|
validateBody(
|
||||||
|
z.object({
|
||||||
|
to: z.string().email('Neplatný formát emailu'),
|
||||||
|
subject: z.string().min(1, 'Subject nemôže byť prázdny'),
|
||||||
|
body: z.string().min(1, 'Telo emailu nemôže byť prázdne'),
|
||||||
|
inReplyTo: z.string().optional(),
|
||||||
|
threadId: z.string().optional(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
crmEmailController.replyToEmail
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
108
src/services/audit.service.js
Normal file
108
src/services/audit.service.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { db } from '../config/database.js';
|
||||||
|
import { auditLogs } from '../db/schema.js';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zaloguje audit event
|
||||||
|
*/
|
||||||
|
export const logAuditEvent = async ({
|
||||||
|
userId = null,
|
||||||
|
action,
|
||||||
|
resource,
|
||||||
|
resourceId = null,
|
||||||
|
oldValue = null,
|
||||||
|
newValue = null,
|
||||||
|
ipAddress = null,
|
||||||
|
userAgent = null,
|
||||||
|
success = true,
|
||||||
|
errorMessage = null,
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
await db.insert(auditLogs).values({
|
||||||
|
userId,
|
||||||
|
action,
|
||||||
|
resource,
|
||||||
|
resourceId,
|
||||||
|
oldValue: oldValue ? JSON.stringify(oldValue) : null,
|
||||||
|
newValue: newValue ? JSON.stringify(newValue) : null,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
success,
|
||||||
|
errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.audit(
|
||||||
|
`${action} on ${resource}${resourceId ? ` (${resourceId})` : ''} ${success ? 'SUCCESS' : 'FAILED'}${userId ? ` by user ${userId}` : ''}`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to log audit event', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pomocné funkcie pre špecifické audit eventy
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const logLoginAttempt = async (username, success, ipAddress, userAgent, errorMessage = null) => {
|
||||||
|
await logAuditEvent({
|
||||||
|
action: 'login_attempt',
|
||||||
|
resource: 'user',
|
||||||
|
resourceId: username,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
success,
|
||||||
|
errorMessage,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const logPasswordChange = async (userId, ipAddress, userAgent) => {
|
||||||
|
await logAuditEvent({
|
||||||
|
userId,
|
||||||
|
action: 'password_change',
|
||||||
|
resource: 'user',
|
||||||
|
resourceId: userId,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const logEmailLink = async (userId, email, ipAddress, userAgent) => {
|
||||||
|
await logAuditEvent({
|
||||||
|
userId,
|
||||||
|
action: 'email_linked',
|
||||||
|
resource: 'user',
|
||||||
|
resourceId: userId,
|
||||||
|
newValue: { email },
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const logRoleChange = async (adminId, userId, oldRole, newRole, ipAddress, userAgent) => {
|
||||||
|
await logAuditEvent({
|
||||||
|
userId: adminId,
|
||||||
|
action: 'role_change',
|
||||||
|
resource: 'user',
|
||||||
|
resourceId: userId,
|
||||||
|
oldValue: { role: oldRole },
|
||||||
|
newValue: { role: newRole },
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const logUserCreation = async (adminId, newUserId, username, role, ipAddress, userAgent) => {
|
||||||
|
await logAuditEvent({
|
||||||
|
userId: adminId,
|
||||||
|
action: 'user_created',
|
||||||
|
resource: 'user',
|
||||||
|
resourceId: newUserId,
|
||||||
|
newValue: { username, role },
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
218
src/services/auth.service.js
Normal file
218
src/services/auth.service.js
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '../config/database.js';
|
||||||
|
import { users } from '../db/schema.js';
|
||||||
|
import { hashPassword, comparePassword, encryptPassword, decryptPassword } from '../utils/password.js';
|
||||||
|
import { generateTokenPair } from '../utils/jwt.js';
|
||||||
|
import { validateJmapCredentials } from './email.service.js';
|
||||||
|
import {
|
||||||
|
AuthenticationError,
|
||||||
|
ConflictError,
|
||||||
|
NotFoundError,
|
||||||
|
ValidationError,
|
||||||
|
} from '../utils/errors.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KROK 1: Login s temporary password
|
||||||
|
*/
|
||||||
|
export const loginWithTempPassword = async (username, password, ipAddress, userAgent) => {
|
||||||
|
// Najdi usera
|
||||||
|
const [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.username, username))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new AuthenticationError('Nesprávne prihlasovacie údaje');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ak už user zmenil heslo, použije sa permanentné heslo
|
||||||
|
if (user.changedPassword && user.password) {
|
||||||
|
const isValid = await comparePassword(password, user.password);
|
||||||
|
if (!isValid) {
|
||||||
|
throw new AuthenticationError('Nesprávne prihlasovacie údaje');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Ak ešte nezmenil heslo, použije sa temporary password
|
||||||
|
if (!user.tempPassword) {
|
||||||
|
throw new AuthenticationError('Účet nie je správne nastavený');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Temporary password môže byť plain text alebo hash (závisí od seeding)
|
||||||
|
// Pre bezpečnosť ho budeme hashovať pri vytvorení
|
||||||
|
const isValid = await comparePassword(password, user.tempPassword);
|
||||||
|
if (!isValid) {
|
||||||
|
throw new AuthenticationError('Nesprávne prihlasovacie údaje');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last login
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({ lastLogin: new Date() })
|
||||||
|
.where(eq(users.id, user.id));
|
||||||
|
|
||||||
|
// Generuj JWT tokeny
|
||||||
|
const tokens = generateTokenPair(user);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
role: user.role,
|
||||||
|
changedPassword: user.changedPassword,
|
||||||
|
},
|
||||||
|
tokens,
|
||||||
|
needsPasswordChange: !user.changedPassword,
|
||||||
|
needsEmailSetup: !user.email,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KROK 2: Nastavenie nového hesla
|
||||||
|
*/
|
||||||
|
export const setNewPassword = async (userId, newPassword) => {
|
||||||
|
const [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundError('Používateľ nenájdený');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.changedPassword) {
|
||||||
|
throw new ValidationError('Heslo už bolo zmenené');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash nového hesla
|
||||||
|
const hashedPassword = await hashPassword(newPassword);
|
||||||
|
|
||||||
|
// Update user
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
password: hashedPassword,
|
||||||
|
changedPassword: true,
|
||||||
|
tempPassword: null, // Vymaž temporary password
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(users.id, userId));
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Heslo úspešne nastavené',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KROK 3: Pripojenie emailu s JMAP validáciou
|
||||||
|
*/
|
||||||
|
export const linkEmail = async (userId, email, emailPassword) => {
|
||||||
|
const [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundError('Používateľ nenájdený');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skontroluj či email už nie je použitý
|
||||||
|
const [existingUser] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.email, email))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
throw new ConflictError('Email už je použitý');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validuj JMAP credentials a získaj account ID
|
||||||
|
const { accountId } = await validateJmapCredentials(email, emailPassword);
|
||||||
|
|
||||||
|
// Encrypt email password pre bezpečné uloženie (nie hash, lebo potrebujeme decryption pre JMAP)
|
||||||
|
const encryptedEmailPassword = encryptPassword(emailPassword);
|
||||||
|
|
||||||
|
// Update user s email credentials
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
email,
|
||||||
|
emailPassword: encryptedEmailPassword,
|
||||||
|
jmapAccountId: accountId,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(users.id, userId));
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
accountId,
|
||||||
|
message: 'Email účet úspešne pripojený',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skip email setup
|
||||||
|
*/
|
||||||
|
export const skipEmailSetup = async (userId) => {
|
||||||
|
const [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundError('Používateľ nenájdený');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Email setup preskočený',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout - clear tokens (handled on client side)
|
||||||
|
*/
|
||||||
|
export const logout = async () => {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Úspešne odhlásený',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user by ID
|
||||||
|
*/
|
||||||
|
export const getUserById = async (userId) => {
|
||||||
|
const [user] = await db
|
||||||
|
.select({
|
||||||
|
id: users.id,
|
||||||
|
username: users.username,
|
||||||
|
email: users.email,
|
||||||
|
emailPassword: users.emailPassword,
|
||||||
|
jmapAccountId: users.jmapAccountId,
|
||||||
|
firstName: users.firstName,
|
||||||
|
lastName: users.lastName,
|
||||||
|
role: users.role,
|
||||||
|
changedPassword: users.changedPassword,
|
||||||
|
lastLogin: users.lastLogin,
|
||||||
|
createdAt: users.createdAt,
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundError('Používateľ nenájdený');
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
};
|
||||||
102
src/services/contact.service.js
Normal file
102
src/services/contact.service.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { db } from '../config/database.js';
|
||||||
|
import { contacts, emails } from '../db/schema.js';
|
||||||
|
import { eq, and, desc } from 'drizzle-orm';
|
||||||
|
import { NotFoundError, ConflictError } from '../utils/errors.js';
|
||||||
|
import { syncEmailsFromSender } from './jmap.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all contacts for a user
|
||||||
|
*/
|
||||||
|
export const getUserContacts = async (userId) => {
|
||||||
|
const userContacts = await db
|
||||||
|
.select()
|
||||||
|
.from(contacts)
|
||||||
|
.where(eq(contacts.userId, userId))
|
||||||
|
.orderBy(desc(contacts.addedAt));
|
||||||
|
|
||||||
|
return userContacts;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new contact and sync their emails
|
||||||
|
*/
|
||||||
|
export const addContact = async (userId, jmapConfig, email, name = '', notes = '') => {
|
||||||
|
// Check if contact already exists
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(contacts)
|
||||||
|
.where(and(eq(contacts.userId, userId), eq(contacts.email, email)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Kontakt už existuje');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create contact
|
||||||
|
const [newContact] = await db
|
||||||
|
.insert(contacts)
|
||||||
|
.values({
|
||||||
|
userId,
|
||||||
|
email,
|
||||||
|
name: name || email.split('@')[0],
|
||||||
|
notes: notes || null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Sync emails from this sender
|
||||||
|
try {
|
||||||
|
await syncEmailsFromSender(jmapConfig, userId, newContact.id, email);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to sync emails for new contact:', error);
|
||||||
|
// Don't throw - contact was created successfully
|
||||||
|
}
|
||||||
|
|
||||||
|
return newContact;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a contact
|
||||||
|
*/
|
||||||
|
export const removeContact = async (userId, contactId) => {
|
||||||
|
const [contact] = await db
|
||||||
|
.select()
|
||||||
|
.from(contacts)
|
||||||
|
.where(and(eq(contacts.id, contactId), eq(contacts.userId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!contact) {
|
||||||
|
throw new NotFoundError('Kontakt nenájdený');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete contact (emails will be cascade deleted)
|
||||||
|
await db.delete(contacts).where(eq(contacts.id, contactId));
|
||||||
|
|
||||||
|
return { success: true, message: 'Kontakt bol odstránený' };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update contact
|
||||||
|
*/
|
||||||
|
export const updateContact = async (userId, contactId, { name, notes }) => {
|
||||||
|
const [contact] = await db
|
||||||
|
.select()
|
||||||
|
.from(contacts)
|
||||||
|
.where(and(eq(contacts.id, contactId), eq(contacts.userId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!contact) {
|
||||||
|
throw new NotFoundError('Kontakt nenájdený');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(contacts)
|
||||||
|
.set({
|
||||||
|
name: name !== undefined ? name : contact.name,
|
||||||
|
notes: notes !== undefined ? notes : contact.notes,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(contacts.id, contactId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
};
|
||||||
120
src/services/crm-email.service.js
Normal file
120
src/services/crm-email.service.js
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { db } from '../config/database.js';
|
||||||
|
import { emails, contacts } from '../db/schema.js';
|
||||||
|
import { eq, and, or, desc, like, sql } from 'drizzle-orm';
|
||||||
|
import { NotFoundError } from '../utils/errors.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all emails for a user (only from added contacts)
|
||||||
|
*/
|
||||||
|
export const getUserEmails = async (userId) => {
|
||||||
|
const userEmails = await db
|
||||||
|
.select({
|
||||||
|
id: emails.id,
|
||||||
|
jmapId: emails.jmapId,
|
||||||
|
messageId: emails.messageId,
|
||||||
|
threadId: emails.threadId,
|
||||||
|
inReplyTo: emails.inReplyTo,
|
||||||
|
from: emails.from,
|
||||||
|
to: emails.to,
|
||||||
|
subject: emails.subject,
|
||||||
|
body: emails.body,
|
||||||
|
isRead: emails.isRead,
|
||||||
|
date: emails.date,
|
||||||
|
createdAt: emails.createdAt,
|
||||||
|
contact: {
|
||||||
|
id: contacts.id,
|
||||||
|
email: contacts.email,
|
||||||
|
name: contacts.name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.from(emails)
|
||||||
|
.leftJoin(contacts, eq(emails.contactId, contacts.id))
|
||||||
|
.where(eq(emails.userId, userId))
|
||||||
|
.orderBy(desc(emails.date));
|
||||||
|
|
||||||
|
return userEmails;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get emails by thread ID
|
||||||
|
*/
|
||||||
|
export const getEmailThread = async (userId, threadId) => {
|
||||||
|
const thread = await db
|
||||||
|
.select()
|
||||||
|
.from(emails)
|
||||||
|
.where(and(eq(emails.userId, userId), eq(emails.threadId, threadId)))
|
||||||
|
.orderBy(emails.date);
|
||||||
|
|
||||||
|
if (thread.length === 0) {
|
||||||
|
throw new NotFoundError('Thread nenájdený');
|
||||||
|
}
|
||||||
|
|
||||||
|
return thread;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search emails (from, to, subject)
|
||||||
|
*/
|
||||||
|
export const searchEmails = async (userId, query) => {
|
||||||
|
if (!query || query.trim().length < 2) {
|
||||||
|
throw new Error('Search term must be at least 2 characters');
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchPattern = `%${query}%`;
|
||||||
|
|
||||||
|
const results = await db
|
||||||
|
.select()
|
||||||
|
.from(emails)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(emails.userId, userId),
|
||||||
|
or(
|
||||||
|
like(emails.from, searchPattern),
|
||||||
|
like(emails.to, searchPattern),
|
||||||
|
like(emails.subject, searchPattern)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(emails.date))
|
||||||
|
.limit(50);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unread email count
|
||||||
|
*/
|
||||||
|
export const getUnreadCount = async (userId) => {
|
||||||
|
const result = await db
|
||||||
|
.select({ count: sql`count(*)::int` })
|
||||||
|
.from(emails)
|
||||||
|
.where(and(eq(emails.userId, userId), eq(emails.isRead, false)));
|
||||||
|
|
||||||
|
return result[0]?.count || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark thread as read
|
||||||
|
*/
|
||||||
|
export const markThreadAsRead = async (userId, threadId) => {
|
||||||
|
const result = await db
|
||||||
|
.update(emails)
|
||||||
|
.set({ isRead: true, updatedAt: new Date() })
|
||||||
|
.where(and(eq(emails.userId, userId), eq(emails.threadId, threadId), eq(emails.isRead, false)))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return { success: true, count: result.length };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get emails for a specific contact
|
||||||
|
*/
|
||||||
|
export const getContactEmails = async (userId, contactId) => {
|
||||||
|
const contactEmails = await db
|
||||||
|
.select()
|
||||||
|
.from(emails)
|
||||||
|
.where(and(eq(emails.userId, userId), eq(emails.contactId, contactId)))
|
||||||
|
.orderBy(desc(emails.date));
|
||||||
|
|
||||||
|
return contactEmails;
|
||||||
|
};
|
||||||
255
src/services/email.service.js
Normal file
255
src/services/email.service.js
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
const JMAP_CONFIG = {
|
||||||
|
server: process.env.JMAP_SERVER || 'https://mail.truemail.sk/jmap/',
|
||||||
|
username: process.env.JMAP_USERNAME || 'info1_test@truemail.sk',
|
||||||
|
password: process.env.JMAP_PASSWORD || 'info1',
|
||||||
|
accountId: process.env.JMAP_ACCOUNT_ID || 'ba',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Získa JMAP session
|
||||||
|
*/
|
||||||
|
const getJmapSession = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${JMAP_CONFIG.server}session`, {
|
||||||
|
auth: {
|
||||||
|
username: JMAP_CONFIG.username,
|
||||||
|
password: JMAP_CONFIG.password,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get JMAP session', error);
|
||||||
|
throw new Error('Email service nedostupný');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validuje JMAP credentials a vráti account ID
|
||||||
|
* @param {string} email - Email address
|
||||||
|
* @param {string} password - Email password
|
||||||
|
* @returns {Promise<{accountId: string, session: object}>}
|
||||||
|
*/
|
||||||
|
export const validateJmapCredentials = async (email, password) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${JMAP_CONFIG.server}session`, {
|
||||||
|
auth: {
|
||||||
|
username: email,
|
||||||
|
password: password,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = response.data;
|
||||||
|
|
||||||
|
// Získaj account ID z session
|
||||||
|
const accountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'] ||
|
||||||
|
session.primaryAccounts?.['urn:ietf:params:jmap:core'] ||
|
||||||
|
Object.keys(session.accounts || {})[0];
|
||||||
|
|
||||||
|
if (!accountId) {
|
||||||
|
throw new Error('Nepodarilo sa získať JMAP account ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`JMAP credentials validated for ${email}, accountId: ${accountId}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
accountId,
|
||||||
|
session,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to validate JMAP credentials for ${email}`, error);
|
||||||
|
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
throw new Error('Nesprávne prihlasovacie údaje k emailu');
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Nepodarilo sa overiť emailový účet');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pošle email pomocou JMAP
|
||||||
|
*/
|
||||||
|
const sendJmapEmail = async ({ to, subject, htmlBody, textBody }) => {
|
||||||
|
try {
|
||||||
|
const session = await getJmapSession();
|
||||||
|
const apiUrl = session.apiUrl;
|
||||||
|
|
||||||
|
const emailObject = {
|
||||||
|
from: [{ email: JMAP_CONFIG.username }],
|
||||||
|
to: [{ email: to }],
|
||||||
|
subject,
|
||||||
|
htmlBody: [
|
||||||
|
{
|
||||||
|
partId: '1',
|
||||||
|
type: 'text/html',
|
||||||
|
value: htmlBody,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
textBody: [
|
||||||
|
{
|
||||||
|
partId: '2',
|
||||||
|
type: 'text/plain',
|
||||||
|
value: textBody || subject,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await axios.post(
|
||||||
|
apiUrl,
|
||||||
|
{
|
||||||
|
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
|
||||||
|
methodCalls: [
|
||||||
|
[
|
||||||
|
'Email/set',
|
||||||
|
{
|
||||||
|
accountId: JMAP_CONFIG.accountId,
|
||||||
|
create: {
|
||||||
|
draft: emailObject,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'EmailSubmission/set',
|
||||||
|
{
|
||||||
|
accountId: JMAP_CONFIG.accountId,
|
||||||
|
create: {
|
||||||
|
submission: {
|
||||||
|
emailId: '#draft',
|
||||||
|
envelope: {
|
||||||
|
mailFrom: { email: JMAP_CONFIG.username },
|
||||||
|
rcptTo: [{ email: to }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'1',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
auth: {
|
||||||
|
username: JMAP_CONFIG.username,
|
||||||
|
password: JMAP_CONFIG.password,
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.success(`Email sent to ${to}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to send email to ${to}`, error);
|
||||||
|
throw new Error('Nepodarilo sa odoslať email');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email templates
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const sendVerificationEmail = async (to, username, verificationToken) => {
|
||||||
|
const verificationUrl = `${process.env.BETTER_AUTH_URL}/api/auth/verify-email?token=${verificationToken}`;
|
||||||
|
|
||||||
|
const htmlBody = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; line-height: 1.6; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||||
|
.button {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 12px 24px;
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.footer { margin-top: 30px; font-size: 12px; color: #666; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h2>Vitajte v CRM systéme, ${username}!</h2>
|
||||||
|
<p>Prosím, verifikujte svoju emailovú adresu kliknutím na tlačidlo nižšie:</p>
|
||||||
|
<a href="${verificationUrl}" class="button">Verifikovať email</a>
|
||||||
|
<p>Alebo skopírujte tento link do prehliadača:</p>
|
||||||
|
<p>${verificationUrl}</p>
|
||||||
|
<p class="footer">
|
||||||
|
Tento link vyprší za 24 hodín.<br>
|
||||||
|
Ak ste tento email neočakávali, môžete ho ignorovať.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const textBody = `
|
||||||
|
Vitajte v CRM systéme, ${username}!
|
||||||
|
|
||||||
|
Prosím, verifikujte svoju emailovú adresu kliknutím na tento link:
|
||||||
|
${verificationUrl}
|
||||||
|
|
||||||
|
Tento link vyprší za 24 hodín.
|
||||||
|
Ak ste tento email neočakávali, môžete ho ignorovať.
|
||||||
|
`;
|
||||||
|
|
||||||
|
return sendJmapEmail({
|
||||||
|
to,
|
||||||
|
subject: 'Verifikácia emailu - CRM systém',
|
||||||
|
htmlBody,
|
||||||
|
textBody,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sendWelcomeEmail = async (to, username) => {
|
||||||
|
const htmlBody = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; line-height: 1.6; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h2>Vitajte v CRM systéme, ${username}!</h2>
|
||||||
|
<p>Váš účet bol úspešne vytvorený a nastavený.</p>
|
||||||
|
<p>Môžete sa prihlásiť a začať používať systém.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const textBody = `
|
||||||
|
Vitajte v CRM systéme, ${username}!
|
||||||
|
|
||||||
|
Váš účet bol úspešne vytvorený a nastavený.
|
||||||
|
Môžete sa prihlásiť a začať používať systém.
|
||||||
|
`;
|
||||||
|
|
||||||
|
return sendJmapEmail({
|
||||||
|
to,
|
||||||
|
subject: 'Vitajte v CRM systéme',
|
||||||
|
htmlBody,
|
||||||
|
textBody,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asynchrónne posielanie emailov (non-blocking)
|
||||||
|
*/
|
||||||
|
export const sendEmailAsync = (emailFunction, ...args) => {
|
||||||
|
// Spustí email sending v pozadí
|
||||||
|
emailFunction(...args).catch((error) => {
|
||||||
|
logger.error('Async email sending failed', error);
|
||||||
|
});
|
||||||
|
};
|
||||||
498
src/services/jmap.service.js
Normal file
498
src/services/jmap.service.js
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
import { db } from '../config/database.js';
|
||||||
|
import { emails, contacts } from '../db/schema.js';
|
||||||
|
import { eq, and, or, desc, sql } from 'drizzle-orm';
|
||||||
|
import { decryptPassword } from '../utils/password.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JMAP Service - integrácia s Truemail.sk JMAP serverom
|
||||||
|
* Syncuje emaily, označuje ako prečítané, posiela odpovede
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get JMAP configuration for user
|
||||||
|
*/
|
||||||
|
export const getJmapConfig = (user) => {
|
||||||
|
if (!user.email || !user.emailPassword || !user.jmapAccountId) {
|
||||||
|
throw new Error('User nemá nastavený email account');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt email password for JMAP API
|
||||||
|
const decryptedPassword = decryptPassword(user.emailPassword);
|
||||||
|
|
||||||
|
return {
|
||||||
|
server: process.env.JMAP_SERVER || 'https://mail.truemail.sk/jmap/',
|
||||||
|
username: user.email,
|
||||||
|
password: decryptedPassword,
|
||||||
|
accountId: user.jmapAccountId,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make JMAP API request
|
||||||
|
*/
|
||||||
|
export const jmapRequest = async (jmapConfig, methodCalls) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
jmapConfig.server,
|
||||||
|
{
|
||||||
|
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
|
||||||
|
methodCalls,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
auth: {
|
||||||
|
username: jmapConfig.username,
|
||||||
|
password: jmapConfig.password,
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('JMAP request failed', error);
|
||||||
|
throw new Error(`JMAP request failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's mailboxes (Inbox, Sent, etc.)
|
||||||
|
*/
|
||||||
|
export const getMailboxes = async (jmapConfig) => {
|
||||||
|
try {
|
||||||
|
const response = await jmapRequest(jmapConfig, [
|
||||||
|
[
|
||||||
|
'Mailbox/get',
|
||||||
|
{
|
||||||
|
accountId: jmapConfig.accountId,
|
||||||
|
},
|
||||||
|
'mailbox1',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response.methodResponses[0][1].list;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get mailboxes', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user identities (for sending emails)
|
||||||
|
*/
|
||||||
|
export const getIdentities = async (jmapConfig) => {
|
||||||
|
try {
|
||||||
|
const response = await jmapRequest(jmapConfig, [
|
||||||
|
[
|
||||||
|
'Identity/get',
|
||||||
|
{
|
||||||
|
accountId: jmapConfig.accountId,
|
||||||
|
},
|
||||||
|
'identity1',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response.methodResponses[0][1].list;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get identities', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover potential contacts from JMAP (no DB storage)
|
||||||
|
* Returns list of unique senders
|
||||||
|
*/
|
||||||
|
export const discoverContactsFromJMAP = async (jmapConfig, userId, searchTerm = '', limit = 50) => {
|
||||||
|
try {
|
||||||
|
logger.info(`Discovering contacts from JMAP (search: "${searchTerm}")`);
|
||||||
|
|
||||||
|
// Query emails, sorted by date
|
||||||
|
const queryResponse = await jmapRequest(jmapConfig, [
|
||||||
|
[
|
||||||
|
'Email/query',
|
||||||
|
{
|
||||||
|
accountId: jmapConfig.accountId,
|
||||||
|
filter: searchTerm
|
||||||
|
? {
|
||||||
|
operator: 'OR',
|
||||||
|
conditions: [{ from: searchTerm }, { subject: searchTerm }],
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
sort: [{ property: 'receivedAt', isAscending: false }],
|
||||||
|
limit: 200, // Fetch more to get diverse senders
|
||||||
|
},
|
||||||
|
'query1',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const emailIds = queryResponse.methodResponses?.[0]?.[1]?.ids;
|
||||||
|
|
||||||
|
if (!emailIds || emailIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch email metadata (no bodies)
|
||||||
|
const getResponse = await jmapRequest(jmapConfig, [
|
||||||
|
[
|
||||||
|
'Email/get',
|
||||||
|
{
|
||||||
|
accountId: jmapConfig.accountId,
|
||||||
|
ids: emailIds,
|
||||||
|
properties: ['from', 'subject', 'receivedAt', 'preview'],
|
||||||
|
},
|
||||||
|
'get1',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const emailsList = getResponse.methodResponses[0][1].list;
|
||||||
|
|
||||||
|
// Get existing contacts for this user
|
||||||
|
const existingContacts = await db
|
||||||
|
.select()
|
||||||
|
.from(contacts)
|
||||||
|
.where(eq(contacts.userId, userId));
|
||||||
|
|
||||||
|
const contactEmailsSet = new Set(existingContacts.map((c) => c.email.toLowerCase()));
|
||||||
|
|
||||||
|
// Group by sender (unique senders)
|
||||||
|
const sendersMap = new Map();
|
||||||
|
const myEmail = jmapConfig.username.toLowerCase();
|
||||||
|
|
||||||
|
emailsList.forEach((email) => {
|
||||||
|
const fromEmail = email.from?.[0]?.email;
|
||||||
|
|
||||||
|
if (!fromEmail || fromEmail.toLowerCase() === myEmail) {
|
||||||
|
return; // Skip my own emails
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep only the most recent email from each sender
|
||||||
|
if (!sendersMap.has(fromEmail)) {
|
||||||
|
sendersMap.set(fromEmail, {
|
||||||
|
email: fromEmail,
|
||||||
|
name: email.from?.[0]?.name || fromEmail.split('@')[0],
|
||||||
|
latestSubject: email.subject || '(No Subject)',
|
||||||
|
latestDate: email.receivedAt,
|
||||||
|
snippet: email.preview || '',
|
||||||
|
isContact: contactEmailsSet.has(fromEmail.toLowerCase()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const senders = Array.from(sendersMap.values())
|
||||||
|
.sort((a, b) => new Date(b.latestDate) - new Date(a.latestDate))
|
||||||
|
.slice(0, limit);
|
||||||
|
|
||||||
|
logger.success(`Found ${senders.length} unique senders`);
|
||||||
|
return senders;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to discover contacts from JMAP', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync emails from a specific sender (when adding as contact)
|
||||||
|
*/
|
||||||
|
export const syncEmailsFromSender = async (jmapConfig, userId, contactId, senderEmail) => {
|
||||||
|
try {
|
||||||
|
logger.info(`Syncing emails from sender: ${senderEmail}`);
|
||||||
|
|
||||||
|
// Query all emails from this sender
|
||||||
|
const queryResponse = await jmapRequest(jmapConfig, [
|
||||||
|
[
|
||||||
|
'Email/query',
|
||||||
|
{
|
||||||
|
accountId: jmapConfig.accountId,
|
||||||
|
filter: {
|
||||||
|
operator: 'OR',
|
||||||
|
conditions: [{ from: senderEmail }, { to: senderEmail }],
|
||||||
|
},
|
||||||
|
sort: [{ property: 'receivedAt', isAscending: false }],
|
||||||
|
limit: 500,
|
||||||
|
},
|
||||||
|
'query1',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const emailIds = queryResponse.methodResponses[0][1].ids;
|
||||||
|
logger.info(`Found ${emailIds.length} emails from ${senderEmail}`);
|
||||||
|
|
||||||
|
if (emailIds.length === 0) {
|
||||||
|
return { total: 0, saved: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch full email details
|
||||||
|
const getResponse = await jmapRequest(jmapConfig, [
|
||||||
|
[
|
||||||
|
'Email/get',
|
||||||
|
{
|
||||||
|
accountId: jmapConfig.accountId,
|
||||||
|
ids: emailIds,
|
||||||
|
properties: [
|
||||||
|
'id',
|
||||||
|
'messageId',
|
||||||
|
'threadId',
|
||||||
|
'inReplyTo',
|
||||||
|
'from',
|
||||||
|
'to',
|
||||||
|
'subject',
|
||||||
|
'receivedAt',
|
||||||
|
'textBody',
|
||||||
|
'htmlBody',
|
||||||
|
'bodyValues',
|
||||||
|
'keywords',
|
||||||
|
],
|
||||||
|
fetchTextBodyValues: true,
|
||||||
|
fetchHTMLBodyValues: true,
|
||||||
|
},
|
||||||
|
'get1',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const emailsList = getResponse.methodResponses[0][1].list;
|
||||||
|
let savedCount = 0;
|
||||||
|
|
||||||
|
// Save emails to database
|
||||||
|
for (const email of emailsList) {
|
||||||
|
try {
|
||||||
|
const fromEmail = email.from?.[0]?.email;
|
||||||
|
const toEmail = email.to?.[0]?.email;
|
||||||
|
const messageId = Array.isArray(email.messageId) ? email.messageId[0] : email.messageId;
|
||||||
|
const inReplyTo = Array.isArray(email.inReplyTo) ? email.inReplyTo[0] : email.inReplyTo;
|
||||||
|
const isRead = email.keywords && email.keywords['$seen'] === true;
|
||||||
|
|
||||||
|
// Skip if already exists
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(emails)
|
||||||
|
.where(eq(emails.messageId, messageId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save email
|
||||||
|
await db.insert(emails).values({
|
||||||
|
userId,
|
||||||
|
contactId,
|
||||||
|
jmapId: email.id,
|
||||||
|
messageId,
|
||||||
|
threadId: email.threadId || messageId,
|
||||||
|
inReplyTo: inReplyTo || null,
|
||||||
|
from: fromEmail,
|
||||||
|
to: toEmail,
|
||||||
|
subject: email.subject || '(No Subject)',
|
||||||
|
body:
|
||||||
|
email.bodyValues?.[email.textBody?.[0]?.partId]?.value ||
|
||||||
|
email.bodyValues?.[email.htmlBody?.[0]?.partId]?.value ||
|
||||||
|
'(Empty message)',
|
||||||
|
date: email.receivedAt ? new Date(email.receivedAt) : new Date(),
|
||||||
|
isRead,
|
||||||
|
});
|
||||||
|
|
||||||
|
savedCount++;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error saving email ${email.messageId}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`Synced ${savedCount} new emails from ${senderEmail}`);
|
||||||
|
return { total: emailsList.length, saved: savedCount };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to sync emails from sender', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark email as read/unread in JMAP and local DB
|
||||||
|
*/
|
||||||
|
export const markEmailAsRead = async (jmapConfig, userId, jmapId, isRead) => {
|
||||||
|
try {
|
||||||
|
logger.info(`Marking email ${jmapId} as ${isRead ? 'read' : 'unread'}`);
|
||||||
|
|
||||||
|
// Get current keywords
|
||||||
|
const getResponse = await jmapRequest(jmapConfig, [
|
||||||
|
[
|
||||||
|
'Email/get',
|
||||||
|
{
|
||||||
|
accountId: jmapConfig.accountId,
|
||||||
|
ids: [jmapId],
|
||||||
|
properties: ['keywords', 'mailboxIds'],
|
||||||
|
},
|
||||||
|
'get1',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const email = getResponse.methodResponses[0][1].list?.[0];
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
// Update local DB even if JMAP fails
|
||||||
|
await db.update(emails).set({ isRead }).where(eq(emails.jmapId, jmapId));
|
||||||
|
throw new Error('Email not found in JMAP');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build new keywords
|
||||||
|
const newKeywords = { ...email.keywords };
|
||||||
|
if (isRead) {
|
||||||
|
newKeywords['$seen'] = true;
|
||||||
|
} else {
|
||||||
|
delete newKeywords['$seen'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update in JMAP
|
||||||
|
const updateResponse = await jmapRequest(jmapConfig, [
|
||||||
|
[
|
||||||
|
'Email/set',
|
||||||
|
{
|
||||||
|
accountId: jmapConfig.accountId,
|
||||||
|
update: {
|
||||||
|
[jmapId]: {
|
||||||
|
keywords: newKeywords,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'set1',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const updateResult = updateResponse.methodResponses[0][1];
|
||||||
|
|
||||||
|
// Check if update succeeded
|
||||||
|
if (updateResult.notUpdated?.[jmapId]) {
|
||||||
|
logger.error('Failed to update email in JMAP', updateResult.notUpdated[jmapId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update in local DB
|
||||||
|
await db.update(emails).set({ isRead }).where(eq(emails.jmapId, jmapId));
|
||||||
|
|
||||||
|
logger.success(`Email ${jmapId} marked as ${isRead ? 'read' : 'unread'}`);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error marking email as read', error);
|
||||||
|
|
||||||
|
// Still try to update local DB
|
||||||
|
try {
|
||||||
|
await db.update(emails).set({ isRead }).where(eq(emails.jmapId, jmapId));
|
||||||
|
} catch (dbError) {
|
||||||
|
logger.error('Failed to update DB', dbError);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send email via JMAP
|
||||||
|
*/
|
||||||
|
export const sendEmail = async (jmapConfig, userId, to, subject, body, inReplyTo = null, threadId = null) => {
|
||||||
|
try {
|
||||||
|
logger.info(`Sending email to: ${to}`);
|
||||||
|
|
||||||
|
// Get mailboxes
|
||||||
|
const mailboxes = await getMailboxes(jmapConfig);
|
||||||
|
const sentMailbox = mailboxes.find((m) => m.role === 'sent' || m.name === 'Sent');
|
||||||
|
|
||||||
|
if (!sentMailbox) {
|
||||||
|
throw new Error('Sent mailbox not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create email draft
|
||||||
|
const createResponse = await jmapRequest(jmapConfig, [
|
||||||
|
[
|
||||||
|
'Email/set',
|
||||||
|
{
|
||||||
|
accountId: jmapConfig.accountId,
|
||||||
|
create: {
|
||||||
|
draft: {
|
||||||
|
mailboxIds: {
|
||||||
|
[sentMailbox.id]: true,
|
||||||
|
},
|
||||||
|
keywords: {
|
||||||
|
$draft: true,
|
||||||
|
},
|
||||||
|
from: [{ email: jmapConfig.username }],
|
||||||
|
to: [{ email: to }],
|
||||||
|
subject: subject,
|
||||||
|
textBody: [{ partId: 'body', type: 'text/plain' }],
|
||||||
|
bodyValues: {
|
||||||
|
body: {
|
||||||
|
value: body,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
inReplyTo: inReplyTo ? [inReplyTo] : null,
|
||||||
|
references: inReplyTo ? [inReplyTo] : null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'set1',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const createdEmailId = createResponse.methodResponses[0][1].created?.draft?.id;
|
||||||
|
|
||||||
|
if (!createdEmailId) {
|
||||||
|
throw new Error('Failed to create email draft');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user identity
|
||||||
|
const identities = await getIdentities(jmapConfig);
|
||||||
|
const identity = identities.find((i) => i.email === jmapConfig.username) || identities[0];
|
||||||
|
|
||||||
|
if (!identity) {
|
||||||
|
throw new Error('No identity found for sending email');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit the email
|
||||||
|
const submitResponse = await jmapRequest(jmapConfig, [
|
||||||
|
[
|
||||||
|
'EmailSubmission/set',
|
||||||
|
{
|
||||||
|
accountId: jmapConfig.accountId,
|
||||||
|
create: {
|
||||||
|
submission: {
|
||||||
|
emailId: createdEmailId,
|
||||||
|
identityId: identity.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'submit1',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const submissionId = submitResponse.methodResponses[0][1].created?.submission?.id;
|
||||||
|
|
||||||
|
if (!submissionId) {
|
||||||
|
throw new Error('Failed to submit email');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`Email sent successfully to ${to}`);
|
||||||
|
|
||||||
|
// Save sent email to database
|
||||||
|
const messageId = `<${Date.now()}.${Math.random().toString(36).substr(2, 9)}@${jmapConfig.username.split('@')[1]}>`;
|
||||||
|
|
||||||
|
await db.insert(emails).values({
|
||||||
|
userId,
|
||||||
|
contactId: null, // Will be linked later if recipient is a contact
|
||||||
|
jmapId: createdEmailId,
|
||||||
|
messageId,
|
||||||
|
threadId: threadId || messageId,
|
||||||
|
inReplyTo: inReplyTo || null,
|
||||||
|
from: jmapConfig.username,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
body,
|
||||||
|
date: new Date(),
|
||||||
|
isRead: true, // Sent emails are always read
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, messageId };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error sending email', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
82
src/utils/errors.js
Normal file
82
src/utils/errors.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Custom error classes pre aplikáciu
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class AppError extends Error {
|
||||||
|
constructor(message, statusCode = 500, details = null) {
|
||||||
|
super(message);
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.details = details;
|
||||||
|
this.isOperational = true;
|
||||||
|
|
||||||
|
Error.captureStackTrace(this, this.constructor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ValidationError extends AppError {
|
||||||
|
constructor(message, details = null) {
|
||||||
|
super(message, 400, details);
|
||||||
|
this.name = 'ValidationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuthenticationError extends AppError {
|
||||||
|
constructor(message = 'Neautorizovaný prístup') {
|
||||||
|
super(message, 401);
|
||||||
|
this.name = 'AuthenticationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ForbiddenError extends AppError {
|
||||||
|
constructor(message = 'Prístup zamietnutý') {
|
||||||
|
super(message, 403);
|
||||||
|
this.name = 'ForbiddenError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NotFoundError extends AppError {
|
||||||
|
constructor(message = 'Nenájdené') {
|
||||||
|
super(message, 404);
|
||||||
|
this.name = 'NotFoundError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConflictError extends AppError {
|
||||||
|
constructor(message = 'Konflikt') {
|
||||||
|
super(message, 409);
|
||||||
|
this.name = 'ConflictError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RateLimitError extends AppError {
|
||||||
|
constructor(message = 'Príliš veľa požiadaviek') {
|
||||||
|
super(message, 429);
|
||||||
|
this.name = 'RateLimitError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error response formatter
|
||||||
|
* @param {Error} error
|
||||||
|
* @param {boolean} includeStack - Či má zahrnúť stack trace (len development)
|
||||||
|
* @returns {Object} Formatted error response
|
||||||
|
*/
|
||||||
|
export const formatErrorResponse = (error, includeStack = false) => {
|
||||||
|
const response = {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: error.message || 'Interná chyba servera',
|
||||||
|
statusCode: error.statusCode || 500,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error.details) {
|
||||||
|
response.error.details = error.details;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeStack && process.env.NODE_ENV === 'development') {
|
||||||
|
response.error.stack = error.stack;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
};
|
||||||
85
src/utils/jwt.js
Normal file
85
src/utils/jwt.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generuje access JWT token
|
||||||
|
* @param {Object} payload - User data (id, username, role)
|
||||||
|
* @returns {string} JWT token
|
||||||
|
*/
|
||||||
|
export const generateAccessToken = (payload) => {
|
||||||
|
return jwt.sign(payload, process.env.JWT_SECRET, {
|
||||||
|
expiresIn: process.env.JWT_EXPIRES_IN || '1h',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generuje refresh JWT token
|
||||||
|
* @param {Object} payload - User data (id)
|
||||||
|
* @returns {string} Refresh token
|
||||||
|
*/
|
||||||
|
export const generateRefreshToken = (payload) => {
|
||||||
|
return jwt.sign(payload, process.env.JWT_REFRESH_SECRET, {
|
||||||
|
expiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overí access JWT token
|
||||||
|
* @param {string} token - JWT token
|
||||||
|
* @returns {Object} Decoded payload
|
||||||
|
* @throws {Error} Ak je token neplatný alebo expirovaný
|
||||||
|
*/
|
||||||
|
export const verifyAccessToken = (token) => {
|
||||||
|
try {
|
||||||
|
return jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'TokenExpiredError') {
|
||||||
|
throw new Error('Token expiroval');
|
||||||
|
}
|
||||||
|
if (error.name === 'JsonWebTokenError') {
|
||||||
|
throw new Error('Neplatný token');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overí refresh JWT token
|
||||||
|
* @param {string} token - Refresh token
|
||||||
|
* @returns {Object} Decoded payload
|
||||||
|
* @throws {Error} Ak je token neplatný alebo expirovaný
|
||||||
|
*/
|
||||||
|
export const verifyRefreshToken = (token) => {
|
||||||
|
try {
|
||||||
|
return jwt.verify(token, process.env.JWT_REFRESH_SECRET);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'TokenExpiredError') {
|
||||||
|
throw new Error('Refresh token expiroval');
|
||||||
|
}
|
||||||
|
if (error.name === 'JsonWebTokenError') {
|
||||||
|
throw new Error('Neplatný refresh token');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vytvorí obidva tokeny (access + refresh)
|
||||||
|
* @param {Object} user - User object
|
||||||
|
* @returns {Object} { accessToken, refreshToken }
|
||||||
|
*/
|
||||||
|
export const generateTokenPair = (user) => {
|
||||||
|
const payload = {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshPayload = {
|
||||||
|
id: user.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken: generateAccessToken(payload),
|
||||||
|
refreshToken: generateRefreshToken(refreshPayload),
|
||||||
|
};
|
||||||
|
};
|
||||||
66
src/utils/logger.js
Normal file
66
src/utils/logger.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Simple logger utility
|
||||||
|
*/
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
reset: '\x1b[0m',
|
||||||
|
red: '\x1b[31m',
|
||||||
|
green: '\x1b[32m',
|
||||||
|
yellow: '\x1b[33m',
|
||||||
|
blue: '\x1b[34m',
|
||||||
|
magenta: '\x1b[35m',
|
||||||
|
cyan: '\x1b[36m',
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTimestamp = () => {
|
||||||
|
return new Date().toISOString();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const logger = {
|
||||||
|
info: (message, ...args) => {
|
||||||
|
console.log(
|
||||||
|
`${colors.blue}[INFO]${colors.reset} ${getTimestamp()} - ${message}`,
|
||||||
|
...args
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
success: (message, ...args) => {
|
||||||
|
console.log(
|
||||||
|
`${colors.green}[SUCCESS]${colors.reset} ${getTimestamp()} - ${message}`,
|
||||||
|
...args
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
warn: (message, ...args) => {
|
||||||
|
console.warn(
|
||||||
|
`${colors.yellow}[WARN]${colors.reset} ${getTimestamp()} - ${message}`,
|
||||||
|
...args
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
error: (message, error, ...args) => {
|
||||||
|
console.error(
|
||||||
|
`${colors.red}[ERROR]${colors.reset} ${getTimestamp()} - ${message}`,
|
||||||
|
...args
|
||||||
|
);
|
||||||
|
if (error && process.env.NODE_ENV === 'development') {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
debug: (message, ...args) => {
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.log(
|
||||||
|
`${colors.cyan}[DEBUG]${colors.reset} ${getTimestamp()} - ${message}`,
|
||||||
|
...args
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
audit: (message, ...args) => {
|
||||||
|
console.log(
|
||||||
|
`${colors.magenta}[AUDIT]${colors.reset} ${getTimestamp()} - ${message}`,
|
||||||
|
...args
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
104
src/utils/password.js
Normal file
104
src/utils/password.js
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hashuje heslo pomocou bcrypt
|
||||||
|
* @param {string} password - Plain text heslo
|
||||||
|
* @returns {Promise<string>} Hashované heslo
|
||||||
|
*/
|
||||||
|
export const hashPassword = async (password) => {
|
||||||
|
const rounds = parseInt(process.env.BCRYPT_ROUNDS) || 12;
|
||||||
|
return bcrypt.hash(password, rounds);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Porovná plain text heslo s hash
|
||||||
|
* @param {string} password - Plain text heslo
|
||||||
|
* @param {string} hash - Hashované heslo
|
||||||
|
* @returns {Promise<boolean>} True ak sa zhodujú
|
||||||
|
*/
|
||||||
|
export const comparePassword = async (password, hash) => {
|
||||||
|
return bcrypt.compare(password, hash);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generuje náhodné dočasné heslo
|
||||||
|
* @param {number} length - Dĺžka hesla (default: 12)
|
||||||
|
* @returns {string} Náhodné heslo
|
||||||
|
*/
|
||||||
|
export const generateTempPassword = (length = 12) => {
|
||||||
|
const uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||||
|
const lowercase = 'abcdefghijklmnopqrstuvwxyz';
|
||||||
|
const numbers = '0123456789';
|
||||||
|
const special = '!@#$%^&*';
|
||||||
|
const all = uppercase + lowercase + numbers + special;
|
||||||
|
|
||||||
|
let password = '';
|
||||||
|
|
||||||
|
// Zaručí aspoň jeden znak z každej kategórie
|
||||||
|
password += uppercase[Math.floor(Math.random() * uppercase.length)];
|
||||||
|
password += lowercase[Math.floor(Math.random() * lowercase.length)];
|
||||||
|
password += numbers[Math.floor(Math.random() * numbers.length)];
|
||||||
|
password += special[Math.floor(Math.random() * special.length)];
|
||||||
|
|
||||||
|
// Zvyšok náhodne
|
||||||
|
for (let i = password.length; i < length; i++) {
|
||||||
|
password += all[Math.floor(Math.random() * all.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zamieša znaky
|
||||||
|
return password
|
||||||
|
.split('')
|
||||||
|
.sort(() => Math.random() - 0.5)
|
||||||
|
.join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generuje náhodný verification token
|
||||||
|
* @returns {string} UUID token
|
||||||
|
*/
|
||||||
|
export const generateVerificationToken = () => {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt emailPassword using AES-256-GCM
|
||||||
|
* @param {string} text - Plain text password
|
||||||
|
* @returns {string} Encrypted password in format: iv:authTag:encrypted
|
||||||
|
*/
|
||||||
|
export const encryptPassword = (text) => {
|
||||||
|
const algorithm = 'aes-256-gcm';
|
||||||
|
const key = crypto.scryptSync(process.env.JWT_SECRET || 'default-secret', 'salt', 32);
|
||||||
|
const iv = crypto.randomBytes(16);
|
||||||
|
|
||||||
|
const cipher = crypto.createCipheriv(algorithm, key, iv);
|
||||||
|
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||||
|
encrypted += cipher.final('hex');
|
||||||
|
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt emailPassword
|
||||||
|
* @param {string} encryptedText - Encrypted password in format: iv:authTag:encrypted
|
||||||
|
* @returns {string} Plain text password
|
||||||
|
*/
|
||||||
|
export const decryptPassword = (encryptedText) => {
|
||||||
|
const algorithm = 'aes-256-gcm';
|
||||||
|
const key = crypto.scryptSync(process.env.JWT_SECRET || 'default-secret', 'salt', 32);
|
||||||
|
|
||||||
|
const parts = encryptedText.split(':');
|
||||||
|
const iv = Buffer.from(parts[0], 'hex');
|
||||||
|
const authTag = Buffer.from(parts[1], 'hex');
|
||||||
|
const encrypted = parts[2];
|
||||||
|
|
||||||
|
const decipher = crypto.createDecipheriv(algorithm, key, iv);
|
||||||
|
decipher.setAuthTag(authTag);
|
||||||
|
|
||||||
|
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||||
|
decrypted += decipher.final('utf8');
|
||||||
|
|
||||||
|
return decrypted;
|
||||||
|
};
|
||||||
100
src/validators/auth.validators.js
Normal file
100
src/validators/auth.validators.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// Login s temporary password schema
|
||||||
|
export const loginSchema = z.object({
|
||||||
|
username: z
|
||||||
|
.string({
|
||||||
|
required_error: 'Username je povinný',
|
||||||
|
invalid_type_error: 'Username musí byť text',
|
||||||
|
})
|
||||||
|
.min(3, 'Username musí mať aspoň 3 znaky')
|
||||||
|
.max(50, 'Username môže mať maximálne 50 znakov'),
|
||||||
|
password: z
|
||||||
|
.string({
|
||||||
|
required_error: 'Heslo je povinné',
|
||||||
|
invalid_type_error: 'Heslo musí byť text',
|
||||||
|
})
|
||||||
|
.min(1, 'Heslo nemôže byť prázdne'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set new password schema (krok 2)
|
||||||
|
export const setPasswordSchema = z
|
||||||
|
.object({
|
||||||
|
newPassword: z
|
||||||
|
.string({
|
||||||
|
required_error: 'Nové heslo je povinné',
|
||||||
|
})
|
||||||
|
.min(8, 'Heslo musí mať aspoň 8 znakov')
|
||||||
|
.max(100, 'Heslo môže mať maximálne 100 znakov')
|
||||||
|
.regex(/[a-z]/, 'Heslo musí obsahovať aspoň jedno malé písmeno')
|
||||||
|
.regex(/[A-Z]/, 'Heslo musí obsahovať aspoň jedno veľké písmeno')
|
||||||
|
.regex(/[0-9]/, 'Heslo musí obsahovať aspoň jedno číslo')
|
||||||
|
.regex(
|
||||||
|
/[^a-zA-Z0-9]/,
|
||||||
|
'Heslo musí obsahovať aspoň jeden špeciálny znak'
|
||||||
|
),
|
||||||
|
confirmPassword: z.string({
|
||||||
|
required_error: 'Potvrdenie hesla je povinné',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||||
|
message: 'Heslá sa nezhodujú',
|
||||||
|
path: ['confirmPassword'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Link email schema (krok 3)
|
||||||
|
export const linkEmailSchema = z.object({
|
||||||
|
email: z
|
||||||
|
.string({
|
||||||
|
required_error: 'Email je povinný',
|
||||||
|
})
|
||||||
|
.email('Neplatný formát emailu')
|
||||||
|
.max(255, 'Email môže mať maximálne 255 znakov'),
|
||||||
|
emailPassword: z
|
||||||
|
.string({
|
||||||
|
required_error: 'Heslo k emailu je povinné',
|
||||||
|
})
|
||||||
|
.min(1, 'Heslo k emailu nemôže byť prázdne'),
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Create user schema (admin only)
|
||||||
|
export const createUserSchema = z.object({
|
||||||
|
username: z
|
||||||
|
.string({
|
||||||
|
required_error: 'Username je povinný',
|
||||||
|
})
|
||||||
|
.min(3, 'Username musí mať aspoň 3 znaky')
|
||||||
|
.max(50, 'Username môže mať maximálne 50 znakov')
|
||||||
|
.regex(
|
||||||
|
/^[a-zA-Z0-9_-]+$/,
|
||||||
|
'Username môže obsahovať iba písmená, čísla, pomlčky a podčiarkovníky'
|
||||||
|
),
|
||||||
|
tempPassword: z
|
||||||
|
.string({
|
||||||
|
required_error: 'Dočasné heslo je povinné',
|
||||||
|
})
|
||||||
|
.min(8, 'Dočasné heslo musí mať aspoň 8 znakov'),
|
||||||
|
role: z.enum(['admin', 'member'], {
|
||||||
|
required_error: 'Rola je povinná',
|
||||||
|
invalid_type_error: 'Neplatná rola',
|
||||||
|
}),
|
||||||
|
firstName: z.string().max(100).optional(),
|
||||||
|
lastName: z.string().max(100).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update user schema
|
||||||
|
export const updateUserSchema = z.object({
|
||||||
|
firstName: z.string().max(100).optional(),
|
||||||
|
lastName: z.string().max(100).optional(),
|
||||||
|
email: z.string().email('Neplatný formát emailu').max(255).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Change role schema (admin only)
|
||||||
|
export const changeRoleSchema = z.object({
|
||||||
|
userId: z.string().uuid('Neplatný formát user ID'),
|
||||||
|
role: z.enum(['admin', 'member'], {
|
||||||
|
required_error: 'Rola je povinná',
|
||||||
|
invalid_type_error: 'Neplatná rola',
|
||||||
|
}),
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user