feat: v2 organization bookings endpoints (#18875)

* chore: extra team bookings e2e tests

* feat: v2 organizations bookings endpoints

* feat: v2 organizations bookings endpoints

* test

* revert seed.ts change
This commit is contained in:
Lauris Skraucis
2025-02-05 10:58:36 +02:00
committed by GitHub
parent 14ec3813ee
commit da02cd06d6
11 changed files with 986 additions and 2 deletions
@@ -4,6 +4,7 @@ import { BillingModule } from "@/modules/billing/billing.module";
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
import { DestinationCalendarsModule } from "@/modules/destination-calendars/destination-calendars.module";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { OrganizationsTeamsBookingsModule } from "@/modules/organizations/controllers/teams/bookings/organizations-teams-bookings.module";
import { RouterModule } from "@/modules/router/router.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { TimezoneModule } from "@/modules/timezones/timezones.module";
@@ -25,6 +26,7 @@ import { WebhooksModule } from "./webhooks/webhooks.module";
AtomsModule,
StripeModule,
ConferencingModule,
OrganizationsTeamsBookingsModule,
RouterModule,
],
})
@@ -0,0 +1,179 @@
import { ApiProperty } from "@nestjs/swagger";
import { Transform, Type } from "class-transformer";
import {
ArrayMinSize,
ArrayNotEmpty,
IsArray,
IsEnum,
IsInt,
IsISO8601,
IsNumber,
IsOptional,
IsString,
Max,
Min,
} from "class-validator";
enum Status {
upcoming = "upcoming",
recurring = "recurring",
past = "past",
cancelled = "cancelled",
unconfirmed = "unconfirmed",
}
type StatusType = keyof typeof Status;
enum SortOrder {
asc = "asc",
desc = "desc",
}
type SortOrderType = keyof typeof SortOrder;
export class GetOrganizationsTeamsBookingsInput_2024_08_13 {
// note(Lauris): filters
@IsOptional()
@Transform(({ value }) => {
if (typeof value === "string") {
return value.split(",").map((status: string) => status.trim());
}
return value;
})
@ArrayNotEmpty({ message: "status cannot be empty." })
@IsEnum(Status, {
each: true,
message: "Invalid status. Allowed are upcoming, recurring, past, cancelled, unconfirmed",
})
@ApiProperty({
required: false,
description:
"Filter bookings by status. If you want to filter by multiple statuses, separate them with a comma.",
example: "?status=upcoming,past",
enum: Status,
isArray: true,
})
status?: StatusType[];
@IsString()
@IsOptional()
@ApiProperty({
type: String,
required: false,
description: "Filter bookings by the attendee's email address.",
example: "example@domain.com",
})
attendeeEmail?: string;
@IsString()
@IsOptional()
@ApiProperty({
type: String,
required: false,
description: "Filter bookings by the attendee's name.",
example: "John Doe",
})
attendeeName?: string;
@IsOptional()
@Transform(({ value }) => {
if (typeof value === "string") {
return value.split(",").map((eventTypeId: string) => parseInt(eventTypeId));
}
return value;
})
@IsArray()
@IsNumber({}, { each: true })
@ArrayMinSize(1, { message: "eventTypeIds must contain at least 1 event type id" })
@ApiProperty({
type: String,
required: false,
description:
"Filter bookings by event type ids belonging to the team. Event type ids must be separated by a comma.",
example: "?eventTypeIds=100,200",
})
eventTypeIds?: number[];
@IsInt()
@IsOptional()
@Type(() => Number)
@ApiProperty({
type: String,
required: false,
description: "Filter bookings by event type id belonging to the team.",
example: "?eventTypeId=100",
})
eventTypeId?: number;
@IsOptional()
@IsISO8601({ strict: true }, { message: "fromDate must be a valid ISO 8601 date." })
@ApiProperty({
type: String,
required: false,
description: "Filter bookings with start after this date string.",
example: "?afterStart=2025-03-07T10:00:00.000Z",
})
afterStart?: string;
@IsOptional()
@IsISO8601({ strict: true }, { message: "toDate must be a valid ISO 8601 date." })
@ApiProperty({
type: String,
required: false,
description: "Filter bookings with end before this date string.",
example: "?beforeEnd=2025-03-07T11:00:00.000Z",
})
beforeEnd?: string;
// note(Lauris): sort
@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;
@IsOptional()
@IsEnum(SortOrder, {
message: 'SortCreated must be either "asc" or "desc".',
})
@ApiProperty({
required: false,
description:
"Sort results by their creation time (when booking was made) in ascending or descending order.",
example: "?sortCreated=asc OR ?sortCreated=desc",
enum: SortOrder,
})
sortCreated?: SortOrderType;
// note(Lauris): pagination
@ApiProperty({ required: false, description: "The number of items to return", example: 10 })
@Transform(({ value }: { value: string }) => value && parseInt(value))
@IsNumber()
@Min(1)
@Max(250)
@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;
}
@@ -0,0 +1,364 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { OrganizationsTeamsBookingsModule } from "@/modules/organizations/controllers/teams/bookings/organizations-teams-bookings.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 { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.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 {
CAL_API_VERSION_HEADER,
SUCCESS_STATUS,
VERSION_2024_08_13,
X_CAL_CLIENT_ID,
X_CAL_SECRET_KEY,
} from "@calcom/platform-constants";
import {
CreateBookingInput_2024_08_13,
BookingOutput_2024_08_13,
RecurringBookingOutput_2024_08_13,
GetBookingsOutput_2024_08_13,
GetSeatedBookingOutput_2024_08_13,
} from "@calcom/platform-types";
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Organizations TeamsBookings Endpoints 2024-08-13", () => {
describe("Organization Team bookings", () => {
let app: INestApplication;
let organization: Team;
let team1: Team;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let oAuthClient: PlatformOAuthClient;
let teamRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let hostsRepositoryFixture: HostsRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
const teamUserEmail = "orgUser1team1@api.com";
const teamUserEmail2 = "orgUser2team1@api.com";
let teamUser: User;
let teamUser2: User;
let team1EventTypeId: number;
beforeAll(async () => {
const moduleRef = await withApiAuth(
teamUserEmail,
Test.createTestingModule({
imports: [AppModule, OrganizationsTeamsBookingsModule],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await organizationsRepositoryFixture.create({ name: "organization team bookings" });
oAuthClient = await createOAuthClient(organization.id);
team1 = await teamRepositoryFixture.create({
name: "team 1",
isOrganization: false,
parent: { connect: { id: organization.id } },
createdByOAuthClient: {
connect: {
id: oAuthClient.id,
},
},
});
teamUser = await userRepositoryFixture.create({
email: teamUserEmail,
locale: "it",
name: "orgUser1team1",
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
teamUser2 = await userRepositoryFixture.create({
email: teamUserEmail2,
locale: "es",
name: "orgUser2team1",
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(teamUser.id, userSchedule);
await schedulesService.createUserSchedule(teamUser2.id, userSchedule);
await profileRepositoryFixture.create({
uid: `usr-${teamUser.id}`,
username: teamUserEmail,
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: teamUser.id,
},
},
});
await profileRepositoryFixture.create({
uid: `usr-${teamUser2.id}`,
username: teamUserEmail2,
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: teamUser2.id,
},
},
});
await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: teamUser.id } },
team: { connect: { id: team1.id } },
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: teamUser.id } },
team: { connect: { id: organization.id } },
accepted: true,
});
const team1EventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: {
connect: { id: team1.id },
},
title: "Collective Event Type",
slug: "collective-event-type",
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
team1EventTypeId = team1EventType.id;
await hostsRepositoryFixture.create({
isFixed: true,
user: {
connect: {
id: teamUser.id,
},
},
eventType: {
connect: {
id: team1EventType.id,
},
},
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
describe("create team bookings", () => {
it("should create a team 1 booking", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId: team1EventTypeId,
attendee: {
name: "alice",
email: "alice@gmail.com",
timeZone: "Europe/Madrid",
language: "es",
},
meetingUrl: "https://meet.google.com/abc-def-ghi",
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts.length).toEqual(1);
expect(data.hosts[0].id).toEqual(teamUser.id);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 14, 0, 0)).toISOString());
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(team1EventTypeId);
expect(data.attendees.length).toEqual(1);
expect(data.attendees[0]).toEqual({
name: body.attendee.name,
email: body.attendee.email,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(data.meetingUrl).toEqual(body.meetingUrl);
expect(data.absentHost).toEqual(false);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
});
describe("get team bookings", () => {
it("should should get bookings by teamId", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${organization.id}/teams/${team1.id}/bookings`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(1);
expect(data[0].eventTypeId).toEqual(team1EventTypeId);
});
});
it("should get bookings by teamId and eventTypeId", async () => {
return request(app.getHttpServer())
.get(
`/v2/organizations/${organization.id}/teams/${team1.id}/bookings?eventTypeId=${team1EventTypeId}`
)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(1);
expect(data[0].eventTypeId).toEqual(team1EventTypeId);
});
});
it("should not get bookings by teamId and non existing eventTypeId", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${organization.id}/teams/${team1.id}/bookings?eventTypeId=90909`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(0);
});
});
it("should not get bookings by non existing teamId", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${organization.id}/teams/90909/bookings`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(404);
});
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
}
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(teamUser.email);
await userRepositoryFixture.deleteByEmail(teamUserEmail2);
await bookingsRepositoryFixture.deleteAllBookings(teamUser.id, teamUser.email);
await app.close();
});
});
});
@@ -0,0 +1,47 @@
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.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 { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { GetOrganizationsTeamsBookingsInput_2024_08_13 } from "@/modules/organizations/controllers/teams/bookings/inputs/get-organizations-teams-bookings.input";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Controller, UseGuards, Get, Param, ParseIntPipe, Query, HttpStatus, HttpCode } from "@nestjs/common";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { GetBookingsOutput_2024_08_13 } from "@calcom/platform-types";
@Controller({
path: "/v2/organizations/:orgId/teams/:teamId/bookings",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@DocsTags("Orgs / Teams / Bookings")
export class OrganizationsTeamsBookingsController {
constructor(private readonly bookingsService: BookingsService_2024_08_13) {}
@Get("/")
@ApiOperation({ summary: "Get organization team bookings" })
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@HttpCode(HttpStatus.OK)
async getAllOrgTeamBookings(
@Query() queryParams: GetOrganizationsTeamsBookingsInput_2024_08_13,
@Param("teamId", ParseIntPipe) teamId: number,
@GetUser() user: UserWithProfile
): Promise<GetBookingsOutput_2024_08_13> {
const bookings = await this.bookingsService.getBookings({ ...queryParams, teamId }, user);
return {
status: SUCCESS_STATUS,
data: bookings,
};
}
}
@@ -0,0 +1,16 @@
import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OrganizationsTeamsBookingsController } from "@/modules/organizations/controllers/teams/bookings/organizations-teams-bookings.controller";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { Module } from "@nestjs/common";
@Module({
imports: [BookingsModule_2024_08_13, PrismaModule, StripeModule, RedisModule, MembershipsModule],
providers: [OrganizationsRepository, OrganizationsTeamsRepository],
controllers: [OrganizationsTeamsBookingsController],
})
export class OrganizationsTeamsBookingsModule {}