Skip to content

Commit

Permalink
Merge pull request #53 from Xitija/main2
Browse files Browse the repository at this point in the history
Zoom Attendance integration
  • Loading branch information
Shubham4026 authored Dec 29, 2024
2 parents 5abd552 + 4426f75 commit 955e1af
Show file tree
Hide file tree
Showing 9 changed files with 444 additions and 48 deletions.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,14 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/axios": "^3.0.1",
"@nestjs/axios": "^3.1.3",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/swagger": "^7.1.16",
"@nestjs/typeorm": "^10.0.2",
"axios": "^1.6.2",
"axios": "^1.7.9",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"moment-timezone": "^0.5.45",
Expand Down
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import { AppService } from './app.service';
import { EventModule } from './modules/event/event.module';
import { DatabaseModule } from './common/database-modules';
import { AttendeesModule } from './modules/attendees/attendees.module';
import { AttendanceModule } from './modules/attendance/attendance.module';

@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
EventModule,
DatabaseModule,
AttendeesModule,
AttendanceModule,
],
controllers: [AppController],
providers: [AppService],
Expand Down
1 change: 1 addition & 0 deletions src/common/utils/constants.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,4 +129,5 @@ export const API_ID = {
CREATE_EVENT_ATTENDEE_HISTORY_ITEM: 'api.event.attendee.history.item.create',
UPDATE_EVENT_ATTENDEE_HISTORY_ITEM: 'api.event.attendee.history.item.update',
DELETE_EVENT_ATTENDEE_HISTORY_ITEM: 'api.event.attendee.history.item.delete',
MARK_ZOOM_ATTENDANCE: 'mark.zoom.event.attendance',
};
36 changes: 36 additions & 0 deletions src/modules/attendance/attendance.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Body, Controller, Post, Res, Req, UseFilters } from '@nestjs/common';
import { Response, Request } from 'express';
import { ApiBody, ApiQuery, ApiTags } from '@nestjs/swagger';
import { AttendanceService } from './attendance.service';
import { AllExceptionsFilter } from 'src/common/filters/exception.filter';
import { MarkZoomAttendanceDto } from './dto/MarkZoomAttendance.dto';
import { checkValidUserId } from 'src/common/utils/functions.util';
import { API_ID, ERROR_MESSAGES } from 'src/common/utils/constants.util';

@Controller('attendance/v1')
@ApiTags('Event-Attendance')
export class EventAttendance {
constructor(private readonly attendanceService: AttendanceService) {}

@UseFilters(new AllExceptionsFilter(API_ID.MARK_ZOOM_ATTENDANCE))
@Post('/markeventattendance')
@ApiBody({ type: MarkZoomAttendanceDto })
@ApiQuery({
name: 'userId',
required: true,
description: ERROR_MESSAGES.USERID_REQUIRED,
example: '123e4567-e89b-12d3-a456-426614174000',
})
async markEventAttendance(
@Body() markZoomAttendanceDto: MarkZoomAttendanceDto,
@Res() response: Response,
@Req() request: Request,
): Promise<Response> {
const userId: string = checkValidUserId(request.query?.userId);
return this.attendanceService.markAttendanceForZoomMeetingParticipants(
markZoomAttendanceDto,
userId,
response,
);
}
}
12 changes: 12 additions & 0 deletions src/modules/attendance/attendance.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { EventAttendance } from './attendance.controller';
import { AttendanceService } from './attendance.service';
import { HttpModule } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';

@Module({
imports: [HttpModule],
controllers: [EventAttendance],
providers: [AttendanceService, ConfigService],
})
export class AttendanceModule {}
256 changes: 256 additions & 0 deletions src/modules/attendance/attendance.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
import { HttpService } from '@nestjs/axios';
import {
BadRequestException,
HttpStatus,
Injectable,
InternalServerErrorException,
OnModuleInit,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AxiosResponse } from 'axios';
import { Response } from 'express';
import APIResponse from 'src/common/utils/response';
import { MarkZoomAttendanceDto } from './dto/MarkZoomAttendance.dto';
import { API_ID } from 'src/common/utils/constants.util';

@Injectable()
export class AttendanceService implements OnModuleInit {
// utilize apis of the attendance service to mark event attendance

private readonly userServiceUrl: string;
private readonly attendanceServiceUrl: string;
private readonly accountId: string;
private readonly username: string;
private readonly password: string;
private readonly authUrl: string;
private readonly zoomPastMeetings: string;

constructor(
private readonly httpService: HttpService,
private readonly configService: ConfigService,
) {
this.userServiceUrl = this.configService.get('USER_SERVICE');
this.attendanceServiceUrl = this.configService.get('ATTENDANCE_SERVICE');
this.accountId = this.configService.get('ZOOM_ACCOUNT_ID');
this.username = this.configService.get('ZOOM_USERNAME');
this.password = this.configService.get('ZOOM_PASSWORD');
this.authUrl = this.configService.get('ZOOM_AUTH_URL');
this.zoomPastMeetings = this.configService.get('ZOOM_PAST_MEETINGS');
}

onModuleInit() {
if (
!this.userServiceUrl.trim().length ||
!this.attendanceServiceUrl.trim().length ||
!this.accountId.trim().length ||
!this.username.trim().length ||
!this.password.trim().length ||
!this.authUrl.trim().length ||
!this.zoomPastMeetings.trim().length
) {
throw new InternalServerErrorException('Environment variables missing!');
}
}

async markAttendanceForZoomMeetingParticipants(
markZoomAttendanceDto: MarkZoomAttendanceDto,
userId: string,
response: Response,
) {
const apiId = API_ID.MARK_ZOOM_ATTENDANCE;

const participantEmails = await this.getZoomMeetingParticipantsEmail(
markZoomAttendanceDto.zoomMeetingId,
);

// get userids from email list in user service

const userList = await this.getUserIdList(participantEmails);

// mark attendance for each user
const res = await this.markUsersAttendance(
userList,
markZoomAttendanceDto,
userId,
);

return response
.status(HttpStatus.CREATED)
.json(APIResponse.success(apiId, res, 'Created'));
}

async getUserIdList(emailList: string[]): Promise<string[]> {
try {
const userListResponse = await this.httpService.axiosRef.post(
`${this.userServiceUrl}/user/v1/list`,
{
limit: emailList.length,
offset: 0,
filters: {
email: emailList,
},
},
{
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
},
);

const userDetails = userListResponse.data.result.getUserDetails;

if (!userDetails.length) {
throw new BadRequestException('No users found');
}

return userDetails.map(({ userId }) => userId);
} catch (e) {
if (e.status === 404) {
throw new BadRequestException('Service not found');
}
throw e;
}
}

async markUsersAttendance(
userIds: string[],
markZoomAttendanceDto: MarkZoomAttendanceDto,
loggedInUserId: string,
): Promise<any> {
// mark attendance for each user
try {
const userAttendance = userIds.map((userId) => ({
userId,
attendance: 'present',
}));

const attendanceMarkResponse = await this.httpService.axiosRef.post(
`${this.attendanceServiceUrl}/api/v1/attendance/bulkAttendance`,
{
attendanceDate: markZoomAttendanceDto.attendanceDate,
contextId: markZoomAttendanceDto.eventId,
scope: markZoomAttendanceDto.scope,
context: 'event',
userAttendance,
},
{
headers: {
Accept: 'application/json',
tenantid: markZoomAttendanceDto.tenantId,
userId: loggedInUserId,
},
},
);

return attendanceMarkResponse.data;
} catch (e) {
if (e.status === 404) {
throw new BadRequestException(
`Service not found ${e?.response?.data?.message}`,
);
} else if (e.status === 400) {
throw new BadRequestException(
`Bad request ${e?.response?.data?.message}`,
);
}
throw e;
}
}

async getZoomMeetingParticipantsEmail(
zoomMeetingId: string,
): Promise<string[]> {
try {
const token = await this.getZoomToken();

const userList = await this.getZoomParticipantList(
token,
[],
zoomMeetingId,
);

const emailIds = userList
.filter(({ user_email, status }) => {
if (status === 'in_meeting') return user_email;
})
.map(({ user_email }) => user_email);

if (!emailIds.length) {
throw new BadRequestException('No participants found for meeting');
}

return emailIds;
} catch (e) {
if (e.status === 404) {
throw new BadRequestException('Meeting not found');
}
throw e;
}
}

async getZoomToken() {
try {
const auth =
'Basic ' +
Buffer.from(this.username + ':' + this.password).toString('base64');

const tokenResponse: AxiosResponse = await this.httpService.axiosRef.post(
`${this.authUrl}?grant_type=account_credentials&account_id=${this.accountId}`,
{},
{
headers: {
Accept: 'application/json',
Authorization: auth,
},
},
);

return tokenResponse.data.access_token;
} catch (e) {
if (e.status === 404) {
throw new BadRequestException('Service not found');
}
throw e;
}
}

async getZoomParticipantList(
token: string,
userArray: any[],
meetId: string,
url = '',
) {
const headers = {
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + token,
},
};

let manualPageSize = 100;
const finalUrl =
`${this.zoomPastMeetings}/${meetId}/participants?page_size=${manualPageSize}` +
url;

return await this.httpService.axiosRef
.get(finalUrl, headers)
.then((response) => {
const retrievedUsersArray = userArray.concat(
response.data.participants,
);
if (response.data.next_page_token) {
let nextPath = `&next_page_token=${response.data.next_page_token}`;

return this.getZoomParticipantList(
token,
retrievedUsersArray,
meetId,
nextPath,
);
} else {
return retrievedUsersArray;
}
});
}
}
50 changes: 50 additions & 0 deletions src/modules/attendance/dto/MarkZoomAttendance.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, IsUUID, Matches } from 'class-validator';

export class MarkZoomAttendanceDto {
@ApiProperty({
type: String,
description: 'Meeting ID',
example: '1234567890',
})
@IsString()
@IsNotEmpty()
zoomMeetingId: string;

@ApiProperty({
type: String,
description: 'Event ID',
example: '123e4567-e89b-12d3-a456-426614174000',
})
@IsUUID()
@IsNotEmpty()
eventId: string;

@ApiProperty({
type: String,
description: 'The date of the attendance in format yyyy-mm-dd',
})
@IsNotEmpty()
@Matches(/^\d{4}-\d{2}-\d{2}$/, {
message: 'Please provide a valid date in the format yyyy-mm-dd',
})
attendanceDate: string;

@ApiProperty({
type: String,
description: 'Scope of the attendance',
example: 'self / student',
})
@IsString()
@IsNotEmpty()
scope: string;

@ApiProperty({
type: String,
description: 'Tenant ID',
example: '123e4567-e89b-12d3-a456-426614174000',
})
@IsUUID()
@IsNotEmpty()
tenantId: string;
}
Loading

0 comments on commit 955e1af

Please sign in to comment.