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 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) => {
|
||||||
|
|||||||
@@ -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í
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
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(),
|
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(),
|
||||||
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
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