Compare commits
157
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8f5bd6fe9 | ||
|
|
a1c725bb35 | ||
|
|
e4eeb11a61 | ||
|
|
3b0bb25251 | ||
|
|
d27490e6f2 | ||
|
|
b3c191b553 | ||
|
|
0b977e615d | ||
|
|
979e1fd28b | ||
|
|
3437c4331d | ||
|
|
616d62094c | ||
|
|
ce7ff1c43c | ||
|
|
e27e41bc16 | ||
|
|
93e183e6c3 | ||
|
|
88a2bd3317 | ||
|
|
45caaa55b0 | ||
|
|
8c9b7a701d | ||
|
|
c47b992125 | ||
|
|
493c4f89dc | ||
|
|
27234dc809 | ||
|
|
5690b8e1d5 | ||
|
|
61274bc7ef | ||
|
|
e66be23996 | ||
|
|
3c43ce4165 | ||
|
|
5060824795 | ||
|
|
c4941f25a2 | ||
|
|
e7c219905f | ||
|
|
499cbd08ca | ||
|
|
0a3b3a69e3 | ||
|
|
e8607845e2 | ||
|
|
02ef4db0eb | ||
|
|
f8d48a341d | ||
|
|
251393101a | ||
|
|
b50430114f | ||
|
|
b0999abc68 | ||
|
|
2bbd92db13 | ||
|
|
64092abdca | ||
|
|
b7b631f0b0 | ||
|
|
c10f15e75b | ||
|
|
6983179b2f | ||
|
|
aea0661303 | ||
|
|
3fd9ef366f | ||
|
|
cf79a3f580 | ||
|
|
c5da78593a | ||
|
|
801b0be414 | ||
|
|
29aad2c535 | ||
|
|
82d3b9d29d | ||
|
|
a542ba486c | ||
|
|
d50e1a0e15 | ||
|
|
79a0071d65 | ||
|
|
2f1f989266 | ||
|
|
2303eb7908 | ||
|
|
c3666dd064 | ||
|
|
6ed197d35c | ||
|
|
164c9e37d7 | ||
|
|
fa0a18b31d | ||
|
|
80e2118e68 | ||
|
|
ed5086df6b | ||
|
|
218ec9140c | ||
|
|
6dad0e7679 | ||
|
|
cb96a56960 | ||
|
|
b35eb77edb | ||
|
|
89408ce12b | ||
|
|
e22d9a551c | ||
|
|
c215dc5864 | ||
|
|
a37f1d9798 | ||
|
|
07c8bbc562 | ||
|
|
b381cfe207 | ||
|
|
a560b9c8f4 | ||
|
|
e5b8cc6912 | ||
|
|
18b5612c0d | ||
|
|
fad488623f | ||
|
|
c490f084dc | ||
|
|
b1caacd643 | ||
|
|
76c8876923 | ||
|
|
a911c9cb8d | ||
|
|
07d5594085 | ||
|
|
a745d8a60a | ||
|
|
8b83d39ac3 | ||
|
|
4edb39616d | ||
|
|
7a2060fe08 | ||
|
|
d009682d4a | ||
|
|
8adb720a80 | ||
|
|
4d6f338f0c | ||
|
|
ffe9d43d09 | ||
|
|
2227b4ff91 | ||
|
|
5dd4937a1a | ||
|
|
866a0e2596 | ||
|
|
64bfda9dbe | ||
|
|
aacbbc1785 | ||
|
|
3714d5faab | ||
|
|
12cb94950a | ||
|
|
795fd788e2 | ||
|
|
956fd6ae2f | ||
|
|
04e60611ed | ||
|
|
bc99e9e504 | ||
|
|
2f35428232 | ||
|
|
00aac89ed7 | ||
|
|
f390e91bd7 | ||
|
|
e413359c8c | ||
|
|
601df7f808 | ||
|
|
e160a4add0 | ||
|
|
3bea03f4b1 | ||
|
|
19dbde8a3c | ||
|
|
a5fe10189f | ||
|
|
7075384313 | ||
|
|
25bf43cddd | ||
|
|
2880a681a8 | ||
|
|
bbcd0b7e78 | ||
|
|
916ed13bea | ||
|
|
11a06324be | ||
|
|
bcb340d97e | ||
|
|
aabef47ff1 | ||
|
|
e27de47d9f | ||
|
|
7900be3a30 | ||
|
|
ddc5838c7b | ||
|
|
6a13d7c5c6 | ||
|
|
4f0b35d9e2 | ||
|
|
3b19ede6a3 | ||
|
|
1a0ce47404 | ||
|
|
22f136d19b | ||
|
|
9a40e54159 | ||
|
|
229f2627c8 | ||
|
|
6f6454df40 | ||
|
|
3da9fc5500 | ||
|
|
540bf868b5 | ||
|
|
1674ecc585 | ||
|
|
4920abdaf7 | ||
|
|
a6b98bd35d | ||
|
|
34d4f394fc | ||
|
|
a4ef8d4200 | ||
|
|
9d9662687e | ||
|
|
2d41c719c4 | ||
|
|
b69bd966a6 | ||
|
|
09a49dd0fd | ||
|
|
3dd3372c35 | ||
|
|
0a60864310 | ||
|
|
cb24e5d0aa | ||
|
|
8c5aebdca1 | ||
|
|
4e2cff0fd5 | ||
|
|
54e518efad | ||
|
|
ad93099171 | ||
|
|
630065ca89 | ||
|
|
ddf5717992 | ||
|
|
4a53ec0b74 | ||
|
|
d7fd055171 | ||
|
|
874973d78f | ||
|
|
8078c5657b | ||
|
|
b39404860f | ||
|
|
860eae8dcb | ||
|
|
b931d3ee13 | ||
|
|
ba8a71bf3a | ||
|
|
8de4897042 | ||
|
|
6de6f8e697 | ||
|
|
e55ecff899 | ||
|
|
3895ef002e | ||
|
|
afc28503a5 | ||
|
|
e6497c87ca |
@@ -7,7 +7,7 @@
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": [],
|
||||
"ignore": ["@calcom/platform-libraries"],
|
||||
"privatePackages": {
|
||||
"version": false,
|
||||
"tag": false
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@
|
||||
/packages/lib/server/getLuckyUser.ts @calcom/Foundation
|
||||
/packages/lib/slots.ts @calcom/Foundation
|
||||
/packages/platform/**/* @calcom/Platform @calcom/Foundation
|
||||
/packages/prisma/**/* @calcom/Foundation
|
||||
/packages/prisma/**/* @calcom/DBA
|
||||
/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @calcom/Foundation
|
||||
/packages/trpc/server/routers/viewer/bookings/get.handler.ts @calcom/Foundation
|
||||
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
contents: write # to create release by changesets/action below
|
||||
pull-requests: write # to create pull request by changesets/action below
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
Vendored
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
undecided:
|
||||
- "@calcom/platform-constants"
|
||||
- "@calcom/platform-enums"
|
||||
- "@calcom/platform-types"
|
||||
- "@calcom/platform-utils"
|
||||
- "@calcom/prisma"
|
||||
Vendored
Vendored
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
undecided:
|
||||
- "@calcom/app-store-cli"
|
||||
- "@calcom/platform-constants"
|
||||
- "@calcom/platform-enums"
|
||||
- "@calcom/platform-types"
|
||||
- "@calcom/platform-utils"
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/prisma"
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/app-store-cli"
|
||||
- "@calcom/platform-constants"
|
||||
- "@calcom/platform-enums"
|
||||
- "@calcom/platform-types"
|
||||
- "@calcom/platform-utils"
|
||||
- "@calcom/prisma"
|
||||
Vendored
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/prisma"
|
||||
@@ -3,6 +3,7 @@
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node ./index.js"
|
||||
},
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"next-axiom": "^0.17.0",
|
||||
"next-swagger-doc": "^0.3.6",
|
||||
"next-validations": "^0.2.0",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript": "^5.7.2",
|
||||
"tzdata": "^1.0.30",
|
||||
"uuid": "^8.3.2",
|
||||
"zod": "^3.22.4"
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"description": "Platform API for Cal.com",
|
||||
"author": "Cal.com Inc.",
|
||||
"license": "UNLICENSED",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "yarn dev:build && nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
@@ -37,7 +38,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.231",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
@@ -108,7 +109,7 @@
|
||||
"ts-loader": "^9.4.3",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "^4.1.0",
|
||||
"typescript": "^5.8.3"
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"prisma": {
|
||||
"schema": "../../../packages/prisma/schema.prisma"
|
||||
|
||||
@@ -86,10 +86,16 @@ export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer): void {
|
||||
consumer
|
||||
.apply(RawBodyMiddleware)
|
||||
.forRoutes({
|
||||
path: "/api/v2/billing/webhook",
|
||||
method: RequestMethod.POST,
|
||||
})
|
||||
.forRoutes(
|
||||
{
|
||||
path: "/api/v2/billing/webhook",
|
||||
method: RequestMethod.POST,
|
||||
},
|
||||
{
|
||||
path: "/v2/billing/webhook",
|
||||
method: RequestMethod.POST,
|
||||
}
|
||||
)
|
||||
.apply(JsonBodyMiddleware)
|
||||
.forRoutes("*")
|
||||
.apply(RequestIdMiddleware)
|
||||
|
||||
@@ -81,5 +81,9 @@ export const bootstrap = (app: NestExpressApplication): NestExpressApplication =
|
||||
|
||||
app.use(cookieParser());
|
||||
|
||||
if (process?.env?.API_GLOBAL_PREFIX) {
|
||||
app.setGlobalPrefix(process?.env?.API_GLOBAL_PREFIX);
|
||||
}
|
||||
|
||||
return app;
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
@@ -233,7 +267,14 @@ export class BookingsController_2024_08_13 {
|
||||
@ApiOperation({
|
||||
summary: "Cancel a booking",
|
||||
description: `:bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.
|
||||
For seated bookings to cancel one individual booking provide :bookingUid and :seatUid in the request body. For recurring seated bookings it is not possible to cancel all of them with 1 call
|
||||
|
||||
\nCancelling seated bookings:
|
||||
It is possible to cancel specific seat within a booking as an attendee or all of the seats as the host.
|
||||
\n1. As an attendee - provide :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and seatUid in the request body \`{"seatUid": "123-123-123"}\` . This will remove this particular attendance from the booking.
|
||||
\n2. As the host - host can cancel booking for all attendees aka for every seat. Provide :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and cancellationReason in the request body \`{"cancellationReason": "Will travel"}\` and \`Authorization: Bearer token\` request header where token is event type owner (host) credential. This will cancel the booking for all attendees.
|
||||
|
||||
\nCancelling recurring seated bookings:
|
||||
For recurring seated bookings it is not possible to cancel all of them with 1 call
|
||||
like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.`,
|
||||
})
|
||||
@ApiBody({
|
||||
|
||||
+206
-78
@@ -14,29 +14,27 @@ import { Test } from "@nestjs/testing";
|
||||
import { User } from "@prisma/client";
|
||||
import { DateTime } from "luxon";
|
||||
import * as request from "supertest";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { BookingSeatRepositoryFixture } from "test/fixtures/repository/booking-seat.repository.fixture";
|
||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||
import {
|
||||
CancelBookingInput_2024_08_13,
|
||||
CancelSeatedBookingInput_2024_08_13,
|
||||
CreateRecurringSeatedBookingOutput_2024_08_13,
|
||||
CreateSeatedBookingOutput_2024_08_13,
|
||||
GetBookingOutput_2024_08_13,
|
||||
GetBookingsOutput_2024_08_13,
|
||||
GetRecurringSeatedBookingOutput_2024_08_13,
|
||||
GetSeatedBookingOutput_2024_08_13,
|
||||
RescheduleSeatedBookingInput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
import {
|
||||
CreateBookingInput_2024_08_13,
|
||||
CreateRecurringBookingInput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
import { CreateBookingInput_2024_08_13 } from "@calcom/platform-types";
|
||||
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Bookings Endpoints 2024-08-13", () => {
|
||||
@@ -51,11 +49,13 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
let schedulesService: SchedulesService_2024_04_15;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let oAuthClient: PlatformOAuthClient;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let bookingSeatRepositoryFixture: BookingSeatRepositoryFixture;
|
||||
|
||||
const userEmail = `seated-bookings-user-${randomString()}@api.com`;
|
||||
let user: User;
|
||||
let apiKeyString: string;
|
||||
|
||||
let seatedEventTypeId: number;
|
||||
const maxRecurrenceCount = 3;
|
||||
@@ -63,6 +63,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const seatedEventSlug = `seated-bookings-event-type-${randomString()}`;
|
||||
|
||||
let createdSeatedBooking: CreateSeatedBookingOutput_2024_08_13;
|
||||
let createdSeatedBooking2: CreateSeatedBookingOutput_2024_08_13;
|
||||
|
||||
const emailAttendeeOne = `seated-bookings-attendee1-${randomString()}@api.com`;
|
||||
const nameAttendeeOne = `Attendee One ${randomString()}`;
|
||||
@@ -70,12 +71,9 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const nameAttendeeTwo = `Attendee Two ${randomString()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
)
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
@@ -87,22 +85,21 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
bookingSeatRepositoryFixture = new BookingSeatRepositoryFixture(moduleRef);
|
||||
|
||||
organization = await teamRepositoryFixture.create({
|
||||
name: `seated-bookings-organization-${randomString()}`,
|
||||
});
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
platformOAuthClients: {
|
||||
connect: {
|
||||
id: oAuthClient.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
|
||||
apiKeyString = `cal_test_${keyString}`;
|
||||
|
||||
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||
name: `seated-bookings-2024-08-13-schedule-${randomString()}`,
|
||||
timeZone: "Europe/Rome",
|
||||
@@ -118,6 +115,14 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
seatsShowAttendees: true,
|
||||
seatsShowAvailabilityCount: true,
|
||||
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
|
||||
metadata: {
|
||||
disableStandardEmails: {
|
||||
all: {
|
||||
attendee: true,
|
||||
host: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
user.id
|
||||
);
|
||||
@@ -129,19 +134,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
await app.init();
|
||||
});
|
||||
|
||||
async function createOAuthClient(organizationId: number) {
|
||||
const data = {
|
||||
logo: "logo-url",
|
||||
name: "name",
|
||||
redirectUris: ["http://localhost:5555"],
|
||||
permissions: 32,
|
||||
};
|
||||
const secret = "secret";
|
||||
|
||||
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||
return client;
|
||||
}
|
||||
|
||||
it("should book an event type with seats for the first time", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
|
||||
@@ -333,6 +325,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
return request(app.getHttpServer())
|
||||
.get("/v2/bookings?sortCreated=asc")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
@@ -350,6 +343,22 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should not be able to reschedule seated booking if seatUid is not provided", async () => {
|
||||
const body = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(),
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${createdSeatedBooking.uid}/reschedule`)
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error.message).toEqual(
|
||||
`Booking with uid=${createdSeatedBooking.uid} is a seated booking which means you have to provide seatUid in the request body to specify which seat specifically you want to reschedule. First, fetch the booking using https://cal.com/docs/api-reference/v2/bookings/get-a-booking and then within the attendees array you will find the seatUid of the booking you want to reschedule. Second, provide the seatUid in the request body to specify which seat specifically you want to reschedule using the reschedule endpoint https://cal.com/docs/api-reference/v2/bookings/reschedule-a-booking#option-2`
|
||||
);
|
||||
});
|
||||
|
||||
it("should reschedule seated booking", async () => {
|
||||
const body: RescheduleSeatedBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(),
|
||||
@@ -409,67 +418,186 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should cancel seated booking", async () => {
|
||||
const body: CancelSeatedBookingInput_2024_08_13 = {
|
||||
seatUid: createdSeatedBooking.seatUid,
|
||||
};
|
||||
describe("cancel seated booking", () => {
|
||||
describe("cancel seated booking as attendee", () => {
|
||||
it("should cancel seated booking", async () => {
|
||||
const body: CancelSeatedBookingInput_2024_08_13 = {
|
||||
seatUid: createdSeatedBooking.seatUid,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${createdSeatedBooking.uid}/cancel`)
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true);
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${createdSeatedBooking.uid}/cancel`)
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsGetSeatedBooking(responseBody.data)) {
|
||||
const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts[0].id).toEqual(user.id);
|
||||
expect(data.status).toEqual("cancelled");
|
||||
expect(data.start).toEqual(createdSeatedBooking.start);
|
||||
expect(data.end).toEqual(createdSeatedBooking.end);
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(seatedEventTypeId);
|
||||
expect(data.eventType).toEqual({
|
||||
id: seatedEventTypeId,
|
||||
slug: seatedEventSlug,
|
||||
if (responseDataIsGetSeatedBooking(responseBody.data)) {
|
||||
const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts[0].id).toEqual(user.id);
|
||||
expect(data.status).toEqual("cancelled");
|
||||
expect(data.start).toEqual(createdSeatedBooking.start);
|
||||
expect(data.end).toEqual(createdSeatedBooking.end);
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(seatedEventTypeId);
|
||||
expect(data.eventType).toEqual({
|
||||
id: seatedEventTypeId,
|
||||
slug: seatedEventSlug,
|
||||
});
|
||||
expect(data.attendees.length).toEqual(0);
|
||||
expect(data.location).toBeDefined();
|
||||
expect(data.absentHost).toEqual(false);
|
||||
} else {
|
||||
throw new Error("Invalid response data - expected booking but received array response");
|
||||
}
|
||||
});
|
||||
expect(data.attendees.length).toEqual(0);
|
||||
expect(data.location).toBeDefined();
|
||||
expect(data.absentHost).toEqual(false);
|
||||
} else {
|
||||
throw new Error("Invalid response data - expected booking but received array response");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancel seated booking as host", () => {
|
||||
it("should book an event type with seats", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 14, 0, 0)).toISOString(),
|
||||
eventTypeId: seatedEventTypeId,
|
||||
attendee: {
|
||||
name: nameAttendeeOne,
|
||||
email: emailAttendeeOne,
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
bookingFieldsResponses: {
|
||||
codingLanguage: "TypeScript",
|
||||
},
|
||||
metadata: {
|
||||
userId: "100",
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsCreateSeatedBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsCreateSeatedBooking(responseBody.data)) {
|
||||
const data: CreateSeatedBookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.seatUid).toBeDefined();
|
||||
const seatUid = data.seatUid;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts[0].id).toEqual(user.id);
|
||||
expect(data.status).toEqual("accepted");
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(
|
||||
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
|
||||
);
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(seatedEventTypeId);
|
||||
expect(data.eventType).toEqual({
|
||||
id: seatedEventTypeId,
|
||||
slug: seatedEventSlug,
|
||||
});
|
||||
expect(data.attendees.length).toEqual(1);
|
||||
expect(data.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
seatUid,
|
||||
bookingFieldsResponses: {
|
||||
name: body.attendee.name,
|
||||
...body.bookingFieldsResponses,
|
||||
},
|
||||
metadata: body.metadata,
|
||||
});
|
||||
expect(data.location).toBeDefined();
|
||||
expect(data.absentHost).toEqual(false);
|
||||
createdSeatedBooking2 = data;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected recurring booking but received non array response"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should not be able to cancel without cancellation reason", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${createdSeatedBooking2.uid}/cancel`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.message).toEqual("Cancellation reason is required when you are the host");
|
||||
});
|
||||
|
||||
it("should cancel seated booking", async () => {
|
||||
const body: CancelBookingInput_2024_08_13 = {
|
||||
cancellationReason: "I will be travelling without internet",
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${createdSeatedBooking2.uid}/cancel`)
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsGetSeatedBooking(responseBody.data)) {
|
||||
const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts[0].id).toEqual(user.id);
|
||||
expect(data.status).toEqual("cancelled");
|
||||
expect(data.start).toEqual(createdSeatedBooking2.start);
|
||||
expect(data.end).toEqual(createdSeatedBooking2.end);
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(seatedEventTypeId);
|
||||
expect(data.eventType).toEqual({
|
||||
id: seatedEventTypeId,
|
||||
slug: seatedEventSlug,
|
||||
});
|
||||
expect(data.attendees.length).toEqual(0);
|
||||
expect(data.location).toBeDefined();
|
||||
expect(data.absentHost).toEqual(false);
|
||||
expect(data.cancellationReason).toEqual("I will be travelling without internet");
|
||||
|
||||
const seats = await bookingSeatRepositoryFixture.findAllByBookingId(data.id);
|
||||
expect(seats.length).toEqual(0);
|
||||
} else {
|
||||
throw new Error("Invalid response data - expected booking but received array response");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function responseDataIsCreateSeatedBooking(data: any): data is CreateSeatedBookingOutput_2024_08_13 {
|
||||
return data.hasOwnProperty("seatUid");
|
||||
}
|
||||
|
||||
function responseDataIsCreateRecurringSeatedBooking(
|
||||
data: any
|
||||
): data is CreateRecurringSeatedBookingOutput_2024_08_13[] {
|
||||
return Array.isArray(data);
|
||||
}
|
||||
|
||||
function responseDataIsGetSeatedBooking(data: any): data is GetSeatedBookingOutput_2024_08_13 {
|
||||
return data?.attendees?.every((attendee: any) => attendee?.hasOwnProperty("seatUid"));
|
||||
}
|
||||
|
||||
function responseDataIsGetRecurringSeatedBooking(
|
||||
data: any
|
||||
): data is GetRecurringSeatedBookingOutput_2024_08_13[] {
|
||||
return Array.isArray(data);
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
X_CAL_CLIENT_ID,
|
||||
} from "@calcom/platform-constants";
|
||||
import { AttendeeScheduledEmail, OrganizerScheduledEmail } from "@calcom/platform-libraries/emails";
|
||||
import { EventManager } from "@calcom/platform-libraries/event-types";
|
||||
import {
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
FAILED_EVENT_TYPE_IDENTIFICATION_ERROR_MESSAGE,
|
||||
@@ -78,6 +79,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const eventTypeSlug = `user-bookings-event-type-${randomString()}`;
|
||||
let recurringEventTypeId: number;
|
||||
const recurringEventTypeSlug = `user-bookings-event-type-${randomString()}`;
|
||||
let eventTypeRequiresConfirmationId: number;
|
||||
const eventTypeRequiresConfirmationSlug = `user-bookings-event-type-requires-confirmation-${randomString()}`;
|
||||
|
||||
let createdBooking: BookingOutput_2024_08_13;
|
||||
let rescheduledBooking: BookingOutput_2024_08_13;
|
||||
@@ -153,6 +156,17 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
);
|
||||
recurringEventTypeId = recurringEvent.id;
|
||||
|
||||
const eventTypeRequiresConfirmation = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `user-bookings-2024-08-13-event-type-requires-confirmation-${randomString()}`,
|
||||
slug: eventTypeRequiresConfirmationSlug,
|
||||
length: 60,
|
||||
requiresConfirmation: true,
|
||||
},
|
||||
user.id
|
||||
);
|
||||
eventTypeRequiresConfirmationId = eventTypeRequiresConfirmation.id;
|
||||
|
||||
bookingInThePast = await bookingsRepositoryFixture.create({
|
||||
user: {
|
||||
connect: {
|
||||
@@ -2539,6 +2553,285 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("calendar events", () => {
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest
|
||||
.spyOn(EventManager.prototype, "create")
|
||||
.mockImplementation(() => Promise.resolve({ results: [], referencesToCreate: [] }));
|
||||
});
|
||||
|
||||
describe("platform oAuth client has calendar events enabled", () => {
|
||||
beforeAll(async () => {
|
||||
await oauthClientRepositoryFixture.update(oAuthClient.id, {
|
||||
areCalendarEventsEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should create calendar event when booking normal event", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2040, 0, 9, 9, 0, 0)).toISOString(),
|
||||
eventTypeId,
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(EventManager.prototype.create).toHaveBeenCalledTimes(1);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should create calendar events when booking recurring event", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2040, 0, 10, 9, 0, 0)).toISOString(),
|
||||
eventTypeId: recurringEventTypeId,
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(EventManager.prototype.create).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("event type requires confirmation", () => {
|
||||
let bookingThatRequiresConfirmationUid: string;
|
||||
|
||||
it("should not create calendar event when booking event that requires confirmation", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2040, 0, 12, 9, 0, 0)).toISOString(),
|
||||
eventTypeId: eventTypeRequiresConfirmationId,
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(EventManager.prototype.create).toHaveBeenCalledTimes(0);
|
||||
bookingThatRequiresConfirmationUid = data.uid;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should create calendar event when confirming event that requires confirmation", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${bookingThatRequiresConfirmationUid}/confirm`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(EventManager.prototype.create).toHaveBeenCalledTimes(1);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("platform oAuth client has calendar events disabled", () => {
|
||||
beforeAll(async () => {
|
||||
await oauthClientRepositoryFixture.update(oAuthClient.id, {
|
||||
areCalendarEventsEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should not create calendar event when booking", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2040, 0, 9, 10, 0, 0)).toISOString(),
|
||||
eventTypeId,
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
};
|
||||
|
||||
const beforeCreate = new Date();
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const afterCreate = new Date();
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(EventManager.prototype.create).toHaveBeenCalledTimes(0);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should not create calendar events when booking recurring event", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2040, 0, 11, 10, 0, 0)).toISOString(),
|
||||
eventTypeId: recurringEventTypeId,
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(EventManager.prototype.create).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("event type requires confirmation", () => {
|
||||
let bookingThatRequiresConfirmationUid: string;
|
||||
|
||||
it("should not create calendar event when booking event that requires confirmation", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2040, 0, 12, 10, 0, 0)).toISOString(),
|
||||
eventTypeId: eventTypeRequiresConfirmationId,
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(EventManager.prototype.create).toHaveBeenCalledTimes(0);
|
||||
bookingThatRequiresConfirmationUid = data.uid;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should not create calendar event when confirming event that requires confirmation", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${bookingThatRequiresConfirmationUid}/confirm`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(EventManager.prototype.create).toHaveBeenCalledTimes(0);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function responseDataIsRecurranceBooking(data: any): data is RecurringBookingOutput_2024_08_13 {
|
||||
return (
|
||||
!Array.isArray(data) &&
|
||||
|
||||
@@ -404,6 +404,7 @@ export class BookingsService_2024_08_13 {
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
noEmail: bookingRequest.noEmail,
|
||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
||||
});
|
||||
const ids = bookings.map((booking) => booking.id || 0);
|
||||
return this.outputService.getOutputRecurringBookings(ids);
|
||||
@@ -424,6 +425,7 @@ export class BookingsService_2024_08_13 {
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
||||
});
|
||||
return this.outputService.getOutputCreateRecurringSeatedBookings(
|
||||
bookings.map((booking) => ({ uid: booking.uid || "", seatUid: booking.seatReferenceUid || "" }))
|
||||
@@ -445,6 +447,7 @@ export class BookingsService_2024_08_13 {
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
||||
});
|
||||
|
||||
if (!booking.uid) {
|
||||
@@ -475,6 +478,7 @@ export class BookingsService_2024_08_13 {
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
||||
});
|
||||
|
||||
if (!booking.uid) {
|
||||
@@ -652,6 +656,7 @@ export class BookingsService_2024_08_13 {
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
||||
});
|
||||
if (!booking.uid) {
|
||||
throw new Error("Booking missing uid");
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import {
|
||||
getRecordingsOfCalVideoByRoomName,
|
||||
getAllTranscriptsAccessLinkFromRoomName,
|
||||
getDownloadLinkOfCalVideoByRecordingId,
|
||||
} from "@calcom/platform-libraries/conferencing";
|
||||
|
||||
@Injectable()
|
||||
export class CalVideoService {
|
||||
private readonly logger = new Logger("CalVideoService");
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository_2024_08_13,
|
||||
private readonly outputService: OutputBookingsService_2024_08_13
|
||||
) {}
|
||||
|
||||
async getRecordings(bookingUid: string) {
|
||||
const booking = await this.bookingsRepository.getByUidWithBookingReference(bookingUid);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
|
||||
}
|
||||
|
||||
const roomName =
|
||||
booking?.references?.filter((reference) => reference.type === "daily_video")?.pop()?.meetingId ??
|
||||
undefined;
|
||||
if (!roomName) {
|
||||
throw new NotFoundException(`No Cal Video reference found with booking uid ${bookingUid}`);
|
||||
}
|
||||
|
||||
const recordings = await getRecordingsOfCalVideoByRoomName(roomName);
|
||||
|
||||
if (!recordings || !("data" in recordings)) return [];
|
||||
|
||||
const recordingWithDownloadLink = recordings.data.map((recording) => {
|
||||
return getDownloadLinkOfCalVideoByRecordingId(recording.id)
|
||||
.then((res: { download_link: string } | undefined) => ({
|
||||
id: recording.id,
|
||||
roomName: recording.room_name,
|
||||
startTs: recording.start_ts,
|
||||
status: recording.status,
|
||||
maxParticipants: recording.max_participants,
|
||||
duration: recording.duration,
|
||||
shareToken: recording.share_token,
|
||||
downloadLink: res?.download_link,
|
||||
}))
|
||||
.catch((err: Error) => ({
|
||||
id: recording.id,
|
||||
roomName: recording.room_name,
|
||||
startTs: recording.start_ts,
|
||||
status: recording.status,
|
||||
maxParticipants: recording.max_participants,
|
||||
duration: recording.duration,
|
||||
shareToken: recording.share_token,
|
||||
downloadLink: null,
|
||||
error: err.message,
|
||||
}));
|
||||
});
|
||||
const allRecordingsWithDownloadLink = await Promise.all(recordingWithDownloadLink);
|
||||
|
||||
return allRecordingsWithDownloadLink;
|
||||
}
|
||||
|
||||
async getTranscripts(bookingUid: string) {
|
||||
const booking = await this.bookingsRepository.getByUidWithBookingReference(bookingUid);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
|
||||
}
|
||||
|
||||
const roomName =
|
||||
booking?.references?.filter((reference) => reference.type === "daily_video")?.pop()?.meetingId ??
|
||||
undefined;
|
||||
|
||||
if (!roomName) {
|
||||
throw new NotFoundException(`No Cal Video reference found with booking uid ${bookingUid}`);
|
||||
}
|
||||
|
||||
const transcripts = await getAllTranscriptsAccessLinkFromRoomName(roomName);
|
||||
|
||||
return transcripts;
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@ type OAuthRequestParams = {
|
||||
platformBookingUrl: string;
|
||||
platformBookingLocation?: string;
|
||||
arePlatformEmailsEnabled: boolean;
|
||||
areCalendarEventsEnabled: boolean;
|
||||
};
|
||||
|
||||
export enum Frequency {
|
||||
@@ -585,6 +586,11 @@ export class InputBookingsService_2024_08_13 {
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event type with id=${booking.eventTypeId} not found`);
|
||||
}
|
||||
if (eventType.seatsPerTimeSlot) {
|
||||
throw new BadRequestException(
|
||||
`Booking with uid=${bookingUid} is a seated booking which means you have to provide seatUid in the request body to specify which seat specifically you want to reschedule. First, fetch the booking using https://cal.com/docs/api-reference/v2/bookings/get-a-booking and then within the attendees array you will find the seatUid of the booking you want to reschedule. Second, provide the seatUid in the request body to specify which seat specifically you want to reschedule using the reschedule endpoint https://cal.com/docs/api-reference/v2/bookings/reschedule-a-booking#option-2`
|
||||
);
|
||||
}
|
||||
|
||||
const bookingResponses = safeParse(
|
||||
bookingResponsesSchema,
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
RecurringBookingOutput_2024_08_13,
|
||||
SeatedAttendee,
|
||||
} from "@calcom/platform-types";
|
||||
import { Booking, BookingReference, BookingSeat } from "@calcom/prisma/client";
|
||||
import { Booking, BookingSeat } from "@calcom/prisma/client";
|
||||
|
||||
export const bookingResponsesSchema = z
|
||||
.object({
|
||||
@@ -266,6 +266,7 @@ export class OutputBookingsService_2024_08_13 {
|
||||
description: databaseBooking.description,
|
||||
hosts: [this.getHost(databaseBooking.user)],
|
||||
status: databaseBooking.status.toLowerCase(),
|
||||
cancellationReason: databaseBooking.cancellationReason || undefined,
|
||||
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||
start: databaseBooking.startTime,
|
||||
end: databaseBooking.endTime,
|
||||
|
||||
@@ -54,6 +54,7 @@ export class PlatformBookingsService {
|
||||
platformRescheduleUrl: oAuthClient.bookingRescheduleRedirectUri,
|
||||
platformBookingUrl: oAuthClient.bookingRedirectUri,
|
||||
arePlatformEmailsEnabled: oAuthClient.areEmailsEnabled,
|
||||
areCalendarEventsEnabled: oAuthClient.areCalendarEventsEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,6 +79,7 @@ export class PlatformBookingsService {
|
||||
platformRescheduleUrl: oAuthClient.bookingRescheduleRedirectUri,
|
||||
platformBookingUrl: oAuthClient.bookingRedirectUri,
|
||||
arePlatformEmailsEnabled: oAuthClient.areEmailsEnabled,
|
||||
areCalendarEventsEnabled: oAuthClient.areCalendarEventsEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||
import { CalendarsController } from "@/ee/calendars/controllers/calendars.controller";
|
||||
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
|
||||
@@ -25,8 +27,10 @@ import { Module } from "@nestjs/common";
|
||||
SelectedCalendarsRepository,
|
||||
AppsRepository,
|
||||
CalendarsRepository,
|
||||
BookingsRepository_2024_08_13,
|
||||
BookingReferencesRepository_2024_08_13,
|
||||
],
|
||||
controllers: [CalendarsController],
|
||||
exports: [CalendarsService],
|
||||
exports: [CalendarsService, GoogleCalendarService],
|
||||
})
|
||||
export class CalendarsModule {}
|
||||
|
||||
@@ -54,6 +54,23 @@ import {
|
||||
} from "@calcom/platform-constants";
|
||||
import { ApiResponse, CalendarBusyTimesInput, CreateCalendarCredentialsInput } from "@calcom/platform-types";
|
||||
|
||||
export interface CalendarState {
|
||||
accessToken: string;
|
||||
origin: string;
|
||||
redir?: string;
|
||||
isDryRun?: boolean;
|
||||
}
|
||||
|
||||
const calendarStateSchema = z.object({
|
||||
accessToken: z.string(),
|
||||
origin: z.string(),
|
||||
redir: z.string().optional(),
|
||||
isDryRun: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => val === "true"),
|
||||
});
|
||||
|
||||
@Controller({
|
||||
path: "/v2/calendars",
|
||||
version: API_VERSIONS_VALUES,
|
||||
@@ -181,32 +198,31 @@ export class CalendarsController {
|
||||
@Query("code") code: string,
|
||||
@Param("calendar") calendar: string
|
||||
): Promise<{ url: string }> {
|
||||
// state params contains our user access token
|
||||
const stateParams = new URLSearchParams(state);
|
||||
const { accessToken, origin, redir, isDryRun } = z
|
||||
.object({
|
||||
accessToken: z.string(),
|
||||
origin: z.string(),
|
||||
redir: z.string().nullish().optional(),
|
||||
isDryRun: z.string().nullish().optional(),
|
||||
})
|
||||
.parse({
|
||||
let stateObj: CalendarState;
|
||||
|
||||
try {
|
||||
// First try to parse as JSON
|
||||
stateObj = JSON.parse(state) as CalendarState;
|
||||
} catch (e) {
|
||||
// If JSON parsing fails, try URL params
|
||||
const stateParams = new URLSearchParams(state);
|
||||
|
||||
const parsedState = calendarStateSchema.parse({
|
||||
accessToken: stateParams.get("accessToken"),
|
||||
origin: stateParams.get("origin"),
|
||||
redir: stateParams.get("redir"),
|
||||
redir: stateParams.get("redir") || undefined,
|
||||
isDryRun: stateParams.get("isDryRun"),
|
||||
});
|
||||
|
||||
stateObj = parsedState;
|
||||
}
|
||||
|
||||
const { accessToken, origin, redir, isDryRun } = stateObj;
|
||||
switch (calendar) {
|
||||
case OFFICE_365_CALENDAR:
|
||||
return await this.outlookService.save(code, accessToken, origin, redir ?? "", isDryRun === "true");
|
||||
return await this.outlookService.save(code, accessToken, origin, redir ?? "", !!isDryRun);
|
||||
case GOOGLE_CALENDAR:
|
||||
return await this.googleCalendarService.save(
|
||||
code,
|
||||
accessToken,
|
||||
origin,
|
||||
redir ?? "",
|
||||
isDryRun === "true"
|
||||
);
|
||||
return await this.googleCalendarService.save(code, accessToken, origin, redir ?? "", !!isDryRun);
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid calendar type, available calendars are: ",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { OAuthCalendarApp } from "@/ee/calendars/calendars.interface";
|
||||
import type { CalendarState } from "@/ee/calendars/controllers/calendars.controller";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
@@ -23,7 +24,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");
|
||||
|
||||
@@ -65,12 +66,18 @@ export class GoogleCalendarService implements OAuthCalendarApp {
|
||||
|
||||
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string, isDryRun?: boolean) {
|
||||
const oAuth2Client = await this.getOAuthClient(this.redirectUri);
|
||||
const state: CalendarState = {
|
||||
accessToken,
|
||||
origin,
|
||||
redir,
|
||||
isDryRun,
|
||||
};
|
||||
|
||||
const authUrl = oAuth2Client.generateAuthUrl({
|
||||
access_type: "offline",
|
||||
scope: CALENDAR_SCOPES,
|
||||
prompt: "consent",
|
||||
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}&isDryRun=${isDryRun}`,
|
||||
state: JSON.stringify(state),
|
||||
});
|
||||
|
||||
return authUrl;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { OAuthCalendarApp } from "@/ee/calendars/calendars.interface";
|
||||
import { CalendarState } from "@/ee/calendars/controllers/calendars.controller";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
@@ -60,6 +61,13 @@ export class OutlookService implements OAuthCalendarApp {
|
||||
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string, isDryRun?: boolean) {
|
||||
const { client_id } = await this.calendarsService.getAppKeys(OFFICE_365_CALENDAR_ID);
|
||||
|
||||
const state: CalendarState = {
|
||||
accessToken,
|
||||
origin,
|
||||
redir,
|
||||
isDryRun,
|
||||
};
|
||||
|
||||
const scopes = ["User.Read", "Calendars.Read", "Calendars.ReadWrite", "offline_access"];
|
||||
const params = {
|
||||
response_type: "code",
|
||||
@@ -67,7 +75,7 @@ export class OutlookService implements OAuthCalendarApp {
|
||||
client_id,
|
||||
prompt: "select_account",
|
||||
redirect_uri: this.redirectUri,
|
||||
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}&isDryRun=${isDryRun}`,
|
||||
state: JSON.stringify(state),
|
||||
};
|
||||
|
||||
const query = stringify(params);
|
||||
|
||||
+137
@@ -34,6 +34,7 @@ import {
|
||||
ApiSuccessResponse,
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
EventTypeOutput_2024_06_14,
|
||||
GuestsDefaultFieldOutput_2024_06_14,
|
||||
NameDefaultFieldInput_2024_06_14,
|
||||
NotesDefaultFieldInput_2024_06_14,
|
||||
UpdateEventTypeInput_2024_06_14,
|
||||
@@ -535,6 +536,7 @@ describe("Event types Endpoints", () => {
|
||||
body.lockTimeZoneToggleOnBookingPage
|
||||
);
|
||||
expect(createdEventType.color).toEqual(body.color);
|
||||
expect(createdEventType.disableGuests).toEqual(false);
|
||||
|
||||
const expectedBookingFields = [
|
||||
{ ...defaultResponseBookingFieldName, ...nameBookingField },
|
||||
@@ -962,6 +964,10 @@ describe("Event types Endpoints", () => {
|
||||
title: newTitle,
|
||||
scheduleId: secondSchedule.id,
|
||||
lengthInMinutesOptions: [15, 30],
|
||||
calVideoSettings: {
|
||||
disableRecordingForGuests: true,
|
||||
disableRecordingForOrganizer: true,
|
||||
},
|
||||
bookingFields: [
|
||||
nameBookingField,
|
||||
{
|
||||
@@ -1037,6 +1043,7 @@ describe("Event types Endpoints", () => {
|
||||
expect(updatedEventType.description).toEqual(eventType.description);
|
||||
expect(updatedEventType.lengthInMinutes).toEqual(eventType.lengthInMinutes);
|
||||
expect(updatedEventType.locations).toEqual(eventType.locations);
|
||||
expect(updatedEventType.disableGuests).toEqual(false);
|
||||
|
||||
const expectedBookingFields = [
|
||||
{ ...defaultResponseBookingFieldName, ...nameBookingField },
|
||||
@@ -1082,6 +1089,12 @@ describe("Event types Endpoints", () => {
|
||||
body.lockTimeZoneToggleOnBookingPage
|
||||
);
|
||||
expect(updatedEventType.color).toEqual(body.color);
|
||||
expect(updatedEventType.calVideoSettings?.disableRecordingForGuests).toEqual(
|
||||
body.calVideoSettings?.disableRecordingForGuests
|
||||
);
|
||||
expect(updatedEventType.calVideoSettings?.disableRecordingForOrganizer).toEqual(
|
||||
body.calVideoSettings?.disableRecordingForOrganizer
|
||||
);
|
||||
|
||||
eventType.title = newTitle;
|
||||
eventType.scheduleId = secondSchedule.id;
|
||||
@@ -1841,6 +1854,130 @@ describe("Event types Endpoints", () => {
|
||||
expect(slug2).not.toEqual(slug);
|
||||
});
|
||||
});
|
||||
|
||||
it("should create event type with cal video settings", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "event type with cal video settings",
|
||||
slug: "event-type-with-cal-video-settings",
|
||||
lengthInMinutes: 60,
|
||||
calVideoSettings: {
|
||||
disableRecordingForGuests: true,
|
||||
disableRecordingForOrganizer: true,
|
||||
},
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "cal-video",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const createdEventType = responseBody.data;
|
||||
expect(createdEventType).toHaveProperty("id");
|
||||
expect(createdEventType.title).toEqual(body.title);
|
||||
expect(createdEventType.locations).toEqual(body.locations);
|
||||
expect(createdEventType.calVideoSettings?.disableRecordingForGuests).toEqual(true);
|
||||
expect(createdEventType.calVideoSettings?.disableRecordingForOrganizer).toEqual(true);
|
||||
firstCreatedEventType = responseBody.data;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggle disable guests", () => {
|
||||
let eventTypeWithGuestsDisabledId: number;
|
||||
|
||||
it("should create an event type with guests disabled", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class guests disabled",
|
||||
slug: "coding-class-guests-disabled",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
disableGuests: true,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const createdEventType = responseBody.data;
|
||||
expect(createdEventType).toHaveProperty("id");
|
||||
expect(createdEventType.title).toEqual(body.title);
|
||||
expect(createdEventType.description).toEqual(body.description);
|
||||
expect(createdEventType.lengthInMinutes).toEqual(body.lengthInMinutes);
|
||||
|
||||
expect(createdEventType.disableGuests).toEqual(true);
|
||||
const guestsBookingField = createdEventType.bookingFields.find(
|
||||
(field) => field.slug === "guests"
|
||||
) as GuestsDefaultFieldOutput_2024_06_14 | undefined;
|
||||
expect(guestsBookingField?.hidden).toEqual(true);
|
||||
|
||||
eventTypeWithGuestsDisabledId = createdEventType.id;
|
||||
});
|
||||
});
|
||||
|
||||
it("should update an event type with guests enabled", async () => {
|
||||
const body: UpdateEventTypeInput_2024_06_14 = {
|
||||
disableGuests: false,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${eventTypeWithGuestsDisabledId}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const updatedEventType = responseBody.data;
|
||||
|
||||
expect(updatedEventType.disableGuests).toEqual(false);
|
||||
const guestsBookingField = updatedEventType.bookingFields.find(
|
||||
(field) => field.slug === "guests"
|
||||
) as GuestsDefaultFieldOutput_2024_06_14 | undefined;
|
||||
expect(guestsBookingField?.hidden).toEqual(false);
|
||||
|
||||
eventTypeWithGuestsDisabledId = updatedEventType.id;
|
||||
});
|
||||
});
|
||||
|
||||
it("should update an event type with guests disabled", async () => {
|
||||
const body: UpdateEventTypeInput_2024_06_14 = {
|
||||
disableGuests: true,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${eventTypeWithGuestsDisabledId}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const updatedEventType = responseBody.data;
|
||||
|
||||
expect(updatedEventType.disableGuests).toEqual(true);
|
||||
const guestsBookingField = updatedEventType.bookingFields.find(
|
||||
(field) => field.slug === "guests"
|
||||
) as GuestsDefaultFieldOutput_2024_06_14 | undefined;
|
||||
expect(guestsBookingField?.hidden).toEqual(true);
|
||||
|
||||
eventTypeWithGuestsDisabledId = updatedEventType.id;
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (eventTypeWithGuestsDisabledId) {
|
||||
await eventTypesRepositoryFixture.delete(eventTypeWithGuestsDisabledId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+39
-2
@@ -32,6 +32,7 @@ import { validateCustomEventName, EventTypeMetaDataSchema } from "@calcom/platfo
|
||||
import {
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
DestinationCalendar_2024_06_14,
|
||||
InputBookingField_2024_06_14,
|
||||
OutputUnknownLocation_2024_06_14,
|
||||
UpdateEventTypeInput_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
@@ -117,16 +118,23 @@ export class InputEventTypesService_2024_06_14 {
|
||||
seats,
|
||||
customName,
|
||||
useDestinationCalendarEmail,
|
||||
disableGuests,
|
||||
...rest
|
||||
} = inputEventType;
|
||||
const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy);
|
||||
|
||||
const locationsTransformed = locations?.length ? this.transformInputLocations(locations) : undefined;
|
||||
|
||||
const effectiveBookingFields =
|
||||
disableGuests !== undefined
|
||||
? this.getBookingFieldsWithGuestsToggled(bookingFields, disableGuests)
|
||||
: bookingFields;
|
||||
|
||||
const eventType = {
|
||||
...rest,
|
||||
length: lengthInMinutes,
|
||||
locations: locationsTransformed,
|
||||
bookingFields: this.transformInputBookingFields(bookingFields),
|
||||
bookingFields: this.transformInputBookingFields(effectiveBookingFields),
|
||||
bookingLimits: bookingLimitsCount ? this.transformInputIntervalLimits(bookingLimitsCount) : undefined,
|
||||
durationLimits: bookingLimitsDuration
|
||||
? this.transformInputIntervalLimits(bookingLimitsDuration)
|
||||
@@ -167,6 +175,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
seats,
|
||||
customName,
|
||||
useDestinationCalendarEmail,
|
||||
disableGuests,
|
||||
...rest
|
||||
} = inputEventType;
|
||||
const eventTypeDb = await this.eventTypesRepository.getEventTypeWithMetaData(eventTypeId);
|
||||
@@ -176,11 +185,18 @@ export class InputEventTypesService_2024_06_14 {
|
||||
|
||||
const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy);
|
||||
|
||||
const effectiveBookingFields =
|
||||
disableGuests !== undefined
|
||||
? this.getBookingFieldsWithGuestsToggled(bookingFields, disableGuests)
|
||||
: bookingFields;
|
||||
|
||||
const eventType = {
|
||||
...rest,
|
||||
length: lengthInMinutes,
|
||||
locations: locations ? this.transformInputLocations(locations) : undefined,
|
||||
bookingFields: bookingFields ? this.transformInputBookingFields(bookingFields) : undefined,
|
||||
bookingFields: effectiveBookingFields
|
||||
? this.transformInputBookingFields(effectiveBookingFields)
|
||||
: undefined,
|
||||
bookingLimits: bookingLimitsCount ? this.transformInputIntervalLimits(bookingLimitsCount) : undefined,
|
||||
durationLimits: bookingLimitsDuration
|
||||
? this.transformInputIntervalLimits(bookingLimitsDuration)
|
||||
@@ -206,6 +222,27 @@ export class InputEventTypesService_2024_06_14 {
|
||||
return eventType;
|
||||
}
|
||||
|
||||
getBookingFieldsWithGuestsToggled(
|
||||
bookingFields: InputBookingField_2024_06_14[] | undefined,
|
||||
hideGuests: boolean
|
||||
) {
|
||||
const toggledGuestsBookingField: InputBookingField_2024_06_14 = { slug: "guests", hidden: hideGuests };
|
||||
if (!bookingFields) {
|
||||
return [toggledGuestsBookingField];
|
||||
}
|
||||
|
||||
const bookingFieldsCopy = [...bookingFields];
|
||||
|
||||
const guestsBookingField = bookingFieldsCopy.find((field) => "slug" in field && field.slug === "guests");
|
||||
if (guestsBookingField) {
|
||||
Object.assign(guestsBookingField, { hidden: hideGuests });
|
||||
return bookingFieldsCopy;
|
||||
}
|
||||
|
||||
bookingFieldsCopy.push(toggledGuestsBookingField);
|
||||
return bookingFieldsCopy;
|
||||
}
|
||||
|
||||
transformInputLocations(inputLocations: CreateEventTypeInput_2024_06_14["locations"]) {
|
||||
return transformLocationsApiToInternal(inputLocations);
|
||||
}
|
||||
|
||||
+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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,100 @@ import { ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { TRPCError } from "@calcom/platform-libraries";
|
||||
import { Response } from "@calcom/platform-types";
|
||||
|
||||
export type TRPCErrorCode = TRPCError["code"];
|
||||
|
||||
export interface ErrorDefinition {
|
||||
statusCode: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Define the specific TRPC error codes
|
||||
export const TRPC_ERROR_CODE = {
|
||||
UNAUTHORIZED: "UNAUTHORIZED",
|
||||
FORBIDDEN: "FORBIDDEN",
|
||||
NOT_FOUND: "NOT_FOUND",
|
||||
INTERNAL_SERVER_ERROR: "INTERNAL_SERVER_ERROR",
|
||||
BAD_REQUEST: "BAD_REQUEST",
|
||||
CONFLICT: "CONFLICT",
|
||||
TOO_MANY_REQUESTS: "TOO_MANY_REQUESTS",
|
||||
PAYLOAD_TOO_LARGE: "PAYLOAD_TOO_LARGE",
|
||||
CLIENT_CLOSED_REQUEST: "CLIENT_CLOSED_REQUEST",
|
||||
METHOD_NOT_SUPPORTED: "METHOD_NOT_SUPPORTED",
|
||||
NOT_IMPLEMENTED: "NOT_IMPLEMENTED",
|
||||
PRECONDITION_FAILED: "PRECONDITION_FAILED",
|
||||
TIMEOUT: "TIMEOUT",
|
||||
UNPROCESSABLE_CONTENT: "UNPROCESSABLE_CONTENT",
|
||||
PARSE_ERROR: "PARSE_ERROR",
|
||||
} as const; // `as const` ensures literal types, improving type inference
|
||||
|
||||
// Map TRPC error codes to their HTTP status codes and default messages
|
||||
export const TRPC_ERROR_MAP: Record<(typeof TRPC_ERROR_CODE)[keyof typeof TRPC_ERROR_CODE], ErrorDefinition> =
|
||||
{
|
||||
[TRPC_ERROR_CODE.UNAUTHORIZED]: {
|
||||
statusCode: 401,
|
||||
message: "You are not authorized to access this resource",
|
||||
},
|
||||
[TRPC_ERROR_CODE.FORBIDDEN]: {
|
||||
statusCode: 403,
|
||||
message: "You don't have necessary permissions to access this resource",
|
||||
},
|
||||
[TRPC_ERROR_CODE.NOT_FOUND]: {
|
||||
statusCode: 404,
|
||||
message: "The requested resource was not found",
|
||||
},
|
||||
[TRPC_ERROR_CODE.INTERNAL_SERVER_ERROR]: {
|
||||
statusCode: 500,
|
||||
message: "An unexpected error occurred on the server. Please try again later",
|
||||
},
|
||||
[TRPC_ERROR_CODE.BAD_REQUEST]: {
|
||||
statusCode: 400,
|
||||
message: "Bad request: Please check your request data and try again",
|
||||
},
|
||||
[TRPC_ERROR_CODE.CONFLICT]: {
|
||||
statusCode: 409,
|
||||
message: "Could not process the request due to a resource conflict with the current state",
|
||||
},
|
||||
[TRPC_ERROR_CODE.TOO_MANY_REQUESTS]: {
|
||||
statusCode: 429,
|
||||
message: "You have exceeded the allowed number of requests",
|
||||
},
|
||||
[TRPC_ERROR_CODE.PAYLOAD_TOO_LARGE]: {
|
||||
statusCode: 413,
|
||||
message: "The request payload is too large",
|
||||
},
|
||||
[TRPC_ERROR_CODE.CLIENT_CLOSED_REQUEST]: {
|
||||
statusCode: 499,
|
||||
message: "The client closed the connection before the server could finish processing the request",
|
||||
},
|
||||
[TRPC_ERROR_CODE.METHOD_NOT_SUPPORTED]: {
|
||||
statusCode: 405,
|
||||
message: "The requested method is not supported for this resource",
|
||||
},
|
||||
[TRPC_ERROR_CODE.NOT_IMPLEMENTED]: {
|
||||
statusCode: 501,
|
||||
message: "The requested method is not implemented on the server",
|
||||
},
|
||||
[TRPC_ERROR_CODE.PRECONDITION_FAILED]: {
|
||||
statusCode: 412,
|
||||
message: "The server does not meet one of the preconditions that the requester put on the request",
|
||||
},
|
||||
[TRPC_ERROR_CODE.TIMEOUT]: {
|
||||
statusCode: 408,
|
||||
message: "The request took too long to complete. Please try again later",
|
||||
},
|
||||
[TRPC_ERROR_CODE.UNPROCESSABLE_CONTENT]: {
|
||||
statusCode: 422,
|
||||
message:
|
||||
"The server was unable to process the request because the request payload is semantically incorrect or because the request is syntactically incorrect",
|
||||
},
|
||||
[TRPC_ERROR_CODE.PARSE_ERROR]: {
|
||||
statusCode: 400,
|
||||
message: "The request could not be parsed due to invalid syntax.",
|
||||
},
|
||||
};
|
||||
|
||||
// --- TRPCExceptionFilter (updated) ---
|
||||
|
||||
@Catch(TRPCError)
|
||||
export class TRPCExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger("TRPCExceptionFilter");
|
||||
@@ -15,77 +109,13 @@ export class TRPCExceptionFilter implements ExceptionFilter {
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
let statusCode = 500;
|
||||
let errorMessage = exception.message;
|
||||
const errorDefinition = TRPC_ERROR_MAP[exception.code as keyof typeof TRPC_ERROR_CODE] || {
|
||||
statusCode: 500,
|
||||
message: exception.message,
|
||||
};
|
||||
|
||||
switch (exception.code) {
|
||||
case "UNAUTHORIZED":
|
||||
statusCode = 401;
|
||||
errorMessage = "You are not authorized to access this resource";
|
||||
break;
|
||||
case "FORBIDDEN":
|
||||
statusCode = 403;
|
||||
errorMessage = "You don't have necessary permissions to access this resource";
|
||||
break;
|
||||
case "NOT_FOUND":
|
||||
statusCode = 404;
|
||||
errorMessage = "The requested resource was not found";
|
||||
break;
|
||||
case "INTERNAL_SERVER_ERROR":
|
||||
statusCode = 500;
|
||||
errorMessage = "An unexpected error occurred on the server. Please try again later";
|
||||
break;
|
||||
case "BAD_REQUEST":
|
||||
statusCode = 400;
|
||||
errorMessage = "Bad request: Please check your request data and try again";
|
||||
break;
|
||||
case "CONFLICT":
|
||||
statusCode = 409;
|
||||
errorMessage = "Could not process the request due to a resource conflict with the current state";
|
||||
break;
|
||||
case "TOO_MANY_REQUESTS":
|
||||
statusCode = 429;
|
||||
errorMessage = "You have exceeded the allowed number of requests";
|
||||
break;
|
||||
case "PAYLOAD_TOO_LARGE":
|
||||
statusCode = 413;
|
||||
errorMessage = "The request payload is too large";
|
||||
break;
|
||||
case "CLIENT_CLOSED_REQUEST":
|
||||
statusCode = 499;
|
||||
errorMessage =
|
||||
"The client closed the connection before the server could finish processing the request";
|
||||
break;
|
||||
case "METHOD_NOT_SUPPORTED":
|
||||
statusCode = 405;
|
||||
errorMessage = "The requested method is not supported for this resource";
|
||||
case "NOT_IMPLEMENTED":
|
||||
statusCode = 501;
|
||||
errorMessage = "The requested method is not implemented on the server";
|
||||
break;
|
||||
case "PRECONDITION_FAILED":
|
||||
statusCode = 412;
|
||||
errorMessage =
|
||||
"The server does not meet one of the preconditions that the requester put on the request";
|
||||
break;
|
||||
case "TIMEOUT":
|
||||
statusCode = 408;
|
||||
errorMessage = "The request took too long to complete. Please try again later";
|
||||
break;
|
||||
case "UNPROCESSABLE_CONTENT":
|
||||
statusCode = 422;
|
||||
errorMessage =
|
||||
"The server was unable to process the request because the request payload is semantically incorrect or because the request is syntactically incorrect";
|
||||
break;
|
||||
case "PARSE_ERROR":
|
||||
statusCode = 400;
|
||||
errorMessage = "The request could not be parsed due to invalid syntax.";
|
||||
break;
|
||||
default:
|
||||
statusCode = 500;
|
||||
errorMessage = exception.message;
|
||||
break;
|
||||
}
|
||||
const statusCode = errorDefinition.statusCode;
|
||||
const errorMessage = errorDefinition.message;
|
||||
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
@@ -103,7 +133,7 @@ export class TRPCExceptionFilter implements ExceptionFilter {
|
||||
status: ERROR_STATUS,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
error: { code: exception.name, message: errorMessage },
|
||||
error: { code: exception.name, message: errorMessage }, // exception.name is usually "TRPCError"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,12 +58,6 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
const IP = request?.headers?.["cf-connecting-ip"] ?? request?.headers?.["CF-Connecting-IP"] ?? request.ip;
|
||||
const response = context.switchToHttp().getResponse<Response>();
|
||||
const tracker = await this.getTracker(request);
|
||||
this.logger.verbose(
|
||||
`Tracker "${tracker}" generated based on: Bearer token "${request.get(
|
||||
"Authorization"
|
||||
)}", OAuth client ID "${request.get(X_CAL_CLIENT_ID)}" and IP "${IP}"`
|
||||
);
|
||||
|
||||
if (throttleOptions) {
|
||||
return this.handleApiEndpointThrottle(tracker, throttleOptions, response);
|
||||
}
|
||||
@@ -104,9 +98,9 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
|
||||
private async handleNonApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
|
||||
const rateLimit = this.getDefaultRateLimit(tracker);
|
||||
this.logger.verbose(`Tracker "${tracker}" uses default rate limits because it is not tracking api key:
|
||||
/*this.logger.verbose(`Tracker "${tracker}" uses default rate limits because it is not tracking api key:
|
||||
${JSON.stringify(rateLimit, null, 2)}
|
||||
`);
|
||||
`);*/
|
||||
|
||||
const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response);
|
||||
if (isBlocked) {
|
||||
@@ -150,9 +144,9 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
|
||||
const cachedRateLimits = await this.storageService.redis.get(cacheKey);
|
||||
if (cachedRateLimits) {
|
||||
this.logger.verbose(`Tracker "${tracker}" rate limits retrieved from redis cache:
|
||||
/*this.logger.verbose(`Tracker "${tracker}" rate limits retrieved from redis cache:
|
||||
${cachedRateLimits}
|
||||
`);
|
||||
`);*/
|
||||
return rateLimitsSchema.parse(JSON.parse(cachedRateLimits));
|
||||
}
|
||||
|
||||
@@ -172,15 +166,10 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
select: { name: true, limit: true, ttl: true, blockDuration: true },
|
||||
});
|
||||
|
||||
if (rateLimits) {
|
||||
this.logger.verbose(`Tracker "${tracker}" rate limits retrieved from database:
|
||||
${JSON.stringify(rateLimits, null, 2)}`);
|
||||
}
|
||||
|
||||
if (!rateLimits || rateLimits.length === 0) {
|
||||
rateLimits = [this.getDefaultRateLimit(tracker)];
|
||||
this.logger.verbose(`Tracker "${tracker}" rate limits not found in database. Using default rate limits:
|
||||
${JSON.stringify(rateLimits, null, 2)}`);
|
||||
/*this.logger.verbose(`Tracker "${tracker}" rate limits not found in database. Using default rate limits:
|
||||
${JSON.stringify(rateLimits, null, 2)}`);*/
|
||||
}
|
||||
|
||||
await this.storageService.redis.set(cacheKey, JSON.stringify(rateLimits), "EX", 3600);
|
||||
@@ -209,15 +198,15 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
);
|
||||
response.setHeader(`X-RateLimit-Reset-${nameFirstUpper}`, timeToBlockExpire || timeToExpire);
|
||||
|
||||
this.logger.verbose(
|
||||
/*this.logger.verbose(
|
||||
`Tracker "${tracker}" rate limit "${name}" incremented. isBlocked ${isBlocked}, totalHits ${totalHits}, timeToExpire ${timeToExpire}, timeToBlockExpire ${timeToBlockExpire}`
|
||||
);
|
||||
this.logger.verbose(
|
||||
);*/
|
||||
/*this.logger.verbose(
|
||||
`Tracker "${tracker}" rate limit "${name}" response headers:
|
||||
X-RateLimit-Limit-${nameFirstUpper}: ${limit},
|
||||
X-RateLimit-Remaining-${nameFirstUpper}: ${timeToBlockExpire ? 0 : Math.max(0, limit - totalHits)},
|
||||
X-RateLimit-Reset-${nameFirstUpper}: ${timeToBlockExpire || timeToExpire}`
|
||||
);
|
||||
);*/
|
||||
|
||||
return { isBlocked };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -116,6 +116,7 @@ describe("Platform Billing Controller (e2e)", () => {
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/billing/webhook")
|
||||
.set("stripe-signature", "t=1234567890,v1=random_signature_for_e2e_test")
|
||||
.expect(200)
|
||||
.then(async (res) => {
|
||||
const billing = await platformBillingRepositoryFixture.get(organization.id);
|
||||
@@ -143,6 +144,7 @@ describe("Platform Billing Controller (e2e)", () => {
|
||||
);
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/billing/webhook")
|
||||
.set("stripe-signature", "t=1234567890,v1=random_signature_for_e2e_test")
|
||||
.expect(200)
|
||||
.then(async (res) => {
|
||||
const billing = await platformBillingRepositoryFixture.get(organization.id);
|
||||
@@ -172,6 +174,7 @@ describe("Platform Billing Controller (e2e)", () => {
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/billing/webhook")
|
||||
.set("stripe-signature", "t=1234567890,v1=random_signature_for_e2e_test")
|
||||
.expect(200)
|
||||
.then(async (res) => {
|
||||
const billing = await platformBillingRepositoryFixture.get(organization.id);
|
||||
@@ -204,6 +207,7 @@ describe("Platform Billing Controller (e2e)", () => {
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/billing/webhook")
|
||||
.set("stripe-signature", "t=1234567890,v1=random_signature_for_e2e_test")
|
||||
.expect(200)
|
||||
.then(async (res) => {
|
||||
const billing = await platformBillingRepositoryFixture.get(organization.id);
|
||||
|
||||
@@ -21,10 +21,12 @@ import {
|
||||
HttpStatus,
|
||||
Logger,
|
||||
Delete,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { ApiExcludeController } from "@nestjs/swagger";
|
||||
import { Request } from "express";
|
||||
import Stripe from "stripe";
|
||||
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
|
||||
@@ -116,35 +118,59 @@ export class BillingController {
|
||||
@Req() request: Request,
|
||||
@Headers("stripe-signature") stripeSignature: string
|
||||
): Promise<ApiResponse> {
|
||||
const event = await this.billingService.stripeService
|
||||
.getStripe()
|
||||
.webhooks.constructEventAsync(request.body, stripeSignature, this.stripeWhSecret);
|
||||
try {
|
||||
if (!stripeSignature) {
|
||||
this.logger.warn("Missing stripe-signature header in webhook request");
|
||||
return {
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case "checkout.session.completed":
|
||||
await this.billingService.handleStripeCheckoutEvents(event);
|
||||
break;
|
||||
case "customer.subscription.updated":
|
||||
await this.billingService.handleStripePaymentPastDue(event);
|
||||
break;
|
||||
case "customer.subscription.deleted":
|
||||
await this.billingService.handleStripeSubscriptionDeleted(event);
|
||||
break;
|
||||
case "invoice.created":
|
||||
await this.billingService.handleStripeSubscriptionForActiveManagedUsers(event);
|
||||
break;
|
||||
case "invoice.payment_failed":
|
||||
await this.billingService.handleStripePaymentFailed(event);
|
||||
break;
|
||||
case "invoice.payment_succeeded":
|
||||
await this.billingService.handleStripePaymentSuccess(event);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
if (!this.stripeWhSecret) {
|
||||
this.logger.error("Missing STRIPE_WEBHOOK_SECRET configuration");
|
||||
return {
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
const event = await this.billingService.stripeService
|
||||
.getStripe()
|
||||
.webhooks.constructEventAsync(request.body, stripeSignature, this.stripeWhSecret);
|
||||
|
||||
switch (event.type) {
|
||||
case "checkout.session.completed":
|
||||
await this.billingService.handleStripeCheckoutEvents(event);
|
||||
break;
|
||||
case "customer.subscription.updated":
|
||||
await this.billingService.handleStripePaymentPastDue(event);
|
||||
break;
|
||||
case "customer.subscription.deleted":
|
||||
await this.billingService.handleStripeSubscriptionDeleted(event);
|
||||
break;
|
||||
case "invoice.created":
|
||||
await this.billingService.handleStripeSubscriptionForActiveManagedUsers(event);
|
||||
break;
|
||||
case "invoice.payment_failed":
|
||||
await this.billingService.handleStripePaymentFailed(event);
|
||||
break;
|
||||
case "invoice.payment_succeeded":
|
||||
await this.billingService.handleStripePaymentSuccess(event);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Stripe.errors.StripeSignatureVerificationError) {
|
||||
this.logger.error("Webhook signature validation failed", error);
|
||||
return {
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
|
||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { GoogleCalendarService as GCalService } from "@/ee/calendars/services/gcal.service";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CalUnifiedCalendarsController } from "@/modules/cal-unified-calendars/controllers/cal-unified-calendars.controller";
|
||||
import { GoogleCalendarService } from "@/modules/cal-unified-calendars/services/google-calendar.service";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
providers: [
|
||||
GCalService,
|
||||
GoogleCalendarService,
|
||||
AppsRepository,
|
||||
BookingReferencesRepository_2024_08_13,
|
||||
CredentialsRepository,
|
||||
CalendarsService,
|
||||
TokensRepository,
|
||||
SelectedCalendarsRepository,
|
||||
PrismaReadService,
|
||||
PrismaWriteService,
|
||||
UsersRepository,
|
||||
CalendarsRepository,
|
||||
],
|
||||
controllers: [CalUnifiedCalendarsController],
|
||||
exports: [GoogleCalendarService],
|
||||
})
|
||||
export class CalUnifiedCalendarsModule {}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { GetUnifiedCalendarEventOutput } from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event";
|
||||
import { GoogleCalendarEventOutputPipe } from "@/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe";
|
||||
import { GoogleCalendarService } from "@/modules/cal-unified-calendars/services/google-calendar.service";
|
||||
import { Controller, Get, Param, UseGuards, HttpCode, HttpStatus, BadRequestException } from "@nestjs/common";
|
||||
import { ApiTags as DocsTags, ApiParam, ApiHeader, ApiOperation } from "@nestjs/swagger";
|
||||
|
||||
import { GOOGLE_CALENDAR, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/calendars",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Cal Unified Calendars")
|
||||
export class CalUnifiedCalendarsController {
|
||||
constructor(private readonly googleCalendarService: GoogleCalendarService) {}
|
||||
|
||||
@ApiParam({
|
||||
name: "calendar",
|
||||
enum: [GOOGLE_CALENDAR],
|
||||
type: String,
|
||||
})
|
||||
@ApiParam({
|
||||
name: "eventUid",
|
||||
description:
|
||||
"The Google Calendar event ID. You can retrieve this by getting booking references from the following endpoints:\n\n- For team events: https://cal.com/docs/api-reference/v2/orgs-teams-bookings/get-booking-references-for-a-booking\n\n- For user events: https://cal.com/docs/api-reference/v2/bookings/get-booking-references-for-a-booking",
|
||||
type: String,
|
||||
})
|
||||
@Get("/:calendar/event/:eventUid")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(ApiAuthGuard, PermissionsGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Get meeting Details from calendar",
|
||||
description: "Returns detailed information about a meeting including attendance metrics",
|
||||
})
|
||||
async getCalendarEventDetails(
|
||||
@Param("calendar") calendar: string,
|
||||
@Param("eventUid") eventUid: string
|
||||
): Promise<GetUnifiedCalendarEventOutput> {
|
||||
if (calendar !== GOOGLE_CALENDAR) {
|
||||
throw new BadRequestException("Meeting details are currently only available for Google Calendar");
|
||||
}
|
||||
|
||||
const eventDetails = await this.googleCalendarService.getEventDetails(eventUid);
|
||||
|
||||
const transformedEvent = new GoogleCalendarEventOutputPipe().transform(eventDetails);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: transformedEvent,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
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;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@IsEnum(CalendarEventResponseStatus)
|
||||
@ApiPropertyOptional({
|
||||
enum: CalendarEventResponseStatus,
|
||||
nullable: true,
|
||||
enumName: "CalendarEventResponseStatus",
|
||||
description: "Host's response to the invitation",
|
||||
example: CalendarEventResponseStatus.ACCEPTED,
|
||||
})
|
||||
responseStatus!: CalendarEventResponseStatus | null;
|
||||
}
|
||||
|
||||
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) {
|
||||
const hosts: Array<{ email: string; name?: string; responseStatus: CalendarEventResponseStatus | null }> =
|
||||
[];
|
||||
|
||||
const organizerAttendees = googleEvent?.attendees?.filter((attendee) => attendee.organizer);
|
||||
if (organizerAttendees?.length) {
|
||||
organizerAttendees.forEach((organizer) => {
|
||||
hosts.push({
|
||||
email: organizer.email,
|
||||
name: organizer.displayName,
|
||||
responseStatus: this.transformAttendeeResponseStatus(organizer.responseStatus),
|
||||
});
|
||||
});
|
||||
} else {
|
||||
hosts.push({
|
||||
email: googleEvent.organizer.email,
|
||||
name: googleEvent.organizer.displayName,
|
||||
responseStatus: null,
|
||||
});
|
||||
}
|
||||
|
||||
return hosts;
|
||||
}
|
||||
|
||||
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 { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
import { GOOGLE_MEET_TYPE } from "@calcom/platform-constants";
|
||||
|
||||
@@ -8,12 +9,26 @@ import { GOOGLE_MEET_TYPE } from "@calcom/platform-constants";
|
||||
export class ConferencingRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
private readonly credentialSelect = Prisma.validator<Prisma.CredentialSelect>()({
|
||||
id: true,
|
||||
type: true,
|
||||
userId: true,
|
||||
teamId: true,
|
||||
appId: true,
|
||||
subscriptionId: true,
|
||||
paymentStatus: true,
|
||||
billingCycleStart: true,
|
||||
invalid: true,
|
||||
delegationCredentialId: true,
|
||||
});
|
||||
|
||||
async findConferencingApps(userId: number) {
|
||||
return this.dbRead.prisma.credential.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: { endsWith: "_video" },
|
||||
},
|
||||
select: this.credentialSelect,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,24 +38,28 @@ export class ConferencingRepository {
|
||||
teamId,
|
||||
type: { endsWith: "_video" },
|
||||
},
|
||||
select: this.credentialSelect,
|
||||
});
|
||||
}
|
||||
|
||||
async findGoogleMeet(userId: number) {
|
||||
return this.dbRead.prisma.credential.findFirst({
|
||||
where: { userId, type: GOOGLE_MEET_TYPE },
|
||||
select: this.credentialSelect,
|
||||
});
|
||||
}
|
||||
|
||||
async findConferencingApp(userId: number, app: string) {
|
||||
return this.dbRead.prisma.credential.findFirst({
|
||||
where: { userId, appId: app },
|
||||
select: this.credentialSelect,
|
||||
});
|
||||
}
|
||||
|
||||
async findTeamConferencingApp(teamId: number, app: string) {
|
||||
return this.dbRead.prisma.credential.findFirst({
|
||||
where: { teamId, appId: app },
|
||||
select: this.credentialSelect,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -144,6 +144,9 @@ export class OAuthClientUsersService {
|
||||
}
|
||||
|
||||
static getOAuthUserEmail(oAuthClientId: string, userEmail: string) {
|
||||
if (userEmail.includes(`+${oAuthClientId}@`)) {
|
||||
return userEmail;
|
||||
}
|
||||
const [username, emailDomain] = userEmail.split("@");
|
||||
return `${username}+${oAuthClientId}@${emailDomain}`;
|
||||
}
|
||||
|
||||
+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 };
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TRPC_ERROR_CODE, TRPC_ERROR_MAP, TRPCErrorCode } from "@/filters/trpc-exception.filter";
|
||||
import { SlotsOutputService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
|
||||
import type { RangeSlots, TimeSlots } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
|
||||
import { SlotsWorkerService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots-worker.service";
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
VERSION_2024_06_11,
|
||||
VERSION_2024_08_13,
|
||||
} from "@calcom/platform-constants";
|
||||
import { TRPCError } from "@calcom/platform-libraries";
|
||||
import { getAvailableSlots } from "@calcom/platform-libraries/slots";
|
||||
import { RemoveSelectedSlotInput_2024_04_15, ReserveSlotInput_2024_04_15 } from "@calcom/platform-types";
|
||||
import { ApiResponse, GetAvailableSlotsInput_2024_04_15 } from "@calcom/platform-types";
|
||||
@@ -209,7 +211,12 @@ export class SlotsController_2024_04_15 {
|
||||
"Invalid time range given - check the 'startTime' and 'endTime' query parameters."
|
||||
);
|
||||
}
|
||||
|
||||
if (TRPC_ERROR_MAP[error.message as keyof typeof TRPC_ERROR_CODE]) {
|
||||
throw new TRPCError({ code: error.message as TRPCErrorCode });
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -16,4 +16,8 @@ export class BookingSeatRepositoryFixture {
|
||||
async create(bookingSeat: Prisma.BookingSeatCreateInput) {
|
||||
return this.prismaWriteClient.bookingSeat.create({ data: bookingSeat });
|
||||
}
|
||||
|
||||
async findAllByBookingId(bookingId: number) {
|
||||
return this.prismaReadClient.bookingSeat.findMany({ where: { bookingId } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,9 @@
|
||||
"@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"],
|
||||
"@calcom/platform-libraries/conferencing": ["../../../packages/platform/libraries/conferencing.ts"]
|
||||
},
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ImageExample } from "./avatar.image"
|
||||
import { TooltipExample } from "./avatar.tooltip"
|
||||
import { LinkExample } from "./avatar.link"
|
||||
import { IndicatorExample } from "./avatar.indicator"
|
||||
import { ShapeExample } from "./avatar.shape"
|
||||
|
||||
## Size Variations
|
||||
|
||||
@@ -17,6 +18,12 @@ Avatars come in different sizes to suit various use cases.
|
||||
|
||||
<SizesExample/>
|
||||
|
||||
## Shape Variations
|
||||
|
||||
Avatars can be displayed in either circular or square shapes.
|
||||
|
||||
<ShapeExample/>
|
||||
|
||||
## With and Without Image
|
||||
|
||||
Avatars can display either an image or a fallback placeholder.
|
||||
@@ -40,3 +47,57 @@ Avatars can be made clickable by providing an href.
|
||||
Avatars can include status indicators or badges.
|
||||
|
||||
<IndicatorExample/>
|
||||
|
||||
## Usage Example
|
||||
|
||||
Here's a complete example showing how to use the Avatar component with all its props:
|
||||
|
||||
```tsx
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
|
||||
function Example() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Basic Avatar */}
|
||||
<Avatar
|
||||
alt="User Name"
|
||||
title="User Name"
|
||||
size="md"
|
||||
shape="circle"
|
||||
imageSrc="https://example.com/avatar.jpg"
|
||||
/>
|
||||
|
||||
{/* Avatar with Link */}
|
||||
<Avatar
|
||||
alt="User Name"
|
||||
title="User Name"
|
||||
size="lg"
|
||||
shape="square"
|
||||
imageSrc="https://example.com/avatar.jpg"
|
||||
href="/profile"
|
||||
/>
|
||||
|
||||
{/* Avatar with Tooltip and Indicator */}
|
||||
<Avatar
|
||||
alt="User Name"
|
||||
title="User Name"
|
||||
size="md"
|
||||
shape="circle"
|
||||
imageSrc="https://example.com/avatar.jpg"
|
||||
indicator={<div className="absolute bottom-0 right-0 h-3 w-3 rounded-full bg-green-500" />}
|
||||
/>
|
||||
|
||||
{/* Avatar with Fallback */}
|
||||
<Avatar
|
||||
alt="User Name"
|
||||
title="User Name"
|
||||
size="md"
|
||||
shape="circle"
|
||||
fallback={<span>UN</span>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
|
||||
export function ShapeExample() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar
|
||||
alt="Circle Avatar"
|
||||
title="Circle Avatar"
|
||||
size="lg"
|
||||
shape="circle"
|
||||
imageSrc="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
|
||||
/>
|
||||
<Avatar
|
||||
alt="Square Avatar"
|
||||
title="Square Avatar"
|
||||
size="lg"
|
||||
shape="square"
|
||||
imageSrc="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import type { PageProps as ServerPageProps } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -21,11 +23,18 @@ export const generateMetadata = async () => {
|
||||
};
|
||||
|
||||
const getData = withAppDirSsr<ClientPageProps>(getServerSideProps);
|
||||
const stepSchema = z.enum(["1", "2", "3", "4"]);
|
||||
|
||||
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
|
||||
const props = await getData(
|
||||
buildLegacyCtx(await headers(), await cookies(), await params, await searchParams)
|
||||
);
|
||||
const ServerPage = async ({ params, searchParams: _searchParams }: ServerPageProps) => {
|
||||
const searchParams = await _searchParams;
|
||||
const stepResult = stepSchema.safeParse(searchParams?.step);
|
||||
|
||||
// If step parameter is invalid, redirect to step 1
|
||||
if (!stepResult.success) {
|
||||
return redirect(`/auth/setup?step=1`);
|
||||
}
|
||||
|
||||
const props = await getData(buildLegacyCtx(await headers(), await cookies(), await params, searchParams));
|
||||
return <Setup {...props} />;
|
||||
};
|
||||
|
||||
|
||||
+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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user