feat: Add internal chat system and network access support

- Add messages table schema with soft delete support
- Add message service, controller and routes
- Update CORS to allow local network IPs
- Update server to listen on 0.0.0.0
- Fix cookie sameSite for local network development

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
richardtekula
2026-01-15 10:13:14 +01:00
parent 70fa080455
commit 2a9377ce3d
7 changed files with 470 additions and 9 deletions

View File

@@ -27,6 +27,7 @@ import timeTrackingRoutes from './routes/time-tracking.routes.js';
import noteRoutes from './routes/note.routes.js'; import noteRoutes from './routes/note.routes.js';
import auditRoutes from './routes/audit.routes.js'; import auditRoutes from './routes/audit.routes.js';
import eventRoutes from './routes/event.routes.js'; import eventRoutes from './routes/event.routes.js';
import messageRoutes from './routes/message.routes.js';
const app = express(); const app = express();
@@ -53,9 +54,33 @@ app.use(
}) })
); );
// CORS configuration // CORS configuration - allow local network access
const corsOptions = { 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, credentials: true,
optionsSuccessStatus: 200, optionsSuccessStatus: 200,
}; };
@@ -96,6 +121,7 @@ app.use('/api/time-tracking', timeTrackingRoutes);
app.use('/api/notes', noteRoutes); app.use('/api/notes', noteRoutes);
app.use('/api/audit-logs', auditRoutes); app.use('/api/audit-logs', auditRoutes);
app.use('/api/events', eventRoutes); app.use('/api/events', eventRoutes);
app.use('/api/messages', messageRoutes);
// Basic route // Basic route
app.get('/', (req, res) => { app.get('/', (req, res) => {

View File

@@ -29,17 +29,18 @@ export const login = async (req, res, next) => {
await logLogin(result.user.id, username, ipAddress, userAgent); await logLogin(result.user.id, username, ipAddress, userAgent);
// Nastav cookie s access tokenom (httpOnly, secure) // Nastav cookie s access tokenom (httpOnly, secure)
const isProduction = process.env.NODE_ENV === 'production';
res.cookie('accessToken', result.tokens.accessToken, { res.cookie('accessToken', result.tokens.accessToken, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: isProduction,
sameSite: 'strict', sameSite: isProduction ? 'strict' : 'lax',
maxAge: 60 * 60 * 1000, // 1 hodina maxAge: 60 * 60 * 1000, // 1 hodina
}); });
res.cookie('refreshToken', result.tokens.refreshToken, { res.cookie('refreshToken', result.tokens.refreshToken, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: isProduction,
sameSite: 'strict', sameSite: isProduction ? 'strict' : 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 dní maxAge: 7 * 24 * 60 * 60 * 1000, // 7 dní
}); });

View File

@@ -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);
}
};

View File

@@ -283,3 +283,16 @@ export const timeEntries = pgTable('time_entries', {
createdAt: timestamp('created_at').defaultNow().notNull(), createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_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(),
});

View File

@@ -11,9 +11,9 @@ const start = async () => {
await testConnection(); await testConnection();
logger.success('Database connected'); logger.success('Database connected');
// Start server // Start server - listen on all interfaces for network access
app.listen(port, () => { app.listen(port, '0.0.0.0', () => {
logger.info(`Server running on http://localhost:${port}`); logger.info(`Server running on http://0.0.0.0:${port}`);
startAllCronJobs(); startAllCronJobs();
}); });
} catch (error) { } catch (error) {

View File

@@ -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;

View File

@@ -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;
};