diff --git a/src/adapters/postgres/postgres-module.ts b/src/adapters/postgres/postgres-module.ts index ea8095a..3fe0d65 100644 --- a/src/adapters/postgres/postgres-module.ts +++ b/src/adapters/postgres/postgres-module.ts @@ -13,6 +13,7 @@ import { PostgresFieldsService } from "./fields-adapter"; import { Cohort } from "src/cohort/entities/cohort.entity"; import { UserTenantMapping } from "src/userTenantMapping/entities/user-tenant-mapping.entity"; import { Tenants } from "src/userTenantMapping/entities/tenant.entity"; +import { Invitations } from "src/invitation/entities/invitation.entity"; import { UserRoleMapping } from "src/rbac/assign-role/entities/assign-role.entity"; import { Role } from "src/rbac/role/entities/role.entity"; import { PostgresRoleService } from "./rbac/role-adapter"; @@ -35,6 +36,7 @@ import { JwtService } from "@nestjs/jwt"; Cohort, UserTenantMapping, Tenants, + Invitations, UserRoleMapping, Role, RolePrivilegeMapping, diff --git a/src/app.module.ts b/src/app.module.ts index 41bdb2b..a20810a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -24,6 +24,7 @@ import { CoursePlannerModule } from './course-planner/course-planner.module'; import { HttpService } from "@utils/http-service"; import { TenantModule } from "./tenant/tenant.module"; import { AcademicyearsModule } from './academicyears/academicyears.module'; +import { InvitationModule } from './invitation/invitation.module'; @Module({ imports: [ @@ -45,6 +46,7 @@ import { AcademicyearsModule } from './academicyears/academicyears.module'; CoursePlannerModule, TenantModule, AcademicyearsModule, + InvitationModule, ], controllers: [AppController], providers: [AppService,HttpService] diff --git a/src/common/utils/api-id.config.ts b/src/common/utils/api-id.config.ts index 49a841f..fd093a9 100644 --- a/src/common/utils/api-id.config.ts +++ b/src/common/utils/api-id.config.ts @@ -50,5 +50,6 @@ export const APIID = { TENANT_LIST: "api.tenant.list", ACADEMICYEAR_CREATE: 'api.academicyear.create', ACADEMICYEAR_LIST: 'api.academicyear.list', - ACADEMICYEAR_GET: 'api.academicyear.get' + ACADEMICYEAR_GET: 'api.academicyear.get', + SEND_INVITATION:'api.invitation.send' } diff --git a/src/common/utils/response.messages.ts b/src/common/utils/response.messages.ts index 22de581..16bb3c8 100644 --- a/src/common/utils/response.messages.ts +++ b/src/common/utils/response.messages.ts @@ -16,7 +16,10 @@ export const API_RESPONSES = { TENANT_GET: 'Tenant fetched successfully.', TENANT_NOT_FOUND: 'Tenant does not exist', CONFLICT: 'Conflict detected', + INVITATION_SUCCESS: 'Invitation sent successfully', + INVITEDUSER_CONFLICT: (admin)=>`Invited user is already a ${admin} of invited cohort`, TENANT_EXISTS: 'Tenant already exists', + TENANT_NAME_EXISTS :(name)=>`Tenant with name ${name} already exists. Cannot Update`, TENANT_CREATE: 'Tenant created successfully', TENANT_UPDATE: 'Tenant updated successfully', TENANT_DELETE: 'Tenant deleted successfully', diff --git a/src/invitation/dto/create-invitation.dto.ts b/src/invitation/dto/create-invitation.dto.ts new file mode 100644 index 0000000..6830275 --- /dev/null +++ b/src/invitation/dto/create-invitation.dto.ts @@ -0,0 +1,23 @@ +import { Expose } from 'class-transformer'; +import { IsUUID, IsEmail, IsNotEmpty, isNotEmpty, IsDefined, IsOptional } from 'class-validator'; + +export class CreateInvitationDto { + @IsUUID() + @IsNotEmpty({message : "tenantId is required"}) + @Expose() + tenantId: string; + + @IsUUID() + @IsNotEmpty({message : "cohortId is required"}) + @Expose() + cohortId: string; + + @IsEmail() + @IsNotEmpty() + invitedTo: string; + + @IsEmail() + @IsOptional() + invitedBy: string; + +} diff --git a/src/invitation/entities/invitation.entity.ts b/src/invitation/entities/invitation.entity.ts new file mode 100644 index 0000000..7508dc3 --- /dev/null +++ b/src/invitation/entities/invitation.entity.ts @@ -0,0 +1,45 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + Timestamp, + UpdateDateColumn, +} from "typeorm"; + +@Entity("Invitations") +export class Invitations { + @PrimaryGeneratedColumn("uuid") + invitationId: string; + + @Column() + tenantId: string; + + @Column() + cohortId: string; + + @Column() + invitedTo: string; + + @Column() + invitedBy: string; + + @Column({ + type: "enum", + enum: ["Pending", "Accepted", "Rejected"], + default: "Pending", + }) + invitationStatus: "Pending" | "Accepted" | "Rejected"; + + @CreateDateColumn({ + type: "timestamp with time zone", + default: () => "CURRENT_TIMESTAMP", + }) + sentAt: Date; + + @UpdateDateColumn({ + type: "timestamp with time zone", + default: () => "CURRENT_TIMESTAMP", + }) + updatedAt: Date; +} diff --git a/src/invitation/invitation.controller.spec.ts b/src/invitation/invitation.controller.spec.ts new file mode 100644 index 0000000..ed17dd9 --- /dev/null +++ b/src/invitation/invitation.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { InvitationController } from './invitation.controller'; + +describe('InvitationController', () => { + let controller: InvitationController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [InvitationController], + }).compile(); + + controller = module.get(InvitationController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/invitation/invitation.controller.ts b/src/invitation/invitation.controller.ts new file mode 100644 index 0000000..f8ee084 --- /dev/null +++ b/src/invitation/invitation.controller.ts @@ -0,0 +1,29 @@ +import { Body, Controller, Post, Req, Res, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common'; +import { InvitationService } from './invitation.service'; +import { ApiBadRequestResponse, ApiBody, ApiCreatedResponse, ApiForbiddenResponse } from '@nestjs/swagger'; +import { CreateInvitationDto } from './dto/create-invitation.dto'; +import { Request, Response } from 'express'; +import { JwtAuthGuard } from 'src/common/guards/keycloak.guard'; + +@Controller('invitation') +@UseGuards(JwtAuthGuard) +export class InvitationController { + constructor ( + private invitationService: InvitationService, + ) {} + @Post("/sendinvite") + @ApiBody({type :CreateInvitationDto}) + @UsePipes(new ValidationPipe({ transform: true })) + @ApiCreatedResponse({ description: "Invite Send Successfully" }) + @ApiForbiddenResponse({ description: "Forbidden" }) + @ApiBadRequestResponse({ description: "Bad request." }) + + public async sendInvite( + @Req() request: Request, + @Res() response: Response, + @Body() createInvitationDto: CreateInvitationDto, + ) { + return await this.invitationService.sendInvite(request, createInvitationDto, response); + } + +} diff --git a/src/invitation/invitation.module.ts b/src/invitation/invitation.module.ts new file mode 100644 index 0000000..432e48a --- /dev/null +++ b/src/invitation/invitation.module.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common'; +import { InvitationController } from './invitation.controller'; +import { InvitationService } from './invitation.service'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { PostgresModule } from 'src/adapters/postgres/postgres-module'; +import { RolePrivilegeMapping } from 'src/rbac/assign-privilege/entities/assign-privilege.entity'; +import { UserRoleMapping } from 'src/rbac/assign-role/entities/assign-role.entity'; +import { Role } from 'src/rbac/role/entities/role.entity'; +import { UserTenantMapping } from 'src/userTenantMapping/entities/user-tenant-mapping.entity'; +import { User } from 'src/user/entities/user-entity' +import { Cohort } from 'src/cohort/entities/cohort.entity'; +import { Tenants } from 'src/userTenantMapping/entities/tenant.entity'; +import { Invitations } from './entities/invitation.entity'; +import { PostgresRoleService } from 'src/adapters/postgres/rbac/role-adapter'; +import { PostgresAssignPrivilegeService } from 'src/adapters/postgres/rbac/privilegerole.adapter'; +import { CohortMembers } from 'src/cohortMembers/entities/cohort-member.entity'; +import { PostgresUserService } from 'src/adapters/postgres/user-adapter'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Invitations,UserRoleMapping,UserTenantMapping,Role,RolePrivilegeMapping,User,Cohort,Tenants,CohortMembers]), + PostgresModule + ], + controllers: [InvitationController], + providers: [InvitationService,PostgresRoleService,PostgresAssignPrivilegeService] +}) +export class InvitationModule {} diff --git a/src/invitation/invitation.service.spec.ts b/src/invitation/invitation.service.spec.ts new file mode 100644 index 0000000..0d727af --- /dev/null +++ b/src/invitation/invitation.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { InvitationService } from './invitation.service'; + +describe('InvitationService', () => { + let service: InvitationService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [InvitationService], + }).compile(); + + service = module.get(InvitationService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/invitation/invitation.service.ts b/src/invitation/invitation.service.ts new file mode 100644 index 0000000..83ffb6e --- /dev/null +++ b/src/invitation/invitation.service.ts @@ -0,0 +1,161 @@ +import { HttpStatus, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import jwt_decode from "jwt-decode"; +import APIResponse from "src/common/responses/response"; +import { PostgresAssignPrivilegeService } from 'src/adapters/postgres/rbac/privilegerole.adapter'; +import { PostgresRoleService } from 'src/adapters/postgres/rbac/role-adapter'; +import { PostgresUserService } from 'src/adapters/postgres/user-adapter'; +import { Cohort } from 'src/cohort/entities/cohort.entity'; +import { UserRoleMapping } from 'src/rbac/assign-role/entities/assign-role.entity'; +import { User } from 'src/user/entities/user-entity'; +import { Tenants } from 'src/userTenantMapping/entities/tenant.entity'; +import { UserTenantMapping } from 'src/userTenantMapping/entities/user-tenant-mapping.entity'; +import { Repository } from 'typeorm'; +import { API_RESPONSES } from '@utils/response.messages'; +import { Invitations } from './entities/invitation.entity'; +import { CohortMembers } from 'src/cohortMembers/entities/cohort-member.entity'; +import { APIID } from '@utils/api-id.config'; +@Injectable() +export class InvitationService { + constructor( + @InjectRepository(UserTenantMapping) + private UserTenantMappingRepository: Repository, + @InjectRepository(Invitations) + public invitationsRepository: Repository, + @InjectRepository(User) + private usersRepository: Repository, + @InjectRepository(CohortMembers) + private cohortMembersRepository: Repository, + @InjectRepository(Cohort) + private cohortRepository: Repository, + private readonly userService: PostgresUserService, + private roleService: PostgresRoleService, + private rolePrivilegeService: PostgresAssignPrivilegeService, + ) {} + public async sendInvite(request, createInvitationDto, response) { + const apiId =APIID.SEND_INVITATION + try { + const decoded = jwt_decode(request.headers["authorization"]); + createInvitationDto.invitedBy = decoded["email"]; + + // Check if the tenant-cohort mapping exists + const tenantCohortExist = await this.cohortRepository.findOne({ + where: { + tenantId: createInvitationDto.tenantId, + cohortId: createInvitationDto.cohortId, + }, + }); + + if (!tenantCohortExist) { + return APIResponse.error( + response, + apiId, + API_RESPONSES.CONFLICT, + "Tenant and cohort mapping not found", + HttpStatus.CONFLICT + ); + } + // check if duplicate request exist with status pending + const checkInvitaionExist = await this.invitationsRepository.findOne({ + where: { + tenantId: createInvitationDto.tenantId, + cohortId: createInvitationDto.cohortId, + invitedTo: createInvitationDto.invitedTo, + invitationStatus: "Pending" + }, + }) + if (checkInvitaionExist) { + return APIResponse.error( + response, + apiId, + API_RESPONSES.CONFLICT, + "Invitation already sent", + HttpStatus.CONFLICT + ); + } + + // Check if the user exists + const checkUser = await this.usersRepository.findOne({ + where: { email: createInvitationDto.invitedTo }, + }); + + if (!checkUser) { + const result = await this.invitationsRepository.save( + createInvitationDto + ); + return APIResponse.success( + response, + apiId, + result, + HttpStatus.OK, + API_RESPONSES.INVITATION_SUCCESS + ); + } + + // Fetch user roles + const userRoles = await this.userService.getUserRoles(checkUser.userId,createInvitationDto.tenantId); + + if (!userRoles) { + const result = await this.invitationsRepository.save( + createInvitationDto + ); + return APIResponse.success( + response, + apiId, + result, + HttpStatus.OK, + API_RESPONSES.INVITATION_SUCCESS + ); + } + + // Handle different user roles + if (userRoles.code === "tenant_admin") { + return APIResponse.error( + response, + apiId, + API_RESPONSES.CONFLICT, + API_RESPONSES.INVITEDUSER_CONFLICT('tenant admin'), + HttpStatus.CONFLICT + ); + } + + if (userRoles.code === "cohort_admin") { + // Check if the user is already mapped to the cohort + const cohortExists = await this.cohortMembersRepository.findOne({ + where: { + userId: checkUser.userId, + cohortId: createInvitationDto.cohortId, + }, + }); + + if (cohortExists) { + return APIResponse.error( + response, + apiId, + API_RESPONSES.CONFLICT, + API_RESPONSES.INVITEDUSER_CONFLICT('cohort admin'), + HttpStatus.CONFLICT + ); + } + } + + // Save invitation if no conflicts + const result = await this.invitationsRepository.save(createInvitationDto); + return APIResponse.success( + response, + apiId, + result, + HttpStatus.OK, + API_RESPONSES.INVITATION_SUCCESS + ); + } catch (error) { + return APIResponse.error( + response, + apiId, + API_RESPONSES.INTERNAL_SERVER_ERROR, + error, + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + } +}