Skip to content

Commit

Permalink
feat: get org ooo entries and filters/sort (#18645)
Browse files Browse the repository at this point in the history
* feat: get org ooo entries and filters/sort

* remove console.log

---------

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
  • Loading branch information
ThyMinimalDev and CarinaWolli authored Jan 14, 2025
1 parent 21694c0 commit 2aa3d1d
Show file tree
Hide file tree
Showing 11 changed files with 418 additions and 17 deletions.
46 changes: 46 additions & 0 deletions apps/api/v2/src/modules/ooo/inputs/ooo.input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { ApiProperty, ApiPropertyOptional, PartialType } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsDate, IsInt, IsOptional, IsString, IsEnum, isDate } from "class-validator";

import { SkipTakePagination } from "@calcom/platform-types";

export enum OutOfOfficeReason {
UNSPECIFIED = "unspecified",
VACATION = "vacation",
Expand Down Expand Up @@ -80,3 +82,47 @@ export class CreateOutOfOfficeEntryDto {
}

export class UpdateOutOfOfficeEntryDto extends PartialType(CreateOutOfOfficeEntryDto) {}

export enum SortOrder {
asc = "asc",
desc = "desc",
}
type SortOrderType = keyof typeof SortOrder;

export class GetOutOfOfficeEntryFiltersDTO extends SkipTakePagination {
@IsOptional()
@IsEnum(SortOrder, {
message: 'SortStart must be either "asc" or "desc".',
})
@ApiProperty({
required: false,
description: "Sort results by their start time in ascending or descending order.",
example: "?sortStart=asc OR ?sortStart=desc",
enum: SortOrder,
})
sortStart?: SortOrderType;

@IsOptional()
@IsEnum(SortOrder, {
message: 'SortEnd must be either "asc" or "desc".',
})
@ApiProperty({
required: false,
description: "Sort results by their end time in ascending or descending order.",
example: "?sortEnd=asc OR ?sortEnd=desc",
enum: SortOrder,
})
sortEnd?: SortOrderType;
}

export class GetOrgUsersOutOfOfficeEntryFiltersDTO extends GetOutOfOfficeEntryFiltersDTO {
@IsString()
@IsOptional()
@ApiProperty({
type: String,
required: false,
description: "Filter ooo entries by the user email address. user must be within your organization.",
example: "example@domain.com",
})
email?: string;
}
9 changes: 8 additions & 1 deletion apps/api/v2/src/modules/ooo/repositories/ooo.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,19 @@ export class UserOOORepository {
});
}

async getUserOOOPaginated(userId: number, skip: number, take: number) {
async getUserOOOPaginated(
userId: number,
skip: number,
take: number,
sort?: { sortStart?: "asc" | "desc"; sortEnd?: "asc" | "desc" }
) {
return this.dbRead.prisma.outOfOfficeEntry.findMany({
where: { userId },
skip,
take,
include: { reason: true },
...(sort?.sortStart && { orderBy: { start: sort.sortStart } }),
...(sort?.sortEnd && { orderBy: { end: sort.sortEnd } }),
});
}

Expand Down
11 changes: 9 additions & 2 deletions apps/api/v2/src/modules/ooo/services/ooo.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import {
CreateOutOfOfficeEntryDto,
UpdateOutOfOfficeEntryDto,
OutOfOfficeReason,
GetOutOfOfficeEntryFiltersDTO,
SortOrder,
} from "@/modules/ooo/inputs/ooo.input";
import { UserOOORepository } from "@/modules/ooo/repositories/ooo.repository";
import { UsersRepository } from "@/modules/users/users.repository";
Expand Down Expand Up @@ -128,8 +130,13 @@ export class UserOOOService {
return this.formatOooReason(ooo);
}

async getUserOOOPaginated(userId: number, skip: number, take: number) {
const ooos = await this.oooRepository.getUserOOOPaginated(userId, skip, take);
async getUserOOOPaginated(
userId: number,
skip: number,
take: number,
sort?: { sortStart?: "asc" | "desc"; sortEnd?: "asc" | "desc" }
) {
const ooos = await this.oooRepository.getUserOOOPaginated(userId, skip, take, sort);
return ooos.map((ooo) => this.formatOooReason(ooo));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,19 @@ 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 {
CreateOutOfOfficeEntryDto,
UpdateOutOfOfficeEntryDto,
GetOutOfOfficeEntryFiltersDTO,
GetOrgUsersOutOfOfficeEntryFiltersDTO,
} 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 { OrgUsersOOOService } from "@/modules/organizations/controllers/users/ooo/services/organization-users-ooo.service";
import {
Controller,
UseGuards,
Expand All @@ -33,37 +39,40 @@ 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",
path: "/v2/organizations/:orgId",
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) {}
constructor(
private readonly userOOOService: UserOOOService,
private readonly orgUsersOOOService: OrgUsersOOOService
) {}

@Get()
@Get("/users/:userId/ooo")
@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
@Query() query: GetOutOfOfficeEntryFiltersDTO
): Promise<UserOoosOutputResponseDto> {
const ooos = await this.userOOOService.getUserOOOPaginated(userId, query.skip ?? 0, query.take ?? 250);
const { skip, take, ...rest } = query ?? { skip: 0, take: 250 };
const ooos = await this.userOOOService.getUserOOOPaginated(userId, skip ?? 0, take ?? 250, rest);

return {
status: SUCCESS_STATUS,
data: ooos.map((ooo) => plainToInstance(UserOooOutputDto, ooo, { strategy: "excludeAll" })),
};
}

@Post()
@Post("/users/:userId/ooo")
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsUserInOrg)
Expand All @@ -79,7 +88,7 @@ export class OrganizationsUsersOOOController {
};
}

@Patch("/:oooId")
@Patch("/users/:userId/ooo/:oooId")
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsUserInOrg, IsUserOOO)
Expand All @@ -97,7 +106,7 @@ export class OrganizationsUsersOOOController {
};
}

@Delete("/:oooId")
@Delete("/users/:userId/ooo/:oooId")
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsUserInOrg, IsUserOOO)
Expand All @@ -111,4 +120,23 @@ export class OrganizationsUsersOOOController {
data: plainToInstance(UserOooOutputDto, ooo, { strategy: "excludeAll" }),
};
}

@Get("/ooo")
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@ApiOperation({ summary: "Get all OOO entries of org users" })
async getOrganizationUsersOOO(
@Param("orgId", ParseIntPipe) orgId: number,
@Query() query: GetOrgUsersOutOfOfficeEntryFiltersDTO
): Promise<UserOoosOutputResponseDto> {
const { skip, take, email, ...rest } = query ?? { skip: 0, take: 250 };
const ooos = await this.orgUsersOOOService.getOrgUsersOOOPaginated(orgId, skip ?? 0, take ?? 250, rest, {
email,
});

return {
status: SUCCESS_STATUS,
data: ooos.map((ooo) => plainToInstance(UserOooOutputDto, ooo, { strategy: "excludeAll" })),
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,31 @@ describe("Organizations User OOO Endpoints", () => {
});
});

it("should get 2 ooo entries", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/ooo?sortEnd=desc&email=${teammate1Email}`)
.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");
}
// test sort
expect(data[1].id).toEqual(oooCreatedViaApiId);

});
});

it("should get 2 ooo entries", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo`)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { Injectable } from "@nestjs/common";

@Injectable()
export class OrgUsersOOORepository {
constructor(private readonly dbRead: PrismaReadService) {}
async getOrgUsersOOOPaginated(
orgId: number,
skip: number,
take: number,
sort?: { sortStart?: "asc" | "desc"; sortEnd?: "asc" | "desc" },
filters?: { email?: string }
) {
console.log({ sort, filters });
return this.dbRead.prisma.outOfOfficeEntry.findMany({
where: {
user: {
...(filters?.email && { email: filters.email }),
profiles: {
some: {
organizationId: orgId,
},
},
},
},
skip,
take,
include: { reason: true },
...(sort?.sortStart && { orderBy: { start: sort.sortStart } }),
...(sort?.sortEnd && { orderBy: { end: sort.sortEnd } }),
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { UserOOORepository } from "@/modules/ooo/repositories/ooo.repository";
import { UserOOOService } from "@/modules/ooo/services/ooo.service";
import { OrgUsersOOORepository } from "@/modules/organizations/controllers/users/ooo/repositories/organizations-users-ooo.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";

@Injectable()
export class OrgUsersOOOService {
constructor(
private readonly oooRepository: UserOOORepository,
private readonly oooUserService: UserOOOService,
private readonly usersRepository: UsersRepository,
private readonly orgUsersOOORepository: OrgUsersOOORepository
) {}

async getOrgUsersOOOPaginated(
orgId: number,
skip: number,
take: number,
sort?: { sortStart?: "asc" | "desc"; sortEnd?: "asc" | "desc" },
filters?: { email?: string }
) {
const ooos = await this.orgUsersOOORepository.getOrgUsersOOOPaginated(orgId, skip, take, sort, filters);
return ooos.map((ooo) => this.oooUserService.formatOooReason(ooo));
}
}
4 changes: 4 additions & 0 deletions apps/api/v2/src/modules/organizations/organizations.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { OrganizationsTeamsMembershipsController } from "@/modules/organizations
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 { OrgUsersOOORepository } from "@/modules/organizations/controllers/users/ooo/repositories/organizations-users-ooo.repository";
import { OrgUsersOOOService } from "@/modules/organizations/controllers/users/ooo/services/organization-users-ooo.service";
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";
Expand Down Expand Up @@ -91,6 +93,8 @@ import { Module } from "@nestjs/common";
OutputTeamEventTypesResponsePipe,
UserOOOService,
UserOOORepository,
OrgUsersOOOService,
OrgUsersOOORepository,
],
exports: [
OrganizationsService,
Expand Down
21 changes: 17 additions & 4 deletions apps/api/v2/src/modules/users/inputs/get-users.input.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsOptional, Validate } from "class-validator";

import { SkipTakePagination } from "@calcom/platform-types";
import { IsNumber, IsOptional, Max, Min, Validate } from "class-validator";

import { IsEmailStringOrArray } from "../validators/isEmailStringOrArray";

export class GetUsersInput extends SkipTakePagination {
export class GetUsersInput {
@ApiProperty({ required: false, description: "The number of items to return", example: 10 })
@Transform(({ value }: { value: string }) => value && parseInt(value))
@IsNumber()
@Min(1)
@Max(1000)
@IsOptional()
take?: number;

@ApiProperty({ required: false, description: "The number of items to skip", example: 0 })
@Transform(({ value }: { value: string }) => value && parseInt(value))
@IsNumber()
@Min(0)
@IsOptional()
skip?: number;

@IsOptional()
@Validate(IsEmailStringOrArray)
@Transform(({ value }: { value: string | string[] }) => {
Expand Down
Loading

0 comments on commit 2aa3d1d

Please sign in to comment.