Compare commits
29
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 |
@@ -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=
|
||||
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
Vendored
Vendored
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/app-store-cli"
|
||||
- "@calcom/platform-constants"
|
||||
- "@calcom/platform-enums"
|
||||
- "@calcom/platform-types"
|
||||
- "@calcom/platform-utils"
|
||||
- "@calcom/prisma"
|
||||
@@ -38,8 +38,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.231",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.236",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
"@calcom/prisma": "*",
|
||||
|
||||
@@ -232,6 +232,15 @@ export class BookingsController_2024_04_15 {
|
||||
}
|
||||
|
||||
if (bookingUid) {
|
||||
const { bookingInfo } = await getBookingInfo(bookingUid);
|
||||
if (!bookingInfo) {
|
||||
throw new NotFoundException(`Booking with UID=${bookingUid} does not exist.`);
|
||||
}
|
||||
if (bookingInfo.status === "CANCELLED") {
|
||||
throw new BadRequestException(
|
||||
`Can't cancel booking with uid=${bookingUid} because it has been cancelled already. Please provide uid of a booking that is not cancelled.`
|
||||
);
|
||||
}
|
||||
try {
|
||||
req.body.uid = bookingUid;
|
||||
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
|
||||
|
||||
@@ -2553,6 +2553,59 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("cant't cancel already cancelled booking", () => {
|
||||
it("should not be able to cancel alraedy cancelled booking", async () => {
|
||||
const cancelledBooking = await bookingsRepositoryFixture.create({
|
||||
status: "CANCELLED",
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
|
||||
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
|
||||
title: "peer coding lets goo",
|
||||
uid: `cancelled-booking-${randomString()}`,
|
||||
eventType: {
|
||||
connect: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
},
|
||||
location: "integrations:daily",
|
||||
customInputs: {},
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "Oldie",
|
||||
email: "oldie@gmail.com",
|
||||
},
|
||||
attendees: {
|
||||
create: {
|
||||
email: "oldie@gmail.com",
|
||||
name: "Oldie",
|
||||
locale: "lv",
|
||||
timeZone: "Europe/Rome",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const body: CancelBookingInput_2024_08_13 = {
|
||||
cancellationReason: "Going on a vacation",
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${cancelledBooking.uid}/cancel`)
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error.message).toEqual(
|
||||
`Can't cancel booking with uid=${cancelledBooking.uid} because it has been cancelled already. Please provide uid of a booking that is not cancelled.`
|
||||
);
|
||||
await bookingsRepositoryFixture.deleteById(cancelledBooking.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calendar events", () => {
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
|
||||
@@ -711,6 +711,12 @@ export class InputBookingsService_2024_08_13 {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} not found`);
|
||||
}
|
||||
|
||||
if (booking.status === "CANCELLED") {
|
||||
throw new BadRequestException(
|
||||
`Can't cancel booking with uid=${bookingUid} because it has been cancelled already. Please provide uid of a booking that is not cancelled.`
|
||||
);
|
||||
}
|
||||
|
||||
const oAuthClientParams = booking.eventTypeId
|
||||
? await this.platformBookingsService.getOAuthClientParams(booking.eventTypeId)
|
||||
: undefined;
|
||||
|
||||
+24
-5
@@ -18,6 +18,7 @@ import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
@@ -33,13 +34,19 @@ import {
|
||||
HttpStatus,
|
||||
Delete,
|
||||
Query,
|
||||
Headers,
|
||||
InternalServerErrorException,
|
||||
ParseIntPipe,
|
||||
} from "@nestjs/common";
|
||||
import { ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
|
||||
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { getPublicEvent, getEventTypesByViewer } from "@calcom/platform-libraries-0.0.2";
|
||||
import {
|
||||
EVENT_TYPE_READ,
|
||||
EVENT_TYPE_WRITE,
|
||||
SUCCESS_STATUS,
|
||||
X_CAL_CLIENT_ID,
|
||||
} from "@calcom/platform-constants";
|
||||
import { getPublicEvent, getEventTypesByViewer } from "@calcom/platform-libraries/event-types";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Controller({
|
||||
@@ -51,7 +58,8 @@ import { PrismaClient } from "@calcom/prisma";
|
||||
export class EventTypesController_2024_04_15 {
|
||||
constructor(
|
||||
private readonly eventTypesService: EventTypesService_2024_04_15,
|
||||
private readonly prismaReadService: PrismaReadService
|
||||
private readonly prismaReadService: PrismaReadService,
|
||||
private readonly organizationsRepository: OrganizationsRepository
|
||||
) {}
|
||||
|
||||
@Post("/")
|
||||
@@ -109,19 +117,30 @@ export class EventTypesController_2024_04_15 {
|
||||
async getPublicEventType(
|
||||
@Param("username") username: string,
|
||||
@Param("eventSlug") eventSlug: string,
|
||||
@Query() queryParams: GetPublicEventTypeQueryParams_2024_04_15
|
||||
@Query() queryParams: GetPublicEventTypeQueryParams_2024_04_15,
|
||||
@Headers(X_CAL_CLIENT_ID) clientId?: string
|
||||
): Promise<GetEventTypePublicOutput> {
|
||||
try {
|
||||
let orgSlug = queryParams.org;
|
||||
|
||||
if (clientId && !orgSlug && username.includes(`-${clientId}`)) {
|
||||
const org = await this.organizationsRepository.findTeamIdAndSlugFromClientId(clientId).catch(() => null);
|
||||
if (org) {
|
||||
orgSlug = org.slug;
|
||||
}
|
||||
}
|
||||
|
||||
const event = await getPublicEvent(
|
||||
username.toLowerCase(),
|
||||
eventSlug,
|
||||
queryParams.isTeamEvent,
|
||||
queryParams.org || null,
|
||||
orgSlug ?? null,
|
||||
this.prismaReadService.prisma as unknown as PrismaClient,
|
||||
// We should be fine allowing unpublished orgs events to be servable through platform because Platform access is behind license
|
||||
// If there is ever a need to restrict this, we can introduce a new query param `fromRedirectOfNonOrgLink`
|
||||
true
|
||||
);
|
||||
|
||||
return {
|
||||
data: event,
|
||||
status: SUCCESS_STATUS,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { EventTypesController_2024_04_15 } from "@/ee/event-types/event-types_20
|
||||
import { EventTypesRepository_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.repository";
|
||||
import { EventTypesService_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/services/event-types.service";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { SelectedCalendarsModule } from "@/modules/selected-calendars/selected-calendars.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
@@ -9,7 +10,14 @@ import { UsersModule } from "@/modules/users/users.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, MembershipsModule, TokensModule, UsersModule, SelectedCalendarsModule],
|
||||
imports: [
|
||||
PrismaModule,
|
||||
MembershipsModule,
|
||||
TokensModule,
|
||||
UsersModule,
|
||||
SelectedCalendarsModule,
|
||||
OrganizationsModule,
|
||||
],
|
||||
providers: [EventTypesRepository_2024_04_15, EventTypesService_2024_04_15],
|
||||
controllers: [EventTypesController_2024_04_15],
|
||||
exports: [EventTypesService_2024_04_15, EventTypesRepository_2024_04_15],
|
||||
|
||||
+9
-1
@@ -1721,7 +1721,7 @@ describe("Event types Endpoints", () => {
|
||||
description: "undefined booking fields",
|
||||
length: 40,
|
||||
hidden: false,
|
||||
slug: "undefined-booking-fields",
|
||||
slug: `undefined-booking-fields-${randomString()}`,
|
||||
locations: [],
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
};
|
||||
@@ -1751,6 +1751,14 @@ describe("Event types Endpoints", () => {
|
||||
disableOnPrefill: false,
|
||||
hidden: false,
|
||||
},
|
||||
{
|
||||
disableOnPrefill: false,
|
||||
hidden: true,
|
||||
isDefault: true,
|
||||
required: false,
|
||||
slug: "attendeePhoneNumber",
|
||||
type: "phone",
|
||||
},
|
||||
{
|
||||
isDefault: true,
|
||||
type: "radioInput",
|
||||
|
||||
+9
-11
@@ -46,11 +46,10 @@ export class SchedulesController_2024_04_15 {
|
||||
@Body() bodySchedule: CreateScheduleInput_2024_04_15
|
||||
): Promise<CreateScheduleOutput_2024_04_15> {
|
||||
const schedule = await this.schedulesService.createUserSchedule(user.id, bodySchedule);
|
||||
const scheduleFormatted = await this.schedulesService.formatScheduleForAtom(user, schedule);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: scheduleFormatted,
|
||||
data: schedule,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,13 +59,10 @@ export class SchedulesController_2024_04_15 {
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<GetDefaultScheduleOutput_2024_04_15 | null> {
|
||||
const schedule = await this.schedulesService.getUserScheduleDefault(user.id);
|
||||
const scheduleFormatted = schedule
|
||||
? await this.schedulesService.formatScheduleForAtom(user, schedule)
|
||||
: null;
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: scheduleFormatted,
|
||||
data: schedule,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,23 +73,25 @@ export class SchedulesController_2024_04_15 {
|
||||
@Param("scheduleId") scheduleId: number
|
||||
): Promise<GetScheduleOutput_2024_04_15> {
|
||||
const schedule = await this.schedulesService.getUserSchedule(user.id, scheduleId);
|
||||
const scheduleFormatted = await this.schedulesService.formatScheduleForAtom(user, schedule);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: scheduleFormatted,
|
||||
data: schedule,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/")
|
||||
@Permissions([SCHEDULE_READ])
|
||||
async getSchedules(@GetUser() user: UserWithProfile): Promise<GetSchedulesOutput_2024_04_15> {
|
||||
const schedules = await this.schedulesService.getUserSchedules(user.id);
|
||||
const schedulesFormatted = await this.schedulesService.formatSchedulesForAtom(user, schedules);
|
||||
const schedules = await this.schedulesService.getUserSchedules(
|
||||
user.id,
|
||||
user.timeZone,
|
||||
user.defaultScheduleId
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: schedulesFormatted,
|
||||
data: schedules,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
import { CreateAvailabilityInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-availability.input";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/outputs/schedule.output";
|
||||
import { SchedulesRepository_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.repository";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Schedule } from "@prisma/client";
|
||||
import { User } from "@prisma/client";
|
||||
|
||||
import type { ScheduleWithAvailabilities } from "@calcom/platform-libraries-0.0.2";
|
||||
import { updateScheduleHandler } from "@calcom/platform-libraries-0.0.2";
|
||||
import {
|
||||
transformWorkingHoursForClient,
|
||||
transformAvailabilityForClient,
|
||||
transformDateOverridesForClient,
|
||||
} from "@calcom/platform-libraries-0.0.2";
|
||||
import { updateSchedule } from "@calcom/platform-libraries/schedules";
|
||||
import { ScheduleRepository } from "@calcom/platform-libraries/schedules";
|
||||
import { UpdateScheduleInput_2024_04_15 } from "@calcom/platform-types";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Injectable()
|
||||
export class SchedulesService_2024_04_15 {
|
||||
constructor(
|
||||
private readonly schedulesRepository: SchedulesRepository_2024_04_15,
|
||||
private readonly usersRepository: UsersRepository
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly dbWrite: PrismaWriteService
|
||||
) {}
|
||||
|
||||
async createUserDefaultSchedule(userId: number, timeZone: string) {
|
||||
@@ -48,7 +44,9 @@ export class SchedulesService_2024_04_15 {
|
||||
await this.usersRepository.setDefaultSchedule(userId, createdSchedule.id);
|
||||
}
|
||||
|
||||
return createdSchedule;
|
||||
const formattedSchedule = await this.getUserSchedule(userId, createdSchedule.id);
|
||||
|
||||
return formattedSchedule;
|
||||
}
|
||||
|
||||
async getUserScheduleDefault(userId: number) {
|
||||
@@ -56,11 +54,29 @@ export class SchedulesService_2024_04_15 {
|
||||
|
||||
if (!user?.defaultScheduleId) return null;
|
||||
|
||||
return this.schedulesRepository.getScheduleById(user.defaultScheduleId);
|
||||
return await ScheduleRepository.findDetailedScheduleById({
|
||||
scheduleId: user.defaultScheduleId,
|
||||
isManagedEventType: undefined,
|
||||
userId,
|
||||
timeZone: user.timeZone,
|
||||
defaultScheduleId: user.defaultScheduleId,
|
||||
});
|
||||
}
|
||||
|
||||
async getUserSchedule(userId: number, scheduleId: number) {
|
||||
const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId);
|
||||
const user = await this.usersRepository.findById(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException(`User with ID=${userId} does not exist.`);
|
||||
}
|
||||
|
||||
const existingSchedule = await ScheduleRepository.findDetailedScheduleById({
|
||||
scheduleId: scheduleId,
|
||||
isManagedEventType: undefined,
|
||||
userId,
|
||||
timeZone: user.timeZone,
|
||||
defaultScheduleId: user.defaultScheduleId,
|
||||
});
|
||||
|
||||
if (!existingSchedule) {
|
||||
throw new NotFoundException(`Schedule with ID=${scheduleId} does not exist.`);
|
||||
@@ -71,8 +87,8 @@ export class SchedulesService_2024_04_15 {
|
||||
return existingSchedule;
|
||||
}
|
||||
|
||||
async getUserSchedules(userId: number) {
|
||||
return this.schedulesRepository.getSchedulesByUserId(userId);
|
||||
async getUserSchedules(userId: number, timeZone: string, defaultScheduleId: number | null) {
|
||||
return ScheduleRepository.findManyDetailedScheduleByUserId({ userId, timeZone, defaultScheduleId });
|
||||
}
|
||||
|
||||
async updateUserSchedule(
|
||||
@@ -89,7 +105,6 @@ export class SchedulesService_2024_04_15 {
|
||||
this.checkUserOwnsSchedule(user.id, existingSchedule);
|
||||
|
||||
const schedule = await this.getUserSchedule(user.id, Number(scheduleId));
|
||||
const scheduleFormatted = await this.formatScheduleForAtom(user, schedule);
|
||||
|
||||
if (!bodySchedule.schedule) {
|
||||
// note(Lauris): When updating an availability in cal web app, lets say only its name, also
|
||||
@@ -97,12 +112,16 @@ export class SchedulesService_2024_04_15 {
|
||||
// and they have same shape, so to match shapes I attach "scheduleFormatted.availability" to reflect
|
||||
// schedule that would be passed by the web app. If we don't, then updating schedule name will erase
|
||||
// schedule.
|
||||
bodySchedule.schedule = scheduleFormatted.availability;
|
||||
bodySchedule.schedule = schedule.availability;
|
||||
}
|
||||
|
||||
return updateScheduleHandler({
|
||||
input: { scheduleId: Number(scheduleId), ...bodySchedule },
|
||||
ctx: { user },
|
||||
return updateSchedule({
|
||||
input: {
|
||||
scheduleId: Number(scheduleId),
|
||||
...bodySchedule,
|
||||
},
|
||||
user,
|
||||
prisma: this.dbWrite.prisma as unknown as PrismaClient,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -118,44 +137,6 @@ export class SchedulesService_2024_04_15 {
|
||||
return this.schedulesRepository.deleteScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
async formatScheduleForAtom(user: User, schedule: ScheduleWithAvailabilities): Promise<ScheduleOutput> {
|
||||
const usersSchedulesCount = await this.schedulesRepository.getUserSchedulesCount(user.id);
|
||||
return this.transformScheduleForAtom(schedule, usersSchedulesCount, user);
|
||||
}
|
||||
|
||||
async formatSchedulesForAtom(
|
||||
user: User,
|
||||
schedules: ScheduleWithAvailabilities[]
|
||||
): Promise<ScheduleOutput[]> {
|
||||
const usersSchedulesCount = await this.schedulesRepository.getUserSchedulesCount(user.id);
|
||||
return Promise.all(
|
||||
schedules.map((schedule) => this.transformScheduleForAtom(schedule, usersSchedulesCount, user))
|
||||
);
|
||||
}
|
||||
|
||||
async transformScheduleForAtom(
|
||||
schedule: ScheduleWithAvailabilities,
|
||||
userSchedulesCount: number,
|
||||
user: Pick<User, "id" | "defaultScheduleId" | "timeZone">
|
||||
): Promise<ScheduleOutput> {
|
||||
const timeZone = schedule.timeZone || user.timeZone;
|
||||
const defaultSchedule = await this.getUserScheduleDefault(user.id);
|
||||
|
||||
return {
|
||||
id: schedule.id,
|
||||
name: schedule.name,
|
||||
isManaged: schedule.userId !== user.id,
|
||||
workingHours: transformWorkingHoursForClient(schedule),
|
||||
schedule: schedule.availability,
|
||||
availability: transformAvailabilityForClient(schedule),
|
||||
timeZone,
|
||||
dateOverrides: transformDateOverridesForClient(schedule, timeZone),
|
||||
isDefault: defaultSchedule?.id === schedule.id,
|
||||
isLastSchedule: userSchedulesCount <= 1,
|
||||
readOnly: schedule.userId !== user.id,
|
||||
};
|
||||
}
|
||||
|
||||
checkUserOwnsSchedule(userId: number, schedule: Pick<Schedule, "id" | "userId">) {
|
||||
if (userId !== schedule.userId) {
|
||||
throw new ForbiddenException(`User with ID=${userId} does not own schedule with ID=${schedule.id}`);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { extractUserContext } from "@/lib/extract-user-context";
|
||||
import { filterReqHeaders } from "@/lib/filterReqHeaders";
|
||||
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
@@ -39,6 +40,7 @@ export class CalendarServiceExceptionFilter implements ExceptionFilter {
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
|
||||
const userContext = extractUserContext(request);
|
||||
this.logger.error(`Calendar Service Exception Filter: ${exception?.message}`, {
|
||||
exception,
|
||||
body: request.body,
|
||||
@@ -46,6 +48,7 @@ export class CalendarServiceExceptionFilter implements ExceptionFilter {
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
requestId,
|
||||
...userContext,
|
||||
});
|
||||
|
||||
response.status(400).json({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { extractUserContext } from "@/lib/extract-user-context";
|
||||
import { filterReqHeaders } from "@/lib/filterReqHeaders";
|
||||
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Logger } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
@@ -16,6 +17,7 @@ export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
|
||||
const statusCode = exception.getStatus();
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
const userContext = extractUserContext(request);
|
||||
this.logger.error(`Http Exception Filter: ${exception?.message}`, {
|
||||
exception,
|
||||
body: request.body,
|
||||
@@ -23,6 +25,7 @@ export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
requestId,
|
||||
...userContext,
|
||||
});
|
||||
|
||||
response.status(statusCode).json({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { extractUserContext } from "@/lib/extract-user-context";
|
||||
import { filterReqHeaders } from "@/lib/filterReqHeaders";
|
||||
import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
|
||||
import { Catch, HttpStatus, Logger } from "@nestjs/common";
|
||||
@@ -43,6 +44,7 @@ export class PrismaExceptionFilter implements ExceptionFilter {
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
|
||||
const userContext = extractUserContext(request);
|
||||
this.logger.error(`PrismaError: ${error.message}`, {
|
||||
error,
|
||||
body: request.body,
|
||||
@@ -50,6 +52,7 @@ export class PrismaExceptionFilter implements ExceptionFilter {
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
requestId,
|
||||
...userContext,
|
||||
});
|
||||
|
||||
let message = "There was an error, please try again later.";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { extractUserContext } from "@/lib/extract-user-context";
|
||||
import { filterReqHeaders } from "@/lib/filterReqHeaders";
|
||||
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
@@ -120,6 +121,7 @@ export class TRPCExceptionFilter implements ExceptionFilter {
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
|
||||
const userContext = extractUserContext(request);
|
||||
this.logger.error(`TRPC Exception Filter: ${exception?.message}`, {
|
||||
exception,
|
||||
body: request.body,
|
||||
@@ -127,6 +129,7 @@ export class TRPCExceptionFilter implements ExceptionFilter {
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
requestId,
|
||||
...userContext,
|
||||
});
|
||||
|
||||
response.status(statusCode).json({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { extractUserContext } from "@/lib/extract-user-context";
|
||||
import { filterReqHeaders } from "@/lib/filterReqHeaders";
|
||||
import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
|
||||
import { Catch, HttpStatus, Logger } from "@nestjs/common";
|
||||
@@ -18,6 +19,7 @@ export class ZodExceptionFilter implements ExceptionFilter {
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
|
||||
const userContext = extractUserContext(request);
|
||||
this.logger.error(`ZodError: ${error.message}`, {
|
||||
error,
|
||||
body: request.body,
|
||||
@@ -25,6 +27,7 @@ export class ZodExceptionFilter implements ExceptionFilter {
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
requestId,
|
||||
...userContext,
|
||||
});
|
||||
|
||||
response.status(HttpStatus.BAD_REQUEST).json({
|
||||
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
MostCancelledBookingsTables,
|
||||
PopularEventsTable,
|
||||
RecentFeedbackTable,
|
||||
TimezoneBadge,
|
||||
} from "@calcom/features/insights/components";
|
||||
import "@calcom/features/insights/components/tremor.css";
|
||||
import { InsightsOrgTeamsProvider } from "@calcom/features/insights/context/InsightsOrgTeamsProvider";
|
||||
@@ -62,6 +63,7 @@ function InsightsPageContent() {
|
||||
<div className="grow" />
|
||||
<Download />
|
||||
<DateRangeFilter column={createdAtColumn} />
|
||||
<TimezoneBadge />
|
||||
</div>
|
||||
|
||||
<div className="my-4 space-y-4">
|
||||
|
||||
@@ -652,8 +652,9 @@ const ProfileForm = ({
|
||||
/>
|
||||
</div>
|
||||
{extraField}
|
||||
<p className="text-subtle mt-1 flex items-center gap-1 text-sm">
|
||||
<Icon name="info" /> {t("tip_username_plus")}
|
||||
<p className="text-subtle mt-1 flex gap-1 text-sm">
|
||||
<Icon name="info" className="mt-0.5 flex-shrink-0" />
|
||||
<span className="flex-1">{t("tip_username_plus")}</span>
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<TextField label={t("full_name")} {...formMethods.register("name")} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.4.9",
|
||||
"version": "5.4.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,
|
||||
});
|
||||
|
||||
@@ -3267,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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
+3170
-765
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -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;
|
||||
};
|
||||
|
||||
@@ -100,6 +100,7 @@ export async function getBookingToDelete(id: number | undefined, uid: string | u
|
||||
responses: true,
|
||||
iCalUID: true,
|
||||
iCalSequence: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -89,6 +89,19 @@ async function handler(input: CancelBookingInput) {
|
||||
arePlatformEmailsEnabled,
|
||||
} = input;
|
||||
|
||||
/**
|
||||
* Important: We prevent cancelling an already cancelled booking.
|
||||
* A booking could have been CANCELLED due to a reschedule,
|
||||
* in which case we simply update the existing calendar event and meeting.
|
||||
* We want to avoid deleting them by a subsequent cancellation attempt.
|
||||
*/
|
||||
if (bookingToDelete.status === BookingStatus.CANCELLED) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: "This booking has already been cancelled.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!bookingToDelete.userId || !bookingToDelete.user) {
|
||||
throw new HttpError({ statusCode: 400, message: "User not found" });
|
||||
}
|
||||
|
||||
@@ -1502,15 +1502,6 @@ async function handler(
|
||||
evt.videoCallData = undefined;
|
||||
// To prevent "The requested identifier already exists" error while updating event, we need to remove iCalUID
|
||||
evt.iCalUID = undefined;
|
||||
} else {
|
||||
// In case of rescheduling, we need to keep the previous host destination calendar
|
||||
evt = CalendarEventBuilder.fromEvent(evt)
|
||||
.withDestinationCalendar(
|
||||
originalRescheduledBooking?.destinationCalendar
|
||||
? [originalRescheduledBooking?.destinationCalendar]
|
||||
: evt.destinationCalendar
|
||||
)
|
||||
.build();
|
||||
}
|
||||
|
||||
const updateManager = await eventManager.reschedule(
|
||||
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
import {
|
||||
createBookingScenario,
|
||||
getGoogleCalendarCredential,
|
||||
TestData,
|
||||
getOrganizer,
|
||||
getBooker,
|
||||
getScenarioData,
|
||||
mockSuccessfulVideoMeetingCreation,
|
||||
mockCalendarToHaveNoBusySlots,
|
||||
getDate,
|
||||
BookingLocations,
|
||||
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
|
||||
import { expectBookingToBeInDatabase } from "@calcom/web/test/utils/bookingScenario/expects";
|
||||
import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking";
|
||||
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { describe, expect, beforeEach } from "vitest";
|
||||
|
||||
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
|
||||
import { resetTestEmails } from "@calcom/lib/testEmails";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { test } from "@calcom/web/test/fixtures/fixtures";
|
||||
|
||||
export type CustomNextApiRequest = NextApiRequest & Request;
|
||||
|
||||
export type CustomNextApiResponse = NextApiResponse & Response;
|
||||
|
||||
const timeout = process.env.CI ? 5000 : 20000;
|
||||
|
||||
describe("handleNewBooking", () => {
|
||||
setupAndTeardown();
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestEmails();
|
||||
});
|
||||
|
||||
describe("Collective Event Reschedule with Destination Calendar Bug", () => {
|
||||
test(
|
||||
`should reschedule a collective event booking and ensure both hosts have the booking in their calendars when a second host is added
|
||||
1. Create a collective event with 1 host and book it
|
||||
2. Add a second host to the event type
|
||||
3. Reschedule the booking
|
||||
4. Verify both hosts have the booking in their calendars
|
||||
`,
|
||||
async ({ emails }) => {
|
||||
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
|
||||
|
||||
const booker = getBooker({
|
||||
email: "booker@example.com",
|
||||
name: "Booker",
|
||||
});
|
||||
|
||||
const organizer = getOrganizer({
|
||||
name: "Organizer",
|
||||
email: "organizer@example.com",
|
||||
id: 101,
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
credentials: [getGoogleCalendarCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
destinationCalendar: {
|
||||
integration: "google_calendar",
|
||||
externalId: "organizer@google-calendar.com",
|
||||
primaryEmail: "organizer@google-calendar.com",
|
||||
},
|
||||
});
|
||||
|
||||
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
||||
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
|
||||
const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP";
|
||||
const iCalUID = `${uidOfBookingToBeRescheduled}@Cal.com`;
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
slotInterval: 15,
|
||||
length: 30,
|
||||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
users: [
|
||||
{
|
||||
id: 101, // organizer
|
||||
},
|
||||
{
|
||||
id: 102, // second host (will be added to event type)
|
||||
},
|
||||
],
|
||||
destinationCalendar: {
|
||||
integration: "google_calendar",
|
||||
externalId: "event-type-1@google-calendar.com",
|
||||
primaryEmail: "event-type-1@google-calendar.com",
|
||||
},
|
||||
},
|
||||
],
|
||||
users: [
|
||||
{
|
||||
id: 101,
|
||||
name: "Organizer",
|
||||
email: "organizer@example.com",
|
||||
username: "organizer",
|
||||
timeZone: "Asia/Kolkata",
|
||||
defaultScheduleId: null,
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
credentials: [getGoogleCalendarCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
destinationCalendar: {
|
||||
integration: "google_calendar",
|
||||
externalId: "organizer@google-calendar.com",
|
||||
primaryEmail: "organizer@google-calendar.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
name: "Second Host",
|
||||
email: "second-host@example.com",
|
||||
username: "second-host",
|
||||
timeZone: "Asia/Kolkata",
|
||||
defaultScheduleId: null,
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
credentials: [getGoogleCalendarCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
destinationCalendar: {
|
||||
integration: "google_calendar",
|
||||
externalId: "second-host@google-calendar.com",
|
||||
primaryEmail: "second-host@google-calendar.com",
|
||||
},
|
||||
},
|
||||
],
|
||||
bookings: [
|
||||
{
|
||||
uid: uidOfBookingToBeRescheduled,
|
||||
eventTypeId: 1,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
startTime: `${plus1DateString}T05:00:00.000Z`,
|
||||
endTime: `${plus1DateString}T05:30:00.000Z`,
|
||||
users: [
|
||||
{
|
||||
id: 101, // organizer
|
||||
},
|
||||
],
|
||||
references: [
|
||||
{
|
||||
type: appStoreMetadata.dailyvideo.type,
|
||||
uid: "MOCK_ID_1",
|
||||
meetingId: "MOCK_ID_1",
|
||||
meetingPassword: "MOCK_PASS_1",
|
||||
meetingUrl: "http://mock-dailyvideo.example.com/meeting-1",
|
||||
credentialId: null,
|
||||
},
|
||||
{
|
||||
type: appStoreMetadata.googlecalendar.type,
|
||||
uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID_1",
|
||||
meetingId: "MOCKED_GOOGLE_CALENDAR_EVENT_ID_1",
|
||||
meetingPassword: "MOCK_PASSWORD",
|
||||
meetingUrl: "https://UNUSED_URL",
|
||||
externalCalendarId: "event-type-1@google-calendar.com",
|
||||
credentialId: undefined,
|
||||
},
|
||||
],
|
||||
iCalUID,
|
||||
},
|
||||
],
|
||||
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
||||
})
|
||||
);
|
||||
|
||||
mockSuccessfulVideoMeetingCreation({
|
||||
metadataLookupKey: appStoreMetadata.dailyvideo.dirName,
|
||||
videoMeetingData: {
|
||||
id: "MOCK_ID_1", // Video meeting ID is not updated during reschedule
|
||||
password: "MOCK_PASS_1",
|
||||
url: `http://mock-dailyvideo.example.com/meeting-1`,
|
||||
},
|
||||
});
|
||||
|
||||
const organizerCalendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", {
|
||||
update: {
|
||||
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID_2",
|
||||
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID_2",
|
||||
},
|
||||
});
|
||||
|
||||
const rescheduleBookingData = getMockRequestDataForBooking({
|
||||
data: {
|
||||
start: `${plus2DateString}T11:00:00.000Z`,
|
||||
end: `${plus2DateString}T11:30:00.000Z`,
|
||||
eventTypeId: 1,
|
||||
rescheduleUid: uidOfBookingToBeRescheduled,
|
||||
responses: {
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
location: { optionValue: "", value: BookingLocations.CalVideo },
|
||||
},
|
||||
rescheduledBy: organizer.email,
|
||||
},
|
||||
});
|
||||
|
||||
const rescheduledBooking = await handleNewBooking({
|
||||
bookingData: rescheduleBookingData,
|
||||
});
|
||||
|
||||
await expectBookingToBeInDatabase({
|
||||
description: "",
|
||||
location: BookingLocations.CalVideo,
|
||||
responses: expect.objectContaining({
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
}),
|
||||
uid: rescheduledBooking.uid!,
|
||||
eventTypeId: rescheduleBookingData.eventTypeId,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
references: [
|
||||
{
|
||||
type: appStoreMetadata.dailyvideo.type,
|
||||
uid: "MOCK_ID_1",
|
||||
meetingId: "MOCK_ID_1",
|
||||
meetingPassword: "MOCK_PASS_1",
|
||||
meetingUrl: "http://mock-dailyvideo.example.com/meeting-1",
|
||||
},
|
||||
{
|
||||
type: appStoreMetadata.googlecalendar.type,
|
||||
uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID_1",
|
||||
externalCalendarId: "event-type-1@google-calendar.com",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(organizerCalendarMock.updateEventCalls.length).toBe(1);
|
||||
expect(organizerCalendarMock.createEventCalls.length).toBe(0);
|
||||
|
||||
// Check availability calls for both hosts
|
||||
expect(organizerCalendarMock.getAvailabilityCalls.length).toBe(2);
|
||||
},
|
||||
timeout
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -55,7 +55,10 @@ export class StripeBillingService implements BillingService {
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
metadata: metadata,
|
||||
});
|
||||
invoice_creation: {
|
||||
enabled: true,
|
||||
},
|
||||
} as any);
|
||||
|
||||
return {
|
||||
checkoutUrl: session.url,
|
||||
|
||||
@@ -34,6 +34,11 @@ vi.mock("@calcom/prisma", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/getOrgIdFromMemberOrTeamId", () => ({
|
||||
default: vi.fn().mockResolvedValue(null),
|
||||
getPublishedOrgIdFromMemberOrTeamId: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
describe("Twilio Webhook Handler", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -117,10 +122,6 @@ describe("Twilio Webhook Handler", () => {
|
||||
mockFindFirst.mockResolvedValue(null);
|
||||
const webhookHandler = (await import("../../../../../../apps/web/pages/api/twilio/webhook")).default;
|
||||
|
||||
vi.mock("@calcom/lib/getOrgIdFromMemberOrTeamId", () => ({
|
||||
default: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
const mockRequest = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
||||
@@ -107,6 +107,66 @@ const getLocationInfo = ({
|
||||
return { locationAvailable, locationDetails };
|
||||
};
|
||||
|
||||
const LocationInput = (props: {
|
||||
eventLocationType: EventLocationType;
|
||||
defaultValue?: string;
|
||||
index: number;
|
||||
customClassNames?: LocationInputCustomClassNames;
|
||||
disableLocationProp?: boolean;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const { eventLocationType, index, customClassNames, disableLocationProp, ...remainingProps } = props;
|
||||
if (eventLocationType?.organizerInputType === "text") {
|
||||
const { defaultValue, ...rest } = remainingProps;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
|
||||
defaultValue={defaultValue}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<Input
|
||||
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
|
||||
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
|
||||
type="text"
|
||||
required
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
{...(disableLocationProp ? { disabled: true } : {})}
|
||||
className={classNames("my-0", customClassNames?.addressInput)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (eventLocationType?.organizerInputType === "phone") {
|
||||
const { defaultValue, ...rest } = remainingProps;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
|
||||
defaultValue={defaultValue}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<PhoneInput
|
||||
required
|
||||
disabled={disableLocationProp}
|
||||
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
|
||||
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
|
||||
className={customClassNames?.phoneInput}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const Locations: React.FC<LocationsProps> = ({
|
||||
isChildrenManagedEventType,
|
||||
disableLocationProp,
|
||||
@@ -168,64 +228,6 @@ const Locations: React.FC<LocationsProps> = ({
|
||||
locationOptions: props.locationOptions,
|
||||
});
|
||||
|
||||
const LocationInput = (props: {
|
||||
eventLocationType: EventLocationType;
|
||||
defaultValue?: string;
|
||||
index: number;
|
||||
customClassNames?: LocationInputCustomClassNames;
|
||||
}) => {
|
||||
const { eventLocationType, index, customClassNames, ...remainingProps } = props;
|
||||
if (eventLocationType?.organizerInputType === "text") {
|
||||
const { defaultValue, ...rest } = remainingProps;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
|
||||
defaultValue={defaultValue}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<Input
|
||||
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
|
||||
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
|
||||
type="text"
|
||||
required
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
{...(disableLocationProp ? { disabled: true } : {})}
|
||||
className={classNames("my-0", customClassNames?.addressInput)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (eventLocationType?.organizerInputType === "phone") {
|
||||
const { defaultValue, ...rest } = remainingProps;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
|
||||
defaultValue={defaultValue}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<PhoneInput
|
||||
required
|
||||
disabled={disableLocationProp}
|
||||
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
|
||||
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
|
||||
className={customClassNames?.phoneInput}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const [showEmptyLocationSelect, setShowEmptyLocationSelect] = useState(false);
|
||||
const defaultInitialLocation = defaultValue || null;
|
||||
const [selectedNewOption, setSelectedNewOption] = useState<SingleValueLocationOption | null>(
|
||||
@@ -459,6 +461,7 @@ const Locations: React.FC<LocationsProps> = ({
|
||||
eventLocationType={eventLocationType}
|
||||
index={index}
|
||||
customClassNames={customClassNames?.organizerContactInput?.locationInput}
|
||||
disableLocationProp={disableLocationProp}
|
||||
/>
|
||||
</div>
|
||||
<ErrorMessage
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useDataTable } from "@calcom/features/data-table";
|
||||
import NoSSR from "@calcom/lib/components/NoSSR";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
const TimezoneBadgeContent = () => {
|
||||
const { t } = useLocale();
|
||||
const { timeZone: userTimezone } = useDataTable();
|
||||
|
||||
const timezoneData = useMemo(() => {
|
||||
// Use Cal's standard CURRENT_TIMEZONE constant
|
||||
const browserTimezone = CURRENT_TIMEZONE;
|
||||
|
||||
if (!browserTimezone || !userTimezone || browserTimezone === userTimezone) return null;
|
||||
|
||||
const tooltipContent = t("timezone_mismatch_tooltip", {
|
||||
browserTimezone,
|
||||
userTimezone,
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
|
||||
return {
|
||||
browser: browserTimezone,
|
||||
user: userTimezone,
|
||||
tooltipContent,
|
||||
badgeContent: userTimezone,
|
||||
};
|
||||
}, [userTimezone, t]);
|
||||
|
||||
// Don't render anything if no timezone mismatch
|
||||
if (!timezoneData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip content={timezoneData.tooltipContent}>
|
||||
<Badge variant="gray" size="sm" data-testid="timezone-mismatch-badge">
|
||||
<Icon name="info" />
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export const TimezoneBadge = () => {
|
||||
return (
|
||||
<NoSSR fallback={null}>
|
||||
<TimezoneBadgeContent />
|
||||
</NoSSR>
|
||||
);
|
||||
};
|
||||
@@ -13,3 +13,4 @@ export { RecentFeedbackTable } from "./RecentFeedbackTable";
|
||||
export { RoutedToPerPeriod } from "./RoutedToPerPeriod";
|
||||
export { RoutingFormResponsesTable, type RoutingFormTableType } from "./RoutingFormResponsesTable";
|
||||
export { RoutingKPICards } from "./RoutingKPICards";
|
||||
export { TimezoneBadge } from "./TimezoneBadge";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -138,6 +138,70 @@ export class ScheduleRepository {
|
||||
isDefault: !scheduleId || defaultScheduleId === schedule.id,
|
||||
isLastSchedule: schedulesCount <= 1,
|
||||
readOnly: schedule.userId !== userId && !isManagedEventType,
|
||||
userId: schedule.userId,
|
||||
};
|
||||
}
|
||||
|
||||
static async findManyDetailedScheduleByUserId({
|
||||
isManagedEventType,
|
||||
userId,
|
||||
defaultScheduleId,
|
||||
|
||||
timeZone: userTimeZone,
|
||||
}: {
|
||||
timeZone: string;
|
||||
userId: number;
|
||||
defaultScheduleId: number | null;
|
||||
|
||||
isManagedEventType?: boolean;
|
||||
}) {
|
||||
const schedules = await prisma.schedule.findMany({
|
||||
where: {
|
||||
userId: userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
name: true,
|
||||
availability: true,
|
||||
timeZone: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!schedules?.length) {
|
||||
throw new Error("Schedules not found");
|
||||
}
|
||||
|
||||
const isCurrentUserPartOfTeam = await hasReadPermissionsForUserId({
|
||||
memberId: schedules[0].userId,
|
||||
userId,
|
||||
});
|
||||
|
||||
const schedulesFormatted = schedules.map((schedule) => {
|
||||
const isCurrentUserOwner = schedule?.userId === userId;
|
||||
|
||||
if (!isCurrentUserPartOfTeam && !isCurrentUserOwner) {
|
||||
throw new Error("UNAUTHORIZED");
|
||||
}
|
||||
|
||||
const timeZone = schedule.timeZone || userTimeZone;
|
||||
// disabling utc casting while fetching WorkingHours
|
||||
return {
|
||||
id: schedule.id,
|
||||
name: schedule.name,
|
||||
isManaged: schedule.userId !== userId,
|
||||
workingHours: transformWorkingHoursForAtom(schedule),
|
||||
schedule: schedule.availability,
|
||||
availability: transformAvailabilityForAtom(schedule),
|
||||
timeZone,
|
||||
isDefault: schedule.id === defaultScheduleId,
|
||||
dateOverrides: transformDateOverridesForAtom(schedule, timeZone),
|
||||
readOnly: schedule.userId !== userId && !isManagedEventType,
|
||||
isLastSchedule: schedules.length <= 1,
|
||||
userId: schedule.userId,
|
||||
};
|
||||
});
|
||||
|
||||
return schedulesFormatted;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
globals.min.css
|
||||
globals.min.css
|
||||
cal-atoms-dev.tgz
|
||||
@@ -1,5 +1,7 @@
|
||||
Customizable UI components to integrate scheduling into your services.
|
||||
|
||||
## Users guide
|
||||
|
||||
### Support
|
||||
Currently supports React 18, React 19, Next 14 and Next 15.
|
||||
|
||||
@@ -16,3 +18,22 @@ want to use version without any suffix e.g. `1.0.103`.
|
||||
### Documentation
|
||||
Documentation on how to get started with platform solution is [here](https://cal.com/docs/platform/quickstart) and list of atoms can be viewed
|
||||
[here](https://cal.com/docs/platform/atoms/cal-provider)
|
||||
|
||||
## Contributors guide
|
||||
|
||||
### Versioning
|
||||
We use a tool called changesets that helps documenting changes related to your development branch and then manages
|
||||
atoms versioning and publishing to [npm](https://www.npmjs.com/package/@calcom/atoms). We need to add a log documenting changes and then letting changesets to gather the changes, update Changelog and update version in `package.json`.
|
||||
|
||||
1. Let's say you are on a development branch and just finished adding a new feature to atoms. While on the development branch, you have to add a log documenting this feature so that it later ends up in the atoms [CHANGELOG.md](https://github.com/calcom/cal.com/blob/main/packages/platform/atoms/CHANGELOG.md).
|
||||
- Run `yarn changesets-add` from monorepo root and then select `@calcom/atoms` using space bar and press enter to go to the next step.
|
||||
- Then, you have to select whether this is a major, minor or patch update following semantic versioning. Since it is a feature skip major by pressing enter and then select minor by pressing space bar and press enter to go to the next step.
|
||||
- Then, you have to write a description of the change and press enter. This will generate a log file in the `.changeset` directory e.g. `.changeset/hungry-donuts-cross.md`.
|
||||
- Commit this log file to your development branch and push it.
|
||||
Notably, you do not have to change `"version"` in the atoms `package.json` file because changesets will do it in the next step.
|
||||
2. After the development branch is merged changesets will open a pull request titled `chore: version packages` containing next release changes. This pull request will contain the new log file, it being added to the atoms `CHANGELOG.md` file and changesets will update the atoms `package.json` file based whether or not is is major, minor or patch update. When we want to release atoms we simply have to merge this pull request and changesets will publish the new atoms version to npm. Notably,
|
||||
changesets will publish atoms to npm only if the `"version"` in the atoms `package.json` of changeset's PR is higher than in the npm.
|
||||
|
||||
The following 2 articles teach how to write good change summaries for each PR when it deserves to end up in CHANGELOG.md
|
||||
- https://keepachangelog.com/en/1.0.0/
|
||||
- https://docs.gitlab.com/development/changelog/
|
||||
@@ -9,7 +9,8 @@
|
||||
"dev": "yarn vite build --watch & npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify --watch",
|
||||
"build": "NODE_OPTIONS='--max_old_space_size=12288' rm -rf dist && yarn vite build && npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify && mkdir ./dist/packages/prisma-client && cp -rf ../../../node_modules/.prisma/client/*.d.ts ./dist/packages/prisma-client",
|
||||
"publish-npm": "yarn build && npm publish --access public",
|
||||
"test": "jest"
|
||||
"test": "jest",
|
||||
"pack-dev": "yarn build && npm pack && mv calcom-atoms-*.tgz cal-atoms-dev.tgz"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-node-resolve": "^15.0.1",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -19,7 +19,7 @@ REDIS_URL="redis://localhost:6379"
|
||||
```
|
||||
|
||||
3. Start "apps/api/v2" api using `yarn dev`.
|
||||
4. Start "packages/platform/atoms" atoms package with `yarn dev`.
|
||||
4. Setup "packages/platform/atoms" by running `yarn atoms-dev-on` in the example app directory - it will pack atoms into a dev .tgz and switch the dependency to point to it. When you are done with development, run `yarn atoms-dev-off` to switch back to the published version because otherwise examples app would have entry in the yarn.lock file pointing to the dev .tgz and installation would fail in prod because dev tar file exists only locally.
|
||||
5. Start "apps/web" cal web app using `yarn dx`.
|
||||
6. Open your browser at "http://localhost:3000/" and login with admin username `admin@example.com` and password `ADMINadmin2022!`.
|
||||
7. In the web app navigate to `http://localhost:3000/settings/organizations/new` and create a sample organization. When asked for phone verification code enter `111111`.
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
echo "📦 Packing @calcom/atoms into dev .tgz..."
|
||||
yarn workspace @calcom/atoms pack-dev
|
||||
|
||||
echo "🔧 Switching @calcom/atoms to dev .tgz..."
|
||||
|
||||
# Detect platform for sed compatibility
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
sed -i '' 's|"@calcom/atoms": ".*"|"@calcom/atoms": "file:../../atoms/cal-atoms-dev.tgz"|' package.json
|
||||
else
|
||||
sed -i 's|"@calcom/atoms": ".*"|"@calcom/atoms": "file:../../atoms/cal-atoms-dev.tgz"|' package.json
|
||||
fi
|
||||
|
||||
yarn install
|
||||
|
||||
echo "✅ Local atoms .tgz linked. Ready for dev."
|
||||
@@ -4,6 +4,8 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "PORT=4321 next dev",
|
||||
"atoms-dev-on": "./atoms-dev-on.sh",
|
||||
"atoms-dev-off": "git checkout -- package.json ../../../../yarn.lock",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+148
@@ -0,0 +1,148 @@
|
||||
import type { User, Team, EventType } from "@prisma/client";
|
||||
import { describe, it, expect, afterAll, beforeAll } from "vitest";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { listWithTeamHandler } from "./listWithTeam.handler";
|
||||
|
||||
let user1: User;
|
||||
let user2: User;
|
||||
let team1: Team;
|
||||
let team2: Team;
|
||||
let eventType1: EventType;
|
||||
let eventType2: EventType;
|
||||
let eventType3: EventType;
|
||||
let eventType4: EventType;
|
||||
|
||||
describe("listWithTeamHandler", () => {
|
||||
beforeAll(async () => {
|
||||
// Create users, teams and event types
|
||||
user1 = await prisma.user.create({
|
||||
data: {
|
||||
username: "testuser-lwt-1",
|
||||
email: "testuser-lwt-1@example.com",
|
||||
name: "Test User 1",
|
||||
},
|
||||
});
|
||||
user2 = await prisma.user.create({
|
||||
data: {
|
||||
username: "testuser-lwt-2",
|
||||
email: "testuser-lwt-2@example.com",
|
||||
name: "Test User 2",
|
||||
},
|
||||
});
|
||||
|
||||
team1 = await prisma.team.create({
|
||||
data: {
|
||||
name: "Team 1 lwt",
|
||||
slug: "team-1-lwt",
|
||||
members: {
|
||||
create: {
|
||||
userId: user1.id,
|
||||
role: MembershipRole.MEMBER,
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
team2 = await prisma.team.create({
|
||||
data: {
|
||||
name: "Team 2 lwt",
|
||||
slug: "team-2-lwt",
|
||||
},
|
||||
});
|
||||
|
||||
eventType1 = await prisma.eventType.create({
|
||||
data: {
|
||||
title: "User 1 Event",
|
||||
slug: "user1-event-lwt",
|
||||
length: 30,
|
||||
userId: user1.id,
|
||||
},
|
||||
});
|
||||
|
||||
eventType2 = await prisma.eventType.create({
|
||||
data: {
|
||||
title: "Team 1 Event",
|
||||
slug: "team1-event-lwt",
|
||||
length: 30,
|
||||
teamId: team1.id,
|
||||
userId: user1.id,
|
||||
},
|
||||
});
|
||||
|
||||
eventType3 = await prisma.eventType.create({
|
||||
data: {
|
||||
title: "User 2 Event",
|
||||
slug: "user2-event-lwt",
|
||||
length: 30,
|
||||
userId: user2.id,
|
||||
},
|
||||
});
|
||||
|
||||
eventType4 = await prisma.eventType.create({
|
||||
data: {
|
||||
title: "Team 2 Event",
|
||||
slug: "team2-event-lwt",
|
||||
length: 30,
|
||||
teamId: team2.id,
|
||||
userId: user2.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.eventType.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: [eventType1.id, eventType2.id, eventType3.id, eventType4.id],
|
||||
},
|
||||
},
|
||||
});
|
||||
await prisma.team.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: [team1.id, team2.id],
|
||||
},
|
||||
},
|
||||
});
|
||||
await prisma.user.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: [user1.id, user2.id],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should return user's own event types and event types of teams they are a member of", async () => {
|
||||
const result = await listWithTeamHandler({
|
||||
ctx: {
|
||||
// we only need the id from the user object
|
||||
user: { id: user1.id } as any,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
|
||||
const resultIds = result.map((e) => e.id);
|
||||
expect(resultIds).toContain(eventType1.id);
|
||||
expect(resultIds).toContain(eventType2.id);
|
||||
|
||||
const eventType1Result = result.find((e) => e.id === eventType1.id);
|
||||
expect(eventType1Result).toBeDefined();
|
||||
expect(eventType1Result?.title).toBe(eventType1.title);
|
||||
expect(eventType1Result?.slug).toBe(eventType1.slug);
|
||||
expect(eventType1Result?.team).toBeNull();
|
||||
|
||||
const eventType2Result = result.find((e) => e.id === eventType2.id);
|
||||
expect(eventType2Result).toBeDefined();
|
||||
expect(eventType2Result?.title).toBe(eventType2.title);
|
||||
expect(eventType2Result?.slug).toBe(eventType2.slug);
|
||||
expect(eventType2Result?.team).toBeDefined();
|
||||
expect(eventType2Result?.team?.id).toBe(team1.id);
|
||||
expect(eventType2Result?.team?.name).toBe(team1.name);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
import db from "@calcom/prisma";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../types";
|
||||
|
||||
@@ -9,31 +11,26 @@ type ListWithTeamOptions = {
|
||||
};
|
||||
|
||||
export const listWithTeamHandler = async ({ ctx }: ListWithTeamOptions) => {
|
||||
return await prisma.eventType.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ userId: ctx.user.id },
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId: ctx.user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
title: true,
|
||||
slug: true,
|
||||
},
|
||||
});
|
||||
const userId = ctx.user.id;
|
||||
const query = Prisma.sql`SELECT "public"."EventType"."id", "public"."EventType"."teamId", "public"."EventType"."title", "public"."EventType"."slug", "j1"."name" as "teamName"
|
||||
FROM "public"."EventType"
|
||||
LEFT JOIN "public"."Team" AS "j1" ON ("j1"."id") = ("public"."EventType"."teamId")
|
||||
WHERE "public"."EventType"."userId" = ${userId}
|
||||
UNION
|
||||
SELECT "public"."EventType"."id", "public"."EventType"."teamId", "public"."EventType"."title", "public"."EventType"."slug", "j1"."name" as "teamName"
|
||||
FROM "public"."EventType"
|
||||
INNER JOIN "public"."Team" AS "j1" ON ("j1"."id") = ("public"."EventType"."teamId")
|
||||
INNER JOIN "public"."Membership" AS "t2" ON "t2"."teamId" = "j1"."id"
|
||||
WHERE "t2"."userId" = ${userId} AND "t2"."accepted" = true`;
|
||||
|
||||
const result = await db.$queryRaw<
|
||||
{ id: number; teamId: number | null; title: string; slug: string; teamName: string | null }[]
|
||||
>(query);
|
||||
|
||||
return result.map((row) => ({
|
||||
id: row.id,
|
||||
team: row.teamId ? { id: row.teamId, name: row.teamName || "" } : null,
|
||||
title: row.title,
|
||||
slug: row.slug,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"NEXT_PUBLIC_BOOKER_NUMBER_OF_DAYS_TO_LOAD",
|
||||
"NEXT_PUBLIC_IS_E2E",
|
||||
"NEXT_PUBLIC_SENTRY_DSN",
|
||||
"NEXT_PUBLIC_SENTRY_DSN_CLIENT",
|
||||
"NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE_MONTHLY",
|
||||
"NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRODUCT_ID",
|
||||
"NEXT_PUBLIC_STRIPE_PUBLIC_KEY",
|
||||
|
||||
@@ -2519,8 +2519,7 @@ __metadata:
|
||||
"@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": "*"
|
||||
@@ -2712,8 +2711,7 @@ __metadata:
|
||||
vite-plugin-node-polyfills: ^0.22.0
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-awesome-query-builder: ^5.1.2
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
typescript: ^5.0.0
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
@@ -3567,23 +3565,13 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@calcom/platform-libraries-0.0.2@npm:@calcom/platform-libraries@0.0.2":
|
||||
version: 0.0.2
|
||||
resolution: "@calcom/platform-libraries@npm:0.0.2"
|
||||
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.236":
|
||||
version: 0.0.236
|
||||
resolution: "@calcom/platform-libraries@npm:0.0.236"
|
||||
dependencies:
|
||||
"@calcom/features": "*"
|
||||
"@calcom/lib": "*"
|
||||
checksum: 61b6be1b9d0be8a54ca8b1dff4b1e4db122b3c30d9203467f5347232eaf600ca3892da45a2de8abfe75327086406c33c1f6f75cd12a7147744437bc85a0ee755
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.231":
|
||||
version: 0.0.231
|
||||
resolution: "@calcom/platform-libraries@npm:0.0.231"
|
||||
dependencies:
|
||||
"@calcom/features": "*"
|
||||
"@calcom/lib": "*"
|
||||
checksum: 0abd36b041cc2ed2068c56d36aa94286f381f425b88a0d0d7cdb14498263bbece4928162d8cea00bbf4fa01f4fb60a018933f3f8e4573331d7be2b22aab2eeaa
|
||||
checksum: 7670da54aa73dfe39838ad370fd5c48ab7c8fd99f812cd68bcde76fe79d2079f8885cee24b04e3f232ef18e55c2ef9ebb940d5022751baf435d3be9c517710d9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user