Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
979e1fd28b | ||
|
|
3437c4331d | ||
|
|
616d62094c | ||
|
|
ce7ff1c43c | ||
|
|
e27e41bc16 | ||
|
|
93e183e6c3 | ||
|
|
88a2bd3317 | ||
|
|
45caaa55b0 | ||
|
|
8c9b7a701d | ||
|
|
c47b992125 | ||
|
|
493c4f89dc | ||
|
|
27234dc809 | ||
|
|
5690b8e1d5 | ||
|
|
61274bc7ef | ||
|
|
e66be23996 | ||
|
|
3c43ce4165 | ||
|
|
5060824795 | ||
|
|
c4941f25a2 | ||
|
|
e7c219905f | ||
|
|
499cbd08ca | ||
|
|
0a3b3a69e3 | ||
|
|
e8607845e2 | ||
|
|
02ef4db0eb | ||
|
|
f8d48a341d |
@@ -7,7 +7,7 @@
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": [],
|
||||
"ignore": ["@calcom/platform-libraries"],
|
||||
"privatePackages": {
|
||||
"version": false,
|
||||
"tag": false
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@calcom/atoms": patch
|
||||
---
|
||||
|
||||
fix saving event type settings
|
||||
@@ -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=
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@
|
||||
/packages/lib/server/getLuckyUser.ts @calcom/Foundation
|
||||
/packages/lib/slots.ts @calcom/Foundation
|
||||
/packages/platform/**/* @calcom/Platform @calcom/Foundation
|
||||
/packages/prisma/**/* @calcom/Foundation
|
||||
/packages/prisma/**/* @calcom/DBA
|
||||
/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @calcom/Foundation
|
||||
/packages/trpc/server/routers/viewer/bookings/get.handler.ts @calcom/Foundation
|
||||
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
contents: write # to create release by changesets/action below
|
||||
pull-requests: write # to create pull request by changesets/action below
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
undecided:
|
||||
- "@calcom/platform-constants"
|
||||
- "@calcom/platform-enums"
|
||||
- "@calcom/platform-types"
|
||||
- "@calcom/platform-utils"
|
||||
- "@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"
|
||||
@@ -3,6 +3,7 @@
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node ./index.js"
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"description": "Platform API for Cal.com",
|
||||
"author": "Cal.com Inc.",
|
||||
"license": "UNLICENSED",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "yarn dev:build && nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
@@ -37,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,34 @@
|
||||
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?: number;
|
||||
userEmail?: string;
|
||||
organizationId?: number;
|
||||
teamId?: number;
|
||||
}
|
||||
|
||||
export function extractUserContext(request: Request): UserContext {
|
||||
const context: UserContext = {};
|
||||
|
||||
const user = (request as any).user as ApiAuthGuardUser | undefined;
|
||||
if (user) {
|
||||
context.userId = user.id;
|
||||
context.userEmail = user.email;
|
||||
}
|
||||
|
||||
const organizationId = (request as any).organizationId as number | undefined;
|
||||
if (organizationId) {
|
||||
context.organizationId = organizationId;
|
||||
}
|
||||
|
||||
const team = (request as any).team as Team | undefined;
|
||||
if (team) {
|
||||
context.teamId = team.id;
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -58,12 +58,6 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
const IP = request?.headers?.["cf-connecting-ip"] ?? request?.headers?.["CF-Connecting-IP"] ?? request.ip;
|
||||
const response = context.switchToHttp().getResponse<Response>();
|
||||
const tracker = await this.getTracker(request);
|
||||
this.logger.verbose(
|
||||
`Tracker "${tracker}" generated based on: Bearer token "${request.get(
|
||||
"Authorization"
|
||||
)}", OAuth client ID "${request.get(X_CAL_CLIENT_ID)}" and IP "${IP}"`
|
||||
);
|
||||
|
||||
if (throttleOptions) {
|
||||
return this.handleApiEndpointThrottle(tracker, throttleOptions, response);
|
||||
}
|
||||
@@ -104,9 +98,9 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
|
||||
private async handleNonApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
|
||||
const rateLimit = this.getDefaultRateLimit(tracker);
|
||||
this.logger.verbose(`Tracker "${tracker}" uses default rate limits because it is not tracking api key:
|
||||
/*this.logger.verbose(`Tracker "${tracker}" uses default rate limits because it is not tracking api key:
|
||||
${JSON.stringify(rateLimit, null, 2)}
|
||||
`);
|
||||
`);*/
|
||||
|
||||
const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response);
|
||||
if (isBlocked) {
|
||||
@@ -150,9 +144,9 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
|
||||
const cachedRateLimits = await this.storageService.redis.get(cacheKey);
|
||||
if (cachedRateLimits) {
|
||||
this.logger.verbose(`Tracker "${tracker}" rate limits retrieved from redis cache:
|
||||
/*this.logger.verbose(`Tracker "${tracker}" rate limits retrieved from redis cache:
|
||||
${cachedRateLimits}
|
||||
`);
|
||||
`);*/
|
||||
return rateLimitsSchema.parse(JSON.parse(cachedRateLimits));
|
||||
}
|
||||
|
||||
@@ -172,15 +166,10 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
select: { name: true, limit: true, ttl: true, blockDuration: true },
|
||||
});
|
||||
|
||||
if (rateLimits) {
|
||||
this.logger.verbose(`Tracker "${tracker}" rate limits retrieved from database:
|
||||
${JSON.stringify(rateLimits, null, 2)}`);
|
||||
}
|
||||
|
||||
if (!rateLimits || rateLimits.length === 0) {
|
||||
rateLimits = [this.getDefaultRateLimit(tracker)];
|
||||
this.logger.verbose(`Tracker "${tracker}" rate limits not found in database. Using default rate limits:
|
||||
${JSON.stringify(rateLimits, null, 2)}`);
|
||||
/*this.logger.verbose(`Tracker "${tracker}" rate limits not found in database. Using default rate limits:
|
||||
${JSON.stringify(rateLimits, null, 2)}`);*/
|
||||
}
|
||||
|
||||
await this.storageService.redis.set(cacheKey, JSON.stringify(rateLimits), "EX", 3600);
|
||||
@@ -209,15 +198,15 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
);
|
||||
response.setHeader(`X-RateLimit-Reset-${nameFirstUpper}`, timeToBlockExpire || timeToExpire);
|
||||
|
||||
this.logger.verbose(
|
||||
/*this.logger.verbose(
|
||||
`Tracker "${tracker}" rate limit "${name}" incremented. isBlocked ${isBlocked}, totalHits ${totalHits}, timeToExpire ${timeToExpire}, timeToBlockExpire ${timeToBlockExpire}`
|
||||
);
|
||||
this.logger.verbose(
|
||||
);*/
|
||||
/*this.logger.verbose(
|
||||
`Tracker "${tracker}" rate limit "${name}" response headers:
|
||||
X-RateLimit-Limit-${nameFirstUpper}: ${limit},
|
||||
X-RateLimit-Remaining-${nameFirstUpper}: ${timeToBlockExpire ? 0 : Math.max(0, limit - totalHits)},
|
||||
X-RateLimit-Reset-${nameFirstUpper}: ${timeToBlockExpire || timeToExpire}`
|
||||
);
|
||||
);*/
|
||||
|
||||
return { isBlocked };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ImageExample } from "./avatar.image"
|
||||
import { TooltipExample } from "./avatar.tooltip"
|
||||
import { LinkExample } from "./avatar.link"
|
||||
import { IndicatorExample } from "./avatar.indicator"
|
||||
import { ShapeExample } from "./avatar.shape"
|
||||
|
||||
## Size Variations
|
||||
|
||||
@@ -17,6 +18,12 @@ Avatars come in different sizes to suit various use cases.
|
||||
|
||||
<SizesExample/>
|
||||
|
||||
## Shape Variations
|
||||
|
||||
Avatars can be displayed in either circular or square shapes.
|
||||
|
||||
<ShapeExample/>
|
||||
|
||||
## With and Without Image
|
||||
|
||||
Avatars can display either an image or a fallback placeholder.
|
||||
@@ -40,3 +47,57 @@ Avatars can be made clickable by providing an href.
|
||||
Avatars can include status indicators or badges.
|
||||
|
||||
<IndicatorExample/>
|
||||
|
||||
## Usage Example
|
||||
|
||||
Here's a complete example showing how to use the Avatar component with all its props:
|
||||
|
||||
```tsx
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
|
||||
function Example() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Basic Avatar */}
|
||||
<Avatar
|
||||
alt="User Name"
|
||||
title="User Name"
|
||||
size="md"
|
||||
shape="circle"
|
||||
imageSrc="https://example.com/avatar.jpg"
|
||||
/>
|
||||
|
||||
{/* Avatar with Link */}
|
||||
<Avatar
|
||||
alt="User Name"
|
||||
title="User Name"
|
||||
size="lg"
|
||||
shape="square"
|
||||
imageSrc="https://example.com/avatar.jpg"
|
||||
href="/profile"
|
||||
/>
|
||||
|
||||
{/* Avatar with Tooltip and Indicator */}
|
||||
<Avatar
|
||||
alt="User Name"
|
||||
title="User Name"
|
||||
size="md"
|
||||
shape="circle"
|
||||
imageSrc="https://example.com/avatar.jpg"
|
||||
indicator={<div className="absolute bottom-0 right-0 h-3 w-3 rounded-full bg-green-500" />}
|
||||
/>
|
||||
|
||||
{/* Avatar with Fallback */}
|
||||
<Avatar
|
||||
alt="User Name"
|
||||
title="User Name"
|
||||
size="md"
|
||||
shape="circle"
|
||||
fallback={<span>UN</span>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
|
||||
export function ShapeExample() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar
|
||||
alt="Circle Avatar"
|
||||
title="Circle Avatar"
|
||||
size="lg"
|
||||
shape="circle"
|
||||
imageSrc="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
|
||||
/>
|
||||
<Avatar
|
||||
alt="Square Avatar"
|
||||
title="Square Avatar"
|
||||
size="lg"
|
||||
shape="square"
|
||||
imageSrc="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
@@ -279,6 +280,7 @@ const NewRoutingManager = ({
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const bookerUrl = useBookerUrl();
|
||||
const session = useSession();
|
||||
const teamMemberIdsMatchingAttributeLogic =
|
||||
teamMembersMatchingAttributeLogic?.data
|
||||
?.map((member) => member.id)
|
||||
@@ -305,7 +307,7 @@ const NewRoutingManager = ({
|
||||
booking,
|
||||
currentResponse,
|
||||
searchParams: new URLSearchParams({
|
||||
// rescheduleReason
|
||||
rescheduledBy: session?.data?.user?.email ?? "",
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -469,6 +471,7 @@ const NewRoutingManager = ({
|
||||
// TODO: Long term, we should refactor handleNewBooking and use a different route specific for this purpose,
|
||||
createBookingMutation.mutate({
|
||||
rescheduleUid: booking.uid,
|
||||
rescheduledBy: session?.data?.user?.email ?? undefined,
|
||||
// rescheduleReason,
|
||||
reroutingFormResponses: reroutingFormResponses,
|
||||
...getTimeslotFields(),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { Session } from "next-auth";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { RouteActionType } from "@calcom/app-store/routing-forms/zod";
|
||||
@@ -37,6 +39,15 @@ const mockOpen = vi.fn((_url: string) => {
|
||||
|
||||
vi.stubGlobal("open", mockOpen);
|
||||
|
||||
const mockSession = {
|
||||
expires: new Date(Date.now() + 2 * 86400).toISOString(),
|
||||
user: {
|
||||
id: 1,
|
||||
name: "Test User",
|
||||
email: "user@example.com",
|
||||
},
|
||||
} as Session;
|
||||
|
||||
vi.mock("@calcom/app-store/routing-forms/components/FormInputFields", () => ({
|
||||
default: vi.fn(({ response, form, setResponse, disabledFields }) => {
|
||||
return (
|
||||
@@ -404,9 +415,11 @@ describe("RerouteDialog", () => {
|
||||
describe("New Routing tests", () => {
|
||||
test("when verify_new_route is clicked, the form is submitted", async () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
||||
</TooltipProvider>
|
||||
<SessionProvider session={mockSession}>
|
||||
<TooltipProvider>
|
||||
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
||||
</TooltipProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
fireEvent.click(screen.getByText("verify_new_route"));
|
||||
|
||||
@@ -425,9 +438,11 @@ describe("RerouteDialog", () => {
|
||||
describe("New tab rescheduling", () => {
|
||||
test("new tab is closed when new booking is rerouted", async () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
||||
</TooltipProvider>
|
||||
<SessionProvider session={mockSession}>
|
||||
<TooltipProvider>
|
||||
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
||||
</TooltipProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
clickVerifyNewRouteButton();
|
||||
clickRescheduleToTheNewEventWithDifferentTimeslotButton();
|
||||
@@ -466,9 +481,11 @@ describe("RerouteDialog", () => {
|
||||
|
||||
test("Rescheduling with same timeslot works", async () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
||||
</TooltipProvider>
|
||||
<SessionProvider session={mockSession}>
|
||||
<TooltipProvider>
|
||||
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
||||
</TooltipProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
clickVerifyNewRouteButton();
|
||||
clickRescheduleWithSameTimeslotOfChosenEventButton();
|
||||
|
||||
@@ -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")} />
|
||||
|
||||
@@ -691,25 +691,23 @@ export default function Signup({
|
||||
/>
|
||||
</div>
|
||||
<div className="mr-12 mt-8 hidden h-full w-full grid-cols-3 gap-4 overflow-hidden lg:grid">
|
||||
{FEATURES.map((feature) => (
|
||||
<>
|
||||
<div className="max-w-52 mb-8 flex flex-col leading-none sm:mb-0">
|
||||
<div className="text-emphasis items-center">
|
||||
<Icon name={feature.icon} className="mb-1 h-4 w-4" />
|
||||
<span className="text-sm font-medium">{t(feature.title)}</span>
|
||||
</div>
|
||||
<div className="text-subtle text-sm">
|
||||
<p>
|
||||
{t(
|
||||
feature.description,
|
||||
feature.i18nOptions && {
|
||||
...feature.i18nOptions,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{FEATURES.map((feature, index) => (
|
||||
<div key={index} className="max-w-52 mb-8 flex flex-col leading-none sm:mb-0">
|
||||
<div className="text-emphasis items-center">
|
||||
<Icon name={feature.icon} className="mb-1 h-4 w-4" />
|
||||
<span className="text-sm font-medium">{t(feature.title)}</span>
|
||||
</div>
|
||||
</>
|
||||
<div className="text-subtle text-sm">
|
||||
<p>
|
||||
{t(
|
||||
feature.description,
|
||||
feature.i18nOptions && {
|
||||
...feature.i18nOptions,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.4.8",
|
||||
"version": "5.4.12",
|
||||
"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">
|
||||
|
||||
@@ -267,6 +267,8 @@
|
||||
"guests": "Guests",
|
||||
"guest": "Guest",
|
||||
"web_conferencing_details_to_follow": "Web conferencing details to follow in the confirmation email.",
|
||||
"confirmation":"Confirmation",
|
||||
"what_booker_should_provide": "What your booker should provide to receive confirmations",
|
||||
"404_the_user": "The username",
|
||||
"username": "Username",
|
||||
"is_still_available": "is still available.",
|
||||
@@ -1710,6 +1712,8 @@
|
||||
"show_available_seats_count": "Show the number of available seats",
|
||||
"how_booking_questions_as_variables": "How to use booking questions as variables?",
|
||||
"format": "Format",
|
||||
"questions": "Questions",
|
||||
"all_info_your_booker_provide": "All the info your booker should provide before booking with you.",
|
||||
"uppercase_for_letters": "Use uppercase for all letters",
|
||||
"replace_whitespaces_underscores": "Replace whitespaces with underscores",
|
||||
"platform_members": "Platform members",
|
||||
@@ -3263,5 +3267,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
+2
-1
@@ -80,8 +80,9 @@
|
||||
"docker-build-api": "docker build -t cal-api -f ./infra/docker/api/Dockerfile .",
|
||||
"docker-run-api": "docker run -p 80:80 cal-api",
|
||||
"docker-stop-api": "docker ps --filter 'ancestor=cal-api' -q | xargs docker stop",
|
||||
"changesets-add": "yarn changeset add",
|
||||
"changesets-version": "yarn changeset version",
|
||||
"changesets-release": "turbo run build && yarn changeset publish"
|
||||
"changesets-release": "NODE_OPTIONS='--max_old_space_size=12288' turbo run build --filter=@calcom/atoms && yarn changeset publish"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/changelog-github": "^0.5.1",
|
||||
|
||||
Binary file not shown.
@@ -1,7 +1,12 @@
|
||||
/**
|
||||
* Manages OAuth2.0 tokens for an app and resourceOwner. It automatically refreshes the token when needed.
|
||||
* It is aware of the credential sync endpoint and can sync the token from the third party source.
|
||||
* It is unaware of Prisma and App logic. It is just a utility to manage OAuth2.0 tokens with life cycle methods
|
||||
* Manages OAuth2.0 as well as JWT tokens(For JWT tokens, only Google Calendar use it at the moment) for an app and resourceOwner.
|
||||
* What it does
|
||||
* - It automatically refreshes the token if needed when making a request.
|
||||
* - It is aware of the credential sync endpoint and can sync the token from the third party source.
|
||||
* - It is kept unaware of Prisma and App logic. It is just a utility to manage OAuth2.0 tokens with life cycle methods
|
||||
*
|
||||
* What it doesn't do yet
|
||||
* - It doesn't have a flow to re-send the request if the access-token had been communicated as invalid after making the request itself. It relies on the caller to make the next request in which it will actually refresh the token.
|
||||
*
|
||||
* For a recommended usage example, see Zoom VideoApiAdapter.ts
|
||||
*/
|
||||
@@ -89,7 +94,13 @@ export class OAuthManager {
|
||||
currentTokenObject,
|
||||
fetchNewTokenObject,
|
||||
updateTokenObject,
|
||||
/**
|
||||
* The fn must not crash. It is the responsibility of the caller to handle any error and appropriately decide what to return
|
||||
*/
|
||||
isTokenObjectUnusable,
|
||||
/**
|
||||
* The fn must not crash. It is the responsibility of the caller to handle any error and appropriately decide what to return
|
||||
*/
|
||||
isAccessTokenUnusable,
|
||||
invalidateTokenObject,
|
||||
expireAccessToken,
|
||||
|
||||
@@ -19,7 +19,7 @@ export default async function getAttributesForTeamHandler({
|
||||
}: GetAttributesForTeamHandlerOptions) {
|
||||
const { teamId } = input;
|
||||
const { user } = ctx;
|
||||
const isMemberOfTeam = await MembershipRepository.findFirstByUserIdAndTeamId({ userId: user.id, teamId });
|
||||
const isMemberOfTeam = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId: user.id, teamId });
|
||||
|
||||
if (!isMemberOfTeam) {
|
||||
throw new TRPCError({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
import { stringify } from "querystring";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
|
||||
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -26,7 +26,7 @@ async function handler(req: NextApiRequest) {
|
||||
const params = {
|
||||
response_type: "code",
|
||||
client_id,
|
||||
redirect_uri: `${WEBAPP_URL}/api/integrations/zoomvideo/callback`,
|
||||
redirect_uri: `${WEBAPP_URL_FOR_OAUTH}/api/integrations/zoomvideo/callback`,
|
||||
state,
|
||||
};
|
||||
const query = stringify(params);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -14,7 +14,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
const { code } = req.query;
|
||||
const { client_id, client_secret } = await getZoomAppKeys();
|
||||
|
||||
const redirectUri = encodeURI(`${WEBAPP_URL}/api/integrations/zoomvideo/callback`);
|
||||
const redirectUri = encodeURI(`${WEBAPP_URL_FOR_OAUTH}/api/integrations/zoomvideo/callback`);
|
||||
const authHeader = `Basic ${Buffer.from(`${client_id}:${client_secret}`).toString("base64")}`;
|
||||
const result = await fetch(
|
||||
`https://zoom.us/oauth/token?grant_type=authorization_code&code=${code}&redirect_uri=${redirectUri}`,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CREDENTIAL_SYNC_SECRET_HEADER_NAME,
|
||||
} from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { getPiiFreeCalendarEvent } from "@calcom/lib/piiFreeData";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { Frequency } from "@calcom/prisma/zod-utils";
|
||||
@@ -65,12 +66,12 @@ export type ZoomUserSettings = z.infer<typeof zoomUserSettingsSchema>;
|
||||
export const zoomUserSettingsSchema = z.object({
|
||||
recording: z
|
||||
.object({
|
||||
auto_recording: z.string(),
|
||||
auto_recording: z.string().nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
schedule_meeting: z
|
||||
.object({
|
||||
default_password_for_scheduled_meetings: z.string(),
|
||||
default_password_for_scheduled_meetings: z.string().nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
});
|
||||
@@ -154,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
|
||||
@@ -165,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,
|
||||
@@ -185,6 +198,32 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Zoom is known to return xml response in some cases.
|
||||
* e.g. Wrong request or some special case of invalid token
|
||||
*/
|
||||
const handleZoomResponseJsonParseError = async ({
|
||||
error,
|
||||
clonedResponse,
|
||||
}: {
|
||||
error: unknown;
|
||||
clonedResponse: Response;
|
||||
}) => {
|
||||
// In some cases, Zoom responds with xml response, so we log the response for debugging
|
||||
// We need to see why that error occurs exactly and then later we decide if mark the access token and token object unusable or not
|
||||
log.error(
|
||||
"Error in JSON parsing Zoom API response",
|
||||
safeStringify({
|
||||
error: safeStringify(error),
|
||||
// Log Raw response body here.
|
||||
responseBody: await clonedResponse.text(),
|
||||
status: clonedResponse.status,
|
||||
})
|
||||
);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const fetchZoomApi = async (endpoint: string, options?: RequestInit) => {
|
||||
const auth = new OAuthManager({
|
||||
credentialSyncVariables: {
|
||||
@@ -220,9 +259,18 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
||||
},
|
||||
isTokenObjectUnusable: async function (response) {
|
||||
const myLog = logger.getSubLogger({ prefix: ["zoomvideo:isTokenObjectUnusable"] });
|
||||
myLog.debug(safeStringify({ status: response.status, ok: response.ok }));
|
||||
if (!response.ok || (response.status < 200 && response.status >= 300)) {
|
||||
const responseBody = await response.json();
|
||||
myLog.info(safeStringify({ status: response.status, ok: response.ok }));
|
||||
if (!response.ok) {
|
||||
let responseBody;
|
||||
const responseToUseInCaseOfError = response.clone();
|
||||
try {
|
||||
responseBody = await response.json();
|
||||
} catch (e) {
|
||||
return await handleZoomResponseJsonParseError({
|
||||
error: e,
|
||||
clonedResponse: responseToUseInCaseOfError,
|
||||
});
|
||||
}
|
||||
myLog.debug(safeStringify({ responseBody }));
|
||||
|
||||
if (responseBody.error === "invalid_grant") {
|
||||
@@ -233,11 +281,20 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
||||
},
|
||||
isAccessTokenUnusable: async function (response) {
|
||||
const myLog = logger.getSubLogger({ prefix: ["zoomvideo:isAccessTokenUnusable"] });
|
||||
myLog.debug(safeStringify({ status: response.status, ok: response.ok }));
|
||||
if (!response.ok || (response.status < 200 && response.status >= 300)) {
|
||||
const responseBody = await response.json();
|
||||
myLog.info(safeStringify({ status: response.status, ok: response.ok }));
|
||||
if (!response.ok) {
|
||||
let responseBody;
|
||||
const responseToUseInCaseOfError = response.clone();
|
||||
try {
|
||||
responseBody = await response.json();
|
||||
} catch (e) {
|
||||
return await handleZoomResponseJsonParseError({
|
||||
error: e,
|
||||
clonedResponse: responseToUseInCaseOfError,
|
||||
});
|
||||
}
|
||||
myLog.debug(safeStringify({ responseBody }));
|
||||
|
||||
// 124 is the error code for invalid access token from Zoom API
|
||||
if (responseBody.code === 124) {
|
||||
return { reason: responseBody.message ?? "" };
|
||||
}
|
||||
@@ -284,7 +341,7 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
||||
end: new Date(new Date(meeting.start_time).getTime() + meeting.duration * 60000).toISOString(),
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
log.error("Failed to get availability", safeStringify(err));
|
||||
/* Prevents booking failure when Zoom Token is expired */
|
||||
return [];
|
||||
}
|
||||
@@ -311,8 +368,10 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
||||
}
|
||||
throw new Error(`Failed to create meeting. Response is ${JSON.stringify(result)}`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
log.error("Zoom meeting creation failed", safeStringify({ error: err, event }));
|
||||
log.error(
|
||||
"Zoom meeting creation failed",
|
||||
safeStringify({ error: safeStringify(err), event: getPiiFreeCalendarEvent(event) })
|
||||
);
|
||||
/* Prevents meeting creation failure when Zoom Token is expired */
|
||||
throw new Error("Unexpected error");
|
||||
}
|
||||
@@ -347,7 +406,10 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
||||
url: result.join_url,
|
||||
};
|
||||
} catch (err) {
|
||||
log.error("Failed to update meeting", safeStringify(err));
|
||||
log.error(
|
||||
"Failed to update meeting",
|
||||
safeStringify({ error: err, event: getPiiFreeCalendarEvent(event) })
|
||||
);
|
||||
return Promise.reject(new Error("Failed to update meeting"));
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -164,4 +164,354 @@ describe("checkForConflicts", () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("comprehensive edge cases and boundary conditions", () => {
|
||||
it("should return false for empty busy array", () => {
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T09:00:00Z"),
|
||||
busy: [],
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle slot that starts exactly when busy period ends", () => {
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T09:30:00Z"),
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:30:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle slot that ends exactly when busy period starts", () => {
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T08:30:00Z"),
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:30:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle slot start exactly matching busy period start (inclusive)", () => {
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T09:00:00Z"),
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle slot end exactly matching busy period end (inclusive)", () => {
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T09:15:00Z"),
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:30:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle very short event length (1 minute)", () => {
|
||||
const result = checkForConflicts({
|
||||
time: dayjs("2023-01-01T09:00:00Z"),
|
||||
eventLength: 1,
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:00:30Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:01:30Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle very long event length (8 hours)", () => {
|
||||
const result = checkForConflicts({
|
||||
time: dayjs("2023-01-01T09:00:00Z"),
|
||||
eventLength: 480, // 8 hours
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T12:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T13:00:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiple busy periods scenarios", () => {
|
||||
it("should return true when first busy period conflicts", () => {
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T09:00:00Z"),
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
|
||||
},
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T10:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T11:00:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when last busy period conflicts", () => {
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T10:30:00Z"),
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T08:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:00:00Z").toDate(),
|
||||
},
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T10:45:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T11:15:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when middle busy period conflicts", () => {
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T10:00:00Z"),
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T08:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:00:00Z").toDate(),
|
||||
},
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T10:15:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T10:45:00Z").toDate(),
|
||||
},
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T11:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T12:00:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle many non-overlapping busy periods", () => {
|
||||
const busyPeriods = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const hour = (8 + i * 2) % 24; // Wrap around to prevent invalid hours
|
||||
busyPeriods.push({
|
||||
start: dayjs.utc(`2023-01-01T${hour.toString().padStart(2, "0")}:00:00Z`).toDate(),
|
||||
end: dayjs.utc(`2023-01-01T${hour.toString().padStart(2, "0")}:30:00Z`).toDate(),
|
||||
});
|
||||
}
|
||||
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T07:00:00Z"),
|
||||
busy: busyPeriods,
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle overlapping busy periods", () => {
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T09:00:00Z"),
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
|
||||
},
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:30:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T10:00:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("currentSeats comprehensive scenarios", () => {
|
||||
it("should return false when currentSeats has exact time match", () => {
|
||||
const testTime = dayjs.utc("2023-01-01T09:00:00Z");
|
||||
const currentSeats: CurrentSeats = [
|
||||
{
|
||||
uid: "booking-1",
|
||||
startTime: testTime.toDate(),
|
||||
_count: { attendees: 2 },
|
||||
},
|
||||
];
|
||||
|
||||
const result = checkForConflicts({
|
||||
time: testTime,
|
||||
eventLength: 30,
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
currentSeats,
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when currentSeats has no matching time but busy period conflicts", () => {
|
||||
const currentSeats: CurrentSeats = [
|
||||
{
|
||||
uid: "booking-1",
|
||||
startTime: dayjs.utc("2023-01-01T08:00:00Z").toDate(),
|
||||
_count: { attendees: 1 },
|
||||
},
|
||||
];
|
||||
|
||||
const result = checkForConflicts({
|
||||
time: dayjs.utc("2023-01-01T09:00:00Z"),
|
||||
eventLength: 30,
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
currentSeats,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle multiple currentSeats with one matching", () => {
|
||||
const testTime = dayjs.utc("2023-01-01T09:00:00Z");
|
||||
const currentSeats: CurrentSeats = [
|
||||
{
|
||||
uid: "booking-1",
|
||||
startTime: dayjs.utc("2023-01-01T08:00:00Z").toDate(),
|
||||
_count: { attendees: 1 },
|
||||
},
|
||||
{
|
||||
uid: "booking-2",
|
||||
startTime: testTime.toDate(),
|
||||
_count: { attendees: 1 },
|
||||
},
|
||||
];
|
||||
|
||||
const result = checkForConflicts({
|
||||
time: testTime,
|
||||
eventLength: 30,
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
currentSeats,
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle empty currentSeats array", () => {
|
||||
const result = checkForConflicts({
|
||||
time: dayjs.utc("2023-01-01T09:00:00Z"),
|
||||
eventLength: 30,
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
currentSeats: [],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("timezone and date handling", () => {
|
||||
it("should handle different timezone inputs correctly", () => {
|
||||
const result = checkForConflicts({
|
||||
time: dayjs("2023-01-01T09:00:00-05:00"), // EST
|
||||
eventLength: 30,
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T14:15:00Z").toDate(), // UTC equivalent of 9:15 EST
|
||||
end: dayjs.utc("2023-01-01T14:45:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle cross-day boundaries", () => {
|
||||
const result = checkForConflicts({
|
||||
time: dayjs.utc("2023-01-01T23:45:00Z"),
|
||||
eventLength: 30,
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-02T00:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-02T00:30:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("performance and stress scenarios", () => {
|
||||
it("should handle large number of busy periods efficiently", () => {
|
||||
const busyPeriods = [];
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const hour = (8 + (i % 12)) % 24; // Wrap around to prevent invalid hours
|
||||
const minute = i % 60;
|
||||
const endMinute = ((i % 60) + 15) % 60;
|
||||
const endHour = endMinute < minute ? (hour + 1) % 24 : hour; // Handle minute overflow
|
||||
|
||||
busyPeriods.push({
|
||||
start: dayjs
|
||||
.utc(`2023-01-01T${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}:00Z`)
|
||||
.toDate(),
|
||||
end: dayjs
|
||||
.utc(
|
||||
`2023-01-01T${endHour.toString().padStart(2, "0")}:${endMinute.toString().padStart(2, "0")}:00Z`
|
||||
)
|
||||
.toDate(),
|
||||
});
|
||||
}
|
||||
|
||||
const startTime = performance.now();
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T07:00:00Z"),
|
||||
busy: busyPeriods,
|
||||
});
|
||||
const endTime = performance.now();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(endTime - startTime).toBeLessThan(100); // Should complete in under 100ms
|
||||
});
|
||||
|
||||
it("should handle zero-duration busy periods", () => {
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T09:00:00Z"),
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:15:00Z").toDate(), // Zero duration
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,34 +26,25 @@ export function checkForConflicts({
|
||||
if (currentSeats?.some((booking) => booking.startTime.toISOString() === time.toISOString())) {
|
||||
return false;
|
||||
}
|
||||
const slotStartDate = time.utc().toDate();
|
||||
const slotEndDate = time.add(eventLength, "minutes").utc().toDate();
|
||||
// handle busy times.
|
||||
return busy.some((busyTime) => {
|
||||
const busyStartDate = dayjs.utc(busyTime.start).toDate();
|
||||
const busyEndDate = dayjs.utc(busyTime.end).toDate();
|
||||
// First check if there's any overlap at all
|
||||
// If busy period ends before slot starts or starts after slot ends, there's no overlap
|
||||
if (busyEndDate <= slotStartDate || busyStartDate >= slotEndDate) {
|
||||
return false;
|
||||
const slotStart = time.valueOf();
|
||||
const slotEnd = slotStart + eventLength * 60 * 1000;
|
||||
|
||||
const sortedBusyTimes = busy
|
||||
.map((busyTime) => ({
|
||||
start: dayjs.utc(busyTime.start).valueOf(),
|
||||
end: dayjs.utc(busyTime.end).valueOf(),
|
||||
}))
|
||||
.sort((a, b) => a.start - b.start);
|
||||
|
||||
for (const busyTime of sortedBusyTimes) {
|
||||
if (busyTime.start >= slotEnd) {
|
||||
break;
|
||||
}
|
||||
// Now check all possible overlap scenarios:
|
||||
// 1. Slot start falls within busy period (inclusive start, exclusive end)
|
||||
if (slotStartDate >= busyStartDate && slotStartDate < busyEndDate) {
|
||||
return true;
|
||||
if (busyTime.end <= slotStart) {
|
||||
continue;
|
||||
}
|
||||
// 2. Slot end falls within busy period (exclusive start, inclusive end)
|
||||
if (slotEndDate > busyStartDate && slotEndDate <= busyEndDate) {
|
||||
return true;
|
||||
}
|
||||
// 3. Busy period completely contained within slot
|
||||
if (busyStartDate >= slotStartDate && busyEndDate <= slotEndDate) {
|
||||
return true;
|
||||
}
|
||||
// 4. Slot completely contained within busy period
|
||||
if (busyStartDate <= slotStartDate && busyEndDate >= slotEndDate) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -160,7 +160,22 @@ export const ensureBookingInputsHaveSystemFields = ({
|
||||
type: "email",
|
||||
name: "email",
|
||||
required: !isEmailFieldOptional,
|
||||
editable: isOrgTeamEvent ? "system-but-optional" : "system",
|
||||
editable: "system-but-optional",
|
||||
sources: [
|
||||
{
|
||||
label: "Default",
|
||||
id: "default",
|
||||
type: "default",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defaultLabel: "phone_number",
|
||||
type: "phone",
|
||||
name: "attendeePhoneNumber",
|
||||
required: false,
|
||||
hidden: true,
|
||||
editable: "system-but-optional",
|
||||
sources: [
|
||||
{
|
||||
label: "Default",
|
||||
@@ -169,7 +184,6 @@ export const ensureBookingInputsHaveSystemFields = ({
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
defaultLabel: "location",
|
||||
type: "radioInput",
|
||||
@@ -204,23 +218,6 @@ export const ensureBookingInputsHaveSystemFields = ({
|
||||
],
|
||||
},
|
||||
];
|
||||
if (isOrgTeamEvent) {
|
||||
systemBeforeFields.splice(2, 0, {
|
||||
defaultLabel: "phone_number",
|
||||
type: "phone",
|
||||
name: "attendeePhoneNumber",
|
||||
required: false,
|
||||
hidden: true,
|
||||
editable: "system-but-optional",
|
||||
sources: [
|
||||
{
|
||||
label: "Default",
|
||||
id: "default",
|
||||
type: "default",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// These fields should be added after other user fields
|
||||
const systemAfterFields: typeof bookingFields = [
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -1396,6 +1396,8 @@ async function handler(
|
||||
routingFormResponseId,
|
||||
organizerId: organizerUser.id,
|
||||
teamId,
|
||||
isRerouting: !!reroutingFormResponses,
|
||||
reroutedByEmail: reqBody.rescheduledBy,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1500,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(
|
||||
|
||||
+8
-8
@@ -23,7 +23,6 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import { describe, expect } from "vitest";
|
||||
|
||||
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
|
||||
import { test } from "@calcom/web/test/fixtures/fixtures";
|
||||
@@ -256,7 +255,7 @@ describe("handleNewRecurringBooking", () => {
|
||||
timeout
|
||||
);
|
||||
|
||||
test(
|
||||
test.skip(
|
||||
`should fail recurring booking if second slot is already booked`,
|
||||
async ({}) => {
|
||||
const booker = getBooker({
|
||||
@@ -372,11 +371,12 @@ describe("handleNewRecurringBooking", () => {
|
||||
};
|
||||
});
|
||||
|
||||
await expect(() =>
|
||||
handleNewRecurringBooking({
|
||||
bookingData: bookingDataArray,
|
||||
userId: -1,
|
||||
})
|
||||
await expect(
|
||||
async () =>
|
||||
await handleNewRecurringBooking({
|
||||
bookingData: bookingDataArray,
|
||||
userId: -1,
|
||||
})
|
||||
).rejects.toThrow(ErrorCode.NoAvailableUsersFound);
|
||||
},
|
||||
timeout
|
||||
@@ -384,7 +384,7 @@ describe("handleNewRecurringBooking", () => {
|
||||
});
|
||||
|
||||
describe("Round robin event type:", () => {
|
||||
test("should fail recurring booking if a fixed host is not available on the second slot", async () => {
|
||||
test.skip("should fail recurring booking if a fixed host is not available on the second slot", async () => {
|
||||
const booker = getBooker({
|
||||
email: "booker@example.com",
|
||||
name: "Booker",
|
||||
|
||||
@@ -256,7 +256,7 @@ describe("handleNewBooking", () => {
|
||||
timeout
|
||||
);
|
||||
|
||||
test(
|
||||
test.skip(
|
||||
`should fail recurring booking if second slot is already booked`,
|
||||
async ({}) => {
|
||||
const handleRecurringEventBooking = (await import("@calcom/web/pages/api/book/recurring-event"))
|
||||
@@ -822,7 +822,7 @@ describe("handleNewBooking", () => {
|
||||
);
|
||||
});
|
||||
describe("Round robin event type:", () => {
|
||||
test("should when when a fixed host is not available on the second slot", async () => {
|
||||
test.skip("should when when a fixed host is not available on the second slot", async () => {
|
||||
const handleRecurringEventBooking = (await import("@calcom/web/pages/api/book/recurring-event"))
|
||||
.handleRecurringEventBooking;
|
||||
const booker = getBooker({
|
||||
|
||||
+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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,7 @@ export const handleNewRecurringBooking = async (input: BookingHandlerInput): Pro
|
||||
});
|
||||
const appsStatus: AppsStatus[] | undefined = undefined;
|
||||
|
||||
const numSlotsToCheckForAvailability = 2;
|
||||
const numSlotsToCheckForAvailability = 1;
|
||||
|
||||
let thirdPartyRecurringEventId = null;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -28,11 +28,15 @@ export default class AssignmentReasonRecorder {
|
||||
routingFormResponseId,
|
||||
organizerId,
|
||||
teamId,
|
||||
isRerouting,
|
||||
reroutedByEmail,
|
||||
}: {
|
||||
bookingId: number;
|
||||
routingFormResponseId: number;
|
||||
organizerId: number;
|
||||
teamId: number;
|
||||
isRerouting: boolean;
|
||||
reroutedByEmail?: string | null;
|
||||
}) {
|
||||
// Get the routing form data
|
||||
const routingFormResponse = await prisma.app_RoutingForms_FormResponse.findUnique({
|
||||
@@ -113,8 +117,12 @@ export default class AssignmentReasonRecorder {
|
||||
}
|
||||
}
|
||||
|
||||
const reasonEnum = AssignmentReasonEnum.ROUTING_FORM_ROUTING;
|
||||
const reasonString = attributeValues.join(", ");
|
||||
const reasonEnum = isRerouting
|
||||
? AssignmentReasonEnum.REROUTED
|
||||
: AssignmentReasonEnum.ROUTING_FORM_ROUTING;
|
||||
const reasonString = `${
|
||||
isRerouting && reroutedByEmail ? `Rerouted by ${reroutedByEmail}` : ""
|
||||
} ${attributeValues.join(", ")}`;
|
||||
|
||||
await prisma.assignmentReason.create({
|
||||
data: {
|
||||
|
||||
@@ -157,6 +157,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 +169,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: {
|
||||
|
||||
@@ -244,7 +244,7 @@ export default function Booker( props : BookerProps ) {
|
||||
namespace: string;
|
||||
}) => {
|
||||
return code`${getApiNameForVanillaJsSnippet({ namespace, mainApiName: "Cal" })}("inline", {
|
||||
elementOrSelector:"#my-cal-inline",
|
||||
elementOrSelector:"#my-cal-inline-${namespace}",
|
||||
config: ${JSON.stringify(previewState.config)},
|
||||
calLink: "${calLink}",
|
||||
});
|
||||
|
||||
@@ -59,7 +59,7 @@ export const tabs = [
|
||||
embedType === "inline"
|
||||
? `<div style="width:${getDimension(previewState.inline.width)};height:${getDimension(
|
||||
previewState.inline.height
|
||||
)};overflow:scroll" id="my-cal-inline"></div>\n`
|
||||
)};overflow:scroll" id="my-cal-inline-${namespace}"></div>\n`
|
||||
: ""
|
||||
}<script type="text/javascript">
|
||||
${embedSnippetString}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -577,27 +577,38 @@ export const EventAdvancedTab = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="border-subtle space-y-6 rounded-lg border p-6">
|
||||
<FormBuilder
|
||||
title={t("booking_questions_title")}
|
||||
description={t("booking_questions_description")}
|
||||
addFieldLabel={t("add_a_booking_question")}
|
||||
formProp="bookingFields"
|
||||
{...shouldLockDisableProps("bookingFields")}
|
||||
dataStore={{
|
||||
options: {
|
||||
locations: {
|
||||
// FormBuilder doesn't handle plural for non-english languages. So, use english(Location) only. This is similar to 'Workflow'
|
||||
source: { label: "Location" },
|
||||
value: getLocationsOptionsForSelect(formMethods.getValues("locations") ?? [], t),
|
||||
<div className="border-subtle bg-muted rounded-lg border p-1">
|
||||
<div className="p-5">
|
||||
<div className="text-default text-sm font-semibold leading-none ltr:mr-1 rtl:ml-1">
|
||||
{t("booking_questions_title")}
|
||||
</div>
|
||||
<p className="text-subtle mt-1 max-w-[280px] break-words text-sm sm:max-w-[500px]">
|
||||
{t("booking_questions_description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-subtle bg-default rounded-lg border p-5">
|
||||
<FormBuilder
|
||||
showPhoneAndEmailToggle
|
||||
title={t("confirmation")}
|
||||
description={t("what_booker_should_provide")}
|
||||
addFieldLabel={t("add_a_booking_question")}
|
||||
formProp="bookingFields"
|
||||
{...shouldLockDisableProps("bookingFields")}
|
||||
dataStore={{
|
||||
options: {
|
||||
locations: {
|
||||
// FormBuilder doesn't handle plural for non-english languages. So, use english(Location) only. This is similar to 'Workflow'
|
||||
source: { label: "Location" },
|
||||
value: getLocationsOptionsForSelect(formMethods.getValues("locations") ?? [], t),
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
shouldConsiderRequired={(field: BookingField) => {
|
||||
// Location field has a default value at backend so API can send no location but we don't allow it in UI and thus we want to show it as required to user
|
||||
return field.name === "location" ? true : field.required;
|
||||
}}
|
||||
/>
|
||||
}}
|
||||
shouldConsiderRequired={(field: BookingField) => {
|
||||
// Location field has a default value at backend so API can send no location but we don't allow it in UI and thus we want to show it as required to user
|
||||
return field.name === "location" ? true : field.required;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<RequiresConfirmationController
|
||||
eventType={eventType}
|
||||
|
||||
@@ -24,7 +24,6 @@ import { weekdayNames } from "@calcom/lib/weekday";
|
||||
import { weekStartNum } from "@calcom/lib/weekstart";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
@@ -121,6 +120,7 @@ type EventTypeScheduleProps = {
|
||||
isSchedulePending?: boolean;
|
||||
isRestrictionSchedulePending?: boolean;
|
||||
restrictionScheduleRedirectUrl?: string;
|
||||
isRestrictionScheduleEnabled?: boolean;
|
||||
} & Omit<EventTypeScheduleDetailsProps, "customClassNames"> &
|
||||
Omit<EventTypeTeamScheduleProps, "customClassNames">;
|
||||
|
||||
@@ -810,6 +810,7 @@ const useRestrictionScheduleState = (initialRestrictionScheduleId: number | null
|
||||
const UseTeamEventScheduleSettingsToggle = ({
|
||||
eventType,
|
||||
customClassNames,
|
||||
isRestrictionScheduleEnabled,
|
||||
...rest
|
||||
}: UseTeamEventScheduleSettingsToggle) => {
|
||||
const { t } = useLocale();
|
||||
@@ -819,18 +820,6 @@ const UseTeamEventScheduleSettingsToggle = ({
|
||||
eventType.restrictionScheduleId
|
||||
);
|
||||
|
||||
// Check if team has restriction schedule feature enabled
|
||||
const { data: isRestrictionScheduleEnabled = false } = trpc.viewer.features.checkTeamFeature.useQuery(
|
||||
{
|
||||
teamId: eventType.team?.id || 0,
|
||||
feature: "restriction-schedule",
|
||||
},
|
||||
{
|
||||
enabled: !!eventType.team?.id,
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="border-subtle space-y-6 rounded-lg border p-6">
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { DialogContent, DialogFooter, DialogHeader, DialogClose } from "@calcom/ui/components/dialog";
|
||||
import { Editor } from "@calcom/ui/components/editor";
|
||||
import { ToggleGroup } from "@calcom/ui/components/form";
|
||||
import {
|
||||
Switch,
|
||||
CheckboxField,
|
||||
@@ -59,6 +60,7 @@ export const FormBuilder = function FormBuilder({
|
||||
LockedIcon,
|
||||
dataStore,
|
||||
shouldConsiderRequired,
|
||||
showPhoneAndEmailToggle = false,
|
||||
}: {
|
||||
formProp: string;
|
||||
title: string;
|
||||
@@ -66,6 +68,7 @@ export const FormBuilder = function FormBuilder({
|
||||
addFieldLabel: string;
|
||||
disabled: boolean;
|
||||
LockedIcon: false | JSX.Element;
|
||||
showPhoneAndEmailToggle?: boolean;
|
||||
/**
|
||||
* A readonly dataStore that is used to lookup the options for the fields. It works in conjunction with the field.getOptionAt property which acts as the key in options
|
||||
*/
|
||||
@@ -129,7 +132,68 @@ export const FormBuilder = function FormBuilder({
|
||||
{title}
|
||||
{LockedIcon}
|
||||
</div>
|
||||
<p className="text-subtle mt-0.5 max-w-[280px] break-words text-sm sm:max-w-[500px]">{description}</p>
|
||||
<div className="flex items-start justify-between">
|
||||
<p className="text-subtle mt-1 max-w-[280px] break-words text-sm sm:max-w-[500px]">{description}</p>
|
||||
{showPhoneAndEmailToggle && (
|
||||
<ToggleGroup
|
||||
value={(() => {
|
||||
const phoneField = fields.find((field) => field.name === "attendeePhoneNumber");
|
||||
const emailField = fields.find((field) => field.name === "email");
|
||||
|
||||
if (phoneField && !phoneField.hidden && phoneField.required && !emailField?.required) {
|
||||
return "phone";
|
||||
}
|
||||
|
||||
return "email";
|
||||
})()}
|
||||
options={[
|
||||
{
|
||||
value: "email",
|
||||
label: "Email",
|
||||
iconLeft: <Icon name="mail" className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: "phone",
|
||||
label: "Phone",
|
||||
iconLeft: <Icon name="phone" className="h-4 w-4" />,
|
||||
},
|
||||
]}
|
||||
onValueChange={(value) => {
|
||||
const phoneFieldIndex = fields.findIndex((field) => field.name === "attendeePhoneNumber");
|
||||
const emailFieldIndex = fields.findIndex((field) => field.name === "email");
|
||||
if (value === "email") {
|
||||
update(emailFieldIndex, {
|
||||
...fields[emailFieldIndex],
|
||||
hidden: false,
|
||||
required: true,
|
||||
});
|
||||
update(phoneFieldIndex, {
|
||||
...fields[phoneFieldIndex],
|
||||
hidden: true,
|
||||
required: false,
|
||||
});
|
||||
} else if (value === "phone") {
|
||||
update(emailFieldIndex, {
|
||||
...fields[emailFieldIndex],
|
||||
hidden: true,
|
||||
required: false,
|
||||
});
|
||||
update(phoneFieldIndex, {
|
||||
...fields[phoneFieldIndex],
|
||||
hidden: false,
|
||||
required: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-default mt-5 text-sm font-semibold leading-none ltr:mr-1 rtl:ml-1">
|
||||
{t("questions")}
|
||||
</p>
|
||||
<p className="text-subtle mt-1 max-w-[280px] break-words text-sm sm:max-w-[500px]">
|
||||
{t("all_info_your_booker_provide")}
|
||||
</p>
|
||||
<ul ref={parent} className="border-subtle divide-subtle mt-4 divide-y rounded-md border">
|
||||
{fields.map((field, index) => {
|
||||
let options = field.options ?? null;
|
||||
@@ -480,6 +544,7 @@ function FieldEditDialog({
|
||||
const variantsConfig = fieldForm.watch("variantsConfig");
|
||||
|
||||
const fieldTypes = Object.values(fieldTypesConfigMap);
|
||||
const fieldName = fieldForm.getValues("name");
|
||||
|
||||
return (
|
||||
<Dialog open={dialog.isOpen} onOpenChange={onOpenChange} modal={false}>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -484,16 +484,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 +520,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}}`, "");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/");
|
||||
}
|
||||
|
||||
@@ -263,7 +263,7 @@ export class MembershipRepository {
|
||||
});
|
||||
}
|
||||
|
||||
static async findFirstByUserIdAndTeamId({ userId, teamId }: { userId: number; teamId: number }) {
|
||||
static async findUniqueByUserIdAndTeamId({ userId, teamId }: { userId: number; teamId: number }) {
|
||||
return await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
@@ -398,4 +398,24 @@ export class MembershipRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async findAllByTeamIds({
|
||||
teamIds,
|
||||
select = { userId: true },
|
||||
}: {
|
||||
teamIds: number[];
|
||||
select?: Prisma.MembershipSelect;
|
||||
}) {
|
||||
return prisma.membership.findMany({
|
||||
where: {
|
||||
team: {
|
||||
id: {
|
||||
in: teamIds,
|
||||
},
|
||||
},
|
||||
accepted: true,
|
||||
},
|
||||
select,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,6 +186,39 @@ export class TeamRepository {
|
||||
return getParsedTeam(team);
|
||||
}
|
||||
|
||||
static async findAllByParentId({
|
||||
parentId,
|
||||
select = teamSelect,
|
||||
}: {
|
||||
parentId: number;
|
||||
select?: Prisma.TeamSelect;
|
||||
}) {
|
||||
return await prisma.team.findMany({
|
||||
where: {
|
||||
parentId,
|
||||
},
|
||||
select,
|
||||
});
|
||||
}
|
||||
|
||||
static async findByIdAndParentId({
|
||||
id,
|
||||
parentId,
|
||||
select = teamSelect,
|
||||
}: {
|
||||
id: number;
|
||||
parentId: number;
|
||||
select?: Prisma.TeamSelect;
|
||||
}) {
|
||||
return await prisma.team.findFirst({
|
||||
where: {
|
||||
id,
|
||||
parentId,
|
||||
},
|
||||
select,
|
||||
});
|
||||
}
|
||||
|
||||
static async deleteById({ id }: { id: number }) {
|
||||
try {
|
||||
await WorkflowService.deleteWorkflowRemindersOfRemovedTeam(id);
|
||||
@@ -360,4 +393,18 @@ export class TeamRepository {
|
||||
inviteToken: inviteTokens.find((token) => token.identifier === `invite-link-for-teamId-${team.id}`),
|
||||
}));
|
||||
}
|
||||
|
||||
static async findTeamWithOrganizationSettings(teamId: number) {
|
||||
return await prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
select: {
|
||||
parent: {
|
||||
select: {
|
||||
isOrganization: true,
|
||||
organizationSettings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+28
-28
@@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus, MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { InsightsBookingRepository } from "../insightsBooking";
|
||||
import { InsightsBookingService } from "../../service/insightsBooking";
|
||||
|
||||
// Helper function to create unique test data
|
||||
async function createTestData({
|
||||
@@ -195,15 +195,15 @@ async function createTestData({
|
||||
};
|
||||
}
|
||||
|
||||
describe("InsightsBookingRepository Integration Tests", () => {
|
||||
describe("InsightsBookingService Integration Tests", () => {
|
||||
describe("Authorization Conditions", () => {
|
||||
it("should return NOTHING for invalid options", async () => {
|
||||
const repository = new InsightsBookingRepository({
|
||||
const service = new InsightsBookingService({
|
||||
prisma,
|
||||
options: null as any,
|
||||
});
|
||||
|
||||
const conditions = await repository.getAuthorizationConditions();
|
||||
const conditions = await service.getAuthorizationConditions();
|
||||
expect(conditions).toEqual({ id: -1 });
|
||||
});
|
||||
|
||||
@@ -229,7 +229,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const repository = new InsightsBookingRepository({
|
||||
const service = new InsightsBookingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "org",
|
||||
@@ -238,7 +238,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const conditions = await repository.getAuthorizationConditions();
|
||||
const conditions = await service.getAuthorizationConditions();
|
||||
expect(conditions).toEqual({ id: -1 });
|
||||
|
||||
// Clean up
|
||||
@@ -257,7 +257,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const repository = new InsightsBookingRepository({
|
||||
const service = new InsightsBookingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
@@ -266,7 +266,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const conditions = await repository.getAuthorizationConditions();
|
||||
const conditions = await service.getAuthorizationConditions();
|
||||
expect(conditions).toEqual({
|
||||
AND: [
|
||||
{
|
||||
@@ -285,7 +285,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const repository = new InsightsBookingRepository({
|
||||
const service = new InsightsBookingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "team",
|
||||
@@ -295,7 +295,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const conditions = await repository.getAuthorizationConditions();
|
||||
const conditions = await service.getAuthorizationConditions();
|
||||
expect(conditions).toEqual({
|
||||
AND: [
|
||||
{
|
||||
@@ -335,7 +335,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
|
||||
const team2 = testData.additionalTeams[0]; // First additional team
|
||||
const team3 = testData.additionalTeams[1]; // Second additional team
|
||||
|
||||
const repository = new InsightsBookingRepository({
|
||||
const service = new InsightsBookingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "org",
|
||||
@@ -344,7 +344,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const conditions = await repository.getAuthorizationConditions();
|
||||
const conditions = await service.getAuthorizationConditions();
|
||||
|
||||
expect(conditions).toEqual({
|
||||
AND: [
|
||||
@@ -375,7 +375,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
|
||||
it("should return null when no filters are provided", async () => {
|
||||
const testData = await createTestData();
|
||||
|
||||
const repository = new InsightsBookingRepository({
|
||||
const service = new InsightsBookingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
@@ -384,7 +384,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const conditions = await repository.getFilterConditions();
|
||||
const conditions = await service.getFilterConditions();
|
||||
expect(conditions).toBeNull();
|
||||
|
||||
await testData.cleanup();
|
||||
@@ -393,7 +393,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
|
||||
it("should build eventTypeId filter conditions", async () => {
|
||||
const testData = await createTestData();
|
||||
|
||||
const repository = new InsightsBookingRepository({
|
||||
const service = new InsightsBookingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
@@ -405,7 +405,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const conditions = await repository.getFilterConditions();
|
||||
const conditions = await service.getFilterConditions();
|
||||
expect(conditions).toEqual({
|
||||
AND: [
|
||||
{
|
||||
@@ -420,7 +420,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
|
||||
it("should build memberUserId filter conditions", async () => {
|
||||
const testData = await createTestData();
|
||||
|
||||
const repository = new InsightsBookingRepository({
|
||||
const service = new InsightsBookingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
@@ -432,7 +432,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const conditions = await repository.getFilterConditions();
|
||||
const conditions = await service.getFilterConditions();
|
||||
expect(conditions).toEqual({
|
||||
AND: [
|
||||
{
|
||||
@@ -447,7 +447,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
|
||||
it("should combine multiple filter conditions", async () => {
|
||||
const testData = await createTestData();
|
||||
|
||||
const repository = new InsightsBookingRepository({
|
||||
const service = new InsightsBookingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
@@ -460,7 +460,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const conditions = await repository.getFilterConditions();
|
||||
const conditions = await service.getFilterConditions();
|
||||
expect(conditions).toEqual({
|
||||
AND: [
|
||||
{
|
||||
@@ -483,7 +483,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const repository = new InsightsBookingRepository({
|
||||
const service = new InsightsBookingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
@@ -493,7 +493,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
|
||||
});
|
||||
|
||||
// First call should build conditions
|
||||
const conditions1 = await repository.getAuthorizationConditions();
|
||||
const conditions1 = await service.getAuthorizationConditions();
|
||||
expect(conditions1).toEqual({
|
||||
AND: [
|
||||
{
|
||||
@@ -504,7 +504,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
|
||||
});
|
||||
|
||||
// Second call should use cached conditions
|
||||
const conditions2 = await repository.getAuthorizationConditions();
|
||||
const conditions2 = await service.getAuthorizationConditions();
|
||||
expect(conditions2).toEqual(conditions1);
|
||||
|
||||
// Clean up
|
||||
@@ -514,7 +514,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
|
||||
it("should cache filter conditions", async () => {
|
||||
const testData = await createTestData();
|
||||
|
||||
const repository = new InsightsBookingRepository({
|
||||
const service = new InsightsBookingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
@@ -527,7 +527,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
|
||||
});
|
||||
|
||||
// First call should build conditions
|
||||
const conditions1 = await repository.getFilterConditions();
|
||||
const conditions1 = await service.getFilterConditions();
|
||||
expect(conditions1).toEqual({
|
||||
AND: [
|
||||
{
|
||||
@@ -537,7 +537,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
|
||||
});
|
||||
|
||||
// Second call should use cached conditions
|
||||
const conditions2 = await repository.getFilterConditions();
|
||||
const conditions2 = await service.getFilterConditions();
|
||||
expect(conditions2).toEqual(conditions1);
|
||||
|
||||
await testData.cleanup();
|
||||
@@ -575,7 +575,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const repository = new InsightsBookingRepository({
|
||||
const service = new InsightsBookingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
@@ -587,7 +587,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const results = await repository.findMany({
|
||||
const results = await service.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
@@ -0,0 +1,705 @@
|
||||
import type { Team, User, Membership } from "@prisma/client";
|
||||
import { randomUUID } from "crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus, MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { InsightsRoutingService } from "../../service/insightsRouting";
|
||||
|
||||
// Helper function to create unique test data
|
||||
async function createTestData({
|
||||
teamRole = MembershipRole.MEMBER,
|
||||
orgRole = MembershipRole.MEMBER,
|
||||
moreTeamsAndUsers = [],
|
||||
}: {
|
||||
teamRole?: MembershipRole;
|
||||
orgRole?: MembershipRole;
|
||||
moreTeamsAndUsers?: MembershipRole[][]; // Array of teams, each team has array of user roles
|
||||
} = {}) {
|
||||
// Create test user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: `test-user-${randomUUID()}@example.com`,
|
||||
username: `testuser-${randomUUID()}`,
|
||||
name: "Test User",
|
||||
},
|
||||
});
|
||||
|
||||
// Create test organization
|
||||
const org = await prisma.team.create({
|
||||
data: {
|
||||
name: "Test Org",
|
||||
slug: `test-org-${randomUUID()}`,
|
||||
isOrganization: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Create test team under the organization
|
||||
const team = await prisma.team.create({
|
||||
data: {
|
||||
name: "Test Team",
|
||||
slug: `test-team-${randomUUID()}`,
|
||||
isOrganization: false,
|
||||
parentId: org.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Create test event type
|
||||
const eventType = await prisma.eventType.create({
|
||||
data: {
|
||||
title: "Test Event Type",
|
||||
slug: `test-event-type-${randomUUID()}`,
|
||||
length: 60,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Create test booking
|
||||
const booking = await prisma.booking.create({
|
||||
data: {
|
||||
uid: `test-booking-${randomUUID()}`,
|
||||
title: "Test Booking",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(Date.now() + 60 * 60 * 1000),
|
||||
userId: user.id,
|
||||
eventTypeId: eventType.id,
|
||||
status: BookingStatus.PENDING,
|
||||
},
|
||||
});
|
||||
|
||||
// Create test routing form
|
||||
const form = await prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
name: "Test Routing Form",
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
fields: [
|
||||
{
|
||||
id: `text-field-id-${randomUUID()}`,
|
||||
type: "text",
|
||||
label: "Name",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: `email-field-id-${randomUUID()}`,
|
||||
type: "email",
|
||||
label: "Email",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Create test form response
|
||||
const formResponse = await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: `test-filler-${randomUUID()}`,
|
||||
formId: form.id,
|
||||
response: {
|
||||
[`text-field-id-${randomUUID()}`]: {
|
||||
label: "Name",
|
||||
value: "Test Name",
|
||||
},
|
||||
[`email-field-id-${randomUUID()}`]: {
|
||||
label: "Email",
|
||||
value: "test@example.com",
|
||||
},
|
||||
},
|
||||
routedToBookingUid: booking.uid,
|
||||
},
|
||||
});
|
||||
|
||||
const membership = await prisma.membership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
role: teamRole,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
const orgMembership = await prisma.membership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
teamId: org.id,
|
||||
role: orgRole,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Create additional teams and users based on moreTeamsAndUsers
|
||||
const additionalTeams: Team[] = [];
|
||||
const additionalUsers: User[] = [];
|
||||
const additionalMemberships: Membership[] = [];
|
||||
|
||||
for (let i = 0; i < moreTeamsAndUsers.length; i++) {
|
||||
const teamRoles = moreTeamsAndUsers[i];
|
||||
const additionalTeam = await prisma.team.create({
|
||||
data: {
|
||||
name: `Additional Team ${i + 1}`,
|
||||
slug: `additional-team-${i + 1}-${randomUUID()}`,
|
||||
isOrganization: false,
|
||||
parentId: org.id,
|
||||
},
|
||||
});
|
||||
additionalTeams.push(additionalTeam);
|
||||
|
||||
for (let j = 0; j < teamRoles.length; j++) {
|
||||
const role = teamRoles[j];
|
||||
const additionalUser = await prisma.user.create({
|
||||
data: {
|
||||
email: `additional-user-${i}-${j}-${randomUUID()}@example.com`,
|
||||
username: `additionaluser-${i}-${j}-${randomUUID()}`,
|
||||
name: `Additional User ${i}-${j}`,
|
||||
},
|
||||
});
|
||||
additionalUsers.push(additionalUser);
|
||||
|
||||
const additionalMembership = await prisma.membership.create({
|
||||
data: {
|
||||
userId: additionalUser.id,
|
||||
teamId: additionalTeam.id,
|
||||
role: role,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
additionalMemberships.push(additionalMembership);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
const cleanup = async () => {
|
||||
// Delete in reverse order to avoid foreign key constraints
|
||||
for (const membership of additionalMemberships) {
|
||||
await prisma.membership.delete({
|
||||
where: { id: membership.id },
|
||||
});
|
||||
}
|
||||
for (const user of additionalUsers) {
|
||||
await prisma.user.delete({
|
||||
where: { id: user.id },
|
||||
});
|
||||
}
|
||||
for (const team of additionalTeams) {
|
||||
await prisma.team.delete({
|
||||
where: { id: team.id },
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.membership.delete({
|
||||
where: { id: orgMembership.id },
|
||||
});
|
||||
await prisma.membership.delete({
|
||||
where: { id: membership.id },
|
||||
});
|
||||
await prisma.app_RoutingForms_FormResponse.delete({
|
||||
where: { id: formResponse.id },
|
||||
});
|
||||
await prisma.booking.delete({
|
||||
where: { id: booking.id },
|
||||
});
|
||||
await prisma.eventType.delete({
|
||||
where: { id: eventType.id },
|
||||
});
|
||||
await prisma.app_RoutingForms_Form.delete({
|
||||
where: { id: form.id },
|
||||
});
|
||||
await prisma.team.delete({
|
||||
where: { id: team.id },
|
||||
});
|
||||
await prisma.team.delete({
|
||||
where: { id: org.id },
|
||||
});
|
||||
await prisma.user.delete({
|
||||
where: { id: user.id },
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
user,
|
||||
org,
|
||||
team,
|
||||
eventType,
|
||||
booking,
|
||||
form,
|
||||
formResponse,
|
||||
membership,
|
||||
orgMembership,
|
||||
additionalTeams,
|
||||
additionalUsers,
|
||||
additionalMemberships,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
describe("InsightsRoutingService Integration Tests", () => {
|
||||
describe("Authorization Conditions", () => {
|
||||
it("should return NOTHING for invalid options", async () => {
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: null as any,
|
||||
});
|
||||
|
||||
const conditions = await service.getAuthorizationConditions();
|
||||
expect(conditions).toEqual({ id: -1 });
|
||||
});
|
||||
|
||||
it("should return NOTHING for non-owner/admin user", async () => {
|
||||
const testData = await createTestData();
|
||||
|
||||
// Create a regular user who is not an owner/admin
|
||||
const regularUser = await prisma.user.create({
|
||||
data: {
|
||||
email: `regular-user-${randomUUID()}@example.com`,
|
||||
username: `regularuser-${randomUUID()}`,
|
||||
name: "Regular User",
|
||||
},
|
||||
});
|
||||
|
||||
// Add the user as a regular member (not owner/admin) to the organization
|
||||
const membership = await prisma.membership.create({
|
||||
data: {
|
||||
userId: regularUser.id,
|
||||
teamId: testData.org.id,
|
||||
role: MembershipRole.MEMBER,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "org",
|
||||
userId: regularUser.id,
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
});
|
||||
|
||||
const conditions = await service.getAuthorizationConditions();
|
||||
expect(conditions).toEqual({ id: -1 });
|
||||
|
||||
// Clean up
|
||||
await prisma.membership.delete({
|
||||
where: { id: membership.id },
|
||||
});
|
||||
await prisma.user.delete({
|
||||
where: { id: regularUser.id },
|
||||
});
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should build user scope conditions", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
});
|
||||
|
||||
const conditions = await service.getAuthorizationConditions();
|
||||
expect(conditions).toEqual({
|
||||
AND: [
|
||||
{
|
||||
formUserId: testData.user.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should build team scope conditions", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "team",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
teamId: testData.team.id,
|
||||
},
|
||||
});
|
||||
|
||||
const conditions = await service.getAuthorizationConditions();
|
||||
expect(conditions).toEqual({
|
||||
AND: [
|
||||
{
|
||||
formTeamId: testData.team.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Clean up
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should build org scope conditions", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
moreTeamsAndUsers: [
|
||||
[MembershipRole.MEMBER], // Team 2: 1 user with MEMBER role
|
||||
[MembershipRole.MEMBER], // Team 3: 1 user with MEMBER role
|
||||
],
|
||||
});
|
||||
|
||||
// Get references to additional data
|
||||
const team2 = testData.additionalTeams[0]; // First additional team
|
||||
const team3 = testData.additionalTeams[1]; // Second additional team
|
||||
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "org",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
});
|
||||
|
||||
const conditions = await service.getAuthorizationConditions();
|
||||
|
||||
expect(conditions).toEqual({
|
||||
AND: [
|
||||
{
|
||||
formTeamId: {
|
||||
in: [testData.org.id, testData.team.id, team2.id, team3.id],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should return NOTHING for team scope when team is not child of org", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
// Create a team that is not a child of the org
|
||||
const unrelatedTeam = await prisma.team.create({
|
||||
data: {
|
||||
name: "Unrelated Team",
|
||||
slug: `unrelated-team-${randomUUID()}`,
|
||||
isOrganization: false,
|
||||
parentId: null, // Not part of the org
|
||||
},
|
||||
});
|
||||
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "team",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
teamId: unrelatedTeam.id,
|
||||
},
|
||||
});
|
||||
|
||||
const conditions = await service.getAuthorizationConditions();
|
||||
expect(conditions).toEqual({ AND: [{ id: -1 }] });
|
||||
|
||||
// Clean up
|
||||
await prisma.team.delete({
|
||||
where: { id: unrelatedTeam.id },
|
||||
});
|
||||
await testData.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Filter Conditions", () => {
|
||||
it("should return null when no filters are provided", async () => {
|
||||
const testData = await createTestData();
|
||||
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
});
|
||||
|
||||
const conditions = await service.getFilterConditions();
|
||||
expect(conditions).toBeNull();
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Caching", () => {
|
||||
it("should cache authorization conditions", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
});
|
||||
|
||||
// First call should build conditions
|
||||
const conditions1 = await service.getAuthorizationConditions();
|
||||
expect(conditions1).toEqual({
|
||||
AND: [
|
||||
{
|
||||
formUserId: testData.user.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Second call should use cached conditions
|
||||
const conditions2 = await service.getAuthorizationConditions();
|
||||
expect(conditions2).toEqual(conditions1);
|
||||
|
||||
// Clean up
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should cache filter conditions", async () => {
|
||||
const testData = await createTestData();
|
||||
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
});
|
||||
|
||||
// First call should build conditions
|
||||
const conditions1 = await service.getFilterConditions();
|
||||
expect(conditions1).toBeNull();
|
||||
|
||||
// Second call should use cached conditions
|
||||
const conditions2 = await service.getFilterConditions();
|
||||
expect(conditions2).toEqual(conditions1);
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findMany", () => {
|
||||
it("should combine authorization and filter conditions", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
});
|
||||
|
||||
const results = await service.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
formName: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Should return the user form response since it matches the authorization conditions
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.id).toBe(testData.formResponse.id);
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should filter out unauthorized responses", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
// Create another user and form
|
||||
const otherUser = await prisma.user.create({
|
||||
data: {
|
||||
email: `other-user-${randomUUID()}@example.com`,
|
||||
username: `otheruser-${randomUUID()}`,
|
||||
name: "Other User",
|
||||
},
|
||||
});
|
||||
|
||||
const otherForm = await prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
name: "Other User Form",
|
||||
userId: otherUser.id,
|
||||
teamId: null,
|
||||
fields: [
|
||||
{
|
||||
id: "text-field-id",
|
||||
type: "text",
|
||||
label: "Name",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const otherFormResponse = await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: `other-form-filler-${randomUUID()}`,
|
||||
formId: otherForm.id,
|
||||
response: {
|
||||
"text-field-id": {
|
||||
label: "Name",
|
||||
value: "Other Form Response",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
});
|
||||
|
||||
const results = await service.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
formName: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Should only return the authorized user's form response
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.id).toBe(testData.formResponse.id);
|
||||
expect(results[0]?.id).not.toBe(otherFormResponse.id);
|
||||
|
||||
// Clean up
|
||||
await prisma.app_RoutingForms_FormResponse.delete({
|
||||
where: { id: otherFormResponse.id },
|
||||
});
|
||||
await prisma.app_RoutingForms_Form.delete({
|
||||
where: { id: otherForm.id },
|
||||
});
|
||||
await prisma.user.delete({
|
||||
where: { id: otherUser.id },
|
||||
});
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should fetch all forms in org scope when user is in same team", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
// Create another user in the same team
|
||||
const teamMember = await prisma.user.create({
|
||||
data: {
|
||||
email: `team-member-${randomUUID()}@example.com`,
|
||||
username: `teammember-${randomUUID()}`,
|
||||
name: "Team Member",
|
||||
},
|
||||
});
|
||||
|
||||
// Add the team member to the same team
|
||||
const teamMembership = await prisma.membership.create({
|
||||
data: {
|
||||
userId: teamMember.id,
|
||||
teamId: testData.team.id,
|
||||
role: MembershipRole.MEMBER,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Create a form under the team member
|
||||
const teamMemberForm = await prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
name: "Team Member Form",
|
||||
userId: teamMember.id,
|
||||
teamId: testData.team.id, // Same team as the original user
|
||||
fields: [
|
||||
{
|
||||
id: `text-field-id-${randomUUID()}`,
|
||||
type: "text",
|
||||
label: "Name",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Create a form response for the team member's form
|
||||
const teamMemberFormResponse = await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: `team-member-form-filler-${randomUUID()}`,
|
||||
formId: teamMemberForm.id,
|
||||
response: {
|
||||
[`text-field-id-${randomUUID()}`]: {
|
||||
label: "Name",
|
||||
value: "Team Member Form Response",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "org",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
});
|
||||
|
||||
const results = await service.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
formName: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Should return both form responses (original user's and team member's)
|
||||
expect(results).toHaveLength(2);
|
||||
|
||||
const responseIds = results.map((r) => r.id).sort();
|
||||
const expectedIds = [testData.formResponse.id, teamMemberFormResponse.id].sort();
|
||||
expect(responseIds).toEqual(expectedIds);
|
||||
|
||||
// Clean up
|
||||
await prisma.app_RoutingForms_FormResponse.delete({
|
||||
where: { id: teamMemberFormResponse.id },
|
||||
});
|
||||
await prisma.app_RoutingForms_Form.delete({
|
||||
where: { id: teamMemberForm.id },
|
||||
});
|
||||
await prisma.membership.delete({
|
||||
where: { id: teamMembership.id },
|
||||
});
|
||||
await prisma.user.delete({
|
||||
where: { id: teamMember.id },
|
||||
});
|
||||
await testData.cleanup();
|
||||
});
|
||||
});
|
||||
});
|
||||
+35
-64
@@ -2,8 +2,12 @@ import type { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { readonlyPrisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
export const insightsBookingRepositoryOptionsSchema = z.discriminatedUnion("scope", [
|
||||
import { MembershipRepository } from "../repository/membership";
|
||||
import { TeamRepository } from "../repository/team";
|
||||
|
||||
export const insightsBookingServiceOptionsSchema = z.discriminatedUnion("scope", [
|
||||
z.object({
|
||||
scope: z.literal("user"),
|
||||
userId: z.number(),
|
||||
@@ -22,9 +26,9 @@ export const insightsBookingRepositoryOptionsSchema = z.discriminatedUnion("scop
|
||||
}),
|
||||
]);
|
||||
|
||||
export type InsightsBookingRepositoryOptions = z.infer<typeof insightsBookingRepositoryOptionsSchema>;
|
||||
export type InsightsBookingServiceOptions = z.infer<typeof insightsBookingServiceOptionsSchema>;
|
||||
|
||||
export type InsightsBookingRepositoryFilterOptions = {
|
||||
export type InsightsBookingServiceFilterOptions = {
|
||||
eventTypeId?: number;
|
||||
memberUserId?: number;
|
||||
};
|
||||
@@ -33,10 +37,10 @@ const NOTHING = {
|
||||
id: -1,
|
||||
} as const;
|
||||
|
||||
export class InsightsBookingRepository {
|
||||
export class InsightsBookingService {
|
||||
private prisma: typeof readonlyPrisma;
|
||||
private options: InsightsBookingRepositoryOptions | null;
|
||||
private filters?: InsightsBookingRepositoryFilterOptions;
|
||||
private options: InsightsBookingServiceOptions | null;
|
||||
private filters?: InsightsBookingServiceFilterOptions;
|
||||
private cachedAuthConditions?: Prisma.BookingTimeStatusDenormalizedWhereInput;
|
||||
private cachedFilterConditions?: Prisma.BookingTimeStatusDenormalizedWhereInput | null;
|
||||
|
||||
@@ -46,12 +50,11 @@ export class InsightsBookingRepository {
|
||||
filters,
|
||||
}: {
|
||||
prisma: typeof readonlyPrisma;
|
||||
options: InsightsBookingRepositoryOptions;
|
||||
filters?: InsightsBookingRepositoryFilterOptions;
|
||||
options: InsightsBookingServiceOptions;
|
||||
filters?: InsightsBookingServiceFilterOptions;
|
||||
}) {
|
||||
this.prisma = prisma;
|
||||
|
||||
const validation = insightsBookingRepositoryOptionsSchema.safeParse(options);
|
||||
const validation = insightsBookingServiceOptionsSchema.safeParse(options);
|
||||
this.options = validation.success ? validation.data : null;
|
||||
|
||||
this.filters = filters;
|
||||
@@ -138,37 +141,21 @@ export class InsightsBookingRepository {
|
||||
}
|
||||
|
||||
private async buildOrgAuthorizationCondition(
|
||||
options: Extract<InsightsBookingRepositoryOptions, { scope: "org" }>
|
||||
options: Extract<InsightsBookingServiceOptions, { scope: "org" }>
|
||||
): Promise<Prisma.BookingTimeStatusDenormalizedWhereInput> {
|
||||
// Get all teams from the organization
|
||||
const teamsFromOrg = await this.prisma.team.findMany({
|
||||
where: {
|
||||
parentId: options.orgId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
const teamsFromOrg = await TeamRepository.findAllByParentId({
|
||||
parentId: options.orgId,
|
||||
select: { id: true },
|
||||
});
|
||||
const teamIds = [options.orgId, ...teamsFromOrg.map((t) => t.id)];
|
||||
|
||||
// Get all users from the organization
|
||||
const userIdsFromOrg =
|
||||
teamsFromOrg.length > 0
|
||||
? (
|
||||
await this.prisma.membership.findMany({
|
||||
where: {
|
||||
team: {
|
||||
id: {
|
||||
in: teamIds,
|
||||
},
|
||||
},
|
||||
accepted: true,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
})
|
||||
).map((m) => m.userId)
|
||||
? (await MembershipRepository.findAllByTeamIds({ teamIds, select: { userId: true } })).map(
|
||||
(m) => m.userId
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
@@ -194,26 +181,20 @@ export class InsightsBookingRepository {
|
||||
}
|
||||
|
||||
private async buildTeamAuthorizationCondition(
|
||||
options: Extract<InsightsBookingRepositoryOptions, { scope: "team" }>
|
||||
options: Extract<InsightsBookingServiceOptions, { scope: "team" }>
|
||||
): Promise<Prisma.BookingTimeStatusDenormalizedWhereInput> {
|
||||
const childTeamOfOrg = await this.prisma.team.findFirst({
|
||||
where: {
|
||||
id: options.teamId,
|
||||
parentId: options.orgId,
|
||||
},
|
||||
const childTeamOfOrg = await TeamRepository.findByIdAndParentId({
|
||||
id: options.teamId,
|
||||
parentId: options.orgId,
|
||||
select: { id: true },
|
||||
});
|
||||
if (!childTeamOfOrg) {
|
||||
return NOTHING;
|
||||
}
|
||||
|
||||
const usersFromTeam = await this.prisma.membership.findMany({
|
||||
where: {
|
||||
teamId: options.teamId,
|
||||
accepted: true,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
const usersFromTeam = await MembershipRepository.findAllByTeamIds({
|
||||
teamIds: [options.teamId],
|
||||
select: { userId: true },
|
||||
});
|
||||
const userIdsFromTeam = usersFromTeam.map((u) => u.userId);
|
||||
|
||||
@@ -235,22 +216,12 @@ export class InsightsBookingRepository {
|
||||
|
||||
private async isOrgOwnerOrAdmin(userId: number, orgId: number): Promise<boolean> {
|
||||
// Check if the user is an owner or admin of the organization
|
||||
const membershipOrg = await this.prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId,
|
||||
teamId: orgId,
|
||||
},
|
||||
accepted: true,
|
||||
role: {
|
||||
in: ["OWNER", "ADMIN"],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
return Boolean(membershipOrg);
|
||||
const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId, teamId: orgId });
|
||||
return Boolean(
|
||||
membership &&
|
||||
membership.accepted &&
|
||||
membership.role &&
|
||||
([MembershipRole.OWNER, MembershipRole.ADMIN] as const).includes(membership.role)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { readonlyPrisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { MembershipRepository } from "../repository/membership";
|
||||
import { TeamRepository } from "../repository/team";
|
||||
|
||||
export const insightsRoutingServiceOptionsSchema = z.discriminatedUnion("scope", [
|
||||
z.object({
|
||||
scope: z.literal("user"),
|
||||
userId: z.number(),
|
||||
orgId: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
scope: z.literal("org"),
|
||||
userId: z.number(),
|
||||
orgId: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
scope: z.literal("team"),
|
||||
userId: z.number(),
|
||||
orgId: z.number(),
|
||||
teamId: z.number(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type InsightsRoutingServiceOptions = z.infer<typeof insightsRoutingServiceOptionsSchema>;
|
||||
|
||||
export type InsightsRoutingServiceFilterOptions = {
|
||||
// Empty for now
|
||||
};
|
||||
|
||||
const NOTHING = {
|
||||
id: -1,
|
||||
} as const;
|
||||
|
||||
export class InsightsRoutingService {
|
||||
private prisma: typeof readonlyPrisma;
|
||||
private options: InsightsRoutingServiceOptions | null;
|
||||
private filters?: InsightsRoutingServiceFilterOptions;
|
||||
private cachedAuthConditions?: Prisma.RoutingFormResponseDenormalizedWhereInput;
|
||||
private cachedFilterConditions?: Prisma.RoutingFormResponseDenormalizedWhereInput | null;
|
||||
|
||||
constructor({
|
||||
prisma,
|
||||
options,
|
||||
filters,
|
||||
}: {
|
||||
prisma: typeof readonlyPrisma;
|
||||
options: InsightsRoutingServiceOptions;
|
||||
filters?: InsightsRoutingServiceFilterOptions;
|
||||
}) {
|
||||
this.prisma = prisma;
|
||||
|
||||
const validation = insightsRoutingServiceOptionsSchema.safeParse(options);
|
||||
this.options = validation.success ? validation.data : null;
|
||||
|
||||
this.filters = filters;
|
||||
}
|
||||
|
||||
async findMany(findManyArgs: Prisma.RoutingFormResponseDenormalizedFindManyArgs) {
|
||||
const authConditions = await this.getAuthorizationConditions();
|
||||
const filterConditions = await this.getFilterConditions();
|
||||
|
||||
return this.prisma.routingFormResponseDenormalized.findMany({
|
||||
...findManyArgs,
|
||||
where: {
|
||||
...findManyArgs.where,
|
||||
AND: [authConditions, filterConditions].filter(
|
||||
(c): c is Prisma.RoutingFormResponseDenormalizedWhereInput => c !== null && c !== undefined
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getAuthorizationConditions(): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput> {
|
||||
if (this.cachedAuthConditions === undefined) {
|
||||
this.cachedAuthConditions = await this.buildAuthorizationConditions();
|
||||
}
|
||||
return this.cachedAuthConditions;
|
||||
}
|
||||
|
||||
async getFilterConditions(): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput | null> {
|
||||
if (this.cachedFilterConditions === undefined) {
|
||||
this.cachedFilterConditions = await this.buildFilterConditions();
|
||||
}
|
||||
return this.cachedFilterConditions;
|
||||
}
|
||||
|
||||
async buildFilterConditions(): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput | null> {
|
||||
// Empty for now
|
||||
return null;
|
||||
}
|
||||
|
||||
async buildAuthorizationConditions(): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput> {
|
||||
if (!this.options) {
|
||||
return NOTHING;
|
||||
}
|
||||
const isOwnerOrAdmin = await this.isOrgOwnerOrAdmin(this.options.userId, this.options.orgId);
|
||||
if (!isOwnerOrAdmin) {
|
||||
return NOTHING;
|
||||
}
|
||||
|
||||
const conditions: Prisma.RoutingFormResponseDenormalizedWhereInput[] = [];
|
||||
|
||||
if (this.options.scope === "user") {
|
||||
conditions.push({
|
||||
formUserId: this.options.userId,
|
||||
});
|
||||
} else if (this.options.scope === "org") {
|
||||
conditions.push(await this.buildOrgAuthorizationCondition(this.options));
|
||||
} else if (this.options.scope === "team") {
|
||||
conditions.push(await this.buildTeamAuthorizationCondition(this.options));
|
||||
} else {
|
||||
return NOTHING;
|
||||
}
|
||||
|
||||
return {
|
||||
AND: conditions,
|
||||
};
|
||||
}
|
||||
|
||||
private async buildOrgAuthorizationCondition(
|
||||
options: Extract<InsightsRoutingServiceOptions, { scope: "org" }>
|
||||
): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput> {
|
||||
// Get all teams from the organization
|
||||
const teamsFromOrg = await TeamRepository.findAllByParentId({
|
||||
parentId: options.orgId,
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return {
|
||||
formTeamId: {
|
||||
in: [options.orgId, ...teamsFromOrg.map((t) => t.id)],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async buildTeamAuthorizationCondition(
|
||||
options: Extract<InsightsRoutingServiceOptions, { scope: "team" }>
|
||||
): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput> {
|
||||
const childTeamOfOrg = await TeamRepository.findByIdAndParentId({
|
||||
id: options.teamId,
|
||||
parentId: options.orgId,
|
||||
select: { id: true },
|
||||
});
|
||||
if (!childTeamOfOrg) {
|
||||
return NOTHING;
|
||||
}
|
||||
|
||||
return {
|
||||
formTeamId: options.teamId,
|
||||
};
|
||||
}
|
||||
|
||||
private async isOrgOwnerOrAdmin(userId: number, orgId: number): Promise<boolean> {
|
||||
// Check if the user is an owner or admin of the organization
|
||||
const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId, teamId: orgId });
|
||||
return Boolean(
|
||||
membership &&
|
||||
membership.accepted &&
|
||||
membership.role &&
|
||||
(membership.role === MembershipRole.OWNER || membership.role === MembershipRole.ADMIN)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -418,7 +418,7 @@ export const assignValueToUserInOrgBulk = async ({
|
||||
attributeLabelToValueMap: AttributeLabelToValueMap;
|
||||
updater: BulkAttributeAssigner;
|
||||
}) => {
|
||||
const membership = await MembershipRepository.findFirstByUserIdAndTeamId({ userId, teamId: orgId });
|
||||
const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId, teamId: orgId });
|
||||
const defaultReturn = { numOfAttributeOptionsSet: 0, numOfAttributeOptionsDeleted: 0 };
|
||||
if (!membership) {
|
||||
console.error(`User ${userId} not a member of org ${orgId}, not assigning attribute options`);
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
globals.min.css
|
||||
globals.min.css
|
||||
cal-atoms-dev.tgz
|
||||
@@ -1,6 +1,14 @@
|
||||
## 1.0.114
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#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. 💥 BREAKING - `useGetBooking` hook has been renamed to `useBooking` hook and `useGetBookings` hook to `useBookings` and the data returned has different
|
||||
structure. Here is example response from `useBooking`:
|
||||
structure. Here is example response from `useBooking`:
|
||||
|
||||
```
|
||||
{
|
||||
"id": 73,
|
||||
@@ -47,9 +55,11 @@ structure. Here is example response from `useBooking`:
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
It is important to note that `useBooking` can return one booking, one individual recurrence of recurring booking or if id of recurring booking is passed then array
|
||||
of all recurrences within recurring booking. Response schema can be viewed in api docs https://cal.com/docs/api-reference/v2/bookings/get-a-booking and switching
|
||||
between data response objects in the dropdown. This means that in your frontend you need to handle case if its an individual object or an array of bookings:
|
||||
|
||||
```
|
||||
const { isLoading, data: booking, refetch } = useBooking((router.query.bookingUid as string) ?? "");
|
||||
|
||||
@@ -63,5 +73,5 @@ if (!Array.isArray(booking)) {
|
||||
```
|
||||
|
||||
2. FIX - phone booking field not showing correctly https://linear.app/calcom/issue/CAL-4465/platform-fix-phone-booking-field
|
||||
3. FEAT - ability to hide booker event type details sidebar https://linear.app/calcom/issue/CAL-4443/platform-feat-booker-hide-event-details
|
||||
4. FEAT - make booker name and email booker properties read only if specified https://linear.app/calcom/issue/CAL-4441/platform-feat-make-name-and-email-fields-not-editable
|
||||
3. FEAT - ability to hide booker event type details sidebar https://linear.app/calcom/issue/CAL-4443/platform-feat-booker-hide-event-details
|
||||
4. FEAT - make booker name and email booker properties read only if specified https://linear.app/calcom/issue/CAL-4441/platform-feat-make-name-and-email-fields-not-editable
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
Customizable UI components to integrate scheduling into your services.
|
||||
|
||||
### Support
|
||||
Currently supports React 18 and Next 14 while Next 15 is experimental.
|
||||
## Users guide
|
||||
|
||||
### Changelog
|
||||
### Support
|
||||
Currently supports React 18, React 19, Next 14 and Next 15.
|
||||
|
||||
### Changelog
|
||||
1. Changelog can be viewed [here](https://github.com/calcom/cal.com/blob/main/packages/platform/atoms/CHANGELOG.md).
|
||||
2. For upcoming changes in the next release click [here](https://github.com/calcom/cal.com/pulls?q=is%3Apr+is%3Aopen+%22chore%3A+version+packages%22+in%3Atitle) to see a pull request titled `chore: version packages` containing next release changes.
|
||||
|
||||
@@ -13,6 +15,25 @@ Currently supports React 18 and Next 14 while Next 15 is experimental.
|
||||
3. Some of the versions are suffixed e.g. `1.0.102-framer` and are intended for isolated use cases, so you most probably
|
||||
want to use version without any suffix e.g. `1.0.103`.
|
||||
|
||||
### Documentation
|
||||
### 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/
|
||||
@@ -30,6 +30,18 @@ const EventAvailabilityTabWebWrapper = (props: EventAvailabilityTabWebWrapperPro
|
||||
formMethods,
|
||||
});
|
||||
|
||||
// Check if team has restriction schedule feature enabled
|
||||
const { data: isRestrictionScheduleEnabled = false } = trpc.viewer.features.checkTeamFeature.useQuery(
|
||||
{
|
||||
teamId: props.eventType.team?.id || 0,
|
||||
feature: "restriction-schedule",
|
||||
},
|
||||
{
|
||||
enabled: !!props.eventType.team?.id,
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
}
|
||||
);
|
||||
|
||||
const { isPending: isSchedulePending, data: scheduleQueryData } =
|
||||
trpc.viewer.availability.schedule.get.useQuery(
|
||||
{
|
||||
@@ -66,6 +78,7 @@ const EventAvailabilityTabWebWrapper = (props: EventAvailabilityTabWebWrapperPro
|
||||
editAvailabilityRedirectUrl={`/availability/${scheduleQueryData?.id}`}
|
||||
restrictionScheduleRedirectUrl={`/availability/${restrictionScheduleQueryData?.id}`}
|
||||
hostSchedulesQuery={hostSchedulesQuery}
|
||||
isRestrictionScheduleEnabled={isRestrictionScheduleEnabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user