Skip to content

Commit 20e9d11

Browse files
committed
feat: Implement room duration management and notifications for chat rooms
1 parent 942c0da commit 20e9d11

7 files changed

Lines changed: 199 additions & 7 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { MigrationInterface, QueryRunner } from "typeorm";
2+
3+
export class AutoGenerated1766007704989 implements MigrationInterface {
4+
name = 'AutoGenerated1766007704989'
5+
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(`ALTER TABLE "rooms" ALTER COLUMN "ephemeral" SET DEFAULT true`);
8+
}
9+
10+
public async down(queryRunner: QueryRunner): Promise<void> {
11+
await queryRunner.query(`ALTER TABLE "rooms" ALTER COLUMN "ephemeral" SET DEFAULT false`);
12+
}
13+
14+
}

src/rooms/entities/room.entity.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export class Room {
2626
@Column({ default: true })
2727
isOpen: boolean;
2828

29-
@Column({ default: false })
29+
@Column({ default: true })
3030
ephemeral: boolean;
3131

3232
@Column({ type: 'int', default: 2592000 })

src/rooms/rooms.controller.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,15 @@ export class RoomsController {
340340
return false;
341341
}
342342

343+
@Get(':id/remaining-time')
344+
async getRemainingTime(@Param('id') id: string) {
345+
const remainingSeconds = await this.roomsService.getRoomRemainingTime(id);
346+
return {
347+
remainingSeconds,
348+
hasTimeLimit: remainingSeconds !== null,
349+
};
350+
}
351+
343352
private async claimUsernameAtomically(roomId: string, username: string): Promise<boolean> {
344353
const client = this.redisService.getClient();
345354
const key = `room:${roomId}:username:${username}`;

src/rooms/rooms.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { ParticipantsModule } from '../participants/participants.module';
99
import {RoomBan} from "../participants/entities/room-ban.entity";
1010
import {WebsocketModule} from "../websocket/websocket.module";
1111
import { RoomFingerprint } from './entities/room-fingerprint.entity';
12+
import { forwardRef } from '@nestjs/common';
1213

1314
@Module({
1415
imports: [
@@ -20,7 +21,7 @@ import { RoomFingerprint } from './entities/room-fingerprint.entity';
2021
AuthModule,
2122
RedisModule,
2223
ParticipantsModule,
23-
WebsocketModule
24+
forwardRef(() => WebsocketModule)
2425
],
2526
providers: [RoomsService],
2627
controllers: [RoomsController],

src/rooms/rooms.service.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,13 @@ import { SessionService } from '../auth/session.service';
99
import { ParticipantsService } from '../participants/participants.service';
1010
import { RoomBan } from '../participants/entities/room-ban.entity';
1111
import { RoomFingerprint } from './entities/room-fingerprint.entity';
12+
import { forwardRef, Inject } from '@nestjs/common';
1213

1314
@Injectable()
1415
export class RoomsService {
1516
private readonly logger = new Logger(RoomsService.name);
17+
private roomTimers = new Map<string, NodeJS.Timeout>();
18+
private chatGateway: any; // Will be set after module initialization to avoid circular dependency
1619

1720
constructor(
1821
@InjectRepository(Room) private readonly roomRepo: Repository<Room>,
@@ -22,6 +25,11 @@ export class RoomsService {
2225
@InjectRepository(RoomFingerprint) private readonly roomFingerprintRepo: Repository<RoomFingerprint>,
2326
) {}
2427

28+
// Setter to inject ChatGateway after initialization
29+
setChatGateway(gateway: any) {
30+
this.chatGateway = gateway;
31+
}
32+
2533
async verifyModeratorToken(roomId: string, moderatorToken: string) {
2634
const room = await this.roomRepo.findOne({ where: { id: roomId } });
2735
if (!room) {
@@ -200,6 +208,9 @@ export class RoomsService {
200208
throw new ForbiddenException('Invalid moderator token');
201209
}
202210

211+
// Cancel the duration timer if it exists
212+
this.cancelRoomDurationTimer(roomId);
213+
203214
// First, close the room so no new participants can join
204215
await this.roomRepo
205216
.createQueryBuilder()
@@ -273,4 +284,123 @@ export class RoomsService {
273284
.where("id = :id", { id: roomId })
274285
.execute();
275286
}
287+
288+
/**
289+
* Start a timer for a room based on its chatDurationSeconds.
290+
* When the timer expires, the room will be automatically closed and all sessions ended.
291+
*/
292+
async startRoomDurationTimer(roomId: string) {
293+
// Check if timer already exists
294+
if (this.roomTimers.has(roomId)) {
295+
this.logger.log(`Timer already exists for room ${roomId}`);
296+
return;
297+
}
298+
299+
const room = await this.roomRepo.findOne({ where: { id: roomId } });
300+
if (!room) {
301+
this.logger.warn(`Cannot start timer for non-existent room ${roomId}`);
302+
return;
303+
}
304+
305+
if (!room.chatDurationSeconds || room.chatDurationSeconds <= 0) {
306+
this.logger.log(`Room ${roomId} has no duration limit, skipping timer`);
307+
return;
308+
}
309+
310+
// Calculate remaining time
311+
const now = new Date();
312+
const elapsed = (now.getTime() - room.createdAt.getTime()) / 1000;
313+
const remaining = room.chatDurationSeconds - elapsed;
314+
315+
if (remaining <= 0) {
316+
this.logger.log(`Room ${roomId} duration already expired, ending immediately`);
317+
await this.endRoomByDuration(roomId);
318+
return;
319+
}
320+
321+
this.logger.log(`Starting ${remaining}s timer for room ${roomId}`);
322+
const timeout = setTimeout(() => {
323+
this.endRoomByDuration(roomId);
324+
}, remaining * 1000);
325+
326+
this.roomTimers.set(roomId, timeout);
327+
}
328+
329+
/**
330+
* End a room automatically when its chat duration is reached.
331+
*/
332+
async endRoomByDuration(roomId: string) {
333+
this.logger.log(`Room ${roomId} duration reached, ending all sessions`);
334+
335+
// Clear the timer
336+
const timer = this.roomTimers.get(roomId);
337+
if (timer) {
338+
clearTimeout(timer);
339+
this.roomTimers.delete(roomId);
340+
}
341+
342+
const room = await this.roomRepo.findOne({ where: { id: roomId } });
343+
if (!room) {
344+
this.logger.warn(`Room ${roomId} not found when trying to end by duration`);
345+
return;
346+
}
347+
348+
if (room.closedAt) {
349+
this.logger.log(`Room ${roomId} already closed`);
350+
return;
351+
}
352+
353+
// Close the room
354+
await this.roomRepo
355+
.createQueryBuilder()
356+
.update(Room)
357+
.set({ isOpen: false, closedAt: new Date() })
358+
.where("id = :id", { id: roomId })
359+
.execute();
360+
361+
this.logger.log(`Room ${roomId} closed due to duration limit, destroying sessions`);
362+
363+
// Notify all participants that the room has ended due to duration
364+
if (this.chatGateway) {
365+
this.chatGateway.emitRoomEnded(roomId, 'Chat duration time limit has been reached');
366+
}
367+
368+
// Destroy all sessions for the room
369+
await this.sessionService.destroyAllSessionsForRoom(roomId);
370+
}
371+
372+
/**
373+
* Cancel the duration timer for a room (e.g., if manually closed by moderator)
374+
*/
375+
cancelRoomDurationTimer(roomId: string) {
376+
const timer = this.roomTimers.get(roomId);
377+
if (timer) {
378+
clearTimeout(timer);
379+
this.roomTimers.delete(roomId);
380+
this.logger.log(`Cancelled duration timer for room ${roomId}`);
381+
}
382+
}
383+
384+
/**
385+
* Get remaining time in seconds for a room's chat duration.
386+
* Returns null if no duration limit or if already expired/closed.
387+
*/
388+
async getRoomRemainingTime(roomId: string): Promise<number | null> {
389+
const room = await this.roomRepo.findOne({ where: { id: roomId } });
390+
if (!room) return null;
391+
392+
if (!room.chatDurationSeconds || room.chatDurationSeconds <= 0) {
393+
return null; // No duration limit
394+
}
395+
396+
if (room.closedAt || !room.isOpen) {
397+
return 0; // Room is closed
398+
}
399+
400+
const now = new Date();
401+
const elapsed = (now.getTime() - room.createdAt.getTime()) / 1000;
402+
const remaining = room.chatDurationSeconds - elapsed;
403+
404+
return remaining > 0 ? remaining : 0;
405+
}
276406
}

src/websocket/chat.gateway.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
ConnectedSocket,
99
} from '@nestjs/websockets';
1010
import { Server, Socket } from 'socket.io';
11-
import { Logger } from '@nestjs/common';
11+
import { Logger, forwardRef, Inject } from '@nestjs/common';
1212
import { RedisService } from '../redis/redis.service';
1313
import { SessionService } from '../auth/session.service';
1414
import { MessagesService } from '../messages/messages.service';
@@ -24,13 +24,19 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
2424

2525
// Track active connections to prevent duplicate presence events
2626
private activeConnections = new Map<string, { socketId: string; timestamp: number }>();
27+
private roomsService: any; // Will be set after initialization
2728

2829
constructor(
2930
private readonly redisService: RedisService,
3031
private readonly sessionService: SessionService,
3132
private readonly messagesService: MessagesService,
3233
) {}
3334

35+
// Setter to inject RoomsService after initialization
36+
setRoomsService(roomsService: any) {
37+
this.roomsService = roomsService;
38+
}
39+
3440
afterInit(server: Server) {
3541
this.server = server;
3642
this.logger.log('WebSocket server initialized');
@@ -179,6 +185,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
179185
})
180186
);
181187

188+
// Start room duration timer when first participant joins
189+
const presenceCount = await redis.hlen(`presence:${roomId}`);
190+
if (presenceCount === 1 && this.roomsService) {
191+
this.logger.log(`First participant joined room ${roomId}, starting duration timer`);
192+
await this.roomsService.startRoomDurationTimer(roomId);
193+
}
194+
182195
// Only emit presence if this is a new connection
183196
this.server.to(`room_${roomId}`)
184197
.emit('presence', {

src/websocket/websocket.module.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,38 @@
1-
import { Module } from '@nestjs/common';
1+
import { Module, forwardRef, OnModuleInit } from '@nestjs/common';
22
import { ChatGateway } from './chat.gateway';
33
import { RedisModule } from '../redis/redis.module';
44
import { AuthModule } from '../auth/auth.module';
55
import { MessagesModule } from '../messages/messages.module';
6-
6+
import { ModuleRef } from '@nestjs/core';
77

88
@Module({
9-
imports: [RedisModule, AuthModule, MessagesModule],
9+
imports: [
10+
RedisModule,
11+
AuthModule,
12+
MessagesModule,
13+
],
1014
providers: [ChatGateway],
1115
exports: [ChatGateway],
1216
})
13-
export class WebsocketModule {}
17+
export class WebsocketModule implements OnModuleInit {
18+
constructor(
19+
private readonly moduleRef: ModuleRef,
20+
private readonly chatGateway: ChatGateway,
21+
) {}
22+
23+
async onModuleInit() {
24+
// Dynamically resolve RoomsService to avoid circular dependency at module level
25+
try {
26+
const { RoomsService } = await import('../rooms/rooms.service');
27+
const roomsService = this.moduleRef.get(RoomsService, { strict: false });
28+
29+
if (roomsService) {
30+
this.chatGateway.setRoomsService(roomsService);
31+
roomsService.setChatGateway(this.chatGateway);
32+
console.log('Successfully linked ChatGateway and RoomsService');
33+
}
34+
} catch (error) {
35+
console.error('Failed to link ChatGateway and RoomsService:', error);
36+
}
37+
}
38+
}

0 commit comments

Comments
 (0)