Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e2cff0fd5 | ||
|
|
54e518efad | ||
|
|
ad93099171 | ||
|
|
630065ca89 | ||
|
|
ddf5717992 | ||
|
|
4a53ec0b74 | ||
|
|
d7fd055171 | ||
|
|
874973d78f | ||
|
|
8078c5657b | ||
|
|
b39404860f | ||
|
|
860eae8dcb | ||
|
|
b931d3ee13 | ||
|
|
ba8a71bf3a | ||
|
|
8de4897042 | ||
|
|
6de6f8e697 | ||
|
|
e55ecff899 | ||
|
|
3895ef002e | ||
|
|
afc28503a5 | ||
|
|
e6497c87ca |
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/prisma"
|
||||
@@ -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.214",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.222",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
|
||||
@@ -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({
|
||||
@@ -195,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, {
|
||||
@@ -428,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 {
|
||||
@@ -440,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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@ type OAuthRequestParams = {
|
||||
platformBookingUrl: string;
|
||||
platformBookingLocation?: string;
|
||||
arePlatformEmailsEnabled: boolean;
|
||||
areCalendarEventsEnabled: boolean;
|
||||
};
|
||||
|
||||
export enum Frequency {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
+44
@@ -962,6 +962,10 @@ describe("Event types Endpoints", () => {
|
||||
title: newTitle,
|
||||
scheduleId: secondSchedule.id,
|
||||
lengthInMinutesOptions: [15, 30],
|
||||
calVideoSettings: {
|
||||
disableRecordingForGuests: true,
|
||||
disableRecordingForOrganizer: true,
|
||||
},
|
||||
bookingFields: [
|
||||
nameBookingField,
|
||||
{
|
||||
@@ -1082,6 +1086,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 +1851,40 @@ 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;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -12,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,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -71,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 },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -15,7 +15,7 @@ import {
|
||||
BookingFieldSchema,
|
||||
} from "@/ee/event-types/event-types_2024_06_14/transformers";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { EventType, User, Schedule, DestinationCalendar } from "@prisma/client";
|
||||
import type { EventType, User, Schedule, DestinationCalendar, CalVideoSettings } from "@prisma/client";
|
||||
|
||||
import {
|
||||
userMetadata,
|
||||
@@ -37,6 +37,7 @@ type EventTypeRelations = {
|
||||
users: User[];
|
||||
schedule: Schedule | null;
|
||||
destinationCalendar?: DestinationCalendar | null;
|
||||
calVideoSettings?: CalVideoSettings | null;
|
||||
};
|
||||
export type DatabaseEventType = Omit<EventType, "allowReschedulingCancelledBookings"> & EventTypeRelations;
|
||||
|
||||
@@ -87,6 +88,7 @@ type Input = Pick<
|
||||
| "useEventTypeDestinationCalendarEmail"
|
||||
| "hideCalendarEventDetails"
|
||||
| "hideOrganizerEmail"
|
||||
| "calVideoSettings"
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
@@ -124,6 +126,7 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
hideCalendarEventDetails,
|
||||
hideOrganizerEmail,
|
||||
calVideoSettings,
|
||||
} = databaseEventType;
|
||||
|
||||
const locations = this.transformLocations(databaseEventType.locations);
|
||||
@@ -199,6 +202,7 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
useDestinationCalendarEmail: useEventTypeDestinationCalendarEmail,
|
||||
hideCalendarEventDetails,
|
||||
hideOrganizerEmail,
|
||||
calVideoSettings,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
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",
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
+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 };
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ interface WorkerResult {
|
||||
|
||||
@Injectable()
|
||||
export class SlotsWorkerService_2024_04_15 implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(SlotsWorkerService_2024_04_15.name);
|
||||
private readonly logger = new Logger("SlotsWorkerService_2024_04_15");
|
||||
private readonly workerPool: Worker[] = [];
|
||||
private readonly maxWorkers: number;
|
||||
private readonly taskQueue: Array<{
|
||||
@@ -94,9 +94,27 @@ export class SlotsWorkerService_2024_04_15 implements OnModuleDestroy {
|
||||
*/
|
||||
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();
|
||||
|
||||
+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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -83,6 +83,7 @@ export class SlotsController_2024_09_04 {
|
||||
- timeZone: Time zone in which the available slots should be returned. Defaults to UTC.
|
||||
- duration: Only use for event types that allow multiple durations or for dynamic event types. If not passed for multiple duration event types defaults to default duration. For dynamic event types defaults to 30 aka each returned slot is 30 minutes long. So duration=60 means that returned slots will be each 60 minutes long.
|
||||
- format: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use "range" as value.
|
||||
- bookingUidToReschedule: When rescheduling an existing booking, provide the booking's unique identifier to exclude its time slot from busy time calculations. This ensures the original booking time appears as available for rescheduling.
|
||||
`,
|
||||
})
|
||||
@ApiQuery({
|
||||
@@ -173,6 +174,13 @@ export class SlotsController_2024_09_04 {
|
||||
2024-08-13 (will have hours 00:00:00 aka at very beginning of the date) or you can specify hours manually like 2024-08-13T09:00:00Z.`,
|
||||
example: "2050-09-05",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "bookingUidToReschedule",
|
||||
required: false,
|
||||
description:
|
||||
"The unique identifier of the booking being rescheduled. When provided will ensure that the original booking time appears within the returned available slots when rescheduling.",
|
||||
example: "abc123def456",
|
||||
})
|
||||
@DocsResponse({
|
||||
status: 200,
|
||||
description: `A map of available slots indexed by date, where each date is associated with an array of time slots. If format=range is specified, each slot will be an object with start and end properties denoting start and end of the slot.
|
||||
|
||||
@@ -46,6 +46,7 @@ export class SlotsInputService_2024_09_04 {
|
||||
const usernameList = "usernames" in query ? query.usernames : [];
|
||||
const timeZone = query.timeZone;
|
||||
const orgSlug = "organizationSlug" in query ? query.organizationSlug : null;
|
||||
const rescheduleUid = query.bookingUidToReschedule || null;
|
||||
|
||||
return {
|
||||
isTeamEvent,
|
||||
@@ -57,6 +58,7 @@ export class SlotsInputService_2024_09_04 {
|
||||
usernameList,
|
||||
timeZone,
|
||||
orgSlug,
|
||||
rescheduleUid,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,13 @@ export class TeamsEventTypesRepository {
|
||||
id: eventTypeId,
|
||||
teamId,
|
||||
},
|
||||
include: { users: true, schedule: true, hosts: true, destinationCalendar: true },
|
||||
include: {
|
||||
users: true,
|
||||
schedule: true,
|
||||
hosts: true,
|
||||
destinationCalendar: true,
|
||||
calVideoSettings: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -33,6 +39,7 @@ export class TeamsEventTypesRepository {
|
||||
}
|
||||
: true,
|
||||
destinationCalendar: true,
|
||||
calVideoSettings: true,
|
||||
team: {
|
||||
select: {
|
||||
bannerUrl: true,
|
||||
@@ -82,6 +89,7 @@ export class TeamsEventTypesRepository {
|
||||
schedule: true,
|
||||
hosts: true,
|
||||
destinationCalendar: true,
|
||||
calVideoSettings: true,
|
||||
team: {
|
||||
select: {
|
||||
bannerUrl: true,
|
||||
@@ -101,7 +109,13 @@ export class TeamsEventTypesRepository {
|
||||
async getEventTypeById(eventTypeId: number) {
|
||||
return this.dbRead.prisma.eventType.findUnique({
|
||||
where: { id: eventTypeId },
|
||||
include: { users: true, schedule: true, hosts: true, destinationCalendar: true },
|
||||
include: {
|
||||
users: true,
|
||||
schedule: true,
|
||||
hosts: true,
|
||||
destinationCalendar: true,
|
||||
calVideoSettings: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsBoolean,
|
||||
ArrayMinSize,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
ValidateIf,
|
||||
} from "class-validator";
|
||||
|
||||
import {
|
||||
BaseWorkflowStepDto,
|
||||
EMAIL_ADDRESS,
|
||||
EMAIL_ATTENDEE,
|
||||
EMAIL_HOST,
|
||||
SMS_ATTENDEE,
|
||||
SMS_NUMBER,
|
||||
WHATSAPP_ATTENDEE,
|
||||
WHATSAPP_NUMBER,
|
||||
WorkflowEmailAddressStepDto,
|
||||
WorkflowEmailAttendeeStepDto,
|
||||
WorkflowEmailHostStepDto,
|
||||
WorkflowPhoneAttendeeStepDto,
|
||||
WorkflowPhoneNumberStepDto,
|
||||
WorkflowPhoneWhatsAppAttendeeStepDto,
|
||||
WorkflowPhoneWhatsAppNumberStepDto,
|
||||
} from "./workflow-step.input";
|
||||
import {
|
||||
AFTER_EVENT,
|
||||
AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
|
||||
AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
|
||||
BaseWorkflowTriggerDto,
|
||||
BEFORE_EVENT,
|
||||
EVENT_CANCELLED,
|
||||
NEW_EVENT,
|
||||
OnAfterCalVideoGuestsNoShowTriggerDto,
|
||||
OnAfterCalVideoHostsNoShowTriggerDto,
|
||||
OnAfterEventTriggerDto,
|
||||
OnBeforeEventTriggerDto,
|
||||
OnCancelTriggerDto,
|
||||
OnCreationTriggerDto,
|
||||
OnRescheduleTriggerDto,
|
||||
RESCHEDULE_EVENT,
|
||||
} from "./workflow-trigger.input";
|
||||
|
||||
export class WorkflowActivationDto {
|
||||
@ApiProperty({
|
||||
description: "Whether the workflow is active for all the event-types",
|
||||
example: false,
|
||||
type: Boolean,
|
||||
})
|
||||
@IsBoolean()
|
||||
isActiveOnAllEventTypes = false;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"List of event-types IDs the workflow applies to, required if isActiveOnAllEventTypes is false",
|
||||
example: [698191],
|
||||
type: [Number],
|
||||
})
|
||||
@ValidateIf((o) => !Boolean(o.isActiveOnAllEventTypes))
|
||||
@IsOptional()
|
||||
@IsNumber({}, { each: true })
|
||||
activeOnEventTypeIds: number[] = [];
|
||||
}
|
||||
|
||||
export type TriggerDtoType =
|
||||
| OnAfterEventTriggerDto
|
||||
| OnBeforeEventTriggerDto
|
||||
| OnCreationTriggerDto
|
||||
| OnRescheduleTriggerDto
|
||||
| OnCancelTriggerDto
|
||||
| OnAfterCalVideoGuestsNoShowTriggerDto
|
||||
| OnAfterCalVideoHostsNoShowTriggerDto;
|
||||
export class CreateWorkflowDto {
|
||||
@ApiProperty({ description: "Name of the workflow", example: "Platform Test Workflow" })
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ description: "Activation settings for the workflow", type: WorkflowActivationDto })
|
||||
@ValidateNested()
|
||||
@Type(() => WorkflowActivationDto)
|
||||
activation!: WorkflowActivationDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Trigger configuration for the workflow",
|
||||
items: {
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(OnBeforeEventTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnAfterEventTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnCancelTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnCreationTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnRescheduleTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnAfterCalVideoGuestsNoShowTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnAfterCalVideoHostsNoShowTriggerDto) },
|
||||
],
|
||||
},
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => BaseWorkflowTriggerDto, {
|
||||
discriminator: {
|
||||
property: "type",
|
||||
subTypes: [
|
||||
{ value: OnBeforeEventTriggerDto, name: BEFORE_EVENT },
|
||||
{ value: OnAfterEventTriggerDto, name: AFTER_EVENT },
|
||||
{ value: OnCancelTriggerDto, name: EVENT_CANCELLED },
|
||||
{ value: OnCreationTriggerDto, name: NEW_EVENT },
|
||||
{ value: OnRescheduleTriggerDto, name: RESCHEDULE_EVENT },
|
||||
{ value: OnAfterCalVideoGuestsNoShowTriggerDto, name: AFTER_GUESTS_CAL_VIDEO_NO_SHOW },
|
||||
{ value: OnAfterCalVideoHostsNoShowTriggerDto, name: AFTER_HOSTS_CAL_VIDEO_NO_SHOW },
|
||||
],
|
||||
},
|
||||
})
|
||||
trigger!:
|
||||
| OnAfterEventTriggerDto
|
||||
| OnBeforeEventTriggerDto
|
||||
| OnCreationTriggerDto
|
||||
| OnRescheduleTriggerDto
|
||||
| OnCancelTriggerDto
|
||||
| OnAfterCalVideoGuestsNoShowTriggerDto
|
||||
| OnAfterCalVideoHostsNoShowTriggerDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Steps to execute as part of the workflow",
|
||||
items: {
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(WorkflowEmailAddressStepDto) },
|
||||
{ $ref: getSchemaPath(WorkflowEmailAttendeeStepDto) },
|
||||
{ $ref: getSchemaPath(WorkflowEmailHostStepDto) },
|
||||
{ $ref: getSchemaPath(WorkflowPhoneWhatsAppAttendeeStepDto) },
|
||||
{ $ref: getSchemaPath(WorkflowPhoneWhatsAppNumberStepDto) },
|
||||
{ $ref: getSchemaPath(WorkflowPhoneNumberStepDto) },
|
||||
{ $ref: getSchemaPath(WorkflowPhoneAttendeeStepDto) },
|
||||
],
|
||||
},
|
||||
})
|
||||
@ValidateNested({ each: true })
|
||||
@ArrayMinSize(1, { message: "Your workflow must contain at least one step." })
|
||||
@Type(() => BaseWorkflowStepDto, {
|
||||
discriminator: {
|
||||
property: "action",
|
||||
subTypes: [
|
||||
{ value: WorkflowEmailAddressStepDto, name: EMAIL_ADDRESS },
|
||||
{ value: WorkflowEmailAttendeeStepDto, name: EMAIL_ATTENDEE },
|
||||
{ value: WorkflowEmailHostStepDto, name: EMAIL_HOST },
|
||||
{ value: WorkflowPhoneWhatsAppAttendeeStepDto, name: WHATSAPP_ATTENDEE },
|
||||
{ value: WorkflowPhoneWhatsAppNumberStepDto, name: WHATSAPP_NUMBER },
|
||||
{ value: WorkflowPhoneNumberStepDto, name: SMS_NUMBER },
|
||||
{ value: WorkflowPhoneAttendeeStepDto, name: SMS_ATTENDEE },
|
||||
],
|
||||
},
|
||||
})
|
||||
steps!: (
|
||||
| WorkflowEmailAddressStepDto
|
||||
| WorkflowEmailAttendeeStepDto
|
||||
| WorkflowEmailHostStepDto
|
||||
| WorkflowPhoneWhatsAppAttendeeStepDto
|
||||
| WorkflowPhoneWhatsAppNumberStepDto
|
||||
| WorkflowPhoneNumberStepDto
|
||||
| WorkflowPhoneAttendeeStepDto
|
||||
)[];
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsNumber, IsString, IsOptional, ValidateNested, ArrayMinSize } from "class-validator";
|
||||
|
||||
import { WorkflowActivationDto } from "./create-workflow.input";
|
||||
import {
|
||||
WorkflowEmailAttendeeStepDto,
|
||||
WorkflowEmailAddressStepDto,
|
||||
WorkflowEmailHostStepDto,
|
||||
WorkflowPhoneWhatsAppNumberStepDto,
|
||||
WorkflowPhoneAttendeeStepDto,
|
||||
WorkflowPhoneNumberStepDto,
|
||||
WorkflowPhoneWhatsAppAttendeeStepDto,
|
||||
BaseWorkflowStepDto,
|
||||
EMAIL_ADDRESS,
|
||||
EMAIL_ATTENDEE,
|
||||
EMAIL_HOST,
|
||||
WHATSAPP_ATTENDEE,
|
||||
WHATSAPP_NUMBER,
|
||||
SMS_NUMBER,
|
||||
SMS_ATTENDEE,
|
||||
} from "./workflow-step.input";
|
||||
import {
|
||||
BaseWorkflowTriggerDto,
|
||||
OnBeforeEventTriggerDto,
|
||||
BEFORE_EVENT,
|
||||
OnAfterEventTriggerDto,
|
||||
AFTER_EVENT,
|
||||
OnCancelTriggerDto,
|
||||
EVENT_CANCELLED,
|
||||
OnCreationTriggerDto,
|
||||
NEW_EVENT,
|
||||
OnRescheduleTriggerDto,
|
||||
RESCHEDULE_EVENT,
|
||||
OnAfterCalVideoGuestsNoShowTriggerDto,
|
||||
AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
|
||||
OnAfterCalVideoHostsNoShowTriggerDto,
|
||||
AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
|
||||
} from "./workflow-trigger.input";
|
||||
|
||||
export type UpdateWorkflowStepDto =
|
||||
| UpdateEmailAttendeeWorkflowStepDto
|
||||
| UpdateEmailAddressWorkflowStepDto
|
||||
| UpdateEmailHostWorkflowStepDto
|
||||
| UpdateWhatsAppAttendeePhoneWorkflowStepDto
|
||||
| UpdatePhoneWhatsAppNumberWorkflowStepDto
|
||||
| UpdatePhoneAttendeeWorkflowStepDto
|
||||
| UpdatePhoneNumberWorkflowStepDto;
|
||||
export class UpdateEmailAttendeeWorkflowStepDto extends WorkflowEmailAttendeeStepDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
|
||||
example: 67244,
|
||||
})
|
||||
@IsNumber()
|
||||
id?: number;
|
||||
}
|
||||
|
||||
export class UpdateEmailAddressWorkflowStepDto extends WorkflowEmailAddressStepDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
|
||||
example: 67244,
|
||||
})
|
||||
@IsNumber()
|
||||
id?: number;
|
||||
}
|
||||
|
||||
export class UpdateEmailHostWorkflowStepDto extends WorkflowEmailHostStepDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
|
||||
example: 67244,
|
||||
})
|
||||
@IsNumber()
|
||||
id?: number;
|
||||
}
|
||||
|
||||
export class UpdatePhoneWhatsAppNumberWorkflowStepDto extends WorkflowPhoneWhatsAppNumberStepDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
|
||||
example: 67244,
|
||||
})
|
||||
@IsNumber()
|
||||
id?: number;
|
||||
}
|
||||
export class UpdatePhoneAttendeeWorkflowStepDto extends WorkflowPhoneAttendeeStepDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
|
||||
example: 67244,
|
||||
})
|
||||
@IsNumber()
|
||||
id?: number;
|
||||
}
|
||||
export class UpdatePhoneNumberWorkflowStepDto extends WorkflowPhoneNumberStepDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
|
||||
example: 67244,
|
||||
})
|
||||
@IsNumber()
|
||||
id?: number;
|
||||
}
|
||||
export class UpdateWhatsAppAttendeePhoneWorkflowStepDto extends WorkflowPhoneWhatsAppAttendeeStepDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
|
||||
example: 67244,
|
||||
})
|
||||
@IsNumber()
|
||||
id?: number;
|
||||
}
|
||||
|
||||
export class UpdateWorkflowDto {
|
||||
@ApiPropertyOptional({ description: "Name of the workflow", example: "Platform Test Workflow" })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Activation settings for the workflow, the action that will trigger the workflow.",
|
||||
type: WorkflowActivationDto,
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => WorkflowActivationDto)
|
||||
@IsOptional()
|
||||
activation?: WorkflowActivationDto;
|
||||
|
||||
@Type(() => BaseWorkflowTriggerDto, {
|
||||
discriminator: {
|
||||
property: "type",
|
||||
subTypes: [
|
||||
{ value: OnBeforeEventTriggerDto, name: BEFORE_EVENT },
|
||||
{ value: OnAfterEventTriggerDto, name: AFTER_EVENT },
|
||||
{ value: OnCancelTriggerDto, name: EVENT_CANCELLED },
|
||||
{ value: OnCreationTriggerDto, name: NEW_EVENT },
|
||||
{ value: OnRescheduleTriggerDto, name: RESCHEDULE_EVENT },
|
||||
{ value: OnAfterCalVideoGuestsNoShowTriggerDto, name: AFTER_GUESTS_CAL_VIDEO_NO_SHOW },
|
||||
{ value: OnAfterCalVideoHostsNoShowTriggerDto, name: AFTER_HOSTS_CAL_VIDEO_NO_SHOW },
|
||||
],
|
||||
},
|
||||
})
|
||||
trigger!:
|
||||
| OnAfterEventTriggerDto
|
||||
| OnBeforeEventTriggerDto
|
||||
| OnCreationTriggerDto
|
||||
| OnRescheduleTriggerDto
|
||||
| OnCancelTriggerDto
|
||||
| OnAfterCalVideoGuestsNoShowTriggerDto
|
||||
| OnAfterCalVideoHostsNoShowTriggerDto;
|
||||
@ApiPropertyOptional({
|
||||
description: "Steps to execute as part of the workflow",
|
||||
items: {
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(UpdateEmailAddressWorkflowStepDto) },
|
||||
{ $ref: getSchemaPath(UpdateEmailAttendeeWorkflowStepDto) },
|
||||
{ $ref: getSchemaPath(UpdateEmailHostWorkflowStepDto) },
|
||||
{ $ref: getSchemaPath(UpdatePhoneAttendeeWorkflowStepDto) },
|
||||
{ $ref: getSchemaPath(UpdatePhoneWhatsAppNumberWorkflowStepDto) },
|
||||
{ $ref: getSchemaPath(UpdateWhatsAppAttendeePhoneWorkflowStepDto) },
|
||||
{ $ref: getSchemaPath(UpdatePhoneNumberWorkflowStepDto) },
|
||||
],
|
||||
},
|
||||
})
|
||||
@ValidateNested({ each: true })
|
||||
@ArrayMinSize(1, { message: "Your workflow must contain at least one step." })
|
||||
@IsOptional()
|
||||
@Type(() => BaseWorkflowStepDto, {
|
||||
discriminator: {
|
||||
property: "action",
|
||||
subTypes: [
|
||||
{ value: UpdateEmailAddressWorkflowStepDto, name: EMAIL_ADDRESS },
|
||||
{ value: UpdateEmailAttendeeWorkflowStepDto, name: EMAIL_ATTENDEE },
|
||||
{ value: UpdateEmailHostWorkflowStepDto, name: EMAIL_HOST },
|
||||
{ value: UpdateWhatsAppAttendeePhoneWorkflowStepDto, name: WHATSAPP_ATTENDEE },
|
||||
{ value: UpdatePhoneWhatsAppNumberWorkflowStepDto, name: WHATSAPP_NUMBER },
|
||||
{ value: UpdatePhoneNumberWorkflowStepDto, name: SMS_NUMBER },
|
||||
{ value: UpdatePhoneAttendeeWorkflowStepDto, name: SMS_ATTENDEE },
|
||||
],
|
||||
},
|
||||
})
|
||||
steps?: (
|
||||
| UpdateEmailAddressWorkflowStepDto
|
||||
| UpdateEmailAttendeeWorkflowStepDto
|
||||
| UpdateEmailHostWorkflowStepDto
|
||||
| UpdatePhoneAttendeeWorkflowStepDto
|
||||
| UpdatePhoneWhatsAppNumberWorkflowStepDto
|
||||
| UpdateWhatsAppAttendeePhoneWorkflowStepDto
|
||||
| UpdatePhoneNumberWorkflowStepDto
|
||||
)[];
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { WorkflowActions, WorkflowTemplates } from "@prisma/client";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsNumber, IsBoolean, IsString, ValidateNested, IsIn } from "class-validator";
|
||||
|
||||
export const EMAIL_HOST = "email_host";
|
||||
export const EMAIL_ATTENDEE = "email_attendee";
|
||||
export const EMAIL_ADDRESS = "email_address";
|
||||
export const SMS_ATTENDEE = "sms_attendee";
|
||||
export const SMS_NUMBER = "sms_number";
|
||||
export const WHATSAPP_ATTENDEE = "whatsapp_attendee";
|
||||
export const WHATSAPP_NUMBER = "whatsapp_number";
|
||||
|
||||
export const STEP_ACTIONS = [
|
||||
EMAIL_HOST,
|
||||
EMAIL_ATTENDEE,
|
||||
EMAIL_ADDRESS,
|
||||
SMS_ATTENDEE,
|
||||
SMS_NUMBER,
|
||||
WHATSAPP_ATTENDEE,
|
||||
WHATSAPP_NUMBER,
|
||||
] as const;
|
||||
|
||||
export const STEP_ACTIONS_TO_ENUM = {
|
||||
[EMAIL_HOST]: WorkflowActions.EMAIL_HOST,
|
||||
[EMAIL_ATTENDEE]: WorkflowActions.EMAIL_ATTENDEE,
|
||||
[EMAIL_ADDRESS]: WorkflowActions.EMAIL_ADDRESS,
|
||||
[SMS_ATTENDEE]: WorkflowActions.SMS_ATTENDEE,
|
||||
[WHATSAPP_ATTENDEE]: WorkflowActions.WHATSAPP_ATTENDEE,
|
||||
[WHATSAPP_NUMBER]: WorkflowActions.WHATSAPP_NUMBER,
|
||||
[SMS_NUMBER]: WorkflowActions.SMS_NUMBER,
|
||||
} as const;
|
||||
|
||||
export const ENUM_TO_STEP_ACTIONS = {
|
||||
[WorkflowActions.EMAIL_HOST]: EMAIL_HOST,
|
||||
[WorkflowActions.EMAIL_ATTENDEE]: EMAIL_ATTENDEE,
|
||||
[WorkflowActions.EMAIL_ADDRESS]: EMAIL_ADDRESS,
|
||||
[WorkflowActions.SMS_ATTENDEE]: SMS_ATTENDEE,
|
||||
[WorkflowActions.WHATSAPP_ATTENDEE]: WHATSAPP_ATTENDEE,
|
||||
[WorkflowActions.WHATSAPP_NUMBER]: WHATSAPP_NUMBER,
|
||||
[WorkflowActions.SMS_NUMBER]: SMS_NUMBER,
|
||||
} as const;
|
||||
|
||||
export type StepAction = (typeof STEP_ACTIONS)[number];
|
||||
export type StepActionsType = (typeof STEP_ACTIONS)[number];
|
||||
|
||||
export const REMINDER = "reminder";
|
||||
export const CUSTOM = "custom";
|
||||
export const CANCELLED = "cancelled";
|
||||
export const RESCHEDULED = "rescheduled";
|
||||
export const COMPLETED = "completed";
|
||||
export const RATING = "rating";
|
||||
export const TEMPLATES = [REMINDER, CUSTOM, RESCHEDULED, COMPLETED, RATING, CANCELLED] as const;
|
||||
export const TEMPLATES_TO_ENUM = {
|
||||
[WorkflowTemplates.REMINDER]: REMINDER,
|
||||
[CUSTOM]: WorkflowTemplates.CUSTOM,
|
||||
[RESCHEDULED]: WorkflowTemplates.RESCHEDULED,
|
||||
[CANCELLED]: WorkflowTemplates.CANCELLED,
|
||||
[COMPLETED]: WorkflowTemplates.COMPLETED,
|
||||
[RATING]: WorkflowTemplates.RATING,
|
||||
} as const;
|
||||
|
||||
export const ENUM_TO_TEMPLATES = {
|
||||
[WorkflowTemplates.REMINDER]: REMINDER,
|
||||
[WorkflowTemplates.CUSTOM]: CUSTOM,
|
||||
[WorkflowTemplates.RESCHEDULED]: RESCHEDULED,
|
||||
[WorkflowTemplates.CANCELLED]: CANCELLED,
|
||||
[WorkflowTemplates.COMPLETED]: COMPLETED,
|
||||
[WorkflowTemplates.RATING]: RATING,
|
||||
} as const;
|
||||
|
||||
export type TemplateType = (typeof TEMPLATES)[number];
|
||||
|
||||
export const HOST = "const";
|
||||
export const ATTENDEE = "attendee";
|
||||
export const EMAIL = "email";
|
||||
export const PHONE_NUMBER = "phone_number";
|
||||
|
||||
export const RECIPIENT_TYPES = [HOST, ATTENDEE, EMAIL, PHONE_NUMBER];
|
||||
export type RecipientType = (typeof RECIPIENT_TYPES)[number];
|
||||
|
||||
export class BaseWorkflowMessageDto {
|
||||
@ApiProperty({
|
||||
description: "Subject of the message",
|
||||
example: "Reminder: Your Meeting {EVENT_NAME} - {EVENT_DATE_ddd, MMM D, YYYY h:mma} with Cal.com",
|
||||
})
|
||||
@IsString()
|
||||
subject!: string;
|
||||
}
|
||||
|
||||
export class HtmlWorkflowMessageDto extends BaseWorkflowMessageDto {
|
||||
@ApiProperty({
|
||||
description: "HTML content of the message (used for Emails)",
|
||||
example:
|
||||
"<p>This is a reminder from {ORGANIZER} of {EVENT_NAME} to {ATTENDEE} starting here {LOCATION} {MEETING_URL} at {START_TIME_h:mma} {TIMEZONE}.</p>",
|
||||
})
|
||||
@IsString()
|
||||
html!: string;
|
||||
}
|
||||
|
||||
export class TextWorkflowMessageDto extends BaseWorkflowMessageDto {
|
||||
@ApiProperty({
|
||||
description: "Text content of the message (used for SMS)",
|
||||
example:
|
||||
"This is a reminder message from {ORGANIZER} of {EVENT_NAME} to {ATTENDEE} starting here {LOCATION} {MEETING_URL} at {START_TIME_h:mma} {TIMEZONE}.",
|
||||
})
|
||||
@IsString()
|
||||
text!: string;
|
||||
}
|
||||
|
||||
export class BaseWorkflowStepDto {
|
||||
@ApiProperty({ description: "Action to perform", example: EMAIL_HOST, enum: STEP_ACTIONS })
|
||||
@IsString()
|
||||
@IsIn(STEP_ACTIONS)
|
||||
action!: StepAction;
|
||||
|
||||
@ApiProperty({ description: "Step number in the workflow sequence", example: 1 })
|
||||
@IsNumber()
|
||||
stepNumber!: number;
|
||||
|
||||
@ApiProperty({ description: "Recipient type", example: ATTENDEE, enum: RECIPIENT_TYPES })
|
||||
recipient!: RecipientType;
|
||||
|
||||
@ApiProperty({ description: "Template type for the step", example: REMINDER, enum: TEMPLATES })
|
||||
template!: TemplateType;
|
||||
|
||||
@ApiProperty({ description: "Displayed sender name.", type: String })
|
||||
@IsString()
|
||||
sender!: string;
|
||||
}
|
||||
|
||||
export class WorkflowEmailHostStepDto extends BaseWorkflowStepDto {
|
||||
@ApiProperty({
|
||||
description: "Action to perform, send an email to the host of the event",
|
||||
example: EMAIL_HOST,
|
||||
enum: STEP_ACTIONS,
|
||||
})
|
||||
@IsString()
|
||||
@IsIn(STEP_ACTIONS)
|
||||
action: typeof EMAIL_HOST = EMAIL_HOST;
|
||||
|
||||
@ApiProperty({
|
||||
description: `Whether to include a calendar event in the notification, can be included with actions ${EMAIL_HOST}, ${EMAIL_ATTENDEE}, ${EMAIL_ADDRESS}`,
|
||||
example: true,
|
||||
})
|
||||
@IsBoolean()
|
||||
includeCalendarEvent = false;
|
||||
|
||||
@ApiProperty({ description: "Message content for this step", type: HtmlWorkflowMessageDto })
|
||||
@ValidateNested()
|
||||
@Type(() => HtmlWorkflowMessageDto)
|
||||
message!: HtmlWorkflowMessageDto;
|
||||
}
|
||||
|
||||
export class WorkflowEmailAddressStepDto extends BaseWorkflowStepDto {
|
||||
@ApiProperty({
|
||||
description: "Action to perform, send an email to a specific email address",
|
||||
example: EMAIL_ADDRESS,
|
||||
enum: STEP_ACTIONS,
|
||||
})
|
||||
@IsString()
|
||||
@IsIn(STEP_ACTIONS)
|
||||
action: typeof EMAIL_ADDRESS = EMAIL_ADDRESS;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Email address if recipient is EMAIL, required for action EMAIL_ADDRESS",
|
||||
example: "31214",
|
||||
externalDocs: {
|
||||
url: "https://cal.com/docs/api-reference/v2/organization-team-verified-resources/verify-an-email-for-an-org-team",
|
||||
},
|
||||
})
|
||||
@IsNumber()
|
||||
verifiedEmailId!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: `Whether to include a calendar event in the notification, can be included with actions ${EMAIL_HOST}, ${EMAIL_ATTENDEE}, ${EMAIL_ADDRESS}`,
|
||||
example: true,
|
||||
})
|
||||
@IsBoolean()
|
||||
includeCalendarEvent = false;
|
||||
|
||||
@ApiProperty({ description: "Message content for this step", type: HtmlWorkflowMessageDto })
|
||||
@ValidateNested()
|
||||
@Type(() => HtmlWorkflowMessageDto)
|
||||
message!: HtmlWorkflowMessageDto;
|
||||
}
|
||||
|
||||
export class WorkflowEmailAttendeeStepDto extends BaseWorkflowStepDto {
|
||||
@ApiProperty({
|
||||
description: "Action to perform, send an email to the attendees of the event",
|
||||
example: EMAIL_ATTENDEE,
|
||||
enum: STEP_ACTIONS,
|
||||
})
|
||||
@IsString()
|
||||
@IsIn(STEP_ACTIONS)
|
||||
action: typeof EMAIL_ATTENDEE = EMAIL_ATTENDEE;
|
||||
|
||||
@ApiProperty({
|
||||
description: `Whether to include a calendar event in the notification, can be included with actions ${EMAIL_HOST}, ${EMAIL_ATTENDEE}, ${EMAIL_ADDRESS}`,
|
||||
example: true,
|
||||
})
|
||||
@IsBoolean()
|
||||
includeCalendarEvent = false;
|
||||
|
||||
@ApiProperty({ description: "Message content for this step", type: HtmlWorkflowMessageDto })
|
||||
@ValidateNested()
|
||||
@Type(() => HtmlWorkflowMessageDto)
|
||||
message!: HtmlWorkflowMessageDto;
|
||||
}
|
||||
|
||||
export class WorkflowPhoneWhatsAppNumberStepDto extends BaseWorkflowStepDto {
|
||||
@ApiProperty({
|
||||
description: "Action to perform, send a text message via whatsapp to a specific phone number",
|
||||
example: WHATSAPP_NUMBER,
|
||||
})
|
||||
@IsString()
|
||||
@IsIn(STEP_ACTIONS)
|
||||
action: typeof WHATSAPP_NUMBER = WHATSAPP_NUMBER;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Phone number if recipient is PHONE_NUMBER, required for actions SMS_NUMBER and WHATSAPP_NUMBER",
|
||||
example: "3243434",
|
||||
externalDocs: {
|
||||
url: "https://cal.com/docs/api-reference/v2/organization-team-verified-resources/verify-a-phone-number-for-an-org-team",
|
||||
},
|
||||
})
|
||||
@IsNumber()
|
||||
verifiedPhoneId!: number;
|
||||
|
||||
@ApiProperty({ description: "Message content for this step", type: TextWorkflowMessageDto })
|
||||
@ValidateNested()
|
||||
@Type(() => TextWorkflowMessageDto)
|
||||
message!: TextWorkflowMessageDto;
|
||||
}
|
||||
|
||||
export class WorkflowPhoneAttendeeStepDto extends BaseWorkflowStepDto {
|
||||
@ApiProperty({
|
||||
description: "Action to perform, send a text message to the phone numbers of the attendees",
|
||||
example: SMS_ATTENDEE,
|
||||
})
|
||||
@IsString()
|
||||
@IsIn(STEP_ACTIONS)
|
||||
action: typeof SMS_ATTENDEE = SMS_ATTENDEE;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Phone number if recipient is PHONE_NUMBER, required for actions SMS_NUMBER and WHATSAPP_NUMBER",
|
||||
example: "3243434",
|
||||
externalDocs: {
|
||||
url: "https://cal.com/docs/api-reference/v2/organization-team-verified-resources/verify-a-phone-number-for-an-org-team",
|
||||
},
|
||||
})
|
||||
@ApiProperty({ description: "Message content for this step", type: TextWorkflowMessageDto })
|
||||
@ValidateNested()
|
||||
@Type(() => TextWorkflowMessageDto)
|
||||
message!: TextWorkflowMessageDto;
|
||||
}
|
||||
|
||||
export class WorkflowPhoneNumberStepDto extends BaseWorkflowStepDto {
|
||||
@ApiProperty({
|
||||
description: "Action to perform, send a text message to a specific phone number",
|
||||
example: SMS_NUMBER,
|
||||
})
|
||||
@IsString()
|
||||
@IsIn(STEP_ACTIONS)
|
||||
action: typeof SMS_NUMBER = SMS_NUMBER;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Phone number if recipient is PHONE_NUMBER, required for actions SMS_NUMBER and WHATSAPP_NUMBER",
|
||||
example: "3243434",
|
||||
externalDocs: {
|
||||
url: "https://cal.com/docs/api-reference/v2/organization-team-verified-resources/verify-a-phone-number-for-an-org-team",
|
||||
},
|
||||
})
|
||||
@IsNumber()
|
||||
verifiedPhoneId!: number;
|
||||
|
||||
@ApiProperty({ description: "Message content for this step", type: TextWorkflowMessageDto })
|
||||
@ValidateNested()
|
||||
@Type(() => TextWorkflowMessageDto)
|
||||
message!: TextWorkflowMessageDto;
|
||||
}
|
||||
|
||||
export class WorkflowPhoneWhatsAppAttendeeStepDto extends BaseWorkflowStepDto {
|
||||
@ApiProperty({ description: "Action to perform", example: WHATSAPP_ATTENDEE })
|
||||
@IsString()
|
||||
@IsIn(STEP_ACTIONS)
|
||||
action: typeof WHATSAPP_ATTENDEE = WHATSAPP_ATTENDEE;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Message content for this step, send a text message via whatsapp to the phone numbers of the attendees",
|
||||
type: TextWorkflowMessageDto,
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => TextWorkflowMessageDto)
|
||||
message!: TextWorkflowMessageDto;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { TimeUnit, WorkflowTriggerEvents } from "@prisma/client";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsNumber, ValidateNested } from "class-validator";
|
||||
|
||||
export const BEFORE_EVENT = "beforeEvent";
|
||||
export const EVENT_CANCELLED = "eventCancelled";
|
||||
export const NEW_EVENT = "newEvent";
|
||||
export const AFTER_EVENT = "afterEvent";
|
||||
export const RESCHEDULE_EVENT = "rescheduleEvent";
|
||||
export const AFTER_HOSTS_CAL_VIDEO_NO_SHOW = "afterHostsCalVideoNoShow";
|
||||
export const AFTER_GUESTS_CAL_VIDEO_NO_SHOW = "afterGuestsCalVideoNoShow";
|
||||
export const WORKFLOW_TRIGGER_TYPES = [
|
||||
BEFORE_EVENT,
|
||||
EVENT_CANCELLED,
|
||||
NEW_EVENT,
|
||||
AFTER_EVENT,
|
||||
RESCHEDULE_EVENT,
|
||||
AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
|
||||
AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
|
||||
] as const;
|
||||
|
||||
export const WORKFLOW_TRIGGER_TO_ENUM = {
|
||||
[BEFORE_EVENT]: WorkflowTriggerEvents.BEFORE_EVENT,
|
||||
[EVENT_CANCELLED]: WorkflowTriggerEvents.EVENT_CANCELLED,
|
||||
[NEW_EVENT]: WorkflowTriggerEvents.NEW_EVENT,
|
||||
[AFTER_EVENT]: WorkflowTriggerEvents.AFTER_EVENT,
|
||||
[RESCHEDULE_EVENT]: WorkflowTriggerEvents.RESCHEDULE_EVENT,
|
||||
[AFTER_HOSTS_CAL_VIDEO_NO_SHOW]: WorkflowTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
|
||||
[AFTER_GUESTS_CAL_VIDEO_NO_SHOW]: WorkflowTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
|
||||
} as const;
|
||||
|
||||
export const ENUM_TO_WORKFLOW_TRIGGER = {
|
||||
[WorkflowTriggerEvents.BEFORE_EVENT]: BEFORE_EVENT,
|
||||
[WorkflowTriggerEvents.EVENT_CANCELLED]: EVENT_CANCELLED,
|
||||
[WorkflowTriggerEvents.NEW_EVENT]: NEW_EVENT,
|
||||
[WorkflowTriggerEvents.AFTER_EVENT]: AFTER_EVENT,
|
||||
[WorkflowTriggerEvents.RESCHEDULE_EVENT]: RESCHEDULE_EVENT,
|
||||
[WorkflowTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW]: AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
|
||||
[WorkflowTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW]: AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
|
||||
} as const;
|
||||
|
||||
export const HOUR = "hour";
|
||||
export const MINUTE = "minute";
|
||||
export const DAY = "day";
|
||||
|
||||
export const TIME_UNITS = [HOUR, MINUTE, DAY] as const;
|
||||
|
||||
export type TimeUnitType = (typeof TIME_UNITS)[number];
|
||||
|
||||
export const TIME_UNIT_TO_ENUM = {
|
||||
[HOUR]: TimeUnit.HOUR,
|
||||
[MINUTE]: TimeUnit.MINUTE,
|
||||
[DAY]: TimeUnit.DAY,
|
||||
} as const;
|
||||
|
||||
export const ENUM_TO_TIME_UNIT = {
|
||||
[TimeUnit.HOUR]: HOUR,
|
||||
[TimeUnit.MINUTE]: MINUTE,
|
||||
[TimeUnit.DAY]: DAY,
|
||||
} as const;
|
||||
|
||||
export type WorkflowTriggerType = (typeof WORKFLOW_TRIGGER_TYPES)[number];
|
||||
|
||||
export class WorkflowTriggerOffsetDto {
|
||||
@ApiProperty({ description: "Time value for offset before/after event trigger", example: 24, type: Number })
|
||||
@IsNumber()
|
||||
value!: number;
|
||||
|
||||
@ApiProperty({ description: "Unit for the offset time", example: HOUR })
|
||||
unit!: TimeUnitType;
|
||||
}
|
||||
|
||||
export class BaseWorkflowTriggerDto {
|
||||
@ApiProperty({
|
||||
description: "Trigger type for the workflow",
|
||||
})
|
||||
type!: WorkflowTriggerType;
|
||||
}
|
||||
|
||||
export class OnCreationTriggerDto {
|
||||
@ApiProperty({
|
||||
description: "Trigger type for the workflow",
|
||||
})
|
||||
type: typeof NEW_EVENT = NEW_EVENT;
|
||||
}
|
||||
|
||||
export class OnRescheduleTriggerDto {
|
||||
@ApiProperty({
|
||||
description: "Trigger type for the workflow",
|
||||
})
|
||||
type: typeof RESCHEDULE_EVENT = RESCHEDULE_EVENT;
|
||||
}
|
||||
export class OnCancelTriggerDto {
|
||||
@ApiProperty({
|
||||
description: "Trigger type for the workflow",
|
||||
})
|
||||
type: typeof EVENT_CANCELLED = EVENT_CANCELLED;
|
||||
}
|
||||
|
||||
export class TriggerOffsetDTO {
|
||||
@ApiProperty({
|
||||
description: "Offset before/after the trigger time; required for BEFORE_EVENT and AFTER_EVENT only",
|
||||
type: WorkflowTriggerOffsetDto,
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => WorkflowTriggerOffsetDto)
|
||||
offset!: WorkflowTriggerOffsetDto;
|
||||
}
|
||||
|
||||
export class OnBeforeEventTriggerDto extends TriggerOffsetDTO {
|
||||
@ApiProperty({
|
||||
description: "Trigger type for the workflow",
|
||||
example: BEFORE_EVENT,
|
||||
})
|
||||
type: typeof BEFORE_EVENT = BEFORE_EVENT;
|
||||
}
|
||||
|
||||
export class OnAfterEventTriggerDto extends TriggerOffsetDTO {
|
||||
@ApiProperty({
|
||||
description: "Trigger type for the workflow",
|
||||
example: AFTER_EVENT,
|
||||
})
|
||||
type: typeof AFTER_EVENT = AFTER_EVENT;
|
||||
}
|
||||
|
||||
export class OnAfterCalVideoGuestsNoShowTriggerDto extends TriggerOffsetDTO {
|
||||
@ApiProperty({
|
||||
description: "Trigger type for the workflow",
|
||||
example: AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
|
||||
})
|
||||
type: typeof AFTER_GUESTS_CAL_VIDEO_NO_SHOW = AFTER_GUESTS_CAL_VIDEO_NO_SHOW;
|
||||
}
|
||||
|
||||
export class OnAfterCalVideoHostsNoShowTriggerDto extends TriggerOffsetDTO {
|
||||
@ApiProperty({
|
||||
description: "Trigger type for the workflow",
|
||||
example: AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
|
||||
})
|
||||
type: typeof AFTER_HOSTS_CAL_VIDEO_NO_SHOW = AFTER_HOSTS_CAL_VIDEO_NO_SHOW;
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsArray, IsEnum, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
import {
|
||||
EMAIL_HOST,
|
||||
HOST,
|
||||
RECIPIENT_TYPES,
|
||||
RecipientType,
|
||||
REMINDER,
|
||||
STEP_ACTIONS,
|
||||
StepAction,
|
||||
TEMPLATES,
|
||||
TemplateType,
|
||||
} from "../inputs/workflow-step.input";
|
||||
import {
|
||||
BEFORE_EVENT,
|
||||
HOUR,
|
||||
TIME_UNITS,
|
||||
TimeUnitType,
|
||||
WORKFLOW_TRIGGER_TYPES,
|
||||
WorkflowTriggerType,
|
||||
} from "../inputs/workflow-trigger.input";
|
||||
|
||||
export class WorkflowMessageOutputDto {
|
||||
@ApiProperty({
|
||||
description: "Subject of the message",
|
||||
example: "Reminder: Your Meeting {EVENT_NAME} - {EVENT_DATE_ddd, MMM D, YYYY h:mma} with Cal.com",
|
||||
})
|
||||
@Expose()
|
||||
subject!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "HTML content of the message",
|
||||
example: "<p>Reminder for {EVENT_NAME}.</p>",
|
||||
})
|
||||
@Expose()
|
||||
html?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Text content of the message (used for SMS/WhatsApp)",
|
||||
example: "Reminder for {EVENT_NAME}.",
|
||||
})
|
||||
@Expose()
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export class WorkflowStepOutputDto {
|
||||
@ApiProperty({ description: "Unique identifier of the step", example: 67244 })
|
||||
@Expose()
|
||||
id!: number;
|
||||
|
||||
@ApiProperty({ description: "Step number in the workflow sequence", example: 1 })
|
||||
@Expose()
|
||||
stepNumber!: number;
|
||||
|
||||
@ApiProperty({ description: "Action to perform", example: EMAIL_HOST, enum: STEP_ACTIONS })
|
||||
@Expose()
|
||||
action!: StepAction;
|
||||
|
||||
@ApiProperty({ description: "Intended recipient type", example: HOST, enum: RECIPIENT_TYPES })
|
||||
@Expose()
|
||||
recipient!: RecipientType;
|
||||
|
||||
@ApiPropertyOptional({ description: "Verified Email if action is EMAIL_ADDRESS", example: 31214 })
|
||||
@Expose()
|
||||
email?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Verified Phone if action is SMS_NUMBER or WHATSAPP_NUMBER",
|
||||
})
|
||||
@Expose()
|
||||
phone?: string;
|
||||
|
||||
@ApiProperty({ description: "Template type used", example: REMINDER, enum: TEMPLATES })
|
||||
@Expose()
|
||||
template!: TemplateType;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Whether a calendar event (.ics) was included (for email actions)",
|
||||
example: true,
|
||||
})
|
||||
@Expose()
|
||||
includeCalendarEvent = false;
|
||||
|
||||
@ApiProperty({ description: "Displayed sender name used for this step", example: "Cal.com Notifications" })
|
||||
@Expose()
|
||||
sender!: string;
|
||||
|
||||
@ApiProperty({ description: "Message content for this step", type: WorkflowMessageOutputDto })
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => WorkflowMessageOutputDto)
|
||||
message!: WorkflowMessageOutputDto;
|
||||
}
|
||||
|
||||
export class WorkflowTriggerOffsetOutputDto {
|
||||
@ApiProperty({ description: "Time value for offset", example: 24 })
|
||||
@Expose()
|
||||
value!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Unit for the offset time",
|
||||
example: HOUR,
|
||||
enum: TIME_UNITS,
|
||||
})
|
||||
@Expose()
|
||||
unit!: TimeUnitType;
|
||||
}
|
||||
|
||||
export class WorkflowTriggerOutputDto {
|
||||
@ApiProperty({
|
||||
description: "Trigger type for the workflow",
|
||||
example: BEFORE_EVENT,
|
||||
enum: WORKFLOW_TRIGGER_TYPES,
|
||||
})
|
||||
@Expose()
|
||||
type!: WorkflowTriggerType;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Offset details (present for BEFORE_EVENT/AFTER_EVENT)",
|
||||
type: WorkflowTriggerOffsetOutputDto,
|
||||
})
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => WorkflowTriggerOffsetOutputDto)
|
||||
offset?: WorkflowTriggerOffsetOutputDto;
|
||||
}
|
||||
|
||||
export class WorkflowActivationOutputDto {
|
||||
@ApiProperty({
|
||||
description: "Whether the workflow is active for all event types associated with the team/user",
|
||||
example: false,
|
||||
})
|
||||
@Expose()
|
||||
isActiveOnAllEventTypes?: boolean = false;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "List of Event Type IDs the workflow is specifically active on (if not active on all)",
|
||||
example: [698191, 698192],
|
||||
})
|
||||
@Expose()
|
||||
@IsArray()
|
||||
activeOnEventTypeIds?: number[];
|
||||
}
|
||||
|
||||
// --- Main Workflow Output DTO ---
|
||||
|
||||
export class WorkflowOutput {
|
||||
@ApiProperty({ description: "Unique identifier of the workflow", example: 101 })
|
||||
@Expose()
|
||||
id!: number;
|
||||
|
||||
@ApiProperty({ description: "Name of the workflow", example: "Platform Test Workflow" })
|
||||
@Expose()
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "ID of the user who owns the workflow (if not team-owned)",
|
||||
example: 2313,
|
||||
})
|
||||
@Expose()
|
||||
userId?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: "ID of the team owning the workflow", example: 4214321 })
|
||||
@Expose()
|
||||
teamId?: number;
|
||||
|
||||
@ApiProperty({ description: "Activation settings (scope)", type: WorkflowActivationOutputDto })
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => WorkflowActivationOutputDto)
|
||||
activation!: WorkflowActivationOutputDto;
|
||||
|
||||
@ApiProperty({ description: "Trigger configuration", type: WorkflowTriggerOutputDto })
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => WorkflowTriggerOutputDto)
|
||||
trigger!: WorkflowTriggerOutputDto;
|
||||
|
||||
@ApiProperty({ description: "Steps comprising the workflow", type: [WorkflowStepOutputDto] })
|
||||
@Expose()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => WorkflowStepOutputDto)
|
||||
steps!: WorkflowStepOutputDto[];
|
||||
|
||||
@ApiPropertyOptional({ description: "Timestamp of creation", example: "2024-05-12T10:00:00.000Z" })
|
||||
@Expose()
|
||||
createdAt?: Date | string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Timestamp of last update", example: "2024-05-12T11:30:00.000Z" })
|
||||
@Expose()
|
||||
updatedAt?: Date | string;
|
||||
}
|
||||
|
||||
// --- List Response Output DTO ---
|
||||
|
||||
export class GetWorkflowsOutput {
|
||||
@ApiProperty({
|
||||
description: "Indicates the status of the response",
|
||||
example: SUCCESS_STATUS,
|
||||
enum: [SUCCESS_STATUS, ERROR_STATUS],
|
||||
})
|
||||
@Expose()
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
description: "List of workflows",
|
||||
type: [WorkflowOutput],
|
||||
})
|
||||
@Expose()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => WorkflowOutput)
|
||||
data!: WorkflowOutput[];
|
||||
}
|
||||
|
||||
export class GetWorkflowOutput {
|
||||
@ApiProperty({
|
||||
description: "Indicates the status of the response",
|
||||
example: SUCCESS_STATUS,
|
||||
enum: [SUCCESS_STATUS, ERROR_STATUS],
|
||||
})
|
||||
@Expose()
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
description: "workflow",
|
||||
type: [WorkflowOutput],
|
||||
})
|
||||
@Expose()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => WorkflowOutput)
|
||||
data!: WorkflowOutput;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { TeamsVerifiedResourcesRepository } from "@/modules/verified-resources/teams-verified-resources.repository";
|
||||
import { CreateWorkflowDto } from "@/modules/workflows/inputs/create-workflow.input";
|
||||
import { UpdateWorkflowDto } from "@/modules/workflows/inputs/update-workflow.input";
|
||||
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 { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class TeamWorkflowsService {
|
||||
constructor(
|
||||
private readonly workflowsRepository: WorkflowsRepository,
|
||||
private readonly teamsVerifiedResourcesRepository: TeamsVerifiedResourcesRepository,
|
||||
private readonly workflowInputService: WorkflowsInputService,
|
||||
private readonly workflowOutputService: WorkflowsOutputService
|
||||
) {}
|
||||
|
||||
async getTeamWorkflows(teamId: number, skip: number, take: number) {
|
||||
const workflows = await this.workflowsRepository.getTeamWorkflows(teamId, skip, take);
|
||||
|
||||
return workflows.map((workflow) => this.workflowOutputService.toOutputDto(workflow));
|
||||
}
|
||||
|
||||
async getTeamWorkflowById(teamId: number, workflowId: number) {
|
||||
const workflow = await this.workflowsRepository.getTeamWorkflowById(teamId, workflowId);
|
||||
|
||||
if (!workflow) {
|
||||
throw new NotFoundException(`Workflow with ID ${workflowId} not found for team ${teamId}`);
|
||||
}
|
||||
|
||||
return this.workflowOutputService.toOutputDto(workflow);
|
||||
}
|
||||
|
||||
async createTeamWorkflow(user: UserWithProfile, teamId: number, data: CreateWorkflowDto) {
|
||||
const workflowHusk = await this.workflowsRepository.createTeamWorkflowHusk(teamId);
|
||||
const mappedData = await this.workflowInputService.mapUpdateDtoToZodUpdateSchema(
|
||||
data,
|
||||
workflowHusk.id,
|
||||
teamId,
|
||||
workflowHusk
|
||||
);
|
||||
|
||||
const createdWorkflow = await this.workflowsRepository.updateTeamWorkflow(
|
||||
user,
|
||||
teamId,
|
||||
workflowHusk.id,
|
||||
mappedData
|
||||
);
|
||||
if (!createdWorkflow) {
|
||||
throw new BadRequestException(`Could not create Workflow in team ${teamId}`);
|
||||
}
|
||||
|
||||
return this.workflowOutputService.toOutputDto(createdWorkflow);
|
||||
}
|
||||
|
||||
async updateTeamWorkflow(
|
||||
user: UserWithProfile,
|
||||
teamId: number,
|
||||
workflowId: number,
|
||||
data: UpdateWorkflowDto
|
||||
) {
|
||||
const currentWorkflow = await this.workflowsRepository.getTeamWorkflowById(teamId, workflowId);
|
||||
|
||||
if (!currentWorkflow) {
|
||||
throw new NotFoundException(`Workflow with ID ${workflowId} not found for team ${teamId}`);
|
||||
}
|
||||
const mappedData = await this.workflowInputService.mapUpdateDtoToZodUpdateSchema(
|
||||
data,
|
||||
workflowId,
|
||||
teamId,
|
||||
currentWorkflow
|
||||
);
|
||||
|
||||
const updatedWorkflow = await this.workflowsRepository.updateTeamWorkflow(
|
||||
user,
|
||||
teamId,
|
||||
workflowId,
|
||||
mappedData
|
||||
);
|
||||
|
||||
if (!updatedWorkflow) {
|
||||
throw new BadRequestException(`Could not update Workflow with ID ${workflowId} in team ${teamId}`);
|
||||
}
|
||||
|
||||
return this.workflowOutputService.toOutputDto(updatedWorkflow);
|
||||
}
|
||||
|
||||
async deleteTeamWorkflow(teamId: number, workflowId: number) {
|
||||
return await this.workflowsRepository.deleteTeamWorkflowById(teamId, workflowId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { TeamsVerifiedResourcesRepository } from "@/modules/verified-resources/teams-verified-resources.repository";
|
||||
import {
|
||||
UpdateWorkflowDto,
|
||||
UpdateWorkflowStepDto,
|
||||
UpdateEmailAttendeeWorkflowStepDto,
|
||||
UpdateEmailAddressWorkflowStepDto,
|
||||
UpdateEmailHostWorkflowStepDto,
|
||||
} from "@/modules/workflows/inputs/update-workflow.input";
|
||||
import { WorkflowType } from "@/modules/workflows/workflows.repository";
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
|
||||
import { TUpdateInputSchema } from "@calcom/platform-libraries/workflows";
|
||||
|
||||
import {
|
||||
EMAIL_ADDRESS,
|
||||
EMAIL_ATTENDEE,
|
||||
EMAIL_HOST,
|
||||
HtmlWorkflowMessageDto,
|
||||
SMS_ATTENDEE,
|
||||
SMS_NUMBER,
|
||||
STEP_ACTIONS_TO_ENUM,
|
||||
TemplateType,
|
||||
TextWorkflowMessageDto,
|
||||
WHATSAPP_ATTENDEE,
|
||||
WHATSAPP_NUMBER,
|
||||
WorkflowPhoneNumberStepDto,
|
||||
WorkflowPhoneWhatsAppNumberStepDto,
|
||||
} from "../inputs/workflow-step.input";
|
||||
import {
|
||||
OnAfterEventTriggerDto,
|
||||
OnBeforeEventTriggerDto,
|
||||
TIME_UNIT_TO_ENUM,
|
||||
WORKFLOW_TRIGGER_TO_ENUM,
|
||||
} from "../inputs/workflow-trigger.input";
|
||||
|
||||
@Injectable()
|
||||
export class WorkflowsInputService {
|
||||
constructor(private readonly teamsVerifiedResourcesRepository: TeamsVerifiedResourcesRepository) {}
|
||||
|
||||
private async mapUpdateWorkflowStepToZodUpdateSchema(
|
||||
stepDto: UpdateWorkflowStepDto,
|
||||
index: number,
|
||||
teamId: number,
|
||||
workflowIdToUse: number
|
||||
) {
|
||||
let reminderBody: string | null = null;
|
||||
let sendTo: string | null = null;
|
||||
const html = stepDto.message instanceof HtmlWorkflowMessageDto ? stepDto.message.html : null;
|
||||
const text = stepDto.message instanceof TextWorkflowMessageDto ? stepDto.message.text : null;
|
||||
const includeCalendarEvent =
|
||||
stepDto instanceof UpdateEmailAddressWorkflowStepDto ||
|
||||
stepDto instanceof UpdateEmailAttendeeWorkflowStepDto ||
|
||||
stepDto instanceof UpdateEmailHostWorkflowStepDto
|
||||
? stepDto.includeCalendarEvent
|
||||
: false;
|
||||
|
||||
switch (stepDto.action) {
|
||||
case EMAIL_HOST:
|
||||
case EMAIL_ATTENDEE:
|
||||
case EMAIL_ADDRESS:
|
||||
reminderBody = html ?? null;
|
||||
break;
|
||||
case SMS_ATTENDEE:
|
||||
case SMS_NUMBER:
|
||||
case WHATSAPP_ATTENDEE:
|
||||
case WHATSAPP_NUMBER:
|
||||
reminderBody = text ?? null;
|
||||
break;
|
||||
}
|
||||
if (stepDto.action === EMAIL_ADDRESS) {
|
||||
if (stepDto.verifiedEmailId) {
|
||||
const emailResource = await this.teamsVerifiedResourcesRepository.getTeamVerifiedEmailById(
|
||||
stepDto.verifiedEmailId,
|
||||
teamId
|
||||
);
|
||||
if (!emailResource?.email) {
|
||||
throw new BadRequestException("Invalid Verified Email Id.");
|
||||
}
|
||||
sendTo = emailResource.email;
|
||||
}
|
||||
} else if (stepDto.action === SMS_NUMBER || stepDto.action === WHATSAPP_NUMBER) {
|
||||
if (
|
||||
stepDto instanceof WorkflowPhoneNumberStepDto ||
|
||||
stepDto instanceof WorkflowPhoneWhatsAppNumberStepDto
|
||||
) {
|
||||
if (stepDto.verifiedPhoneId) {
|
||||
const phoneResource = await this.teamsVerifiedResourcesRepository.getTeamVerifiedPhoneNumberById(
|
||||
stepDto.verifiedPhoneId,
|
||||
teamId
|
||||
);
|
||||
|
||||
if (!phoneResource?.phoneNumber) {
|
||||
throw new BadRequestException("Invalid Verified Phone Id.");
|
||||
}
|
||||
|
||||
sendTo = phoneResource.phoneNumber;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const actionForZod = STEP_ACTIONS_TO_ENUM[stepDto.action];
|
||||
const templateForZod = stepDto.template as unknown as Uppercase<TemplateType>;
|
||||
|
||||
return {
|
||||
id: stepDto.id ?? -(index + 1),
|
||||
stepNumber: stepDto.stepNumber,
|
||||
action: actionForZod,
|
||||
workflowId: workflowIdToUse,
|
||||
sendTo: sendTo,
|
||||
reminderBody: reminderBody,
|
||||
emailSubject: stepDto.message.subject ?? null,
|
||||
template: templateForZod,
|
||||
numberRequired: null,
|
||||
sender: stepDto.sender ?? null,
|
||||
senderName: stepDto.sender ?? null,
|
||||
includeCalendarEvent: includeCalendarEvent,
|
||||
};
|
||||
}
|
||||
|
||||
async mapUpdateDtoToZodUpdateSchema(
|
||||
updateDto: UpdateWorkflowDto,
|
||||
workflowIdToUse: number,
|
||||
teamId: number,
|
||||
currentData: WorkflowType
|
||||
): Promise<TUpdateInputSchema> {
|
||||
const mappedSteps = updateDto?.steps
|
||||
? await Promise.all(
|
||||
updateDto.steps.map(async (stepDto: UpdateWorkflowStepDto, index: number) =>
|
||||
this.mapUpdateWorkflowStepToZodUpdateSchema(stepDto, index, teamId, workflowIdToUse)
|
||||
)
|
||||
)
|
||||
: currentData.steps.map((step) => ({ ...step, senderName: step.sender }));
|
||||
|
||||
const triggerForZod = updateDto?.trigger?.type
|
||||
? WORKFLOW_TRIGGER_TO_ENUM[updateDto?.trigger?.type]
|
||||
: currentData.trigger;
|
||||
const timeUnitForZod =
|
||||
updateDto.trigger instanceof OnBeforeEventTriggerDto ||
|
||||
updateDto.trigger instanceof OnAfterEventTriggerDto
|
||||
? updateDto?.trigger?.offset?.unit ?? currentData.timeUnit ?? null
|
||||
: undefined;
|
||||
|
||||
const updateData: TUpdateInputSchema = {
|
||||
id: workflowIdToUse,
|
||||
name: updateDto.name ?? currentData.name,
|
||||
activeOn:
|
||||
updateDto?.activation?.activeOnEventTypeIds ??
|
||||
currentData?.activeOn.map((active) => active.eventTypeId) ??
|
||||
[],
|
||||
steps: mappedSteps,
|
||||
trigger: triggerForZod,
|
||||
time:
|
||||
updateDto.trigger instanceof OnBeforeEventTriggerDto ||
|
||||
updateDto.trigger instanceof OnAfterEventTriggerDto
|
||||
? updateDto?.trigger?.offset?.value ?? currentData?.time ?? null
|
||||
: null,
|
||||
timeUnit: timeUnitForZod ? TIME_UNIT_TO_ENUM[timeUnitForZod] : null,
|
||||
isActiveOnAll: updateDto?.activation?.isActiveOnAllEventTypes ?? currentData.isActiveOnAll ?? false,
|
||||
} as const satisfies TUpdateInputSchema;
|
||||
|
||||
return updateData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { WorkflowActivationDto, TriggerDtoType } from "@/modules/workflows/inputs/create-workflow.input";
|
||||
import { WorkflowOutput, WorkflowStepOutputDto } from "@/modules/workflows/outputs/workflow.output";
|
||||
import { WorkflowType } from "@/modules/workflows/workflows.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import {
|
||||
ATTENDEE,
|
||||
EMAIL,
|
||||
EMAIL_ADDRESS,
|
||||
EMAIL_ATTENDEE,
|
||||
EMAIL_HOST,
|
||||
ENUM_TO_STEP_ACTIONS,
|
||||
ENUM_TO_TEMPLATES,
|
||||
HOST,
|
||||
PHONE_NUMBER,
|
||||
RecipientType,
|
||||
SMS_ATTENDEE,
|
||||
SMS_NUMBER,
|
||||
WHATSAPP_ATTENDEE,
|
||||
WHATSAPP_NUMBER,
|
||||
} from "../inputs/workflow-step.input";
|
||||
import {
|
||||
AFTER_EVENT,
|
||||
AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
|
||||
AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
|
||||
BEFORE_EVENT,
|
||||
ENUM_TO_TIME_UNIT,
|
||||
ENUM_TO_WORKFLOW_TRIGGER,
|
||||
HOUR,
|
||||
WORKFLOW_TRIGGER_TO_ENUM,
|
||||
} from "../inputs/workflow-trigger.input";
|
||||
|
||||
@Injectable()
|
||||
export class WorkflowsOutputService {
|
||||
toOutputDto(workflow: WorkflowType): WorkflowOutput {
|
||||
const activation: WorkflowActivationDto = {
|
||||
isActiveOnAllEventTypes: workflow.isActiveOnAll,
|
||||
activeOnEventTypeIds: workflow.activeOn?.map((relation) => relation.eventTypeId) ?? [],
|
||||
};
|
||||
|
||||
const trigger: TriggerDtoType =
|
||||
workflow.trigger === WORKFLOW_TRIGGER_TO_ENUM[BEFORE_EVENT] ||
|
||||
workflow.trigger === WORKFLOW_TRIGGER_TO_ENUM[AFTER_EVENT] ||
|
||||
workflow.trigger === WORKFLOW_TRIGGER_TO_ENUM[AFTER_GUESTS_CAL_VIDEO_NO_SHOW] ||
|
||||
workflow.trigger === WORKFLOW_TRIGGER_TO_ENUM[AFTER_HOSTS_CAL_VIDEO_NO_SHOW]
|
||||
? {
|
||||
type: ENUM_TO_WORKFLOW_TRIGGER[workflow.trigger],
|
||||
offset: {
|
||||
value: workflow.time ?? 1,
|
||||
unit: workflow.timeUnit ? ENUM_TO_TIME_UNIT[workflow.timeUnit] : HOUR,
|
||||
},
|
||||
}
|
||||
: { type: ENUM_TO_WORKFLOW_TRIGGER[workflow.trigger] };
|
||||
|
||||
const steps: WorkflowStepOutputDto[] = workflow.steps.map((step) => {
|
||||
let recipient: RecipientType;
|
||||
let email = "";
|
||||
let phone = "";
|
||||
let text;
|
||||
let html;
|
||||
switch (ENUM_TO_STEP_ACTIONS[step.action]) {
|
||||
case EMAIL_HOST:
|
||||
recipient = HOST;
|
||||
html = step.reminderBody ?? "";
|
||||
break;
|
||||
case EMAIL_ATTENDEE:
|
||||
html = step.reminderBody ?? "";
|
||||
recipient = ATTENDEE;
|
||||
break;
|
||||
case SMS_ATTENDEE:
|
||||
text = step.reminderBody ?? "";
|
||||
recipient = ATTENDEE;
|
||||
break;
|
||||
case WHATSAPP_ATTENDEE:
|
||||
text = step.reminderBody ?? "";
|
||||
recipient = ATTENDEE;
|
||||
break;
|
||||
case EMAIL_ADDRESS:
|
||||
html = step.reminderBody ?? "";
|
||||
recipient = EMAIL;
|
||||
email = step.sendTo ?? "";
|
||||
break;
|
||||
case SMS_NUMBER:
|
||||
case WHATSAPP_NUMBER:
|
||||
text = step.reminderBody ?? "";
|
||||
recipient = PHONE_NUMBER;
|
||||
phone = step.sendTo ?? "";
|
||||
break;
|
||||
default:
|
||||
recipient = ATTENDEE;
|
||||
}
|
||||
|
||||
return {
|
||||
id: step.id,
|
||||
stepNumber: step.stepNumber,
|
||||
action: ENUM_TO_STEP_ACTIONS[step.action],
|
||||
recipient: recipient,
|
||||
email,
|
||||
phone,
|
||||
template: ENUM_TO_TEMPLATES[step.template],
|
||||
includeCalendarEvent: step.includeCalendarEvent,
|
||||
sender: step.sender ?? "Default Sender",
|
||||
message: {
|
||||
subject: step.emailSubject ?? "",
|
||||
text,
|
||||
html,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
activation: activation,
|
||||
trigger: trigger,
|
||||
steps: steps.sort((stepA, stepB) => stepA.stepNumber - stepB.stepNumber),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { TimeUnit, Workflow, WorkflowStep, WorkflowTriggerEvents } from "@prisma/client";
|
||||
|
||||
import { TUpdateInputSchema } from "@calcom/platform-libraries/workflows";
|
||||
import { updateWorkflow } from "@calcom/platform-libraries/workflows";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
export type WorkflowType = Workflow & { activeOn: { eventTypeId: number }[]; steps: WorkflowStep[] };
|
||||
|
||||
@Injectable()
|
||||
export class WorkflowsRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async deleteTeamWorkflowById(teamId: number, workflowId: number) {
|
||||
return await this.dbWrite.prisma.workflow.delete({ where: { id: workflowId, teamId } });
|
||||
}
|
||||
|
||||
async getTeamWorkflowById(teamId: number, id: number): Promise<WorkflowType | null> {
|
||||
const workflow = await this.dbRead.prisma.workflow.findUnique({
|
||||
where: {
|
||||
id: id,
|
||||
teamId: teamId,
|
||||
},
|
||||
include: {
|
||||
steps: true,
|
||||
activeOn: { select: { eventTypeId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return workflow;
|
||||
}
|
||||
|
||||
async getTeamWorkflows(teamId: number, skip: number, take: number): Promise<WorkflowType[]> {
|
||||
const workflows = await this.dbRead.prisma.workflow.findMany({
|
||||
where: {
|
||||
teamId: teamId,
|
||||
},
|
||||
include: {
|
||||
steps: true,
|
||||
activeOn: { select: { eventTypeId: true } },
|
||||
},
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
|
||||
return workflows;
|
||||
}
|
||||
|
||||
async createTeamWorkflowHusk(teamId: number) {
|
||||
return this.dbWrite.prisma.workflow.create({
|
||||
data: {
|
||||
name: "",
|
||||
trigger: WorkflowTriggerEvents.BEFORE_EVENT,
|
||||
time: 24,
|
||||
timeUnit: TimeUnit.HOUR,
|
||||
teamId,
|
||||
},
|
||||
include: { activeOn: true, steps: true },
|
||||
});
|
||||
}
|
||||
|
||||
async updateTeamWorkflow(
|
||||
user: UserWithProfile,
|
||||
teamId: number,
|
||||
workflowId: number,
|
||||
data: TUpdateInputSchema
|
||||
) {
|
||||
await updateWorkflow({
|
||||
ctx: {
|
||||
user: { ...user, locale: user?.locale ?? "en" },
|
||||
prisma: this.dbWrite.prisma as unknown as PrismaClient,
|
||||
},
|
||||
input: data,
|
||||
});
|
||||
|
||||
const workflow = await this.getTeamWorkflowById(teamId, workflowId);
|
||||
return workflow;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,13 @@ export class OAuthClientRepositoryFixture {
|
||||
});
|
||||
}
|
||||
|
||||
async update(clientId: PlatformOAuthClient["id"], data: Prisma.PlatformOAuthClientUpdateInput) {
|
||||
return this.prismaWriteClient.platformOAuthClient.update({
|
||||
where: { id: clientId },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
async delete(clientId: PlatformOAuthClient["id"]) {
|
||||
return this.prismaWriteClient.platformOAuthClient.delete({ where: { id: clientId } });
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ export class VerifiedResourcesRepositoryFixtures {
|
||||
return this.prismaWriteClient.verifiedNumber.create({ data });
|
||||
}
|
||||
|
||||
async createEmail(data: Prisma.VerifiedEmailCreateInput) {
|
||||
return this.prismaWriteClient.verifiedEmail.create({ data });
|
||||
}
|
||||
|
||||
async deleteEmailById(id: number) {
|
||||
return this.prismaWriteClient.verifiedEmail.delete({ where: { id } });
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"@calcom/platform-libraries/slots": ["../../../packages/platform/libraries/slots.ts"],
|
||||
"@calcom/platform-libraries/emails": ["../../../packages/platform/libraries/emails.ts"],
|
||||
"@calcom/platform-libraries/schedules": ["../../../packages/platform/libraries/schedules.ts"],
|
||||
"@calcom/platform-libraries/app-store": ["../../../packages/platform/libraries/app-store.ts"]
|
||||
"@calcom/platform-libraries/app-store": ["../../../packages/platform/libraries/app-store.ts"],
|
||||
"@calcom/platform-libraries/workflows": ["../../../packages/platform/libraries/workflows.ts"]
|
||||
},
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
export async function revalidateApiKeysList() {
|
||||
revalidatePath("/settings/developer/api-keys");
|
||||
revalidateTag("viewer.apiKeys.list");
|
||||
}
|
||||
|
||||
+24
-4
@@ -1,8 +1,13 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { apiKeysRouter } from "@calcom/trpc/server/routers/viewer/apiKeys/_router";
|
||||
import { ApiKeyRepository } from "@calcom/lib/server/repository/apiKey";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import ApiKeysView from "~/settings/developer/api-keys-view";
|
||||
|
||||
@@ -15,9 +20,24 @@ export const generateMetadata = async () =>
|
||||
"/settings/developer/api-keys"
|
||||
);
|
||||
|
||||
const getCachedApiKeys = unstable_cache(
|
||||
async (userId: number) => {
|
||||
return await ApiKeyRepository.findApiKeysFromUserId({ userId });
|
||||
},
|
||||
undefined,
|
||||
{ revalidate: 3600, tags: ["viewer.apiKeys.list"] } // Cache for 1 hour
|
||||
);
|
||||
|
||||
const Page = async () => {
|
||||
const caller = await createRouterCaller(apiKeysRouter);
|
||||
const apiKeys = await caller.list();
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session) {
|
||||
redirect("/auth/login?callbackUrl=/settings/developer/api-keys");
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
const apiKeys = await getCachedApiKeys(userId);
|
||||
|
||||
return <ApiKeysView apiKeys={apiKeys} />;
|
||||
};
|
||||
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import BillingPage, { generateMetadata } from "../../../billing/page";
|
||||
|
||||
export { generateMetadata };
|
||||
|
||||
export default BillingPage;
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
const OrgAdminOnlyLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
const isOrgAdminOrOwner = checkAdminOrOwner(session?.user?.org?.role);
|
||||
|
||||
if (!isOrgAdminOrOwner) {
|
||||
return redirect("/settings/organizations/profile");
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export default OrgAdminOnlyLayout;
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
import BillingPage, { generateMetadata } from "../../billing/page";
|
||||
|
||||
export { generateMetadata };
|
||||
|
||||
export default BillingPage;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
const SettingsOrganizationsLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
const orgExists =
|
||||
session?.user?.org || session?.user?.profile?.organizationId || session?.user?.profile?.organization;
|
||||
if (!orgExists) {
|
||||
return redirect("/settings/my-account/profile");
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export default SettingsOrganizationsLayout;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
const OrgAdminOnlyLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
const isOrgAdminOrOwner = checkAdminOrOwner(session?.user?.org?.role);
|
||||
|
||||
if (!isOrgAdminOrOwner) {
|
||||
return redirect("/settings/organizations/profile");
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export default OrgAdminOnlyLayout;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
const SettingsOrganizationsLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
const orgExists =
|
||||
session?.user?.org || session?.user?.profile?.organizationId || session?.user?.profile?.organization;
|
||||
if (!orgExists) {
|
||||
return redirect("/settings/my-account/profile");
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export default SettingsOrganizationsLayout;
|
||||
@@ -48,6 +48,7 @@ export const OAuthClientsList = ({ oauthClients, isDeleting, handleDelete }: OAu
|
||||
onDelete={handleDelete}
|
||||
areEmailsEnabled={client.areEmailsEnabled}
|
||||
areDefaultEventTypesEnabled={client.areDefaultEventTypesEnabled}
|
||||
areCalendarEventsEnabled={client.areCalendarEventsEnabled}
|
||||
organizationId={client.organizationId}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ type OAuthClientCardProps = {
|
||||
bookingRescheduleRedirectUri: string | null | undefined;
|
||||
areEmailsEnabled: boolean | undefined;
|
||||
areDefaultEventTypesEnabled: boolean;
|
||||
areCalendarEventsEnabled: boolean;
|
||||
permissions: Array<keyof typeof PERMISSION_MAP>;
|
||||
lastItem: boolean;
|
||||
id: string;
|
||||
@@ -42,6 +43,7 @@ export const OAuthClientCard = ({
|
||||
isLoading,
|
||||
areEmailsEnabled,
|
||||
areDefaultEventTypesEnabled,
|
||||
areCalendarEventsEnabled,
|
||||
organizationId,
|
||||
}: OAuthClientCardProps) => {
|
||||
const router = useRouter();
|
||||
@@ -173,6 +175,10 @@ export const OAuthClientCard = ({
|
||||
<span className="text-sm font-semibold">Default event types enabled:</span>{" "}
|
||||
{areDefaultEventTypesEnabled ? "Yes" : "No"}
|
||||
</div>
|
||||
<div className="flex gap-1 text-sm">
|
||||
<span className="text-sm font-semibold">Calendar events enabled:</span>{" "}
|
||||
{areCalendarEventsEnabled ? "Yes" : "No"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<Button
|
||||
|
||||
@@ -37,6 +37,7 @@ export type FormValues = {
|
||||
bookingRescheduleRedirectUri?: string;
|
||||
areEmailsEnabled?: boolean;
|
||||
areDefaultEventTypesEnabled?: boolean;
|
||||
areCalendarEventsEnabled?: boolean;
|
||||
};
|
||||
|
||||
export const OAuthClientForm = ({
|
||||
@@ -236,6 +237,27 @@ export const OAuthClientForm = ({
|
||||
Enable emails
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
{...register("areCalendarEventsEnabled")}
|
||||
id="areCalendarEventsEnabled"
|
||||
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
||||
type="checkbox"
|
||||
disabled={isFormDisabled}
|
||||
/>
|
||||
<label htmlFor="areCalendarEventsEnabled" className="cursor-pointer px-2 text-base font-semibold">
|
||||
Enable calendar events
|
||||
</label>
|
||||
<Tooltip
|
||||
className="max-w-[400px] whitespace-normal"
|
||||
content="If enabled and the managed user has calendar connected, an event in the calendar will be created. By default true. Disable it if you want to create events in the calendar manually.">
|
||||
<div className="ml-1">
|
||||
<Icon name="info" className="h-4 w-4 text-gray-500" aria-hidden="true" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
|
||||
@@ -22,6 +22,7 @@ const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true }
|
||||
type CalVideoSettings = {
|
||||
disableRecordingForGuests: boolean;
|
||||
disableRecordingForOrganizer: boolean;
|
||||
enableAutomaticTranscription: boolean;
|
||||
};
|
||||
|
||||
const shouldEnableRecordButton = ({
|
||||
@@ -43,6 +44,19 @@ const shouldEnableRecordButton = ({
|
||||
return !calVideoSettings.disableRecordingForGuests;
|
||||
};
|
||||
|
||||
const shouldEnableAutomaticTranscription = ({
|
||||
hasTeamPlan,
|
||||
calVideoSettings,
|
||||
}: {
|
||||
hasTeamPlan: boolean;
|
||||
calVideoSettings?: CalVideoSettings | null;
|
||||
}) => {
|
||||
if (!hasTeamPlan) return false;
|
||||
if (!calVideoSettings) return false;
|
||||
|
||||
return !!calVideoSettings.enableAutomaticTranscription;
|
||||
};
|
||||
|
||||
const checkIfUserIsHost = async ({
|
||||
booking,
|
||||
sessionUserId,
|
||||
@@ -204,6 +218,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
isOrganizer: sessionUserId === bookingObj.user?.id,
|
||||
});
|
||||
|
||||
const enableAutomaticTranscription = shouldEnableAutomaticTranscription({
|
||||
hasTeamPlan: !!hasTeamPlan,
|
||||
calVideoSettings: bookingObj.eventType?.calVideoSettings,
|
||||
});
|
||||
|
||||
return {
|
||||
props: {
|
||||
meetingUrl: videoReference.meetingUrl ?? "",
|
||||
@@ -225,6 +244,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
displayLogInOverlay,
|
||||
loggedInUserName: sessionUserId ? session?.user?.name : undefined,
|
||||
showRecordingButton,
|
||||
enableAutomaticTranscription,
|
||||
rediectAttendeeToOnExit: isOrganizer
|
||||
? undefined
|
||||
: bookingObj.eventType?.calVideoSettings?.redirectUrlOnExit,
|
||||
|
||||
@@ -60,6 +60,7 @@ export default function EditOAuthClient() {
|
||||
bookingRescheduleRedirectUri: data.bookingRescheduleRedirectUri,
|
||||
areEmailsEnabled: data.areEmailsEnabled,
|
||||
areDefaultEventTypesEnabled: data.areDefaultEventTypesEnabled,
|
||||
areCalendarEventsEnabled: data.areCalendarEventsEnabled,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -87,6 +88,7 @@ export default function EditOAuthClient() {
|
||||
name: data?.name ?? "",
|
||||
areEmailsEnabled: data.areEmailsEnabled ?? false,
|
||||
areDefaultEventTypesEnabled: data.areDefaultEventTypesEnabled ?? false,
|
||||
areCalendarEventsEnabled: data.areCalendarEventsEnabled,
|
||||
redirectUris: data?.redirectUris?.map((uri) => ({ uri })) ?? [{ uri: "" }],
|
||||
bookingRedirectUri: data?.bookingRedirectUri ?? "",
|
||||
bookingCancelRedirectUri: data?.bookingCancelRedirectUri ?? "",
|
||||
|
||||
@@ -56,6 +56,7 @@ export default function CreateOAuthClient() {
|
||||
bookingRescheduleRedirectUri: data.bookingRescheduleRedirectUri,
|
||||
areEmailsEnabled: data.areEmailsEnabled,
|
||||
areDefaultEventTypesEnabled: data.areDefaultEventTypesEnabled,
|
||||
areCalendarEventsEnabled: data.areCalendarEventsEnabled,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -76,7 +77,24 @@ export default function CreateOAuthClient() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<OAuthClientForm isPending={isSaving} onSubmit={onSubmit} />
|
||||
<OAuthClientForm
|
||||
isPending={isSaving}
|
||||
onSubmit={onSubmit}
|
||||
defaultValues={{
|
||||
areCalendarEventsEnabled: true,
|
||||
areEmailsEnabled: true,
|
||||
eventTypeRead: true,
|
||||
eventTypeWrite: true,
|
||||
bookingRead: true,
|
||||
bookingWrite: true,
|
||||
scheduleRead: true,
|
||||
scheduleWrite: true,
|
||||
appsRead: true,
|
||||
appsWrite: true,
|
||||
profileRead: true,
|
||||
profileWrite: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Shell>
|
||||
</div>
|
||||
|
||||
@@ -70,7 +70,13 @@ export interface DailyCustomTrayButton {
|
||||
tooltip: string;
|
||||
visualState?: DailyCustomTrayButtonVisualState;
|
||||
}
|
||||
export const CalAiTranscribe = ({ showRecordingButton }: { showRecordingButton: boolean }) => {
|
||||
export const CalAiTranscribe = ({
|
||||
showRecordingButton,
|
||||
enableAutomaticTranscription,
|
||||
}: {
|
||||
showRecordingButton: boolean;
|
||||
enableAutomaticTranscription: boolean;
|
||||
}) => {
|
||||
const daily = useDaily();
|
||||
const { t } = useLocale();
|
||||
|
||||
@@ -107,6 +113,18 @@ export const CalAiTranscribe = ({ showRecordingButton }: { showRecordingButton:
|
||||
}, [])
|
||||
);
|
||||
|
||||
useDailyEvent("joined-meeting", (ev) => {
|
||||
if (enableAutomaticTranscription) {
|
||||
daily?.startTranscription();
|
||||
updateCustomTrayButtons({
|
||||
recording: BUTTONS.START_RECORDING,
|
||||
transcription: transcription?.isTranscribing
|
||||
? BUTTONS.STOP_TRANSCRIPTION
|
||||
: BUTTONS.START_TRANSCRIPTION,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
useDailyEvent("transcription-started", (ev) => {
|
||||
updateCustomTrayButtons({
|
||||
recording: recording?.isRecording ? BUTTONS.STOP_RECORDING : BUTTONS.START_RECORDING,
|
||||
|
||||
@@ -36,6 +36,7 @@ export default function JoinCall(props: PageProps) {
|
||||
loggedInUserName,
|
||||
overrideName,
|
||||
showRecordingButton,
|
||||
enableAutomaticTranscription,
|
||||
rediectAttendeeToOnExit,
|
||||
} = props;
|
||||
const [daily, setDaily] = useState<DailyCall | null>(null);
|
||||
@@ -96,7 +97,6 @@ export default function JoinCall(props: PageProps) {
|
||||
callFrame = DailyIframe.getCallInstance();
|
||||
} finally {
|
||||
setDaily(callFrame ?? null);
|
||||
|
||||
callFrame?.join();
|
||||
}
|
||||
|
||||
@@ -111,7 +111,10 @@ export default function JoinCall(props: PageProps) {
|
||||
<div
|
||||
className="mx-auto hidden sm:block"
|
||||
style={{ zIndex: 2, left: "30%", position: "absolute", bottom: 100, width: "auto" }}>
|
||||
<CalAiTranscribe showRecordingButton={showRecordingButton} />
|
||||
<CalAiTranscribe
|
||||
showRecordingButton={showRecordingButton}
|
||||
enableAutomaticTranscription={enableAutomaticTranscription}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ zIndex: 2, position: "relative" }}>
|
||||
{calVideoLogo ? (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.3.12",
|
||||
"version": "5.3.13",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"customize_calvideo_settings": "Customize Cal Video Settings",
|
||||
"calvideo_settings_description": "Configure video meeting preferences including recording permissions for hosts and attendees.",
|
||||
"disable_recording_for_guests": "Disable recording for guests",
|
||||
"enable_automatic_transcription": "Enable automatic transcription after joining the meeting",
|
||||
"disable_recording_for_organizer": "Disable recording for organizer",
|
||||
"event_request_declined_recurring": "Your recurring event request has been declined",
|
||||
"event_request_cancelled": "Your scheduled event was canceled",
|
||||
@@ -1698,6 +1699,9 @@
|
||||
"only_show_first_available_slot_description": "This will limit your availability for this event type to one slot per day, scheduled at the earliest available time.",
|
||||
"limit_total_booking_duration": "Limit total booking duration",
|
||||
"limit_total_booking_duration_description": "Limit total amount of time that this event can be booked",
|
||||
"booker_booking_limit": "Booker active booking limit",
|
||||
"booker_booking_limit_description": "Limit the number of active bookings a booker can make for this event type",
|
||||
"recurring_event_doesnt_support_booker_booking_limit": "Recurring events don't support booker booking limit",
|
||||
"add_limit": "Add Limit",
|
||||
"team_name_required": "Team name required",
|
||||
"show_attendees": "Share attendee information between guests",
|
||||
@@ -3097,6 +3101,7 @@
|
||||
"user_has_no_team_yet": "You don't have a team yet",
|
||||
"no_team_members": "You don't have team members yet",
|
||||
"recurring_event_doesnt_support_seats": "Recurring event doesn't support seats feature. Make it non-recurring to enable seats.",
|
||||
"booking_limit_per_booker_doesnt_support_recurring": "Recurring event doesn't support booking limit per booker feature. Disable booking limit per booker to enable Recurring event.",
|
||||
"seats_doesnt_support_recurring": "Seats feature doesn't support recurring event. Disable seats feature to make it recurring.",
|
||||
"recurring_event_seats_error": "Recurring event doesn't support seats feature. Disable seats feature or make the event non-recurring.",
|
||||
"segment": "Segment",
|
||||
@@ -3241,6 +3246,9 @@
|
||||
"meeting_start_time": "Meeting Start Time",
|
||||
"members_affected_by_disabling_delegation_credential": "Affected members",
|
||||
"no_members_affected_by_disabling_delegation_credential": "No members affected by disabling delegation credential",
|
||||
"offer_to_reschedule_last_booking": "Offer to reschedule last active booking to chosen time slot",
|
||||
"booker_limit_exceeded_error": "Booker maximum active booking limit exceeded",
|
||||
"booker_limit_exceeded_error_reschedule": "You already have a booking for this event on {{date}}. Would you like to reschedule to the new selected time?",
|
||||
"duration_min_error": "Duration must be at least {{min}} minute(s)",
|
||||
"duration_max_error": "Duration cannot exceed {{max}} minutes (24 hours)",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
|
||||
+1217
-46
File diff suppressed because it is too large
Load Diff
@@ -65,6 +65,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
}
|
||||
|
||||
if (status !== "completed") {
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: payment.bookingId,
|
||||
},
|
||||
data: {
|
||||
status: "CANCELLED",
|
||||
},
|
||||
});
|
||||
const url = `/${payment.booking.user.username}/${payment.booking.eventType.slug}`;
|
||||
return res.redirect(url);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
if (req.method !== "POST") {
|
||||
throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" });
|
||||
}
|
||||
|
||||
const obj: WebhookReturn = req.body as WebhookReturn;
|
||||
const excluded = { ...obj } as Partial<WebhookReturn>;
|
||||
delete excluded.hmac;
|
||||
|
||||
@@ -50,6 +50,7 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
title: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
eventTypeId: true,
|
||||
eventType: {
|
||||
select: {
|
||||
slug: true,
|
||||
@@ -64,11 +65,13 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
throw new Error("Booking or API key not found");
|
||||
}
|
||||
|
||||
const { startTime, endTime } = booking;
|
||||
const { startTime, endTime, eventTypeId } = booking;
|
||||
const bookingsWithSameTimeSlot = await prisma.booking.findMany({
|
||||
where: {
|
||||
eventTypeId,
|
||||
startTime,
|
||||
endTime,
|
||||
OR: [{ status: "PENDING" }, { status: "AWAITING_HOST" }],
|
||||
},
|
||||
select: {
|
||||
uid: true,
|
||||
@@ -169,8 +172,24 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
throw new Error("Failed to store Payment data");
|
||||
}
|
||||
return paymentData;
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
log.error("Payment could not be created", bookingId, safeStringify(error));
|
||||
try {
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: bookingId,
|
||||
},
|
||||
data: {
|
||||
status: "CANCELLED",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(ErrorCode.PaymentCreationFailure);
|
||||
}
|
||||
|
||||
if (error.message === ErrorCode.BookingSeatsFull || error.message === ErrorCode.NoAvailableUsersFound) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(ErrorCode.PaymentCreationFailure);
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -6,6 +6,7 @@ export type PaidBooking = Prisma.BookingGetPayload<{
|
||||
title: true;
|
||||
startTime: true;
|
||||
endTime: true;
|
||||
eventTypeId: true;
|
||||
eventType: {
|
||||
select: {
|
||||
slug: true;
|
||||
|
||||
@@ -237,7 +237,6 @@ const BookerComponent = ({
|
||||
bookingForm={bookingForm}
|
||||
eventQuery={event}
|
||||
extraOptions={extraOptions}
|
||||
rescheduleUid={rescheduleUid}
|
||||
isVerificationCodeSending={isVerificationCodeSending}
|
||||
confirmButtonDisabled={confirmButtonDisabled}
|
||||
classNames={{
|
||||
@@ -278,7 +277,6 @@ const BookerComponent = ({
|
||||
loadingStates,
|
||||
onGoBackInstantMeeting,
|
||||
renderConfirmNotVerifyEmailButtonCond,
|
||||
rescheduleUid,
|
||||
seatedEventData,
|
||||
setSeatedEventData,
|
||||
setSelectedTimeslot,
|
||||
|
||||
@@ -7,14 +7,18 @@ import { useIsPlatformBookerEmbed } from "@calcom/atoms/hooks/useIsPlatformBooke
|
||||
import type { BookerEvent } from "@calcom/features/bookings/types";
|
||||
import ServerTrans from "@calcom/lib/components/ServerTrans";
|
||||
import { WEBSITE_PRIVACY_POLICY_URL, WEBSITE_TERMS_URL } from "@calcom/lib/constants";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { getPaymentAppData } from "@calcom/lib/getPaymentAppData";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { TimeFormat } from "@calcom/lib/timeFormat";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
|
||||
import { Form } from "@calcom/ui/components/form";
|
||||
|
||||
import { useBookerStore } from "../../store";
|
||||
import { formatEventFromTime } from "../../utils/dates";
|
||||
import { useBookerTime } from "../hooks/useBookerTime";
|
||||
import type { UseBookingFormReturnType } from "../hooks/useBookingForm";
|
||||
import type { IUseBookingErrors, IUseBookingLoadingStates } from "../hooks/useBookings";
|
||||
import { BookingFields } from "./BookingFields";
|
||||
@@ -44,7 +48,6 @@ type BookEventFormProps = {
|
||||
export const BookEventForm = ({
|
||||
onCancel,
|
||||
eventQuery,
|
||||
rescheduleUid,
|
||||
onSubmit,
|
||||
errorRef,
|
||||
errors,
|
||||
@@ -65,18 +68,19 @@ export const BookEventForm = ({
|
||||
isPending: boolean;
|
||||
data?: Pick<BookerEvent, "price" | "currency" | "metadata" | "bookingFields" | "locations"> | null;
|
||||
};
|
||||
rescheduleUid: string | null;
|
||||
}) => {
|
||||
const eventType = eventQuery.data;
|
||||
const setFormValues = useBookerStore((state) => state.setFormValues);
|
||||
const bookingData = useBookerStore((state) => state.bookingData);
|
||||
const rescheduleUid = useBookerStore((state) => state.rescheduleUid);
|
||||
const timeslot = useBookerStore((state) => state.selectedTimeslot);
|
||||
const username = useBookerStore((state) => state.username);
|
||||
const isInstantMeeting = useBookerStore((state) => state.isInstantMeeting);
|
||||
const isPlatformBookerEmbed = useIsPlatformBookerEmbed();
|
||||
const { timeFormat, timezone } = useBookerTime();
|
||||
|
||||
const [responseVercelIdHeader] = useState<string | null>(null);
|
||||
const { t } = useLocale();
|
||||
const { t, i18n } = useLocale();
|
||||
|
||||
const isPaidEvent = useMemo(() => {
|
||||
if (!eventType?.price) return false;
|
||||
@@ -132,7 +136,15 @@ export const BookEventForm = ({
|
||||
className="my-2"
|
||||
severity="info"
|
||||
title={rescheduleUid ? t("reschedule_fail") : t("booking_fail")}
|
||||
message={getError(errors.formErrors, errors.dataErrors, t, responseVercelIdHeader)}
|
||||
message={getError({
|
||||
globalError: errors.formErrors,
|
||||
dataError: errors.dataErrors,
|
||||
t,
|
||||
responseVercelIdHeader,
|
||||
timeFormat,
|
||||
timezone,
|
||||
language: i18n.language,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
) : isTimeslotUnavailable ? (
|
||||
@@ -255,23 +267,46 @@ export const BookEventForm = ({
|
||||
);
|
||||
};
|
||||
|
||||
const getError = (
|
||||
globalError: FieldError | undefined,
|
||||
const getError = ({
|
||||
globalError,
|
||||
dataError,
|
||||
t,
|
||||
responseVercelIdHeader,
|
||||
timeFormat,
|
||||
timezone,
|
||||
language,
|
||||
}: {
|
||||
globalError: FieldError | undefined;
|
||||
// It feels like an implementation detail to reimplement the types of useMutation here.
|
||||
// Since they don't matter for this function, I'd rather disable them then giving you
|
||||
// the cognitive overload of thinking to update them here when anything changes.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
dataError: any,
|
||||
t: TFunction,
|
||||
responseVercelIdHeader: string | null
|
||||
) => {
|
||||
dataError: any;
|
||||
t: TFunction;
|
||||
responseVercelIdHeader: string | null;
|
||||
timeFormat: TimeFormat;
|
||||
timezone: string;
|
||||
language: string;
|
||||
}) => {
|
||||
if (globalError) return globalError?.message;
|
||||
|
||||
const error = dataError;
|
||||
|
||||
let date = "";
|
||||
|
||||
if (error.message === ErrorCode.BookerLimitExceededReschedule) {
|
||||
const formattedDate = formatEventFromTime({
|
||||
date: error.data.startTime,
|
||||
timeFormat,
|
||||
timeZone: timezone,
|
||||
language,
|
||||
});
|
||||
date = `${formattedDate.date} ${formattedDate.time}`;
|
||||
}
|
||||
|
||||
return error?.message ? (
|
||||
<>
|
||||
{responseVercelIdHeader ?? ""} {t(error.message)}
|
||||
{responseVercelIdHeader ?? ""} {t(error.message, { date })}
|
||||
</>
|
||||
) : (
|
||||
<>{t("can_you_try_again")}</>
|
||||
|
||||
@@ -11,9 +11,11 @@ import { sdkActionManager } from "@calcom/embed-core/embed-iframe";
|
||||
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
|
||||
import { updateQueryParam, getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param";
|
||||
import { createBooking, createRecurringBooking, createInstantBooking } from "@calcom/features/bookings/lib";
|
||||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import type { BookerEvent } from "@calcom/features/bookings/types";
|
||||
import { getFullName } from "@calcom/features/form-builder/utils";
|
||||
import { useBookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
@@ -285,6 +287,23 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata, teamMemb
|
||||
onError: (err, _, ctx) => {
|
||||
// eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- It is only called when user takes an action in embed
|
||||
bookerFormErrorRef && bookerFormErrorRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
|
||||
const error = err as Error & {
|
||||
data: { rescheduleUid: string; startTime: string; attendees: string[] };
|
||||
};
|
||||
|
||||
if (error.message === ErrorCode.BookerLimitExceededReschedule && error.data?.rescheduleUid) {
|
||||
useBookerStore.setState({
|
||||
rescheduleUid: error.data?.rescheduleUid,
|
||||
});
|
||||
useBookerStore.setState({
|
||||
bookingData: {
|
||||
uid: error.data?.rescheduleUid,
|
||||
startTime: error.data?.startTime,
|
||||
attendees: error.data?.attendees,
|
||||
} as unknown as GetBookingType,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
|
||||
import type { EventPayloadType, EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
|
||||
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
|
||||
import type { EventManagerUser } from "@calcom/lib/EventManager";
|
||||
import EventManager from "@calcom/lib/EventManager";
|
||||
import EventManager, { placeholderCreatedEvent } from "@calcom/lib/EventManager";
|
||||
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
|
||||
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
|
||||
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
|
||||
@@ -88,7 +88,8 @@ export async function handleConfirmation(args: {
|
||||
const eventTypeMetadata = EventTypeMetaDataSchema.parse(eventType?.metadata || {});
|
||||
const apps = eventTypeAppMetadataOptionalSchema.parse(eventTypeMetadata?.apps);
|
||||
const eventManager = new EventManager(user, apps);
|
||||
const scheduleResult = await eventManager.create(evt);
|
||||
const areCalendarEventsEnabled = platformClientParams?.areCalendarEventsEnabled ?? true;
|
||||
const scheduleResult = areCalendarEventsEnabled ? await eventManager.create(evt) : placeholderCreatedEvent;
|
||||
const results = scheduleResult.results;
|
||||
const metadata: AdditionalInformation = {};
|
||||
const workflows = await getAllWorkflowsFromEventType(eventType, booking.userId);
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
scheduleTrigger,
|
||||
} from "@calcom/features/webhooks/lib/scheduleTrigger";
|
||||
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
|
||||
import EventManager from "@calcom/lib/EventManager";
|
||||
import EventManager, { placeholderCreatedEvent } from "@calcom/lib/EventManager";
|
||||
import { handleAnalyticsEvents } from "@calcom/lib/analyticsManager/handleAnalyticsEvents";
|
||||
import { shouldIgnoreContactOwner } from "@calcom/lib/bookings/routing/utils";
|
||||
import { getUsernameList } from "@calcom/lib/defaultEvents";
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
getFirstDelegationConferencingCredentialAppLocation,
|
||||
} from "@calcom/lib/delegationCredential/server";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import type { ErrorWithCode } from "@calcom/lib/errors";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import { getEventName, updateHostInEventName } from "@calcom/lib/event";
|
||||
import { extractBaseEmail } from "@calcom/lib/extract-base-email";
|
||||
@@ -88,6 +89,7 @@ import { getAllCredentialsIncludeServiceAccountKey } from "./getAllCredentialsFo
|
||||
import { refreshCredentials } from "./getAllCredentialsForUsersOnEvent/refreshCredentials";
|
||||
import getBookingDataSchema from "./getBookingDataSchema";
|
||||
import { addVideoCallDataToEvent } from "./handleNewBooking/addVideoCallDataToEvent";
|
||||
import { checkActiveBookingsLimitForBooker } from "./handleNewBooking/checkActiveBookingsLimitForBooker";
|
||||
import { checkBookingAndDurationLimits } from "./handleNewBooking/checkBookingAndDurationLimits";
|
||||
import { checkIfBookerEmailIsBlocked } from "./handleNewBooking/checkIfBookerEmailIsBlocked";
|
||||
import { createBooking } from "./handleNewBooking/createBooking";
|
||||
@@ -373,6 +375,7 @@ export type PlatformParams = {
|
||||
platformBookingUrl?: string;
|
||||
platformRescheduleUrl?: string;
|
||||
platformBookingLocation?: string;
|
||||
areCalendarEventsEnabled?: boolean;
|
||||
};
|
||||
|
||||
export type BookingHandlerInput = {
|
||||
@@ -397,6 +400,7 @@ async function handler(
|
||||
platformBookingLocation,
|
||||
hostname,
|
||||
forcedSlug,
|
||||
areCalendarEventsEnabled = true,
|
||||
} = input;
|
||||
|
||||
const isPlatformBooking = !!platformClientId;
|
||||
@@ -450,6 +454,25 @@ async function handler(
|
||||
|
||||
await checkIfBookerEmailIsBlocked({ loggedInUserId: userId, bookerEmail });
|
||||
|
||||
if (!rawBookingData.rescheduleUid) {
|
||||
try {
|
||||
await checkActiveBookingsLimitForBooker({
|
||||
eventTypeId,
|
||||
maxActiveBookingsPerBooker: eventType.maxActiveBookingsPerBooker,
|
||||
bookerEmail,
|
||||
offerToRescheduleLastBooking: eventType.maxActiveBookingPerBookerOfferReschedule,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err as ErrorWithCode;
|
||||
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: error.message,
|
||||
data: error.data,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isEventTypeLoggingEnabled({ eventTypeId, usernameOrTeamName: reqBody.user })) {
|
||||
logger.settings.minLevel = 0;
|
||||
}
|
||||
@@ -1737,7 +1760,7 @@ async function handler(
|
||||
// Create a booking
|
||||
} else if (isConfirmedByDefault) {
|
||||
// Use EventManager to conditionally use all needed integrations.
|
||||
const createManager = await eventManager.create(evt);
|
||||
const createManager = areCalendarEventsEnabled ? await eventManager.create(evt) : placeholderCreatedEvent;
|
||||
if (evt.location) {
|
||||
booking.location = evt.location;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { ErrorWithCode } from "@calcom/lib/errors";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
export const checkActiveBookingsLimitForBooker = async ({
|
||||
eventTypeId,
|
||||
maxActiveBookingsPerBooker,
|
||||
bookerEmail,
|
||||
offerToRescheduleLastBooking,
|
||||
}: {
|
||||
eventTypeId: number;
|
||||
maxActiveBookingsPerBooker: number | null;
|
||||
bookerEmail: string;
|
||||
offerToRescheduleLastBooking: boolean;
|
||||
}) => {
|
||||
if (!maxActiveBookingsPerBooker) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (offerToRescheduleLastBooking) {
|
||||
await checkActiveBookingsLimitAndOfferReschedule({
|
||||
eventTypeId,
|
||||
maxActiveBookingsPerBooker,
|
||||
bookerEmail,
|
||||
});
|
||||
} else {
|
||||
await checkActiveBookingsLimit({ eventTypeId, maxActiveBookingsPerBooker, bookerEmail });
|
||||
}
|
||||
};
|
||||
|
||||
/** If we don't need the last record then we should just use COUNT */
|
||||
const checkActiveBookingsLimit = async ({
|
||||
eventTypeId,
|
||||
maxActiveBookingsPerBooker,
|
||||
bookerEmail,
|
||||
}: {
|
||||
eventTypeId: number;
|
||||
maxActiveBookingsPerBooker: number;
|
||||
bookerEmail: string;
|
||||
}) => {
|
||||
const bookingsCount = await prisma.booking.count({
|
||||
where: {
|
||||
eventTypeId,
|
||||
startTime: {
|
||||
gte: new Date(),
|
||||
},
|
||||
status: {
|
||||
in: [BookingStatus.ACCEPTED],
|
||||
},
|
||||
attendees: {
|
||||
some: {
|
||||
email: bookerEmail,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (bookingsCount >= maxActiveBookingsPerBooker) {
|
||||
throw new ErrorWithCode(ErrorCode.BookerLimitExceeded, ErrorCode.BookerLimitExceeded);
|
||||
}
|
||||
};
|
||||
|
||||
const checkActiveBookingsLimitAndOfferReschedule = async ({
|
||||
eventTypeId,
|
||||
maxActiveBookingsPerBooker,
|
||||
bookerEmail,
|
||||
}: {
|
||||
eventTypeId: number;
|
||||
maxActiveBookingsPerBooker: number;
|
||||
bookerEmail: string;
|
||||
}) => {
|
||||
const bookingsCount = await prisma.booking.findMany({
|
||||
where: {
|
||||
eventTypeId,
|
||||
startTime: {
|
||||
gte: new Date(),
|
||||
},
|
||||
status: {
|
||||
in: [BookingStatus.ACCEPTED],
|
||||
},
|
||||
attendees: {
|
||||
some: {
|
||||
email: bookerEmail,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
startTime: "desc",
|
||||
},
|
||||
take: maxActiveBookingsPerBooker,
|
||||
select: {
|
||||
uid: true,
|
||||
startTime: true,
|
||||
attendees: {
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const lastBooking = bookingsCount[bookingsCount.length - 1];
|
||||
|
||||
if (bookingsCount.length >= maxActiveBookingsPerBooker) {
|
||||
throw new ErrorWithCode(
|
||||
ErrorCode.BookerLimitExceededReschedule,
|
||||
ErrorCode.BookerLimitExceededReschedule,
|
||||
{
|
||||
rescheduleUid: lastBooking.uid,
|
||||
startTime: lastBooking.startTime,
|
||||
attendees: lastBooking.attendees,
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -65,6 +65,8 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
|
||||
maxLeadThreshold: true,
|
||||
includeNoShowInRRCalculation: true,
|
||||
minimumBookingNotice: true,
|
||||
maxActiveBookingsPerBooker: true,
|
||||
maxActiveBookingPerBookerOfferReschedule: true,
|
||||
userId: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
|
||||
@@ -9,6 +9,7 @@ export type PlatformParams = {
|
||||
platformBookingUrl?: string;
|
||||
platformRescheduleUrl?: string;
|
||||
platformBookingLocation?: string;
|
||||
areCalendarEventsEnabled?: boolean;
|
||||
};
|
||||
|
||||
export type BookingHandlerInput = {
|
||||
@@ -45,6 +46,7 @@ export const handleNewRecurringBooking = async (input: BookingHandlerInput): Pro
|
||||
platformCancelUrl: input.platformCancelUrl,
|
||||
platformBookingUrl: input.platformBookingUrl,
|
||||
platformBookingLocation: input.platformBookingLocation,
|
||||
areCalendarEventsEnabled: input.areCalendarEventsEnabled,
|
||||
};
|
||||
|
||||
if (isRoundRobin) {
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { ConfirmationDialogContent } from "@calcom/ui/components/dialog";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { revalidateAttributesList } from "@calcom/web/app/(use-page-wrapper)/settings/organizations/members/actions";
|
||||
import { revalidateAttributesList } from "@calcom/web/app/(use-page-wrapper)/settings/organizations/(org-user-only)/members/actions";
|
||||
|
||||
type AttributeItemProps = RouterOutputs["viewer"]["attributes"]["list"][number];
|
||||
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { revalidateAttributesList } from "@calcom/web/app/(use-page-wrapper)/settings/organizations/members/actions";
|
||||
import { revalidateAttributesList } from "@calcom/web/app/(use-page-wrapper)/settings/organizations/(org-user-only)/members/actions";
|
||||
|
||||
import { AttributeForm } from "./AttributesForm";
|
||||
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { revalidateAttributesList } from "@calcom/web/app/(use-page-wrapper)/settings/organizations/members/actions";
|
||||
import { revalidateAttributesList } from "@calcom/web/app/(use-page-wrapper)/settings/organizations/(org-user-only)/members/actions";
|
||||
|
||||
import { AttributeForm } from "./AttributesForm";
|
||||
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ import {
|
||||
import { Switch } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { revalidateAttributesList } from "@calcom/web/app/(use-page-wrapper)/settings/organizations/members/actions";
|
||||
import { revalidateAttributesList } from "@calcom/web/app/(use-page-wrapper)/settings/organizations/(org-user-only)/members/actions";
|
||||
|
||||
import { DeleteAttributeModal } from "./DeleteAttributeModal";
|
||||
import { ListSkeleton } from "./ListSkeleton";
|
||||
|
||||
@@ -8,7 +8,9 @@ import { doesBookingRequireConfirmation } from "@calcom/features/bookings/lib/do
|
||||
import { getAllCredentialsIncludeServiceAccountKey } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials";
|
||||
import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation";
|
||||
import stripe from "@calcom/features/ee/payments/server/stripe";
|
||||
import EventManager from "@calcom/lib/EventManager";
|
||||
import { getPlatformParams } from "@calcom/features/platform-oauth-client/get-platform-params";
|
||||
import { PlatformOAuthClientRepository } from "@calcom/features/platform-oauth-client/platform-oauth-client.repository";
|
||||
import EventManager, { placeholderCreatedEvent } from "@calcom/lib/EventManager";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import { HttpError as HttpCode } from "@calcom/lib/http-error";
|
||||
@@ -80,9 +82,18 @@ const handleSetupSuccess = async (event: Stripe.Event) => {
|
||||
metadata,
|
||||
});
|
||||
|
||||
const platformOAuthClientRepository = new PlatformOAuthClientRepository();
|
||||
const platformOAuthClient = user.isPlatformManaged
|
||||
? await platformOAuthClientRepository.getByUserId(user.id)
|
||||
: null;
|
||||
const areCalendarEventsEnabled = platformOAuthClient?.areCalendarEventsEnabled ?? true;
|
||||
const areEmailsEnabled = platformOAuthClient?.areEmailsEnabled ?? true;
|
||||
|
||||
if (!requiresConfirmation) {
|
||||
const eventManager = new EventManager({ ...user, credentials: allCredentials }, metadata?.apps);
|
||||
const scheduleResult = await eventManager.create(evt);
|
||||
const scheduleResult = areCalendarEventsEnabled
|
||||
? await eventManager.create(evt)
|
||||
: placeholderCreatedEvent;
|
||||
bookingData.references = { create: scheduleResult.referencesToCreate };
|
||||
bookingData.status = BookingStatus.ACCEPTED;
|
||||
}
|
||||
@@ -114,8 +125,9 @@ const handleSetupSuccess = async (event: Stripe.Event) => {
|
||||
bookingId: booking.id,
|
||||
booking,
|
||||
paid: true,
|
||||
platformClientParams: platformOAuthClient ? getPlatformParams(platformOAuthClient) : undefined,
|
||||
});
|
||||
} else {
|
||||
} else if (areEmailsEnabled) {
|
||||
await sendOrganizerRequestEmail({ ...evt }, eventType.metadata);
|
||||
await sendAttendeeRequestEmailAndSMS({ ...evt }, evt.attendees[0], eventType.metadata);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user