diff --git a/src/app.js b/src/app.js index 30c91f2..bd1b016 100644 --- a/src/app.js +++ b/src/app.js @@ -27,6 +27,7 @@ import timeTrackingRoutes from './routes/time-tracking.routes.js'; import noteRoutes from './routes/note.routes.js'; import auditRoutes from './routes/audit.routes.js'; import eventRoutes from './routes/event.routes.js'; +import messageRoutes from './routes/message.routes.js'; const app = express(); @@ -53,9 +54,33 @@ app.use( }) ); -// CORS configuration +// CORS configuration - allow local network access const corsOptions = { - origin: process.env.CORS_ORIGIN || 'http://localhost:5173', + origin: (origin, callback) => { + // Allow requests with no origin (mobile apps, curl, etc.) + if (!origin) return callback(null, true); + + // Allow localhost and local network IPs + const allowedPatterns = [ + /^http:\/\/localhost(:\d+)?$/, + /^http:\/\/127\.0\.0\.1(:\d+)?$/, + /^http:\/\/192\.168\.\d{1,3}\.\d{1,3}(:\d+)?$/, + /^http:\/\/10\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d+)?$/, + /^http:\/\/172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}(:\d+)?$/, + ]; + + // Check if origin matches allowed patterns or CORS_ORIGIN env + const corsOrigin = process.env.CORS_ORIGIN; + if (corsOrigin && origin === corsOrigin) { + return callback(null, true); + } + + if (allowedPatterns.some(pattern => pattern.test(origin))) { + return callback(null, true); + } + + callback(new Error('Not allowed by CORS')); + }, credentials: true, optionsSuccessStatus: 200, }; @@ -96,6 +121,7 @@ app.use('/api/time-tracking', timeTrackingRoutes); app.use('/api/notes', noteRoutes); app.use('/api/audit-logs', auditRoutes); app.use('/api/events', eventRoutes); +app.use('/api/messages', messageRoutes); // Basic route app.get('/', (req, res) => { diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js index b47f61c..56ab88a 100644 --- a/src/controllers/auth.controller.js +++ b/src/controllers/auth.controller.js @@ -29,17 +29,18 @@ export const login = async (req, res, next) => { await logLogin(result.user.id, username, ipAddress, userAgent); // Nastav cookie s access tokenom (httpOnly, secure) + const isProduction = process.env.NODE_ENV === 'production'; res.cookie('accessToken', result.tokens.accessToken, { httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'strict', + secure: isProduction, + sameSite: isProduction ? 'strict' : 'lax', maxAge: 60 * 60 * 1000, // 1 hodina }); res.cookie('refreshToken', result.tokens.refreshToken, { httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'strict', + secure: isProduction, + sameSite: isProduction ? 'strict' : 'lax', maxAge: 7 * 24 * 60 * 60 * 1000, // 7 dní }); diff --git a/src/controllers/message.controller.js b/src/controllers/message.controller.js new file mode 100644 index 0000000..4c6d1ba --- /dev/null +++ b/src/controllers/message.controller.js @@ -0,0 +1,110 @@ +import * as messageService from '../services/message.service.js'; + +/** + * Get all conversations for current user + * GET /api/messages/conversations + */ +export const getConversations = async (req, res, next) => { + try { + const conversations = await messageService.getConversations(req.userId); + + res.status(200).json({ + success: true, + data: conversations, + }); + } catch (error) { + next(error); + } +}; + +/** + * Get messages with a specific user + * GET /api/messages/conversation/:userId + */ +export const getConversation = async (req, res, next) => { + const { userId: partnerId } = req.params; + + try { + const conversation = await messageService.getConversation(req.userId, partnerId); + + res.status(200).json({ + success: true, + data: conversation, + }); + } catch (error) { + next(error); + } +}; + +/** + * Send a message to another user + * POST /api/messages/send + */ +export const sendMessage = async (req, res, next) => { + const { receiverId, content } = req.body; + + try { + const message = await messageService.sendMessage(req.userId, receiverId, content); + + res.status(201).json({ + success: true, + data: message, + message: 'Správa bola odoslaná', + }); + } catch (error) { + next(error); + } +}; + +/** + * Delete conversation with a user + * DELETE /api/messages/conversation/:userId + */ +export const deleteConversation = async (req, res, next) => { + const { userId: partnerId } = req.params; + + try { + await messageService.deleteConversation(req.userId, partnerId); + + res.status(200).json({ + success: true, + message: 'Konverzácia bola odstránená', + }); + } catch (error) { + next(error); + } +}; + +/** + * Get unread message count + * GET /api/messages/unread-count + */ +export const getUnreadCount = async (req, res, next) => { + try { + const count = await messageService.getUnreadCount(req.userId); + + res.status(200).json({ + success: true, + data: { unreadCount: count }, + }); + } catch (error) { + next(error); + } +}; + +/** + * Get all CRM users available for chat + * GET /api/messages/users + */ +export const getChatUsers = async (req, res, next) => { + try { + const users = await messageService.getChatUsers(req.userId); + + res.status(200).json({ + success: true, + data: users, + }); + } catch (error) { + next(error); + } +}; diff --git a/src/db/schema.js b/src/db/schema.js index 2c9999c..fc38db3 100644 --- a/src/db/schema.js +++ b/src/db/schema.js @@ -283,3 +283,16 @@ export const timeEntries = pgTable('time_entries', { createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), }); + +// Messages table - interná komunikácia medzi používateľmi +export const messages = pgTable('messages', { + id: uuid('id').primaryKey().defaultRandom(), + senderId: uuid('sender_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), + receiverId: uuid('receiver_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), + content: text('content').notNull(), + isRead: boolean('is_read').default(false).notNull(), + deletedBySender: boolean('deleted_by_sender').default(false).notNull(), // soft delete pre odosielateľa + deletedByReceiver: boolean('deleted_by_receiver').default(false).notNull(), // soft delete pre príjemcu + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); diff --git a/src/index.js b/src/index.js index 9c25529..592f450 100644 --- a/src/index.js +++ b/src/index.js @@ -11,9 +11,9 @@ const start = async () => { await testConnection(); logger.success('Database connected'); - // Start server - app.listen(port, () => { - logger.info(`Server running on http://localhost:${port}`); + // Start server - listen on all interfaces for network access + app.listen(port, '0.0.0.0', () => { + logger.info(`Server running on http://0.0.0.0:${port}`); startAllCronJobs(); }); } catch (error) { diff --git a/src/routes/message.routes.js b/src/routes/message.routes.js new file mode 100644 index 0000000..f391cc9 --- /dev/null +++ b/src/routes/message.routes.js @@ -0,0 +1,45 @@ +import express from 'express'; +import * as messageController from '../controllers/message.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 message routes require authentication +router.use(authenticate); + +// Get all conversations +router.get('/conversations', messageController.getConversations); + +// Get all CRM users available for chat +router.get('/users', messageController.getChatUsers); + +// Get unread message count +router.get('/unread-count', messageController.getUnreadCount); + +// Get messages with specific user +router.get( + '/conversation/:userId', + validateParams(z.object({ userId: z.string().uuid() })), + messageController.getConversation +); + +// Send a message +router.post( + '/send', + validateBody(z.object({ + receiverId: z.string().uuid('Neplatný formát user ID'), + content: z.string().min(1, 'Správa nemôže byť prázdna').max(5000, 'Správa je príliš dlhá'), + })), + messageController.sendMessage +); + +// Delete conversation +router.delete( + '/conversation/:userId', + validateParams(z.object({ userId: z.string().uuid() })), + messageController.deleteConversation +); + +export default router; diff --git a/src/services/message.service.js b/src/services/message.service.js new file mode 100644 index 0000000..6728ed0 --- /dev/null +++ b/src/services/message.service.js @@ -0,0 +1,266 @@ +import { db } from '../config/database.js'; +import { messages, users } from '../db/schema.js'; +import { eq, and, or, desc, ne, sql } from 'drizzle-orm'; +import { NotFoundError } from '../utils/errors.js'; + +/** + * Get all conversations for a user + * Returns list of users with last message preview + */ +export const getConversations = async (userId) => { + // Get all messages where user is sender or receiver (not deleted) + const userMessages = await db + .select({ + id: messages.id, + senderId: messages.senderId, + receiverId: messages.receiverId, + content: messages.content, + isRead: messages.isRead, + createdAt: messages.createdAt, + deletedBySender: messages.deletedBySender, + deletedByReceiver: messages.deletedByReceiver, + }) + .from(messages) + .where( + or( + and(eq(messages.senderId, userId), eq(messages.deletedBySender, false)), + and(eq(messages.receiverId, userId), eq(messages.deletedByReceiver, false)) + ) + ) + .orderBy(desc(messages.createdAt)); + + // Group by conversation partner and get last message + const conversationsMap = new Map(); + + for (const msg of userMessages) { + const partnerId = msg.senderId === userId ? msg.receiverId : msg.senderId; + + if (!conversationsMap.has(partnerId)) { + conversationsMap.set(partnerId, { + partnerId, + lastMessage: msg, + unreadCount: 0, + }); + } + + // Count unread messages (only where user is receiver) + if (msg.receiverId === userId && !msg.isRead) { + const conv = conversationsMap.get(partnerId); + conv.unreadCount++; + } + } + + // Get user details for each partner + const partnerIds = Array.from(conversationsMap.keys()); + + if (partnerIds.length === 0) { + return []; + } + + const partners = await db + .select({ + id: users.id, + username: users.username, + firstName: users.firstName, + lastName: users.lastName, + }) + .from(users) + .where(sql`${users.id} IN ${partnerIds}`); + + const partnersMap = new Map(partners.map(p => [p.id, p])); + + // Build final conversation list + const conversations = Array.from(conversationsMap.values()) + .map(conv => ({ + user: partnersMap.get(conv.partnerId), + lastMessage: { + content: conv.lastMessage.content, + createdAt: conv.lastMessage.createdAt, + isMine: conv.lastMessage.senderId === userId, + }, + unreadCount: conv.unreadCount, + })) + .filter(conv => conv.user) // Filter out any invalid users + .sort((a, b) => new Date(b.lastMessage.createdAt) - new Date(a.lastMessage.createdAt)); + + return conversations; +}; + +/** + * Get messages with a specific user + */ +export const getConversation = async (userId, partnerId) => { + // Verify partner exists + const [partner] = await db + .select({ + id: users.id, + username: users.username, + firstName: users.firstName, + lastName: users.lastName, + }) + .from(users) + .where(eq(users.id, partnerId)) + .limit(1); + + if (!partner) { + throw new NotFoundError('Používateľ nenájdený'); + } + + // Get messages between these two users + const conversationMessages = await db + .select({ + id: messages.id, + senderId: messages.senderId, + receiverId: messages.receiverId, + content: messages.content, + isRead: messages.isRead, + createdAt: messages.createdAt, + }) + .from(messages) + .where( + and( + or( + and( + eq(messages.senderId, userId), + eq(messages.receiverId, partnerId), + eq(messages.deletedBySender, false) + ), + and( + eq(messages.senderId, partnerId), + eq(messages.receiverId, userId), + eq(messages.deletedByReceiver, false) + ) + ) + ) + ) + .orderBy(messages.createdAt); + + // Mark messages as read (where user is receiver) + await db + .update(messages) + .set({ isRead: true, updatedAt: new Date() }) + .where( + and( + eq(messages.senderId, partnerId), + eq(messages.receiverId, userId), + eq(messages.isRead, false) + ) + ); + + return { + partner, + messages: conversationMessages.map(msg => ({ + id: msg.id, + content: msg.content, + isMine: msg.senderId === userId, + isRead: msg.isRead, + createdAt: msg.createdAt, + })), + }; +}; + +/** + * Send a message to another user + */ +export const sendMessage = async (senderId, receiverId, content) => { + // Verify receiver exists + const [receiver] = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.id, receiverId)) + .limit(1); + + if (!receiver) { + throw new NotFoundError('Príjemca nenájdený'); + } + + // Cannot send message to yourself + if (senderId === receiverId) { + throw new Error('Nemôžete poslať správu sám sebe'); + } + + const [newMessage] = await db + .insert(messages) + .values({ + senderId, + receiverId, + content: content.trim(), + }) + .returning(); + + return { + id: newMessage.id, + content: newMessage.content, + isMine: true, + isRead: false, + createdAt: newMessage.createdAt, + }; +}; + +/** + * Delete conversation for a user (soft delete) + */ +export const deleteConversation = async (userId, partnerId) => { + // Mark all messages as deleted for this user + // Messages where user is sender + await db + .update(messages) + .set({ deletedBySender: true, updatedAt: new Date() }) + .where( + and( + eq(messages.senderId, userId), + eq(messages.receiverId, partnerId) + ) + ); + + // Messages where user is receiver + await db + .update(messages) + .set({ deletedByReceiver: true, updatedAt: new Date() }) + .where( + and( + eq(messages.senderId, partnerId), + eq(messages.receiverId, userId) + ) + ); + + return { success: true }; +}; + +/** + * Get unread message count for a user + */ +export const getUnreadCount = async (userId) => { + const result = await db + .select({ count: sql`count(*)::int` }) + .from(messages) + .where( + and( + eq(messages.receiverId, userId), + eq(messages.isRead, false), + eq(messages.deletedByReceiver, false) + ) + ); + + return result[0]?.count || 0; +}; + +/** + * Get all CRM users available for chat (excluding current user) + */ +export const getChatUsers = async (currentUserId) => { + const chatUsers = await db + .select({ + id: users.id, + username: users.username, + firstName: users.firstName, + lastName: users.lastName, + role: users.role, + lastLogin: users.lastLogin, + }) + .from(users) + .where(ne(users.id, currentUserId)) + .orderBy(users.username); + + return chatUsers; +};