Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f0b35d9e2 | ||
|
|
3b19ede6a3 | ||
|
|
1a0ce47404 | ||
|
|
22f136d19b | ||
|
|
9a40e54159 | ||
|
|
229f2627c8 | ||
|
|
6f6454df40 | ||
|
|
3da9fc5500 | ||
|
|
540bf868b5 | ||
|
|
1674ecc585 | ||
|
|
4920abdaf7 | ||
|
|
a6b98bd35d | ||
|
|
34d4f394fc | ||
|
|
a4ef8d4200 | ||
|
|
9d9662687e | ||
|
|
2d41c719c4 | ||
|
|
b69bd966a6 | ||
|
|
09a49dd0fd | ||
|
|
3dd3372c35 | ||
|
|
0a60864310 | ||
|
|
cb24e5d0aa | ||
|
|
8c5aebdca1 | ||
|
|
4e2cff0fd5 | ||
|
|
54e518efad | ||
|
|
ad93099171 | ||
|
|
630065ca89 | ||
|
|
ddf5717992 | ||
|
|
4a53ec0b74 | ||
|
|
d7fd055171 | ||
|
|
874973d78f | ||
|
|
8078c5657b | ||
|
|
b39404860f | ||
|
|
860eae8dcb | ||
|
|
b931d3ee13 | ||
|
|
ba8a71bf3a | ||
|
|
8de4897042 | ||
|
|
6de6f8e697 | ||
|
|
e55ecff899 | ||
|
|
3895ef002e | ||
|
|
afc28503a5 | ||
|
|
e6497c87ca | ||
|
|
cbb020911d | ||
|
|
200b9d037c | ||
|
|
69667a7528 | ||
|
|
9f655ed819 | ||
|
|
5a1e1e231b | ||
|
|
6cccb1725f | ||
|
|
5bb2a904ce | ||
|
|
5e2f06cdea | ||
|
|
cf83038a7a | ||
|
|
c7662cad61 | ||
|
|
86912e0cbc | ||
|
|
74234b4ed1 | ||
|
|
2fcb8a750d | ||
|
|
2aaf672b10 | ||
|
|
34beadeef6 | ||
|
|
6de715b884 | ||
|
|
84717ca73e | ||
|
|
86b1db57d5 | ||
|
|
50c15f0d51 | ||
|
|
86d5c43307 | ||
|
|
e82bd45b55 | ||
|
|
13966e0ec8 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@calcom/atoms": patch
|
||||
---
|
||||
|
||||
fix saving event type settings
|
||||
Vendored
Vendored
Vendored
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/prisma"
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/app-store-cli"
|
||||
- "@calcom/platform-constants"
|
||||
- "@calcom/platform-enums"
|
||||
- "@calcom/platform-types"
|
||||
- "@calcom/platform-utils"
|
||||
- "@calcom/prisma"
|
||||
Vendored
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/prisma"
|
||||
@@ -1,6 +1,10 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { MAX_SEATS_PER_TIME_SLOT } from "@calcom/lib/constants";
|
||||
import {
|
||||
MAX_SEATS_PER_TIME_SLOT,
|
||||
MAX_EVENT_DURATION_MINUTES,
|
||||
MIN_EVENT_DURATION_MINUTES,
|
||||
} from "@calcom/lib/constants";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { _EventTypeModel as EventType, _HostModel } from "@calcom/prisma/zod";
|
||||
import { customInputSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||
@@ -80,7 +84,7 @@ const schemaEventTypeCreateParams = z
|
||||
title: z.string(),
|
||||
slug: z.string().transform((s) => slugify(s)),
|
||||
description: z.string().optional().nullable(),
|
||||
length: z.number().int(),
|
||||
length: z.number().int().min(MIN_EVENT_DURATION_MINUTES).max(MAX_EVENT_DURATION_MINUTES),
|
||||
metadata: z.any().optional(),
|
||||
recurringEvent: recurringEventInputSchema.optional(),
|
||||
seatsPerTimeSlot: z.number().optional(),
|
||||
|
||||
@@ -42,11 +42,6 @@ describe("GET /api/slots", () => {
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res._getData())).toMatchInlineSnapshot(`
|
||||
{
|
||||
"message": "invalid_type in 'startTime': Required; invalid_type in 'endTime': Required",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.208",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.223",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
|
||||
@@ -81,5 +81,9 @@ export const bootstrap = (app: NestExpressApplication): NestExpressApplication =
|
||||
|
||||
app.use(cookieParser());
|
||||
|
||||
if (process?.env?.API_GLOBAL_PREFIX) {
|
||||
app.setGlobalPrefix(process?.env?.API_GLOBAL_PREFIX);
|
||||
}
|
||||
|
||||
return app;
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@ const loadConfig = (): AppConfig => {
|
||||
app: {
|
||||
baseUrl: getEnv("WEB_APP_URL", "https://app.cal.com"),
|
||||
},
|
||||
e2e: getEnv("IS_E2E", false),
|
||||
e2e: getEnv("IS_E2E", "false") === "true" ? true : false,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ type OAuthRequestParams = {
|
||||
platformBookingUrl: string;
|
||||
platformBookingLocation?: string;
|
||||
arePlatformEmailsEnabled: boolean;
|
||||
areCalendarEventsEnabled: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_PLATFORM_PARAMS = {
|
||||
@@ -86,6 +87,7 @@ const DEFAULT_PLATFORM_PARAMS = {
|
||||
platformBookingUrl: "",
|
||||
arePlatformEmailsEnabled: false,
|
||||
platformBookingLocation: undefined,
|
||||
areCalendarEventsEnabled: false,
|
||||
};
|
||||
|
||||
@Controller({
|
||||
@@ -135,9 +137,13 @@ export class BookingsController_2024_04_15 {
|
||||
},
|
||||
});
|
||||
|
||||
let nextCursor = null;
|
||||
if (bookings.totalCount > (cursor ?? 0) + (limit ?? 10)) {
|
||||
nextCursor = (cursor ?? 0) + (limit ?? 10);
|
||||
}
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: bookings,
|
||||
data: { ...bookings, nextCursor },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -191,6 +197,7 @@ export class BookingsController_2024_04_15 {
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
||||
});
|
||||
if (booking.userId && booking.uid && booking.startTime) {
|
||||
void (await this.billingService.increaseUsageByUserId(booking.userId, {
|
||||
@@ -424,7 +431,7 @@ export class BookingsController_2024_04_15 {
|
||||
|
||||
if (isEmbed) {
|
||||
// embed should ignore oauth client settings and enable emails by default
|
||||
return { ...res, arePlatformEmailsEnabled: true };
|
||||
return { ...res, arePlatformEmailsEnabled: true, areCalendarEventsEnabled: true };
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -436,6 +443,7 @@ export class BookingsController_2024_04_15 {
|
||||
res.platformRescheduleUrl = client.bookingRescheduleRedirectUri ?? "";
|
||||
res.platformBookingUrl = client.bookingRedirectUri ?? "";
|
||||
res.arePlatformEmailsEnabled = client.areEmailsEnabled ?? false;
|
||||
res.areCalendarEventsEnabled = client.areCalendarEventsEnabled;
|
||||
}
|
||||
return res;
|
||||
} catch (err) {
|
||||
|
||||
@@ -16,13 +16,15 @@ import {
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
enum Status {
|
||||
CANCELLED = "CANCELLED",
|
||||
REJECTED = "REJECTED",
|
||||
ACCEPTED = "ACCEPTED",
|
||||
PENDING = "PENDING",
|
||||
AWAITING_HOST = "AWAITING_HOST",
|
||||
}
|
||||
const Status = {
|
||||
CANCELLED: "CANCELLED",
|
||||
REJECTED: "REJECTED",
|
||||
ACCEPTED: "ACCEPTED",
|
||||
PENDING: "PENDING",
|
||||
AWAITING_HOST: "AWAITING_HOST",
|
||||
} as const;
|
||||
|
||||
export type Status = (typeof Status)[keyof typeof Status];
|
||||
|
||||
class Attendee {
|
||||
@IsInt()
|
||||
|
||||
@@ -24,4 +24,26 @@ export class BookingReferencesRepository_2024_08_13 {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getBookingReferencesIncludeSensitiveCredentials(eventUid: string) {
|
||||
return this.dbRead.prisma.bookingReference.findFirst({
|
||||
where: {
|
||||
uid: eventUid,
|
||||
},
|
||||
include: {
|
||||
credential: true,
|
||||
delegationCredential: true,
|
||||
booking: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings
|
||||
import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller";
|
||||
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
|
||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.service";
|
||||
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
|
||||
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
||||
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
|
||||
@@ -77,6 +78,7 @@ import { Module } from "@nestjs/common";
|
||||
ErrorsBookingsService_2024_08_13,
|
||||
BookingReferencesService_2024_08_13,
|
||||
BookingReferencesRepository_2024_08_13,
|
||||
CalVideoService,
|
||||
],
|
||||
controllers: [BookingsController_2024_08_13],
|
||||
exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13],
|
||||
|
||||
@@ -169,4 +169,15 @@ export class BookingsRepository_2024_08_13 {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getByUidWithBookingReference(uid: string) {
|
||||
return this.dbRead.prisma.booking.findUnique({
|
||||
where: {
|
||||
uid,
|
||||
},
|
||||
select: {
|
||||
references: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ReassignBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outpu
|
||||
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
|
||||
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
|
||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.service";
|
||||
import { VERSION_2024_08_13_VALUE, VERSION_2024_08_13 } from "@/lib/api-versions";
|
||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
@@ -54,6 +55,8 @@ import {
|
||||
RescheduleBookingInput_2024_08_13,
|
||||
RescheduleBookingInputPipe,
|
||||
RescheduleSeatedBookingInput_2024_08_13,
|
||||
GetBookingRecordingsOutput,
|
||||
GetBookingTranscriptsOutput,
|
||||
} from "@calcom/platform-types";
|
||||
import {
|
||||
CreateBookingInputPipe,
|
||||
@@ -88,7 +91,8 @@ export class BookingsController_2024_08_13 {
|
||||
constructor(
|
||||
private readonly bookingsService: BookingsService_2024_08_13,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly bookingReferencesService: BookingReferencesService_2024_08_13
|
||||
private readonly bookingReferencesService: BookingReferencesService_2024_08_13,
|
||||
private readonly calVideoService: CalVideoService
|
||||
) {}
|
||||
|
||||
@Post("/")
|
||||
@@ -171,6 +175,36 @@ export class BookingsController_2024_08_13 {
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/:bookingUid/recordings")
|
||||
@UseGuards(BookingUidGuard)
|
||||
@ApiOperation({
|
||||
summary: "Get all the recordings for the booking",
|
||||
description: `Fetches all the recordings for the booking \`:bookingUid\``,
|
||||
})
|
||||
async getBookingRecordings(@Param("bookingUid") bookingUid: string): Promise<GetBookingRecordingsOutput> {
|
||||
const recordings = await this.calVideoService.getRecordings(bookingUid);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: recordings,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/:bookingUid/transcripts")
|
||||
@UseGuards(BookingUidGuard)
|
||||
@ApiOperation({
|
||||
summary: "Get all the transcripts download links for the booking",
|
||||
description: `Fetches all the transcripts download links for the booking \`:bookingUid\``,
|
||||
})
|
||||
async getBookingTranscripts(@Param("bookingUid") bookingUid: string): Promise<GetBookingTranscriptsOutput> {
|
||||
const transcripts = await this.calVideoService.getTranscripts(bookingUid);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: transcripts ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/")
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@@ -233,7 +267,14 @@ export class BookingsController_2024_08_13 {
|
||||
@ApiOperation({
|
||||
summary: "Cancel a booking",
|
||||
description: `:bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.
|
||||
For seated bookings to cancel one individual booking provide :bookingUid and :seatUid in the request body. For recurring seated bookings it is not possible to cancel all of them with 1 call
|
||||
|
||||
\nCancelling seated bookings:
|
||||
It is possible to cancel specific seat within a booking as an attendee or all of the seats as the host.
|
||||
\n1. As an attendee - provide :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and seatUid in the request body \`{"seatUid": "123-123-123"}\` . This will remove this particular attendance from the booking.
|
||||
\n2. As the host - host can cancel booking for all attendees aka for every seat. Provide :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and cancellationReason in the request body \`{"cancellationReason": "Will travel"}\` and \`Authorization: Bearer token\` request header where token is event type owner (host) credential. This will cancel the booking for all attendees.
|
||||
|
||||
\nCancelling recurring seated bookings:
|
||||
For recurring seated bookings it is not possible to cancel all of them with 1 call
|
||||
like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.`,
|
||||
})
|
||||
@ApiBody({
|
||||
|
||||
+1
-2
@@ -874,9 +874,8 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
|
||||
expect(AttendeeRescheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
|
||||
expect(AttendeeRescheduledEmail.prototype.getHtml).toHaveBeenCalled();
|
||||
|
||||
expect(OrganizerRescheduledEmail.prototype.getHtml).toHaveBeenCalled();
|
||||
expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled();
|
||||
expect(OrganizerCancelledEmail.prototype.getHtml).toHaveBeenCalled();
|
||||
emailsEnabledSetup.roundRobinEventType.rescheduledBookingUid = responseBody.data.uid;
|
||||
|
||||
+206
-78
@@ -14,29 +14,27 @@ import { Test } from "@nestjs/testing";
|
||||
import { User } from "@prisma/client";
|
||||
import { DateTime } from "luxon";
|
||||
import * as request from "supertest";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { BookingSeatRepositoryFixture } from "test/fixtures/repository/booking-seat.repository.fixture";
|
||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||
import {
|
||||
CancelBookingInput_2024_08_13,
|
||||
CancelSeatedBookingInput_2024_08_13,
|
||||
CreateRecurringSeatedBookingOutput_2024_08_13,
|
||||
CreateSeatedBookingOutput_2024_08_13,
|
||||
GetBookingOutput_2024_08_13,
|
||||
GetBookingsOutput_2024_08_13,
|
||||
GetRecurringSeatedBookingOutput_2024_08_13,
|
||||
GetSeatedBookingOutput_2024_08_13,
|
||||
RescheduleSeatedBookingInput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
import {
|
||||
CreateBookingInput_2024_08_13,
|
||||
CreateRecurringBookingInput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
import { CreateBookingInput_2024_08_13 } from "@calcom/platform-types";
|
||||
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Bookings Endpoints 2024-08-13", () => {
|
||||
@@ -51,11 +49,13 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
let schedulesService: SchedulesService_2024_04_15;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let oAuthClient: PlatformOAuthClient;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let bookingSeatRepositoryFixture: BookingSeatRepositoryFixture;
|
||||
|
||||
const userEmail = `seated-bookings-user-${randomString()}@api.com`;
|
||||
let user: User;
|
||||
let apiKeyString: string;
|
||||
|
||||
let seatedEventTypeId: number;
|
||||
const maxRecurrenceCount = 3;
|
||||
@@ -63,6 +63,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const seatedEventSlug = `seated-bookings-event-type-${randomString()}`;
|
||||
|
||||
let createdSeatedBooking: CreateSeatedBookingOutput_2024_08_13;
|
||||
let createdSeatedBooking2: CreateSeatedBookingOutput_2024_08_13;
|
||||
|
||||
const emailAttendeeOne = `seated-bookings-attendee1-${randomString()}@api.com`;
|
||||
const nameAttendeeOne = `Attendee One ${randomString()}`;
|
||||
@@ -70,12 +71,9 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const nameAttendeeTwo = `Attendee Two ${randomString()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
)
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
@@ -87,22 +85,21 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
bookingSeatRepositoryFixture = new BookingSeatRepositoryFixture(moduleRef);
|
||||
|
||||
organization = await teamRepositoryFixture.create({
|
||||
name: `seated-bookings-organization-${randomString()}`,
|
||||
});
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
platformOAuthClients: {
|
||||
connect: {
|
||||
id: oAuthClient.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
|
||||
apiKeyString = `cal_test_${keyString}`;
|
||||
|
||||
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||
name: `seated-bookings-2024-08-13-schedule-${randomString()}`,
|
||||
timeZone: "Europe/Rome",
|
||||
@@ -118,6 +115,14 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
seatsShowAttendees: true,
|
||||
seatsShowAvailabilityCount: true,
|
||||
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
|
||||
metadata: {
|
||||
disableStandardEmails: {
|
||||
all: {
|
||||
attendee: true,
|
||||
host: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
user.id
|
||||
);
|
||||
@@ -129,19 +134,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
await app.init();
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
it("should book an event type with seats for the first time", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
|
||||
@@ -333,6 +325,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
return request(app.getHttpServer())
|
||||
.get("/v2/bookings?sortCreated=asc")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
@@ -350,6 +343,22 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should not be able to reschedule seated booking if seatUid is not provided", async () => {
|
||||
const body = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(),
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${createdSeatedBooking.uid}/reschedule`)
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error.message).toEqual(
|
||||
`Booking with uid=${createdSeatedBooking.uid} is a seated booking which means you have to provide seatUid in the request body to specify which seat specifically you want to reschedule. First, fetch the booking using https://cal.com/docs/api-reference/v2/bookings/get-a-booking and then within the attendees array you will find the seatUid of the booking you want to reschedule. Second, provide the seatUid in the request body to specify which seat specifically you want to reschedule using the reschedule endpoint https://cal.com/docs/api-reference/v2/bookings/reschedule-a-booking#option-2`
|
||||
);
|
||||
});
|
||||
|
||||
it("should reschedule seated booking", async () => {
|
||||
const body: RescheduleSeatedBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(),
|
||||
@@ -409,67 +418,186 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should cancel seated booking", async () => {
|
||||
const body: CancelSeatedBookingInput_2024_08_13 = {
|
||||
seatUid: createdSeatedBooking.seatUid,
|
||||
};
|
||||
describe("cancel seated booking", () => {
|
||||
describe("cancel seated booking as attendee", () => {
|
||||
it("should cancel seated booking", async () => {
|
||||
const body: CancelSeatedBookingInput_2024_08_13 = {
|
||||
seatUid: createdSeatedBooking.seatUid,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${createdSeatedBooking.uid}/cancel`)
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true);
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${createdSeatedBooking.uid}/cancel`)
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsGetSeatedBooking(responseBody.data)) {
|
||||
const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts[0].id).toEqual(user.id);
|
||||
expect(data.status).toEqual("cancelled");
|
||||
expect(data.start).toEqual(createdSeatedBooking.start);
|
||||
expect(data.end).toEqual(createdSeatedBooking.end);
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(seatedEventTypeId);
|
||||
expect(data.eventType).toEqual({
|
||||
id: seatedEventTypeId,
|
||||
slug: seatedEventSlug,
|
||||
if (responseDataIsGetSeatedBooking(responseBody.data)) {
|
||||
const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts[0].id).toEqual(user.id);
|
||||
expect(data.status).toEqual("cancelled");
|
||||
expect(data.start).toEqual(createdSeatedBooking.start);
|
||||
expect(data.end).toEqual(createdSeatedBooking.end);
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(seatedEventTypeId);
|
||||
expect(data.eventType).toEqual({
|
||||
id: seatedEventTypeId,
|
||||
slug: seatedEventSlug,
|
||||
});
|
||||
expect(data.attendees.length).toEqual(0);
|
||||
expect(data.location).toBeDefined();
|
||||
expect(data.absentHost).toEqual(false);
|
||||
} else {
|
||||
throw new Error("Invalid response data - expected booking but received array response");
|
||||
}
|
||||
});
|
||||
expect(data.attendees.length).toEqual(0);
|
||||
expect(data.location).toBeDefined();
|
||||
expect(data.absentHost).toEqual(false);
|
||||
} else {
|
||||
throw new Error("Invalid response data - expected booking but received array response");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancel seated booking as host", () => {
|
||||
it("should book an event type with seats", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 14, 0, 0)).toISOString(),
|
||||
eventTypeId: seatedEventTypeId,
|
||||
attendee: {
|
||||
name: nameAttendeeOne,
|
||||
email: emailAttendeeOne,
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
bookingFieldsResponses: {
|
||||
codingLanguage: "TypeScript",
|
||||
},
|
||||
metadata: {
|
||||
userId: "100",
|
||||
},
|
||||
};
|
||||
|
||||
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(responseDataIsCreateSeatedBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsCreateSeatedBooking(responseBody.data)) {
|
||||
const data: CreateSeatedBookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.seatUid).toBeDefined();
|
||||
const seatUid = data.seatUid;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts[0].id).toEqual(user.id);
|
||||
expect(data.status).toEqual("accepted");
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(
|
||||
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
|
||||
);
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(seatedEventTypeId);
|
||||
expect(data.eventType).toEqual({
|
||||
id: seatedEventTypeId,
|
||||
slug: seatedEventSlug,
|
||||
});
|
||||
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,
|
||||
seatUid,
|
||||
bookingFieldsResponses: {
|
||||
name: body.attendee.name,
|
||||
...body.bookingFieldsResponses,
|
||||
},
|
||||
metadata: body.metadata,
|
||||
});
|
||||
expect(data.location).toBeDefined();
|
||||
expect(data.absentHost).toEqual(false);
|
||||
createdSeatedBooking2 = data;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected recurring booking but received non array response"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should not be able to cancel without cancellation reason", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${createdSeatedBooking2.uid}/cancel`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.message).toEqual("Cancellation reason is required when you are the host");
|
||||
});
|
||||
|
||||
it("should cancel seated booking", async () => {
|
||||
const body: CancelBookingInput_2024_08_13 = {
|
||||
cancellationReason: "I will be travelling without internet",
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${createdSeatedBooking2.uid}/cancel`)
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsGetSeatedBooking(responseBody.data)) {
|
||||
const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts[0].id).toEqual(user.id);
|
||||
expect(data.status).toEqual("cancelled");
|
||||
expect(data.start).toEqual(createdSeatedBooking2.start);
|
||||
expect(data.end).toEqual(createdSeatedBooking2.end);
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(seatedEventTypeId);
|
||||
expect(data.eventType).toEqual({
|
||||
id: seatedEventTypeId,
|
||||
slug: seatedEventSlug,
|
||||
});
|
||||
expect(data.attendees.length).toEqual(0);
|
||||
expect(data.location).toBeDefined();
|
||||
expect(data.absentHost).toEqual(false);
|
||||
expect(data.cancellationReason).toEqual("I will be travelling without internet");
|
||||
|
||||
const seats = await bookingSeatRepositoryFixture.findAllByBookingId(data.id);
|
||||
expect(seats.length).toEqual(0);
|
||||
} else {
|
||||
throw new Error("Invalid response data - expected booking but received array response");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function responseDataIsCreateSeatedBooking(data: any): data is CreateSeatedBookingOutput_2024_08_13 {
|
||||
return data.hasOwnProperty("seatUid");
|
||||
}
|
||||
|
||||
function responseDataIsCreateRecurringSeatedBooking(
|
||||
data: any
|
||||
): data is CreateRecurringSeatedBookingOutput_2024_08_13[] {
|
||||
return Array.isArray(data);
|
||||
}
|
||||
|
||||
function responseDataIsGetSeatedBooking(data: any): data is GetSeatedBookingOutput_2024_08_13 {
|
||||
return data?.attendees?.every((attendee: any) => attendee?.hasOwnProperty("seatUid"));
|
||||
}
|
||||
|
||||
function responseDataIsGetRecurringSeatedBooking(
|
||||
data: any
|
||||
): data is GetRecurringSeatedBookingOutput_2024_08_13[] {
|
||||
return Array.isArray(data);
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
X_CAL_CLIENT_ID,
|
||||
} from "@calcom/platform-constants";
|
||||
import { AttendeeScheduledEmail, OrganizerScheduledEmail } from "@calcom/platform-libraries/emails";
|
||||
import { EventManager } from "@calcom/platform-libraries/event-types";
|
||||
import {
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
FAILED_EVENT_TYPE_IDENTIFICATION_ERROR_MESSAGE,
|
||||
@@ -78,6 +79,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const eventTypeSlug = `user-bookings-event-type-${randomString()}`;
|
||||
let recurringEventTypeId: number;
|
||||
const recurringEventTypeSlug = `user-bookings-event-type-${randomString()}`;
|
||||
let eventTypeRequiresConfirmationId: number;
|
||||
const eventTypeRequiresConfirmationSlug = `user-bookings-event-type-requires-confirmation-${randomString()}`;
|
||||
|
||||
let createdBooking: BookingOutput_2024_08_13;
|
||||
let rescheduledBooking: BookingOutput_2024_08_13;
|
||||
@@ -153,6 +156,17 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
);
|
||||
recurringEventTypeId = recurringEvent.id;
|
||||
|
||||
const eventTypeRequiresConfirmation = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `user-bookings-2024-08-13-event-type-requires-confirmation-${randomString()}`,
|
||||
slug: eventTypeRequiresConfirmationSlug,
|
||||
length: 60,
|
||||
requiresConfirmation: true,
|
||||
},
|
||||
user.id
|
||||
);
|
||||
eventTypeRequiresConfirmationId = eventTypeRequiresConfirmation.id;
|
||||
|
||||
bookingInThePast = await bookingsRepositoryFixture.create({
|
||||
user: {
|
||||
connect: {
|
||||
@@ -2539,6 +2553,285 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("calendar events", () => {
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest
|
||||
.spyOn(EventManager.prototype, "create")
|
||||
.mockImplementation(() => Promise.resolve({ results: [], referencesToCreate: [] }));
|
||||
});
|
||||
|
||||
describe("platform oAuth client has calendar events enabled", () => {
|
||||
beforeAll(async () => {
|
||||
await oauthClientRepositoryFixture.update(oAuthClient.id, {
|
||||
areCalendarEventsEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should create calendar event when booking normal event", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2040, 0, 9, 9, 0, 0)).toISOString(),
|
||||
eventTypeId,
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
};
|
||||
|
||||
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(EventManager.prototype.create).toHaveBeenCalledTimes(1);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should create calendar events when booking recurring event", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2040, 0, 10, 9, 0, 0)).toISOString(),
|
||||
eventTypeId: recurringEventTypeId,
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
};
|
||||
|
||||
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(EventManager.prototype.create).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("event type requires confirmation", () => {
|
||||
let bookingThatRequiresConfirmationUid: string;
|
||||
|
||||
it("should not create calendar event when booking event that requires confirmation", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2040, 0, 12, 9, 0, 0)).toISOString(),
|
||||
eventTypeId: eventTypeRequiresConfirmationId,
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
};
|
||||
|
||||
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(EventManager.prototype.create).toHaveBeenCalledTimes(0);
|
||||
bookingThatRequiresConfirmationUid = data.uid;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should create calendar event when confirming event that requires confirmation", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${bookingThatRequiresConfirmationUid}/confirm`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.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(EventManager.prototype.create).toHaveBeenCalledTimes(1);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("platform oAuth client has calendar events disabled", () => {
|
||||
beforeAll(async () => {
|
||||
await oauthClientRepositoryFixture.update(oAuthClient.id, {
|
||||
areCalendarEventsEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should not create calendar event when booking", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2040, 0, 9, 10, 0, 0)).toISOString(),
|
||||
eventTypeId,
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
};
|
||||
|
||||
const beforeCreate = new Date();
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const afterCreate = new Date();
|
||||
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(EventManager.prototype.create).toHaveBeenCalledTimes(0);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should not create calendar events when booking recurring event", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2040, 0, 11, 10, 0, 0)).toISOString(),
|
||||
eventTypeId: recurringEventTypeId,
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
};
|
||||
|
||||
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(EventManager.prototype.create).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("event type requires confirmation", () => {
|
||||
let bookingThatRequiresConfirmationUid: string;
|
||||
|
||||
it("should not create calendar event when booking event that requires confirmation", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2040, 0, 12, 10, 0, 0)).toISOString(),
|
||||
eventTypeId: eventTypeRequiresConfirmationId,
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
};
|
||||
|
||||
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(EventManager.prototype.create).toHaveBeenCalledTimes(0);
|
||||
bookingThatRequiresConfirmationUid = data.uid;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should not create calendar event when confirming event that requires confirmation", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${bookingThatRequiresConfirmationUid}/confirm`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.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(EventManager.prototype.create).toHaveBeenCalledTimes(0);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function responseDataIsRecurranceBooking(data: any): data is RecurringBookingOutput_2024_08_13 {
|
||||
return (
|
||||
!Array.isArray(data) &&
|
||||
|
||||
@@ -404,6 +404,7 @@ export class BookingsService_2024_08_13 {
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
noEmail: bookingRequest.noEmail,
|
||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
||||
});
|
||||
const ids = bookings.map((booking) => booking.id || 0);
|
||||
return this.outputService.getOutputRecurringBookings(ids);
|
||||
@@ -424,6 +425,7 @@ export class BookingsService_2024_08_13 {
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
||||
});
|
||||
return this.outputService.getOutputCreateRecurringSeatedBookings(
|
||||
bookings.map((booking) => ({ uid: booking.uid || "", seatUid: booking.seatReferenceUid || "" }))
|
||||
@@ -445,6 +447,7 @@ export class BookingsService_2024_08_13 {
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
||||
});
|
||||
|
||||
if (!booking.uid) {
|
||||
@@ -475,6 +478,7 @@ export class BookingsService_2024_08_13 {
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
||||
});
|
||||
|
||||
if (!booking.uid) {
|
||||
@@ -652,6 +656,7 @@ export class BookingsService_2024_08_13 {
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
||||
});
|
||||
if (!booking.uid) {
|
||||
throw new Error("Booking missing uid");
|
||||
@@ -930,15 +935,19 @@ export class BookingsService_2024_08_13 {
|
||||
: undefined;
|
||||
|
||||
const emailsEnabled = platformClientParams ? platformClientParams.arePlatformEmailsEnabled : true;
|
||||
const userCalendars = await this.usersRepository.findByIdWithCalendars(requestUser.id);
|
||||
|
||||
await confirmBookingHandler({
|
||||
ctx: {
|
||||
user: requestUser,
|
||||
user: {
|
||||
...requestUser,
|
||||
destinationCalendar: userCalendars?.destinationCalendar ?? null,
|
||||
},
|
||||
},
|
||||
input: {
|
||||
bookingId: booking.id,
|
||||
confirmed: true,
|
||||
recurringEventId: booking.recurringEventId,
|
||||
recurringEventId: booking.recurringEventId ?? undefined,
|
||||
emailsEnabled,
|
||||
platformClientParams,
|
||||
},
|
||||
@@ -958,15 +967,19 @@ export class BookingsService_2024_08_13 {
|
||||
: undefined;
|
||||
|
||||
const emailsEnabled = platformClientParams ? platformClientParams.arePlatformEmailsEnabled : true;
|
||||
const userCalendars = await this.usersRepository.findByIdWithCalendars(requestUser.id);
|
||||
|
||||
await confirmBookingHandler({
|
||||
ctx: {
|
||||
user: requestUser,
|
||||
user: {
|
||||
...requestUser,
|
||||
destinationCalendar: userCalendars?.destinationCalendar ?? null,
|
||||
},
|
||||
},
|
||||
input: {
|
||||
bookingId: booking.id,
|
||||
confirmed: false,
|
||||
recurringEventId: booking.recurringEventId,
|
||||
recurringEventId: booking.recurringEventId ?? undefined,
|
||||
reason,
|
||||
emailsEnabled,
|
||||
platformClientParams,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import {
|
||||
getRecordingsOfCalVideoByRoomName,
|
||||
getAllTranscriptsAccessLinkFromRoomName,
|
||||
getDownloadLinkOfCalVideoByRecordingId,
|
||||
} from "@calcom/platform-libraries/conferencing";
|
||||
|
||||
@Injectable()
|
||||
export class CalVideoService {
|
||||
private readonly logger = new Logger("CalVideoService");
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository_2024_08_13,
|
||||
private readonly outputService: OutputBookingsService_2024_08_13
|
||||
) {}
|
||||
|
||||
async getRecordings(bookingUid: string) {
|
||||
const booking = await this.bookingsRepository.getByUidWithBookingReference(bookingUid);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
|
||||
}
|
||||
|
||||
const roomName =
|
||||
booking?.references?.filter((reference) => reference.type === "daily_video")?.pop()?.meetingId ??
|
||||
undefined;
|
||||
if (!roomName) {
|
||||
throw new NotFoundException(`No Cal Video reference found with booking uid ${bookingUid}`);
|
||||
}
|
||||
|
||||
const recordings = await getRecordingsOfCalVideoByRoomName(roomName);
|
||||
|
||||
if (!recordings || !("data" in recordings)) return [];
|
||||
|
||||
const recordingWithDownloadLink = recordings.data.map((recording) => {
|
||||
return getDownloadLinkOfCalVideoByRecordingId(recording.id)
|
||||
.then((res: { download_link: string } | undefined) => ({
|
||||
id: recording.id,
|
||||
roomName: recording.room_name,
|
||||
startTs: recording.start_ts,
|
||||
status: recording.status,
|
||||
maxParticipants: recording.max_participants,
|
||||
duration: recording.duration,
|
||||
shareToken: recording.share_token,
|
||||
downloadLink: res?.download_link,
|
||||
}))
|
||||
.catch((err: Error) => ({
|
||||
id: recording.id,
|
||||
roomName: recording.room_name,
|
||||
startTs: recording.start_ts,
|
||||
status: recording.status,
|
||||
maxParticipants: recording.max_participants,
|
||||
duration: recording.duration,
|
||||
shareToken: recording.share_token,
|
||||
downloadLink: null,
|
||||
error: err.message,
|
||||
}));
|
||||
});
|
||||
const allRecordingsWithDownloadLink = await Promise.all(recordingWithDownloadLink);
|
||||
|
||||
return allRecordingsWithDownloadLink;
|
||||
}
|
||||
|
||||
async getTranscripts(bookingUid: string) {
|
||||
const booking = await this.bookingsRepository.getByUidWithBookingReference(bookingUid);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
|
||||
}
|
||||
|
||||
const roomName =
|
||||
booking?.references?.filter((reference) => reference.type === "daily_video")?.pop()?.meetingId ??
|
||||
undefined;
|
||||
|
||||
if (!roomName) {
|
||||
throw new NotFoundException(`No Cal Video reference found with booking uid ${bookingUid}`);
|
||||
}
|
||||
|
||||
const transcripts = await getAllTranscriptsAccessLinkFromRoomName(roomName);
|
||||
|
||||
return transcripts;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
||||
import { apiToInternalintegrationsMapping } from "@/ee/event-types/event-types_2024_06_14/transformers";
|
||||
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||
import { defaultBookingResponses } from "@/lib/safe-parse/default-responses-booking";
|
||||
import { safeParse } from "@/lib/safe-parse/safe-parse";
|
||||
@@ -29,10 +30,7 @@ import { NextApiRequest } from "next/types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
EventTypeMetaDataSchema,
|
||||
apiToInternalintegrationsMapping,
|
||||
} from "@calcom/platform-libraries/event-types";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/platform-libraries/event-types";
|
||||
import {
|
||||
CancelBookingInput,
|
||||
CancelBookingInput_2024_08_13,
|
||||
@@ -61,6 +59,7 @@ type OAuthRequestParams = {
|
||||
platformBookingUrl: string;
|
||||
platformBookingLocation?: string;
|
||||
arePlatformEmailsEnabled: boolean;
|
||||
areCalendarEventsEnabled: boolean;
|
||||
};
|
||||
|
||||
export enum Frequency {
|
||||
@@ -587,6 +586,11 @@ export class InputBookingsService_2024_08_13 {
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event type with id=${booking.eventTypeId} not found`);
|
||||
}
|
||||
if (eventType.seatsPerTimeSlot) {
|
||||
throw new BadRequestException(
|
||||
`Booking with uid=${bookingUid} is a seated booking which means you have to provide seatUid in the request body to specify which seat specifically you want to reschedule. First, fetch the booking using https://cal.com/docs/api-reference/v2/bookings/get-a-booking and then within the attendees array you will find the seatUid of the booking you want to reschedule. Second, provide the seatUid in the request body to specify which seat specifically you want to reschedule using the reschedule endpoint https://cal.com/docs/api-reference/v2/bookings/reschedule-a-booking#option-2`
|
||||
);
|
||||
}
|
||||
|
||||
const bookingResponses = safeParse(
|
||||
bookingResponsesSchema,
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
RecurringBookingOutput_2024_08_13,
|
||||
SeatedAttendee,
|
||||
} from "@calcom/platform-types";
|
||||
import { Booking, BookingReference, BookingSeat } from "@calcom/prisma/client";
|
||||
import { Booking, BookingSeat } from "@calcom/prisma/client";
|
||||
|
||||
export const bookingResponsesSchema = z
|
||||
.object({
|
||||
@@ -266,6 +266,7 @@ export class OutputBookingsService_2024_08_13 {
|
||||
description: databaseBooking.description,
|
||||
hosts: [this.getHost(databaseBooking.user)],
|
||||
status: databaseBooking.status.toLowerCase(),
|
||||
cancellationReason: databaseBooking.cancellationReason || undefined,
|
||||
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||
start: databaseBooking.startTime,
|
||||
end: databaseBooking.endTime,
|
||||
|
||||
@@ -54,6 +54,7 @@ export class PlatformBookingsService {
|
||||
platformRescheduleUrl: oAuthClient.bookingRescheduleRedirectUri,
|
||||
platformBookingUrl: oAuthClient.bookingRedirectUri,
|
||||
arePlatformEmailsEnabled: oAuthClient.areEmailsEnabled,
|
||||
areCalendarEventsEnabled: oAuthClient.areCalendarEventsEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,6 +79,7 @@ export class PlatformBookingsService {
|
||||
platformRescheduleUrl: oAuthClient.bookingRescheduleRedirectUri,
|
||||
platformBookingUrl: oAuthClient.bookingRedirectUri,
|
||||
arePlatformEmailsEnabled: oAuthClient.areEmailsEnabled,
|
||||
areCalendarEventsEnabled: oAuthClient.areCalendarEventsEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||
import { CalendarsController } from "@/ee/calendars/controllers/calendars.controller";
|
||||
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
|
||||
@@ -25,8 +27,10 @@ import { Module } from "@nestjs/common";
|
||||
SelectedCalendarsRepository,
|
||||
AppsRepository,
|
||||
CalendarsRepository,
|
||||
BookingsRepository_2024_08_13,
|
||||
BookingReferencesRepository_2024_08_13,
|
||||
],
|
||||
controllers: [CalendarsController],
|
||||
exports: [CalendarsService],
|
||||
exports: [CalendarsService, GoogleCalendarService],
|
||||
})
|
||||
export class CalendarsModule {}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { APPS_TYPE_ID_MAPPING } from "@calcom/platform-constants";
|
||||
import {
|
||||
getConnectedDestinationCalendarsAndEnsureDefaultsInDb,
|
||||
getBusyCalendarTimes,
|
||||
type EventBusyDate,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { Calendar } from "@calcom/platform-types";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
@@ -45,6 +46,7 @@ export class CalendarsService {
|
||||
...credential,
|
||||
delegatedTo: null,
|
||||
delegatedToId: null,
|
||||
delegationCredentialId: null,
|
||||
}))
|
||||
.filter((credential) => !!credential);
|
||||
}
|
||||
@@ -88,18 +90,19 @@ export class CalendarsService {
|
||||
dateTo,
|
||||
composedSelectedCalendars
|
||||
);
|
||||
// @ts-expect-error Element implicitly has any type
|
||||
const calendarBusyTimesConverted = calendarBusyTimes.map((busyTime) => {
|
||||
const busyTimeStart = DateTime.fromJSDate(new Date(busyTime.start)).setZone(timezone);
|
||||
const busyTimeEnd = DateTime.fromJSDate(new Date(busyTime.end)).setZone(timezone);
|
||||
const busyTimeStartDate = busyTimeStart.toJSDate();
|
||||
const busyTimeEndDate = busyTimeEnd.toJSDate();
|
||||
return {
|
||||
...busyTime,
|
||||
start: busyTimeStartDate,
|
||||
end: busyTimeEndDate,
|
||||
};
|
||||
});
|
||||
const calendarBusyTimesConverted = calendarBusyTimes.map(
|
||||
(busyTime: EventBusyDate & { timeZone?: string }) => {
|
||||
const busyTimeStart = DateTime.fromJSDate(new Date(busyTime.start)).setZone(timezone);
|
||||
const busyTimeEnd = DateTime.fromJSDate(new Date(busyTime.end)).setZone(timezone);
|
||||
const busyTimeStartDate = busyTimeStart.toJSDate();
|
||||
const busyTimeEndDate = busyTimeEnd.toJSDate();
|
||||
return {
|
||||
...busyTime,
|
||||
start: busyTimeStartDate,
|
||||
end: busyTimeEndDate,
|
||||
};
|
||||
}
|
||||
);
|
||||
return calendarBusyTimesConverted;
|
||||
} catch (error) {
|
||||
throw new InternalServerErrorException(
|
||||
|
||||
@@ -23,7 +23,7 @@ const CALENDAR_SCOPES = [
|
||||
|
||||
@Injectable()
|
||||
export class GoogleCalendarService implements OAuthCalendarApp {
|
||||
private redirectUri = `${this.config.get("api.url")}/gcal/oauth/save`;
|
||||
public readonly redirectUri = `${this.config.get("api.url")}/gcal/oauth/save`;
|
||||
private gcalResponseSchema = z.object({ client_id: z.string(), client_secret: z.string() });
|
||||
private logger = new Logger("GcalService");
|
||||
|
||||
|
||||
+1
-1
@@ -5,6 +5,7 @@ import { Editable } from "@/ee/event-types/event-types_2024_04_15/inputs/enums/e
|
||||
import { BaseField } from "@/ee/event-types/event-types_2024_04_15/inputs/enums/field-type";
|
||||
import { UpdateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/update-event-type.input";
|
||||
import { EventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/event-type.output";
|
||||
import { systemBeforeFieldEmail } from "@/ee/event-types/event-types_2024_06_14/transformers";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
updateEventType,
|
||||
EventTypesPublic,
|
||||
getEventTypesPublic,
|
||||
systemBeforeFieldEmail,
|
||||
} from "@calcom/platform-libraries/event-types";
|
||||
import { EventType } from "@calcom/prisma/client";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { OrganizerIntegrationLocation } from "@calcom/lib/event-types/transformers/internal/locations";
|
||||
import { OrganizerIntegrationLocation } from "@/ee/event-types/event-types_2024_06_14/transformers";
|
||||
|
||||
type BaseEventType = {
|
||||
length: number;
|
||||
|
||||
+137
@@ -34,6 +34,7 @@ import {
|
||||
ApiSuccessResponse,
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
EventTypeOutput_2024_06_14,
|
||||
GuestsDefaultFieldOutput_2024_06_14,
|
||||
NameDefaultFieldInput_2024_06_14,
|
||||
NotesDefaultFieldInput_2024_06_14,
|
||||
UpdateEventTypeInput_2024_06_14,
|
||||
@@ -535,6 +536,7 @@ describe("Event types Endpoints", () => {
|
||||
body.lockTimeZoneToggleOnBookingPage
|
||||
);
|
||||
expect(createdEventType.color).toEqual(body.color);
|
||||
expect(createdEventType.disableGuests).toEqual(false);
|
||||
|
||||
const expectedBookingFields = [
|
||||
{ ...defaultResponseBookingFieldName, ...nameBookingField },
|
||||
@@ -962,6 +964,10 @@ describe("Event types Endpoints", () => {
|
||||
title: newTitle,
|
||||
scheduleId: secondSchedule.id,
|
||||
lengthInMinutesOptions: [15, 30],
|
||||
calVideoSettings: {
|
||||
disableRecordingForGuests: true,
|
||||
disableRecordingForOrganizer: true,
|
||||
},
|
||||
bookingFields: [
|
||||
nameBookingField,
|
||||
{
|
||||
@@ -1037,6 +1043,7 @@ describe("Event types Endpoints", () => {
|
||||
expect(updatedEventType.description).toEqual(eventType.description);
|
||||
expect(updatedEventType.lengthInMinutes).toEqual(eventType.lengthInMinutes);
|
||||
expect(updatedEventType.locations).toEqual(eventType.locations);
|
||||
expect(updatedEventType.disableGuests).toEqual(false);
|
||||
|
||||
const expectedBookingFields = [
|
||||
{ ...defaultResponseBookingFieldName, ...nameBookingField },
|
||||
@@ -1082,6 +1089,12 @@ describe("Event types Endpoints", () => {
|
||||
body.lockTimeZoneToggleOnBookingPage
|
||||
);
|
||||
expect(updatedEventType.color).toEqual(body.color);
|
||||
expect(updatedEventType.calVideoSettings?.disableRecordingForGuests).toEqual(
|
||||
body.calVideoSettings?.disableRecordingForGuests
|
||||
);
|
||||
expect(updatedEventType.calVideoSettings?.disableRecordingForOrganizer).toEqual(
|
||||
body.calVideoSettings?.disableRecordingForOrganizer
|
||||
);
|
||||
|
||||
eventType.title = newTitle;
|
||||
eventType.scheduleId = secondSchedule.id;
|
||||
@@ -1841,6 +1854,130 @@ describe("Event types Endpoints", () => {
|
||||
expect(slug2).not.toEqual(slug);
|
||||
});
|
||||
});
|
||||
|
||||
it("should create event type with cal video settings", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "event type with cal video settings",
|
||||
slug: "event-type-with-cal-video-settings",
|
||||
lengthInMinutes: 60,
|
||||
calVideoSettings: {
|
||||
disableRecordingForGuests: true,
|
||||
disableRecordingForOrganizer: true,
|
||||
},
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "cal-video",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const createdEventType = responseBody.data;
|
||||
expect(createdEventType).toHaveProperty("id");
|
||||
expect(createdEventType.title).toEqual(body.title);
|
||||
expect(createdEventType.locations).toEqual(body.locations);
|
||||
expect(createdEventType.calVideoSettings?.disableRecordingForGuests).toEqual(true);
|
||||
expect(createdEventType.calVideoSettings?.disableRecordingForOrganizer).toEqual(true);
|
||||
firstCreatedEventType = responseBody.data;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggle disable guests", () => {
|
||||
let eventTypeWithGuestsDisabledId: number;
|
||||
|
||||
it("should create an event type with guests disabled", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class guests disabled",
|
||||
slug: "coding-class-guests-disabled",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
disableGuests: true,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const createdEventType = responseBody.data;
|
||||
expect(createdEventType).toHaveProperty("id");
|
||||
expect(createdEventType.title).toEqual(body.title);
|
||||
expect(createdEventType.description).toEqual(body.description);
|
||||
expect(createdEventType.lengthInMinutes).toEqual(body.lengthInMinutes);
|
||||
|
||||
expect(createdEventType.disableGuests).toEqual(true);
|
||||
const guestsBookingField = createdEventType.bookingFields.find(
|
||||
(field) => field.slug === "guests"
|
||||
) as GuestsDefaultFieldOutput_2024_06_14 | undefined;
|
||||
expect(guestsBookingField?.hidden).toEqual(true);
|
||||
|
||||
eventTypeWithGuestsDisabledId = createdEventType.id;
|
||||
});
|
||||
});
|
||||
|
||||
it("should update an event type with guests enabled", async () => {
|
||||
const body: UpdateEventTypeInput_2024_06_14 = {
|
||||
disableGuests: false,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${eventTypeWithGuestsDisabledId}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const updatedEventType = responseBody.data;
|
||||
|
||||
expect(updatedEventType.disableGuests).toEqual(false);
|
||||
const guestsBookingField = updatedEventType.bookingFields.find(
|
||||
(field) => field.slug === "guests"
|
||||
) as GuestsDefaultFieldOutput_2024_06_14 | undefined;
|
||||
expect(guestsBookingField?.hidden).toEqual(false);
|
||||
|
||||
eventTypeWithGuestsDisabledId = updatedEventType.id;
|
||||
});
|
||||
});
|
||||
|
||||
it("should update an event type with guests disabled", async () => {
|
||||
const body: UpdateEventTypeInput_2024_06_14 = {
|
||||
disableGuests: true,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${eventTypeWithGuestsDisabledId}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const updatedEventType = responseBody.data;
|
||||
|
||||
expect(updatedEventType.disableGuests).toEqual(true);
|
||||
const guestsBookingField = updatedEventType.bookingFields.find(
|
||||
(field) => field.slug === "guests"
|
||||
) as GuestsDefaultFieldOutput_2024_06_14 | undefined;
|
||||
expect(guestsBookingField?.hidden).toEqual(true);
|
||||
|
||||
eventTypeWithGuestsDisabledId = updatedEventType.id;
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (eventTypeWithGuestsDisabledId) {
|
||||
await eventTypesRepositoryFixture.delete(eventTypeWithGuestsDisabledId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
+3
@@ -114,6 +114,9 @@ export class EventTypesController_2024_06_14 {
|
||||
@Query() queryParams: GetEventTypesQuery_2024_06_14
|
||||
): Promise<GetEventTypesOutput_2024_06_14> {
|
||||
const eventTypes = await this.eventTypesService.getEventTypes(queryParams);
|
||||
if (!eventTypes || eventTypes.length === 0) {
|
||||
throw new NotFoundException(`Event types not found`);
|
||||
}
|
||||
const eventTypesFormatted = this.eventTypeResponseTransformPipe.transform(eventTypes);
|
||||
const eventTypesWithoutHiddenFields = eventTypesFormatted.map((eventType) => {
|
||||
return {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { InputEventTransformed_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/transformed";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
import { InputEventTransformed_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class EventTypesRepository_2024_06_14 {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
@@ -13,13 +12,20 @@ export class EventTypesRepository_2024_06_14 {
|
||||
userId: number,
|
||||
body: Omit<InputEventTransformed_2024_06_14, "destinationCalendar">
|
||||
) {
|
||||
const { calVideoSettings, ...restBody } = body;
|
||||
|
||||
return this.dbWrite.prisma.eventType.create({
|
||||
data: {
|
||||
...body,
|
||||
...restBody,
|
||||
userId,
|
||||
locations: body.locations,
|
||||
bookingFields: body.bookingFields,
|
||||
users: { connect: { id: userId } },
|
||||
...(calVideoSettings && {
|
||||
calVideoSettings: {
|
||||
create: calVideoSettings,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -72,7 +78,7 @@ export class EventTypesRepository_2024_06_14 {
|
||||
async getEventTypeById(eventTypeId: number) {
|
||||
return this.dbRead.prisma.eventType.findUnique({
|
||||
where: { id: eventTypeId },
|
||||
include: { users: true, schedule: true, destinationCalendar: true },
|
||||
include: { users: true, schedule: true, destinationCalendar: true, calVideoSettings: true },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -1,5 +1,7 @@
|
||||
import { DEFAULT_EVENT_TYPES } from "@/ee/event-types/event-types_2024_06_14/constants/constants";
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { InputEventTransformed_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/transformed";
|
||||
import { SystemField, CustomField } from "@/ee/event-types/event-types_2024_06_14/transformers";
|
||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
@@ -14,10 +16,8 @@ import {
|
||||
updateEventType,
|
||||
getEventTypesPublic,
|
||||
EventTypesPublic,
|
||||
SystemField,
|
||||
CustomField,
|
||||
} from "@calcom/platform-libraries/event-types";
|
||||
import { GetEventTypesQuery_2024_06_14, InputEventTransformed_2024_06_14 } from "@calcom/platform-types";
|
||||
import { GetEventTypesQuery_2024_06_14 } from "@calcom/platform-types";
|
||||
import { EventType } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
|
||||
+47
-14
@@ -1,13 +1,7 @@
|
||||
import { ConnectedCalendarsData } from "@/ee/calendars/outputs/connected-calendars.output";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable, BadRequestException } from "@nestjs/common";
|
||||
|
||||
import { CONFERENCING_APPS } from "@calcom/platform-constants";
|
||||
import { getUsersCredentials } from "@calcom/platform-libraries";
|
||||
import { getApps } from "@calcom/platform-libraries/app-store";
|
||||
import { InputEventTransformed_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/transformed";
|
||||
import {
|
||||
transformBookingFieldsApiToInternal,
|
||||
transformLocationsApiToInternal,
|
||||
@@ -21,21 +15,24 @@ import {
|
||||
systemAfterFieldNotes,
|
||||
systemAfterFieldGuests,
|
||||
systemAfterFieldRescheduleReason,
|
||||
EventTypeMetaDataSchema,
|
||||
transformBookerLayoutsApiToInternal,
|
||||
transformConfirmationPolicyApiToInternal,
|
||||
transformEventColorsApiToInternal,
|
||||
validateCustomEventName,
|
||||
transformSeatsApiToInternal,
|
||||
SystemField,
|
||||
CustomField,
|
||||
InternalLocation,
|
||||
InternalLocationSchema,
|
||||
} from "@calcom/platform-libraries/event-types";
|
||||
} from "@/ee/event-types/event-types_2024_06_14/transformers";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable, BadRequestException } from "@nestjs/common";
|
||||
|
||||
import { getApps, getUsersCredentialsIncludeServiceAccountKey } from "@calcom/platform-libraries/app-store";
|
||||
import { validateCustomEventName, EventTypeMetaDataSchema } from "@calcom/platform-libraries/event-types";
|
||||
import {
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
DestinationCalendar_2024_06_14,
|
||||
InputEventTransformed_2024_06_14,
|
||||
InputBookingField_2024_06_14,
|
||||
OutputUnknownLocation_2024_06_14,
|
||||
UpdateEventTypeInput_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
@@ -121,16 +118,23 @@ export class InputEventTypesService_2024_06_14 {
|
||||
seats,
|
||||
customName,
|
||||
useDestinationCalendarEmail,
|
||||
disableGuests,
|
||||
...rest
|
||||
} = inputEventType;
|
||||
const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy);
|
||||
|
||||
const locationsTransformed = locations?.length ? this.transformInputLocations(locations) : undefined;
|
||||
|
||||
const effectiveBookingFields =
|
||||
disableGuests !== undefined
|
||||
? this.getBookingFieldsWithGuestsToggled(bookingFields, disableGuests)
|
||||
: bookingFields;
|
||||
|
||||
const eventType = {
|
||||
...rest,
|
||||
length: lengthInMinutes,
|
||||
locations: locationsTransformed,
|
||||
bookingFields: this.transformInputBookingFields(bookingFields),
|
||||
bookingFields: this.transformInputBookingFields(effectiveBookingFields),
|
||||
bookingLimits: bookingLimitsCount ? this.transformInputIntervalLimits(bookingLimitsCount) : undefined,
|
||||
durationLimits: bookingLimitsDuration
|
||||
? this.transformInputIntervalLimits(bookingLimitsDuration)
|
||||
@@ -171,6 +175,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
seats,
|
||||
customName,
|
||||
useDestinationCalendarEmail,
|
||||
disableGuests,
|
||||
...rest
|
||||
} = inputEventType;
|
||||
const eventTypeDb = await this.eventTypesRepository.getEventTypeWithMetaData(eventTypeId);
|
||||
@@ -180,11 +185,18 @@ export class InputEventTypesService_2024_06_14 {
|
||||
|
||||
const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy);
|
||||
|
||||
const effectiveBookingFields =
|
||||
disableGuests !== undefined
|
||||
? this.getBookingFieldsWithGuestsToggled(bookingFields, disableGuests)
|
||||
: bookingFields;
|
||||
|
||||
const eventType = {
|
||||
...rest,
|
||||
length: lengthInMinutes,
|
||||
locations: locations ? this.transformInputLocations(locations) : undefined,
|
||||
bookingFields: bookingFields ? this.transformInputBookingFields(bookingFields) : undefined,
|
||||
bookingFields: effectiveBookingFields
|
||||
? this.transformInputBookingFields(effectiveBookingFields)
|
||||
: undefined,
|
||||
bookingLimits: bookingLimitsCount ? this.transformInputIntervalLimits(bookingLimitsCount) : undefined,
|
||||
durationLimits: bookingLimitsDuration
|
||||
? this.transformInputIntervalLimits(bookingLimitsDuration)
|
||||
@@ -210,6 +222,27 @@ export class InputEventTypesService_2024_06_14 {
|
||||
return eventType;
|
||||
}
|
||||
|
||||
getBookingFieldsWithGuestsToggled(
|
||||
bookingFields: InputBookingField_2024_06_14[] | undefined,
|
||||
hideGuests: boolean
|
||||
) {
|
||||
const toggledGuestsBookingField: InputBookingField_2024_06_14 = { slug: "guests", hidden: hideGuests };
|
||||
if (!bookingFields) {
|
||||
return [toggledGuestsBookingField];
|
||||
}
|
||||
|
||||
const bookingFieldsCopy = [...bookingFields];
|
||||
|
||||
const guestsBookingField = bookingFieldsCopy.find((field) => "slug" in field && field.slug === "guests");
|
||||
if (guestsBookingField) {
|
||||
Object.assign(guestsBookingField, { hidden: hideGuests });
|
||||
return bookingFieldsCopy;
|
||||
}
|
||||
|
||||
bookingFieldsCopy.push(toggledGuestsBookingField);
|
||||
return bookingFieldsCopy;
|
||||
}
|
||||
|
||||
transformInputLocations(inputLocations: CreateEventTypeInput_2024_06_14["locations"]) {
|
||||
return transformLocationsApiToInternal(inputLocations);
|
||||
}
|
||||
@@ -478,7 +511,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
appSlug = "msteams";
|
||||
}
|
||||
|
||||
const credentials = await getUsersCredentials(user);
|
||||
const credentials = await getUsersCredentialsIncludeServiceAccountKey(user);
|
||||
|
||||
const foundApp = getApps(credentials, true).filter((app) => app.slug === appSlug)[0];
|
||||
|
||||
|
||||
+16
-13
@@ -1,14 +1,4 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { EventType, User, Schedule, DestinationCalendar } from "@prisma/client";
|
||||
|
||||
import {
|
||||
userMetadata,
|
||||
parseBookingLimit,
|
||||
parseRecurringEvent,
|
||||
getBookingFieldsWithSystemFields,
|
||||
} from "@calcom/platform-libraries";
|
||||
import {
|
||||
EventTypeMetaDataSchema,
|
||||
transformLocationsInternalToApi,
|
||||
transformBookingFieldsInternalToApi,
|
||||
InternalLocationSchema,
|
||||
@@ -20,11 +10,20 @@ import {
|
||||
transformBookerLayoutsInternalToApi,
|
||||
transformRequiresConfirmationInternalToApi,
|
||||
transformEventTypeColorsInternalToApi,
|
||||
parseEventTypeColor,
|
||||
transformSeatsInternalToApi,
|
||||
InternalLocation,
|
||||
BookingFieldSchema,
|
||||
} from "@calcom/platform-libraries/event-types";
|
||||
} from "@/ee/event-types/event-types_2024_06_14/transformers";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { EventType, User, Schedule, DestinationCalendar, CalVideoSettings } from "@prisma/client";
|
||||
|
||||
import {
|
||||
userMetadata,
|
||||
parseBookingLimit,
|
||||
parseRecurringEvent,
|
||||
getBookingFieldsWithSystemFields,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { EventTypeMetaDataSchema, parseEventTypeColor } from "@calcom/platform-libraries/event-types";
|
||||
import {
|
||||
TransformFutureBookingsLimitSchema_2024_06_14,
|
||||
BookerLayoutsTransformedSchema,
|
||||
@@ -38,8 +37,9 @@ type EventTypeRelations = {
|
||||
users: User[];
|
||||
schedule: Schedule | null;
|
||||
destinationCalendar?: DestinationCalendar | null;
|
||||
calVideoSettings?: CalVideoSettings | null;
|
||||
};
|
||||
export type DatabaseEventType = EventType & EventTypeRelations;
|
||||
export type DatabaseEventType = Omit<EventType, "allowReschedulingCancelledBookings"> & EventTypeRelations;
|
||||
|
||||
type Input = Pick<
|
||||
DatabaseEventType,
|
||||
@@ -88,6 +88,7 @@ type Input = Pick<
|
||||
| "useEventTypeDestinationCalendarEmail"
|
||||
| "hideCalendarEventDetails"
|
||||
| "hideOrganizerEmail"
|
||||
| "calVideoSettings"
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
@@ -125,6 +126,7 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
hideCalendarEventDetails,
|
||||
hideOrganizerEmail,
|
||||
calVideoSettings,
|
||||
} = databaseEventType;
|
||||
|
||||
const locations = this.transformLocations(databaseEventType.locations);
|
||||
@@ -200,6 +202,7 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
useDestinationCalendarEmail: useEventTypeDestinationCalendarEmail,
|
||||
hideCalendarEventDetails,
|
||||
hideOrganizerEmail,
|
||||
calVideoSettings,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+6
-4
@@ -1,5 +1,3 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
import type {
|
||||
transformEventColorsApiToInternal,
|
||||
transformFutureBookingLimitsApiToInternal,
|
||||
@@ -8,9 +6,13 @@ import type {
|
||||
transformSeatsApiToInternal,
|
||||
transformBookingFieldsApiToInternal,
|
||||
InternalLocationsSchema,
|
||||
} from "@calcom/lib/event-types/transformers";
|
||||
} from "@/ee/event-types/event-types_2024_06_14/transformers";
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { CreateEventTypeInput_2024_06_14, ConfirmationPolicyTransformedSchema } from "../inputs";
|
||||
import type {
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
ConfirmationPolicyTransformedSchema,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
export type InputEventTransformed_2024_06_14 = Omit<
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
+1
-3
@@ -1,12 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
BookerLayoutsInputEnum_2024_06_14,
|
||||
BookerLayoutsOutputEnum_2024_06_14,
|
||||
ConfirmationPolicyEnum,
|
||||
NoticeThresholdUnitEnum,
|
||||
FrequencyInput,
|
||||
} from "@calcom/platform-enums/monorepo";
|
||||
} from "@calcom/platform-enums";
|
||||
import type {
|
||||
InputBookingField_2024_06_14,
|
||||
InputLocation_2024_06_14,
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
BookerLayoutsInputEnum_2024_06_14,
|
||||
BookerLayoutsOutputEnum_2024_06_14,
|
||||
} from "@calcom/platform-enums/monorepo";
|
||||
} from "@calcom/platform-enums";
|
||||
import { type CreateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
export function transformBookerLayoutsApiToInternal(
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { ConfirmationPolicyEnum } from "@calcom/platform-enums/monorepo";
|
||||
import { ConfirmationPolicyEnum } from "@calcom/platform-enums";
|
||||
import type {
|
||||
ConfirmationPolicyTransformedSchema,
|
||||
NoticeThresholdTransformedSchema,
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
BookingWindowPeriodInputTypeEnum_2024_06_14,
|
||||
BookingWindowPeriodOutputTypeEnum_2024_06_14,
|
||||
} from "@calcom/platform-enums/monorepo";
|
||||
} from "@calcom/platform-enums";
|
||||
import {
|
||||
type CreateEventTypeInput_2024_06_14,
|
||||
type BusinessDaysWindow_2024_06_14,
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { BookingLimitsEnum_2024_06_14 } from "@calcom/platform-enums/monorepo";
|
||||
import { BookingLimitsEnum_2024_06_14 } from "@calcom/platform-enums";
|
||||
import {
|
||||
type CreateEventTypeInput_2024_06_14,
|
||||
type BookingLimitsKeyOutputType_2024_06_14,
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { Frequency } from "@calcom/platform-enums/monorepo";
|
||||
import { Frequency } from "@calcom/platform-enums";
|
||||
import type { Recurrence_2024_06_14 } from "@calcom/platform-types";
|
||||
import { type TransformRecurringEventSchema_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
BookerLayoutsInputEnum_2024_06_14,
|
||||
BookerLayoutsOutputEnum_2024_06_14,
|
||||
} from "@calcom/platform-enums/monorepo";
|
||||
} from "@calcom/platform-enums";
|
||||
import type { BookerLayoutsTransformedSchema } from "@calcom/platform-types";
|
||||
|
||||
export function transformBookerLayoutsInternalToApi(
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
BookingWindowPeriodInputTypeEnum_2024_06_14,
|
||||
BookingWindowPeriodOutputTypeEnum_2024_06_14,
|
||||
} from "@calcom/platform-enums/monorepo";
|
||||
} from "@calcom/platform-enums";
|
||||
import type {
|
||||
TransformFutureBookingsLimitSchema_2024_06_14,
|
||||
BookingWindow_2024_06_14,
|
||||
+1
-3
@@ -1,11 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
BookerLayoutsInputEnum_2024_06_14,
|
||||
BookerLayoutsOutputEnum_2024_06_14,
|
||||
ConfirmationPolicyEnum,
|
||||
NoticeThresholdUnitEnum,
|
||||
} from "@calcom/platform-enums/monorepo";
|
||||
} from "@calcom/platform-enums";
|
||||
import type {
|
||||
TransformBookingLimitsSchema_2024_06_14,
|
||||
TransformFutureBookingsLimitSchema_2024_06_14,
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { BookingLimitsEnum_2024_06_14 } from "@calcom/platform-enums/monorepo";
|
||||
import { BookingLimitsEnum_2024_06_14 } from "@calcom/platform-enums";
|
||||
import type {
|
||||
BookingLimitsKeysInputType,
|
||||
TransformBookingLimitsSchema_2024_06_14,
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { Frequency, FrequencyInput } from "@calcom/platform-enums/monorepo";
|
||||
import { Frequency, FrequencyInput } from "@calcom/platform-enums";
|
||||
import type { Recurrence_2024_06_14, TransformRecurringEventSchema_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
export function transformRecurrenceInternalToApi(
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { ConfirmationPolicyEnum } from "@calcom/platform-enums/monorepo";
|
||||
import { ConfirmationPolicyEnum } from "@calcom/platform-enums";
|
||||
import type { NoticeThresholdTransformedSchema, ConfirmationPolicy_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
export function transformRequiresConfirmationInternalToApi(
|
||||
+45
-7
@@ -1,11 +1,7 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import {
|
||||
transformApiScheduleOverrides,
|
||||
transformApiScheduleAvailability,
|
||||
} from "@calcom/platform-libraries/schedules";
|
||||
import { CreateScheduleInput_2024_06_11, ScheduleAvailabilityInput_2024_06_11 } from "@calcom/platform-types";
|
||||
import { ScheduleOverrideInput_2024_06_11 } from "@calcom/platform-types";
|
||||
import { ScheduleOverrideInput_2024_06_11, WeekDay } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class InputSchedulesService_2024_06_11 {
|
||||
@@ -34,10 +30,52 @@ export class InputSchedulesService_2024_06_11 {
|
||||
}
|
||||
|
||||
transformInputScheduleAvailability(inputAvailability: ScheduleAvailabilityInput_2024_06_11[]) {
|
||||
return transformApiScheduleAvailability(inputAvailability);
|
||||
return inputAvailability.map((availability) => ({
|
||||
days: availability.days.map(this.transformDayToNumber),
|
||||
startTime: this.createDateFromHoursMinutes(availability.startTime),
|
||||
endTime: this.createDateFromHoursMinutes(availability.endTime),
|
||||
}));
|
||||
}
|
||||
|
||||
transformInputOverrides(inputOverrides: ScheduleOverrideInput_2024_06_11[]) {
|
||||
return transformApiScheduleOverrides(inputOverrides);
|
||||
return inputOverrides.map((override) => ({
|
||||
date: new Date(override.date),
|
||||
startTime: this.createDateFromHoursMinutes(override.startTime),
|
||||
endTime: this.createDateFromHoursMinutes(override.endTime),
|
||||
}));
|
||||
}
|
||||
|
||||
createDateFromHoursMinutes(hoursMinutes: string): Date {
|
||||
const parts = hoursMinutes.split(":");
|
||||
|
||||
if (parts.length < 2) {
|
||||
throw new Error("Invalid time format. Please use 'hh:mm'.");
|
||||
}
|
||||
|
||||
const hours = parseInt(parts[0], 10);
|
||||
const minutes = parseInt(parts[1], 10);
|
||||
|
||||
if (isNaN(hours) || hours < 0 || hours > 23) {
|
||||
throw new Error("Hours must be a number between 0 and 23.");
|
||||
}
|
||||
if (isNaN(minutes) || minutes < 0 || minutes > 59) {
|
||||
throw new Error("Minutes must be a number between 0 and 59.");
|
||||
}
|
||||
|
||||
const utcDate = new Date(Date.UTC(1970, 0, 1, hours, minutes));
|
||||
|
||||
return utcDate;
|
||||
}
|
||||
transformDayToNumber(day: WeekDay): number {
|
||||
const weekMap: { [key in WeekDay]: number } = {
|
||||
Sunday: 0,
|
||||
Monday: 1,
|
||||
Tuesday: 2,
|
||||
Wednesday: 3,
|
||||
Thursday: 4,
|
||||
Friday: 5,
|
||||
Saturday: 6,
|
||||
};
|
||||
return weekMap[day];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export type Environment = {
|
||||
STRIPE_API_KEY: string;
|
||||
STRIPE_WEBHOOK_SECRET: string;
|
||||
WEB_APP_URL: string;
|
||||
IS_E2E: boolean;
|
||||
IS_E2E: string;
|
||||
CALCOM_LICENSE_KEY: string;
|
||||
GET_LICENSE_KEY_URL: string;
|
||||
API_KEY_PREFIX: string;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
|
||||
import { AtomsConferencingAppsController } from "@/modules/atoms/controllers/atoms.conferencing-apps.controller";
|
||||
import { AtomsController } from "@/modules/atoms/controllers/atoms.controller";
|
||||
import { AtomsEventTypesController } from "@/modules/atoms/controllers/atoms.event-types.controller";
|
||||
import { AtomsSchedulesController } from "@/modules/atoms/controllers/atoms.schedules.controller";
|
||||
import { AttributesAtomsService } from "@/modules/atoms/services/attributes-atom.service";
|
||||
import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service";
|
||||
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
|
||||
import { SchedulesAtomsService } from "@/modules/atoms/services/schedules-atom.service";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
@@ -26,10 +29,15 @@ import { Module } from "@nestjs/common";
|
||||
UsersRepository,
|
||||
AtomsRepository,
|
||||
UsersService,
|
||||
SchedulesRepository_2024_06_11,
|
||||
SchedulesAtomsService,
|
||||
RedisService,
|
||||
],
|
||||
exports: [EventTypesAtomService],
|
||||
controllers: [AtomsController],
|
||||
controllers: [
|
||||
AtomsController,
|
||||
AtomsEventTypesController,
|
||||
AtomsConferencingAppsController,
|
||||
AtomsSchedulesController,
|
||||
],
|
||||
})
|
||||
export class AtomsModule {}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service";
|
||||
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 { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Controller, Get, Param, ParseIntPipe, UseGuards, Version, VERSION_NEUTRAL } from "@nestjs/common";
|
||||
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { ConnectedApps } from "@calcom/platform-libraries/app-store";
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
|
||||
/*
|
||||
Conferencing endpoints for atoms, split from AtomsController for clarity and maintainability.
|
||||
These endpoints should not be recommended for use by third party and are excluded from docs.
|
||||
*/
|
||||
|
||||
@Controller({
|
||||
path: "/v2/atoms",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Atoms - conferencing endpoints for atoms")
|
||||
@DocsExcludeController(true)
|
||||
export class AtomsConferencingAppsController {
|
||||
constructor(private readonly conferencingService: ConferencingAtomsService) {}
|
||||
|
||||
@Get("/organizations/:orgId/teams/:teamId/conferencing")
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Version(VERSION_NEUTRAL)
|
||||
async listTeamInstalledConferencingApps(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<ApiResponse<ConnectedApps>> {
|
||||
const conferencingApps = await this.conferencingService.getTeamConferencingApps(user, teamId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: conferencingApps,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/conferencing")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async listUserInstalledConferencingApps(
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<ApiResponse<ConnectedApps>> {
|
||||
const conferencingApps = await this.conferencingService.getUserConferencingApps(user);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: conferencingApps,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,9 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import {
|
||||
BulkUpdateEventTypeToDefaultLocationDto,
|
||||
EventTypesAppInput,
|
||||
} from "@/modules/atoms/inputs/event-types-app.input";
|
||||
import { FindTeamMembersMatchingAttributeQueryDto } from "@/modules/atoms/inputs/find-team-members-matching-attribute.input";
|
||||
import { AttributesAtomsService } from "@/modules/atoms/services/attributes-atom.service";
|
||||
import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service";
|
||||
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
|
||||
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 { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
@@ -25,24 +13,17 @@ import {
|
||||
UseGuards,
|
||||
Version,
|
||||
VERSION_NEUTRAL,
|
||||
Patch,
|
||||
Body,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
|
||||
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { ConnectedApps } from "@calcom/platform-libraries/app-store";
|
||||
import type { UpdateEventTypeReturn } from "@calcom/platform-libraries/event-types";
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
import { FindTeamMembersMatchingAttributeResponseDto } from "../outputs/find-team-members-matching-attribute.output";
|
||||
|
||||
/*
|
||||
|
||||
Endpoints used only by platform atoms, reusing code from other modules, data is already formatted and ready to be used by frontend atoms
|
||||
these endpoints should not be recommended for use by third party and are excluded from docs
|
||||
|
||||
*/
|
||||
|
||||
@Controller({
|
||||
@@ -53,180 +34,10 @@ these endpoints should not be recommended for use by third party and are exclude
|
||||
@DocsExcludeController(true)
|
||||
export class AtomsController {
|
||||
constructor(
|
||||
private readonly eventTypesService: EventTypesAtomService,
|
||||
private readonly conferencingService: ConferencingAtomsService,
|
||||
private readonly attributesService: AttributesAtomsService
|
||||
) {}
|
||||
|
||||
@Get("event-types/:eventTypeId")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async getAtomEventType(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number
|
||||
): Promise<ApiResponse<unknown>> {
|
||||
const eventType = await this.eventTypesService.getUserEventType(user, eventTypeId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventType,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/organizations/:orgId/teams/:teamId/event-types")
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Version(VERSION_NEUTRAL)
|
||||
async listTeamEventTypes(@Param("teamId", ParseIntPipe) teamId: number): Promise<ApiResponse<unknown>> {
|
||||
const eventTypes = await this.eventTypesService.getTeamEventTypes(teamId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventTypes,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/event-types")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async listUserEventTypes(@GetUser("id") userId: number): Promise<ApiResponse<unknown>> {
|
||||
const eventTypes = await this.eventTypesService.getUserEventTypes(userId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventTypes,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("event-types-app/:appSlug")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async getAtomEventTypeApp(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("appSlug") appSlug: string,
|
||||
@Query() queryParams: EventTypesAppInput
|
||||
): Promise<ApiResponse<unknown>> {
|
||||
const { teamId } = queryParams;
|
||||
|
||||
const app = await this.eventTypesService.getEventTypesAppIntegration(appSlug, user, teamId);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: {
|
||||
app,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Get("payment/:uid")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async getUserPaymentInfoById(@Param("uid") uid: string): Promise<ApiResponse<unknown>> {
|
||||
const data = await this.eventTypesService.getUserPaymentInfo(uid);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("/event-types/bulk-update-to-default-location")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async bulkUpdateAtomEventTypes(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() body: BulkUpdateEventTypeToDefaultLocationDto
|
||||
): Promise<{ status: typeof SUCCESS_STATUS | typeof ERROR_STATUS }> {
|
||||
await this.eventTypesService.bulkUpdateEventTypesDefaultLocation(user, body.eventTypeIds);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("/organizations/:orgId/teams/:teamId/event-types/bulk-update-to-default-location")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
async bulkUpdateAtomTeamEventTypes(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() body: BulkUpdateEventTypeToDefaultLocationDto,
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<{ status: typeof SUCCESS_STATUS | typeof ERROR_STATUS }> {
|
||||
await this.eventTypesService.bulkUpdateTeamEventTypesDefaultLocation(body.eventTypeIds, teamId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("event-types/:eventTypeId")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async updateAtomEventType(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||
@Body() body: UpdateEventTypeReturn
|
||||
): Promise<ApiResponse<UpdateEventTypeReturn>> {
|
||||
const eventType = await this.eventTypesService.updateEventType(
|
||||
eventTypeId,
|
||||
{ ...body, id: eventTypeId },
|
||||
user
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventType,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("/organizations/:orgId/teams/:teamId/event-types/:eventTypeId")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
async updateAtomTeamEventType(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Body() body: UpdateEventTypeReturn
|
||||
): Promise<ApiResponse<UpdateEventTypeReturn>> {
|
||||
const eventType = await this.eventTypesService.updateTeamEventType(
|
||||
eventTypeId,
|
||||
{ ...body, id: eventTypeId },
|
||||
user,
|
||||
teamId
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventType,
|
||||
};
|
||||
}
|
||||
@Get("/organizations/:orgId/teams/:teamId/conferencing")
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Version(VERSION_NEUTRAL)
|
||||
async listTeamInstalledConferencingApps(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<ApiResponse<ConnectedApps>> {
|
||||
const conferencingApps = await this.conferencingService.getTeamConferencingApps(user, teamId);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: conferencingApps,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/conferencing")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async listUserInstalledConferencingApps(
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<ApiResponse<ConnectedApps>> {
|
||||
const conferencingApps = await this.conferencingService.getUserConferencingApps(user);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: conferencingApps,
|
||||
};
|
||||
}
|
||||
@Get("/organizations/:orgId/teams/:teamId/members-matching-attribute")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { GetEventTypePublicOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/get-event-type-public.output";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import {
|
||||
BulkUpdateEventTypeToDefaultLocationDto,
|
||||
EventTypesAppInput,
|
||||
} from "@/modules/atoms/inputs/event-types-app.input";
|
||||
import { GetAtomPublicEventTypeQueryParams } from "@/modules/atoms/inputs/get-atom-public-event-type-query-params.input";
|
||||
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
|
||||
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 { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
UseGuards,
|
||||
Version,
|
||||
VERSION_NEUTRAL,
|
||||
Patch,
|
||||
Body,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
|
||||
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import type { UpdateEventTypeReturn } from "@calcom/platform-libraries/event-types";
|
||||
import { PublicEventType } from "@calcom/platform-libraries/event-types";
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
|
||||
/*
|
||||
Event-types endpoints for atoms, split from AtomsController for clarity and maintainability.
|
||||
These endpoints should not be recommended for use by third party and are excluded from docs.
|
||||
*/
|
||||
|
||||
@Controller({
|
||||
path: "/v2/atoms",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Atoms - event-types endpoints for atoms")
|
||||
@DocsExcludeController(true)
|
||||
export class AtomsEventTypesController {
|
||||
constructor(private readonly eventTypesService: EventTypesAtomService) {}
|
||||
|
||||
@Get("/event-types/:eventSlug/public")
|
||||
async getPublicEventType(
|
||||
@Param("eventSlug") eventSlug: string,
|
||||
@Query() queryParams: GetAtomPublicEventTypeQueryParams
|
||||
): Promise<ApiResponse<PublicEventType>> {
|
||||
const { username, teamId, orgId, isTeamEvent } = queryParams;
|
||||
|
||||
const event = await this.eventTypesService.getPublicEventTypeForAtoms({
|
||||
username,
|
||||
eventSlug,
|
||||
isTeamEvent: isTeamEvent ?? false,
|
||||
teamId,
|
||||
orgId,
|
||||
});
|
||||
|
||||
return {
|
||||
data: event,
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("event-types/:eventTypeId")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async getAtomEventType(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number
|
||||
): Promise<ApiResponse<unknown>> {
|
||||
const eventType = await this.eventTypesService.getUserEventType(user, eventTypeId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventType,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/organizations/:orgId/teams/:teamId/event-types")
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Version(VERSION_NEUTRAL)
|
||||
async listTeamEventTypes(@Param("teamId", ParseIntPipe) teamId: number): Promise<ApiResponse<unknown>> {
|
||||
const eventTypes = await this.eventTypesService.getTeamEventTypes(teamId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventTypes,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/event-types")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async listUserEventTypes(@GetUser("id") userId: number): Promise<ApiResponse<unknown>> {
|
||||
const eventTypes = await this.eventTypesService.getUserEventTypes(userId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventTypes,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("event-types-app/:appSlug")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async getAtomEventTypeApp(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("appSlug") appSlug: string,
|
||||
@Query() queryParams: EventTypesAppInput
|
||||
): Promise<ApiResponse<unknown>> {
|
||||
const { teamId } = queryParams;
|
||||
const app = await this.eventTypesService.getEventTypesAppIntegration(appSlug, user, teamId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: {
|
||||
app,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Get("payment/:uid")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async getUserPaymentInfoById(@Param("uid") uid: string): Promise<ApiResponse<unknown>> {
|
||||
const data = await this.eventTypesService.getUserPaymentInfo(uid);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("/event-types/bulk-update-to-default-location")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async bulkUpdateAtomEventTypes(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() body: BulkUpdateEventTypeToDefaultLocationDto
|
||||
): Promise<{ status: typeof SUCCESS_STATUS | typeof ERROR_STATUS }> {
|
||||
await this.eventTypesService.bulkUpdateEventTypesDefaultLocation(user, body.eventTypeIds);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("/organizations/:orgId/teams/:teamId/event-types/bulk-update-to-default-location")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
async bulkUpdateAtomTeamEventTypes(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() body: BulkUpdateEventTypeToDefaultLocationDto,
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<{ status: typeof SUCCESS_STATUS | typeof ERROR_STATUS }> {
|
||||
await this.eventTypesService.bulkUpdateTeamEventTypesDefaultLocation(body.eventTypeIds, teamId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("event-types/:eventTypeId")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async updateAtomEventType(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||
@Body() body: UpdateEventTypeReturn
|
||||
): Promise<ApiResponse<UpdateEventTypeReturn>> {
|
||||
const eventType = await this.eventTypesService.updateEventType(
|
||||
eventTypeId,
|
||||
{ ...body, id: eventTypeId },
|
||||
user
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventType,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("/organizations/:orgId/teams/:teamId/event-types/:eventTypeId")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
async updateAtomTeamEventType(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Body() body: UpdateEventTypeReturn
|
||||
): Promise<ApiResponse<UpdateEventTypeReturn>> {
|
||||
const eventType = await this.eventTypesService.updateTeamEventType(
|
||||
eventTypeId,
|
||||
{ ...body, id: eventTypeId },
|
||||
user,
|
||||
teamId
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventType,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { GetAtomSchedulesQueryParams } from "@/modules/atoms/inputs/get-atom-schedules-query-params.input";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Query,
|
||||
UseGuards,
|
||||
Version,
|
||||
VERSION_NEUTRAL,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiTags as DocsTags,
|
||||
ApiExcludeController as DocsExcludeController,
|
||||
ApiOperation,
|
||||
} from "@nestjs/swagger";
|
||||
|
||||
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { FindDetailedScheduleByIdReturnType } from "@calcom/platform-libraries/schedules";
|
||||
import { ApiResponse, UpdateAtomScheduleDto } from "@calcom/platform-types";
|
||||
|
||||
import { SchedulesAtomsService } from "../services/schedules-atom.service";
|
||||
|
||||
/*
|
||||
Endpoints used only by platform atoms, reusing code from other modules, data is already formatted and ready to be used by frontend atoms
|
||||
these endpoints should not be recommended for use by third party and are excluded from docs
|
||||
*/
|
||||
|
||||
@Controller({
|
||||
path: "/v2/atoms",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Atoms - endpoints for atoms")
|
||||
@DocsExcludeController(true)
|
||||
export class AtomsSchedulesController {
|
||||
constructor(private readonly schedulesService: SchedulesAtomsService) {}
|
||||
|
||||
@Get("/schedules")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@Permissions([SCHEDULE_READ])
|
||||
async getSchedule(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Query() queryParams: GetAtomSchedulesQueryParams
|
||||
): Promise<ApiResponse<FindDetailedScheduleByIdReturnType | null>> {
|
||||
const { isManagedEventType, scheduleId } = queryParams;
|
||||
const schedule = await this.schedulesService.getSchedule({
|
||||
scheduleId,
|
||||
userId: user.id,
|
||||
timeZone: user.timeZone,
|
||||
isManagedEventType,
|
||||
});
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: schedule,
|
||||
};
|
||||
}
|
||||
@Patch("schedules/:scheduleId")
|
||||
@Permissions([SCHEDULE_WRITE])
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiOperation({ summary: "Update atom schedule" })
|
||||
async updateSchedule(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() bodySchedule: UpdateAtomScheduleDto,
|
||||
@Param("scheduleId", ParseIntPipe) scheduleId: number
|
||||
): Promise<ApiResponse<any>> {
|
||||
const updatedSchedule = await this.schedulesService.updateUserSchedule({
|
||||
user,
|
||||
input: bodySchedule,
|
||||
scheduleId,
|
||||
});
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: updatedSchedule,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
|
||||
import type { TFindTeamMembersMatchingAttributeLogicInputSchema } from "@calcom/platform-libraries";
|
||||
|
||||
export class FindTeamMembersMatchingAttributeQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
@ApiProperty({
|
||||
nullable: true,
|
||||
})
|
||||
attributesQueryValue: TFindTeamMembersMatchingAttributeLogicInputSchema["attributesQueryValue"];
|
||||
attributesQueryValue!: TFindTeamMembersMatchingAttributeLogicInputSchema["attributesQueryValue"] | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Boolean,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform, Type } from "class-transformer";
|
||||
import { IsBoolean, IsOptional, IsString, IsNumber } from "class-validator";
|
||||
|
||||
export class GetAtomPublicEventTypeQueryParams {
|
||||
@Transform(({ value }: { value: string }) => value === "true")
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional()
|
||||
isTeamEvent?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }: { value: string }) => value && parseInt(value))
|
||||
@IsNumber()
|
||||
@ApiPropertyOptional({ type: Number })
|
||||
teamId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }: { value: string }) => value && parseInt(value))
|
||||
@IsNumber()
|
||||
@ApiPropertyOptional({ type: Number })
|
||||
orgId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ApiPropertyOptional({ type: String })
|
||||
username?: string;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { IsBoolean, IsOptional } from "class-validator";
|
||||
|
||||
export class GetAtomSchedulesQueryParams {
|
||||
@IsOptional()
|
||||
@Transform(({ value }: { value: string }) => value && parseInt(value))
|
||||
@ApiPropertyOptional({ type: Number })
|
||||
scheduleId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@ApiPropertyOptional({ type: Boolean })
|
||||
isManagedEventType?: boolean;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
|
||||
import { systemBeforeFieldEmail } from "@/ee/event-types/event-types_2024_06_14/transformers";
|
||||
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
@@ -7,7 +8,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable, NotFoundException, ForbiddenException } from "@nestjs/common";
|
||||
import { Injectable, NotFoundException, ForbiddenException, BadRequestException } from "@nestjs/common";
|
||||
|
||||
import { checkAdminOrOwner, getClientSecretFromPayment } from "@calcom/platform-libraries";
|
||||
import type { TeamQuery } from "@calcom/platform-libraries";
|
||||
@@ -21,6 +22,7 @@ import type {
|
||||
CredentialDataWithTeamName,
|
||||
LocationOption,
|
||||
} from "@calcom/platform-libraries/app-store";
|
||||
import { type PublicEventType, getPublicEvent } from "@calcom/platform-libraries/event-types";
|
||||
import {
|
||||
getEventTypeById,
|
||||
bulkUpdateEventsToDefaultLocation,
|
||||
@@ -31,7 +33,6 @@ import {
|
||||
import {
|
||||
updateEventType,
|
||||
TUpdateEventTypeInputSchema,
|
||||
systemBeforeFieldEmail,
|
||||
EventTypeMetaDataSchema,
|
||||
} from "@calcom/platform-libraries/event-types";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
@@ -55,6 +56,18 @@ export class EventTypesAtomService {
|
||||
private readonly teamEventTypeService: TeamsEventTypesService
|
||||
) {}
|
||||
|
||||
private async getTeamSlug(teamId: number): Promise<string> {
|
||||
const team = await this.dbRead.prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
select: { slug: true },
|
||||
});
|
||||
|
||||
if (!team?.slug) {
|
||||
throw new NotFoundException(`Team with id ${teamId} not found`);
|
||||
}
|
||||
return team.slug;
|
||||
}
|
||||
|
||||
async getUserEventType(user: UserWithProfile, eventTypeId: number) {
|
||||
const organizationId = this.usersService.getUserMainOrgId(user);
|
||||
|
||||
@@ -369,4 +382,61 @@ export class EventTypesAtomService {
|
||||
teamId,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Returns the public event type for atoms, handling both team and user events.
|
||||
*/
|
||||
async getPublicEventTypeForAtoms({
|
||||
username,
|
||||
eventSlug,
|
||||
isTeamEvent,
|
||||
orgId,
|
||||
teamId,
|
||||
}: {
|
||||
username?: string;
|
||||
eventSlug: string;
|
||||
isTeamEvent?: boolean;
|
||||
orgId?: number;
|
||||
teamId?: number;
|
||||
}): Promise<PublicEventType> {
|
||||
const orgSlug = orgId ? await this.getTeamSlug(orgId) : null;
|
||||
|
||||
let slug: string | null = null;
|
||||
if (isTeamEvent) {
|
||||
if (!teamId) {
|
||||
throw new BadRequestException("teamId is required for team events, please provide a valid teamId");
|
||||
}
|
||||
slug = await this.getTeamSlug(teamId);
|
||||
} else {
|
||||
if (!username) {
|
||||
throw new BadRequestException(
|
||||
"username is required for non-team events, please provide a valid username"
|
||||
);
|
||||
}
|
||||
slug = username;
|
||||
}
|
||||
|
||||
const slugLower = slug.toLowerCase();
|
||||
|
||||
try {
|
||||
const event = await getPublicEvent(
|
||||
slugLower,
|
||||
eventSlug,
|
||||
isTeamEvent,
|
||||
orgSlug,
|
||||
this.dbRead.prisma as unknown as PrismaClient,
|
||||
true
|
||||
);
|
||||
|
||||
if (!event) {
|
||||
throw new NotFoundException(`Event type with slug ${eventSlug} not found`);
|
||||
}
|
||||
|
||||
return event;
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
throw new NotFoundException(err.message);
|
||||
}
|
||||
throw new NotFoundException(`Event type with slug ${eventSlug} not found`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { ScheduleRepository, UpdateScheduleResponse } from "@calcom/platform-libraries/schedules";
|
||||
import { updateSchedule } from "@calcom/platform-libraries/schedules";
|
||||
import { UpdateAtomScheduleDto } from "@calcom/platform-types";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Injectable()
|
||||
export class SchedulesAtomsService {
|
||||
private logger = new Logger("SchedulesAtomService");
|
||||
|
||||
constructor(
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly dbWrite: PrismaWriteService
|
||||
) {}
|
||||
|
||||
async getSchedule({
|
||||
timeZone,
|
||||
userId,
|
||||
scheduleId,
|
||||
isManagedEventType,
|
||||
}: {
|
||||
timeZone: string;
|
||||
userId: number;
|
||||
scheduleId?: number;
|
||||
isManagedEventType?: boolean;
|
||||
}) {
|
||||
const user = await this.usersRepository.findById(userId);
|
||||
|
||||
if (!user?.defaultScheduleId) return null;
|
||||
|
||||
return await ScheduleRepository.findDetailedScheduleById({
|
||||
scheduleId: scheduleId ?? user.defaultScheduleId,
|
||||
isManagedEventType,
|
||||
userId,
|
||||
timeZone,
|
||||
defaultScheduleId: user.defaultScheduleId,
|
||||
});
|
||||
}
|
||||
|
||||
async updateUserSchedule({
|
||||
input,
|
||||
user,
|
||||
scheduleId,
|
||||
}: {
|
||||
input: UpdateAtomScheduleDto;
|
||||
user: UserWithProfile;
|
||||
scheduleId: number;
|
||||
}): Promise<UpdateScheduleResponse> {
|
||||
return updateSchedule({
|
||||
input: {
|
||||
scheduleId,
|
||||
...input,
|
||||
},
|
||||
user,
|
||||
prisma: this.dbWrite.prisma as unknown as PrismaClient,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { WorkflowsRepository, WorkflowType } from "@/modules/workflows/workflows.repository";
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
|
||||
@Injectable()
|
||||
export class IsWorkflowInTeam implements CanActivate {
|
||||
constructor(private workflowsRepository: WorkflowsRepository) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request & { workflow?: WorkflowType }>();
|
||||
const teamId: string = request.params.teamId;
|
||||
const workflowId: string = request.params.workflowId;
|
||||
|
||||
if (!workflowId) {
|
||||
throw new ForbiddenException("IsWorkflowInTeam - No workflow found in request params.");
|
||||
}
|
||||
|
||||
if (!teamId) {
|
||||
throw new ForbiddenException("IsWorkflowInTeam - No team id found in request params.");
|
||||
}
|
||||
|
||||
const { canAccess, workflow } = await this.checkIfWorkflowIsInTeam(teamId, workflowId);
|
||||
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException(
|
||||
`IsTeamInOrg - Workflow with id=${workflowId} is not part of the team with id=${teamId}.`
|
||||
);
|
||||
}
|
||||
|
||||
request.workflow = workflow;
|
||||
return true;
|
||||
}
|
||||
|
||||
async checkIfWorkflowIsInTeam(
|
||||
teamId: string,
|
||||
workflowId: string
|
||||
): Promise<{ canAccess: boolean; workflow?: WorkflowType }> {
|
||||
const workflow = await this.workflowsRepository.getTeamWorkflowById(Number(teamId), Number(workflowId));
|
||||
|
||||
if (!workflow) {
|
||||
throw new NotFoundException(`IsWorkflowInTeam - workflow (${workflowId}) not found.`);
|
||||
}
|
||||
|
||||
if (workflow.teamId === Number(teamId)) {
|
||||
return { canAccess: true, workflow };
|
||||
}
|
||||
|
||||
return { canAccess: false };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
|
||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { GoogleCalendarService as GCalService } from "@/ee/calendars/services/gcal.service";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CalUnifiedCalendarsController } from "@/modules/cal-unified-calendars/controllers/cal-unified-calendars.controller";
|
||||
import { GoogleCalendarService } from "@/modules/cal-unified-calendars/services/google-calendar.service";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
providers: [
|
||||
GCalService,
|
||||
GoogleCalendarService,
|
||||
AppsRepository,
|
||||
BookingReferencesRepository_2024_08_13,
|
||||
CredentialsRepository,
|
||||
CalendarsService,
|
||||
TokensRepository,
|
||||
SelectedCalendarsRepository,
|
||||
PrismaReadService,
|
||||
PrismaWriteService,
|
||||
UsersRepository,
|
||||
CalendarsRepository,
|
||||
],
|
||||
controllers: [CalUnifiedCalendarsController],
|
||||
exports: [GoogleCalendarService],
|
||||
})
|
||||
export class CalUnifiedCalendarsModule {}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { GetUnifiedCalendarEventOutput } from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event";
|
||||
import { GoogleCalendarEventOutputPipe } from "@/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe";
|
||||
import { GoogleCalendarService } from "@/modules/cal-unified-calendars/services/google-calendar.service";
|
||||
import { Controller, Get, Param, UseGuards, HttpCode, HttpStatus, BadRequestException } from "@nestjs/common";
|
||||
import { ApiTags as DocsTags, ApiParam, ApiHeader, ApiOperation } from "@nestjs/swagger";
|
||||
|
||||
import { GOOGLE_CALENDAR, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/calendars",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Cal Unified Calendars")
|
||||
export class CalUnifiedCalendarsController {
|
||||
constructor(private readonly googleCalendarService: GoogleCalendarService) {}
|
||||
|
||||
@ApiParam({
|
||||
name: "calendar",
|
||||
enum: [GOOGLE_CALENDAR],
|
||||
type: String,
|
||||
})
|
||||
@ApiParam({
|
||||
name: "eventUid",
|
||||
description:
|
||||
"The Google Calendar event ID. You can retrieve this by getting booking references from the following endpoints:\n\n- For team events: https://cal.com/docs/api-reference/v2/orgs-teams-bookings/get-booking-references-for-a-booking\n\n- For user events: https://cal.com/docs/api-reference/v2/bookings/get-booking-references-for-a-booking",
|
||||
type: String,
|
||||
})
|
||||
@Get("/:calendar/event/:eventUid")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(ApiAuthGuard, PermissionsGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Get meeting Details from calendar",
|
||||
description: "Returns detailed information about a meeting including attendance metrics",
|
||||
})
|
||||
async getCalendarEventDetails(
|
||||
@Param("calendar") calendar: string,
|
||||
@Param("eventUid") eventUid: string
|
||||
): Promise<GetUnifiedCalendarEventOutput> {
|
||||
if (calendar !== GOOGLE_CALENDAR) {
|
||||
throw new BadRequestException("Meeting details are currently only available for Google Calendar");
|
||||
}
|
||||
|
||||
const eventDetails = await this.googleCalendarService.getEventDetails(eventUid);
|
||||
|
||||
const transformedEvent = new GoogleCalendarEventOutputPipe().transform(eventDetails);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: transformedEvent,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
import { ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsISO8601, IsOptional, IsString, ValidateNested } from "class-validator";
|
||||
|
||||
import { CALENDARS, SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export enum CalendarEventStatus {
|
||||
ACCEPTED = "accepted",
|
||||
PENDING = "pending",
|
||||
DECLINED = "declined",
|
||||
CANCELLED = "cancelled",
|
||||
}
|
||||
|
||||
export enum CalendarEventResponseStatus {
|
||||
ACCEPTED = "accepted",
|
||||
PENDING = "pending",
|
||||
DECLINED = "declined",
|
||||
NEEDS_ACTION = "needsAction",
|
||||
}
|
||||
|
||||
/**
|
||||
* Base interface for all calendar event locations
|
||||
*/
|
||||
export interface ICalendarEventLocation {
|
||||
type: string;
|
||||
url: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export class CalendarEventVideoLocation implements ICalendarEventLocation {
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
enum: ["video"],
|
||||
description: "Indicates this is a video conference location",
|
||||
})
|
||||
type = "video";
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
description: "URL for joining the video conference",
|
||||
})
|
||||
url!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: "Display name for the video conference",
|
||||
})
|
||||
label?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: "Password required to join the video conference",
|
||||
})
|
||||
password?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: "Meeting code or ID required to join the conference",
|
||||
})
|
||||
meetingCode?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: "Access code required to join the conference",
|
||||
})
|
||||
accessCode?: string;
|
||||
}
|
||||
|
||||
export class CalendarEventPhoneLocation implements ICalendarEventLocation {
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
enum: ["phone"],
|
||||
description: "Indicates this is a phone conference location",
|
||||
})
|
||||
type = "phone";
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
description: "Phone number or URL for dialing into the conference",
|
||||
})
|
||||
url!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: "Display name for the phone conference",
|
||||
})
|
||||
label?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: "PIN number required for the phone conference",
|
||||
})
|
||||
pin?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: "Password required for the phone conference",
|
||||
})
|
||||
password?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: "Access code required for the phone conference",
|
||||
})
|
||||
accessCode?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: "Country/region code for the phone number",
|
||||
})
|
||||
regionCode?: string;
|
||||
}
|
||||
|
||||
export class CalendarEventSipLocation implements ICalendarEventLocation {
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
enum: ["sip"],
|
||||
description: "Indicates this is a SIP (Session Initiation Protocol) conference location",
|
||||
})
|
||||
type = "sip";
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
description: "SIP URL for joining the conference",
|
||||
})
|
||||
url!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: "Display name for the SIP conference",
|
||||
})
|
||||
label?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: "PIN number required for the SIP conference",
|
||||
})
|
||||
pin?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: "Password required for the SIP conference",
|
||||
})
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export class CalendarEventMoreLocation implements ICalendarEventLocation {
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
enum: ["more"],
|
||||
description: "Indicates this is an additional conference location type",
|
||||
})
|
||||
type = "more";
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
description: "URL for accessing this location",
|
||||
})
|
||||
url!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: "Display name for this location",
|
||||
})
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export type CalendarEventLocation =
|
||||
| CalendarEventVideoLocation
|
||||
| CalendarEventPhoneLocation
|
||||
| CalendarEventSipLocation
|
||||
| CalendarEventMoreLocation;
|
||||
|
||||
export class CalendarEventHost {
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
description: "Email address of the event host",
|
||||
})
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: "Display name of the event host",
|
||||
})
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export class CalendarEventAttendee {
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
description: "Email address of the attendee",
|
||||
})
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
description: "Display name of the attendee",
|
||||
})
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@IsEnum(CalendarEventResponseStatus)
|
||||
@ApiPropertyOptional({
|
||||
enum: CalendarEventResponseStatus,
|
||||
nullable: true,
|
||||
enumName: "CalendarEventResponseStatus",
|
||||
description: "Attendee's response to the invitation",
|
||||
example: CalendarEventResponseStatus.ACCEPTED,
|
||||
})
|
||||
responseStatus!: CalendarEventResponseStatus | null;
|
||||
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: "Indicates if this attendee is the current user",
|
||||
})
|
||||
self?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: "Indicates if this attendee's attendance is optional",
|
||||
})
|
||||
optional?: boolean;
|
||||
}
|
||||
|
||||
export class DateTimeWithZone {
|
||||
@IsISO8601()
|
||||
time!: string;
|
||||
|
||||
@IsString()
|
||||
timeZone!: string;
|
||||
}
|
||||
|
||||
export class UnifiedCalendarEventOutput {
|
||||
@ValidateNested()
|
||||
@Type(() => DateTimeWithZone)
|
||||
@ApiProperty({
|
||||
type: "object",
|
||||
properties: {
|
||||
time: { type: "string", format: "date-time" },
|
||||
timeZone: { type: "string" },
|
||||
},
|
||||
description: "Start date and time of the calendar event with timezone information",
|
||||
})
|
||||
start!: DateTimeWithZone;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => DateTimeWithZone)
|
||||
@ApiProperty({
|
||||
type: "object",
|
||||
properties: {
|
||||
time: { type: "string", format: "date-time" },
|
||||
timeZone: { type: "string" },
|
||||
},
|
||||
description: "End date and time of the calendar event with timezone information",
|
||||
})
|
||||
end!: DateTimeWithZone;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
description: "Unique identifier of the calendar event",
|
||||
})
|
||||
id!: string;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
description: "Title of the calendar event",
|
||||
})
|
||||
title!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
nullable: true,
|
||||
description: "Detailed description of the calendar event",
|
||||
})
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => Object, {
|
||||
discriminator: {
|
||||
property: "type",
|
||||
subTypes: [
|
||||
{ value: CalendarEventVideoLocation, name: "video" },
|
||||
{ value: CalendarEventPhoneLocation, name: "phone" },
|
||||
{ value: CalendarEventSipLocation, name: "sip" },
|
||||
{ value: CalendarEventMoreLocation, name: "more" },
|
||||
],
|
||||
},
|
||||
})
|
||||
@ApiPropertyOptional({
|
||||
type: "array",
|
||||
items: {
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(CalendarEventVideoLocation) },
|
||||
{ $ref: getSchemaPath(CalendarEventPhoneLocation) },
|
||||
{ $ref: getSchemaPath(CalendarEventSipLocation) },
|
||||
{ $ref: getSchemaPath(CalendarEventMoreLocation) },
|
||||
],
|
||||
discriminator: {
|
||||
propertyName: "type",
|
||||
},
|
||||
},
|
||||
nullable: true,
|
||||
description: "Conference locations with entry points (video, phone, sip, more)",
|
||||
})
|
||||
locations?: CalendarEventLocation[];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CalendarEventAttendee)
|
||||
@ApiPropertyOptional({
|
||||
type: [CalendarEventAttendee],
|
||||
nullable: true,
|
||||
description: "List of attendees with their response status",
|
||||
})
|
||||
attendees?: CalendarEventAttendee[];
|
||||
|
||||
@IsEnum(CalendarEventStatus)
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
enum: CalendarEventStatus,
|
||||
enumName: "CalendarEventStatus",
|
||||
nullable: true,
|
||||
description: "Status of the event (accepted, pending, declined, cancelled)",
|
||||
example: CalendarEventStatus.ACCEPTED,
|
||||
})
|
||||
status?: CalendarEventStatus | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CalendarEventHost)
|
||||
@ApiPropertyOptional({
|
||||
type: [CalendarEventHost],
|
||||
nullable: true,
|
||||
description: "Information about the event hosts (organizers)",
|
||||
})
|
||||
hosts?: CalendarEventHost[];
|
||||
|
||||
@IsEnum(CALENDARS)
|
||||
@ApiProperty({
|
||||
enum: CALENDARS,
|
||||
enumName: "CalendarSource",
|
||||
description:
|
||||
"Calendar integration source (e.g., Google Calendar, Office 365, Apple Calendar). Currently only Google Calendar is supported.",
|
||||
example: "google",
|
||||
})
|
||||
source!: (typeof CALENDARS)[number];
|
||||
}
|
||||
|
||||
export class GetUnifiedCalendarEventOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => UnifiedCalendarEventOutput)
|
||||
@ApiProperty({ type: UnifiedCalendarEventOutput })
|
||||
data!: UnifiedCalendarEventOutput;
|
||||
}
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
import {
|
||||
CalendarEventResponseStatus,
|
||||
CalendarEventStatus,
|
||||
DateTimeWithZone,
|
||||
UnifiedCalendarEventOutput,
|
||||
} from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event";
|
||||
import { PipeTransform, Injectable } from "@nestjs/common";
|
||||
|
||||
export interface GoogleCalendarEventResponse {
|
||||
kind: string;
|
||||
etag: string;
|
||||
id: string;
|
||||
status: string;
|
||||
htmlLink: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
summary: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
creator: {
|
||||
email: string;
|
||||
displayName?: string;
|
||||
};
|
||||
organizer: {
|
||||
email: string;
|
||||
displayName?: string;
|
||||
self?: boolean;
|
||||
};
|
||||
start: {
|
||||
dateTime: string;
|
||||
timeZone: string;
|
||||
};
|
||||
end: {
|
||||
dateTime: string;
|
||||
timeZone: string;
|
||||
};
|
||||
iCalUID: string;
|
||||
sequence: number;
|
||||
attendees?: Array<{
|
||||
email: string;
|
||||
displayName?: string;
|
||||
organizer?: boolean;
|
||||
self?: boolean;
|
||||
responseStatus?: string;
|
||||
optional?: boolean;
|
||||
}>;
|
||||
hangoutLink?: string;
|
||||
conferenceData?: {
|
||||
createRequest?: {
|
||||
requestId: string;
|
||||
conferenceSolutionKey: {
|
||||
type: string;
|
||||
};
|
||||
status: {
|
||||
statusCode: string;
|
||||
};
|
||||
};
|
||||
entryPoints?: Array<{
|
||||
entryPointType: string;
|
||||
uri: string;
|
||||
label?: string;
|
||||
pin?: string;
|
||||
regionCode?: string;
|
||||
}>;
|
||||
conferenceSolution?: {
|
||||
key: {
|
||||
type: string;
|
||||
};
|
||||
name: string;
|
||||
iconUri: string;
|
||||
};
|
||||
conferenceId: string;
|
||||
};
|
||||
reminders?: {
|
||||
useDefault: boolean;
|
||||
};
|
||||
eventType?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GoogleCalendarEventOutputPipe
|
||||
implements PipeTransform<GoogleCalendarEventResponse, UnifiedCalendarEventOutput>
|
||||
{
|
||||
transform(googleEvent: GoogleCalendarEventResponse): UnifiedCalendarEventOutput {
|
||||
const calendarEvent = new UnifiedCalendarEventOutput();
|
||||
|
||||
calendarEvent.id = googleEvent.id;
|
||||
calendarEvent.title = googleEvent.summary;
|
||||
calendarEvent.description = googleEvent.description || null;
|
||||
|
||||
calendarEvent.start = this.transformDateTimeWithZone(googleEvent.start);
|
||||
calendarEvent.end = this.transformDateTimeWithZone(googleEvent.end);
|
||||
|
||||
calendarEvent.locations = this.transformLocations(googleEvent);
|
||||
|
||||
if (googleEvent.attendees && googleEvent.attendees.length > 0) {
|
||||
calendarEvent.attendees = googleEvent.attendees
|
||||
.filter((attendee) => !attendee.organizer)
|
||||
.map((attendee) => {
|
||||
return {
|
||||
email: attendee.email,
|
||||
name: attendee.displayName,
|
||||
responseStatus: this.transformAttendeeResponseStatus(attendee.responseStatus),
|
||||
organizer: attendee.organizer,
|
||||
self: attendee.self,
|
||||
optional: attendee.optional,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
calendarEvent.status = this.transformEventStatus(googleEvent.status);
|
||||
|
||||
calendarEvent.hosts = this.transformHosts(googleEvent);
|
||||
|
||||
calendarEvent.source = "google";
|
||||
|
||||
return calendarEvent;
|
||||
}
|
||||
|
||||
private transformDateTimeWithZone(googleDateTime: {
|
||||
dateTime: string;
|
||||
timeZone: string;
|
||||
}): DateTimeWithZone {
|
||||
const dateTimeWithZone = new DateTimeWithZone();
|
||||
dateTimeWithZone.time = googleDateTime.dateTime;
|
||||
dateTimeWithZone.timeZone = googleDateTime.timeZone;
|
||||
return dateTimeWithZone;
|
||||
}
|
||||
|
||||
private transformAttendeeResponseStatus(responseStatus?: string): CalendarEventResponseStatus | null {
|
||||
if (!responseStatus) return null;
|
||||
|
||||
switch (responseStatus.toLowerCase()) {
|
||||
case "accepted":
|
||||
return CalendarEventResponseStatus.ACCEPTED;
|
||||
case "tentative":
|
||||
return CalendarEventResponseStatus.PENDING;
|
||||
case "declined":
|
||||
return CalendarEventResponseStatus.DECLINED;
|
||||
case "needsaction":
|
||||
return CalendarEventResponseStatus.NEEDS_ACTION;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private transformEventStatus(eventStatus?: string): CalendarEventStatus | null {
|
||||
if (!eventStatus) return null;
|
||||
|
||||
switch (eventStatus.toLowerCase()) {
|
||||
case "confirmed":
|
||||
return CalendarEventStatus.ACCEPTED;
|
||||
case "tentative":
|
||||
return CalendarEventStatus.PENDING;
|
||||
case "cancelled":
|
||||
return CalendarEventStatus.CANCELLED;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private transformHosts(googleEvent: GoogleCalendarEventResponse): Array<{ email: string; name?: string }> {
|
||||
if (googleEvent.organizer) {
|
||||
return [
|
||||
{
|
||||
email: googleEvent.organizer.email,
|
||||
name: googleEvent.organizer.displayName,
|
||||
},
|
||||
];
|
||||
} else if (googleEvent.attendees) {
|
||||
// If no explicit organizer, find the first attendee with organizer flag
|
||||
const organizerAttendee = googleEvent.attendees.find((attendee) => attendee.organizer);
|
||||
if (organizerAttendee) {
|
||||
return [
|
||||
{
|
||||
email: organizerAttendee.email,
|
||||
name: organizerAttendee.displayName,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private transformLocations(
|
||||
googleEvent: GoogleCalendarEventResponse
|
||||
): Array<{ type: string; url: string; label?: string; pin?: string; regionCode?: string }> {
|
||||
if (googleEvent?.conferenceData?.entryPoints) {
|
||||
return googleEvent.conferenceData.entryPoints.map((entryPoint) => {
|
||||
return {
|
||||
type: entryPoint.entryPointType,
|
||||
url: entryPoint.uri,
|
||||
label: entryPoint.label,
|
||||
pin: entryPoint.pin,
|
||||
regionCode: entryPoint.regionCode,
|
||||
};
|
||||
});
|
||||
} else if (googleEvent.hangoutLink) {
|
||||
return [{ type: "video", url: googleEvent.hangoutLink }];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
|
||||
import { GoogleCalendarService as GCalService } from "@/ee/calendars/services/gcal.service";
|
||||
import { GoogleCalendarEventResponse } from "@/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe";
|
||||
import { calendar_v3 } from "@googleapis/calendar";
|
||||
import { Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { NotFoundException } from "@nestjs/common";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { JWT } from "googleapis-common";
|
||||
|
||||
import { DelegationCredentialRepository, OAuth2UniversalSchema } from "@calcom/platform-libraries/app-store";
|
||||
|
||||
import { UnifiedCalendarEventOutput } from "../outputs/get-unified-calendar-event";
|
||||
|
||||
@Injectable()
|
||||
export class GoogleCalendarService {
|
||||
private logger = new Logger("GoogleCalendarService");
|
||||
constructor(
|
||||
private readonly bookingReferencesRepository: BookingReferencesRepository_2024_08_13,
|
||||
private readonly gCalService: GCalService
|
||||
) {}
|
||||
|
||||
async getEventDetails(eventUid: string): Promise<GoogleCalendarEventResponse> {
|
||||
const bookingReference =
|
||||
await this.bookingReferencesRepository.getBookingReferencesIncludeSensitiveCredentials(eventUid);
|
||||
|
||||
if (!bookingReference) {
|
||||
throw new NotFoundException("Booking reference not found");
|
||||
}
|
||||
|
||||
const ownerUserEmail = bookingReference?.booking?.user?.email;
|
||||
|
||||
// Get authenticated calendar instance
|
||||
const calendar = await this.getAuthorizedCalendarInstance(
|
||||
ownerUserEmail,
|
||||
bookingReference.credential?.key,
|
||||
bookingReference.delegationCredential
|
||||
);
|
||||
|
||||
try {
|
||||
const event = await calendar.events.get({
|
||||
calendarId: bookingReference?.externalCalendarId ?? "primary",
|
||||
eventId: bookingReference?.uid,
|
||||
// fields: "id,status,created,updated,summary,description,location,creator,organizer,start,end,attendees,conferenceData"
|
||||
});
|
||||
|
||||
if (!event.data) {
|
||||
throw new NotFoundException("Meeting not found");
|
||||
}
|
||||
return event.data as GoogleCalendarEventResponse;
|
||||
} catch (error) {
|
||||
throw new NotFoundException("Failed to retrieve meeting details");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an authorized Google Calendar instance
|
||||
* Tries delegation credentials first, falls back to direct OAuth
|
||||
*/
|
||||
private async getAuthorizedCalendarInstance(
|
||||
userEmail?: string,
|
||||
oAuthCredentials?: Prisma.JsonValue | undefined,
|
||||
delegationCredential?: { id: string } | null
|
||||
): Promise<calendar_v3.Calendar> {
|
||||
if (userEmail && delegationCredential?.id) {
|
||||
const delegatedCalendar = await this.getDelegatedCalendarInstance(delegationCredential, userEmail);
|
||||
if (delegatedCalendar) {
|
||||
return delegatedCalendar;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to direct OAuth authentication
|
||||
if (!oAuthCredentials) {
|
||||
throw new UnauthorizedException("No valid credentials available for Google Calendar");
|
||||
}
|
||||
const parsedOAuthCredentials = OAuth2UniversalSchema.parse(oAuthCredentials);
|
||||
const oAuth2Client = await this.gCalService.getOAuthClient(this.gCalService.redirectUri);
|
||||
oAuth2Client.setCredentials(parsedOAuthCredentials);
|
||||
|
||||
return new calendar_v3.Calendar({ auth: oAuth2Client });
|
||||
}
|
||||
|
||||
private async getDelegatedCalendarInstance(
|
||||
delegationCredential: { id: string },
|
||||
emailToImpersonate: string
|
||||
): Promise<calendar_v3.Calendar | null> {
|
||||
try {
|
||||
const oauthClientIdAliasRegex = /\+[a-zA-Z0-9]{25}/;
|
||||
const cleanEmail = emailToImpersonate.replace(oauthClientIdAliasRegex, "");
|
||||
|
||||
const serviceAccountCreds =
|
||||
await DelegationCredentialRepository.findByIdIncludeSensitiveServiceAccountKey({
|
||||
id: delegationCredential.id,
|
||||
});
|
||||
|
||||
if (
|
||||
!serviceAccountCreds?.serviceAccountKey?.client_email ||
|
||||
!serviceAccountCreds?.serviceAccountKey?.private_key
|
||||
) {
|
||||
this.logger.error("Missing service account credentials for delegation", {
|
||||
delegationCredentialId: delegationCredential.id,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const authClient = new JWT({
|
||||
email: serviceAccountCreds.serviceAccountKey.client_email,
|
||||
key: serviceAccountCreds.serviceAccountKey.private_key,
|
||||
scopes: ["https://www.googleapis.com/auth/calendar"],
|
||||
subject: cleanEmail,
|
||||
});
|
||||
|
||||
await authClient.authorize();
|
||||
return new calendar_v3.Calendar({ auth: authClient });
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,8 @@ import {
|
||||
ZOOM,
|
||||
OFFICE_365_VIDEO,
|
||||
} from "@calcom/platform-constants";
|
||||
import { userMetadata, getUsersCredentials } from "@calcom/platform-libraries";
|
||||
import { userMetadata } from "@calcom/platform-libraries";
|
||||
import { getUsersCredentialsIncludeServiceAccountKey } from "@calcom/platform-libraries/app-store";
|
||||
import { getApps, handleDeleteCredential } from "@calcom/platform-libraries/app-store";
|
||||
|
||||
@Injectable()
|
||||
@@ -90,7 +91,7 @@ export class ConferencingService {
|
||||
if (!CONFERENCING_APPS.includes(appSlug)) {
|
||||
throw new BadRequestException("Invalid app, available apps are: ", CONFERENCING_APPS.join(", "));
|
||||
}
|
||||
const credentials = await getUsersCredentials(user);
|
||||
const credentials = await getUsersCredentialsIncludeServiceAccountKey(user);
|
||||
|
||||
const foundApp = getApps(credentials, true).filter((app) => app.slug === appSlug)[0];
|
||||
|
||||
|
||||
+25
-11
@@ -73,6 +73,7 @@ describe("Platform Destination Calendar Endpoints", () => {
|
||||
user.id,
|
||||
APPLE_CALENDAR_ID
|
||||
);
|
||||
|
||||
jest.spyOn(CalendarsService.prototype, "getCalendars").mockReturnValue(
|
||||
Promise.resolve({
|
||||
connectedCalendars: [
|
||||
@@ -92,19 +93,32 @@ describe("Platform Destination Calendar Endpoints", () => {
|
||||
url: "",
|
||||
email: "",
|
||||
},
|
||||
calendars: {
|
||||
externalId:
|
||||
"https://caldav.icloud.com/20961146906/calendars/83C4F9A1-F1D0-41C7-8FC3-0B$9AE22E813/",
|
||||
readOnly: false,
|
||||
integration: "apple_calendar",
|
||||
credentialId: appleCalendarCredentials.id,
|
||||
primary: true,
|
||||
email: user.email,
|
||||
},
|
||||
// calendars: {
|
||||
// externalId:
|
||||
// "https://caldav.icloud.com/20961146906/calendars/83C4F9A1-F1D0-41C7-8FC3-0B$9AE22E813/",
|
||||
// readOnly: false,
|
||||
// integration: "apple_calendar",
|
||||
// credentialId: appleCalendarCredentials.id,
|
||||
// primary: true,
|
||||
// email: user.email,
|
||||
// },
|
||||
error: { message: "" },
|
||||
delegationCredentialId: null,
|
||||
credentialId: appleCalendarCredentials.id,
|
||||
},
|
||||
],
|
||||
destinationCalendar: null,
|
||||
destinationCalendar: {
|
||||
name: "destinationCalendar",
|
||||
eventTypeId: 1,
|
||||
credentialId: appleCalendarCredentials.id,
|
||||
primaryEmail: "primaryEmail",
|
||||
integration: "apple_calendar",
|
||||
externalId: "externalId",
|
||||
userId: null,
|
||||
id: 0,
|
||||
delegationCredentialId: null,
|
||||
domainWideDelegationCredentialId: null,
|
||||
},
|
||||
})
|
||||
);
|
||||
app = moduleRef.createNestApplication();
|
||||
@@ -134,7 +148,7 @@ describe("Platform Destination Calendar Endpoints", () => {
|
||||
expect(user).toBeDefined();
|
||||
});
|
||||
|
||||
it(`POST /v2/destination-calendars: it should respond with a 200 returning back the user updated destination calendar`, async () => {
|
||||
it.skip(`POST /v2/destination-calendars: it should respond with a 200 returning back the user updated destination calendar`, async () => {
|
||||
const body = {
|
||||
integration: appleCalendarCredentials.type,
|
||||
externalId: "https://caldav.icloud.com/20961146906/calendars/83C4F9A1-F1D0-41C7-8FC3-0B$9AE22E813/",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PlatformEndpointsModule } from "@/ee/platform-endpoints-module";
|
||||
import { AtomsModule } from "@/modules/atoms/atoms.module";
|
||||
import { BillingModule } from "@/modules/billing/billing.module";
|
||||
import { CalUnifiedCalendarsModule } from "@/modules/cal-unified-calendars/cal-unified-calendars.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";
|
||||
@@ -30,6 +31,7 @@ import { WebhooksModule } from "./webhooks/webhooks.module";
|
||||
AtomsModule,
|
||||
StripeModule,
|
||||
ConferencingModule,
|
||||
CalUnifiedCalendarsModule,
|
||||
OrganizationsTeamsBookingsModule,
|
||||
OrganizationsUsersBookingsModule,
|
||||
OrganizationsBookingsModule,
|
||||
|
||||
+1
@@ -25,6 +25,7 @@ export class OAuthClientsOutputService {
|
||||
bookingRescheduleRedirectUri: client.bookingRescheduleRedirectUri ?? undefined,
|
||||
areEmailsEnabled: client.areEmailsEnabled,
|
||||
areDefaultEventTypesEnabled: client.areDefaultEventTypesEnabled,
|
||||
areCalendarEventsEnabled: client.areCalendarEventsEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+8
-2
@@ -19,6 +19,7 @@ import { encryptServiceAccountKey } from "@calcom/platform-libraries";
|
||||
import {
|
||||
addDelegationCredential,
|
||||
toggleDelegationCredentialEnabled,
|
||||
type TServiceAccountKeySchema,
|
||||
} from "@calcom/platform-libraries/app-store";
|
||||
|
||||
@Injectable()
|
||||
@@ -131,12 +132,17 @@ export class OrganizationsDelegationCredentialService {
|
||||
delegationCredentialId: string,
|
||||
serviceAccountKey: GoogleServiceAccountKeyInput | MicrosoftServiceAccountKeyInput
|
||||
) {
|
||||
const encryptedServiceAccountKey = encryptServiceAccountKey(serviceAccountKey);
|
||||
// First encrypt the service account key
|
||||
const encryptedServiceAccountKey = encryptServiceAccountKey(
|
||||
serviceAccountKey as TServiceAccountKeySchema
|
||||
);
|
||||
const prismaJsonValue = JSON.parse(JSON.stringify(encryptedServiceAccountKey));
|
||||
|
||||
const delegationCredential =
|
||||
await this.organizationsDelegationCredentialRepository.updateIncludeWorkspacePlatform(
|
||||
delegationCredentialId,
|
||||
{
|
||||
serviceAccountKey: encryptedServiceAccountKey,
|
||||
serviceAccountKey: prismaJsonValue,
|
||||
enabled: false,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
|
||||
import { transformTeamLocationsApiToInternal } from "@/ee/event-types/event-types_2024_06_14/transformers/api-to-internal/locations";
|
||||
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
|
||||
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
||||
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
||||
@@ -6,7 +7,6 @@ import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { transformTeamLocationsApiToInternal } from "@calcom/platform-libraries/event-types";
|
||||
import {
|
||||
CreateTeamEventTypeInput_2024_06_14,
|
||||
UpdateTeamEventTypeInput_2024_06_14,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types
|
||||
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { EventType, User, Schedule, Host, DestinationCalendar } from "@prisma/client";
|
||||
import type { EventType, User, Schedule, Host, DestinationCalendar, CalVideoSettings } from "@prisma/client";
|
||||
import { SchedulingType, Team } from "@prisma/client";
|
||||
|
||||
import { HostPriority, TeamEventTypeResponseHost } from "@calcom/platform-types";
|
||||
@@ -16,6 +16,7 @@ type EventTypeRelations = {
|
||||
Team,
|
||||
"bannerUrl" | "name" | "logoUrl" | "slug" | "weekStart" | "brandColor" | "darkBrandColor" | "theme"
|
||||
> | null;
|
||||
calVideoSettings?: CalVideoSettings | null;
|
||||
};
|
||||
export type DatabaseTeamEventType = EventType & EventTypeRelations;
|
||||
|
||||
@@ -72,6 +73,7 @@ type Input = Pick<
|
||||
| "hideCalendarEventDetails"
|
||||
| "hideOrganizerEmail"
|
||||
| "team"
|
||||
| "calVideoSettings"
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -48,6 +48,7 @@ import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations
|
||||
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/teams/memberships/services/organizations-teams-memberships.service";
|
||||
import { OrganizationsTeamsRoutingFormsModule } from "@/modules/organizations/teams/routing-forms/organizations-teams-routing-forms.module";
|
||||
import { OrganizationsTeamsSchedulesController } from "@/modules/organizations/teams/schedules/organizations-teams-schedules.controller";
|
||||
import { OrganizationTeamWorkflowsController } from "@/modules/organizations/teams/workflows/controllers/org-team-workflows.controller";
|
||||
import { OrganizationsUsersController } from "@/modules/organizations/users/index/controllers/organizations-users.controller";
|
||||
import { OrganizationsUsersRepository } from "@/modules/organizations/users/index/organizations-users.repository";
|
||||
import { OrganizationsUsersService } from "@/modules/organizations/users/index/services/organizations-users-service";
|
||||
@@ -65,8 +66,13 @@ import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-t
|
||||
import { TeamsModule } from "@/modules/teams/teams/teams.module";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { TeamsVerifiedResourcesRepository } from "@/modules/verified-resources/teams-verified-resources.repository";
|
||||
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
|
||||
import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository";
|
||||
import { TeamWorkflowsService } from "@/modules/workflows/services/team-workflows.service";
|
||||
import { WorkflowsInputService } from "@/modules/workflows/services/workflows.input.service";
|
||||
import { WorkflowsOutputService } from "@/modules/workflows/services/workflows.output.service";
|
||||
import { WorkflowsRepository } from "@/modules/workflows/workflows.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
@@ -130,6 +136,11 @@ import { Module } from "@nestjs/common";
|
||||
ZoomVideoService,
|
||||
Office365VideoService,
|
||||
TokensRepository,
|
||||
TeamsVerifiedResourcesRepository,
|
||||
WorkflowsRepository,
|
||||
TeamWorkflowsService,
|
||||
WorkflowsInputService,
|
||||
WorkflowsOutputService,
|
||||
],
|
||||
exports: [
|
||||
OrganizationsService,
|
||||
@@ -165,6 +176,7 @@ import { Module } from "@nestjs/common";
|
||||
OrganizationsWebhooksController,
|
||||
OrganizationsTeamsSchedulesController,
|
||||
OrganizationsUsersOOOController,
|
||||
OrganizationTeamWorkflowsController,
|
||||
],
|
||||
})
|
||||
export class OrganizationsModule {}
|
||||
|
||||
@@ -56,7 +56,6 @@ export type OAuthCallbackState = {
|
||||
@DocsTags("Organizations/Teams Stripe")
|
||||
export class OrganizationsStripeController {
|
||||
constructor(
|
||||
private readonly stripeService: StripeService,
|
||||
private readonly organizationsStripeService: OrganizationsStripeService,
|
||||
private readonly tokensRepository: TokensRepository
|
||||
) {}
|
||||
|
||||
+411
@@ -0,0 +1,411 @@
|
||||
import { bootstrap } from "@/app";
|
||||
// Assuming this is your main app bootstrapper
|
||||
import { AppModule } from "@/app.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { CreateWorkflowDto } from "@/modules/workflows/inputs/create-workflow.input";
|
||||
import {
|
||||
ATTENDEE,
|
||||
REMINDER,
|
||||
PHONE_NUMBER,
|
||||
EMAIL,
|
||||
WorkflowEmailAttendeeStepDto,
|
||||
} from "@/modules/workflows/inputs/workflow-step.input";
|
||||
import { BEFORE_EVENT, DAY } from "@/modules/workflows/inputs/workflow-trigger.input";
|
||||
// Adjust path if needed
|
||||
import { GetWorkflowOutput, GetWorkflowsOutput } from "@/modules/workflows/outputs/workflow.output";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { User, Team } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
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 { VerifiedResourcesRepositoryFixtures } from "test/fixtures/repository/verified-resources.repository.fixture";
|
||||
import { WorkflowRepositoryFixture } from "test/fixtures/repository/workflow.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
|
||||
let app: INestApplication;
|
||||
let verifiedResourcesRepositoryFixtures: VerifiedResourcesRepositoryFixtures;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let teamsRepositoryFixture: TeamRepositoryFixture;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
let workflowsRepositoryFixture: WorkflowRepositoryFixture;
|
||||
let basePath = "";
|
||||
let org: Team;
|
||||
let orgTeam: Team;
|
||||
let createdWorkflowId: number;
|
||||
const authEmail = `org-teams-workflows-user-${randomString()}@example.com`;
|
||||
let user: User;
|
||||
let apiKeyString: string;
|
||||
let verifiedPhoneId: number;
|
||||
let verifiedEmailId: number;
|
||||
let createdWorkflow: GetWorkflowOutput["data"];
|
||||
|
||||
let sampleCreateWorkflowDto: CreateWorkflowDto = {
|
||||
name: `E2E Test Workflow ${randomString()}`,
|
||||
activation: {
|
||||
isActiveOnAllEventTypes: true,
|
||||
activeOnEventTypeIds: [],
|
||||
},
|
||||
trigger: {
|
||||
type: BEFORE_EVENT,
|
||||
offset: {
|
||||
value: 1,
|
||||
unit: DAY,
|
||||
},
|
||||
},
|
||||
steps: [],
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
}).compile();
|
||||
|
||||
// Instantiate Fixtures
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
workflowsRepositoryFixture = new WorkflowRepositoryFixture(moduleRef);
|
||||
verifiedResourcesRepositoryFixtures = new VerifiedResourcesRepositoryFixtures(moduleRef);
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
name: `org-teams-workflows-org-${randomString()}`,
|
||||
slug: `org-teams-workflows-org-${randomString()}`,
|
||||
isOrganization: true,
|
||||
platformBilling: {
|
||||
create: {
|
||||
customerId: "cus_999",
|
||||
plan: "SCALE",
|
||||
subscriptionId: "sub_999",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: authEmail,
|
||||
username: authEmail.split("@")[0],
|
||||
});
|
||||
|
||||
const apiKey = await apiKeysRepositoryFixture.createApiKey(user.id, null);
|
||||
apiKeyString = apiKey.keyString;
|
||||
|
||||
orgTeam = await teamsRepositoryFixture.create({
|
||||
name: `org-teams-workflows-team-${randomString()}`,
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "ADMIN",
|
||||
user: { connect: { id: user.id } },
|
||||
accepted: true,
|
||||
team: { connect: { id: org.id } },
|
||||
});
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "ADMIN",
|
||||
user: { connect: { id: user.id } },
|
||||
accepted: true,
|
||||
team: { connect: { id: orgTeam.id } },
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${user.id}`,
|
||||
username: authEmail.split("@")[0],
|
||||
organization: {
|
||||
connect: {
|
||||
id: org.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const verifiedPhone = await verifiedResourcesRepositoryFixtures.createPhone({
|
||||
user: { connect: { id: user.id } },
|
||||
phoneNumber: "+37255555555",
|
||||
team: { connect: { id: orgTeam.id } },
|
||||
});
|
||||
const verifiedEmail = await verifiedResourcesRepositoryFixtures.createEmail({
|
||||
user: { connect: { id: user.id } },
|
||||
email: authEmail,
|
||||
team: { connect: { id: orgTeam.id } },
|
||||
});
|
||||
verifiedEmailId = verifiedEmail.id;
|
||||
verifiedPhoneId = verifiedPhone.id;
|
||||
|
||||
sampleCreateWorkflowDto = {
|
||||
name: `E2E Test Workflow ${randomString()}`,
|
||||
activation: {
|
||||
isActiveOnAllEventTypes: true,
|
||||
activeOnEventTypeIds: [],
|
||||
},
|
||||
trigger: {
|
||||
type: BEFORE_EVENT,
|
||||
offset: {
|
||||
value: 1,
|
||||
unit: DAY,
|
||||
},
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
stepNumber: 1,
|
||||
action: "email_attendee",
|
||||
recipient: ATTENDEE,
|
||||
template: REMINDER,
|
||||
sender: "CalcomE2EStep1",
|
||||
includeCalendarEvent: true,
|
||||
message: {
|
||||
subject: "Upcoming: {EVENT_NAME}",
|
||||
html: "<p>Reminder for your event {EVENT_NAME}.</p>",
|
||||
},
|
||||
},
|
||||
{
|
||||
stepNumber: 2,
|
||||
action: "sms_number",
|
||||
recipient: PHONE_NUMBER,
|
||||
template: REMINDER,
|
||||
verifiedPhoneId: verifiedPhoneId,
|
||||
sender: "CalcomE2EStep2",
|
||||
message: {
|
||||
subject: "Upcoming: {EVENT_NAME}",
|
||||
text: "Reminder for your event {EVENT_NAME}.",
|
||||
},
|
||||
},
|
||||
{
|
||||
stepNumber: 3,
|
||||
action: "email_address",
|
||||
recipient: EMAIL,
|
||||
template: REMINDER,
|
||||
verifiedEmailId: verifiedEmailId,
|
||||
sender: "CalcomE2EStep3",
|
||||
includeCalendarEvent: true,
|
||||
message: {
|
||||
subject: "Upcoming: {EVENT_NAME}",
|
||||
html: "<p>Reminder for your event {EVENT_NAME}.</p>",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
basePath = `/v2/organizations/${org.id}/teams/${orgTeam.id}/workflows`;
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdWorkflowId) {
|
||||
try {
|
||||
await workflowsRepositoryFixture.delete(createdWorkflowId);
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await teamsRepositoryFixture.delete(orgTeam.id);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe(`POST ${basePath}`, () => {
|
||||
it("should create a new workflow", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(basePath)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send(sampleCreateWorkflowDto)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody: GetWorkflowOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.name).toEqual(sampleCreateWorkflowDto.name);
|
||||
expect(responseBody.data.activation.isActiveOnAllEventTypes).toEqual(
|
||||
sampleCreateWorkflowDto.activation.isActiveOnAllEventTypes
|
||||
);
|
||||
expect(responseBody.data.trigger.type).toEqual(sampleCreateWorkflowDto.trigger.type);
|
||||
expect(responseBody.data.steps).toHaveLength(sampleCreateWorkflowDto.steps.length);
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 1)?.id).toBeDefined();
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 2)?.id).toBeDefined();
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 3)?.id).toBeDefined();
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 1)?.sender).toEqual(
|
||||
"CalcomE2EStep1"
|
||||
);
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 2)?.sender).toEqual(
|
||||
"CalcomE2EStep2"
|
||||
);
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 2)?.phone).toEqual(
|
||||
"+37255555555"
|
||||
);
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 3)?.email).toEqual(authEmail);
|
||||
createdWorkflowId = responseBody.data.id;
|
||||
createdWorkflow = responseBody.data;
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 401 if not authenticated", async () => {
|
||||
return request(app.getHttpServer()).post(basePath).send(sampleCreateWorkflowDto).expect(401);
|
||||
});
|
||||
|
||||
it("should return 400 for invalid data (e.g. missing name)", async () => {
|
||||
const invalidDto = { ...sampleCreateWorkflowDto, name: undefined };
|
||||
return request(app.getHttpServer())
|
||||
.post(basePath)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send(invalidDto)
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe(`GET ${basePath}`, () => {
|
||||
it("should get a list of workflows for the team", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`${basePath}?skip=0&take=10`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetWorkflowsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeInstanceOf(Array);
|
||||
expect(responseBody.data.length).toBeGreaterThanOrEqual(1);
|
||||
expect(responseBody.data.some((wf) => wf.id === createdWorkflowId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 401 if not authenticated", async () => {
|
||||
return request(app.getHttpServer()).get(basePath).expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe(`GET ${basePath}/:workflowId`, () => {
|
||||
it("should get a specific workflow by ID", async () => {
|
||||
expect(createdWorkflowId).toBeDefined();
|
||||
return request(app.getHttpServer())
|
||||
.get(`${basePath}/${createdWorkflowId}`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetWorkflowOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.id).toEqual(createdWorkflowId);
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 404 for a non-existent workflow ID", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`${basePath}/999999`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should return 401 if not authenticated", async () => {
|
||||
expect(createdWorkflowId).toBeDefined();
|
||||
return request(app.getHttpServer()).get(`${basePath}/${createdWorkflowId}`).expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe(`PATCH ${basePath}/:workflowId`, () => {
|
||||
const updatedName = `Updated Workflow Name ${randomString()}`;
|
||||
|
||||
it("should update an existing workflow, update the first step and discard other steps", async () => {
|
||||
const step1 = createdWorkflow.steps.find((step) => step.stepNumber === 1);
|
||||
expect(step1).toBeDefined();
|
||||
const partialUpdateDto: Partial<CreateWorkflowDto> = {
|
||||
name: updatedName,
|
||||
steps: step1 ? [{ ...step1, sender: "updatedSender" } as WorkflowEmailAttendeeStepDto] : [],
|
||||
};
|
||||
expect(createdWorkflowId).toBeDefined();
|
||||
expect(createdWorkflow).toBeDefined();
|
||||
return request(app.getHttpServer())
|
||||
.patch(`${basePath}/${createdWorkflowId}`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send(partialUpdateDto)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetWorkflowOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.id).toEqual(createdWorkflowId);
|
||||
expect(responseBody.data.name).toEqual(updatedName);
|
||||
step1 && expect(responseBody.data.steps[0].id).toEqual(step1.id);
|
||||
expect(responseBody.data.steps[0].sender).toEqual("updatedSender");
|
||||
expect(responseBody.data.steps[1]?.id).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 404 for updating a non-existent workflow ID", async () => {
|
||||
const partialUpdateDto: Partial<CreateWorkflowDto> = {
|
||||
name: updatedName,
|
||||
steps: [{ ...createdWorkflow.steps[0], sender: "updatedSender" } as WorkflowEmailAttendeeStepDto],
|
||||
};
|
||||
return request(app.getHttpServer())
|
||||
.patch(`${basePath}/999999`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send(partialUpdateDto)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should return 401 if not authenticated", async () => {
|
||||
const partialUpdateDto: Partial<CreateWorkflowDto> = {
|
||||
name: updatedName,
|
||||
steps: [{ ...createdWorkflow.steps[0], sender: "updatedSender" } as WorkflowEmailAttendeeStepDto],
|
||||
};
|
||||
expect(createdWorkflowId).toBeDefined();
|
||||
return request(app.getHttpServer())
|
||||
.patch(`${basePath}/${createdWorkflowId}`)
|
||||
.send(partialUpdateDto)
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe(`DELETE ${basePath}/:workflowId`, () => {
|
||||
let workflowToDeleteId: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(basePath)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({ ...sampleCreateWorkflowDto, name: `Workflow To Delete ${randomString()}` });
|
||||
workflowToDeleteId = res.body.data.id;
|
||||
});
|
||||
|
||||
it("should delete an existing workflow", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`${basePath}/${workflowToDeleteId}`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 404 when trying to delete a non-existent workflow ID", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`${basePath}/999999`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should return 401 if not authenticated", async () => {
|
||||
return request(app.getHttpServer()).delete(`${basePath}/${workflowToDeleteId}`).expect(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import {
|
||||
OPTIONAL_API_KEY_HEADER,
|
||||
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
|
||||
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
|
||||
} from "@/lib/docs/headers";
|
||||
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 { IsWorkflowInTeam } from "@/modules/auth/guards/workflows/is-workflow-in-team";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { CreateWorkflowDto } from "@/modules/workflows/inputs/create-workflow.input";
|
||||
import { UpdateWorkflowDto } from "@/modules/workflows/inputs/update-workflow.input";
|
||||
import { GetWorkflowOutput, GetWorkflowsOutput } from "@/modules/workflows/outputs/workflow.output";
|
||||
import { TeamWorkflowsService } from "@/modules/workflows/services/team-workflows.service";
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Patch,
|
||||
Post,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Query,
|
||||
UseGuards,
|
||||
Body,
|
||||
Delete,
|
||||
} from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/teams/:teamId/workflows",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@ApiTags("Orgs / Teams / Workflows")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, IsTeamInOrg, PlatformPlanGuard, RolesGuard, IsAdminAPIEnabledGuard)
|
||||
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
|
||||
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
||||
@ApiHeader(OPTIONAL_API_KEY_HEADER)
|
||||
export class OrganizationTeamWorkflowsController {
|
||||
constructor(private readonly workflowsService: TeamWorkflowsService) {}
|
||||
|
||||
@Get("/")
|
||||
@ApiOperation({ summary: "Get organization team workflows" })
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
async getWorkflows(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Query() queryParams: SkipTakePagination
|
||||
): Promise<GetWorkflowsOutput> {
|
||||
const { skip, take } = queryParams;
|
||||
|
||||
const workflows = await this.workflowsService.getTeamWorkflows(teamId, skip, take);
|
||||
|
||||
return { data: workflows, status: SUCCESS_STATUS };
|
||||
}
|
||||
|
||||
@Get("/:workflowId")
|
||||
@UseGuards(IsWorkflowInTeam)
|
||||
@ApiOperation({ summary: "Get organization team workflow" })
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
async getWorkflowById(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("workflowId", ParseIntPipe) workflowId: number
|
||||
): Promise<GetWorkflowOutput> {
|
||||
const workflow = await this.workflowsService.getTeamWorkflowById(teamId, workflowId);
|
||||
|
||||
return { data: workflow, status: SUCCESS_STATUS };
|
||||
}
|
||||
|
||||
@Post("/")
|
||||
@ApiOperation({ summary: "Create organization team workflow" })
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
async createWorkflow(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Body() data: CreateWorkflowDto
|
||||
): Promise<GetWorkflowOutput> {
|
||||
const workflow = await this.workflowsService.createTeamWorkflow(user, teamId, data);
|
||||
return { data: workflow, status: SUCCESS_STATUS };
|
||||
}
|
||||
|
||||
@Patch("/:workflowId")
|
||||
@UseGuards(IsWorkflowInTeam)
|
||||
@ApiOperation({ summary: "Update workflow" })
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
async updateWorkflow(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("workflowId", ParseIntPipe) workflowId: number,
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() data: UpdateWorkflowDto
|
||||
): Promise<GetWorkflowOutput> {
|
||||
const workflow = await this.workflowsService.updateTeamWorkflow(user, teamId, workflowId, data);
|
||||
return { data: workflow, status: SUCCESS_STATUS };
|
||||
}
|
||||
|
||||
@Delete("/:workflowId")
|
||||
@UseGuards(IsWorkflowInTeam)
|
||||
@ApiOperation({ summary: "Delete organization team workflow" })
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
async deleteWorkflow(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("workflowId") workflowId: number
|
||||
): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
await this.workflowsService.deleteTeamWorkflow(teamId, workflowId);
|
||||
return { status: SUCCESS_STATUS };
|
||||
}
|
||||
}
|
||||
+1
@@ -89,6 +89,7 @@ export class OrganizationsUsersService {
|
||||
autoAccept: userCreateBody.autoAccept,
|
||||
},
|
||||
},
|
||||
language: "en",
|
||||
});
|
||||
|
||||
const createdUser = createdUserCall[0];
|
||||
|
||||
@@ -71,6 +71,10 @@ export class RouterController {
|
||||
3
|
||||
);
|
||||
|
||||
if (!eventTypeData) {
|
||||
throw new NotFoundException("Event type not found.");
|
||||
}
|
||||
|
||||
// get the salesforce record owner email for the email given as a form response.
|
||||
const {
|
||||
email: teamMemberEmail,
|
||||
@@ -81,9 +85,9 @@ export class RouterController {
|
||||
eventData: eventTypeData,
|
||||
});
|
||||
|
||||
Boolean(teamMemberEmail) && routingUrl.searchParams.set("cal.teamMemberEmail", teamMemberEmail);
|
||||
Boolean(crmOwnerRecordType) && routingUrl.searchParams.set("cal.crmOwnerRecordType", crmOwnerRecordType);
|
||||
Boolean(crmAppSlug) && routingUrl.searchParams.set("cal.crmAppSlug", crmAppSlug);
|
||||
teamMemberEmail && routingUrl.searchParams.set("cal.teamMemberEmail", teamMemberEmail);
|
||||
crmOwnerRecordType && routingUrl.searchParams.set("cal.crmOwnerRecordType", crmOwnerRecordType);
|
||||
crmAppSlug && routingUrl.searchParams.set("cal.crmAppSlug", crmAppSlug);
|
||||
|
||||
return { status: "success", data: routingUrl.toString(), redirect: true };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { SlotsOutputService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
|
||||
import type { RangeSlots, TimeSlots } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
|
||||
import { SlotsWorkerService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots-worker.service";
|
||||
import { SlotsService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots.service";
|
||||
import { Query, Body, Controller, Get, Delete, Post, Req, Res, BadRequestException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
import { ApiTags as DocsTags, ApiCreatedResponse, ApiOkResponse, ApiOperation } from "@nestjs/swagger";
|
||||
import { Response as ExpressResponse, Request as ExpressRequest } from "express";
|
||||
@@ -25,7 +27,9 @@ import { ApiResponse, GetAvailableSlotsInput_2024_04_15 } from "@calcom/platform
|
||||
export class SlotsController_2024_04_15 {
|
||||
constructor(
|
||||
private readonly slotsService: SlotsService_2024_04_15,
|
||||
private readonly slotsOutputService: SlotsOutputService_2024_04_15
|
||||
private readonly config: ConfigService,
|
||||
private readonly slotsOutputService: SlotsOutputService_2024_04_15,
|
||||
private readonly slotsWorkerService: SlotsWorkerService_2024_04_15
|
||||
) {}
|
||||
|
||||
@Post("/reserve")
|
||||
@@ -158,19 +162,31 @@ export class SlotsController_2024_04_15 {
|
||||
@Req() req: ExpressRequest
|
||||
): Promise<ApiResponse<{ slots: TimeSlots["slots"] | RangeSlots["slots"] }>> {
|
||||
try {
|
||||
const isTeamEvent =
|
||||
query.isTeamEvent === undefined
|
||||
? await this.slotsService.checkIfIsTeamEvent(query.eventTypeId)
|
||||
: query.isTeamEvent;
|
||||
const availableSlots = await getAvailableSlots({
|
||||
input: {
|
||||
...query,
|
||||
isTeamEvent,
|
||||
},
|
||||
ctx: {
|
||||
req,
|
||||
},
|
||||
});
|
||||
const isTeamEvent =
|
||||
query.isTeamEvent === undefined
|
||||
? await this.slotsService.checkIfIsTeamEvent(query.eventTypeId)
|
||||
: query.isTeamEvent;
|
||||
|
||||
// Do not use workers in E2E, not supported by TS-JEST
|
||||
const availableSlots = this.config.get<boolean>("e2e")
|
||||
? await getAvailableSlots({
|
||||
input: {
|
||||
...query,
|
||||
isTeamEvent,
|
||||
},
|
||||
ctx: {
|
||||
req,
|
||||
},
|
||||
})
|
||||
: await this.slotsWorkerService.getAvailableSlotsInWorker({
|
||||
input: {
|
||||
...query,
|
||||
isTeamEvent,
|
||||
},
|
||||
ctx: {
|
||||
req,
|
||||
},
|
||||
});
|
||||
|
||||
const { slots } = await this.slotsOutputService.getOutputSlots(
|
||||
availableSlots,
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { Injectable, Logger, OnModuleDestroy } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import * as path from "path";
|
||||
import { Worker } from "worker_threads";
|
||||
|
||||
// Import WorkerOptions type
|
||||
import type { GetScheduleOptions } from "@calcom/trpc/server/routers/viewer/slots/types";
|
||||
|
||||
import { TimeSlots } from "./slots-output.service";
|
||||
|
||||
/**
|
||||
* Interface to define the structure of messages sent to the worker.
|
||||
*/
|
||||
interface WorkerMessage {
|
||||
input: GetScheduleOptions["input"];
|
||||
ctx?: {
|
||||
req?: {
|
||||
cookies?: Record<string, string>;
|
||||
headers?: Record<string, string | string[]>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface to define the structure of results received from the worker.
|
||||
*/
|
||||
interface WorkerResult {
|
||||
success: boolean;
|
||||
data?: TimeSlots;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SlotsWorkerService_2024_04_15 implements OnModuleDestroy {
|
||||
private readonly logger = new Logger("SlotsWorkerService_2024_04_15");
|
||||
private readonly workerPool: Worker[] = [];
|
||||
private readonly maxWorkers: number;
|
||||
private readonly taskQueue: Array<{
|
||||
resolve: (value: TimeSlots) => void;
|
||||
reject: (reason: Error) => void;
|
||||
options: GetScheduleOptions;
|
||||
}> = [];
|
||||
private availableWorkers: Worker[] = [];
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
this.maxWorkers = process.env.SLOTS_WORKER_POOL_SIZE
|
||||
? parseInt(process.env.SLOTS_WORKER_POOL_SIZE, 10)
|
||||
: 4;
|
||||
// Workers are not initialized in E@E
|
||||
if (!this.config.get<boolean>("e2e")) {
|
||||
this.initializeWorkerPool();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the worker pool by creating a fixed number of worker threads.
|
||||
* Each worker is set up with persistent event listeners for errors and exits.
|
||||
*/
|
||||
private initializeWorkerPool(): void {
|
||||
for (let i = 0; i < this.maxWorkers; i++) {
|
||||
this.createNewWorker();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new worker thread and configures its essential, persistent event listeners.
|
||||
* Adds the new worker to the pool and available workers list.
|
||||
*/
|
||||
private createNewWorker(): void {
|
||||
const worker = new Worker(path.join(__dirname, "../workers/slots.worker.js"));
|
||||
|
||||
// These 'on' listeners are for the worker's overall lifecycle (crashes, exits).
|
||||
// They are persistent and responsible for calling handleWorkerFailure.
|
||||
worker.on("error", (err: Error) => {
|
||||
this.logger.error(`Worker experienced a persistent error: ${err.message}`, err.stack);
|
||||
this.handleWorkerFailure(worker);
|
||||
});
|
||||
|
||||
worker.on("exit", (code: number) => {
|
||||
if (code !== 0) {
|
||||
this.logger.error(`Worker exited with code ${code}.`);
|
||||
}
|
||||
this.handleWorkerFailure(worker);
|
||||
});
|
||||
|
||||
this.workerPool.push(worker);
|
||||
this.availableWorkers.push(worker);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the failure of a worker by removing it from pools and creating a new one.
|
||||
* This ensures the worker pool remains at the desired size and healthy.
|
||||
* @param failedWorker The worker that failed or exited.
|
||||
*/
|
||||
private handleWorkerFailure(failedWorker: Worker): void {
|
||||
// Remove the failed worker from both pools
|
||||
this.logger.error(`Handling Worker ${failedWorker.threadId} failure`);
|
||||
this.workerPool.splice(this.workerPool.indexOf(failedWorker), 1);
|
||||
this.availableWorkers = this.availableWorkers.filter((w) => w !== failedWorker);
|
||||
|
||||
try {
|
||||
failedWorker
|
||||
.terminate()
|
||||
.then(() => {
|
||||
this.logger.log(`Terminated failed worker ${failedWorker.threadId}`);
|
||||
})
|
||||
.catch((err) => {
|
||||
this.logger.error(`Error terminating failed worker ${failedWorker.threadId}: ${err?.message}`);
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to invoke terminate method on failed worker ${failedWorker.threadId}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
// Attempt to create a new worker to replace the failed one
|
||||
try {
|
||||
this.createNewWorker();
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to create replacement worker after failure: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
error instanceof Error ? error.stack : undefined
|
||||
);
|
||||
}
|
||||
|
||||
// After a worker fails, process the next task in case there are queued tasks
|
||||
this.processNextTask();
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the next task in the queue if there are available workers.
|
||||
* Assigns a task to an available worker and sets up 'once' listeners for its specific result.
|
||||
*/
|
||||
private processNextTask(): void {
|
||||
if (this.taskQueue.length > 0 && this.availableWorkers.length > 0) {
|
||||
const task = this.taskQueue.shift();
|
||||
const worker = this.availableWorkers.shift();
|
||||
|
||||
if (!task || !worker) {
|
||||
// This should theoretically not happen if the checks above pass, but good for type narrowing.
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare context for serialization
|
||||
const serializableCtx: WorkerMessage["ctx"] = task.options.ctx
|
||||
? {
|
||||
req: task.options.ctx.req
|
||||
? {
|
||||
cookies: (task.options.ctx.req.cookies as Record<string, string>) || {},
|
||||
headers: (task.options.ctx.req.headers as Record<string, string>) || {},
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
// Use 'once' listeners for task-specific responses and errors.
|
||||
// 'once' listeners automatically remove themselves after being invoked, preventing leaks.
|
||||
const messageListener = (result: WorkerResult) => {
|
||||
this.availableWorkers.push(worker); // Return worker to the available pool
|
||||
if (result.success) {
|
||||
task.resolve(result.data as TimeSlots);
|
||||
} else {
|
||||
task.reject(result.error ?? new Error("An error occurred in the worker thread."));
|
||||
}
|
||||
this.processNextTask(); // Attempt to process the next task
|
||||
};
|
||||
|
||||
const errorListener = (err: Error) => {
|
||||
this.availableWorkers.push(worker); // Ensure worker is returned
|
||||
task.reject(new Error(`Worker thread error during task execution: ${err.message}`));
|
||||
this.processNextTask(); // Attempt to process the next task
|
||||
};
|
||||
|
||||
worker.once("message", messageListener); // Use 'once' for task results
|
||||
worker.once("error", errorListener); // Use 'once' for task-specific errors
|
||||
|
||||
worker.postMessage({
|
||||
input: task.options.input,
|
||||
ctx: serializableCtx,
|
||||
} as WorkerMessage);
|
||||
} catch (error) {
|
||||
// If posting the message itself fails (e.g., serialization error)
|
||||
this.availableWorkers.push(worker); // Ensure worker is returned to pool
|
||||
task.reject(
|
||||
new Error(
|
||||
`Failed to dispatch task to worker: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
);
|
||||
this.processNextTask(); // Try to process next task if available
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public method to request available time slots, offloading the computation to a worker thread.
|
||||
* Returns a Promise that resolves with the TimeSlots or rejects with an Error.
|
||||
* @param options The GetScheduleOptions to pass to the worker.
|
||||
* @returns A Promise resolving to TimeSlots.
|
||||
*/
|
||||
public async getAvailableSlotsInWorker(options: GetScheduleOptions): Promise<TimeSlots> {
|
||||
return new Promise<TimeSlots>((resolve, reject) => {
|
||||
this.taskQueue.push({
|
||||
resolve,
|
||||
reject,
|
||||
options,
|
||||
});
|
||||
this.processNextTask(); // Attempt to process immediately
|
||||
});
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
this.logger.log("Terminating worker pool...");
|
||||
for (const worker of this.workerPool) {
|
||||
worker.terminate();
|
||||
}
|
||||
this.logger.log("Worker pool terminated.");
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,19 @@ import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_0
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { SlotsController_2024_04_15 } from "@/modules/slots/slots-2024-04-15/controllers/slots.controller";
|
||||
import { SlotsOutputService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
|
||||
import { SlotsWorkerService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots-worker.service";
|
||||
import { SlotsService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots.service";
|
||||
import { SlotsRepository_2024_04_15 } from "@/modules/slots/slots-2024-04-15/slots.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, EventTypesModule_2024_04_15],
|
||||
providers: [SlotsRepository_2024_04_15, SlotsService_2024_04_15, SlotsOutputService_2024_04_15],
|
||||
providers: [
|
||||
SlotsRepository_2024_04_15,
|
||||
SlotsService_2024_04_15,
|
||||
SlotsOutputService_2024_04_15,
|
||||
SlotsWorkerService_2024_04_15,
|
||||
],
|
||||
controllers: [SlotsController_2024_04_15],
|
||||
exports: [SlotsService_2024_04_15],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { parentPort } from "worker_threads";
|
||||
|
||||
import { getAvailableSlots } from "@calcom/platform-libraries/slots";
|
||||
|
||||
parentPort?.on("message", async (data) => {
|
||||
try {
|
||||
const { input, ctx } = data;
|
||||
const result = await getAvailableSlots({ input, ctx });
|
||||
parentPort?.postMessage({ success: true, data: result });
|
||||
} catch (error) {
|
||||
parentPort?.postMessage({
|
||||
success: false,
|
||||
error: error instanceof Error ? error : new Error("Unknown error"),
|
||||
});
|
||||
}
|
||||
});
|
||||
+343
@@ -0,0 +1,343 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
|
||||
import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import {
|
||||
expectedSlotsUTC,
|
||||
expectedSlotsUTCRange,
|
||||
} from "@/modules/slots/slots-2024-09-04/controllers/e2e/expected-slots";
|
||||
import { GetSlotsOutput_2024_09_04 } from "@/modules/slots/slots-2024-09-04/outputs/get-slots.output";
|
||||
import { SlotsModule_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.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, Booking } 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 { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_09_04 } from "@calcom/platform-constants";
|
||||
import { CreateScheduleInput_2024_06_11 } from "@calcom/platform-types";
|
||||
|
||||
describe("Slots 2024-09-04 Endpoints", () => {
|
||||
describe("rescheduleUid functionality", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let schedulesService: SchedulesService_2024_06_11;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||
|
||||
const userEmail = `slots-reschedule-uid-${randomString()}@example.com`;
|
||||
const bookedStartTime = "2050-09-05T11:00:00.000Z";
|
||||
let user: User;
|
||||
let eventTypeId: number;
|
||||
let eventTypeSlug: string;
|
||||
let existingBooking: Booking;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [
|
||||
AppModule,
|
||||
PrismaModule,
|
||||
UsersModule,
|
||||
TokensModule,
|
||||
SchedulesModule_2024_06_11,
|
||||
SlotsModule_2024_09_04,
|
||||
],
|
||||
})
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_06_11>(SchedulesService_2024_06_11);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
name: userEmail,
|
||||
username: userEmail,
|
||||
});
|
||||
|
||||
const userSchedule: CreateScheduleInput_2024_06_11 = {
|
||||
name: `reschedule-uid-schedule-${randomString()}`,
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
};
|
||||
// note(Lauris): this creates default schedule monday to friday from 9AM to 5PM in Europe/Rome timezone
|
||||
await schedulesService.createUserSchedule(user.id, userSchedule);
|
||||
|
||||
const event = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: "reschedule test meeting",
|
||||
slug: `reschedule-uid-event-type-${randomString()}`,
|
||||
length: 60,
|
||||
},
|
||||
user.id
|
||||
);
|
||||
eventTypeId = event.id;
|
||||
eventTypeSlug = event.slug;
|
||||
|
||||
// Create an existing booking that will be used for reschedule testing
|
||||
existingBooking = await bookingsRepositoryFixture.create({
|
||||
uid: `reschedule-booking-uid-${randomString()}`,
|
||||
title: "existing booking for reschedule",
|
||||
startTime: bookedStartTime,
|
||||
endTime: "2050-09-05T12:00:00.000Z",
|
||||
eventType: {
|
||||
connect: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
},
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "reschedule tester",
|
||||
email: "reschedule@example.com",
|
||||
guests: [],
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
describe("bookingUidToReschedule parameter validation", () => {
|
||||
it("should accept bookingUidToReschedule as optional string parameter", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/slots?eventTypeId=${eventTypeId}&start=2050-09-05&end=2050-09-09&bookingUidToReschedule=${existingBooking.uid}`
|
||||
)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
});
|
||||
|
||||
it("should accept numeric bookingUidToReschedule parameter and convert to string", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/slots?eventTypeId=${eventTypeId}&start=2050-09-05&end=2050-09-09&bookingUidToReschedule=12345`
|
||||
)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(200);
|
||||
|
||||
// Note: The API accepts numeric bookingUidToReschedule and converts it to string
|
||||
// This is expected behavior for query parameters
|
||||
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
});
|
||||
|
||||
it("should handle empty bookingUidToReschedule parameter gracefully", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/slots?eventTypeId=${eventTypeId}&start=2050-09-05&end=2050-09-09&bookingUidToReschedule=`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bookingUidToReschedule slot availability behavior", () => {
|
||||
it("should exclude booked slot when bookingUidToReschedule is not provided", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/slots?eventTypeId=${eventTypeId}&start=2050-09-05&end=2050-09-09`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const slots = responseBody.data;
|
||||
|
||||
expect(slots).toBeDefined();
|
||||
const days = Object.keys(slots);
|
||||
expect(days.length).toEqual(5);
|
||||
|
||||
// Check that the booked slot time is NOT available
|
||||
const slotsForBookedDay = slots["2050-09-05"];
|
||||
expect(slotsForBookedDay).toBeDefined();
|
||||
|
||||
// Verify the booked slot is excluded
|
||||
const bookedSlotExists = slotsForBookedDay.some((slot: any) => slot.start === bookedStartTime);
|
||||
expect(bookedSlotExists).toBe(false);
|
||||
|
||||
// Verify we still have slots for that day (just not the booked one)
|
||||
expect(slotsForBookedDay.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should include booked slot when matching bookingUidToReschedule is provided", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/slots?eventTypeId=${eventTypeId}&start=2050-09-05&end=2050-09-09&bookingUidToReschedule=${existingBooking.uid}`
|
||||
)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const slots = responseBody.data;
|
||||
|
||||
expect(slots).toBeDefined();
|
||||
const days = Object.keys(slots);
|
||||
expect(days.length).toEqual(5);
|
||||
|
||||
// Check that the booked slot time IS available when rescheduling
|
||||
const slotsForBookedDay = slots["2050-09-05"];
|
||||
expect(slotsForBookedDay).toBeDefined();
|
||||
|
||||
// Verify the booked slot is now included due to bookingUidToReschedule
|
||||
const bookedSlotExists = slotsForBookedDay.some((slot: any) => slot.start === bookedStartTime);
|
||||
expect(bookedSlotExists).toBe(true);
|
||||
});
|
||||
|
||||
it("should work with non-existent bookingUidToReschedule without errors", async () => {
|
||||
const nonExistentUid = `non-existent-${randomString()}`;
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/slots?eventTypeId=${eventTypeId}&start=2050-09-05&end=2050-09-09&bookingUidToReschedule=${nonExistentUid}`
|
||||
)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const slots = responseBody.data;
|
||||
|
||||
expect(slots).toBeDefined();
|
||||
|
||||
// Should behave like normal slots query when bookingUidToReschedule doesn't match any booking
|
||||
const slotsForBookedDay = slots["2050-09-05"];
|
||||
expect(slotsForBookedDay).toBeDefined();
|
||||
|
||||
// Verify the booked slot is excluded (same as without bookingUidToReschedule)
|
||||
const bookedSlotExists = slotsForBookedDay.some((slot: any) => slot.start === bookedStartTime);
|
||||
expect(bookedSlotExists).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bookingUidToReschedule with different query types", () => {
|
||||
it("should work with bookingUidToReschedule using event type slug query", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/slots?eventTypeSlug=${eventTypeSlug}&username=${user.username}&start=2050-09-05&end=2050-09-09&bookingUidToReschedule=${existingBooking.uid}`
|
||||
)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const slots = responseBody.data;
|
||||
|
||||
expect(slots).toBeDefined();
|
||||
expect(slots).toEqual(expectedSlotsUTC);
|
||||
});
|
||||
|
||||
it("should work with bookingUidToReschedule in range format", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/slots?eventTypeId=${eventTypeId}&start=2050-09-05&end=2050-09-09&format=range&bookingUidToReschedule=${existingBooking.uid}`
|
||||
)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const slots = responseBody.data;
|
||||
|
||||
expect(slots).toBeDefined();
|
||||
expect(slots).toEqual(expectedSlotsUTCRange);
|
||||
|
||||
// Verify range format structure
|
||||
const daySlots = slots["2050-09-05"];
|
||||
if (daySlots && daySlots.length > 0) {
|
||||
expect(daySlots[0]).toHaveProperty("start");
|
||||
expect(daySlots[0]).toHaveProperty("end");
|
||||
}
|
||||
});
|
||||
|
||||
it("should work with bookingUidToReschedule and timezone parameter", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/slots?eventTypeId=${eventTypeId}&start=2050-09-05&end=2050-09-09&timeZone=Europe/Rome&bookingUidToReschedule=${existingBooking.uid}`
|
||||
)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const slots = responseBody.data;
|
||||
|
||||
expect(slots).toBeDefined();
|
||||
const days = Object.keys(slots);
|
||||
expect(days.length).toEqual(5);
|
||||
|
||||
// Verify timezone conversion works with bookingUidToReschedule
|
||||
const daySlots = slots["2050-09-05"];
|
||||
if (daySlots && daySlots.length > 0) {
|
||||
// Should contain timezone info (+02:00 for Europe/Rome)
|
||||
expect(daySlots[0].start).toContain("+02:00");
|
||||
|
||||
// Verify rescheduleUid functionality: the booked slot should be available
|
||||
const bookedSlotTimeRome = "2050-09-05T13:00:00.000+02:00"; // 11:00 UTC = 13:00 Rome
|
||||
const bookedSlotExists = daySlots.some((slot: any) => slot.start === bookedSlotTimeRome);
|
||||
expect(bookedSlotExists).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("bookingUidToReschedule edge cases", () => {
|
||||
it("should handle bookingUidToReschedule with special characters", async () => {
|
||||
const specialUid = `special-uid-${randomString()}-with-dashes`;
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/slots?eventTypeId=${eventTypeId}&start=2050-09-05&end=2050-09-09&bookingUidToReschedule=${specialUid}`
|
||||
)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
});
|
||||
|
||||
it("should handle very long bookingUidToReschedule", async () => {
|
||||
const longUid = `very-long-uid-${randomString()}-${"x".repeat(100)}`;
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/slots?eventTypeId=${eventTypeId}&start=2050-09-05&end=2050-09-09&bookingUidToReschedule=${longUid}`
|
||||
)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up test data
|
||||
await bookingsRepositoryFixture.deleteById(existingBooking.id);
|
||||
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
|
||||
await userRepositoryFixture.deleteByEmail(userEmail);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user