Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da6fc9f2d5 | ||
|
|
20434856e9 | ||
|
|
7f891adff2 | ||
|
|
eb66fbe054 | ||
|
|
29122934a9 | ||
|
|
0825cce82c | ||
|
|
fd964131d1 | ||
|
|
77d9c15fed | ||
|
|
b21295f268 | ||
|
|
5769553659 | ||
|
|
d23d68bf2d | ||
|
|
d04c7f58a1 | ||
|
|
93e2426760 | ||
|
|
9f6c05f389 | ||
|
|
f8a1bd7b94 | ||
|
|
02967961f3 | ||
|
|
053b8b5c0a | ||
|
|
d59efd3ba0 | ||
|
|
ce56cd1ab0 | ||
|
|
54b6deee19 | ||
|
|
8315641724 | ||
|
|
aa3086cfae | ||
|
|
b599ce4919 | ||
|
|
52f16604c1 | ||
|
|
c98c06062b | ||
|
|
4963d247dc | ||
|
|
9405bc9553 | ||
|
|
8a080b91b9 | ||
|
|
4aa05fe523 | ||
|
|
d74d99dde7 | ||
|
|
0ee137efbb | ||
|
|
90f55670dc | ||
|
|
9c53f392ca | ||
|
|
cc19a81875 | ||
|
|
67032ddaf1 | ||
|
|
2fe71c3695 | ||
|
|
21422fe027 | ||
|
|
95575291ff | ||
|
|
3e61a0633d | ||
|
|
85c9a790f2 | ||
|
|
e17d55457f | ||
|
|
d8d8bc4d00 | ||
|
|
55cc1aea59 | ||
|
|
ae383164bc | ||
|
|
0d8863fe8e | ||
|
|
51f6654635 | ||
|
|
fa11e10bcb | ||
|
|
a8f5bd6fe9 | ||
|
|
a1c725bb35 | ||
|
|
e4eeb11a61 | ||
|
|
3b0bb25251 | ||
|
|
d27490e6f2 | ||
|
|
b3c191b553 | ||
|
|
0b977e615d |
@@ -164,6 +164,7 @@ SENTRY_PROJECT=
|
||||
SENTRY_AUTH_TOKEN=
|
||||
# Used for capturing exceptions and logging messages
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
NEXT_PUBLIC_SENTRY_DSN_CLIENT=
|
||||
SENTRY_DEBUG=
|
||||
SENTRY_MAX_SPANS=
|
||||
SENTRY_SAMPLE_RATE=
|
||||
|
||||
@@ -27,8 +27,24 @@ jobs:
|
||||
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config --local user.email "github-actions@github.com"
|
||||
git config --local user.name "GitHub Actions"
|
||||
# Define authorized users and their emails using organization secret
|
||||
# Expected format: JSON object with username -> email mapping
|
||||
# Example: {"zomars": "zomars@cal.com", "peer": "peer@cal.com", "username3": "email3@cal.com"}
|
||||
USER_EMAILS='${{ secrets.RELEASE_USER_EMAILS }}'
|
||||
|
||||
# Extract email for the triggering user
|
||||
USER_EMAIL=$(echo "$USER_EMAILS" | jq -r --arg user "${{ github.actor }}" '.[$user] // empty')
|
||||
|
||||
# Fail if user is not authorized or not in mapping
|
||||
if [ -z "$USER_EMAIL" ] || [ "$USER_EMAIL" = "null" ]; then
|
||||
echo "Error: User '${{ github.actor }}' is not authorized to run the release workflow."
|
||||
echo "Only authorized team members can trigger releases."
|
||||
echo "Contact your administrator to be added to the authorized users list."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git config --local user.email "$USER_EMAIL"
|
||||
git config --local user.name "${{ github.actor }}"
|
||||
|
||||
- uses: ./.github/actions/yarn-install
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
with:
|
||||
repo-token: ${{ secrets.EQUITY_BEE_TEAM_LABELER_ACTION_TOKEN }}
|
||||
organization-name: calcom
|
||||
ignore-labels: "admin, app-store, ai, authentication, automated-testing, devops, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
|
||||
ignore-labels: "admin, app-store, ai, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, dba, devops, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
|
||||
apply-labels-from-issue:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
permissions:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
run-e2e: ${{ steps.check-if-pr-has-label.outputs.run-e2e == 'true' && (github.event.action != 'labeled' || (github.event.action == 'labeled' && github.event.label.name == 'ready-for-e2e')) }}
|
||||
run-e2e: ${{ steps.check-if-pr-has-label.outputs.run-e2e == 'true' && (github.event.action != 'labeled' || github.event.label.name == 'ready-for-e2e') }}
|
||||
steps:
|
||||
- name: Check if PR exists with ready-for-e2e label for this SHA
|
||||
id: check-if-pr-has-label
|
||||
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
Vendored
Vendored
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
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
+1
-1
@@ -2,4 +2,4 @@
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -5,9 +5,6 @@ const plugins = [withAxiom];
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
experimental: {
|
||||
instrumentationHook: true,
|
||||
},
|
||||
transpilePackages: [
|
||||
"@calcom/app-store",
|
||||
"@calcom/dayjs",
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"@sentry/nextjs": "^9.15.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"memory-cache": "^0.2.0",
|
||||
"next": "^13.5.9",
|
||||
"next": "^15.3.0",
|
||||
"next-api-middleware": "^1.0.1",
|
||||
"next-axiom": "^0.17.0",
|
||||
"next-swagger-doc": "^0.3.6",
|
||||
|
||||
@@ -38,8 +38,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@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-libraries": "npm:@calcom/platform-libraries@0.0.236",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
"@calcom/prisma": "*",
|
||||
|
||||
@@ -232,6 +232,15 @@ export class BookingsController_2024_04_15 {
|
||||
}
|
||||
|
||||
if (bookingUid) {
|
||||
const { bookingInfo } = await getBookingInfo(bookingUid);
|
||||
if (!bookingInfo) {
|
||||
throw new NotFoundException(`Booking with UID=${bookingUid} does not exist.`);
|
||||
}
|
||||
if (bookingInfo.status === "CANCELLED") {
|
||||
throw new BadRequestException(
|
||||
`Can't cancel booking with uid=${bookingUid} because it has been cancelled already. Please provide uid of a booking that is not cancelled.`
|
||||
);
|
||||
}
|
||||
try {
|
||||
req.body.uid = bookingUid;
|
||||
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
|
||||
|
||||
@@ -2553,6 +2553,59 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("cant't cancel already cancelled booking", () => {
|
||||
it("should not be able to cancel alraedy cancelled booking", async () => {
|
||||
const cancelledBooking = await bookingsRepositoryFixture.create({
|
||||
status: "CANCELLED",
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
|
||||
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
|
||||
title: "peer coding lets goo",
|
||||
uid: `cancelled-booking-${randomString()}`,
|
||||
eventType: {
|
||||
connect: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
},
|
||||
location: "integrations:daily",
|
||||
customInputs: {},
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "Oldie",
|
||||
email: "oldie@gmail.com",
|
||||
},
|
||||
attendees: {
|
||||
create: {
|
||||
email: "oldie@gmail.com",
|
||||
name: "Oldie",
|
||||
locale: "lv",
|
||||
timeZone: "Europe/Rome",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const body: CancelBookingInput_2024_08_13 = {
|
||||
cancellationReason: "Going on a vacation",
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${cancelledBooking.uid}/cancel`)
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error.message).toEqual(
|
||||
`Can't cancel booking with uid=${cancelledBooking.uid} because it has been cancelled already. Please provide uid of a booking that is not cancelled.`
|
||||
);
|
||||
await bookingsRepositoryFixture.deleteById(cancelledBooking.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calendar events", () => {
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
|
||||
@@ -711,6 +711,12 @@ export class InputBookingsService_2024_08_13 {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} not found`);
|
||||
}
|
||||
|
||||
if (booking.status === "CANCELLED") {
|
||||
throw new BadRequestException(
|
||||
`Can't cancel booking with uid=${bookingUid} because it has been cancelled already. Please provide uid of a booking that is not cancelled.`
|
||||
);
|
||||
}
|
||||
|
||||
const oAuthClientParams = booking.eventTypeId
|
||||
? await this.platformBookingsService.getOAuthClientParams(booking.eventTypeId)
|
||||
: undefined;
|
||||
|
||||
+24
-5
@@ -18,6 +18,7 @@ import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
@@ -33,13 +34,19 @@ import {
|
||||
HttpStatus,
|
||||
Delete,
|
||||
Query,
|
||||
Headers,
|
||||
InternalServerErrorException,
|
||||
ParseIntPipe,
|
||||
} from "@nestjs/common";
|
||||
import { ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
|
||||
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { getPublicEvent, getEventTypesByViewer } from "@calcom/platform-libraries-0.0.2";
|
||||
import {
|
||||
EVENT_TYPE_READ,
|
||||
EVENT_TYPE_WRITE,
|
||||
SUCCESS_STATUS,
|
||||
X_CAL_CLIENT_ID,
|
||||
} from "@calcom/platform-constants";
|
||||
import { getPublicEvent, getEventTypesByViewer } from "@calcom/platform-libraries/event-types";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Controller({
|
||||
@@ -51,7 +58,8 @@ import { PrismaClient } from "@calcom/prisma";
|
||||
export class EventTypesController_2024_04_15 {
|
||||
constructor(
|
||||
private readonly eventTypesService: EventTypesService_2024_04_15,
|
||||
private readonly prismaReadService: PrismaReadService
|
||||
private readonly prismaReadService: PrismaReadService,
|
||||
private readonly organizationsRepository: OrganizationsRepository
|
||||
) {}
|
||||
|
||||
@Post("/")
|
||||
@@ -109,19 +117,30 @@ export class EventTypesController_2024_04_15 {
|
||||
async getPublicEventType(
|
||||
@Param("username") username: string,
|
||||
@Param("eventSlug") eventSlug: string,
|
||||
@Query() queryParams: GetPublicEventTypeQueryParams_2024_04_15
|
||||
@Query() queryParams: GetPublicEventTypeQueryParams_2024_04_15,
|
||||
@Headers(X_CAL_CLIENT_ID) clientId?: string
|
||||
): Promise<GetEventTypePublicOutput> {
|
||||
try {
|
||||
let orgSlug = queryParams.org;
|
||||
|
||||
if (clientId && !orgSlug && username.includes(`-${clientId}`)) {
|
||||
const org = await this.organizationsRepository.findTeamIdAndSlugFromClientId(clientId).catch(() => null);
|
||||
if (org) {
|
||||
orgSlug = org.slug;
|
||||
}
|
||||
}
|
||||
|
||||
const event = await getPublicEvent(
|
||||
username.toLowerCase(),
|
||||
eventSlug,
|
||||
queryParams.isTeamEvent,
|
||||
queryParams.org || null,
|
||||
orgSlug ?? null,
|
||||
this.prismaReadService.prisma as unknown as PrismaClient,
|
||||
// We should be fine allowing unpublished orgs events to be servable through platform because Platform access is behind license
|
||||
// If there is ever a need to restrict this, we can introduce a new query param `fromRedirectOfNonOrgLink`
|
||||
true
|
||||
);
|
||||
|
||||
return {
|
||||
data: event,
|
||||
status: SUCCESS_STATUS,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { EventTypesController_2024_04_15 } from "@/ee/event-types/event-types_20
|
||||
import { EventTypesRepository_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.repository";
|
||||
import { EventTypesService_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/services/event-types.service";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { SelectedCalendarsModule } from "@/modules/selected-calendars/selected-calendars.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
@@ -9,7 +10,14 @@ import { UsersModule } from "@/modules/users/users.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, MembershipsModule, TokensModule, UsersModule, SelectedCalendarsModule],
|
||||
imports: [
|
||||
PrismaModule,
|
||||
MembershipsModule,
|
||||
TokensModule,
|
||||
UsersModule,
|
||||
SelectedCalendarsModule,
|
||||
OrganizationsModule,
|
||||
],
|
||||
providers: [EventTypesRepository_2024_04_15, EventTypesService_2024_04_15],
|
||||
controllers: [EventTypesController_2024_04_15],
|
||||
exports: [EventTypesService_2024_04_15, EventTypesRepository_2024_04_15],
|
||||
|
||||
+9
-1
@@ -1721,7 +1721,7 @@ describe("Event types Endpoints", () => {
|
||||
description: "undefined booking fields",
|
||||
length: 40,
|
||||
hidden: false,
|
||||
slug: "undefined-booking-fields",
|
||||
slug: `undefined-booking-fields-${randomString()}`,
|
||||
locations: [],
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
};
|
||||
@@ -1751,6 +1751,14 @@ describe("Event types Endpoints", () => {
|
||||
disableOnPrefill: false,
|
||||
hidden: false,
|
||||
},
|
||||
{
|
||||
disableOnPrefill: false,
|
||||
hidden: true,
|
||||
isDefault: true,
|
||||
required: false,
|
||||
slug: "attendeePhoneNumber",
|
||||
type: "phone",
|
||||
},
|
||||
{
|
||||
isDefault: true,
|
||||
type: "radioInput",
|
||||
|
||||
+9
-11
@@ -46,11 +46,10 @@ export class SchedulesController_2024_04_15 {
|
||||
@Body() bodySchedule: CreateScheduleInput_2024_04_15
|
||||
): Promise<CreateScheduleOutput_2024_04_15> {
|
||||
const schedule = await this.schedulesService.createUserSchedule(user.id, bodySchedule);
|
||||
const scheduleFormatted = await this.schedulesService.formatScheduleForAtom(user, schedule);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: scheduleFormatted,
|
||||
data: schedule,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,13 +59,10 @@ export class SchedulesController_2024_04_15 {
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<GetDefaultScheduleOutput_2024_04_15 | null> {
|
||||
const schedule = await this.schedulesService.getUserScheduleDefault(user.id);
|
||||
const scheduleFormatted = schedule
|
||||
? await this.schedulesService.formatScheduleForAtom(user, schedule)
|
||||
: null;
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: scheduleFormatted,
|
||||
data: schedule,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,23 +73,25 @@ export class SchedulesController_2024_04_15 {
|
||||
@Param("scheduleId") scheduleId: number
|
||||
): Promise<GetScheduleOutput_2024_04_15> {
|
||||
const schedule = await this.schedulesService.getUserSchedule(user.id, scheduleId);
|
||||
const scheduleFormatted = await this.schedulesService.formatScheduleForAtom(user, schedule);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: scheduleFormatted,
|
||||
data: schedule,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/")
|
||||
@Permissions([SCHEDULE_READ])
|
||||
async getSchedules(@GetUser() user: UserWithProfile): Promise<GetSchedulesOutput_2024_04_15> {
|
||||
const schedules = await this.schedulesService.getUserSchedules(user.id);
|
||||
const schedulesFormatted = await this.schedulesService.formatSchedulesForAtom(user, schedules);
|
||||
const schedules = await this.schedulesService.getUserSchedules(
|
||||
user.id,
|
||||
user.timeZone,
|
||||
user.defaultScheduleId
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: schedulesFormatted,
|
||||
data: schedules,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
import { CreateAvailabilityInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-availability.input";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/outputs/schedule.output";
|
||||
import { SchedulesRepository_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.repository";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Schedule } from "@prisma/client";
|
||||
import { User } from "@prisma/client";
|
||||
|
||||
import type { ScheduleWithAvailabilities } from "@calcom/platform-libraries-0.0.2";
|
||||
import { updateScheduleHandler } from "@calcom/platform-libraries-0.0.2";
|
||||
import {
|
||||
transformWorkingHoursForClient,
|
||||
transformAvailabilityForClient,
|
||||
transformDateOverridesForClient,
|
||||
} from "@calcom/platform-libraries-0.0.2";
|
||||
import { updateSchedule } from "@calcom/platform-libraries/schedules";
|
||||
import { ScheduleRepository } from "@calcom/platform-libraries/schedules";
|
||||
import { UpdateScheduleInput_2024_04_15 } from "@calcom/platform-types";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Injectable()
|
||||
export class SchedulesService_2024_04_15 {
|
||||
constructor(
|
||||
private readonly schedulesRepository: SchedulesRepository_2024_04_15,
|
||||
private readonly usersRepository: UsersRepository
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly dbWrite: PrismaWriteService
|
||||
) {}
|
||||
|
||||
async createUserDefaultSchedule(userId: number, timeZone: string) {
|
||||
@@ -48,7 +44,9 @@ export class SchedulesService_2024_04_15 {
|
||||
await this.usersRepository.setDefaultSchedule(userId, createdSchedule.id);
|
||||
}
|
||||
|
||||
return createdSchedule;
|
||||
const formattedSchedule = await this.getUserSchedule(userId, createdSchedule.id);
|
||||
|
||||
return formattedSchedule;
|
||||
}
|
||||
|
||||
async getUserScheduleDefault(userId: number) {
|
||||
@@ -56,11 +54,29 @@ export class SchedulesService_2024_04_15 {
|
||||
|
||||
if (!user?.defaultScheduleId) return null;
|
||||
|
||||
return this.schedulesRepository.getScheduleById(user.defaultScheduleId);
|
||||
return await ScheduleRepository.findDetailedScheduleById({
|
||||
scheduleId: user.defaultScheduleId,
|
||||
isManagedEventType: undefined,
|
||||
userId,
|
||||
timeZone: user.timeZone,
|
||||
defaultScheduleId: user.defaultScheduleId,
|
||||
});
|
||||
}
|
||||
|
||||
async getUserSchedule(userId: number, scheduleId: number) {
|
||||
const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId);
|
||||
const user = await this.usersRepository.findById(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException(`User with ID=${userId} does not exist.`);
|
||||
}
|
||||
|
||||
const existingSchedule = await ScheduleRepository.findDetailedScheduleById({
|
||||
scheduleId: scheduleId,
|
||||
isManagedEventType: undefined,
|
||||
userId,
|
||||
timeZone: user.timeZone,
|
||||
defaultScheduleId: user.defaultScheduleId,
|
||||
});
|
||||
|
||||
if (!existingSchedule) {
|
||||
throw new NotFoundException(`Schedule with ID=${scheduleId} does not exist.`);
|
||||
@@ -71,8 +87,8 @@ export class SchedulesService_2024_04_15 {
|
||||
return existingSchedule;
|
||||
}
|
||||
|
||||
async getUserSchedules(userId: number) {
|
||||
return this.schedulesRepository.getSchedulesByUserId(userId);
|
||||
async getUserSchedules(userId: number, timeZone: string, defaultScheduleId: number | null) {
|
||||
return ScheduleRepository.findManyDetailedScheduleByUserId({ userId, timeZone, defaultScheduleId });
|
||||
}
|
||||
|
||||
async updateUserSchedule(
|
||||
@@ -89,7 +105,6 @@ export class SchedulesService_2024_04_15 {
|
||||
this.checkUserOwnsSchedule(user.id, existingSchedule);
|
||||
|
||||
const schedule = await this.getUserSchedule(user.id, Number(scheduleId));
|
||||
const scheduleFormatted = await this.formatScheduleForAtom(user, schedule);
|
||||
|
||||
if (!bodySchedule.schedule) {
|
||||
// note(Lauris): When updating an availability in cal web app, lets say only its name, also
|
||||
@@ -97,12 +112,16 @@ export class SchedulesService_2024_04_15 {
|
||||
// and they have same shape, so to match shapes I attach "scheduleFormatted.availability" to reflect
|
||||
// schedule that would be passed by the web app. If we don't, then updating schedule name will erase
|
||||
// schedule.
|
||||
bodySchedule.schedule = scheduleFormatted.availability;
|
||||
bodySchedule.schedule = schedule.availability;
|
||||
}
|
||||
|
||||
return updateScheduleHandler({
|
||||
input: { scheduleId: Number(scheduleId), ...bodySchedule },
|
||||
ctx: { user },
|
||||
return updateSchedule({
|
||||
input: {
|
||||
scheduleId: Number(scheduleId),
|
||||
...bodySchedule,
|
||||
},
|
||||
user,
|
||||
prisma: this.dbWrite.prisma as unknown as PrismaClient,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -118,44 +137,6 @@ export class SchedulesService_2024_04_15 {
|
||||
return this.schedulesRepository.deleteScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
async formatScheduleForAtom(user: User, schedule: ScheduleWithAvailabilities): Promise<ScheduleOutput> {
|
||||
const usersSchedulesCount = await this.schedulesRepository.getUserSchedulesCount(user.id);
|
||||
return this.transformScheduleForAtom(schedule, usersSchedulesCount, user);
|
||||
}
|
||||
|
||||
async formatSchedulesForAtom(
|
||||
user: User,
|
||||
schedules: ScheduleWithAvailabilities[]
|
||||
): Promise<ScheduleOutput[]> {
|
||||
const usersSchedulesCount = await this.schedulesRepository.getUserSchedulesCount(user.id);
|
||||
return Promise.all(
|
||||
schedules.map((schedule) => this.transformScheduleForAtom(schedule, usersSchedulesCount, user))
|
||||
);
|
||||
}
|
||||
|
||||
async transformScheduleForAtom(
|
||||
schedule: ScheduleWithAvailabilities,
|
||||
userSchedulesCount: number,
|
||||
user: Pick<User, "id" | "defaultScheduleId" | "timeZone">
|
||||
): Promise<ScheduleOutput> {
|
||||
const timeZone = schedule.timeZone || user.timeZone;
|
||||
const defaultSchedule = await this.getUserScheduleDefault(user.id);
|
||||
|
||||
return {
|
||||
id: schedule.id,
|
||||
name: schedule.name,
|
||||
isManaged: schedule.userId !== user.id,
|
||||
workingHours: transformWorkingHoursForClient(schedule),
|
||||
schedule: schedule.availability,
|
||||
availability: transformAvailabilityForClient(schedule),
|
||||
timeZone,
|
||||
dateOverrides: transformDateOverridesForClient(schedule, timeZone),
|
||||
isDefault: defaultSchedule?.id === schedule.id,
|
||||
isLastSchedule: userSchedulesCount <= 1,
|
||||
readOnly: schedule.userId !== user.id,
|
||||
};
|
||||
}
|
||||
|
||||
checkUserOwnsSchedule(userId: number, schedule: Pick<Schedule, "id" | "userId">) {
|
||||
if (userId !== schedule.userId) {
|
||||
throw new ForbiddenException(`User with ID=${userId} does not own schedule with ID=${schedule.id}`);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { extractUserContext } from "@/lib/extract-user-context";
|
||||
import { filterReqHeaders } from "@/lib/filterReqHeaders";
|
||||
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
@@ -39,6 +40,7 @@ export class CalendarServiceExceptionFilter implements ExceptionFilter {
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
|
||||
const userContext = extractUserContext(request);
|
||||
this.logger.error(`Calendar Service Exception Filter: ${exception?.message}`, {
|
||||
exception,
|
||||
body: request.body,
|
||||
@@ -46,6 +48,7 @@ export class CalendarServiceExceptionFilter implements ExceptionFilter {
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
requestId,
|
||||
...userContext,
|
||||
});
|
||||
|
||||
response.status(400).json({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { extractUserContext } from "@/lib/extract-user-context";
|
||||
import { filterReqHeaders } from "@/lib/filterReqHeaders";
|
||||
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Logger } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
@@ -16,6 +17,7 @@ export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
|
||||
const statusCode = exception.getStatus();
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
const userContext = extractUserContext(request);
|
||||
this.logger.error(`Http Exception Filter: ${exception?.message}`, {
|
||||
exception,
|
||||
body: request.body,
|
||||
@@ -23,6 +25,7 @@ export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
requestId,
|
||||
...userContext,
|
||||
});
|
||||
|
||||
response.status(statusCode).json({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { extractUserContext } from "@/lib/extract-user-context";
|
||||
import { filterReqHeaders } from "@/lib/filterReqHeaders";
|
||||
import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
|
||||
import { Catch, HttpStatus, Logger } from "@nestjs/common";
|
||||
@@ -43,6 +44,7 @@ export class PrismaExceptionFilter implements ExceptionFilter {
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
|
||||
const userContext = extractUserContext(request);
|
||||
this.logger.error(`PrismaError: ${error.message}`, {
|
||||
error,
|
||||
body: request.body,
|
||||
@@ -50,6 +52,7 @@ export class PrismaExceptionFilter implements ExceptionFilter {
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
requestId,
|
||||
...userContext,
|
||||
});
|
||||
|
||||
let message = "There was an error, please try again later.";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { extractUserContext } from "@/lib/extract-user-context";
|
||||
import { filterReqHeaders } from "@/lib/filterReqHeaders";
|
||||
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
@@ -120,6 +121,7 @@ export class TRPCExceptionFilter implements ExceptionFilter {
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
|
||||
const userContext = extractUserContext(request);
|
||||
this.logger.error(`TRPC Exception Filter: ${exception?.message}`, {
|
||||
exception,
|
||||
body: request.body,
|
||||
@@ -127,6 +129,7 @@ export class TRPCExceptionFilter implements ExceptionFilter {
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
requestId,
|
||||
...userContext,
|
||||
});
|
||||
|
||||
response.status(statusCode).json({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { extractUserContext } from "@/lib/extract-user-context";
|
||||
import { filterReqHeaders } from "@/lib/filterReqHeaders";
|
||||
import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
|
||||
import { Catch, HttpStatus, Logger } from "@nestjs/common";
|
||||
@@ -18,6 +19,7 @@ export class ZodExceptionFilter implements ExceptionFilter {
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
|
||||
const userContext = extractUserContext(request);
|
||||
this.logger.error(`ZodError: ${error.message}`, {
|
||||
error,
|
||||
body: request.body,
|
||||
@@ -25,6 +27,7 @@ export class ZodExceptionFilter implements ExceptionFilter {
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
requestId,
|
||||
...userContext,
|
||||
});
|
||||
|
||||
response.status(HttpStatus.BAD_REQUEST).json({
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Request } from "express";
|
||||
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
|
||||
import { ApiAuthGuardUser } from "../modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
|
||||
export interface UserContext {
|
||||
userId?: string;
|
||||
userEmail?: string;
|
||||
organizationId?: string;
|
||||
teamId?: string;
|
||||
userOrgId?: string;
|
||||
}
|
||||
|
||||
export function extractUserContext(request: Request): UserContext {
|
||||
const context: UserContext = {};
|
||||
|
||||
const user = (request as any).user as ApiAuthGuardUser | undefined;
|
||||
if (user) {
|
||||
context.userId = String(user.id);
|
||||
context.userEmail = user.email;
|
||||
if (user.profiles?.[0]?.organizationId) {
|
||||
context.userOrgId = String(user.profiles?.[0].organizationId);
|
||||
}
|
||||
}
|
||||
|
||||
const organizationId = (request as any).organizationId as number | undefined;
|
||||
if (organizationId) {
|
||||
context.organizationId = String(organizationId);
|
||||
}
|
||||
|
||||
const team = (request as any).team as Team | undefined;
|
||||
if (team) {
|
||||
context.teamId = String(team.id);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { extractUserContext } from "@/lib/extract-user-context";
|
||||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor, Logger } from "@nestjs/common";
|
||||
import { Request, Response } from "express";
|
||||
import { tap } from "rxjs/operators";
|
||||
@@ -14,6 +15,7 @@ export class ResponseInterceptor implements NestInterceptor {
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
const { method, url } = request;
|
||||
const userContext = extractUserContext(request);
|
||||
const startTime = Date.now();
|
||||
|
||||
return next.handle().pipe(
|
||||
@@ -39,6 +41,7 @@ export class ResponseInterceptor implements NestInterceptor {
|
||||
responseTime,
|
||||
responseBody: jsonBodyString,
|
||||
timestamp: new Date().toISOString(),
|
||||
...userContext,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
@@ -69,7 +69,7 @@ export class OrganizationsRepository {
|
||||
return id;
|
||||
}
|
||||
|
||||
async findTeamIdFromClientId(clientId: string) {
|
||||
async findTeamIdAndSlugFromClientId(clientId: string) {
|
||||
return this.dbRead.prisma.team.findFirstOrThrow({
|
||||
where: {
|
||||
platformOAuthClient: {
|
||||
@@ -80,6 +80,7 @@ export class OrganizationsRepository {
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import dayjs from "@calcom/dayjs";
|
||||
// TODO: Use browser locale, implement Intl in Dayjs maybe?
|
||||
import "@calcom/dayjs/locales";
|
||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||
import { MeetingSessionDetailsDialog } from "@calcom/features/ee/video/MeetingSessionDetailsDialog";
|
||||
import ViewRecordingsDialog from "@calcom/features/ee/video/ViewRecordingsDialog";
|
||||
import { formatTime } from "@calcom/lib/dayjs";
|
||||
import { getPaymentAppData } from "@calcom/lib/getPaymentAppData";
|
||||
@@ -118,6 +119,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
||||
const [chargeCardDialogIsOpen, setChargeCardDialogIsOpen] = useState(false);
|
||||
const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState<boolean>(false);
|
||||
const [meetingSessionDetailsDialogIsOpen, setMeetingSessionDetailsDialogIsOpen] = useState<boolean>(false);
|
||||
const [isNoShowDialogOpen, setIsNoShowDialogOpen] = useState<boolean>(false);
|
||||
const cardCharged = booking?.payment[0]?.success;
|
||||
|
||||
@@ -465,7 +467,10 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
!booking.isRecorded &&
|
||||
(!booking.location || booking.location === "integrations:daily" || booking?.location?.trim() === "");
|
||||
|
||||
const showRecordingActions: ActionType[] = [
|
||||
const isCalVideoLocation =
|
||||
!booking.location || booking.location === "integrations:daily" || booking?.location?.trim() === "";
|
||||
|
||||
const videoOptionsActions: ActionType[] = [
|
||||
{
|
||||
id: "view_recordings",
|
||||
label: showCheckRecordingButton ? t("check_for_recordings") : t("view_recordings"),
|
||||
@@ -475,6 +480,14 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
color: showCheckRecordingButton ? "secondary" : "primary",
|
||||
disabled: mutation.isPending,
|
||||
},
|
||||
{
|
||||
id: "meeting_session_details",
|
||||
label: t("get_meeting_session_details"),
|
||||
onClick: () => {
|
||||
setMeetingSessionDetailsDialogIsOpen(true);
|
||||
},
|
||||
disabled: mutation.isPending,
|
||||
},
|
||||
];
|
||||
|
||||
const showPendingPayment = paymentAppData.enabled && booking.payment.length && !booking.paid;
|
||||
@@ -516,7 +529,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
paymentCurrency={booking.payment[0].currency}
|
||||
/>
|
||||
)}
|
||||
{(showViewRecordingsButton || showCheckRecordingButton) && (
|
||||
{isCalVideoLocation && (
|
||||
<ViewRecordingsDialog
|
||||
booking={booking}
|
||||
isOpenDialog={viewRecordingsDialogIsOpen}
|
||||
@@ -524,6 +537,14 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
timeFormat={userTimeFormat ?? null}
|
||||
/>
|
||||
)}
|
||||
{isCalVideoLocation && meetingSessionDetailsDialogIsOpen && (
|
||||
<MeetingSessionDetailsDialog
|
||||
booking={booking}
|
||||
isOpenDialog={meetingSessionDetailsDialogIsOpen}
|
||||
setIsOpenDialog={setMeetingSessionDetailsDialogIsOpen}
|
||||
timeFormat={userTimeFormat ?? null}
|
||||
/>
|
||||
)}
|
||||
{isNoShowDialogOpen && (
|
||||
<NoShowAttendeesDialog
|
||||
bookingUid={booking.uid}
|
||||
@@ -714,8 +735,10 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
) : null}
|
||||
{isBookingInPast && isPending && !isConfirmed ? <TableActions actions={bookedActions} /> : null}
|
||||
{isBookingInPast && isConfirmed ? <TableActions actions={bookedActions} /> : null}
|
||||
{(showViewRecordingsButton || showCheckRecordingButton) && (
|
||||
<TableActions actions={showRecordingActions} />
|
||||
{isCalVideoLocation && (
|
||||
<TableActions
|
||||
actions={[{ id: "video_options", label: t("video_options"), actions: videoOptionsActions }]}
|
||||
/>
|
||||
)}
|
||||
{isCancelled && booking.rescheduled && (
|
||||
<div className="hidden h-full items-center md:flex">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN_CLIENT,
|
||||
|
||||
sampleRate: parseFloat(process.env.SENTRY_SAMPLE_RATE ?? "1.0") || 1.0,
|
||||
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
|
||||
|
||||
@@ -117,8 +117,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
// A booking that has been rescheduled to a new booking will also have a status of CANCELLED
|
||||
const isDisabledRescheduling = booking.eventType?.disableRescheduling;
|
||||
// This comes from query param and thus is considered forced
|
||||
const canRescheduleCancelledBooking =
|
||||
isForcedRescheduleForCancelledBooking || booking.eventType?.allowReschedulingCancelledBookings;
|
||||
const canBookThroughCancelledBookingRescheduleLink = booking.eventType?.allowReschedulingCancelledBookings;
|
||||
const isNonRescheduleableBooking =
|
||||
booking.status === BookingStatus.CANCELLED || booking.status === BookingStatus.REJECTED;
|
||||
|
||||
@@ -131,8 +130,9 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
};
|
||||
}
|
||||
|
||||
if (isNonRescheduleableBooking) {
|
||||
const canReschedule = booking.status === BookingStatus.CANCELLED && canRescheduleCancelledBooking;
|
||||
if (isNonRescheduleableBooking && !isForcedRescheduleForCancelledBooking) {
|
||||
const canReschedule =
|
||||
booking.status === BookingStatus.CANCELLED && canBookThroughCancelledBookingRescheduleLink;
|
||||
return {
|
||||
redirect: {
|
||||
destination: canReschedule ? eventUrl : `/booking/${uid}`,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
MostCancelledBookingsTables,
|
||||
PopularEventsTable,
|
||||
RecentFeedbackTable,
|
||||
TimezoneBadge,
|
||||
} from "@calcom/features/insights/components";
|
||||
import "@calcom/features/insights/components/tremor.css";
|
||||
import { InsightsOrgTeamsProvider } from "@calcom/features/insights/context/InsightsOrgTeamsProvider";
|
||||
@@ -62,6 +63,7 @@ function InsightsPageContent() {
|
||||
<div className="grow" />
|
||||
<Download />
|
||||
<DateRangeFilter column={createdAtColumn} />
|
||||
<TimezoneBadge />
|
||||
</div>
|
||||
|
||||
<div className="my-4 space-y-4">
|
||||
|
||||
@@ -652,8 +652,9 @@ const ProfileForm = ({
|
||||
/>
|
||||
</div>
|
||||
{extraField}
|
||||
<p className="text-subtle mt-1 flex items-center gap-1 text-sm">
|
||||
<Icon name="info" /> {t("tip_username_plus")}
|
||||
<p className="text-subtle mt-1 flex gap-1 text-sm">
|
||||
<Icon name="info" className="mt-0.5 flex-shrink-0" />
|
||||
<span className="flex-1">{t("tip_username_plus")}</span>
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<TextField label={t("full_name")} {...formMethods.register("name")} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.4.9",
|
||||
"version": "5.4.13",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
||||
|
||||
import * as twilio from "@calcom/features/ee/workflows/lib/reminders/providers/twilioProvider";
|
||||
import { IS_SMS_CREDITS_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
|
||||
import { getPublishedOrgIdFromMemberOrTeamId } from "@calcom/lib/getOrgIdFromMemberOrTeamId";
|
||||
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -120,7 +120,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
}
|
||||
|
||||
if (!orgId) {
|
||||
orgId = await getOrgIdFromMemberOrTeamId({
|
||||
orgId = await getPublishedOrgIdFromMemberOrTeamId({
|
||||
...(!parsedTeamId ? { memberId: parsedUserId } : {}),
|
||||
teamId: parsedTeamId,
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function Router({ form, message, errorMessage }: inferSSRProps<ty
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{form.name} | Cal.com Forms</title>
|
||||
<title>{form?.name} | Cal.com Forms</title>
|
||||
</Head>
|
||||
<div className="mx-auto my-0 max-w-3xl md:my-24">
|
||||
<div className="w-full max-w-4xl ltr:mr-2 rtl:ml-2">
|
||||
|
||||
@@ -48,6 +48,21 @@
|
||||
"calvideo_settings_description": "Configure video meeting preferences including recording permissions for hosts and attendees.",
|
||||
"disable_recording_for_guests": "Disable recording for guests",
|
||||
"enable_automatic_transcription": "Enable automatic transcription after joining the meeting",
|
||||
"video_options": "Video Options",
|
||||
"get_meeting_session_details": "Get Meeting Session Details",
|
||||
"meeting_session_details": "Meeting Session Details",
|
||||
"meeting_session": "Meeting Session",
|
||||
"session_id": "Session ID",
|
||||
"max_participants": "Max Participants",
|
||||
"unlimited": "Unlimited",
|
||||
"ongoing": "Ongoing",
|
||||
"ended": "Ended",
|
||||
"participants": "Participants",
|
||||
"anonymous_user": "Anonymous User",
|
||||
"session_duration": "Session Duration",
|
||||
"minutes": "minutes",
|
||||
"no_meeting_sessions_found": "No meeting sessions found",
|
||||
"not_available": "Not available",
|
||||
"disable_recording_for_organizer": "Disable recording for organizer",
|
||||
"disable_transcription_for_guests": "Disable transcription for guests",
|
||||
"disable_transcription_for_organizer": "Disable transcription for organizer",
|
||||
@@ -3267,5 +3282,6 @@
|
||||
"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",
|
||||
"timezone_mismatch_tooltip": "You are viewing the report based on your profile timezone ({{userTimezone}}), while your browser is set to timezone ({{browserTimezone}})",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
import { wrapGetServerSidePropsWithSentry } from "@sentry/nextjs";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import { getRoutedUrl } from "@calcom/lib/server/getRoutedUrl";
|
||||
import { getRoutedUrl, hasEmbedPath } from "@calcom/lib/server/getRoutedUrl";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
export const getServerSideProps = wrapGetServerSidePropsWithSentry(async function getServerSideProps(
|
||||
context: GetServerSidePropsContext
|
||||
) {
|
||||
return await getRoutedUrl(context);
|
||||
try {
|
||||
return await getRoutedUrl(context);
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError && error.code === "TOO_MANY_REQUESTS") {
|
||||
context.res.statusCode = 429;
|
||||
const isEmbed = hasEmbedPath(context.req.url || "");
|
||||
|
||||
return {
|
||||
props: {
|
||||
isEmbed,
|
||||
form: null,
|
||||
message: null,
|
||||
errorMessage: error.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
"/router");
|
||||
|
||||
+3170
-765
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -2,15 +2,34 @@ import { oauth2_v2 } from "@googleapis/oauth2";
|
||||
import type { OAuth2Client } from "googleapis-common";
|
||||
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { uploadAvatar } from "@calcom/lib/server/avatar";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
|
||||
|
||||
export async function updateProfilePhotoGoogle(oAuth2Client: OAuth2Client, userId: number) {
|
||||
try {
|
||||
const oauth2 = new oauth2_v2.Oauth2({ auth: oAuth2Client });
|
||||
const userDetails = await oauth2.userinfo.get();
|
||||
if (userDetails.data?.picture) {
|
||||
await UserRepository.updateAvatar({ id: userId, avatarUrl: userDetails.data.picture });
|
||||
const avatarUrl = userDetails.data?.picture;
|
||||
if (!avatarUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle base64 data
|
||||
if (
|
||||
avatarUrl.startsWith("data:image/png;base64,") ||
|
||||
avatarUrl.startsWith("data:image/jpeg;base64,") ||
|
||||
avatarUrl.startsWith("data:image/jpg;base64,")
|
||||
) {
|
||||
const resizedAvatarUrl = await uploadAvatar({
|
||||
avatar: await resizeBase64Image(avatarUrl),
|
||||
userId,
|
||||
});
|
||||
await UserRepository.updateAvatar({ id: userId, avatarUrl: resizedAvatarUrl });
|
||||
return;
|
||||
}
|
||||
|
||||
await UserRepository.updateAvatar({ id: userId, avatarUrl });
|
||||
} catch (error) {
|
||||
logger.error("Error updating avatarUrl from google calendar connect", error);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,28 @@ import {
|
||||
ZGetMeetingTokenResponseSchema,
|
||||
} from "./types";
|
||||
|
||||
const meetingParticipantSchema = z.object({
|
||||
user_id: z.string().nullable(),
|
||||
participant_id: z.string(),
|
||||
user_name: z.string().nullable(),
|
||||
join_time: z.number(),
|
||||
duration: z.number(),
|
||||
});
|
||||
|
||||
const meetingSessionSchema = z.object({
|
||||
id: z.string(),
|
||||
room: z.string(),
|
||||
start_time: z.number(),
|
||||
duration: z.number(),
|
||||
ongoing: z.boolean(),
|
||||
max_participants: z.number(),
|
||||
participants: z.array(meetingParticipantSchema),
|
||||
});
|
||||
|
||||
const getMeetingInformationResponseSchema = z.object({
|
||||
data: z.array(meetingSessionSchema),
|
||||
});
|
||||
|
||||
export interface DailyEventResult {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -501,6 +523,17 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
||||
throw new Error(`Something went wrong! Unable to checkIfRoomNameMatchesInRecording. ${err}`);
|
||||
}
|
||||
},
|
||||
getMeetingInformation: async (roomName: string) => {
|
||||
try {
|
||||
const res = await fetcher(`/meetings?room=${encodeURIComponent(roomName)}`).then(
|
||||
getMeetingInformationResponseSchema.parse
|
||||
);
|
||||
return res;
|
||||
} catch (err) {
|
||||
console.error("err", err);
|
||||
throw new Error("Something went wrong! Unable to get meeting information");
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,12 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import GoogleCalendarService from "@calcom/app-store/googlecalendar/lib/CalendarService";
|
||||
import { renewSelectedCalendarCredentialId } from "@calcom/lib/connectedCalendar";
|
||||
import { GOOGLE_CALENDAR_SCOPES, WEBAPP_URL, WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
|
||||
import {
|
||||
GOOGLE_CALENDAR_SCOPES,
|
||||
SCOPE_USERINFO_PROFILE,
|
||||
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";
|
||||
@@ -14,6 +19,7 @@ import { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
|
||||
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
|
||||
import { updateProfilePhotoGoogle } from "../../_utils/oauth/updateProfilePhotoGoogle";
|
||||
import { getGoogleAppKeys } from "../lib/getGoogleAppKeys";
|
||||
|
||||
async function getHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
@@ -95,10 +101,9 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
}
|
||||
|
||||
// Only attempt to update the user's profile photo if the user has granted the required scope
|
||||
// TODO: Use the avatarUrl when setting the profile picture
|
||||
// if (grantedScopes.includes(SCOPE_USERINFO_PROFILE)) {
|
||||
// await updateProfilePhotoGoogle(oAuth2Client, req.session.user.id);
|
||||
// }
|
||||
if (grantedScopes.includes(SCOPE_USERINFO_PROFILE)) {
|
||||
await updateProfilePhotoGoogle(oAuth2Client, req.session.user.id);
|
||||
}
|
||||
|
||||
const selectedCalendarWhereUnique = {
|
||||
userId: req.session.user.id,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { RetryableError } from "@calcom/lib/crmManager/errors";
|
||||
import { checkIfFreeEmailDomain } from "@calcom/lib/freeEmailDomainCheck/checkIfFreeEmailDomain";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { AssignmentReasonRepository } from "@calcom/lib/server/repository/assignmentReason";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { CalendarEvent, CalEventResponses } from "@calcom/types/Calendar";
|
||||
import type { CredentialPayload } from "@calcom/types/Credential";
|
||||
@@ -1244,6 +1245,16 @@ export default class SalesforceCRMService implements CRM {
|
||||
return;
|
||||
}
|
||||
valueToWrite = await this.getTextValueFromBookingTracking(fieldValue, bookingUid);
|
||||
} else if (fieldValue === "{assignmentReason}") {
|
||||
if (!bookingUid) {
|
||||
log.error(`BookingUid not passed. Cannot get assignment reason without it`);
|
||||
return;
|
||||
}
|
||||
valueToWrite = await this.getAssignmentReason(bookingUid);
|
||||
if (!valueToWrite) {
|
||||
log.error(`No assignment reason found for bookingUid ${bookingUid}`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Get the value from the booking response
|
||||
if (!calEventResponses) {
|
||||
@@ -1336,6 +1347,11 @@ export default class SalesforceCRMService implements CRM {
|
||||
return tracking[`utm_${utmParam}` as keyof typeof tracking]?.toString() ?? "";
|
||||
}
|
||||
|
||||
private async getAssignmentReason(bookingId: string) {
|
||||
const assignmentReason = await AssignmentReasonRepository.findLatestReasonFromBookingUid(bookingId);
|
||||
return assignmentReason?.reasonString ?? "";
|
||||
}
|
||||
|
||||
private getTextValueFromBookingResponse(fieldValue: string, calEventResponses: CalEventResponses) {
|
||||
const regexValueToReplace = /\{(.*?)\}/g;
|
||||
return fieldValue.replace(regexValueToReplace, (match, captured) => {
|
||||
|
||||
@@ -155,6 +155,18 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
||||
};
|
||||
};
|
||||
|
||||
// Zoom agenda field has a 2000 character limit; we set maxLength to 1900 to leave a safety buffer
|
||||
const truncateAgenda = (description?: string | null) => {
|
||||
if (!description) return description;
|
||||
|
||||
const maxLength = 1900;
|
||||
const trimmed = description.trimEnd();
|
||||
if (trimmed.length > maxLength) {
|
||||
return `${trimmed.substring(0, maxLength).trimEnd()}...`;
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const userSettings = await getUserSettings();
|
||||
const recurrence = getRecurrence(event);
|
||||
// Documentation at: https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate
|
||||
@@ -166,7 +178,7 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
||||
//schedule_for: "string", TODO: Used when scheduling the meeting for someone else (needed?)
|
||||
timezone: event.organizer.timeZone,
|
||||
password: userSettings?.schedule_meeting?.default_password_for_scheduled_meetings ?? undefined,
|
||||
agenda: event.description,
|
||||
agenda: truncateAgenda(event.description),
|
||||
settings: {
|
||||
host_video: true,
|
||||
participant_video: true,
|
||||
|
||||
@@ -4,6 +4,7 @@ import "dayjs/locale/cs";
|
||||
import "dayjs/locale/de";
|
||||
import "dayjs/locale/es";
|
||||
import "dayjs/locale/es-mx";
|
||||
import "dayjs/locale/fi";
|
||||
import "dayjs/locale/fr";
|
||||
import "dayjs/locale/he";
|
||||
import "dayjs/locale/hu";
|
||||
|
||||
+52
-13
@@ -166,8 +166,8 @@ For example, if your page is `https.example.com/contact`, you can enable logging
|
||||
#### Routing Prerendering System
|
||||
The prerendering system optimizes the initial load:
|
||||
```typescript
|
||||
Cal.prerender({
|
||||
calLink: "organization/event-type",
|
||||
Cal("prerender", {
|
||||
calLink,
|
||||
type: "modal"
|
||||
});
|
||||
```
|
||||
@@ -178,24 +178,63 @@ Key aspects:
|
||||
- Loads the booking page but doesn't send the slots availability request
|
||||
- Tries to reuse whenever it makes sense and do a fresh load otherwise
|
||||
|
||||
Iframe Reuse and Reload Conditions. There could be three situations:
|
||||
Modal's Iframe Reuse and Reload Conditions. There could be four situations:
|
||||
|
||||
1. Reuse
|
||||
2. Reuse the iframe but refetch the slots
|
||||
3. Do a fresh load in iframe
|
||||
1. Show modal as is. No connect and no requests happen - Corresponding action is "noAction"
|
||||
2. Connect but don't fetch the slots- Corresponding action is "connect-no-slots-fetch"
|
||||
3. Connect and fetch the slots - Corresponding action is "connect"
|
||||
4. Do a fresh load in iframe. Corresponding action is "fullReload"
|
||||
|
||||
- **Reuse**:
|
||||
- Modal opens when
|
||||
- Modal is not in a failed state
|
||||
- config, params are same as the last time
|
||||
- No threshold violations
|
||||
- **Show modal as is**:
|
||||
- Modal is not in a failed state
|
||||
- config, params are same as the last time
|
||||
- No time threshold violations
|
||||
|
||||
- **Reuse the iframe but refetch the slots**:
|
||||
- **Connect but don't fetch the slots**:
|
||||
- Only embed `config` changes (handled via "connect" flow)
|
||||
- Query query params changes (handled via "connect" flow)
|
||||
- Crossed slots stale time threshold (EMBED_MODAL_IFRAME_SLOT_STALE_TIME)
|
||||
|
||||
- **Fresh Reload Conditions**:
|
||||
- **Connect and fetch the slots**:
|
||||
- Slots are stale
|
||||
- Crossed slots stale time threshold (EMBED_MODAL_IFRAME_SLOT_STALE_TIME)
|
||||
|
||||
- **Do a fresh load in iframe**:
|
||||
- Different path being loaded(i.e. /pro vs /free)
|
||||
- Modal is in a failed state
|
||||
- Time since last render exceeds EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS
|
||||
|
||||
|
||||
#### Prerendering headless router
|
||||
|
||||
**Without namespace:**
|
||||
Prerender when there are high chances of user clicking the CTA.
|
||||
```js
|
||||
Cal('prerender', {
|
||||
calLink: "router?formId=123&ONLY_THOSE_FIELDS_THAT_ARE_REQUIRED_BY_ROUTING_RULES_SHOULD_BE_PRESENT_HERE",
|
||||
// Prerender right now works only with "modal", so 'element click' embed is able to reuse this prerendered iframe
|
||||
type: "modal",
|
||||
// Shows skeleton loader for a Team Event's booking slots page
|
||||
pageType: "team.event.booking.slots"
|
||||
});
|
||||
```
|
||||
Using the prerendered iframe with a CTA:
|
||||
```js
|
||||
<button data-cal-link="router?formId=123&ALL_FIELDS_HERE">Demo</button>
|
||||
```
|
||||
|
||||
**With namespace:**
|
||||
Prerender when there are high changes of user clicking the CTA.
|
||||
```js
|
||||
Cal.ns.myNamespace('prerender', {
|
||||
calLink: "router?formId=123&ONLY_THOSE_FIELDS_THAT_ARE_REQUIRED_BY_ROUTING_RULES_SHOULD_BE_PRESENT_HERE",
|
||||
// Prerender right now works only with "modal", so 'element click' embed is able to reuse this prerendered iframe
|
||||
type: "modal"
|
||||
// Shows skeleton loader for a Team Event's booking slots page
|
||||
pageType: "team.event.booking.slots"
|
||||
});
|
||||
```
|
||||
Using the prerendered iframe with a CTA:
|
||||
```js
|
||||
<button data-cal-namespace="myNamespace" data-cal-link="router?formId=123&ALL_FIELDS_HERE">Demo</button>
|
||||
```
|
||||
@@ -4,6 +4,8 @@ export const EMBED_MODAL_IFRAME_SLOT_STALE_TIME = 60 * 1000; // 1 minute
|
||||
// Keep it high by default to avoid impact on loading time as it would cause a full reload of the iframe user can configure it via options.iframeForceReloadThresholdMs
|
||||
// Also full reload means that response would be resubmitted.
|
||||
export const EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes
|
||||
// Ensures that prerender instruction would never re-prerender within one minute as long as the link is the same.
|
||||
export const EMBED_MODAL_PRERENDER_PREVENT_THRESHOLD_MS = 1 * 60 * 1000; // 1 minute
|
||||
|
||||
if (EMBED_MODAL_IFRAME_SLOT_STALE_TIME > EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS) {
|
||||
throw new Error(
|
||||
|
||||
@@ -5,22 +5,13 @@ import { describe, it, expect, beforeEach, vi, beforeAll } from "vitest";
|
||||
import {
|
||||
EMBED_MODAL_IFRAME_SLOT_STALE_TIME,
|
||||
EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS,
|
||||
EMBED_MODAL_PRERENDER_PREVENT_THRESHOLD_MS,
|
||||
} from "./constants";
|
||||
import { submitResponseAndGetRoutingResult } from "./utils";
|
||||
|
||||
vi.mock("./tailwindCss", () => ({
|
||||
default: "mockedTailwindCss",
|
||||
}));
|
||||
|
||||
vi.mock("./utils", async () => {
|
||||
const actual = (await vi.importActual("./utils")) as any;
|
||||
|
||||
return {
|
||||
...actual,
|
||||
submitResponseAndGetRoutingResult: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
type ExpectedModalBoxAttrs = {
|
||||
theme: string;
|
||||
layout: string;
|
||||
@@ -335,6 +326,7 @@ describe("Cal", () => {
|
||||
__prerender: true,
|
||||
};
|
||||
calInstance.api.modal(modalArg);
|
||||
expect(calInstance.isPrerendering).toBe(true);
|
||||
|
||||
const modalBox = expectCalModalBoxToBeInDocument({
|
||||
state: "prerendering",
|
||||
@@ -399,11 +391,82 @@ describe("Cal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("Accidental repeat prerender prevention", async () => {
|
||||
const modalArg = {
|
||||
...baseModalArgs,
|
||||
calLink: "router?form=123&email=john@example.com",
|
||||
};
|
||||
|
||||
const { status: status1 } = await calInstance.api.modal({
|
||||
...modalArg,
|
||||
__prerender: true,
|
||||
});
|
||||
expect(status1).toBe("created");
|
||||
const embedRenderStartTime = calInstance.embedRenderStartTime;
|
||||
|
||||
const { status: status2 } = await calInstance.api.modal({
|
||||
...modalArg,
|
||||
__prerender: true,
|
||||
});
|
||||
expect(status2).toBe("prerender-prevented");
|
||||
// embedRenderStartTime should not be updated prerender did not happen
|
||||
expect(calInstance.embedRenderStartTime).toBe(embedRenderStartTime);
|
||||
});
|
||||
|
||||
it("should allow repeat prerender after threshold time has passed", async () => {
|
||||
vi.useFakeTimers();
|
||||
const modalArg = {
|
||||
...baseModalArgs,
|
||||
calLink: "router?form=123&email=john@example.com",
|
||||
};
|
||||
|
||||
// First prerender
|
||||
const { status: status1 } = await calInstance.api.modal({
|
||||
...modalArg,
|
||||
__prerender: true,
|
||||
});
|
||||
expect(status1).toBe("created");
|
||||
|
||||
// Mock time passage beyond threshold
|
||||
vi.advanceTimersByTime(EMBED_MODAL_PRERENDER_PREVENT_THRESHOLD_MS + 1000);
|
||||
|
||||
// Second prerender after threshold - should be ALLOWED
|
||||
const { status: status2 } = await calInstance.api.modal({
|
||||
...modalArg,
|
||||
__prerender: true,
|
||||
});
|
||||
|
||||
expect(status2).toBe("created");
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("Prerender with non-headless router link and then CTA click with headless router is not allowed", async () => {
|
||||
const modalArg = {
|
||||
...baseModalArgs,
|
||||
calLink: "john-doe/meeting",
|
||||
};
|
||||
|
||||
const { status: status1 } = await calInstance.api.modal({
|
||||
...modalArg,
|
||||
__prerender: true,
|
||||
});
|
||||
expect(status1).toBe("created");
|
||||
|
||||
await expect(
|
||||
calInstance.api.modal({
|
||||
...modalArg,
|
||||
calLink: "router?form=123&email=john@example.com",
|
||||
})
|
||||
).rejects.toThrow("`prerender` instruction should have been fired with headless router path");
|
||||
});
|
||||
|
||||
describe("Modal State Transitions", () => {
|
||||
it(`should handle prerender -> open(with prefill) -> reopen scenario`, () => {
|
||||
it(`should handle prerender -> open(with prefill) -> reopen(with re-submission) scenario`, () => {
|
||||
// Prerender the modal
|
||||
const modalArg = {
|
||||
...baseModalArgs,
|
||||
calLink: "router?form=FORM_ID&routingField1=value1&routingField2=value2",
|
||||
};
|
||||
|
||||
const { modalBoxUid, expectedConfig } = (function prerender(): {
|
||||
@@ -412,6 +475,7 @@ describe("Cal", () => {
|
||||
} {
|
||||
log("Prerendering the modal");
|
||||
calInstance.api.modal({ ...buildModalArg(modalArg), __prerender: true });
|
||||
expect(calInstance.isPrerendering).toBe(true);
|
||||
const expectedConfig = {
|
||||
...modalArg.config,
|
||||
embedType: "modal",
|
||||
@@ -425,11 +489,14 @@ describe("Cal", () => {
|
||||
pageType: null,
|
||||
},
|
||||
expectedIframeUrlObject: {
|
||||
pathname: `/${modalArg.calLink}`,
|
||||
pathname: `${new URL(modalArg.calLink, "https://baseurl.example").pathname}`,
|
||||
searchParams: new URLSearchParams({
|
||||
...expectedConfig,
|
||||
prerender: "true",
|
||||
"cal.skipSlotsFetch": "true",
|
||||
form: "FORM_ID",
|
||||
"cal.queueFormResponse": "true",
|
||||
routingField1: "value1",
|
||||
routingField2: "value2",
|
||||
}),
|
||||
origin: null,
|
||||
},
|
||||
@@ -439,138 +506,14 @@ describe("Cal", () => {
|
||||
return { modalBoxUid, expectedConfig };
|
||||
})();
|
||||
|
||||
const { modalArgWithPrefilledConfig } = (function prefill() {
|
||||
log("Opening the modal with prefill config");
|
||||
const modalArgWithPrefilledConfig = {
|
||||
...modalArg,
|
||||
config: { ...modalArg.config, name: "John Doe", email: "john@example.com" },
|
||||
};
|
||||
// Second modal call with additional config value for prefilling
|
||||
calInstance.api.modal(modalArgWithPrefilledConfig);
|
||||
|
||||
const expectedConfigAfterPrefilling = {
|
||||
...expectedConfig,
|
||||
name: modalArgWithPrefilledConfig.config.name,
|
||||
email: modalArgWithPrefilledConfig.config.email,
|
||||
};
|
||||
|
||||
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
|
||||
expectedModalBoxAttrs: {
|
||||
// Expect the modal to go to loading state - which starts the loader animation
|
||||
state: "loading",
|
||||
theme: modalArg.config.theme,
|
||||
layout: modalArg.config.layout,
|
||||
pageType: null,
|
||||
uid: modalBoxUid,
|
||||
},
|
||||
expectedIframeUrlObject: {
|
||||
pathname: `/${modalArg.calLink}`,
|
||||
searchParams: new URLSearchParams({
|
||||
...expectedConfig,
|
||||
// It remains because internally we remove prerender=true in iframe, as it is connect mode
|
||||
prerender: "true",
|
||||
"cal.skipSlotsFetch": "true",
|
||||
}),
|
||||
origin: null,
|
||||
},
|
||||
});
|
||||
|
||||
// This would update the iframe document URL through embed-iframe.
|
||||
expect(calInstance.doInIframe).toHaveBeenCalledWith({
|
||||
method: "connect",
|
||||
arg: {
|
||||
config: {
|
||||
...expectedConfigAfterPrefilling,
|
||||
},
|
||||
params: {},
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(calInstance.doInIframe).mockClear();
|
||||
return { modalArgWithPrefilledConfig };
|
||||
})();
|
||||
|
||||
(function reopen() {
|
||||
log("Reopening the same modal without any changes");
|
||||
calInstance.api.modal(modalArgWithPrefilledConfig);
|
||||
|
||||
// Connect won't happen again
|
||||
expect(calInstance.doInIframe).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "connect",
|
||||
})
|
||||
);
|
||||
|
||||
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
|
||||
expectedModalBoxAttrs: {
|
||||
// Expect the modal to just go to "reopened" state
|
||||
state: "reopened",
|
||||
theme: modalArg.config.theme,
|
||||
layout: modalArg.config.layout,
|
||||
pageType: null,
|
||||
uid: modalBoxUid,
|
||||
},
|
||||
expectedIframeUrlObject: {
|
||||
pathname: `/${modalArg.calLink}`,
|
||||
searchParams: new URLSearchParams({
|
||||
...expectedConfig,
|
||||
// It remains because we are just reopening the same modal
|
||||
prerender: "true",
|
||||
"cal.skipSlotsFetch": "true",
|
||||
}),
|
||||
origin: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(document.querySelectorAll("cal-modal-box").length).toBe(1);
|
||||
})();
|
||||
});
|
||||
|
||||
it(`should handle prerender -> open(with prefill) -> error -> loading scenario`, () => {
|
||||
// Prerender the modal
|
||||
const modalArg = {
|
||||
...baseModalArgs,
|
||||
};
|
||||
|
||||
const { modalBoxUid, modalBox, expectedConfig } = (function prerender() {
|
||||
calInstance.api.modal({ ...buildModalArg(modalArg), __prerender: true });
|
||||
|
||||
const expectedConfig = {
|
||||
...modalArg.config,
|
||||
embedType: "modal",
|
||||
};
|
||||
|
||||
const { modalBox } = expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
|
||||
expectedModalBoxAttrs: {
|
||||
state: "prerendering",
|
||||
theme: modalArg.config.theme,
|
||||
layout: modalArg.config.layout,
|
||||
pageType: null,
|
||||
},
|
||||
expectedIframeUrlObject: {
|
||||
pathname: `/${modalArg.calLink}`,
|
||||
searchParams: new URLSearchParams({
|
||||
...expectedConfig,
|
||||
prerender: "true",
|
||||
"cal.skipSlotsFetch": "true",
|
||||
}),
|
||||
origin: null,
|
||||
},
|
||||
});
|
||||
|
||||
const modalBoxUid = modalBox.getAttribute("uid");
|
||||
return { modalBoxUid, modalBox, expectedConfig };
|
||||
})();
|
||||
|
||||
const { modalArgWithPrefilledConfig, expectedConfigAfterPrefilling } = (function prefill() {
|
||||
log("Opening the modal with prefill config");
|
||||
const modalArgWithPrefilledConfig = {
|
||||
...modalArg,
|
||||
config: { ...modalArg.config, name: "John Doe", email: "john@example.com" },
|
||||
};
|
||||
|
||||
// Second modal call with additional config value for prefilling
|
||||
calInstance.api.modal({ ...buildModalArg(modalArgWithPrefilledConfig) });
|
||||
calInstance.api.modal(modalArgWithPrefilledConfig);
|
||||
|
||||
const expectedConfigAfterPrefilling = {
|
||||
...expectedConfig,
|
||||
@@ -588,24 +531,33 @@ describe("Cal", () => {
|
||||
uid: modalBoxUid,
|
||||
},
|
||||
expectedIframeUrlObject: {
|
||||
pathname: `/${modalArg.calLink}`,
|
||||
pathname: `${new URL(modalArg.calLink, "https://baseurl.example").pathname}`,
|
||||
searchParams: new URLSearchParams({
|
||||
...expectedConfig,
|
||||
// It remains because internally we remove prerender=true in iframe - through connect flow
|
||||
// It remains because internally we remove prerender=true in iframe, as it is connect mode
|
||||
prerender: "true",
|
||||
"cal.skipSlotsFetch": "true",
|
||||
form: "FORM_ID",
|
||||
"cal.queueFormResponse": "true",
|
||||
routingField1: "value1",
|
||||
routingField2: "value2",
|
||||
}),
|
||||
origin: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Right now we allow re-submission of the same form data, for which we need to connect again
|
||||
expect(calInstance.doInIframe).toHaveBeenCalledWith({
|
||||
method: "connect",
|
||||
arg: {
|
||||
config: {
|
||||
"cal.embed.noSlotsFetchOnConnect": "true",
|
||||
...expectedConfigAfterPrefilling,
|
||||
},
|
||||
params: {},
|
||||
params: {
|
||||
routingField1: "value1",
|
||||
routingField2: "value2",
|
||||
form: "FORM_ID",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -613,412 +565,52 @@ describe("Cal", () => {
|
||||
return { modalArgWithPrefilledConfig, expectedConfigAfterPrefilling };
|
||||
})();
|
||||
|
||||
log("Simulating an error state");
|
||||
modalBox.setAttribute("state", "failed");
|
||||
|
||||
(function reopen() {
|
||||
log("Reopening the same modal but it is in error state, so full page load would be initiated");
|
||||
log("Reopening the same modal without any changes");
|
||||
calInstance.api.modal(modalArgWithPrefilledConfig);
|
||||
expect(calInstance.isPrerendering).toBe(false);
|
||||
|
||||
expect(calInstance.doInIframe).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "connect",
|
||||
})
|
||||
);
|
||||
|
||||
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
|
||||
expectedModalBoxAttrs: {
|
||||
// Expect the modal to just go to "reopened" state
|
||||
state: "loading",
|
||||
theme: modalArg.config.theme,
|
||||
layout: modalArg.config.layout,
|
||||
pageType: null,
|
||||
uid: modalBoxUid,
|
||||
},
|
||||
expectedIframeUrlObject: {
|
||||
pathname: `/${modalArg.calLink}`,
|
||||
searchParams: new URLSearchParams({
|
||||
...expectedConfigAfterPrefilling,
|
||||
}),
|
||||
origin: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(document.querySelectorAll("cal-modal-box").length).toBe(1);
|
||||
})();
|
||||
});
|
||||
|
||||
it("should handle prerender(booking link) -> headless router submission -> reuse booking link ", async () => {
|
||||
// Prerender the modal
|
||||
const modalArg = {
|
||||
...baseModalArgs,
|
||||
calLink: "sales/demo",
|
||||
};
|
||||
|
||||
const { expectedConfig } = (function prerender(): { expectedConfig: Record<string, string> } {
|
||||
log("Prerendering the modal");
|
||||
calInstance.api.modal({ ...buildModalArg(modalArg), __prerender: true });
|
||||
|
||||
const expectedConfig = {
|
||||
...modalArg.config,
|
||||
embedType: "modal",
|
||||
};
|
||||
|
||||
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
|
||||
expectedModalBoxAttrs: {
|
||||
state: "prerendering",
|
||||
theme: modalArg.config.theme,
|
||||
layout: modalArg.config.layout,
|
||||
pageType: null,
|
||||
},
|
||||
expectedIframeUrlObject: {
|
||||
pathname: `/${modalArg.calLink}`,
|
||||
searchParams: new URLSearchParams({
|
||||
...expectedConfig,
|
||||
prerender: "true",
|
||||
"cal.skipSlotsFetch": "true",
|
||||
}),
|
||||
origin: null,
|
||||
},
|
||||
});
|
||||
|
||||
return { expectedConfig };
|
||||
})();
|
||||
|
||||
vi.mocked(submitResponseAndGetRoutingResult).mockImplementation(() => {
|
||||
return Promise.resolve({
|
||||
// Redirecting to the booking link that is already prerendered
|
||||
redirect: `${process.env.EMBED_PUBLIC_WEBAPP_URL}/${modalArg.calLink}`,
|
||||
});
|
||||
});
|
||||
|
||||
await (async function headlessRouterSubmission() {
|
||||
log("Submitting the routing form headless");
|
||||
const config = expectedConfig;
|
||||
const configWithPrefilledValues = {
|
||||
...config,
|
||||
name: "John Doe",
|
||||
email: "john@example.com",
|
||||
};
|
||||
|
||||
const modalOpenPromise = calInstance.api.modal({
|
||||
...modalArg,
|
||||
calLink: "router?form=FORM_ID",
|
||||
config: configWithPrefilledValues,
|
||||
});
|
||||
|
||||
// State must immediately change to loading, so that loader is shown
|
||||
const calModalBox = document.querySelector("cal-modal-box") as HTMLElement;
|
||||
expect(calModalBox.getAttribute("state")).toBe("loading");
|
||||
await modalOpenPromise;
|
||||
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
|
||||
expectedModalBoxAttrs: {
|
||||
state: "loading",
|
||||
theme: modalArg.config.theme,
|
||||
layout: modalArg.config.layout,
|
||||
pageType: null,
|
||||
},
|
||||
expectedIframeUrlObject: {
|
||||
pathname: `/${modalArg.calLink}`,
|
||||
searchParams: new URLSearchParams({
|
||||
...config,
|
||||
// It remains because internally we remove prerender=true in iframe as it is connect mode
|
||||
prerender: "true",
|
||||
"cal.skipSlotsFetch": "true",
|
||||
}),
|
||||
origin: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Now check that we connected with the right config
|
||||
// Connect won't happen again
|
||||
expect(calInstance.doInIframe).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "connect",
|
||||
arg: {
|
||||
config: {
|
||||
...configWithPrefilledValues,
|
||||
"cal.embed.noSlotsFetchOnConnect": "true",
|
||||
embedType: "modal",
|
||||
...expectedConfigAfterPrefilling,
|
||||
},
|
||||
params: {
|
||||
routingField1: "value1",
|
||||
routingField2: "value2",
|
||||
form: "FORM_ID",
|
||||
},
|
||||
params: {},
|
||||
},
|
||||
})
|
||||
);
|
||||
})();
|
||||
});
|
||||
|
||||
it("should handle prerender(booking link) -> headless router submission -> different booking link redirect -> fresh page load", async () => {
|
||||
// Prerender the modal
|
||||
const modalArg = {
|
||||
...baseModalArgs,
|
||||
calLink: "sales/demo",
|
||||
};
|
||||
// expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
|
||||
// expectedModalBoxAttrs: {
|
||||
// // Expect the modal to just go to "reopened" state
|
||||
// state: "reopened",
|
||||
// theme: modalArg.config.theme,
|
||||
// layout: modalArg.config.layout,
|
||||
// pageType: null,
|
||||
// uid: modalBoxUid,
|
||||
// },
|
||||
// expectedIframeUrlObject: {
|
||||
// pathname: `/${modalArg.calLink}`,
|
||||
// searchParams: new URLSearchParams({
|
||||
// ...expectedConfig,
|
||||
// // It remains because we are just reopening the same modal
|
||||
// prerender: "true",
|
||||
// "cal.skipSlotsFetch": "true",
|
||||
// }),
|
||||
// origin: null,
|
||||
// },
|
||||
// });
|
||||
|
||||
const { expectedConfig } = (function prerender(): { expectedConfig: Record<string, string> } {
|
||||
log("Prerendering the modal");
|
||||
calInstance.api.modal({ ...buildModalArg(modalArg), __prerender: true });
|
||||
|
||||
const expectedConfig = {
|
||||
...modalArg.config,
|
||||
embedType: "modal",
|
||||
};
|
||||
|
||||
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
|
||||
expectedModalBoxAttrs: {
|
||||
state: "prerendering",
|
||||
theme: modalArg.config.theme,
|
||||
layout: modalArg.config.layout,
|
||||
pageType: null,
|
||||
},
|
||||
expectedIframeUrlObject: {
|
||||
pathname: `/${modalArg.calLink}`,
|
||||
searchParams: new URLSearchParams({
|
||||
...expectedConfig,
|
||||
prerender: "true",
|
||||
"cal.skipSlotsFetch": "true",
|
||||
}),
|
||||
origin: null,
|
||||
},
|
||||
});
|
||||
|
||||
return { expectedConfig };
|
||||
})();
|
||||
|
||||
const headlessRouterRedirectUrlWithPathOnly = `/something_else_than_prerendered_link`;
|
||||
vi.mocked(submitResponseAndGetRoutingResult).mockImplementation(() => {
|
||||
return Promise.resolve({
|
||||
// Redirecting to the booking link that is already prerendered
|
||||
redirect: `${process.env.EMBED_PUBLIC_WEBAPP_URL}${headlessRouterRedirectUrlWithPathOnly}?routingFormResponseId=xyz`,
|
||||
});
|
||||
});
|
||||
|
||||
await (async function headlessRouterSubmission() {
|
||||
log("Submitting the modal");
|
||||
const config = expectedConfig;
|
||||
const configWithPrefilledValues = {
|
||||
...config,
|
||||
name: "John Doe",
|
||||
email: "john@example.com",
|
||||
};
|
||||
const configWithPrefilledValuesAndHeadlessRouterRedirectParams = {
|
||||
...configWithPrefilledValues,
|
||||
routingFormResponseId: "xyz",
|
||||
};
|
||||
|
||||
const modalOpenPromise = calInstance.api.modal({
|
||||
...modalArg,
|
||||
calLink: "router?form=FORM_ID",
|
||||
config: configWithPrefilledValues,
|
||||
});
|
||||
|
||||
const calModalBox = document.querySelector("cal-modal-box") as HTMLElement;
|
||||
expect(calModalBox.getAttribute("state")).toBe("loading");
|
||||
await modalOpenPromise;
|
||||
|
||||
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
|
||||
expectedModalBoxAttrs: {
|
||||
state: "loading",
|
||||
theme: modalArg.config.theme,
|
||||
layout: modalArg.config.layout,
|
||||
pageType: null,
|
||||
},
|
||||
expectedIframeUrlObject: {
|
||||
pathname: headlessRouterRedirectUrlWithPathOnly,
|
||||
searchParams: new URLSearchParams({
|
||||
...configWithPrefilledValuesAndHeadlessRouterRedirectParams,
|
||||
}),
|
||||
origin: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Now check that we connected with the right config
|
||||
expect(calInstance.doInIframe).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "connect",
|
||||
})
|
||||
);
|
||||
})();
|
||||
});
|
||||
|
||||
it("should handle prerender(booking link) -> headless router submission -> Message ", async () => {
|
||||
// Prerender the modal
|
||||
const modalArg = {
|
||||
...baseModalArgs,
|
||||
calLink: "sales/demo",
|
||||
};
|
||||
|
||||
const { expectedConfig } = (function prerender(): { expectedConfig: Record<string, string> } {
|
||||
log("Prerendering the modal");
|
||||
calInstance.api.modal({ ...buildModalArg(modalArg), __prerender: true });
|
||||
|
||||
const expectedConfig = {
|
||||
...modalArg.config,
|
||||
embedType: "modal",
|
||||
};
|
||||
|
||||
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
|
||||
expectedModalBoxAttrs: {
|
||||
state: "prerendering",
|
||||
theme: modalArg.config.theme,
|
||||
layout: modalArg.config.layout,
|
||||
pageType: null,
|
||||
},
|
||||
expectedIframeUrlObject: {
|
||||
pathname: `/${modalArg.calLink}`,
|
||||
searchParams: new URLSearchParams({
|
||||
...expectedConfig,
|
||||
prerender: "true",
|
||||
"cal.skipSlotsFetch": "true",
|
||||
}),
|
||||
origin: null,
|
||||
},
|
||||
});
|
||||
|
||||
return { expectedConfig };
|
||||
})();
|
||||
|
||||
vi.mocked(submitResponseAndGetRoutingResult).mockImplementation(() => {
|
||||
return Promise.resolve({
|
||||
status: "success",
|
||||
// Redirecting to the booking link that is already prerendered
|
||||
message: "Fallback Route Message",
|
||||
});
|
||||
});
|
||||
|
||||
await (async function headlessRouterSubmission() {
|
||||
log("Submitting the modal");
|
||||
const config = expectedConfig;
|
||||
const configWithPrefilledValues = {
|
||||
...config,
|
||||
name: "John Doe",
|
||||
email: "john@example.com",
|
||||
};
|
||||
|
||||
const modalOpenPromise = calInstance.api.modal({
|
||||
...modalArg,
|
||||
calLink: "router?form=FORM_ID",
|
||||
config: configWithPrefilledValues,
|
||||
});
|
||||
|
||||
const calModalBox = document.querySelector("cal-modal-box") as HTMLElement;
|
||||
expect(calModalBox.getAttribute("state")).toBe("loading");
|
||||
await modalOpenPromise;
|
||||
|
||||
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
|
||||
expectedModalBoxAttrs: {
|
||||
state: "has-message",
|
||||
theme: modalArg.config.theme,
|
||||
layout: modalArg.config.layout,
|
||||
pageType: null,
|
||||
},
|
||||
expectedIframeUrlObject: {
|
||||
pathname: `/${modalArg.calLink}`,
|
||||
searchParams: new URLSearchParams({
|
||||
...expectedConfig,
|
||||
prerender: "true",
|
||||
"cal.skipSlotsFetch": "true",
|
||||
}),
|
||||
origin: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Now check that we connected with the right config
|
||||
expect(calInstance.doInIframe).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "connect",
|
||||
})
|
||||
);
|
||||
|
||||
expect(calModalBox.getAttribute("data-message")).toBe("Fallback Route Message");
|
||||
})();
|
||||
});
|
||||
|
||||
it("should handle prerender(booking link) -> headless router submission -> Error Message", async () => {
|
||||
// Prerender the modal
|
||||
const modalArg = {
|
||||
...baseModalArgs,
|
||||
calLink: "sales/demo",
|
||||
};
|
||||
|
||||
const { expectedConfig } = (function prerender(): { expectedConfig: Record<string, string> } {
|
||||
log("Prerendering the modal");
|
||||
calInstance.api.modal({ ...buildModalArg(modalArg), __prerender: true });
|
||||
|
||||
const expectedConfig = {
|
||||
...modalArg.config,
|
||||
embedType: "modal",
|
||||
};
|
||||
|
||||
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
|
||||
expectedModalBoxAttrs: {
|
||||
state: "prerendering",
|
||||
theme: modalArg.config.theme,
|
||||
layout: modalArg.config.layout,
|
||||
pageType: null,
|
||||
},
|
||||
expectedIframeUrlObject: {
|
||||
pathname: `/${modalArg.calLink}`,
|
||||
searchParams: new URLSearchParams({
|
||||
...expectedConfig,
|
||||
prerender: "true",
|
||||
"cal.skipSlotsFetch": "true",
|
||||
}),
|
||||
origin: null,
|
||||
},
|
||||
});
|
||||
|
||||
return { expectedConfig };
|
||||
})();
|
||||
|
||||
vi.mocked(submitResponseAndGetRoutingResult).mockImplementation(() => {
|
||||
return Promise.resolve({
|
||||
error: "Fallback Route Error",
|
||||
});
|
||||
});
|
||||
|
||||
await (async function headlessRouterSubmission() {
|
||||
log("Submitting the modal");
|
||||
const config = expectedConfig;
|
||||
const configWithPrefilledValues = {
|
||||
...config,
|
||||
name: "John Doe",
|
||||
email: "john@example.com",
|
||||
};
|
||||
|
||||
const modalOpenPromise = calInstance.api.modal({
|
||||
...modalArg,
|
||||
calLink: "router?form=FORM_ID",
|
||||
config: configWithPrefilledValues,
|
||||
});
|
||||
|
||||
const calModalBox = document.querySelector("cal-modal-box") as HTMLElement;
|
||||
expect(calModalBox.getAttribute("state")).toBe("loading");
|
||||
await modalOpenPromise;
|
||||
|
||||
expectCalModalBoxToBeInDocumentWithIframeHavingUrl({
|
||||
expectedModalBoxAttrs: {
|
||||
state: "failed",
|
||||
theme: modalArg.config.theme,
|
||||
layout: modalArg.config.layout,
|
||||
pageType: null,
|
||||
},
|
||||
expectedIframeUrlObject: {
|
||||
pathname: `/${modalArg.calLink}`,
|
||||
searchParams: new URLSearchParams({
|
||||
...expectedConfig,
|
||||
prerender: "true",
|
||||
"cal.skipSlotsFetch": "true",
|
||||
}),
|
||||
origin: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Now check that we connected with the right config
|
||||
expect(calInstance.doInIframe).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "connect",
|
||||
})
|
||||
);
|
||||
expect(calModalBox.getAttribute("data-error-code")).toBe("routerError");
|
||||
expect(calModalBox.getAttribute("data-message")).toBe("Fallback Route Error");
|
||||
expect(document.querySelectorAll("cal-modal-box").length).toBe(1);
|
||||
})();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,21 +6,22 @@ import { addAppCssVars } from "./addAppCssVars";
|
||||
import {
|
||||
EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS,
|
||||
EMBED_MODAL_IFRAME_SLOT_STALE_TIME,
|
||||
EMBED_MODAL_PRERENDER_PREVENT_THRESHOLD_MS,
|
||||
} from "./constants";
|
||||
import type { InterfaceWithParent, interfaceWithParent } from "./embed-iframe";
|
||||
import css from "./embed.css";
|
||||
import { SdkActionManager } from "./sdk-action-manager";
|
||||
import type { EventData, EventDataMap } from "./sdk-action-manager";
|
||||
import tailwindCss from "./tailwindCss";
|
||||
import type { UiConfig, EmbedPageType, PrefillAndIframeAttrsConfig } from "./types";
|
||||
import type { UiConfig, EmbedPageType, PrefillAndIframeAttrsConfig, ModalPrerenderOptions } from "./types";
|
||||
import { getMaxHeightForModal } from "./ui-utils";
|
||||
import {
|
||||
fromEntriesWithDuplicateKeys,
|
||||
isRouterPath,
|
||||
submitResponseAndGetRoutingResult,
|
||||
generateDataAttributes,
|
||||
getConfigProp,
|
||||
isSameBookingLink,
|
||||
buildConfigWithPrerenderRelatedFields,
|
||||
} from "./utils";
|
||||
|
||||
// Exporting for consumption by @calcom/embed-core user
|
||||
@@ -51,12 +52,8 @@ type CalConfig = {
|
||||
uiDebug?: boolean;
|
||||
};
|
||||
|
||||
type ModalPrerenderOptions = {
|
||||
slotsStaleTimeMs?: number;
|
||||
iframeForceReloadThresholdMs?: number;
|
||||
reuseFully?: boolean;
|
||||
// Default value is true right now but it would change soon
|
||||
backgroundSlotsFetch?: boolean;
|
||||
type ModalBoxElement = Element & {
|
||||
uid?: string;
|
||||
};
|
||||
|
||||
type ModalStateData = {
|
||||
@@ -221,7 +218,7 @@ export class Cal {
|
||||
|
||||
__config: CalConfig;
|
||||
|
||||
modalBox?: Element;
|
||||
modalBox?: ModalBoxElement;
|
||||
|
||||
inlineEl?: Element;
|
||||
|
||||
@@ -688,89 +685,128 @@ export class Cal {
|
||||
return new URL(`${urlObject.pathname}${urlObject.search}`, urlObject.origin);
|
||||
}
|
||||
|
||||
async submitThroughHeadlessRouterInModal({
|
||||
modal,
|
||||
calLinkUrlObject,
|
||||
stateData,
|
||||
}: {
|
||||
modal: { uid: string; element: Element; calOrigin: string | null };
|
||||
calLinkUrlObject: URL;
|
||||
stateData: ModalStateData;
|
||||
}) {
|
||||
const { uid: modalBoxUid, element: modalEl, calOrigin: _calOrigin } = modal;
|
||||
const { embedConfig } = stateData;
|
||||
// We record the updated params on dataset.calLink which allows us to determine if something actually changed since the last time we loaded
|
||||
// This is used by getNextActionForModal
|
||||
recordUpdatedParamsForIframe(params: Record<string, string | string[]>) {
|
||||
const lastLoadedUrlInIframeObject = this.getLastLoadedLinkInframe();
|
||||
const lastLoadedPathInIframe = lastLoadedUrlInIframeObject?.pathname ?? null;
|
||||
const calConfig = this.getCalConfig();
|
||||
const calOrigin = _calOrigin ?? calConfig.calOrigin;
|
||||
|
||||
const headlessRouterPageObject = calLinkUrlObject;
|
||||
const result = await submitResponseAndGetRoutingResult({
|
||||
headlessRouterPageUrl: headlessRouterPageObject.toString(),
|
||||
});
|
||||
log("Headless router result", result);
|
||||
if ("redirect" in result) {
|
||||
const routerRedirectUrl = new URL(result.redirect);
|
||||
const paramsFromRedirect = fromEntriesWithDuplicateKeys(routerRedirectUrl.searchParams.entries());
|
||||
|
||||
const newEmbedConfig = (this.embedConfig = {
|
||||
...embedConfig,
|
||||
...paramsFromRedirect,
|
||||
});
|
||||
const actionToTake = this.getNextActionForModal({
|
||||
modal: { uid: modalBoxUid },
|
||||
pathWithQueryToLoad: `${routerRedirectUrl.pathname}${routerRedirectUrl.search}`,
|
||||
stateData: {
|
||||
...stateData,
|
||||
embedConfig: newEmbedConfig,
|
||||
},
|
||||
});
|
||||
if (actionToTake === "fullReload") {
|
||||
if (lastLoadedPathInIframe) {
|
||||
console.error("Preloaded iframe couldn't be used", {
|
||||
preloadedPath: lastLoadedPathInIframe,
|
||||
newPath: routerRedirectUrl.pathname,
|
||||
});
|
||||
}
|
||||
const pathWithoutStartingSlash = routerRedirectUrl.pathname.replace(/^\//, "");
|
||||
this.loadInIframe({
|
||||
calLink: pathWithoutStartingSlash,
|
||||
calOrigin,
|
||||
config: newEmbedConfig,
|
||||
iframe: this.iframe as HTMLIFrameElement,
|
||||
});
|
||||
} else {
|
||||
log("Reusing the same iframe for headless router", {
|
||||
routerRedirectUrl: routerRedirectUrl.toString(),
|
||||
lastLoadedPathInIframe,
|
||||
});
|
||||
|
||||
// Connection Initiated
|
||||
this.doInIframe({
|
||||
method: "connect",
|
||||
arg: {
|
||||
config: newEmbedConfig,
|
||||
params: fromEntriesWithDuplicateKeys(routerRedirectUrl.searchParams.entries()),
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if ("message" in result) {
|
||||
log("Setting message in modal", {
|
||||
message: result.message,
|
||||
});
|
||||
// TODO: We might need to sanitize the error message and error code before setting it
|
||||
modalEl.setAttribute("data-message", result.message);
|
||||
modalEl.setAttribute("state", "has-message");
|
||||
} else if ("error" in result) {
|
||||
log("Setting error in modal", {
|
||||
error: result.error,
|
||||
});
|
||||
// We need to show this message in the modal
|
||||
modalEl.setAttribute("data-error-code", "routerError");
|
||||
// TODO: We might need to sanitize the error message and error code before setting it
|
||||
modalEl.setAttribute("data-message", result.error);
|
||||
modalEl.setAttribute("state", "failed");
|
||||
if (!lastLoadedUrlInIframeObject || !this.iframe) {
|
||||
return;
|
||||
}
|
||||
// Create a clone of the last loaded url object in iframe
|
||||
const urlObject = new URL(lastLoadedUrlInIframeObject.toString());
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
urlObject.searchParams.set(key, value as string);
|
||||
}
|
||||
this.iframe.dataset.calLink = `${urlObject.pathname.replace(/^\//, "")}${urlObject.search}`;
|
||||
}
|
||||
|
||||
canPrerenderLink({
|
||||
calLink,
|
||||
calOrigin,
|
||||
previousEmbedRenderStartTime,
|
||||
}: {
|
||||
calLink: string;
|
||||
calOrigin: string;
|
||||
previousEmbedRenderStartTime: number | null;
|
||||
}) {
|
||||
const lastLoadedUrlInIframeObject = this.getLastLoadedLinkInframe();
|
||||
// Trying to prerender a link that is already prerendered.
|
||||
// Prevent unnecessary repeat prerenders
|
||||
if (lastLoadedUrlInIframeObject?.toString() === new URL(calLink, calOrigin).toString()) {
|
||||
const isThresholdCrossed = hasCrossedThreshold();
|
||||
if (isThresholdCrossed) {
|
||||
log("Threshold crossed, allowing repeat prerender");
|
||||
return true;
|
||||
}
|
||||
log("Threshold not crossed, preventing repeat prerender");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
function hasCrossedThreshold() {
|
||||
if (!previousEmbedRenderStartTime) {
|
||||
return false;
|
||||
}
|
||||
const timeDiff = Date.now() - previousEmbedRenderStartTime;
|
||||
if (timeDiff >= EMBED_MODAL_PRERENDER_PREVENT_THRESHOLD_MS) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
prepareForPrerender({
|
||||
calLink,
|
||||
calOrigin,
|
||||
previousEmbedRenderStartTime,
|
||||
prerenderOptions,
|
||||
isHeadlessRouterPath,
|
||||
}: {
|
||||
calLink: string;
|
||||
calOrigin: string;
|
||||
previousEmbedRenderStartTime: number | null;
|
||||
prerenderOptions: ModalPrerenderOptions;
|
||||
isHeadlessRouterPath: boolean;
|
||||
}) {
|
||||
if (!this.canPrerenderLink({ calLink, calOrigin, previousEmbedRenderStartTime })) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.modalBox) {
|
||||
log("Destroying previous prerendered modalbox");
|
||||
// If we are re-prerendering, we destroy the previous modalbox, allowing user to prerender as many times as they want
|
||||
this.modalBox.remove();
|
||||
}
|
||||
|
||||
const DEFAULT_BACKGROUND_SLOTS_FETCH = isHeadlessRouterPath ? true : false;
|
||||
|
||||
const backgroundSlotsFetch =
|
||||
typeof prerenderOptions.backgroundSlotsFetch === "undefined"
|
||||
? DEFAULT_BACKGROUND_SLOTS_FETCH
|
||||
: prerenderOptions.backgroundSlotsFetch;
|
||||
const enrichedPrerenderOptions = { ...prerenderOptions, backgroundSlotsFetch };
|
||||
|
||||
// Persist prerender options here so that during CTA click we can use these.
|
||||
this.api.prerenderOptions = enrichedPrerenderOptions;
|
||||
this.isPrerendering = true;
|
||||
|
||||
return { enrichedPrerenderOptions };
|
||||
}
|
||||
|
||||
setModalRenderStartVariables({
|
||||
embedConfig,
|
||||
embedRenderStartTime,
|
||||
}: {
|
||||
embedConfig: PrefillAndIframeAttrsConfig;
|
||||
embedRenderStartTime: number;
|
||||
}) {
|
||||
this.embedConfig = embedConfig;
|
||||
this.embedRenderStartTime = embedRenderStartTime;
|
||||
}
|
||||
|
||||
getPreviousModalRenderStartVariables() {
|
||||
return {
|
||||
embedConfig: this.embedConfig,
|
||||
embedRenderStartTime: this.embedRenderStartTime,
|
||||
};
|
||||
}
|
||||
|
||||
connect({
|
||||
config,
|
||||
params,
|
||||
}: {
|
||||
config: PrefillAndIframeAttrsConfig;
|
||||
params: Record<string, string | string[]>;
|
||||
}) {
|
||||
// Update the iframe query params and record the change as well on the iframe for getNextActionForModal to use
|
||||
this.recordUpdatedParamsForIframe(params);
|
||||
this.doInIframe({
|
||||
method: "connect",
|
||||
arg: {
|
||||
config,
|
||||
params,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -979,56 +1015,24 @@ class CalApi {
|
||||
__prerender?: boolean;
|
||||
prerenderOptions?: ModalPrerenderOptions;
|
||||
}) {
|
||||
const isPrerendering = !!__prerender;
|
||||
if (typeof config.iframeAttrs === "string" || config.iframeAttrs instanceof Array) {
|
||||
throw new Error("iframeAttrs should be an object");
|
||||
}
|
||||
const containerEl = document.body;
|
||||
const calConfig = this.cal.getCalConfig();
|
||||
// Clone so that it doesn't mutate the original config
|
||||
config = { ...config };
|
||||
|
||||
// calOrigin could have been passed as empty string by the user
|
||||
calOrigin = calOrigin || calConfig.calOrigin;
|
||||
|
||||
const calLinkUrlObject = new URL(calLink, calOrigin);
|
||||
const isHeadlessRouterPath = calLinkUrlObject ? isRouterPath(calLinkUrlObject.toString()) : false;
|
||||
const { embedConfig: previousEmbedConfig, embedRenderStartTime: previousEmbedRenderStartTime } =
|
||||
this.cal.getPreviousModalRenderStartVariables();
|
||||
|
||||
if (__prerender && this.cal.modalBox) {
|
||||
log("Destroying previous prerendered modalbox");
|
||||
// If we are re-prerendering, we destroy the previous modalbox, allowing user to prerender as many times as they want
|
||||
this.cal.modalBox.remove();
|
||||
}
|
||||
|
||||
// `this.modalUid` is set in non-preload case(Temporarily not being-set)
|
||||
// `this.prerenderedModalUid` is set for a modal created through "prerender"
|
||||
const uid = this.modalUid || this.prerenderedModalUid || String(Date.now()) || "0";
|
||||
// Means whether there is already an attempt to use the prerendered modal
|
||||
const isConnectionInitiated = !!(this.modalUid && this.prerenderedModalUid);
|
||||
|
||||
const containerEl = document.body;
|
||||
this.cal.isPrerendering = !!__prerender;
|
||||
if (__prerender) {
|
||||
// While prerendering headless router path, we by default want to fetch slots in background(for the time being)
|
||||
// For other prerenderings, we don't want to fetch slots in background - Keeping the behaviour same as before
|
||||
const DEFAULT_BACKGROUND_SLOTS_FETCH = isHeadlessRouterPath ? true : false;
|
||||
const backgroundSlotsFetch =
|
||||
typeof prerenderOptions.backgroundSlotsFetch === "undefined"
|
||||
? DEFAULT_BACKGROUND_SLOTS_FETCH
|
||||
: prerenderOptions.backgroundSlotsFetch;
|
||||
|
||||
this.prerenderOptions = {
|
||||
...prerenderOptions,
|
||||
backgroundSlotsFetch,
|
||||
};
|
||||
// Add prerender query param
|
||||
config.prerender = "true";
|
||||
|
||||
// If we are prerendering a headless router path, we don't want to record the response immediately.
|
||||
if (isHeadlessRouterPath) {
|
||||
config["cal.queueFormResponse"] = "true";
|
||||
}
|
||||
|
||||
if (!backgroundSlotsFetch) {
|
||||
// When prerendering, we don't want to preload slots as they might be outdated anyway by the time they are used
|
||||
// Also, when used with Headless Router attributes setup, we might endup fetching slots for a lot of people, which would be a waste and unnecessary load on Cal.com resources
|
||||
config["cal.skipSlotsFetch"] = "true";
|
||||
}
|
||||
}
|
||||
|
||||
let enrichedConfig;
|
||||
const configWithGuestKeyAndColorScheme = withColorScheme(
|
||||
Cal.ensureGuestKey({
|
||||
...config,
|
||||
@@ -1037,41 +1041,99 @@ class CalApi {
|
||||
containerEl
|
||||
);
|
||||
|
||||
const previousEmbedRenderStartTime = this.cal.embedRenderStartTime;
|
||||
const previousEmbedConfig = this.cal.embedConfig;
|
||||
let enrichedPrerenderOptions;
|
||||
if (isPrerendering) {
|
||||
const preparationResult = this.cal.prepareForPrerender({
|
||||
calLink,
|
||||
calOrigin,
|
||||
previousEmbedRenderStartTime,
|
||||
prerenderOptions,
|
||||
isHeadlessRouterPath,
|
||||
});
|
||||
|
||||
if (!preparationResult) {
|
||||
log(`Prevented unnecessary repeat prerender for ${calLink}`);
|
||||
return {
|
||||
status: "prerender-prevented",
|
||||
};
|
||||
}
|
||||
|
||||
enrichedPrerenderOptions = preparationResult.enrichedPrerenderOptions;
|
||||
enrichedConfig = buildConfigWithPrerenderRelatedFields({
|
||||
config: configWithGuestKeyAndColorScheme,
|
||||
isHeadlessRouterPath,
|
||||
backgroundSlotsFetch: !!this.prerenderOptions?.backgroundSlotsFetch,
|
||||
});
|
||||
} else {
|
||||
this.cal.isPrerendering = false;
|
||||
enrichedConfig = configWithGuestKeyAndColorScheme;
|
||||
}
|
||||
|
||||
// `this.modalUid` is set in non-preload case(Temporarily not being-set)
|
||||
// `this.prerenderedModalUid` is set for a modal created through "prerender"
|
||||
const reusableModalUid = this.modalUid || this.prerenderedModalUid;
|
||||
const uid = reusableModalUid || String(Date.now());
|
||||
// Means whether there is already an attempt to use the prerendered modal
|
||||
const isConnectionInitiated = !!(this.modalUid && this.prerenderedModalUid);
|
||||
const embedRenderStartTime = Date.now();
|
||||
this.cal.embedRenderStartTime = embedRenderStartTime;
|
||||
this.cal.embedConfig = configWithGuestKeyAndColorScheme;
|
||||
|
||||
const stateData = {
|
||||
embedConfig: enrichedConfig,
|
||||
previousEmbedConfig,
|
||||
embedRenderStartTime,
|
||||
previousEmbedRenderStartTime,
|
||||
isConnectionInitiated,
|
||||
// If prerenderOptions are available, use them. Otherwise use the prerenderOptions from the previous call to modal for prerender
|
||||
prerenderOptions: enrichedPrerenderOptions ?? this.prerenderOptions ?? null,
|
||||
};
|
||||
|
||||
this.cal.setModalRenderStartVariables({
|
||||
embedConfig: enrichedConfig,
|
||||
embedRenderStartTime,
|
||||
});
|
||||
|
||||
return this.__modal({
|
||||
calLink,
|
||||
enrichedConfig,
|
||||
calOrigin,
|
||||
isPrerendering,
|
||||
stateData,
|
||||
uid,
|
||||
containerEl,
|
||||
});
|
||||
}
|
||||
|
||||
async __modal({
|
||||
uid,
|
||||
calLink,
|
||||
enrichedConfig,
|
||||
calOrigin,
|
||||
isPrerendering,
|
||||
stateData,
|
||||
containerEl,
|
||||
}: {
|
||||
uid: string;
|
||||
calLink: string;
|
||||
enrichedConfig: PrefillAndIframeAttrsConfig;
|
||||
calOrigin: string;
|
||||
isPrerendering: boolean;
|
||||
stateData: ModalStateData;
|
||||
containerEl: HTMLElement;
|
||||
}) {
|
||||
const calLinkUrlObject = new URL(calLink, calOrigin);
|
||||
const isHeadlessRouterPath = calLinkUrlObject ? isRouterPath(calLinkUrlObject.toString()) : false;
|
||||
|
||||
const existingModalEl = document.querySelector(`cal-modal-box[uid="${uid}"]`);
|
||||
|
||||
// isConnectionPossible
|
||||
if (!!existingModalEl && !!this.cal.iframe) {
|
||||
log(`Trying to reuse modal ${uid}`);
|
||||
const stateData = {
|
||||
embedConfig: configWithGuestKeyAndColorScheme,
|
||||
previousEmbedConfig,
|
||||
embedRenderStartTime,
|
||||
previousEmbedRenderStartTime,
|
||||
isConnectionInitiated,
|
||||
prerenderOptions: this.prerenderOptions ?? null,
|
||||
};
|
||||
const lastLoadedUrlObject = this.cal.getLastLoadedLinkInframe();
|
||||
const lastLoadedPathIsRouter = lastLoadedUrlObject?.pathname?.includes("/router");
|
||||
|
||||
// TODO: This branch needs to be removed as we don't want to support this use case anymore.
|
||||
if (isHeadlessRouterPath && !lastLoadedPathIsRouter) {
|
||||
// Immediately take it to loading state. Either through connect or through loadInIframe, it would later be updated
|
||||
existingModalEl.setAttribute("state", "loading");
|
||||
|
||||
// submitThroughHeadlessRouterInModal would further decide whether full page reload is needed or a connect would suffice
|
||||
// actionToTake might be "fullReload" and still connect could work in case of headless router because there might be just query params change(which can be handled by connect) of calLink(i.e. ?form=formid&newParam=newValue)
|
||||
await this.cal.submitThroughHeadlessRouterInModal({
|
||||
modal: { uid, element: existingModalEl, calOrigin },
|
||||
calLinkUrlObject,
|
||||
stateData,
|
||||
});
|
||||
throw new Error(
|
||||
"`prerender` instruction should have been fired with headless router path as calLink(i.e. router?form={FORM_ID}&PARAMS=VALUES)"
|
||||
);
|
||||
} else {
|
||||
const actionToTake = this.cal.getNextActionForModal({
|
||||
modal: { uid },
|
||||
@@ -1080,7 +1142,7 @@ class CalApi {
|
||||
});
|
||||
|
||||
if (actionToTake === "noAction") {
|
||||
if (__prerender) {
|
||||
if (isPrerendering) {
|
||||
// A prerender instruction can never show the modal
|
||||
return;
|
||||
}
|
||||
@@ -1100,22 +1162,20 @@ class CalApi {
|
||||
calLink,
|
||||
calOrigin,
|
||||
iframe: this.cal.iframe,
|
||||
config: configWithGuestKeyAndColorScheme,
|
||||
config: enrichedConfig,
|
||||
});
|
||||
} else if (actionToTake === "connect" || actionToTake === "connect-no-slots-fetch") {
|
||||
this.cal.doInIframe({
|
||||
method: "connect",
|
||||
arg: {
|
||||
config: {
|
||||
...configWithGuestKeyAndColorScheme,
|
||||
...(actionToTake === "connect-no-slots-fetch"
|
||||
? {
|
||||
"cal.embed.noSlotsFetchOnConnect": "true",
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
params: fromEntriesWithDuplicateKeys(calLinkUrlObject.searchParams.entries()),
|
||||
const paramsToAdd = fromEntriesWithDuplicateKeys(calLinkUrlObject.searchParams.entries());
|
||||
this.cal.connect({
|
||||
config: {
|
||||
...enrichedConfig,
|
||||
...(actionToTake === "connect-no-slots-fetch"
|
||||
? {
|
||||
"cal.embed.noSlotsFetchOnConnect": "true",
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
params: paramsToAdd,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1127,23 +1187,19 @@ class CalApi {
|
||||
|
||||
log(`Creating new modal ${uid}`);
|
||||
|
||||
if (__prerender) {
|
||||
if (isPrerendering) {
|
||||
this.prerenderedModalUid = uid;
|
||||
} else {
|
||||
// Intentionally not setting it to avoid the behaviour of reusing the same modal. It was disabled earlier but now can be enabled but we will enable it later.
|
||||
// this.modalUid = uid;
|
||||
}
|
||||
|
||||
if (typeof config.iframeAttrs === "string" || config.iframeAttrs instanceof Array) {
|
||||
throw new Error("iframeAttrs should be an object");
|
||||
}
|
||||
|
||||
let iframe = null;
|
||||
|
||||
if (!iframe) {
|
||||
iframe = this.cal.createIframe({
|
||||
calLink,
|
||||
config: configWithGuestKeyAndColorScheme,
|
||||
config: enrichedConfig,
|
||||
calOrigin,
|
||||
});
|
||||
}
|
||||
@@ -1152,9 +1208,9 @@ class CalApi {
|
||||
iframe.style.height = "100%";
|
||||
iframe.style.width = "100%";
|
||||
const template = document.createElement("template");
|
||||
const pageType = getConfigProp(configWithGuestKeyAndColorScheme, "cal.embed.pageType");
|
||||
const theme = getConfigProp(configWithGuestKeyAndColorScheme, "theme");
|
||||
const layout = getConfigProp(configWithGuestKeyAndColorScheme, "layout");
|
||||
const pageType = getConfigProp(enrichedConfig, "cal.embed.pageType");
|
||||
const theme = getConfigProp(enrichedConfig, "theme");
|
||||
const layout = getConfigProp(enrichedConfig, "layout");
|
||||
|
||||
template.innerHTML = `<cal-modal-box
|
||||
${generateDataAttributes({
|
||||
@@ -1169,11 +1225,14 @@ class CalApi {
|
||||
// Set state through setAttribute so that onAttributeChangedCallback is triggered
|
||||
this.cal.modalBox.setAttribute("state", "loading");
|
||||
|
||||
if (__prerender) {
|
||||
if (isPrerendering) {
|
||||
this.cal.modalBox.setAttribute("state", "prerendering");
|
||||
}
|
||||
this.handleClose();
|
||||
containerEl.appendChild(template.content);
|
||||
return {
|
||||
status: "created",
|
||||
};
|
||||
}
|
||||
|
||||
private handleClose() {
|
||||
|
||||
@@ -102,3 +102,9 @@ export type PrefillAndIframeAttrsConfig = Record<string, string | string[] | Rec
|
||||
|
||||
export type SetStyles = React.Dispatch<React.SetStateAction<EmbedStyles>>;
|
||||
export type setNonStylesConfig = React.Dispatch<React.SetStateAction<EmbedNonStylesConfig>>;
|
||||
|
||||
export type ModalPrerenderOptions = {
|
||||
slotsStaleTimeMs?: number;
|
||||
iframeForceReloadThresholdMs?: number;
|
||||
backgroundSlotsFetch?: boolean;
|
||||
};
|
||||
|
||||
@@ -196,3 +196,32 @@ export function buildSearchParamsFromConfig(config: PrefillAndIframeAttrsConfig)
|
||||
}
|
||||
return searchParams;
|
||||
}
|
||||
|
||||
export function buildConfigWithPrerenderRelatedFields({
|
||||
config,
|
||||
isHeadlessRouterPath,
|
||||
backgroundSlotsFetch,
|
||||
}: {
|
||||
config: PrefillAndIframeAttrsConfig;
|
||||
isHeadlessRouterPath: boolean;
|
||||
backgroundSlotsFetch: boolean;
|
||||
}) {
|
||||
// While prerendering headless router path, we by default want to fetch slots in background(for the time being)
|
||||
// For other prerenderings, we don't want to fetch slots in background - Keeping the behaviour same as before
|
||||
|
||||
// If we are prerendering a headless router path, we don't want to record the response immediately.
|
||||
if (isHeadlessRouterPath) {
|
||||
config["cal.queueFormResponse"] = "true";
|
||||
}
|
||||
|
||||
if (!backgroundSlotsFetch) {
|
||||
// When prerendering, we don't want to preload slots as they might be outdated anyway by the time they are used
|
||||
// Also, when used with Headless Router attributes setup, we might endup fetching slots for a lot of people, which would be a waste and unnecessary load on Cal.com resources
|
||||
config["cal.skipSlotsFetch"] = "true";
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
prerender: "true",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,12 +1,33 @@
|
||||
import type { Account, IdentityProvider, Prisma, User, VerificationToken } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import type { Awaitable } from "next-auth";
|
||||
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
import { identityProviderNameMap } from "./identityProviderNameMap";
|
||||
|
||||
type CalComAdapter = {
|
||||
createUser: (data: Prisma.UserCreateInput) => Awaitable<User>;
|
||||
getUser: (id: string | number) => Awaitable<User | null>;
|
||||
getUserByEmail: (email: User["email"]) => Awaitable<User | null>;
|
||||
getUserByAccount: (provider_providerAccountId: {
|
||||
providerAccountId: Account["providerAccountId"];
|
||||
provider: User["identityProvider"];
|
||||
}) => Awaitable<User | null>;
|
||||
updateUser: (data: Prisma.UserUncheckedCreateInput) => Awaitable<User>;
|
||||
deleteUser: (id: User["id"]) => Awaitable<User>;
|
||||
createVerificationToken: (data: VerificationToken) => Awaitable<Omit<VerificationToken, "id">>;
|
||||
useVerificationToken: (
|
||||
identifier_token: Prisma.VerificationTokenIdentifierTokenCompoundUniqueInput
|
||||
) => Awaitable<Omit<VerificationToken, "id"> | null>;
|
||||
linkAccount: (data: Prisma.AccountCreateInput) => Awaitable<Account>;
|
||||
unlinkAccount: (
|
||||
provider_providerAccountId: Prisma.AccountProviderProviderAccountIdCompoundUniqueInput
|
||||
) => Awaitable<Account>;
|
||||
};
|
||||
|
||||
/** @return { import("next-auth/adapters").Adapter } */
|
||||
export default function CalComAdapter(prismaClient: PrismaClient) {
|
||||
export default function CalComAdapter(prismaClient: PrismaClient): CalComAdapter {
|
||||
return {
|
||||
createUser: (data: Prisma.UserCreateInput) => prismaClient.user.create({ data }),
|
||||
getUser: (id: string | number) =>
|
||||
|
||||
@@ -408,6 +408,7 @@ const BookerComponent = ({
|
||||
!(layout === "mobile" && bookerState === "booking") && (
|
||||
<div className="mt-auto px-5 py-3">
|
||||
<DatePicker
|
||||
classNames={customClassNames?.datePickerCustomClassNames}
|
||||
event={event}
|
||||
slots={schedule?.data?.slots}
|
||||
isLoading={schedule.isPending}
|
||||
@@ -435,14 +436,7 @@ const BookerComponent = ({
|
||||
initial="visible"
|
||||
className="md:border-subtle ml-[-1px] h-full flex-shrink px-5 py-3 md:border-l lg:w-[var(--booker-main-width)]">
|
||||
<DatePicker
|
||||
classNames={{
|
||||
datePickerContainer: customClassNames?.datePickerCustomClassNames?.datePickerContainer,
|
||||
datePickerTitle: customClassNames?.datePickerCustomClassNames?.datePickerTitle,
|
||||
datePickerDays: customClassNames?.datePickerCustomClassNames?.datePickerDays,
|
||||
datePickerDate: customClassNames?.datePickerCustomClassNames?.datePickerDate,
|
||||
datePickerDatesActive: customClassNames?.datePickerCustomClassNames?.datePickerDatesActive,
|
||||
datePickerToggle: customClassNames?.datePickerCustomClassNames?.datePickerToggle,
|
||||
}}
|
||||
classNames={customClassNames?.datePickerCustomClassNames}
|
||||
event={event}
|
||||
slots={schedule?.data?.slots}
|
||||
isLoading={schedule.isPending}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { shallow } from "zustand/shallow";
|
||||
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import type { DatePickerClassNames } from "@calcom/features/bookings/Booker/types";
|
||||
import { DatePicker as DatePickerComponent } from "@calcom/features/calendars/DatePicker";
|
||||
import { useNonEmptyScheduleDays } from "@calcom/features/schedules/lib/use-schedule/useNonEmptyScheduleDays";
|
||||
import { weekdayToWeekIndex } from "@calcom/lib/dayjs";
|
||||
@@ -69,14 +70,7 @@ export const DatePicker = ({
|
||||
};
|
||||
slots?: Slots;
|
||||
isLoading?: boolean;
|
||||
classNames?: {
|
||||
datePickerContainer?: string;
|
||||
datePickerTitle?: string;
|
||||
datePickerDays?: string;
|
||||
datePickerDate?: string;
|
||||
datePickerDatesActive?: string;
|
||||
datePickerToggle?: string;
|
||||
};
|
||||
classNames?: DatePickerClassNames;
|
||||
scrollToTimeSlots?: () => void;
|
||||
}) => {
|
||||
const { i18n } = useLocale();
|
||||
|
||||
@@ -161,14 +161,7 @@ export type CustomClassNames = {
|
||||
eventMetaTimezoneSelect?: string;
|
||||
eventMetaChildren?: string;
|
||||
};
|
||||
datePickerCustomClassNames?: {
|
||||
datePickerContainer?: string;
|
||||
datePickerTitle?: string;
|
||||
datePickerDays?: string;
|
||||
datePickerDate?: string;
|
||||
datePickerDatesActive?: string;
|
||||
datePickerToggle?: string;
|
||||
};
|
||||
datePickerCustomClassNames?: DatePickerClassNames;
|
||||
availableTimeSlotsCustomClassNames?: {
|
||||
availableTimeSlotsContainer?: string;
|
||||
availableTimeSlotsHeaderContainer?: string;
|
||||
@@ -182,3 +175,12 @@ export type CustomClassNames = {
|
||||
backButton?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type DatePickerClassNames = {
|
||||
datePickerContainer?: string;
|
||||
datePickerTitle?: string;
|
||||
datePickerDays?: string;
|
||||
datePickerDate?: string;
|
||||
datePickerDatesActive?: string;
|
||||
datePickerToggle?: string;
|
||||
};
|
||||
|
||||
@@ -100,6 +100,7 @@ export async function getBookingToDelete(id: number | undefined, uid: string | u
|
||||
responses: true,
|
||||
iCalUID: true,
|
||||
iCalSequence: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -89,6 +89,19 @@ async function handler(input: CancelBookingInput) {
|
||||
arePlatformEmailsEnabled,
|
||||
} = input;
|
||||
|
||||
/**
|
||||
* Important: We prevent cancelling an already cancelled booking.
|
||||
* A booking could have been CANCELLED due to a reschedule,
|
||||
* in which case we simply update the existing calendar event and meeting.
|
||||
* We want to avoid deleting them by a subsequent cancellation attempt.
|
||||
*/
|
||||
if (bookingToDelete.status === BookingStatus.CANCELLED) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: "This booking has already been cancelled.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!bookingToDelete.userId || !bookingToDelete.user) {
|
||||
throw new HttpError({ statusCode: 400, message: "User not found" });
|
||||
}
|
||||
|
||||
@@ -1502,15 +1502,6 @@ async function handler(
|
||||
evt.videoCallData = undefined;
|
||||
// To prevent "The requested identifier already exists" error while updating event, we need to remove iCalUID
|
||||
evt.iCalUID = undefined;
|
||||
} else {
|
||||
// In case of rescheduling, we need to keep the previous host destination calendar
|
||||
evt = CalendarEventBuilder.fromEvent(evt)
|
||||
.withDestinationCalendar(
|
||||
originalRescheduledBooking?.destinationCalendar
|
||||
? [originalRescheduledBooking?.destinationCalendar]
|
||||
: evt.destinationCalendar
|
||||
)
|
||||
.build();
|
||||
}
|
||||
|
||||
const updateManager = await eventManager.reschedule(
|
||||
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
import {
|
||||
createBookingScenario,
|
||||
getGoogleCalendarCredential,
|
||||
TestData,
|
||||
getOrganizer,
|
||||
getBooker,
|
||||
getScenarioData,
|
||||
mockSuccessfulVideoMeetingCreation,
|
||||
mockCalendarToHaveNoBusySlots,
|
||||
getDate,
|
||||
BookingLocations,
|
||||
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
|
||||
import { expectBookingToBeInDatabase } from "@calcom/web/test/utils/bookingScenario/expects";
|
||||
import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking";
|
||||
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { describe, expect, beforeEach } from "vitest";
|
||||
|
||||
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
|
||||
import { resetTestEmails } from "@calcom/lib/testEmails";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { test } from "@calcom/web/test/fixtures/fixtures";
|
||||
|
||||
export type CustomNextApiRequest = NextApiRequest & Request;
|
||||
|
||||
export type CustomNextApiResponse = NextApiResponse & Response;
|
||||
|
||||
const timeout = process.env.CI ? 5000 : 20000;
|
||||
|
||||
describe("handleNewBooking", () => {
|
||||
setupAndTeardown();
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestEmails();
|
||||
});
|
||||
|
||||
describe("Collective Event Reschedule with Destination Calendar Bug", () => {
|
||||
test(
|
||||
`should reschedule a collective event booking and ensure both hosts have the booking in their calendars when a second host is added
|
||||
1. Create a collective event with 1 host and book it
|
||||
2. Add a second host to the event type
|
||||
3. Reschedule the booking
|
||||
4. Verify both hosts have the booking in their calendars
|
||||
`,
|
||||
async ({ emails }) => {
|
||||
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
|
||||
|
||||
const booker = getBooker({
|
||||
email: "booker@example.com",
|
||||
name: "Booker",
|
||||
});
|
||||
|
||||
const organizer = getOrganizer({
|
||||
name: "Organizer",
|
||||
email: "organizer@example.com",
|
||||
id: 101,
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
credentials: [getGoogleCalendarCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
destinationCalendar: {
|
||||
integration: "google_calendar",
|
||||
externalId: "organizer@google-calendar.com",
|
||||
primaryEmail: "organizer@google-calendar.com",
|
||||
},
|
||||
});
|
||||
|
||||
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
||||
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
|
||||
const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP";
|
||||
const iCalUID = `${uidOfBookingToBeRescheduled}@Cal.com`;
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
slotInterval: 15,
|
||||
length: 30,
|
||||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
users: [
|
||||
{
|
||||
id: 101, // organizer
|
||||
},
|
||||
{
|
||||
id: 102, // second host (will be added to event type)
|
||||
},
|
||||
],
|
||||
destinationCalendar: {
|
||||
integration: "google_calendar",
|
||||
externalId: "event-type-1@google-calendar.com",
|
||||
primaryEmail: "event-type-1@google-calendar.com",
|
||||
},
|
||||
},
|
||||
],
|
||||
users: [
|
||||
{
|
||||
id: 101,
|
||||
name: "Organizer",
|
||||
email: "organizer@example.com",
|
||||
username: "organizer",
|
||||
timeZone: "Asia/Kolkata",
|
||||
defaultScheduleId: null,
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
credentials: [getGoogleCalendarCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
destinationCalendar: {
|
||||
integration: "google_calendar",
|
||||
externalId: "organizer@google-calendar.com",
|
||||
primaryEmail: "organizer@google-calendar.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
name: "Second Host",
|
||||
email: "second-host@example.com",
|
||||
username: "second-host",
|
||||
timeZone: "Asia/Kolkata",
|
||||
defaultScheduleId: null,
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
credentials: [getGoogleCalendarCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
destinationCalendar: {
|
||||
integration: "google_calendar",
|
||||
externalId: "second-host@google-calendar.com",
|
||||
primaryEmail: "second-host@google-calendar.com",
|
||||
},
|
||||
},
|
||||
],
|
||||
bookings: [
|
||||
{
|
||||
uid: uidOfBookingToBeRescheduled,
|
||||
eventTypeId: 1,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
startTime: `${plus1DateString}T05:00:00.000Z`,
|
||||
endTime: `${plus1DateString}T05:30:00.000Z`,
|
||||
users: [
|
||||
{
|
||||
id: 101, // organizer
|
||||
},
|
||||
],
|
||||
references: [
|
||||
{
|
||||
type: appStoreMetadata.dailyvideo.type,
|
||||
uid: "MOCK_ID_1",
|
||||
meetingId: "MOCK_ID_1",
|
||||
meetingPassword: "MOCK_PASS_1",
|
||||
meetingUrl: "http://mock-dailyvideo.example.com/meeting-1",
|
||||
credentialId: null,
|
||||
},
|
||||
{
|
||||
type: appStoreMetadata.googlecalendar.type,
|
||||
uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID_1",
|
||||
meetingId: "MOCKED_GOOGLE_CALENDAR_EVENT_ID_1",
|
||||
meetingPassword: "MOCK_PASSWORD",
|
||||
meetingUrl: "https://UNUSED_URL",
|
||||
externalCalendarId: "event-type-1@google-calendar.com",
|
||||
credentialId: undefined,
|
||||
},
|
||||
],
|
||||
iCalUID,
|
||||
},
|
||||
],
|
||||
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
||||
})
|
||||
);
|
||||
|
||||
mockSuccessfulVideoMeetingCreation({
|
||||
metadataLookupKey: appStoreMetadata.dailyvideo.dirName,
|
||||
videoMeetingData: {
|
||||
id: "MOCK_ID_1", // Video meeting ID is not updated during reschedule
|
||||
password: "MOCK_PASS_1",
|
||||
url: `http://mock-dailyvideo.example.com/meeting-1`,
|
||||
},
|
||||
});
|
||||
|
||||
const organizerCalendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", {
|
||||
update: {
|
||||
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID_2",
|
||||
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID_2",
|
||||
},
|
||||
});
|
||||
|
||||
const rescheduleBookingData = getMockRequestDataForBooking({
|
||||
data: {
|
||||
start: `${plus2DateString}T11:00:00.000Z`,
|
||||
end: `${plus2DateString}T11:30:00.000Z`,
|
||||
eventTypeId: 1,
|
||||
rescheduleUid: uidOfBookingToBeRescheduled,
|
||||
responses: {
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
location: { optionValue: "", value: BookingLocations.CalVideo },
|
||||
},
|
||||
rescheduledBy: organizer.email,
|
||||
},
|
||||
});
|
||||
|
||||
const rescheduledBooking = await handleNewBooking({
|
||||
bookingData: rescheduleBookingData,
|
||||
});
|
||||
|
||||
await expectBookingToBeInDatabase({
|
||||
description: "",
|
||||
location: BookingLocations.CalVideo,
|
||||
responses: expect.objectContaining({
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
}),
|
||||
uid: rescheduledBooking.uid!,
|
||||
eventTypeId: rescheduleBookingData.eventTypeId,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
references: [
|
||||
{
|
||||
type: appStoreMetadata.dailyvideo.type,
|
||||
uid: "MOCK_ID_1",
|
||||
meetingId: "MOCK_ID_1",
|
||||
meetingPassword: "MOCK_PASS_1",
|
||||
meetingUrl: "http://mock-dailyvideo.example.com/meeting-1",
|
||||
},
|
||||
{
|
||||
type: appStoreMetadata.googlecalendar.type,
|
||||
uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID_1",
|
||||
externalCalendarId: "event-type-1@google-calendar.com",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(organizerCalendarMock.updateEventCalls.length).toBe(1);
|
||||
expect(organizerCalendarMock.createEventCalls.length).toBe(0);
|
||||
|
||||
// Check availability calls for both hosts
|
||||
expect(organizerCalendarMock.getAvailabilityCalls.length).toBe(2);
|
||||
},
|
||||
timeout
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -55,7 +55,10 @@ export class StripeBillingService implements BillingService {
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
metadata: metadata,
|
||||
});
|
||||
invoice_creation: {
|
||||
enabled: true,
|
||||
},
|
||||
} as any);
|
||||
|
||||
return {
|
||||
checkoutUrl: session.url,
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { PartialReference } from "@calcom/types/EventManager";
|
||||
import { DialogContent, DialogFooter, DialogHeader, DialogClose } from "@calcom/ui/components/dialog";
|
||||
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
|
||||
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
|
||||
type Session = RouterOutputs["viewer"]["calVideo"]["getMeetingInformation"]["data"][number];
|
||||
type Participant = Session["participants"][number];
|
||||
|
||||
interface IMeetingSessionDetailsDialog {
|
||||
booking?: BookingItem;
|
||||
isOpenDialog: boolean;
|
||||
setIsOpenDialog: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
timeFormat: number | null;
|
||||
}
|
||||
|
||||
const MeetingSessionDetailsSkeleton = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{[...Array(2)].map((_, index) => (
|
||||
<div key={index} className="border-subtle flex w-full flex-col rounded-md border p-4">
|
||||
<div className="mb-3">
|
||||
<SkeletonText className="h-5 w-24" />
|
||||
<SkeletonText className="mt-1 h-4 w-32" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i}>
|
||||
<SkeletonText className="h-4 w-16" />
|
||||
<SkeletonText className="mt-1 h-4 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<SkeletonText className="mb-2 h-5 w-20" />
|
||||
<div className="space-y-2">
|
||||
{[...Array(2)].map((_, i) => (
|
||||
<div key={i} className="border-subtle rounded border p-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<SkeletonText className="h-4 w-24" />
|
||||
<SkeletonText className="h-4 w-16" />
|
||||
</div>
|
||||
<SkeletonText className="mt-1 h-3 w-32" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MeetingSessionDetailsList = ({ roomName }: { roomName: string }) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const { data: meetingInfo, isLoading } = trpc.viewer.calVideo.getMeetingInformation.useQuery(
|
||||
{ roomName },
|
||||
{
|
||||
suspense: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <MeetingSessionDetailsSkeleton />;
|
||||
}
|
||||
|
||||
if (!meetingInfo || !meetingInfo.data || meetingInfo.data.length === 0) {
|
||||
return <p className="font-semibold">{t("no_meeting_sessions_found")}</p>;
|
||||
}
|
||||
|
||||
const formatDuration = (totalSeconds: number) => {
|
||||
if (!totalSeconds) {
|
||||
return t("ongoing");
|
||||
}
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
const parts = [];
|
||||
if (minutes > 0) {
|
||||
parts.push(`${minutes} ${t("minutes")}`);
|
||||
}
|
||||
if (seconds > 0) {
|
||||
parts.push(`${seconds} ${t("seconds")}`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{meetingInfo.data.map((session: Session, index: number) => (
|
||||
<div key={session.id} className="border-subtle flex w-full flex-col rounded-md border p-4">
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t("meeting_session")} {index + 1}
|
||||
</h3>
|
||||
<p className="text-subtle text-sm">
|
||||
{t("session_id")}: {session.id}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-medium">{t("start_time")}:</span>
|
||||
<p className="text-subtle">
|
||||
{session.start_time
|
||||
? dayjs(session.start_time * 1000).format("MMM D, YYYY h:mm A")
|
||||
: t("not_available")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="font-medium">{t("duration")}:</span>
|
||||
<p className="text-subtle">
|
||||
{session.duration ? formatDuration(session.duration) : t("ongoing")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="font-medium">{t("max_participants")}:</span>
|
||||
<p className="text-subtle">{session.max_participants || t("unlimited")}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="font-medium">{t("status")}:</span>
|
||||
<p className="text-subtle">{session.ongoing ? t("ongoing") : t("ended")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{session.participants && session.participants.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="mb-2 text-sm font-medium">{t("participants")}:</h4>
|
||||
<div className="space-y-2">
|
||||
{session.participants.map((participant: Participant) => (
|
||||
<div key={participant.participant_id} className="border-subtle rounded border p-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="font-medium">{participant.user_name || t("anonymous_user")}</span>
|
||||
<span className="text-subtle">
|
||||
{participant.join_time ? dayjs(participant.join_time * 1000).format("h:mm A") : ""}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-subtle text-xs">
|
||||
{t("user_id")}: {participant.user_id || t("not_available")}
|
||||
</p>
|
||||
<p className="text-subtle text-xs">
|
||||
{t("participant_id")}: {participant.participant_id}
|
||||
</p>
|
||||
{participant.duration && (
|
||||
<p className="text-subtle text-xs">
|
||||
{t("session_duration")}: {formatDuration(participant.duration)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MeetingSessionDetailsDialog = (props: IMeetingSessionDetailsDialog) => {
|
||||
const { t, i18n } = useLocale();
|
||||
const { isOpenDialog, setIsOpenDialog, booking, timeFormat } = props;
|
||||
|
||||
const roomName =
|
||||
booking?.references?.find((reference: PartialReference) => reference.type === "daily_video")?.meetingId ??
|
||||
undefined;
|
||||
|
||||
const subtitle = `${booking?.title} - ${dayjs(booking?.startTime).format("ddd")} ${dayjs(
|
||||
booking?.startTime
|
||||
).format("D")}, ${dayjs(booking?.startTime).format("MMM")}`;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<DialogContent enableOverflow>
|
||||
<DialogHeader title={t("meeting_session_details")} subtitle={subtitle} />
|
||||
{roomName ? (
|
||||
<Suspense fallback={<MeetingSessionDetailsSkeleton />}>
|
||||
<MeetingSessionDetailsList roomName={roomName} />
|
||||
</Suspense>
|
||||
) : (
|
||||
<p className="font-semibold">{t("no_meeting_sessions_found")}</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<DialogClose className="border" />
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -37,6 +37,9 @@ export async function handler(req: NextRequest) {
|
||||
scheduledDate: {
|
||||
lte: dayjs().add(2, "hour").toISOString(),
|
||||
},
|
||||
retryCount: {
|
||||
lt: 3, // Don't continue retrying if it's already failed 3 times
|
||||
},
|
||||
},
|
||||
select: {
|
||||
...select,
|
||||
@@ -157,6 +160,8 @@ export async function handler(req: NextRequest) {
|
||||
}
|
||||
|
||||
if (message?.length && message?.length > 0 && sendTo) {
|
||||
const smsMessageWithoutOptOut = message;
|
||||
|
||||
if (process.env.TWILIO_OPT_OUT_ENABLED === "true") {
|
||||
message = await WorkflowOptOutService.addOptOutMessage(message, locale || "en");
|
||||
}
|
||||
@@ -167,6 +172,7 @@ export async function handler(req: NextRequest) {
|
||||
body: message,
|
||||
scheduledDate: reminder.scheduledDate,
|
||||
sender: senderID,
|
||||
bodyWithoutOptOut: smsMessageWithoutOptOut,
|
||||
bookingUid: reminder.booking.uid,
|
||||
userId,
|
||||
teamId,
|
||||
|
||||
@@ -14,6 +14,7 @@ export async function sendSmsOrFallbackEmail(props: {
|
||||
phoneNumber: string;
|
||||
body: string;
|
||||
sender: string;
|
||||
bodyWithoutOptOut?: string;
|
||||
bookingUid?: string | null;
|
||||
userId?: number | null;
|
||||
teamId?: number | null;
|
||||
@@ -40,7 +41,7 @@ export async function sendSmsOrFallbackEmail(props: {
|
||||
await sendOrScheduleWorkflowEmails({
|
||||
to: [fallbackData.email],
|
||||
subject: fallbackData.t("notification_about_your_booking"),
|
||||
html: twilioData.body,
|
||||
html: twilioData.bodyWithoutOptOut || twilioData.body,
|
||||
replyTo: fallbackData.replyTo,
|
||||
});
|
||||
}
|
||||
@@ -60,6 +61,7 @@ export async function scheduleSmsOrFallbackEmail(props: {
|
||||
body: string;
|
||||
scheduledDate: Date;
|
||||
sender: string;
|
||||
bodyWithoutOptOut?: string;
|
||||
bookingUid?: string | null;
|
||||
userId?: number | null;
|
||||
teamId?: number | null;
|
||||
@@ -96,7 +98,7 @@ export async function scheduleSmsOrFallbackEmail(props: {
|
||||
await sendOrScheduleWorkflowEmails({
|
||||
to: [fallbackData.email],
|
||||
subject: fallbackData.t("notification_about_your_booking"),
|
||||
html: twilioData.body,
|
||||
html: twilioData.bodyWithoutOptOut || twilioData.body,
|
||||
replyTo: fallbackData.replyTo,
|
||||
sendAt: twilioData.scheduledDate,
|
||||
referenceUid: reminder.uuid || undefined,
|
||||
|
||||
@@ -176,6 +176,8 @@ export const scheduleSMSReminder = async (args: ScheduleTextReminderArgs) => {
|
||||
}
|
||||
|
||||
if (smsMessage.length > 0) {
|
||||
const smsMessageWithoutOptOut = smsMessage;
|
||||
|
||||
if (process.env.TWILIO_OPT_OUT_ENABLED === "true") {
|
||||
smsMessage = await WorkflowOptOutService.addOptOutMessage(
|
||||
smsMessage,
|
||||
@@ -196,6 +198,7 @@ export const scheduleSMSReminder = async (args: ScheduleTextReminderArgs) => {
|
||||
phoneNumber: reminderPhone,
|
||||
body: smsMessage,
|
||||
sender: senderID,
|
||||
bodyWithoutOptOut: smsMessageWithoutOptOut,
|
||||
bookingUid: evt.uid,
|
||||
userId,
|
||||
teamId,
|
||||
|
||||
@@ -34,6 +34,11 @@ vi.mock("@calcom/prisma", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/getOrgIdFromMemberOrTeamId", () => ({
|
||||
default: vi.fn().mockResolvedValue(null),
|
||||
getPublishedOrgIdFromMemberOrTeamId: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
describe("Twilio Webhook Handler", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -117,10 +122,6 @@ describe("Twilio Webhook Handler", () => {
|
||||
mockFindFirst.mockResolvedValue(null);
|
||||
const webhookHandler = (await import("../../../../../../apps/web/pages/api/twilio/webhook")).default;
|
||||
|
||||
vi.mock("@calcom/lib/getOrgIdFromMemberOrTeamId", () => ({
|
||||
default: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
const mockRequest = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
||||
@@ -107,6 +107,66 @@ const getLocationInfo = ({
|
||||
return { locationAvailable, locationDetails };
|
||||
};
|
||||
|
||||
const LocationInput = (props: {
|
||||
eventLocationType: EventLocationType;
|
||||
defaultValue?: string;
|
||||
index: number;
|
||||
customClassNames?: LocationInputCustomClassNames;
|
||||
disableLocationProp?: boolean;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const { eventLocationType, index, customClassNames, disableLocationProp, ...remainingProps } = props;
|
||||
if (eventLocationType?.organizerInputType === "text") {
|
||||
const { defaultValue, ...rest } = remainingProps;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
|
||||
defaultValue={defaultValue}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<Input
|
||||
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
|
||||
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
|
||||
type="text"
|
||||
required
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
{...(disableLocationProp ? { disabled: true } : {})}
|
||||
className={classNames("my-0", customClassNames?.addressInput)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (eventLocationType?.organizerInputType === "phone") {
|
||||
const { defaultValue, ...rest } = remainingProps;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
|
||||
defaultValue={defaultValue}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<PhoneInput
|
||||
required
|
||||
disabled={disableLocationProp}
|
||||
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
|
||||
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
|
||||
className={customClassNames?.phoneInput}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const Locations: React.FC<LocationsProps> = ({
|
||||
isChildrenManagedEventType,
|
||||
disableLocationProp,
|
||||
@@ -168,64 +228,6 @@ const Locations: React.FC<LocationsProps> = ({
|
||||
locationOptions: props.locationOptions,
|
||||
});
|
||||
|
||||
const LocationInput = (props: {
|
||||
eventLocationType: EventLocationType;
|
||||
defaultValue?: string;
|
||||
index: number;
|
||||
customClassNames?: LocationInputCustomClassNames;
|
||||
}) => {
|
||||
const { eventLocationType, index, customClassNames, ...remainingProps } = props;
|
||||
if (eventLocationType?.organizerInputType === "text") {
|
||||
const { defaultValue, ...rest } = remainingProps;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
|
||||
defaultValue={defaultValue}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<Input
|
||||
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
|
||||
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
|
||||
type="text"
|
||||
required
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
{...(disableLocationProp ? { disabled: true } : {})}
|
||||
className={classNames("my-0", customClassNames?.addressInput)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (eventLocationType?.organizerInputType === "phone") {
|
||||
const { defaultValue, ...rest } = remainingProps;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
|
||||
defaultValue={defaultValue}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<PhoneInput
|
||||
required
|
||||
disabled={disableLocationProp}
|
||||
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
|
||||
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
|
||||
className={customClassNames?.phoneInput}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const [showEmptyLocationSelect, setShowEmptyLocationSelect] = useState(false);
|
||||
const defaultInitialLocation = defaultValue || null;
|
||||
const [selectedNewOption, setSelectedNewOption] = useState<SingleValueLocationOption | null>(
|
||||
@@ -459,6 +461,7 @@ const Locations: React.FC<LocationsProps> = ({
|
||||
eventLocationType={eventLocationType}
|
||||
index={index}
|
||||
customClassNames={customClassNames?.organizerContactInput?.locationInput}
|
||||
disableLocationProp={disableLocationProp}
|
||||
/>
|
||||
</div>
|
||||
<ErrorMessage
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useDataTable } from "@calcom/features/data-table";
|
||||
import NoSSR from "@calcom/lib/components/NoSSR";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
const TimezoneBadgeContent = () => {
|
||||
const { t } = useLocale();
|
||||
const { timeZone: userTimezone } = useDataTable();
|
||||
|
||||
const timezoneData = useMemo(() => {
|
||||
// Use Cal's standard CURRENT_TIMEZONE constant
|
||||
const browserTimezone = CURRENT_TIMEZONE;
|
||||
|
||||
if (!browserTimezone || !userTimezone || browserTimezone === userTimezone) return null;
|
||||
|
||||
const tooltipContent = t("timezone_mismatch_tooltip", {
|
||||
browserTimezone,
|
||||
userTimezone,
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
|
||||
return {
|
||||
browser: browserTimezone,
|
||||
user: userTimezone,
|
||||
tooltipContent,
|
||||
badgeContent: userTimezone,
|
||||
};
|
||||
}, [userTimezone, t]);
|
||||
|
||||
// Don't render anything if no timezone mismatch
|
||||
if (!timezoneData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip content={timezoneData.tooltipContent}>
|
||||
<Badge variant="gray" size="sm" data-testid="timezone-mismatch-badge">
|
||||
<Icon name="info" />
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export const TimezoneBadge = () => {
|
||||
return (
|
||||
<NoSSR fallback={null}>
|
||||
<TimezoneBadgeContent />
|
||||
</NoSSR>
|
||||
);
|
||||
};
|
||||
@@ -13,3 +13,4 @@ export { RecentFeedbackTable } from "./RecentFeedbackTable";
|
||||
export { RoutedToPerPeriod } from "./RoutedToPerPeriod";
|
||||
export { RoutingFormResponsesTable, type RoutingFormTableType } from "./RoutingFormResponsesTable";
|
||||
export { RoutingKPICards } from "./RoutingKPICards";
|
||||
export { TimezoneBadge } from "./TimezoneBadge";
|
||||
|
||||
@@ -30,16 +30,19 @@ describe("EventsInsights", () => {
|
||||
startDate: "2025-05-01T00:00:00.000Z",
|
||||
endDate: "2025-05-01T23:59:59.999Z",
|
||||
formattedDate: "May 1",
|
||||
formattedDateFull: "May 1",
|
||||
});
|
||||
expect(ranges[1]).toEqual({
|
||||
startDate: "2025-05-02T00:00:00.000Z",
|
||||
endDate: "2025-05-02T23:59:59.999Z",
|
||||
formattedDate: "2",
|
||||
formattedDateFull: "May 2",
|
||||
});
|
||||
expect(ranges[2]).toEqual({
|
||||
startDate: "2025-05-03T00:00:00.000Z",
|
||||
endDate: "2025-05-03T23:59:59.999Z",
|
||||
formattedDate: "3",
|
||||
formattedDateFull: "May 3",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,26 +67,31 @@ describe("EventsInsights", () => {
|
||||
startDate: "2025-05-01T00:00:00.000Z",
|
||||
endDate: "2025-05-03T23:59:59.999Z",
|
||||
formattedDate: "May 1 - 3",
|
||||
formattedDateFull: "May 1 - May 3",
|
||||
});
|
||||
expect(ranges[1]).toEqual({
|
||||
startDate: "2025-05-04T00:00:00.000Z",
|
||||
endDate: "2025-05-10T23:59:59.999Z",
|
||||
formattedDate: "May 4 - 10",
|
||||
formattedDateFull: "May 4 - May 10",
|
||||
});
|
||||
expect(ranges[2]).toEqual({
|
||||
startDate: "2025-05-11T00:00:00.000Z",
|
||||
endDate: "2025-05-17T23:59:59.999Z",
|
||||
formattedDate: "May 11 - 17",
|
||||
formattedDateFull: "May 11 - May 17",
|
||||
});
|
||||
expect(ranges[3]).toEqual({
|
||||
startDate: "2025-05-18T00:00:00.000Z",
|
||||
endDate: "2025-05-24T23:59:59.999Z",
|
||||
formattedDate: "May 18 - 24",
|
||||
formattedDateFull: "May 18 - May 24",
|
||||
});
|
||||
expect(ranges[4]).toEqual({
|
||||
startDate: "2025-05-25T00:00:00.000Z",
|
||||
endDate: "2025-05-25T23:59:59.999Z",
|
||||
formattedDate: "May 25 - 25",
|
||||
formattedDateFull: "May 25 - May 25",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,16 +116,19 @@ describe("EventsInsights", () => {
|
||||
startDate: "2025-05-15T00:00:00.000Z",
|
||||
endDate: "2025-05-31T23:59:59.999Z",
|
||||
formattedDate: "May",
|
||||
formattedDateFull: "May",
|
||||
});
|
||||
expect(ranges[1]).toEqual({
|
||||
startDate: "2025-06-01T00:00:00.000Z",
|
||||
endDate: "2025-06-30T23:59:59.999Z",
|
||||
formattedDate: "Jun",
|
||||
formattedDateFull: "Jun",
|
||||
});
|
||||
expect(ranges[2]).toEqual({
|
||||
startDate: "2025-07-01T00:00:00.000Z",
|
||||
endDate: "2025-07-15T23:59:59.999Z",
|
||||
formattedDate: "Jul",
|
||||
formattedDateFull: "Jul",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -142,16 +153,19 @@ describe("EventsInsights", () => {
|
||||
startDate: "2025-06-15T00:00:00.000Z",
|
||||
endDate: "2025-12-31T23:59:59.999Z",
|
||||
formattedDate: "2025",
|
||||
formattedDateFull: "2025",
|
||||
});
|
||||
expect(ranges[1]).toEqual({
|
||||
startDate: "2026-01-01T00:00:00.000Z",
|
||||
endDate: "2026-12-31T23:59:59.999Z",
|
||||
formattedDate: "2026",
|
||||
formattedDateFull: "2026",
|
||||
});
|
||||
expect(ranges[2]).toEqual({
|
||||
startDate: "2027-01-01T00:00:00.000Z",
|
||||
endDate: "2027-03-15T23:59:59.999Z",
|
||||
formattedDate: "2027",
|
||||
formattedDateFull: "2027",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,6 +190,7 @@ describe("EventsInsights", () => {
|
||||
startDate: "2025-05-01T00:00:00.000Z",
|
||||
endDate: "2025-05-01T23:59:59.999Z",
|
||||
formattedDate: "May 1",
|
||||
formattedDateFull: "May 1",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -205,6 +220,7 @@ describe("EventsInsights", () => {
|
||||
startDate: "2025-03-29T23:00:00.000Z", // March 30th 00:00 Paris time
|
||||
endDate: "2025-03-30T21:59:59.999Z", // March 30th 23:59:59 Paris time
|
||||
formattedDate: "Mar 30",
|
||||
formattedDateFull: "Mar 30",
|
||||
});
|
||||
expect(new Date(ranges[0].endDate).getTime() - new Date(ranges[0].startDate).getTime()).toBeLessThan(
|
||||
23 * 60 * 60 * 1000
|
||||
@@ -213,6 +229,7 @@ describe("EventsInsights", () => {
|
||||
startDate: "2025-03-30T22:00:00.000Z", // March 31st 00:00 Paris time
|
||||
endDate: "2025-03-31T21:59:59.999Z", // March 31st 23:59:59 Paris time
|
||||
formattedDate: "31",
|
||||
formattedDateFull: "Mar 31",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -237,16 +254,19 @@ describe("EventsInsights", () => {
|
||||
startDate: "2025-05-15T22:00:00.000Z",
|
||||
endDate: "2025-05-17T21:59:59.999Z",
|
||||
formattedDate: "May 16 - 17",
|
||||
formattedDateFull: "May 16 - May 17",
|
||||
});
|
||||
expect(ranges[1]).toEqual({
|
||||
startDate: "2025-05-17T22:00:00.000Z",
|
||||
endDate: "2025-05-24T21:59:59.999Z",
|
||||
formattedDate: "May 18 - 24",
|
||||
formattedDateFull: "May 18 - May 24",
|
||||
});
|
||||
expect(ranges[2]).toEqual({
|
||||
startDate: "2025-05-24T22:00:00.000Z",
|
||||
endDate: "2025-05-29T21:59:59.999Z",
|
||||
formattedDate: "May 25 - 29",
|
||||
formattedDateFull: "May 25 - May 29",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -271,11 +291,13 @@ describe("EventsInsights", () => {
|
||||
startDate: "2025-05-31T22:00:00.000Z",
|
||||
endDate: "2025-06-30T21:59:59.999Z",
|
||||
formattedDate: "Jun",
|
||||
formattedDateFull: "Jun",
|
||||
});
|
||||
expect(ranges[1]).toEqual({
|
||||
startDate: "2025-06-30T22:00:00.000Z",
|
||||
endDate: "2025-07-31T21:59:59.999Z",
|
||||
formattedDate: "Jul",
|
||||
formattedDateFull: "Jul",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -304,11 +326,13 @@ describe("EventsInsights", () => {
|
||||
startDate: "2025-05-14T15:00:00.000Z",
|
||||
endDate: "2025-05-15T14:59:59.999Z",
|
||||
formattedDate: "May 15",
|
||||
formattedDateFull: "May 15",
|
||||
});
|
||||
expect(ranges[1]).toEqual({
|
||||
startDate: "2025-05-15T15:00:00.000Z",
|
||||
endDate: "2025-05-16T14:59:59.999Z",
|
||||
formattedDate: "16",
|
||||
formattedDateFull: "May 16",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -333,16 +357,19 @@ describe("EventsInsights", () => {
|
||||
startDate: "2025-05-11T15:00:00.000Z",
|
||||
endDate: "2025-05-17T14:59:59.999Z",
|
||||
formattedDate: "May 12 - 17",
|
||||
formattedDateFull: "May 12 - May 17",
|
||||
});
|
||||
expect(ranges[1]).toEqual({
|
||||
startDate: "2025-05-17T15:00:00.000Z",
|
||||
endDate: "2025-05-24T14:59:59.999Z",
|
||||
formattedDate: "May 18 - 24",
|
||||
formattedDateFull: "May 18 - May 24",
|
||||
});
|
||||
expect(ranges[2]).toEqual({
|
||||
startDate: "2025-05-24T15:00:00.000Z",
|
||||
endDate: "2025-05-25T14:59:59.999Z",
|
||||
formattedDate: "May 25 - 25",
|
||||
formattedDateFull: "May 25 - May 25",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -367,11 +394,13 @@ describe("EventsInsights", () => {
|
||||
startDate: "2025-04-30T15:00:00.000Z",
|
||||
endDate: "2025-05-31T14:59:59.999Z",
|
||||
formattedDate: "May",
|
||||
formattedDateFull: "May",
|
||||
});
|
||||
expect(ranges[1]).toEqual({
|
||||
startDate: "2025-05-31T15:00:00.000Z",
|
||||
endDate: "2025-06-30T14:59:59.999Z",
|
||||
formattedDate: "Jun",
|
||||
formattedDateFull: "Jun",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -421,16 +450,19 @@ describe("EventsInsights", () => {
|
||||
startDate: "2025-05-01T00:00:00.000Z",
|
||||
endDate: "2025-05-04T23:59:59.999Z",
|
||||
formattedDate: "May 1 - 4",
|
||||
formattedDateFull: "May 1 - May 4",
|
||||
});
|
||||
expect(ranges[1]).toEqual({
|
||||
startDate: "2025-05-05T00:00:00.000Z",
|
||||
endDate: "2025-05-11T23:59:59.999Z",
|
||||
formattedDate: "May 5 - 11",
|
||||
formattedDateFull: "May 5 - May 11",
|
||||
});
|
||||
expect(ranges[2]).toEqual({
|
||||
startDate: "2025-05-12T00:00:00.000Z",
|
||||
endDate: "2025-05-14T23:59:59.999Z",
|
||||
formattedDate: "May 12 - 14",
|
||||
formattedDateFull: "May 12 - May 14",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -455,16 +487,19 @@ describe("EventsInsights", () => {
|
||||
startDate: "2025-05-01T00:00:00.000Z",
|
||||
endDate: "2025-05-03T23:59:59.999Z",
|
||||
formattedDate: "May 1 - 3",
|
||||
formattedDateFull: "May 1 - May 3",
|
||||
});
|
||||
expect(ranges[1]).toEqual({
|
||||
startDate: "2025-05-04T00:00:00.000Z",
|
||||
endDate: "2025-05-10T23:59:59.999Z",
|
||||
formattedDate: "May 4 - 10",
|
||||
formattedDateFull: "May 4 - May 10",
|
||||
});
|
||||
expect(ranges[2]).toEqual({
|
||||
startDate: "2025-05-11T00:00:00.000Z",
|
||||
endDate: "2025-05-14T23:59:59.999Z",
|
||||
formattedDate: "May 11 - 14",
|
||||
formattedDateFull: "May 11 - May 14",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -489,16 +524,19 @@ describe("EventsInsights", () => {
|
||||
startDate: "2025-05-01T00:00:00.000Z",
|
||||
endDate: "2025-05-02T23:59:59.999Z",
|
||||
formattedDate: "May 1 - 2",
|
||||
formattedDateFull: "May 1 - May 2",
|
||||
});
|
||||
expect(ranges[1]).toEqual({
|
||||
startDate: "2025-05-03T00:00:00.000Z",
|
||||
endDate: "2025-05-09T23:59:59.999Z",
|
||||
formattedDate: "May 3 - 9",
|
||||
formattedDateFull: "May 3 - May 9",
|
||||
});
|
||||
expect(ranges[2]).toEqual({
|
||||
startDate: "2025-05-10T00:00:00.000Z",
|
||||
endDate: "2025-05-14T23:59:59.999Z",
|
||||
formattedDate: "May 10 - 14",
|
||||
formattedDateFull: "May 10 - May 14",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -846,4 +884,204 @@ describe("EventsInsights", () => {
|
||||
expect(result).toBe("day");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDateRanges - Formatting Comparison", () => {
|
||||
const timeZone = "UTC";
|
||||
|
||||
describe("Daily view formatting differences", () => {
|
||||
it("should show smart vs full formatting for consecutive days", () => {
|
||||
const startDate = "2024-01-15T00:00:00.000Z";
|
||||
const endDate = "2024-01-19T23:59:59.999Z";
|
||||
|
||||
const ranges = EventsInsights.getDateRanges({
|
||||
startDate,
|
||||
endDate,
|
||||
timeZone,
|
||||
timeView: "day",
|
||||
weekStart: "Monday",
|
||||
});
|
||||
|
||||
if (!ranges) {
|
||||
throw new Error("Expected ranges to be defined");
|
||||
}
|
||||
|
||||
expect(ranges).toHaveLength(5);
|
||||
|
||||
const formattedDates = ranges.map((r) => r.formattedDate);
|
||||
const formattedDatesFull = ranges.map((r) => r.formattedDateFull);
|
||||
|
||||
expect(formattedDates).toEqual(["Jan 15", "16", "17", "18", "19"]);
|
||||
|
||||
expect(formattedDatesFull).toEqual(["Jan 15", "Jan 16", "Jan 17", "Jan 18", "Jan 19"]);
|
||||
});
|
||||
|
||||
it("should show formatting differences across month boundaries", () => {
|
||||
const startDate = "2024-01-30T00:00:00.000Z";
|
||||
const endDate = "2024-02-02T23:59:59.999Z";
|
||||
|
||||
const ranges = EventsInsights.getDateRanges({
|
||||
startDate,
|
||||
endDate,
|
||||
timeZone,
|
||||
timeView: "day",
|
||||
weekStart: "Monday",
|
||||
});
|
||||
|
||||
if (!ranges) {
|
||||
throw new Error("Expected ranges to be defined");
|
||||
}
|
||||
|
||||
expect(ranges).toHaveLength(4);
|
||||
|
||||
const formattedDates = ranges.map((r) => r.formattedDate);
|
||||
const formattedDatesFull = ranges.map((r) => r.formattedDateFull);
|
||||
|
||||
expect(formattedDates).toEqual(["Jan 30", "31", "Feb 1", "2"]);
|
||||
|
||||
expect(formattedDatesFull).toEqual(["Jan 30", "Jan 31", "Feb 1", "Feb 2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Weekly view formatting differences", () => {
|
||||
it("should show smart vs full formatting for weekly ranges", () => {
|
||||
const startDate = "2024-01-15T00:00:00.000Z"; // Monday
|
||||
const endDate = "2024-01-28T23:59:59.999Z"; // Sunday
|
||||
|
||||
const ranges = EventsInsights.getDateRanges({
|
||||
startDate,
|
||||
endDate,
|
||||
timeZone,
|
||||
timeView: "week",
|
||||
weekStart: "Monday",
|
||||
});
|
||||
|
||||
if (!ranges) {
|
||||
throw new Error("Expected ranges to be defined");
|
||||
}
|
||||
|
||||
expect(ranges).toHaveLength(2);
|
||||
|
||||
const formattedDates = ranges.map((r) => r.formattedDate);
|
||||
const formattedDatesFull = ranges.map((r) => r.formattedDateFull);
|
||||
|
||||
expect(formattedDates).toEqual(["Jan 15 - 21", "Jan 22 - 28"]);
|
||||
|
||||
expect(formattedDatesFull).toEqual(["Jan 15 - Jan 21", "Jan 22 - Jan 28"]);
|
||||
});
|
||||
|
||||
it("should show formatting differences for cross-month weekly ranges", () => {
|
||||
const startDate = "2024-01-29T00:00:00.000Z"; // Monday
|
||||
const endDate = "2024-02-11T23:59:59.999Z"; // Sunday
|
||||
|
||||
const ranges = EventsInsights.getDateRanges({
|
||||
startDate,
|
||||
endDate,
|
||||
timeZone,
|
||||
timeView: "week",
|
||||
weekStart: "Monday",
|
||||
});
|
||||
|
||||
if (!ranges) {
|
||||
throw new Error("Expected ranges to be defined");
|
||||
}
|
||||
|
||||
expect(ranges).toHaveLength(2);
|
||||
|
||||
const formattedDates = ranges.map((r) => r.formattedDate);
|
||||
const formattedDatesFull = ranges.map((r) => r.formattedDateFull);
|
||||
|
||||
expect(formattedDates).toEqual(["Jan 29 - Feb 4", "Feb 5 - 11"]);
|
||||
|
||||
expect(formattedDatesFull).toEqual(["Jan 29 - Feb 4", "Feb 5 - Feb 11"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Monthly view formatting differences", () => {
|
||||
it("should show consistent formatting for monthly ranges", () => {
|
||||
const startDate = "2024-01-15T00:00:00.000Z";
|
||||
const endDate = "2024-03-15T23:59:59.999Z";
|
||||
|
||||
const ranges = EventsInsights.getDateRanges({
|
||||
startDate,
|
||||
endDate,
|
||||
timeZone,
|
||||
timeView: "month",
|
||||
weekStart: "Monday",
|
||||
});
|
||||
|
||||
if (!ranges) {
|
||||
throw new Error("Expected ranges to be defined");
|
||||
}
|
||||
|
||||
expect(ranges).toHaveLength(3);
|
||||
|
||||
const formattedDates = ranges.map((r) => r.formattedDate);
|
||||
const formattedDatesFull = ranges.map((r) => r.formattedDateFull);
|
||||
|
||||
expect(formattedDates).toEqual(["Jan", "Feb", "Mar"]);
|
||||
|
||||
expect(formattedDatesFull).toEqual(["Jan", "Feb", "Mar"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Real-world usage examples", () => {
|
||||
it("should demonstrate practical differences for chart display", () => {
|
||||
// Simulate a common analytics scenario: 7 days of data
|
||||
const startDate = "2024-01-15T00:00:00.000Z";
|
||||
const endDate = "2024-01-21T23:59:59.999Z";
|
||||
|
||||
const ranges = EventsInsights.getDateRanges({
|
||||
startDate,
|
||||
endDate,
|
||||
timeZone,
|
||||
timeView: "day",
|
||||
weekStart: "Monday",
|
||||
});
|
||||
|
||||
if (!ranges) {
|
||||
throw new Error("Expected ranges to be defined");
|
||||
}
|
||||
|
||||
const formattedDates = ranges.map((r) => r.formattedDate);
|
||||
const formattedDatesFull = ranges.map((r) => r.formattedDateFull);
|
||||
|
||||
expect(formattedDates).toEqual(["Jan 15", "16", "17", "18", "19", "20", "21"]);
|
||||
|
||||
expect(formattedDatesFull).toEqual([
|
||||
"Jan 15",
|
||||
"Jan 16",
|
||||
"Jan 17",
|
||||
"Jan 18",
|
||||
"Jan 19",
|
||||
"Jan 20",
|
||||
"Jan 21",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should show benefits of full formatting for data export", () => {
|
||||
// Simulate data that might be exported or used in external systems
|
||||
const startDate = "2024-01-30T00:00:00.000Z";
|
||||
const endDate = "2024-02-05T23:59:59.999Z";
|
||||
|
||||
const ranges = EventsInsights.getDateRanges({
|
||||
startDate,
|
||||
endDate,
|
||||
timeZone,
|
||||
timeView: "day",
|
||||
weekStart: "Monday",
|
||||
});
|
||||
|
||||
if (!ranges) {
|
||||
throw new Error("Expected ranges to be defined");
|
||||
}
|
||||
|
||||
const formattedDates = ranges.map((r) => r.formattedDate);
|
||||
const formattedDatesFull = ranges.map((r) => r.formattedDateFull);
|
||||
|
||||
expect(formattedDates).toEqual(["Jan 30", "31", "Feb 1", "2", "3", "4", "5"]);
|
||||
|
||||
expect(formattedDatesFull).toEqual(["Jan 30", "Jan 31", "Feb 1", "Feb 2", "Feb 3", "Feb 4", "Feb 5"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,6 +65,7 @@ export interface DateRange {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
formattedDate: string;
|
||||
formattedDateFull: string;
|
||||
}
|
||||
|
||||
export interface GetDateRangesParams {
|
||||
@@ -330,6 +331,13 @@ class EventsInsights {
|
||||
wholeStart: startDate,
|
||||
wholeEnd: endDate,
|
||||
}),
|
||||
formattedDateFull: this.formatPeriodFull({
|
||||
start: currentStartDate,
|
||||
end: currentEndDate,
|
||||
timeView,
|
||||
wholeStart: startDate,
|
||||
wholeEnd: endDate,
|
||||
}),
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -344,6 +352,13 @@ class EventsInsights {
|
||||
wholeStart: startDate,
|
||||
wholeEnd: endDate,
|
||||
}),
|
||||
formattedDateFull: this.formatPeriodFull({
|
||||
start: currentStartDate,
|
||||
end: currentEndDate,
|
||||
timeView,
|
||||
wholeStart: startDate,
|
||||
wholeEnd: endDate,
|
||||
}),
|
||||
});
|
||||
|
||||
currentStartDate = currentEndDate.add(1, "day").startOf("day").tz(timeZone);
|
||||
@@ -401,6 +416,46 @@ class EventsInsights {
|
||||
}
|
||||
}
|
||||
|
||||
static formatPeriodFull({
|
||||
start,
|
||||
end,
|
||||
timeView,
|
||||
wholeStart,
|
||||
wholeEnd,
|
||||
}: {
|
||||
start: dayjs.Dayjs;
|
||||
end: dayjs.Dayjs;
|
||||
timeView: TimeViewType;
|
||||
wholeStart: dayjs.Dayjs;
|
||||
wholeEnd: dayjs.Dayjs;
|
||||
}): string {
|
||||
const omitYear = wholeStart.year() === wholeEnd.year();
|
||||
|
||||
switch (timeView) {
|
||||
case "day":
|
||||
return omitYear ? start.format("MMM D") : start.format("MMM D, YYYY");
|
||||
case "week":
|
||||
const startFormat = "MMM D";
|
||||
const endFormat = "MMM D";
|
||||
|
||||
if (start.format("YYYY") !== end.format("YYYY")) {
|
||||
return `${start.format(`${startFormat}, YYYY`)} - ${end.format(`${endFormat}, YYYY`)}`;
|
||||
}
|
||||
|
||||
if (omitYear) {
|
||||
return `${start.format(startFormat)} - ${end.format(endFormat)}`;
|
||||
} else {
|
||||
return `${start.format(startFormat)} - ${end.format(endFormat)}, ${end.format("YYYY")}`;
|
||||
}
|
||||
case "month":
|
||||
return omitYear ? start.format("MMM") : start.format("MMM YYYY");
|
||||
case "year":
|
||||
return start.format("YYYY");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
static getCsvData = async (
|
||||
props: RawDataInput & {
|
||||
organizationId: number | null;
|
||||
@@ -484,16 +539,34 @@ class EventsInsights {
|
||||
: booking.attendees;
|
||||
|
||||
const formattedAttendees = attendeeList
|
||||
.slice(0, 3)
|
||||
.map((attendee) => (attendee ? `${attendee.name} (${attendee.email})` : null));
|
||||
.map((attendee) => (attendee ? `${attendee.name} (${attendee.email})` : null))
|
||||
.filter(Boolean);
|
||||
|
||||
return [
|
||||
booking.uid,
|
||||
{ attendeeList: formattedAttendees, noShowGuest: attendeeList[0]?.noShow || false },
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
const maxAttendees = Math.max(
|
||||
...Array.from(bookingMap.values()).map((data) => data.attendeeList.length),
|
||||
0
|
||||
);
|
||||
|
||||
const finalBookingMap = new Map(
|
||||
Array.from(bookingMap.entries()).map(([uid, data]) => {
|
||||
const attendeeFields: Record<string, string | null> = {};
|
||||
|
||||
for (let i = 1; i <= maxAttendees; i++) {
|
||||
attendeeFields[`attendee${i}`] = data.attendeeList[i - 1] || null;
|
||||
}
|
||||
|
||||
return [
|
||||
uid,
|
||||
{
|
||||
noShowGuest: attendeeList[0]?.noShow || false,
|
||||
attendee1: formattedAttendees[0] || null,
|
||||
attendee2: formattedAttendees[1] || null,
|
||||
attendee3: formattedAttendees[2] || null,
|
||||
noShowGuest: data.noShowGuest,
|
||||
...attendeeFields,
|
||||
},
|
||||
];
|
||||
})
|
||||
@@ -502,33 +575,37 @@ class EventsInsights {
|
||||
const data = csvData.map((bookingTimeStatus) => {
|
||||
if (!bookingTimeStatus.uid) {
|
||||
// should not be reached because we filtered above
|
||||
const nullAttendeeFields: Record<string, null> = {};
|
||||
for (let i = 1; i <= maxAttendees; i++) {
|
||||
nullAttendeeFields[`attendee${i}`] = null;
|
||||
}
|
||||
|
||||
return {
|
||||
...bookingTimeStatus,
|
||||
noShowGuest: false,
|
||||
attendee1: null,
|
||||
attendee2: null,
|
||||
attendee3: null,
|
||||
...nullAttendeeFields,
|
||||
};
|
||||
}
|
||||
|
||||
const attendeeData = bookingMap.get(bookingTimeStatus.uid);
|
||||
const attendeeData = finalBookingMap.get(bookingTimeStatus.uid);
|
||||
|
||||
if (!attendeeData) {
|
||||
const nullAttendeeFields: Record<string, null> = {};
|
||||
for (let i = 1; i <= maxAttendees; i++) {
|
||||
nullAttendeeFields[`attendee${i}`] = null;
|
||||
}
|
||||
|
||||
return {
|
||||
...bookingTimeStatus,
|
||||
noShowGuest: false,
|
||||
attendee1: null,
|
||||
attendee2: null,
|
||||
attendee3: null,
|
||||
...nullAttendeeFields,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...bookingTimeStatus,
|
||||
noShowGuest: attendeeData.noShowGuest,
|
||||
attendee1: attendeeData.attendee1,
|
||||
attendee2: attendeeData.attendee2,
|
||||
attendee3: attendeeData.attendee3,
|
||||
...Object.fromEntries(Object.entries(attendeeData).filter(([key]) => key.startsWith("attendee"))),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -382,9 +382,11 @@ function OutOfOfficeEntriesListContent() {
|
||||
<div className="w-12" />
|
||||
</div>
|
||||
<div className="dark:bg-darkgray-50 text-inverted relative z-0 flex h-[70px] w-[70px] items-center justify-center rounded-3xl border-2 border-[#e5e7eb] bg-white">
|
||||
<Icon name="clock" size={28} />
|
||||
<Icon name="clock" size={28} className="text-black" />
|
||||
<div className="dark:bg-darkgray-50 absolute right-4 top-5 h-[12px] w-[12px] rotate-[56deg] bg-white text-lg font-bold" />
|
||||
<span className="absolute right-4 top-3 font-sans text-sm font-extrabold">z</span>
|
||||
<span className="absolute right-4 top-3 font-sans text-sm font-extrabold text-black">
|
||||
z
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { CookieOption, CookiesOptions } from "next-auth";
|
||||
|
||||
import { isENVDev } from "@calcom/lib/env";
|
||||
|
||||
/**
|
||||
* Copy from 'https://github.com/nextauthjs/next-auth/blob/227ff2259f/src/core/lib/cookie.ts' as we can't import it directly
|
||||
*
|
||||
@@ -20,12 +18,7 @@ export function defaultCookies(useSecureCookies: boolean): CookiesOptions {
|
||||
const cookiePrefix = useSecureCookies ? "__Secure-" : "";
|
||||
|
||||
const defaultOptions: CookieOption["options"] = {
|
||||
domain: isENVDev
|
||||
? process.env.ORGANIZATIONS_ENABLED
|
||||
? //FIXME: This is causing login to not work if someone uses anything other .cal.local for testing
|
||||
".cal.local"
|
||||
: undefined
|
||||
: NEXTAUTH_COOKIE_DOMAIN,
|
||||
domain: NEXTAUTH_COOKIE_DOMAIN || undefined,
|
||||
// To enable cookies on widgets,
|
||||
// https://stackoverflow.com/questions/45094712/iframe-not-reading-cookies-in-chrome
|
||||
// But we need to set it as `lax` in development
|
||||
|
||||
@@ -199,8 +199,10 @@ export const getUsernameList = (users: string | string[] | undefined): string[]
|
||||
// Multiple users can come in case of a team round-robin booking and in that case dynamic link won't be a user.
|
||||
// So, even though this code handles even if individual user is dynamic link, that isn't a possibility right now.
|
||||
users = arrayCast(users);
|
||||
|
||||
const allUsers = users.map((user) => user.replace(/( |%20|%2b)/gi, "+").split("+")).flat();
|
||||
const allUsers = users
|
||||
.map((user) => user.replace(/( |%20|%2b)/gi, "+").split("+"))
|
||||
.flat()
|
||||
.filter(Boolean);
|
||||
return Array.prototype.concat(...allUsers.map((userSlug) => slugify(userSlug)));
|
||||
};
|
||||
|
||||
|
||||
@@ -104,6 +104,8 @@ export function getEventName(eventNameObj: EventNameObjectType, forAttendeeView
|
||||
}
|
||||
|
||||
dynamicEventName = dynamicEventName.replace(`{${variable}}`, fieldValue || "");
|
||||
} else {
|
||||
dynamicEventName = dynamicEventName.replace(`{${variable}}`, "");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+97
@@ -42,4 +42,101 @@ describe("filterRedundantDateRanges", () => {
|
||||
|
||||
expect(filterRedundantDateRanges(dateRanges)).toEqual(dateRanges);
|
||||
});
|
||||
|
||||
it("should handle three identical ranges", () => {
|
||||
const dateRanges = [
|
||||
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") },
|
||||
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") },
|
||||
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") },
|
||||
];
|
||||
|
||||
const result = filterRedundantDateRanges(dateRanges);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].start.format()).toEqual(dayjs("2025-01-23T11:00:00.000Z").format());
|
||||
expect(result[0].end.format()).toEqual(dayjs("2025-01-23T12:00:00.000Z").format());
|
||||
});
|
||||
|
||||
it("should handle nested containment scenario", () => {
|
||||
const dateRanges = [
|
||||
{ start: dayjs("2025-01-23T10:00:00.000Z"), end: dayjs("2025-01-23T14:00:00.000Z") },
|
||||
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T13:00:00.000Z") },
|
||||
{ start: dayjs("2025-01-23T11:30:00.000Z"), end: dayjs("2025-01-23T12:30:00.000Z") },
|
||||
];
|
||||
|
||||
const result = filterRedundantDateRanges(dateRanges);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].start.format()).toEqual(dayjs("2025-01-23T10:00:00.000Z").format());
|
||||
expect(result[0].end.format()).toEqual(dayjs("2025-01-23T14:00:00.000Z").format());
|
||||
});
|
||||
|
||||
it("should handle ranges with same start time", () => {
|
||||
const dateRanges = [
|
||||
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T14:00:00.000Z") },
|
||||
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T13:00:00.000Z") },
|
||||
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") },
|
||||
];
|
||||
|
||||
const result = filterRedundantDateRanges(dateRanges);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].end.format()).toEqual(dayjs("2025-01-23T14:00:00.000Z").format());
|
||||
});
|
||||
|
||||
it("should handle ranges with same end time", () => {
|
||||
const dateRanges = [
|
||||
{ start: dayjs("2025-01-23T09:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") },
|
||||
{ start: dayjs("2025-01-23T10:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") },
|
||||
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") },
|
||||
];
|
||||
|
||||
const result = filterRedundantDateRanges(dateRanges);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].start.format()).toEqual(dayjs("2025-01-23T09:00:00.000Z").format());
|
||||
});
|
||||
|
||||
it("should preserve invalid ranges", () => {
|
||||
const dateRanges = [
|
||||
{ start: dayjs("2025-01-23T12:00:00.000Z"), end: dayjs("2025-01-23T11:00:00.000Z") },
|
||||
{ start: dayjs("2025-01-23T10:00:00.000Z"), end: dayjs("2025-01-23T14:00:00.000Z") },
|
||||
];
|
||||
|
||||
const result = filterRedundantDateRanges(dateRanges);
|
||||
expect(result.length).toBe(2);
|
||||
});
|
||||
|
||||
it("should handle touching ranges", () => {
|
||||
const dateRanges = [
|
||||
{ start: dayjs("2025-01-23T10:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") },
|
||||
{ start: dayjs("2025-01-23T12:00:00.000Z"), end: dayjs("2025-01-23T14:00:00.000Z") },
|
||||
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T11:30:00.000Z") },
|
||||
];
|
||||
|
||||
const result = filterRedundantDateRanges(dateRanges);
|
||||
expect(result.length).toBe(2);
|
||||
});
|
||||
|
||||
it("should handle overlapping but non-containing ranges", () => {
|
||||
const dateRanges = [
|
||||
{ start: dayjs("2025-01-23T10:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") },
|
||||
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T13:00:00.000Z") },
|
||||
];
|
||||
|
||||
const result = filterRedundantDateRanges(dateRanges);
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0].start.format()).toEqual(dayjs("2025-01-23T10:00:00.000Z").format());
|
||||
expect(result[1].start.format()).toEqual(dayjs("2025-01-23T11:00:00.000Z").format());
|
||||
});
|
||||
|
||||
it("should handle complex overlapping pattern that exposed cubic-dev-ai bug", () => {
|
||||
const dateRanges = [
|
||||
{ start: dayjs("2025-01-23T10:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") },
|
||||
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T13:00:00.000Z") },
|
||||
{ start: dayjs("2025-01-23T09:00:00.000Z"), end: dayjs("2025-01-23T11:30:00.000Z") },
|
||||
];
|
||||
|
||||
const result = filterRedundantDateRanges(dateRanges);
|
||||
expect(result.length).toBe(3);
|
||||
expect(result[0].start.format()).toEqual(dayjs("2025-01-23T09:00:00.000Z").format());
|
||||
expect(result[1].start.format()).toEqual(dayjs("2025-01-23T10:00:00.000Z").format());
|
||||
expect(result[2].start.format()).toEqual(dayjs("2025-01-23T11:00:00.000Z").format());
|
||||
});
|
||||
});
|
||||
|
||||
+23
-10
@@ -1,7 +1,9 @@
|
||||
import type { DateRange } from "@calcom/lib/date-ranges";
|
||||
import { IntervalTree, ContainmentSearchAlgorithm, createIntervalNodes } from "@calcom/lib/intervalTree";
|
||||
|
||||
/**
|
||||
* Filters out date ranges that are completely covered by other date ranges.
|
||||
* Uses an interval tree for O(n log n) worst-case complexity.
|
||||
* Unlike mergeOverlappingDateRanges, this doesn't merge overlapping ranges,
|
||||
* it only removes ranges that are completely contained within others.
|
||||
*/
|
||||
@@ -9,27 +11,38 @@ export function filterRedundantDateRanges(dateRanges: DateRange[]): DateRange[]
|
||||
if (dateRanges.length <= 1) return dateRanges;
|
||||
|
||||
const sortedRanges = [...dateRanges].sort((a, b) => a.start.valueOf() - b.start.valueOf());
|
||||
const intervalNodes = createIntervalNodes(
|
||||
sortedRanges,
|
||||
(range) => range.start.valueOf(),
|
||||
(range) => range.end.valueOf()
|
||||
);
|
||||
const intervalTree = new IntervalTree(intervalNodes);
|
||||
const searchAlgorithm = new ContainmentSearchAlgorithm(intervalTree);
|
||||
|
||||
return sortedRanges.filter((range, index) => {
|
||||
if (range.end.valueOf() < range.start.valueOf()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (let i = 0; i < sortedRanges.length; i++) {
|
||||
if (i === index) continue; // Skip comparing with itself
|
||||
const containingIntervals = searchAlgorithm.findContainingIntervals(
|
||||
range.start.valueOf(),
|
||||
range.end.valueOf(),
|
||||
index
|
||||
);
|
||||
|
||||
const otherRange = sortedRanges[i];
|
||||
|
||||
if (otherRange.end.valueOf() < otherRange.start.valueOf()) {
|
||||
continue;
|
||||
}
|
||||
for (const containingNode of containingIntervals) {
|
||||
const otherRange = containingNode.item;
|
||||
const otherIndex = containingNode.index;
|
||||
|
||||
if (
|
||||
otherRange.start.valueOf() <= range.start.valueOf() &&
|
||||
otherRange.end.valueOf() >= range.end.valueOf()
|
||||
otherRange.start.valueOf() === range.start.valueOf() &&
|
||||
otherRange.end.valueOf() === range.end.valueOf()
|
||||
) {
|
||||
return false; // This range is redundant, filter it out
|
||||
return otherIndex > index; // Keep current range only if other range has higher index
|
||||
}
|
||||
|
||||
// If we reach here, the other range actually contains this range
|
||||
return false;
|
||||
}
|
||||
|
||||
return true; // Keep this range
|
||||
|
||||
@@ -1,45 +1,70 @@
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
const getOrgMemberOrTeamWhere = (memberId?: number | null, teamId?: number | null) => {
|
||||
const conditions: Prisma.TeamWhereInput[] = [];
|
||||
|
||||
if (memberId) {
|
||||
conditions.push({
|
||||
AND: [
|
||||
{
|
||||
members: {
|
||||
some: {
|
||||
userId: memberId,
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
isOrganization: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (teamId) {
|
||||
conditions.push({
|
||||
AND: [
|
||||
{
|
||||
children: {
|
||||
some: {
|
||||
id: teamId,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
isOrganization: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
OR: conditions,
|
||||
};
|
||||
};
|
||||
|
||||
export default async function getOrgIdFromMemberOrTeamId(args: {
|
||||
memberId?: number | null;
|
||||
teamId?: number | null;
|
||||
}) {
|
||||
const userId = args.memberId ?? 0;
|
||||
const teamId = args.teamId ?? 0;
|
||||
const orgId = await prisma.team.findFirst({
|
||||
where: getOrgMemberOrTeamWhere(args.memberId, args.teamId),
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
return orgId?.id;
|
||||
}
|
||||
|
||||
export async function getPublishedOrgIdFromMemberOrTeamId(args: {
|
||||
memberId?: number | null;
|
||||
teamId?: number | null;
|
||||
}) {
|
||||
const orgId = await prisma.team.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
AND: [
|
||||
{
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
isOrganization: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
AND: [
|
||||
{
|
||||
children: {
|
||||
some: {
|
||||
id: teamId,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
isOrganization: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
...getOrgMemberOrTeamWhere(args.memberId, args.teamId),
|
||||
slug: { not: null },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
export interface IntervalNode<T> {
|
||||
item: T;
|
||||
index: number;
|
||||
start: number;
|
||||
end: number;
|
||||
maxEnd: number;
|
||||
left?: IntervalNode<T>;
|
||||
right?: IntervalNode<T>;
|
||||
}
|
||||
|
||||
export function createIntervalNodes<T>(
|
||||
items: T[],
|
||||
getStart: (item: T) => number,
|
||||
getEnd: (item: T) => number
|
||||
): IntervalNode<T>[] {
|
||||
return items.map((item, index) => ({
|
||||
item,
|
||||
index,
|
||||
start: getStart(item),
|
||||
end: getEnd(item),
|
||||
maxEnd: getEnd(item),
|
||||
}));
|
||||
}
|
||||
|
||||
export class IntervalTree<T> {
|
||||
private root?: IntervalNode<T>;
|
||||
|
||||
constructor(nodes: IntervalNode<T>[]) {
|
||||
this.root = this.buildTree([...nodes]);
|
||||
}
|
||||
|
||||
private buildTree(nodes: IntervalNode<T>[]): IntervalNode<T> | undefined {
|
||||
if (nodes.length === 0) return undefined;
|
||||
|
||||
const mid = Math.floor(nodes.length / 2);
|
||||
const node = nodes[mid];
|
||||
|
||||
const leftNodes = nodes.slice(0, mid);
|
||||
const rightNodes = nodes.slice(mid + 1);
|
||||
|
||||
node.left = this.buildTree(leftNodes);
|
||||
node.right = this.buildTree(rightNodes);
|
||||
|
||||
node.maxEnd = Math.max(node.end, node.left?.maxEnd ?? 0, node.right?.maxEnd ?? 0);
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
getRoot(): IntervalNode<T> | undefined {
|
||||
return this.root;
|
||||
}
|
||||
}
|
||||
|
||||
export class ContainmentSearchAlgorithm<T> {
|
||||
private tree: IntervalTree<T>;
|
||||
|
||||
constructor(tree: IntervalTree<T>) {
|
||||
this.tree = tree;
|
||||
}
|
||||
|
||||
findContainingIntervals(targetStart: number, targetEnd: number, targetIndex: number): IntervalNode<T>[] {
|
||||
const result: IntervalNode<T>[] = [];
|
||||
this.searchContaining(this.tree.getRoot(), targetStart, targetEnd, targetIndex, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private searchContaining(
|
||||
node: IntervalNode<T> | undefined,
|
||||
targetStart: number,
|
||||
targetEnd: number,
|
||||
targetIndex: number,
|
||||
result: IntervalNode<T>[]
|
||||
): void {
|
||||
if (!node) return;
|
||||
|
||||
if (node.end < node.start) {
|
||||
this.searchContaining(node.left, targetStart, targetEnd, targetIndex, result);
|
||||
this.searchContaining(node.right, targetStart, targetEnd, targetIndex, result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.start <= targetStart && node.end >= targetEnd && node.index !== targetIndex) {
|
||||
result.push(node);
|
||||
}
|
||||
|
||||
if (node.left && node.left.maxEnd >= targetStart) {
|
||||
this.searchContaining(node.left, targetStart, targetEnd, targetIndex, result);
|
||||
}
|
||||
|
||||
if (node.right && node.start <= targetEnd) {
|
||||
this.searchContaining(node.right, targetStart, targetEnd, targetIndex, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import "@calcom/lib/__mocks__/logger";
|
||||
|
||||
import { createHash } from "crypto";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -12,12 +13,14 @@ import { substituteVariables } from "@calcom/app-store/routing-forms/lib/substit
|
||||
import { getUrlSearchParamsToForward } from "@calcom/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { isAuthorizedToViewFormOnOrgDomain } from "@calcom/features/routing-forms/lib/isAuthorizedToViewForm";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { RoutingFormRepository } from "@calcom/lib/server/repository/routingForm";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
|
||||
import { getRoutedUrl } from "./getRoutedUrl";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@calcom/lib/checkRateLimitAndThrowError");
|
||||
vi.mock("@calcom/app-store/routing-forms/lib/handleResponse");
|
||||
vi.mock("@calcom/lib/server/repository/routingForm");
|
||||
vi.mock("@calcom/lib/server/repository/user");
|
||||
@@ -112,6 +115,7 @@ describe("getRoutedUrl", () => {
|
||||
it("should return notFound if form is not found", async () => {
|
||||
vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(null);
|
||||
const context = mockContext({});
|
||||
|
||||
const result = await getRoutedUrl(context);
|
||||
expect(result).toEqual({ notFound: true });
|
||||
expect(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).toHaveBeenCalledWith("form-id");
|
||||
@@ -259,6 +263,19 @@ describe("getRoutedUrl", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw an error if rate limit is exceeded", async () => {
|
||||
vi.mocked(checkRateLimitAndThrowError).mockRejectedValue(new Error("Rate limit exceeded"));
|
||||
const context = mockContext({ email: "test@cal.com" });
|
||||
const expectedHash = createHash("sha256")
|
||||
.update(JSON.stringify({ email: "test@cal.com" }))
|
||||
.digest("hex");
|
||||
|
||||
await expect(getRoutedUrl(context)).rejects.toThrow("Rate limit exceeded");
|
||||
expect(checkRateLimitAndThrowError).toHaveBeenCalledWith({
|
||||
identifier: `form:form-id:hash:${expectedHash}`,
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dry Run", () => {
|
||||
it("should call handleResponse with isPreview:true", async () => {
|
||||
vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never);
|
||||
|
||||
@@ -44,7 +44,7 @@ const getDeterministicHashForResponse = (fieldsResponses: Record<string, unknown
|
||||
return hash;
|
||||
};
|
||||
|
||||
function hasEmbedPath(pathWithQuery: string) {
|
||||
export function hasEmbedPath(pathWithQuery: string) {
|
||||
const onlyPath = pathWithQuery.split("?")[0];
|
||||
return onlyPath.endsWith("/embed") || onlyPath.endsWith("/embed/");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
export class AssignmentReasonRepository {
|
||||
static async findLatestReasonFromBookingUid(bookingUid: string) {
|
||||
return await prisma.assignmentReason.findFirst({
|
||||
where: {
|
||||
booking: {
|
||||
uid: bookingUid,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,70 @@ export class ScheduleRepository {
|
||||
isDefault: !scheduleId || defaultScheduleId === schedule.id,
|
||||
isLastSchedule: schedulesCount <= 1,
|
||||
readOnly: schedule.userId !== userId && !isManagedEventType,
|
||||
userId: schedule.userId,
|
||||
};
|
||||
}
|
||||
|
||||
static async findManyDetailedScheduleByUserId({
|
||||
isManagedEventType,
|
||||
userId,
|
||||
defaultScheduleId,
|
||||
|
||||
timeZone: userTimeZone,
|
||||
}: {
|
||||
timeZone: string;
|
||||
userId: number;
|
||||
defaultScheduleId: number | null;
|
||||
|
||||
isManagedEventType?: boolean;
|
||||
}) {
|
||||
const schedules = await prisma.schedule.findMany({
|
||||
where: {
|
||||
userId: userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
name: true,
|
||||
availability: true,
|
||||
timeZone: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!schedules?.length) {
|
||||
throw new Error("Schedules not found");
|
||||
}
|
||||
|
||||
const isCurrentUserPartOfTeam = await hasReadPermissionsForUserId({
|
||||
memberId: schedules[0].userId,
|
||||
userId,
|
||||
});
|
||||
|
||||
const schedulesFormatted = schedules.map((schedule) => {
|
||||
const isCurrentUserOwner = schedule?.userId === userId;
|
||||
|
||||
if (!isCurrentUserPartOfTeam && !isCurrentUserOwner) {
|
||||
throw new Error("UNAUTHORIZED");
|
||||
}
|
||||
|
||||
const timeZone = schedule.timeZone || userTimeZone;
|
||||
// disabling utc casting while fetching WorkingHours
|
||||
return {
|
||||
id: schedule.id,
|
||||
name: schedule.name,
|
||||
isManaged: schedule.userId !== userId,
|
||||
workingHours: transformWorkingHoursForAtom(schedule),
|
||||
schedule: schedule.availability,
|
||||
availability: transformAvailabilityForAtom(schedule),
|
||||
timeZone,
|
||||
isDefault: schedule.id === defaultScheduleId,
|
||||
dateOverrides: transformDateOverridesForAtom(schedule, timeZone),
|
||||
readOnly: schedule.userId !== userId && !isManagedEventType,
|
||||
isLastSchedule: schedules.length <= 1,
|
||||
userId: schedule.userId,
|
||||
};
|
||||
});
|
||||
|
||||
return schedulesFormatted;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,8 @@
|
||||
## 1.0.108
|
||||
|
||||
## 1.0.112
|
||||
## 1.0.114
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#22134](https://github.com/calcom/cal.com/pull/22134) [`3437c43`](https://github.com/calcom/cal.com/commit/3437c4331da892bd77afaea84be2db196543a207) Thanks [@supalarry](https://github.com/supalarry)! - ignore this test log
|
||||
|
||||
## 1.0.110
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#22107](https://github.com/calcom/cal.com/pull/22107) [`88a2bd3`](https://github.com/calcom/cal.com/commit/88a2bd3317a2cfaf6b367524e2736445544dbd63) Thanks [@supalarry](https://github.com/supalarry)! - test log can ignore
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#22103](https://github.com/calcom/cal.com/pull/22103) [`61274bc`](https://github.com/calcom/cal.com/commit/61274bc7efc67b162d46b59cd75bd376ad515c51) Thanks [@supalarry](https://github.com/supalarry)! - testing changesets - can ignore this
|
||||
|
||||
## 1.0.107
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#22095](https://github.com/calcom/cal.com/pull/22095) [`3c43ce4`](https://github.com/calcom/cal.com/commit/3c43ce4165bce1da29b203d5f2eb62090c5fddd2) Thanks [@supalarry](https://github.com/supalarry)! - test by updating changelog
|
||||
|
||||
- [#21864](https://github.com/calcom/cal.com/pull/21864) [`540bf86`](https://github.com/calcom/cal.com/commit/540bf868b5b60a98d9d3aeb565e2089f15c3dfd3) Thanks [@supalarry](https://github.com/supalarry)! - fix saving event type settings
|
||||
- [#22148](https://github.com/calcom/cal.com/pull/22148) [`d27490e`](https://github.com/calcom/cal.com/commit/d27490e6f2438d353dfcf3e793c8886e723307f3) Thanks [@supalarry](https://github.com/supalarry)! - test release
|
||||
|
||||
## 1.0.63
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
Customizable UI components to integrate scheduling into your services.
|
||||
|
||||
## Users guide
|
||||
|
||||
### Support
|
||||
Currently supports React 18, React 19, Next 14 and Next 15.
|
||||
|
||||
@@ -16,3 +18,22 @@ want to use version without any suffix e.g. `1.0.103`.
|
||||
### Documentation
|
||||
Documentation on how to get started with platform solution is [here](https://cal.com/docs/platform/quickstart) and list of atoms can be viewed
|
||||
[here](https://cal.com/docs/platform/atoms/cal-provider)
|
||||
|
||||
## Contributors guide
|
||||
|
||||
### Versioning
|
||||
We use a tool called changesets that helps documenting changes related to your development branch and then manages
|
||||
atoms versioning and publishing to [npm](https://www.npmjs.com/package/@calcom/atoms). We need to add a log documenting changes and then letting changesets to gather the changes, update Changelog and update version in `package.json`.
|
||||
|
||||
1. Let's say you are on a development branch and just finished adding a new feature to atoms. While on the development branch, you have to add a log documenting this feature so that it later ends up in the atoms [CHANGELOG.md](https://github.com/calcom/cal.com/blob/main/packages/platform/atoms/CHANGELOG.md).
|
||||
- Run `yarn changesets-add` from monorepo root and then select `@calcom/atoms` using space bar and press enter to go to the next step.
|
||||
- Then, you have to select whether this is a major, minor or patch update following semantic versioning. Since it is a feature skip major by pressing enter and then select minor by pressing space bar and press enter to go to the next step.
|
||||
- Then, you have to write a description of the change and press enter. This will generate a log file in the `.changeset` directory e.g. `.changeset/hungry-donuts-cross.md`.
|
||||
- Commit this log file to your development branch and push it.
|
||||
Notably, you do not have to change `"version"` in the atoms `package.json` file because changesets will do it in the next step.
|
||||
2. After the development branch is merged changesets will open a pull request titled `chore: version packages` containing next release changes. This pull request will contain the new log file, it being added to the atoms `CHANGELOG.md` file and changesets will update the atoms `package.json` file based whether or not is is major, minor or patch update. When we want to release atoms we simply have to merge this pull request and changesets will publish the new atoms version to npm. Notably,
|
||||
changesets will publish atoms to npm only if the `"version"` in the atoms `package.json` of changeset's PR is higher than in the npm.
|
||||
|
||||
The following 2 articles teach how to write good change summaries for each PR when it deserves to end up in CHANGELOG.md
|
||||
- https://keepachangelog.com/en/1.0.0/
|
||||
- https://docs.gitlab.com/development/changelog/
|
||||
@@ -4,12 +4,14 @@
|
||||
"type": "module",
|
||||
"description": "Customizable UI components to integrate scheduling into your product.",
|
||||
"authors": "Cal.com, Inc.",
|
||||
"version": "1.0.112",
|
||||
"version": "1.0.114",
|
||||
"scripts": {
|
||||
"dev": "yarn vite build --watch & npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify --watch",
|
||||
"build": "NODE_OPTIONS='--max_old_space_size=12288' rm -rf dist && yarn vite build && npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify && mkdir ./dist/packages/prisma-client && cp -rf ../../../node_modules/.prisma/client/*.d.ts ./dist/packages/prisma-client",
|
||||
"publish-npm": "yarn build && npm publish --access public",
|
||||
"test": "jest"
|
||||
"test": "jest",
|
||||
"dev-on": "node scripts/dev-on.js",
|
||||
"dev-off": "git checkout -- package.json vite.config.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-node-resolve": "^15.0.1",
|
||||
@@ -37,11 +39,9 @@
|
||||
"dist",
|
||||
"globals.min.css"
|
||||
],
|
||||
"main": "./dist/cal-atoms.umd.cjs",
|
||||
"module": "./dist/cal-atoms.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"require": "./dist/cal-atoms.umd.cjs",
|
||||
"import": "./dist/cal-atoms.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
@@ -83,7 +83,6 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-awesome-query-builder": "^5.1.2",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const packageJsonPath = path.join(__dirname, "../package.json");
|
||||
const viteConfigPath = path.join(__dirname, "../vite.config.ts");
|
||||
|
||||
// Modify package.json
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
||||
packageJson.main = "./dist/cal-atoms.umd.cjs";
|
||||
packageJson.exports["."].require = "./dist/cal-atoms.umd.cjs";
|
||||
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
|
||||
|
||||
// Modify vite.config.ts
|
||||
let viteConfig = fs.readFileSync(viteConfigPath, "utf-8");
|
||||
viteConfig = viteConfig.replace(/formats: \["es"\],/g, "");
|
||||
viteConfig = viteConfig.replace(/external: \[([^\]]+)\]/, 'external: [$1, "react-awesome-query-builder"]');
|
||||
viteConfig = viteConfig.replace(/format: "esm",\s*/g, "");
|
||||
fs.writeFileSync(viteConfigPath, viteConfig);
|
||||
@@ -31,16 +31,32 @@
|
||||
"../../types/@wojtekmaj__react-daterange-picker.d.ts",
|
||||
"../../types/business-days-plugin.d.ts",
|
||||
"../../types/window.d.ts",
|
||||
"../../lib",
|
||||
"../../features",
|
||||
"../types",
|
||||
"../constants",
|
||||
"../utils",
|
||||
"../libraries",
|
||||
"../../prisma",
|
||||
"../../dayjs",
|
||||
"../../trpc",
|
||||
"../../app-store"
|
||||
"../../prisma/zod-utils.ts",
|
||||
"../../prisma/zod",
|
||||
"../../trpc/react",
|
||||
"../../trpc/server/routers/viewer/slots",
|
||||
"../../trpc/server/types",
|
||||
"../../features/eventtypes",
|
||||
"../../features/schedules",
|
||||
"../../features/bookings/Booker",
|
||||
"../../features/bookings/Booker",
|
||||
"../../features/instant-meeting",
|
||||
"../../features/bookings/handleMarkNoShow.ts",
|
||||
"../../features/bookings/types.ts",
|
||||
"../../features/bookings/lib",
|
||||
"../../features/ee/workflows",
|
||||
"../../features/ee/payments",
|
||||
"../../features/ee/round-robin",
|
||||
"../../features/ee/billing",
|
||||
"../../features/shell",
|
||||
"../../features/components",
|
||||
"../../features/form-builder",
|
||||
"../../features/data-table"
|
||||
],
|
||||
"exclude": [
|
||||
"dist",
|
||||
|
||||
@@ -3,7 +3,6 @@ import path from "path";
|
||||
import { resolve } from "path";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
import Inspect from "vite-plugin-inspect";
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), ""); // .env inside of packages/platform/atoms
|
||||
@@ -20,7 +19,7 @@ export default defineConfig(({ mode }) => {
|
||||
"@calcom/platform-utils",
|
||||
],
|
||||
},
|
||||
plugins: [Inspect(), react(), dts({ insertTypesEntry: true })],
|
||||
plugins: [react(), dts({ insertTypesEntry: true })],
|
||||
define: {
|
||||
"process.env.NEXT_PUBLIC_WEBAPP_URL": `"${webAppUrl}"`,
|
||||
},
|
||||
@@ -28,17 +27,16 @@ export default defineConfig(({ mode }) => {
|
||||
noExternal: ["turndown"], // Example if you want to disable SSR for your library
|
||||
},
|
||||
build: {
|
||||
commonjsOptions: {
|
||||
include: [/@calcom\/lib/, /@calcom\/features/, /node_modules/],
|
||||
},
|
||||
lib: {
|
||||
entry: [resolve(__dirname, "index.ts")],
|
||||
name: "CalAtoms",
|
||||
fileName: "cal-atoms",
|
||||
formats: ["es"],
|
||||
},
|
||||
rollupOptions: {
|
||||
external: ["react", "fs", "path", "os", "react-dom", "react-awesome-query-builder"],
|
||||
external: ["react", "fs", "path", "os", "react/jsx-runtime", "react-dom", "react-dom/client"],
|
||||
output: {
|
||||
format: "esm",
|
||||
globals: {
|
||||
react: "React",
|
||||
"react-dom": "ReactDOM",
|
||||
@@ -58,6 +56,7 @@ export default defineConfig(({ mode }) => {
|
||||
"@radix-ui/react-dialog": path.resolve(__dirname, "./src/components/ui/dialog.tsx"),
|
||||
".prisma/client": path.resolve(__dirname, "../../prisma-client"),
|
||||
"@prisma/client": path.resolve(__dirname, "../../prisma-client"),
|
||||
"@calcom/prisma/client": path.resolve(__dirname, "../../prisma-client"),
|
||||
"@calcom/prisma": path.resolve(__dirname, "../../prisma"),
|
||||
"@calcom/dayjs": path.resolve(__dirname, "../../dayjs"),
|
||||
"@calcom/platform-constants": path.resolve(__dirname, "../constants/index.ts"),
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -489,7 +489,7 @@ export async function getBookings({
|
||||
"EventType.disableCancelling",
|
||||
"EventType.disableRescheduling",
|
||||
eb
|
||||
.cast<SchedulingType>(
|
||||
.cast<SchedulingType | null>(
|
||||
eb
|
||||
.case()
|
||||
.when("EventType.schedulingType", "=", "roundRobin")
|
||||
@@ -498,7 +498,7 @@ export async function getBookings({
|
||||
.then(SchedulingType["COLLECTIVE"])
|
||||
.when("EventType.schedulingType", "=", "managed")
|
||||
.then(SchedulingType["MANAGED"])
|
||||
.else(SchedulingType["ROUND_ROBIN"]) // Ensure ELSE provides a value within SchedulingTypeLiteral for cast safety
|
||||
.else(null)
|
||||
.end(),
|
||||
"varchar" // Or 'text' - use the actual SQL data type
|
||||
)
|
||||
|
||||
@@ -2,10 +2,12 @@ import authedProcedure from "../../../procedures/authedProcedure";
|
||||
import { router } from "../../../trpc";
|
||||
import { ZGetCalVideoRecordingsInputSchema } from "./getCalVideoRecordings.schema";
|
||||
import { ZGetDownloadLinkOfCalVideoRecordingsInputSchema } from "./getDownloadLinkOfCalVideoRecordings.schema";
|
||||
import { ZGetMeetingInformationInputSchema } from "./getMeetingInformation.schema";
|
||||
|
||||
type CalVideoRouterHandlerCache = {
|
||||
getCalVideoRecordings?: typeof import("./getCalVideoRecordings.handler").getCalVideoRecordingsHandler;
|
||||
getDownloadLinkOfCalVideoRecordings?: typeof import("./getDownloadLinkOfCalVideoRecordings.handler").getDownloadLinkOfCalVideoRecordingsHandler;
|
||||
getMeetingInformation?: typeof import("./getMeetingInformation.handler").getMeetingInformationHandler;
|
||||
};
|
||||
|
||||
export const calVideoRouter = router({
|
||||
@@ -26,4 +28,12 @@ export const calVideoRouter = router({
|
||||
|
||||
return getDownloadLinkOfCalVideoRecordingsHandler({ ctx, input });
|
||||
}),
|
||||
|
||||
getMeetingInformation: authedProcedure
|
||||
.input(ZGetMeetingInformationInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { getMeetingInformationHandler } = await import("./getMeetingInformation.handler");
|
||||
|
||||
return getMeetingInformationHandler({ ctx, input });
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import DailyVideoApiAdapter from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TGetMeetingInformationInputSchema } from "./getMeetingInformation.schema";
|
||||
|
||||
type GetMeetingInformationOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TGetMeetingInformationInputSchema;
|
||||
};
|
||||
|
||||
export const getMeetingInformationHandler = async ({ ctx: _ctx, input }: GetMeetingInformationOptions) => {
|
||||
const { roomName } = input;
|
||||
|
||||
try {
|
||||
const videoApiAdapter = DailyVideoApiAdapter();
|
||||
if (!videoApiAdapter || !videoApiAdapter.getMeetingInformation) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Meeting information feature not available",
|
||||
});
|
||||
}
|
||||
const res = await videoApiAdapter.getMeetingInformation(roomName);
|
||||
return res;
|
||||
} catch (err) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZGetMeetingInformationInputSchema = z.object({
|
||||
roomName: z.string(),
|
||||
});
|
||||
|
||||
export type TGetMeetingInformationInputSchema = z.infer<typeof ZGetMeetingInformationInputSchema>;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user