diff --git a/apps/api/v2/src/modules/ooo/guards/is-user-ooo.ts b/apps/api/v2/src/modules/ooo/guards/is-user-ooo.ts new file mode 100644 index 0000000000..05ed3c78bc --- /dev/null +++ b/apps/api/v2/src/modules/ooo/guards/is-user-ooo.ts @@ -0,0 +1,30 @@ +import { UserOOORepository } from "@/modules/ooo/repositories/ooo.repository"; +import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common"; +import { Request } from "express"; + +@Injectable() +export class IsUserOOO implements CanActivate { + constructor(private oooRepo: UserOOORepository) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const oooId: string = request.params.oooId; + const userId: string = request.params.userId; + + if (!userId) { + throw new ForbiddenException("No user id found in request params."); + } + + if (!oooId) { + throw new ForbiddenException("No ooo entry id found in request params."); + } + + const ooo = await this.oooRepo.getUserOOOByIdAndUserId(Number(oooId), Number(userId)); + + if (ooo) { + return true; + } + + throw new ForbiddenException("This OOO entry does not belong to this user."); + } +} diff --git a/apps/api/v2/src/modules/ooo/inputs/ooo.input.ts b/apps/api/v2/src/modules/ooo/inputs/ooo.input.ts new file mode 100644 index 0000000000..1e67a9bb50 --- /dev/null +++ b/apps/api/v2/src/modules/ooo/inputs/ooo.input.ts @@ -0,0 +1,81 @@ +import { BadRequestException } from "@nestjs/common"; +import { ApiProperty, ApiPropertyOptional, PartialType } from "@nestjs/swagger"; +import { Transform } from "class-transformer"; +import { IsDate, IsInt, IsOptional, IsString, IsEnum, isDate } from "class-validator"; + +export enum OutOfOfficeReason { + UNSPECIFIED = "unspecified", + VACATION = "vacation", + TRAVEL = "travel", + SICK_LEAVE = "sick", + PUBLIC_HOLIDAY = "public_holiday", +} + +export type OutOfOfficeReasonType = `${OutOfOfficeReason}`; + +const isDateString = (dateString: string) => { + try { + const isoDateRegex = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d{3})?Z$/; + return isoDateRegex.test(dateString); + } catch { + throw new BadRequestException("Invalid Date."); + } +}; + +export class CreateOutOfOfficeEntryDto { + @Transform(({ value }: { value: string }) => { + if (isDateString(value)) { + const date = new Date(value); + date.setUTCHours(0, 0, 0, 0); + return date; + } + throw new BadRequestException("Invalid Date."); + }) + @IsDate() + @ApiProperty({ + description: "The start date and time of the out of office period in ISO 8601 format in UTC timezone.", + example: "2023-05-01T00:00:00.000Z", + }) + start!: Date; + + @Transform(({ value }: { value: string }) => { + if (isDateString(value)) { + const date = new Date(value); + date.setUTCHours(23, 59, 59, 999); + return date; + } + throw new BadRequestException("Invalid Date."); + }) + @IsDate() + @ApiProperty({ + description: "The end date and time of the out of office period in ISO 8601 format in UTC timezone.", + example: "2023-05-10T23:59:59.999Z", + }) + end!: Date; + + @IsString() + @IsOptional() + @ApiPropertyOptional({ + description: "Optional notes for the out of office entry.", + example: "Vacation in Hawaii", + }) + notes?: string; + + @IsInt() + @IsOptional() + @ApiPropertyOptional({ + description: "The ID of the user covering for the out of office period, if applicable.", + example: 2, + }) + toUserId?: number; + + @IsEnum(OutOfOfficeReason) + @IsOptional() + @ApiPropertyOptional({ + description: "the reason for the out of office entry, if applicable", + example: "vacation", + }) + reason?: OutOfOfficeReasonType; +} + +export class UpdateOutOfOfficeEntryDto extends PartialType(CreateOutOfOfficeEntryDto) {} diff --git a/apps/api/v2/src/modules/ooo/outputs/ooo.output.ts b/apps/api/v2/src/modules/ooo/outputs/ooo.output.ts new file mode 100644 index 0000000000..48a62e4001 --- /dev/null +++ b/apps/api/v2/src/modules/ooo/outputs/ooo.output.ts @@ -0,0 +1,99 @@ +import { OutOfOfficeReason } from "@/modules/ooo/inputs/ooo.input"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { IsInt, IsEnum, ValidateNested, IsString, IsDateString, IsOptional } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class UserOooOutputDto { + @IsInt() + @Expose() + @ApiProperty({ + description: "The ID of the user.", + example: 2, + }) + readonly userId!: number; + + @IsInt() + @IsOptional() + @ApiPropertyOptional({ + description: "The ID of the user covering for the out of office period, if applicable.", + example: 2, + }) + @Expose() + readonly toUserId?: number; + + @IsInt() + @Expose() + @ApiProperty({ + description: "The ID of the ooo entry.", + example: 2, + }) + readonly id!: number; + + @IsString() + @Expose() + @ApiProperty({ + description: "The UUID of the ooo entry.", + example: 2, + }) + readonly uuid!: string; + + @IsDateString() + @ApiProperty({ + description: "The start date and time of the out of office period in ISO 8601 format in UTC timezone.", + example: "2023-05-01T00:00:00.000Z", + }) + @Expose() + start!: Date; + + @IsDateString() + @ApiProperty({ + description: "The end date and time of the out of office period in ISO 8601 format in UTC timezone.", + example: "2023-05-10T23:59:59.999Z", + }) + @Expose() + end!: Date; + + @IsString() + @IsOptional() + @ApiPropertyOptional({ + description: "Optional notes for the out of office entry.", + example: "Vacation in Hawaii", + }) + @Expose() + notes?: string; + + @IsEnum(OutOfOfficeReason) + @IsOptional() + @ApiPropertyOptional({ + description: "the reason for the out of office entry, if applicable", + example: "vacation", + }) + @Expose() + reason?: OutOfOfficeReason; +} + +export class UserOooOutputResponseDto { + @Expose() + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => UserOooOutputDto) + data!: UserOooOutputDto; +} + +export class UserOoosOutputResponseDto { + @Expose() + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => UserOooOutputDto) + data!: UserOooOutputDto[]; +} diff --git a/apps/api/v2/src/modules/ooo/repositories/ooo.repository.ts b/apps/api/v2/src/modules/ooo/repositories/ooo.repository.ts new file mode 100644 index 0000000000..c2363ccded --- /dev/null +++ b/apps/api/v2/src/modules/ooo/repositories/ooo.repository.ts @@ -0,0 +1,100 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { Injectable } from "@nestjs/common"; +import { v4 as uuidv4 } from "uuid"; + +import { Prisma } from "@calcom/prisma/client"; + +import { PrismaWriteService } from "../../prisma/prisma-write.service"; + +type OOOInputData = Omit & { + toUserId?: number; + userId: number; + reasonId?: number; +}; + +@Injectable() +export class UserOOORepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async createUserOOO(data: OOOInputData) { + const uuid = uuidv4(); + return this.dbWrite.prisma.outOfOfficeEntry.create({ + data: { ...data, uuid }, + include: { reason: true }, + }); + } + + async updateUserOOO(oooId: number, data: Partial) { + return this.dbWrite.prisma.outOfOfficeEntry.update({ + where: { id: oooId }, + data, + include: { reason: true }, + }); + } + + async getUserOOOById(oooId: number) { + return this.dbRead.prisma.outOfOfficeEntry.findFirst({ + where: { id: oooId }, + include: { reason: true }, + }); + } + + async getUserOOOByIdAndUserId(oooId: number, userId: number) { + return this.dbRead.prisma.outOfOfficeEntry.findFirst({ + where: { id: oooId, userId }, + include: { reason: true }, + }); + } + + async getUserOOOPaginated(userId: number, skip: number, take: number) { + return this.dbRead.prisma.outOfOfficeEntry.findMany({ + where: { userId }, + skip, + take, + include: { reason: true }, + }); + } + + async deleteUserOOO(oooId: number) { + return this.dbWrite.prisma.outOfOfficeEntry.delete({ + where: { id: oooId }, + include: { reason: true }, + }); + } + + async findExistingOooRedirect(userId: number, start: Date, end: Date, toUserId?: number) { + const existingOutOfOfficeEntry = await this.dbRead.prisma.outOfOfficeEntry.findFirst({ + select: { + userId: true, + toUserId: true, + }, + where: { + ...(toUserId && { userId: toUserId }), + toUserId: userId, + // Check for time overlap or collision + OR: [ + // Outside of range + { + AND: [{ start: { lte: end } }, { end: { gte: start } }], + }, + // Inside of range + { + AND: [{ start: { gte: start } }, { end: { lte: end } }], + }, + ], + }, + }); + + return existingOutOfOfficeEntry; + } + + async getOooByUserIdAndTime(userId: number, start: Date, end: Date) { + return await this.dbRead.prisma.outOfOfficeEntry.findFirst({ + where: { + userId, + start, + end, + }, + }); + } +} diff --git a/apps/api/v2/src/modules/ooo/services/ooo.service.ts b/apps/api/v2/src/modules/ooo/services/ooo.service.ts new file mode 100644 index 0000000000..8905065bda --- /dev/null +++ b/apps/api/v2/src/modules/ooo/services/ooo.service.ts @@ -0,0 +1,135 @@ +import { + CreateOutOfOfficeEntryDto, + UpdateOutOfOfficeEntryDto, + OutOfOfficeReason, +} from "@/modules/ooo/inputs/ooo.input"; +import { UserOOORepository } from "@/modules/ooo/repositories/ooo.repository"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { BadRequestException, ConflictException, Injectable } from "@nestjs/common"; + +import { OutOfOfficeEntry } from "@calcom/prisma/client"; + +const OOO_REASON_ID_TO_REASON = { + 1: OutOfOfficeReason["UNSPECIFIED"], + 2: OutOfOfficeReason["VACATION"], + 3: OutOfOfficeReason["TRAVEL"], + 4: OutOfOfficeReason["SICK_LEAVE"], + 5: OutOfOfficeReason["PUBLIC_HOLIDAY"], +}; + +const OOO_REASON_TO_REASON_ID = { + [OutOfOfficeReason["UNSPECIFIED"]]: 1, + [OutOfOfficeReason["VACATION"]]: 2, + [OutOfOfficeReason["TRAVEL"]]: 3, + [OutOfOfficeReason["SICK_LEAVE"]]: 4, + [OutOfOfficeReason["PUBLIC_HOLIDAY"]]: 5, +}; + +@Injectable() +export class UserOOOService { + constructor( + private readonly oooRepository: UserOOORepository, + private readonly usersRepository: UsersRepository + ) {} + + formatOooReason(ooo: OutOfOfficeEntry) { + return { + ...ooo, + reason: ooo.reasonId + ? OOO_REASON_ID_TO_REASON[ooo.reasonId as keyof typeof OOO_REASON_ID_TO_REASON] + : OOO_REASON_ID_TO_REASON[1], + }; + } + + isStartBeforeEnd(start?: Date, end?: Date) { + if ((end && !start) || (start && !end)) { + throw new BadRequestException("Please specify both ooo start and end time."); + } + + if (start && end) { + if (start.getTime() > end.getTime()) { + throw new BadRequestException("Start date must be before end date."); + } + } + return true; + } + + async checkUserEligibleForRedirect(userId: number, toUserId?: number) { + if (toUserId) { + const user = await this.usersRepository.findUserOOORedirectEligible(userId, toUserId); + if (!user) { + throw new BadRequestException("Cannot redirect to this user."); + } + } + } + + async checkExistingOooRedirect(userId: number, start?: Date, end?: Date, toUserId?: number) { + if (start && end) { + const existingOooRedirect = await this.oooRepository.findExistingOooRedirect( + userId, + start, + end, + toUserId + ); + + if (existingOooRedirect) { + throw new BadRequestException("Booking redirect infinite not allowed."); + } + } + } + + async checkDuplicateOOOEntry(userId: number, start?: Date, end?: Date) { + if (start && end) { + const duplicateEntry = await this.oooRepository.getOooByUserIdAndTime(userId, start, end); + + if (duplicateEntry) { + throw new ConflictException("Ooo entry already exists."); + } + } + } + + checkRedirectToSelf(userId: number, toUserId?: number) { + if (toUserId && toUserId === userId) { + throw new BadRequestException("Cannot redirect to self."); + } + } + + async checkIsValidOOO(userId: number, ooo: CreateOutOfOfficeEntryDto | UpdateOutOfOfficeEntryDto) { + this.isStartBeforeEnd(ooo.start, ooo.end); + await this.checkExistingOooRedirect(userId, ooo.start, ooo.end, ooo.toUserId); + await this.checkDuplicateOOOEntry(userId, ooo.start, ooo.end); + await this.checkRedirectToSelf(userId, ooo.toUserId); + await this.checkUserEligibleForRedirect(userId, ooo.toUserId); + } + + async createUserOOO(userId: number, body: CreateOutOfOfficeEntryDto) { + await this.checkIsValidOOO(userId, body); + const { reason, ...rest } = body; + const ooo = await this.oooRepository.createUserOOO({ + ...rest, + userId, + reasonId: OOO_REASON_TO_REASON_ID[reason ?? OutOfOfficeReason["UNSPECIFIED"]], + }); + return this.formatOooReason(ooo); + } + + async updateUserOOO(userId: number, oooId: number, body: UpdateOutOfOfficeEntryDto) { + await this.checkIsValidOOO(userId, body); + const { reason, ...rest } = body; + const data = reason + ? { ...rest, reasonId: OOO_REASON_TO_REASON_ID[reason ?? OutOfOfficeReason["UNSPECIFIED"]] } + : rest; + const ooo = await this.oooRepository.updateUserOOO(oooId, data); + return this.formatOooReason(ooo); + } + + async deleteUserOOO(oooId: number) { + const ooo = await this.oooRepository.deleteUserOOO(oooId); + return this.formatOooReason(ooo); + } + + async getUserOOOPaginated(userId: number, skip: number, take: number) { + const ooos = await this.oooRepository.getUserOOOPaginated(userId, skip, take); + return ooos.map((ooo) => this.formatOooReason(ooo)); + } +} diff --git a/apps/api/v2/src/modules/organizations/controllers/users/ooo/organizations-users-ooo-controller.ts b/apps/api/v2/src/modules/organizations/controllers/users/ooo/organizations-users-ooo-controller.ts new file mode 100644 index 0000000000..90f6f604d6 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/users/ooo/organizations-users-ooo-controller.ts @@ -0,0 +1,114 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator"; +import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard"; +import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard"; +import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; +import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; +import { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard"; +import { IsUserOOO } from "@/modules/ooo/guards/is-user-ooo"; +import { CreateOutOfOfficeEntryDto, UpdateOutOfOfficeEntryDto } from "@/modules/ooo/inputs/ooo.input"; +import { + UserOooOutputDto, + UserOooOutputResponseDto, + UserOoosOutputResponseDto, +} from "@/modules/ooo/outputs/ooo.output"; +import { UserOOOService } from "@/modules/ooo/services/ooo.service"; +import { + Controller, + UseGuards, + Get, + Post, + Patch, + Delete, + Param, + ParseIntPipe, + Body, + UseInterceptors, + Query, +} from "@nestjs/common"; +import { ClassSerializerInterceptor } from "@nestjs/common"; +import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; +import { plainToInstance } from "class-transformer"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { SkipTakePagination } from "@calcom/platform-types"; + +@Controller({ + path: "/v2/organizations/:orgId/users/:userId/ooo", + version: API_VERSIONS_VALUES, +}) +@UseInterceptors(ClassSerializerInterceptor) +@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard) +@UseGuards(IsOrgGuard) +@DocsTags("Orgs / Users / OOO") +export class OrganizationsUsersOOOController { + constructor(private readonly userOOOService: UserOOOService) {} + + @Get() + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(IsUserInOrg) + @ApiOperation({ summary: "Get all ooo entries of a user" }) + async getOrganizationUserOOO( + @Param("userId", ParseIntPipe) userId: number, + @Query() query: SkipTakePagination + ): Promise { + const ooos = await this.userOOOService.getUserOOOPaginated(userId, query.skip ?? 0, query.take ?? 250); + + return { + status: SUCCESS_STATUS, + data: ooos.map((ooo) => plainToInstance(UserOooOutputDto, ooo, { strategy: "excludeAll" })), + }; + } + + @Post() + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(IsUserInOrg) + @ApiOperation({ summary: "Create an ooo entry for user" }) + async createOrganizationUserOOO( + @Param("userId", ParseIntPipe) userId: number, + @Body() input: CreateOutOfOfficeEntryDto + ): Promise { + const ooo = await this.userOOOService.createUserOOO(userId, input); + return { + status: SUCCESS_STATUS, + data: plainToInstance(UserOooOutputDto, ooo, { strategy: "excludeAll" }), + }; + } + + @Patch("/:oooId") + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(IsUserInOrg, IsUserOOO) + @ApiOperation({ summary: "Update ooo entry of a user" }) + async updateOrganizationUserOOO( + @Param("userId", ParseIntPipe) userId: number, + @Param("oooId", ParseIntPipe) oooId: number, + + @Body() input: UpdateOutOfOfficeEntryDto + ): Promise { + const ooo = await this.userOOOService.updateUserOOO(userId, oooId, input); + return { + status: SUCCESS_STATUS, + data: plainToInstance(UserOooOutputDto, ooo, { strategy: "excludeAll" }), + }; + } + + @Delete("/:oooId") + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(IsUserInOrg, IsUserOOO) + @ApiOperation({ summary: "Delete ooo entry of a user" }) + async deleteOrganizationUserOOO( + @Param("oooId", ParseIntPipe) oooId: number + ): Promise { + const ooo = await this.userOOOService.deleteUserOOO(oooId); + return { + status: SUCCESS_STATUS, + data: plainToInstance(UserOooOutputDto, ooo, { strategy: "excludeAll" }), + }; + } +} diff --git a/apps/api/v2/src/modules/organizations/controllers/users/ooo/organizations-users-ooo.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/users/ooo/organizations-users-ooo.e2e-spec.ts new file mode 100644 index 0000000000..ab8252b703 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/users/ooo/organizations-users-ooo.e2e-spec.ts @@ -0,0 +1,421 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { UserOooOutputDto } from "@/modules/ooo/outputs/ooo.output"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "@prisma/client"; +import * as request from "supertest"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; +import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { Team } from "@calcom/prisma/client"; + +describe("Organizations User OOO Endpoints", () => { + describe("User Authentication - User is Org Admin", () => { + let app: INestApplication; + let oooCreatedViaApiId: number; + let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let teamsRepositoryFixture: TeamRepositoryFixture; + let membershipsRepositoryFixture: MembershipRepositoryFixture; + let profileRepositoryFixture: ProfileRepositoryFixture; + + let org: Team; + let team: Team; + let falseTestOrg: Team; + let falseTestTeam: Team; + + const userEmail = "org-admin-ooo-controller-e222e@api.com"; + let userAdmin: User; + + const teammate1Email = "teammate111ooo@team.com"; + const teammate2Email = "teammate221ooo@team.com"; + const falseTestUserEmail = "false-user-ooo@false-team.com"; + let teammate1: User; + let teammate2: User; + let falseTestUser: User; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + + userAdmin = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + role: "ADMIN", + }); + + teammate1 = await userRepositoryFixture.create({ + email: teammate1Email, + username: teammate1Email, + }); + + teammate2 = await userRepositoryFixture.create({ + email: teammate2Email, + username: teammate2Email, + }); + + falseTestUser = await userRepositoryFixture.create({ + email: falseTestUserEmail, + username: falseTestUserEmail, + }); + + org = await organizationsRepositoryFixture.create({ + name: "Test Organization ooo", + isOrganization: true, + }); + + falseTestOrg = await organizationsRepositoryFixture.create({ + name: "False test org ooo", + isOrganization: true, + }); + + team = await teamsRepositoryFixture.create({ + name: "Test org team ooo", + isOrganization: false, + parent: { connect: { id: org.id } }, + }); + + falseTestTeam = await teamsRepositoryFixture.create({ + name: "Outside org team ooo", + isOrganization: false, + parent: { connect: { id: falseTestOrg.id } }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${userAdmin.id}`, + username: userEmail, + organization: { + connect: { + id: org.id, + }, + }, + user: { + connect: { + id: userAdmin.id, + }, + }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${teammate1.id}`, + username: teammate1Email, + organization: { + connect: { + id: org.id, + }, + }, + user: { + connect: { + id: teammate1.id, + }, + }, + }); + + await membershipsRepositoryFixture.create({ + role: "ADMIN", + user: { connect: { id: userAdmin.id } }, + team: { connect: { id: org.id } }, + accepted: true, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: teammate1.id } }, + team: { connect: { id: team.id } }, + accepted: true, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: teammate2.id } }, + team: { connect: { id: team.id } }, + accepted: true, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: falseTestUser.id } }, + team: { connect: { id: falseTestTeam.id } }, + accepted: true, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(organizationsRepositoryFixture).toBeDefined(); + expect(userAdmin).toBeDefined(); + expect(org).toBeDefined(); + }); + + it("should create a ooo entry without redirect", async () => { + const body = { + start: "2025-05-01T01:00:00.000Z", + end: "2025-05-10T13:59:59.999Z", + notes: "ooo numero uno", + }; + + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo`) + .send(body) + .expect(201) + .then((response) => { + const responseBody = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + const data = responseBody.data as UserOooOutputDto; + expect(data.reason).toEqual("unspecified"); + expect(data.userId).toEqual(teammate1.id); + expect(data.start).toEqual("2025-05-01T00:00:00.000Z"); + expect(data.end).toEqual("2025-05-10T23:59:59.999Z"); + oooCreatedViaApiId = data.id; + }); + }); + + it("should create a ooo entry with redirect", async () => { + const body = { + start: "2025-08-01T01:00:00.000Z", + end: "2025-10-10T13:59:59.999Z", + notes: "ooo numero dos with redirect", + toUserId: teammate2.id, + }; + + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo`) + .send(body) + .expect(201) + .then((response) => { + const responseBody = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + const data = responseBody.data as UserOooOutputDto; + expect(data.reason).toEqual("unspecified"); + expect(data.userId).toEqual(teammate1.id); + expect(data.start).toEqual("2025-08-01T00:00:00.000Z"); + expect(data.end).toEqual("2025-10-10T23:59:59.999Z"); + expect(data.toUserId).toEqual(teammate2.id); + }); + }); + + it("should fail to create a ooo entry with start after end", async () => { + const body = { + start: "2025-07-01T00:00:00.000Z", + end: "2025-05-10T23:59:59.999Z", + notes: "ooo numero uno duplicate", + }; + + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo`) + .send(body) + .expect(400); + }); + + it("should fail to create a duplicate ooo entry", async () => { + const body = { + start: "2025-05-01T00:00:00.000Z", + end: "2025-05-10T23:59:59.999Z", + notes: "ooo numero uno duplicate", + }; + + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo`) + .send(body) + .expect(409); + }); + + it("should fail to create an ooo entry that redirects to self", async () => { + const body = { + start: "2025-05-02T00:00:00.000Z", + end: "2025-05-03T23:59:59.999Z", + notes: "ooo infinite redirect", + toUserId: teammate1.id, + }; + + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo`) + .send(body) + .expect(400); + }); + + it("should fail to create an ooo entry that redirects to member outside of org", async () => { + const body = { + start: "2025-05-02T00:00:00.000Z", + end: "2025-05-03T23:59:59.999Z", + notes: "ooo invalid redirect", + toUserId: falseTestUser.id, + }; + + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo`) + .send(body) + .expect(400); + }); + + it("should update a ooo entry without redirect", async () => { + const body = { + start: "2025-06-01T01:00:00.000Z", + end: "2025-06-10T13:59:59.999Z", + notes: "ooo numero uno", + reason: "vacation", + }; + + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo/${oooCreatedViaApiId}`) + .send(body) + .expect(200) + .then((response) => { + const responseBody = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + const data = responseBody.data as UserOooOutputDto; + expect(data.reason).toEqual("vacation"); + expect(data.userId).toEqual(teammate1.id); + expect(data.start).toEqual("2025-06-01T00:00:00.000Z"); + expect(data.end).toEqual("2025-06-10T23:59:59.999Z"); + }); + }); + + it("should fail to update a ooo entry with redirect outside of org", async () => { + const body = { + toUserId: falseTestUser.id, + }; + + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo/${oooCreatedViaApiId}`) + .send(body) + .expect(400); + }); + + it("should fail to update a ooo entry with start after end ", async () => { + const body = { + start: "2025-07-01T00:00:00.000Z", + end: "2025-05-10T23:59:59.999Z", + }; + + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo/${oooCreatedViaApiId}`) + .send(body) + .expect(400); + }); + + it("should fail to update a ooo entry with redirect to self ", async () => { + const body = { + toUserId: teammate1.id, + }; + + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo/${oooCreatedViaApiId}`) + .send(body) + .expect(400); + }); + + it("should fail to update a ooo entry with duplicate time", async () => { + const body = { + start: "2025-06-01T01:00:00.000Z", + end: "2025-06-10T13:59:59.999Z", + }; + + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo/${oooCreatedViaApiId}`) + .send(body) + .expect(409); + }); + + it("should update a ooo entry without redirect", async () => { + const body = { + toUserId: teammate2.id, + }; + + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo/${oooCreatedViaApiId}`) + .send(body) + .expect(200) + .then((response) => { + const responseBody = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + const data = responseBody.data as UserOooOutputDto; + expect(data.reason).toEqual("vacation"); + expect(data.toUserId).toEqual(teammate2.id); + expect(data.userId).toEqual(teammate1.id); + expect(data.start).toEqual("2025-06-01T00:00:00.000Z"); + expect(data.end).toEqual("2025-06-10T23:59:59.999Z"); + }); + }); + + it("should get 2 ooo entries", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo`) + .expect(200) + .then((response) => { + const responseBody = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + const data = responseBody.data as UserOooOutputDto[]; + expect(data.length).toEqual(2); + const oooUno = data.find((ooo) => ooo.id === oooCreatedViaApiId); + expect(oooUno).toBeDefined(); + if (oooUno) { + expect(oooUno.reason).toEqual("vacation"); + expect(oooUno.toUserId).toEqual(teammate2.id); + expect(oooUno.userId).toEqual(teammate1.id); + expect(oooUno.start).toEqual("2025-06-01T00:00:00.000Z"); + expect(oooUno.end).toEqual("2025-06-10T23:59:59.999Z"); + } + }); + }); + + it("should delete ooo entry", async () => { + return request(app.getHttpServer()) + .delete(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo/${oooCreatedViaApiId}`) + .expect(200); + }); + + it("user should have 1 ooo entries", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo`) + .expect(200) + .then((response) => { + const responseBody = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + const data = responseBody.data as UserOooOutputDto[]; + expect(data.length).toEqual(1); + }); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(userAdmin.email); + await userRepositoryFixture.deleteByEmail(teammate1.email); + await userRepositoryFixture.deleteByEmail(teammate2.email); + await userRepositoryFixture.deleteByEmail(falseTestUser.email); + await teamsRepositoryFixture.delete(team.id); + await teamsRepositoryFixture.delete(falseTestTeam.id); + await organizationsRepositoryFixture.delete(org.id); + await organizationsRepositoryFixture.delete(falseTestOrg.id); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/organizations/organizations.module.ts b/apps/api/v2/src/modules/organizations/organizations.module.ts index 3a708a0d71..d741a89866 100644 --- a/apps/api/v2/src/modules/organizations/organizations.module.ts +++ b/apps/api/v2/src/modules/organizations/organizations.module.ts @@ -3,6 +3,8 @@ import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/ import { EmailModule } from "@/modules/email/email.module"; import { EmailService } from "@/modules/email/email.service"; import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; +import { UserOOORepository } from "@/modules/ooo/repositories/ooo.repository"; +import { UserOOOService } from "@/modules/ooo/services/ooo.service"; import { OrganizationsOptionsAttributesController } from "@/modules/organizations/controllers/attributes/organizations-attributes-options.controller"; import { OrganizationsAttributesController } from "@/modules/organizations/controllers/attributes/organizations-attributes.controller"; import { OrganizationsEventTypesController } from "@/modules/organizations/controllers/event-types/organizations-event-types.controller"; @@ -12,6 +14,7 @@ import { OrganizationsSchedulesController } from "@/modules/organizations/contro import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller"; import { OrganizationsTeamsController } from "@/modules/organizations/controllers/teams/organizations-teams.controller"; import { OrganizationsTeamsSchedulesController } from "@/modules/organizations/controllers/teams/schedules/organizations-teams-schedules.controller"; +import { OrganizationsUsersOOOController } from "@/modules/organizations/controllers/users/ooo/organizations-users-ooo-controller"; import { OrganizationsUsersController } from "@/modules/organizations/controllers/users/organizations-users.controller"; import { OrganizationsWebhooksController } from "@/modules/organizations/controllers/webhooks/organizations-webhooks.controller"; import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; @@ -86,6 +89,8 @@ import { Module } from "@nestjs/common"; WebhooksRepository, WebhooksService, OutputTeamEventTypesResponsePipe, + UserOOOService, + UserOOORepository, ], exports: [ OrganizationsService, @@ -118,6 +123,7 @@ import { Module } from "@nestjs/common"; OrganizationsOptionsAttributesController, OrganizationsWebhooksController, OrganizationsTeamsSchedulesController, + OrganizationsUsersOOOController, ], }) export class OrganizationsModule {} diff --git a/apps/api/v2/src/modules/users/users.repository.ts b/apps/api/v2/src/modules/users/users.repository.ts index 2a8cc0331b..f113708d3d 100644 --- a/apps/api/v2/src/modules/users/users.repository.ts +++ b/apps/api/v2/src/modules/users/users.repository.ts @@ -265,4 +265,27 @@ export class UsersRepository { where: { id: userId }, }); } + + async findUserOOORedirectEligible(userId: number, toTeamUserId: number) { + return await this.dbRead.prisma.user.findUnique({ + where: { + id: toTeamUserId, + teams: { + some: { + team: { + members: { + some: { + userId: userId, + accepted: true, + }, + }, + }, + }, + }, + }, + select: { + id: true, + }, + }); + } } diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index e1c631dd3b..f04d0a9cc8 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -2684,6 +2684,146 @@ ] } }, + "/v2/organizations/{orgId}/users/{userId}/ooo": { + "get": { + "operationId": "OrganizationsUsersOOOController_getOrganizationUserOOO", + "summary": "Get all ooo entries of a user", + "parameters": [ + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "take", + "required": false, + "in": "query", + "description": "The number of items to return", + "example": 10, + "schema": { + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "The number of items to skip", + "example": 0, + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Orgs / Users / OOO" + ] + }, + "post": { + "operationId": "OrganizationsUsersOOOController_createOrganizationUserOOO", + "summary": "Create an ooo entry for user", + "parameters": [ + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOutOfOfficeEntryDto" + } + } + } + }, + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "Orgs / Users / OOO" + ] + } + }, + "/v2/organizations/{orgId}/users/{userId}/ooo/{oooId}": { + "patch": { + "operationId": "OrganizationsUsersOOOController_updateOrganizationUserOOO", + "summary": "Update ooo entry of a user", + "parameters": [ + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "oooId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOutOfOfficeEntryDto" + } + } + } + }, + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Orgs / Users / OOO" + ] + }, + "delete": { + "operationId": "OrganizationsUsersOOOController_deleteOrganizationUserOOO", + "summary": "Delete ooo entry of a user", + "parameters": [ + { + "name": "oooId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Orgs / Users / OOO" + ] + } + }, "/v2/organizations/{orgId}/webhooks": { "get": { "operationId": "OrganizationsWebhooksController_getAllOrganizationWebhooks", @@ -13454,6 +13594,74 @@ } } }, + "CreateOutOfOfficeEntryDto": { + "type": "object", + "properties": { + "start": { + "format": "date-time", + "type": "string", + "description": "The start date and time of the out of office period in ISO 8601 format in UTC timezone.", + "example": "2023-05-01T00:00:00.000Z" + }, + "end": { + "format": "date-time", + "type": "string", + "description": "The end date and time of the out of office period in ISO 8601 format in UTC timezone.", + "example": "2023-05-10T23:59:59.999Z" + }, + "notes": { + "type": "string", + "description": "Optional notes for the out of office entry.", + "example": "Vacation in Hawaii" + }, + "toUserId": { + "type": "number", + "description": "The ID of the user covering for the out of office period, if applicable.", + "example": 2 + }, + "reason": { + "type": "object", + "description": "the reason for the out of office entry, if applicable", + "example": "vacation" + } + }, + "required": [ + "start", + "end" + ] + }, + "UpdateOutOfOfficeEntryDto": { + "type": "object", + "properties": { + "start": { + "format": "date-time", + "type": "string", + "description": "The start date and time of the out of office period in ISO 8601 format in UTC timezone.", + "example": "2023-05-01T00:00:00.000Z" + }, + "end": { + "format": "date-time", + "type": "string", + "description": "The end date and time of the out of office period in ISO 8601 format in UTC timezone.", + "example": "2023-05-10T23:59:59.999Z" + }, + "notes": { + "type": "string", + "description": "Optional notes for the out of office entry.", + "example": "Vacation in Hawaii" + }, + "toUserId": { + "type": "number", + "description": "The ID of the user covering for the out of office period, if applicable.", + "example": 2 + }, + "reason": { + "type": "object", + "description": "the reason for the out of office entry, if applicable", + "example": "vacation" + } + } + }, "StripConnectOutputDto": { "type": "object", "properties": { diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index e7a70411a0..dc0446dd31 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -2549,6 +2549,138 @@ "tags": ["Orgs / Users"] } }, + "/v2/organizations/{orgId}/users/{userId}/ooo": { + "get": { + "operationId": "OrganizationsUsersOOOController_getOrganizationUserOOO", + "summary": "Get all ooo entries of a user", + "parameters": [ + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "take", + "required": false, + "in": "query", + "description": "The number of items to return", + "example": 10, + "schema": { + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "The number of items to skip", + "example": 0, + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": ["Orgs / Users / OOO"] + }, + "post": { + "operationId": "OrganizationsUsersOOOController_createOrganizationUserOOO", + "summary": "Create an ooo entry for user", + "parameters": [ + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOutOfOfficeEntryDto" + } + } + } + }, + "responses": { + "201": { + "description": "" + } + }, + "tags": ["Orgs / Users / OOO"] + } + }, + "/v2/organizations/{orgId}/users/{userId}/ooo/{oooId}": { + "patch": { + "operationId": "OrganizationsUsersOOOController_updateOrganizationUserOOO", + "summary": "Update ooo entry of a user", + "parameters": [ + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "oooId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOutOfOfficeEntryDto" + } + } + } + }, + "responses": { + "200": { + "description": "" + } + }, + "tags": ["Orgs / Users / OOO"] + }, + "delete": { + "operationId": "OrganizationsUsersOOOController_deleteOrganizationUserOOO", + "summary": "Delete ooo entry of a user", + "parameters": [ + { + "name": "oooId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": ["Orgs / Users / OOO"] + } + }, "/v2/organizations/{orgId}/webhooks": { "get": { "operationId": "OrganizationsWebhooksController_getAllOrganizationWebhooks", @@ -12157,6 +12289,71 @@ } } }, + "CreateOutOfOfficeEntryDto": { + "type": "object", + "properties": { + "start": { + "format": "date-time", + "type": "string", + "description": "The start date and time of the out of office period in ISO 8601 format in UTC timezone.", + "example": "2023-05-01T00:00:00.000Z" + }, + "end": { + "format": "date-time", + "type": "string", + "description": "The end date and time of the out of office period in ISO 8601 format in UTC timezone.", + "example": "2023-05-10T23:59:59.999Z" + }, + "notes": { + "type": "string", + "description": "Optional notes for the out of office entry.", + "example": "Vacation in Hawaii" + }, + "toUserId": { + "type": "number", + "description": "The ID of the user covering for the out of office period, if applicable.", + "example": 2 + }, + "reason": { + "type": "object", + "description": "the reason for the out of office entry, if applicable", + "example": "vacation" + } + }, + "required": ["start", "end"] + }, + "UpdateOutOfOfficeEntryDto": { + "type": "object", + "properties": { + "start": { + "format": "date-time", + "type": "string", + "description": "The start date and time of the out of office period in ISO 8601 format in UTC timezone.", + "example": "2023-05-01T00:00:00.000Z" + }, + "end": { + "format": "date-time", + "type": "string", + "description": "The end date and time of the out of office period in ISO 8601 format in UTC timezone.", + "example": "2023-05-10T23:59:59.999Z" + }, + "notes": { + "type": "string", + "description": "Optional notes for the out of office entry.", + "example": "Vacation in Hawaii" + }, + "toUserId": { + "type": "number", + "description": "The ID of the user covering for the out of office period, if applicable.", + "example": 2 + }, + "reason": { + "type": "object", + "description": "the reason for the out of office entry, if applicable", + "example": "vacation" + } + } + }, "StripConnectOutputDto": { "type": "object", "properties": {