Skip to content

Commit 862e7fd

Browse files
authored
[PB-5187] feat: remove limits and expire rooms after 30 days (#45)
* feat: remove limits and expire rooms after 30 days * fix(remove-limits): fix unit tests * feat: add tests for removing limits * refactor: improve tests and webhook service * feat: remove call if expired * chore: add unit tests * chore: added jitsi webhook event fixtures
1 parent 7566d69 commit 862e7fd

13 files changed

Lines changed: 328 additions & 88 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
'use strict';
2+
3+
/** @type {import('sequelize-cli').Migration} */
4+
module.exports = {
5+
async up(queryInterface, Sequelize) {
6+
await queryInterface.addColumn('rooms', 'remove_at', {
7+
type: Sequelize.DATE,
8+
allowNull: true,
9+
});
10+
},
11+
12+
async down(queryInterface, Sequelize) {
13+
await queryInterface.removeColumn('rooms', 'remove_at');
14+
},
15+
};

src/modules/call/call.controller.spec.ts

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -63,16 +63,6 @@ describe('Testing Call Endpoints', () => {
6363
});
6464

6565
describe('Creating a call', () => {
66-
it('When the user id is not provided (uuid), then an error indicating so is thrown', async () => {
67-
await expect(
68-
callController.createCall(
69-
createMockUserToken({
70-
payload: { ...createMockUserToken().payload, uuid: undefined },
71-
}).payload,
72-
),
73-
).rejects.toThrow(BadRequestException);
74-
});
75-
7666
it('When the user id exists and has meet enabled, then should create the call and the room', async () => {
7767
const mockUserToken = createMockUserToken();
7868
const mockResponse = {
@@ -267,14 +257,6 @@ describe('Testing Call Endpoints', () => {
267257
});
268258
expect(result).toEqual(mockJoinCallResponse);
269259
});
270-
271-
it('When joining a call with invalid room name (not UUID), then it should throw', async () => {
272-
callUseCase.joinCall.mockResolvedValue(mockJoinCallResponse);
273-
274-
await expect(
275-
callController.joinCall('invalid room name', null, mockJoinCallDto),
276-
).rejects.toThrow(BadRequestException);
277-
});
278260
});
279261

280262
describe('Getting users in a call', () => {

src/modules/call/call.controller.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import { CallUseCase } from './call.usecase';
3333
import { CreateCallResponseDto } from './dto/create-call.dto';
3434
import { JoinCallDto, JoinCallResponseDto } from './dto/join-call.dto';
3535
import { LeaveCallDto } from './dto/leave-call.dto';
36-
import { isUUID } from 'class-validator';
36+
import { ValidateUUIDPipe } from '../../common/pipes/validate-uuid.pipe';
3737

3838
@ApiTags('Call')
3939
@Controller('call')
@@ -115,14 +115,10 @@ export class CallController {
115115
@ApiConflictResponse({ description: 'User is already in this room' })
116116
@ApiInternalServerErrorResponse({ description: 'Internal server error' })
117117
async joinCall(
118-
@Param('id') roomId: string,
118+
@Param('id', ValidateUUIDPipe) roomId: string,
119119
@User() user: UserTokenData['payload'],
120120
@Body() joinCallDto?: JoinCallDto,
121121
): Promise<JoinCallResponseDto> {
122-
if (!isUUID(roomId)) {
123-
throw new BadRequestException('Room id is not valid');
124-
}
125-
126122
const { uuid, email } = user || {};
127123
const isUserAnonymous =
128124
!user || !!joinCallDto?.anonymousId || joinCallDto?.anonymous === true;

src/modules/call/call.usecase.spec.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
BadRequestException,
44
ConflictException,
55
ForbiddenException,
6+
GoneException,
67
NotFoundException,
78
} from '@nestjs/common';
89
import { ConfigService } from '@nestjs/config';
@@ -14,6 +15,7 @@ import { CallService } from './services/call.service';
1415
import { CallUseCase } from './call.usecase';
1516
import { mockRoomData, mockUserPayload } from './fixtures';
1617
import { v4 } from 'uuid';
18+
import { Time } from '../../common/time';
1719

1820
const autoGeneratedUUID = 'generated-uuid';
1921

@@ -124,6 +126,10 @@ describe('CallUseCase', () => {
124126
anonymous: true,
125127
});
126128

129+
afterEach(() => {
130+
jest.useRealTimers();
131+
});
132+
127133
it('When joining call with registered user data, then it should join successfully with current user data', async () => {
128134
const userData = {
129135
userId,
@@ -280,6 +286,62 @@ describe('CallUseCase', () => {
280286
expect(roomService.getRoomByRoomId).toHaveBeenCalledWith(roomId);
281287
});
282288

289+
it('When room is expired, then it should remove the room and throw', async () => {
290+
const userData = { userId };
291+
const pastDate = Time.now('2024-01-01');
292+
const currentDate = Time.now('2024-01-02');
293+
const expiredRoomMock = {
294+
...roomMock,
295+
removeAt: pastDate,
296+
};
297+
jest.useFakeTimers();
298+
jest.setSystemTime(currentDate);
299+
300+
roomService.getRoomByRoomId.mockResolvedValueOnce(expiredRoomMock);
301+
roomService.removeRoom.mockResolvedValueOnce(undefined);
302+
303+
await expect(callUseCase.joinCall(roomId, userData)).rejects.toThrow(
304+
GoneException,
305+
);
306+
expect(roomService.getRoomByRoomId).toHaveBeenCalledWith(roomId);
307+
expect(roomService.removeRoom).toHaveBeenCalledWith(expiredRoomMock.id);
308+
});
309+
310+
it('When room has removeAt in the future, then it should allow joining', async () => {
311+
const userData = {
312+
userId,
313+
name: userName,
314+
lastName: userLastName,
315+
};
316+
const futureDate = new Date('2024-01-03');
317+
const currentDate = new Date('2024-01-02');
318+
const nonExpiredRoomMock = {
319+
...roomMock,
320+
removeAt: futureDate,
321+
};
322+
jest.useFakeTimers();
323+
jest.setSystemTime(currentDate);
324+
roomService.getRoomByRoomId.mockResolvedValueOnce(nonExpiredRoomMock);
325+
roomService.getUserInRoom.mockResolvedValueOnce(null);
326+
roomService.countUsersInRoom.mockResolvedValueOnce(0);
327+
roomService.handleUserJoined.mockResolvedValueOnce({
328+
roomUser: roomUserMock,
329+
oldParticipantId: undefined,
330+
});
331+
callService.generateJitsiJWT.mockReturnValueOnce('test-jwt-token');
332+
333+
const result = await callUseCase.joinCall(roomId, userData);
334+
335+
expect(roomService.getRoomByRoomId).toHaveBeenCalledWith(roomId);
336+
expect(roomService.removeRoom).not.toHaveBeenCalled();
337+
expect(result).toEqual({
338+
token: 'test-jwt-token',
339+
room: roomId,
340+
userId,
341+
appId: 'jitsi-app-id',
342+
});
343+
});
344+
283345
it('When non-owner tries to join closed room, then it should throw', async () => {
284346
const userData = { userId };
285347
const closedRoomMock = {

src/modules/call/call.usecase.ts

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
BadRequestException,
33
ConflictException,
44
ForbiddenException,
5+
GoneException,
56
Injectable,
67
Logger,
78
NotFoundException,
@@ -15,6 +16,7 @@ import { CallService } from './services/call.service';
1516
import { CreateCallResponseDto } from './dto/create-call.dto';
1617
import { JoinCallResponseDto } from './dto/join-call.dto';
1718
import { ConfigService } from '@nestjs/config';
19+
import { Time } from '../../common/time';
1820

1921
@Injectable()
2022
export class CallUseCase {
@@ -29,19 +31,6 @@ export class CallUseCase {
2931
async createCallAndRoom(
3032
user: User | UserTokenData['payload'],
3133
): Promise<CreateCallResponseDto> {
32-
const activeRoom = await this.roomService.getOpenRoomByHostId(user.uuid);
33-
34-
// TODO: Remove this check and look for a better way to handle this
35-
if (activeRoom) {
36-
this.logger.warn(
37-
{ userId: user.uuid, roomId: activeRoom.id },
38-
`User already has an active room as host, closing previous room`,
39-
);
40-
await this.roomService.removeRoom(activeRoom.id);
41-
}
42-
43-
await this.validateUserHasNoActiveRoom(user.uuid, user.email);
44-
4534
const call = await this.callService.createCall(user);
4635

4736
const newRoom = new Room({
@@ -85,6 +74,11 @@ export class CallUseCase {
8574
throw new NotFoundException(`Specified room not found`);
8675
}
8776

77+
if (room.removeAt && Time.isBefore(room.removeAt, Time.now())) {
78+
await this.roomService.removeRoom(room.id);
79+
throw new GoneException('Room is expired');
80+
}
81+
8882
const joiningUserData = {
8983
userId: userData?.anonymous
9084
? (userData?.anonymousId ?? v4())
@@ -131,7 +125,7 @@ export class CallUseCase {
131125
await this.callService.kickParticipant(roomId, oldParticipantId);
132126
}
133127

134-
const tokenData = this.callService.generateJitsiJWT(
128+
const callTokenData = this.callService.generateJitsiJWT(
135129
{
136130
id: joiningUserData.userId,
137131
userRoomId: roomUser.id,
@@ -151,7 +145,7 @@ export class CallUseCase {
151145
}
152146

153147
return {
154-
token: tokenData,
148+
token: callTokenData,
155149
room: roomId,
156150
userId: roomUser.userId,
157151
appId: this.configService.get<string>('jitsi.appId'),

src/modules/call/domain/room.domain.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
const ROOM_EXPIRATION_DAYS = 30;
12
export interface RoomAttributes {
23
id: string;
34
maxUsersAllowed: number;
45
hostId: string;
56
isClosed?: boolean;
7+
removeAt?: Date;
68
createdAt?: Date;
79
updatedAt?: Date;
810
}
@@ -12,6 +14,7 @@ export class Room implements RoomAttributes {
1214
maxUsersAllowed: number;
1315
hostId: string;
1416
isClosed?: boolean;
17+
removeAt?: Date;
1518
createdAt?: Date;
1619
updatedAt?: Date;
1720

@@ -25,17 +28,22 @@ export class Room implements RoomAttributes {
2528
maxUsersAllowed: this.maxUsersAllowed,
2629
hostId: this.hostId,
2730
isClosed: this.isClosed,
31+
removeAt: this.removeAt,
2832
createdAt: this.createdAt,
2933
updatedAt: this.updatedAt,
3034
};
3135
}
36+
static getRoomExpirationDays() {
37+
return ROOM_EXPIRATION_DAYS;
38+
}
3239

3340
static build(attributes: RoomAttributes): Room {
3441
return new Room({
3542
id: attributes.id,
3643
maxUsersAllowed: attributes.maxUsersAllowed,
3744
hostId: attributes.hostId,
3845
isClosed: attributes.isClosed,
46+
removeAt: attributes.removeAt,
3947
createdAt: attributes.createdAt,
4048
updatedAt: attributes.updatedAt,
4149
});

src/modules/call/fixtures.ts

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ import { CreateCallResponseDto } from './dto/create-call.dto';
55
import { RoomUser, RoomUserAttributes } from './domain/room-user.domain';
66
import { User } from '../../shared/user/user.domain';
77
import { v4 } from 'uuid';
8+
import {
9+
JitsiParticipantJoinedWebHookPayload,
10+
JitsiGenericWebHookEvent,
11+
} from './webhooks/jitsi/interfaces/JitsiGenericWebHookPayload';
12+
import { JitsiParticipantLeftWebHookPayload } from './webhooks/jitsi/interfaces/JitsiParticipantLeftData';
813

914
const randomDataGenerator = new Chance();
1015

@@ -40,13 +45,22 @@ export const mockRoomData = {
4045
isClosed: false,
4146
createdAt: randomDataGenerator.date(),
4247
updatedAt: randomDataGenerator.date(),
48+
removeAt: undefined,
4349
};
4450

45-
export const createMockRoom = (overrides?: Partial<Room>): Room => ({
46-
...mockRoomData,
47-
...overrides,
48-
toJSON: () => mockRoomData,
49-
});
51+
export const createMockRoom = (attributes?: Partial<Room>): Room => {
52+
const mockRoom = new Room({
53+
id: randomDataGenerator.guid(),
54+
hostId: randomDataGenerator.guid(),
55+
maxUsersAllowed: randomDataGenerator.integer({ min: 2, max: 10 }),
56+
isClosed: false,
57+
createdAt: randomDataGenerator.date(),
58+
updatedAt: randomDataGenerator.date(),
59+
removeAt: undefined,
60+
...attributes,
61+
});
62+
return mockRoom;
63+
};
5064

5165
export const mockCallResponse: CreateCallResponseDto = {
5266
room: mockRoomData.id,
@@ -99,3 +113,44 @@ export const createMockUser = (overrides?: Partial<User>): User => {
99113
...overrides,
100114
});
101115
};
116+
117+
export const createMockJitsiWebhookEvent = ({
118+
overrides,
119+
participantId,
120+
roomUserId,
121+
roomId,
122+
appId,
123+
eventType,
124+
}: {
125+
overrides?: Partial<
126+
JitsiParticipantJoinedWebHookPayload | JitsiParticipantLeftWebHookPayload
127+
>;
128+
participantId?: string;
129+
roomUserId?: string;
130+
roomId?: string;
131+
appId?: string;
132+
eventType: JitsiGenericWebHookEvent;
133+
}): JitsiParticipantJoinedWebHookPayload => {
134+
const mockParticipantId = participantId ?? randomDataGenerator.guid();
135+
const mockRoomUserId = roomUserId ?? v4();
136+
const mockAppId = appId ?? randomDataGenerator.word();
137+
const mockRoomId = roomId ?? randomDataGenerator.guid();
138+
139+
return {
140+
idempotencyKey: v4(),
141+
customerId: randomDataGenerator.guid(),
142+
appId: mockAppId,
143+
eventType,
144+
sessionId: v4(),
145+
timestamp: Date.now(),
146+
fqn: `${mockAppId}/${mockRoomId}`,
147+
data: {
148+
moderator: false,
149+
name: randomDataGenerator.name(),
150+
id: `${mockParticipantId}/${mockRoomUserId}`,
151+
participantJid: randomDataGenerator.guid(),
152+
participantId: mockParticipantId,
153+
},
154+
...overrides,
155+
};
156+
};

src/modules/call/infrastructure/room-user.repository.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@ describe('SequelizeRoomUserRepository', () => {
5757

5858
const result = await repository.create(roomUserCreateData);
5959

60-
expect(createRoomUserSpy).toHaveBeenCalledWith(roomUserCreateData, { transaction: undefined });
60+
expect(createRoomUserSpy).toHaveBeenCalledWith(roomUserCreateData, {
61+
transaction: undefined,
62+
});
6163
expect(result).toBeInstanceOf(RoomUser);
6264
expect(result.id).toEqual(mockRoomUserData.id);
6365
expect(result.roomId).toEqual(mockRoomUserData.roomId);

src/modules/call/infrastructure/room.repository.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { InjectModel } from '@nestjs/sequelize';
33
import { OmitCreateProperties } from '../../../shared/types/OmitCreateProperties';
44
import { Room, RoomAttributes } from '../domain/room.domain';
55
import { RoomModel } from '../models/room.model';
6+
import { Transaction } from 'sequelize';
67

78
@Injectable()
89
export class SequelizeRoomRepository {
@@ -38,6 +39,14 @@ export class SequelizeRoomRepository {
3839
await this.roomModel.update(data, { where: { id } });
3940
}
4041

42+
async updateWhere(
43+
where: Partial<RoomAttributes>,
44+
data: Partial<RoomAttributes>,
45+
t?: Transaction,
46+
): Promise<void> {
47+
await this.roomModel.update(data, { where, transaction: t });
48+
}
49+
4150
async delete(id: RoomAttributes['id']): Promise<void> {
4251
await this.roomModel.destroy({ where: { id } });
4352
}

0 commit comments

Comments
 (0)