diff --git a/src/common/utils/constants.util.ts b/src/common/utils/constants.util.ts index 5d560de..68391aa 100644 --- a/src/common/utils/constants.util.ts +++ b/src/common/utils/constants.util.ts @@ -90,6 +90,13 @@ export const ERROR_MESSAGES = { USERID_INVALID: 'Invalid UserId', USERID_REQUIRED: 'UserId Required', PROVIDE_ONE_USERID_IN_QUERY: 'Please provide userId in query params', + ENVIRONMENT_VARIABLES_MISSING: 'Environment variables missing!', + USERS_NOT_FOUND_IN_SERVICE: 'Users not found in user service', + SERVICE_NOT_FOUND: 'Service not found', + NO_PARTICIPANTS_FOUND: 'No participants found for the meeting', + MEETING_NOT_FOUND: 'Meeting not found', + NO_USERS_FOUND: 'No users found in system', + EVENT_DOES_NOT_EXIST: 'Event does not exist', API_REQ_FAILURE: (url: string) => `Error occurred on API Request: ${url}`, DB_QUERY_FAILURE: (url: string) => `Database Query Failed on API: ${url}`, API_FAILURE: (url: string) => `API Failure: ${url}`, @@ -111,6 +118,7 @@ export const SUCCESS_MESSAGES = { EVENT_CREATED_LOG: (url: string) => `Event created with ID: ${url}`, EVENTS_FETCHED_LOG: 'Successfully fetched events', EVENT_UPDATED_LOG: 'Successfully updated events', + ATTENDANCE_MARKED_FOR_MEETING: 'Attendance marked for meeting', }; export const API_ID = { @@ -129,5 +137,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', + MARK_EVENT_ATTENDANCE: 'api.event.mark.attendance', }; diff --git a/src/common/utils/types.ts b/src/common/utils/types.ts index 7f3ba8c..f802ba6 100644 --- a/src/common/utils/types.ts +++ b/src/common/utils/types.ts @@ -77,3 +77,50 @@ export type RecurrencePattern = { value: string; }; }; + +export type ZoomParticipant = { + id: string; + user_id: string; + name: string; + user_email: string; + join_time: string; + leave_time: string; + duration: number; + registrant_id: string; + failover: boolean; + status: string; + groupId: string; + internal_user: boolean; +}; + +export type UserDetails = { + userId: string; + username: string; + email: string; + name: string; + role: string; + mobile: string; + createdBy: string; + updatedBy: string; + createdAt: string; + updatedAt: string; + status: string; + total_count: string; +}; + +export interface AttendanceRecord { + userId: string; + attendance: 'present' | 'absent'; + metaData: { + duration: number; + joinTime: string; + leaveTime: string; + }; +} + +export interface InZoomMeetingUserDetails { + user_email: string; + duration: number; + join_time: string; + leave_time: string; +} diff --git a/src/modules/attendance/attendance.controller.ts b/src/modules/attendance/attendance.controller.ts index 5eb1718..55473d1 100644 --- a/src/modules/attendance/attendance.controller.ts +++ b/src/modules/attendance/attendance.controller.ts @@ -1,9 +1,18 @@ -import { Body, Controller, Post, Res, Req, UseFilters } from '@nestjs/common'; +import { + Body, + Controller, + Post, + Res, + Req, + UseFilters, + UsePipes, + ValidationPipe, +} 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 { MarkMeetingAttendanceDto } from './dto/MarkAttendance.dto'; import { checkValidUserId } from 'src/common/utils/functions.util'; import { API_ID, ERROR_MESSAGES } from 'src/common/utils/constants.util'; @@ -12,22 +21,23 @@ import { API_ID, ERROR_MESSAGES } from 'src/common/utils/constants.util'; export class EventAttendance { constructor(private readonly attendanceService: AttendanceService) {} - @UseFilters(new AllExceptionsFilter(API_ID.MARK_ZOOM_ATTENDANCE)) + @UseFilters(new AllExceptionsFilter(API_ID.MARK_EVENT_ATTENDANCE)) @Post('/markeventattendance') - @ApiBody({ type: MarkZoomAttendanceDto }) + @ApiBody({ type: MarkMeetingAttendanceDto }) @ApiQuery({ name: 'userId', required: true, description: ERROR_MESSAGES.USERID_REQUIRED, example: '123e4567-e89b-12d3-a456-426614174000', }) + @UsePipes(new ValidationPipe({ transform: true })) async markEventAttendance( - @Body() markZoomAttendanceDto: MarkZoomAttendanceDto, + @Body() markZoomAttendanceDto: MarkMeetingAttendanceDto, @Res() response: Response, @Req() request: Request, ): Promise { const userId: string = checkValidUserId(request.query?.userId); - return this.attendanceService.markAttendanceForZoomMeetingParticipants( + return this.attendanceService.markAttendanceForMeetingParticipants( markZoomAttendanceDto, userId, response, diff --git a/src/modules/attendance/attendance.module.ts b/src/modules/attendance/attendance.module.ts index 9a506f6..34498d9 100644 --- a/src/modules/attendance/attendance.module.ts +++ b/src/modules/attendance/attendance.module.ts @@ -3,10 +3,19 @@ import { EventAttendance } from './attendance.controller'; import { AttendanceService } from './attendance.service'; import { HttpModule } from '@nestjs/axios'; import { ConfigService } from '@nestjs/config'; +import { OnlineMeetingAdapter } from 'src/online-meeting-adapters/onlineMeeting.adapter'; +import { ZoomService } from 'src/online-meeting-adapters/zoom/zoom.adapter'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { EventRepetition } from '../event/entities/eventRepetition.entity'; @Module({ - imports: [HttpModule], + imports: [HttpModule, TypeOrmModule.forFeature([EventRepetition])], controllers: [EventAttendance], - providers: [AttendanceService, ConfigService], + providers: [ + AttendanceService, + ConfigService, + OnlineMeetingAdapter, + ZoomService, + ], }) export class AttendanceModule {} diff --git a/src/modules/attendance/attendance.service.ts b/src/modules/attendance/attendance.service.ts index 095c400..4a97bb3 100644 --- a/src/modules/attendance/attendance.service.ts +++ b/src/modules/attendance/attendance.service.ts @@ -7,11 +7,20 @@ import { 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'; +import { MarkMeetingAttendanceDto } from './dto/MarkAttendance.dto'; +import { + API_ID, + ERROR_MESSAGES, + SUCCESS_MESSAGES, +} from 'src/common/utils/constants.util'; +import { OnlineMeetingAdapter } from 'src/online-meeting-adapters/onlineMeeting.adapter'; +import { AttendanceRecord, UserDetails } from 'src/common/utils/types'; +import { EventRepetition } from '../event/entities/eventRepetition.entity'; +import { Not, Repository } from 'typeorm'; +import { InjectRepository } from '@nestjs/typeorm'; +import { LoggerWinston } from 'src/common/logger/logger.util'; @Injectable() export class AttendanceService implements OnModuleInit { @@ -19,67 +28,92 @@ export class AttendanceService implements OnModuleInit { 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( + @InjectRepository(EventRepetition) + private readonly eventRepetitionRepository: Repository, private readonly httpService: HttpService, private readonly configService: ConfigService, + private readonly onlineMeetingAdapter: OnlineMeetingAdapter, ) { 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 + !this.attendanceServiceUrl.trim().length ) { - throw new InternalServerErrorException('Environment variables missing!'); + throw new InternalServerErrorException( + `${ERROR_MESSAGES.ENVIRONMENT_VARIABLES_MISSING}: USER_SERVICE, ATTENDANCE_SERVICE`, + ); } } - async markAttendanceForZoomMeetingParticipants( - markZoomAttendanceDto: MarkZoomAttendanceDto, + async markAttendanceForMeetingParticipants( + markMeetingAttendanceDto: MarkMeetingAttendanceDto, userId: string, response: Response, ) { - const apiId = API_ID.MARK_ZOOM_ATTENDANCE; + const apiId = API_ID.MARK_EVENT_ATTENDANCE; + + // check event exists + const eventRepetition = await this.eventRepetitionRepository.findOne({ + where: { + eventRepetitionId: markMeetingAttendanceDto.eventRepetitionId, + eventDetail: { + status: Not('archived'), + }, + }, + }); - const participantEmails = await this.getZoomMeetingParticipantsEmail( - markZoomAttendanceDto.zoomMeetingId, - ); + if (!eventRepetition) { + throw new BadRequestException(ERROR_MESSAGES.EVENT_DOES_NOT_EXIST); + } + + const participantEmails = await this.onlineMeetingAdapter + .getAdapter() + .getMeetingParticipantsEmail(markMeetingAttendanceDto.meetingId); - // get userids from email list in user service + // get userIds from email list in user service + const userList: UserDetails[] = await this.getUserIdList( + participantEmails.emailIds, + ); - const userList = await this.getUserIdList(participantEmails); + const userDetailList = this.onlineMeetingAdapter + .getAdapter() + .getParticipantAttendance( + userList, + participantEmails.inMeetingUserDetails, + ); // mark attendance for each user const res = await this.markUsersAttendance( - userList, - markZoomAttendanceDto, + userDetailList, + markMeetingAttendanceDto, + userId, + ); + + LoggerWinston.log( + SUCCESS_MESSAGES.ATTENDANCE_MARKED_FOR_MEETING, + apiId, userId, ); return response .status(HttpStatus.CREATED) - .json(APIResponse.success(apiId, res, 'Created')); + .json( + APIResponse.success( + apiId, + res, + SUCCESS_MESSAGES.ATTENDANCE_MARKED_FOR_MEETING, + ), + ); } - async getUserIdList(emailList: string[]): Promise { + async getUserIdList(emailList: string[]): Promise { + // get userIds for emails provided from user service try { const userListResponse = await this.httpService.axiosRef.post( `${this.userServiceUrl}/user/v1/list`, @@ -101,43 +135,38 @@ export class AttendanceService implements OnModuleInit { const userDetails = userListResponse.data.result.getUserDetails; if (!userDetails.length) { - throw new BadRequestException('No users found'); + throw new BadRequestException(ERROR_MESSAGES.NO_USERS_FOUND); } - return userDetails.map(({ userId }) => userId); + return userDetails; } catch (e) { if (e.status === 404) { - throw new BadRequestException('Service not found'); + throw new BadRequestException(ERROR_MESSAGES.SERVICE_NOT_FOUND); } throw e; } } async markUsersAttendance( - userIds: string[], - markZoomAttendanceDto: MarkZoomAttendanceDto, + userAttendance: AttendanceRecord[], + markMeetingAttendanceDto: MarkMeetingAttendanceDto, loggedInUserId: string, ): Promise { - // mark attendance for each user + // mark attendance for each user in attendance service try { - const userAttendance = userIds.map((userId) => ({ - userId, - attendance: 'present', - })); - const attendanceMarkResponse = await this.httpService.axiosRef.post( - `${this.attendanceServiceUrl}/api/v1/attendance/bulkAttendance`, + `${this.attendanceServiceUrl}/api/v1/attendance/bulkAttendance?userId=${loggedInUserId}`, { - attendanceDate: markZoomAttendanceDto.attendanceDate, - contextId: markZoomAttendanceDto.eventId, - scope: markZoomAttendanceDto.scope, + attendanceDate: markMeetingAttendanceDto.attendanceDate, + contextId: markMeetingAttendanceDto.eventRepetitionId, + scope: markMeetingAttendanceDto.scope, context: 'event', userAttendance, }, { headers: { Accept: 'application/json', - tenantid: markZoomAttendanceDto.tenantId, + tenantid: markMeetingAttendanceDto.tenantId, userId: loggedInUserId, }, }, @@ -157,100 +186,4 @@ export class AttendanceService implements OnModuleInit { throw e; } } - - async getZoomMeetingParticipantsEmail( - zoomMeetingId: string, - ): Promise { - 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; - } - }); - } } diff --git a/src/modules/attendance/dto/MarkZoomAttendance.dto.ts b/src/modules/attendance/dto/MarkAttendance.dto.ts similarity index 91% rename from src/modules/attendance/dto/MarkZoomAttendance.dto.ts rename to src/modules/attendance/dto/MarkAttendance.dto.ts index aa19c71..de335ab 100644 --- a/src/modules/attendance/dto/MarkZoomAttendance.dto.ts +++ b/src/modules/attendance/dto/MarkAttendance.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsNotEmpty, IsString, IsUUID, Matches } from 'class-validator'; -export class MarkZoomAttendanceDto { +export class MarkMeetingAttendanceDto { @ApiProperty({ type: String, description: 'Meeting ID', @@ -9,7 +9,7 @@ export class MarkZoomAttendanceDto { }) @IsString() @IsNotEmpty() - zoomMeetingId: string; + meetingId: string; @ApiProperty({ type: String, @@ -18,7 +18,7 @@ export class MarkZoomAttendanceDto { }) @IsUUID() @IsNotEmpty() - eventId: string; + eventRepetitionId: string; @ApiProperty({ type: String, diff --git a/src/online-meeting-adapters/onlineMeeting.adapter.ts b/src/online-meeting-adapters/onlineMeeting.adapter.ts new file mode 100644 index 0000000..c2de990 --- /dev/null +++ b/src/online-meeting-adapters/onlineMeeting.adapter.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@nestjs/common'; +import { IOnlineMeetingLocator } from './onlineMeeting.locator'; +import { ZoomService } from './zoom/zoom.adapter'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class OnlineMeetingAdapter { + private readonly adapterMap: Record; + + constructor( + private readonly zoomProvider: ZoomService, + private readonly configService: ConfigService, + ) { + this.adapterMap = { + zoom: this.zoomProvider, + // Add more adapters here + }; + } + + getAdapter(): IOnlineMeetingLocator { + const source = this.configService.get('ONLINE_MEETING_ADAPTER'); + const adapter = this.adapterMap[source]; + + if (!adapter) { + throw new Error( + `Invalid online meeting adapter: '${source}'. Supported adapters: ${Object.keys(this.adapterMap).join(', ')}`, + ); + } + + return adapter; + } +} diff --git a/src/online-meeting-adapters/onlineMeeting.locator.ts b/src/online-meeting-adapters/onlineMeeting.locator.ts new file mode 100644 index 0000000..245304c --- /dev/null +++ b/src/online-meeting-adapters/onlineMeeting.locator.ts @@ -0,0 +1,16 @@ +import { AttendanceRecord, UserDetails } from 'src/common/utils/types'; + +export interface IOnlineMeetingLocator { + getToken: () => Promise; + getMeetingParticipantList: ( + token: string, + userArray: any[], + meetingId: string, + url: string, + ) => Promise; + getParticipantAttendance: ( + userList: UserDetails[], + meetingParticipantDetails: any[], + ) => AttendanceRecord[]; + getMeetingParticipantsEmail: (meetingId: string) => Promise; +} diff --git a/src/online-meeting-adapters/zoom/zoom.adapter.ts b/src/online-meeting-adapters/zoom/zoom.adapter.ts new file mode 100644 index 0000000..e30b70c --- /dev/null +++ b/src/online-meeting-adapters/zoom/zoom.adapter.ts @@ -0,0 +1,171 @@ +import { + BadRequestException, + Injectable, + InternalServerErrorException, +} from '@nestjs/common'; +import { IOnlineMeetingLocator } from '../onlineMeeting.locator'; +import { ERROR_MESSAGES } from 'src/common/utils/constants.util'; +import { AxiosResponse } from 'axios'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { + AttendanceRecord, + InZoomMeetingUserDetails, + UserDetails, + ZoomParticipant, +} from 'src/common/utils/types'; + +@Injectable() +export class ZoomService implements IOnlineMeetingLocator { + 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.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.accountId.trim().length || + !this.username.trim().length || + !this.password.trim().length || + !this.authUrl.trim().length || + !this.zoomPastMeetings.trim().length + ) { + throw new InternalServerErrorException( + ERROR_MESSAGES.ENVIRONMENT_VARIABLES_MISSING, + ); + } + } + + async getToken(): Promise { + 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.response && e.response.status === 404) { + throw new BadRequestException(ERROR_MESSAGES.SERVICE_NOT_FOUND); + } + throw e; + } + } + + async getMeetingParticipantList( + token: string, + userArray: ZoomParticipant[], + zoomMeetingId: string, + url: string = '', + ): Promise { + const headers = { + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + token, + }, + }; + + let manualPageSize = 100; + const finalUrl = + `${this.zoomPastMeetings}/${zoomMeetingId}/participants?page_size=${manualPageSize}` + + url; + + const response = await this.httpService.axiosRef.get(finalUrl, headers); + + const retrievedUsersArray = userArray.concat(response.data.participants); + if (response.data.next_page_token) { + let nextPath = `&next_page_token=${response.data.next_page_token}`; + + return await this.getMeetingParticipantList( + token, + retrievedUsersArray, + zoomMeetingId, + nextPath, + ); + } else { + return retrievedUsersArray; + } + } + + async getMeetingParticipantsEmail( + meetingId: string, + ): Promise<{ emailIds: string[]; inMeetingUserDetails: any[] }> { + try { + const token = await this.getToken(); + + const userList = await this.getMeetingParticipantList( + token, + [], + meetingId, + '', + ); + + const inMeetingUserDetails = userList.filter(({ user_email, status }) => { + if (status === 'in_meeting') return user_email; + }); + + const emailIds = inMeetingUserDetails.map(({ user_email }) => user_email); + + if (!emailIds.length) { + throw new BadRequestException(ERROR_MESSAGES.NO_PARTICIPANTS_FOUND); + } + + return { emailIds, inMeetingUserDetails }; + } catch (e) { + if (e.status === 404) { + throw new BadRequestException(ERROR_MESSAGES.MEETING_NOT_FOUND); + } + throw e; + } + } + + getParticipantAttendance( + userList: UserDetails[], + meetingParticipantDetails: InZoomMeetingUserDetails[], + ): AttendanceRecord[] { + const userDetailList = []; + const userMap = new Map(userList.map((user) => [user.email, user])); + meetingParticipantDetails.forEach( + (participantDetail: InZoomMeetingUserDetails) => { + const userDetailExists = userMap.get(participantDetail.user_email); + if (userDetailExists) { + userDetailList.push({ ...userDetailExists, ...participantDetail }); + } + }, + ); + + return userDetailList.map( + ({ userId, duration, join_time, leave_time }) => ({ + userId, + attendance: 'present', + metaData: { + duration, + joinTime: join_time, + leaveTime: leave_time, + }, + }), + ); + } +}