Construire un chat style Slack from scratch : architecture et compromis
Le chat est d'une complexité trompeuse — salons, messages directs, modération, utilisateurs bloqués et persistance des messages interagissent de façon non évidente. Ce guide parcourt l'implémentation complète que nous avons construite pour une plateforme multijoueur live et les décisions qui l'ont façonnée.
Construire un système de chat prêt pour la production nécessite bien plus que simplement envoyer des messages. Cette analyse approfondie couvre l'implémentation complète de ft_transcendence : une architecture de messagerie à deux niveaux avec messages directs et canaux de groupe, livraison en temps réel via les namespaces Socket.io, modération basée sur les rôles avec bannissements et silençages temporisés, IDs de salle hachés, filtrage des utilisateurs bloqués et persistance des messages avec Prisma et PostgreSQL.
Note : Tous les exemples de code sont tirés directement de la base de code ft_transcendence réelle. Les noms de variables comme recieverId, generateHashedRommId, unbanneTime et hachedChannelPswd reflètent le code source original tel quel.
Fondamentaux de la Communication en Temps Réel
Avant de plonger dans l'implémentation, il est essentiel de comprendre les technologies et concepts fondamentaux qui alimentent le système de chat.
Protocole WebSocket
Le HTTP traditionnel suit un cycle requête-réponse — le client demande, le serveur répond, et la connexion se ferme. Mais le chat nécessite l'inverse : le serveur doit pousser des messages aux clients instantanément, sans attendre qu'ils demandent. WebSocket résout cela en faisant évoluer une connexion HTTP vers un canal persistant et bidirectionnel où les deux parties peuvent envoyer des données à tout moment.
HTTP (Requête-Réponse) :
Client ──── GET /messages ────▶ Server
Client ◀──── 200 OK ────────── Server
(connexion fermée)
WebSocket (Full-Duplex) :
Client ──── HTTP Upgrade ────▶ Server
Client ◀──── 101 Switching ── Server
Client ◀────────────────────▶ Server (persistant, bidirectionnel)
Socket.io & Gateways NestJS
Socket.io s'appuie sur les WebSockets bruts et ajoute des fonctionnalités critiques pour la production : reconnexion automatique, repli vers le long-polling HTTP, diffusion basée sur les salles et accusés de réception. Dans notre backend NestJS, Socket.io est intégré via des Gateways — des classes décorées avec @WebSocketGateway qui définissent des gestionnaires d'événements avec @SubscribeMessage.
// Pattern Gateway NestJS
@WebSocketGateway({ namespace: 'chatGateway' })
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;
// Déclenché quand un client se connecte
handleConnection(client: Socket): void { ... }
// Écouter les événements 'message' des clients
@SubscribeMessage('message')
handleMessage(@MessageBody() data: any, @ConnectedSocket() socket: Socket) { ... }
// Déclenché quand un client se déconnecte
handleDisconnect(client: Socket): void { ... }
}
Namespaces & Salles
Socket.io fournit deux niveaux de portée des messages :
- Les namespaces sont des canaux de communication séparés sur le même serveur. Chaque namespace possède son propre ensemble de gestionnaires d'événements, de clients connectés et de salles. Nous en utilisons trois :
/appGateway,/chatGatewayet/channelGateway. - Les salles sont des regroupements arbitraires au sein d'un namespace. Un socket peut rejoindre ou quitter des salles à tout moment. Lorsque vous émettez vers une salle, seuls les sockets dans cette salle reçoivent le message — c'est ainsi que nous limitons les DM à deux utilisateurs et les messages de canal aux membres du groupe.
Prisma ORM & PostgreSQL
Prisma fournit un accès type-safe à la base de données avec des types TypeScript auto-générés à partir du schéma. Chaque modèle dans le schéma Prisma correspond directement à une table PostgreSQL, et les relations sont appliquées au niveau de la base de données avec des clés étrangères. Cela signifie que nos données de chat — messages, canaux, adhésions — sont toujours cohérentes et interrogeables avec une sécurité de type complète.
Architecture du Système de Chat
Le système de chat ft_transcendence est construit sur une architecture à deux niveaux : les Messages Directs pour les conversations en tête-à-tête et les Canaux pour la communication de groupe. Chaque couche possède son propre namespace Socket.io dédié, service et gateway — maintenant les préoccupations séparées et évolutives.
+--------------------------------------------------------+
| FRONTEND |
| |
| +-----------+ +-----------+ +----------+ +-----------+ |
| | Chat UI | | Channel | | Member | | Friend | |
| | (1-on-1) | | Room | | List | | List | |
| +-----+-----+ +-----+-----+ +----+-----+ +-----+-----+ |
| | | | | |
| +-------------+------------+-------------+ |
| | |
| Socket.io Client (3 Namespaces) |
+----------------------------+---------------------------+
|
WebSocket
|
+----------------------------+---------------------------+
| NestJS Backend |
| | |
| +----------------------------------------------------+ |
| | Gateways Socket.io | |
| | | |
| | /appGateway - Amis, Invitations, Blocage | |
| | /chatGateway - Messages Directs (1-on-1) | |
| | /channelGateway - Canaux de Groupe, Modération | |
| +-----------------------+----------------------------+ |
| | |
| +-----------------------+----------------------------+ |
| | Services & Suivi des Clients | |
| | | |
| | ChatService ChannelService | |
| | UsersService ConnectedClientsService | |
| +-----------------------+----------------------------+ |
| | |
+----------------------------+---------------------------+
|
Prisma ORM
|
+-------------------+
| PostgreSQL |
| |
| - User |
| - DirectMessage |
| - Channel |
| - ChannelMember |
| - BlockedUser |
+-------------------+
Conception du Schéma de Base de Données
Le schéma sépare la messagerie directe de la communication basée sur les canaux. Les messages directs utilisent un ID de salle haché SHA-256 dérivé des deux IDs d'utilisateurs, tandis que les canaux supportent des types public, protégé (avec mot de passe) et privé avec une adhésion granulaire basée sur les rôles.
Modèles Principaux — Utilisateurs & Messages Directs
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 // Hash SHA-256 des IDs sender+receiver triés
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())
}
Modèles de Canal — Communication de Groupe
enum ChannelType {
PUBLIC // Ouvert à tous les utilisateurs
PROTECTED // Nécessite un mot de passe pour rejoindre
PRIVATE // Sur invitation uniquement via UUID
}
enum Role {
OWNER
ADMIN
MEMBER
MUTED_MEMBER // Temporairement silencieux
BANNED_MEMBER // Temporairement banni
MUTED_ADMIN // Admin temporairement silencieux
BANNED_ADMIN // Admin temporairement banni
}
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())
}
Implémentation Backend
Service de Clients Connectés
Un service centralisé suit tous les clients connectés à travers différentes gateways. Des maps séparées sont maintenues pour la gateway app et la gateway chat, permettant des notifications cross-namespace — par exemple, envoyer une notification via la gateway app lorsqu'un message arrive sur la gateway chat.
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);
}
}
Service de Messages Directs
Le service de chat gère la messagerie en tête-à-tête avec une stratégie intelligente d'ID de salle : les deux IDs d'utilisateurs sont triés et joints, puis hachés en SHA-256. Cela garantit que le même ID de salle est généré quelle que soit la personne qui initie la conversation — pas de salles en doublon, pas de recherches nécessaires.
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,
});
}
}
Gateway Chat — Messages Directs
La gateway chat opère sur le namespace /chatGateway. Lorsqu'un utilisateur rejoint une salle, la gateway hache les IDs sender+receiver, charge l'historique des messages et gère les abonnements aux salles Socket.io. L'envoi de messages inclut des vérifications des utilisateurs bloqués et des notifications cross-namespace.
@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);
// Initialisation : créer un message vide pour les nouvelles conversations
if (messages.length === 0) {
await this.prisma.directMessage.create({
data: {
content: '',
senderId: payload.senderId,
recieverId: payload.recieverId,
roomId: hasshedRoomName,
seen: true,
},
});
}
// Quitter toutes les salles précédentes (sauf la salle propre au socket)
Array.from(socket.rooms)
.filter((id) => id !== socket.id)
.forEach((id) => socket.leave(id));
// Rejoindre la salle hachée
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> {
// Vérifier si l'expéditeur est bloqué par le destinataire
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);
}
// Diffuser aux participants de la salle
this.server.to(hasshedRoomName).emit('getMessage', {
senderId: payload.senderId,
receiverId: payload.recieverId,
text: payload.content,
room: hasshedRoomName,
});
// Persister le message en base de données
await this.chatService.createChat(payload);
// Notifier le destinataire dans le namespace chat (rafraîchir sa liste de conversations)
for (const [key, val] of this.connectedClientsService.getAllClientsFromChat()) {
if (val === payload.recieverId) {
this.server.to(key).emit('refresh');
}
}
// Notification cross-namespace via la gateway app
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}`);
}
}
Système de Canaux — Communication de Groupe
Gateway de Canal
La gateway de canal gère le chat de groupe avec la prise en charge des canaux publics, des canaux protégés par mot de passe et des canaux privés (sur invitation uniquement). Elle gère le cycle de vie complet : création, adhésion, messagerie, gestion des rôles et suppression.
Création de Canal avec Protection par Mot de Passe
@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) {
// Hacher le mot de passe avec SHA-256 avant de le stocker
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');
}
}
Messagerie dans les Canaux
Une fois qu'un membre a rejoint un canal, l'envoi de messages est simple : s'assurer que le socket est dans la salle, persister le message en base de données, puis diffuser à tous les membres du canal.
@SubscribeMessage('messageChannel')
async handleMessage(
@MessageBody() payload: CreateChannelMessageDto,
@ConnectedSocket() socket: Socket,
): Promise<void> {
// S'assurer que l'expéditeur est dans la salle du canal
if (!socket.rooms.has(payload.channelId)) {
socket.join(payload.channelId);
}
// Persister le message en base de données
await this.channelService.createChannelMessage(payload);
// Diffuser à tous les membres du canal
this.server.to(payload.channelId).emit('onMessage', payload);
}
Rejoindre des Canaux avec Contrôle d'Accès
Rejoindre un canal implique plusieurs couches de validation : vérification de l'adhésion existante, vérification du statut d'expulsion, et pour les canaux protégés, validation du mot de passe haché.
@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) {
// Membre existant — vérifier le mot de passe pour les canaux PROTECTED
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 {
// Nouveau membre — vérifier s'il a été expulsé
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');
}
}
}
Système de Modération
Le système de modération utilise des permissions basées sur les rôles avec des bannissements et silençages temporisés. Un job @Cron s'exécute chaque seconde pour débannir ou désilencer automatiquement les membres lorsque leur punition expire — en préservant leur rôle d'origine (MEMBER ou ADMIN).
Machine à États des Rôles
Chaque membre de canal possède un rôle qui évolue selon les actions de modération. L'insight clé est que MEMBER et ADMIN sont traités comme des pistes parallèles — un admin silencieux/banni revient à ADMIN, et non MEMBER :
+--------------------------------------------------+ | TRANSITIONS DE RÔLES | +--------------------------------------------------+ | | | Piste MEMBER : | | +---------+ mute +---------------+ | | | MEMBER |----------->| MUTED_MEMBER | | | | |<-----------| (temporisé) | | | +---------+ unmute +---------------+ | | | | | | ban +---------------+ | | +---------------->| BANNED_MEMBER | | | |<----------------| (temporisé) | | | | unban +---------------+ | | | | Piste ADMIN : | | +---------+ mute +---------------+ | | | ADMIN |----------->| MUTED_ADMIN | | | | |<-----------| (temporisé) | | | +---------+ unmute +---------------+ | | | | | | ban +---------------+ | | +---------------->| BANNED_ADMIN | | | |<----------------| (temporisé) | | | | unban +---------------+ | | | | Cross-piste : | | MEMBER <--- promotion/rétrogradation ---> ADMIN | | OWNER (immuable — créateur du canal) | +--------------------------------------------------+
Bannissements Temporisés & Débannissement Automatique
// Bannir un membre — transitions MEMBER → BANNED_MEMBER ou 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 : débannissement automatique des membres expirés → restaure le rôle MEMBER
@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 : débannissement automatique des admins expirés → restaure le rôle ADMIN
@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 },
});
}
Silençages Temporisés & Désilençage Automatique
Le silençage suit le même pattern que le bannissement — le rôle du membre évolue vers MUTED_MEMBER ou MUTED_ADMIN, et un job cron restaure le rôle d'origine lorsque le silençage expire.
// Silencer un membre — transitions MEMBER → MUTED_MEMBER ou 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 : désilençage automatique des membres expirés → restaure le rôle MEMBER
@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 : désilençage automatique des admins expirés → restaure le rôle ADMIN
@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 },
});
}
Expulsion de Membres avec Redirection Cross-Namespace
@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);
// Enregistrer l'expulsion — empêche le retour
await this.channelService.createKickedMember(payload);
// Retirer de l'adhésion au canal
await this.channelService.removeChannelMember(
payload.channelId,
payload.userId,
);
// Notifier le canal
this.server
.to(payload.channelId)
.emit('onMessage', `${user.name} is kicked from channel: ${channel.channelName}`);
this.server.to(payload.channelId).emit('refrechMember');
// Forcer la redirection de l'utilisateur expulsé via son socket
for (const [key, val] of this.connectedClientsInChannel) {
if (val === payload.userId) {
this.server.to(key).emit('redirectAfterGetKicked');
}
}
}
}
Filtrage des Messages d'Utilisateurs Bloqués dans les Canaux
async findAllChannelNonBlockedMessages(
channelId: string,
senderId: string,
): Promise<ChannelMessage[]> {
// Récupérer la liste de blocage de l'utilisateur
const user = await this.prisma.user.findUnique({
where: { id: senderId },
include: { userThatBlock: {} },
});
const blockedUserIds = user.userThatBlock.map(
(blocked) => blocked.blockedUserId,
);
// Retourner les messages en excluant les utilisateurs bloqués
return this.prisma.channelMessage.findMany({
where: {
channelId: channelId,
userId: { notIn: blockedUserIds },
},
orderBy: { created_at: 'asc' },
});
}
Conclusion
Construire un système de chat prêt pour la production exige une séparation rigoureuse des préoccupations. L'architecture à deux niveaux (DM + Canaux) avec des namespaces Socket.io isolés est naturellement évolutive, tandis que le système de modération basé sur les rôles avec des punitions temporisées fournit le contrôle nécessaire à la gestion communautaire.
Points clés à retenir :
- IDs de salle déterministes — les paires d'IDs utilisateur hachées en SHA-256 éliminent les conversations en doublon sans aucune recherche
- Isolation des namespaces — des gateways séparées pour les DM, canaux et fonctionnalités sociales maintiennent les préoccupations propres
- Notifications cross-namespace — injectez des gateways pour envoyer des alertes à travers les namespaces Socket.io
- Machine à états des rôles — les transitions MEMBER ↔ MUTED/BANNED préservent les privilèges après expiration
- Modération basée sur les crons — débannissement/désilençage automatique avec restauration du rôle d'origine
- Filtrage côté serveur — les messages des utilisateurs bloqués filtrés au niveau de la requête, pas côté client
Cette architecture alimente la communication en temps réel pour une plateforme de jeu multijoueur, gérant des utilisateurs concurrents à travers les messages directs, les canaux de groupe et les invitations de jeu simultanément.
Explorer le Code
Consultez le dépôt ft_transcendence sur GitHub pour voir ces patterns dans une vraie plateforme de jeu multijoueur.
Écrit par

Tech Lead et Ingénieur Full Stack pilotant une équipe de 5 ingénieurs chez Fygurs (Paris, Remote) sur un SaaS cloud-native Azure. Diplômé de 1337 Coding School (42 Network / UM6P). Écrit sur l'architecture, l'infrastructure cloud et le leadership technique.