Compare commits

..
47 Commits
Author SHA1 Message Date
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
github-actions[bot] 616d62094c chore: version packages (#22116)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-29 13:25:48 +02:00
ce7ff1c43c fix(signup): add missing key prop to feature list (#21437) (#21506)
* fix(signup): add missing key prop to feature list (#21437)

* refactor

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-06-28 09:27:25 +00:00
Romit e27e41bc16 fix (#22112) 2025-06-28 14:30:54 +05:30
Hariom Balhara 93e183e6c3 fix: Zoom - More logging and fix for unexpected errors seen in logs (#22118)
* fix: Use WEBAPP_URL_FOR_OAUTH to be able to use http://localhost:3000
for local testing

* In case of JSON.parse crash that could happen if zoom reponsed with xml

* Fix cases where auto_recording and default_password_for_scheduled_meetings could be nullish
2025-06-28 08:11:25 +00:00
Lauris Skraucis 88a2bd3317 fix: changesets release memory (#22107)
* chore: increase instance size for changesets

* test release

* Update CHANGELOG.md
2025-06-28 04:37:47 +00:00
Keith Williams 45caaa55b0 chore: Use DBA team for prisma CODEOWNER (#22109) 2025-06-27 23:01:32 +00:00
GitHub Actions 8c9b7a701d chore: release v5.4.9
Post release / release (push) Canceled after 0s
2025-06-27 22:17:36 +00:00
c47b992125 perf: optimize slot conflict checking from O(n²) to O(n log n) (#22101)
* perf: optimize slot conflict checking from O(n²) to O(n log n)

- Replace nested mapping with pre-sorted busy slots for faster lookups
- Use early termination when busy slots are sorted by start time
- Maintain exact same interface and behavior as original checkForConflicts
- All existing tests pass, preserving edge case handling
- Reduces complexity from O(available slots × busy slots) to O(n log n)

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* refactor: move O(n log n) optimization to checkForConflicts function

- Replace inline optimization with optimized checkForConflicts function
- Maintain same performance while improving code architecture
- Avoid duplication of conflict checking logic
- Keep all existing interfaces and behavior unchanged

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* test: add comprehensive unit tests for checkForConflicts function

- Add 20 additional test cases covering edge cases and boundary conditions
- Test multiple busy periods scenarios and complex overlaps
- Comprehensive currentSeats handling scenarios
- Timezone and cross-day boundary testing
- Performance testing with large datasets
- Fix invalid date generation in test data that was causing NaN conflicts
- Achieve near 100% coverage of all conflict scenarios documented in function comments
- Verify behavioral equivalence between original O(n²) and optimized O(n log n) implementations

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* Simple, safe performance tweak

* Explicitly define the input of busy to be either Date or Dayjs, not string

* EventBusyDate must ALWAYS be a type that supports valueOf

* Revert "EventBusyDate must ALWAYS be a type that supports valueOf"

This reverts commit 35b572266219d64c22db3c3e24b0cb6ca7a901a6.

* Revert "Explicitly define the input of busy to be either Date or Dayjs, not string"

This reverts commit 902f29708e4b7990a4e5e2865cab2f18ab3e44f0.

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-06-27 22:10:17 +00:00
Alex van Andel 493c4f89dc Remove redundant logging (#22108) 2025-06-27 21:01:01 +00:00
Lauris Skraucis 27234dc809 fix: changesets memory issue (#22105) 2025-06-27 16:06:13 +00:00
github-actions[bot] 5690b8e1d5 chore: version packages (#22104)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-27 15:19:56 +00:00
Lauris Skraucis 61274bc7ef fix: changesets release (#22103) 2025-06-27 14:37:23 +00:00
github-actions[bot] e66be23996 chore: version packages (#22098)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-27 12:35:20 +00:00
Lauris Skraucis 3c43ce4165 chore: test changesets (#22095)
* chore: test changesets

* bump atoms package.json

* Revert "bump atoms package.json"

This reverts commit 6105f8cf2486085e5da317a6969044693f4a3aa0.

* chore: set packages to private

* ignore private packages

* ignore private
2025-06-27 13:49:28 +02:00
Somay Chauhan 5060824795 fix: EventAvailabilityTab atom breaking (#22059) 2025-06-27 09:40:38 +00:00
c4941f25a2 fix: no available users found (recurring events) (#22033)
* only check first slot

* skip test for second slot availability check

* fixing tests in handleNewRecurringBooking.test.ts

* skip test

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-06-27 09:31:56 +00:00
e7c219905f fix: add shape docs to avatar (#20552)
Co-authored-by: Sean Brydon <sean@Seans-Mac-mini.local>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-06-27 09:09:13 +00:00
Udit TakkarandHariom Balhara 499cbd08ca feat: Phone based bookings for everyone (#21320) (#22024)
* Revert "Revert "feat: Phone based bookings for everyone (#21320)" (#22018)"

This reverts commit 80e2118e68.

* chore: don't use repository

* chore: create getTeamWithOrganizationSettings function

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-06-27 10:09:04 +01:00
Eunjae Lee 0a3b3a69e3 fix: add InsightsRoutingService and refactor InsightsBookingRepository (#22057)
* chore: convert InsightsBookingRepository to service

* remove repository

* rename

* clean up

* add InsightsRoutingService

* add tests
2025-06-27 11:08:46 +02:00
Lauris Skraucis e8607845e2 fix: changesets atoms release (#22090) 2025-06-27 09:07:02 +00:00
02ef4db0eb feat: Add rerouting to AssignmentReasonRecorder (#19252)
* Add `rerouting` to `AssignmentReasonRecorder`

* Pass session email as `rescheduledBy`

* Write reassign reason

* Edit message

* Use `routingFormRoute` when rerouting

* Pass isRerouting & reroutedByEmail to `routingFormRoute` method

* Type fix

* Fix test

* Fix test

* Fix test

* Type fix

* Refactor to use UserRepository

* Show rerouted by email

* Replace isReroutingCase

* Fix rescheduledBy for new tab reschduling

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
2025-06-27 10:06:39 +01:00
sean-brydon f8d48a341d show icon button when one item in array (#22054) 2025-06-27 07:52:18 +00:00
108 changed files with 6319 additions and 1338 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": [],
"ignore": ["@calcom/platform-libraries"],
"privatePackages": {
"version": false,
"tag": false
-5
View File
@@ -1,5 +0,0 @@
---
"@calcom/atoms": patch
---
fix saving event type settings
+1 -1
View File
@@ -24,7 +24,7 @@
/packages/lib/server/getLuckyUser.ts @calcom/Foundation
/packages/lib/slots.ts @calcom/Foundation
/packages/platform/**/* @calcom/Platform @calcom/Foundation
/packages/prisma/**/* @calcom/Foundation
/packages/prisma/**/* @calcom/DBA
/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @calcom/Foundation
/packages/trpc/server/routers/viewer/bookings/get.handler.ts @calcom/Foundation
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
contents: write # to create release by changesets/action below
pull-requests: write # to create pull request by changesets/action below
name: Release
runs-on: ubuntu-latest
runs-on: buildjet-4vcpu-ubuntu-2204
steps:
- name: Checkout Repo
uses: actions/checkout@v4
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+6
View File
@@ -0,0 +1,6 @@
undecided:
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
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
View File
@@ -3,6 +3,7 @@
"version": "1.0.0",
"description": "",
"main": "index.js",
"private": true,
"scripts": {
"dev": "node ./index.js"
},
+2 -2
View File
@@ -4,6 +4,7 @@
"description": "Platform API for Cal.com",
"author": "Cal.com Inc.",
"license": "UNLICENSED",
"private": true,
"scripts": {
"build": "yarn dev:build && nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
@@ -37,8 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.231",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.236",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
@@ -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;
}
+10 -21
View File
@@ -58,12 +58,6 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
const IP = request?.headers?.["cf-connecting-ip"] ?? request?.headers?.["CF-Connecting-IP"] ?? request.ip;
const response = context.switchToHttp().getResponse<Response>();
const tracker = await this.getTracker(request);
this.logger.verbose(
`Tracker "${tracker}" generated based on: Bearer token "${request.get(
"Authorization"
)}", OAuth client ID "${request.get(X_CAL_CLIENT_ID)}" and IP "${IP}"`
);
if (throttleOptions) {
return this.handleApiEndpointThrottle(tracker, throttleOptions, response);
}
@@ -104,9 +98,9 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
private async handleNonApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
const rateLimit = this.getDefaultRateLimit(tracker);
this.logger.verbose(`Tracker "${tracker}" uses default rate limits because it is not tracking api key:
/*this.logger.verbose(`Tracker "${tracker}" uses default rate limits because it is not tracking api key:
${JSON.stringify(rateLimit, null, 2)}
`);
`);*/
const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response);
if (isBlocked) {
@@ -150,9 +144,9 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
const cachedRateLimits = await this.storageService.redis.get(cacheKey);
if (cachedRateLimits) {
this.logger.verbose(`Tracker "${tracker}" rate limits retrieved from redis cache:
/*this.logger.verbose(`Tracker "${tracker}" rate limits retrieved from redis cache:
${cachedRateLimits}
`);
`);*/
return rateLimitsSchema.parse(JSON.parse(cachedRateLimits));
}
@@ -172,15 +166,10 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
select: { name: true, limit: true, ttl: true, blockDuration: true },
});
if (rateLimits) {
this.logger.verbose(`Tracker "${tracker}" rate limits retrieved from database:
${JSON.stringify(rateLimits, null, 2)}`);
}
if (!rateLimits || rateLimits.length === 0) {
rateLimits = [this.getDefaultRateLimit(tracker)];
this.logger.verbose(`Tracker "${tracker}" rate limits not found in database. Using default rate limits:
${JSON.stringify(rateLimits, null, 2)}`);
/*this.logger.verbose(`Tracker "${tracker}" rate limits not found in database. Using default rate limits:
${JSON.stringify(rateLimits, null, 2)}`);*/
}
await this.storageService.redis.set(cacheKey, JSON.stringify(rateLimits), "EX", 3600);
@@ -209,15 +198,15 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
);
response.setHeader(`X-RateLimit-Reset-${nameFirstUpper}`, timeToBlockExpire || timeToExpire);
this.logger.verbose(
/*this.logger.verbose(
`Tracker "${tracker}" rate limit "${name}" incremented. isBlocked ${isBlocked}, totalHits ${totalHits}, timeToExpire ${timeToExpire}, timeToBlockExpire ${timeToBlockExpire}`
);
this.logger.verbose(
);*/
/*this.logger.verbose(
`Tracker "${tracker}" rate limit "${name}" response headers:
X-RateLimit-Limit-${nameFirstUpper}: ${limit},
X-RateLimit-Remaining-${nameFirstUpper}: ${timeToBlockExpire ? 0 : Math.max(0, limit - totalHits)},
X-RateLimit-Reset-${nameFirstUpper}: ${timeToBlockExpire || timeToExpire}`
);
);*/
return { isBlocked };
}
@@ -69,7 +69,7 @@ export class OrganizationsRepository {
return id;
}
async findTeamIdFromClientId(clientId: string) {
async findTeamIdAndSlugFromClientId(clientId: string) {
return this.dbRead.prisma.team.findFirstOrThrow({
where: {
platformOAuthClient: {
@@ -80,6 +80,7 @@ export class OrganizationsRepository {
},
select: {
id: true,
slug: true,
},
});
}
@@ -10,6 +10,7 @@ import { ImageExample } from "./avatar.image"
import { TooltipExample } from "./avatar.tooltip"
import { LinkExample } from "./avatar.link"
import { IndicatorExample } from "./avatar.indicator"
import { ShapeExample } from "./avatar.shape"
## Size Variations
@@ -17,6 +18,12 @@ Avatars come in different sizes to suit various use cases.
<SizesExample/>
## Shape Variations
Avatars can be displayed in either circular or square shapes.
<ShapeExample/>
## With and Without Image
Avatars can display either an image or a fallback placeholder.
@@ -40,3 +47,57 @@ Avatars can be made clickable by providing an href.
Avatars can include status indicators or badges.
<IndicatorExample/>
## Usage Example
Here's a complete example showing how to use the Avatar component with all its props:
```tsx
import { Avatar } from "@calcom/ui/components/avatar";
function Example() {
return (
<div className="flex flex-col gap-4">
{/* Basic Avatar */}
<Avatar
alt="User Name"
title="User Name"
size="md"
shape="circle"
imageSrc="https://example.com/avatar.jpg"
/>
{/* Avatar with Link */}
<Avatar
alt="User Name"
title="User Name"
size="lg"
shape="square"
imageSrc="https://example.com/avatar.jpg"
href="/profile"
/>
{/* Avatar with Tooltip and Indicator */}
<Avatar
alt="User Name"
title="User Name"
size="md"
shape="circle"
imageSrc="https://example.com/avatar.jpg"
indicator={<div className="absolute bottom-0 right-0 h-3 w-3 rounded-full bg-green-500" />}
/>
{/* Avatar with Fallback */}
<Avatar
alt="User Name"
title="User Name"
size="md"
shape="circle"
fallback={<span>UN</span>}
/>
</div>
);
}
```
@@ -0,0 +1,24 @@
import { Avatar } from "@calcom/ui/components/avatar";
export function ShapeExample() {
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-4">
<Avatar
alt="Circle Avatar"
title="Circle Avatar"
size="lg"
shape="circle"
imageSrc="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
/>
<Avatar
alt="Square Avatar"
title="Square Avatar"
size="lg"
shape="square"
imageSrc="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
/>
</div>
</div>
);
}
+4 -1
View File
@@ -1,4 +1,5 @@
import { useMutation } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
@@ -279,6 +280,7 @@ const NewRoutingManager = ({
const { t } = useLocale();
const router = useRouter();
const bookerUrl = useBookerUrl();
const session = useSession();
const teamMemberIdsMatchingAttributeLogic =
teamMembersMatchingAttributeLogic?.data
?.map((member) => member.id)
@@ -305,7 +307,7 @@ const NewRoutingManager = ({
booking,
currentResponse,
searchParams: new URLSearchParams({
// rescheduleReason
rescheduledBy: session?.data?.user?.email ?? "",
}),
});
@@ -469,6 +471,7 @@ const NewRoutingManager = ({
// TODO: Long term, we should refactor handleNewBooking and use a different route specific for this purpose,
createBookingMutation.mutate({
rescheduleUid: booking.uid,
rescheduledBy: session?.data?.user?.email ?? undefined,
// rescheduleReason,
reroutingFormResponses: reroutingFormResponses,
...getTimeslotFields(),
@@ -1,5 +1,7 @@
import { TooltipProvider } from "@radix-ui/react-tooltip";
import { act, fireEvent, render, screen } from "@testing-library/react";
import type { Session } from "next-auth";
import { SessionProvider } from "next-auth/react";
import { vi } from "vitest";
import { RouteActionType } from "@calcom/app-store/routing-forms/zod";
@@ -37,6 +39,15 @@ const mockOpen = vi.fn((_url: string) => {
vi.stubGlobal("open", mockOpen);
const mockSession = {
expires: new Date(Date.now() + 2 * 86400).toISOString(),
user: {
id: 1,
name: "Test User",
email: "user@example.com",
},
} as Session;
vi.mock("@calcom/app-store/routing-forms/components/FormInputFields", () => ({
default: vi.fn(({ response, form, setResponse, disabledFields }) => {
return (
@@ -404,9 +415,11 @@ describe("RerouteDialog", () => {
describe("New Routing tests", () => {
test("when verify_new_route is clicked, the form is submitted", async () => {
render(
<TooltipProvider>
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
</TooltipProvider>
<SessionProvider session={mockSession}>
<TooltipProvider>
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
</TooltipProvider>
</SessionProvider>
);
fireEvent.click(screen.getByText("verify_new_route"));
@@ -425,9 +438,11 @@ describe("RerouteDialog", () => {
describe("New tab rescheduling", () => {
test("new tab is closed when new booking is rerouted", async () => {
render(
<TooltipProvider>
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
</TooltipProvider>
<SessionProvider session={mockSession}>
<TooltipProvider>
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
</TooltipProvider>
</SessionProvider>
);
clickVerifyNewRouteButton();
clickRescheduleToTheNewEventWithDifferentTimeslotButton();
@@ -466,9 +481,11 @@ describe("RerouteDialog", () => {
test("Rescheduling with same timeslot works", async () => {
render(
<TooltipProvider>
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
</TooltipProvider>
<SessionProvider session={mockSession}>
<TooltipProvider>
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
</TooltipProvider>
</SessionProvider>
);
clickVerifyNewRouteButton();
clickRescheduleWithSameTimeslotOfChosenEventButton();
@@ -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}`,
@@ -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")} />
+16 -18
View File
@@ -691,25 +691,23 @@ export default function Signup({
/>
</div>
<div className="mr-12 mt-8 hidden h-full w-full grid-cols-3 gap-4 overflow-hidden lg:grid">
{FEATURES.map((feature) => (
<>
<div className="max-w-52 mb-8 flex flex-col leading-none sm:mb-0">
<div className="text-emphasis items-center">
<Icon name={feature.icon} className="mb-1 h-4 w-4" />
<span className="text-sm font-medium">{t(feature.title)}</span>
</div>
<div className="text-subtle text-sm">
<p>
{t(
feature.description,
feature.i18nOptions && {
...feature.i18nOptions,
}
)}
</p>
</div>
{FEATURES.map((feature, index) => (
<div key={index} className="max-w-52 mb-8 flex flex-col leading-none sm:mb-0">
<div className="text-emphasis items-center">
<Icon name={feature.icon} className="mb-1 h-4 w-4" />
<span className="text-sm font-medium">{t(feature.title)}</span>
</div>
</>
<div className="text-subtle text-sm">
<p>
{t(
feature.description,
feature.i18nOptions && {
...feature.i18nOptions,
}
)}
</p>
</div>
</div>
))}
</div>
</div>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.4.8",
"version": "5.4.11",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+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">
@@ -267,6 +267,8 @@
"guests": "Guests",
"guest": "Guest",
"web_conferencing_details_to_follow": "Web conferencing details to follow in the confirmation email.",
"confirmation":"Confirmation",
"what_booker_should_provide": "What your booker should provide to receive confirmations",
"404_the_user": "The username",
"username": "Username",
"is_still_available": "is still available.",
@@ -1710,6 +1712,8 @@
"show_available_seats_count": "Show the number of available seats",
"how_booking_questions_as_variables": "How to use booking questions as variables?",
"format": "Format",
"questions": "Questions",
"all_info_your_booker_provide": "All the info your booker should provide before booking with you.",
"uppercase_for_letters": "Use uppercase for all letters",
"replace_whitespaces_underscores": "Replace whitespaces with underscores",
"platform_members": "Platform members",
@@ -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
+2 -1
View File
@@ -80,8 +80,9 @@
"docker-build-api": "docker build -t cal-api -f ./infra/docker/api/Dockerfile .",
"docker-run-api": "docker run -p 80:80 cal-api",
"docker-stop-api": "docker ps --filter 'ancestor=cal-api' -q | xargs docker stop",
"changesets-add": "yarn changeset add",
"changesets-version": "yarn changeset version",
"changesets-release": "turbo run build && yarn changeset publish"
"changesets-release": "NODE_OPTIONS='--max_old_space_size=12288' turbo run build --filter=@calcom/atoms && yarn changeset publish"
},
"devDependencies": {
"@changesets/changelog-github": "^0.5.1",
@@ -1,7 +1,12 @@
/**
* Manages OAuth2.0 tokens for an app and resourceOwner. It automatically refreshes the token when needed.
* It is aware of the credential sync endpoint and can sync the token from the third party source.
* It is unaware of Prisma and App logic. It is just a utility to manage OAuth2.0 tokens with life cycle methods
* Manages OAuth2.0 as well as JWT tokens(For JWT tokens, only Google Calendar use it at the moment) for an app and resourceOwner.
* What it does
* - It automatically refreshes the token if needed when making a request.
* - It is aware of the credential sync endpoint and can sync the token from the third party source.
* - It is kept unaware of Prisma and App logic. It is just a utility to manage OAuth2.0 tokens with life cycle methods
*
* What it doesn't do yet
* - It doesn't have a flow to re-send the request if the access-token had been communicated as invalid after making the request itself. It relies on the caller to make the next request in which it will actually refresh the token.
*
* For a recommended usage example, see Zoom VideoApiAdapter.ts
*/
@@ -89,7 +94,13 @@ export class OAuthManager {
currentTokenObject,
fetchNewTokenObject,
updateTokenObject,
/**
* The fn must not crash. It is the responsibility of the caller to handle any error and appropriately decide what to return
*/
isTokenObjectUnusable,
/**
* The fn must not crash. It is the responsibility of the caller to handle any error and appropriately decide what to return
*/
isAccessTokenUnusable,
invalidateTokenObject,
expireAccessToken,
@@ -0,0 +1,114 @@
import { oauth2_v2 } from "@googleapis/oauth2";
import type { OAuth2Client } from "googleapis-common";
import { describe, it, expect, vi, beforeEach } from "vitest";
import logger from "@calcom/lib/logger";
import { uploadAvatar } from "@calcom/lib/server/avatar";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
import { updateProfilePhotoGoogle } from "./updateProfilePhotoGoogle";
vi.mock("@googleapis/oauth2", () => ({
oauth2_v2: {
Oauth2: vi.fn(),
},
}));
vi.mock("@calcom/lib/logger", () => ({
default: {
error: vi.fn(),
},
}));
vi.mock("@calcom/lib/server/avatar", () => ({
uploadAvatar: vi.fn(),
}));
vi.mock("@calcom/lib/server/resizeBase64Image", () => ({
resizeBase64Image: vi.fn(),
}));
vi.mock("@calcom/lib/server/repository/user", () => ({
UserRepository: {
updateAvatar: vi.fn(),
},
}));
describe("updateProfilePhotoGoogle", () => {
const mockOAuth2Client = {} as OAuth2Client;
const userId = 123;
let mockOauth2Instance: any;
beforeEach(() => {
vi.clearAllMocks();
mockOauth2Instance = {
userinfo: {
get: vi.fn(),
},
};
(oauth2_v2.Oauth2 as any).mockImplementation(() => mockOauth2Instance);
(uploadAvatar as any).mockResolvedValue("/api/avatar/processed-123.png");
(resizeBase64Image as any).mockResolvedValue("data:image/png;base64,resized-data");
(UserRepository.updateAvatar as any).mockResolvedValue(undefined);
});
it("should update avatar with valid URL", async () => {
const validUrl = "https://example.com/avatar.jpg";
mockOauth2Instance.userinfo.get.mockResolvedValue({
data: { picture: validUrl },
});
await updateProfilePhotoGoogle(mockOAuth2Client, userId);
expect(UserRepository.updateAvatar).toHaveBeenCalledWith({
id: userId,
avatarUrl: validUrl,
});
expect(uploadAvatar).not.toHaveBeenCalled();
});
it("should process base64 data", async () => {
const base64Data =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
mockOauth2Instance.userinfo.get.mockResolvedValue({
data: { picture: base64Data },
});
await updateProfilePhotoGoogle(mockOAuth2Client, userId);
expect(resizeBase64Image).toHaveBeenCalledWith(base64Data);
expect(uploadAvatar).toHaveBeenCalledWith({
avatar: "data:image/png;base64,resized-data",
userId,
});
expect(UserRepository.updateAvatar).toHaveBeenCalledWith({
id: userId,
avatarUrl: "/api/avatar/processed-123.png",
});
});
it("should do nothing when no picture provided", async () => {
mockOauth2Instance.userinfo.get.mockResolvedValue({
data: {},
});
await updateProfilePhotoGoogle(mockOAuth2Client, userId);
expect(UserRepository.updateAvatar).not.toHaveBeenCalled();
expect(uploadAvatar).not.toHaveBeenCalled();
});
it("should handle errors gracefully", async () => {
const error = new Error("OAuth API failed");
mockOauth2Instance.userinfo.get.mockRejectedValue(error);
await updateProfilePhotoGoogle(mockOAuth2Client, userId);
expect(logger.error).toHaveBeenCalledWith("Error updating avatarUrl from google calendar connect", error);
expect(UserRepository.updateAvatar).not.toHaveBeenCalled();
});
});
@@ -1,16 +1,32 @@
import { oauth2_v2 } from "@googleapis/oauth2";
import type { OAuth2Client } from "googleapis-common";
import { isBase64Image } from "@calcom/lib/isBase64Image";
import logger from "@calcom/lib/logger";
import { uploadAvatar } from "@calcom/lib/server/avatar";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
export async function updateProfilePhotoGoogle(oAuth2Client: OAuth2Client, userId: number) {
try {
const oauth2 = new oauth2_v2.Oauth2({ auth: oAuth2Client });
const userDetails = await oauth2.userinfo.get();
if (userDetails.data?.picture) {
await UserRepository.updateAvatar({ id: userId, avatarUrl: userDetails.data.picture });
const avatarUrl = userDetails.data?.picture;
if (!avatarUrl) {
return;
}
// Handle base64 data
if (isBase64Image(avatarUrl)) {
const resizedAvatarUrl = await uploadAvatar({
avatar: await resizeBase64Image(avatarUrl),
userId,
});
await UserRepository.updateAvatar({ id: userId, avatarUrl: resizedAvatarUrl });
return;
}
await UserRepository.updateAvatar({ id: userId, avatarUrl });
} catch (error) {
logger.error("Error updating avatarUrl from google calendar connect", error);
}
@@ -4,7 +4,12 @@ import type { NextApiRequest, NextApiResponse } from "next";
import GoogleCalendarService from "@calcom/app-store/googlecalendar/lib/CalendarService";
import { renewSelectedCalendarCredentialId } from "@calcom/lib/connectedCalendar";
import { GOOGLE_CALENDAR_SCOPES, WEBAPP_URL, WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import {
GOOGLE_CALENDAR_SCOPES,
SCOPE_USERINFO_PROFILE,
WEBAPP_URL,
WEBAPP_URL_FOR_OAUTH,
} from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { HttpError } from "@calcom/lib/http-error";
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
@@ -14,6 +19,7 @@ import { Prisma } from "@calcom/prisma/client";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
import { updateProfilePhotoGoogle } from "../../_utils/oauth/updateProfilePhotoGoogle";
import { getGoogleAppKeys } from "../lib/getGoogleAppKeys";
async function getHandler(req: NextApiRequest, res: NextApiResponse) {
@@ -95,10 +101,9 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
}
// Only attempt to update the user's profile photo if the user has granted the required scope
// TODO: Use the avatarUrl when setting the profile picture
// if (grantedScopes.includes(SCOPE_USERINFO_PROFILE)) {
// await updateProfilePhotoGoogle(oAuth2Client, req.session.user.id);
// }
if (grantedScopes.includes(SCOPE_USERINFO_PROFILE)) {
await updateProfilePhotoGoogle(oAuth2Client, req.session.user.id);
}
const selectedCalendarWhereUnique = {
userId: req.session.user.id,
@@ -19,7 +19,7 @@ export default async function getAttributesForTeamHandler({
}: GetAttributesForTeamHandlerOptions) {
const { teamId } = input;
const { user } = ctx;
const isMemberOfTeam = await MembershipRepository.findFirstByUserIdAndTeamId({ userId: user.id, teamId });
const isMemberOfTeam = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId: user.id, teamId });
if (!isMemberOfTeam) {
throw new TRPCError({
+2 -2
View File
@@ -1,7 +1,7 @@
import type { NextApiRequest } from "next";
import { stringify } from "querystring";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
@@ -26,7 +26,7 @@ async function handler(req: NextApiRequest) {
const params = {
response_type: "code",
client_id,
redirect_uri: `${WEBAPP_URL}/api/integrations/zoomvideo/callback`,
redirect_uri: `${WEBAPP_URL_FOR_OAUTH}/api/integrations/zoomvideo/callback`,
state,
};
const query = stringify(params);
+2 -2
View File
@@ -1,6 +1,6 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import prisma from "@calcom/prisma";
@@ -14,7 +14,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const { code } = req.query;
const { client_id, client_secret } = await getZoomAppKeys();
const redirectUri = encodeURI(`${WEBAPP_URL}/api/integrations/zoomvideo/callback`);
const redirectUri = encodeURI(`${WEBAPP_URL_FOR_OAUTH}/api/integrations/zoomvideo/callback`);
const authHeader = `Basic ${Buffer.from(`${client_id}:${client_secret}`).toString("base64")}`;
const result = await fetch(
`https://zoom.us/oauth/token?grant_type=authorization_code&code=${code}&redirect_uri=${redirectUri}`,
@@ -8,6 +8,7 @@ import {
CREDENTIAL_SYNC_SECRET_HEADER_NAME,
} from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { getPiiFreeCalendarEvent } from "@calcom/lib/piiFreeData";
import { safeStringify } from "@calcom/lib/safeStringify";
import prisma from "@calcom/prisma";
import { Frequency } from "@calcom/prisma/zod-utils";
@@ -65,12 +66,12 @@ export type ZoomUserSettings = z.infer<typeof zoomUserSettingsSchema>;
export const zoomUserSettingsSchema = z.object({
recording: z
.object({
auto_recording: z.string(),
auto_recording: z.string().nullish(),
})
.nullish(),
schedule_meeting: z
.object({
default_password_for_scheduled_meetings: z.string(),
default_password_for_scheduled_meetings: z.string().nullish(),
})
.nullish(),
});
@@ -154,6 +155,18 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
};
};
// Zoom agenda field has a 2000 character limit; we set maxLength to 1900 to leave a safety buffer
const truncateAgenda = (description?: string | null) => {
if (!description) return description;
const maxLength = 1900;
const trimmed = description.trimEnd();
if (trimmed.length > maxLength) {
return `${trimmed.substring(0, maxLength).trimEnd()}...`;
}
return trimmed;
};
const userSettings = await getUserSettings();
const recurrence = getRecurrence(event);
// Documentation at: https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate
@@ -165,7 +178,7 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
//schedule_for: "string", TODO: Used when scheduling the meeting for someone else (needed?)
timezone: event.organizer.timeZone,
password: userSettings?.schedule_meeting?.default_password_for_scheduled_meetings ?? undefined,
agenda: event.description,
agenda: truncateAgenda(event.description),
settings: {
host_video: true,
participant_video: true,
@@ -185,6 +198,32 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
};
};
/**
* Zoom is known to return xml response in some cases.
* e.g. Wrong request or some special case of invalid token
*/
const handleZoomResponseJsonParseError = async ({
error,
clonedResponse,
}: {
error: unknown;
clonedResponse: Response;
}) => {
// In some cases, Zoom responds with xml response, so we log the response for debugging
// We need to see why that error occurs exactly and then later we decide if mark the access token and token object unusable or not
log.error(
"Error in JSON parsing Zoom API response",
safeStringify({
error: safeStringify(error),
// Log Raw response body here.
responseBody: await clonedResponse.text(),
status: clonedResponse.status,
})
);
return null;
};
const fetchZoomApi = async (endpoint: string, options?: RequestInit) => {
const auth = new OAuthManager({
credentialSyncVariables: {
@@ -220,9 +259,18 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
},
isTokenObjectUnusable: async function (response) {
const myLog = logger.getSubLogger({ prefix: ["zoomvideo:isTokenObjectUnusable"] });
myLog.debug(safeStringify({ status: response.status, ok: response.ok }));
if (!response.ok || (response.status < 200 && response.status >= 300)) {
const responseBody = await response.json();
myLog.info(safeStringify({ status: response.status, ok: response.ok }));
if (!response.ok) {
let responseBody;
const responseToUseInCaseOfError = response.clone();
try {
responseBody = await response.json();
} catch (e) {
return await handleZoomResponseJsonParseError({
error: e,
clonedResponse: responseToUseInCaseOfError,
});
}
myLog.debug(safeStringify({ responseBody }));
if (responseBody.error === "invalid_grant") {
@@ -233,11 +281,20 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
},
isAccessTokenUnusable: async function (response) {
const myLog = logger.getSubLogger({ prefix: ["zoomvideo:isAccessTokenUnusable"] });
myLog.debug(safeStringify({ status: response.status, ok: response.ok }));
if (!response.ok || (response.status < 200 && response.status >= 300)) {
const responseBody = await response.json();
myLog.info(safeStringify({ status: response.status, ok: response.ok }));
if (!response.ok) {
let responseBody;
const responseToUseInCaseOfError = response.clone();
try {
responseBody = await response.json();
} catch (e) {
return await handleZoomResponseJsonParseError({
error: e,
clonedResponse: responseToUseInCaseOfError,
});
}
myLog.debug(safeStringify({ responseBody }));
// 124 is the error code for invalid access token from Zoom API
if (responseBody.code === 124) {
return { reason: responseBody.message ?? "" };
}
@@ -284,7 +341,7 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
end: new Date(new Date(meeting.start_time).getTime() + meeting.duration * 60000).toISOString(),
}));
} catch (err) {
console.error(err);
log.error("Failed to get availability", safeStringify(err));
/* Prevents booking failure when Zoom Token is expired */
return [];
}
@@ -311,8 +368,10 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
}
throw new Error(`Failed to create meeting. Response is ${JSON.stringify(result)}`);
} catch (err) {
console.error(err);
log.error("Zoom meeting creation failed", safeStringify({ error: err, event }));
log.error(
"Zoom meeting creation failed",
safeStringify({ error: safeStringify(err), event: getPiiFreeCalendarEvent(event) })
);
/* Prevents meeting creation failure when Zoom Token is expired */
throw new Error("Unexpected error");
}
@@ -347,7 +406,10 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
url: result.join_url,
};
} catch (err) {
log.error("Failed to update meeting", safeStringify(err));
log.error(
"Failed to update meeting",
safeStringify({ error: err, event: getPiiFreeCalendarEvent(event) })
);
return Promise.reject(new Error("Failed to update meeting"));
}
},
+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";
@@ -164,4 +164,354 @@ describe("checkForConflicts", () => {
expect(result).toBe(false);
});
});
describe("comprehensive edge cases and boundary conditions", () => {
it("should return false for empty busy array", () => {
const result = checkForConflicts({
...createTestData("2023-01-01T09:00:00Z"),
busy: [],
});
expect(result).toBe(false);
});
it("should handle slot that starts exactly when busy period ends", () => {
const result = checkForConflicts({
...createTestData("2023-01-01T09:30:00Z"),
busy: [
{
start: dayjs.utc("2023-01-01T09:00:00Z").toDate(),
end: dayjs.utc("2023-01-01T09:30:00Z").toDate(),
},
],
});
expect(result).toBe(false);
});
it("should handle slot that ends exactly when busy period starts", () => {
const result = checkForConflicts({
...createTestData("2023-01-01T08:30:00Z"),
busy: [
{
start: dayjs.utc("2023-01-01T09:00:00Z").toDate(),
end: dayjs.utc("2023-01-01T09:30:00Z").toDate(),
},
],
});
expect(result).toBe(false);
});
it("should handle slot start exactly matching busy period start (inclusive)", () => {
const result = checkForConflicts({
...createTestData("2023-01-01T09:00:00Z"),
busy: [
{
start: dayjs.utc("2023-01-01T09:00:00Z").toDate(),
end: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
},
],
});
expect(result).toBe(true);
});
it("should handle slot end exactly matching busy period end (inclusive)", () => {
const result = checkForConflicts({
...createTestData("2023-01-01T09:15:00Z"),
busy: [
{
start: dayjs.utc("2023-01-01T09:30:00Z").toDate(),
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
},
],
});
expect(result).toBe(true);
});
it("should handle very short event length (1 minute)", () => {
const result = checkForConflicts({
time: dayjs("2023-01-01T09:00:00Z"),
eventLength: 1,
busy: [
{
start: dayjs.utc("2023-01-01T09:00:30Z").toDate(),
end: dayjs.utc("2023-01-01T09:01:30Z").toDate(),
},
],
});
expect(result).toBe(true);
});
it("should handle very long event length (8 hours)", () => {
const result = checkForConflicts({
time: dayjs("2023-01-01T09:00:00Z"),
eventLength: 480, // 8 hours
busy: [
{
start: dayjs.utc("2023-01-01T12:00:00Z").toDate(),
end: dayjs.utc("2023-01-01T13:00:00Z").toDate(),
},
],
});
expect(result).toBe(true);
});
});
describe("multiple busy periods scenarios", () => {
it("should return true when first busy period conflicts", () => {
const result = checkForConflicts({
...createTestData("2023-01-01T09:00:00Z"),
busy: [
{
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
},
{
start: dayjs.utc("2023-01-01T10:00:00Z").toDate(),
end: dayjs.utc("2023-01-01T11:00:00Z").toDate(),
},
],
});
expect(result).toBe(true);
});
it("should return true when last busy period conflicts", () => {
const result = checkForConflicts({
...createTestData("2023-01-01T10:30:00Z"),
busy: [
{
start: dayjs.utc("2023-01-01T08:00:00Z").toDate(),
end: dayjs.utc("2023-01-01T09:00:00Z").toDate(),
},
{
start: dayjs.utc("2023-01-01T10:45:00Z").toDate(),
end: dayjs.utc("2023-01-01T11:15:00Z").toDate(),
},
],
});
expect(result).toBe(true);
});
it("should return true when middle busy period conflicts", () => {
const result = checkForConflicts({
...createTestData("2023-01-01T10:00:00Z"),
busy: [
{
start: dayjs.utc("2023-01-01T08:00:00Z").toDate(),
end: dayjs.utc("2023-01-01T09:00:00Z").toDate(),
},
{
start: dayjs.utc("2023-01-01T10:15:00Z").toDate(),
end: dayjs.utc("2023-01-01T10:45:00Z").toDate(),
},
{
start: dayjs.utc("2023-01-01T11:00:00Z").toDate(),
end: dayjs.utc("2023-01-01T12:00:00Z").toDate(),
},
],
});
expect(result).toBe(true);
});
it("should handle many non-overlapping busy periods", () => {
const busyPeriods = [];
for (let i = 0; i < 10; i++) {
const hour = (8 + i * 2) % 24; // Wrap around to prevent invalid hours
busyPeriods.push({
start: dayjs.utc(`2023-01-01T${hour.toString().padStart(2, "0")}:00:00Z`).toDate(),
end: dayjs.utc(`2023-01-01T${hour.toString().padStart(2, "0")}:30:00Z`).toDate(),
});
}
const result = checkForConflicts({
...createTestData("2023-01-01T07:00:00Z"),
busy: busyPeriods,
});
expect(result).toBe(false);
});
it("should handle overlapping busy periods", () => {
const result = checkForConflicts({
...createTestData("2023-01-01T09:00:00Z"),
busy: [
{
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
},
{
start: dayjs.utc("2023-01-01T09:30:00Z").toDate(),
end: dayjs.utc("2023-01-01T10:00:00Z").toDate(),
},
],
});
expect(result).toBe(true);
});
});
describe("currentSeats comprehensive scenarios", () => {
it("should return false when currentSeats has exact time match", () => {
const testTime = dayjs.utc("2023-01-01T09:00:00Z");
const currentSeats: CurrentSeats = [
{
uid: "booking-1",
startTime: testTime.toDate(),
_count: { attendees: 2 },
},
];
const result = checkForConflicts({
time: testTime,
eventLength: 30,
busy: [
{
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
},
],
currentSeats,
});
expect(result).toBe(false);
});
it("should return true when currentSeats has no matching time but busy period conflicts", () => {
const currentSeats: CurrentSeats = [
{
uid: "booking-1",
startTime: dayjs.utc("2023-01-01T08:00:00Z").toDate(),
_count: { attendees: 1 },
},
];
const result = checkForConflicts({
time: dayjs.utc("2023-01-01T09:00:00Z"),
eventLength: 30,
busy: [
{
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
},
],
currentSeats,
});
expect(result).toBe(true);
});
it("should handle multiple currentSeats with one matching", () => {
const testTime = dayjs.utc("2023-01-01T09:00:00Z");
const currentSeats: CurrentSeats = [
{
uid: "booking-1",
startTime: dayjs.utc("2023-01-01T08:00:00Z").toDate(),
_count: { attendees: 1 },
},
{
uid: "booking-2",
startTime: testTime.toDate(),
_count: { attendees: 1 },
},
];
const result = checkForConflicts({
time: testTime,
eventLength: 30,
busy: [
{
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
},
],
currentSeats,
});
expect(result).toBe(false);
});
it("should handle empty currentSeats array", () => {
const result = checkForConflicts({
time: dayjs.utc("2023-01-01T09:00:00Z"),
eventLength: 30,
busy: [
{
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
},
],
currentSeats: [],
});
expect(result).toBe(true);
});
});
describe("timezone and date handling", () => {
it("should handle different timezone inputs correctly", () => {
const result = checkForConflicts({
time: dayjs("2023-01-01T09:00:00-05:00"), // EST
eventLength: 30,
busy: [
{
start: dayjs.utc("2023-01-01T14:15:00Z").toDate(), // UTC equivalent of 9:15 EST
end: dayjs.utc("2023-01-01T14:45:00Z").toDate(),
},
],
});
expect(result).toBe(true);
});
it("should handle cross-day boundaries", () => {
const result = checkForConflicts({
time: dayjs.utc("2023-01-01T23:45:00Z"),
eventLength: 30,
busy: [
{
start: dayjs.utc("2023-01-02T00:00:00Z").toDate(),
end: dayjs.utc("2023-01-02T00:30:00Z").toDate(),
},
],
});
expect(result).toBe(true);
});
});
describe("performance and stress scenarios", () => {
it("should handle large number of busy periods efficiently", () => {
const busyPeriods = [];
for (let i = 0; i < 1000; i++) {
const hour = (8 + (i % 12)) % 24; // Wrap around to prevent invalid hours
const minute = i % 60;
const endMinute = ((i % 60) + 15) % 60;
const endHour = endMinute < minute ? (hour + 1) % 24 : hour; // Handle minute overflow
busyPeriods.push({
start: dayjs
.utc(`2023-01-01T${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}:00Z`)
.toDate(),
end: dayjs
.utc(
`2023-01-01T${endHour.toString().padStart(2, "0")}:${endMinute.toString().padStart(2, "0")}:00Z`
)
.toDate(),
});
}
const startTime = performance.now();
const result = checkForConflicts({
...createTestData("2023-01-01T07:00:00Z"),
busy: busyPeriods,
});
const endTime = performance.now();
expect(result).toBe(false);
expect(endTime - startTime).toBeLessThan(100); // Should complete in under 100ms
});
it("should handle zero-duration busy periods", () => {
const result = checkForConflicts({
...createTestData("2023-01-01T09:00:00Z"),
busy: [
{
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
end: dayjs.utc("2023-01-01T09:15:00Z").toDate(), // Zero duration
},
],
});
expect(result).toBe(true);
});
});
});
@@ -26,34 +26,25 @@ export function checkForConflicts({
if (currentSeats?.some((booking) => booking.startTime.toISOString() === time.toISOString())) {
return false;
}
const slotStartDate = time.utc().toDate();
const slotEndDate = time.add(eventLength, "minutes").utc().toDate();
// handle busy times.
return busy.some((busyTime) => {
const busyStartDate = dayjs.utc(busyTime.start).toDate();
const busyEndDate = dayjs.utc(busyTime.end).toDate();
// First check if there's any overlap at all
// If busy period ends before slot starts or starts after slot ends, there's no overlap
if (busyEndDate <= slotStartDate || busyStartDate >= slotEndDate) {
return false;
const slotStart = time.valueOf();
const slotEnd = slotStart + eventLength * 60 * 1000;
const sortedBusyTimes = busy
.map((busyTime) => ({
start: dayjs.utc(busyTime.start).valueOf(),
end: dayjs.utc(busyTime.end).valueOf(),
}))
.sort((a, b) => a.start - b.start);
for (const busyTime of sortedBusyTimes) {
if (busyTime.start >= slotEnd) {
break;
}
// Now check all possible overlap scenarios:
// 1. Slot start falls within busy period (inclusive start, exclusive end)
if (slotStartDate >= busyStartDate && slotStartDate < busyEndDate) {
return true;
if (busyTime.end <= slotStart) {
continue;
}
// 2. Slot end falls within busy period (exclusive start, inclusive end)
if (slotEndDate > busyStartDate && slotEndDate <= busyEndDate) {
return true;
}
// 3. Busy period completely contained within slot
if (busyStartDate >= slotStartDate && busyEndDate <= slotEndDate) {
return true;
}
// 4. Slot completely contained within busy period
if (busyStartDate <= slotStartDate && busyEndDate >= slotEndDate) {
return true;
}
return false;
});
return true;
}
return false;
}
@@ -160,7 +160,22 @@ export const ensureBookingInputsHaveSystemFields = ({
type: "email",
name: "email",
required: !isEmailFieldOptional,
editable: isOrgTeamEvent ? "system-but-optional" : "system",
editable: "system-but-optional",
sources: [
{
label: "Default",
id: "default",
type: "default",
},
],
},
{
defaultLabel: "phone_number",
type: "phone",
name: "attendeePhoneNumber",
required: false,
hidden: true,
editable: "system-but-optional",
sources: [
{
label: "Default",
@@ -169,7 +184,6 @@ export const ensureBookingInputsHaveSystemFields = ({
},
],
},
{
defaultLabel: "location",
type: "radioInput",
@@ -204,23 +218,6 @@ export const ensureBookingInputsHaveSystemFields = ({
],
},
];
if (isOrgTeamEvent) {
systemBeforeFields.splice(2, 0, {
defaultLabel: "phone_number",
type: "phone",
name: "attendeePhoneNumber",
required: false,
hidden: true,
editable: "system-but-optional",
sources: [
{
label: "Default",
id: "default",
type: "default",
},
],
});
}
// These fields should be added after other user fields
const systemAfterFields: typeof bookingFields = [
@@ -1396,6 +1396,8 @@ async function handler(
routingFormResponseId,
organizerId: organizerUser.id,
teamId,
isRerouting: !!reroutingFormResponses,
reroutedByEmail: reqBody.rescheduledBy,
});
}
}
@@ -1500,15 +1502,6 @@ async function handler(
evt.videoCallData = undefined;
// To prevent "The requested identifier already exists" error while updating event, we need to remove iCalUID
evt.iCalUID = undefined;
} else {
// In case of rescheduling, we need to keep the previous host destination calendar
evt = CalendarEventBuilder.fromEvent(evt)
.withDestinationCalendar(
originalRescheduledBooking?.destinationCalendar
? [originalRescheduledBooking?.destinationCalendar]
: evt.destinationCalendar
)
.build();
}
const updateManager = await eventManager.reschedule(
@@ -23,7 +23,6 @@ import { v4 as uuidv4 } from "uuid";
import { describe, expect } from "vitest";
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
import { ErrorCode } from "@calcom/lib/errorCodes";
import logger from "@calcom/lib/logger";
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
@@ -256,7 +255,7 @@ describe("handleNewRecurringBooking", () => {
timeout
);
test(
test.skip(
`should fail recurring booking if second slot is already booked`,
async ({}) => {
const booker = getBooker({
@@ -372,11 +371,12 @@ describe("handleNewRecurringBooking", () => {
};
});
await expect(() =>
handleNewRecurringBooking({
bookingData: bookingDataArray,
userId: -1,
})
await expect(
async () =>
await handleNewRecurringBooking({
bookingData: bookingDataArray,
userId: -1,
})
).rejects.toThrow(ErrorCode.NoAvailableUsersFound);
},
timeout
@@ -384,7 +384,7 @@ describe("handleNewRecurringBooking", () => {
});
describe("Round robin event type:", () => {
test("should fail recurring booking if a fixed host is not available on the second slot", async () => {
test.skip("should fail recurring booking if a fixed host is not available on the second slot", async () => {
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
@@ -256,7 +256,7 @@ describe("handleNewBooking", () => {
timeout
);
test(
test.skip(
`should fail recurring booking if second slot is already booked`,
async ({}) => {
const handleRecurringEventBooking = (await import("@calcom/web/pages/api/book/recurring-event"))
@@ -822,7 +822,7 @@ describe("handleNewBooking", () => {
);
});
describe("Round robin event type:", () => {
test("should when when a fixed host is not available on the second slot", async () => {
test.skip("should when when a fixed host is not available on the second slot", async () => {
const handleRecurringEventBooking = (await import("@calcom/web/pages/api/book/recurring-event"))
.handleRecurringEventBooking;
const booker = getBooker({
@@ -0,0 +1,240 @@
import {
createBookingScenario,
getGoogleCalendarCredential,
TestData,
getOrganizer,
getBooker,
getScenarioData,
mockSuccessfulVideoMeetingCreation,
mockCalendarToHaveNoBusySlots,
getDate,
BookingLocations,
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import { expectBookingToBeInDatabase } from "@calcom/web/test/utils/bookingScenario/expects";
import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking";
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { describe, expect, beforeEach } from "vitest";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { resetTestEmails } from "@calcom/lib/testEmails";
import { SchedulingType } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
export type CustomNextApiRequest = NextApiRequest & Request;
export type CustomNextApiResponse = NextApiResponse & Response;
const timeout = process.env.CI ? 5000 : 20000;
describe("handleNewBooking", () => {
setupAndTeardown();
beforeEach(() => {
resetTestEmails();
});
describe("Collective Event Reschedule with Destination Calendar Bug", () => {
test(
`should reschedule a collective event booking and ensure both hosts have the booking in their calendars when a second host is added
1. Create a collective event with 1 host and book it
2. Add a second host to the event type
3. Reschedule the booking
4. Verify both hosts have the booking in their calendars
`,
async ({ emails }) => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
destinationCalendar: {
integration: "google_calendar",
externalId: "organizer@google-calendar.com",
primaryEmail: "organizer@google-calendar.com",
},
});
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP";
const iCalUID = `${uidOfBookingToBeRescheduled}@Cal.com`;
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 15,
length: 30,
schedulingType: SchedulingType.COLLECTIVE,
users: [
{
id: 101, // organizer
},
{
id: 102, // second host (will be added to event type)
},
],
destinationCalendar: {
integration: "google_calendar",
externalId: "event-type-1@google-calendar.com",
primaryEmail: "event-type-1@google-calendar.com",
},
},
],
users: [
{
id: 101,
name: "Organizer",
email: "organizer@example.com",
username: "organizer",
timeZone: "Asia/Kolkata",
defaultScheduleId: null,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
destinationCalendar: {
integration: "google_calendar",
externalId: "organizer@google-calendar.com",
primaryEmail: "organizer@google-calendar.com",
},
},
{
id: 102,
name: "Second Host",
email: "second-host@example.com",
username: "second-host",
timeZone: "Asia/Kolkata",
defaultScheduleId: null,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
destinationCalendar: {
integration: "google_calendar",
externalId: "second-host@google-calendar.com",
primaryEmail: "second-host@google-calendar.com",
},
},
],
bookings: [
{
uid: uidOfBookingToBeRescheduled,
eventTypeId: 1,
status: BookingStatus.ACCEPTED,
startTime: `${plus1DateString}T05:00:00.000Z`,
endTime: `${plus1DateString}T05:30:00.000Z`,
users: [
{
id: 101, // organizer
},
],
references: [
{
type: appStoreMetadata.dailyvideo.type,
uid: "MOCK_ID_1",
meetingId: "MOCK_ID_1",
meetingPassword: "MOCK_PASS_1",
meetingUrl: "http://mock-dailyvideo.example.com/meeting-1",
credentialId: null,
},
{
type: appStoreMetadata.googlecalendar.type,
uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID_1",
meetingId: "MOCKED_GOOGLE_CALENDAR_EVENT_ID_1",
meetingPassword: "MOCK_PASSWORD",
meetingUrl: "https://UNUSED_URL",
externalCalendarId: "event-type-1@google-calendar.com",
credentialId: undefined,
},
],
iCalUID,
},
],
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
})
);
mockSuccessfulVideoMeetingCreation({
metadataLookupKey: appStoreMetadata.dailyvideo.dirName,
videoMeetingData: {
id: "MOCK_ID_1", // Video meeting ID is not updated during reschedule
password: "MOCK_PASS_1",
url: `http://mock-dailyvideo.example.com/meeting-1`,
},
});
const organizerCalendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", {
update: {
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID_2",
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID_2",
},
});
const rescheduleBookingData = getMockRequestDataForBooking({
data: {
start: `${plus2DateString}T11:00:00.000Z`,
end: `${plus2DateString}T11:30:00.000Z`,
eventTypeId: 1,
rescheduleUid: uidOfBookingToBeRescheduled,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
rescheduledBy: organizer.email,
},
});
const rescheduledBooking = await handleNewBooking({
bookingData: rescheduleBookingData,
});
await expectBookingToBeInDatabase({
description: "",
location: BookingLocations.CalVideo,
responses: expect.objectContaining({
email: booker.email,
name: booker.name,
}),
uid: rescheduledBooking.uid!,
eventTypeId: rescheduleBookingData.eventTypeId,
status: BookingStatus.ACCEPTED,
references: [
{
type: appStoreMetadata.dailyvideo.type,
uid: "MOCK_ID_1",
meetingId: "MOCK_ID_1",
meetingPassword: "MOCK_PASS_1",
meetingUrl: "http://mock-dailyvideo.example.com/meeting-1",
},
{
type: appStoreMetadata.googlecalendar.type,
uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID_1",
externalCalendarId: "event-type-1@google-calendar.com",
},
],
});
expect(organizerCalendarMock.updateEventCalls.length).toBe(1);
expect(organizerCalendarMock.createEventCalls.length).toBe(0);
// Check availability calls for both hosts
expect(organizerCalendarMock.getAvailabilityCalls.length).toBe(2);
},
timeout
);
});
});
@@ -29,7 +29,7 @@ export const handleNewRecurringBooking = async (input: BookingHandlerInput): Pro
});
const appsStatus: AppsStatus[] | undefined = undefined;
const numSlotsToCheckForAvailability = 2;
const numSlotsToCheckForAvailability = 1;
let thirdPartyRecurringEventId = null;
@@ -28,11 +28,15 @@ export default class AssignmentReasonRecorder {
routingFormResponseId,
organizerId,
teamId,
isRerouting,
reroutedByEmail,
}: {
bookingId: number;
routingFormResponseId: number;
organizerId: number;
teamId: number;
isRerouting: boolean;
reroutedByEmail?: string | null;
}) {
// Get the routing form data
const routingFormResponse = await prisma.app_RoutingForms_FormResponse.findUnique({
@@ -113,8 +117,12 @@ export default class AssignmentReasonRecorder {
}
}
const reasonEnum = AssignmentReasonEnum.ROUTING_FORM_ROUTING;
const reasonString = attributeValues.join(", ");
const reasonEnum = isRerouting
? AssignmentReasonEnum.REROUTED
: AssignmentReasonEnum.ROUTING_FORM_ROUTING;
const reasonString = `${
isRerouting && reroutedByEmail ? `Rerouted by ${reroutedByEmail}` : ""
} ${attributeValues.join(", ")}`;
await prisma.assignmentReason.create({
data: {
@@ -157,6 +157,8 @@ export async function handler(req: NextRequest) {
}
if (message?.length && message?.length > 0 && sendTo) {
const smsMessageWithoutOptOut = message;
if (process.env.TWILIO_OPT_OUT_ENABLED === "true") {
message = await WorkflowOptOutService.addOptOutMessage(message, locale || "en");
}
@@ -167,6 +169,7 @@ export async function handler(req: NextRequest) {
body: message,
scheduledDate: reminder.scheduledDate,
sender: senderID,
bodyWithoutOptOut: smsMessageWithoutOptOut,
bookingUid: reminder.booking.uid,
userId,
teamId,
@@ -14,6 +14,7 @@ export async function sendSmsOrFallbackEmail(props: {
phoneNumber: string;
body: string;
sender: string;
bodyWithoutOptOut?: string;
bookingUid?: string | null;
userId?: number | null;
teamId?: number | null;
@@ -40,7 +41,7 @@ export async function sendSmsOrFallbackEmail(props: {
await sendOrScheduleWorkflowEmails({
to: [fallbackData.email],
subject: fallbackData.t("notification_about_your_booking"),
html: twilioData.body,
html: twilioData.bodyWithoutOptOut || twilioData.body,
replyTo: fallbackData.replyTo,
});
}
@@ -60,6 +61,7 @@ export async function scheduleSmsOrFallbackEmail(props: {
body: string;
scheduledDate: Date;
sender: string;
bodyWithoutOptOut?: string;
bookingUid?: string | null;
userId?: number | null;
teamId?: number | null;
@@ -96,7 +98,7 @@ export async function scheduleSmsOrFallbackEmail(props: {
await sendOrScheduleWorkflowEmails({
to: [fallbackData.email],
subject: fallbackData.t("notification_about_your_booking"),
html: twilioData.body,
html: twilioData.bodyWithoutOptOut || twilioData.body,
replyTo: fallbackData.replyTo,
sendAt: twilioData.scheduledDate,
referenceUid: reminder.uuid || undefined,
@@ -176,6 +176,8 @@ export const scheduleSMSReminder = async (args: ScheduleTextReminderArgs) => {
}
if (smsMessage.length > 0) {
const smsMessageWithoutOptOut = smsMessage;
if (process.env.TWILIO_OPT_OUT_ENABLED === "true") {
smsMessage = await WorkflowOptOutService.addOptOutMessage(
smsMessage,
@@ -196,6 +198,7 @@ export const scheduleSMSReminder = async (args: ScheduleTextReminderArgs) => {
phoneNumber: reminderPhone,
body: smsMessage,
sender: senderID,
bodyWithoutOptOut: smsMessageWithoutOptOut,
bookingUid: evt.uid,
userId,
teamId,
+1 -1
View File
@@ -244,7 +244,7 @@ export default function Booker( props : BookerProps ) {
namespace: string;
}) => {
return code`${getApiNameForVanillaJsSnippet({ namespace, mainApiName: "Cal" })}("inline", {
elementOrSelector:"#my-cal-inline",
elementOrSelector:"#my-cal-inline-${namespace}",
config: ${JSON.stringify(previewState.config)},
calLink: "${calLink}",
});
+1 -1
View File
@@ -59,7 +59,7 @@ export const tabs = [
embedType === "inline"
? `<div style="width:${getDimension(previewState.inline.width)};height:${getDimension(
previewState.inline.height
)};overflow:scroll" id="my-cal-inline"></div>\n`
)};overflow:scroll" id="my-cal-inline-${namespace}"></div>\n`
: ""
}<script type="text/javascript">
${embedSnippetString}
@@ -107,6 +107,66 @@ const getLocationInfo = ({
return { locationAvailable, locationDetails };
};
const LocationInput = (props: {
eventLocationType: EventLocationType;
defaultValue?: string;
index: number;
customClassNames?: LocationInputCustomClassNames;
disableLocationProp?: boolean;
}) => {
const { t } = useLocale();
const { eventLocationType, index, customClassNames, disableLocationProp, ...remainingProps } = props;
if (eventLocationType?.organizerInputType === "text") {
const { defaultValue, ...rest } = remainingProps;
return (
<Controller
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
defaultValue={defaultValue}
render={({ field: { onChange, value } }) => {
return (
<Input
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
type="text"
required
onChange={onChange}
value={value}
{...(disableLocationProp ? { disabled: true } : {})}
className={classNames("my-0", customClassNames?.addressInput)}
{...rest}
/>
);
}}
/>
);
} else if (eventLocationType?.organizerInputType === "phone") {
const { defaultValue, ...rest } = remainingProps;
return (
<Controller
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
defaultValue={defaultValue}
render={({ field: { onChange, value } }) => {
return (
<PhoneInput
required
disabled={disableLocationProp}
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
className={customClassNames?.phoneInput}
value={value}
onChange={onChange}
{...rest}
/>
);
}}
/>
);
}
return null;
};
const Locations: React.FC<LocationsProps> = ({
isChildrenManagedEventType,
disableLocationProp,
@@ -168,64 +228,6 @@ const Locations: React.FC<LocationsProps> = ({
locationOptions: props.locationOptions,
});
const LocationInput = (props: {
eventLocationType: EventLocationType;
defaultValue?: string;
index: number;
customClassNames?: LocationInputCustomClassNames;
}) => {
const { eventLocationType, index, customClassNames, ...remainingProps } = props;
if (eventLocationType?.organizerInputType === "text") {
const { defaultValue, ...rest } = remainingProps;
return (
<Controller
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
defaultValue={defaultValue}
render={({ field: { onChange, value } }) => {
return (
<Input
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
type="text"
required
onChange={onChange}
value={value}
{...(disableLocationProp ? { disabled: true } : {})}
className={classNames("my-0", customClassNames?.addressInput)}
{...rest}
/>
);
}}
/>
);
} else if (eventLocationType?.organizerInputType === "phone") {
const { defaultValue, ...rest } = remainingProps;
return (
<Controller
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
defaultValue={defaultValue}
render={({ field: { onChange, value } }) => {
return (
<PhoneInput
required
disabled={disableLocationProp}
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
className={customClassNames?.phoneInput}
value={value}
onChange={onChange}
{...rest}
/>
);
}}
/>
);
}
return null;
};
const [showEmptyLocationSelect, setShowEmptyLocationSelect] = useState(false);
const defaultInitialLocation = defaultValue || null;
const [selectedNewOption, setSelectedNewOption] = useState<SingleValueLocationOption | null>(
@@ -459,6 +461,7 @@ const Locations: React.FC<LocationsProps> = ({
eventLocationType={eventLocationType}
index={index}
customClassNames={customClassNames?.organizerContactInput?.locationInput}
disableLocationProp={disableLocationProp}
/>
</div>
<ErrorMessage
@@ -577,27 +577,38 @@ export const EventAdvancedTab = ({
/>
)}
<div className="border-subtle space-y-6 rounded-lg border p-6">
<FormBuilder
title={t("booking_questions_title")}
description={t("booking_questions_description")}
addFieldLabel={t("add_a_booking_question")}
formProp="bookingFields"
{...shouldLockDisableProps("bookingFields")}
dataStore={{
options: {
locations: {
// FormBuilder doesn't handle plural for non-english languages. So, use english(Location) only. This is similar to 'Workflow'
source: { label: "Location" },
value: getLocationsOptionsForSelect(formMethods.getValues("locations") ?? [], t),
<div className="border-subtle bg-muted rounded-lg border p-1">
<div className="p-5">
<div className="text-default text-sm font-semibold leading-none ltr:mr-1 rtl:ml-1">
{t("booking_questions_title")}
</div>
<p className="text-subtle mt-1 max-w-[280px] break-words text-sm sm:max-w-[500px]">
{t("booking_questions_description")}
</p>
</div>
<div className="border-subtle bg-default rounded-lg border p-5">
<FormBuilder
showPhoneAndEmailToggle
title={t("confirmation")}
description={t("what_booker_should_provide")}
addFieldLabel={t("add_a_booking_question")}
formProp="bookingFields"
{...shouldLockDisableProps("bookingFields")}
dataStore={{
options: {
locations: {
// FormBuilder doesn't handle plural for non-english languages. So, use english(Location) only. This is similar to 'Workflow'
source: { label: "Location" },
value: getLocationsOptionsForSelect(formMethods.getValues("locations") ?? [], t),
},
},
},
}}
shouldConsiderRequired={(field: BookingField) => {
// Location field has a default value at backend so API can send no location but we don't allow it in UI and thus we want to show it as required to user
return field.name === "location" ? true : field.required;
}}
/>
}}
shouldConsiderRequired={(field: BookingField) => {
// Location field has a default value at backend so API can send no location but we don't allow it in UI and thus we want to show it as required to user
return field.name === "location" ? true : field.required;
}}
/>
</div>
</div>
<RequiresConfirmationController
eventType={eventType}
@@ -24,7 +24,6 @@ import { weekdayNames } from "@calcom/lib/weekday";
import { weekStartNum } from "@calcom/lib/weekstart";
import { SchedulingType } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import classNames from "@calcom/ui/classNames";
import { Avatar } from "@calcom/ui/components/avatar";
import { Badge } from "@calcom/ui/components/badge";
@@ -121,6 +120,7 @@ type EventTypeScheduleProps = {
isSchedulePending?: boolean;
isRestrictionSchedulePending?: boolean;
restrictionScheduleRedirectUrl?: string;
isRestrictionScheduleEnabled?: boolean;
} & Omit<EventTypeScheduleDetailsProps, "customClassNames"> &
Omit<EventTypeTeamScheduleProps, "customClassNames">;
@@ -810,6 +810,7 @@ const useRestrictionScheduleState = (initialRestrictionScheduleId: number | null
const UseTeamEventScheduleSettingsToggle = ({
eventType,
customClassNames,
isRestrictionScheduleEnabled,
...rest
}: UseTeamEventScheduleSettingsToggle) => {
const { t } = useLocale();
@@ -819,18 +820,6 @@ const UseTeamEventScheduleSettingsToggle = ({
eventType.restrictionScheduleId
);
// Check if team has restriction schedule feature enabled
const { data: isRestrictionScheduleEnabled = false } = trpc.viewer.features.checkTeamFeature.useQuery(
{
teamId: eventType.team?.id || 0,
feature: "restriction-schedule",
},
{
enabled: !!eventType.team?.id,
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
}
);
return (
<div className="space-y-4">
<div className="border-subtle space-y-6 rounded-lg border p-6">
+66 -1
View File
@@ -15,6 +15,7 @@ import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { DialogContent, DialogFooter, DialogHeader, DialogClose } from "@calcom/ui/components/dialog";
import { Editor } from "@calcom/ui/components/editor";
import { ToggleGroup } from "@calcom/ui/components/form";
import {
Switch,
CheckboxField,
@@ -59,6 +60,7 @@ export const FormBuilder = function FormBuilder({
LockedIcon,
dataStore,
shouldConsiderRequired,
showPhoneAndEmailToggle = false,
}: {
formProp: string;
title: string;
@@ -66,6 +68,7 @@ export const FormBuilder = function FormBuilder({
addFieldLabel: string;
disabled: boolean;
LockedIcon: false | JSX.Element;
showPhoneAndEmailToggle?: boolean;
/**
* A readonly dataStore that is used to lookup the options for the fields. It works in conjunction with the field.getOptionAt property which acts as the key in options
*/
@@ -129,7 +132,68 @@ export const FormBuilder = function FormBuilder({
{title}
{LockedIcon}
</div>
<p className="text-subtle mt-0.5 max-w-[280px] break-words text-sm sm:max-w-[500px]">{description}</p>
<div className="flex items-start justify-between">
<p className="text-subtle mt-1 max-w-[280px] break-words text-sm sm:max-w-[500px]">{description}</p>
{showPhoneAndEmailToggle && (
<ToggleGroup
value={(() => {
const phoneField = fields.find((field) => field.name === "attendeePhoneNumber");
const emailField = fields.find((field) => field.name === "email");
if (phoneField && !phoneField.hidden && phoneField.required && !emailField?.required) {
return "phone";
}
return "email";
})()}
options={[
{
value: "email",
label: "Email",
iconLeft: <Icon name="mail" className="h-4 w-4" />,
},
{
value: "phone",
label: "Phone",
iconLeft: <Icon name="phone" className="h-4 w-4" />,
},
]}
onValueChange={(value) => {
const phoneFieldIndex = fields.findIndex((field) => field.name === "attendeePhoneNumber");
const emailFieldIndex = fields.findIndex((field) => field.name === "email");
if (value === "email") {
update(emailFieldIndex, {
...fields[emailFieldIndex],
hidden: false,
required: true,
});
update(phoneFieldIndex, {
...fields[phoneFieldIndex],
hidden: true,
required: false,
});
} else if (value === "phone") {
update(emailFieldIndex, {
...fields[emailFieldIndex],
hidden: true,
required: false,
});
update(phoneFieldIndex, {
...fields[phoneFieldIndex],
hidden: false,
required: true,
});
}
}}
/>
)}
</div>
<p className="text-default mt-5 text-sm font-semibold leading-none ltr:mr-1 rtl:ml-1">
{t("questions")}
</p>
<p className="text-subtle mt-1 max-w-[280px] break-words text-sm sm:max-w-[500px]">
{t("all_info_your_booker_provide")}
</p>
<ul ref={parent} className="border-subtle divide-subtle mt-4 divide-y rounded-md border">
{fields.map((field, index) => {
let options = field.options ?? null;
@@ -480,6 +544,7 @@ function FieldEditDialog({
const variantsConfig = fieldForm.watch("variantsConfig");
const fieldTypes = Object.values(fieldTypesConfigMap);
const fieldName = fieldForm.getValues("name");
return (
<Dialog open={dialog.isOpen} onOpenChange={onOpenChange} modal={false}>
+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"))),
};
});
+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
+2
View File
@@ -104,6 +104,8 @@ export function getEventName(eventNameObj: EventNameObjectType, forAttendeeView
}
dynamicEventName = dynamicEventName.replace(`{${variable}}`, fieldValue || "");
} else {
dynamicEventName = dynamicEventName.replace(`{${variable}}`, "");
}
});
+12
View File
@@ -0,0 +1,12 @@
/**
* Checks if a string is a supported base64 image format
*/
export function isBase64Image(input: string | null | undefined): boolean {
if (!input) return false;
return (
input.startsWith("data:image/png;base64,") ||
input.startsWith("data:image/jpeg;base64,") ||
input.startsWith("data:image/jpg;base64,")
);
}
+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/");
}
+21 -1
View File
@@ -263,7 +263,7 @@ export class MembershipRepository {
});
}
static async findFirstByUserIdAndTeamId({ userId, teamId }: { userId: number; teamId: number }) {
static async findUniqueByUserIdAndTeamId({ userId, teamId }: { userId: number; teamId: number }) {
return await prisma.membership.findUnique({
where: {
userId_teamId: {
@@ -398,4 +398,24 @@ export class MembershipRepository {
},
});
}
static async findAllByTeamIds({
teamIds,
select = { userId: true },
}: {
teamIds: number[];
select?: Prisma.MembershipSelect;
}) {
return prisma.membership.findMany({
where: {
team: {
id: {
in: teamIds,
},
},
accepted: true,
},
select,
});
}
}
@@ -138,6 +138,70 @@ export class ScheduleRepository {
isDefault: !scheduleId || defaultScheduleId === schedule.id,
isLastSchedule: schedulesCount <= 1,
readOnly: schedule.userId !== userId && !isManagedEventType,
userId: schedule.userId,
};
}
static async findManyDetailedScheduleByUserId({
isManagedEventType,
userId,
defaultScheduleId,
timeZone: userTimeZone,
}: {
timeZone: string;
userId: number;
defaultScheduleId: number | null;
isManagedEventType?: boolean;
}) {
const schedules = await prisma.schedule.findMany({
where: {
userId: userId,
},
select: {
id: true,
userId: true,
name: true,
availability: true,
timeZone: true,
},
});
if (!schedules?.length) {
throw new Error("Schedules not found");
}
const isCurrentUserPartOfTeam = await hasReadPermissionsForUserId({
memberId: schedules[0].userId,
userId,
});
const schedulesFormatted = schedules.map((schedule) => {
const isCurrentUserOwner = schedule?.userId === userId;
if (!isCurrentUserPartOfTeam && !isCurrentUserOwner) {
throw new Error("UNAUTHORIZED");
}
const timeZone = schedule.timeZone || userTimeZone;
// disabling utc casting while fetching WorkingHours
return {
id: schedule.id,
name: schedule.name,
isManaged: schedule.userId !== userId,
workingHours: transformWorkingHoursForAtom(schedule),
schedule: schedule.availability,
availability: transformAvailabilityForAtom(schedule),
timeZone,
isDefault: schedule.id === defaultScheduleId,
dateOverrides: transformDateOverridesForAtom(schedule, timeZone),
readOnly: schedule.userId !== userId && !isManagedEventType,
isLastSchedule: schedules.length <= 1,
userId: schedule.userId,
};
});
return schedulesFormatted;
}
}
+47
View File
@@ -186,6 +186,39 @@ export class TeamRepository {
return getParsedTeam(team);
}
static async findAllByParentId({
parentId,
select = teamSelect,
}: {
parentId: number;
select?: Prisma.TeamSelect;
}) {
return await prisma.team.findMany({
where: {
parentId,
},
select,
});
}
static async findByIdAndParentId({
id,
parentId,
select = teamSelect,
}: {
id: number;
parentId: number;
select?: Prisma.TeamSelect;
}) {
return await prisma.team.findFirst({
where: {
id,
parentId,
},
select,
});
}
static async deleteById({ id }: { id: number }) {
try {
await WorkflowService.deleteWorkflowRemindersOfRemovedTeam(id);
@@ -360,4 +393,18 @@ export class TeamRepository {
inviteToken: inviteTokens.find((token) => token.identifier === `invite-link-for-teamId-${team.id}`),
}));
}
static async findTeamWithOrganizationSettings(teamId: number) {
return await prisma.team.findUnique({
where: { id: teamId },
select: {
parent: {
select: {
isOrganization: true,
organizationSettings: true,
},
},
},
});
}
}
@@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest";
import prisma from "@calcom/prisma";
import { BookingStatus, MembershipRole } from "@calcom/prisma/enums";
import { InsightsBookingRepository } from "../insightsBooking";
import { InsightsBookingService } from "../../service/insightsBooking";
// Helper function to create unique test data
async function createTestData({
@@ -195,15 +195,15 @@ async function createTestData({
};
}
describe("InsightsBookingRepository Integration Tests", () => {
describe("InsightsBookingService Integration Tests", () => {
describe("Authorization Conditions", () => {
it("should return NOTHING for invalid options", async () => {
const repository = new InsightsBookingRepository({
const service = new InsightsBookingService({
prisma,
options: null as any,
});
const conditions = await repository.getAuthorizationConditions();
const conditions = await service.getAuthorizationConditions();
expect(conditions).toEqual({ id: -1 });
});
@@ -229,7 +229,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
},
});
const repository = new InsightsBookingRepository({
const service = new InsightsBookingService({
prisma,
options: {
scope: "org",
@@ -238,7 +238,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
},
});
const conditions = await repository.getAuthorizationConditions();
const conditions = await service.getAuthorizationConditions();
expect(conditions).toEqual({ id: -1 });
// Clean up
@@ -257,7 +257,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
orgRole: MembershipRole.OWNER,
});
const repository = new InsightsBookingRepository({
const service = new InsightsBookingService({
prisma,
options: {
scope: "user",
@@ -266,7 +266,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
},
});
const conditions = await repository.getAuthorizationConditions();
const conditions = await service.getAuthorizationConditions();
expect(conditions).toEqual({
AND: [
{
@@ -285,7 +285,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
orgRole: MembershipRole.OWNER,
});
const repository = new InsightsBookingRepository({
const service = new InsightsBookingService({
prisma,
options: {
scope: "team",
@@ -295,7 +295,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
},
});
const conditions = await repository.getAuthorizationConditions();
const conditions = await service.getAuthorizationConditions();
expect(conditions).toEqual({
AND: [
{
@@ -335,7 +335,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
const team2 = testData.additionalTeams[0]; // First additional team
const team3 = testData.additionalTeams[1]; // Second additional team
const repository = new InsightsBookingRepository({
const service = new InsightsBookingService({
prisma,
options: {
scope: "org",
@@ -344,7 +344,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
},
});
const conditions = await repository.getAuthorizationConditions();
const conditions = await service.getAuthorizationConditions();
expect(conditions).toEqual({
AND: [
@@ -375,7 +375,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
it("should return null when no filters are provided", async () => {
const testData = await createTestData();
const repository = new InsightsBookingRepository({
const service = new InsightsBookingService({
prisma,
options: {
scope: "user",
@@ -384,7 +384,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
},
});
const conditions = await repository.getFilterConditions();
const conditions = await service.getFilterConditions();
expect(conditions).toBeNull();
await testData.cleanup();
@@ -393,7 +393,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
it("should build eventTypeId filter conditions", async () => {
const testData = await createTestData();
const repository = new InsightsBookingRepository({
const service = new InsightsBookingService({
prisma,
options: {
scope: "user",
@@ -405,7 +405,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
},
});
const conditions = await repository.getFilterConditions();
const conditions = await service.getFilterConditions();
expect(conditions).toEqual({
AND: [
{
@@ -420,7 +420,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
it("should build memberUserId filter conditions", async () => {
const testData = await createTestData();
const repository = new InsightsBookingRepository({
const service = new InsightsBookingService({
prisma,
options: {
scope: "user",
@@ -432,7 +432,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
},
});
const conditions = await repository.getFilterConditions();
const conditions = await service.getFilterConditions();
expect(conditions).toEqual({
AND: [
{
@@ -447,7 +447,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
it("should combine multiple filter conditions", async () => {
const testData = await createTestData();
const repository = new InsightsBookingRepository({
const service = new InsightsBookingService({
prisma,
options: {
scope: "user",
@@ -460,7 +460,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
},
});
const conditions = await repository.getFilterConditions();
const conditions = await service.getFilterConditions();
expect(conditions).toEqual({
AND: [
{
@@ -483,7 +483,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
orgRole: MembershipRole.OWNER,
});
const repository = new InsightsBookingRepository({
const service = new InsightsBookingService({
prisma,
options: {
scope: "user",
@@ -493,7 +493,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
});
// First call should build conditions
const conditions1 = await repository.getAuthorizationConditions();
const conditions1 = await service.getAuthorizationConditions();
expect(conditions1).toEqual({
AND: [
{
@@ -504,7 +504,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
});
// Second call should use cached conditions
const conditions2 = await repository.getAuthorizationConditions();
const conditions2 = await service.getAuthorizationConditions();
expect(conditions2).toEqual(conditions1);
// Clean up
@@ -514,7 +514,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
it("should cache filter conditions", async () => {
const testData = await createTestData();
const repository = new InsightsBookingRepository({
const service = new InsightsBookingService({
prisma,
options: {
scope: "user",
@@ -527,7 +527,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
});
// First call should build conditions
const conditions1 = await repository.getFilterConditions();
const conditions1 = await service.getFilterConditions();
expect(conditions1).toEqual({
AND: [
{
@@ -537,7 +537,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
});
// Second call should use cached conditions
const conditions2 = await repository.getFilterConditions();
const conditions2 = await service.getFilterConditions();
expect(conditions2).toEqual(conditions1);
await testData.cleanup();
@@ -575,7 +575,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
},
});
const repository = new InsightsBookingRepository({
const service = new InsightsBookingService({
prisma,
options: {
scope: "user",
@@ -587,7 +587,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
},
});
const results = await repository.findMany({
const results = await service.findMany({
select: {
id: true,
title: true,
@@ -0,0 +1,705 @@
import type { Team, User, Membership } from "@prisma/client";
import { randomUUID } from "crypto";
import { describe, expect, it } from "vitest";
import prisma from "@calcom/prisma";
import { BookingStatus, MembershipRole } from "@calcom/prisma/enums";
import { InsightsRoutingService } from "../../service/insightsRouting";
// Helper function to create unique test data
async function createTestData({
teamRole = MembershipRole.MEMBER,
orgRole = MembershipRole.MEMBER,
moreTeamsAndUsers = [],
}: {
teamRole?: MembershipRole;
orgRole?: MembershipRole;
moreTeamsAndUsers?: MembershipRole[][]; // Array of teams, each team has array of user roles
} = {}) {
// Create test user
const user = await prisma.user.create({
data: {
email: `test-user-${randomUUID()}@example.com`,
username: `testuser-${randomUUID()}`,
name: "Test User",
},
});
// Create test organization
const org = await prisma.team.create({
data: {
name: "Test Org",
slug: `test-org-${randomUUID()}`,
isOrganization: true,
},
});
// Create test team under the organization
const team = await prisma.team.create({
data: {
name: "Test Team",
slug: `test-team-${randomUUID()}`,
isOrganization: false,
parentId: org.id,
},
});
// Create test event type
const eventType = await prisma.eventType.create({
data: {
title: "Test Event Type",
slug: `test-event-type-${randomUUID()}`,
length: 60,
userId: user.id,
teamId: team.id,
},
});
// Create test booking
const booking = await prisma.booking.create({
data: {
uid: `test-booking-${randomUUID()}`,
title: "Test Booking",
startTime: new Date(),
endTime: new Date(Date.now() + 60 * 60 * 1000),
userId: user.id,
eventTypeId: eventType.id,
status: BookingStatus.PENDING,
},
});
// Create test routing form
const form = await prisma.app_RoutingForms_Form.create({
data: {
name: "Test Routing Form",
userId: user.id,
teamId: team.id,
fields: [
{
id: `text-field-id-${randomUUID()}`,
type: "text",
label: "Name",
required: true,
},
{
id: `email-field-id-${randomUUID()}`,
type: "email",
label: "Email",
required: true,
},
],
},
});
// Create test form response
const formResponse = await prisma.app_RoutingForms_FormResponse.create({
data: {
formFillerId: `test-filler-${randomUUID()}`,
formId: form.id,
response: {
[`text-field-id-${randomUUID()}`]: {
label: "Name",
value: "Test Name",
},
[`email-field-id-${randomUUID()}`]: {
label: "Email",
value: "test@example.com",
},
},
routedToBookingUid: booking.uid,
},
});
const membership = await prisma.membership.create({
data: {
userId: user.id,
teamId: team.id,
role: teamRole,
accepted: true,
},
});
const orgMembership = await prisma.membership.create({
data: {
userId: user.id,
teamId: org.id,
role: orgRole,
accepted: true,
},
});
// Create additional teams and users based on moreTeamsAndUsers
const additionalTeams: Team[] = [];
const additionalUsers: User[] = [];
const additionalMemberships: Membership[] = [];
for (let i = 0; i < moreTeamsAndUsers.length; i++) {
const teamRoles = moreTeamsAndUsers[i];
const additionalTeam = await prisma.team.create({
data: {
name: `Additional Team ${i + 1}`,
slug: `additional-team-${i + 1}-${randomUUID()}`,
isOrganization: false,
parentId: org.id,
},
});
additionalTeams.push(additionalTeam);
for (let j = 0; j < teamRoles.length; j++) {
const role = teamRoles[j];
const additionalUser = await prisma.user.create({
data: {
email: `additional-user-${i}-${j}-${randomUUID()}@example.com`,
username: `additionaluser-${i}-${j}-${randomUUID()}`,
name: `Additional User ${i}-${j}`,
},
});
additionalUsers.push(additionalUser);
const additionalMembership = await prisma.membership.create({
data: {
userId: additionalUser.id,
teamId: additionalTeam.id,
role: role,
accepted: true,
},
});
additionalMemberships.push(additionalMembership);
}
}
// Cleanup function
const cleanup = async () => {
// Delete in reverse order to avoid foreign key constraints
for (const membership of additionalMemberships) {
await prisma.membership.delete({
where: { id: membership.id },
});
}
for (const user of additionalUsers) {
await prisma.user.delete({
where: { id: user.id },
});
}
for (const team of additionalTeams) {
await prisma.team.delete({
where: { id: team.id },
});
}
await prisma.membership.delete({
where: { id: orgMembership.id },
});
await prisma.membership.delete({
where: { id: membership.id },
});
await prisma.app_RoutingForms_FormResponse.delete({
where: { id: formResponse.id },
});
await prisma.booking.delete({
where: { id: booking.id },
});
await prisma.eventType.delete({
where: { id: eventType.id },
});
await prisma.app_RoutingForms_Form.delete({
where: { id: form.id },
});
await prisma.team.delete({
where: { id: team.id },
});
await prisma.team.delete({
where: { id: org.id },
});
await prisma.user.delete({
where: { id: user.id },
});
};
return {
user,
org,
team,
eventType,
booking,
form,
formResponse,
membership,
orgMembership,
additionalTeams,
additionalUsers,
additionalMemberships,
cleanup,
};
}
describe("InsightsRoutingService Integration Tests", () => {
describe("Authorization Conditions", () => {
it("should return NOTHING for invalid options", async () => {
const service = new InsightsRoutingService({
prisma,
options: null as any,
});
const conditions = await service.getAuthorizationConditions();
expect(conditions).toEqual({ id: -1 });
});
it("should return NOTHING for non-owner/admin user", async () => {
const testData = await createTestData();
// Create a regular user who is not an owner/admin
const regularUser = await prisma.user.create({
data: {
email: `regular-user-${randomUUID()}@example.com`,
username: `regularuser-${randomUUID()}`,
name: "Regular User",
},
});
// Add the user as a regular member (not owner/admin) to the organization
const membership = await prisma.membership.create({
data: {
userId: regularUser.id,
teamId: testData.org.id,
role: MembershipRole.MEMBER,
accepted: true,
},
});
const service = new InsightsRoutingService({
prisma,
options: {
scope: "org",
userId: regularUser.id,
orgId: testData.org.id,
},
});
const conditions = await service.getAuthorizationConditions();
expect(conditions).toEqual({ id: -1 });
// Clean up
await prisma.membership.delete({
where: { id: membership.id },
});
await prisma.user.delete({
where: { id: regularUser.id },
});
await testData.cleanup();
});
it("should build user scope conditions", async () => {
const testData = await createTestData({
teamRole: MembershipRole.OWNER,
orgRole: MembershipRole.OWNER,
});
const service = new InsightsRoutingService({
prisma,
options: {
scope: "user",
userId: testData.user.id,
orgId: testData.org.id,
},
});
const conditions = await service.getAuthorizationConditions();
expect(conditions).toEqual({
AND: [
{
formUserId: testData.user.id,
},
],
});
await testData.cleanup();
});
it("should build team scope conditions", async () => {
const testData = await createTestData({
teamRole: MembershipRole.OWNER,
orgRole: MembershipRole.OWNER,
});
const service = new InsightsRoutingService({
prisma,
options: {
scope: "team",
userId: testData.user.id,
orgId: testData.org.id,
teamId: testData.team.id,
},
});
const conditions = await service.getAuthorizationConditions();
expect(conditions).toEqual({
AND: [
{
formTeamId: testData.team.id,
},
],
});
// Clean up
await testData.cleanup();
});
it("should build org scope conditions", async () => {
const testData = await createTestData({
teamRole: MembershipRole.OWNER,
orgRole: MembershipRole.OWNER,
moreTeamsAndUsers: [
[MembershipRole.MEMBER], // Team 2: 1 user with MEMBER role
[MembershipRole.MEMBER], // Team 3: 1 user with MEMBER role
],
});
// Get references to additional data
const team2 = testData.additionalTeams[0]; // First additional team
const team3 = testData.additionalTeams[1]; // Second additional team
const service = new InsightsRoutingService({
prisma,
options: {
scope: "org",
userId: testData.user.id,
orgId: testData.org.id,
},
});
const conditions = await service.getAuthorizationConditions();
expect(conditions).toEqual({
AND: [
{
formTeamId: {
in: [testData.org.id, testData.team.id, team2.id, team3.id],
},
},
],
});
await testData.cleanup();
});
it("should return NOTHING for team scope when team is not child of org", async () => {
const testData = await createTestData({
teamRole: MembershipRole.OWNER,
orgRole: MembershipRole.OWNER,
});
// Create a team that is not a child of the org
const unrelatedTeam = await prisma.team.create({
data: {
name: "Unrelated Team",
slug: `unrelated-team-${randomUUID()}`,
isOrganization: false,
parentId: null, // Not part of the org
},
});
const service = new InsightsRoutingService({
prisma,
options: {
scope: "team",
userId: testData.user.id,
orgId: testData.org.id,
teamId: unrelatedTeam.id,
},
});
const conditions = await service.getAuthorizationConditions();
expect(conditions).toEqual({ AND: [{ id: -1 }] });
// Clean up
await prisma.team.delete({
where: { id: unrelatedTeam.id },
});
await testData.cleanup();
});
});
describe("Filter Conditions", () => {
it("should return null when no filters are provided", async () => {
const testData = await createTestData();
const service = new InsightsRoutingService({
prisma,
options: {
scope: "user",
userId: testData.user.id,
orgId: testData.org.id,
},
});
const conditions = await service.getFilterConditions();
expect(conditions).toBeNull();
await testData.cleanup();
});
});
describe("Caching", () => {
it("should cache authorization conditions", async () => {
const testData = await createTestData({
teamRole: MembershipRole.OWNER,
orgRole: MembershipRole.OWNER,
});
const service = new InsightsRoutingService({
prisma,
options: {
scope: "user",
userId: testData.user.id,
orgId: testData.org.id,
},
});
// First call should build conditions
const conditions1 = await service.getAuthorizationConditions();
expect(conditions1).toEqual({
AND: [
{
formUserId: testData.user.id,
},
],
});
// Second call should use cached conditions
const conditions2 = await service.getAuthorizationConditions();
expect(conditions2).toEqual(conditions1);
// Clean up
await testData.cleanup();
});
it("should cache filter conditions", async () => {
const testData = await createTestData();
const service = new InsightsRoutingService({
prisma,
options: {
scope: "user",
userId: testData.user.id,
orgId: testData.org.id,
},
});
// First call should build conditions
const conditions1 = await service.getFilterConditions();
expect(conditions1).toBeNull();
// Second call should use cached conditions
const conditions2 = await service.getFilterConditions();
expect(conditions2).toEqual(conditions1);
await testData.cleanup();
});
});
describe("findMany", () => {
it("should combine authorization and filter conditions", async () => {
const testData = await createTestData({
teamRole: MembershipRole.OWNER,
orgRole: MembershipRole.OWNER,
});
const service = new InsightsRoutingService({
prisma,
options: {
scope: "user",
userId: testData.user.id,
orgId: testData.org.id,
},
});
const results = await service.findMany({
select: {
id: true,
formName: true,
},
});
// Should return the user form response since it matches the authorization conditions
expect(results).toHaveLength(1);
expect(results[0]?.id).toBe(testData.formResponse.id);
await testData.cleanup();
});
it("should filter out unauthorized responses", async () => {
const testData = await createTestData({
teamRole: MembershipRole.OWNER,
orgRole: MembershipRole.OWNER,
});
// Create another user and form
const otherUser = await prisma.user.create({
data: {
email: `other-user-${randomUUID()}@example.com`,
username: `otheruser-${randomUUID()}`,
name: "Other User",
},
});
const otherForm = await prisma.app_RoutingForms_Form.create({
data: {
name: "Other User Form",
userId: otherUser.id,
teamId: null,
fields: [
{
id: "text-field-id",
type: "text",
label: "Name",
required: true,
},
],
},
});
const otherFormResponse = await prisma.app_RoutingForms_FormResponse.create({
data: {
formFillerId: `other-form-filler-${randomUUID()}`,
formId: otherForm.id,
response: {
"text-field-id": {
label: "Name",
value: "Other Form Response",
},
},
},
});
const service = new InsightsRoutingService({
prisma,
options: {
scope: "user",
userId: testData.user.id,
orgId: testData.org.id,
},
});
const results = await service.findMany({
select: {
id: true,
formName: true,
},
});
// Should only return the authorized user's form response
expect(results).toHaveLength(1);
expect(results[0]?.id).toBe(testData.formResponse.id);
expect(results[0]?.id).not.toBe(otherFormResponse.id);
// Clean up
await prisma.app_RoutingForms_FormResponse.delete({
where: { id: otherFormResponse.id },
});
await prisma.app_RoutingForms_Form.delete({
where: { id: otherForm.id },
});
await prisma.user.delete({
where: { id: otherUser.id },
});
await testData.cleanup();
});
it("should fetch all forms in org scope when user is in same team", async () => {
const testData = await createTestData({
teamRole: MembershipRole.OWNER,
orgRole: MembershipRole.OWNER,
});
// Create another user in the same team
const teamMember = await prisma.user.create({
data: {
email: `team-member-${randomUUID()}@example.com`,
username: `teammember-${randomUUID()}`,
name: "Team Member",
},
});
// Add the team member to the same team
const teamMembership = await prisma.membership.create({
data: {
userId: teamMember.id,
teamId: testData.team.id,
role: MembershipRole.MEMBER,
accepted: true,
},
});
// Create a form under the team member
const teamMemberForm = await prisma.app_RoutingForms_Form.create({
data: {
name: "Team Member Form",
userId: teamMember.id,
teamId: testData.team.id, // Same team as the original user
fields: [
{
id: `text-field-id-${randomUUID()}`,
type: "text",
label: "Name",
required: true,
},
],
},
});
// Create a form response for the team member's form
const teamMemberFormResponse = await prisma.app_RoutingForms_FormResponse.create({
data: {
formFillerId: `team-member-form-filler-${randomUUID()}`,
formId: teamMemberForm.id,
response: {
[`text-field-id-${randomUUID()}`]: {
label: "Name",
value: "Team Member Form Response",
},
},
},
});
const service = new InsightsRoutingService({
prisma,
options: {
scope: "org",
userId: testData.user.id,
orgId: testData.org.id,
},
});
const results = await service.findMany({
select: {
id: true,
formName: true,
},
});
// Should return both form responses (original user's and team member's)
expect(results).toHaveLength(2);
const responseIds = results.map((r) => r.id).sort();
const expectedIds = [testData.formResponse.id, teamMemberFormResponse.id].sort();
expect(responseIds).toEqual(expectedIds);
// Clean up
await prisma.app_RoutingForms_FormResponse.delete({
where: { id: teamMemberFormResponse.id },
});
await prisma.app_RoutingForms_Form.delete({
where: { id: teamMemberForm.id },
});
await prisma.membership.delete({
where: { id: teamMembership.id },
});
await prisma.user.delete({
where: { id: teamMember.id },
});
await testData.cleanup();
});
});
});
@@ -2,8 +2,12 @@ import type { Prisma } from "@prisma/client";
import { z } from "zod";
import type { readonlyPrisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
export const insightsBookingRepositoryOptionsSchema = z.discriminatedUnion("scope", [
import { MembershipRepository } from "../repository/membership";
import { TeamRepository } from "../repository/team";
export const insightsBookingServiceOptionsSchema = z.discriminatedUnion("scope", [
z.object({
scope: z.literal("user"),
userId: z.number(),
@@ -22,9 +26,9 @@ export const insightsBookingRepositoryOptionsSchema = z.discriminatedUnion("scop
}),
]);
export type InsightsBookingRepositoryOptions = z.infer<typeof insightsBookingRepositoryOptionsSchema>;
export type InsightsBookingServiceOptions = z.infer<typeof insightsBookingServiceOptionsSchema>;
export type InsightsBookingRepositoryFilterOptions = {
export type InsightsBookingServiceFilterOptions = {
eventTypeId?: number;
memberUserId?: number;
};
@@ -33,10 +37,10 @@ const NOTHING = {
id: -1,
} as const;
export class InsightsBookingRepository {
export class InsightsBookingService {
private prisma: typeof readonlyPrisma;
private options: InsightsBookingRepositoryOptions | null;
private filters?: InsightsBookingRepositoryFilterOptions;
private options: InsightsBookingServiceOptions | null;
private filters?: InsightsBookingServiceFilterOptions;
private cachedAuthConditions?: Prisma.BookingTimeStatusDenormalizedWhereInput;
private cachedFilterConditions?: Prisma.BookingTimeStatusDenormalizedWhereInput | null;
@@ -46,12 +50,11 @@ export class InsightsBookingRepository {
filters,
}: {
prisma: typeof readonlyPrisma;
options: InsightsBookingRepositoryOptions;
filters?: InsightsBookingRepositoryFilterOptions;
options: InsightsBookingServiceOptions;
filters?: InsightsBookingServiceFilterOptions;
}) {
this.prisma = prisma;
const validation = insightsBookingRepositoryOptionsSchema.safeParse(options);
const validation = insightsBookingServiceOptionsSchema.safeParse(options);
this.options = validation.success ? validation.data : null;
this.filters = filters;
@@ -138,37 +141,21 @@ export class InsightsBookingRepository {
}
private async buildOrgAuthorizationCondition(
options: Extract<InsightsBookingRepositoryOptions, { scope: "org" }>
options: Extract<InsightsBookingServiceOptions, { scope: "org" }>
): Promise<Prisma.BookingTimeStatusDenormalizedWhereInput> {
// Get all teams from the organization
const teamsFromOrg = await this.prisma.team.findMany({
where: {
parentId: options.orgId,
},
select: {
id: true,
},
const teamsFromOrg = await TeamRepository.findAllByParentId({
parentId: options.orgId,
select: { id: true },
});
const teamIds = [options.orgId, ...teamsFromOrg.map((t) => t.id)];
// Get all users from the organization
const userIdsFromOrg =
teamsFromOrg.length > 0
? (
await this.prisma.membership.findMany({
where: {
team: {
id: {
in: teamIds,
},
},
accepted: true,
},
select: {
userId: true,
},
})
).map((m) => m.userId)
? (await MembershipRepository.findAllByTeamIds({ teamIds, select: { userId: true } })).map(
(m) => m.userId
)
: [];
return {
@@ -194,26 +181,20 @@ export class InsightsBookingRepository {
}
private async buildTeamAuthorizationCondition(
options: Extract<InsightsBookingRepositoryOptions, { scope: "team" }>
options: Extract<InsightsBookingServiceOptions, { scope: "team" }>
): Promise<Prisma.BookingTimeStatusDenormalizedWhereInput> {
const childTeamOfOrg = await this.prisma.team.findFirst({
where: {
id: options.teamId,
parentId: options.orgId,
},
const childTeamOfOrg = await TeamRepository.findByIdAndParentId({
id: options.teamId,
parentId: options.orgId,
select: { id: true },
});
if (!childTeamOfOrg) {
return NOTHING;
}
const usersFromTeam = await this.prisma.membership.findMany({
where: {
teamId: options.teamId,
accepted: true,
},
select: {
userId: true,
},
const usersFromTeam = await MembershipRepository.findAllByTeamIds({
teamIds: [options.teamId],
select: { userId: true },
});
const userIdsFromTeam = usersFromTeam.map((u) => u.userId);
@@ -235,22 +216,12 @@ export class InsightsBookingRepository {
private async isOrgOwnerOrAdmin(userId: number, orgId: number): Promise<boolean> {
// Check if the user is an owner or admin of the organization
const membershipOrg = await this.prisma.membership.findUnique({
where: {
userId_teamId: {
userId,
teamId: orgId,
},
accepted: true,
role: {
in: ["OWNER", "ADMIN"],
},
},
select: {
id: true,
},
});
return Boolean(membershipOrg);
const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId, teamId: orgId });
return Boolean(
membership &&
membership.accepted &&
membership.role &&
([MembershipRole.OWNER, MembershipRole.ADMIN] as const).includes(membership.role)
);
}
}
@@ -0,0 +1,168 @@
import type { Prisma } from "@prisma/client";
import { z } from "zod";
import type { readonlyPrisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { MembershipRepository } from "../repository/membership";
import { TeamRepository } from "../repository/team";
export const insightsRoutingServiceOptionsSchema = z.discriminatedUnion("scope", [
z.object({
scope: z.literal("user"),
userId: z.number(),
orgId: z.number(),
}),
z.object({
scope: z.literal("org"),
userId: z.number(),
orgId: z.number(),
}),
z.object({
scope: z.literal("team"),
userId: z.number(),
orgId: z.number(),
teamId: z.number(),
}),
]);
export type InsightsRoutingServiceOptions = z.infer<typeof insightsRoutingServiceOptionsSchema>;
export type InsightsRoutingServiceFilterOptions = {
// Empty for now
};
const NOTHING = {
id: -1,
} as const;
export class InsightsRoutingService {
private prisma: typeof readonlyPrisma;
private options: InsightsRoutingServiceOptions | null;
private filters?: InsightsRoutingServiceFilterOptions;
private cachedAuthConditions?: Prisma.RoutingFormResponseDenormalizedWhereInput;
private cachedFilterConditions?: Prisma.RoutingFormResponseDenormalizedWhereInput | null;
constructor({
prisma,
options,
filters,
}: {
prisma: typeof readonlyPrisma;
options: InsightsRoutingServiceOptions;
filters?: InsightsRoutingServiceFilterOptions;
}) {
this.prisma = prisma;
const validation = insightsRoutingServiceOptionsSchema.safeParse(options);
this.options = validation.success ? validation.data : null;
this.filters = filters;
}
async findMany(findManyArgs: Prisma.RoutingFormResponseDenormalizedFindManyArgs) {
const authConditions = await this.getAuthorizationConditions();
const filterConditions = await this.getFilterConditions();
return this.prisma.routingFormResponseDenormalized.findMany({
...findManyArgs,
where: {
...findManyArgs.where,
AND: [authConditions, filterConditions].filter(
(c): c is Prisma.RoutingFormResponseDenormalizedWhereInput => c !== null && c !== undefined
),
},
});
}
async getAuthorizationConditions(): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput> {
if (this.cachedAuthConditions === undefined) {
this.cachedAuthConditions = await this.buildAuthorizationConditions();
}
return this.cachedAuthConditions;
}
async getFilterConditions(): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput | null> {
if (this.cachedFilterConditions === undefined) {
this.cachedFilterConditions = await this.buildFilterConditions();
}
return this.cachedFilterConditions;
}
async buildFilterConditions(): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput | null> {
// Empty for now
return null;
}
async buildAuthorizationConditions(): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput> {
if (!this.options) {
return NOTHING;
}
const isOwnerOrAdmin = await this.isOrgOwnerOrAdmin(this.options.userId, this.options.orgId);
if (!isOwnerOrAdmin) {
return NOTHING;
}
const conditions: Prisma.RoutingFormResponseDenormalizedWhereInput[] = [];
if (this.options.scope === "user") {
conditions.push({
formUserId: this.options.userId,
});
} else if (this.options.scope === "org") {
conditions.push(await this.buildOrgAuthorizationCondition(this.options));
} else if (this.options.scope === "team") {
conditions.push(await this.buildTeamAuthorizationCondition(this.options));
} else {
return NOTHING;
}
return {
AND: conditions,
};
}
private async buildOrgAuthorizationCondition(
options: Extract<InsightsRoutingServiceOptions, { scope: "org" }>
): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput> {
// Get all teams from the organization
const teamsFromOrg = await TeamRepository.findAllByParentId({
parentId: options.orgId,
select: { id: true },
});
return {
formTeamId: {
in: [options.orgId, ...teamsFromOrg.map((t) => t.id)],
},
};
}
private async buildTeamAuthorizationCondition(
options: Extract<InsightsRoutingServiceOptions, { scope: "team" }>
): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput> {
const childTeamOfOrg = await TeamRepository.findByIdAndParentId({
id: options.teamId,
parentId: options.orgId,
select: { id: true },
});
if (!childTeamOfOrg) {
return NOTHING;
}
return {
formTeamId: options.teamId,
};
}
private async isOrgOwnerOrAdmin(userId: number, orgId: number): Promise<boolean> {
// Check if the user is an owner or admin of the organization
const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId, teamId: orgId });
return Boolean(
membership &&
membership.accepted &&
membership.role &&
(membership.role === MembershipRole.OWNER || membership.role === MembershipRole.ADMIN)
);
}
}
@@ -418,7 +418,7 @@ export const assignValueToUserInOrgBulk = async ({
attributeLabelToValueMap: AttributeLabelToValueMap;
updater: BulkAttributeAssigner;
}) => {
const membership = await MembershipRepository.findFirstByUserIdAndTeamId({ userId, teamId: orgId });
const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId, teamId: orgId });
const defaultReturn = { numOfAttributeOptionsSet: 0, numOfAttributeOptionsDeleted: 0 };
if (!membership) {
console.error(`User ${userId} not a member of org ${orgId}, not assigning attribute options`);
+13 -3
View File
@@ -1,6 +1,14 @@
## 1.0.114
### Patch Changes
- [#22148](https://github.com/calcom/cal.com/pull/22148) [`d27490e`](https://github.com/calcom/cal.com/commit/d27490e6f2438d353dfcf3e793c8886e723307f3) Thanks [@supalarry](https://github.com/supalarry)! - test release
## 1.0.63
1. 💥 BREAKING - `useGetBooking` hook has been renamed to `useBooking` hook and `useGetBookings` hook to `useBookings` and the data returned has different
structure. Here is example response from `useBooking`:
structure. Here is example response from `useBooking`:
```
{
"id": 73,
@@ -47,9 +55,11 @@ structure. Here is example response from `useBooking`:
}
}
```
It is important to note that `useBooking` can return one booking, one individual recurrence of recurring booking or if id of recurring booking is passed then array
of all recurrences within recurring booking. Response schema can be viewed in api docs https://cal.com/docs/api-reference/v2/bookings/get-a-booking and switching
between data response objects in the dropdown. This means that in your frontend you need to handle case if its an individual object or an array of bookings:
```
const { isLoading, data: booking, refetch } = useBooking((router.query.bookingUid as string) ?? "");
@@ -63,5 +73,5 @@ if (!Array.isArray(booking)) {
```
2. FIX - phone booking field not showing correctly https://linear.app/calcom/issue/CAL-4465/platform-fix-phone-booking-field
3. FEAT - ability to hide booker event type details sidebar https://linear.app/calcom/issue/CAL-4443/platform-feat-booker-hide-event-details
4. FEAT - make booker name and email booker properties read only if specified https://linear.app/calcom/issue/CAL-4441/platform-feat-make-name-and-email-fields-not-editable
3. FEAT - ability to hide booker event type details sidebar https://linear.app/calcom/issue/CAL-4443/platform-feat-booker-hide-event-details
4. FEAT - make booker name and email booker properties read only if specified https://linear.app/calcom/issue/CAL-4441/platform-feat-make-name-and-email-fields-not-editable
+3 -3
View File
@@ -1,9 +1,9 @@
Customizable UI components to integrate scheduling into your services.
### Support
Currently supports React 18 and Next 14 while Next 15 is experimental.
Currently supports React 18, React 19, Next 14 and Next 15.
### Changelog
### Changelog
1. Changelog can be viewed [here](https://github.com/calcom/cal.com/blob/main/packages/platform/atoms/CHANGELOG.md).
2. For upcoming changes in the next release click [here](https://github.com/calcom/cal.com/pulls?q=is%3Apr+is%3Aopen+%22chore%3A+version+packages%22+in%3Atitle) to see a pull request titled `chore: version packages` containing next release changes.
@@ -13,6 +13,6 @@ Currently supports React 18 and Next 14 while Next 15 is experimental.
3. Some of the versions are suffixed e.g. `1.0.102-framer` and are intended for isolated use cases, so you most probably
want to use version without any suffix e.g. `1.0.103`.
### Documentation
### Documentation
Documentation on how to get started with platform solution is [here](https://cal.com/docs/platform/quickstart) and list of atoms can be viewed
[here](https://cal.com/docs/platform/atoms/cal-provider)
@@ -30,6 +30,18 @@ const EventAvailabilityTabWebWrapper = (props: EventAvailabilityTabWebWrapperPro
formMethods,
});
// Check if team has restriction schedule feature enabled
const { data: isRestrictionScheduleEnabled = false } = trpc.viewer.features.checkTeamFeature.useQuery(
{
teamId: props.eventType.team?.id || 0,
feature: "restriction-schedule",
},
{
enabled: !!props.eventType.team?.id,
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
}
);
const { isPending: isSchedulePending, data: scheduleQueryData } =
trpc.viewer.availability.schedule.get.useQuery(
{
@@ -66,6 +78,7 @@ const EventAvailabilityTabWebWrapper = (props: EventAvailabilityTabWebWrapperPro
editAvailabilityRedirectUrl={`/availability/${scheduleQueryData?.id}`}
restrictionScheduleRedirectUrl={`/availability/${restrictionScheduleQueryData?.id}`}
hostSchedulesQuery={hostSchedulesQuery}
isRestrictionScheduleEnabled={isRestrictionScheduleEnabled}
/>
);
};
+4 -7
View File
@@ -4,11 +4,11 @@
"type": "module",
"description": "Customizable UI components to integrate scheduling into your product.",
"authors": "Cal.com, Inc.",
"version": "1.0.105",
"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=16384' 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": "yarn build && npm publish --access public",
"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"
},
"devDependencies": {
@@ -37,11 +37,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 +81,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
View File
@@ -3,6 +3,7 @@
"version": "0.0.0",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"private": true,
"scripts": {
"build": "tsc --build --force tsconfig.json",
"build:watch": "tsc --build --force ./tsconfig.json --watch",
+1
View File
@@ -3,6 +3,7 @@
"version": "0.0.0",
"main": "./dist/index.ts",
"types": "./dist/index.ts",
"private": true,
"exports": {
".": {
"require": "./dist/index.js",
+1
View File
@@ -3,6 +3,7 @@
"version": "0.0.0",
"main": "./dist/index.js",
"types": "./dist/index.js",
"private": true,
"scripts": {
"build": "yarn clean && tsc --build --force tsconfig.json",
"clean": "rm -rf ./dist",
+1
View File
@@ -3,6 +3,7 @@
"version": "0.0.0",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"private": true,
"scripts": {
"test": "jest ./tests",
"build": "tsc --build --force tsconfig.json",
Binary file not shown.
+58 -26
View File
@@ -14,36 +14,25 @@ const handleSendingSMS = async ({
senderID,
teamId,
bookingUid,
organizerUserId,
}: {
reminderPhone: string;
smsMessage: string;
senderID: string;
teamId: number;
teamId?: number;
bookingUid?: string | null;
organizerUserId?: number;
}) => {
const team = await prisma.team.findUnique({
where: { id: teamId },
select: {
parent: {
select: {
isOrganization: true,
organizationSettings: {
select: {
disablePhoneOnlySMSNotifications: true,
},
},
},
},
},
});
if (!team?.parent?.isOrganization || team?.parent?.organizationSettings?.disablePhoneOnlySMSNotifications) {
return; // resolves implicitly (as undefined)
}
try {
// If teamId is provided, we check the rate limit for the team.
// If organizerUserId is provided, we check the rate limit for the organizer.
// If neither is provided(Just in case), we check the rate limit for the reminderPhone.
await checkSMSRateLimit({
identifier: `handleSendingSMS:team:${teamId}`,
identifier: teamId
? `handleSendingSMS:team:${teamId}`
: organizerUserId
? `handleSendingSMS:user:${organizerUserId}`
: `handleSendingSMS:user:${reminderPhone}`,
rateLimitingType: "sms",
});
@@ -52,7 +41,7 @@ const handleSendingSMS = async ({
phoneNumber: reminderPhone,
body: smsMessage,
sender: senderID,
teamId,
...(!!teamId ? { teamId } : { userId: organizerUserId }),
bookingUid,
},
});
@@ -64,15 +53,50 @@ const handleSendingSMS = async ({
}
};
const getTeamWithOrganizationSettings = async (teamId: number) => {
return await prisma.team.findUnique({
where: { id: teamId },
select: {
parent: {
select: {
isOrganization: true,
organizationSettings: true,
},
},
},
});
};
export default abstract class SMSManager {
calEvent: CalendarEvent;
isTeamEvent = false;
teamId: number | undefined = undefined;
organizerUserId: number | undefined = undefined;
private _isSMSNotificationEnabled: boolean | null = null;
constructor(calEvent: CalendarEvent) {
this.calEvent = calEvent;
this.teamId = this.calEvent?.team?.id;
this.isTeamEvent = !!this.calEvent?.team?.id;
this.organizerUserId = this.calEvent?.organizer?.id;
}
private async isSMSNotificationEnabled(): Promise<boolean> {
if (this._isSMSNotificationEnabled !== null) {
return this._isSMSNotificationEnabled;
}
const teamId = this.teamId;
if (teamId) {
const team = await getTeamWithOrganizationSettings(teamId);
this._isSMSNotificationEnabled = !team?.parent?.organizationSettings?.disablePhoneOnlySMSNotifications;
return this._isSMSNotificationEnabled;
}
this._isSMSNotificationEnabled = true;
return true;
}
getFormattedTime(
@@ -99,17 +123,25 @@ export default abstract class SMSManager {
const attendeePhoneNumber = attendee.phoneNumber;
const isPhoneOnlyBooking = attendeePhoneNumber && isSmsCalEmail(attendee.email);
if (!this.isTeamEvent || !teamId || !attendeePhoneNumber || !isPhoneOnlyBooking) return;
if (!attendeePhoneNumber || !isPhoneOnlyBooking || !(await this.isSMSNotificationEnabled())) return;
const smsMessage = this.getMessage(attendee);
const senderID = getSenderId(attendeePhoneNumber, SENDER_ID);
return handleSendingSMS({ reminderPhone: attendeePhoneNumber, smsMessage, senderID, teamId, bookingUid });
return handleSendingSMS({
reminderPhone: attendeePhoneNumber,
smsMessage,
senderID,
teamId,
bookingUid,
organizerUserId: this.organizerUserId,
});
}
async sendSMSToAttendees() {
if (!this.isTeamEvent) return;
const smsToSend: Promise<unknown>[] = [];
if (!(await this.isSMSNotificationEnabled())) return;
for (const attendee of this.calEvent.attendees) {
smsToSend.push(this.sendSMSToAttendee(attendee, this.calEvent.uid));
}
+255
View File
@@ -0,0 +1,255 @@
import type { TFunction } from "i18next";
import { describe, expect, test, vi, beforeEach } from "vitest";
import { sendSmsOrFallbackEmail } from "@calcom/features/ee/workflows/lib/reminders/messageDispatcher";
import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError";
import prisma from "@calcom/prisma";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import SMSManager from "../sms-manager";
vi.mock("@calcom/lib/checkRateLimitAndThrowError");
vi.mock("@calcom/features/ee/workflows/lib/reminders/messageDispatcher");
vi.mock("@calcom/prisma", () => ({
default: {
team: {
findUnique: vi.fn(),
},
},
}));
interface TestAttendee extends Person {
name: string;
email: string;
phoneNumber?: string;
timeZone: string;
language: {
translate: TFunction;
locale: string;
};
}
interface TestCalEvent extends CalendarEvent {
uid: string;
type: string;
title: string;
startTime: string;
endTime: string;
attendees: TestAttendee[];
organizer: {
id: number;
name: string;
email: string;
timeZone: string;
language: {
translate: TFunction;
locale: string;
};
};
team?: {
id: number;
name: string;
members: unknown[];
};
}
// Create a concrete implementation of SMSManager for testing
class TestSMSManager extends SMSManager {
getMessage(attendee: TestAttendee): string {
return `Test message for ${attendee.name}`;
}
}
describe("SMSManager", () => {
const mockTranslate = ((key: string) => key) as TFunction;
const mockCalEvent: TestCalEvent = {
uid: "test-booking-uid",
type: "test",
title: "Test Event",
startTime: "2024-03-20T10:00:00Z",
endTime: "2024-03-20T11:00:00Z",
attendees: [
{
name: "John Doe",
email: "john@sms.cal.com",
phoneNumber: "+1234567890",
timeZone: "America/New_York",
language: { translate: mockTranslate, locale: "en" },
},
{
name: "Jane Smith",
email: "jane@example.com",
phoneNumber: "+1987654321",
timeZone: "America/New_York",
language: { translate: mockTranslate, locale: "en" },
},
],
organizer: {
id: 1,
name: "Organizer",
email: "organizer@example.com",
timeZone: "America/New_York",
language: { translate: mockTranslate, locale: "en" },
},
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("sendSMSToAttendee", () => {
test("should not send SMS if phone number is missing", async () => {
const smsManager = new TestSMSManager(mockCalEvent);
const attendeeWithoutPhone: TestAttendee = {
...mockCalEvent.attendees[0],
phoneNumber: undefined,
timeZone: "America/New_York",
language: { translate: mockTranslate, locale: "en" },
};
await smsManager.sendSMSToAttendee(attendeeWithoutPhone);
expect(sendSmsOrFallbackEmail).not.toHaveBeenCalled();
});
test("should not send SMS if email is not @sms.cal.com", async () => {
const smsManager = new TestSMSManager(mockCalEvent);
const attendeeWithRegularEmail: TestAttendee = {
...mockCalEvent.attendees[0],
email: "john@example.com",
phoneNumber: "+1234567890",
timeZone: "America/New_York",
language: { translate: mockTranslate, locale: "en" },
};
await smsManager.sendSMSToAttendee(attendeeWithRegularEmail);
expect(sendSmsOrFallbackEmail).not.toHaveBeenCalled();
});
test("should send SMS only when both phone number and @sms.cal.com email are present", async () => {
const smsManager = new TestSMSManager(mockCalEvent);
const mockSmsResponse = { success: true };
(sendSmsOrFallbackEmail as jest.Mock).mockResolvedValue(mockSmsResponse);
(checkSMSRateLimit as jest.Mock).mockResolvedValue(undefined);
const result = await smsManager.sendSMSToAttendee(mockCalEvent.attendees[0], "test-booking-uid");
expect(checkSMSRateLimit).toHaveBeenCalledWith({
identifier: "handleSendingSMS:user:1",
rateLimitingType: "sms",
});
expect(sendSmsOrFallbackEmail).toHaveBeenCalledWith({
twilioData: {
phoneNumber: mockCalEvent.attendees[0].phoneNumber,
body: expect.stringContaining(mockCalEvent.attendees[0].name),
sender: expect.any(String),
teamId: undefined,
userId: 1,
bookingUid: "test-booking-uid",
},
});
expect(result).toEqual(mockSmsResponse);
});
test("should not send SMS if SMS notifications are disabled for team", async () => {
const mockTeamId = 123;
const mockTeam = {
id: mockTeamId,
name: "Test Team",
members: [],
};
const smsManager = new TestSMSManager({
...mockCalEvent,
team: mockTeam,
});
// Mock team settings to disable SMS
(prisma.team.findUnique as jest.Mock).mockResolvedValue({
parent: {
isOrganization: true,
organizationSettings: {
disablePhoneOnlySMSNotifications: true,
},
},
});
await smsManager.sendSMSToAttendee(mockCalEvent.attendees[0]);
expect(sendSmsOrFallbackEmail).not.toHaveBeenCalled();
});
test("should handle SMS sending errors", async () => {
const smsManager = new TestSMSManager(mockCalEvent);
const mockError = new Error("SMS sending failed");
(sendSmsOrFallbackEmail as jest.Mock).mockRejectedValue(mockError);
(checkSMSRateLimit as jest.Mock).mockResolvedValue(undefined);
await expect(smsManager.sendSMSToAttendee(mockCalEvent.attendees[0])).rejects.toThrow(mockError);
});
});
describe("sendSMSToAttendees", () => {
test("should send SMS only to attendees with phone number and @sms.cal.com email", async () => {
const smsManager = new TestSMSManager(mockCalEvent);
const mockSmsResponse = { success: true };
(sendSmsOrFallbackEmail as jest.Mock).mockResolvedValue(mockSmsResponse);
(checkSMSRateLimit as jest.Mock).mockResolvedValue(undefined);
await smsManager.sendSMSToAttendees();
// Only one attendee has both phone number and @sms.cal.com email
expect(sendSmsOrFallbackEmail).toHaveBeenCalledTimes(1);
});
test("should not send SMS if notifications are disabled", async () => {
const mockTeamId = 123;
const mockTeam = {
id: mockTeamId,
name: "Test Team",
members: [],
};
const smsManager = new TestSMSManager({
...mockCalEvent,
team: mockTeam,
});
(prisma.team.findUnique as jest.Mock).mockResolvedValue({
parent: {
isOrganization: true,
organizationSettings: {
disablePhoneOnlySMSNotifications: true,
},
},
});
await smsManager.sendSMSToAttendees();
expect(sendSmsOrFallbackEmail).not.toHaveBeenCalled();
});
});
describe("getFormattedTime and getFormattedDate", () => {
test("should format time correctly", () => {
const smsManager = new TestSMSManager(mockCalEvent);
const formattedTime = smsManager.getFormattedTime("America/New_York", "en", "2024-03-20T10:00:00Z");
expect(formattedTime).toContain("2024");
expect(formattedTime).toContain("6:00am");
});
test("should format date range correctly", () => {
const smsManager = new TestSMSManager(mockCalEvent);
const formattedDate = smsManager.getFormattedDate("America/New_York", "en");
expect(formattedDate).toContain("2024");
expect(formattedDate).toContain("6:00am");
expect(formattedDate).toContain("7:00am");
});
});
});
@@ -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,
}));
};
@@ -9,6 +9,7 @@ import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billlin
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import { HttpError } from "@calcom/lib/http-error";
import { isBase64Image } from "@calcom/lib/isBase64Image";
import logger from "@calcom/lib/logger";
import { uploadAvatar } from "@calcom/lib/server/avatar";
import { checkUsername } from "@calcom/lib/server/checkUsername";
@@ -152,7 +153,7 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
}
// if defined AND a base 64 string, upload and update the avatar URL
if (input.avatarUrl && input.avatarUrl.startsWith("data:image/png;base64,")) {
if (input.avatarUrl && isBase64Image(input.avatarUrl)) {
data.avatarUrl = await uploadAvatar({
avatar: await resizeBase64Image(input.avatarUrl),
userId: user.id,
@@ -2,6 +2,7 @@ import type { Prisma } from "@prisma/client";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { getMetadataHelpers } from "@calcom/lib/getMetadataHelpers";
import { isBase64Image } from "@calcom/lib/isBase64Image";
import { uploadLogo } from "@calcom/lib/server/avatar";
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
@@ -161,12 +162,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
metadata: mergeMetadata({ ...input.metadata }),
};
if (
input.banner &&
(input.banner.startsWith("data:image/png;base64,") ||
input.banner.startsWith("data:image/jpeg;base64,") ||
input.banner.startsWith("data:image/jpg;base64,"))
) {
if (input.banner && isBase64Image(input.banner)) {
const banner = await resizeBase64Image(input.banner, { maxSize: 1500 });
data.bannerUrl = await uploadLogo({
logo: banner,
@@ -177,12 +173,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
data.bannerUrl = null;
}
if (
input.logoUrl &&
(input.logoUrl.startsWith("data:image/png;base64,") ||
input.logoUrl.startsWith("data:image/jpeg;base64,") ||
input.logoUrl.startsWith("data:image/jpg;base64,"))
) {
if (input.logoUrl && isBase64Image(input.logoUrl)) {
data.logoUrl = await uploadLogo({
logo: await resizeBase64Image(input.logoUrl),
teamId: currentOrgId,

Some files were not shown because too many files have changed in this diff Show More