diff --git a/src/app.js b/src/app.js index e5e5781..a9a1fce 100644 --- a/src/app.js +++ b/src/app.js @@ -3,8 +3,6 @@ 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'; diff --git a/src/config/database.js b/src/config/database.js index b4078fd..bd13686 100644 --- a/src/config/database.js +++ b/src/config/database.js @@ -8,7 +8,7 @@ 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', + password: process.env.DB_PASSWORD, database: process.env.DB_NAME || 'crm', max: 20, // maximum number of connections in pool idleTimeoutMillis: 30000, diff --git a/src/config/env.js b/src/config/env.js new file mode 100644 index 0000000..3c83d9f --- /dev/null +++ b/src/config/env.js @@ -0,0 +1,2 @@ +import dotenv from 'dotenv'; +dotenv.config(); diff --git a/src/db/migrations/0002_add_indexes.sql b/src/db/migrations/0002_add_indexes.sql new file mode 100644 index 0000000..1692acf --- /dev/null +++ b/src/db/migrations/0002_add_indexes.sql @@ -0,0 +1,21 @@ +-- Add indexes for frequently used foreign keys +CREATE INDEX IF NOT EXISTS idx_contacts_email_account_id ON contacts(email_account_id); +CREATE INDEX IF NOT EXISTS idx_contacts_company_id ON contacts(company_id); +CREATE INDEX IF NOT EXISTS idx_todos_project_id ON todos(project_id); +CREATE INDEX IF NOT EXISTS idx_todos_company_id ON todos(company_id); +CREATE INDEX IF NOT EXISTS idx_notes_company_id ON notes(company_id); +CREATE INDEX IF NOT EXISTS idx_notes_project_id ON notes(project_id); +CREATE INDEX IF NOT EXISTS idx_notes_todo_id ON notes(todo_id); + +-- Add indexes for search fields +CREATE INDEX IF NOT EXISTS idx_contacts_email ON contacts(email); +CREATE INDEX IF NOT EXISTS idx_companies_name ON companies(name); +CREATE INDEX IF NOT EXISTS idx_projects_name ON projects(name); + +-- Add indexes for status/filter fields +CREATE INDEX IF NOT EXISTS idx_todos_status ON todos(status); +CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status); + +-- Add composite indexes for frequent queries +CREATE INDEX IF NOT EXISTS idx_todos_user_status ON todo_users(user_id, todo_id); +CREATE INDEX IF NOT EXISTS idx_time_entries_user_start ON time_entries(user_id, start_time); diff --git a/src/index.js b/src/index.js index 592f450..56b062d 100644 --- a/src/index.js +++ b/src/index.js @@ -1,3 +1,4 @@ +import './config/env.js'; import app from './app.js'; import { startAllCronJobs } from './cron/index.js'; import { logger } from './utils/logger.js'; diff --git a/src/middlewares/global/validateBody.js b/src/middlewares/global/validateBody.js index 9ce8ee2..fd8dd2f 100644 --- a/src/middlewares/global/validateBody.js +++ b/src/middlewares/global/validateBody.js @@ -1,20 +1,9 @@ -import { logger } from '../../utils/logger.js'; - +/** + * Body validation middleware + * NOTE: SQL injection regex patterns have been removed as they are unnecessary + * when using Drizzle ORM which uses parameterized queries. + * The regex patterns also caused false positives (e.g., when user types "SELECT" in text). + */ 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, - /(|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)) { - logger.warn('Detegovaný podozrivý vstup', { data: data.substring(0, 100) }); - return res.status(400).json({ message: 'Detegovaný škodlivý obsah v požiadavke' }); - } - } next(); } diff --git a/src/services/contact.service.js b/src/services/contact.service.js index ccb3cce..c0f6ff8 100644 --- a/src/services/contact.service.js +++ b/src/services/contact.service.js @@ -1,9 +1,33 @@ import { db } from '../config/database.js'; -import { contacts, emails, companies } from '../db/schema.js'; +import { contacts, emails, companies, emailAccounts } from '../db/schema.js'; import { eq, and, desc, or, ne } from 'drizzle-orm'; import { NotFoundError, ConflictError } from '../utils/errors.js'; import { syncEmailsFromSender } from './jmap/index.js'; +/** + * Get contacts with related data (emailAccount, company) using joins + * Avoids N+1 query problem by fetching all related data in a single query + */ +export const getContactsWithRelations = async (emailAccountId) => { + const result = await db + .select({ + contact: contacts, + emailAccount: emailAccounts, + company: companies, + }) + .from(contacts) + .leftJoin(emailAccounts, eq(contacts.emailAccountId, emailAccounts.id)) + .leftJoin(companies, eq(contacts.companyId, companies.id)) + .where(eq(contacts.emailAccountId, emailAccountId)) + .orderBy(desc(contacts.addedAt)); + + return result.map((row) => ({ + ...row.contact, + emailAccount: row.emailAccount, + company: row.company, + })); +}; + /** * Get all contacts for an email account * Kontakty patria k email accountu, nie k jednotlivým používateľom diff --git a/src/services/jmap/config.js b/src/services/jmap/config.js index 25e33be..69257db 100644 --- a/src/services/jmap/config.js +++ b/src/services/jmap/config.js @@ -1,38 +1,40 @@ import { decryptPassword } from '../../utils/password.js'; /** - * Get JMAP configuration for user (legacy - for backward compatibility) + * Get JMAP configuration - from email account or user object + * @param {object} source - Email account object or user object with email credentials + * @param {boolean} isEncrypted - Whether the password needs decryption (default: true for user, false for account) */ -export const getJmapConfig = (user) => { - if (!user.email || !user.emailPassword || !user.jmapAccountId) { - throw new Error('Používateľ nemá nastavený email účet'); +export const getJmapConfig = (source, isEncrypted = true) => { + if (!source) { + throw new Error('Source object is required'); } - // Decrypt email password for JMAP API - const decryptedPassword = decryptPassword(user.emailPassword); + // Check required fields + const email = source.email; + const password = source.emailPassword; + const accountId = source.jmapAccountId; + + if (!email || !password || !accountId) { + throw new Error('Email account je neuplny'); + } + + // Decrypt password if needed + const decryptedPassword = isEncrypted ? decryptPassword(password) : password; return { server: process.env.JMAP_SERVER || 'https://mail.truemail.sk/jmap/', - username: user.email, + username: email, password: decryptedPassword, - accountId: user.jmapAccountId, + accountId: accountId, }; }; /** * Get JMAP configuration from email account object * NOTE: Expects emailPassword to be already decrypted (from getEmailAccountWithCredentials) + * @deprecated Use getJmapConfig(emailAccount, false) instead */ export const getJmapConfigFromAccount = (emailAccount) => { - if (!emailAccount.email || !emailAccount.emailPassword || !emailAccount.jmapAccountId) { - throw new Error('Email účet je neúplný'); - } - - // Password is already decrypted by getEmailAccountWithCredentials - return { - server: process.env.JMAP_SERVER || 'https://mail.truemail.sk/jmap/', - username: emailAccount.email, - password: emailAccount.emailPassword, - accountId: emailAccount.jmapAccountId, - }; + return getJmapConfig(emailAccount, false); }; diff --git a/src/services/status.service.js b/src/services/status.service.js index a065e9d..7e6b49c 100644 --- a/src/services/status.service.js +++ b/src/services/status.service.js @@ -1,6 +1,7 @@ import os from 'os'; import path from 'path'; -import { execSync } from 'child_process'; +import { execFileSync } from 'child_process'; +import fs from 'fs'; import { pool } from '../config/database.js'; /** @@ -31,7 +32,7 @@ const getCpuUsage = () => { const getMemoryUsage = () => { try { // Read /proc/meminfo for accurate memory stats (like htop) - const meminfo = execSync('cat /proc/meminfo', { encoding: 'utf8' }); + const meminfo = fs.readFileSync('/proc/meminfo', 'utf8'); const lines = meminfo.split('\n'); const getValue = (key) => { @@ -79,8 +80,9 @@ const getMemoryUsage = () => { */ const getDiskUsage = () => { try { - const output = execSync('df -BG / | tail -1', { encoding: 'utf8' }); - const parts = output.trim().split(/\s+/); + const output = execFileSync('df', ['-BG', '/'], { encoding: 'utf8' }); + const lines = output.trim().split('\n'); + const parts = lines[lines.length - 1].split(/\s+/); const totalGB = parseInt(parts[1]) || 0; const usedGB = parseInt(parts[2]) || 0; @@ -118,7 +120,7 @@ const getBackendStats = () => { const getUploadsSize = () => { try { const uploadsPath = path.join(process.cwd(), 'uploads'); - const output = execSync(`du -sm "${uploadsPath}" 2>/dev/null || echo "0"`, { encoding: 'utf8' }); + const output = execFileSync('du', ['-sm', uploadsPath], { encoding: 'utf8' }); const sizeMB = parseInt(output.split('\t')[0]) || 0; return { diff --git a/src/services/todo.service.js b/src/services/todo.service.js index eb411fb..b8f2454 100644 --- a/src/services/todo.service.js +++ b/src/services/todo.service.js @@ -325,54 +325,51 @@ export const deleteTodo = async (todoId) => { /** * Get todo with related data (notes, project, company, assigned users) + * Optimized to use joins and Promise.all to avoid N+1 query problem */ export const getTodoWithRelations = async (todoId) => { - const todo = await getTodoById(todoId); - - // Get project if exists - let project = null; - if (todo.projectId) { - [project] = await db - .select() - .from(projects) - .where(eq(projects.id, todo.projectId)) - .limit(1); - } - - // Get company if exists - let company = null; - if (todo.companyId) { - [company] = await db - .select() - .from(companies) - .where(eq(companies.id, todo.companyId)) - .limit(1); - } - - // Get assigned users from todo_users junction table - const assignedUsers = await db + // Fetch todo with project and company in single query using joins + const [todoResult] = await db .select({ - id: users.id, - username: users.username, - firstName: users.firstName, - lastName: users.lastName, - assignedAt: todoUsers.assignedAt, + todo: todos, + project: projects, + company: companies, }) - .from(todoUsers) - .innerJoin(users, eq(todoUsers.userId, users.id)) - .where(eq(todoUsers.todoId, todoId)); + .from(todos) + .leftJoin(projects, eq(todos.projectId, projects.id)) + .leftJoin(companies, eq(todos.companyId, companies.id)) + .where(eq(todos.id, todoId)) + .limit(1); - // Get related notes - const todoNotes = await db - .select() - .from(notes) - .where(eq(notes.todoId, todoId)) - .orderBy(desc(notes.createdAt)); + if (!todoResult) { + throw new NotFoundError('Todo nenajdene'); + } + + // Batch fetch for assigned users and notes in parallel + const [assignedUsers, todoNotes] = await Promise.all([ + db + .select({ + id: users.id, + username: users.username, + firstName: users.firstName, + lastName: users.lastName, + assignedAt: todoUsers.assignedAt, + }) + .from(todoUsers) + .innerJoin(users, eq(todoUsers.userId, users.id)) + .where(eq(todoUsers.todoId, todoId)), + + db + .select() + .from(notes) + .where(eq(notes.todoId, todoId)) + .orderBy(desc(notes.createdAt)), + ]); return { - ...todo, - project, - company, + ...todoResult.todo, + project: todoResult.project, + company: todoResult.company, assignedUsers, notes: todoNotes, }; diff --git a/src/utils/emailAccountHelper.js b/src/utils/emailAccountHelper.js new file mode 100644 index 0000000..7d0e65c --- /dev/null +++ b/src/utils/emailAccountHelper.js @@ -0,0 +1,31 @@ +import { db } from '../config/database.js'; +import { emailAccounts } from '../db/schema.js'; +import { eq, and } from 'drizzle-orm'; +import { NotFoundError, BadRequestError } from './errors.js'; + +export const getEmailAccountById = async (accountId, userId = null) => { + const conditions = [eq(emailAccounts.id, accountId)]; + if (userId) { + conditions.push(eq(emailAccounts.userId, userId)); + } + + const [account] = await db + .select() + .from(emailAccounts) + .where(and(...conditions)) + .limit(1); + + if (!account) { + throw new NotFoundError('Email account not found'); + } + + return account; +}; + +export const getActiveEmailAccount = async (accountId, userId = null) => { + const account = await getEmailAccountById(accountId, userId); + if (!account.isActive) { + throw new BadRequestError('Email account is not active'); + } + return account; +}; diff --git a/src/utils/errors.js b/src/utils/errors.js index bc87700..be70a4c 100644 --- a/src/utils/errors.js +++ b/src/utils/errors.js @@ -1,11 +1,12 @@ /** - * Custom error classes pre aplikáciu + * Custom error classes pre aplikaciu */ export class AppError extends Error { - constructor(message, statusCode = 500, details = null) { + constructor(message, statusCode = 500, code = 'INTERNAL_ERROR', details = null) { super(message); this.statusCode = statusCode; + this.code = code; this.details = details; this.isOperational = true; @@ -15,75 +16,77 @@ export class AppError extends Error { export class ValidationError extends AppError { constructor(message, details = null) { - super(message, 400, details); + super(message, 400, 'VALIDATION_ERROR', details); this.name = 'ValidationError'; } } export class BadRequestError extends AppError { - constructor(message = 'Zlá požiadavka') { - super(message, 400); + constructor(message = 'Zla poziadavka') { + super(message, 400, 'BAD_REQUEST'); this.name = 'BadRequestError'; } } export class AuthenticationError extends AppError { - constructor(message = 'Neautorizovaný prístup') { - super(message, 401); + constructor(message = 'Neautorizovany pristup') { + super(message, 401, 'UNAUTHORIZED'); this.name = 'AuthenticationError'; } } export class ForbiddenError extends AppError { - constructor(message = 'Prístup zamietnutý') { - super(message, 403); + constructor(message = 'Pristup zamietnuty') { + super(message, 403, 'FORBIDDEN'); this.name = 'ForbiddenError'; } } export class NotFoundError extends AppError { - constructor(message = 'Nenájdené') { - super(message, 404); + constructor(message = 'Nenajdene') { + super(message, 404, 'NOT_FOUND'); this.name = 'NotFoundError'; } } export class ConflictError extends AppError { constructor(message = 'Konflikt') { - super(message, 409); + super(message, 409, 'CONFLICT'); this.name = 'ConflictError'; } } export class RateLimitError extends AppError { - constructor(message = 'Príliš veľa požiadaviek') { - super(message, 429); + constructor(message = 'Prilis vela poziadaviek') { + super(message, 429, 'RATE_LIMIT_EXCEEDED'); this.name = 'RateLimitError'; } } /** - * Error response formatter + * Standardized error response formatter * @param {Error} error - * @param {boolean} includeStack - Či má zahrnúť stack trace (len development) + * @param {boolean} includeStack - Whether to include stack trace (development only) * @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, + code: error.code || 'INTERNAL_ERROR', + message: error.message || 'Interna chyba servera', + details: error.details || null, }, }; - if (error.details) { - response.error.details = error.details; - } - if (includeStack && process.env.NODE_ENV === 'development') { response.error.stack = error.stack; } return response; }; + +/** + * Format error for API response (alias for formatErrorResponse) + */ +export const formatError = formatErrorResponse; diff --git a/src/utils/pagination.js b/src/utils/pagination.js new file mode 100644 index 0000000..f1f3f71 --- /dev/null +++ b/src/utils/pagination.js @@ -0,0 +1,25 @@ +export const DEFAULT_PAGE_SIZE = 20; +export const MAX_PAGE_SIZE = 100; + +export const parsePagination = (query) => { + const page = Math.max(1, parseInt(query.page) || 1); + const limit = Math.min( + MAX_PAGE_SIZE, + Math.max(1, parseInt(query.limit) || DEFAULT_PAGE_SIZE) + ); + const offset = (page - 1) * limit; + + return { page, limit, offset }; +}; + +export const paginatedResponse = (data, total, { page, limit }) => ({ + data, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + hasNext: page * limit < total, + hasPrev: page > 1, + }, +}); diff --git a/src/utils/password.js b/src/utils/password.js index 3c7b196..09114ab 100644 --- a/src/utils/password.js +++ b/src/utils/password.js @@ -66,15 +66,18 @@ export const generateVerificationToken = () => { * @returns {string} Encrypted password in format: iv:authTag:encrypted */ export const encryptPassword = (text) => { - if (!process.env.JWT_SECRET) { - throw new Error('JWT_SECRET environment variable is required for password encryption'); + if (!process.env.EMAIL_ENCRYPTION_KEY) { + throw new Error('EMAIL_ENCRYPTION_KEY environment variable is required for password encryption'); } if (!process.env.ENCRYPTION_SALT) { throw new Error('ENCRYPTION_SALT environment variable is required for password encryption'); } + if (process.env.ENCRYPTION_SALT.length < 32) { + throw new Error('ENCRYPTION_SALT must be at least 32 characters'); + } const algorithm = 'aes-256-gcm'; - const key = crypto.scryptSync(process.env.JWT_SECRET, process.env.ENCRYPTION_SALT, 32); + const key = crypto.scryptSync(process.env.EMAIL_ENCRYPTION_KEY, process.env.ENCRYPTION_SALT, 32); const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv(algorithm, key, iv); @@ -92,15 +95,18 @@ export const encryptPassword = (text) => { * @returns {string} Plain text password */ export const decryptPassword = (encryptedText) => { - if (!process.env.JWT_SECRET) { - throw new Error('JWT_SECRET environment variable is required for password decryption'); + if (!process.env.EMAIL_ENCRYPTION_KEY) { + throw new Error('EMAIL_ENCRYPTION_KEY environment variable is required for password decryption'); } if (!process.env.ENCRYPTION_SALT) { throw new Error('ENCRYPTION_SALT environment variable is required for password decryption'); } + if (process.env.ENCRYPTION_SALT.length < 32) { + throw new Error('ENCRYPTION_SALT must be at least 32 characters'); + } const algorithm = 'aes-256-gcm'; - const key = crypto.scryptSync(process.env.JWT_SECRET, process.env.ENCRYPTION_SALT, 32); + const key = crypto.scryptSync(process.env.EMAIL_ENCRYPTION_KEY, process.env.ENCRYPTION_SALT, 32); const parts = encryptedText.split(':'); const iv = Buffer.from(parts[0], 'hex'); diff --git a/src/utils/queryBuilder.js b/src/utils/queryBuilder.js new file mode 100644 index 0000000..e104e5a --- /dev/null +++ b/src/utils/queryBuilder.js @@ -0,0 +1,63 @@ +import { and, or, ilike, eq, desc, asc } from 'drizzle-orm'; +import { NotFoundError } from './errors.js'; + +/** + * Genericky query builder pre list operacie + * @param {object} db - Drizzle db instance + * @param {object} table - Drizzle table + * @param {object} options - Query options + */ +export const buildListQuery = (db, table, options = {}) => { + const { + searchTerm, + searchFields = [], + filters = {}, + orderBy = 'createdAt', + orderDir = 'desc', + limit, + offset, + } = options; + + let query = db.select().from(table); + const conditions = []; + + // Search + if (searchTerm && searchFields.length > 0) { + const searchConditions = searchFields.map((field) => + ilike(table[field], `%${searchTerm}%`) + ); + conditions.push(or(...searchConditions)); + } + + // Filters + Object.entries(filters).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + conditions.push(eq(table[key], value)); + } + }); + + if (conditions.length > 0) { + query = query.where(and(...conditions)); + } + + // Order + const orderFn = orderDir === 'desc' ? desc : asc; + query = query.orderBy(orderFn(table[orderBy])); + + // Pagination + if (limit) query = query.limit(limit); + if (offset) query = query.offset(offset); + + return query; +}; + +/** + * Wrapper pre single item fetch s NotFoundError + */ +export const findOneOrThrow = async (db, table, whereClause, errorMessage) => { + const [item] = await db.select().from(table).where(whereClause).limit(1); + if (!item) { + throw new NotFoundError(errorMessage); + } + return item; +};