Compare commits
131
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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
+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"
|
||||
|
||||
@@ -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.222",
|
||||
"@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;
|
||||
};
|
||||
|
||||
@@ -267,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);
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
getRecordingsOfCalVideoByRoomName,
|
||||
getAllTranscriptsAccessLinkFromRoomName,
|
||||
getDownloadLinkOfCalVideoByRecordingId,
|
||||
} from "@calcom/platform-libraries";
|
||||
} from "@calcom/platform-libraries/conferencing";
|
||||
|
||||
@Injectable()
|
||||
export class CalVideoService {
|
||||
|
||||
@@ -586,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,
|
||||
|
||||
@@ -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,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";
|
||||
@@ -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);
|
||||
|
||||
+93
@@ -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 },
|
||||
@@ -1041,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 },
|
||||
@@ -1887,6 +1890,96 @@ describe("Event types Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
|
||||
+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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -25,7 +25,8 @@ export class CalUnifiedCalendarsController {
|
||||
})
|
||||
@ApiParam({
|
||||
name: "eventUid",
|
||||
description: "The Google Calendar event ID",
|
||||
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")
|
||||
|
||||
@@ -211,6 +211,18 @@ export class CalendarEventHost {
|
||||
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 {
|
||||
|
||||
+20
-20
@@ -159,28 +159,28 @@ export class GoogleCalendarEventOutputPipe
|
||||
}
|
||||
}
|
||||
|
||||
private transformHosts(googleEvent: GoogleCalendarEventResponse): Array<{ email: string; name?: string }> {
|
||||
if (googleEvent.organizer) {
|
||||
return [
|
||||
{
|
||||
email: googleEvent.organizer.email,
|
||||
name: googleEvent.organizer.displayName,
|
||||
},
|
||||
];
|
||||
} else if (googleEvent.attendees) {
|
||||
// If no explicit organizer, find the first attendee with organizer flag
|
||||
const organizerAttendee = googleEvent.attendees.find((attendee) => attendee.organizer);
|
||||
if (organizerAttendee) {
|
||||
return [
|
||||
{
|
||||
email: organizerAttendee.email,
|
||||
name: organizerAttendee.displayName,
|
||||
},
|
||||
];
|
||||
}
|
||||
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 [];
|
||||
return hosts;
|
||||
}
|
||||
|
||||
private transformLocations(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7653,7 +7653,7 @@
|
||||
"post": {
|
||||
"operationId": "BookingsController_2024_08_13_cancelBooking",
|
||||
"summary": "Cancel a booking",
|
||||
"description": ":bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.\n 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\n like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.",
|
||||
"description": ":bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.\n \n \nCancelling seated bookings:\n It is possible to cancel specific seat within a booking as an attendee or all of the seats as the host.\n \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.\n \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.\n \n \nCancelling recurring seated bookings:\n For recurring seated bookings it is not possible to cancel all of them with 1 call\n like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
@@ -8142,7 +8142,7 @@
|
||||
"name": "eventUid",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"description": "The Google Calendar event ID",
|
||||
"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",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -10695,6 +10695,16 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bookingUidToReschedule",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"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",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "routingFormId",
|
||||
"required": true,
|
||||
@@ -25447,8 +25457,7 @@
|
||||
"startTs",
|
||||
"status",
|
||||
"duration",
|
||||
"shareToken",
|
||||
"downloadLink"
|
||||
"shareToken"
|
||||
]
|
||||
},
|
||||
"GetBookingRecordingsOutput": {
|
||||
@@ -26391,7 +26400,7 @@
|
||||
},
|
||||
"CalendarEventResponseStatus": {
|
||||
"type": "string",
|
||||
"description": "Attendee's response to the invitation",
|
||||
"description": "Host's response to the invitation",
|
||||
"enum": [
|
||||
"accepted",
|
||||
"pending",
|
||||
@@ -26451,6 +26460,11 @@
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Display name of the event host"
|
||||
},
|
||||
"responseStatus": {
|
||||
"nullable": true,
|
||||
"example": "accepted",
|
||||
"$ref": "#/components/schemas/CalendarEventResponseStatus"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"@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/workflows": ["../../../packages/platform/libraries/workflows.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} />;
|
||||
};
|
||||
|
||||
|
||||
+14
-5
@@ -3,6 +3,7 @@ import { _generateMetadata } from "app/_utils";
|
||||
import { unstable_cache } from "next/cache";
|
||||
|
||||
import { AttributeRepository } from "@calcom/lib/server/repository/attribute";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router";
|
||||
|
||||
import { MembersView } from "~/members/members-view";
|
||||
@@ -26,12 +27,20 @@ const getCachedAttributes = unstable_cache(
|
||||
|
||||
const Page = async () => {
|
||||
const orgCaller = await createRouterCaller(viewerOrganizationsRouter);
|
||||
const [org, teams, facetedTeamValues] = await Promise.all([
|
||||
orgCaller.listCurrent(),
|
||||
orgCaller.getTeams(),
|
||||
orgCaller.getFacetedValues(),
|
||||
]);
|
||||
const [org, teams] = await Promise.all([orgCaller.listCurrent(), orgCaller.getTeams()]);
|
||||
const attributes = await getCachedAttributes(org.id);
|
||||
const facetedTeamValues = {
|
||||
roles: [MembershipRole.OWNER, MembershipRole.ADMIN, MembershipRole.MEMBER],
|
||||
teams,
|
||||
attributes: attributes.map((attribute) => ({
|
||||
id: attribute.id,
|
||||
name: attribute.name,
|
||||
options: Array.from(new Set(attribute.options.map((option) => option.value))).map((value) => ({
|
||||
value,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
|
||||
return (
|
||||
<MembersView org={org} teams={teams} facetedTeamValues={facetedTeamValues} attributes={attributes} />
|
||||
);
|
||||
|
||||
@@ -46,13 +46,13 @@ async function postHandler(req: NextRequest) {
|
||||
if (!log.smsSid) return;
|
||||
|
||||
try {
|
||||
const price = await twilio.getPriceForSMS(log.smsSid);
|
||||
const { price, numSegments } = await twilio.getMessageInfo(log.smsSid);
|
||||
const credits = price ? creditService.calculateCreditsFromPrice(price) : null;
|
||||
if (!credits) return;
|
||||
|
||||
const updatedLog = await prisma.creditExpenseLog.update({
|
||||
where: { id: log.id },
|
||||
data: { credits },
|
||||
data: { credits, smsSegments: numSegments },
|
||||
select: {
|
||||
creditBalance: {
|
||||
select: {
|
||||
|
||||
@@ -8,11 +8,11 @@ import { CalendarAppDelegationCredentialInvalidGrantError } from "@calcom/lib/Ca
|
||||
import { handleCreateSelectedCalendars, isSameEmail } from "../route";
|
||||
|
||||
// Mock GoogleCalendarService
|
||||
const fetchPrimaryCalendarMock = vi.fn();
|
||||
const getPrimaryCalendarMock = vi.fn();
|
||||
vi.mock("@calcom/app-store/googlecalendar/lib/CalendarService", () => {
|
||||
return {
|
||||
default: vi.fn().mockImplementation(() => ({
|
||||
fetchPrimaryCalendar: fetchPrimaryCalendarMock,
|
||||
getPrimaryCalendar: getPrimaryCalendarMock,
|
||||
})),
|
||||
};
|
||||
});
|
||||
@@ -116,7 +116,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
||||
prismock.user.deleteMany();
|
||||
prismock.team.deleteMany();
|
||||
prismock.workspacePlatform.deleteMany();
|
||||
fetchPrimaryCalendarMock.mockReset();
|
||||
getPrimaryCalendarMock.mockReset();
|
||||
});
|
||||
|
||||
it("shows a helpful message when no Delegation Credentials are set up in the system", async () => {
|
||||
@@ -138,7 +138,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
||||
domain: "example.com",
|
||||
});
|
||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
||||
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||
getPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||
|
||||
const result = await handleCreateSelectedCalendars();
|
||||
expect(result.success).toBe(1);
|
||||
@@ -159,7 +159,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
||||
domain: "example.com",
|
||||
});
|
||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
||||
fetchPrimaryCalendarMock.mockResolvedValue({ id: "notuser@example.com" });
|
||||
getPrimaryCalendarMock.mockResolvedValue({ id: "notuser@example.com" });
|
||||
|
||||
const result = await handleCreateSelectedCalendars();
|
||||
expect(result.success).toBe(1);
|
||||
@@ -183,7 +183,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
||||
domain: "example.com",
|
||||
});
|
||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
||||
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||
getPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||
|
||||
const result = await handleCreateSelectedCalendars();
|
||||
expect(result.success).toBe(1);
|
||||
@@ -210,7 +210,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
||||
delegationCredentialId: delegationCredential.id,
|
||||
credentialId: 1,
|
||||
});
|
||||
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||
getPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||
|
||||
const result = await handleCreateSelectedCalendars();
|
||||
expect(result.success).toBe(0);
|
||||
@@ -247,7 +247,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
||||
error: "some error" as unknown as null,
|
||||
credentialId: regularCredential.id,
|
||||
});
|
||||
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||
getPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||
|
||||
const result = await handleCreateSelectedCalendars();
|
||||
expect(result.success).toBe(1);
|
||||
@@ -272,7 +272,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
||||
domain: "example.com",
|
||||
});
|
||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
||||
fetchPrimaryCalendarMock.mockRejectedValue(
|
||||
getPrimaryCalendarMock.mockRejectedValue(
|
||||
new CalendarAppDelegationCredentialInvalidGrantError("some error")
|
||||
);
|
||||
|
||||
@@ -293,7 +293,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
||||
domain: "example.com",
|
||||
});
|
||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
||||
fetchPrimaryCalendarMock.mockRejectedValue(new Error("some error"));
|
||||
getPrimaryCalendarMock.mockRejectedValue(new Error("some error"));
|
||||
|
||||
const result = await handleCreateSelectedCalendars();
|
||||
expect(result.success).toBe(0);
|
||||
|
||||
@@ -153,7 +153,7 @@ async function fetchPrimaryCalendarId({
|
||||
const userEmail = delegationUserCredential.user.email;
|
||||
|
||||
try {
|
||||
const primaryCalendar = await googleCalendarService.fetchPrimaryCalendar();
|
||||
const primaryCalendar = await googleCalendarService.getPrimaryCalendar();
|
||||
primaryCalendarId = primaryCalendar?.id;
|
||||
} catch (error) {
|
||||
log.error(
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { onSubmissionOfFormResponse } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
|
||||
import { getResponseToStore } from "@calcom/app-store/routing-forms/lib/getResponseToStore";
|
||||
import { getSerializableForm } from "@calcom/app-store/routing-forms/lib/getSerializableForm";
|
||||
import { RoutingFormResponseRepository } from "@calcom/lib/server/repository/formResponse";
|
||||
|
||||
import { queuedResponseHandler } from "../route";
|
||||
|
||||
vi.mock("@calcom/lib/server/repository/formResponse");
|
||||
vi.mock("@calcom/app-store/routing-forms/lib/getSerializableForm");
|
||||
vi.mock("@calcom/app-store/routing-forms/lib/getResponseToStore");
|
||||
vi.mock("@calcom/app-store/routing-forms/lib/formSubmissionUtils");
|
||||
|
||||
const mockQueuedFormResponse = {
|
||||
id: "1",
|
||||
formId: "mock-form-id",
|
||||
form: {
|
||||
id: "mock-form-id",
|
||||
name: "Test Form",
|
||||
description: "Test Form Description",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
fields: [],
|
||||
routes: [],
|
||||
userId: 1,
|
||||
user: {
|
||||
id: 1,
|
||||
email: "test@example.com",
|
||||
},
|
||||
team: null,
|
||||
teamId: null,
|
||||
position: 1,
|
||||
updatedById: null,
|
||||
settings: {},
|
||||
disabled: false,
|
||||
},
|
||||
chosenRouteId: "mock-chosen-route-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
response: {},
|
||||
actualResponseId: 1,
|
||||
};
|
||||
|
||||
describe("queuedResponseHandler", () => {
|
||||
it("should process a queued form response", async () => {
|
||||
vi.mocked(RoutingFormResponseRepository.getQueuedFormResponseFromId).mockResolvedValue(
|
||||
mockQueuedFormResponse
|
||||
);
|
||||
|
||||
vi.mocked(getSerializableForm).mockResolvedValue({
|
||||
id: "mock-form-id",
|
||||
name: "Test Form",
|
||||
description: "Test Form Description",
|
||||
fields: [],
|
||||
routes: [],
|
||||
userId: 1,
|
||||
teamId: null,
|
||||
position: 1,
|
||||
updatedById: null,
|
||||
disabled: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
settings: {},
|
||||
} as unknown as Awaited<ReturnType<typeof getSerializableForm>>);
|
||||
|
||||
vi.mocked(getResponseToStore).mockResolvedValue({
|
||||
id: "mock-form-id",
|
||||
name: "Test Form",
|
||||
description: "Test Form Description",
|
||||
fields: [],
|
||||
routes: [],
|
||||
user: {
|
||||
id: 1,
|
||||
email: "test@example.com",
|
||||
},
|
||||
team: null,
|
||||
} as unknown as Awaited<ReturnType<typeof getResponseToStore>>);
|
||||
|
||||
vi.mocked(onSubmissionOfFormResponse).mockResolvedValue({
|
||||
id: "mock-form-id",
|
||||
name: "Test Form",
|
||||
description: "Test Form Description",
|
||||
fields: [],
|
||||
routes: [],
|
||||
user: {
|
||||
id: 1,
|
||||
email: "test@example.com",
|
||||
},
|
||||
team: null,
|
||||
} as unknown as Awaited<ReturnType<typeof onSubmissionOfFormResponse>>);
|
||||
|
||||
vi.mocked(RoutingFormResponseRepository.recordFormResponse).mockResolvedValue({
|
||||
id: "mock-form-id",
|
||||
name: "Test Form",
|
||||
description: "Test Form Description",
|
||||
fields: [],
|
||||
routes: [],
|
||||
user: {
|
||||
id: 1,
|
||||
email: "test@example.com",
|
||||
},
|
||||
team: null,
|
||||
chosenRouteId: "mock-chosen-route-id",
|
||||
} as unknown as Awaited<ReturnType<typeof RoutingFormResponseRepository.recordFormResponse>>);
|
||||
|
||||
const response = await queuedResponseHandler({
|
||||
queuedFormResponseId: "1",
|
||||
params: {},
|
||||
});
|
||||
expect(response).toEqual({
|
||||
formResponseId: "mock-form-id",
|
||||
message: "Processed",
|
||||
});
|
||||
});
|
||||
|
||||
it("if no queued form response is found, should return early", async () => {
|
||||
vi.mocked(RoutingFormResponseRepository.getQueuedFormResponseFromId).mockResolvedValue(null);
|
||||
const response = await queuedResponseHandler({
|
||||
queuedFormResponseId: "1",
|
||||
params: {},
|
||||
});
|
||||
expect(response).toEqual({
|
||||
formResponseId: null,
|
||||
message: "Already processed",
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw if form has no fields", async () => {
|
||||
vi.mocked(RoutingFormResponseRepository.getQueuedFormResponseFromId).mockResolvedValue(
|
||||
mockQueuedFormResponse
|
||||
);
|
||||
vi.mocked(getSerializableForm).mockResolvedValue({
|
||||
id: "mock-form-id",
|
||||
name: "Test Form",
|
||||
description: "Test Form Description",
|
||||
fields: undefined,
|
||||
routes: [],
|
||||
user: {
|
||||
id: 1,
|
||||
email: "test@example.com",
|
||||
},
|
||||
team: null,
|
||||
} as unknown as Awaited<ReturnType<typeof getSerializableForm>>);
|
||||
await expect(
|
||||
queuedResponseHandler({
|
||||
queuedFormResponseId: "1",
|
||||
params: {},
|
||||
})
|
||||
).rejects.toThrow("Form has no fields");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z, ZodError } from "zod";
|
||||
|
||||
import { onSubmissionOfFormResponse } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
|
||||
import { getResponseToStore } from "@calcom/app-store/routing-forms/lib/getResponseToStore";
|
||||
import { getSerializableForm } from "@calcom/app-store/routing-forms/lib/getSerializableForm";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { RoutingFormResponseRepository } from "@calcom/lib/server/repository/formResponse";
|
||||
|
||||
import { defaultResponderForAppDir } from "../../defaultResponderForAppDir";
|
||||
|
||||
const queuedResponseSchema = z.object({
|
||||
queuedFormResponseId: z.string(),
|
||||
params: z.record(z.string(), z.string().or(z.array(z.string()))),
|
||||
});
|
||||
|
||||
export const queuedResponseHandler = async ({
|
||||
queuedFormResponseId,
|
||||
params,
|
||||
}: {
|
||||
queuedFormResponseId: string;
|
||||
params: Record<string, string | string[]>;
|
||||
}) => {
|
||||
// Get the queued response
|
||||
const queuedFormResponse = await RoutingFormResponseRepository.getQueuedFormResponseFromId(
|
||||
queuedFormResponseId
|
||||
);
|
||||
|
||||
if (!queuedFormResponse) {
|
||||
return {
|
||||
formResponseId: null,
|
||||
message: "Already processed",
|
||||
};
|
||||
}
|
||||
|
||||
const serializableForm = await getSerializableForm({
|
||||
form: queuedFormResponse.form,
|
||||
});
|
||||
|
||||
if (!serializableForm.fields) {
|
||||
throw new Error("Form has no fields");
|
||||
}
|
||||
|
||||
const response = getResponseToStore({
|
||||
formFields: serializableForm.fields,
|
||||
fieldsResponses: params,
|
||||
});
|
||||
|
||||
const formResponse = await RoutingFormResponseRepository.recordFormResponse({
|
||||
formId: queuedFormResponse.formId,
|
||||
queuedFormResponseId: queuedFormResponse.id,
|
||||
// We record new response here as that might be different from the queued response depending on if the user changed something in b/w before clicking CTA and that something wasn't prerendered
|
||||
response,
|
||||
// We use the queuedFormResponse's chosenRouteId because that is what decided routed team members
|
||||
chosenRouteId: queuedFormResponse.chosenRouteId,
|
||||
});
|
||||
|
||||
const chosenRoute = serializableForm.routes?.find((r) => r.id === queuedFormResponse.chosenRouteId);
|
||||
await onSubmissionOfFormResponse({
|
||||
form: {
|
||||
...queuedFormResponse.form,
|
||||
...serializableForm,
|
||||
},
|
||||
formResponseInDb: formResponse,
|
||||
chosenRouteAction: chosenRoute ? ("action" in chosenRoute ? chosenRoute.action : null) : null,
|
||||
});
|
||||
|
||||
return {
|
||||
formResponseId: formResponse.id,
|
||||
message: "Processed",
|
||||
};
|
||||
};
|
||||
|
||||
export const handler = async (req: NextRequest) => {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { params, queuedFormResponseId } = queuedResponseSchema.parse(body);
|
||||
const result = await queuedResponseHandler({
|
||||
queuedFormResponseId,
|
||||
params,
|
||||
});
|
||||
|
||||
return NextResponse.json({ status: "success", data: result });
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
logger.error("Invalid input", safeStringify(error));
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "error",
|
||||
message: "Invalid input",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
logger.error("Error in queuedResponseHandler", safeStringify(error));
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "error",
|
||||
message: "Internal server error",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const POST = defaultResponderForAppDir(handler);
|
||||
@@ -0,0 +1,110 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { describe, expect, test, vi, beforeEach } from "vitest";
|
||||
|
||||
import { GET } from "../route";
|
||||
|
||||
vi.mock("next/og", () => ({
|
||||
ImageResponse: vi.fn().mockImplementation(() => ({
|
||||
body: new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array([1, 2, 3, 4]));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/OgImages", () => ({
|
||||
Meeting: vi.fn(() => null),
|
||||
App: vi.fn(() => null),
|
||||
Generic: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/constants", () => ({
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
}));
|
||||
|
||||
global.fetch = vi.fn();
|
||||
|
||||
function createNextRequest(url: string): NextRequest {
|
||||
const request = new Request(url, { method: "GET" });
|
||||
return new NextRequest(request);
|
||||
}
|
||||
|
||||
describe("GET /api/social/og/image", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)),
|
||||
} as any);
|
||||
});
|
||||
|
||||
describe("Validation errors (400 Bad Request)", () => {
|
||||
test("meeting type: returns 400 when required parameters are missing", async () => {
|
||||
const request = createNextRequest("http://example.com/api/social/og/image?type=meeting");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.headers.get("Content-Type")).toBe("application/json");
|
||||
|
||||
const errorData = await response.json();
|
||||
expect(errorData.error).toBe("Invalid parameters for meeting image");
|
||||
expect(errorData.message).toBe(
|
||||
"Required parameters: title, meetingProfileName. Optional: names, usernames, meetingImage"
|
||||
);
|
||||
});
|
||||
|
||||
test("app type: returns 400 when required parameters are missing", async () => {
|
||||
const request = createNextRequest("http://example.com/api/social/og/image?type=app");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const errorData = await response.json();
|
||||
expect(errorData.error).toBe("Invalid parameters for app image");
|
||||
expect(errorData.message).toBe("Required parameters: name, description, slug");
|
||||
});
|
||||
|
||||
test("generic type: returns 400 when required parameters are missing", async () => {
|
||||
const request = createNextRequest("http://example.com/api/social/og/image?type=generic");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const errorData = await response.json();
|
||||
expect(errorData.error).toBe("Invalid parameters for generic image");
|
||||
expect(errorData.message).toBe("Required parameters: title, description");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Not found errors (404 Not Found)", () => {
|
||||
test("returns 404 when no type parameter is provided", async () => {
|
||||
const request = createNextRequest("http://example.com/api/social/og/image");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.text()).toBe("What you're looking for is not here..");
|
||||
});
|
||||
|
||||
test("returns 404 when invalid type parameter is provided", async () => {
|
||||
const request = createNextRequest("http://example.com/api/social/og/image?type=invalid");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.text()).toBe("What you're looking for is not here..");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Server errors (500 Internal Server Error)", () => {
|
||||
test("returns 500 when font loading fails", async () => {
|
||||
vi.mocked(global.fetch).mockRejectedValue(new Error("Font loading failed"));
|
||||
|
||||
const request = createNextRequest(
|
||||
"http://example.com/api/social/og/image?type=meeting&title=Test&meetingProfileName=John"
|
||||
);
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(await response.text()).toBe("Internal server error");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
import type { NextRequest } from "next/server";
|
||||
import type { SatoriOptions } from "satori";
|
||||
import { z } from "zod";
|
||||
import { z, ZodError } from "zod";
|
||||
|
||||
import { Meeting, App, Generic } from "@calcom/lib/OgImages";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
@@ -34,75 +34,128 @@ async function handler(req: NextRequest) {
|
||||
const { searchParams } = req.nextUrl;
|
||||
const imageType = searchParams.get("type");
|
||||
|
||||
const [calFontData, interFontData, interFontMediumData] = await Promise.all([
|
||||
fetch(new URL("/fonts/cal.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
||||
fetch(new URL("/fonts/Inter-Regular.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
||||
fetch(new URL("/fonts/Inter-Medium.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
||||
]);
|
||||
const ogConfig = {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
fonts: [
|
||||
{ name: "inter", data: interFontData, weight: 400 },
|
||||
{ name: "inter", data: interFontMediumData, weight: 500 },
|
||||
{ name: "cal", data: calFontData, weight: 400 },
|
||||
{ name: "cal", data: calFontData, weight: 600 },
|
||||
] as SatoriOptions["fonts"],
|
||||
};
|
||||
try {
|
||||
const [calFontData, interFontData, interFontMediumData] = await Promise.all([
|
||||
fetch(new URL("/fonts/cal.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
||||
fetch(new URL("/fonts/Inter-Regular.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
||||
fetch(new URL("/fonts/Inter-Medium.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
||||
]);
|
||||
const ogConfig = {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
fonts: [
|
||||
{ name: "inter", data: interFontData, weight: 400 },
|
||||
{ name: "inter", data: interFontMediumData, weight: 500 },
|
||||
{ name: "cal", data: calFontData, weight: 400 },
|
||||
{ name: "cal", data: calFontData, weight: 600 },
|
||||
] as SatoriOptions["fonts"],
|
||||
};
|
||||
|
||||
switch (imageType) {
|
||||
case "meeting": {
|
||||
const { names, usernames, title, meetingProfileName, meetingImage } = meetingSchema.parse({
|
||||
names: searchParams.getAll("names"),
|
||||
usernames: searchParams.getAll("usernames"),
|
||||
title: searchParams.get("title"),
|
||||
meetingProfileName: searchParams.get("meetingProfileName"),
|
||||
meetingImage: searchParams.get("meetingImage"),
|
||||
imageType,
|
||||
});
|
||||
switch (imageType) {
|
||||
case "meeting": {
|
||||
try {
|
||||
const { names, usernames, title, meetingProfileName, meetingImage } = meetingSchema.parse({
|
||||
names: searchParams.getAll("names"),
|
||||
usernames: searchParams.getAll("usernames"),
|
||||
title: searchParams.get("title"),
|
||||
meetingProfileName: searchParams.get("meetingProfileName"),
|
||||
meetingImage: searchParams.get("meetingImage"),
|
||||
imageType,
|
||||
});
|
||||
|
||||
const img = new ImageResponse(
|
||||
(
|
||||
<Meeting
|
||||
title={title}
|
||||
profile={{ name: meetingProfileName, image: meetingImage }}
|
||||
users={names.map((name, index) => ({ name, username: usernames[index] }))}
|
||||
/>
|
||||
),
|
||||
ogConfig
|
||||
);
|
||||
const img = new ImageResponse(
|
||||
(
|
||||
<Meeting
|
||||
title={title}
|
||||
profile={{ name: meetingProfileName, image: meetingImage }}
|
||||
users={names.map((name, index) => ({ name, username: usernames[index] }))}
|
||||
/>
|
||||
),
|
||||
ogConfig
|
||||
);
|
||||
|
||||
return new Response(img.body, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "image/png", "cache-control": "max-age=0" },
|
||||
});
|
||||
return new Response(img.body, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "image/png", "cache-control": "max-age=0" },
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Invalid parameters for meeting image",
|
||||
message:
|
||||
"Required parameters: title, meetingProfileName. Optional: names, usernames, meetingImage",
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
case "app": {
|
||||
try {
|
||||
const { name, description, slug } = appSchema.parse({
|
||||
name: searchParams.get("name"),
|
||||
description: searchParams.get("description"),
|
||||
slug: searchParams.get("slug"),
|
||||
imageType,
|
||||
});
|
||||
const img = new ImageResponse(<App name={name} description={description} slug={slug} />, ogConfig);
|
||||
|
||||
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Invalid parameters for app image",
|
||||
message: "Required parameters: name, description, slug",
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
case "generic": {
|
||||
try {
|
||||
const { title, description } = genericSchema.parse({
|
||||
title: searchParams.get("title"),
|
||||
description: searchParams.get("description"),
|
||||
imageType,
|
||||
});
|
||||
|
||||
const img = new ImageResponse(<Generic title={title} description={description} />, ogConfig);
|
||||
|
||||
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Invalid parameters for generic image",
|
||||
message: "Required parameters: title, description",
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return new Response("What you're looking for is not here..", { status: 404 });
|
||||
}
|
||||
case "app": {
|
||||
const { name, description, slug } = appSchema.parse({
|
||||
name: searchParams.get("name"),
|
||||
description: searchParams.get("description"),
|
||||
slug: searchParams.get("slug"),
|
||||
imageType,
|
||||
});
|
||||
const img = new ImageResponse(<App name={name} description={description} slug={slug} />, ogConfig);
|
||||
|
||||
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
|
||||
}
|
||||
|
||||
case "generic": {
|
||||
const { title, description } = genericSchema.parse({
|
||||
title: searchParams.get("title"),
|
||||
description: searchParams.get("description"),
|
||||
imageType,
|
||||
});
|
||||
|
||||
const img = new ImageResponse(<Generic title={title} description={description} />, ogConfig);
|
||||
|
||||
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
|
||||
}
|
||||
|
||||
default:
|
||||
return new Response("What you're looking for is not here..", { status: 404 });
|
||||
} catch (error) {
|
||||
return new Response("Internal server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
@@ -279,6 +280,7 @@ const NewRoutingManager = ({
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const bookerUrl = useBookerUrl();
|
||||
const session = useSession();
|
||||
const teamMemberIdsMatchingAttributeLogic =
|
||||
teamMembersMatchingAttributeLogic?.data
|
||||
?.map((member) => member.id)
|
||||
@@ -305,7 +307,7 @@ const NewRoutingManager = ({
|
||||
booking,
|
||||
currentResponse,
|
||||
searchParams: new URLSearchParams({
|
||||
// rescheduleReason
|
||||
rescheduledBy: session?.data?.user?.email ?? "",
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -469,6 +471,7 @@ const NewRoutingManager = ({
|
||||
// TODO: Long term, we should refactor handleNewBooking and use a different route specific for this purpose,
|
||||
createBookingMutation.mutate({
|
||||
rescheduleUid: booking.uid,
|
||||
rescheduledBy: session?.data?.user?.email ?? undefined,
|
||||
// rescheduleReason,
|
||||
reroutingFormResponses: reroutingFormResponses,
|
||||
...getTimeslotFields(),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { Session } from "next-auth";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { RouteActionType } from "@calcom/app-store/routing-forms/zod";
|
||||
@@ -37,6 +39,15 @@ const mockOpen = vi.fn((_url: string) => {
|
||||
|
||||
vi.stubGlobal("open", mockOpen);
|
||||
|
||||
const mockSession = {
|
||||
expires: new Date(Date.now() + 2 * 86400).toISOString(),
|
||||
user: {
|
||||
id: 1,
|
||||
name: "Test User",
|
||||
email: "user@example.com",
|
||||
},
|
||||
} as Session;
|
||||
|
||||
vi.mock("@calcom/app-store/routing-forms/components/FormInputFields", () => ({
|
||||
default: vi.fn(({ response, form, setResponse, disabledFields }) => {
|
||||
return (
|
||||
@@ -404,9 +415,11 @@ describe("RerouteDialog", () => {
|
||||
describe("New Routing tests", () => {
|
||||
test("when verify_new_route is clicked, the form is submitted", async () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
||||
</TooltipProvider>
|
||||
<SessionProvider session={mockSession}>
|
||||
<TooltipProvider>
|
||||
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
||||
</TooltipProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
fireEvent.click(screen.getByText("verify_new_route"));
|
||||
|
||||
@@ -425,9 +438,11 @@ describe("RerouteDialog", () => {
|
||||
describe("New tab rescheduling", () => {
|
||||
test("new tab is closed when new booking is rerouted", async () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
||||
</TooltipProvider>
|
||||
<SessionProvider session={mockSession}>
|
||||
<TooltipProvider>
|
||||
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
||||
</TooltipProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
clickVerifyNewRouteButton();
|
||||
clickRescheduleToTheNewEventWithDifferentTimeslotButton();
|
||||
@@ -466,9 +481,11 @@ describe("RerouteDialog", () => {
|
||||
|
||||
test("Rescheduling with same timeslot works", async () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
||||
</TooltipProvider>
|
||||
<SessionProvider session={mockSession}>
|
||||
<TooltipProvider>
|
||||
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
||||
</TooltipProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
clickVerifyNewRouteButton();
|
||||
clickRescheduleWithSameTimeslotOfChosenEventButton();
|
||||
|
||||
@@ -105,7 +105,7 @@ const CustomEmailTextField = ({
|
||||
<DropdownItem
|
||||
StartIcon="trash"
|
||||
color="destructive"
|
||||
className="disabled:opacity-40"
|
||||
className="rounded-t-none disabled:opacity-40"
|
||||
onClick={handleItemDelete}
|
||||
disabled={emailPrimary}
|
||||
data-testid="secondary-email-delete-button">
|
||||
|
||||
@@ -134,7 +134,7 @@ const getEventTypes = async (userId: number, teamIds?: number[]) => {
|
||||
.sort((eventTypeA, eventTypeB) => eventTypeB.position - eventTypeA.position),
|
||||
}));
|
||||
} else {
|
||||
const user = await prisma.user.findFirst({
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
|
||||
@@ -157,7 +157,7 @@ export const handleSeatsEventTypeOnBooking = async (
|
||||
} | null;
|
||||
let seatAttendee: seatAttendee = null;
|
||||
if (seatReferenceUid) {
|
||||
seatAttendee = await prisma.bookingSeat.findFirst({
|
||||
seatAttendee = await prisma.bookingSeat.findUnique({
|
||||
where: {
|
||||
referenceUid: seatReferenceUid,
|
||||
},
|
||||
|
||||
@@ -23,6 +23,8 @@ type CalVideoSettings = {
|
||||
disableRecordingForGuests: boolean;
|
||||
disableRecordingForOrganizer: boolean;
|
||||
enableAutomaticTranscription: boolean;
|
||||
disableTranscriptionForGuests: boolean;
|
||||
disableTranscriptionForOrganizer: boolean;
|
||||
};
|
||||
|
||||
const shouldEnableRecordButton = ({
|
||||
@@ -57,6 +59,25 @@ const shouldEnableAutomaticTranscription = ({
|
||||
return !!calVideoSettings.enableAutomaticTranscription;
|
||||
};
|
||||
|
||||
const shouldEnableTranscriptionButton = ({
|
||||
hasTeamPlan,
|
||||
calVideoSettings,
|
||||
isOrganizer,
|
||||
}: {
|
||||
hasTeamPlan: boolean;
|
||||
calVideoSettings?: CalVideoSettings | null;
|
||||
isOrganizer: boolean;
|
||||
}) => {
|
||||
if (!hasTeamPlan) return false;
|
||||
if (!calVideoSettings) return true;
|
||||
|
||||
if (isOrganizer) {
|
||||
return !calVideoSettings.disableTranscriptionForOrganizer;
|
||||
}
|
||||
|
||||
return !calVideoSettings.disableTranscriptionForGuests;
|
||||
};
|
||||
|
||||
const checkIfUserIsHost = async ({
|
||||
booking,
|
||||
sessionUserId,
|
||||
@@ -222,6 +243,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
hasTeamPlan: !!hasTeamPlan,
|
||||
calVideoSettings: bookingObj.eventType?.calVideoSettings,
|
||||
});
|
||||
const showTranscriptionButton = shouldEnableTranscriptionButton({
|
||||
hasTeamPlan: !!hasTeamPlan,
|
||||
calVideoSettings: bookingObj.eventType?.calVideoSettings,
|
||||
isOrganizer: sessionUserId === bookingObj.user?.id,
|
||||
});
|
||||
|
||||
return {
|
||||
props: {
|
||||
@@ -245,6 +271,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
loggedInUserName: sessionUserId ? session?.user?.name : undefined,
|
||||
showRecordingButton,
|
||||
enableAutomaticTranscription,
|
||||
showTranscriptionButton,
|
||||
rediectAttendeeToOnExit: isOrganizer
|
||||
? undefined
|
||||
: bookingObj.eventType?.calVideoSettings?.redirectUrlOnExit,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useReactTable, getCoreRowModel, getSortedRowModel, createColumnHelper } from "@tanstack/react-table";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useMemo, useRef } from "react";
|
||||
|
||||
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
|
||||
@@ -44,34 +45,6 @@ type RecurringInfo = {
|
||||
bookings: { [key: string]: Date[] };
|
||||
};
|
||||
|
||||
const tabs: (VerticalTabItemProps | HorizontalTabItemProps)[] = [
|
||||
{
|
||||
name: "upcoming",
|
||||
href: "/bookings/upcoming",
|
||||
"data-testid": "upcoming",
|
||||
},
|
||||
{
|
||||
name: "unconfirmed",
|
||||
href: "/bookings/unconfirmed",
|
||||
"data-testid": "unconfirmed",
|
||||
},
|
||||
{
|
||||
name: "recurring",
|
||||
href: "/bookings/recurring",
|
||||
"data-testid": "recurring",
|
||||
},
|
||||
{
|
||||
name: "past",
|
||||
href: "/bookings/past",
|
||||
"data-testid": "past",
|
||||
},
|
||||
{
|
||||
name: "cancelled",
|
||||
href: "/bookings/cancelled",
|
||||
"data-testid": "cancelled",
|
||||
},
|
||||
];
|
||||
|
||||
const descriptionByStatus: Record<BookingListingStatus, string> = {
|
||||
upcoming: "upcoming_bookings",
|
||||
recurring: "recurring_bookings",
|
||||
@@ -107,6 +80,46 @@ function BookingsContent({ status }: BookingsProps) {
|
||||
const { t } = useLocale();
|
||||
const user = useMeQuery().data;
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Generate dynamic tabs that preserve query parameters
|
||||
const tabs: (VerticalTabItemProps | HorizontalTabItemProps)[] = useMemo(() => {
|
||||
const queryString = searchParams?.toString() || "";
|
||||
|
||||
const baseTabConfigs = [
|
||||
{
|
||||
name: "upcoming",
|
||||
path: "/bookings/upcoming",
|
||||
"data-testid": "upcoming",
|
||||
},
|
||||
{
|
||||
name: "unconfirmed",
|
||||
path: "/bookings/unconfirmed",
|
||||
"data-testid": "unconfirmed",
|
||||
},
|
||||
{
|
||||
name: "recurring",
|
||||
path: "/bookings/recurring",
|
||||
"data-testid": "recurring",
|
||||
},
|
||||
{
|
||||
name: "past",
|
||||
path: "/bookings/past",
|
||||
"data-testid": "past",
|
||||
},
|
||||
{
|
||||
name: "cancelled",
|
||||
path: "/bookings/cancelled",
|
||||
"data-testid": "cancelled",
|
||||
},
|
||||
];
|
||||
|
||||
return baseTabConfigs.map((tabConfig) => ({
|
||||
name: tabConfig.name,
|
||||
href: queryString ? `${tabConfig.path}?${queryString}` : tabConfig.path,
|
||||
"data-testid": tabConfig["data-testid"],
|
||||
}));
|
||||
}, [searchParams?.toString()]);
|
||||
|
||||
const eventTypeIds = useFilterValue("eventTypeId", ZMultiSelectFilterValue)?.data as number[] | undefined;
|
||||
const teamIds = useFilterValue("teamId", ZMultiSelectFilterValue)?.data as number[] | undefined;
|
||||
|
||||
@@ -234,7 +234,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
eventTypeId: eventType.id,
|
||||
team: eventType.team,
|
||||
owner: eventType.users[0] ?? null,
|
||||
orgSlug: currentOrgDomain,
|
||||
organizationId: session?.user?.profile?.organizationId ?? session?.user?.org?.id ?? null,
|
||||
}),
|
||||
profile,
|
||||
eventType,
|
||||
|
||||
@@ -315,17 +315,20 @@ export default function Success(props: PageProps) {
|
||||
return t(`${titlePrefix}emailed_host_and_attendee${titleSuffix}`, {
|
||||
host,
|
||||
attendee,
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
}
|
||||
if (isAttendee) {
|
||||
return t(`${titlePrefix}emailed_host_and_attendee${titleSuffix}`, {
|
||||
host,
|
||||
attendee,
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
}
|
||||
return t(`${titlePrefix}emailed_host_and_attendee${titleSuffix}`, {
|
||||
host,
|
||||
attendee,
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
}
|
||||
return t(`emailed_host_and_attendee${titleSuffix}`);
|
||||
@@ -546,7 +549,7 @@ export default function Success(props: PageProps) {
|
||||
</h4>
|
||||
)}
|
||||
|
||||
<div className="border-subtle text-default mt-8 grid grid-cols-3 border-t pt-8 text-left rtl:text-right">
|
||||
<div className="border-subtle text-default mt-8 grid grid-cols-3 gap-x-4 border-t pt-8 text-left rtl:text-right sm:gap-x-0">
|
||||
{(isCancelled || reschedule) && cancellationReason && (
|
||||
<>
|
||||
<div className="font-medium">
|
||||
|
||||
@@ -653,7 +653,7 @@ export const InfiniteEventTypeList = ({
|
||||
setDeleteDialogSchedulingType(type.schedulingType);
|
||||
}}
|
||||
StartIcon="trash"
|
||||
className="w-full rounded-none">
|
||||
className="w-full rounded-t-none">
|
||||
{t("delete")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
@@ -750,7 +750,7 @@ export const InfiniteEventTypeList = ({
|
||||
setDeleteDialogSchedulingType(type.schedulingType);
|
||||
}}
|
||||
StartIcon="trash"
|
||||
className="w-full rounded-none">
|
||||
className="w-full rounded-t-none">
|
||||
{t("delete")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -3,22 +3,60 @@
|
||||
import { ProgressBar } from "@tremor/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import ServerTrans from "@calcom/lib/components/ServerTrans";
|
||||
import { IS_SMS_CREDITS_ENABLED } from "@calcom/lib/constants";
|
||||
import { downloadAsCsv } from "@calcom/lib/csvUtils";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Select } from "@calcom/ui/components/form";
|
||||
import { TextField, Label, InputError } from "@calcom/ui/components/form";
|
||||
import { showToast } from "@calcom/ui/toast";
|
||||
|
||||
import { BillingCreditsSkeleton } from "./BillingCreditsSkeleton";
|
||||
|
||||
type MonthOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
|
||||
// returns the last 12 months starting from May 2025 (when credits were introduced)
|
||||
const getMonthOptions = (): MonthOption[] => {
|
||||
const options: MonthOption[] = [];
|
||||
const minDate = dayjs.utc("2025-05-01");
|
||||
|
||||
let date = dayjs.utc();
|
||||
let count = 0;
|
||||
while ((date.isAfter(minDate) || date.isSame(minDate, "month")) && count < 12) {
|
||||
const startDate = date.startOf("month");
|
||||
const endDate = date.endOf("month");
|
||||
options.push({
|
||||
value: date.format("MMMM YYYY"),
|
||||
label: date.format("MMMM YYYY"),
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
});
|
||||
date = date.subtract(1, "month");
|
||||
count++;
|
||||
}
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
export default function BillingCredits() {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const monthOptions = useMemo(() => getMonthOptions(), []);
|
||||
const [selectedMonth, setSelectedMonth] = useState<MonthOption>(monthOptions[0]);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -28,8 +66,8 @@ export default function BillingCredits() {
|
||||
} = useForm<{ quantity: number }>({ defaultValues: { quantity: 50 } });
|
||||
|
||||
const params = useParamsWithFallback();
|
||||
|
||||
const teamId = params.id ? Number(params.id) : undefined;
|
||||
|
||||
const { data: creditsData, isLoading } = trpc.viewer.credits.getAllCredits.useQuery({ teamId });
|
||||
|
||||
const buyCreditsMutation = trpc.viewer.credits.buyCredits.useMutation({
|
||||
@@ -43,6 +81,27 @@ export default function BillingCredits() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleDownload = async () => {
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
const result = await utils.viewer.credits.downloadExpenseLog.fetch({
|
||||
teamId,
|
||||
startDate: selectedMonth.startDate,
|
||||
endDate: selectedMonth.endDate,
|
||||
});
|
||||
if (result?.csvData) {
|
||||
const filename = `credit-expense-log-${selectedMonth.value.toLowerCase().replace(" ", "-")}.csv`;
|
||||
downloadAsCsv(result.csvData, filename);
|
||||
} else {
|
||||
showToast(t("error_downloading_expense_log"), "error");
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(t("error_downloading_expense_log"), "error");
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!IS_SMS_CREDITS_ENABLED) {
|
||||
return null;
|
||||
}
|
||||
@@ -112,8 +171,8 @@ export default function BillingCredits() {
|
||||
<hr className="border-subtle mb-3 mt-3" />
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex">
|
||||
<div className="mr-auto">
|
||||
<div className="mb-2 font-semibold">{t("buy_additional_credits")}</div>
|
||||
<div className="-mb-1 mr-auto">
|
||||
<Label>{t("buy_additional_credits")}</Label>
|
||||
<div className="flex flex-col">
|
||||
<TextField
|
||||
required
|
||||
@@ -132,12 +191,32 @@ export default function BillingCredits() {
|
||||
{errors.quantity && <InputError message={errors.quantity.message ?? t("invalid_input")} />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-1 mt-auto">
|
||||
<div className="mt-auto">
|
||||
<Button color="primary" target="_blank" EndIcon="external-link" type="submit">
|
||||
{t("buy_credits")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="-mx-6 mb-6 mt-6">
|
||||
<hr className="border-subtle mb-3 mt-3" />
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="mr-auto ">
|
||||
<Label className="mb-4">{t("download_expense_log")}</Label>
|
||||
<div className="mt-2 flex flex-col">
|
||||
<Select
|
||||
options={monthOptions}
|
||||
value={selectedMonth}
|
||||
onChange={(option) => option && setSelectedMonth(option)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
<Button onClick={handleDownload} loading={isDownloading} StartIcon="file-down">
|
||||
{t("download")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -45,7 +45,7 @@ export function BillingCreditsSkeleton() {
|
||||
<div className="flex">
|
||||
<div className="mr-auto">
|
||||
<div>
|
||||
<SkeletonText className="mb-2 mt-1 h-5 w-[268px]" /> {/* Buy credits label */}
|
||||
<SkeletonText className="mb-2 h-4 w-[268px]" /> {/* Buy credits label */}
|
||||
</div>
|
||||
<div>
|
||||
<SkeletonText className="mt-2 h-8 w-[240px] rounded-md" /> {/* Input field */}
|
||||
@@ -56,6 +56,24 @@ export function BillingCreditsSkeleton() {
|
||||
<SkeletonButton className="h-9 w-[120px] rounded-md" /> {/* Buy button */}
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mx-6 mb-6 mt-6">
|
||||
<hr className="border-subtle mb-3 mt-3" />
|
||||
</div>
|
||||
{/* Download expense log */}
|
||||
<div className="flex">
|
||||
<div className="mr-auto mt-1">
|
||||
<div>
|
||||
<SkeletonText className="mb-2 h-4 w-[156px]" /> {/* Label */}
|
||||
</div>
|
||||
<div>
|
||||
<SkeletonText className="mt-2 h-8 w-[156px] rounded-md" /> {/* Select */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto ">
|
||||
<SkeletonButton className="h-9 w-[109px] rounded-md" /> {/* Download button */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
|
||||
@@ -9,14 +9,14 @@ import { useOnboarding } from "@calcom/features/ee/organizations/lib/onboardingS
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
import { TextField } from "@calcom/ui/components/form";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { TextField } from "@calcom/ui/components/form";
|
||||
import { SkeletonButton, SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
type TeamMember = RouterOutputs["viewer"]["teams"]["listMembers"]["members"][number];
|
||||
|
||||
@@ -29,10 +29,15 @@ const AddNewTeamMembers = () => {
|
||||
return <AddNewTeamMembersForm />;
|
||||
};
|
||||
|
||||
const useCheckout = () => {
|
||||
const useOrgCreation = () => {
|
||||
const { t } = useLocale();
|
||||
const session = useSession();
|
||||
const utils = trpc.useUtils();
|
||||
const [serverErrorMessage, setServerErrorMessage] = useState("");
|
||||
const mutation = trpc.viewer.organizations.createWithPaymentIntent.useMutation({
|
||||
const { useOnboardingStore, isBillingEnabled } = useOnboarding();
|
||||
const { reset } = useOnboardingStore();
|
||||
|
||||
const checkoutMutation = trpc.viewer.organizations.createWithPaymentIntent.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data.checkoutUrl) {
|
||||
window.location.href = data.checkoutUrl;
|
||||
@@ -43,17 +48,34 @@ const useCheckout = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const createOrgMutation = trpc.viewer.organizations.createSelfHosted.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
if (data.organization) {
|
||||
// Invalidate the organizations query to ensure fresh data on the next page
|
||||
await utils.viewer.organizations.listCurrent.invalidate();
|
||||
await session.update();
|
||||
reset();
|
||||
window.location.href = `${window.location.origin}/settings/organizations/profile`;
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setServerErrorMessage(t(error.message));
|
||||
},
|
||||
});
|
||||
|
||||
const mutationToUse = isBillingEnabled ? checkoutMutation : createOrgMutation;
|
||||
|
||||
return {
|
||||
mutation,
|
||||
mutate: mutation.mutate,
|
||||
isPending: mutation.isPending,
|
||||
mutation: mutationToUse,
|
||||
mutate: mutationToUse.mutate,
|
||||
isPending: mutationToUse.isPending,
|
||||
errorMessage: serverErrorMessage,
|
||||
};
|
||||
};
|
||||
|
||||
export const AddNewTeamMembersForm = () => {
|
||||
const { t } = useLocale();
|
||||
const { useOnboardingStore } = useOnboarding();
|
||||
const { useOnboardingStore, isBillingEnabled } = useOnboarding();
|
||||
const {
|
||||
addInvitedMember,
|
||||
removeInvitedMember,
|
||||
@@ -64,7 +86,7 @@ export const AddNewTeamMembersForm = () => {
|
||||
bio,
|
||||
onboardingId,
|
||||
} = useOnboardingStore();
|
||||
const checkout = useCheckout();
|
||||
const orgCreation = useOrgCreation();
|
||||
|
||||
const teamIds = teams.filter((team) => team.isBeingMigrated && team.id > 0).map((team) => team.id);
|
||||
|
||||
@@ -117,9 +139,9 @@ export const AddNewTeamMembersForm = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{checkout.errorMessage && (
|
||||
{orgCreation.errorMessage && (
|
||||
<div className="mb-4">
|
||||
<Alert severity="error" message={checkout.errorMessage} />
|
||||
<Alert severity="error" message={orgCreation.errorMessage} />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-6">
|
||||
@@ -180,6 +202,7 @@ export const AddNewTeamMembersForm = () => {
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 mt-6 flex items-center justify-end">
|
||||
<Button
|
||||
onClick={() => {
|
||||
@@ -190,7 +213,7 @@ export const AddNewTeamMembersForm = () => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
checkout.mutation.mutate({
|
||||
orgCreation.mutation.mutate({
|
||||
logo,
|
||||
bio,
|
||||
teams,
|
||||
@@ -198,8 +221,8 @@ export const AddNewTeamMembersForm = () => {
|
||||
onboardingId,
|
||||
});
|
||||
}}
|
||||
loading={checkout.isPending}>
|
||||
{t("checkout")}
|
||||
loading={orgCreation.isPending}>
|
||||
{isBillingEnabled ? t("checkout") : t("create")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -691,25 +691,23 @@ export default function Signup({
|
||||
/>
|
||||
</div>
|
||||
<div className="mr-12 mt-8 hidden h-full w-full grid-cols-3 gap-4 overflow-hidden lg:grid">
|
||||
{FEATURES.map((feature) => (
|
||||
<>
|
||||
<div className="max-w-52 mb-8 flex flex-col leading-none sm:mb-0">
|
||||
<div className="text-emphasis items-center">
|
||||
<Icon name={feature.icon} className="mb-1 h-4 w-4" />
|
||||
<span className="text-sm font-medium">{t(feature.title)}</span>
|
||||
</div>
|
||||
<div className="text-subtle text-sm">
|
||||
<p>
|
||||
{t(
|
||||
feature.description,
|
||||
feature.i18nOptions && {
|
||||
...feature.i18nOptions,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{FEATURES.map((feature, index) => (
|
||||
<div key={index} className="max-w-52 mb-8 flex flex-col leading-none sm:mb-0">
|
||||
<div className="text-emphasis items-center">
|
||||
<Icon name={feature.icon} className="mb-1 h-4 w-4" />
|
||||
<span className="text-sm font-medium">{t(feature.title)}</span>
|
||||
</div>
|
||||
</>
|
||||
<div className="text-subtle text-sm">
|
||||
<p>
|
||||
{t(
|
||||
feature.description,
|
||||
feature.i18nOptions && {
|
||||
...feature.i18nOptions,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -73,9 +73,11 @@ export interface DailyCustomTrayButton {
|
||||
export const CalAiTranscribe = ({
|
||||
showRecordingButton,
|
||||
enableAutomaticTranscription,
|
||||
showTranscriptionButton,
|
||||
}: {
|
||||
showRecordingButton: boolean;
|
||||
enableAutomaticTranscription: boolean;
|
||||
showTranscriptionButton: boolean;
|
||||
}) => {
|
||||
const daily = useDaily();
|
||||
const { t } = useLocale();
|
||||
@@ -101,7 +103,11 @@ export const CalAiTranscribe = ({
|
||||
recording,
|
||||
}
|
||||
: {}),
|
||||
transcription,
|
||||
...(showTranscriptionButton
|
||||
? {
|
||||
transcription,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ export default function JoinCall(props: PageProps) {
|
||||
overrideName,
|
||||
showRecordingButton,
|
||||
enableAutomaticTranscription,
|
||||
showTranscriptionButton,
|
||||
rediectAttendeeToOnExit,
|
||||
} = props;
|
||||
const [daily, setDaily] = useState<DailyCall | null>(null);
|
||||
@@ -80,12 +81,16 @@ export default function JoinCall(props: PageProps) {
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
transcription: {
|
||||
label: "Cal.ai",
|
||||
tooltip: "Transcription powered by AI",
|
||||
iconPath: TRANSCRIPTION_STOPPED_ICON,
|
||||
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
|
||||
},
|
||||
...(showTranscriptionButton
|
||||
? {
|
||||
transcription: {
|
||||
label: "Cal.ai",
|
||||
tooltip: "Transcription powered by AI",
|
||||
iconPath: TRANSCRIPTION_STOPPED_ICON,
|
||||
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -114,6 +119,7 @@ export default function JoinCall(props: PageProps) {
|
||||
<CalAiTranscribe
|
||||
showRecordingButton={showRecordingButton}
|
||||
enableAutomaticTranscription={enableAutomaticTranscription}
|
||||
showTranscriptionButton={showTranscriptionButton}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ zIndex: 2, position: "relative" }}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.3.13",
|
||||
"version": "5.4.9",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -194,7 +194,7 @@
|
||||
"tailwindcss": "^3.3.3",
|
||||
"tailwindcss-animate": "^1.0.6",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.8.3"
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"nextBundleAnalysis": {
|
||||
"budget": 358400,
|
||||
|
||||
@@ -121,7 +121,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
if (!orgId) {
|
||||
orgId = await getOrgIdFromMemberOrTeamId({
|
||||
memberId: parsedUserId,
|
||||
...(!parsedTeamId ? { memberId: parsedUserId } : {}),
|
||||
teamId: parsedTeamId,
|
||||
});
|
||||
}
|
||||
@@ -137,7 +137,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
return res.status(200).send(`SMS are free for organizations. Credits set to 0`);
|
||||
}
|
||||
|
||||
const price = await twilio.getPriceForSMS(smsSid);
|
||||
const { price, numSegments } = await twilio.getMessageInfo(smsSid);
|
||||
|
||||
const credits = price ? creditService.calculateCreditsFromPrice(price) : null;
|
||||
|
||||
@@ -147,6 +147,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
userId: parsedUserId,
|
||||
smsSid,
|
||||
bookingUid: parsedBookingUid,
|
||||
smsSegments: numSegments ?? undefined,
|
||||
});
|
||||
|
||||
if (chargedUserOrTeamId) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { randomString } from "@calcom/lib/random";
|
||||
import { test } from "./lib/fixtures";
|
||||
import {
|
||||
bookTimeSlot,
|
||||
createNewEventType,
|
||||
createNewUserEventType,
|
||||
gotoBookingPage,
|
||||
gotoFirstEventType,
|
||||
saveEventType,
|
||||
@@ -55,7 +55,7 @@ test.describe("Event Types tests", () => {
|
||||
test("can add new event type", async ({ page }) => {
|
||||
const nonce = randomString(3);
|
||||
const eventTitle = `hello ${nonce}`;
|
||||
await createNewEventType(page, { eventTitle });
|
||||
await createNewUserEventType(page, { eventTitle });
|
||||
await page.goto("/event-types");
|
||||
await expect(page.locator(`text='${eventTitle}'`)).toBeVisible();
|
||||
});
|
||||
@@ -63,7 +63,7 @@ test.describe("Event Types tests", () => {
|
||||
test("new event type appears first in the list", async ({ page }) => {
|
||||
const nonce = randomString(3);
|
||||
const eventTitle = `hello ${nonce}`;
|
||||
await createNewEventType(page, { eventTitle });
|
||||
await createNewUserEventType(page, { eventTitle });
|
||||
await page.goto("/event-types");
|
||||
const firstEvent = page.locator("[data-testid=event-types] > li a").first();
|
||||
const firstEventTitle = await firstEvent.getAttribute("title");
|
||||
@@ -73,7 +73,7 @@ test.describe("Event Types tests", () => {
|
||||
test("enabling recurring event comes with default options", async ({ page }) => {
|
||||
const nonce = randomString(3);
|
||||
const eventTitle = `my recurring event ${nonce}`;
|
||||
await createNewEventType(page, { eventTitle });
|
||||
await createNewUserEventType(page, { eventTitle });
|
||||
|
||||
// fix the race condition
|
||||
await page.waitForSelector('[data-testid="event-title"]');
|
||||
@@ -393,7 +393,7 @@ test.describe("Event Types tests", () => {
|
||||
}) => {
|
||||
const nonce = randomString(3);
|
||||
const eventTitle = `Conflict event ${nonce}`;
|
||||
await createNewEventType(page, { eventTitle });
|
||||
await createNewUserEventType(page, { eventTitle });
|
||||
await page.goto("/event-types");
|
||||
await page.click(`text=${eventTitle}`);
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { loginUser } from "playwright/fixtures/regularBookings";
|
||||
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { test } from "../lib/fixtures";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.describe("AI Translation - Event Type", () => {
|
||||
test("Toggle should not be clickable for free plan members", async ({ page, users, bookingPage }) => {
|
||||
await loginUser(users);
|
||||
await page.goto("/event-types");
|
||||
await bookingPage.goToEventType("30 min");
|
||||
expect(await bookingPage.getAITranslationToggleDisabled()).toBe(true);
|
||||
});
|
||||
|
||||
test("Toggle should not be clickable for team plan members", async ({ page, users, bookingPage }) => {
|
||||
const user = await users.create(
|
||||
{ name: "testuser" },
|
||||
{
|
||||
hasTeam: true,
|
||||
teamRole: MembershipRole.MEMBER,
|
||||
}
|
||||
);
|
||||
await user.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
await bookingPage.goToEventType("30 min");
|
||||
expect(await bookingPage.getAITranslationToggleDisabled()).toBe(true);
|
||||
});
|
||||
|
||||
test("Toggle should be clickable for org members", async ({ page, orgs, users, bookingPage }) => {
|
||||
const org = await orgs.create({
|
||||
name: "acme",
|
||||
});
|
||||
const user = await users.create({
|
||||
organizationId: org.id,
|
||||
roleInOrganization: MembershipRole.MEMBER,
|
||||
});
|
||||
await user.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
await bookingPage.goToEventType("30 min");
|
||||
expect(await bookingPage.getAITranslationToggleDisabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -198,5 +198,19 @@ export function createBookingPageFixture(page: Page) {
|
||||
checkTimeSlotsCount: async (eventTypePage: Page, count: number) => {
|
||||
await expect(eventTypePage.getByTestId("time")).toHaveCount(count);
|
||||
},
|
||||
getAITranslationToggleDisabled: async () => {
|
||||
await page.waitForSelector('[data-testid="ai_translation_toggle"]', {
|
||||
timeout: 5000,
|
||||
state: "attached",
|
||||
});
|
||||
const toggle = page.getByTestId("ai_translation_toggle");
|
||||
return (await toggle.getAttribute("disabled")) !== null;
|
||||
},
|
||||
toggleAITranslation: async () => {
|
||||
await page.getByTestId("ai_translation_toggle").click();
|
||||
},
|
||||
updateEventTypeDescription: async (description: string) => {
|
||||
await page.getByTestId("editor-input").fill(description);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -395,7 +395,7 @@ export const createUsersFixture = (
|
||||
},
|
||||
{
|
||||
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
|
||||
action: { type: "externalRedirectUrl", value: "https://cal.com/peer" },
|
||||
action: { type: "externalRedirectUrl", value: `${WEBAPP_URL}/pro` },
|
||||
queryValue: {
|
||||
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
|
||||
type: "group",
|
||||
@@ -768,6 +768,13 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
|
||||
* @deprecated use apiLogin instead
|
||||
*/
|
||||
login: async () => login({ ...(await self()), password: user.username }, store.page),
|
||||
loginOnNewBrowser: async (browser: Browser) => {
|
||||
const newContext = await browser.newContext();
|
||||
const newPage = await newContext.newPage();
|
||||
await login({ ...(await self()), password: user.username }, newPage);
|
||||
// Don't forget to: newContext.close();
|
||||
return [newContext, newPage] as const;
|
||||
},
|
||||
logout: async () => {
|
||||
await page.goto("/auth/logout");
|
||||
},
|
||||
@@ -1074,9 +1081,11 @@ export async function apiLogin(
|
||||
json: "true",
|
||||
csrfToken,
|
||||
};
|
||||
return page.context().request.post("/api/auth/callback/credentials", {
|
||||
const response = await page.context().request.post("/api/auth/callback/credentials", {
|
||||
data,
|
||||
});
|
||||
expect(response.status()).toBe(200);
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function setupEventWithPrice(eventType: Pick<Prisma.EventType, "id">, slug: string, page: Page) {
|
||||
|
||||
@@ -65,10 +65,12 @@ export async function moveUserToOrg({
|
||||
);
|
||||
}
|
||||
|
||||
const userWithSameUsernameInOrg = await prisma.user.findFirst({
|
||||
const userWithSameUsernameInOrg = await prisma.user.findUnique({
|
||||
where: {
|
||||
username: targetOrgUsername,
|
||||
organizationId: targetOrgId,
|
||||
username_organizationId: {
|
||||
username: targetOrgUsername,
|
||||
organizationId: targetOrgId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -7,12 +7,14 @@ import { createServer } from "http";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import type { Messages } from "mailhog";
|
||||
import { totp } from "otplib";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
|
||||
|
||||
import type { createEmailsFixture } from "../fixtures/emails";
|
||||
import type { CreateUsersFixture } from "../fixtures/users";
|
||||
import type { Fixtures } from "./fixtures";
|
||||
|
||||
type Request = IncomingMessage & { body?: unknown };
|
||||
@@ -177,10 +179,12 @@ export async function expectSlotNotAllowedToBook(page: Page) {
|
||||
await expect(page.locator("[data-testid=slot-not-allowed-to-book]")).toBeVisible();
|
||||
}
|
||||
|
||||
export const createNewEventType = async (page: Page, args: { eventTitle: string }) => {
|
||||
export const createNewUserEventType = async (page: Page, args: { eventTitle: string; username?: string }) => {
|
||||
await page.click("[data-testid=new-event-type]");
|
||||
const eventTitle = args.eventTitle;
|
||||
await page.fill("[name=title]", eventTitle);
|
||||
if (args.username) {
|
||||
await page.getByRole("button", { name: args.username }).click();
|
||||
}
|
||||
await page.fill("[name=title]", args.eventTitle);
|
||||
await page.fill("[name=length]", "10");
|
||||
await page.click("[type=submit]");
|
||||
|
||||
@@ -216,7 +220,7 @@ export async function setupManagedEvent({
|
||||
|
||||
export const createNewSeatedEventType = async (page: Page, args: { eventTitle: string }) => {
|
||||
const eventTitle = args.eventTitle;
|
||||
await createNewEventType(page, { eventTitle });
|
||||
await createNewUserEventType(page, { eventTitle });
|
||||
await page.waitForSelector('[data-testid="event-title"]');
|
||||
await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toHaveAttribute(
|
||||
"aria-current",
|
||||
@@ -563,3 +567,26 @@ export async function expectPageToBeNotFound({ page, url }: { page: Page; url: s
|
||||
await page.goto(`${url}`);
|
||||
await expect(page.getByTestId(`404-page`)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function setupOrgMember(users: CreateUsersFixture) {
|
||||
const orgRequestedSlug = `example-${uuid()}`;
|
||||
|
||||
const orgMember = await users.create(undefined, {
|
||||
hasTeam: true,
|
||||
isOrg: true,
|
||||
hasSubteam: true,
|
||||
isOrgVerified: true,
|
||||
isDnsSetup: true,
|
||||
orgRequestedSlug,
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
});
|
||||
|
||||
const { team: org } = await orgMember.getOrgMembership();
|
||||
const { team } = await orgMember.getFirstTeamMembership();
|
||||
const teamEvent = await orgMember.getFirstTeamEvent(team.id);
|
||||
const userEvent = orgMember.eventTypes[0];
|
||||
|
||||
await orgMember.apiLogin();
|
||||
|
||||
return { orgMember, org, team, teamEvent, userEvent };
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ test.describe("2FA Tests", async () => {
|
||||
test.afterEach(async ({ users }) => {
|
||||
await users.deleteAll();
|
||||
});
|
||||
test("should allow a user to enable 2FA and login using 2FA", async ({ page, users }) => {
|
||||
test("should allow a user to enable 2FA and login using 2FA", async ({ page, users, browser }) => {
|
||||
// log in trail user
|
||||
const user = await test.step("Enable 2FA", async () => {
|
||||
const user = await users.create();
|
||||
@@ -60,7 +60,7 @@ test.describe("2FA Tests", async () => {
|
||||
});
|
||||
|
||||
await test.step("Login with 2FA enabled", async () => {
|
||||
await user.login();
|
||||
const [secondContext, secondPage] = await user.loginOnNewBrowser(browser);
|
||||
const userWith2FaSecret = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: user.id,
|
||||
@@ -74,13 +74,14 @@ test.describe("2FA Tests", async () => {
|
||||
process.env.CALENDSO_ENCRYPTION_KEY!
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
await fillOtp({ page, secret: secret! });
|
||||
await fillOtp({ page: secondPage, secret: secret! });
|
||||
await Promise.all([
|
||||
page.press('input[name="2fa6"]', "Enter"),
|
||||
page.waitForResponse("**/api/auth/callback/credentials**"),
|
||||
secondPage.press('input[name="2fa6"]', "Enter"),
|
||||
secondPage.waitForResponse("**/api/auth/callback/credentials**"),
|
||||
]);
|
||||
const shellLocator = page.locator(`[data-testid=dashboard-shell]`);
|
||||
const shellLocator = secondPage.locator(`[data-testid=dashboard-shell]`);
|
||||
await expect(shellLocator).toBeVisible();
|
||||
await secondContext.close();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import { test } from "./lib/fixtures";
|
||||
import {
|
||||
createHttpServer,
|
||||
createNewEventType,
|
||||
createNewUserEventType,
|
||||
selectFirstAvailableTimeSlotNextMonth,
|
||||
submitAndWaitForResponse,
|
||||
} from "./lib/testUtils";
|
||||
@@ -752,7 +752,7 @@ test.describe("Text area min and max characters text", () => {
|
||||
|
||||
// We wait until loading is finished
|
||||
await page.waitForSelector('[data-testid="event-types"]');
|
||||
await createNewEventType(page, { eventTitle });
|
||||
await createNewUserEventType(page, { eventTitle });
|
||||
await page.waitForSelector('[data-testid="event-title"]');
|
||||
await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toContainText("Event Setup"); //fix the race condition
|
||||
await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toHaveAttribute(
|
||||
|
||||
@@ -368,10 +368,11 @@ test.describe("Bookings", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("Round robin event type with confirmation required handles cancellation and rebooking correctly with the same details and slot", async ({
|
||||
test("Round robin event type with confirmation required handles rejection and rebooking correctly with the same details and slot", async ({
|
||||
page,
|
||||
users,
|
||||
orgs,
|
||||
browser,
|
||||
}) => {
|
||||
const org = await orgs.create({
|
||||
name: "TestOrg",
|
||||
@@ -421,38 +422,41 @@ test.describe("Bookings", () => {
|
||||
const bookingUid = page.url().split("/booking/")[1];
|
||||
expect(bookingUid).not.toBeNull();
|
||||
|
||||
// Login as the host to cancel the booking
|
||||
await page.goto("/auth/logout");
|
||||
const allUsers = await users.get();
|
||||
|
||||
const allUsers = users.get();
|
||||
const hostUser = allUsers.find((mate) => mate.name === firstHost);
|
||||
if (!hostUser) throw new Error("Host not found");
|
||||
|
||||
await hostUser.apiLogin();
|
||||
const [secondContext, secondPage] = await hostUser.apiLoginOnNewBrowser(browser);
|
||||
|
||||
// Cancel the booking
|
||||
await page.goto(`/booking/${bookingUid}`);
|
||||
await page.getByTestId("cancel").click();
|
||||
await page.getByTestId("confirm_cancel").click();
|
||||
await page.waitForResponse((response) => response.url().includes("/api/cancel"));
|
||||
// Reject the booking
|
||||
await secondPage.goto("/bookings/upcoming");
|
||||
await secondPage.click('[data-testid="reject"]');
|
||||
await submitAndWaitForResponse(secondPage, "/api/trpc/bookings/confirm?batch=1", {
|
||||
action: () => secondPage.click('[data-testid="rejection-confirm"]'),
|
||||
});
|
||||
|
||||
// Logout and go back to booking page
|
||||
await page.goto("/auth/logout");
|
||||
await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
|
||||
await secondPage.goto("/auth/logout");
|
||||
await secondPage.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
|
||||
|
||||
// Rebook with the same details
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await bookTimeSlot(page);
|
||||
await expect(page.getByTestId("success-page")).toBeVisible();
|
||||
await selectFirstAvailableTimeSlotNextMonth(secondPage);
|
||||
await bookTimeSlot(secondPage);
|
||||
await expect(secondPage.getByTestId("success-page")).toBeVisible();
|
||||
|
||||
// Verify a new host is assigned
|
||||
const newHost = await page.getByTestId("booking-host-name").textContent();
|
||||
const newHost = await secondPage.getByTestId("booking-host-name").textContent();
|
||||
expect(newHost).not.toBeNull();
|
||||
expect(newHost).toBe(firstHost);
|
||||
|
||||
// Verify the booking was successful by checking the new booking UID
|
||||
const newBookingUid = page.url().split("/booking/")[1];
|
||||
const newBookingUid = secondPage.url().split("/booking/")[1];
|
||||
expect(newBookingUid).not.toBeNull();
|
||||
expect(newBookingUid).not.toBe(bookingUid);
|
||||
await secondContext.close();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -134,7 +134,7 @@ test.describe("Organization", () => {
|
||||
await test.step("Basic info", async () => {
|
||||
// Check required fields
|
||||
await page.locator("button[type=submit]").click();
|
||||
await expect(page.getByTestId("field-error")).toHaveCount(3);
|
||||
await expect(page.getByTestId("field-error")).toHaveCount(2);
|
||||
|
||||
// Happy path
|
||||
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
|
||||
@@ -168,7 +168,7 @@ test.describe("Organization", () => {
|
||||
await test.step("Basic info", async () => {
|
||||
// Check required fields
|
||||
await page.locator("button[type=submit]").click();
|
||||
await expect(page.getByTestId("field-error")).toHaveCount(3);
|
||||
await expect(page.getByTestId("field-error")).toHaveCount(2);
|
||||
|
||||
// Happy path
|
||||
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
|
||||
|
||||
@@ -1,36 +1,9 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { doOnOrgDomain } from "playwright/lib/testUtils";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { doOnOrgDomain, setupOrgMember } from "playwright/lib/testUtils";
|
||||
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
|
||||
import type { CreateUsersFixture } from "../fixtures/users";
|
||||
import { test } from "../lib/fixtures";
|
||||
|
||||
async function setupOrgMember(users: CreateUsersFixture) {
|
||||
const orgRequestedSlug = `example-${uuid()}`;
|
||||
|
||||
const orgMember = await users.create(undefined, {
|
||||
hasTeam: true,
|
||||
isOrg: true,
|
||||
hasSubteam: true,
|
||||
isOrgVerified: true,
|
||||
isDnsSetup: true,
|
||||
orgRequestedSlug,
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
});
|
||||
|
||||
const { team: org } = await orgMember.getOrgMembership();
|
||||
const { team } = await orgMember.getFirstTeamMembership();
|
||||
const teamEvent = await orgMember.getFirstTeamEvent(team.id);
|
||||
const userEvent = orgMember.eventTypes[0];
|
||||
|
||||
await orgMember.apiLogin();
|
||||
|
||||
return { orgMember, org, team, teamEvent, userEvent };
|
||||
}
|
||||
|
||||
type TestContext = Awaited<ReturnType<typeof setupOrgMember>>;
|
||||
|
||||
interface ToggleSeoSwitchParams {
|
||||
|
||||
@@ -49,6 +49,8 @@
|
||||
"disable_recording_for_guests": "Disable recording for guests",
|
||||
"enable_automatic_transcription": "Enable automatic transcription after joining the meeting",
|
||||
"disable_recording_for_organizer": "Disable recording for organizer",
|
||||
"disable_transcription_for_guests": "Disable transcription for guests",
|
||||
"disable_transcription_for_organizer": "Disable transcription for organizer",
|
||||
"event_request_declined_recurring": "Your recurring event request has been declined",
|
||||
"event_request_cancelled": "Your scheduled event was canceled",
|
||||
"event_request_reassigned": "Your scheduled event was reassigned",
|
||||
@@ -265,6 +267,8 @@
|
||||
"guests": "Guests",
|
||||
"guest": "Guest",
|
||||
"web_conferencing_details_to_follow": "Web conferencing details to follow in the confirmation email.",
|
||||
"confirmation":"Confirmation",
|
||||
"what_booker_should_provide": "What your booker should provide to receive confirmations",
|
||||
"404_the_user": "The username",
|
||||
"username": "Username",
|
||||
"is_still_available": "is still available.",
|
||||
@@ -1708,6 +1712,8 @@
|
||||
"show_available_seats_count": "Show the number of available seats",
|
||||
"how_booking_questions_as_variables": "How to use booking questions as variables?",
|
||||
"format": "Format",
|
||||
"questions": "Questions",
|
||||
"all_info_your_booker_provide": "All the info your booker should provide before booking with you.",
|
||||
"uppercase_for_letters": "Use uppercase for all letters",
|
||||
"replace_whitespaces_underscores": "Replace whitespaces with underscores",
|
||||
"platform_members": "Platform members",
|
||||
@@ -2833,7 +2839,7 @@
|
||||
"salesforce_skip_entry_creation": "Skip creating {{entry}} record if they do not exist in Salesforce",
|
||||
"salesforce_if_account_does_not_exist": "If the contact does not exist under an account, create new lead from attendee",
|
||||
"salesforce_create_new_contact_under_account": "Create a new contact under an account based on email domain of attendee and existing contacts",
|
||||
"salesforce_change_record_owner_on_booking":"Change record owner on booking",
|
||||
"salesforce_change_record_owner_on_booking": "Change record owner on booking",
|
||||
"mass_assign_attributes": "Mass assign attributes",
|
||||
"reroute_preview_custom_message": "It results in showing custom message. Try changing the response to route to an event",
|
||||
"reroute_preview_external_redirect": "It results in redirecting to {{externalUrl}}. Try changing the response to route to an event",
|
||||
@@ -2886,6 +2892,7 @@
|
||||
"yes_reassign": "Yes, reassign",
|
||||
"filter_by_attributes": "Filter by attributes",
|
||||
"reassign_reason": "Reason for reassigning",
|
||||
"field_type_change_suggestion": "Changing the field type may cause data loss. Please create a new field instead.",
|
||||
"reassigned_by": "Reassigned by",
|
||||
"row": "row",
|
||||
"rows": "rows",
|
||||
@@ -2931,7 +2938,7 @@
|
||||
"routing_form_insights_assignment_reason": "Assignment Reason",
|
||||
"access_denied": "Access Denied",
|
||||
"salesforce_route_to_owner": "Contact owner will be the Round Robin host if available",
|
||||
"salesforce_add_attendees_as":"Add new attendees as",
|
||||
"salesforce_add_attendees_as": "Add new attendees as",
|
||||
"salesforce_do_not_route_to_owner": "Contact owner will not be forced (can still be host if it matches the attributes and Round Robin criteria)",
|
||||
"salesforce_route_to_custom_lookup_field": "Route to a user that matches a lookup field on an account",
|
||||
"salesforce_option": "Salesforce Option",
|
||||
@@ -2994,7 +3001,7 @@
|
||||
"shortfall": "Shortfall",
|
||||
"routing_form": "Routing Form",
|
||||
"go_back_and_save": "Go back and save",
|
||||
"save_changes":"Save changes",
|
||||
"save_changes": "Save changes",
|
||||
"leave_without_saving": "Leave without saving",
|
||||
"leave_without_saving_description": "Are you sure you want to leave without saving changes to your routing form?",
|
||||
"something_unexpected_occurred": "Something unexpected occurred",
|
||||
@@ -3029,9 +3036,9 @@
|
||||
"authorize": "Authorize",
|
||||
"next": "Next",
|
||||
"hide": "Hide",
|
||||
"show":"Show",
|
||||
"utm_params":"UTM parameters",
|
||||
"no_utm_params":"No UTM parameters available.",
|
||||
"show": "Show",
|
||||
"utm_params": "UTM parameters",
|
||||
"no_utm_params": "No UTM parameters available.",
|
||||
"asc": "Asc",
|
||||
"desc": "Desc",
|
||||
"verify_email_change": "Verify email change",
|
||||
@@ -3101,7 +3108,7 @@
|
||||
"user_has_no_team_yet": "You don't have a team yet",
|
||||
"no_team_members": "You don't have team members yet",
|
||||
"recurring_event_doesnt_support_seats": "Recurring event doesn't support seats feature. Make it non-recurring to enable seats.",
|
||||
"booking_limit_per_booker_doesnt_support_recurring": "Recurring event doesn't support booking limit per booker feature. Disable booking limit per booker to enable Recurring event.",
|
||||
"booking_limit_per_booker_doesnt_support_recurring": "Recurring event doesn't support booking limit per booker feature. Disable booking limit per booker to enable Recurring event.",
|
||||
"seats_doesnt_support_recurring": "Seats feature doesn't support recurring event. Disable seats feature to make it recurring.",
|
||||
"recurring_event_seats_error": "Recurring event doesn't support seats feature. Disable seats feature or make the event non-recurring.",
|
||||
"segment": "Segment",
|
||||
@@ -3179,8 +3186,8 @@
|
||||
"resend": "Resend",
|
||||
"verification_email_sent": "Verification email sent",
|
||||
"select_all_members": "Select all members",
|
||||
"select_all_members_tooltip":"Selecting all members will add all future team members when they are added to the team.",
|
||||
"form_settings":"Form Settings",
|
||||
"select_all_members_tooltip": "Selecting all members will add all future team members when they are added to the team.",
|
||||
"form_settings": "Form Settings",
|
||||
"remove_whitelist_status": "Remove whitelist status",
|
||||
"whitelist_user_workflows": "Whitelist user workflows",
|
||||
"user_workflows_whitelisted": "User workflows whitelisted",
|
||||
@@ -3238,6 +3245,10 @@
|
||||
"requires_credits": "Requires credits",
|
||||
"requires_credits_tooltip": "You need enough credits in your account to use this feature",
|
||||
"available_credits": "Available credits",
|
||||
"choose_restriction_schedule": "Add restriction schedule",
|
||||
"choose_restriction_schedule_description": "Enable this if you want to restrict the availability of hosts for this event type based on a restriction schedule. This only removes the slots that are not in the restriction schedule, it does not add any new slots.",
|
||||
"use_booker_timezone": "Use booker's timezone",
|
||||
"use_booker_timezone_info": "Apply the selected restriction schedule in the booker's timezone",
|
||||
"load_balancing_warning": "This will disable Load Balancing on all your Round Robin event types",
|
||||
"rr_load_balancing_disabled": "Load Balancing is only available when the timestamp basis is set to Booking Creation Time",
|
||||
"round_robin_settings_description": "Customize the default round robin settings for this team",
|
||||
@@ -3246,10 +3257,15 @@
|
||||
"meeting_start_time": "Meeting Start Time",
|
||||
"members_affected_by_disabling_delegation_credential": "Affected members",
|
||||
"no_members_affected_by_disabling_delegation_credential": "No members affected by disabling delegation credential",
|
||||
"download_expense_log": "Download Expense Log",
|
||||
"error_downloading_expense_log": "Error downloading expense log",
|
||||
"offer_to_reschedule_last_booking": "Offer to reschedule last active booking to chosen time slot",
|
||||
"booker_limit_exceeded_error": "Booker maximum active booking limit exceeded",
|
||||
"booker_limit_exceeded_error_reschedule": "You already have a booking for this event on {{date}}. Would you like to reschedule to the new selected time?",
|
||||
"duration_min_error": "Duration must be at least {{min}} minute(s)",
|
||||
"duration_max_error": "Duration cannot exceed {{max}} minutes (24 hours)",
|
||||
"booking_not_allowed_by_restriction_schedule_error": "Booking outside restriction schedule availability.",
|
||||
"restriction_schedule_not_found_error": "Restriction schedule not found",
|
||||
"converted_image_size_limit_exceed": "Image size limit exceeded, please use a smaller image preferably in JPEG format",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
"join_by_entrypoint": "Csatlakozás a következővel: {{entryPoint}}",
|
||||
"notes": "Jegyzetek",
|
||||
"manage_my_bookings": "Foglalásaim kezelése",
|
||||
"need_to_make_a_change": "Változtatnál valamit?",
|
||||
"need_to_make_a_change": "Változtatna valamin?",
|
||||
"new_event_scheduled": "Új eseményt foglaltak.",
|
||||
"new_event_scheduled_recurring": "Egy új ismétlődő időpont le lett foglalva.",
|
||||
"invitee_email": "Meghívó e-mail",
|
||||
@@ -240,7 +240,7 @@
|
||||
"copy_public_page_link": "Nyilvános oldal linkjének másolása",
|
||||
"copy_referral_link": "Ajánlói link másolása",
|
||||
"sign_out": "Kijelentkezés",
|
||||
"add_another": "Adj hozzá egy másikat",
|
||||
"add_another": "Másvalaki hozzáadása",
|
||||
"install_another": "Telepítsen másikat",
|
||||
"until": "amíg",
|
||||
"powered_by": "működteti",
|
||||
@@ -354,10 +354,10 @@
|
||||
"connect_caldav": "Csatlakozás a CalDav szerverhez",
|
||||
"connect_ics_feed": "Csatlakozás egy ICS feedhez",
|
||||
"connect": "Csatlakozás",
|
||||
"try_for_free": "Próbáld ki ingyen",
|
||||
"try_for_free": "Próbálja ki ingyen",
|
||||
"create_booking_link_with_calcom": "Hozzon létre saját foglalási linket a {{appName}} segítségével",
|
||||
"who": "Ki",
|
||||
"what": "Mit",
|
||||
"what": "Mi",
|
||||
"when": "Mikor",
|
||||
"where": "Hol",
|
||||
"add_to_calendar": "Hozzáadás naptárhoz",
|
||||
@@ -373,32 +373,32 @@
|
||||
"round_robin_emailed_you_and_attendees_recurring": "Ön találkozik {{user}} résztvevővel. Mindenkinek küldtünk egy e-mailt a naptári meghívóval és a részletekkel az ismétlődő események első alkalmára.",
|
||||
"round_robin_emailed_host_and_attendee_recurring": "{{host}} találkozik {{attendee}} résztvevővel. Mindenkinek küldtünk egy e-mailt a naptári meghívóval és a részletekkel az ismétlődő események első alkalmára.",
|
||||
"emailed_you_and_any_other_attendees": "Küldtünk egy e-mail-t mindenkinek ezekről az információkról.",
|
||||
"needs_to_be_confirmed_or_rejected": "Foglalását még meg kell erősíteni vagy el kell utasítani.",
|
||||
"needs_to_be_confirmed_or_rejected_recurring": "Az ismétlődő találkozót még meg kell erősíteni vagy el kell utasítani.",
|
||||
"user_needs_to_confirm_or_reject_booking": "{{user}} jóváhagyása szükséges a foglalásod véglegesítéséhez.",
|
||||
"user_needs_to_confirm_or_reject_booking_recurring": "{{user}} résztvevőnek még mindig vissza kell igazolnia vagy elutasítania a foglalást az ismétlődő találkozóra.",
|
||||
"needs_to_be_confirmed_or_rejected": "Foglalását még megerősítésre vagy lemondásra vár.",
|
||||
"needs_to_be_confirmed_or_rejected_recurring": "Az ismétlődő találkozó még megerősítésre vagy lemondásra vár.",
|
||||
"user_needs_to_confirm_or_reject_booking": "{{user}} jóváhagyása szükséges foglalása véglegesítéséhez.",
|
||||
"user_needs_to_confirm_or_reject_booking_recurring": "{{user}} résztvevőnek még vissza kell igazolnia vagy el kell utasítania a foglalást az ismétlődő találkozóra.",
|
||||
"meeting_is_scheduled": "Találkozó sikeresen lefoglalva",
|
||||
"meeting_is_scheduled_recurring": "Az ismétlődő időpontod le lett foglalva",
|
||||
"booking_submitted": "A foglalásod be lett küldve",
|
||||
"booking_submitted_recurring": "Ismétlődő találkozóját elküldtük",
|
||||
"booking_confirmed": "A foglalásod vissza lett igazolva",
|
||||
"meeting_is_scheduled_recurring": "Az ismétlődő időpontja le lett foglalva",
|
||||
"booking_submitted": "Foglalását elküldte",
|
||||
"booking_submitted_recurring": "Ismétlődő találkozóját elküldte",
|
||||
"booking_confirmed": "Foglalását visszaigazolták",
|
||||
"booking_confirmed_recurring": "Ismétlődő találkozóját megerősítették",
|
||||
"warning_recurring_event_payment": "A fizetések még nem támogatottak ismétlődő események esetén",
|
||||
"warning_payment_recurring_event": "Az ismétlődő eseményeket a fizetés még nincs támogatva",
|
||||
"enter_new_password": "Adja meg a fiókjához kívánt új jelszót.",
|
||||
"reset_password": "Jelszó visszaállítása",
|
||||
"change_your_password": "Változtasd meg a jelszavadat",
|
||||
"show_password": "Mutasd a jelszót",
|
||||
"change_your_password": "Változtassa meg a jelszavát",
|
||||
"show_password": "Jelszó megjelenítése",
|
||||
"hide_password": "Jelszó elrejtése",
|
||||
"try_again": "Próbáld újra",
|
||||
"try_again": "Próbálja újra",
|
||||
"request_is_expired": "Ez a kérelem lejárt.",
|
||||
"reset_instructions": "Adja meg a fiókjához társított e-mail címet, és küldünk egy linket a jelszó visszaállításához.",
|
||||
"reset_instructions": "Adja meg a fiókjához tartozó e-mail címet, és küldünk egy linket a jelszó visszaállításához.",
|
||||
"request_is_expired_instructions": "Ez a kérelem lejárt. Menjen vissza, és adja meg a fiókjához tartozó e-mail-címet, és küldünk egy újabb linket a jelszó visszaállításához.",
|
||||
"whoops": "Hoppá",
|
||||
"login": "Bejelentkezés",
|
||||
"success": "Siker",
|
||||
"failed": "Nem sikerült",
|
||||
"password_has_been_reset_login": "A jelszavad vissza lett állítva. Most már bejelentkezhet az újonnan létrehozott jelszavával.",
|
||||
"password_has_been_reset_login": "Jelszavad visszaállítottuk. Most már bejelentkezhetsz az újonnan létrehozott jelszavaddal.",
|
||||
"layout": "Elrendezés",
|
||||
"bookerlayout_default_title": "Alapértelmezett nézet",
|
||||
"bookerlayout_description": "Többet is kiválaszthat, és a foglalók nézetet válthatnak.",
|
||||
@@ -641,41 +641,41 @@
|
||||
"additional_notes": "Egyéb megjegyzés",
|
||||
"booking_fail": "Találkozó nem foglalható.",
|
||||
"reschedule_fail": "Nem sikerült időpontot módisítani.",
|
||||
"share_additional_notes": "Kérlek ossz meg velünk bármit, ami segíthet felkészülni a találkozóra.",
|
||||
"booking_confirmation": "Erősítse meg {{eventTypeTitle}} eseményét a következővel: {{profileName}}",
|
||||
"booking_reschedule_confirmation": "Módosítsd {{eventTypeTitle}} időpontod nála: {{profileName}}",
|
||||
"share_additional_notes": "Kérjük osszon meg velünk bármit, ami segíthet felkészülni a találkozóra.",
|
||||
"booking_confirmation": "Erősítse meg {{eventTypeTitle}} eseményét vele: {{profileName}}",
|
||||
"booking_reschedule_confirmation": "Módosítsa {{eventTypeTitle}} időpontját nála: {{profileName}}",
|
||||
"in_person_meeting": "Személyes találkozó",
|
||||
"in_person": "Személyesen (Szervező címén)",
|
||||
"link_meeting": "Találkozó összekapcsolása",
|
||||
"phone_number": "Telefonszám",
|
||||
"attendee_phone_number": "A résztvevő telefonszáma",
|
||||
"attendee_phone_number": "Résztvevő telefonszáma",
|
||||
"organizer_phone_number": "Szervező telefonszáma",
|
||||
"enter_phone_number": "Adja meg a telefonszámot",
|
||||
"reschedule": "átütmezés",
|
||||
"reschedule_this": "Inkább időpontmódosítás",
|
||||
"book_a_team_member": "Foglaljon helyette egy csapattagot",
|
||||
"or": "VAGY",
|
||||
"book_a_team_member": "Foglaljon inkáb egy másik csapattagnál",
|
||||
"or": "vagy",
|
||||
"go_back": "Vissza",
|
||||
"email_or_username": "E-mail cím vagy felhasználónév",
|
||||
"send_invite_email": "Küldj meghívó e-mailt",
|
||||
"send_invite_email": "Meghívó e-mail küldése",
|
||||
"role": "Szerepkör",
|
||||
"edit_role": "Szerepkör szerkesztése",
|
||||
"edit_team": "Csapat szerkesztése",
|
||||
"editing_user": "Felhasználó szerkesztése",
|
||||
"editing_org": "Szervezet szerkesztése",
|
||||
"edit_or_manage_webhooks": "Ha szeretnéd szerkeszteni vagy kezelni a webhookjaidat, látogass el a <0>webhook beállításokhoz</0>",
|
||||
"reject": "Elutasít",
|
||||
"reject": "Elutasítás",
|
||||
"reject_all": "Az összes elutasítása",
|
||||
"accept": "Elfogad",
|
||||
"leave": "Elhagyás",
|
||||
"accept": "Elfogadás",
|
||||
"leave": "Kilépés",
|
||||
"profile": "Profil",
|
||||
"my_team_url": "Csapatom URL-je",
|
||||
"my_teams": "A csapataim",
|
||||
"team_name": "Csapat neve",
|
||||
"your_team_name": "A csapatod neve",
|
||||
"team_updated_successfully": "A csapat sikeresen frissítve",
|
||||
"your_team_updated_successfully": "Csapatát sikeresen frissítette.",
|
||||
"your_org_updated_successfully": "Szervezete sikeresen frissítve.",
|
||||
"your_team_updated_successfully": "Csapatod sikeresen frissítetted.",
|
||||
"your_org_updated_successfully": "Szervezet sikeresen frissítve.",
|
||||
"error_copying_to_clipboard": "Hiba a vágólapra másolás során",
|
||||
"about": "Rólunk",
|
||||
"team_description": "Néhány mondat a csapatodról. Ez megjelenik a csapata URL-oldalán.",
|
||||
|
||||
@@ -66,18 +66,18 @@
|
||||
"rejection_reason_title": "Odbij zahtev za rezervaciju?",
|
||||
"rejection_reason_description": "Da li ste sigurni da želite da odbijete rezervaciju? Obavestićemo osobu koja je pokušala da rezerviše. Možete navesti razlog ispod.",
|
||||
"rejection_confirmation": "Odbij rezervaciju",
|
||||
"manage_this_event": "Upravljaj ovim dogadjajem",
|
||||
"manage_this_event": "Upravljaj ovim događajem",
|
||||
"invite_team_member": "Pozovite člana tima",
|
||||
"invite_team_individual_segment": "Pozovite jednu osobu",
|
||||
"invite_team_bulk_segment": "Pozovite grupno",
|
||||
"invite_team_notifcation_badge": "Poz.",
|
||||
"your_event_has_been_scheduled": "Vaš dogadjaj je odložen",
|
||||
"your_event_has_been_scheduled": "Vaš događaj je zakazan",
|
||||
"your_event_has_been_scheduled_recurring": "Vaš ponavljajući događaj je zakazan",
|
||||
"accept_our_license": "Prihvatite našu dozvolu tako što ćete promeniti .env varijablu <1>NEXT_PUBLIC_LICENSE_CONSENT</1> u '{{agree}}'.",
|
||||
"remove_banner_instructions": "Da uklonite baner, molim vas da otvorite vaš .env file i promenite <1>NEXT_PUBLIC_LICENSE_CONSENT</1> varijablu u '{{agree}}'.",
|
||||
"error_message": "Poruka o grešci je bila: '{{errorMessage}}'",
|
||||
"refund_failed_subject": "Povraćaj novca neuspešan: {{name}} - {{date}} - {{eventType}}",
|
||||
"refund_failed": "Povraćaj novca za dogadjaj {{eventType}} sa {{userName}} datuma {{date}} je neuspešan.",
|
||||
"refund_failed": "Povraćaj novca za događaj {{eventType}} sa {{userName}} datuma {{date}} je neuspešan.",
|
||||
"check_with_provider_and_user": "Molimo vas proverite sa vašom uslugom plaćanja i {{user}} kako bi rešili ovo.",
|
||||
"a_refund_failed": "Povraćaj novca neuspešan",
|
||||
"awaiting_payment_subject": "Čekanje na uplatu: {{title}} datuma {{date}}",
|
||||
@@ -115,23 +115,23 @@
|
||||
"open_env": "Otvorite .env i pristanite na našu Licencu",
|
||||
"env_changed": "Promenio/la sam moj .env",
|
||||
"accept_license": "Prihvati Licencu",
|
||||
"still_waiting_for_approval": "Dogadjaj još čeka na odobrenje",
|
||||
"event_is_still_waiting": "Zahtev na dogadjaj još čeka: {{attendeeName}} - {{date}} - {{eventType}}",
|
||||
"still_waiting_for_approval": "Događaj još čeka na odobrenje",
|
||||
"event_is_still_waiting": "Zahtev na događaj još čeka: {{attendeeName}} - {{date}} - {{eventType}}",
|
||||
"no_result_found_for": "Nema rezultata za \"{{searchTerm}}\"",
|
||||
"no_more_results": "Nema više rezultata",
|
||||
"no_results": "Nema rezultata",
|
||||
"load_more_results": "Učitaj više rezultata",
|
||||
"integration_meeting_id": "{{integrationName}} ID sastanka: {{meetingId}}",
|
||||
"confirmed_event_type_subject": "Potvrdjeno: {{eventType}} sa {{name}} datuma {{date}}",
|
||||
"new_event_request": "Novi zahtev na dogadjaj: {{attendeeName}} - {{date}} - {{eventType}}",
|
||||
"new_event_request": "Novi zahtev na događaj: {{attendeeName}} - {{date}} - {{eventType}}",
|
||||
"confirm_or_reject_request": "Potvrdite ili odbijte zahtev",
|
||||
"check_bookings_page_to_confirm_or_reject": "Proverite stranicu rezervacija da potvrdite ili odbijete rezervaciju.",
|
||||
"check_in_assistant": "Asistent za prijavu",
|
||||
"check_in_assistant_description": "Obavlja odlazni poziv da proveri da li zakazani termin odgovara ili želite da ga pomerite.",
|
||||
"create_your_own_prompt": "Kreirajte sopstveni upit i koristite ga za odlazne pozive.",
|
||||
"event_awaiting_approval": "Dogadjaj čeka vaše odobrenje",
|
||||
"event_awaiting_approval": "Događaj čeka vaše odobrenje",
|
||||
"event_awaiting_approval_recurring": "Ponavljajući događaj čeka na vaše odobrenje",
|
||||
"someone_requested_an_event": "Neko zahteva da rezerviše dogadjaj na vašem kalendaru.",
|
||||
"someone_requested_an_event": "Neko zahteva da rezerviše događaj na vašem kalendaru.",
|
||||
"someone_requested_password_reset": "Neko zahteva link da promeni vašu lozinku.",
|
||||
"password_reset_email_sent": "Ako ovaj imejl postoji našem sistemu, trebalo bi da primite imejl za resetovanje.",
|
||||
"password_reset_instructions": "Ako niste pokrenuli ovaj zahtev, slobodno ignorišite ovu e-poštu i vaša lozinka se neće promeniti.",
|
||||
@@ -145,7 +145,7 @@
|
||||
"your_meeting_has_been_booked": "Sastanak je zakazan",
|
||||
"event_type_has_been_rescheduled_on_time_date": "Ваш {{титле}} је померен за {{дате}}.",
|
||||
"event_type_has_been_updated": "Vaš {{title}} je ažuriran.",
|
||||
"event_has_been_rescheduled": "Ažurirano - Vaš dogadjaj je odložen",
|
||||
"event_has_been_rescheduled": "Ažurirano - Vaš događaj je odložen",
|
||||
"event_has_been_updated": "Događaj je ažuriran",
|
||||
"your_event_has_been_rescheduled": "Vaš događaj je ponovo zakazan",
|
||||
"request_reschedule_subtitle": "{{organizer}} je otkazao/la rezervaciju i tražio/la je da odaberete drugo vreme.",
|
||||
@@ -157,17 +157,17 @@
|
||||
"ics_event_title": "{{eventType}} sa {{name}}",
|
||||
"please_book_a_time_sometime_later": "Izgleda da su svi naši stručnjaci trenutno zauzeti. Molimo vas da zakažete poziv u vreme koje vam najviše odgovara i bićemo spremni da vam pomognemo.",
|
||||
"schedule_instead": "Zakažite umesto toga",
|
||||
"new_event_subject": "Novi dogadjaj: {{attendeeName}} - {{date}} - {{eventType}}",
|
||||
"new_event_subject": "Novi događaj: {{attendeeName}} - {{date}} - {{eventType}}",
|
||||
"join_by_entrypoint": "Pridruži se putem {{entryPoint}}",
|
||||
"notes": "Beleške",
|
||||
"manage_my_bookings": "Upravljaj svojim rezervacijama",
|
||||
"need_to_make_a_change": "Trebate napraviti promenu?",
|
||||
"new_event_scheduled": "Novi dogadjaj je zakazan.",
|
||||
"new_event_scheduled": "Novi događaj je zakazan.",
|
||||
"new_event_scheduled_recurring": "Novi ponavljajući događaj je zakazan.",
|
||||
"invitee_email": "E-pošta od pozvane osobe",
|
||||
"invitee_timezone": "Vremenska zona pozvane osobe",
|
||||
"time_left": "Preostalo vreme",
|
||||
"event_type": "Tip Dogadjaja",
|
||||
"event_type": "Tip Događaja",
|
||||
"duplicate_event_type": "Duplirani tip događaja",
|
||||
"enter_meeting": "Pridruži se sastanku",
|
||||
"video_call_provider": "Provajder video poziva",
|
||||
@@ -205,7 +205,7 @@
|
||||
"use_link_to_reset_password": "Resetujte lozinku koristeći link ispod",
|
||||
"hey_there": "Zdravo",
|
||||
"forgot_your_password_calcom": "Zaboravili ste lozinku? - {{appName}}",
|
||||
"delete_webhook_confirmation_message": "Da li ste sigurni da želite da obrišete ovaj webhook? Nećete više dobijati {{appName}} podatke o sastancima preko naznačenog URL, istovremeno kad je dogadjaj zakazan ili otkazan.",
|
||||
"delete_webhook_confirmation_message": "Da li ste sigurni da želite da obrišete ovaj webhook? Nećete više dobijati {{appName}} podatke o sastancima preko naznačenog URL, istovremeno kad je događaj zakazan ili otkazan.",
|
||||
"confirm_delete_webhook": "Da, obriši webhook",
|
||||
"edit_webhook": "Uredi Webhook",
|
||||
"delete_webhook": "Obriši Webhook",
|
||||
@@ -229,7 +229,7 @@
|
||||
"add_to_homescreen": "Dodaj ovu aplikaciju na telefon za brži pristup i poboljšano korisničko iskustvo.",
|
||||
"upcoming": "Predstojeći",
|
||||
"recurring": "Ponavljajući",
|
||||
"past": "Prošli dogadjaji",
|
||||
"past": "Prošli događaji",
|
||||
"choose_a_file": "Odaberi datoteku...",
|
||||
"upload_image": "Otpremi sliku",
|
||||
"upload_target": "Prikači {{target}}",
|
||||
@@ -343,7 +343,7 @@
|
||||
"connect_your_calendar": "Povežite vaš kalendar",
|
||||
"connect_your_video_app": "Povežite svoje video aplikacije",
|
||||
"connect_your_video_app_instructions": "Povežite svoje video aplikacije da biste ih koristili u vašim tipovima događaja.",
|
||||
"connect_your_calendar_instructions": "Povežite vaš kalendar da automatski radi proveru zauzetih vremena i vidi nove dogadjaje kad se zakažu.",
|
||||
"connect_your_calendar_instructions": "Povežite vaš kalendar da automatski radi proveru zauzetih vremena i vidi nove događaje kad se zakažu.",
|
||||
"set_up_later": "Postavi kasnije",
|
||||
"current_time": "Trenutno vreme",
|
||||
"details": "Detalji",
|
||||
@@ -422,7 +422,7 @@
|
||||
"error_end_time_before_start_time": "Krajnje vreme ne može biti pre početnog vremena",
|
||||
"error_end_time_next_day": "Krajnje vreme ne može biti veće od 24 sata",
|
||||
"back_to_bookings": "Nazad na rezervacije",
|
||||
"free_to_pick_another_event_type": "Slobodno izaberite drugi dogadjaj u bilo kom trenutku.",
|
||||
"free_to_pick_another_event_type": "Slobodno izaberite drugi događaj u bilo kom trenutku.",
|
||||
"cancelled": "Otkazano",
|
||||
"no_booking": "Nema rezervacije",
|
||||
"awaiting_host": "Čeka se domaćin",
|
||||
@@ -450,7 +450,7 @@
|
||||
"event_between_users": "{{eventName}} sa {{host}} i {{attendeeName}}",
|
||||
"bookings": "Rezervacije",
|
||||
"booking_not_found": "Rezervacija nije pronađena",
|
||||
"bookings_description": "Vidite nadolazeće i prošle dogadjaje rezervisane preko linkova vaših tipova događaja.",
|
||||
"bookings_description": "Vidite nadolazeće i prošle događaje rezervisane preko linkova vaših tipova događaja.",
|
||||
"upcoming_bookings": "Čim neko rezerviše termin sa vama to će se pokazati ovde.",
|
||||
"recurring_bookings": "Čim neko zakaže ponavljajući sastanak sa vama, to će se pokazati ovde.",
|
||||
"past_bookings": "Vaše prethodne rezervacije će se pojaviti ovde.",
|
||||
@@ -522,7 +522,7 @@
|
||||
"booking_paid": "Rezervacija je plaćena",
|
||||
"ooo_created": "Kreirano odsustvo iz kancelarije",
|
||||
"booking_no_show_updated": "Ažurirano odsustvo sa rezervacije",
|
||||
"event_triggers": "Okidači Dogadjaja",
|
||||
"event_triggers": "Okidači Događaja",
|
||||
"subscriber_url": "URL pretplatnika",
|
||||
"create_new_webhook": "Napravite novi webhook",
|
||||
"webhooks": "Webhooks",
|
||||
@@ -737,7 +737,7 @@
|
||||
"remove_member": "Ukloni člana",
|
||||
"manage_your_team": "Upravljaj svojim timom",
|
||||
"no_teams": "Još uvek nemate nijedan tim.",
|
||||
"no_teams_description": "Timovi služe za rezervacije dogadjaja koje delite sa vašim kolegama.",
|
||||
"no_teams_description": "Timovi služe za rezervacije događaja koje delite sa vašim kolegama.",
|
||||
"submit": "Podnesi",
|
||||
"confirm_remove_attribute": "Da, ukloni atribut",
|
||||
"remove_attribute": "Ukloni atribut",
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
import prismock from "../../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
import {
|
||||
createBookingScenario,
|
||||
Timezones,
|
||||
TestData,
|
||||
type ScenarioData,
|
||||
} from "../../utils/bookingScenario/bookingScenario";
|
||||
|
||||
import { describe, test, vi } from "vitest";
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { getScheduleSchema } from "@calcom/trpc/server/routers/viewer/slots/types";
|
||||
import { getAvailableSlots as getSchedule } from "@calcom/trpc/server/routers/viewer/slots/util";
|
||||
|
||||
import { expect } from "./expects";
|
||||
import { setupAndTeardown } from "./setupAndTeardown";
|
||||
|
||||
// Mock the FeaturesRepository to enable restriction-schedule feature
|
||||
vi.mock("@calcom/features/flags/features.repository", () => ({
|
||||
FeaturesRepository: vi.fn().mockImplementation(() => ({
|
||||
checkIfTeamHasFeature: vi.fn().mockResolvedValue(true),
|
||||
checkIfFeatureIsEnabledGlobally: vi.fn().mockResolvedValue(true),
|
||||
getAllFeatures: vi.fn().mockResolvedValue([]),
|
||||
getFeatureFlagMap: vi.fn().mockResolvedValue({}),
|
||||
checkIfUserHasFeature: vi.fn().mockResolvedValue(true),
|
||||
})),
|
||||
}));
|
||||
|
||||
type ScheduleScenario = {
|
||||
eventTypes: ScenarioData["eventTypes"];
|
||||
users: ScenarioData["users"];
|
||||
apps: ScenarioData["apps"];
|
||||
selectedSlots?: ScenarioData["selectedSlots"];
|
||||
};
|
||||
|
||||
const getTestScheduleInput = ({
|
||||
yesterdayDateString,
|
||||
plus5DateString,
|
||||
}: {
|
||||
yesterdayDateString: string;
|
||||
plus5DateString: string;
|
||||
}): z.infer<typeof getScheduleSchema> => ({
|
||||
eventTypeId: 1,
|
||||
eventTypeSlug: "",
|
||||
startTime: `${yesterdayDateString}T18:30:00.000Z`,
|
||||
endTime: `${plus5DateString}T18:29:59.999Z`,
|
||||
timeZone: Timezones["+5:30"],
|
||||
isTeamEvent: true,
|
||||
orgSlug: null,
|
||||
});
|
||||
|
||||
const getBaseScenarioData = (): ScheduleScenario => ({
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
slotInterval: 60,
|
||||
length: 60,
|
||||
teamId: 1,
|
||||
hosts: [{ userId: 101 }],
|
||||
},
|
||||
],
|
||||
users: [
|
||||
{
|
||||
...TestData.users.example,
|
||||
id: 101,
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
teams: [
|
||||
{
|
||||
membership: { role: "ADMIN", accepted: true },
|
||||
team: { id: 1, name: "Test Team", slug: "test-team" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
apps: [],
|
||||
});
|
||||
|
||||
// Helper function to set up team for the tests
|
||||
async function setupTeamAndFeatures() {
|
||||
// Create the team (needed for event type configuration)
|
||||
await prismock.team.create({
|
||||
data: {
|
||||
id: 1,
|
||||
name: "Test Team",
|
||||
slug: "test-team",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("getSchedule", () => {
|
||||
setupAndTeardown();
|
||||
|
||||
describe("Restriction Schedule", () => {
|
||||
test("should respect date override rule in restrictionSchedule (Europe/London, useBookerTimezone=false)", async () => {
|
||||
await setupTeamAndFeatures();
|
||||
vi.setSystemTime("2025-06-01T23:30:00Z");
|
||||
|
||||
const plus2DateString = "2025-06-02"; // Date with override
|
||||
const plus5DateString = "2025-06-05";
|
||||
|
||||
const scenarioData: ScheduleScenario = {
|
||||
...getBaseScenarioData(),
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
length: 60,
|
||||
teamId: 1,
|
||||
restrictionScheduleId: 50,
|
||||
useBookerTimezone: false,
|
||||
hosts: [{ userId: 101 }],
|
||||
},
|
||||
],
|
||||
users: [
|
||||
{
|
||||
...TestData.users.example,
|
||||
id: 101,
|
||||
schedules: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Test Schedule",
|
||||
timeZone: "Europe/London",
|
||||
availability: [
|
||||
{
|
||||
days: [0, 1, 2, 3, 4, 5, 6],
|
||||
startTime: new Date("1970-01-01T09:00:00.000Z"),
|
||||
endTime: new Date("1970-01-01T17:00:00.000Z"),
|
||||
date: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
teams: [
|
||||
{
|
||||
membership: { role: "ADMIN", accepted: true },
|
||||
team: { id: 1, name: "Test Team", slug: "test-team" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
scenarioData.users[0].schedules.push({
|
||||
id: 50,
|
||||
name: "Restriction Schedule - Only Date Override",
|
||||
timeZone: "Asia/Dubai",
|
||||
availability: [
|
||||
{
|
||||
date: "2025-06-02T00:00:00.000Z",
|
||||
days: [],
|
||||
startTime: new Date("1970-01-01T10:00:00.000Z"),
|
||||
endTime: new Date("1970-01-01T18:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await createBookingScenario(scenarioData);
|
||||
|
||||
const result = await getSchedule({
|
||||
input: {
|
||||
eventTypeId: 1,
|
||||
eventTypeSlug: "",
|
||||
startTime: "2025-06-01T00:00:00.000Z",
|
||||
endTime: `${plus5DateString}T23:59:59.999Z`,
|
||||
timeZone: "Asia/Kolkata",
|
||||
isTeamEvent: true,
|
||||
orgSlug: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Expected slots based on intersection of:
|
||||
// - User availability: 9 AM - 5 PM London
|
||||
// - Restriction: 10 AM - 6 PM Dubai
|
||||
const expectedUtcSlots = [
|
||||
"2025-06-02T08:00:00.000Z", // 9 AM London, 12 PM Dubai, 1:30 PM Kolkata
|
||||
"2025-06-02T09:00:00.000Z", // 10 AM London, 1 PM Dubai, 2:30 PM Kolkata
|
||||
"2025-06-02T10:00:00.000Z", // 11 AM London, 2 PM Dubai, 3:30 PM Kolkata
|
||||
"2025-06-02T11:00:00.000Z", // 12 PM London, 3 PM Dubai, 4:30 PM Kolkata
|
||||
"2025-06-02T12:00:00.000Z", // 1 PM London, 4 PM Dubai, 5:30 PM Kolkata
|
||||
"2025-06-02T13:00:00.000Z", // 2 PM London, 5 PM Dubai, 6:30 PM Kolkata
|
||||
"2025-06-02T14:00:00.000Z", // 3 PM London, 6 PM Dubai, 7:30 PM Kolkata
|
||||
];
|
||||
|
||||
expect(result).toHaveTimeSlots(expectedUtcSlots, {
|
||||
dateString: plus2DateString,
|
||||
doExactMatch: false,
|
||||
});
|
||||
expect(result).toHaveDateDisabled({ dateString: "2025-06-03" });
|
||||
});
|
||||
|
||||
test("should respect recurring rule in restrictionSchedule (Europe/London, useBookerTimezone=false)", async () => {
|
||||
await setupTeamAndFeatures();
|
||||
vi.setSystemTime("2025-06-01T23:30:00Z");
|
||||
|
||||
const plus2DateString = "2025-06-02"; // Monday
|
||||
const plus6DateString = "2025-06-06"; // Friday
|
||||
|
||||
const scenarioData: ScheduleScenario = {
|
||||
...getBaseScenarioData(),
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
length: 60,
|
||||
teamId: 1,
|
||||
restrictionScheduleId: 50,
|
||||
useBookerTimezone: false,
|
||||
hosts: [{ userId: 101 }],
|
||||
},
|
||||
],
|
||||
users: [
|
||||
{
|
||||
...TestData.users.example,
|
||||
id: 101,
|
||||
schedules: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Test Schedule",
|
||||
timeZone: "Europe/London",
|
||||
availability: [
|
||||
{
|
||||
days: [0, 1, 2, 3, 4, 5, 6],
|
||||
startTime: new Date("1970-01-01T09:00:00.000Z"),
|
||||
endTime: new Date("1970-01-01T17:00:00.000Z"),
|
||||
date: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
teams: [
|
||||
{
|
||||
membership: { role: "ADMIN", accepted: true },
|
||||
team: { id: 1, name: "Test Team", slug: "test-team" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
scenarioData.users[0].schedules.push({
|
||||
id: 50,
|
||||
name: "Test Schedule",
|
||||
timeZone: "Asia/Dubai",
|
||||
availability: [
|
||||
{
|
||||
days: [1, 2, 3, 4, 5],
|
||||
startTime: new Date("1970-01-01T10:00:00.000Z"),
|
||||
endTime: new Date("1970-01-01T18:00:00.000Z"),
|
||||
date: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await createBookingScenario(scenarioData);
|
||||
|
||||
const result = await getSchedule({
|
||||
input: {
|
||||
eventTypeId: 1,
|
||||
eventTypeSlug: "",
|
||||
startTime: "2025-06-01T00:00:00.000Z",
|
||||
endTime: `${plus6DateString}T23:59:59.999Z`,
|
||||
timeZone: "Asia/Kolkata",
|
||||
isTeamEvent: true,
|
||||
orgSlug: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Expected slots based on intersection of:
|
||||
// - User availability: 9 AM - 5 PM London
|
||||
// - Restriction: 10 AM - 6 PM Dubai, weekdays only
|
||||
const getExpectedSlotsForDate = (dateString: string) => [
|
||||
`${dateString}T08:00:00.000Z`, // 9 AM London, 12 PM Dubai, 1:30 PM Kolkata
|
||||
`${dateString}T09:00:00.000Z`, // 10 AM London, 1 PM Dubai, 2:30 PM Kolkata
|
||||
`${dateString}T10:00:00.000Z`, // 11 AM London, 2 PM Dubai, 3:30 PM Kolkata
|
||||
`${dateString}T11:00:00.000Z`, // 12 PM London, 3 PM Dubai, 4:30 PM Kolkata
|
||||
`${dateString}T12:00:00.000Z`, // 1 PM London, 4 PM Dubai, 5:30 PM Kolkata
|
||||
`${dateString}T13:00:00.000Z`, // 2 PM London, 5 PM Dubai, 6:30 PM Kolkata
|
||||
];
|
||||
|
||||
// Verify weekday slots
|
||||
["2025-06-02", "2025-06-03", "2025-06-04", "2025-06-05", "2025-06-06"].forEach((dateString) => {
|
||||
expect(result).toHaveTimeSlots(getExpectedSlotsForDate(dateString), {
|
||||
dateString,
|
||||
doExactMatch: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Verify weekend is disabled
|
||||
expect(result).toHaveDateDisabled({ dateString: "2025-06-07" });
|
||||
expect(result).toHaveDateDisabled({ dateString: "2025-06-08" });
|
||||
});
|
||||
|
||||
test("should respect recurring rule in restrictionSchedule (Europe/London, useBookerTimezone=true)", async () => {
|
||||
await setupTeamAndFeatures();
|
||||
vi.setSystemTime("2025-06-01T23:30:00Z");
|
||||
|
||||
const plus2DateString = "2025-06-02"; // Monday
|
||||
const plus6DateString = "2025-06-06"; // Friday
|
||||
|
||||
const scenarioData: ScheduleScenario = {
|
||||
...getBaseScenarioData(),
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
length: 60,
|
||||
teamId: 1,
|
||||
restrictionScheduleId: 50,
|
||||
useBookerTimezone: true,
|
||||
hosts: [{ userId: 101 }],
|
||||
},
|
||||
],
|
||||
users: [
|
||||
{
|
||||
...TestData.users.example,
|
||||
id: 101,
|
||||
schedules: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Test Schedule",
|
||||
timeZone: "Europe/London",
|
||||
availability: [
|
||||
{
|
||||
days: [0, 1, 2, 3, 4, 5, 6],
|
||||
startTime: new Date("1970-01-01T09:00:00.000Z"),
|
||||
endTime: new Date("1970-01-01T17:00:00.000Z"),
|
||||
date: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
teams: [
|
||||
{
|
||||
membership: { role: "ADMIN", accepted: true },
|
||||
team: { id: 1, name: "Test Team", slug: "test-team" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
scenarioData.users[0].schedules.push({
|
||||
id: 50,
|
||||
name: "Test Schedule",
|
||||
timeZone: "Asia/Dubai",
|
||||
availability: [
|
||||
{
|
||||
days: [1, 2, 3, 4, 5],
|
||||
startTime: new Date("1970-01-01T10:00:00.000Z"),
|
||||
endTime: new Date("1970-01-01T18:00:00.000Z"),
|
||||
date: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await createBookingScenario(scenarioData);
|
||||
|
||||
const result = await getSchedule({
|
||||
input: {
|
||||
eventTypeId: 1,
|
||||
eventTypeSlug: "",
|
||||
startTime: "2025-06-01T00:00:00.000Z",
|
||||
endTime: `${plus6DateString}T23:59:59.999Z`,
|
||||
timeZone: "Asia/Kolkata",
|
||||
isTeamEvent: true,
|
||||
orgSlug: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Expected slots based on:
|
||||
// - User availability: 9 AM - 5 PM London (1:30 PM - 9:30 PM Kolkata)
|
||||
// - Restriction: 10 AM - 6 PM Kolkata
|
||||
const getExpectedSlotsForDate = (dateString: string) => [
|
||||
`${dateString}T08:00:00.000Z`, // 1:30 PM Kolkata, 9 AM London
|
||||
`${dateString}T09:00:00.000Z`, // 2:30 PM Kolkata, 10 AM London
|
||||
`${dateString}T10:00:00.000Z`, // 3:30 PM Kolkata, 11 AM London
|
||||
`${dateString}T11:00:00.000Z`, // 4:30 PM Kolkata, 12 PM London
|
||||
`${dateString}T12:00:00.000Z`, // 5:30 PM Kolkata, 1 PM London
|
||||
];
|
||||
|
||||
// Verify weekday slots
|
||||
["2025-06-02", "2025-06-03", "2025-06-04", "2025-06-05", "2025-06-06"].forEach((dateString) => {
|
||||
expect(result).toHaveTimeSlots(getExpectedSlotsForDate(dateString), {
|
||||
dateString,
|
||||
doExactMatch: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Verify weekend is disabled
|
||||
expect(result).toHaveDateDisabled({ dateString: "2025-06-07" });
|
||||
expect(result).toHaveDateDisabled({ dateString: "2025-06-08" });
|
||||
});
|
||||
|
||||
test("should return all slots when no restriction schedule is applied", async () => {
|
||||
await setupTeamAndFeatures();
|
||||
vi.setSystemTime("2025-06-01T23:30:00Z");
|
||||
|
||||
const plus2DateString = "2025-06-02"; // Monday
|
||||
const plus6DateString = "2025-06-06"; // Friday
|
||||
|
||||
const scenarioData: ScheduleScenario = {
|
||||
...getBaseScenarioData(),
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
length: 60,
|
||||
teamId: 1,
|
||||
useBookerTimezone: false,
|
||||
hosts: [{ userId: 101 }],
|
||||
},
|
||||
],
|
||||
users: [
|
||||
{
|
||||
...TestData.users.example,
|
||||
id: 101,
|
||||
schedules: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Test Schedule",
|
||||
timeZone: "Europe/London",
|
||||
availability: [
|
||||
{
|
||||
days: [0, 1, 2, 3, 4, 5, 6],
|
||||
startTime: new Date("1970-01-01T09:00:00.000Z"),
|
||||
endTime: new Date("1970-01-01T17:00:00.000Z"),
|
||||
date: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
teams: [
|
||||
{
|
||||
membership: { role: "ADMIN", accepted: true },
|
||||
team: { id: 1, name: "Test Team", slug: "test-team" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await createBookingScenario(scenarioData);
|
||||
|
||||
const result = await getSchedule({
|
||||
input: {
|
||||
eventTypeId: 1,
|
||||
eventTypeSlug: "",
|
||||
startTime: "2025-06-01T00:00:00.000Z",
|
||||
endTime: `${plus6DateString}T23:59:59.999Z`,
|
||||
timeZone: "Asia/Kolkata",
|
||||
isTeamEvent: true,
|
||||
orgSlug: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Expected slots based on user's availability: 9 AM - 5 PM London
|
||||
const getExpectedSlotsForDate = (dateString: string) => [
|
||||
`${dateString}T08:00:00.000Z`, // 9 AM London, 1:30 PM Kolkata
|
||||
`${dateString}T09:00:00.000Z`, // 10 AM London, 2:30 PM Kolkata
|
||||
`${dateString}T10:00:00.000Z`, // 11 AM London, 3:30 PM Kolkata
|
||||
`${dateString}T11:00:00.000Z`, // 12 PM London, 4:30 PM Kolkata
|
||||
`${dateString}T12:00:00.000Z`, // 1 PM London, 5:30 PM Kolkata
|
||||
`${dateString}T13:00:00.000Z`, // 2 PM London, 6:30 PM Kolkata
|
||||
`${dateString}T14:00:00.000Z`, // 3 PM London, 7:30 PM Kolkata
|
||||
`${dateString}T15:00:00.000Z`, // 4 PM London, 8:30 PM Kolkata
|
||||
`${dateString}T16:00:00.000Z`, // 5 PM London, 9:30 PM Kolkata
|
||||
];
|
||||
|
||||
// Verify weekday slots
|
||||
["2025-06-02", "2025-06-03", "2025-06-04", "2025-06-05", "2025-06-06"].forEach((dateString) => {
|
||||
expect(result).toHaveTimeSlots(getExpectedSlotsForDate(dateString), {
|
||||
dateString,
|
||||
doExactMatch: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Verify a late evening slot (8:30 PM Kolkata, 4 PM London) is present
|
||||
const lateEveningSlot = `${plus2DateString}T15:00:00.000Z`;
|
||||
expect(result.slots[plus2DateString].map((s) => s.time)).toContain(lateEveningSlot);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -147,6 +147,8 @@ describe("handleChildrenEventTypes", () => {
|
||||
userId: 4,
|
||||
rrSegmentQueryValue: undefined,
|
||||
assignRRMembersUsingSegment: false,
|
||||
useBookerTimezone: false,
|
||||
restrictionScheduleId: null,
|
||||
allowReschedulingCancelledBookings: false,
|
||||
},
|
||||
});
|
||||
@@ -203,6 +205,8 @@ describe("handleChildrenEventTypes", () => {
|
||||
scheduleId: null,
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
requiresBookerEmailVerification: false,
|
||||
useBookerTimezone: false,
|
||||
restrictionScheduleId: null,
|
||||
hashedLink: {
|
||||
deleteMany: {},
|
||||
},
|
||||
@@ -317,6 +321,8 @@ describe("handleChildrenEventTypes", () => {
|
||||
workflows: undefined,
|
||||
rrSegmentQueryValue: undefined,
|
||||
assignRRMembersUsingSegment: false,
|
||||
useBookerTimezone: false,
|
||||
restrictionScheduleId: null,
|
||||
allowReschedulingCancelledBookings: false,
|
||||
},
|
||||
});
|
||||
@@ -372,6 +378,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
hashedLink: {
|
||||
deleteMany: {},
|
||||
},
|
||||
useBookerTimezone: false,
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
requiresBookerEmailVerification: false,
|
||||
allowReschedulingCancelledBookings: false,
|
||||
@@ -469,6 +476,8 @@ describe("handleChildrenEventTypes", () => {
|
||||
locations: [],
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
requiresBookerEmailVerification: false,
|
||||
useBookerTimezone: false,
|
||||
restrictionScheduleId: null,
|
||||
parentId: 1,
|
||||
userId: 5,
|
||||
users: {
|
||||
@@ -493,6 +502,8 @@ describe("handleChildrenEventTypes", () => {
|
||||
customReplyToEmail: null,
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
requiresBookerEmailVerification: false,
|
||||
useBookerTimezone: false,
|
||||
restrictionScheduleId: null,
|
||||
hashedLink: {
|
||||
deleteMany: {},
|
||||
},
|
||||
|
||||
@@ -213,9 +213,6 @@ export type InputEventType = {
|
||||
userId?: number;
|
||||
minimumBookingNotice?: number;
|
||||
useEventLevelSelectedCalendars?: boolean;
|
||||
/**
|
||||
* These user ids are `ScenarioData["users"]["id"]`
|
||||
*/
|
||||
users?: { id: number }[];
|
||||
hosts?: InputHost[];
|
||||
schedulingType?: SchedulingType;
|
||||
@@ -237,6 +234,25 @@ export type InputEventType = {
|
||||
owner?: number;
|
||||
metadata?: any;
|
||||
rescheduleWithSameRoundRobinHost?: boolean;
|
||||
restrictionSchedule?: {
|
||||
create: {
|
||||
name: string;
|
||||
timeZone: string;
|
||||
availability: {
|
||||
createMany: {
|
||||
data: {
|
||||
days: number[];
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
date: string | null;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
user: { connect: { id: number } };
|
||||
};
|
||||
};
|
||||
useBookerTimezone?: boolean;
|
||||
restrictionScheduleId?: number;
|
||||
} & Partial<Omit<Prisma.EventTypeCreateInput, "users" | "schedule" | "bookingLimits" | "durationLimits">>;
|
||||
|
||||
type AttendeeBookingSeatInput = Pick<Prisma.BookingSeatCreateInput, "referenceUid" | "data">;
|
||||
@@ -310,19 +326,31 @@ export async function addEventTypesToDb(
|
||||
"users" | "workflows" | "destinationCalendar" | "schedule"
|
||||
> & {
|
||||
id?: number;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
users?: any[];
|
||||
userId?: number;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
hosts?: any[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
workflows?: any[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
destinationCalendar?: any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
schedule?: any;
|
||||
metadata?: any;
|
||||
team?: { id?: number | null; bookingLimits?: IntervalLimit; includeManagedEventsInLimits?: boolean };
|
||||
restrictionSchedule?: {
|
||||
create: {
|
||||
name: string;
|
||||
timeZone: string;
|
||||
availability: {
|
||||
createMany: {
|
||||
data: {
|
||||
days: number[];
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
date: string | null;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
user: { connect: { id: number } };
|
||||
};
|
||||
};
|
||||
})[]
|
||||
) {
|
||||
log.silly("TestData: Add EventTypes to DB", JSON.stringify(eventTypes));
|
||||
@@ -338,11 +366,6 @@ export async function addEventTypesToDb(
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* This is a hack to get the relationship of schedule to be established with eventType. Looks like a prismock bug that creating eventType along with schedule.create doesn't establish the relationship.
|
||||
* HACK STARTS
|
||||
*/
|
||||
log.silly("Fixed possible prismock bug by creating schedule separately");
|
||||
for (let i = 0; i < eventTypes.length; i++) {
|
||||
const eventType = eventTypes[i];
|
||||
const createdEventType = allEventTypes[i];
|
||||
@@ -350,8 +373,6 @@ export async function addEventTypesToDb(
|
||||
if (eventType.schedule) {
|
||||
log.silly("TestData: Creating Schedule for EventType", JSON.stringify(eventType));
|
||||
await prismock.schedule.create({
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
data: {
|
||||
...eventType.schedule.create,
|
||||
eventType: {
|
||||
@@ -363,6 +384,24 @@ export async function addEventTypesToDb(
|
||||
});
|
||||
}
|
||||
|
||||
// Handle restrictionSchedule creation and connection
|
||||
if (eventType.restrictionSchedule) {
|
||||
const createdRestrictionSchedule = await prismock.schedule.create({
|
||||
data: {
|
||||
...eventType.restrictionSchedule.create,
|
||||
eventType: {
|
||||
connect: {
|
||||
id: createdEventType.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await prismock.eventType.update({
|
||||
where: { id: createdEventType.id },
|
||||
data: { restrictionScheduleId: createdRestrictionSchedule.id },
|
||||
});
|
||||
}
|
||||
|
||||
if (eventType.team?.id) {
|
||||
const createdTeam = await prismock.team.create({
|
||||
data: {
|
||||
@@ -379,9 +418,6 @@ export async function addEventTypesToDb(
|
||||
});
|
||||
}
|
||||
}
|
||||
/***
|
||||
* HACK ENDS
|
||||
*/
|
||||
|
||||
log.silly(
|
||||
"TestData: All EventTypes in DB are",
|
||||
@@ -454,6 +490,22 @@ export async function addEventTypes(eventTypes: InputEventType[], usersStore: In
|
||||
},
|
||||
}
|
||||
: eventType.schedule,
|
||||
restrictionScheduleId: eventType.restrictionScheduleId ?? undefined,
|
||||
useBookerTimezone: eventType.useBookerTimezone ?? false,
|
||||
restrictionSchedule: eventType.restrictionSchedule
|
||||
? {
|
||||
create: {
|
||||
name: eventType.restrictionSchedule.create.name,
|
||||
timeZone: eventType.restrictionSchedule.create.timeZone,
|
||||
availability: {
|
||||
createMany: {
|
||||
data: eventType.restrictionSchedule.create.availability.createMany.data,
|
||||
},
|
||||
},
|
||||
user: { connect: { id: eventType.userId || (eventType.users && eventType.users[0]?.id) || 1 } },
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
owner: eventType.owner ? { connect: { id: eventType.owner } } : undefined,
|
||||
schedulingType: eventType.schedulingType,
|
||||
parent: eventType.parent ? { connect: { id: eventType.parent.id } } : undefined,
|
||||
@@ -602,7 +654,7 @@ async function addWebhooks(webhooks: InputWebhook[]) {
|
||||
async function addWorkflowsToDb(workflows: InputWorkflow[]) {
|
||||
await Promise.all(
|
||||
workflows.map(async (workflow) => {
|
||||
const team = await prismock.team.findFirst({
|
||||
const team = await prismock.team.findUnique({
|
||||
where: {
|
||||
id: workflow.teamId ?? 0,
|
||||
},
|
||||
|
||||
@@ -7321,7 +7321,7 @@
|
||||
"post": {
|
||||
"operationId": "BookingsController_2024_08_13_cancelBooking",
|
||||
"summary": "Cancel a booking",
|
||||
"description": ":bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.\n 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\n like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.",
|
||||
"description": ":bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.\n \n \nCancelling seated bookings:\n It is possible to cancel specific seat within a booking as an attendee or all of the seats as the host.\n \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.\n \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.\n \n \nCancelling recurring seated bookings:\n For recurring seated bookings it is not possible to cancel all of them with 1 call\n like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
@@ -7792,7 +7792,7 @@
|
||||
"name": "eventUid",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"description": "The Google Calendar event ID",
|
||||
"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",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -10205,6 +10205,16 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bookingUidToReschedule",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"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",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "routingFormId",
|
||||
"required": true,
|
||||
@@ -23342,7 +23352,7 @@
|
||||
"example": "Error message"
|
||||
}
|
||||
},
|
||||
"required": ["id", "roomName", "startTs", "status", "duration", "shareToken", "downloadLink"]
|
||||
"required": ["id", "roomName", "startTs", "status", "duration", "shareToken"]
|
||||
},
|
||||
"GetBookingRecordingsOutput": {
|
||||
"type": "object",
|
||||
@@ -24092,7 +24102,7 @@
|
||||
},
|
||||
"CalendarEventResponseStatus": {
|
||||
"type": "string",
|
||||
"description": "Attendee's response to the invitation",
|
||||
"description": "Host's response to the invitation",
|
||||
"enum": ["accepted", "pending", "declined", "needsAction"]
|
||||
},
|
||||
"CalendarEventAttendee": {
|
||||
@@ -24140,6 +24150,11 @@
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Display name of the event host"
|
||||
},
|
||||
"responseStatus": {
|
||||
"nullable": true,
|
||||
"example": "accepted",
|
||||
"$ref": "#/components/schemas/CalendarEventResponseStatus"
|
||||
}
|
||||
},
|
||||
"required": ["email"]
|
||||
|
||||
@@ -156,7 +156,7 @@ To create a new webhook subscription, visit `/settings/developer/webhooks` and p
|
||||
Use the **secret key** to create an HMAC, and update it with the webhook payload received to generate an SHA256 hash.
|
||||
</Step>
|
||||
<Step title="Verify the payload authenticity">
|
||||
Compare the hash received in the header of the webhook `(X-Cal-Signature-256)` with the one you created using the **secret key** and the body of the payload. If they don't match, the received payload has been adulterated and cannot be trusted.
|
||||
Compare the hash received in the header of the webhook `(x-cal-signature-256)` with the one you created using the **secret key** and the body of the payload. If they don't match, the received payload has been adulterated and cannot be trusted.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ Below is a list of props that can be passed to the booker atom.
|
||||
| allowUpdatingUrlParams | No | Boolean indicating if the URL parameters should be updated, defaults to false. |
|
||||
| confirmButtonDisabled | No | Boolean indicating if the submit button should be disabled, defaults to false. |
|
||||
| timeZones | No | Array of valid IANA timezones to be used in the booker. Eg. ["Asia/Kolkata", "Europe/London"] |
|
||||
| onTimeslotsLoaded | No | Callback function triggered once the available timeslots have been fetched. |
|
||||
|
||||
## Styling
|
||||
|
||||
|
||||
@@ -31,7 +31,9 @@ Below is a list of props that can be passed to the Conferencing Apps Atom
|
||||
| :------------------ | :------- | :-------------------------------------------------------------------------- |
|
||||
| returnTo | No | The URL of the page to redirect to after a successful installation. |
|
||||
| onErrorReturnTo | No | The URL of the page to redirect to in case an error occurs. |
|
||||
| disableToasts | No | boolean value to disable toast notifications in the atom. |
|
||||
| disableToasts | No | Boolean value to disable toast notifications in the atom. |
|
||||
| apps | No | Array of conferencing app slugs to display in the dropdown. If provided, only these apps will be shown (if not already installed). Valid values are `'google-meet'`, `'zoom'`, and `'msteams'`. |
|
||||
| disableBulkUpdateEventTypes | No | A Boolean flag that prevents the bulk update of event types modal from appearing when the default conferencing app is changed. Defaults to false |
|
||||
|
||||
|
||||
## Google Meet
|
||||
|
||||
+3
-2
@@ -80,8 +80,9 @@
|
||||
"docker-build-api": "docker build -t cal-api -f ./infra/docker/api/Dockerfile .",
|
||||
"docker-run-api": "docker run -p 80:80 cal-api",
|
||||
"docker-stop-api": "docker ps --filter 'ancestor=cal-api' -q | xargs docker stop",
|
||||
"changesets-add": "yarn changeset add",
|
||||
"changesets-version": "yarn changeset version",
|
||||
"changesets-release": "turbo run build && yarn changeset publish"
|
||||
"changesets-release": "NODE_OPTIONS='--max_old_space_size=12288' turbo run build --filter=@calcom/atoms && yarn changeset publish"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/changelog-github": "^0.5.1",
|
||||
@@ -108,7 +109,7 @@
|
||||
"prismock": "^1.33.4",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"tsc-absolute": "^1.0.0",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^2.1.1",
|
||||
"vitest-fetch-mock": "^0.3.0",
|
||||
"vitest-mock-extended": "^2.0.2"
|
||||
|
||||
Binary file not shown.
@@ -31,6 +31,6 @@
|
||||
"eslint-plugin-react": "^7.30.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.8.3"
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
/**
|
||||
* Manages OAuth2.0 tokens for an app and resourceOwner. It automatically refreshes the token when needed.
|
||||
* It is aware of the credential sync endpoint and can sync the token from the third party source.
|
||||
* It is unaware of Prisma and App logic. It is just a utility to manage OAuth2.0 tokens with life cycle methods
|
||||
* Manages OAuth2.0 as well as JWT tokens(For JWT tokens, only Google Calendar use it at the moment) for an app and resourceOwner.
|
||||
* What it does
|
||||
* - It automatically refreshes the token if needed when making a request.
|
||||
* - It is aware of the credential sync endpoint and can sync the token from the third party source.
|
||||
* - It is kept unaware of Prisma and App logic. It is just a utility to manage OAuth2.0 tokens with life cycle methods
|
||||
*
|
||||
* What it doesn't do yet
|
||||
* - It doesn't have a flow to re-send the request if the access-token had been communicated as invalid after making the request itself. It relies on the caller to make the next request in which it will actually refresh the token.
|
||||
*
|
||||
* For a recommended usage example, see Zoom VideoApiAdapter.ts
|
||||
*/
|
||||
@@ -89,7 +94,13 @@ export class OAuthManager {
|
||||
currentTokenObject,
|
||||
fetchNewTokenObject,
|
||||
updateTokenObject,
|
||||
/**
|
||||
* The fn must not crash. It is the responsibility of the caller to handle any error and appropriately decide what to return
|
||||
*/
|
||||
isTokenObjectUnusable,
|
||||
/**
|
||||
* The fn must not crash. It is the responsibility of the caller to handle any error and appropriately decide what to return
|
||||
*/
|
||||
isAccessTokenUnusable,
|
||||
invalidateTokenObject,
|
||||
expireAccessToken,
|
||||
|
||||
@@ -31,7 +31,7 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
bookingId: Booking["id"]
|
||||
) {
|
||||
try {
|
||||
const booking = await prisma.booking.findFirst({
|
||||
const booking = await prisma.booking.findUnique({
|
||||
select: {
|
||||
uid: true,
|
||||
title: true,
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import type { UseAddAppMutationOptions } from "@calcom/app-store/_utils/useAddAppMutation";
|
||||
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { deriveAppDictKeyFromType } from "@calcom/lib/deriveAppDictKeyFromType";
|
||||
import { useHasTeamPlan } from "@calcom/lib/hooks/useHasPaidPlan";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import type { App } from "@calcom/types/App";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
import { InstallAppButtonMap } from "./apps.browser.generated";
|
||||
import type { InstallAppButtonProps } from "./types";
|
||||
@@ -59,44 +55,8 @@ export const InstallAppButton = (
|
||||
disableInstall?: boolean;
|
||||
} & InstallAppButtonProps
|
||||
) => {
|
||||
const { isPending: isUserLoading, data: user } = trpc.viewer.me.get.useQuery();
|
||||
const router = useRouter();
|
||||
const proProtectionElementRef = useRef<HTMLDivElement | null>(null);
|
||||
const { isPending: isTeamPlanStatusLoading, hasTeamPlan } = useHasTeamPlan();
|
||||
|
||||
useEffect(() => {
|
||||
const el = proProtectionElementRef.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
el.addEventListener(
|
||||
"click",
|
||||
(e) => {
|
||||
if (!user) {
|
||||
router.push(
|
||||
`${WEBAPP_URL}/auth/login?callbackUrl=${WEBAPP_URL + location.pathname + location.search}`
|
||||
);
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.teamsPlanRequired && !hasTeamPlan) {
|
||||
// TODO: I think we should show the UpgradeTip in a Dialog here. This would solve the problem of no way to go back to the App page from the UpgradeTip page(except browser's back button)
|
||||
router.push(props.teamsPlanRequired.upgradeUrl);
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
}, [isUserLoading, user, router, hasTeamPlan, props.teamsPlanRequired]);
|
||||
|
||||
if (isUserLoading || isTeamPlanStatusLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={proProtectionElementRef} className={props.wrapperClassName}>
|
||||
<div className={props.wrapperClassName}>
|
||||
<InstallAppButtonWithoutPlanCheck {...props} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -42,6 +42,35 @@ export interface DailyVideoCallData {
|
||||
url: string;
|
||||
}
|
||||
|
||||
// Regions available to create DailyVideo Rooms in.
|
||||
const REGION_CODES = [
|
||||
"af-south-1",
|
||||
"ap-northeast-2",
|
||||
"ap-southeast-1",
|
||||
"ap-southeast-2",
|
||||
"ap-south-1",
|
||||
"eu-central-1",
|
||||
"eu-west-2",
|
||||
"sa-east-1",
|
||||
"us-east-1",
|
||||
"us-west-2",
|
||||
] as const;
|
||||
|
||||
type RoomGeo = (typeof REGION_CODES)[number];
|
||||
|
||||
function getDailyVideoRegionFromEnv(): RoomGeo | undefined {
|
||||
if (!process?.env?.DAILY_VIDEO_REGION) return;
|
||||
const isRoomGeo = (value: string): value is RoomGeo => REGION_CODES.includes(value as RoomGeo);
|
||||
function assertIsDailyVideoRegion(value: string): asserts value is RoomGeo {
|
||||
if (!isRoomGeo(value)) {
|
||||
throw new Error(`Invalid region code: ${value}. Must be one of: ${REGION_CODES.join(", ")}`);
|
||||
}
|
||||
}
|
||||
const region = process.env.DAILY_VIDEO_REGION;
|
||||
assertIsDailyVideoRegion(region);
|
||||
return region;
|
||||
}
|
||||
|
||||
const isS3StorageEnabled =
|
||||
process.env.CAL_VIDEO_BUCKET_NAME &&
|
||||
process.env.CAL_VIDEO_BUCKET_REGION &&
|
||||
@@ -203,11 +232,15 @@ export const setEnableRecordingUIAndUserIdForOrganizer = async (
|
||||
};
|
||||
|
||||
const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
||||
async function createOrUpdateMeeting(endpoint: string, event: CalendarEvent): Promise<VideoCallData> {
|
||||
async function createOrUpdateMeeting(
|
||||
endpoint: string,
|
||||
event: CalendarEvent,
|
||||
region?: RoomGeo
|
||||
): Promise<VideoCallData> {
|
||||
if (!event.uid) {
|
||||
throw new Error("We need need the booking uid to create the Daily reference in DB");
|
||||
}
|
||||
const body = await translateEvent(event);
|
||||
const body = await translateEvent(event, region);
|
||||
const dailyEvent = await postToDailyAPI(endpoint, body).then(dailyReturnTypeSchema.parse);
|
||||
const meetingToken = await postToDailyAPI("/meeting-tokens", {
|
||||
properties: {
|
||||
@@ -226,7 +259,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
||||
});
|
||||
}
|
||||
|
||||
const translateEvent = async (event: CalendarEvent) => {
|
||||
const translateEvent = async (event: CalendarEvent, region?: RoomGeo) => {
|
||||
// Documentation at: https://docs.daily.co/reference#list-rooms
|
||||
// Adds 14 days from the end of the booking as the expiration date
|
||||
const exp = Math.round(new Date(event.endTime).getTime() / 1000) + 60 * 60 * 24 * 14;
|
||||
@@ -248,6 +281,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
||||
return {
|
||||
privacy: "public",
|
||||
properties: {
|
||||
...(region ? { geo: region } : {}),
|
||||
enable_prejoin_ui: true,
|
||||
enable_knocking: true,
|
||||
enable_screenshare: true,
|
||||
@@ -284,7 +318,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
||||
};
|
||||
};
|
||||
|
||||
async function createInstantMeeting(endTime: string) {
|
||||
async function createInstantMeeting(endTime: string, region?: RoomGeo) {
|
||||
// added a 1 hour buffer for room expiration
|
||||
const exp = Math.round(new Date(endTime).getTime() / 1000) + 60 * 60;
|
||||
const { scale_plan: scalePlan } = await getDailyAppKeys();
|
||||
@@ -296,6 +330,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
||||
const body = {
|
||||
privacy: "public",
|
||||
properties: {
|
||||
...(region ? { geo: region } : {}),
|
||||
enable_prejoin_ui: true,
|
||||
enable_knocking: true,
|
||||
enable_screenshare: true,
|
||||
@@ -347,20 +382,22 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
||||
url: dailyEvent.url,
|
||||
});
|
||||
}
|
||||
|
||||
// Region on which the DailyVideo room is created can be controlled by ENV var
|
||||
// undefined region leaves the choice to DailyVideo
|
||||
const region = getDailyVideoRegionFromEnv();
|
||||
return {
|
||||
/** Daily doesn't need to return busy times, so we return empty */
|
||||
getAvailability: () => {
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
createMeeting: async (event: CalendarEvent): Promise<VideoCallData> =>
|
||||
createOrUpdateMeeting("/rooms", event),
|
||||
createOrUpdateMeeting("/rooms", event, region),
|
||||
deleteMeeting: async (uid: string): Promise<void> => {
|
||||
await fetcher(`/rooms/${uid}`, { method: "DELETE" });
|
||||
return Promise.resolve();
|
||||
},
|
||||
updateMeeting: (bookingRef: PartialReference, event: CalendarEvent): Promise<VideoCallData> =>
|
||||
createOrUpdateMeeting(`/rooms/${bookingRef.uid}`, event),
|
||||
createOrUpdateMeeting(`/rooms/${bookingRef.uid}`, event, region),
|
||||
getRecordings: async (roomName: string): Promise<GetRecordingsResponseSchema> => {
|
||||
try {
|
||||
const res = await fetcher(`/recordings?room_name=${roomName}`).then(
|
||||
@@ -371,7 +408,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
||||
throw new Error("Something went wrong! Unable to get recording");
|
||||
}
|
||||
},
|
||||
createInstantCalVideoRoom: (endTime: string) => createInstantMeeting(endTime),
|
||||
createInstantCalVideoRoom: (endTime: string) => createInstantMeeting(endTime, region),
|
||||
getRecordingDownloadLink: async (recordingId: string): Promise<GetAccessLinkResponseSchema> => {
|
||||
try {
|
||||
const res = await fetcher(`/recordings/${recordingId}/access-link?valid_for_secs=43200`).then(
|
||||
|
||||
@@ -2,15 +2,9 @@ import { calendar_v3 } from "@googleapis/calendar";
|
||||
import { OAuth2Client } from "googleapis-common";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { updateProfilePhotoGoogle } from "@calcom/app-store/_utils/oauth/updateProfilePhotoGoogle";
|
||||
import GoogleCalendarService from "@calcom/app-store/googlecalendar/lib/CalendarService";
|
||||
import { renewSelectedCalendarCredentialId } from "@calcom/lib/connectedCalendar";
|
||||
import {
|
||||
GOOGLE_CALENDAR_SCOPES,
|
||||
SCOPE_USERINFO_PROFILE,
|
||||
WEBAPP_URL,
|
||||
WEBAPP_URL_FOR_OAUTH,
|
||||
} from "@calcom/lib/constants";
|
||||
import { GOOGLE_CALENDAR_SCOPES, WEBAPP_URL, WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
|
||||
@@ -101,9 +95,10 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
}
|
||||
|
||||
// Only attempt to update the user's profile photo if the user has granted the required scope
|
||||
if (grantedScopes.includes(SCOPE_USERINFO_PROFILE)) {
|
||||
await updateProfilePhotoGoogle(oAuth2Client, req.session.user.id);
|
||||
}
|
||||
// TODO: Use the avatarUrl when setting the profile picture
|
||||
// if (grantedScopes.includes(SCOPE_USERINFO_PROFILE)) {
|
||||
// await updateProfilePhotoGoogle(oAuth2Client, req.session.user.id);
|
||||
// }
|
||||
|
||||
const selectedCalendarWhereUnique = {
|
||||
userId: req.session.user.id,
|
||||
|
||||
@@ -29,7 +29,14 @@ const log = logger.getSubLogger({ prefix: ["app-store/googlecalendar/lib/Calenda
|
||||
|
||||
class MyGoogleOAuth2Client extends OAuth2Client {
|
||||
constructor(client_id: string, client_secret: string, redirect_uri: string) {
|
||||
super(client_id, client_secret, redirect_uri);
|
||||
super({
|
||||
clientId: client_id,
|
||||
clientSecret: client_secret,
|
||||
redirectUri: redirect_uri,
|
||||
// default: 5 * 60 * 1000, 5 minutes
|
||||
// tho, fn will never run in excess of 60 seconds
|
||||
eagerRefreshThresholdMillis: 60000,
|
||||
});
|
||||
}
|
||||
|
||||
isTokenExpiring() {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user