Building Slack-Style Chat from Scratch: Architecture and Trade-offs
Chat is deceptively complex, rooms, direct messages, moderation, blocked users, and message persistence all interact in non-obvious ways. This walks through the full implementation we built for a live multiplayer platform and the decisions that shaped it.
Building a production-ready chat system requires more than just sending messages. This deep dive covers the complete implementation from ft_transcendence: a dual-layer messaging architecture with direct messages and group channels, real-time delivery via Socket.io namespaces, role-based moderation with timed bans and mutes, hashed room IDs, blocked user filtering, and message persistence with Prisma and PostgreSQL.
Note: All code examples are taken directly from the actual ft_transcendence codebase. Variable names like recieverId, generateHashedRommId, unbanneTime, and hachedChannelPswd reflect the original source code as-is.
Real-Time Communication Fundamentals
Before diving into the implementation, it is essential to understand the core technologies and concepts that power the chat system.
WebSocket Protocol
Traditional HTTP follows a request-response cycle, the client asks, the server answers, and the connection closes. But chat requires the opposite: the server needs to push messages to clients instantly, without waiting for them to ask. WebSocket solves this by upgrading an HTTP connection into a persistent, full-duplex channel where both sides can send data at any time.
HTTP (Request-Response):
Client ──── GET /messages ────▶ Server
Client ◀──── 200 OK ────────── Server
(connection closed)
WebSocket (Full-Duplex):
Client ──── HTTP Upgrade ────▶ Server
Client ◀──── 101 Switching ── Server
Client ◀────────────────────▶ Server (persistent, bidirectional)
Socket.io & NestJS Gateways
Socket.io builds on top of raw WebSockets and adds critical features for production use: automatic reconnection, fallback to HTTP long-polling, room-based broadcasting, and acknowledgements. In our NestJS backend, Socket.io is integrated through Gateways, classes decorated with @WebSocketGateway that define event handlers using @SubscribeMessage.
// NestJS Gateway Pattern
@WebSocketGateway({ namespace: 'chatGateway' })
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;
// Fired when a client connects
handleConnection(client: Socket): void { ... }
// Listen for 'message' events from clients
@SubscribeMessage('message')
handleMessage(@MessageBody() data: any, @ConnectedSocket() socket: Socket) { ... }
// Fired when a client disconnects
handleDisconnect(client: Socket): void { ... }
}
Namespaces & Rooms
Socket.io provides two levels of message scoping:
- Namespaces are separate communication channels on the same server. Each namespace has its own set of event handlers, connected clients, and rooms. We use three:
/appGateway,/chatGateway, and/channelGateway. - Rooms are arbitrary groupings within a namespace. A socket can join or leave rooms at any time. When you emit to a room, only sockets in that room receive the message, this is how we scope DMs to two users and channel messages to group members.
Prisma ORM & PostgreSQL
Prisma provides type-safe database access with auto-generated TypeScript types from the schema. Every model in the Prisma schema maps directly to a PostgreSQL table, and relations are enforced at the database level with foreign keys. This means our chat data, messages, channels, memberships, is always consistent and queryable with full type safety.
Chat System Architecture
The ft_transcendence chat system is built on a dual-layer architecture: Direct Messages for 1-on-1 conversations and Channels for group communication. Each layer has its own dedicated Socket.io namespace, service, and gateway, keeping concerns separated and scalable.
+--------------------------------------------------------+
| FRONTEND |
| |
| +-----------+ +-----------+ +----------+ +-----------+ |
| | Chat UI | | Channel | | Member | | Friend | |
| | (1-on-1) | | Room | | List | | List | |
| +-----+-----+ +-----+-----+ +----+-----+ +-----+-----+ |
| | | | | |
| +-------------+------------+-------------+ |
| | |
| Socket.io Client (3 Namespaces) |
+----------------------------+---------------------------+
|
WebSocket
|
+----------------------------+---------------------------+
| NestJS Backend |
| | |
| +----------------------------------------------------+ |
| | Socket.io Gateways | |
| | | |
| | /appGateway - Friends, Invitations, Blocking | |
| | /chatGateway - Direct Messages (1-on-1) | |
| | /channelGateway - Group Channels, Moderation | |
| +-----------------------+----------------------------+ |
| | |
| +-----------------------+----------------------------+ |
| | Services & Client Tracking | |
| | | |
| | ChatService ChannelService | |
| | UsersService ConnectedClientsService | |
| +-----------------------+----------------------------+ |
| | |
+----------------------------+---------------------------+
|
Prisma ORM
|
+-------------------+
| PostgreSQL |
| |
| - User |
| - DirectMessage |
| - Channel |
| - ChannelMember |
| - BlockedUser |
+-------------------+
Database Schema Design
The schema separates direct messaging from channel-based communication. Direct messages use a SHA-256 hashed room ID derived from both user IDs, while channels support public, protected (password), and private types with granular role-based membership.
Core Models, Users & Direct Messages
enum Status {
ONLINE
OFFLINE
INAGAME
}
model User {
id String @id @default(uuid())
name String? @unique
email String @unique
intraId String @unique
Avatar String?
status Status @default(ONLINE)
isAuth Boolean @default(true)
twoFactorAuthSecret String?
isTwoFactorEnabled Boolean @default(false)
// Relations
userThatBlock BlockedUser[] @relation("Bloking")
blockedUser BlockedUser[] @relation("blocked")
senders DirectMessage[] @relation("sendeMessage")
receivers DirectMessage[] @relation("receiveMessage")
channelOwner Channel[] @relation("channelOwner")
channelMember ChannelMember[] @relation("channelMember")
kickedMember KickedMember[] @relation("kickedMember")
channelMessage ChannelMessage[] @relation("sendChannelMessage")
created_at DateTime @default(now())
updated_at DateTime @updatedAt
}
model DirectMessage {
id String @id @default(uuid())
content String
sender User @relation("sendeMessage", fields: [senderId], references: [id])
senderId String
reciever User @relation("receiveMessage", fields: [recieverId], references: [id])
recieverId String
roomId String // SHA-256 hash of sorted sender+receiver IDs
seen Boolean @default(false)
created_at DateTime @default(now())
}
model BlockedUser {
id String @id @default(uuid())
userThatBlock User @relation("Bloking", fields: [userId], references: [id])
userId String
blockedUser User @relation("blocked", fields: [blockedUserId], references: [id])
blockedUserId String
created_at DateTime @default(now())
}
Channel Models, Group Communication
enum ChannelType {
PUBLIC // Open to all users
PROTECTED // Requires password to join
PRIVATE // Invite-only via UUID
}
enum Role {
OWNER
ADMIN
MEMBER
MUTED_MEMBER // Temporarily silenced
BANNED_MEMBER // Temporarily banned
MUTED_ADMIN // Admin temporarily silenced
BANNED_ADMIN // Admin temporarily banned
}
model Channel {
id String @id @default(uuid())
channelName String @unique
channelType ChannelType @default(PUBLIC)
channelPassword String?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
user User @relation("channelOwner", fields: [channelOwnerId], references: [id])
channelOwnerId String
channelMember ChannelMember[] @relation("channelMember")
kickedMember KickedMember[] @relation("kickedMember")
recievers ChannelMessage[] @relation("receiverRelation")
}
model ChannelMember {
id String @id @default(uuid())
user User @relation("channelMember", fields: [userId], references: [id])
userId String
channel Channel @relation("channelMember", fields: [channelId], references: [id])
channelId String
role Role @default(MEMBER)
bannedTime DateTime?
unbanneTime DateTime?
mutedTime DateTime?
unmuteTime DateTime?
created_at DateTime @default(now())
@@unique([userId, channelId], name: "userAndChannel")
}
model KickedMember {
id String @id @default(uuid())
user User @relation("kickedMember", fields: [userId], references: [id])
userId String
channel Channel @relation("kickedMember", fields: [channelId], references: [id])
channelId String
created_at DateTime @default(now())
@@unique([userId, channelId], name: "kickedMemberAndChannel")
}
model ChannelMessage {
id String @id @default(uuid())
content String
sender User @relation("sendChannelMessage", fields: [userId], references: [id])
userId String
recievers Channel @relation("receiverRelation", fields: [channelId], references: [id])
channelId String
created_at DateTime @default(now())
}
Backend Implementation
Connected Clients Service
A centralized service tracks all connected clients across different gateways. Separate maps are maintained for the app gateway and chat gateway, enabling cross-namespace notifications, for example, sending a notification via the app gateway when a message arrives on the chat gateway.
import { Injectable } from '@nestjs/common';
import { Socket } from 'socket.io';
import { PrismaService } from 'prisma/prisma.service';
export const connectedClients: Map<string, string> = new Map();
@Injectable()
export class ConnectedClientsService {
private connectedClientsInChat: Map<string, string> = new Map();
constructor(private prisma: PrismaService) {}
addClient(client: Socket) {
const userId = client.handshake.auth.userId as string;
if (userId && client.id) {
if (!connectedClients.has(client.id)) {
connectedClients.set(client.id, userId);
}
}
}
addClientInchat(client: Socket) {
const userId = client.handshake.auth.userId as string;
if (userId && client.id) {
if (!this.connectedClientsInChat.has(client.id)) {
this.connectedClientsInChat.set(client.id, userId);
}
}
}
getAllClients(): Map<string, string> {
return connectedClients;
}
getAllClientsFromChat(): Map<string, string> {
return this.connectedClientsInChat;
}
removeClient(client: Socket) {
connectedClients.delete(client.id);
}
removeClientFromChat(client: Socket) {
this.connectedClientsInChat.delete(client.id);
}
}
Direct Message Service
The chat service handles 1-on-1 messaging with a clever room ID strategy: both user IDs are sorted and joined, then SHA-256 hashed. This ensures the same room ID is generated regardless of which user initiates the conversation, no duplicate rooms, no lookups needed.
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
import { CreateChatDto } from './dto/create-chat.dto';
import { PrismaService } from '../../prisma/prisma.service';
import { DirectMessage } from '@prisma/client';
import { createHash } from 'crypto';
import { UpdateChatDto } from './dto/update-chat.dto';
@Injectable()
export class ChatService {
constructor(private prisma: PrismaService) {}
async generateHashedRommId(
senderId: string,
recieverId: string
): Promise<string> {
const roomID = [senderId, recieverId].sort().join('-');
const hasshedRoomName = createHash('sha256')
.update(roomID)
.digest('hex');
return hasshedRoomName;
}
async createChat(createChatDto: CreateChatDto): Promise<DirectMessage> {
const hasshedRoomName = await this.generateHashedRommId(
createChatDto.senderId,
createChatDto.recieverId
);
return this.prisma.directMessage.create({
data: {
content: createChatDto.content,
senderId: createChatDto.senderId,
recieverId: createChatDto.recieverId,
roomId: hasshedRoomName,
},
});
}
async findAllChats(hashedRoomId: string): Promise<DirectMessage[]> {
return this.prisma.directMessage.findMany({
where: { roomId: hashedRoomId },
orderBy: { created_at: 'asc' },
});
}
async findAllReceivedChats(userId: string): Promise<DirectMessage[]> {
return this.prisma.directMessage.findMany({
where: { recieverId: userId, seen: false },
orderBy: { created_at: 'asc' },
});
}
async updateChat(
senderId: string,
receiverId: string,
updateChatDto: UpdateChatDto
): Promise<void> {
const hasshedRoomName = await this.generateHashedRommId(senderId, receiverId);
await this.prisma.directMessage.updateMany({
where: {
roomId: hasshedRoomName,
recieverId: senderId,
seen: false,
},
data: updateChatDto,
});
}
}
Chat Gateway, Direct Messages
The chat gateway operates on the /chatGateway namespace. When a user joins a room, the gateway hashes the sender+receiver IDs, loads message history, and manages Socket.io room subscriptions. Message sending includes blocked user checks and cross-namespace notifications.
@WebSocketGateway({
namespace: 'chatGateway',
cors: {
origin: `${process.env.APP_URI}:5173/chat`,
},
})
export class ChatGateway
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
constructor(
private readonly chatService: ChatService,
private readonly connectedClientsService: ConnectedClientsService,
private readonly usersService: UsersService,
private prisma: PrismaService,
private appGateway: AppGateway,
) {}
@WebSocketServer()
server: Server;
private logger: Logger = new Logger('CHAT Gateway Log');
handleConnection(client: Socket): void {
this.connectedClientsService.addClientInchat(client);
this.logger.log(`Client connected: ${client.id}`);
}
@SubscribeMessage('joinRoom')
async handleJoinRoom(
@MessageBody() payload: { senderId: string; recieverId: string },
@ConnectedSocket() socket: Socket,
): Promise<void> {
const hasshedRoomName = await this.chatService.generateHashedRommId(
payload.senderId,
payload.recieverId,
);
const messages = await this.chatService.findAllChats(hasshedRoomName);
// Bootstrap: create an empty message for first-time conversations
if (messages.length === 0) {
await this.prisma.directMessage.create({
data: {
content: '',
senderId: payload.senderId,
recieverId: payload.recieverId,
roomId: hasshedRoomName,
seen: true,
},
});
}
// Leave all previous rooms (except socket's own room)
Array.from(socket.rooms)
.filter((id) => id !== socket.id)
.forEach((id) => socket.leave(id));
// Join the hashed room
if (!socket.rooms.has(hasshedRoomName)) {
socket.join(hasshedRoomName);
this.server.to(socket.id).emit('joined', { roomName: hasshedRoomName });
}
}
@SubscribeMessage('message')
async handleEvent(
@MessageBody() payload: CreateChatDto,
@ConnectedSocket() socket: Socket,
): Promise<void> {
// Check if sender is blocked by receiver
const blockedUser = await this.usersService.findOneBlockedUser(
payload.recieverId,
payload.senderId,
);
if (!blockedUser) {
const hasshedRoomName = await this.chatService.generateHashedRommId(
payload.senderId,
payload.recieverId,
);
if (!socket.rooms.has(hasshedRoomName)) {
socket.join(hasshedRoomName);
}
// Broadcast to room participants
this.server.to(hasshedRoomName).emit('getMessage', {
senderId: payload.senderId,
receiverId: payload.recieverId,
text: payload.content,
room: hasshedRoomName,
});
// Persist message to database
await this.chatService.createChat(payload);
// Notify receiver in chat namespace (refresh their conversation list)
for (const [key, val] of this.connectedClientsService.getAllClientsFromChat()) {
if (val === payload.recieverId) {
this.server.to(key).emit('refresh');
}
}
// Cross-namespace notification via app gateway
for (const [key, val] of this.connectedClientsService.getAllClients()) {
if (val === payload.recieverId) {
this.appGateway.server.to(key).emit('notifMessage', payload);
}
}
}
}
handleDisconnect(client: Socket): void {
this.connectedClientsService.removeClientFromChat(client);
this.logger.log(`Client disconnected: ${client.id}`);
}
}
Channel System, Group Communication
Channel Gateway
The channel gateway handles group chat with support for public channels, password-protected channels, and private (invite-only) channels. It manages the full lifecycle: creation, joining, messaging, role management, and deletion.
Channel Creation with Password Protection
@WebSocketGateway({
namespace: 'channelGateway',
cors: {
origin: `${process.env.APP_URI}:5173/channels`,
},
})
export class ChannelGateway
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
@WebSocketServer()
server: Server;
private connectedClientsInChannel: Map<string, string> = new Map();
@SubscribeMessage('createChannel')
async handleCreateChannel(
@MessageBody() payload: CreateChannelDto,
@ConnectedSocket() socket: Socket,
): Promise<void> {
if (payload.channelPassword) {
// Hash password with SHA-256 before storing
const hachedChannelPswd = createHash('sha256')
.update(payload.channelPassword)
.digest('hex');
const channelProtected = await this.prisma.channel.create({
data: {
channelName: payload.channelName,
channelType: payload.channelType,
channelPassword: hachedChannelPswd,
channelOwnerId: payload.channelOwnerId,
},
});
await this.channelService.createChannelOwner(
channelProtected.channelOwnerId,
channelProtected.id,
);
socket.join(channelProtected.id);
} else {
const channel = await this.channelService.createChannel(payload);
await this.channelService.createChannelOwner(
channel.channelOwnerId,
channel.id,
);
socket.join(channel.id);
}
this.server.to(socket.id).emit('refrechCreateChannel');
}
}
Channel Messaging
Once a member has joined a channel, sending messages is straightforward: ensure the socket is in the room, persist the message to the database, then broadcast to all members in the channel.
@SubscribeMessage('messageChannel')
async handleMessage(
@MessageBody() payload: CreateChannelMessageDto,
@ConnectedSocket() socket: Socket,
): Promise<void> {
// Ensure the sender is in the channel room
if (!socket.rooms.has(payload.channelId)) {
socket.join(payload.channelId);
}
// Persist message to database
await this.channelService.createChannelMessage(payload);
// Broadcast to all channel members
this.server.to(payload.channelId).emit('onMessage', payload);
}
Joining Channels with Access Control
Joining a channel involves multiple validation layers: checking existing membership, verifying kicked status, and for protected channels, validating the hashed password.
@SubscribeMessage('joinChannel')
async joinChannel(
@MessageBody() payload: {
userId: string;
channelId: string;
channelPasword: string;
},
@ConnectedSocket() socket: Socket,
): Promise<void> {
const channel = await this.channelService.findOneChannel(payload.channelId);
const member = await this.prisma.channelMember.findUnique({
where: {
userAndChannel: {
userId: payload.userId,
channelId: payload.channelId,
},
},
});
if (member) {
// Existing member — verify password for PROTECTED channels
if (channel.channelType === 'PROTECTED' && payload.channelPasword) {
const hachedChannelPswd = createHash('sha256')
.update(payload.channelPasword)
.digest('hex');
if (channel.channelPassword === hachedChannelPswd) {
socket.join(channel.id);
this.server.to(socket.id).emit('joinedSuccessfully');
} else {
this.server.to(socket.id).emit('error', 'Invalid password');
}
} else {
socket.join(channel.id);
this.server.to(socket.id).emit('joinedSuccessfully');
}
} else {
// New member — check if kicked
const kickedMember = await this.channelService.findOneKickedMember(
payload.channelId,
payload.userId,
);
if (!kickedMember) {
const user = await this.usersService.findOne(payload.userId);
await this.channelService.createChannelMember({
userId: payload.userId,
channelId: payload.channelId,
role: 'MEMBER',
});
socket.join(payload.channelId);
this.server.to(payload.channelId).emit('refrechMember');
} else {
this.server.to(socket.id).emit('error', 'You are kicked from this Channel');
}
}
}
Moderation System
The moderation system uses role-based permissions with time-based bans and mutes. A @Cron job runs every second to automatically unban or unmute members when their punishment expires, preserving their original role (MEMBER or ADMIN).
Role State Machine
Each channel member has a role that transitions based on moderation actions. The key insight is that MEMBER and ADMIN are treated as parallel tracks, a muted/banned admin returns to ADMIN, not MEMBER:
+--------------------------------------------------+ | ROLE TRANSITIONS | +--------------------------------------------------+ | | | MEMBER track: | | +---------+ mute +---------------+ | | | MEMBER |----------->| MUTED_MEMBER | | | | |<-----------| (timed) | | | +---------+ unmute +---------------+ | | | | | | ban +---------------+ | | +---------------->| BANNED_MEMBER | | | |<----------------| (timed) | | | | unban +---------------+ | | | | ADMIN track: | | +---------+ mute +---------------+ | | | ADMIN |----------->| MUTED_ADMIN | | | | |<-----------| (timed) | | | +---------+ unmute +---------------+ | | | | | | ban +---------------+ | | +---------------->| BANNED_ADMIN | | | |<----------------| (timed) | | | | unban +---------------+ | | | | Cross-track: | | MEMBER <--- promote/demote ---> ADMIN | | OWNER (immutable, channel creator) | +--------------------------------------------------+
Timed Bans & Auto-Unban
// Ban a member — transitions MEMBER → BANNED_MEMBER or ADMIN → BANNED_ADMIN
async updateChannelMemberBannedTime(
channelId: string,
userId: string,
bannedTime: string,
): Promise<ChannelMember> {
const unbanneTime = new Date();
const bannTime = new Date(bannedTime);
if (bannTime.getMinutes() > unbanneTime.getMinutes()) {
unbanneTime.setMinutes(
unbanneTime.getMinutes()
+ (bannTime.getMinutes() - unbanneTime.getMinutes()),
);
}
const status = await this.findOneChannelMemberStatus(channelId, userId);
if (status === 'MEMBER') {
return this.prisma.channelMember.update({
where: { userAndChannel: { userId, channelId } },
data: { role: 'BANNED_MEMBER', bannedTime, unbanneTime },
});
} else if (status === 'ADMIN') {
return this.prisma.channelMember.update({
where: { userAndChannel: { userId, channelId } },
data: { role: 'BANNED_ADMIN', bannedTime, unbanneTime },
});
}
}
// Cron: auto-unban expired members → restores MEMBER role
@Cron(CronExpression.EVERY_SECOND)
async handleUnbanneMember(): Promise<{ count: number }> {
const currentDate = new Date();
return this.prisma.channelMember.updateMany({
where: {
role: 'BANNED_MEMBER',
unbanneTime: { lte: currentDate },
},
data: { role: 'MEMBER', bannedTime: null, unbanneTime: null },
});
}
// Cron: auto-unban expired admins → restores ADMIN role
@Cron(CronExpression.EVERY_SECOND)
async handleUnbanneAdmin(): Promise<{ count: number }> {
const currentDate = new Date();
return this.prisma.channelMember.updateMany({
where: {
role: 'BANNED_ADMIN',
unbanneTime: { lte: currentDate },
},
data: { role: 'ADMIN', bannedTime: null, unbanneTime: null },
});
}
Timed Mutes & Auto-Unmute
Muting follows the same pattern as banning, the member's role transitions to MUTED_MEMBER or MUTED_ADMIN, and a cron job restores the original role when the mute expires.
// Mute a member — transitions MEMBER → MUTED_MEMBER or ADMIN → MUTED_ADMIN
async updateChannelMemberMutedTime(
channelId: string,
userId: string,
mutedTime: string,
): Promise<ChannelMember> {
const unmuteTime = new Date();
const muteTime = new Date(mutedTime);
if (muteTime.getMinutes() > unmuteTime.getMinutes()) {
unmuteTime.setMinutes(
unmuteTime.getMinutes()
+ (muteTime.getMinutes() - unmuteTime.getMinutes()),
);
}
const status = await this.findOneChannelMemberStatus(channelId, userId);
if (status === 'MEMBER') {
return this.prisma.channelMember.update({
where: { userAndChannel: { userId, channelId } },
data: { role: 'MUTED_MEMBER', mutedTime, unmuteTime },
});
} else if (status === 'ADMIN') {
return this.prisma.channelMember.update({
where: { userAndChannel: { userId, channelId } },
data: { role: 'MUTED_ADMIN', mutedTime, unmuteTime },
});
}
}
// Cron: auto-unmute expired members → restores MEMBER role
@Cron(CronExpression.EVERY_SECOND)
async handleUnmuteMember(): Promise<{ count: number }> {
const currentDate = new Date();
return this.prisma.channelMember.updateMany({
where: {
role: 'MUTED_MEMBER',
unmuteTime: { lte: currentDate },
},
data: { role: 'MEMBER', mutedTime: null, unmuteTime: null },
});
}
// Cron: auto-unmute expired admins → restores ADMIN role
@Cron(CronExpression.EVERY_SECOND)
async handleUnmuteAdmin(): Promise<{ count: number }> {
const currentDate = new Date();
return this.prisma.channelMember.updateMany({
where: {
role: 'MUTED_ADMIN',
unmuteTime: { lte: currentDate },
},
data: { role: 'ADMIN', mutedTime: null, unmuteTime: null },
});
}
Kick Members with Cross-Namespace Redirect
@SubscribeMessage('kickMember')
async handleKickMember(
@MessageBody() payload: CreateKickedMemberDto,
@ConnectedSocket() socket: Socket,
): Promise<void> {
const member = await this.channelService.findOneChannelMember(
payload.channelId,
payload.userId,
);
if (member && (member.role === 'MEMBER' || member.role === 'ADMIN')) {
const channel = await this.channelService.findOneChannel(payload.channelId);
const user = await this.usersService.findOne(payload.userId);
// Record the kick — prevents rejoin
await this.channelService.createKickedMember(payload);
// Remove from channel membership
await this.channelService.removeChannelMember(
payload.channelId,
payload.userId,
);
// Notify the channel
this.server
.to(payload.channelId)
.emit('onMessage', `${user.name} is kicked from channel: ${channel.channelName}`);
this.server.to(payload.channelId).emit('refrechMember');
// Force-redirect the kicked user via their socket
for (const [key, val] of this.connectedClientsInChannel) {
if (val === payload.userId) {
this.server.to(key).emit('redirectAfterGetKicked');
}
}
}
}
Blocked User Filtering in Channel Messages
async findAllChannelNonBlockedMessages(
channelId: string,
senderId: string,
): Promise<ChannelMessage[]> {
// Fetch the user's block list
const user = await this.prisma.user.findUnique({
where: { id: senderId },
include: { userThatBlock: {} },
});
const blockedUserIds = user.userThatBlock.map(
(blocked) => blocked.blockedUserId,
);
// Return messages excluding blocked users
return this.prisma.channelMessage.findMany({
where: {
channelId: channelId,
userId: { notIn: blockedUserIds },
},
orderBy: { created_at: 'asc' },
});
}
Conclusion
Building a production-ready chat system demands careful separation of concerns. The dual-layer architecture (DMs + Channels) with isolated Socket.io namespaces scales naturally, while the role-based moderation system with timed punishments provides the control needed for community management.
Key takeaways:
- Deterministic room IDs, SHA-256 hashed user ID pairs eliminate duplicate conversations without any lookup
- Namespace isolation, Separate gateways for DMs, channels, and social features keep concerns clean
- Cross-namespace notifications, Inject gateways to send alerts across Socket.io namespaces
- Role state machine, MEMBER ↔ MUTED/BANNED transitions preserve privileges after expiration
- Cron-based moderation, Automatic unban/unmute with original role restoration
- Server-side filtering, Blocked user messages filtered at the query level, not on the client
This architecture powers real-time communication for a multiplayer game platform, handling concurrent users across direct messages, group channels, and game invitations simultaneously.
Explore the Code
Check out the ft_transcendence repository on GitHub to see these patterns in a real-world multiplayer game platform.
Working on something similar?
I'm a Technical Lead & AI Engineer building LLM-powered SaaS in production.
NestJS, Next.js and Django on Azure, from model integration to the architecture around it. If your team is working through the same problems, I'm happy to compare notes.
Get in touchWritten by

Technical Lead at iAgency (Casablanca), previously Technical Lead leading a 5-engineer team at Fygurs on Azure cloud-native SaaS. Graduate of 1337 Coding School (42 Network / UM6P). Writes about architecture, cloud infrastructure, and engineering leadership.