Compare commits

...
Author SHA1 Message Date
GitHub Actions ce56cd1ab0 chore: release v5.4.12
Post release / release (push) Canceled after 0s
2025-07-02 21:05:34 +00:00
Romit 54b6deee19 fix (#22117) 2025-07-02 23:06:28 +05:30
abhix4 8315641724 fix: the icon in ooo empty view in light mode (#22208) 2025-07-02 22:50:20 +05:30
Anik Dhabal Babu aa3086cfae fix: Can cancel an already cancelled booking through the API (#22128)
* fix: can cancel already cancelled booking

* add test

* fix test

* update

* Update bookings.service.ts

* add commnet

* update

* tweak
2025-07-02 08:40:57 -04:00
devin-ai-integration[bot]andmorgan@cal.com b599ce4919 feat: add user context logging to request interceptor (#22180)
- Import extractUserContext function from @/lib/extract-user-context
- Extract user context from request in intercept method
- Add user context to logger output using spread operator
- Follow same pattern as exception filters for consistency

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
2025-07-01 21:08:31 +00:00
Benny Joo 52f16604c1 feat: Use a separate project for Client Sentry (#22183) 2025-07-01 19:26:51 +00:00
Benny Joo c98c06062b revert: Process Base64 Avatar Image (#22185)
* Revert "chore: add test for updateProfilePhotoGoogle (#22169)"

This reverts commit 67032ddaf1.

* Revert "fix: Process base64 avatar image (#22165)"

This reverts commit d8d8bc4d00.
2025-07-01 18:38:32 +00:00
Carina WollendorferandCarinaWolli 4963d247dc fix: don't use unpublished org for credit usage (#22154)
* only return publichsed orgs

* fix twilioWebhook tests

* code clean up

* add missing import

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-07-01 16:04:13 +00:00
9405bc9553 feat: enable stripe invoices for credit purchases (#22096)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-07-01 16:01:53 +00:00
8a080b91b9 feat: add timezone indicator on insights for unmatched timezone (#21568)
* feat: adds timezone indicator on insights informing user that user settings timezone is used instead of browser timezone

* added i18 helper for translation

* chore: use entities.decodeHTML instead of custom decoder

* feat(insights): align TimezoneBadge with new timeZone prop flow

* chore

* chore: smaller icon

* do not escape value

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-07-01 12:49:21 +00:00
Lauris Skraucis 4aa05fe523 docs: releasing atoms (#22160) 2025-07-01 17:54:47 +05:30
Lauris Skraucis d74d99dde7 fix: atoms for local development (#22178)
* fix: atoms for local development

* fix: atoms for local development
2025-07-01 17:33:22 +05:30
Parteek malikandSomay Chauhan 0ee137efbb fix: passing props (#21670)
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
2025-07-01 11:21:25 +00:00
GitHub Actions 90f55670dc chore: release v5.4.11
Post release / release (push) Canceled after 0s
2025-07-01 11:06:49 +00:00
Anik Dhabal Babu 9c53f392ca fix: Optional Question ' Leaves Variable Tag in Event Name (#22175) 2025-07-01 10:50:41 +00:00
Morganandcubic-dev-ai[bot] cc19a81875 chore: deprecate platform libraries 0.0.2 (#22149)
* chore: deprecate platform libraries 0.0.2

* fixup! chore: deprecate platform libraries 0.0.2

* Update packages/lib/server/repository/schedule.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Update apps/api/v2/src/ee/schedules/schedules_2024_04_15/services/schedules.service.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* chore: bump platform libs

* Update apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* chore: bump platform libs

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-07-01 10:23:58 +00:00
Benny Joo 67032ddaf1 chore: add test for updateProfilePhotoGoogle (#22169)
* fix: process base64 avatar image

* better name

* more

* fix import

* add test

* util

* clean up

* address
2025-07-01 11:06:57 +01:00
2fe71c3695 refactor: destination calendars when rescheduling (#21873)
* refactor: destination calendars when rescheduling

* test: add unit test for collective evnet type

* chore: remove log

* chore: bump libraries

* fix: test

* chore: bump libraries

* fix: tests

* reset lock file

* Revert "reset lock file"

This reverts commit 4d48b678778b0daa19624338b302478e4655d200.

* Revert "Revert "reset lock file""

This reverts commit a7c3120c926a0d37a2b55a6ff8d2a070969ec0f4.

* regenerate lock file

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2025-07-01 12:07:48 +03:00
21422fe027 feat: enhance API v2 exception filters with user context for improved Axiom observability (#22157)
* feat: enhance API v2 exception filters with user context for improved Axiom observability

- Add extractUserContext utility to safely extract user/org/team context from request
- Enhance all 5 exception filters (HTTP, TRPC, Prisma, Zod, Calendar) with user context
- Include userId, userEmail, organizationId, teamId in Axiom logs when available
- Improve observability for debugging and monitoring authenticated requests

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* chore: update yarn.lock from lint-staged hooks

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* fix: use request.organization instead of request.organizationId

- Address GitHub comment feedback from ThyMinimalDev
- Extract organization ID from organization object for consistency
- Follows same pattern as user and team extraction

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* fix: revert to using request.organizationId as requested in GitHub comment

- Change back from request.organization to request.organizationId
- This aligns with how auth strategies actually populate the request object
- Addresses GitHub comment from ThyMinimalDev

Co-Authored-By: morgan@cal.com <morgan@cal.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-07-01 10:05:21 +03:00
Devanshu Sharma 95575291ff feat: add Finnish locale to Day.js locales (#22174) 2025-07-01 07:01:32 +00:00
KartikandKartik Saini 3e61a0633d prevent info icon from repositioning when text wraps on small screens (#22132)
Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-07-01 09:32:41 +05:30
abhix4 85c9a790f2 fix: prevent location input from losing focus after each key stroke (#22167)
* fix: prevent location input from losing focus after each key stroke

* typo
2025-07-01 03:54:35 +00:00
Omar López e17d55457f refactor: cookie domain logic in defaultCookies (#22168) 2025-06-30 20:21:16 +00:00
Benny Joo d8d8bc4d00 fix: Process base64 avatar image (#22165)
* fix: process base64 avatar image

* better name

* more

* fix import
2025-06-30 21:08:46 +01:00
Udit TakkarandAlex van Andel 55cc1aea59 perf: optimize listWithTeamHandler query (#22069)
* perf: optimize listWithTeamHandler query

* chore: update query

* chore: add accepted true

* Update packages/trpc/server/routers/viewer/eventTypes/listWithTeam.handler.ts

* test: add integration test for the sql query

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-06-30 18:35:49 +00:00
GitHub Actions ae383164bc chore: release v5.4.10
Post release / release (push) Canceled after 0s
2025-06-30 13:22:55 +00:00
GitHub Actions 0d8863fe8e chore: release v5.4.10 2025-06-30 13:02:06 +00:00
GitHub Actions 51f6654635 chore: release v5.5.0 2025-06-30 12:39:41 +00:00
Eunjae LeeandDevin AI fa11e10bcb feat: remove 3-attendee limitation from CSV export (#22092)
Co-authored-by: eunjae@cal.com <hey@eunjae.dev>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-06-30 12:28:54 +00:00
github-actions[bot] a8f5bd6fe9 chore: version packages (#22152)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-30 11:16:15 +00:00
Lauris Skraucis a1c725bb35 refactor: atoms peer deps (#22153) 2025-06-30 10:22:45 +00:00
Anik Dhabal BabuandHariom Balhara e4eeb11a61 fix: fix request reschedule - not allowing reschedule of cancelled booking (#22115)
* fix: fix broken request reschdule feature

* Update getServerSideProps.ts

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-06-30 09:56:45 +00:00
Udit TakkarandHariom 3b0bb25251 fix: status code or router/embed rate limiting (#21968)
* fix: status code or router embed rate limiting

* fix: type err

* fix: handle api v2

* fix: status code

* chore: remove unused

* chore: update unit test

* chore: unit test

* refactor: make res required

* chore: remove unused

* chore: add a wrapper in getserversideprops

* chore: remlove log

* Add ratelLimiting unit test

---------

Co-authored-by: Hariom <hariombalhara@gmail.com>
2025-06-30 14:58:44 +05:30
Lauris Skraucis d27490e6f2 fix: atoms dist size (#22148)
* fix: reduce atoms dist size

* test changesets entry

* bump version
2025-06-30 09:26:08 +00:00
Anik Dhabal BabuandUdit Takkar b3c191b553 fix: truncate zoom meeting agenda (#22137)
* fix truncate zoom agenda

* type error

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-06-30 08:45:13 +00:00
Anik Dhabal Babuandcubic-dev-ai[bot] 0b977e615d fix: exclude opt out message from body (#22100)
* fix: exclude opt out

* Update packages/features/ee/workflows/api/scheduleSMSReminders.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-06-30 08:17:36 +00:00
github-actions[bot]andLauris Skraucis 979e1fd28b chore: version packages (#22143)
* chore: version packages

* Update package.json

* Update CHANGELOG.md

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
2025-06-30 07:51:11 +00:00
Lauris Skraucis 3437c4331d fix: changesets atoms size (#22134)
* fix: changesets atoms npm package size

* test changesets entry
2025-06-30 07:22:09 +00:00
78 changed files with 4298 additions and 1100 deletions
+1
View File
@@ -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=
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
View File
View File
View File
+8
View File
@@ -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"
+1 -2
View File
@@ -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;
@@ -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],
@@ -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",
@@ -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,
},
});
}
+1 -1
View File
@@ -4,7 +4,7 @@
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN_CLIENT,
sampleRate: parseFloat(process.env.SENTRY_SAMPLE_RATE ?? "1.0") || 1.0,
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
@@ -117,8 +117,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
// A booking that has been rescheduled to a new booking will also have a status of CANCELLED
const isDisabledRescheduling = booking.eventType?.disableRescheduling;
// This comes from query param and thus is considered forced
const canRescheduleCancelledBooking =
isForcedRescheduleForCancelledBooking || booking.eventType?.allowReschedulingCancelledBookings;
const canBookThroughCancelledBookingRescheduleLink = booking.eventType?.allowReschedulingCancelledBookings;
const isNonRescheduleableBooking =
booking.status === BookingStatus.CANCELLED || booking.status === BookingStatus.REJECTED;
@@ -131,8 +130,9 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
};
}
if (isNonRescheduleableBooking) {
const canReschedule = booking.status === BookingStatus.CANCELLED && canRescheduleCancelledBooking;
if (isNonRescheduleableBooking && !isForcedRescheduleForCancelledBooking) {
const canReschedule =
booking.status === BookingStatus.CANCELLED && canBookThroughCancelledBookingRescheduleLink;
return {
redirect: {
destination: canReschedule ? eventUrl : `/booking/${uid}`,
@@ -19,6 +19,7 @@ import {
MostCancelledBookingsTables,
PopularEventsTable,
RecentFeedbackTable,
TimezoneBadge,
} from "@calcom/features/insights/components";
import "@calcom/features/insights/components/tremor.css";
import { InsightsOrgTeamsProvider } from "@calcom/features/insights/context/InsightsOrgTeamsProvider";
@@ -62,6 +63,7 @@ function InsightsPageContent() {
<div className="grow" />
<Download />
<DateRangeFilter column={createdAtColumn} />
<TimezoneBadge />
</div>
<div className="my-4 space-y-4">
@@ -652,8 +652,9 @@ const ProfileForm = ({
/>
</div>
{extraField}
<p className="text-subtle mt-1 flex items-center gap-1 text-sm">
<Icon name="info" /> {t("tip_username_plus")}
<p className="text-subtle mt-1 flex gap-1 text-sm">
<Icon name="info" className="mt-0.5 flex-shrink-0" />
<span className="flex-1">{t("tip_username_plus")}</span>
</p>
<div className="mt-6">
<TextField label={t("full_name")} {...formMethods.register("name")} />
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.4.9",
"version": "5.4.12",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+2 -2
View File
@@ -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,
});
+1 -1
View File
@@ -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">
@@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -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");
File diff suppressed because it is too large Load Diff
@@ -155,6 +155,18 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
};
};
// Zoom agenda field has a 2000 character limit; we set maxLength to 1900 to leave a safety buffer
const truncateAgenda = (description?: string | null) => {
if (!description) return description;
const maxLength = 1900;
const trimmed = description.trimEnd();
if (trimmed.length > maxLength) {
return `${trimmed.substring(0, maxLength).trimEnd()}...`;
}
return trimmed;
};
const userSettings = await getUserSettings();
const recurrence = getRecurrence(event);
// Documentation at: https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate
@@ -166,7 +178,7 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
//schedule_for: "string", TODO: Used when scheduling the meeting for someone else (needed?)
timezone: event.organizer.timeZone,
password: userSettings?.schedule_meeting?.default_password_for_scheduled_meetings ?? undefined,
agenda: event.description,
agenda: truncateAgenda(event.description),
settings: {
host_video: true,
participant_video: true,
+1
View File
@@ -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";
+2 -8
View File
@@ -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();
+10 -8
View File
@@ -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(
@@ -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,
@@ -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: {
@@ -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";
+38 -16
View File
@@ -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 -8
View File
@@ -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
+4 -2
View File
@@ -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)));
};
+2
View File
@@ -104,6 +104,8 @@ export function getEventName(eventNameObj: EventNameObjectType, forAttendeeView
}
dynamicEventName = dynamicEventName.replace(`{${variable}}`, fieldValue || "");
} else {
dynamicEventName = dynamicEventName.replace(`{${variable}}`, "");
}
});
+58 -33
View File
@@ -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,
+17
View File
@@ -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);
+1 -1
View File
@@ -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/");
}
@@ -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;
}
}
+2 -1
View File
@@ -1 +1,2 @@
globals.min.css
globals.min.css
cal-atoms-dev.tgz
+2 -16
View File
@@ -1,22 +1,8 @@
## 1.0.108
## 1.0.110
## 1.0.114
### Patch Changes
- [#22107](https://github.com/calcom/cal.com/pull/22107) [`88a2bd3`](https://github.com/calcom/cal.com/commit/88a2bd3317a2cfaf6b367524e2736445544dbd63) Thanks [@supalarry](https://github.com/supalarry)! - test log can ignore
### Patch Changes
- [#22103](https://github.com/calcom/cal.com/pull/22103) [`61274bc`](https://github.com/calcom/cal.com/commit/61274bc7efc67b162d46b59cd75bd376ad515c51) Thanks [@supalarry](https://github.com/supalarry)! - testing changesets - can ignore this
## 1.0.107
### Patch Changes
- [#22095](https://github.com/calcom/cal.com/pull/22095) [`3c43ce4`](https://github.com/calcom/cal.com/commit/3c43ce4165bce1da29b203d5f2eb62090c5fddd2) Thanks [@supalarry](https://github.com/supalarry)! - test by updating changelog
- [#21864](https://github.com/calcom/cal.com/pull/21864) [`540bf86`](https://github.com/calcom/cal.com/commit/540bf868b5b60a98d9d3aeb565e2089f15c3dfd3) Thanks [@supalarry](https://github.com/supalarry)! - fix saving event type settings
- [#22148](https://github.com/calcom/cal.com/pull/22148) [`d27490e`](https://github.com/calcom/cal.com/commit/d27490e6f2438d353dfcf3e793c8886e723307f3) Thanks [@supalarry](https://github.com/supalarry)! - test release
## 1.0.63
+22 -1
View File
@@ -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.
@@ -13,6 +15,25 @@ Currently supports React 18, React 19, Next 14 and Next 15.
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/
+4 -6
View File
@@ -4,12 +4,13 @@
"type": "module",
"description": "Customizable UI components to integrate scheduling into your product.",
"authors": "Cal.com, Inc.",
"version": "1.0.110",
"version": "1.0.114",
"scripts": {
"dev": "yarn vite build --watch & npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify --watch",
"build": "NODE_OPTIONS='--max_old_space_size=12288' rm -rf dist && yarn vite build && npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify && mkdir ./dist/packages/prisma-client && cp -rf ../../../node_modules/.prisma/client/*.d.ts ./dist/packages/prisma-client",
"publish-npm": "yarn build && npm publish --access public",
"test": "jest"
"test": "jest",
"pack-dev": "yarn build && npm pack && mv calcom-atoms-*.tgz cal-atoms-dev.tgz"
},
"devDependencies": {
"@rollup/plugin-node-resolve": "^15.0.1",
@@ -37,11 +38,9 @@
"dist",
"globals.min.css"
],
"main": "./dist/cal-atoms.umd.cjs",
"module": "./dist/cal-atoms.js",
"exports": {
".": {
"require": "./dist/cal-atoms.umd.cjs",
"import": "./dist/cal-atoms.js",
"types": "./dist/index.d.ts"
},
@@ -83,7 +82,6 @@
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-awesome-query-builder": "^5.1.2",
"react-dom": "^18.0.0 || ^19.0.0"
"typescript": "^5.0.0"
}
}
+21 -5
View File
@@ -31,16 +31,32 @@
"../../types/@wojtekmaj__react-daterange-picker.d.ts",
"../../types/business-days-plugin.d.ts",
"../../types/window.d.ts",
"../../lib",
"../../features",
"../types",
"../constants",
"../utils",
"../libraries",
"../../prisma",
"../../dayjs",
"../../trpc",
"../../app-store"
"../../prisma/zod-utils.ts",
"../../prisma/zod",
"../../trpc/react",
"../../trpc/server/routers/viewer/slots",
"../../trpc/server/types",
"../../features/eventtypes",
"../../features/schedules",
"../../features/bookings/Booker",
"../../features/bookings/Booker",
"../../features/instant-meeting",
"../../features/bookings/handleMarkNoShow.ts",
"../../features/bookings/types.ts",
"../../features/bookings/lib",
"../../features/ee/workflows",
"../../features/ee/payments",
"../../features/ee/round-robin",
"../../features/ee/billing",
"../../features/shell",
"../../features/components",
"../../features/form-builder",
"../../features/data-table"
],
"exclude": [
"dist",
+5 -6
View File
@@ -3,7 +3,6 @@ import path from "path";
import { resolve } from "path";
import { defineConfig, loadEnv } from "vite";
import dts from "vite-plugin-dts";
import Inspect from "vite-plugin-inspect";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), ""); // .env inside of packages/platform/atoms
@@ -20,7 +19,7 @@ export default defineConfig(({ mode }) => {
"@calcom/platform-utils",
],
},
plugins: [Inspect(), react(), dts({ insertTypesEntry: true })],
plugins: [react(), dts({ insertTypesEntry: true })],
define: {
"process.env.NEXT_PUBLIC_WEBAPP_URL": `"${webAppUrl}"`,
},
@@ -28,17 +27,16 @@ export default defineConfig(({ mode }) => {
noExternal: ["turndown"], // Example if you want to disable SSR for your library
},
build: {
commonjsOptions: {
include: [/@calcom\/lib/, /@calcom\/features/, /node_modules/],
},
lib: {
entry: [resolve(__dirname, "index.ts")],
name: "CalAtoms",
fileName: "cal-atoms",
formats: ["es"],
},
rollupOptions: {
external: ["react", "fs", "path", "os", "react-dom", "react-awesome-query-builder"],
external: ["react", "fs", "path", "os", "react/jsx-runtime", "react-dom", "react-dom/client"],
output: {
format: "esm", // Force ESM output
globals: {
react: "React",
"react-dom": "ReactDOM",
@@ -58,6 +56,7 @@ export default defineConfig(({ mode }) => {
"@radix-ui/react-dialog": path.resolve(__dirname, "./src/components/ui/dialog.tsx"),
".prisma/client": path.resolve(__dirname, "../../prisma-client"),
"@prisma/client": path.resolve(__dirname, "../../prisma-client"),
"@calcom/prisma/client": path.resolve(__dirname, "../../prisma-client"),
"@calcom/prisma": path.resolve(__dirname, "../../prisma"),
"@calcom/dayjs": path.resolve(__dirname, "../../dayjs"),
"@calcom/platform-constants": path.resolve(__dirname, "../constants/index.ts"),
+1 -1
View File
@@ -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
View File
@@ -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.
@@ -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,
}));
};
+4
View File
@@ -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",
@@ -68,6 +69,9 @@
"cache": false,
"dependsOn": []
},
"@calcom/atoms#build": {
"outputs": ["dist/**", "globals.min.css"]
},
"@calcom/api-v2#build": {
"dependsOn": ["^build"],
"env": [
+6 -18
View File
@@ -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