Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"@nestjs/swagger": "^11.1.0",
"agentkeepalive": "^4.6.0",
"axios": "^1.8.4",
"body-parser": "^2.2.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"cross-env": "^7.0.3",
Expand Down
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { SequelizeModule, SequelizeModuleOptions } from '@nestjs/sequelize';
import { format } from 'sql-formatter';
import { ReplicationOptions } from 'sequelize';
import { UserModule } from './modules/user/user.module';
import { WebhookModule } from './modules/webhook/webhook.module';

const defaultDbConfig = (
configService: ConfigService,
Expand Down Expand Up @@ -79,6 +80,7 @@ const defaultDbConfig = (
}),
CallModule,
UserModule,
WebhookModule,
],
controllers: [],
})
Expand Down
7 changes: 7 additions & 0 deletions src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ export default () => ({
appId: process.env.JITSI_APP_ID,
apiKey: process.env.JITSI_API_KEY,
},
jitsiWebhook: {
secret: process.env.JITSI_WEBHOOK_SECRET,
events: {
participantLeft:
process.env.JITSI_WEBHOOK_PARTICIPANT_LEFT_ENABLED === 'true',
},
},
database: {
host: process.env.DB_HOSTNAME,
host2: process.env.DB_HOSTNAME2,
Expand Down
3 changes: 1 addition & 2 deletions src/modules/call/call.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,7 @@ describe('Testing Call Endpoints', () => {
mockUserToken.payload.email,
);
expect(callUseCase.createCallAndRoom).toHaveBeenCalledWith(
mockUserToken.payload.uuid,
mockUserToken.payload.email,
mockUserToken.payload,
);
expect(result).toEqual(mockResponse);
});
Expand Down
2 changes: 1 addition & 1 deletion src/modules/call/call.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export class CallController {

try {
await this.callUseCase.validateUserHasNoActiveRoom(uuid, email);
const call = await this.callUseCase.createCallAndRoom(uuid, email);
const call = await this.callUseCase.createCallAndRoom(user);
return call;
} catch (error) {
const err = error as Error;
Expand Down
15 changes: 10 additions & 5 deletions src/modules/call/call.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Test } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import configuration from '../../config/configuration';
import { createMock, DeepMocked } from '@golevelup/ts-jest';
import { mockUserPayload } from './fixtures';

jest.mock('uuid');
jest.mock('jsonwebtoken');
Expand Down Expand Up @@ -39,6 +40,7 @@ describe('Call service', () => {
});

it('When the user has meet enabled, then a call token should be created', async () => {
const userPayload = mockUserPayload;
const getUserTierSpy = jest
.spyOn(paymentService, 'getUserTier')
.mockResolvedValue({
Expand All @@ -53,18 +55,19 @@ describe('Call service', () => {
(uuid.v4 as jest.Mock).mockReturnValue('test-room-id');
(jwt.sign as jest.Mock).mockReturnValue('test-jitsi-token');

const result = await callService.createCallToken('user-123');
const result = await callService.createCallToken(userPayload);

expect(result).toEqual({
token: 'test-jitsi-token',
room: 'test-room-id',
paxPerCall: 10,
});

expect(getUserTierSpy).toHaveBeenCalledWith('user-123');
expect(getUserTierSpy).toHaveBeenCalledWith(userPayload.uuid);
});

it('When the user does not have meet enabled, then an error indicating so is thrown', async () => {
const userPayload = mockUserPayload;
const getUserTierSpy = jest
.spyOn(paymentService, 'getUserTier')
.mockResolvedValue({
Expand All @@ -76,16 +79,17 @@ describe('Call service', () => {
},
} as Tier);

await expect(callService.createCallToken('user-123')).rejects.toThrow(
await expect(callService.createCallToken(userPayload)).rejects.toThrow(
UnauthorizedException,
);

expect(getUserTierSpy).toHaveBeenCalledWith('user-123');
expect(getUserTierSpy).toHaveBeenCalledWith(userPayload.uuid);
});

describe('createCallTokenForParticipant', () => {
it('should create a token for a registered user', () => {
const userId = 'test-user-id';
const userPayload = mockUserPayload;
const userId = userPayload.uuid;
const roomId = 'test-room-id';
const isAnonymous = false;
const expectedToken = 'test-participant-token';
Expand All @@ -96,6 +100,7 @@ describe('Call service', () => {
userId,
roomId,
isAnonymous,
userPayload,
);

expect(result).toBe(expectedToken);
Expand Down
17 changes: 10 additions & 7 deletions src/modules/call/call.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
getJitsiJWTPayload,
getJitsiJWTSecret,
} from '../../lib/jitsi';
import { User } from '../user/user.domain';
import { UserTokenData } from '../auth/dto/user.dto';

export function SignWithRS256AndHeader(
payload: object,
Expand Down Expand Up @@ -48,8 +50,8 @@ export class CallService {
return meetFeature;
}

async createCallToken(userUuid: string) {
const meetFeatures = await this.getMeetFeatureConfigForUser(userUuid);
async createCallToken(user: User | UserTokenData['payload']) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious, what is the difference between User and UserTokenData['payload]? Is it the same? Or User is for authenticated users and UserTokenData['payload'] is for anonymous users?

const meetFeatures = await this.getMeetFeatureConfigForUser(user.uuid);

if (!meetFeatures.enabled)
throw new UnauthorizedException(
Expand All @@ -59,9 +61,9 @@ export class CallService {
const newRoom = v4();
const token = generateJitsiJWT(
{
id: userUuid,
email: 'example@inxt.com',
name: 'Example',
id: user.uuid,
email: user.email,
name: `${user.name} ${user.lastname}`,
},
newRoom,
true,
Expand All @@ -74,12 +76,13 @@ export class CallService {
userId: string,
roomId: string,
isAnonymous: boolean,
user?: User | UserTokenData['payload'],
) {
const token = generateJitsiJWT(
{
id: userId,
email: isAnonymous ? 'anonymous@inxt.com' : 'user@inxt.com',
name: isAnonymous ? 'Anonymous' : 'User',
email: isAnonymous ? 'anonymous@inxt.com' : user.email,
name: isAnonymous ? 'Anonymous' : `${user.name} ${user.lastname}`,
},
roomId,
false,
Expand Down
14 changes: 4 additions & 10 deletions src/modules/call/call.usecase.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,9 @@ describe('CallUseCase', () => {
.spyOn(callUseCase, 'createRoomForCall')
.mockResolvedValueOnce();

const result = await callUseCase.createCallAndRoom(
mockUserPayload.uuid,
mockUserPayload.email,
);
const result = await callUseCase.createCallAndRoom(mockUserPayload);

expect(createCallTokenSpy).toHaveBeenCalledWith(mockUserPayload.uuid);
expect(createCallTokenSpy).toHaveBeenCalledWith(mockUserPayload);
expect(createRoomForCallSpy).toHaveBeenCalledWith(
mockCallResponse,
mockUserPayload.uuid,
Expand All @@ -123,13 +120,10 @@ describe('CallUseCase', () => {
.mockRejectedValueOnce(error);

await expect(
callUseCase.createCallAndRoom(
mockUserPayload.uuid,
mockUserPayload.email,
),
callUseCase.createCallAndRoom(mockUserPayload),
).rejects.toThrow(error);

expect(createCallTokenSpy).toHaveBeenCalledWith(mockUserPayload.uuid);
expect(createCallTokenSpy).toHaveBeenCalledWith(mockUserPayload);
});
});

Expand Down
13 changes: 7 additions & 6 deletions src/modules/call/call.usecase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { RoomUserUseCase } from '../room/room-user.usecase';
import { JoinCallResponseDto } from './dto/join-call.dto';
import { v4 as uuidv4 } from 'uuid';
import { CreateCallResponseDto } from './dto/create-call.dto';
import { User } from '../user/user.domain';
import { UserTokenData } from '../auth/dto/user.dto';

@Injectable()
export class CallUseCase {
Expand Down Expand Up @@ -54,19 +56,18 @@ export class CallUseCase {
}

async createCallAndRoom(
uuid: string,
email: string,
user: User | UserTokenData['payload'],
): Promise<CreateCallResponseDto> {
try {
const call = await this.callService.createCallToken(uuid);
await this.createRoomForCall(call, uuid, email);
this.logger.log(`Successfully created call for user: ${email}`);
const call = await this.callService.createCallToken(user);
await this.createRoomForCall(call, user.uuid, user.email);
this.logger.log(`Successfully created call for user: ${user.email}`);
return call;
} catch (error) {
const err = error as Error;
this.logger.error(
`Failed to create call and room: ${err.message}`,
{ userId: uuid, email },
{ userId: user.uuid, email: user.email },
err.stack,
);
throw err;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
export enum JitsiGenericWebHookEvent {
ROOM_CREATED = 'ROOM_CREATED',
PARTICIPANT_LEFT = 'PARTICIPANT_LEFT',
PARTICIPANT_LEFT_LOBBY = 'PARTICIPANT_LEFT_LOBBY',
TRANSCRIPTION_UPLOADED = 'TRANSCRIPTION_UPLOADED',
CHAT_UPLOADED = 'CHAT_UPLOADED',
ROOM_DESTROYED = 'ROOM_DESTROYED',
PARTICIPANT_JOINED = 'PARTICIPANT_JOINED',
PARTICIPANT_JOINED_LOBBY = 'PARTICIPANT_JOINED_LOBBY',
RECORDING_STARTED = 'RECORDING_STARTED',
RECORDING_ENDED = 'RECORDING_ENDED',
RECORDING_UPLOADED = 'RECORDING_UPLOADED',
LIVE_STREAM_STARTED = 'LIVE_STREAM_STARTED',
LIVE_STREAM_ENDED = 'LIVE_STREAM_ENDED',
SETTINGS_PROVISIONING = 'SETTINGS_PROVISIONING',
SIP_CALL_IN_STARTED = 'SIP_CALL_IN_STARTED',
SIP_CALL_IN_ENDED = 'SIP_CALL_IN_ENDED',
SIP_CALL_OUT_STARTED = 'SIP_CALL_OUT_STARTED',
SIP_CALL_OUT_ENDED = 'SIP_CALL_OUT_ENDED',
FEEDBACK = 'FEEDBACK',
DIAL_IN_STARTED = 'DIAL_IN_STARTED',
DIAL_IN_ENDED = 'DIAL_IN_ENDED',
DIAL_OUT_STARTED = 'DIAL_OUT_STARTED',
DIAL_OUT_ENDED = 'DIAL_OUT_ENDED',
USAGE = 'USAGE',
SPEAKER_STATS = 'SPEAKER_STATS',
POLL_CREATED = 'POLL_CREATED',
POLL_ANSWER = 'POLL_ANSWER',
REACTIONS = 'REACTIONS',
AGGREGATED_REACTIONS = 'AGGREGATED_REACTIONS',
SCREEN_SHARING_HISTORY = 'SCREEN_SHARING_HISTORY',
VIDEO_SEGMENT_UPLOADED = 'VIDEO_SEGMENT_UPLOADED',
ROLE_CHANGED = 'ROLE_CHANGED',
RTCSTATS_UPLOADED = 'RTCSTATS_UPLOADED',
TRANSCRIPTION_CHUNK_RECEIVED = 'TRANSCRIPTION_CHUNK_RECEIVED',
}

export interface JitsiGenericWebHookPayload<T = any> {
idempotencyKey: string;
customerId: string;
appId: string;
eventType: JitsiGenericWebHookEvent;
sessionId: string;
timestamp: number;
fqn: string;
data: T;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { JitsiGenericWebHookPayload } from './JitsiGenericWebHookPayload';

export interface JitsiParticipantLeftData {
moderator: boolean | 'true' | 'false';
name: string;
group?: string;
email?: string;
id?: string;
participantJid?: string;
participantId: string;
avatar?: string;
disconnectReason?:
| 'left'
| 'kicked'
| 'unknown'
| 'switch_room'
| 'unrecoverable_error';
participantName?: string;
endpointId?: string;
}

export type JitsiParticipantLeftWebHookPayload =
JitsiGenericWebHookPayload<JitsiParticipantLeftData>;
5 changes: 5 additions & 0 deletions src/modules/webhook/jitsi/interfaces/request.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Request } from 'express';

export interface RequestWithRawBody extends Request {
rawBody?: Buffer;
}
Loading
Loading