Skip to content

Commit

Permalink
feat: api v2 org ooo crud (#18499)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
ThyMinimalDev authored Jan 8, 2025
1 parent c9c5493 commit b997c65
Show file tree
Hide file tree
Showing 11 changed files with 1,414 additions and 0 deletions.
30 changes: 30 additions & 0 deletions apps/api/v2/src/modules/ooo/guards/is-user-ooo.ts
Original file line number Diff line number Diff line change
@@ -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.");
}
}
81 changes: 81 additions & 0 deletions apps/api/v2/src/modules/ooo/inputs/ooo.input.ts
Original file line number Diff line number Diff line change
@@ -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) {}
99 changes: 99 additions & 0 deletions apps/api/v2/src/modules/ooo/outputs/ooo.output.ts
Original file line number Diff line number Diff line change
@@ -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[];
}
100 changes: 100 additions & 0 deletions apps/api/v2/src/modules/ooo/repositories/ooo.repository.ts
Original file line number Diff line number Diff line change
@@ -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,
},
});
}
}
Loading

0 comments on commit b997c65

Please sign in to comment.