feat: implement messaging functionality with real-time updates
- Introduced a messaging module on the backend using NestJS, including repository, service, controller, DTOs, and WebSocket Gateway. - Developed a frontend messaging page with conversation management, real-time message handling, and chat UI. - Implemented `MessageService` for API integrations and `SocketProvider` for real-time WebSocket updates. - Enhanced database schema to support conversations, participants, and messages with Drizzle ORM.
This commit is contained in:
66
backend/src/database/schemas/messages.ts
Normal file
66
backend/src/database/schemas/messages.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
index,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
text,
|
||||
timestamp,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { users } from "./users";
|
||||
|
||||
export const conversations = pgTable("conversations", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
});
|
||||
|
||||
export const conversationParticipants = pgTable(
|
||||
"conversation_participants",
|
||||
{
|
||||
conversationId: uuid("conversation_id")
|
||||
.notNull()
|
||||
.references(() => conversations.id, { onDelete: "cascade" }),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.uuid, { onDelete: "cascade" }),
|
||||
joinedAt: timestamp("joined_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
pk: primaryKey({ columns: [t.conversationId, t.userId] }),
|
||||
}),
|
||||
);
|
||||
|
||||
export const messages = pgTable(
|
||||
"messages",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
conversationId: uuid("conversation_id")
|
||||
.notNull()
|
||||
.references(() => conversations.id, { onDelete: "cascade" }),
|
||||
senderId: uuid("sender_id")
|
||||
.notNull()
|
||||
.references(() => users.uuid, { onDelete: "cascade" }),
|
||||
text: text("text").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
readAt: timestamp("read_at", { withTimezone: true }),
|
||||
},
|
||||
(table) => ({
|
||||
conversationIdIdx: index("messages_conversation_id_idx").on(
|
||||
table.conversationId,
|
||||
),
|
||||
senderIdIdx: index("messages_sender_id_idx").on(table.senderId),
|
||||
}),
|
||||
);
|
||||
|
||||
export type ConversationInDb = typeof conversations.$inferSelect;
|
||||
export type NewConversationInDb = typeof conversations.$inferInsert;
|
||||
export type MessageInDb = typeof messages.$inferSelect;
|
||||
export type NewMessageInDb = typeof messages.$inferInsert;
|
||||
11
backend/src/messages/dto/create-message.dto.ts
Normal file
11
backend/src/messages/dto/create-message.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IsNotEmpty, IsString, IsUUID, MaxLength } from "class-validator";
|
||||
|
||||
export class CreateMessageDto {
|
||||
@IsUUID()
|
||||
recipientId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(2000)
|
||||
text!: string;
|
||||
}
|
||||
40
backend/src/messages/messages.controller.ts
Normal file
40
backend/src/messages/messages.controller.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import type { AuthenticatedRequest } from "../common/interfaces/request.interface";
|
||||
import { CreateMessageDto } from "./dto/create-message.dto";
|
||||
import { MessagesService } from "./messages.service";
|
||||
|
||||
@Controller("messages")
|
||||
@UseGuards(AuthGuard)
|
||||
export class MessagesController {
|
||||
constructor(private readonly messagesService: MessagesService) {}
|
||||
|
||||
@Get("conversations")
|
||||
getConversations(@Req() req: AuthenticatedRequest) {
|
||||
return this.messagesService.getConversations(req.user.sub);
|
||||
}
|
||||
|
||||
@Get("conversations/:id")
|
||||
getMessages(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param("id") conversationId: string,
|
||||
) {
|
||||
return this.messagesService.getMessages(req.user.sub, conversationId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
sendMessage(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Body() dto: CreateMessageDto,
|
||||
) {
|
||||
return this.messagesService.sendMessage(req.user.sub, dto);
|
||||
}
|
||||
}
|
||||
14
backend/src/messages/messages.module.ts
Normal file
14
backend/src/messages/messages.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { RealtimeModule } from "../realtime/realtime.module";
|
||||
import { MessagesController } from "./messages.controller";
|
||||
import { MessagesService } from "./messages.service";
|
||||
import { MessagesRepository } from "./repositories/messages.repository";
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, RealtimeModule],
|
||||
controllers: [MessagesController],
|
||||
providers: [MessagesService, MessagesRepository],
|
||||
exports: [MessagesService],
|
||||
})
|
||||
export class MessagesModule {}
|
||||
60
backend/src/messages/messages.service.ts
Normal file
60
backend/src/messages/messages.service.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { EventsGateway } from "../realtime/events.gateway";
|
||||
import type { CreateMessageDto } from "./dto/create-message.dto";
|
||||
import { MessagesRepository } from "./repositories/messages.repository";
|
||||
|
||||
@Injectable()
|
||||
export class MessagesService {
|
||||
constructor(
|
||||
private readonly messagesRepository: MessagesRepository,
|
||||
private readonly eventsGateway: EventsGateway,
|
||||
) {}
|
||||
|
||||
async sendMessage(senderId: string, dto: CreateMessageDto) {
|
||||
let conversation = await this.messagesRepository.findConversationBetweenUsers(
|
||||
senderId,
|
||||
dto.recipientId,
|
||||
);
|
||||
|
||||
if (!conversation) {
|
||||
const newConv = await this.messagesRepository.createConversation();
|
||||
await this.messagesRepository.addParticipant(newConv.id, senderId);
|
||||
await this.messagesRepository.addParticipant(newConv.id, dto.recipientId);
|
||||
conversation = newConv;
|
||||
}
|
||||
|
||||
const message = await this.messagesRepository.createMessage({
|
||||
conversationId: conversation.id,
|
||||
senderId,
|
||||
text: dto.text,
|
||||
});
|
||||
|
||||
// Notify recipient via WebSocket
|
||||
this.eventsGateway.sendToUser(dto.recipientId, "new_message", {
|
||||
conversationId: conversation.id,
|
||||
message,
|
||||
});
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
async getConversations(userId: string) {
|
||||
return this.messagesRepository.findAllConversations(userId);
|
||||
}
|
||||
|
||||
async getMessages(userId: string, conversationId: string) {
|
||||
const isParticipant = await this.messagesRepository.isParticipant(
|
||||
conversationId,
|
||||
userId,
|
||||
);
|
||||
if (!isParticipant) {
|
||||
throw new ForbiddenException("You are not part of this conversation");
|
||||
}
|
||||
|
||||
return this.messagesRepository.findMessagesByConversationId(conversationId);
|
||||
}
|
||||
}
|
||||
141
backend/src/messages/repositories/messages.repository.ts
Normal file
141
backend/src/messages/repositories/messages.repository.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { DatabaseService } from "../../database/database.service";
|
||||
import {
|
||||
conversationParticipants,
|
||||
conversations,
|
||||
messages,
|
||||
users,
|
||||
} from "../../database/schemas";
|
||||
|
||||
@Injectable()
|
||||
export class MessagesRepository {
|
||||
constructor(private readonly databaseService: DatabaseService) {}
|
||||
|
||||
async findConversationBetweenUsers(userId1: string, userId2: string) {
|
||||
const results = await this.databaseService.db
|
||||
.select({ id: conversations.id })
|
||||
.from(conversations)
|
||||
.innerJoin(
|
||||
conversationParticipants,
|
||||
eq(conversations.id, conversationParticipants.conversationId),
|
||||
)
|
||||
.where(
|
||||
inArray(conversationParticipants.userId, [userId1, userId2])
|
||||
)
|
||||
.groupBy(conversations.id)
|
||||
.having(sql`count(${conversations.id}) = 2`);
|
||||
|
||||
return results[0];
|
||||
}
|
||||
|
||||
async createConversation() {
|
||||
const [conv] = await this.databaseService.db
|
||||
.insert(conversations)
|
||||
.values({})
|
||||
.returning();
|
||||
return conv;
|
||||
}
|
||||
|
||||
async addParticipant(conversationId: string, userId: string) {
|
||||
await this.databaseService.db
|
||||
.insert(conversationParticipants)
|
||||
.values({ conversationId, userId });
|
||||
}
|
||||
|
||||
async createMessage(data: {
|
||||
conversationId: string;
|
||||
senderId: string;
|
||||
text: string;
|
||||
}) {
|
||||
const [msg] = await this.databaseService.db
|
||||
.insert(messages)
|
||||
.values(data)
|
||||
.returning();
|
||||
|
||||
// Update conversation updatedAt
|
||||
await this.databaseService.db
|
||||
.update(conversations)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(conversations.id, data.conversationId));
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
async findAllConversations(userId: string) {
|
||||
// Sous-requête pour trouver les IDs des conversations de l'utilisateur
|
||||
const userConvs = this.databaseService.db
|
||||
.select({ id: conversationParticipants.conversationId })
|
||||
.from(conversationParticipants)
|
||||
.where(eq(conversationParticipants.userId, userId));
|
||||
|
||||
return this.databaseService.db
|
||||
.select({
|
||||
id: conversations.id,
|
||||
updatedAt: conversations.updatedAt,
|
||||
lastMessage: {
|
||||
text: messages.text,
|
||||
createdAt: messages.createdAt,
|
||||
},
|
||||
recipient: {
|
||||
uuid: users.uuid,
|
||||
username: users.username,
|
||||
displayName: users.displayName,
|
||||
avatarUrl: users.avatarUrl,
|
||||
},
|
||||
})
|
||||
.from(conversations)
|
||||
.innerJoin(
|
||||
conversationParticipants,
|
||||
eq(conversations.id, conversationParticipants.conversationId),
|
||||
)
|
||||
.innerJoin(users, eq(conversationParticipants.userId, users.uuid))
|
||||
.leftJoin(
|
||||
messages,
|
||||
eq(conversations.id, messages.conversationId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(conversations.id, userConvs),
|
||||
eq(conversationParticipants.userId, users.uuid),
|
||||
sql`${users.uuid} != ${userId}`
|
||||
)
|
||||
)
|
||||
.orderBy(desc(conversations.updatedAt));
|
||||
}
|
||||
|
||||
async findMessagesByConversationId(conversationId: string, limit = 50) {
|
||||
return this.databaseService.db
|
||||
.select({
|
||||
id: messages.id,
|
||||
text: messages.text,
|
||||
createdAt: messages.createdAt,
|
||||
senderId: messages.senderId,
|
||||
readAt: messages.readAt,
|
||||
})
|
||||
.from(messages)
|
||||
.where(eq(messages.conversationId, conversationId))
|
||||
.orderBy(desc(messages.createdAt))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
async isParticipant(conversationId: string, userId: string) {
|
||||
const [participant] = await this.databaseService.db
|
||||
.select()
|
||||
.from(conversationParticipants)
|
||||
.where(
|
||||
and(
|
||||
eq(conversationParticipants.conversationId, conversationId),
|
||||
eq(conversationParticipants.userId, userId),
|
||||
),
|
||||
);
|
||||
return !!participant;
|
||||
}
|
||||
|
||||
async getParticipants(conversationId: string) {
|
||||
return this.databaseService.db
|
||||
.select({ userId: conversationParticipants.userId })
|
||||
.from(conversationParticipants)
|
||||
.where(eq(conversationParticipants.conversationId, conversationId));
|
||||
}
|
||||
}
|
||||
82
backend/src/realtime/events.gateway.ts
Normal file
82
backend/src/realtime/events.gateway.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import {
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
OnGatewayInit,
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
} from "@nestjs/websockets";
|
||||
import { getIronSession } from "iron-session";
|
||||
import { Server, Socket } from "socket.io";
|
||||
import { getSessionOptions, SessionData } from "../auth/session.config";
|
||||
import { JwtService } from "../crypto/services/jwt.service";
|
||||
|
||||
@WebSocketGateway({
|
||||
cors: {
|
||||
origin: "*",
|
||||
credentials: true,
|
||||
},
|
||||
})
|
||||
export class EventsGateway
|
||||
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
|
||||
{
|
||||
@WebSocketServer()
|
||||
server!: Server;
|
||||
|
||||
private readonly logger = new Logger(EventsGateway.name);
|
||||
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
afterInit(_server: Server) {
|
||||
this.logger.log("WebSocket Gateway initialized");
|
||||
}
|
||||
|
||||
async handleConnection(client: Socket) {
|
||||
try {
|
||||
// Simuler un objet Request/Response pour iron-session
|
||||
const req: any = {
|
||||
headers: client.handshake.headers,
|
||||
};
|
||||
const res: any = {
|
||||
setHeader: () => {},
|
||||
getHeader: () => {},
|
||||
};
|
||||
|
||||
const session = await getIronSession<SessionData>(
|
||||
req,
|
||||
res,
|
||||
getSessionOptions(this.configService.get("SESSION_PASSWORD") as string),
|
||||
);
|
||||
|
||||
if (!session.accessToken) {
|
||||
this.logger.warn(`Client ${client.id} unauthorized connection`);
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await this.jwtService.verifyJwt(session.accessToken);
|
||||
client.data.user = payload;
|
||||
|
||||
// Rejoindre une room personnelle pour les notifications
|
||||
client.join(`user:${payload.sub}`);
|
||||
|
||||
this.logger.log(`Client connected: ${client.id} (User: ${payload.sub})`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Connection error for client ${client.id}: ${error}`);
|
||||
client.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
handleDisconnect(client: Socket) {
|
||||
this.logger.log(`Client disconnected: ${client.id}`);
|
||||
}
|
||||
|
||||
// Méthode utilitaire pour envoyer des messages à un utilisateur spécifique
|
||||
sendToUser(userId: string, event: string, data: any) {
|
||||
this.server.to(`user:${userId}`).emit(event, data);
|
||||
}
|
||||
}
|
||||
10
backend/src/realtime/realtime.module.ts
Normal file
10
backend/src/realtime/realtime.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { CryptoModule } from "../crypto/crypto.module";
|
||||
import { EventsGateway } from "./events.gateway";
|
||||
|
||||
@Module({
|
||||
imports: [CryptoModule],
|
||||
providers: [EventsGateway],
|
||||
exports: [EventsGateway],
|
||||
})
|
||||
export class RealtimeModule {}
|
||||
Reference in New Issue
Block a user