From 642f9be91edbc11de7ffba155d9d1834aebf4588 Mon Sep 17 00:00:00 2001 From: Navaneethakrishnan Date: Mon, 14 Oct 2024 16:43:37 +0530 Subject: [PATCH] feat: refactored participant request (team, member) service --- apps/web-api/src/admin/admin.controller.ts | 173 -- apps/web-api/src/admin/admin.module.ts | 26 +- apps/web-api/src/admin/admin.service.ts | 41 +- apps/web-api/src/admin/auth.controller.ts | 22 + .../admin/participants-request.controller.ts | 93 + .../web-api/src/members/members.controller.ts | 111 +- apps/web-api/src/members/members.module.ts | 33 +- apps/web-api/src/members/members.service.ts | 1604 ++++++++++++----- .../participants-request.controller.ts | 102 +- .../participants-request.module.ts | 24 +- .../participants-request.service.ts | 1347 ++++---------- .../unique-identifier.controller.ts | 21 - .../participant-request-validation.pipe.ts | 49 + apps/web-api/src/setup.service.ts | 2 +- apps/web-api/src/shared/shared.module.ts | 40 +- apps/web-api/src/teams/teams.controller.ts | 16 +- apps/web-api/src/teams/teams.module.ts | 36 +- apps/web-api/src/teams/teams.service.ts | 795 +++++--- apps/web-api/src/utils/helper/helper.ts | 43 + .../notification/notification.service.ts | 179 ++ libs/contracts/src/schema/admin.ts | 7 + libs/contracts/src/schema/index.ts | 4 +- libs/contracts/src/schema/member.ts | 4 +- .../src/schema/participants-request.ts | 20 +- 24 files changed, 2733 insertions(+), 2059 deletions(-) delete mode 100644 apps/web-api/src/admin/admin.controller.ts create mode 100644 apps/web-api/src/admin/auth.controller.ts create mode 100644 apps/web-api/src/admin/participants-request.controller.ts delete mode 100644 apps/web-api/src/participants-request/unique-identifier/unique-identifier.controller.ts create mode 100644 apps/web-api/src/pipes/participant-request-validation.pipe.ts create mode 100644 apps/web-api/src/utils/notification/notification.service.ts create mode 100644 libs/contracts/src/schema/admin.ts diff --git a/apps/web-api/src/admin/admin.controller.ts b/apps/web-api/src/admin/admin.controller.ts deleted file mode 100644 index 7d8f5b794..000000000 --- a/apps/web-api/src/admin/admin.controller.ts +++ /dev/null @@ -1,173 +0,0 @@ -/* eslint-disable prettier/prettier */ -import { - Body, - Controller, - ForbiddenException, - Get, - Param, - Patch, - Post, - Put, - Query, - Req, - UseGuards, -} from '@nestjs/common'; -import { ApprovalStatus, ParticipantType } from '@prisma/client'; - -import { NoCache } from '../decorators/no-cache.decorator'; -import { ParticipantsRequestService } from '../participants-request/participants-request.service'; -import { - ParticipantProcessRequestSchema, - ParticipantRequestMemberSchema, - ParticipantRequestTeamSchema, -} from 'libs/contracts/src/schema/participants-request'; -import { AdminAuthGuard } from '../guards/admin-auth.guard'; -import { AdminService } from './admin.service'; -@Controller('v1/admin') -export class AdminController { - constructor( - private readonly participantsRequestService: ParticipantsRequestService, - private readonly adminService: AdminService - ) {} - - @Post('signin') - async signIn(@Body() body) { - return await this.adminService.signIn(body.username, body.password); - } - - @Get('participants') - @NoCache() - @UseGuards(AdminAuthGuard) - async findAll(@Query() query) { - const result = await this.participantsRequestService.getAll(query); - return result; - } - - @Get('participants/:uid') - @NoCache() - @UseGuards(AdminAuthGuard) - async findOne(@Param() params) { - const result = await this.participantsRequestService.getByUid(params.uid); - return result; - } - - @Post('participants') - @UseGuards(AdminAuthGuard) - // @UseGuards(GoogleRecaptchaGuard) - async addRequest(@Body() body) { - const postData = body; - const participantType = body.participantType; - // delete postData.captchaToken; - - if ( - participantType === ParticipantType.MEMBER.toString() && - !ParticipantRequestMemberSchema.safeParse(postData).success - ) { - throw new ForbiddenException(); - } else if ( - participantType === ParticipantType.TEAM.toString() && - !ParticipantRequestTeamSchema.safeParse(postData).success - ) { - throw new ForbiddenException(); - } else if ( - participantType !== ParticipantType.TEAM.toString() && - participantType !== ParticipantType.MEMBER.toString() - ) { - throw new ForbiddenException(); - } - - const result = await this.participantsRequestService.addRequest(postData); - return result; - } - - @Put('participants/:uid') - @UseGuards(AdminAuthGuard) - //@UseGuards(GoogleRecaptchaGuard) - async updateRequest(@Body() body, @Param() params) { - const postData = body; - const participantType = body.participantType; - - if ( - participantType === ParticipantType.MEMBER.toString() && - !ParticipantRequestMemberSchema.safeParse(postData).success - ) { - throw new ForbiddenException(); - } else if ( - participantType === ParticipantType.TEAM.toString() && - !ParticipantRequestTeamSchema.safeParse(postData).success - ) { - throw new ForbiddenException(); - } else if ( - participantType !== ParticipantType.TEAM.toString() && - participantType !== ParticipantType.MEMBER.toString() - ) { - throw new ForbiddenException(); - } - const result = await this.participantsRequestService.updateRequest( - postData, - params.uid - ); - return result; - } - - @Patch('participants/:uid') - // @UseGuards(GoogleRecaptchaGuard) - @UseGuards(AdminAuthGuard) - async processRequest(@Body() body, @Param() params) { - const validation = ParticipantProcessRequestSchema.safeParse(body); - if (!validation.success) { - throw new ForbiddenException(); - } - const uid = params.uid; - const participantType = body.participantType; - const referenceUid = body.referenceUid; - const statusToProcess = body.status; - let result; - - // Process reject - if (statusToProcess === ApprovalStatus.REJECTED.toString()) { - result = await this.participantsRequestService.processRejectRequest(uid); - } - // Process approval for create team - else if ( - participantType === 'TEAM' && - statusToProcess === ApprovalStatus.APPROVED.toString() && - !referenceUid - ) { - result = await this.participantsRequestService.processTeamCreateRequest( - uid - ); - } - // Process approval for create Member - else if ( - participantType === 'MEMBER' && - statusToProcess === ApprovalStatus.APPROVED.toString() && - !referenceUid - ) { - result = await this.participantsRequestService.processMemberCreateRequest( - uid - ); - } - // Process approval for Edit Team - else if ( - participantType === 'TEAM' && - statusToProcess === ApprovalStatus.APPROVED.toString() && - referenceUid - ) { - result = await this.participantsRequestService.processTeamEditRequest( - uid - ); - } - // Process approval for Edit Member - else if ( - participantType === 'MEMBER' && - statusToProcess === ApprovalStatus.APPROVED.toString() && - referenceUid - ) { - result = await this.participantsRequestService.processMemberEditRequest( - uid - ); - } - return result; - } -} diff --git a/apps/web-api/src/admin/admin.module.ts b/apps/web-api/src/admin/admin.module.ts index 36b05bb91..35195ad6c 100644 --- a/apps/web-api/src/admin/admin.module.ts +++ b/apps/web-api/src/admin/admin.module.ts @@ -1,28 +1,16 @@ -/* eslint-disable prettier/prettier */ import { CacheModule, Module } from '@nestjs/common'; - -import { ParticipantsRequestService } from '../participants-request/participants-request.service'; -import { LocationTransferService } from '../utils/location-transfer/location-transfer.service'; -import { AwsService } from '../utils/aws/aws.service'; -import { RedisService } from '../utils/redis/redis.service'; -import { SlackService } from '../utils/slack/slack.service'; import { AdminService } from './admin.service'; import { JwtService } from '../utils/jwt/jwt.service'; -import { AdminController } from './admin.controller'; -import { ForestAdminService } from '../utils/forest-admin/forest-admin.service'; - +import { ParticipantsRequestModule } from '../participants-request/participants-request.module'; +import { SharedModule } from '../shared/shared.module'; +import { AdminParticipantsRequestController } from './participants-request.controller'; +import { AdminAuthController } from './auth.controller'; @Module({ - imports: [CacheModule.register()], - controllers: [AdminController], + imports: [CacheModule.register(), ParticipantsRequestModule, SharedModule], + controllers: [AdminParticipantsRequestController, AdminAuthController], providers: [ - ParticipantsRequestService, - LocationTransferService, - AwsService, - RedisService, - SlackService, AdminService, - JwtService, - ForestAdminService, + JwtService ], }) export class AdminModule {} diff --git a/apps/web-api/src/admin/admin.service.ts b/apps/web-api/src/admin/admin.service.ts index fe232ed5e..8f0f32e3c 100644 --- a/apps/web-api/src/admin/admin.service.ts +++ b/apps/web-api/src/admin/admin.service.ts @@ -1,26 +1,41 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '../utils/jwt/jwt.service'; +import { LogService } from '../shared/log.service'; @Injectable() export class AdminService { - constructor(private readonly jwtService: JwtService) {} + constructor( + private readonly jwtService: JwtService, + private logger: LogService, + ) {} /** - * Validates given username and password for the admin against the env config and - * creates a signed jwt token if credentials are valid else, throws {@link UnauthorizedException} - * @param username admin username - * @param password admin password - * @returns a signed jwt token in json format {code: 1, accessToken: } if crdentials valid - * @throws UnauthorizedException if credentials not valid + * Logs in the admin using the provided username and password. + * Validates credentials from environment variables. + * @param username - Admin username + * @param password - Admin password + * @returns Object containing access token on successful login + * @throws UnauthorizedException if credentials are invalid */ - async signIn(username: string, password: string): Promise { - const usernameFromEnv = process.env.ADMIN_USERNAME; - const passwordFromEnv = process.env.ADMIN_PASSWORD; - - if (username !== usernameFromEnv || passwordFromEnv !== password) { - throw new UnauthorizedException(); + async login(username: string, password: string): Promise<{ code:Number, accessToken: string }> { + if (!this.isValidAdminCredentials(username, password)) { + this.logger.error('Invalid credentials provided for admin login.'); + throw new UnauthorizedException('Invalid credentials'); } + this.logger.info('Generating admin access token...'); const accessToken = await this.jwtService.getSignedToken(['DIRECTORYADMIN']); return { code: 1, accessToken: accessToken }; } + + /** + * Validates the provided credentials against stored environment variables. + * @param username - Input username + * @param password - Input password + * @returns Boolean indicating if credentials are valid + */ + private isValidAdminCredentials(username: string, password: string): boolean { + const validUsername = process.env.ADMIN_USERNAME; + const validPassword = process.env.ADMIN_PASSWORD; + return username === validUsername && password === validPassword; + } } diff --git a/apps/web-api/src/admin/auth.controller.ts b/apps/web-api/src/admin/auth.controller.ts new file mode 100644 index 000000000..acb4569f7 --- /dev/null +++ b/apps/web-api/src/admin/auth.controller.ts @@ -0,0 +1,22 @@ +import { Body, Controller, Post, UsePipes } from '@nestjs/common'; +import { LoginRequestDto } from 'libs/contracts/src/schema'; +import { ZodValidationPipe } from 'nestjs-zod'; +import { AdminService } from './admin.service'; + +@Controller('v1/admin/auth') +export class AdminAuthController { + constructor(private readonly adminService: AdminService) {} + + /** + * Handles admin login requests. + * Validates the request body against the LoginRequestDto schema. + * @param loginRequestDto - The login request data transfer object + * @returns Access token if login is successful + */ + @Post('login') + @UsePipes(ZodValidationPipe) + async login(@Body() loginRequestDto: LoginRequestDto): Promise<{ accessToken: string }> { + const { username, password } = loginRequestDto; + return await this.adminService.login(username, password); + } +} diff --git a/apps/web-api/src/admin/participants-request.controller.ts b/apps/web-api/src/admin/participants-request.controller.ts new file mode 100644 index 000000000..1a0a1d027 --- /dev/null +++ b/apps/web-api/src/admin/participants-request.controller.ts @@ -0,0 +1,93 @@ +import { + Body, + Controller, + Get, + Param, + Patch, + Put, + Query, + UseGuards, + UsePipes, + BadRequestException, + NotFoundException +} from '@nestjs/common'; +import { NoCache } from '../decorators/no-cache.decorator'; +import { ParticipantsRequestService } from '../participants-request/participants-request.service'; +import { AdminAuthGuard } from '../guards/admin-auth.guard'; +import { ParticipantsReqValidationPipe } from '../pipes/participant-request-validation.pipe'; +import { ProcessParticipantReqDto } from 'libs/contracts/src/schema'; +import { ApprovalStatus, ParticipantsRequest, ParticipantType } from '@prisma/client'; + +@Controller('v1/admin/participants-request') +@UseGuards(AdminAuthGuard) +export class AdminParticipantsRequestController { + constructor( + private readonly participantsRequestService: ParticipantsRequestService + ) {} + + /** + * Retrieve all participants requests based on query parameters. + * @param query - Filter parameters for participants requests + * @returns A list of participants requests + */ + @Get("/") + @NoCache() + async findAll(@Query() query) { + return this.participantsRequestService.getAll(query); + } + + /** + * Retrieve a single participants request by its UID. + * @param uid - The unique identifier of the participants request + * @returns The participants request entry matching the UID + */ + @Get("/:uid") + @NoCache() + async findOne(@Param('uid') uid: string) { + return await this.participantsRequestService.findOneByUid(uid); + } + + /** + * Update an existing participants request by its UID. + * @param body - The updated data for the participants request + * @param uid - The unique identifier of the participants request + * @returns The updated participants request entry + */ + @Put('/:uid') + @UsePipes(new ParticipantsReqValidationPipe()) + async updateRequest( + @Body() body: any, + @Param('uid') uid: string + ) { + return await this.participantsRequestService.updateByUid(uid, body); + } + + /** + * Process (approve/reject) a pending participants request. + * @param body - The request body containing the status for processing (e.g., approve/reject) + * @param uid - The unique identifier of the participants request + * @returns The result of processing the participants request + */ + @Patch('/:uid') + async processRequest( + @Param('uid') uid: string, + @Body() body: ProcessParticipantReqDto + ): Promise { + const participantRequest: ParticipantsRequest | null = await this.participantsRequestService.findOneByUid(uid); + if (!participantRequest) { + throw new NotFoundException('Request not found'); + } + if (participantRequest.status !== ApprovalStatus.PENDING) { + throw new BadRequestException( + `Request cannot be processed. It has already been ${participantRequest.status.toLowerCase()}.` + ); + } + if (participantRequest.participantType === ParticipantType.TEAM && !participantRequest.requesterEmailId) { + throw new BadRequestException( + 'Requester email is required for team participation requests. Please provide a valid email address.' + ); + } + return await this.participantsRequestService.processRequestByUid(uid, participantRequest, body.status); + } +} + \ No newline at end of file diff --git a/apps/web-api/src/members/members.controller.ts b/apps/web-api/src/members/members.controller.ts index f28d5aa9f..5102acdfd 100644 --- a/apps/web-api/src/members/members.controller.ts +++ b/apps/web-api/src/members/members.controller.ts @@ -1,7 +1,8 @@ -import { Body, Controller, Param, Req, UnauthorizedException, UseGuards } from '@nestjs/common'; +import { Body, Controller, Param, Req, UseGuards, UsePipes, BadRequestException, ForbiddenException } from '@nestjs/common'; import { ApiNotFoundResponse, ApiParam } from '@nestjs/swagger'; import { Api, ApiDecorator, initNestServer } from '@ts-rest/nest'; import { Request } from 'express'; +import { ZodValidationPipe } from 'nestjs-zod'; import { MemberDetailQueryParams, MemberQueryParams, @@ -22,6 +23,7 @@ import { NoCache } from '../decorators/no-cache.decorator'; import { AuthGuard } from '../guards/auth.guard'; import { UserAccessTokenValidateGuard } from '../guards/user-access-token-validate.guard'; import { LogService } from '../shared/log.service'; +import { ParticipantsReqValidationPipe } from '../pipes/participant-request-validation.pipe'; const server = initNestServer(apiMembers); type RouteShape = typeof server.routeShapes; @@ -30,7 +32,14 @@ type RouteShape = typeof server.routeShapes; @NoCache() export class MemberController { constructor(private readonly membersService: MembersService, private logger: LogService) {} - + + /** + * Retrieves a list of members based on query parameters. + * Builds a Prisma query from the queryable fields and adds filters for names, roles, and recent members. + * + * @param request - HTTP request object containing query parameters + * @returns Array of members with related data + */ @Api(server.route.getMembers) @ApiQueryFromZod(MemberQueryParams) @ApiOkResponseFromZod(ResponseMemberWithRelationsSchema.array()) @@ -54,6 +63,13 @@ export class MemberController { return await this.membersService.findAll(builtQuery); } + /** + * Retrieves member roles based on query parameters with their counts. + * Builds a Prisma query and applies filters to return roles with the count of associated members. + * + * @param request - HTTP request object containing query parameters + * @returns Array of roles with member counts + */ @Api(server.route.getMemberRoles) async getMemberFilters(@Req() request: Request) { const queryableFields = prismaQueryableFieldsFromZod(ResponseMemberWithRelationsSchema); @@ -74,6 +90,14 @@ export class MemberController { return await this.membersService.getRolesWithCount(builtQuery, queryParams); } + /** + * Retrieves details of a specific member by UID. + * Builds a query for member details including relations and profile data. + * + * @param request - HTTP request object containing query parameters + * @param uid - UID of the member to fetch + * @returns Member details with related data + */ @Api(server.route.getMember) @ApiParam({ name: 'uid', type: 'string' }) @ApiNotFoundResponse(NOT_FOUND_GLOBAL_RESPONSE_SCHEMA) @@ -88,14 +112,39 @@ export class MemberController { return member; } + /** + * Updates member details based on the provided participant request data. + * Uses a validation pipe to ensure that the request is valid before processing. + * + * @param id - ID of the member to update + * @param body - Request body containing member data to update + * @param req - HTTP request object containing user email + * @returns Updated member data + */ @Api(server.route.modifyMember) @UseGuards(UserTokenValidation) - async updateOne(@Param('id') id, @Body() body, @Req() req) { + @UsePipes(new ParticipantsReqValidationPipe()) + async updateMember(@Param('uid') uid, @Body() participantsRequest, @Req() req) { this.logger.info(`Member update request - Initated by -> ${req.userEmail}`); - const participantsRequest = body; - return await this.membersService.editMemberParticipantsRequest(participantsRequest, req.userEmail); + const requestor = await this.membersService.findMemberByEmail(req.userEmail); + const { referenceUid } = participantsRequest; + if ( + !requestor.isDirectoryAdmin && + referenceUid !== requestor.uid + ) { + throw new ForbiddenException(`Member isn't authorized to update the member`); + } + return await this.membersService.updateMemberFromParticipantsRequest(uid, participantsRequest, requestor.email); } + /** + * Updates a member's preference settings. + * + * @param id - UID of the member whose preferences will be updated + * @param body - Request body containing preference data + * @param req - HTTP request object + * @returns Updated preference data + */ @Api(server.route.modifyMemberPreference) @UseGuards(AuthGuard) async updatePrefernce(@Param('uid') id, @Body() body, @Req() req) { @@ -105,10 +154,16 @@ export class MemberController { @Api(server.route.updateMember) @UseGuards(UserTokenValidation) - async updateMember(@Param('uid') uid, @Body() body) { - return await this.membersService.updateMember(uid, body); + async updateMemberByUid(@Param('uid') uid, @Body() body) { + return await this.membersService.updateMemberByUid(uid, body); } + /** + * Retrieves a member's preference settings by UID. + * + * @param uid - UID of the member whose preferences will be fetched + * @returns Member's preferences + */ @Api(server.route.getMemberPreferences) @UseGuards(AuthGuard) @NoCache() @@ -116,18 +171,56 @@ export class MemberController { return await this.membersService.getPreferences(uid); } + /** + * Sends an OTP for email change to the new email provided by the member. + * + * @param sendOtpRequest - Request DTO containing the new email + * @param req - HTTP request object containing user email + * @returns Response indicating success of OTP sending + */ @Api(server.route.sendOtpForEmailChange) @UseGuards(UserAccessTokenValidateGuard) + @UsePipes(ZodValidationPipe) async sendOtpForEmailChange(@Body() sendOtpRequest: SendEmailOtpRequestDto, @Req() req) { - return await this.membersService.sendOtpForEmailChange(sendOtpRequest.newEmail, req.userEmail); + const oldEmailId = req.userEmail; + if (sendOtpRequest.newEmail.toLowerCase().trim() === oldEmailId.toLowerCase().trim()) { + throw new BadRequestException('New email cannot be same as old email'); + } + let isMemberAvailable = await this.membersService.isMemberExistForEmailId(oldEmailId); + if (!isMemberAvailable) { + throw new ForbiddenException('Your email seems to have been updated recently. Please login and try again'); + } + isMemberAvailable = await this.membersService.isMemberExistForEmailId(sendOtpRequest.newEmail); + if (isMemberAvailable) { + throw new BadRequestException('Above email id is already used. Please try again with different email id.'); + } + return await this.membersService.sendOtpForEmailChange(sendOtpRequest.newEmail); } + /** + * Updates a member's email address to a new one. + * + * @param changeEmailRequest - Request DTO containing the new email address + * @param req - HTTP request object containing user email + * @returns Updated member data with new email + */ @Api(server.route.updateMemberEmail) @UseGuards(UserAccessTokenValidateGuard) + @UsePipes(ZodValidationPipe) async updateMemberEmail(@Body() changeEmailRequest: ChangeEmailRequestDto, @Req() req) { - return await this.membersService.updateMemberEmail(changeEmailRequest.newEmail, req.userEmail); + const memberInfo = await this.membersService.findMemberByEmail(req.userEmail); + if(!memberInfo || !memberInfo.externalId) { + throw new ForbiddenException("Please login again and try") + } + return await this.membersService.updateMemberEmail(changeEmailRequest.newEmail, req.userEmail, memberInfo); } + /** + * Retrieves GitHub projects associated with the member identified by UID. + * + * @param uid - UID of the member whose GitHub projects will be fetched + * @returns Array of GitHub projects associated with the member + */ @Api(server.route.getMemberGitHubProjects) async getGitProjects(@Param('uid') uid) { return await this.membersService.getGitProjects(uid); diff --git a/apps/web-api/src/members/members.module.ts b/apps/web-api/src/members/members.module.ts index f26a31be5..16c05a344 100644 --- a/apps/web-api/src/members/members.module.ts +++ b/apps/web-api/src/members/members.module.ts @@ -1,37 +1,20 @@ import { Module } from '@nestjs/common'; -import { ImagesController } from '../images/images.controller'; -import { ImagesService } from '../images/images.service'; -import { FileEncryptionService } from '../utils/file-encryption/file-encryption.service'; -import { AwsService } from '../utils/aws/aws.service'; -import { FileMigrationService } from '../utils/file-migration/file-migration.service'; -import { FileUploadService } from '../utils/file-upload/file-upload.service'; -import { LocationTransferService } from '../utils/location-transfer/location-transfer.service'; import { MemberController } from './members.controller'; -import { RedisService } from '../utils/redis/redis.service'; -import { SlackService } from '../utils/slack/slack.service'; import { MembersService } from './members.service'; -import { ForestAdminService } from '../utils/forest-admin/forest-admin.service'; -import { AuthService } from '../auth/auth.service'; import { ParticipantsRequestModule } from '../participants-request/participants-request.module'; import { OtpModule } from '../otp/otp.module'; +import { SharedModule } from '../shared/shared.module'; import { AuthModule } from '../auth/auth.module'; - @Module({ - imports: [OtpModule, ParticipantsRequestModule], + imports: [ + SharedModule, + AuthModule, + OtpModule, + ParticipantsRequestModule + ], providers: [ - MembersService, - FileMigrationService, - ImagesController, - ImagesService, - FileUploadService, - FileEncryptionService, - LocationTransferService, - AwsService, - RedisService, - SlackService, - ForestAdminService, - AuthService + MembersService ], controllers: [MemberController], exports: [MembersService] diff --git a/apps/web-api/src/members/members.service.ts b/apps/web-api/src/members/members.service.ts index 70a695e82..40a790f5c 100644 --- a/apps/web-api/src/members/members.service.ts +++ b/apps/web-api/src/members/members.service.ts @@ -2,46 +2,85 @@ import { BadRequestException, CACHE_MANAGER, - ForbiddenException, - HttpException, + ConflictException, + NotFoundException, Inject, Injectable, - InternalServerErrorException, - Logger, - UnauthorizedException, + forwardRef } from '@nestjs/common'; -import { Cache } from 'cache-manager'; -import { ParticipantType, Prisma } from '@prisma/client'; -import * as path from 'path'; import { z } from 'zod'; +import axios from 'axios'; +import * as path from 'path'; +import { Cache } from 'cache-manager'; +import { Prisma, Member, ParticipantsRequest } from '@prisma/client'; import { PrismaService } from '../shared/prisma.service'; import { ParticipantsRequestService } from '../participants-request/participants-request.service'; import { AirtableMemberSchema } from '../utils/airtable/schema/airtable-member.schema'; import { FileMigrationService } from '../utils/file-migration/file-migration.service'; -import { hashFileName } from '../utils/hashing'; +import { ForestAdminService } from '../utils/forest-admin/forest-admin.service'; import { LocationTransferService } from '../utils/location-transfer/location-transfer.service'; -import { ParticipantRequestMemberSchema } from 'libs/contracts/src/schema/participants-request'; -import axios from 'axios'; +import { NotificationService } from '../utils/notification/notification.service'; import { EmailOtpService } from '../otp/email-otp.service'; import { AuthService } from '../auth/auth.service'; import { LogService } from '../shared/log.service'; -import { DIRECTORYADMIN, DEFAULT_MEMBER_ROLES } from '../utils/constants'; +import { DEFAULT_MEMBER_ROLES } from '../utils/constants'; +import { hashFileName } from '../utils/hashing'; +import { copyObj, buildMultiRelationMapping, buildRelationMapping } from '../utils/helper/helper'; + @Injectable() export class MembersService { constructor( private prisma: PrismaService, private locationTransferService: LocationTransferService, - private participantsRequestService: ParticipantsRequestService, private fileMigrationService: FileMigrationService, private emailOtpService: EmailOtpService, private authService: AuthService, private logger: LogService, + private forestadminService: ForestAdminService, + @Inject(forwardRef(() => ParticipantsRequestService)) + private participantsRequestService: ParticipantsRequestService, + @Inject(forwardRef(() => NotificationService)) + private notificationService: NotificationService, @Inject(CACHE_MANAGER) private cacheService: Cache ) {} - findAll(queryOptions: Prisma.MemberFindManyArgs) { - return this.prisma.member.findMany(queryOptions); + /** + * Creates a new member in the database within a transaction. + * + * @param member - The data for the new member to be created + * @param tx - The transaction client to ensure atomicity + * @returns The created member record + */ + async createMember( + member: Prisma.MemberUncheckedCreateInput, + tx: Prisma.TransactionClient = this.prisma + ): Promise { + try { + return await tx.member.create({ + data: member, + }); + } catch(error) { + return this.handleErrors(error); + } + } + + /** + * Retrieves a list of members based on the provided query options. + * + * This method interacts with the Prisma ORM to execute a `findMany` query on the `member` table, + * using the query options specified in the `Prisma.MemberFindManyArgs` object. + * + * @param queryOptions - An object containing the query options to filter, sort, and paginate + * the members. These options are based on Prisma's `MemberFindManyArgs`. + * @returns A promise that resolves to an array of member records matching the query criteria. + */ + async findAll(queryOptions: Prisma.MemberFindManyArgs): Promise { + try { + return await this.prisma.member.findMany(queryOptions); + } catch(error) { + return this.handleErrors(error); + } } /** @@ -146,471 +185,1022 @@ export class MembersService { } } - findOne( + /** + * Updates the Member data in the database within a transaction. + * + * @param uid - Unique identifier of the member being updated + * @param member - The new data to be applied to the member + * @param tx - The transaction client to ensure atomicity + * @returns The updated member record + */ + async updateMemberByUid( + uid: string, + member: Prisma.MemberUncheckedUpdateInput, + tx: Prisma.TransactionClient = this.prisma, + ): Promise { + try { + return await tx.member.update({ + where: { uid }, + data: member, + }); + } catch(error) { + return this.handleErrors(error); + } + } + + /** + * Retrieves a member record by its UID, with additional relational data. + * If the member is not found, an exception is thrown. + * + * @param uid - The unique identifier (UID) of the member to retrieve. + * @param queryOptions - Additional query options to customize the search (excluding the 'where' clause). + * @param tx - An optional Prisma TransactionClient for executing within a transaction. + * @returns A promise that resolves to the member object, including related data such as image, location, + * skills, roles, team roles, and project contributions. Throws an exception if the member is not found. + */ + async findOne( uid: string, queryOptions: Omit = {}, tx?: Prisma.TransactionClient - ) { - return (tx || this.prisma).member.findUniqueOrThrow({ - where: { uid }, - ...queryOptions, - include: { - image: true, - location: true, - skills: true, - memberRoles: true, - teamMemberRoles: { - include: { - team: { - include: { - logo: true, + ): Promise { + try { + return await (tx || this.prisma).member.findUniqueOrThrow({ + where: { uid }, + ...queryOptions, + include: { + image: true, + location: true, + skills: true, + memberRoles: true, + teamMemberRoles: { + include: { + team: { + include: { + logo: true, + }, + }, + }, + }, + projectContributions: { + include: { + project: { + include: { + logo: true, + }, }, }, }, }, - projectContributions: { - include: { - project: { - include:{ - logo: true - } - } - } - } - }, - }); - } - - async findMemberByEmail(emailId) { - return await this.prisma.member.findUnique({ - where: { email: emailId.toLowerCase().trim() }, - include: { - image: true, - memberRoles: true, - teamMemberRoles: true, - projectContributions: true - }, - }); - } - - async findMemberByExternalId(externalId) { - return await this.prisma.member.findUnique({ - where: { externalId: externalId }, - include: { - image: true, - memberRoles: true, - teamMemberRoles: true, - projectContributions: true - }, - }); + }); + } catch(error) { + return this.handleErrors(error); + } } - - async sendOtpForEmailChange(newEmailId, oldEmailId) { - if (newEmailId.toLowerCase().trim() === oldEmailId.toLowerCase().trim()) { - throw new BadRequestException('New email cannot be same as old email'); + /** + * Retrieves a member record by its external ID, with additional relational data. + * + * @param externalId - The external ID of the member to find. + * @returns A promise that resolves to the member object, including associated image, roles, team roles, + * and project contributions. If no member is found, it returns `null`. + */ + async findMemberByExternalId(externalId: string): Promise { + try { + return await this.prisma.member.findUnique({ + where: { externalId }, + include: { + image: true, + memberRoles: true, + teamMemberRoles: true, + projectContributions: true, + } + }); + } catch(error) { + return this.handleErrors(error); } + } - let isMemberAvailable = await this.isMemberExistForEmailId(oldEmailId); - if (!isMemberAvailable) { - throw new ForbiddenException('Your email seems to have been updated recently. Please login and try again'); + /** + * Fetches existing member data including relationships. + * @param tx - Prisma transaction client or Prisma client. + * @param uid - Member UID to fetch. + */ + async findMemberByUid(uid: string, tx: Prisma.TransactionClient = this.prisma){ + try { + return tx.member.findUniqueOrThrow({ + where: { uid }, + include: { + image: true, + location: true, + skills: true, + teamMemberRoles: true, + memberRoles: true, + projectContributions: true + }, + }); + } catch(error) { + return this.handleErrors(error); } + } - isMemberAvailable = await this.isMemberExistForEmailId(newEmailId); - if (isMemberAvailable) { - throw new BadRequestException('Above email id is already used. Please try again with different email id.'); + /** + * Finds a member by their email address. + * + * @param email - The member's email address. + * @returns The member object if found. + */ + async findMemberFromEmail(email: string): Promise { + try { + return await this.prisma.member.findUniqueOrThrow({ + where: { email: email.toLowerCase().trim() }, + include: { + memberRoles: true + }, + }); + } catch(error) { + return this.handleErrors(error); } - return await this.emailOtpService.sendEmailOtp(newEmailId); } - async updateMemberEmail(newEmail, oldEmail) { - const memberInfo = await this.findMemberByEmail(oldEmail); - if(!memberInfo || !memberInfo.externalId) { - throw new ForbiddenException("Please login again and try") + /** + * Retrieves a member by email, including additional data such as roles, teams, and project contributions. + * Also determines if the member is a Directory Admin. + * + * @param userEmail - The email address of the member to retrieve. + * @returns A promise that resolves to an object containing the member's details, their roles, + * and whether they are a Directory Admin. It also returns the teams the member leads. + * If the member is not found, it returns `null`. + */ + async findMemberByEmail(userEmail: string) { + try { + const foundMember = await this.prisma.member.findUnique({ + where: { + email: userEmail.toLowerCase().trim(), + }, + include: { + image: true, + memberRoles: true, + teamMemberRoles: true, + projectContributions: true, + }, + }); + if (!foundMember) { + return null; + } + const roleNames = foundMember.memberRoles.map((m) => m.name); + const isDirectoryAdmin = roleNames.includes('DIRECTORYADMIN'); + return { + ...foundMember, + isDirectoryAdmin, + roleNames, + leadingTeams: foundMember.teamMemberRoles + .filter((role) => role.teamLead) + .map((role) => role.teamUid), + }; + } catch(error) { + return this.handleErrors(error); } + } - let newTokens; - let newMemberInfo; - - await this.prisma.$transaction(async (tx) => { - await this.participantsRequestService.addAutoApprovalEntry(tx, { - status: 'AUTOAPPROVED', - requesterEmailId: oldEmail, - referenceUid: memberInfo.uid, - uniqueIdentifier: oldEmail, - participantType: 'MEMBER', - newData: { oldEmail: oldEmail, newEmail: newEmail } - }) - - newMemberInfo = await tx.member.update({ - where: {email: oldEmail.toLowerCase().trim()}, - data: {email: newEmail.toLowerCase().trim()}, - include: { - memberRoles: true, - image: true, - teamMemberRoles: true, - } - }) - newTokens = await this.authService.updateEmailInAuth(newEmail, oldEmail, memberInfo.externalId) - }) + /** + * Sends an OTP (One-Time Password) to the provided email address for verification purposes. + * This method utilizes the `emailOtpService` to generate and send the OTP. + * + * @param newEmailId - The email address to which the OTP should be sent. + * @returns A promise that resolves when the OTP is successfully sent to the provided email address. + */ + async sendOtpForEmailChange(newEmailId: string) { + return await this.emailOtpService.sendEmailOtp(newEmailId); + } - // Log Info - this.logger.info(`Email has been successfully updated from ${oldEmail} to ${newEmail}`) - await this.cacheService.reset(); - return { - refreshToken: newTokens.refresh_token, - idToken: newTokens.id_token, - accessToken: newTokens.access_token, - userInfo: this.memberToUserInfo(newMemberInfo) + /** + * Updates a member's email address in both the database and the authentication service. + * This method performs the following operations: + * - Logs the email change request in the `participantsRequestService` for audit purposes. + * - Updates the member's email in the database, including associated member roles, images, and team member roles. + * - Updates the member's email in the authentication service to ensure consistency across services. + * - Resets the cache to reflect the updated member information. + * - Logs the successful email update. + * + * @param newEmail - The new email address to update. + * @param oldEmail - The current email address that will be replaced. + * @param memberInfo - An object containing the member's information, including their unique ID and external ID. + * @returns A promise that resolves with updated authentication tokens (refresh token, ID token, access token) + * and the updated member information in the form of `userInfo`. + * + * @throws If any operation within the transaction fails, the entire transaction is rolled back. + */ + async updateMemberEmail(newEmail:string, oldEmail:string, memberInfo) { + try { + let newTokens; + let newMemberInfo; + await this.prisma.$transaction(async (tx) => { + await this.participantsRequestService.addRequest({ + status: 'AUTOAPPROVED', + requesterEmailId: oldEmail, + referenceUid: memberInfo.uid, + uniqueIdentifier: oldEmail, + participantType: 'MEMBER', + newData: { + oldEmail: oldEmail, + newEmail: newEmail + }}, + false, + tx + ); + newMemberInfo = await tx.member.update({ + where: { email: oldEmail.toLowerCase().trim()}, + data: { email: newEmail.toLowerCase().trim()}, + include: { + memberRoles: true, + image: true, + teamMemberRoles: true, + } + }) + newTokens = await this.authService.updateEmailInAuth(newEmail, oldEmail, memberInfo.externalId) + }); + this.logger.info(`Email has been successfully updated from ${oldEmail} to ${newEmail}`) + await this.cacheService.reset(); + return { + refreshToken: newTokens.refresh_token, + idToken: newTokens.id_token, + accessToken: newTokens.access_token, + userInfo: this.memberToUserInfo(newMemberInfo) + }; + } catch(error) { + return this.handleErrors(error); } } - - async isMemberExistForEmailId(emailId) { - const member = await this.prisma.member.findUnique({ - where: { email: emailId.toLowerCase().trim() }, - }); - - return member ? true : false; + /** + * Checks if a member exists with the provided email address. + * The email address is normalized to lowercase and trimmed before querying. + * + * @param emailId - The email address to check for an existing member. + * @returns A boolean value indicating whether the member exists (`true`) or not (`false`). + */ + async isMemberExistForEmailId(emailId: string): Promise { + const member = await this.findMemberByEmail(emailId); + return !!member; // Simplified return to directly return boolean } + + /** + * Converts the member entity to a user information object. + * This method maps necessary member details such as login state, name, email, roles, + * profile image URL, and teams they lead. + * + * @param memberInfo - The member object from the database. + * @returns A structured user information object containing fields like + * isFirstTimeLogin, name, email, profileImageUrl, uid, roles, and leadingTeams. + */ private memberToUserInfo(memberInfo) { return { - isFirstTimeLogin: memberInfo?.externalId ? false : true, + isFirstTimeLogin: !!memberInfo?.externalId === false, name: memberInfo.name, email: memberInfo.email, - profileImageUrl: memberInfo.image?.url, + profileImageUrl: memberInfo.image?.url ?? null, uid: memberInfo.uid, - roles: memberInfo.memberRoles?.map((r) => r.name), + roles: memberInfo.memberRoles?.map((r) => r.name) ?? [], leadingTeams: memberInfo.teamMemberRoles?.filter((role) => role.teamLead) - .map(role => role.teamUid) + .map(role => role.teamUid) ?? [] }; } - - async updateExternalIdByEmail(emailId, externalId) { - return await this.prisma.member.update({ - where: { email: emailId.toLowerCase().trim() }, - data: { externalId: externalId }, - }); + /** + * Updates the external ID for the member identified by the provided email address. + * This method normalizes the email address before updating the external ID in the database. + * + * @param emailId - The email address of the member whose external ID should be updated. + * @param externalId - The new external ID to be assigned to the member. + * @returns The updated member object after the external ID is updated. + * @throws Error if the member does not exist or the update fails. + */ + async updateExternalIdByEmail(emailId: string, externalId: string): Promise { + try { + return await this.prisma.member.update({ + where: { email: emailId.toLowerCase().trim() }, + data: { externalId }, + }); + } catch(error){ + return this.handleErrors(error); + } } - async insertManyWithLocationsFromAirtable( - airtableMembers: z.infer[] - ) { - const skills = await this.prisma.skill.findMany(); - const images = await this.prisma.image.findMany(); - - for (const member of airtableMembers) { - if (!member.fields?.Name) { - continue; - } - - let image; - - if (member.fields['Profile picture']) { - const ppf = member.fields['Profile picture'][0]; - - const hashedPpf = ppf.filename - ? hashFileName(`${path.parse(ppf.filename).name}-${ppf.id}`) - : ''; - - image = - images.find( - (image) => path.parse(image.filename).name === hashedPpf - ) || - (await this.fileMigrationService.migrateFile({ - id: ppf.id || '', - url: ppf.url || '', - filename: ppf.filename || '', - size: ppf.size || 0, - type: ppf.type || '', - height: ppf.height || 0, - width: ppf.width || 0, - })); - } - - const optionalFieldsToAdd = Object.entries({ - email: 'Email', - githubHandler: 'Github Handle', - discordHandler: 'Discord handle', - twitterHandler: 'Twitter', - officeHours: 'Office hours link', - }).reduce( - (optionalFields, [prismaField, airtableField]) => ({ - ...optionalFields, - ...(member.fields?.[airtableField] && { - [prismaField]: member.fields?.[airtableField], - }), - }), - {} - ); - - const manyToManyRelations = { - skills: { - connect: skills - .filter( - (skill) => - !!member.fields?.['Skills'] && - member.fields?.['Skills'].includes(skill.title) - ) - .map((skill) => ({ id: skill.id })), - }, - }; - - const { location } = await this.locationTransferService.transferLocation( - member - ); - - await this.prisma.member.upsert({ - where: { - airtableRecId: member.id, - }, - update: { - ...optionalFieldsToAdd, - ...manyToManyRelations, - }, - create: { - airtableRecId: member.id, - name: member.fields.Name, - plnFriend: member.fields['Friend of PLN'] || false, - locationUid: location ? location?.uid : null, - imageUid: image?.uid, - ...optionalFieldsToAdd, - ...manyToManyRelations, - }, + /** + * Retrieves a member's GitHub handler based on their UID. + * + * @param uid - The UID of the member. + * @returns The GitHub handler of the member or null if not found. + */ + private async getMemberGitHubHandler(uid: string): Promise { + try { + const member = await this.prisma.member.findUnique({ + where: { uid }, + select: { githubHandler: true }, }); + return member?.githubHandler || null; + } catch(error) { + return this.handleErrors(error); } } - async getGitProjects(uid) { - const member = await this.prisma.member.findUnique( - { - where: { uid: uid }, - select: { githubHandler: true } - } - ); - if (!member || !member.githubHandler) { - return []; - } - try { - const resp = await axios - .post( - 'https://api.github.com/graphql', - { - query: `{ - user(login: "${member?.githubHandler}") { - pinnedItems(first: 6, types: REPOSITORY) { - nodes { - ... on RepositoryInfo { - name - description - url - createdAt - updatedAt - } - } - } + /** + * Sends a request to the GitHub GraphQL API to fetch pinned repositories. + * + * @param githubHandler - The GitHub username of the member. + * @returns An array of pinned repositories or an empty array if none are found. + */ + private async fetchPinnedRepositories(githubHandler: string) { + const query = { + query: `{ + user(login: "${githubHandler}") { + pinnedItems(first: 6, types: REPOSITORY) { + nodes { + ... on RepositoryInfo { + name + description + url + createdAt + updatedAt } - }`, - }, - { - headers: { - Authorization: `Bearer ${process.env.GITHUB_API_KEY}`, - 'Content-Type': 'application/json', - }, + } } - ); - const response = await axios - .get(`https://api.github.com/users/${member.githubHandler}/repos?sort=pushed&per_page=50`); - const repositories = response?.data.map((item) => { - return { - name: item.name, - description: item.description, - url: item.html_url, - createdAt: item.created_at, - updatedAt: item.updated_at, - }; - }); - if (resp?.data?.data?.user) { - const { pinnedItems } = resp.data.data.user; - if (pinnedItems?.nodes?.length > 0) { - // Create a Set of pinned repository names for efficient lookup - const pinnedRepositoryNames = new Set(pinnedItems.nodes.map((repo) => repo.name)); - // Filter out the pinned repositories from the list of all repositories - const filteredRepositories = repositories?.filter((repo) => !pinnedRepositoryNames.has(repo.name)); - return [...pinnedItems.nodes, ...filteredRepositories].slice(0, 50); - } else { - return repositories || []; } - } - } - catch(err) { - this.logger.error('Error occured while fetching the git projects.', err); - return { - statusCode: 500, - message: 'Internal Server Error.' - }; - } - return []; - } - - async editMemberParticipantsRequest(participantsRequest, userEmail) { - this.logger.info(`Member update request - Processing with values - ${JSON.stringify(participantsRequest)}`) - const { referenceUid } = participantsRequest; - const requestorDetails = - await this.participantsRequestService.findMemberByEmail(userEmail); - if (!requestorDetails) { - throw new UnauthorizedException(); - } - if ( - !requestorDetails.isDirectoryAdmin && - referenceUid !== requestorDetails.uid - ) { - throw new ForbiddenException(); - } - participantsRequest.requesterEmailId = requestorDetails.email; - if ( - participantsRequest.participantType === - ParticipantType.MEMBER.toString() && - !ParticipantRequestMemberSchema.safeParse(participantsRequest).success - ) { - throw new BadRequestException(); - } - if ( - participantsRequest.participantType === ParticipantType.MEMBER.toString() - ) { - const { city, country, region } = participantsRequest.newData; - if (city || country || region) { - const result: any = await this.locationTransferService.fetchLocation( - city, - country, - null, - region, - null - ); - if (!result || !result?.location) { - throw new BadRequestException('Invalid Location info'); + }`, + }; + try { + const response = await axios.post( + 'https://api.github.com/graphql', + query, + { + headers: { + Authorization: `Bearer ${process.env.GITHUB_API_KEY}`, + 'Content-Type': 'application/json', + }, } - } + ); + return response?.data?.data?.user?.pinnedItems?.nodes || []; + } catch (err) { + this.logger.error('Error fetching pinned repositories from GitHub.', err); + return []; } - let result; + } + + /** + * Sends a request to the GitHub REST API to fetch recent repositories. + * + * @param githubHandler - The GitHub username of the member. + * @returns An array of recent repositories or an empty array if none are found. + */ + private async fetchRecentRepositories(githubHandler: string) { try { - await this.prisma.$transaction(async (tx) => { - result = await this.participantsRequestService.addRequest( - participantsRequest, - true, - tx - ); - if (result?.uid) { - this.logger.info(`Member update request - Added entry in pariticipants request table, requestId -> ${result.uid}, requestor -> ${userEmail}`) - await this.participantsRequestService.processMemberEditRequest( - result.uid, - true, // disable the notification - true, // enable the auto approval - requestorDetails.isDirectoryAdmin, - tx - ); - this.logger.info(`Member update request - completed, requestId -> ${result.uid}, requestor -> ${userEmail}`) - } else { - throw new InternalServerErrorException(`Error in updating member request`); - } - }); - } catch (error) { - this.logger.info(`Member update request - error , requestor -> ${userEmail}, referenceId -> ${referenceUid}, error -> ${JSON.stringify(error)}`) - if (error?.response?.statusCode && error?.response?.message) { - throw new HttpException( - error?.response?.message, - error?.response?.statusCode - ); - } else { - throw new BadRequestException( - 'Oops, something went wrong. Please try again!' - ); - } + const response = await axios.get( + `https://api.github.com/users/${githubHandler}/repos?sort=pushed&per_page=50` + ); + return response?.data.map((repo) => ({ + name: repo.name, + description: repo.description, + url: repo.html_url, + createdAt: repo.created_at, + updatedAt: repo.updated_at, + })) || []; + } catch (err) { + this.logger.error('Error fetching recent repositories from GitHub.', err); + return []; } - return result; } - findMemberFromEmail(email:string){ - return this.prisma.member.findUniqueOrThrow({ - where: { email: email.toLowerCase().trim() }, - include: { - memberRoles: true, - }, + /** + * Combines pinned and recent repositories, ensuring no duplicates. + * + * @param pinnedRepos - Array of pinned repositories. + * @param recentRepos - Array of recent repositories. + * @returns An array of up to 50 combined repositories with pinned ones first. + */ + private combineRepositories(pinnedRepos, recentRepos) { + const pinnedRepoNames = new Set(pinnedRepos.map((repo) => repo.name)); + const filteredRecentRepos = recentRepos.filter((repo) => !pinnedRepoNames.has(repo.name)); + return [...pinnedRepos, ...filteredRecentRepos].slice(0, 50); + } + + /** + * Fetches a member's GitHub repositories (pinned and recent). + * + * @param uid - The UID of the member for whom the GitHub projects are to be fetched. + * @returns An array of repositories (both pinned and recent), or an error response if something goes wrong. + */ + async getGitProjects(uid: string) { + const githubHandler = await this.getMemberGitHubHandler(uid); + if (!githubHandler) { + return []; + } + try { + const pinnedRepos = await this.fetchPinnedRepositories(githubHandler); + const recentRepos = await this.fetchRecentRepositories(githubHandler); + return this.combineRepositories(pinnedRepos, recentRepos); + } catch (err) { + this.logger.error('Error occurred while fetching GitHub projects.', err); + return { + statusCode: 500, + message: 'Internal Server Error.', + }; + } + } + + /** + * Creates a new team from the participants request data. + * resets the cache, and triggers post-update actions like Airtable synchronization. + * @param teamParticipantRequest - The request containing the team details. + * @param requestorEmail - The email of the requestor. + * @param tx - The transaction client to ensure atomicity + * @returns The newly created team. + */ + async createMemberFromParticipantsRequest( + memberParticipantRequest: ParticipantsRequest, + tx: Prisma.TransactionClient = this.prisma + ): Promise { + const memberData: any = memberParticipantRequest.newData; + const member = await this.prepareMemberFromParticipantRequest(null, memberData, null, tx); + await this.mapLocationToMember(memberData, null, member, tx); + return await this.createMember(member, tx); + } + + async updateMemberFromParticipantsRequest( + memberUid: string, + memberParticipantsRequest: ParticipantsRequest, + requestorEmail: string + ): Promise { + let result; + await this.prisma.$transaction(async (tx) => { + const memberData: any = memberParticipantsRequest.newData; + const existingMember = await this.findMemberByUid(memberUid, tx); + const isExternalIdAvailable = existingMember.externalId ? true : false; + const isEmailChanged = await this.checkIfEmailChanged(memberData, existingMember, tx); + this.logger.info(`Member update request - Initiaing update for member uid - ${existingMember.uid}, requestId -> ${memberUid}`) + const member = await this.prepareMemberFromParticipantRequest(memberUid, memberData, existingMember, tx, 'Update'); + await this.mapLocationToMember(memberData, existingMember, member, tx); + result = await this.updateMemberByUid( + memberUid, + { + ...member, + ...(isEmailChanged && isExternalIdAvailable && { externalId: null }) + }, + tx + ); + await this.updateMemberEmailChange(memberUid, isEmailChanged, isExternalIdAvailable, memberData, existingMember); + await this.logParticipantRequest(requestorEmail, memberData, existingMember.uid, tx); + this.notificationService.notifyForMemberEditApproval(memberData.name, memberUid, requestorEmail); + this.logger.info(`Member update request - completed, requestId -> ${result.uid}, requestor -> ${requestorEmail}`) }); + await this.postUpdateActions(); + return result; + } + + /** + * Checks if the email has changed during update and verifies if the new email is already in use. + * + * @param transactionType - The Prisma transaction client, used for querying the database. + * @param dataToProcess - The input data containing the new email. + * @param existingData - The existing member data, used for comparing the current email. + * @throws {BadRequestException} - Throws if the email has been changed and the new email is already in use. + */ + async checkIfEmailChanged( + memberData, + existingMember, + transactionType: Prisma.TransactionClient, + ): Promise { + const isEmailChanged = existingMember.email !== memberData.email; + if (isEmailChanged) { + const foundUser = await transactionType.member.findUnique({ + where: { email: memberData.email.toLowerCase().trim() }, + }); + if (foundUser && foundUser.email) { + throw new BadRequestException('Email already exists. Please try again with a different email'); + } + } + return isEmailChanged; } - async updatePreference(id,preference){ - const response = this.prisma.member.update( - { - where: {uid: id}, - data: {preferences: preference} + /** + * prepare member data for creation or update + * + * @param memberUid - The unique identifier for the member (used for updates) + * @param memberData - Raw member data to be formatted + * @param tx - Transaction client for atomic operations + * @param type - Operation type ('create' or 'update') + * @returns - Formatted member data for Prisma query + */ + async prepareMemberFromParticipantRequest( + memberUid: string | null, + memberData, + existingMember, + tx: Prisma.TransactionClient, + type: string = 'Create' + ) { + const member: any = {}; + const directFields = [ + 'name', 'email', 'githubHandler', 'discordHandler', 'bio', + 'twitterHandler', 'linkedinHandler', 'telegramHandler', + 'officeHours', 'moreDetails', 'plnStartDate', 'openToWork' + ]; + copyObj(memberData, member, directFields); + member.email = member.email.toLowerCase().trim(); + member['image'] = buildRelationMapping('image', memberData); + member['skills'] = buildMultiRelationMapping('skills', memberData, 'create'); + if (type === 'Create') { + member['teamMemberRoles'] = this.buildTeamMemberRoles(memberData); + if (Array.isArray(memberData.projectContributions)) { + member['projectContributions'] = { + createMany: { data: memberData.projectContributions }, + }; + } + } else { + await this.updateProjectContributions(memberData, existingMember, memberUid, tx); + await this.updateTeamMemberRoles(memberData, existingMember, memberUid, tx); } - ); - this.cacheService.reset(); - return response; + return member; } - async updateMember(uid, member) { - const response = this.prisma.member.update( - { - where: { uid }, - data: { ...member } + /** + * Process and map location data for both create and update operations. + * It fetches and upserts location details based on the provided city, country, and region, + * and connects or disconnects the location accordingly. + * + * @param memberData - The input data containing location fields (city, country, region). + * @param existingData - The existing member data, used for comparing locations during updates. + * @param member - The data object that will be saved with the mapped location. + * @param tx - The Prisma transaction client, used for upserting location. + * @returns {Promise} - Resolves once the location has been processed and mapped. + * @throws {BadRequestException} - Throws if the location data is invalid. + */ + async mapLocationToMember( + memberData: any, + existingMember: any, + member: any, + tx: Prisma.TransactionClient, + ): Promise { + const { city, country, region } = memberData; + if (city || country || region) { + const result = await this.locationTransferService.fetchLocation(city, country, null, region, null); + // If the location has a valid placeId, proceed with upsert + if (result?.location?.placeId) { + const finalLocation = await tx.location.upsert({ + where: { placeId: result.location.placeId }, + update: result.location, + create: result.location, + }); + // Only connect the new location if it's different from the existing one + if (finalLocation?.uid && existingMember?.location?.uid !== finalLocation.uid) { + member['location'] = { connect: { uid: finalLocation.uid } }; + } + } else { + // If the location is invalid, throw an exception + throw new BadRequestException('Invalid Location info'); + } + } else { + if (existingMember) { + member['location'] = { disconnect: true }; + } } + } + + /** + * Main function to process team member role updates by creating, updating, or deleting roles. + * + * @param memberData - New data for processing team member roles. + * @param existingMember - Existing member data used to identify roles for update or deletion. + * @param referenceUid - The member's reference UID. + * @param tx - The Prisma transaction client. + * @returns {Promise} + */ + async updateTeamMemberRoles( + memberData, + existingMember, + memberUid, + tx: Prisma.TransactionClient, + ) { + const oldTeamUids = existingMember.teamMemberRoles.map((t: any) => t.teamUid); + const newTeamUids = memberData.teamAndRoles.map((t: any) => t.teamUid); + // Determine which roles need to be deleted, updated, or created + const rolesToDelete = existingMember.teamMemberRoles.filter( + (t: any) => !newTeamUids.includes(t.teamUid) ); - this.cacheService.reset(); - return response; - } - - async getPreferences(uid) { - const resp:any = await this.prisma.member.findUnique( - { - where: { uid: uid }, - select: { - email: true, - githubHandler: true, - telegramHandler:true, - discordHandler: true, - linkedinHandler: true, - twitterHandler: true, - preferences: true, + const rolesToUpdate = memberData.teamAndRoles.filter((t: any, index: number) => { + const foundIndex = existingMember.teamMemberRoles.findIndex( + (v: any) => v.teamUid === t.teamUid + ); + if (foundIndex > -1) { + const foundValue = existingMember.teamMemberRoles[foundIndex]; + if (foundValue.role !== t.role) { + let foundDefaultRoleTag = false; + // Check if there's a default member role tag + foundValue.roleTags?.some((tag: any) => { + if (Object.keys(DEFAULT_MEMBER_ROLES).includes(tag)) { + foundDefaultRoleTag = true; + return true; + } + }); + // Set roleTags for the new role based on default roleTags or split role string + memberData.teamAndRoles[index].roleTags = foundDefaultRoleTag + ? foundValue.roleTags + : t.role?.split(',').map((item: string) => item.trim()); + return true; } } + return false; + }); + const rolesToCreate = memberData.teamAndRoles.filter( + (t: any) => !oldTeamUids.includes(t.teamUid) ); - const preferences = {...resp.preferences}; - if (!resp.preferences) { - preferences.isnull = true; - } else{ - preferences.isnull = false; - } - preferences.email = resp?.email ? true: false; - preferences.github = resp?.githubHandler ? true: false; - preferences.telegram = resp?.telegramHandler ? true: false; - preferences.discord = resp?.discordHandler ? true: false; - preferences.linkedin = resp?.linkedinHandler ? true : false; - preferences.twitter = resp?.twitterHandler ? true: false; - return preferences; + // Process deletions, updates, and creations + await this.deleteTeamMemberRoles(tx, rolesToDelete, memberUid); + await this.modifyTeamMemberRoles(tx, rolesToUpdate, memberUid); + await this.createTeamMemberRoles(tx, rolesToCreate, memberUid); + } + + /** + * Function to handle the creation of new team member roles. + * + * @param tx - The Prisma transaction client. + * @param rolesToCreate - Array of team roles to create. + * @param referenceUid - The member's reference UID. + * @returns {Promise} + */ + async createTeamMemberRoles( + tx: Prisma.TransactionClient, + rolesToCreate: any[], + memberUid: string + ) { + if (rolesToCreate.length > 0) { + const rolesToCreateData = rolesToCreate.map((t: any) => ({ + role: t.role, + mainTeam: false, // Set your default values here if needed + teamLead: false, // Set your default values here if needed + teamUid: t.teamUid, + memberUid, + roleTags: t.role?.split(',').map((item: string) => item.trim()), // Properly format roleTags + })); + + await tx.teamMemberRole.createMany({ + data: rolesToCreateData, + }); + } + } + + + /** + * Function to handle deletion of team member roles. + * + * @param tx - The Prisma transaction client. + * @param rolesToDelete - Array of team UIDs to delete. + * @param referenceUid - The member's reference UID. + * @returns {Promise} + */ + async deleteTeamMemberRoles( + tx: Prisma.TransactionClient, + rolesToDelete, + memberUid: string + ) { + if (rolesToDelete.length > 0) { + await tx.teamMemberRole.deleteMany({ + where: { + teamUid: { in: rolesToDelete.map((t: any) => t.teamUid) }, + memberUid, + }, + }); + } } - async isMemberLeadTeam(member, teamUid) { - const user = await this.memberToUserInfo(member); - if (user.leadingTeams.includes(teamUid)) { - return true; + /** + * Function to handle the update of existing team member roles. + * + * @param tx - The Prisma transaction client. + * @param rolesToUpdate - Array of team roles to update. + * @param referenceUid - The member's reference UID. + * @returns {Promise} + */ + async modifyTeamMemberRoles( + tx: Prisma.TransactionClient, + rolesToUpdate, + memberUid: string + ): Promise { + if (rolesToUpdate.length > 0) { + const updatePromises = rolesToUpdate.map((roleToUpdate: any) => + tx.teamMemberRole.update({ + where: { + memberUid_teamUid: { + teamUid: roleToUpdate.teamUid, + memberUid, + }, + }, + data: { role: roleToUpdate.role, roleTags: roleToUpdate.roleTags }, + }) + ); + await Promise.all(updatePromises); } - return false; } - checkIfAdminUser = (member) => { - const roleFilter = member.memberRoles.filter((roles) => { - return roles.name === DIRECTORYADMIN; + /** + * Builds the team member roles relational data + * @param dataToProcess - Raw data containing team and roles + * @returns - Team member roles relational data for Prisma query + */ + private buildTeamMemberRoles(memberData) { + return { + createMany: { + data: memberData.teamAndRoles.map((t) => ({ + role: t.role, + mainTeam: false, + teamLead: false, + teamUid: t.teamUid, + roleTags: t.role?.split(',')?.map(item => item.trim()), + })), + }, + }; + } + + /** + * function to handle creation, updating, and deletion of project contributions + * with fewer database calls by using batch operations. + * + * @param memberData - The input data containing the new project contributions. + * @param existingMember - The existing member data, used to identify contributions to update or delete. + * @param memberUid - The reference UID for associating the new contributions with the member. + * @param tx - The Prisma transaction client. + * @returns {Promise} + */ + async updateProjectContributions( + memberData, + existingMember, + memberUid: string | null, + tx: Prisma.TransactionClient, + ): Promise { + const contributionsToCreate = memberData.projectContributions?.filter( + (contribution) => !contribution.uid + ) || []; + const contributionUidsInRequest = memberData.projectContributions + ?.filter((contribution) => contribution.uid) + .map((contribution) => contribution.uid) || []; + const contributionIdsToDelete: string[] = []; + const contributionIdsToUpdate: any = []; + existingMember.projectContributions?.forEach((existingContribution: any) => { + if (!contributionUidsInRequest.includes(existingContribution.uid)) { + contributionIdsToDelete.push(existingContribution.uid); + } else { + const newContribution = memberData.projectContributions.find( + (contribution) => contribution.uid === existingContribution.uid + ); + if (JSON.stringify(existingContribution) !== JSON.stringify(newContribution)) { + contributionIdsToUpdate.push(newContribution); + } + } }); - return roleFilter.length > 0; - }; + if (contributionIdsToDelete.length > 0) { + await tx.projectContribution.deleteMany({ + where: { uid: { in: contributionIdsToDelete } }, + }); + } + if (contributionIdsToUpdate.length > 0) { + const updatePromises = contributionIdsToUpdate.map((contribution: any) => + tx.projectContribution.update({ + where: { uid: contribution.uid }, + data: { ...contribution }, + }) + ); + await Promise.all(updatePromises); + } + if (contributionsToCreate.length > 0) { + const contributionsToCreateData = contributionsToCreate.map((contribution: any) => ({ + ...contribution, + memberUid, + })); + await tx.projectContribution.createMany({ + data: contributionsToCreateData, + }); + } + } + + /** + * Update member email and handle external account deletion if email changes. + * + * @param uidToEdit - The unique identifier of the member being updated. + * @param isEmailChange - Boolean flag indicating if the email has changed. + * @param isExternalIdAvailable - Boolean flag indicating if an external ID is available. + * @param memberData - The object containing the updated member data. + * @param existingMember - The object containing the existing member data. + * @returns {Promise} + */ + async updateMemberEmailChange( + uidToEdit: string, + isEmailChange: boolean, + isExternalIdAvailable: boolean, + memberData, + existingMember + ): Promise { + try { + this.logger.info(`Member update request - attributes updated, requestId -> ${uidToEdit}`); + if (isEmailChange && isExternalIdAvailable) { + this.logger.info( + `Member update request - Initiating email change - newEmail: ${memberData.email}, oldEmail: ${existingMember.email}, externalId: ${existingMember.externalId}, requestId -> ${uidToEdit}` + ); + const clientToken = await this.fetchAccessToken(); + const headers = { + Authorization: `Bearer ${clientToken}`, + }; + // Attempt to delete the external account associated with the old email + await this.deleteExternalAccount(existingMember.externalId, headers, uidToEdit); + this.logger.info(`Member update request - Email changed, requestId -> ${uidToEdit}`); + } + } catch (error) { + this.logger.error(`Member update request - Failed to update email, requestId -> ${uidToEdit}, error -> ${error.message}`); + throw new Error(`Email update failed: ${error.message}`); + } + } - async isMemberPartOfTeams(member, teams) { - return member.teamMemberRoles.some((role) => { - return teams.includes(role.teamUid) + /** + * Deletes the external account associated with a given external ID. + * + * @param externalId - The external ID of the account to be deleted. + * @param headers - The authorization headers for the request. + * @param uidToEdit - The unique identifier of the member being updated. + * @returns {Promise} + */ + async deleteExternalAccount(externalId: string, headers: any, uidToEdit: string): Promise { + try { + await axios.delete(`${process.env.AUTH_API_URL}/admin/accounts/external/${externalId}`, { + headers: headers, + }); + this.logger.info(`External account deleted, externalId -> ${externalId}, requestId -> ${uidToEdit}`); + } catch (error) { + // Handle cases where the external account is not found (404) and other errors + if (error?.response?.status === 404) { + this.logger.error(`External account not found for deletion, externalId -> ${externalId}, requestId -> ${uidToEdit}`); + } else { + this.logger.error(`Failed to delete external account, externalId -> ${externalId}, requestId -> ${uidToEdit}, error -> ${error.message}`); + throw error; + } + } + } + + /** + * Fetches the access token from the authentication service. + * + * @returns {Promise} - The client token used for authorization. + * @throws {Error} - Throws an error if token retrieval fails. + */ + async fetchAccessToken(): Promise { + try { + const response = await axios.post(`${process.env.AUTH_API_URL}/auth/token`, { + client_id: process.env.AUTH_APP_CLIENT_ID, + client_secret: process.env.AUTH_APP_CLIENT_SECRET, + grant_type: 'client_credentials', + grantTypes: ['client_credentials', 'authorization_code', 'refresh_token'], + }); + + return response.data.access_token; + } catch (error) { + throw new Error('Failed to retrieve client token'); + } + } + + /** + * Validates if an email change is required and whether the new email is unique. + * @param isEmailChange - Flag indicating if email is being changed. + * @param transactionType - Prisma transaction client or Prisma client. + * @param newEmail - The new email to validate. + */ + async validateEmailChange(isEmailChange, transactionType, newEmail) { + if (isEmailChange) { + const foundUser = await transactionType.member.findUnique({ where: { email: newEmail.toLowerCase().trim() } }); + if (foundUser?.email) { + throw new BadRequestException('Email already exists. Please try again with a different email.'); + } + } + } + + /** + * Logs the participant request in the participants request table for audit and tracking purposes. + * + * @param tx - The transaction client to ensure atomicity + * @param requestorEmail - Email of the requestor who is updating the team + * @param newMemberData - The new data being applied to the team + * @param referenceUid - Unique identifier of the existing team to be referenced + */ + private async logParticipantRequest( + requestorEmail: string, + newMemberData, + referenceUid: string, + tx: Prisma.TransactionClient, + ): Promise { + await this.participantsRequestService.add({ + status: 'AUTOAPPROVED', + requesterEmailId: requestorEmail, + referenceUid, + uniqueIdentifier: newMemberData?.email || '', + participantType: 'MEMBER', + newData: { ...newMemberData }, + }, + tx + ); + } + + /** + * Updates the member's preferences and resets the cache. + * + * @param id - The UID of the member. + * @param preferences - The new preferences data to be updated. + * @returns The updated member object. + */ + async updatePreference(id: string, preferences: any): Promise { + const updatedMember = await this.updateMemberByUid(id, { preferences }); + await this.cacheService.reset(); + return updatedMember; + } + + /** + * Executes post-update actions such as resetting the cache and triggering Airtable sync. + * This ensures that the system is up-to-date with the latest changes. + */ + private async postUpdateActions(): Promise { + await this.cacheService.reset(); + await this.forestadminService.triggerAirtableSync(); + } + + /** + * Retrieves member preferences along with social media handlers. + * + * @param uid - The UID of the member. + * @returns An object containing the member's preferences and handler statuses. + */ + async getPreferences(uid: string): Promise { + const member = await this.prisma.member.findUnique({ + where: { uid }, + select: { + email: true, + githubHandler: true, + telegramHandler: true, + discordHandler: true, + linkedinHandler: true, + twitterHandler: true, + preferences: true, + }, }); + return this.buildPreferenceResponse(member); + } + + /** + * Helper function to build the preference response object. + * + * @param member - The member data. + * @returns The processed preferences and handlers. + */ + private buildPreferenceResponse(member: any): any { + const preferences = { ...member.preferences }; + if (!preferences) { + preferences.isNull = true; + } else { + preferences.isNull = false; + } + preferences.email = !!member.email; + preferences.github = !!member.githubHandler; + preferences.telegram = !!member.telegramHandler; + preferences.discord = !!member.discordHandler; + preferences.linkedin = !!member.linkedinHandler; + preferences.twitter = !!member.twitterHandler; + return preferences; + } + + /** + * Checks if the given member is a team lead for the provided team UID. + * + * @param member - The member object. + * @param teamUid - The UID of the team. + * @returns True if the member is leading the team, false otherwise. + */ + async isMemberLeadTeam(member: Member, teamUid: string): Promise { + const userInfo = await this.memberToUserInfo(member); + return userInfo.leadingTeams.includes(teamUid); + } + + /** + * Checks if the given member is a part of the provided teams. + * + * @param member - The member object. + * @param teams - An array of team UIDs. + * @returns True if the member belongs to any of the provided teams, false otherwise. + */ + isMemberPartOfTeams(member, teams: string[]): boolean { + return member.teamMemberRoles.some((role) => teams.includes(role.teamUid)); + } + + /** + * Checks if the member is an admin. + * + * @param member - The member object. + * @returns True if the member is a directory admin, false otherwise. + */ + checkIfAdminUser(member): boolean { + return member.memberRoles.some((role) => role.name === 'DIRECTORYADMIN'); } /** @@ -701,27 +1291,181 @@ export class MembersService { return { }; } - async updateTelegramIfChanged(member, telegram, tx?:Prisma.TransactionClient) { - if (member.telegramHandler != telegram) { - member = await (tx || this.prisma).member.update({ - where: { uid: member.uid }, - data: { - telegramHandler: telegram - } - }); + /** + * Updates the member's field if the value has changed. + * + * @param member - The member object to check for updates. + * @param field - The field in the member object that may be updated. + * @param newValue - The new value to update the field with. + * @param tx - Optional transaction client. + * @returns Updated member object if a change was made, otherwise the original member object. + */ + private async updateFieldIfChanged( + member: Member, + field: keyof Member, + newValue: string, + tx?: Prisma.TransactionClient + ): Promise { + if (member[field] !== newValue) { + member = await this.updateMemberByUid(member.uid, { [field]: newValue }, tx); } return member; } - async updateOfficeHoursIfChanged(member, officeHours, tx?:Prisma.TransactionClient) { - if (member.officeHours != officeHours) { - member = await (tx || this.prisma).member.update({ - where: { uid: member.uid }, - data: { - officeHours - } + /** + * Updates the member's telegram handler if it has changed. + * + * @param member - The member object to check for updates. + * @param telegram - The new telegram handler value. + * @param tx - Optional transaction client. + * @returns Updated member object if a change was made, otherwise the original member object. + */ + async updateTelegramIfChanged( + member: Member, + telegram: string, + tx?: Prisma.TransactionClient + ): Promise { + return await this.updateFieldIfChanged(member, 'telegramHandler', telegram, tx); + } + + /** + * Updates the member's office hours if it has changed. + * + * @param member - The member object to check for updates. + * @param officeHours - The new office hours value. + * @param tx - Optional transaction client. + * @returns Updated member object if a change was made, otherwise the original member object. + */ + async updateOfficeHoursIfChanged( + member: Member, + officeHours: string, + tx?: Prisma.TransactionClient + ): Promise { + return await this.updateFieldIfChanged(member, 'officeHours', officeHours, tx); + } + + /** + * Handles database-related errors specifically for the Member entity. + * Logs the error and throws an appropriate HTTP exception based on the error type. + * + * @param {any} error - The error object thrown by Prisma or other services. + * @param {string} [message] - An optional message to provide additional context, + * such as the member UID when an entity is not found. + * @throws {ConflictException} - If there's a unique key constraint violation. + * @throws {BadRequestException} - If there's a foreign key constraint violation or validation error. + * @throws {NotFoundException} - If a member is not found with the provided UID. + */ + private handleErrors(error, message?: string) { + this.logger.error(error); + if (error instanceof Prisma.PrismaClientKnownRequestError) { + switch (error?.code) { + case 'P2002': + throw new ConflictException( + 'Unique key constraint error on Member:', + error.message + ); + case 'P2003': + throw new BadRequestException( + 'Foreign key constraint error on Member', + error.message + ); + case 'P2025': + throw new NotFoundException('Member not found with uid: ' + message); + default: + throw error; + } + } else if (error instanceof Prisma.PrismaClientValidationError) { + throw new BadRequestException('Database field validation error on Member', error.message); + } + return error; + } + + + async insertManyWithLocationsFromAirtable( + airtableMembers: z.infer[] + ) { + const skills = await this.prisma.skill.findMany(); + const images = await this.prisma.image.findMany(); + + for (const member of airtableMembers) { + if (!member.fields?.Name) { + continue; + } + + let image; + + if (member.fields['Profile picture']) { + const ppf = member.fields['Profile picture'][0]; + + const hashedPpf = ppf.filename + ? hashFileName(`${path.parse(ppf.filename).name}-${ppf.id}`) + : ''; + + image = + images.find( + (image) => path.parse(image.filename).name === hashedPpf + ) || + (await this.fileMigrationService.migrateFile({ + id: ppf.id || '', + url: ppf.url || '', + filename: ppf.filename || '', + size: ppf.size || 0, + type: ppf.type || '', + height: ppf.height || 0, + width: ppf.width || 0, + })); + } + + const optionalFieldsToAdd = Object.entries({ + email: 'Email', + githubHandler: 'Github Handle', + discordHandler: 'Discord handle', + twitterHandler: 'Twitter', + officeHours: 'Office hours link', + }).reduce( + (optionalFields, [prismaField, airtableField]) => ({ + ...optionalFields, + ...(member.fields?.[airtableField] && { + [prismaField]: member.fields?.[airtableField], + }), + }), + {} + ); + + const manyToManyRelations = { + skills: { + connect: skills + .filter( + (skill) => + !!member.fields?.['Skills'] && + member.fields?.['Skills'].includes(skill.title) + ) + .map((skill) => ({ id: skill.id })), + }, + }; + + const { location } = await this.locationTransferService.transferLocation( + member + ); + + await this.prisma.member.upsert({ + where: { + airtableRecId: member.id, + }, + update: { + ...optionalFieldsToAdd, + ...manyToManyRelations, + }, + create: { + airtableRecId: member.id, + name: member.fields.Name, + plnFriend: member.fields['Friend of PLN'] || false, + locationUid: location ? location?.uid : null, + imageUid: image?.uid, + ...optionalFieldsToAdd, + ...manyToManyRelations, + }, }); } - return member; } } diff --git a/apps/web-api/src/participants-request/participants-request.controller.ts b/apps/web-api/src/participants-request/participants-request.controller.ts index d6672427a..2ca803a32 100644 --- a/apps/web-api/src/participants-request/participants-request.controller.ts +++ b/apps/web-api/src/participants-request/participants-request.controller.ts @@ -1,85 +1,43 @@ -/* eslint-disable prettier/prettier */ -import { - Body, - Controller, - ForbiddenException, - Get, - Param, - Post, - Query, - Req, - BadRequestException -} from '@nestjs/common'; -import { ParticipantType } from '@prisma/client'; +import { Body, Controller, Get, Post, Query, UsePipes } from '@nestjs/common'; import { ParticipantsRequestService } from './participants-request.service'; -import { GoogleRecaptchaGuard } from '../guards/google-recaptcha.guard'; -import { - ParticipantRequestTeamSchema, - ParticipantRequestMemberSchema, -} from '../../../../libs/contracts/src/schema/participants-request'; import { NoCache } from '../decorators/no-cache.decorator'; +import { ParticipantsReqValidationPipe } from '../pipes/participant-request-validation.pipe'; +import { FindUniqueIdentiferDto } from 'libs/contracts/src/schema/participants-request'; + @Controller('v1/participants-request') +@NoCache() export class ParticipantsRequestController { constructor( private readonly participantsRequestService: ParticipantsRequestService ) {} - @Get() - @NoCache() - async findAll(@Query() query) { - const result = await this.participantsRequestService.getAll(query); - return result; - } - - @Get(':uid') - @NoCache() - async findOne(@Param() params) { - const result = await this.participantsRequestService.getByUid(params.uid); - return result; + /** + * Add a new entry to the Participants request table. + * @param body - The request data to be added to the participants request table. + * @returns A promise with the participants request entry that was added. + */ + @Post("/") + @UsePipes(new ParticipantsReqValidationPipe()) + async addRequest(@Body() body) { + const uniqueIdentifier = this.participantsRequestService.getUniqueIdentifier(body); + // Validate unique identifier existence + await this.participantsRequestService.validateUniqueIdentifier(body.participantType, uniqueIdentifier); + await this.participantsRequestService.validateParticipantRequest(body); + return await this.participantsRequestService.addRequest(body); } - @Post() - async addRequest(@Body() body, @Req() req) { - const postData = body; - const participantType = body.participantType; - const referenceUid = body.referenceUid; - - if ( - participantType === ParticipantType.MEMBER.toString() && - !ParticipantRequestMemberSchema.safeParse(postData).success - ) { - throw new BadRequestException("Validation failed") - } else if ( - participantType === ParticipantType.TEAM.toString() && - !ParticipantRequestTeamSchema.safeParse(postData).success - ) { - throw new BadRequestException("Validation failed") - } else if ( - participantType !== ParticipantType.TEAM.toString() && - participantType !== ParticipantType.MEMBER.toString() - ) { - throw new BadRequestException("Validation failed") - } - - const checkDuplicate = await this.participantsRequestService.findDuplicates( - postData?.uniqueIdentifier, - participantType, - referenceUid, - '' + /** + * Check if the given identifier already exists in participants-request, members, or teams tables. + * @param queryParams - The query parameters containing the identifier and its type. + * @returns A promise indicating whether the identifier already exists. + */ + @Get("/unique-identifier") + async findMatchingIdentifier( + @Query() queryParams: FindUniqueIdentiferDto + ) { + return await this.participantsRequestService.checkIfIdentifierAlreadyExist( + queryParams.type, + queryParams.identifier ); - if ( - checkDuplicate && - (checkDuplicate.isUniqueIdentifierExist || - checkDuplicate.isRequestPending) - ) { - const text = - participantType === ParticipantType.MEMBER - ? 'Member email' - : 'Team name'; - throw new BadRequestException(`${text} already exists`); - } - - const result = await this.participantsRequestService.addRequest(postData); - return result; } } diff --git a/apps/web-api/src/participants-request/participants-request.module.ts b/apps/web-api/src/participants-request/participants-request.module.ts index db9c3647f..3356cfbf4 100644 --- a/apps/web-api/src/participants-request/participants-request.module.ts +++ b/apps/web-api/src/participants-request/participants-request.module.ts @@ -1,24 +1,18 @@ /* eslint-disable prettier/prettier */ -import { CacheModule, Module } from '@nestjs/common'; -import { AwsService } from '../utils/aws/aws.service'; -import { LocationTransferService } from '../utils/location-transfer/location-transfer.service'; +import {Module, forwardRef } from '@nestjs/common'; +import { MembersModule } from '../members/members.module'; +import { SharedModule } from '../shared/shared.module'; +import { TeamsModule } from '../teams/teams.module'; import { ParticipantsRequestController } from './participants-request.controller'; import { ParticipantsRequestService } from './participants-request.service'; -import { UniqueIdentifier } from './unique-identifier/unique-identifier.controller'; -import { RedisService } from '../utils/redis/redis.service'; -import { SlackService } from '../utils/slack/slack.service'; -import { ForestAdminService } from '../utils/forest-admin/forest-admin.service'; +import { NotificationService } from '../utils/notification/notification.service'; @Module({ - imports: [], - controllers: [ParticipantsRequestController, UniqueIdentifier], + imports: [forwardRef(() => MembersModule), forwardRef(() => TeamsModule), SharedModule], + controllers: [ParticipantsRequestController], providers: [ ParticipantsRequestService, - LocationTransferService, - AwsService, - RedisService, - SlackService, - ForestAdminService, + NotificationService ], - exports: [ParticipantsRequestService] + exports: [ParticipantsRequestService, NotificationService] }) export class ParticipantsRequestModule {} diff --git a/apps/web-api/src/participants-request/participants-request.service.ts b/apps/web-api/src/participants-request/participants-request.service.ts index 94e0612e2..e19b70071 100644 --- a/apps/web-api/src/participants-request/participants-request.service.ts +++ b/apps/web-api/src/participants-request/participants-request.service.ts @@ -1,1065 +1,414 @@ /* eslint-disable prettier/prettier */ -import { - BadRequestException, - ForbiddenException, - Inject, - Injectable, - CACHE_MANAGER, - CacheModule, - UnauthorizedException, +import { + BadRequestException, + ConflictException, + NotFoundException, + Inject, + Injectable, + CACHE_MANAGER, + forwardRef } from '@nestjs/common'; - -import { - ApprovalStatus, - ParticipantType, - Prisma, - PrismaClient, -} from '@prisma/client'; +import { ApprovalStatus, ParticipantType } from '@prisma/client'; +import { Cache } from 'cache-manager'; +import { Prisma, ParticipantsRequest, PrismaClient } from '@prisma/client'; +import { generateProfileURL } from '../utils/helper/helper'; +import { LogService } from '../shared/log.service'; import { PrismaService } from '../shared/prisma.service'; -import { AwsService } from '../utils/aws/aws.service'; +import { MembersService } from '../members/members.service'; +import { TeamsService } from '../teams/teams.service'; +import { NotificationService } from '../utils/notification/notification.service'; import { LocationTransferService } from '../utils/location-transfer/location-transfer.service'; -import { RedisService } from '../utils/redis/redis.service'; -import { SlackService } from '../utils/slack/slack.service'; import { ForestAdminService } from '../utils/forest-admin/forest-admin.service'; -import { getRandomId, generateProfileURL } from '../utils/helper/helper'; -import axios from 'axios'; -import { LogService } from '../shared/log.service'; -import { Cache } from 'cache-manager'; -import { DEFAULT_MEMBER_ROLES } from '../utils/constants'; @Injectable() export class ParticipantsRequestService { constructor( private prisma: PrismaService, + private logger: LogService, private locationTransferService: LocationTransferService, - private awsService: AwsService, - private redisService: RedisService, - private slackService: SlackService, private forestAdminService: ForestAdminService, - private logger: LogService, - @Inject(CACHE_MANAGER) private cacheService: Cache, + private notificationService: NotificationService, + @Inject(CACHE_MANAGER) + private cacheService: Cache, + @Inject(forwardRef(() => MembersService)) + private membersService: MembersService, + @Inject(forwardRef(() => TeamsService)) + private teamsService: TeamsService, ) {} - async getAll(userQuery) { - const filters = {}; - - if (userQuery.participantType) { - filters['participantType'] = { equals: userQuery.participantType }; - } - - if (userQuery.status) { - filters['status'] = { equals: userQuery.status }; - } - - if (userQuery.uniqueIdentifier) { - filters['uniqueIdentifier'] = { equals: userQuery.uniqueIdentifier }; - } - - if (userQuery.requestType && userQuery.requestType === 'edit') { - filters['referenceUid'] = { not: null }; - } - - if (userQuery.requestType && userQuery.requestType === 'new') { - filters['referenceUid'] = { equals: null }; - } - - if (userQuery.referenceUid) { - filters['referenceUid'] = { equals: userQuery.referenceUid }; + /** + * Find all participant requests based on the query. + * Filters are dynamically applied based on the presence of query parameters. + * + * @param userQuery - The query object containing filtering options like participantType, status, etc. + * @returns A promise that resolves with the filtered participant requests + */ + async getAll(userQuery): Promise { + try { + const filters = { + ...(userQuery.participantType && { + participantType: { equals: userQuery.participantType }, + }), + ...(userQuery.status && { status: { equals: userQuery.status } }), + ...(userQuery.uniqueIdentifier && { + uniqueIdentifier: { equals: userQuery.uniqueIdentifier } + }), + ...('edit' === userQuery.requestType && { referenceUid: { not: null } }), + ...('new' === userQuery.requestType && { referenceUid: { equals: null } }), + ...(userQuery.referenceUid && { + referenceUid: { equals: userQuery.referenceUid } + }) + }; + return await this.prisma.participantsRequest.findMany({ + where: filters, + orderBy: { createdAt: 'desc' }, + }); + } catch(err) { + return this.handleErrors(err) } - - const results = await this.prisma.participantsRequest.findMany({ - where: filters, - orderBy: { createdAt: 'desc' }, - }); - return results; - } - - async addAutoApprovalEntry(tx, newEntry) { - await tx.participantsRequest.create({ - data: {...newEntry} - }) } - async getByUid(uid) { - const result = await this.prisma.participantsRequest.findUnique({ - where: { uid: uid }, - }); - return result; - } - - async findMemberByEmail(userEmail) { - const foundMember = await this.prisma.member.findUnique({ - where: { - email: userEmail, - }, - include: { - memberRoles: true, - teamMemberRoles: true, - }, - }); - - if (!foundMember) { - return null; + /** + * Add a new entry to the participants request table. + * + * @param tx - The transactional Prisma client + * @param newEntry - The data for the new participants request entry + * @returns A promise that resolves with the newly created entry + */ + async add( + newEntry: Prisma.ParticipantsRequestUncheckedCreateInput, + tx?: Prisma.TransactionClient, + ): Promise { + try { + return await (tx || this.prisma).participantsRequest.create({ + data: { ...newEntry }, + }); + } catch(err) { + return this.handleErrors(err) } - - const roleNames = foundMember.memberRoles.map((m) => m.name); - const isDirectoryAdmin = roleNames.includes('DIRECTORYADMIN'); - - const formattedMemberDetails = { - ...foundMember, - isDirectoryAdmin, - roleNames, - leadingTeams: foundMember.teamMemberRoles - .filter((role) => role.teamLead) - .map((role) => role.teamUid), - }; - - return formattedMemberDetails; } - async findDuplicates(uniqueIdentifier, participantType, uid, requestId) { - let itemInRequest = await this.prisma.participantsRequest.findMany({ - where: { - participantType, - status: ApprovalStatus.PENDING, - OR: [{ referenceUid: uid }, { uniqueIdentifier }], - }, - }); - itemInRequest = itemInRequest?.filter((item) => item.uid !== requestId); - if (itemInRequest.length === 0) { - if (participantType === 'TEAM') { - let teamResult = await this.prisma.team.findMany({ - where: { - name: uniqueIdentifier, - }, - }); - teamResult = teamResult?.filter((item) => item.uid !== uid); - if (teamResult.length > 0) { - return { isRequestPending: false, isUniqueIdentifierExist: true }; - } else { - return { isRequestPending: false, isUniqueIdentifierExist: false }; - } - } else { - let memResult = await this.prisma.member.findMany({ - where: { - email: uniqueIdentifier.toLowerCase(), - }, - }); - memResult = memResult?.filter((item) => item.uid !== uid); - if (memResult.length > 0) { - return { isRequestPending: false, isUniqueIdentifierExist: true }; - } else { - return { isRequestPending: false, isUniqueIdentifierExist: false }; - } - } - } else { - return { isRequestPending: true }; + /** + * Find a single entry from the participants request table that matches the provided UID. + * + * @param uid - The UID of the participants request entry to be fetched + * @returns A promise that resolves with the matching entry or null if not found + */ + async findOneByUid(uid: string): Promise { + try { + return await this.prisma.participantsRequest.findUnique({ + where: { uid }, + }); + } catch(err) { + return this.handleErrors(err, uid) } } - async addRequest( - requestData, - disableNotification = false, - transactionType: Prisma.TransactionClient | PrismaClient = this.prisma - ) { - const uniqueIdentifier = - requestData.participantType === 'TEAM' - ? requestData.newData.name - : requestData.newData.email.toLowerCase().trim(); - const postData = { ...requestData, uniqueIdentifier }; - requestData[uniqueIdentifier] = uniqueIdentifier; - if (requestData.participantType === ParticipantType.MEMBER.toString()) { - const { city, country, region } = postData.newData; - if (city || country || region) { - const result: any = await this.locationTransferService.fetchLocation( - city, - country, - null, - region, - null - ); - if (!result || !result?.location) { - throw new BadRequestException('Invalid Location info'); - } + /** + * Check if any entry exists in the participants-request table and the members/teams table + * for the given identifier. + * + * @param type - The participant type (either TEAM or MEMBER) + * @param identifier - The unique identifier (team name or member email) + * @returns A promise that resolves with an object containing flags indicating whether a request is pending and whether the identifier exists + */ + async checkIfIdentifierAlreadyExist( + type: ParticipantType, + identifier: string + ): Promise<{ + isRequestPending: boolean; + isUniqueIdentifierExist: boolean + }> { + try { + const existingRequest = await this.prisma.participantsRequest.findFirst({ + where: { + status: ApprovalStatus.PENDING, + participantType: type, + uniqueIdentifier: identifier, + }, + }); + if (existingRequest) { + return { isRequestPending: true, isUniqueIdentifierExist: false }; + } + const existingEntry = + type === ParticipantType.TEAM + ? await this.teamsService.findTeamByName(identifier) + : await this.membersService.findMemberByEmail(identifier); + if (existingEntry) { + return { isRequestPending: false, isUniqueIdentifierExist: true }; } + return { isRequestPending: false, isUniqueIdentifierExist: false }; + } + catch(err) { + return this.handleErrors(err) } + } - const slackConfig = { - requestLabel: '', - url: '', - name: requestData.newData.name, - }; - const result: any = await transactionType.participantsRequest.create({ - data: { ...postData }, - }); - if ( - result.participantType === ParticipantType.MEMBER.toString() && - result.referenceUid === null - ) { - slackConfig.requestLabel = 'New Labber Request'; - slackConfig.url = `${process.env.WEB_ADMIN_UI_BASE_URL}/member-view?id=${result.uid}`; - await this.awsService.sendEmail('NewMemberRequest', true, [], { - memberName: result.newData.name, - requestUid: result.uid, - adminSiteUrl: `${process.env.WEB_ADMIN_UI_BASE_URL}/member-view?id=${result.uid}`, - }); - } else if ( - result.participantType === ParticipantType.MEMBER.toString() && - result.referenceUid !== null && - !disableNotification - ) { - slackConfig.requestLabel = 'Edit Labber Request'; - slackConfig.url = `${process.env.WEB_ADMIN_UI_BASE_URL}/member-view?id=${result.uid}`; - await this.awsService.sendEmail('EditMemberRequest', true, [], { - memberName: result.newData.name, - requestUid: result.uid, - requesterEmailId: requestData.requesterEmailId, - adminSiteUrl: `${process.env.WEB_ADMIN_UI_BASE_URL}/member-view?id=${result.uid}`, - }); - } else if ( - result.participantType === ParticipantType.TEAM.toString() && - result.referenceUid === null - ) { - slackConfig.requestLabel = 'New Team Request'; - slackConfig.url = `${process.env.WEB_ADMIN_UI_BASE_URL}/team-view?id=${result.uid}`; - await this.awsService.sendEmail('NewTeamRequest', true, [], { - teamName: result.newData.name, - requestUid: result.uid, - adminSiteUrl: `${process.env.WEB_ADMIN_UI_BASE_URL}/team-view?id=${result.uid}`, + /** + * Update a participants request entry by UID. + * The function will omit specific fields (like uid, id, status, participantType) from being updated. + * + * @param participantRequest - The updated data for the participants request + * @param requestedUid - The UID of the participants request to update + * @returns A success response after updating the request + */ + async updateByUid( + uid: string, + participantRequest: Prisma.ParticipantsRequestUncheckedUpdateInput, + ):Promise { + try { + const formattedData = { ...participantRequest }; + delete formattedData.id; + delete formattedData.uid; + delete formattedData.status; + delete formattedData.participantType; + const result:ParticipantsRequest = await this.prisma.participantsRequest.update({ + where: { uid }, + data: formattedData, }); - } else if ( - result.participantType === ParticipantType.TEAM.toString() && - result.referenceUid !== null - ) { - slackConfig.requestLabel = 'Edit Team Request'; - slackConfig.url = `${process.env.WEB_ADMIN_UI_BASE_URL}/team-view?id=${result.uid}`; - if (!disableNotification) - await this.awsService.sendEmail('EditTeamRequest', true, [], { - teamName: result.newData.name, - teamUid: result.referenceUid, - requesterEmailId: requestData.requesterEmailId, - adminSiteUrl: `${process.env.WEB_ADMIN_UI_BASE_URL}/team-view?id=${result.uid}`, - }); + await this.cacheService.reset(); + return result; + } catch(err) { + return this.handleErrors(err) } - - if (!disableNotification) - await this.slackService.notifyToChannel(slackConfig); - await this.cacheService.reset() - return result; } - async updateRequest(newData, requestedUid) { - const formattedData = { ...newData }; - - // remove id and Uid if present - delete formattedData.id; - delete formattedData.uid; - delete formattedData.status; - delete formattedData.participantType; - await this.prisma.participantsRequest.update({ - where: { uid: requestedUid }, - data: { ...formattedData }, - }); - await this.cacheService.reset() - return { code: 1, message: 'success' }; - } - - async processRejectRequest(uidToReject) { - const dataFromDB: any = await this.prisma.participantsRequest.findUnique({ - where: { uid: uidToReject }, - }); - if (dataFromDB.status !== ApprovalStatus.PENDING.toString()) { - throw new BadRequestException('Request already processed'); + /** + * Process a reject operation on a pending participants request. + * If the request is not in a pending state, an exception is thrown. + * + * @param uidToReject - The UID of the participants request to reject + * @returns A success response after rejecting the request + * @throws BadRequestException if the request is already processed + */ + async rejectRequestByUid(uidToReject: string): Promise { + try { + const result:ParticipantsRequest = await this.prisma.participantsRequest.update({ + where: { uid: uidToReject }, + data: { status: ApprovalStatus.REJECTED } + }); + await this.cacheService.reset(); + return result; + } catch(err) { + return this.handleErrors(err) } - await this.prisma.participantsRequest.update({ - where: { uid: uidToReject }, - data: { status: ApprovalStatus.REJECTED }, - }); - await this.cacheService.reset() - return { code: 1, message: 'Success' }; } - async processMemberCreateRequest(uidToApprove) { - // Get - const dataFromDB: any = await this.prisma.participantsRequest.findUnique({ - where: { uid: uidToApprove }, - }); - - if (dataFromDB.status !== ApprovalStatus.PENDING.toString()) { - throw new BadRequestException('Request already processed'); - } - const dataToProcess: any = dataFromDB.newData; - const dataToSave: any = {}; - const slackConfig = { - requestLabel: '', - url: '', - name: dataToProcess.name, - }; - - // Mandatory fields - dataToSave['name'] = dataToProcess.name; - dataToSave['email'] = dataToProcess.email.toLowerCase().trim(); - - // Optional fields - dataToSave['githubHandler'] = dataToProcess.githubHandler; - dataToSave['discordHandler'] = dataToProcess.discordHandler; - dataToSave['twitterHandler'] = dataToProcess.twitterHandler; - dataToSave['linkedinHandler'] = dataToProcess.linkedinHandler; - dataToSave['telegramHandler'] = dataToProcess.telegramHandler; - dataToSave['officeHours'] = dataToProcess.officeHours; - dataToSave['moreDetails'] = dataToProcess.moreDetails; - dataToSave['plnStartDate'] = dataToProcess.plnStartDate; - dataToSave['openToWork'] = dataToProcess.openToWork; - - // Team member roles relational mapping - dataToSave['teamMemberRoles'] = { - createMany: { - data: dataToProcess.teamAndRoles.map((t) => { - return { - role: t.role, - mainTeam: false, - teamLead: false, - teamUid: t.teamUid, - roleTags: t.role?.split(',')?.map(item => item.trim()) - }; - }), - }, - }; - - // Save Experience if available - if(Array.isArray(dataToProcess.projectContributions) - && dataToProcess.projectContributions?.length > 0) { - dataToSave['projectContributions'] = { - createMany: { - data: dataToProcess.projectContributions - }, - }; - } - - // Skills relation mapping - dataToSave['skills'] = { - connect: dataToProcess.skills.map((s) => { - return { uid: s.uid }; - }), - }; - - // Image Mapping - if (dataToProcess.imageUid) { - dataToSave['image'] = { connect: { uid: dataToProcess.imageUid } }; - } - - // Unique Location Uid needs to be formulated based on city, country & region using google places api and mapped to member - const { city, country, region } = dataToProcess; - if (city || country || region) { - const result: any = await this.locationTransferService.fetchLocation( - city, - country, - null, - region, - null - ); - if (result && result?.location?.placeId) { - const finalLocation: any = await this.prisma.location.upsert({ - where: { placeId: result?.location?.placeId }, - update: result?.location, - create: result?.location, - }); - if (finalLocation && finalLocation.uid) { - dataToSave['location'] = { connect: { uid: finalLocation.uid } }; - } + /** + * Approves a participant request by UID, creating either a new member or a team based on the request type. + * + * 1. Validates and processes the new data in the participant request (either MEMBER or TEAM). + * 2. Uses a transaction to: + * - Create a new member or team based on the `participantType`. + * - Update the participant request status to `APPROVED`. + * 3. Sends a notification based on the type of participant (member or team) after creation. + * 4. Resets the cache and triggers an Airtable synchronization. + * + * @param uidToApprove - The unique identifier of the participant request to approve. + * @param participantsRequest - The participant request data containing details of the request. + * @returns The updated participant request with the status set to `APPROVED`. + */ + private async approveRequestByUid( + uidToApprove: string, + participantsRequest: ParticipantsRequest + ): Promise { + let result; + let createdItem; + const dataToProcess: any = participantsRequest; + const participantType = participantsRequest.participantType; + // Add new member or team and update status to approved + await this.prisma.$transaction(async (tx) => { + if (participantType === 'MEMBER') { + dataToProcess.requesterEmailId = dataToProcess.newData.email.toLowerCase().trim(); + createdItem = await this.membersService.createMemberFromParticipantsRequest( + dataToProcess, + tx + ); } else { - throw new BadRequestException('Invalid Location info'); - } - } - - // Insert member details - const newMember = await this.prisma.member.create({ - data: { ...dataToSave }, - }); - await this.prisma.participantsRequest.update({ - where: { uid: uidToApprove }, - data: { status: ApprovalStatus.APPROVED }, - }); - await this.awsService.sendEmail('MemberCreated', true, [], { - memberName: dataToProcess.name, - memberUid: newMember.uid, - adminSiteUrl: `${process.env.WEB_UI_BASE_URL}/members/${ - newMember.uid - }?utm_source=notification&utm_medium=email&utm_code=${getRandomId()}`, - }); - await this.awsService.sendEmail( - 'NewMemberSuccess', - false, - [dataToSave.email], - { - memberName: dataToProcess.name, - memberProfileLink: `${process.env.WEB_UI_BASE_URL}/members/${ - newMember.uid - }?utm_source=notification&utm_medium=email&utm_code=${getRandomId()}`, + createdItem = await this.teamsService.createTeamFromParticipantsRequest( + dataToProcess, + tx + ); } - ); - slackConfig.requestLabel = 'New Labber Added'; - slackConfig.url = `${process.env.WEB_UI_BASE_URL}/members/${ - newMember.uid - }?utm_source=notification&utm_medium=slack&utm_code=${getRandomId()}`; - await this.slackService.notifyToChannel(slackConfig); - await this.cacheService.reset() - //await this.forestAdminService.triggerAirtableSync(); - return { code: 1, message: 'Success' }; - } - - async processMemberEditRequest( - uidToEdit, - disableNotification = false, - isAutoApproval = false, - isDirectoryAdmin = false, - transactionType: Prisma.TransactionClient | PrismaClient = this.prisma - ) { - // Get - const dataFromDB: any = - await transactionType.participantsRequest.findUnique({ - where: { uid: uidToEdit }, + result = await tx.participantsRequest.update({ + where: { uid: uidToApprove }, + data: { status: ApprovalStatus.APPROVED }, }); - if (dataFromDB?.status !== ApprovalStatus.PENDING.toString()) { - throw new BadRequestException('Request already processed'); - } - const existingData: any = await transactionType.member.findUnique({ - where: { uid: dataFromDB.referenceUid }, - include: { - image: true, - location: true, - skills: true, - teamMemberRoles: true, - memberRoles: true, - projectContributions: true - }, }); - const dataToProcess = dataFromDB?.newData; - const dataToSave: any = {}; - const slackConfig = { - requestLabel: '', - url: '', - name: dataToProcess.name, - }; - - const isEmailChange = existingData.email !== dataToProcess.email ? true: false; - if(isEmailChange) { - const foundUser: any = await transactionType.member.findUnique({where: {email: dataToProcess.email.toLowerCase().trim()}}); - if(foundUser && foundUser.email) { - throw new BadRequestException("Email already exists. Please try again with different email") - } - } - this.logger.info(`Member update request - Initiaing update for member uid - ${existingData.uid}, requestId -> ${uidToEdit}`) - // Mandatory fields - dataToSave['name'] = dataToProcess.name; - dataToSave['email'] = dataToProcess.email.toLowerCase().trim(); - - // Optional fields - dataToSave['githubHandler'] = dataToProcess.githubHandler; - dataToSave['discordHandler'] = dataToProcess.discordHandler; - dataToSave['twitterHandler'] = dataToProcess.twitterHandler; - dataToSave['linkedinHandler'] = dataToProcess.linkedinHandler; - dataToSave['telegramHandler'] = dataToProcess.telegramHandler; - dataToSave['officeHours'] = dataToProcess.officeHours; - dataToSave['moreDetails'] = dataToProcess.moreDetails; - dataToSave['plnStartDate'] = dataToProcess.plnStartDate; - dataToSave['openToWork'] = dataToProcess.openToWork; - dataToSave['bio'] = dataToProcess.bio; - - // Skills relation mapping - dataToSave['skills'] = { - set: dataToProcess.skills.map((s) => { - return { uid: s.uid }; - }), - }; - - // Image Mapping - if (dataToProcess.imageUid) { - dataToSave['image'] = { connect: { uid: dataToProcess.imageUid } }; - } else { - dataToSave['image'] = { disconnect: true }; - } - - // Unique Location Uid needs to be formulated based on city, country & region using google places api and mapped to member - const { city, country, region } = dataToProcess; - if (city || country || region) { - const result: any = await this.locationTransferService.fetchLocation( - city, - country, - null, - region, - null + if (participantType === 'MEMBER') { + await this.notificationService.notifyForMemberCreationApproval( + createdItem.name, + createdItem.uid, + dataToProcess.requesterEmailId ); - if (result && result?.location?.placeId) { - const finalLocation: any = await this.prisma.location.upsert({ - where: { placeId: result?.location?.placeId }, - update: result?.location, - create: result?.location, - }); - if ( - finalLocation && - finalLocation.uid && - existingData?.location?.uid !== finalLocation.uid - ) { - dataToSave['location'] = { connect: { uid: finalLocation.uid } }; - } - } else { - throw new BadRequestException('Invalid Location info'); - } } else { - dataToSave['location'] = { disconnect: true }; + await this.notificationService.notifyForTeamCreationApproval( + createdItem.name, + createdItem.uid, + participantsRequest.requesterEmailId + ); } + await this.cacheService.reset(); + await this.forestAdminService.triggerAirtableSync(); + return result; + } - if (transactionType === this.prisma) { - await this.prisma.$transaction(async (tx) => { - await this.processMemberEditChanges( - existingData, - dataFromDB, - dataToSave, - uidToEdit, - isAutoApproval, - tx - ); - }); + /** + * Approve/Reject request in participants-request table. + * @param statusToProcess + * @param uid + * @returns + */ + async processRequestByUid(uid:string, participantsRequest:ParticipantsRequest, statusToProcess) { + if (statusToProcess === ApprovalStatus.REJECTED) { + return await this.rejectRequestByUid(uid); } else { - await this.processMemberEditChanges( - existingData, - dataFromDB, - dataToSave, - uidToEdit, - isAutoApproval, - transactionType - ); + return await this.approveRequestByUid(uid, participantsRequest); } + } + /** + * Adds a new participants request. + * Validates the request data, checks for duplicate identifiers, + * and optionally sends notifications upon successful creation. + * + * @param {Prisma.ParticipantsRequestUncheckedCreateInput} requestData - The request data for adding a new participant. + * @param {boolean} [disableNotification=false] - Flag to disable notification sending. + * @param {Prisma.TransactionClient | PrismaClient} [tx=this.prisma] - Database transaction or client. + * @returns {Promise} - The newly created participant request. + * @throws {BadRequestException} - If validation fails or unique identifier already exists. + */ + async addRequest( + requestData: Prisma.ParticipantsRequestUncheckedCreateInput, + disableNotification: boolean = false, + tx: Prisma.TransactionClient | PrismaClient = this.prisma + ): Promise { + const uniqueIdentifier = this.getUniqueIdentifier(requestData); + const postData = { ...requestData, uniqueIdentifier }; + // Add the new request + const result: ParticipantsRequest = await this.add({ + ...postData + }, + tx + ); if (!disableNotification) { - await this.awsService.sendEmail('MemberEditRequestCompleted', true, [], { - memberName: dataToProcess.name, - }); - await this.awsService.sendEmail( - 'EditMemberSuccess', - false, - [dataFromDB.requesterEmailId], - { - memberName: dataToProcess.name, - memberProfileLink: `${process.env.WEB_UI_BASE_URL}/members/${ - dataFromDB.referenceUid - }?utm_source=notification&utm_medium=email&utm_code=${getRandomId()}`, - } - ); - slackConfig.requestLabel = 'Edit Labber Request Completed'; - slackConfig.url = `${process.env.WEB_UI_BASE_URL}/members/${ - dataFromDB.referenceUid - }?utm_source=notification&utm_medium=slack&utm_code=${getRandomId()}`; - await this.slackService.notifyToChannel(slackConfig); + this.notifyForCreate(result); } await this.cacheService.reset(); - // Send ack email to old & new email of member reg his/her email change. - if (isEmailChange && isDirectoryAdmin) { - const oldEmail = existingData.email; - const newEmail = dataToSave.email; - await this.awsService.sendEmail( - 'MemberEmailChangeAcknowledgement', - false, - [oldEmail, newEmail], - { - oldEmail, - newEmail, - memberName: dataToProcess.name, - profileURL: this.generateMemberProfileURL(existingData.uid), - loginURL: process.env.LOGIN_URL - } - ); - } - //await this.forestAdminService.triggerAirtableSync(); - return { code: 1, message: 'Success' }; + return result; } - async processMemberEditChanges( - existingData, - dataFromDB, - dataToSave, - uidToEdit, - isAutoApproval, - tx - ) { - const dataToProcess = dataFromDB?.newData; - const isEmailChange = - existingData.email !== dataToProcess.email ? true : false; - const isExternalIdAvailable = existingData.externalId ? true : false; - // Team member roles relational mapping - const oldTeamUids = [...existingData.teamMemberRoles].map((t) => t.teamUid); - const newTeamUids = [...dataToProcess.teamAndRoles].map((t) => t.teamUid); - const teamAndRolesUidsToDelete: any[] = [ - ...existingData.teamMemberRoles, - ].filter((t) => !newTeamUids.includes(t.teamUid)); - const teamAndRolesUidsToUpdate = [...dataToProcess.teamAndRoles].filter( - (t, index) => { - if (oldTeamUids.includes(t.teamUid)) { - const foundIndex = [...existingData.teamMemberRoles].findIndex( - (v) => v.teamUid === t.teamUid - ); - if (foundIndex > -1) { - const foundValue = [...existingData.teamMemberRoles][foundIndex]; - if (foundValue.role !== t.role) { - let foundDefaultRoleTag = false; - foundValue.roleTags?.some(tag => { - if (Object.keys(DEFAULT_MEMBER_ROLES).includes(tag)) { - foundDefaultRoleTag = true; - return true - } - }); - if (foundDefaultRoleTag) { - dataToProcess.teamAndRoles[index].roleTags = foundValue.roleTags; - } else { - dataToProcess.teamAndRoles[index].roleTags = - dataToProcess.teamAndRoles[index].role?.split(',')?.map(item => item.trim()); - } - return true; - } - } - } - return false; - } - ); - - const teamAndRolesUidsToCreate = [...dataToProcess.teamAndRoles].filter( - (t) => !oldTeamUids.includes(t.teamUid) - ); - - const promisesToDelete = teamAndRolesUidsToDelete.map((v) => - tx.teamMemberRole.delete({ - where: { - memberUid_teamUid: { - teamUid: v.teamUid, - memberUid: dataFromDB.referenceUid, - }, - }, - }) - ); - const promisesToUpdate = teamAndRolesUidsToUpdate.map((v) => - tx.teamMemberRole.update({ - where: { - memberUid_teamUid: { - teamUid: v.teamUid, - memberUid: dataFromDB.referenceUid, - }, - }, - data: { role: v.role, roleTags: v.roleTags }, - }) - ); - await Promise.all(promisesToDelete); - await Promise.all(promisesToUpdate); - await tx.teamMemberRole.createMany({ - data: teamAndRolesUidsToCreate.map((t) => { - return { - role: t.role, - mainTeam: false, - teamLead: false, - teamUid: t.teamUid, - memberUid: dataFromDB.referenceUid, - roleTags: t.role?.split(',')?.map(item => item.trim()) - }; - }), - }); - - const contributionsToCreate: any = dataToProcess.projectContributions - ?.filter(contribution => !contribution.uid); - const contributionIdsToDelete:any = []; - const contributionIdsToUpdate:any = []; - const contributionIds = dataToProcess.projectContributions - ?.filter(contribution => contribution.uid).map(contribution => contribution.uid); - - existingData.projectContributions?.map((contribution:any)=> { - if(!contributionIds.includes(contribution.uid)) { - contributionIdsToDelete.push(contribution.uid); - } else { - contributionIdsToUpdate.push(contribution.uid); + /** + * Validates the location information for a participant if provided. + * + * @param data - The participant data containing location details (city, country, region). + * @throws {BadRequestException} - If the location data is invalid. + */ + async validateLocation(data: any): Promise { + const { city, country, region } = data; + if (city || country || region) { + const result: any = await this.locationTransferService.fetchLocation(city, country, null, region, null); + if (!result || !result?.location) { + throw new BadRequestException('Invalid Location info'); } - }); - - const contributionToDelete = contributionIdsToDelete.map((uid) => - tx.projectContribution.delete({ - where: { - uid - } - }) - ); - const contributions = dataToProcess.projectContributions. - filter(contribution => contributionIdsToUpdate.includes(contribution.uid)); - const contributionsToUpdate = contributions.map((contribution) => - tx.projectContribution.update({ - where: { - uid: contribution.uid - }, - data: { - ...contribution - } - }) - ); - await Promise.all(contributionToDelete); - await Promise.all(contributionsToUpdate); - await tx.projectContribution.createMany({ - data: contributionsToCreate.map((contribution) => { - contribution.memberUid = dataFromDB.referenceUid; - return contribution; - }), - }); - - // Other member Changes - - await tx.member.update({ - where: { uid: dataFromDB.referenceUid }, - data: { - ...dataToSave, - ...(isEmailChange && isExternalIdAvailable && { externalId: null }), - }, - }); - - this.logger.info(`Member update request - attibutes updated, requestId -> ${uidToEdit}`) - if (isEmailChange && isExternalIdAvailable) { - // try { - this.logger.info(`Member update request - Initiating email change - newEmail - ${dataToSave.email}, oldEmail - ${existingData.email}, externalId - ${existingData.externalId}, requestId -> ${uidToEdit}`) - const response = await axios.post( - `${process.env.AUTH_API_URL}/auth/token`, - { - client_id: process.env.AUTH_APP_CLIENT_ID, - client_secret: process.env.AUTH_APP_CLIENT_SECRET, - grant_type: 'client_credentials', - grantTypes: [ - 'client_credentials', - 'authorization_code', - 'refresh_token', - ], - } - ); - - const clientToken = response.data.access_token; - const headers = { - Authorization: `Bearer ${clientToken}`, - }; - - await axios.delete( - `${process.env.AUTH_API_URL}/admin/accounts/external/${existingData.externalId}`, - { headers: headers } - ); - // } catch (e) { - // if (e?.response?.data?.message && e?.response.status === 404) { - // } else { - // throw e; - // } - // } - this.logger.info(`Member update request - Email changed, requestId -> ${uidToEdit}`) - } - // Updating status - await tx.participantsRequest.update({ - where: { uid: uidToEdit }, - data: { - status: isAutoApproval - ? ApprovalStatus.AUTOAPPROVED - : ApprovalStatus.APPROVED, - }, - }); - } - - async processTeamCreateRequest(uidToApprove) { - const dataFromDB: any = await this.prisma.participantsRequest.findUnique({ - where: { uid: uidToApprove }, - }); - if (dataFromDB.status !== ApprovalStatus.PENDING.toString()) { - throw new BadRequestException('Request already processed'); } - const dataToProcess: any = dataFromDB.newData; - const dataToSave: any = {}; - const slackConfig = { - requestLabel: '', - url: '', - name: dataToProcess.name, - }; - - // Mandatory fields - dataToSave['name'] = dataToProcess.name; - dataToSave['contactMethod'] = dataToProcess.contactMethod; - dataToSave['website'] = dataToProcess.website; - dataToSave['shortDescription'] = dataToProcess.shortDescription; - dataToSave['longDescription'] = dataToProcess.longDescription; - - // Non Mandatory Fields - dataToSave['twitterHandler'] = dataToProcess.twitterHandler; - dataToSave['linkedinHandler'] = dataToProcess.linkedinHandler; - dataToSave['telegramHandler'] = dataToProcess.telegramHandler; - dataToSave['airtableRecId'] = dataToProcess.airtableRecId; - dataToSave['blog'] = dataToProcess.blog; - dataToSave['officeHours'] = dataToProcess.officeHours; - dataToSave['shortDescription'] = dataToProcess.shortDescription; - dataToSave['longDescription'] = dataToProcess.longDescription; - dataToSave['moreDetails'] = dataToProcess.moreDetails; - - // Funding Stage Mapping - dataToSave['fundingStage'] = { - connect: { uid: dataToProcess.fundingStage.uid }, - }; - - // Industry Tag Mapping - dataToSave['industryTags'] = { - connect: dataToProcess.industryTags.map((i) => { - return { uid: i.uid }; - }), - }; - - // Technologies Mapping - if (dataToProcess.technologies && dataToProcess.technologies.length > 0) { - dataToSave['technologies'] = { - connect: dataToProcess.technologies.map((t) => { - return { uid: t.uid }; - }), - }; - } - - // focusAreas Mapping - dataToSave['teamFocusAreas'] = { - ...await this.createTeamWithFocusAreas(dataToProcess, this.prisma) - }; - - // Membership Sources Mapping - dataToSave['membershipSources'] = { - connect: dataToProcess.membershipSources.map((m) => { - return { uid: m.uid }; - }), - }; - - // Logo image Mapping - if (dataToProcess.logoUid) { - dataToSave['logo'] = { connect: { uid: dataToProcess.logoUid } }; - } - - const newTeam = await this.prisma.team.create({ data: { ...dataToSave } }); - await this.prisma.participantsRequest.update({ - where: { uid: uidToApprove }, - data: { status: ApprovalStatus.APPROVED }, - }); - await this.awsService.sendEmail('TeamCreated', true, [], { - teamName: dataToProcess.name, - teamUid: newTeam.uid, - adminSiteUrl: `${process.env.WEB_UI_BASE_URL}/teams/${ - newTeam.uid - }?utm_source=notification&utm_medium=email&utm_code=${getRandomId()}`, - }); - await this.awsService.sendEmail( - 'NewTeamSuccess', - false, - [dataFromDB.requesterEmailId], - { - teamName: dataToProcess.name, - teamProfileLink: `${process.env.WEB_UI_BASE_URL}/teams/${ - newTeam.uid - }?utm_source=notification&utm_medium=email&utm_code=${getRandomId()}`, - } - ); - slackConfig.requestLabel = 'New Team Added'; - slackConfig.url = `${process.env.WEB_UI_BASE_URL}/teams/${ - newTeam.uid - }?utm_source=notification&utm_medium=slack&utm_code=${getRandomId()}`; - await this.slackService.notifyToChannel(slackConfig); - await this.cacheService.reset() - //await this.forestAdminService.triggerAirtableSync(); - return { code: 1, message: 'Success' }; } - - async processTeamEditRequest( - uidToEdit, - disableNotification = false, - isAutoApproval = false, - transactionType: Prisma.TransactionClient | PrismaClient = this.prisma - ) { - const dataFromDB: any = await transactionType.participantsRequest.findUnique({ - where: { uid: uidToEdit }, - }); - if (dataFromDB.status !== ApprovalStatus.PENDING.toString()) { - throw new BadRequestException('Request already processed'); + + /** + * Extract unique identifier based on participant type. + * @param requestData + * @returns string + */ + getUniqueIdentifier(requestData): string { + return requestData.participantType === 'TEAM' + ? requestData.newData.name + : requestData.newData.email.toLowerCase().trim(); + } + + /** + * Validate if the unique identifier already exists. + * @param participantType + * @param uniqueIdentifier + * @throws BadRequestException if identifier already exists + */ + async validateUniqueIdentifier( + participantType: ParticipantType, + uniqueIdentifier: string + ): Promise { + const { isRequestPending, isUniqueIdentifierExist } = await this.checkIfIdentifierAlreadyExist( + participantType, + uniqueIdentifier + ); + if (isRequestPending || isUniqueIdentifierExist) { + const typeLabel = participantType === 'TEAM' ? 'Team name' : 'Member email'; + throw new BadRequestException(`${typeLabel} already exists`); } - const dataToProcess: any = dataFromDB.newData; - const dataToSave: any = {}; - - const slackConfig = { - requestLabel: '', - url: '', - name: dataToProcess.name, - }; - const existingData: any = await this.prisma.team.findUnique({ - where: { uid: dataFromDB.referenceUid }, - include: { - fundingStage: true, - industryTags: true, - logo: true, - membershipSources: true, - technologies: true, - }, - }); - - // Mandatory fields - dataToSave['name'] = dataToProcess.name; - dataToSave['contactMethod'] = dataToProcess.contactMethod; - dataToSave['website'] = dataToProcess.website; - dataToSave['shortDescription'] = dataToProcess.shortDescription; - dataToSave['longDescription'] = dataToProcess.longDescription; - - // Non Mandatory Fields - dataToSave['twitterHandler'] = dataToProcess.twitterHandler; - dataToSave['linkedinHandler'] = dataToProcess.linkedinHandler; - dataToSave['telegramHandler'] = dataToProcess.telegramHandler; - dataToSave['airtableRecId'] = dataToProcess.airtableRecId; - dataToSave['blog'] = dataToProcess.blog; - dataToSave['officeHours'] = dataToProcess.officeHours; - dataToSave['shortDescription'] = dataToProcess.shortDescription; - dataToSave['longDescription'] = dataToProcess.longDescription; - dataToSave['moreDetails'] = dataToProcess.moreDetails; - dataToSave['lastModifier'] = { - connect: { uid: dataToProcess.lastModifiedBy } - }; - - // Funding Stage Mapping - dataToSave['fundingStage'] = { - connect: { uid: dataToProcess.fundingStage.uid }, - }; - - // Logo image Mapping - if (dataToProcess.logoUid) { - dataToSave['logo'] = { connect: { uid: dataToProcess.logoUid } }; - } else { - dataToSave['logo'] = { disconnect: true }; + } + + /** + * Validate location for members or email for teams. + * @param requestData + * @throws BadRequestException if validation fails + */ + async validateParticipantRequest(requestData: any): Promise { + if (requestData.participantType === ParticipantType.MEMBER.toString()) { + await this.validateLocation(requestData.newData); } - - // Industry Tag Mapping - dataToSave['industryTags'] = { - set: dataToProcess.industryTags.map((i) => { - return { uid: i.uid }; - }), - }; - - // Technologies Mapping - if (dataToProcess.technologies) { - dataToSave['technologies'] = { - set: dataToProcess.technologies.map((t) => { - return { uid: t.uid }; - }), - }; + if (requestData.participantType === ParticipantType.TEAM.toString() && !requestData.requesterEmailId) { + throw new BadRequestException( + 'Requester email is required for team participation requests. Please provide a valid email address.' + ); } - - // Membership Sources Mapping - dataToSave['membershipSources'] = { - set: dataToProcess.membershipSources.map((m) => { - return { uid: m.uid }; - }), - }; - - // focusAreas Mapping - dataToSave['teamFocusAreas'] = { - ...await this.updateTeamWithFocusAreas(dataFromDB.referenceUid, dataToProcess, transactionType) - }; - if (transactionType === this.prisma) { - await this.prisma.$transaction(async (tx) => { - // Update data - await tx.team.update({ - where: { uid: dataFromDB.referenceUid }, - data: { ...dataToSave }, - }); - // Updating status - await tx.participantsRequest.update({ - where: { uid: uidToEdit }, - data: { - status: isAutoApproval - ? ApprovalStatus.AUTOAPPROVED - : ApprovalStatus.APPROVED, - }, - }); - }); + } + + /** + * Send notification based on the participant type. + * @param result + */ + private notifyForCreate(result: any): void { + if (result.participantType === ParticipantType.MEMBER.toString()) { + this.notificationService.notifyForCreateMember(result.newData.name, result.uid); } else { - await transactionType.team.update({ - where: { uid: dataFromDB.referenceUid }, - data: { ...dataToSave }, - }); - // Updating status - await transactionType.participantsRequest.update({ - where: { uid: uidToEdit }, - data: { - status: isAutoApproval - ? ApprovalStatus.AUTOAPPROVED - : ApprovalStatus.APPROVED, - }, - }); - } - - if (!disableNotification) { - await this.awsService.sendEmail('TeamEditRequestCompleted', true, [], { - teamName: dataToProcess.name, - }); - await this.awsService.sendEmail( - 'EditTeamSuccess', - false, - [dataFromDB.requesterEmailId], - { - teamName: dataToProcess.name, - teamProfileLink: `${process.env.WEB_UI_BASE_URL}/teams/${existingData.uid}`, - } - ); - slackConfig.requestLabel = 'Edit Team Request Completed '; - slackConfig.url = `${process.env.WEB_UI_BASE_URL}/teams/${existingData.uid}`; - await this.slackService.notifyToChannel(slackConfig); + this.notificationService.notifyForCreateTeam(result.newData.name, result.uid); } - await this.cacheService.reset() - //await this.forestAdminService.triggerAirtableSync(); - return { code: 1, message: 'Success' }; } - async createTeamWithFocusAreas(dataToProcess, transaction) { - if (dataToProcess.focusAreas && dataToProcess.focusAreas.length > 0) { - let teamFocusAreas:any = []; - const focusAreaHierarchies = await transaction.focusAreaHierarchy.findMany({ - where: { - subFocusAreaUid: { - in: dataToProcess.focusAreas.map(area => area.uid) - } - } - }); - focusAreaHierarchies.map(areaHierarchy => { - teamFocusAreas.push({ - focusAreaUid: areaHierarchy.subFocusAreaUid, - ancestorAreaUid: areaHierarchy.focusAreaUid - }); - }); - dataToProcess.focusAreas.map(area => { - teamFocusAreas.push({ - focusAreaUid: area.uid, - ancestorAreaUid: area.uid - }); - }); - return { - createMany: { - data: teamFocusAreas - } + /** + * Handles database-related errors specifically for the Participant entity. + * Logs the error and throws an appropriate HTTP exception based on the error type. + * + * @param {any} error - The error object thrown by Prisma or other services. + * @param {string} [message] - An optional message to provide additional context, + * such as the participant UID when an entity is not found. + * @throws {ConflictException} - If there's a unique key constraint violation. + * @throws {BadRequestException} - If there's a foreign key constraint violation or validation error. + * @throws {NotFoundException} - If a participant is not found with the provided UID. + */ + private handleErrors(error, message?: string) { + this.logger.error(error); + if (error instanceof Prisma.PrismaClientKnownRequestError) { + switch (error?.code) { + case 'P2002': + throw new ConflictException( + 'Unique key constraint error on Participant:', + error.message + ); + case 'P2003': + throw new BadRequestException( + 'Foreign key constraint error on Participant', + error.message + ); + case 'P2025': + throw new NotFoundException('Participant not found with uid: ' + message); + default: + throw error; } + } else if (error instanceof Prisma.PrismaClientValidationError) { + throw new BadRequestException('Database field validation error on Participant', error.message); } - return {}; + return error; } - async updateTeamWithFocusAreas(teamId, dataToProcess, transaction) { - if (dataToProcess.focusAreas && dataToProcess.focusAreas.length > 0) { - await transaction.teamFocusArea.deleteMany({ - where: { - teamUid: teamId - } - }); - return await this.createTeamWithFocusAreas(dataToProcess, transaction); - } else { - await transaction.teamFocusArea.deleteMany({ - where: { - teamUid: teamId - } - }); - } - return {}; - } generateMemberProfileURL(value) { return generateProfileURL(value); diff --git a/apps/web-api/src/participants-request/unique-identifier/unique-identifier.controller.ts b/apps/web-api/src/participants-request/unique-identifier/unique-identifier.controller.ts deleted file mode 100644 index ad43c6496..000000000 --- a/apps/web-api/src/participants-request/unique-identifier/unique-identifier.controller.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* eslint-disable prettier/prettier */ -import { Body, Controller, Post } from '@nestjs/common'; -import { ParticipantsRequestService } from '../participants-request.service'; - -@Controller('v1/participants-request/unique-identifier') -export class UniqueIdentifier { - constructor( - private readonly participantsRequestService: ParticipantsRequestService - ) {} - - @Post() - async findDuplicates(@Body() body) { - const result = await this.participantsRequestService.findDuplicates( - body.uniqueIdentifier, - body.participantType, - body.uid, - body.requestId - ); - return result; - } -} diff --git a/apps/web-api/src/pipes/participant-request-validation.pipe.ts b/apps/web-api/src/pipes/participant-request-validation.pipe.ts new file mode 100644 index 000000000..b23af9f96 --- /dev/null +++ b/apps/web-api/src/pipes/participant-request-validation.pipe.ts @@ -0,0 +1,49 @@ +import { + ArgumentMetadata, + BadRequestException, + Injectable, + PipeTransform, +} from '@nestjs/common'; +import { + ParticipantRequestMemberSchema, + ParticipantRequestTeamSchema, +} from 'libs/contracts/src/schema/participants-request'; +import { ZodError } from 'zod'; + +@Injectable() +export class ParticipantsReqValidationPipe implements PipeTransform { + /** + * Transforms and validates the incoming request body based on the participant type. + * @param value - The incoming request body + * @param metadata - The metadata of the argument (checks if it is 'body') + * @returns The validated value or throws an exception if validation fails + */ + transform(value: any, metadata: ArgumentMetadata): any { + if (metadata.type !== 'body') { + return value; + } + try { + const { participantType } = value; + if (participantType === 'MEMBER') { + ParticipantRequestMemberSchema.parse(value); + } else if (participantType === 'TEAM') { + ParticipantRequestTeamSchema.parse(value); + } else { + throw new BadRequestException({ + statusCode: 400, + message: `Invalid participant request type ${participantType}`, + }); + } + return value; + } catch (error) { + if (error instanceof ZodError) { + throw new BadRequestException({ + statusCode: 400, + message: 'Participant request validation failed', + errors: error.errors, + }); + } + throw error; + } + } +} diff --git a/apps/web-api/src/setup.service.ts b/apps/web-api/src/setup.service.ts index ec055f780..e400b9677 100644 --- a/apps/web-api/src/setup.service.ts +++ b/apps/web-api/src/setup.service.ts @@ -13,7 +13,7 @@ export class SetupService { winston.format.timestamp(), winston.format.ms(), winston.format.printf((info) => { - return `${info.timestamp} : ${info.level} - ${info.message}`; + return `${JSON.stringify(info)}`;; }) //nestWinstonModuleUtilities.format.nestLike() ), diff --git a/apps/web-api/src/shared/shared.module.ts b/apps/web-api/src/shared/shared.module.ts index aa6aab6c2..26a424c30 100644 --- a/apps/web-api/src/shared/shared.module.ts +++ b/apps/web-api/src/shared/shared.module.ts @@ -1,10 +1,44 @@ import { Global, Logger, Module } from '@nestjs/common'; import { PrismaService } from './prisma.service'; import { LogService } from './log.service'; +import { ForestAdminService } from '../utils/forest-admin/forest-admin.service'; +import { AwsService } from '../utils/aws/aws.service'; +import { SlackService } from '../utils/slack/slack.service'; +import { LocationTransferService } from '../utils/location-transfer/location-transfer.service'; +import { FileMigrationService } from '../utils/file-migration/file-migration.service'; +import { ImagesController } from '../images/images.controller'; +import { ImagesService } from '../images/images.service'; +import { FileUploadService } from '../utils/file-upload/file-upload.service'; +import { FileEncryptionService } from '../utils/file-encryption/file-encryption.service'; @Global() @Module({ - providers: [PrismaService, LogService, Logger], - exports: [PrismaService, LogService], + providers: [ + PrismaService, + LogService, + Logger, + ForestAdminService, + AwsService, + SlackService, + LocationTransferService, + FileMigrationService, + ImagesController, + ImagesService, + FileUploadService, + FileEncryptionService, + ], + exports: [ + PrismaService, + LogService, + ForestAdminService, + AwsService, + SlackService, + LocationTransferService, + FileMigrationService, + ImagesController, + ImagesService, + FileUploadService, + FileEncryptionService, + ], }) -export class SharedModule {} +export class SharedModule {} \ No newline at end of file diff --git a/apps/web-api/src/teams/teams.controller.ts b/apps/web-api/src/teams/teams.controller.ts index fa7e18ae1..a026e2138 100644 --- a/apps/web-api/src/teams/teams.controller.ts +++ b/apps/web-api/src/teams/teams.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Req, UseGuards, Body, Param } from '@nestjs/common'; +import { Controller, Req, UseGuards, Body, Param, UsePipes } from '@nestjs/common'; import { ApiNotFoundResponse, ApiParam } from '@nestjs/swagger'; import { Api, ApiDecorator, initNestServer } from '@ts-rest/nest'; import { Request } from 'express'; @@ -17,7 +17,7 @@ import { prismaQueryableFieldsFromZod } from '../utils/prisma-queryable-fields-f import { TeamsService } from './teams.service'; import { NoCache } from '../decorators/no-cache.decorator'; import { UserTokenValidation } from '../guards/user-token-validation.guard'; -import { ParticipantRequestTeamSchema } from '../../../../libs/contracts/src/schema/participants-request'; +import { ParticipantsReqValidationPipe } from '../pipes/participant-request-validation.pipe'; const server = initNestServer(apiTeam); type RouteShape = typeof server.routeShapes; @@ -64,16 +64,14 @@ export class TeamsController { ENABLED_RETRIEVAL_PROFILE ); const builtQuery = builder.build(request.query); - return this.teamsService.findOne(uid, builtQuery); + return this.teamsService.findTeamByUid(uid, builtQuery); } @Api(server.route.modifyTeam) @UseGuards(UserTokenValidation) - async updateOne(@Param('id') id, @Body() body, @Req() req) { - const participantsRequest = body; - return await this.teamsService.editTeamParticipantsRequest( - participantsRequest, - req.userEmail - ); + @UsePipes(new ParticipantsReqValidationPipe()) + async updateOne(@Param('uid') teamUid, @Body() body, @Req() req) { + await this.teamsService.validateRequestor(req.userEmail, teamUid); + return await this.teamsService.updateTeamFromParticipantsRequest(teamUid, body, req.userEmail); } } diff --git a/apps/web-api/src/teams/teams.module.ts b/apps/web-api/src/teams/teams.module.ts index 51b75fc9c..8b55aa8f8 100644 --- a/apps/web-api/src/teams/teams.module.ts +++ b/apps/web-api/src/teams/teams.module.ts @@ -1,34 +1,14 @@ -import { Module } from '@nestjs/common'; -import { ImagesController } from '../images/images.controller'; -import { ImagesService } from '../images/images.service'; -import { ParticipantsRequestService } from '../participants-request/participants-request.service'; -import { AwsService } from '../utils/aws/aws.service'; -import { FileEncryptionService } from '../utils/file-encryption/file-encryption.service'; -import { FileMigrationService } from '../utils/file-migration/file-migration.service'; -import { FileUploadService } from '../utils/file-upload/file-upload.service'; -import { ForestAdminService } from '../utils/forest-admin/forest-admin.service'; -import { LocationTransferService } from '../utils/location-transfer/location-transfer.service'; -import { RedisService } from '../utils/redis/redis.service'; -import { SlackService } from '../utils/slack/slack.service'; +import { Module, forwardRef } from '@nestjs/common'; import { TeamsController } from './teams.controller'; import { TeamsService } from './teams.service'; +import { SharedModule } from '../shared/shared.module'; +import { ParticipantsRequestModule } from '../participants-request/participants-request.module'; +import { MembersModule } from '../members/members.module'; @Module({ + imports: [forwardRef(() => ParticipantsRequestModule), forwardRef(() => MembersModule), SharedModule], controllers: [TeamsController], - providers: [ - TeamsService, - FileMigrationService, - ImagesController, - ImagesService, - FileUploadService, - FileEncryptionService, - ParticipantsRequestService, - LocationTransferService, - AwsService, - RedisService, - SlackService, - ForestAdminService - ], - exports:[TeamsService] + providers: [TeamsService], + exports: [TeamsService] }) -export class TeamsModule {} +export class TeamsModule {} \ No newline at end of file diff --git a/apps/web-api/src/teams/teams.service.ts b/apps/web-api/src/teams/teams.service.ts index e1ef48d34..95a94cd99 100644 --- a/apps/web-api/src/teams/teams.service.ts +++ b/apps/web-api/src/teams/teams.service.ts @@ -1,268 +1,418 @@ import { Injectable, - UnauthorizedException, + ConflictException, ForbiddenException, - InternalServerErrorException, BadRequestException, - HttpException, + NotFoundException, + Inject, + forwardRef, + CACHE_MANAGER } from '@nestjs/common'; -import { Prisma, ParticipantType } from '@prisma/client'; import * as path from 'path'; import { z } from 'zod'; +import { Prisma, Team, Member, ParticipantsRequest } from '@prisma/client'; import { PrismaService } from '../shared/prisma.service'; import { AirtableTeamSchema } from '../utils/airtable/schema/airtable-team.schema'; import { FileMigrationService } from '../utils/file-migration/file-migration.service'; +import { NotificationService } from '../utils/notification/notification.service'; import { ParticipantsRequestService } from '../participants-request/participants-request.service'; import { hashFileName } from '../utils/hashing'; -import { ParticipantRequestTeamSchema } from 'libs/contracts/src/schema/participants-request'; +import { ForestAdminService } from '../utils/forest-admin/forest-admin.service'; +import { MembersService } from '../members/members.service'; +import { LogService } from '../shared/log.service'; +import { Cache } from 'cache-manager'; +import { copyObj, buildMultiRelationMapping, buildRelationMapping } from '../utils/helper/helper'; @Injectable() export class TeamsService { constructor( private prisma: PrismaService, private fileMigrationService: FileMigrationService, - private participantsRequestService: ParticipantsRequestService + @Inject(forwardRef(() => ParticipantsRequestService)) + private participantsRequestService: ParticipantsRequestService, + @Inject(forwardRef(() => MembersService)) + private membersService: MembersService, + private logger: LogService, + private forestadminService: ForestAdminService, + private notificationService: NotificationService, + @Inject(CACHE_MANAGER) private cacheService: Cache ) {} - async findAll(queryOptions: Prisma.TeamFindManyArgs) { - return this.prisma.team.findMany({ - ...queryOptions - }); + /** + * Find all teams based on provided query options. + * Allows flexibility in filtering, sorting, and pagination through Prisma.TeamFindManyArgs. + * + * @param queryOptions - Prisma query options to customize the result set + * (filter, pagination, sorting, etc.) + * @returns A list of teams that match the query options + */ + async findAll(queryOptions: Prisma.TeamFindManyArgs): Promise { + try { + return this.prisma.team.findMany({ ...queryOptions }); + } catch(err) { + return this.handleErrors(err); + } } - async findOne( + /** + * Find a single team by its unique identifier (UID). + * Retrieves detailed information about the team, + * including related data like projects, technologies, and team focus areas. + * + * @param uid - Unique identifier for the team + * @param queryOptions - Additional Prisma query options (excluding 'where') for + * customizing the result set + * @returns The team object with all related information or throws an error if not found + * @throws {NotFoundException} If the team with the given UID is not found + */ + async findTeamByUid( uid: string, queryOptions: Omit = {} - ) { - const team = await this.prisma.team.findUniqueOrThrow({ - where: { uid }, - ...queryOptions, - include: { - fundingStage: true, - industryTags: true, - logo: true, - membershipSources: true, - technologies: true, - maintainingProjects:{ - orderBy: [ - { - name: 'asc' - } - ], - include: { - logo: { select: { url: true, uid: true } }, - maintainingTeam: { - select: { - name: true, - logo: { select: { url: true, uid: true } }, - }, + ): Promise { + try { + const team = await this.prisma.team.findUniqueOrThrow({ + where: { uid }, + ...queryOptions, + include: { + fundingStage: true, + industryTags: true, + logo: true, + membershipSources: true, + technologies: true, + maintainingProjects: { + orderBy: { name: 'asc' }, + include: { + logo: { select: { url: true, uid: true } }, + maintainingTeam: { select: { name: true, logo: { select: { url: true, uid: true } } } }, + contributingTeams: true, }, - contributingTeams: true - } - }, - contributingProjects: { - orderBy: [ - { - name: 'asc' - } - ], - include: { - logo: { select: { url: true, uid: true } }, - maintainingTeam: { - select: { - name: true, - logo: { select: { url: true, uid: true } }, - }, + }, + contributingProjects: { + orderBy: { name: 'asc' }, + include: { + logo: { select: { url: true, uid: true } }, + maintainingTeam: { select: { name: true, logo: { select: { url: true, uid: true } } } }, + contributingTeams: true, }, - contributingTeams: true - } + }, + teamFocusAreas: { + select: { + focusArea: { select: { uid: true, title: true } }, + }, + }, }, - teamFocusAreas: { - select: { - focusArea: { - select: { - uid: true, - title: true - } - } - } - } - }, + }); + team.teamFocusAreas = this.removeDuplicateFocusAreas(team.teamFocusAreas); + return team; + } catch(err) { + return this.handleErrors(err, uid); + } + } + + /** + * Find a team by its name. + * + * @param name - The name of the team to find + * @returns The team object if found, otherwise null + */ + async findTeamByName(name: string): Promise { + try { + return this.prisma.team.findUniqueOrThrow({ + where: { name }, + }); + } catch(err) { + return this.handleErrors(err); + } + }; + + /** + * Creates a new team in the database within a transaction. + * + * @param team - The data for the new team to be created + * @param tx - The transaction client to ensure atomicity + * @returns The created team record + */ + async createTeam( + team: Prisma.TeamUncheckedCreateInput, + tx: Prisma.TransactionClient + ): Promise { + try { + return await tx.team.create({ + data: team, + }); + } catch(err) { + return this.handleErrors(err); + } + } + + /** + * Updates the team data in the database within a transaction. + * + * @param teamUid - Unique identifier of the team being updated + * @param team - The new data to be applied to the team + * @param tx - The transaction client to ensure atomicity + * @returns The updated team record + */ + async updateTeamByUid( + uid: string, + team: Prisma.TeamUncheckedUpdateInput, + tx: Prisma.TransactionClient, + ): Promise { + try { + return await tx.team.update({ + where: { uid }, + data: team, + }); + } catch(err) { + return this.handleErrors(err, `${uid}`); + } + } + + /** + * Updates the existing team with new information. + * updates the team, logs the update in the participants request table, + * resets the cache, and triggers post-update actions like Airtable synchronization. + * + * @param teamUid - Unique identifier of the team to be updated + * @param teamParticipantRequest - Data containing the updated team information + * @param requestorEmail - Email of the person making the request + * @returns A success message if the operation is successful + */ + async updateTeamFromParticipantsRequest( + teamUid: string, + teamParticipantRequest: ParticipantsRequest, + requestorEmail: string + ): Promise { + const updatedTeam: any = teamParticipantRequest.newData; + const existingTeam = await this.findTeamByUid(teamUid); + let result; + await this.prisma.$transaction(async (tx) => { + const team = await this.formatTeam(teamUid, updatedTeam, tx, "Update"); + result = await this.updateTeamByUid(teamUid, team, tx); + await this.logParticipantRequest(requestorEmail, updatedTeam, existingTeam.uid, tx); }); - team.teamFocusAreas = this.removeDuplicateFocusAreas(team.teamFocusAreas); - return team; + this.notificationService.notifyForTeamEditApproval(updatedTeam.name, teamUid, requestorEmail); + await this.postUpdateActions(); + return result; } - async insertManyFromAirtable( - airtableTeams: z.infer[] - ) { - const fundingStages = await this.prisma.fundingStage.findMany(); - const industryTags = await this.prisma.industryTag.findMany(); - const technologies = await this.prisma.technology.findMany(); - const membershipSources = await this.prisma.membershipSource.findMany(); - const images = await this.prisma.image.findMany(); + /** + * Creates a new team from the participants request data. + * resets the cache, and triggers post-update actions like Airtable synchronization. + * @param teamParticipantRequest - The request containing the team details. + * @param requestorEmail - The email of the requestor. + * @returns The newly created team. + */ + async createTeamFromParticipantsRequest( + teamParticipantRequest: ParticipantsRequest, + tx: Prisma.TransactionClient + ): Promise { + const newTeam: any = teamParticipantRequest.newData; + const formattedTeam = await this.formatTeam(null, newTeam, tx); + const createdTeam = await this.createTeam(formattedTeam, tx); + return createdTeam; + } - for (const team of airtableTeams) { - const optionalFieldsToAdd = Object.entries({ - blog: 'Blog', - website: 'Website', - twitterHandler: 'Twitter', - shortDescription: 'Short description', - contactMethod: 'Preferred Method of Contact', - longDescription: 'Long description', - plnFriend: 'Friend of PLN', - }).reduce( - (optionalFields, [prismaField, airtableField]) => ({ - ...optionalFields, - ...(team.fields?.[airtableField] && { - [prismaField]: team.fields?.[airtableField], - }), - }), - {} - ); + /** + * Format team data for creation or update + * + * @param teamUid - The unique identifier for the team (used for updates) + * @param teamData - Raw team data to be formatted + * @param tx - Transaction client for atomic operations + * @param type - Operation type ('create' or 'update') + * @returns - Formatted team data for Prisma query + */ + async formatTeam( + teamUid: string | null, + teamData: Partial, + tx: Prisma.TransactionClient, + type: string = 'create' + ) { + const team: any = {}; + const directFields = [ + 'name', 'blog', 'contactMethod', 'twitterHandler', + 'linkedinHandler', 'telegramHandler', 'officeHours', + 'shortDescription', 'website', 'airtableRecId', + 'longDescription', 'moreDetails' + ]; + copyObj(teamData, team, directFields); + // Handle one-to-one or one-to-many mappings + team['fundingStage'] = buildRelationMapping('fundingStage', teamData); + team['industryTags'] = buildMultiRelationMapping('industryTags', teamData, type); + team['technologies'] = buildMultiRelationMapping('technologies', teamData, type); + team['membershipSources'] = buildMultiRelationMapping('membershipSources', teamData, type); + if (type === 'create') { + team['teamFocusAreas'] = await this.createTeamWithFocusAreas(teamData, tx); + } + if (teamUid) { + team['teamFocusAreas'] = await this.updateTeamWithFocusAreas(teamUid, teamData, tx); + } + team['logo'] = teamData.logoUid + ? { connect: { uid: teamData.logoUid } } + : type === 'update' ? { disconnect: true } : undefined; + return team; + } - const oneToManyRelations = { - fundingStageUid: - fundingStages.find( - (fundingStage) => - fundingStage.title === team.fields?.['Funding Stage'] - )?.uid || null, - }; + /** + * Validates the permissions of the requestor. The requestor must either be an admin or the leader of the team. + * + * @param requestorEmail - The email of the person requesting the update + * @param teamUid - The unique identifier of the team being updated + * @returns The requestor's member data if validation passes + * @throws {UnauthorizedException} If the requestor is not found + * @throws {ForbiddenException} If the requestor does not have sufficient permissions + */ + async validateRequestor(requestorEmail: string, teamUid: string): Promise { + const requestor = await this.membersService.findMemberByEmail(requestorEmail); + if (!requestor.isDirectoryAdmin && !requestor.leadingTeams.includes(teamUid)) { + throw new ForbiddenException('Requestor does not have permission to update this team'); + } + return requestor; + } - const manyToManyRelations = { - industryTags: { - connect: industryTags - .filter( - (tag) => - !!team.fields?.['Tags lookup'] && - team.fields?.['Tags lookup'].includes(tag.title) - ) - .map((tag) => ({ id: tag.id })), - }, - membershipSources: { - connect: membershipSources - .filter( - (program) => - !!team.fields?.['Accelerator Programs'] && - team.fields?.['Accelerator Programs'].includes(program.title) - ) - .map((tag) => ({ id: tag.id })), - }, - technologies: { - connect: technologies - .filter( - (tech) => - (team.fields?.['Filecoin User'] && tech.title === 'Filecoin') || - (team.fields?.['IPFS User'] && tech.title === 'IPFS') - ) - .map((tech) => ({ id: tech.id })), - }, - }; + /** + * Removes duplicate focus areas from the team object based on their UID. + * Ensures that each focus area is unique in the result set. + * + * @param focusAreas - An array of focus areas associated with the team + * @returns A deduplicated array of focus areas + */ + private removeDuplicateFocusAreas(focusAreas):any { + const uniqueFocusAreas = {}; + focusAreas.forEach(item => { + const { uid, title } = item.focusArea; + uniqueFocusAreas[uid] = { uid, title }; + }); + return Object.values(uniqueFocusAreas); + }; - let image; + /** + * Logs the participant request in the participants request table for audit and tracking purposes. + * + * @param requestorEmail - Email of the requestor who is updating the team + * @param newTeamData - The new data being applied to the team + * @param referenceUid - Unique identifier of the existing team to be referenced + * @param tx - The transaction client to ensure atomicity + */ + private async logParticipantRequest( + requestorEmail: string, + newTeamData, + referenceUid: string, + tx: Prisma.TransactionClient, + ): Promise { + await this.participantsRequestService.add({ + status: 'AUTOAPPROVED', + requesterEmailId: requestorEmail, + referenceUid, + uniqueIdentifier: newTeamData?.name || '', + participantType: 'TEAM', + newData: { ...newTeamData }, + }, + tx + ); + } - if (team.fields.Logo) { - const logo = team.fields.Logo[0]; + /** + * Executes post-update actions such as resetting the cache and triggering Airtable sync. + * This ensures that the system is up-to-date with the latest changes. + */ + private async postUpdateActions(): Promise { + await this.cacheService.reset(); + await this.forestadminService.triggerAirtableSync(); + } - const hashedLogo = logo.filename - ? hashFileName(`${path.parse(logo.filename).name}-${logo.id}`) - : ''; - image = - images.find( - (image) => path.parse(image.filename).name === hashedLogo - ) || - (await this.fileMigrationService.migrateFile({ - id: logo.id ? logo.id : '', - url: logo.url ? logo.url : '', - filename: logo.filename ? logo.filename : '', - size: logo.size ? logo.size : 0, - type: logo.type ? logo.type : '', - height: logo.height ? logo.height : 0, - width: logo.width ? logo.width : 0, - })); - } + /** + * Utility function to map single relational data + * + * @param field - The field name to map + * @param rawData - The raw data input + * @returns - Relation object for Prisma query + */ + private buildRelationMapping(field: string, rawData: any) { + return rawData[field]?.uid + ? { connect: { uid: rawData[field].uid } } + : undefined; + } - await this.prisma.team.upsert({ - where: { airtableRecId: team.id }, - update: { - ...optionalFieldsToAdd, - ...oneToManyRelations, - ...manyToManyRelations, - }, - create: { - airtableRecId: team.id, - name: team.fields.Name, - plnFriend: team.fields['Friend of PLN'] || false, - logoUid: image && image.uid ? image.uid : undefined, - ...optionalFieldsToAdd, - ...oneToManyRelations, - ...manyToManyRelations, - ...(team.fields?.['Created'] && { - createdAt: new Date(team.fields['Created']), - }), - ...(team.fields?.['Last Modified'] && { - updatedAt: new Date(team.fields['Last Modified']), - }), - }, - }); + /** + * Utility function to map multiple relational data + * + * @param field - The field name to map + * @param rawData - The raw data input + * @param type - Operation type ('create' or 'update') + * @returns - Multi-relation object for Prisma query + */ + private buildMultiRelationMapping(field: string, rawData: any, type: string) { + const dataExists = rawData[field]?.length > 0; + if (!dataExists) { + return type === 'update' ? { set: [] } : undefined; } + return { + [type === 'create' ? 'connect' : 'set']: rawData[field].map((item: any) => ({ uid: item.uid })) + }; } - async editTeamParticipantsRequest(participantsRequest, userEmail) { - const { referenceUid } = participantsRequest; - const requestorDetails = - await this.participantsRequestService.findMemberByEmail(userEmail); - if (!requestorDetails) { - throw new UnauthorizedException(); - } - if ( - !requestorDetails.isDirectoryAdmin && - !requestorDetails.leadingTeams?.includes(referenceUid) - ) { - throw new ForbiddenException(); - } - participantsRequest.requesterEmailId = requestorDetails.email; - participantsRequest.newData.lastModifiedBy = requestorDetails.uid; - if ( - participantsRequest.participantType === ParticipantType.TEAM.toString() && - !ParticipantRequestTeamSchema.safeParse(participantsRequest).success - ) { - throw new BadRequestException(); - } - let result; - try { - await this.prisma.$transaction(async (tx) => { - result = await this.participantsRequestService.addRequest( - participantsRequest, - true, - tx - ); - if (result?.uid) { - result = await this.participantsRequestService.processTeamEditRequest( - result.uid, - true, // disable the notification - true, // enable the auto approval - tx - ); - } else { - throw new InternalServerErrorException(); + /** + * Creates focus area mappings for a new team. + * + * @param team - The team object containing focus areas + * @param transaction - The transaction client for atomic operations + * @returns - Data for bulk insertion of focus areas + */ + async createTeamWithFocusAreas(team, transaction: Prisma.TransactionClient) { + if (team.focusAreas && team.focusAreas.length > 0) { + let teamFocusAreas:any = []; + const focusAreaHierarchies = await transaction.focusAreaHierarchy.findMany({ + where: { + subFocusAreaUid: { + in: team.focusAreas.map(area => area.uid) + } } }); - } catch (error) { - if (error?.response?.statusCode && error?.response?.message) { - throw new HttpException( - error?.response?.message, - error?.response?.statusCode - ); - } else { - throw new BadRequestException( - 'Oops, something went wrong. Please try again!' - ); + focusAreaHierarchies.map(areaHierarchy => { + teamFocusAreas.push({ + focusAreaUid: areaHierarchy.subFocusAreaUid, + ancestorAreaUid: areaHierarchy.focusAreaUid + }); + }); + team.focusAreas.map(area => { + teamFocusAreas.push({ + focusAreaUid: area.uid, + ancestorAreaUid: area.uid + }); + }); + return { + createMany: { + data: teamFocusAreas + } } } - return result; + return {}; } - + + /** + * Updates focus areas for an existing team. + * + * @param teamUid - The unique identifier of the team + * @param team - The team object containing new focus areas + * @param transaction - The transaction client for atomic operations + * @returns - Data for bulk insertion of updated focus areas + */ + async updateTeamWithFocusAreas(teamUid: string, team, transaction: Prisma.TransactionClient) { + await transaction.teamFocusArea.deleteMany({ + where: { teamUid } + }); + if (!team.focusAreas || team.focusAreas.length === 0) { + return {}; + } + return await this.createTeamWithFocusAreas(team, transaction); + } + + /** + * Builds filter for focus areas by splitting the input and matching ancestor titles. + * @param focusAreas - Comma-separated focus area titles + * @returns - Prisma filter for teamFocusAreas + */ buildFocusAreaFilters(focusAreas) { if (focusAreas?.split(',')?.length > 0) { return { @@ -279,7 +429,12 @@ export class TeamsService { } return {}; } - + + /** + * Constructs the team filter based on multiple query parameters. + * @param queryParams - Query parameters from the request + * @returns - Prisma AND filter combining all conditions + */ buildTeamFilter(queryParams){ const { name, @@ -288,7 +443,7 @@ export class TeamsService { technologies, membershipSources, fundingStage, - officeHours + officeHours } = queryParams; const filter:any = []; this.buildNameAndPLNFriendFilter(name, plnFriend, filter); @@ -303,6 +458,12 @@ export class TeamsService { }; }; + /** + * Adds name and PLN friend filter conditions to the filter array. + * @param name - Team name to search for (case-insensitive) + * @param plnFriend - Boolean to filter teams that are PLN friends + * @param filter - Filter array to be appended to + */ buildNameAndPLNFriendFilter(name, plnFriend, filter) { if (name) { filter.push({ @@ -319,6 +480,11 @@ export class TeamsService { } } + /** + * Adds industry tags filter to the filter array. + * @param industryTags - Comma-separated industry tags + * @param filter - Filter array to be appended to + */ buildIndustryTagsFilter(industryTags, filter) { const tags = industryTags?.split(',').map(tag=> tag.trim()); if (tags?.length > 0) { @@ -336,6 +502,11 @@ export class TeamsService { } } + /** + * Adds technology tags filter to the filter array. + * @param technologies - Comma-separated technology tags + * @param filter - Filter array to be appended to + */ buildTechnologiesFilter(technologies, filter) { const tags = technologies?.split(',').map(tech => tech.trim()); if (tags?.length > 0) { @@ -353,6 +524,11 @@ export class TeamsService { } } + /** + * Adds membership sources filter to the filter array. + * @param membershipSources - Comma-separated membership source titles + * @param filter - Filter array to be appended to + */ buildMembershipSourcesFilter(membershipSources, filter) { const sources = membershipSources?.split(',').map(source => source.trim()); if (sources?.length > 0) { @@ -370,6 +546,11 @@ export class TeamsService { } } + /** + * Adds funding stage filter to the filter array. + * @param fundingStage - Title of the funding stage + * @param filter - Filter array to be appended to + */ buildFundingStageFilter(fundingStage, filter) { if (fundingStage?.length > 0) { filter.push({ @@ -380,25 +561,19 @@ export class TeamsService { } } - removeDuplicateFocusAreas(focusAreas): any { - const uniqueFocusAreas = {}; - focusAreas.forEach(item => { - const uid = item.focusArea.uid; - const title = item.focusArea.title; - uniqueFocusAreas[uid] = { uid, title }; - }); - return Object.values(uniqueFocusAreas); - } - + /** + * Adds office hours filter to the filter array. + * @param officeHours - Boolean to check if teams have office hours + * @param filter - Filter array to be appended to + */ buildOfficeHoursFilter(officeHours, filter) { - if ((officeHours === "true")) { - filter.push({ + if (officeHours === "true") { + filter.push({ officeHours: { not: null } }); } } - /** * Constructs a dynamic filter query for retrieving recent teams based on the 'is_recent' query parameter. * If 'is_recent' is set to 'true', it creates a 'createdAt' filter to retrieve records created within a @@ -426,4 +601,154 @@ export class TeamsService { } return {}; } + + /** + * Handles database-related errors specifically for the Team entity. + * Logs the error and throws an appropriate HTTP exception based on the error type. + * + * @param {any} error - The error object thrown by Prisma or other services. + * @param {string} [message] - An optional message to provide additional context, + * such as the team UID when an entity is not found. + * @throws {ConflictException} - If there's a unique key constraint violation. + * @throws {BadRequestException} - If there's a foreign key constraint violation or validation error. + * @throws {NotFoundException} - If a team is not found with the provided UID. + */ + private handleErrors(error, message?: string) { + this.logger.error(error); + if (error instanceof Prisma.PrismaClientKnownRequestError) { + switch (error?.code) { + case 'P2002': + throw new ConflictException('Unique key constraint error on Team:', error.message); + case 'P2003': + throw new BadRequestException('Foreign key constraint error on Team', error.message); + case 'P2025': + throw new NotFoundException('Team not found with uid: ' + message); + default: + throw error; + } + } + else if (error instanceof Prisma.PrismaClientValidationError) { + throw new BadRequestException( + 'Database field validation error on Team', + error.message + ); + } + return error; + } + + + async insertManyFromAirtable( + airtableTeams: z.infer[] + ) { + const fundingStages = await this.prisma.fundingStage.findMany(); + const industryTags = await this.prisma.industryTag.findMany(); + const technologies = await this.prisma.technology.findMany(); + const membershipSources = await this.prisma.membershipSource.findMany(); + const images = await this.prisma.image.findMany(); + + for (const team of airtableTeams) { + const optionalFieldsToAdd = Object.entries({ + blog: 'Blog', + website: 'Website', + twitterHandler: 'Twitter', + shortDescription: 'Short description', + contactMethod: 'Preferred Method of Contact', + longDescription: 'Long description', + plnFriend: 'Friend of PLN', + }).reduce( + (optionalFields, [prismaField, airtableField]) => ({ + ...optionalFields, + ...(team.fields?.[airtableField] && { + [prismaField]: team.fields?.[airtableField], + }), + }), + {} + ); + + const oneToManyRelations = { + fundingStageUid: + fundingStages.find( + (fundingStage) => + fundingStage.title === team.fields?.['Funding Stage'] + )?.uid || null, + }; + + const manyToManyRelations = { + industryTags: { + connect: industryTags + .filter( + (tag) => + !!team.fields?.['Tags lookup'] && + team.fields?.['Tags lookup'].includes(tag.title) + ) + .map((tag) => ({ id: tag.id })), + }, + membershipSources: { + connect: membershipSources + .filter( + (program) => + !!team.fields?.['Accelerator Programs'] && + team.fields?.['Accelerator Programs'].includes(program.title) + ) + .map((tag) => ({ id: tag.id })), + }, + technologies: { + connect: technologies + .filter( + (tech) => + (team.fields?.['Filecoin User'] && tech.title === 'Filecoin') || + (team.fields?.['IPFS User'] && tech.title === 'IPFS') + ) + .map((tech) => ({ id: tech.id })), + }, + }; + + let image; + + if (team.fields.Logo) { + const logo = team.fields.Logo[0]; + + const hashedLogo = logo.filename + ? hashFileName(`${path.parse(logo.filename).name}-${logo.id}`) + : ''; + image = + images.find( + (image) => path.parse(image.filename).name === hashedLogo + ) || + (await this.fileMigrationService.migrateFile({ + id: logo.id ? logo.id : '', + url: logo.url ? logo.url : '', + filename: logo.filename ? logo.filename : '', + size: logo.size ? logo.size : 0, + type: logo.type ? logo.type : '', + height: logo.height ? logo.height : 0, + width: logo.width ? logo.width : 0, + })); + } + + await this.prisma.team.upsert({ + where: { airtableRecId: team.id }, + update: { + ...optionalFieldsToAdd, + ...oneToManyRelations, + ...manyToManyRelations, + }, + create: { + airtableRecId: team.id, + name: team.fields.Name, + plnFriend: team.fields['Friend of PLN'] || false, + logoUid: image && image.uid ? image.uid : undefined, + ...optionalFieldsToAdd, + ...oneToManyRelations, + ...manyToManyRelations, + ...(team.fields?.['Created'] && { + createdAt: new Date(team.fields['Created']), + }), + ...(team.fields?.['Last Modified'] && { + updatedAt: new Date(team.fields['Last Modified']), + }), + }, + }); + } + } } diff --git a/apps/web-api/src/utils/helper/helper.ts b/apps/web-api/src/utils/helper/helper.ts index 08ae6c7e1..13b3c4076 100644 --- a/apps/web-api/src/utils/helper/helper.ts +++ b/apps/web-api/src/utils/helper/helper.ts @@ -35,4 +35,47 @@ export const slugify = (name: string) => { .replace(/--+/g, '-') // Replace multiple hyphens with single hyphen .replace(/^-+/, '') // Trim hyphens from start of string .replace(/-+$/, ''); // Trim hyphens from end of string +} + +/** + * Copies specific fields from the source JSON to the destination object + * @param srcJson - Source JSON + * @param destJson - Destination object + * @param directFields - List of fields to copy + */ +export const copyObj = (srcJson: any, destJson: any, directFields: string[]) => { + directFields.forEach(field => { + destJson[field] = srcJson[field]; + }); +} + +/** + * Utility function to map single relational data + * + * @param field - The field name to map + * @param rawData - The raw data input + * @returns - Relation object for Prisma query + */ +export const buildRelationMapping = (field: string, rawData: any) => { + return rawData[field]?.uid + ? { connect: { uid: rawData[field].uid } } + : undefined; +} + +/** + * Utility function to map multiple relational data + * + * @param field - The field name to map + * @param rawData - The raw data input + * @param type - Operation type ('create' or 'update') + * @returns - Multi-relation object for Prisma query + */ +export const buildMultiRelationMapping = (field: string, rawData: any, type: string) => { + const dataExists = rawData[field]?.length > 0; + if (!dataExists) { + return type === 'update' ? { set: [] } : undefined; + } + return { + [type === 'create' ? 'connect' : 'set']: rawData[field].map((item: any) => ({ uid: item.uid })) + }; } \ No newline at end of file diff --git a/apps/web-api/src/utils/notification/notification.service.ts b/apps/web-api/src/utils/notification/notification.service.ts new file mode 100644 index 000000000..a6895db7c --- /dev/null +++ b/apps/web-api/src/utils/notification/notification.service.ts @@ -0,0 +1,179 @@ +/* eslint-disable prettier/prettier */ +import { Injectable } from '@nestjs/common'; +import { AwsService } from '../aws/aws.service'; +import { SlackService } from '../slack/slack.service'; +import { getRandomId } from '../helper/helper'; + +@Injectable() +export class NotificationService { + constructor( + private awsService: AwsService, + private slackService: SlackService + ) { } + + /** + * This method sends notifications when a new member is created. + * @param memberName The name of the new member + * @param uid The unique identifier for the member + * @returns Sends an email to admins and posts a notification to Slack. + */ + async notifyForCreateMember(memberName: string, uid: string) { + const backOfficeMemberUrl = `${process.env.WEB_ADMIN_UI_BASE_URL}/member-view?id=${uid}`; + const slackConfig = { requestLabel: 'New Labber Request', url: backOfficeMemberUrl, name: memberName }; + await this.awsService.sendEmail( + 'NewMemberRequest', true, [], + { memberName: memberName, requestUid: uid, adminSiteUrl: backOfficeMemberUrl } + ); + await this.slackService.notifyToChannel(slackConfig); + } + + /** + * This method sends notifications when a member's profile is edited. + * @param memberName The name of the member whose profile is edited + * @param uid The unique identifier for the member + * @param requesterEmailId The email address of the person who requested the edit + * @returns Sends an email to admins and posts a notification to Slack. + */ + async notifyForEditMember(memberName: string, uid: string, requesterEmailId: string) { + const backOfficeMemberUrl = `${process.env.WEB_ADMIN_UI_BASE_URL}/member-view?id=${uid}`; + const slackConfig = { requestLabel: 'Edit Labber Request', url: backOfficeMemberUrl, name: memberName }; + await this.awsService.sendEmail( + 'EditMemberRequest', true, [], + { memberName, requestUid: uid, adminSiteUrl: backOfficeMemberUrl, requesterEmailId } + ); + await this.slackService.notifyToChannel(slackConfig); + } + + /** + * This method sends notifications when a new team is created. + * @param teamName The name of the new team + * @param uid The unique identifier for the team + * @returns Sends an email to admins and posts a notification to Slack. + */ + async notifyForCreateTeam(teamName: string, uid: string) { + const backOfficeTeamUrl = `${process.env.WEB_ADMIN_UI_BASE_URL}/team-view?id=${uid}`; + const slackConfig = { requestLabel: 'New Team Request', url: backOfficeTeamUrl, name: teamName }; + await this.awsService.sendEmail( + 'NewTeamRequest', true, [], + { teamName, requestUid: uid, adminSiteUrl: backOfficeTeamUrl } + ); + await this.slackService.notifyToChannel(slackConfig); + } + + /** + * This method sends notifications when a team's profile is edited. + * @param teamName The name of the team whose profile is edited + * @param teamUid The unique identifier for the team + * @param uid The unique identifier for the edit request + * @returns Sends an email to admins and posts a notification to Slack. + */ + async notifyForEditTeam(teamName: string, teamUid: string, uid: string) { + const backOfficeTeamUrl = `${process.env.WEB_ADMIN_UI_BASE_URL}/team-view?id=${uid}`; + const slackConfig = { requestLabel: 'Edit Team Request', url: backOfficeTeamUrl, name: teamName }; + await this.awsService.sendEmail( + 'EditTeamRequest', true, [], + { teamName, teamUid, requestUid: uid, adminSiteUrl: backOfficeTeamUrl } + ); + await this.slackService.notifyToChannel(slackConfig); + } + + /** + * This method sends notifications after a member is approved. + * @param memberName The name of the member being approved + * @param uid The unique identifier for the member + * @param memberEmailId The email address of the member being approved + * @returns Sends an approval email to the member and posts a notification to Slack. + */ + async notifyForMemberCreationApproval(memberName: string, uid: string, memberEmailId: string) { + const memberUrl = `${process.env.WEB_UI_BASE_URL}/members/${uid}?utm_source=notification&utm_medium=email&utm_code=${getRandomId()}`; + const slackConfig = { requestLabel: 'New Labber Added', url: memberUrl, name: memberName }; + await this.awsService.sendEmail( + 'MemberCreated', true, [], + { memberName, memberUid: uid, adminSiteUrl: memberUrl } + ); + await this.awsService.sendEmail( + 'NewMemberSuccess', false, [memberEmailId], + { memberName, memberProfileLink: memberUrl } + ); + await this.slackService.notifyToChannel(slackConfig); + } + + /** + * This method sends notifications after a member's profile is approved for edits. + * @param memberName The name of the member whose profile was edited + * @param uid The unique identifier for the member + * @param memberEmailId The email address of the member + * @returns Sends an email notifying approval and posts a notification to Slack. + */ + async notifyForMemberEditApproval(memberName: string, uid: string, memberEmailId: string) { + const memberUrl = `${process.env.WEB_UI_BASE_URL}/members/${uid}?utm_source=notification&utm_medium=email&utm_code=${getRandomId()}`; + const slackConfig = { requestLabel: 'Edit Labber Request Completed', url: memberUrl, name: memberName }; + await this.awsService.sendEmail( + 'MemberEditRequestCompleted', true, [], + { memberName, memberUid: uid, adminSiteUrl: memberUrl } + ); + await this.awsService.sendEmail( + 'EditMemberSuccess', false, [memberEmailId], + { memberName, memberProfileLink: memberUrl } + ); + await this.slackService.notifyToChannel(slackConfig); + } + + /** + * This method sends an acknowledgment email when an admin changes a member's email. + * @param memberName The name of the member whose email is being changed + * @param uid The unique identifier for the member + * @param memberOldEmail The member's old email address + * @param memberNewEmail The member's new email address + * @returns Sends an email to both the old and new email addresses. + */ + async notifyForMemberChangesByAdmin(memberName: string, uid: string, memberOldEmail: string, memberNewEmail: string) { + const memberUrl = `${process.env.WEB_UI_BASE_URL}/members/${uid}?utm_source=notification&utm_medium=email&utm_code=${getRandomId()}`; + await this.awsService.sendEmail( + 'MemberEmailChangeAcknowledgement', false, [memberOldEmail, memberNewEmail], + { oldEmail: memberOldEmail, newEmail: memberNewEmail, memberName, profileURL: memberUrl, loginURL: process.env.LOGIN_URL } + ); + } + + /** + * This method sends notifications after a team creation request is approved. + * @param teamName The name of the team being approved + * @param teamUid The unique identifier for the team + * @param requesterEmailId The email address of the person who requested the team creation + * @returns Sends an email notifying approval and posts a notification to Slack. + */ + async notifyForTeamCreationApproval(teamName: string, teamUid: string, requesterEmailId: string) { + const teamUrl = `${process.env.WEB_UI_BASE_URL}/teams/${teamUid}?utm_source=notification&utm_medium=email&utm_code=${getRandomId()}`; + const slackConfig = { requestLabel: 'New Team Added', url: teamUrl, name: teamName }; + await this.awsService.sendEmail( + 'TeamCreated', true, [], + { teamName, teamUid, adminSiteUrl: teamUrl } + ); + await this.awsService.sendEmail( + 'NewTeamSuccess', false, [requesterEmailId], + { teamName, memberProfileLink: teamUrl } + ); + await this.slackService.notifyToChannel(slackConfig); + } + + /** + * This method sends notifications after a team edit request is approved. + * @param teamName The name of the team that was edited + * @param teamUid The unique identifier for the team + * @param requesterEmailId The email address of the person who requested the team edit + * @returns Sends an email notifying approval and posts a notification to Slack. + */ + async notifyForTeamEditApproval(teamName: string, teamUid: string, requesterEmailId: string) { + const teamUrl = `${process.env.WEB_UI_BASE_URL}/teams/${teamUid}?utm_source=notification&utm_medium=email&utm_code=${getRandomId()}`; + const slackConfig = { requestLabel: 'Edit Team Request Completed', url: teamUrl, name: teamName }; + await this.awsService.sendEmail( + 'TeamEditRequestCompleted', true, [], + { teamName, teamUid, adminSiteUrl: teamUrl } + ); + await this.awsService.sendEmail( + 'EditTeamSuccess', false, [requesterEmailId], + { teamName, memberProfileLink: teamUrl } + ); + await this.slackService.notifyToChannel(slackConfig); + } +} diff --git a/libs/contracts/src/schema/admin.ts b/libs/contracts/src/schema/admin.ts new file mode 100644 index 000000000..b08e454b3 --- /dev/null +++ b/libs/contracts/src/schema/admin.ts @@ -0,0 +1,7 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'nestjs-zod/z'; + +export class LoginRequestDto extends createZodDto(z.object({ + username: z.string(), + password: z.string() +})) {}; diff --git a/libs/contracts/src/schema/index.ts b/libs/contracts/src/schema/index.ts index 07e426dbb..bdff59dc1 100644 --- a/libs/contracts/src/schema/index.ts +++ b/libs/contracts/src/schema/index.ts @@ -23,4 +23,6 @@ export * from './member-interaction'; export * from './member-follow-up'; export * from './member-feedback'; export * from './discovery-question'; -export * from './pl-event-location'; \ No newline at end of file +export * from './pl-event-location'; +export * from './admin'; +export * from './participants-request'; \ No newline at end of file diff --git a/libs/contracts/src/schema/member.ts b/libs/contracts/src/schema/member.ts index c7df00a53..7a026bbdd 100644 --- a/libs/contracts/src/schema/member.ts +++ b/libs/contracts/src/schema/member.ts @@ -107,11 +107,11 @@ export class ResponseMemberSchemaDto extends createZodDto( export type TMemberResponse = z.infer; const ChangeEmailRequestSchema = z.object({ - newEmail: z.string(), + newEmail: z.string().email(), }) const SendEmailOtpRequestSchema = z.object({ - newEmail: z.string() + newEmail: z.string().email(), }) export class SendEmailOtpRequestDto extends createZodDto(SendEmailOtpRequestSchema) {} diff --git a/libs/contracts/src/schema/participants-request.ts b/libs/contracts/src/schema/participants-request.ts index 7ad6290c7..4d998cdb9 100644 --- a/libs/contracts/src/schema/participants-request.ts +++ b/libs/contracts/src/schema/participants-request.ts @@ -1,4 +1,5 @@ -import { z } from 'zod'; +import { createZodDto } from 'nestjs-zod'; +import { z } from 'nestjs-zod/z'; import { ProjectContributionSchema } from './project-contribution'; export const statusEnum = z.enum(['PENDING', 'APPROVED', 'REJECTED']); @@ -47,7 +48,7 @@ const newDataMemberSchema = z.object({ officeHours: z.string().optional().nullable(), imageUid: z.string().optional().nullable(), moreDetails: z.string().optional().nullable(), - projectContributions: z.array(ProjectContributionSchema).optional() + projectContributions: z.array(ProjectContributionSchema as any).optional() }); const newDataTeamSchema = z.object({ @@ -77,7 +78,7 @@ export const ParticipantRequestMemberSchema = z.object({ oldData: oldDataPostSchema.optional().nullable(), newData: newDataMemberSchema, referenceUid: z.string().optional().nullable(), - requesterEmailId: z.string(), + requesterEmailId: z.string().nullish(), uniqueIdentifier: z.string(), }); @@ -92,6 +93,17 @@ export const ParticipantRequestTeamSchema = z.object({ oldData: oldDataPostSchema.optional().nullable(), newData: newDataTeamSchema, referenceUid: z.string().optional().nullable(), - requesterEmailId: z.string(), + requesterEmailId: z.string().nullish(), uniqueIdentifier: z.string(), }); + +export const FindUniqueIdentiferSchema = z.object({ + type: participantTypeEnum, + identifier: z.string() +}) + +const ProcessParticipantRequest = z.object({ + status: statusEnum, +}) +export class ProcessParticipantReqDto extends createZodDto(ProcessParticipantRequest) {} +export class FindUniqueIdentiferDto extends createZodDto(FindUniqueIdentiferSchema) { }