feat: api v2 org ooo crud (#18499)

* feat: api v2 org ooo crud

* chore: add guard isUserOOO on ooo controller

* chore: add checks for ooo

* fixup! chore: add checks for ooo

* added e2e tests

* fixup! added e2e tests
This commit is contained in:
Morgan
2025-01-08 10:50:58 -05:00
committed by GitHub
parent c9c5493de6
commit b997c656df
11 changed files with 1414 additions and 0 deletions
@@ -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<boolean> {
const request = context.switchToHttp().getRequest<Request>();
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.");
}
}
@@ -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) {}
@@ -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[];
}
@@ -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<Prisma.OutOfOfficeEntryCreateInput, "user" | "toUser" | "reason" | "uuid"> & {
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<OOOInputData>) {
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,
},
});
}
}
@@ -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));
}
}
@@ -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<UserOoosOutputResponseDto> {
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<UserOooOutputResponseDto> {
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<UserOooOutputResponseDto> {
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<UserOooOutputResponseDto> {
const ooo = await this.userOOOService.deleteUserOOO(oooId);
return {
status: SUCCESS_STATUS,
data: plainToInstance(UserOooOutputDto, ooo, { strategy: "excludeAll" }),
};
}
}
@@ -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();
});
});
});
@@ -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 {}
@@ -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,
},
});
}
}
+208
View File
@@ -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": {
+197
View File
@@ -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": {