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:
30
src/app.js
30
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) => {
|
||||
|
||||
@@ -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í
|
||||
});
|
||||
|
||||
|
||||
110
src/controllers/message.controller.js
Normal file
110
src/controllers/message.controller.js
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
45
src/routes/message.routes.js
Normal file
45
src/routes/message.routes.js
Normal 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;
|
||||
266
src/services/message.service.js
Normal file
266
src/services/message.service.js
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user