Compare commits

...
Author SHA1 Message Date
emrysal a90d765fca chore: release v5.5.15
Post release / release (push) Canceled after 0s
2025-08-11 11:29:57 +00:00
af51df2b4d feat: Add private links to API (#22943)
* --init

* address change requests

* adding further changes

* address feedback

* further changes

* further clean-up

* clean up

* fix module import and others

* add guards

* remove unnecessary comments

* remove unnecessary comments

* cleanup

* sort coderabbig suggestions

* improve check

* chore: bump platform libraries

---------

Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
Co-authored-by: supalarry <laurisskraucis@gmail.com>
2025-08-11 11:04:23 +00:00
Ayush Kumar 076e93ca56 fix: Contact support button position changed from absolute to fixed (#23002) 2025-08-11 10:58:03 +00:00
mintlify[bot] 47f45409d2 Documentation edits made through Mintlify web editor (#23007)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2025-08-11 10:35:15 +00:00
Carina WollendorferandCarinaWolli 759d5b8663 fix error in handleNewBooking (#23011)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-08-11 10:33:46 +00:00
Sahitya Chandra c554396572 fix: Remove Reporting page within Routing Forms (#22990) 2025-08-11 11:26:43 +01:00
Benny Joo fafd3fb181 refactor: Get rid of getServerSideProps for /getting-started pages (#23003)
* refactor

* fix type check
2025-08-11 11:22:13 +01:00
Anik Dhabal Babu eb05d547de fix: Booker active booking limit can't be switched off (#23005) 2025-08-11 08:29:41 +00:00
keerthi vardhanandCarina Wollendorfer 1fa8a5ef25 feat: allow inserting webhook variables into custom payload template (#22835)
* feat: allow inserting webhook variables into custom payload template

* localize UI and apply design system styling

* add translations and minor UI fixes

* add translations

---------

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2025-08-11 07:32:52 +00:00
5a350efb40 perf: optimize app registry performance with caching (#22632)
* feat: optimize app registry performance with memory-cache

- Implement in-memory caching for getInstallCountPerApp with 5-minute TTL
- Add memory-cache and @types/memory-cache dependencies to packages/lib
- Remove invalidateInstallCountCache functionality as requested
- Maintain existing API compatibility for app sorting functionality
- Improve performance by avoiding expensive SQL COUNT queries on every request

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* update

* Update getInstallCountPerApp.ts

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-11 04:36:20 +00:00
VijayandAnik Dhabal Babu 242f86891a fix: MSTeams not created as online meetings (#21377)
* fix MSTeams formatting and duplicate events

* update without changing scopes, create onlineMeetings through CalendarService instead of VideoApiAdapter

* get url after online meeting is created required for downstream

* fallback to callvideo if only MSteams and update evt.videoCallData with url

* for backward compatibility set explicitly 'Microsoft Teams Meeting' for old meetings if rescheduled

* add dependency to msTeams App

* nit

* use interpolation

* update to create MSTeams event if only MSTeams installed and not Outlook Calendar

* make function MSTeams specific

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-08-11 08:11:45 +05:30
Lucas ce6969d058 Fix typo in env var name in log message (#22987) 2025-08-09 09:13:24 +00:00
Anirban SinghaandKartik Saini 26f8246b91 fix: text wrap on /conference (#22864)
Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-08-08 13:59:10 -03:00
Joe Au-Yeung 4baa9b6bc1 wrap samlResponse in try/catch (#22984) 2025-08-08 16:06:18 +00:00
Udit TakkarandHariom Balhara 02c86d0268 feat: cal ai self serve architecture #1 (#22919)
* feat: cal ai self serve architecture

* chore: add package

* chore: update evnet controller

* refactor: improvements

* chore: rename

* chore: type error and naming

* chore: just set it to nul

* chore: just set it to nul

* chore: some more improvements

* chore: packate version

* fix: API v2

* chore: change name of files

* chore: add select

* chore: add missing teamId

* chore: save progress

* refactor: split into multiple services

* refactor: make schema provider agonistic

* chore: improvements

* chore:

* chore: remove duplicate files

* chore: semicolon

* chore: formatting

* refactor: logging and error handling

* chore: rename variable

* refactor: use trpc error

* chore: replace with HttpError

* chore: remove from option

* We need the enum and not just the type

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-08-08 15:57:13 +00:00
Anik Dhabal Babu 49f99d69a8 fix: slugify event url in duplicate dialog (#22983)
* fix: flaky e2e

* fix: slugify url
2025-08-08 15:48:58 +00:00
Alex van Andel 659d9cf767 fix: Hotfix missing schema, out of sync with migrations (#22977) 2025-08-08 14:46:58 +01:00
4d662d31a1 feat: filter embed query params from booking success redirects (#22028)
* feat: filter embed query params from booking success redirects

- Add filterEmbedParams option to getNewSearchParams function
- Filter out embed, layout, embedType, and ui.color-scheme params when redirecting to external pages
- Add comprehensive unit tests for bookingSuccessRedirect covering:
  - External redirects with and without parameter forwarding
  - Embed parameter filtering functionality
  - Internal redirects to booking pages
  - Booking parameter extraction
- All tests pass and type checking succeeds

Fixes #20469

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

* chore: retrigger CI checks

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

* chore: remove temporary CI retrigger file

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

* Handle a case

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: hariom@cal.com <hariom@cal.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-08-08 19:12:36 +05:30
6fe6540bc6 fix: apply PBAC to routing form CRUD (#22859)
* fix: apply PBAC to routing form CRUD

* apply permission checks to the UI

* moving prisma call to repository [WIP]

* rename repository and fix type error

* update implementation

* fix formMutation handler

* remove unused import

* revert some rename

* add RolePermission for 'routingForm'

* Revert "revert some rename"

This reverts commit 0ef3114c0d27b821271b36296de531654b37749f.

* clean up PrismaRoutingFormRepository

* fix unit test

* remove no longer necessary code

* fix type definition

* explicit permission handling

* do not disable un-editable routing form

* fix: correct property name from readonly to readOnly in ListLinkItem

- Fix TypeScript error in routing forms component
- Change readonly={readOnly} to readOnly={readOnly} to match component interface
- Resolves type check failure in CI

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-08 13:18:18 +01:00
emrysal ee1904bb2c chore: release v5.5.14
Post release / release (push) Canceled after 0s
2025-08-08 12:11:15 +00:00
Alex van Andelandcoderabbitai[bot] aaa59f1455 chore: Add relation between a host and a membership for future cascades (#22975)
* chore: Add relation between a host and a membership for future cascades

* Apply suggestion

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-08-08 13:06:02 +01:00
Carina WollendorferandCarinaWolli e29b7e83b8 feat: Round Robin groups (#22296)
* add Add Group button

* add host groups to schema

* UI for host groups

* raname groups to hostGroups

* schema update

* show groups in assignment tab

* add no group hosts to Group 1

* add dummy group for non group hosts

* fix type errors

* use two dimensional array for luckyUserPools

* fix empty array

* group RR hosts in handleNewBooking

* improve logic for grouping lucky users

* find all lucky users of all groups

* allow several RR hosts on booking

* clean up migrations

* create helper function

* group hosts for slots logic

* add group logic to loading available slots

* adding hosts to groups

* add groupId to hostSchema

* disable hosts from other groups

* handle groups in checkedTeamSelect

* fix adding hosts to groups

* remove and add groups

* show hosts if there are no groups

* fixing adding first group with existing hosts

* show groups empty groups correctly

* UI upddate fixes

* fix adding hosts to existing first host group

* small fixes + code clean up

* add availability fix with test

* create new round-robin test file

* disable reassignment

* fix losing fixed hosts

* fix updating weights and priorities

* disable load balancing with Round Robin Groups

* automatically disable load balancing in update handler

* allRRHosts should only include hosts from same group

* fix type errors

* fix type error

* fix tests

* fix type error

* remove undefined from groupId type

* type changes

* add tests for hostGroups

* add tests for host groups

* fixes

* fix type errors with undefined groupId

* remove seperate host groups prop

* fix editing weights

* remove console.log

* code clean up

* improve getAggregatedAvailability tests

* throw error when no available hosts in a group

* add fixme comment

* create constant for DEFAULT_GROUP_ID

* clean up code

* mock default_group_id for unit tests

* don't show fixed hosts in edit weights side bar

* add DEFAULT_GROUP_ID to  mock test-setup

* remove unused index variable

* code clean up

* fix updating host groups

* fix imports

* add default_group_id to mocks

* add uuid() to zod schema

* remove unused code

* fix singular translation key

* remove unnessary !!

* Revert formatting changes

* add additional tests for bookingActions

* use createMany

* import DEFAULT_GROUP_ID for mocks

* fix mocks

* clean up EventTeamAssignmentTab

* fix type errors in tests

* fix mocks

* remove constants.example.test.ts

* fix type error

* add missing groupId

* fix margin

* clean up empty host groups

* fix constants mock

* useCalback

* use reduce

* extract handlers into seperate functions

* fix handler functions

* fix border radius

* fix type error in CheckForEmptyAssignment

* fix type error

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-08-08 12:56:13 +01:00
6af473a486 fix: prevent makeBodyVisible timeout from running after test teardown (#22972)
* fix: prevent makeBodyVisible timeout from running after test teardown

- Add timeout tracking to makeBodyVisible function to enable cleanup
- Add vi.clearAllTimers() to test teardown to clear recursive timeouts
- Prevents 'ReferenceError: document is not defined' in CI tests

Fixes intermittent test failures where makeBodyVisible recursive timeout
continues running after test environment is torn down.

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* simplify: use only vi.clearAllTimers() to fix timeout issue

Based on code review feedback, test if vi.clearAllTimers() alone
is sufficient without modifying makeBodyVisible function.

- Reverted makeBodyVisible function changes
- Kept only vi.clearAllTimers() in test teardown
- Verified with multiple test runs and type checking

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-08 08:07:08 -03:00
Joe Au-YeungandHariom Balhara bf8f966811 chore: Add logging to SAML endpoints (#22968)
* Add logging to SAML endpoints

* Update packages/features/auth/lib/next-auth-options.ts

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-08-08 11:50:46 +01:00
Anik Dhabal Babu c64b663514 chore: Add zoom waiting room setting (#22888)
* chore: zoom waiting room setting

* Update VideoApiAdapter.ts

* Update VideoApiAdapter.ts
2025-08-08 10:37:36 +00:00
29314cde75 refactor: convert getBusyTimes to service class with dependency injection (#22949)
* refactor: convert getBusyTimes to service class with dependency injection

- Create BusyTimesService following UserAvailabilityService pattern
- Add DI tokens, module, and container setup for BusyTimesService
- Update all usage locations to use service instead of direct function calls
- Maintain existing function signatures for backward compatibility
- Add legacy exports to ensure smooth transition
- Update type references in trpc util to use service prototype
- Fix linting issues by making legacy exports async with proper imports
- All tests pass and type checking succeeds

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

* refactor: busy time service

* fix: api v2 eslint plugins version mismatch

* chore: bump platform libs

* chore: bump platform libs

* fix: missing di busyTimesModule in slots service

---------

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-08-08 12:14:08 +03:00
3ea73e75d6 feat: add Workflow resource to PBAC system with permission enforcement (#22845)
Co-authored-by: sean@cal.com <Sean@brydon.io>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-08-08 08:48:18 +00:00
Lauris Skraucis d18e23378b feat: toggle round robin org and team info in booker (#22956)
* feat: toggle round robin booker org and team

* chore: add changeset

* dont hide in dev
2025-08-08 13:55:53 +05:30
Sahitya ChandraandAnik Dhabal Babu 736f1f594c fix: add strict URL validation with consistent error message (#22843)
* added strict url validation with consistent Invalid URL error message

* chore

* Update schema.ts

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-08-08 06:48:28 +00:00
09b1643478 chore: added headless routing link to embed (#22921)
* added headless routing link to embed

* Update apps/web/public/static/locales/en/common.json

* Update apps/web/public/static/locales/en/common.json

* fix: add missing headless embed type handlers to resolve TypeScript errors

- Add headless handlers for react, react-atom, and HTML frameworks in EmbedCodes.tsx
- Add headless case handling in EmbedTabs.tsx getEmbedTypeSpecificString function
- Add embedCalOrigin parameter to HTML framework handlers for type consistency
- All handlers return documentation comments directing users to headless routing docs

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* temp: enable IS_CALCOM for localhost testing

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: change headless handlers to return null instead of documentation code

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* revert: remove temporary IS_CALCOM localhost change

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* refactor: remove unnecessary parameter destructuring from headless embed handlers

- Headless handlers now take no parameters since they only return null
- Simplified function signatures to avoid unused parameter warnings
- Maintains existing functionality while improving code clarity

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: hariom@cal.com <hariombalhara@gmail.com>
2025-08-08 06:18:59 +00:00
86f6e8bac3 Fix: Update BTCPay Server currencies and fix dark mode bug (#22887)
* Update BTCPay Server currencies

* fix type

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-08-08 05:50:14 +00:00
Sahitya Chandra 9c65d02e84 docs: refactor the v2 api README file (#22896)
* doc: fixed the V2 api README file

* chore
2025-08-08 05:49:27 +00:00
Romit be9ab02a7e fix: ICS file on workflows where the invite is added wont display the link of the integration (#22964)
* fix

* fix

* chore
2025-08-08 10:45:14 +05:30
Joe Au-Yeung cfefba6d4d Revert "perf: use repository for webhooks list query & caching in /settings/developer/webhooks/... RSC (#21781)" (#22963) 2025-08-07 11:55:13 -07:00
Joe Au-Yeung 815e13303b Compare membership with teamId (#22957) 2025-08-07 15:07:21 +00:00
Bandhan Majumder 7c8dbb3ea3 fix: contact support button overlap (#22952)
* add: lib folder in tailwind config

* move plainChat and contactForm from lib to components

* Update tailwind-preset.js
2025-08-07 14:29:21 +00:00
Alex van Andel cabd459682 chore: slots time range validation to zod (#22942)
* chore: Move date range validation to zod, removing TRPCError

* This is not a BAD_REQUEST but an internal error, should not happen

* Add a check to ensure the given start time is before end time
2025-08-07 15:46:34 +03:00
Somay ChauhanandRajiv Sahal 33aedbae20 fix: booker embed (#22898)
* fix: make organizationId optional number type in BookerEmbed

* feat: dont call useMe for embeds

* useOAuthClient hook should re-run when url is set

* feat: Remove `?orgId=0` from /public event query params

---------

Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
2025-08-07 10:29:22 +02:00
Somay ChauhanandRajiv Sahal d3fbc73a39 feat: add callback for handleFormSubmit in EventType and AvailabilitySettings atoms (#22911)
* feat: Implement callback pattern in handleFormSubmit of EventTypePlatformWrapper

* feat: Implement callback pattern in handleFormSubmit of AvailabilitySettingsPlatformWrapper

* docs: add handleFormSubmit callback documentation for EventType and AvailabilitySettings atoms

* Update availability.tsx

* chore:added changelog

* update docs

---------

Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
2025-08-07 10:26:09 +02:00
cfda4757e4 refactor: move getTotalBookingDuration to BookingRepository (#22931)
* refactor: move getTotalBookingDuration to BookingRepository

- Move getTotalBookingDuration function from standalone file to BookingRepository class
- Update all usage sites to call method through repository instance
- Remove standalone function file packages/lib/server/queries/booking/index.ts
- Add prisma import to util.ts for BookingRepository instantiation
- Maintain exact same method signature and functionality

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

* fix: remove eslint-config-next to resolve TypeScript ESLint conflicts

- Remove eslint-config-next dependency that was causing version conflicts
- Resolves 'Class extends value undefined is not a constructor or null' errors
- ESLint 'next' config issue appears to be pre-existing in main branch

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

* fixup! Merge branch 'main' into devin/move-getTotalBookingDuration-1754460208

* chore: bump platform libs

---------

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-08-07 10:53:18 +03:00
Anirban Singha 895ed48bd6 feat: rename cal.ai to transcribe (#22940)
* feat: rename cal.ai to transcribe

Signed-off-by: Anirban Singha <anirbansingha20@gmail.com>

* update

Signed-off-by: Anirban Singha <anirbansingha20@gmail.com>

---------

Signed-off-by: Anirban Singha <anirbansingha20@gmail.com>
2025-08-07 04:11:46 +00:00
Yadong (Adam) Zhang eda328d196 chore: update ESLint configuration (#22924) 2025-08-06 17:41:02 +00:00
Lee Salminen 1c501d1501 BTCPayServer App - fixes #22928 (#22933) 2025-08-06 16:48:41 +00:00
PallavandKartik Saini 08a57f32c5 fix: persist team filter across tab switches (#22892)
* fix/persist team filter across tab switches

* fix failing ts errors

* tackled failing ts checks

* fix prop type consistency

* removed unecessary props

* tackled in one file

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-08-06 22:14:31 +05:30
Sahitya Chandra b5a26bc227 fix: fixed the estonian languageon confirmation page (#22936) 2025-08-06 22:10:00 +05:30
Peer Richelsen 737b019199 hotfix for plain support (#22937) 2025-08-06 17:25:33 +01:00
Alex van Andel b41031626f chore: when using pii for rate limit (email, ip) hash (#22922) 2025-08-06 13:54:57 +01:00
Benny Joo a1fc4bccb9 fix: OG image error from custom fonts not being supported (#22916) 2025-08-06 17:19:15 +05:30
Benny Joo d764b6a32d chore: increase i18n fetch timeout to 30s for dev mode (#22912) 2025-08-06 09:56:27 +02:00
emrysal 87cb8b170a chore: release v5.5.13
Post release / release (push) Canceled after 0s
2025-08-06 07:33:08 +00:00
Omar LópezandDevin AI badd0de33c fix: resolve circular dependency in getUserAvailability by extracting getPeriodStartDatesBetween utility (#22913)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-06 06:10:38 +00:00
Benny Joo 124fd8ebca fix: OG image error from missing height field (#22914) 2025-08-06 14:12:28 +09:00
emrysal 5d0dc3f546 chore: release v5.5.12
Post release / release (push) Canceled after 0s
2025-08-06 01:00:26 +00:00
MorganandAlex van Andel e4baf15b53 feat: Sync timezone for users having delegation credentials for google/outlook (#22904)
* feat: sync timezone with google/outlook for delegated credentials users

* chore: dynamic sync timezone in get availble slots

* redis cache for get delegated timezone

* Update packages/lib/getUserAvailability.ts

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-08-06 01:55:47 +01:00
Alex van Andel a1bd8b6259 fix: invalid workflow (apiv2 e2e yaml) preventing checks running 2025-08-06 01:17:27 +01:00
Eunjae Lee 7987c9f45d revert: eslint-config-next to v13.2.1 (#22903) 2025-08-06 00:29:55 +01:00
Andreas Thomas 781d0d92df perf: upgrade unkey to v2 (#22902)
Unkey's v2 api runs on AWS (including us-east-2, where cal runs) and has
significantly lower latency than v1.
2025-08-05 10:49:51 -07:00
Alex van Andelandcal.com b71d8baccc chore: Implement short-lived redis cache for slots (#22787)
* chore: Implement short-lived redis cache for slots

* chore: adapt apiv2 redis service to match with upstash redis

* chore: safer redis service and ms ttl

* fixup! chore: safer redis service and ms ttl

* Wrap with timeout, currently doesn't work yet

* Updated @upstash/redis for better signal support

* Fix type errors, remove ts value

* Inject NoopRedisService for NODE_ENV test

* chore: bump platform libs

* chore: bump platform libs

* Upstash Redis upgrade no longer resulted in expected hard crash on init, so updated factory and our Upstash Redis Adapter to mimick old behaviour

* Add SLOTS_CACHE_TTL variable for configurable ttl on slots cache

* Update parseInt to use right types

* chore: bump platform libs

* chore: bump platform libs

* chore: bump platform libs

* update e2e api v2 action

* set SLOTS_CACHE_TTL env var api v2 e2e

---------

Co-authored-by: cal.com <morgan@cal.com>
2025-08-05 17:32:02 +00:00
a71d949a0c feat: add cron job to cleanup old queued form responses (#22035)
* feat: add cron job to cleanup old queued form responses

- Add cleanup cron job for App_RoutingForms_QueuedFormResponse table
- Delete records with null actualResponseId older than 1 hour
- Schedule to run twice daily (every 12 hours) via vercel.json
- Follow existing cron job patterns for authentication and structure

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

* fix: correct date comparison logic to delete older records

- Change from lte to gte in createdAt comparison
- Now properly deletes records older than 1 hour

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

* refactor: move cleanup logic to routing forms lib and import in API endpoint

- Create cleanupExpiredQueuedFormResponses function in routing forms lib
- Update API endpoint to import and use the new cleanup function
- Follow established patterns for code organization in Cal.com
- Maintain same functionality and authentication logic

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

* refactor: move entire cron logic to routing-forms cron folder

- Create routing-forms/cron/queuedFormResponseCleanup.ts with complete handler
- Move authentication, HTTP handling, and database operations to routing-forms
- Update API endpoint to simply import and call the cron handler
- Remove old lib cleanup function
- Follow user's preferred organizational pattern

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

* fix: correct date comparison logic to delete records older than 1 hour

- Change from gte (greater than or equal) to lt (less than) cutoffTime
- Now properly deletes records where createdAt < cutoffTime (older than 1 hour)
- Previous logic was deleting newer records instead of older ones

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

* fixes

* refactor: reorganize queuedFormResponse into domain-driven structure

- Move repository and service files to routingForm/queuedFormResponse subdirectory
- Clean up unused methods and tests for better maintainability
- Fix import paths and type definitions
- Ensure all tests pass and maintain backward compatibility

* fix: align QueuedFormResponse interface types with Prisma schema

- Update actualResponseId type from string to number to match Prisma Int type
- Make updatedAt nullable (Date | null) to match Prisma DateTime? type
- Fix import path for QueuedFormResponseRepository interface

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* refactor: reorganize queuedFormResponse into domain-driven structure

- Move repository and service files to routingForm/queuedFormResponse subdirectory
- Clean up unused methods and tests for better maintainability
- Fix import paths and type definitions
- Ensure all tests pass and maintain backward compatibility

* test: add comprehensive where clause validation tests and missing test coverage

* chore: update cron schedule to weekly for 7-day retention period

* revert: restore 12-hour cron schedule for better batch management

* Add integration tests

* refactor: apply test data builder pattern to queuedFormResponse integration tests

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: hariom@cal.com <hariom@cal.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
2025-08-05 22:17:50 +05:30
emrysal e55cee443b chore: release v5.5.11
Post release / release (push) Canceled after 0s
2025-08-05 12:20:27 +00:00
MorganandAlex van Andel c3634b3aba refactor: getUserAvailability into service with DI (#22881)
* refactor: getUserAvailability into service with DI

* chore: bump platform libs

* disable bull queue in e2e for bookings

* chore: bump platform libs

* chore: bump platform libs

* fix: should update event type bookingFields test

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-08-05 13:13:42 +01:00
Eunjae LeeandDevin AI cd576f5378 refactor: use dependency injection for InsightsBookingService (#22790)
* refactor: use dependency injection for InsightsBookingService

- Add DI tokens for InsightsBookingService and module
- Rename InsightsBookingService to InsightsBookingBaseService
- Create InsightsBookingService DI interface with create method
- Add DI module and container for InsightsBookingService
- Update tRPC router to use getInsightsBookingService DI container
- Update test file to use InsightsBookingBaseService
- Update documentation to reflect new DI pattern

Follows the same dependency injection pattern established for InsightsRoutingService in PR #22677

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: restore createInsightsBookingService helper using DI internally

- Keep createInsightsBookingService helper function for cleaner API
- Use getInsightsBookingService DI container internally
- Maintain same function signature and behavior
- All existing calls continue to work unchanged

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: rename service files with proper capitalization

- Rename insightsBookingBase.ts to InsightsBookingBaseService.ts
- Rename insightsBookingDI.ts to InsightsBookingDIService.ts
- Update all import statements to use new file names
- Maintain existing handler functionality using createInsightsBookingService(ctx, input)

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* revert some changes

* rename

* update doc

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-05 11:07:07 +02:00
325 changed files with 18998 additions and 2622 deletions
+9
View File
@@ -0,0 +1,9 @@
---
"@calcom/atoms": minor
---
Added new callback functions to the handleFormSubmit method in the EventTypeSettings and AvailabilitySettings atoms. The handleFormSubmit method now accepts an optional callbacks object with the following properties:
- **onSuccess**: Called when the form submission is successful, allowing additional logic to be executed after the update.
- **onError**: Called when an error occurs during form submission, providing details about the error to handle specific cases or display custom messages.
+5
View File
@@ -0,0 +1,5 @@
---
"@calcom/atoms": minor
---
booker atom: allow toggling org and team info when booking round robin
+5 -1
View File
@@ -1 +1,5 @@
module.exports = require("./packages/config/eslint-preset");
// This configuration only applies to the package manager root.
/** @type {import("eslint").Linter.Config} */
module.exports = {
extends: ["./packages/config/eslint-preset.js"],
};
+1
View File
@@ -22,6 +22,7 @@ env:
STRIPE_API_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
SLOTS_CACHE_TTL: ${{ secrets.CI_SLOTS_CACHE_TTL }}
jobs:
e2e:
timeout-minutes: 20
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+4 -3
View File
@@ -1,7 +1,7 @@
import type { NextApiRequest } from "next";
import { z } from "zod";
import { getUserAvailability } from "@calcom/lib/getUserAvailability";
import { getUserAvailabilityService } from "@calcom/lib/di/containers/get-user-availability";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
@@ -191,8 +191,9 @@ const availabilitySchema = z
async function handler(req: NextApiRequest) {
const { isSystemWideAdmin, userId: reqUserId } = req;
const { username, userId, eventTypeId, dateTo, dateFrom, teamId } = availabilitySchema.parse(req.query);
const userAvailabilityService = getUserAvailabilityService();
if (!teamId)
return getUserAvailability({
return userAvailabilityService.getUserAvailability({
username,
dateFrom,
dateTo,
@@ -230,7 +231,7 @@ async function handler(req: NextApiRequest) {
const availabilities = members.map(async (user) => {
return {
userId: user.id,
availability: await getUserAvailability({
availability: await userAvailabilityService.getUserAvailability({
userId: user.id,
dateFrom,
dateTo,
+6 -4
View File
@@ -42,15 +42,17 @@ $ yarn prisma generate
Copy `.env.example` to `.env` and fill values.
## Add license Key to deployments table in DB
## Add license Key to Deployment table in DB
id, logo theme licenseKey agreedLicenseAt
1, null, null, 'c4234812-12ab-42s6-a1e3-55bedd4a5bb7', '2023-05-15 21:39:47.611'
id, logo, theme, licenseKey, agreedLicenseAt:-
1, null, null, '00000000-0000-0000-0000-000000000000', '2023-05-15 21:39:47.611'
Replace with your actual license key.
your CALCOM_LICENSE_KEY env var need to contain the same value
.env
CALCOM_LICENSE_KEY=c4234812-12ab-42s6-a1e3-55bedd4a5bb
CALCOM_LICENSE_KEY=00000000-0000-0000-0000-000000000000
## Running the app
+3 -1
View File
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.276",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.294",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
@@ -98,6 +98,8 @@
"@types/luxon": "^3.3.7",
"@types/passport-jwt": "^3.0.13",
"@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^6",
"@typescript-eslint/parser": "^6",
"jest": "^29.7.0",
"jest-date-mock": "^1.0.10",
"node-mocks-http": "^1.16.2",
@@ -104,10 +104,11 @@ export class OutputBookingsService_2024_08_13 {
const rescheduledToInfo = databaseBooking.rescheduled
? await this.getRescheduledToInfo(databaseBooking.uid)
: undefined;
const rescheduledToUid = rescheduledToInfo?.uid;
const rescheduledByEmail = databaseBooking.rescheduled ? rescheduledToInfo?.rescheduledBy : databaseBooking.rescheduledBy;
const rescheduledToUid = rescheduledToInfo?.uid;
const rescheduledByEmail = databaseBooking.rescheduled
? rescheduledToInfo?.rescheduledBy
: databaseBooking.rescheduledBy;
const booking = {
id: databaseBooking.id,
@@ -157,13 +158,12 @@ export class OutputBookingsService_2024_08_13 {
async getRescheduledToInfo(bookingUid: string): Promise<{ uid?: string; rescheduledBy?: string | null }> {
const rescheduledTo = await this.bookingsRepository.getByFromReschedule(bookingUid);
return {
uid: rescheduledTo?.uid,
rescheduledBy: rescheduledTo?.rescheduledBy
return {
uid: rescheduledTo?.uid,
rescheduledBy: rescheduledTo?.rescheduledBy,
};
}
getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) {
if (databaseMetadata === null) return {};
@@ -279,9 +279,11 @@ export class OutputBookingsService_2024_08_13 {
const rescheduledToInfo = databaseBooking.rescheduled
? await this.getRescheduledToInfo(databaseBooking.uid)
: undefined;
const rescheduledToUid = rescheduledToInfo?.uid;
const rescheduledByEmail = databaseBooking.rescheduled ? rescheduledToInfo?.rescheduledBy : databaseBooking.rescheduledBy;
const rescheduledByEmail = databaseBooking.rescheduled
? rescheduledToInfo?.rescheduledBy
: databaseBooking.rescheduledBy;
const booking = {
id: databaseBooking.id,
@@ -0,0 +1,178 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import * as request from "supertest";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { CreatePrivateLinkInput } from "@calcom/platform-types";
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { randomString } from "test/utils/randomString";
describe("Event Types Private Links Endpoints", () => {
let app: INestApplication;
let oAuthClient: any;
let organization: any;
let userRepositoryFixture: UserRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let user: any;
let eventType: any;
const userEmail = `private-links-user-${randomString()}@api.com`;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter],
imports: [AppModule, UsersModule, EventTypesModule_2024_06_14, TokensModule],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({
name: `private-links-organization-${randomString()}`,
slug: `private-links-org-slug-${randomString()}`,
});
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.create({
email: userEmail,
name: `private-links-user-${randomString()}`,
username: `private-links-user-${randomString()}`,
});
// create an event type owned by user
eventType = await eventTypesRepositoryFixture.create(
{
title: `private-links-event-type-${randomString()}`,
slug: `private-links-event-type-${randomString()}`,
length: 30,
locations: [],
},
user.id
);
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["redirect-uri"],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
it("POST /v2/event-types/:eventTypeId/private-links - create private link", async () => {
const body: CreatePrivateLinkInput = {
expiresAt: undefined,
maxUsageCount: 5,
};
const response = await request(app.getHttpServer())
.post(`/api/v2/event-types/${eventType.id}/private-links`)
.set("Authorization", `Bearer whatever`)
.send(body)
.expect(201);
expect(response.body.status).toBe(SUCCESS_STATUS);
expect(response.body.data.linkId).toBeDefined();
expect(response.body.data.maxUsageCount).toBe(5);
expect(response.body.data.usageCount).toBeDefined();
});
it("GET /v2/event-types/:eventTypeId/private-links - list private links", async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types/${eventType.id}/private-links`)
.set("Authorization", `Bearer whatever`)
.expect(200);
expect(response.body.status).toBe(SUCCESS_STATUS);
expect(Array.isArray(response.body.data)).toBe(true);
expect(response.body.data.length).toBeGreaterThanOrEqual(1);
});
it("PATCH /v2/event-types/:eventTypeId/private-links/:linkId - update private link", async () => {
// create a link first
const createResp = await request(app.getHttpServer())
.post(`/api/v2/event-types/${eventType.id}/private-links`)
.set("Authorization", `Bearer whatever`)
.send({ maxUsageCount: 3 })
.expect(201);
const linkId = createResp.body.data.linkId;
const response = await request(app.getHttpServer())
.patch(`/api/v2/event-types/${eventType.id}/private-links/${linkId}`)
.set("Authorization", `Bearer whatever`)
.send({ maxUsageCount: 10 })
.expect(200);
expect(response.body.status).toBe(SUCCESS_STATUS);
expect(response.body.data.maxUsageCount).toBe(10);
});
it("DELETE /v2/event-types/:eventTypeId/private-links/:linkId - delete private link", async () => {
// create a link to delete
const createResp = await request(app.getHttpServer())
.post(`/api/v2/event-types/${eventType.id}/private-links`)
.set("Authorization", `Bearer whatever`)
.send({ maxUsageCount: 2 })
.expect(201);
const linkId = createResp.body.data.linkId;
const response = await request(app.getHttpServer())
.delete(`/api/v2/event-types/${eventType.id}/private-links/${linkId}`)
.set("Authorization", `Bearer whatever`)
.expect(200);
expect(response.body.status).toBe(SUCCESS_STATUS);
expect(response.body.data.linkId).toBe(linkId);
});
afterAll(async () => {
// cleanup created entities
try {
if (eventType?.id) {
const repo = new EventTypesRepositoryFixture((app as any).select(AppModule));
await repo.delete(eventType.id);
}
} catch {}
await app.close();
});
});
@@ -0,0 +1,123 @@
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
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 { EventTypeOwnershipGuard } from "@/modules/event-types/guards/event-type-ownership.guard";
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
UseGuards,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags, OmitType } from "@nestjs/swagger";
import {
EVENT_TYPE_READ,
EVENT_TYPE_WRITE,
SUCCESS_STATUS,
} from "@calcom/platform-constants";
import {
CreatePrivateLinkInput,
CreatePrivateLinkOutput,
DeletePrivateLinkOutput,
GetPrivateLinksOutput,
UpdatePrivateLinkInput,
UpdatePrivateLinkOutput,
} from "@calcom/platform-types";
import { PrivateLinksService } from "../services/private-links.service";
class UpdatePrivateLinkBody extends OmitType(UpdatePrivateLinkInput, ["linkId"] as const) {}
@Controller({
path: "/v2/event-types/:eventTypeId/private-links",
})
@UseGuards(PermissionsGuard)
@DocsTags("Event Types Private Links")
export class EventTypesPrivateLinksController {
constructor(private readonly privateLinksService: PrivateLinksService) {}
@Post("/")
@Permissions([EVENT_TYPE_WRITE])
@UseGuards(ApiAuthGuard, EventTypeOwnershipGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({ summary: "Create a private link for an event type" })
async createPrivateLink(
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Body() body: CreatePrivateLinkInput,
@GetUser("id") userId: number
): Promise<CreatePrivateLinkOutput> {
const privateLink = await this.privateLinksService.createPrivateLink(eventTypeId, userId, body);
return {
status: SUCCESS_STATUS,
data: privateLink,
};
}
@Get("/")
@Permissions([EVENT_TYPE_READ])
@UseGuards(ApiAuthGuard, EventTypeOwnershipGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({ summary: "Get all private links for an event type" })
async getPrivateLinks(
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@GetUser("id") userId: number
): Promise<GetPrivateLinksOutput> {
const privateLinks = await this.privateLinksService.getPrivateLinks(eventTypeId, userId);
return {
status: SUCCESS_STATUS,
data: privateLinks,
};
}
@Patch("/:linkId")
@Permissions([EVENT_TYPE_WRITE])
@UseGuards(ApiAuthGuard, EventTypeOwnershipGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({ summary: "Update a private link for an event type" })
async updatePrivateLink(
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Param("linkId") linkId: string,
@Body() body: UpdatePrivateLinkBody,
@GetUser("id") userId: number
): Promise<UpdatePrivateLinkOutput> {
const updateInput = { ...body, linkId };
const privateLink = await this.privateLinksService.updatePrivateLink(eventTypeId, userId, updateInput);
return {
status: SUCCESS_STATUS,
data: privateLink,
};
}
@Delete("/:linkId")
@Permissions([EVENT_TYPE_WRITE])
@UseGuards(ApiAuthGuard, EventTypeOwnershipGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({ summary: "Delete a private link for an event type" })
async deletePrivateLink(
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Param("linkId") linkId: string,
@GetUser("id") userId: number
): Promise<DeletePrivateLinkOutput> {
await this.privateLinksService.deletePrivateLink(eventTypeId, userId, linkId);
return {
status: SUCCESS_STATUS,
data: {
linkId,
message: "Private link deleted successfully",
},
};
}
}
@@ -0,0 +1,27 @@
import { Module } from "@nestjs/common";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { EventTypeOwnershipGuard } from "@/modules/event-types/guards/event-type-ownership.guard";
import { EventTypesPrivateLinksController } from "./controllers/event-types-private-links.controller";
import { PrivateLinksInputService } from "./services/private-links-input.service";
import { PrivateLinksOutputService } from "./services/private-links-output.service";
import { PrivateLinksService } from "./services/private-links.service";
import { PrivateLinksRepository } from "./private-links.repository";
@Module({
imports: [TokensModule, OAuthClientModule, PrismaModule, EventTypesModule_2024_06_14],
controllers: [EventTypesPrivateLinksController],
providers: [
PrivateLinksService,
PrivateLinksInputService,
PrivateLinksOutputService,
PrivateLinksRepository,
EventTypeOwnershipGuard,
],
})
export class EventTypesPrivateLinksModule {}
@@ -0,0 +1,54 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class PrivateLinksRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async listByEventTypeId(eventTypeId: number) {
return this.dbRead.prisma.hashedLink.findMany({
where: { eventTypeId },
select: { link: true, expiresAt: true, maxUsageCount: true, usageCount: true },
});
}
async findWithEventTypeDetails(linkId: string) {
return this.dbRead.prisma.hashedLink.findUnique({
where: { link: linkId },
select: {
link: true,
expiresAt: true,
maxUsageCount: true,
usageCount: true,
},
});
}
async create(eventTypeId: number, link: { link: string; expiresAt: Date | null; maxUsageCount?: number | null }) {
return this.dbWrite.prisma.hashedLink.create({
data: {
eventTypeId,
link: link.link,
expiresAt: link.expiresAt,
...(typeof link.maxUsageCount === "number" ? { maxUsageCount: link.maxUsageCount } : {}),
},
});
}
async update(eventTypeId: number, link: { link: string; expiresAt: Date | null; maxUsageCount?: number | null }) {
return this.dbWrite.prisma.hashedLink.updateMany({
where: { eventTypeId, link: link.link },
data: {
expiresAt: link.expiresAt,
...(typeof link.maxUsageCount === "number" ? { maxUsageCount: link.maxUsageCount } : {}),
},
});
}
async delete(eventTypeId: number, linkId: string) {
return this.dbWrite.prisma.hashedLink.deleteMany({ where: { eventTypeId, link: linkId } });
}
}
@@ -0,0 +1,34 @@
import { Injectable } from "@nestjs/common";
import { CreatePrivateLinkInput, UpdatePrivateLinkInput } from "@calcom/platform-types";
@Injectable()
export class PrivateLinksInputService {
constructor() {}
transformCreateInput(input: CreatePrivateLinkInput): CreatePrivateLinkInput {
const hasExpires = input.expiresAt !== undefined && input.expiresAt !== null;
const hasMaxCount = typeof input.maxUsageCount === "number";
if (!hasExpires && !hasMaxCount) {
throw new Error("Either expiresAt or maxUsageCount must be provided");
}
if (hasExpires && hasMaxCount) {
throw new Error("Provide only one of expiresAt or maxUsageCount");
}
return {
expiresAt: input.expiresAt,
maxUsageCount: input.maxUsageCount ?? (hasMaxCount ? input.maxUsageCount : undefined),
};
}
transformUpdateInput(input: UpdatePrivateLinkInput): UpdatePrivateLinkInput {
return {
linkId: input.linkId,
expiresAt: input.expiresAt,
maxUsageCount: input.maxUsageCount,
};
}
}
@@ -0,0 +1,43 @@
import { Injectable } from "@nestjs/common";
import { plainToClass } from "class-transformer";
export type PrivateLinkData = {
id: string;
eventTypeId: number;
isExpired: boolean;
bookingUrl: string;
expiresAt?: Date | null;
maxUsageCount?: number | null;
usageCount?: number;
};
import { PrivateLinkOutput, TimeBasedPrivateLinkOutput, UsageBasedPrivateLinkOutput } from "@calcom/platform-types";
@Injectable()
export class PrivateLinksOutputService {
constructor() {}
transformToOutput(data: PrivateLinkData): PrivateLinkOutput {
const baseData = {
linkId: data.id.toString(),
eventTypeId: data.eventTypeId,
isExpired: data.isExpired,
bookingUrl: data.bookingUrl,
};
if (data.expiresAt !== null && data.expiresAt !== undefined) {
return plainToClass(TimeBasedPrivateLinkOutput, { ...baseData, expiresAt: data.expiresAt });
}
return plainToClass(UsageBasedPrivateLinkOutput, {
...baseData,
maxUsageCount: data.maxUsageCount || 0,
usageCount: data.usageCount || 0,
});
}
transformArrayToOutput(data: PrivateLinkData[]): PrivateLinkOutput[] {
return data.map((item) => this.transformToOutput(item));
}
}
@@ -0,0 +1,128 @@
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
import { generateHashedLink, isLinkExpired } from "@calcom/platform-libraries/private-links";
import { CreatePrivateLinkInput, PrivateLinkOutput, UpdatePrivateLinkInput } from "@calcom/platform-types";
import { PrivateLinksInputService } from "@/ee/event-types-private-links/services/private-links-input.service";
import {
PrivateLinksOutputService,
type PrivateLinkData,
} from "@/ee/event-types-private-links/services/private-links-output.service";
import { PrivateLinksRepository } from "@/ee/event-types-private-links/private-links.repository";
@Injectable()
export class PrivateLinksService {
constructor(
private readonly inputService: PrivateLinksInputService,
private readonly outputService: PrivateLinksOutputService,
private readonly repo: PrivateLinksRepository
) {}
async createPrivateLink(
eventTypeId: number,
userId: number,
input: CreatePrivateLinkInput
): Promise<PrivateLinkOutput> {
try {
const transformedInput = this.inputService.transformCreateInput(input);
const created = await this.repo.create(eventTypeId, {
link: generateHashedLink(userId),
expiresAt: transformedInput.expiresAt ?? null,
maxUsageCount: transformedInput.maxUsageCount ?? null,
});
const mapped: PrivateLinkData = {
id: created.link,
eventTypeId,
isExpired: isLinkExpired(created as any),
bookingUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL || "https://cal.com"}/d/${created.link}`,
expiresAt: created.expiresAt ?? null,
maxUsageCount: (created as any).maxUsageCount ?? null,
usageCount: (created as any).usageCount ?? 0,
};
return this.outputService.transformToOutput(mapped);
} catch (error) {
if (error instanceof Error) {
throw new BadRequestException(error.message);
}
throw new BadRequestException("Failed to create private link");
}
}
async getPrivateLinks(eventTypeId: number, userId: number): Promise<PrivateLinkOutput[]> {
try {
const links = await this.repo.listByEventTypeId(eventTypeId);
const mapped: PrivateLinkData[] = links.map((l) => ({
id: l.link,
eventTypeId,
isExpired: isLinkExpired(l as any),
bookingUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL || "https://cal.com"}/d/${l.link}`,
expiresAt: l.expiresAt ?? null,
maxUsageCount: l.maxUsageCount ?? null,
usageCount: l.usageCount ?? 0,
}));
return this.outputService.transformArrayToOutput(mapped);
} catch (error) {
if (error instanceof Error) {
throw new BadRequestException(error.message);
}
throw new BadRequestException("Failed to get private links");
}
}
async updatePrivateLink(
eventTypeId: number,
userId: number,
input: UpdatePrivateLinkInput
): Promise<PrivateLinkOutput> {
try {
const transformedInput = this.inputService.transformUpdateInput(input);
const updatedResult = await this.repo.update(eventTypeId, {
link: transformedInput.linkId,
expiresAt: transformedInput.expiresAt ?? null,
maxUsageCount: transformedInput.maxUsageCount ?? null,
});
if (!updatedResult || (updatedResult as any).count === 0) {
throw new NotFoundException("Updated link not found");
}
const updated = await this.repo.findWithEventTypeDetails(transformedInput.linkId);
if (!updated) throw new NotFoundException("Updated link not found");
const mapped: PrivateLinkData = {
id: updated.link,
eventTypeId,
isExpired: isLinkExpired(updated as any),
bookingUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL || "https://cal.com"}/d/${updated.link}`,
expiresAt: updated.expiresAt ?? null,
maxUsageCount: updated.maxUsageCount ?? null,
usageCount: updated.usageCount ?? 0,
};
return this.outputService.transformToOutput(mapped);
} catch (error) {
if (error instanceof Error) {
if (error.message.includes("not found")) {
throw new NotFoundException(error.message);
}
throw new BadRequestException(error.message);
}
throw new BadRequestException("Failed to update private link");
}
}
async deletePrivateLink(eventTypeId: number, userId: number, linkId: string): Promise<void> {
try {
const { count } = await this.repo.delete(eventTypeId, linkId);
if (count === 0) {
throw new NotFoundException("Deleted link not found");
}
} catch (error) {
if (error instanceof Error) {
if (error.message.includes("not found")) {
throw new NotFoundException(error.message);
}
throw new BadRequestException(error.message);
}
throw new BadRequestException("Failed to delete private link");
}
}
}
@@ -290,7 +290,10 @@ describe("Event types Endpoints", () => {
expect(responseBookingFields).toBeDefined();
// note(Lauris): response bookingFields are already existing default bookingFields + the new one
const responseBookingField = responseBookingFields.find((field) => field.name === bookingFieldName);
expect(responseBookingField).toEqual(bookingFields[0]);
const fields = responseBookingField
//@ts-ignore
delete fields.labelAsSafeHtml
expect(fields).toEqual(bookingFields[0]);
eventType.bookingFields = responseBookingFields;
});
});
@@ -6,6 +6,7 @@ import { UpdateEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2
import { EventTypeResponseTransformPipe } from "@/ee/event-types/event-types_2024_06_14/pipes/event-type-response.transformer";
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
import { VERSION_2024_06_14_VALUE } from "@/lib/api-versions";
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
@@ -41,6 +42,7 @@ import {
GetEventTypesQuery_2024_06_14,
CreateEventTypeInput_2024_06_14,
EventTypeOutput_2024_06_14,
} from "@calcom/platform-types";
@Controller({
@@ -14,6 +14,7 @@ import { SlotsModule_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.m
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
import { TeamsMembershipsModule } from "@/modules/teams/memberships/teams-memberships.module";
import { TeamsModule } from "@/modules/teams/teams/teams.module";
import { EventTypesPrivateLinksModule } from "@/ee/event-types-private-links/event-types-private-links.module";
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
import { Module } from "@nestjs/common";
@@ -35,6 +36,7 @@ import { Module } from "@nestjs/common";
SlotsModule_2024_09_04,
TeamsModule,
RoutingFormsModule,
EventTypesPrivateLinksModule,
],
})
export class PlatformEndpointsModule implements NestModule {
@@ -8,9 +8,12 @@ import { PrismaSelectedSlotRepository } from "@/lib/repositories/prisma-selected
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
import { AvailableSlotsService } from "@/lib/services/available-slots.service";
import { BusyTimesService } from "@/lib/services/busy-times.service";
import { CacheService } from "@/lib/services/cache.service";
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { UserAvailabilityService } from "@/lib/services/user-availability.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisService } from "@/modules/redis/redis.service";
import { Module } from "@nestjs/common";
@Module({
@@ -24,10 +27,13 @@ import { Module } from "@nestjs/common";
PrismaEventTypeRepository,
PrismaRoutingFormResponseRepository,
PrismaTeamRepository,
RedisService,
PrismaFeaturesRepository,
CheckBookingLimitsService,
CacheService,
AvailableSlotsService,
UserAvailabilityService,
BusyTimesService,
],
exports: [AvailableSlotsService],
})
@@ -7,12 +7,16 @@ import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.rep
import { PrismaSelectedSlotRepository } from "@/lib/repositories/prisma-selected-slot.repository";
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
import { BusyTimesService } from "@/lib/services/busy-times.service";
import { CacheService } from "@/lib/services/cache.service";
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable } from "@nestjs/common";
import { AvailableSlotsService as BaseAvailableSlotsService } from "@calcom/platform-libraries/slots";
import { UserAvailabilityService } from "./user-availability.service";
@Injectable()
export class AvailableSlotsService extends BaseAvailableSlotsService {
constructor(
@@ -24,6 +28,7 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
selectedSlotRepository: PrismaSelectedSlotRepository,
eventTypeRepository: PrismaEventTypeRepository,
userRepository: PrismaUserRepository,
redisService: RedisService,
featuresRepository: PrismaFeaturesRepository
) {
super({
@@ -35,8 +40,16 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
selectedSlotRepo: selectedSlotRepository,
eventTypeRepo: eventTypeRepository,
userRepo: userRepository,
checkBookingLimitsService: new CheckBookingLimitsService(bookingRepository) as any,
redisClient: redisService,
checkBookingLimitsService: new CheckBookingLimitsService(bookingRepository),
cacheService: new CacheService(featuresRepository),
userAvailabilityService: new UserAvailabilityService(
oooRepoDependency,
bookingRepository,
eventTypeRepository,
redisService
),
busyTimesService: new BusyTimesService(bookingRepository),
});
}
}
@@ -0,0 +1,13 @@
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
import { Injectable } from "@nestjs/common";
import { BusyTimesService as BaseBusyTimesService } from "@calcom/platform-libraries/slots";
@Injectable()
export class BusyTimesService extends BaseBusyTimesService {
constructor(bookingRepository: PrismaBookingRepository) {
super({
bookingRepo: bookingRepository,
});
}
}
@@ -0,0 +1,24 @@
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable } from "@nestjs/common";
import { UserAvailabilityService as BaseUserAvailabilityService } from "@calcom/platform-libraries/schedules";
@Injectable()
export class UserAvailabilityService extends BaseUserAvailabilityService {
constructor(
oooRepoDependency: PrismaOOORepository,
bookingRepository: PrismaBookingRepository,
eventTypeRepository: PrismaEventTypeRepository,
redisService: RedisService
) {
super({
oooRepo: oooRepoDependency,
bookingRepo: bookingRepository,
eventTypeRepo: eventTypeRepository,
redisClient: redisService,
});
}
}
@@ -403,6 +403,10 @@ export class BillingService implements OnModuleDestroy {
fromReschedule?: string | null;
}
) {
if (this.configService.get("e2e")) {
return true;
}
const { uid, startTime, fromReschedule } = booking;
const delay = startTime.getTime() - Date.now();
@@ -427,6 +431,9 @@ export class BillingService implements OnModuleDestroy {
* Removing an attendee from a booking does not cancel the usage increment job.
*/
async cancelUsageByBookingUid(bookingUid: string) {
if (this.configService.get("e2e")) {
return true;
}
const job = await this.billingQueue.getJob(`increment-${bookingUid}`);
if (job) {
await job.remove();
@@ -458,6 +465,7 @@ export class BillingService implements OnModuleDestroy {
async onModuleDestroy() {
try {
await this.billingQueue.close();
} catch (err) {
this.logger.error(err);
@@ -0,0 +1,44 @@
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import {
BadRequestException,
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { Request } from "express";
@Injectable()
export class EventTypeOwnershipGuard implements CanActivate {
constructor(private readonly eventTypesService: EventTypesService_2024_06_14) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const user = request.user as ApiAuthGuardUser | undefined;
const eventTypeIdParam = request.params?.eventTypeId;
if (!user) {
throw new ForbiddenException("EventTypeOwnershipGuard - No user associated with the request.");
}
if (!eventTypeIdParam) {
throw new BadRequestException("Missing eventTypeId param.");
}
const eventTypeId = Number(eventTypeIdParam);
if (!Number.isInteger(eventTypeId) || eventTypeId <= 0) {
throw new BadRequestException("Invalid eventTypeId param.");
}
const eventType = await this.eventTypesService.getUserEventType(user.id, eventTypeId);
if (!eventType) {
// Mirrors EventTypesService behavior: NotFound when not owned or not present
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
}
return true;
}
}
@@ -7,12 +7,32 @@ import { Redis } from "ioredis";
export class RedisService implements OnModuleDestroy {
public redis: Redis;
private readonly logger = new Logger("RedisService");
private isReady = false; // Track connection status
constructor(readonly configService: ConfigService<AppConfig>) {
const dbUrl = configService.get<string>("db.redisUrl", { infer: true });
if (!dbUrl) throw new Error("Misconfigured Redis, halting.");
this.redis = new Redis(dbUrl);
this.redis.on("error", (err) => {
this.logger.error(`IoRedis connection error: ${err.message}`);
this.isReady = false;
});
this.redis.on("connect", () => {
this.logger.log("IoRedis connected!");
this.isReady = true;
});
this.redis.on("reconnecting", (delay: string) => {
this.logger.warn(`IoRedis reconnecting... next retry in ${delay}ms`);
});
this.redis.on("end", () => {
this.logger.warn("IoRedis connection ended.");
this.isReady = false;
});
}
async onModuleDestroy() {
@@ -22,4 +42,99 @@ export class RedisService implements OnModuleDestroy {
this.logger.error(err);
}
}
async get<TData>(key: string): Promise<TData | null> {
let data = null;
if (!this.isReady) {
return null;
}
try {
data = await this.redis.get(key);
} catch (err) {
if (err instanceof Error) this.logger.error(`IoRedis get failed: ${err.message}`);
}
if (data === null) {
return null;
}
try {
return JSON.parse(data) as TData;
} catch (e) {
return data as TData;
}
}
async del(key: string): Promise<number> {
if (!this.isReady) {
return 0;
}
try {
return this.redis.del(key);
} catch (err) {
if (err instanceof Error) this.logger.error(`IoRedis del failed: ${err.message}`);
return 0;
}
}
async set<TData>(key: string, value: TData, opts?: { ttl?: number }): Promise<"OK" | TData | null> {
if (!this.isReady) {
return null;
}
try {
const stringifiedValue = typeof value === "object" ? JSON.stringify(value) : String(value);
if (opts?.ttl) {
await this.redis.set(key, stringifiedValue, "PX", opts.ttl);
} else {
await this.redis.set(key, stringifiedValue);
}
} catch (err) {
if (err instanceof Error) this.logger.error(`IoRedis set failed: ${err.message}`);
return null;
}
return "OK";
}
async expire(key: string, seconds: number): Promise<0 | 1> {
if (!this.isReady) {
return 0;
}
try {
return this.redis.expire(key, seconds) as Promise<0 | 1>;
} catch (err) {
if (err instanceof Error) this.logger.error(`IoRedis expire failed: ${err.message}`);
return 0;
}
}
async lrange<TResult = string>(key: string, start: number, end: number): Promise<TResult[]> {
if (!this.isReady) {
return [];
}
try {
const results = await this.redis.lrange(key, start, end);
return results.map((item) => JSON.parse(item) as TResult);
} catch (err) {
if (err instanceof Error) this.logger.error(`IoRedis lrange failed: ${err.message}`);
return [];
}
}
async lpush<TData>(key: string, ...elements: TData[]): Promise<number> {
if (!this.isReady) {
return 0;
}
try {
const stringifiedElements = elements.map((element) =>
typeof element === "object" ? JSON.stringify(element) : String(element)
);
return this.redis.lpush(key, ...stringifiedElements);
} catch (err) {
if (err instanceof Error) this.logger.error(`IoRedis lpush failed: ${err.message}`);
return 0;
}
}
}
@@ -10,6 +10,7 @@ export const SMS_ATTENDEE = "sms_attendee";
export const SMS_NUMBER = "sms_number";
export const WHATSAPP_ATTENDEE = "whatsapp_attendee";
export const WHATSAPP_NUMBER = "whatsapp_number";
export const CAL_AI_PHONE_CALL = "cal_ai_phone_call";
export const STEP_ACTIONS = [
EMAIL_HOST,
@@ -19,6 +20,7 @@ export const STEP_ACTIONS = [
SMS_NUMBER,
WHATSAPP_ATTENDEE,
WHATSAPP_NUMBER,
CAL_AI_PHONE_CALL,
] as const;
export const STEP_ACTIONS_TO_ENUM = {
@@ -29,6 +31,7 @@ export const STEP_ACTIONS_TO_ENUM = {
[WHATSAPP_ATTENDEE]: WorkflowActions.WHATSAPP_ATTENDEE,
[WHATSAPP_NUMBER]: WorkflowActions.WHATSAPP_NUMBER,
[SMS_NUMBER]: WorkflowActions.SMS_NUMBER,
[CAL_AI_PHONE_CALL]: WorkflowActions.CAL_AI_PHONE_CALL,
} as const;
export const ENUM_TO_STEP_ACTIONS = {
@@ -39,6 +42,7 @@ export const ENUM_TO_STEP_ACTIONS = {
[WorkflowActions.WHATSAPP_ATTENDEE]: WHATSAPP_ATTENDEE,
[WorkflowActions.WHATSAPP_NUMBER]: WHATSAPP_NUMBER,
[WorkflowActions.SMS_NUMBER]: SMS_NUMBER,
[WorkflowActions.CAL_AI_PHONE_CALL]: CAL_AI_PHONE_CALL,
} as const;
export type StepAction = (typeof STEP_ACTIONS)[number];
+446 -15
View File
@@ -9893,6 +9893,198 @@
]
}
},
"/v2/event-types/{eventTypeId}/private-links": {
"post": {
"operationId": "EventTypesPrivateLinksController_createPrivateLink",
"summary": "Create a private link for an event type",
"parameters": [
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "Authorization",
"in": "header",
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreatePrivateLinkInput"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreatePrivateLinkOutput"
}
}
}
}
},
"tags": [
"Event Types Private Links"
]
},
"get": {
"operationId": "EventTypesPrivateLinksController_getPrivateLinks",
"summary": "Get all private links for an event type",
"parameters": [
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "Authorization",
"in": "header",
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetPrivateLinksOutput"
}
}
}
}
},
"tags": [
"Event Types Private Links"
]
}
},
"/v2/event-types/{eventTypeId}/private-links/{linkId}": {
"patch": {
"operationId": "EventTypesPrivateLinksController_updatePrivateLink",
"summary": "Update a private link for an event type",
"parameters": [
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "linkId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "Authorization",
"in": "header",
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdatePrivateLinkBody"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdatePrivateLinkOutput"
}
}
}
}
},
"tags": [
"Event Types Private Links"
]
},
"delete": {
"operationId": "EventTypesPrivateLinksController_deletePrivateLink",
"summary": "Delete a private link for an event type",
"parameters": [
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "linkId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "Authorization",
"in": "header",
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeletePrivateLinkOutput"
}
}
}
}
},
"tags": [
"Event Types Private Links"
]
}
},
"/v2/organizations/{orgId}/organizations": {
"post": {
"operationId": "OrganizationsOrganizationsController_createOrganization",
@@ -21856,7 +22048,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
]
},
"recipient": {
@@ -22218,7 +22411,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "email_address",
"description": "Action to perform, send an email to a specific email address",
@@ -22303,7 +22497,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "email_attendee",
"description": "Action to perform, send an email to the attendees of the event",
@@ -22379,7 +22574,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "email_host",
"description": "Action to perform, send an email to the host of the event",
@@ -22474,7 +22670,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "whatsapp_attendee",
"description": "Action to perform",
@@ -22543,7 +22740,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "whatsapp_number",
"description": "Action to perform, send a text message via whatsapp to a specific phone number",
@@ -22621,7 +22819,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "sms_number",
"description": "Action to perform, send a text message to a specific phone number",
@@ -22699,7 +22898,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "sms_attendee",
"description": "Action to perform, send a text message to the phone numbers of the attendees",
@@ -22888,7 +23088,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "email_address",
"description": "Action to perform, send an email to a specific email address",
@@ -22978,7 +23179,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "email_attendee",
"description": "Action to perform, send an email to the attendees of the event",
@@ -23059,7 +23261,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "email_host",
"description": "Action to perform, send an email to the host of the event",
@@ -23140,7 +23343,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "sms_attendee",
"description": "Action to perform, send a text message to the phone numbers of the attendees",
@@ -23218,7 +23422,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "whatsapp_number",
"description": "Action to perform, send a text message via whatsapp to a specific phone number",
@@ -23301,7 +23506,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "whatsapp_attendee",
"description": "Action to perform",
@@ -23375,7 +23581,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "sms_number",
"description": "Action to perform, send a text message to a specific phone number",
@@ -28012,6 +28219,230 @@
"data"
]
},
"CreatePrivateLinkInput": {
"type": "object",
"properties": {
"expiresAt": {
"type": "string",
"description": "Expiration date for time-based links",
"format": "date-time",
"example": "2024-12-31T23:59:59.000Z"
},
"maxUsageCount": {
"type": "number",
"description": "Maximum number of times the link can be used. If omitted and expiresAt is not provided, defaults to 1 (one time use).",
"example": 10,
"minimum": 1,
"default": 1
}
}
},
"TimeBasedPrivateLinkOutput": {
"type": "object",
"properties": {
"linkId": {
"type": "string",
"description": "The private link ID",
"example": "abc123def456"
},
"eventTypeId": {
"type": "number",
"description": "Event type ID this link belongs to",
"example": 123
},
"isExpired": {
"type": "boolean",
"description": "Whether the link is currently expired",
"example": false
},
"bookingUrl": {
"type": "string",
"description": "Full booking URL for this private link",
"format": "uri",
"example": "https://cal.com/d/abc123def456/30min"
},
"expiresAt": {
"type": "string",
"description": "Expiration date for this time-based link",
"format": "date-time",
"example": "2025-12-31T23:59:59.000Z"
}
},
"required": [
"linkId",
"eventTypeId",
"isExpired",
"bookingUrl",
"expiresAt"
]
},
"UsageBasedPrivateLinkOutput": {
"type": "object",
"properties": {
"linkId": {
"type": "string",
"description": "The private link ID",
"example": "abc123def456"
},
"eventTypeId": {
"type": "number",
"description": "Event type ID this link belongs to",
"example": 123
},
"isExpired": {
"type": "boolean",
"description": "Whether the link is currently expired",
"example": false
},
"bookingUrl": {
"type": "string",
"description": "Full booking URL for this private link",
"format": "uri",
"example": "https://cal.com/d/abc123def456/30min"
},
"maxUsageCount": {
"type": "number",
"description": "Maximum number of times this link can be used",
"example": 10
},
"usageCount": {
"type": "number",
"description": "Current usage count for this link",
"example": 3
}
},
"required": [
"linkId",
"eventTypeId",
"isExpired",
"bookingUrl",
"maxUsageCount",
"usageCount"
]
},
"CreatePrivateLinkOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"description": "Created private link data (either time-based or usage-based)",
"oneOf": [
{
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
},
{
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
}
]
}
},
"required": [
"status",
"data"
]
},
"GetPrivateLinksOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"type": "array",
"description": "Array of private links for the event type (mix of time-based and usage-based)",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
},
{
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
}
]
}
}
},
"required": [
"status",
"data"
]
},
"UpdatePrivateLinkBody": {
"type": "object",
"properties": {
"expiresAt": {
"format": "date-time",
"type": "string",
"description": "New expiration date for time-based links",
"example": "2024-12-31T23:59:59.000Z"
},
"maxUsageCount": {
"type": "number",
"description": "New maximum number of times the link can be used",
"example": 10,
"minimum": 1
}
}
},
"UpdatePrivateLinkOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"description": "Updated private link data (either time-based or usage-based)",
"oneOf": [
{
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
},
{
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
}
]
}
},
"required": [
"status",
"data"
]
},
"DeletePrivateLinkOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"type": "object",
"description": "Deleted link information",
"properties": {
"linkId": {
"type": "string",
"example": "abc123def456"
},
"message": {
"type": "string",
"example": "Private link deleted successfully"
}
}
}
},
"required": [
"status",
"data"
]
},
"UserWebhookOutputDto": {
"type": "object",
"properties": {
+1
View File
@@ -36,4 +36,5 @@ process.env = {
CALENDSO_ENCRYPTION_KEY: "22gfxhWUlcKliUeXcu8xNah2+HP/29ZX",
INTEGRATION_TEST_MODE: "true",
e2e: "true",
SLOTS_CACHE_TTL: "1"
};
+2 -1
View File
@@ -27,7 +27,8 @@
"@calcom/platform-libraries/workflows": ["../../../packages/platform/libraries/workflows.ts"],
"@calcom/platform-libraries/conferencing": ["../../../packages/platform/libraries/conferencing.ts"],
"@calcom/platform-libraries/repositories": ["../../../packages/platform/libraries/repositories.ts"],
"@calcom/platform-libraries/bookings": ["../../../packages/platform/libraries/bookings.ts"]
"@calcom/platform-libraries/bookings": ["../../../packages/platform/libraries/bookings.ts"],
"@calcom/platform-libraries/private-links": ["../../../packages/platform/libraries/private-links.ts"]
},
"incremental": true,
"skipLibCheck": true,
@@ -1,14 +1,15 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps as ServerPageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { APP_NAME } from "@calcom/lib/constants";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/getting-started/[[...step]]/getServerSideProps";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import type { PageProps as ClientPageProps } from "~/getting-started/[[...step]]/onboarding-view";
import Page from "~/getting-started/[[...step]]/onboarding-view";
export const generateMetadata = async ({ params }: ServerPageProps) => {
@@ -23,13 +24,23 @@ export const generateMetadata = async ({ params }: ServerPageProps) => {
);
};
const getData = withAppDirSsr<ClientPageProps>(getServerSideProps);
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const props = await getData(context);
return <Page {...props} />;
if (!session?.user?.id) {
return redirect("/auth/login");
}
const userRepo = new UserRepository(prisma);
const user = await userRepo.findUserTeams({
id: session.user.id,
});
if (!user) {
return redirect("/auth/login");
}
return <Page hasPendingInvites={!!user.teams.find((team) => team.accepted === false)} />;
};
export default ServerPage;
@@ -1,7 +1,7 @@
"use server";
import { revalidateTag } from "next/cache";
import { revalidatePath } from "next/cache";
export async function revalidateWebhooksListGetByViewer() {
revalidateTag("viewer.webhook.getByViewer");
export async function revalidateWebhooksList() {
revalidatePath("/settings/developer/webhooks");
}
@@ -1,13 +1,13 @@
import { createRouterCaller } from "app/_trpc/context";
import { _generateMetadata } from "app/_utils";
import { unstable_cache } from "next/cache";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import WebhooksView from "@calcom/features/webhooks/pages/webhooks-view";
import { APP_NAME } from "@calcom/lib/constants";
import { WebhookRepository } from "@calcom/lib/server/repository/webhook";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { webhookRouter } from "@calcom/trpc/server/routers/viewer/webhook/_router";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -20,17 +20,6 @@ export const generateMetadata = async () =>
"/settings/developer/webhooks"
);
const getCachedWebhooksList = unstable_cache(
async ({ userId, userRole }: { userId: number; userRole?: UserPermissionRole }) => {
return await WebhookRepository.getAllWebhooksByUserId({
userId,
userRole,
});
},
undefined,
{ revalidate: 3600, tags: ["viewer.webhook.getByViewer"] }
);
const WebhooksViewServerWrapper = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
@@ -38,9 +27,8 @@ const WebhooksViewServerWrapper = async () => {
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
const userRole = session.user.role !== "INACTIVE_ADMIN" ? session.user.role : undefined;
const data = await getCachedWebhooksList({ userId: session.user.id, userRole });
const caller = await createRouterCaller(webhookRouter);
const data = await caller.getByViewer();
return <WebhooksView data={data} isAdmin={isAdmin} />;
};
@@ -1,7 +0,0 @@
"use server";
import { revalidateTag } from "next/cache";
export async function revalidateWebhookById(id: string) {
revalidateTag(`viewer.webhook.get:${id}`);
}
@@ -1,6 +1,5 @@
import type { PageProps } from "app/_types";
import { getTranslate, _generateMetadata } from "app/_utils";
import { unstable_cache } from "next/cache";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { EditWebhookView } from "@calcom/features/webhooks/pages/webhook-edit-view";
@@ -16,24 +15,12 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
`/settings/developer/webhooks/${(await params).id}`
);
const getCachedWebhook = (id?: string) => {
const fn = unstable_cache(
async () => {
return await WebhookRepository.findByWebhookId(id);
},
undefined,
{ revalidate: 3600, tags: [`viewer.webhook.get:${id}`] }
);
return fn();
};
const Page = async ({ params: _params }: PageProps) => {
const t = await getTranslate();
const params = await _params;
const id = typeof params?.id === "string" ? params.id : undefined;
const webhook = await getCachedWebhook(id);
const webhook = await WebhookRepository.findByWebhookId(id);
return (
<SettingsHeader
@@ -1,7 +0,0 @@
"use server";
import { revalidateTag } from "next/cache";
export async function revalidateWebhookList() {
revalidateTag("viewer.webhook.list");
}
@@ -1,16 +1,10 @@
import { createRouterCaller } from "app/_trpc/context";
import { _generateMetadata } from "app/_utils";
import { unstable_cache } from "next/cache";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { NewWebhookView } from "@calcom/features/webhooks/pages/webhook-new-view";
import { APP_NAME } from "@calcom/lib/constants";
import { WebhookRepository } from "@calcom/lib/server/repository/webhook";
import { appsRouter } from "@calcom/trpc/server/routers/viewer/apps/_router";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { webhookRouter } from "@calcom/trpc/server/routers/viewer/webhook/_router";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -21,25 +15,15 @@ export const generateMetadata = async () =>
"/settings/developer/webhooks/new"
);
const getCachedWebhooksList = unstable_cache(
async ({ userId }: { userId: number }) => {
return await WebhookRepository.findWebhooksByFilters({ userId });
},
undefined,
{ revalidate: 3600, tags: ["viewer.webhook.list"] }
);
const Page = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
redirect("/auth/login");
}
const appsCaller = await createRouterCaller(appsRouter);
const [appsCaller, webhookCaller] = await Promise.all([
createRouterCaller(appsRouter),
createRouterCaller(webhookRouter),
]);
const [installedApps, webhooks] = await Promise.all([
appsCaller.integrations({ variant: "other", onlyInstalled: true }),
getCachedWebhooksList({ userId: session.user.id }),
webhookCaller.list(),
]);
return <NewWebhookView webhooks={webhooks} installedApps={installedApps} />;
@@ -7,6 +7,7 @@ import { passwordResetRequest } from "@calcom/features/auth/lib/passwordResetReq
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { emailSchema } from "@calcom/lib/emailSchema";
import prisma from "@calcom/prisma";
import { piiHasher } from "@calcom/lib/server/PiiHasher";
async function handler(req: NextRequest) {
const body = await parseRequestData(req);
@@ -28,7 +29,7 @@ async function handler(req: NextRequest) {
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: ip,
identifier: piiHasher.hash(ip),
});
try {
+4
View File
@@ -4,13 +4,16 @@ import { NextResponse } from "next/server";
import jackson from "@calcom/features/ee/sso/lib/jackson";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
// This is the callback endpoint for the OIDC provider
// A team must set this endpoint in the OIDC provider's configuration
async function handler(req: NextRequest) {
const log = logger.getSubLogger({ prefix: ["[ODIC auth]"] });
const { searchParams } = req.nextUrl;
const code = searchParams.get("code");
const state = searchParams.get("state");
const tenant = searchParams.get("tenant");
if (!code || !state) {
return NextResponse.json({ message: "Code and state are required" }, { status: 400 });
@@ -30,6 +33,7 @@ async function handler(req: NextRequest) {
return NextResponse.redirect(redirect_url, 302);
} catch (err) {
log.error(`Error authorizing tenant ${tenant}: ${err}`);
const { message, statusCode = 500 } = err as HttpError;
return NextResponse.json({ message }, { status: statusCode });
@@ -5,17 +5,20 @@ import { NextResponse } from "next/server";
import type { OAuthReq } from "@calcom/features/ee/sso/lib/jackson";
import jackson from "@calcom/features/ee/sso/lib/jackson";
import type { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
async function handler(req: NextRequest) {
const log = logger.getSubLogger({ prefix: ["[SAML authorize]"] });
const { oauthController } = await jackson();
const oAuthReq = Object.fromEntries(req.nextUrl.searchParams) as unknown as OAuthReq;
try {
const { redirect_url } = await oauthController.authorize(
Object.fromEntries(req.nextUrl.searchParams) as unknown as OAuthReq
);
const { redirect_url } = await oauthController.authorize(oAuthReq);
return NextResponse.redirect(redirect_url as string, 302);
} catch (err) {
log.error(`Error initaiting SAML login for tenant ${oAuthReq?.tenant}: ${err}`);
const { message, statusCode = 500 } = err as HttpError;
return NextResponse.json({ message }, { status: statusCode });
+20 -5
View File
@@ -2,19 +2,34 @@ import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import { parseRequestData } from "app/api/parseRequestData";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { uuid } from "short-uuid";
import jackson from "@calcom/features/ee/sso/lib/jackson";
import type { SAMLResponsePayload } from "@calcom/features/ee/sso/lib/jackson";
import logger from "@calcom/lib/logger";
async function handler(req: NextRequest) {
const uid = uuid();
const log = logger.getSubLogger({ prefix: ["[SAML callback]", `trace: ${uid}`] });
const { oauthController } = await jackson();
const { redirect_url } = await oauthController.samlResponse(
(await parseRequestData(req)) as SAMLResponsePayload
);
const requestData = (await parseRequestData(req)) as SAMLResponsePayload;
if (redirect_url) {
return NextResponse.redirect(redirect_url, 302);
try {
const { redirect_url, error } = await oauthController.samlResponse(requestData);
if (redirect_url) {
return NextResponse.redirect(redirect_url, 302);
}
if (error) {
const uid = uuid();
log.error(`Error authenticating user with error ${error} for relayState ${requestData?.RelayState}`);
return NextResponse.json({ message: `Error authorizing user. trace: ${uid}` }, { status: 400 });
}
} catch (error) {
log.error(`Error processing SAML response`, error);
return NextResponse.json({ message: `Error processing SAML response. trace: ${uid}` }, { status: 500 });
}
return NextResponse.json({ message: "No redirect URL provided" }, { status: 400 });
+14 -2
View File
@@ -2,14 +2,26 @@ import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import { parseRequestData } from "app/api/parseRequestData";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { uuid } from "short-uuid";
import jackson from "@calcom/features/ee/sso/lib/jackson";
import type { OAuthTokenReq } from "@calcom/features/ee/sso/lib/jackson";
import logger from "@calcom/lib/logger";
async function handler(req: NextRequest) {
const { oauthController } = await jackson();
const tokenResponse = await oauthController.token((await parseRequestData(req)) as OAuthTokenReq);
return NextResponse.json(tokenResponse);
const log = logger.getSubLogger({ prefix: ["[SAML token]"] });
const oauthTokenReq = (await parseRequestData(req)) as OAuthTokenReq;
try {
const tokenResponse = await oauthController.token(oauthTokenReq);
return NextResponse.json(tokenResponse);
} catch (error) {
const uid = uuid();
log.error(`Error getting auth token for client id ${oauthTokenReq?.client_id}: ${error} trace: ${uid}`);
throw new Error(`Error getting auth token with error ${error} trace: ${uid}`);
}
}
export const POST = defaultResponderForAppDir(handler);
+23 -5
View File
@@ -1,23 +1,33 @@
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import z from "zod";
import { uuid } from "short-uuid";
import { z } from "zod";
import jackson from "@calcom/features/ee/sso/lib/jackson";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
const extractAuthToken = (req: NextRequest) => {
const log = logger.getSubLogger({ prefix: ["SAML extractAuthToken"] });
const uid = uuid();
const authHeader = req.headers.get("authorization");
const parts = (authHeader || "").split(" ");
if (parts.length > 1) return parts[1];
// check for query param
let arr: string[] = [];
const { access_token } = requestQuery.parse(Object.fromEntries(req.nextUrl.searchParams));
const tokenParse = requestQuery.safeParse(Object.fromEntries(req.nextUrl.searchParams));
let access_token;
if (!tokenParse.success) {
log.error(`Error parsing request query: ${tokenParse.error} trace ${uid}`);
throw new HttpError({ statusCode: 401, message: `Unauthorized trace: ${uid}` });
}
access_token = tokenParse.data.access_token;
arr = arr.concat(access_token);
if (arr[0].length > 0) return arr[0];
throw new HttpError({ statusCode: 401, message: "Unauthorized" });
throw new HttpError({ statusCode: 401, message: `Unauthorized trace: ${uid}` });
};
const requestQuery = z.object({
@@ -25,10 +35,18 @@ const requestQuery = z.object({
});
async function handler(req: NextRequest) {
const log = logger.getSubLogger({ prefix: ["SAML userinfo"] });
const { oauthController } = await jackson();
const token = extractAuthToken(req);
const userInfo = await oauthController.userInfo(token);
return NextResponse.json(userInfo);
try {
const userInfo = await oauthController.userInfo(token);
return NextResponse.json(userInfo);
} catch (error) {
const uid = uuid();
log.error(`trace: ${uid} Error getting user info from token: ${error}`);
throw new Error(`Error getting user info from token. trace: ${uid}`);
}
}
export const GET = defaultResponderForAppDir(handler);
@@ -0,0 +1,5 @@
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import { handleQueuedFormResponseCleanup } from "@calcom/app-store/routing-forms/cron/queuedFormResponseCleanup";
export const POST = defaultResponderForAppDir(handleQueuedFormResponseCleanup);
+18 -7
View File
@@ -35,20 +35,31 @@ async function handler(req: NextRequest) {
const imageType = searchParams.get("type");
try {
const [calFontData, interFontData, interFontMediumData] = await Promise.all([
const fontResults = await Promise.allSettled([
fetch(new URL("/fonts/cal.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
fetch(new URL("/fonts/Inter-Regular.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
fetch(new URL("/fonts/Inter-Medium.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
]);
const fonts: SatoriOptions["fonts"] = [];
if (fontResults[1].status === "fulfilled") {
fonts.push({ name: "inter", data: fontResults[1].value, weight: 400 });
}
if (fontResults[2].status === "fulfilled") {
fonts.push({ name: "inter", data: fontResults[2].value, weight: 500 });
}
if (fontResults[0].status === "fulfilled") {
fonts.push({ name: "cal", data: fontResults[0].value, weight: 400 });
fonts.push({ name: "cal", data: fontResults[0].value, weight: 600 });
}
const ogConfig = {
width: 1200,
height: 630,
fonts: [
{ name: "inter", data: interFontData, weight: 400 },
{ name: "inter", data: interFontMediumData, weight: 500 },
{ name: "cal", data: calFontData, weight: 400 },
{ name: "cal", data: calFontData, weight: 600 },
] as SatoriOptions["fonts"],
fonts,
};
switch (imageType) {
@@ -546,9 +546,7 @@ function BookingListItem(booking: BookingItemProps) {
</Link>
</div>
</div>
<div
data-testid="title-and-attendees"
className={`w-full px-4${isRejected ? " line-through" : ""}`}>
<div data-testid="title-and-attendees" className={`w-full px-4${isRejected ? "line-through" : ""}`}>
<Link href={bookingLink}>
{/* Time and Badges for mobile */}
<div className="w-full pb-2 pt-4 sm:hidden">
@@ -893,7 +891,7 @@ const FirstAttendee = ({
) : (
<a
key={user.email}
className=" hover:text-blue-500"
className="hover:text-blue-500"
href={`mailto:${user.email}`}
onClick={(e) => e.stopPropagation()}>
{user.name || user.email}
@@ -1081,7 +1079,7 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
/>
))}
<DropdownMenuSeparator />
<div className="flex justify-end p-2 ">
<div className="flex justify-end p-2">
<Button
data-testid="update-no-show"
color="secondary"
@@ -1204,7 +1202,7 @@ const GroupedGuests = ({ guests }: { guests: AttendeeProps[] }) => {
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<div className="flex justify-end space-x-2 p-2 ">
<div className="flex justify-end space-x-2 p-2">
<Link href={`mailto:${selectedEmail}`}>
<Button
color="secondary"
@@ -278,13 +278,14 @@ describe("Booking Actions", () => {
expect(rerouteAction).toBeDefined();
});
it("should include reassign action for round robin events", () => {
it("should include reassign action for round robin events with no host groups", () => {
const context = createMockContext({
booking: {
...createMockContext().booking,
eventType: {
...createMockContext().booking.eventType,
schedulingType: SchedulingType.ROUND_ROBIN,
hostGroups: [],
},
},
});
@@ -294,6 +295,60 @@ describe("Booking Actions", () => {
expect(reassignAction).toBeDefined();
});
it("should include reassign action for round robin events with one host group", () => {
const context = createMockContext({
booking: {
...createMockContext().booking,
eventType: {
...createMockContext().booking.eventType,
schedulingType: SchedulingType.ROUND_ROBIN,
hostGroups: [{ id: "group-1", name: "Group 1" }],
},
},
});
const actions = getEditEventActions(context);
const reassignAction = actions.find((a) => a.id === "reassign");
expect(reassignAction).toBeDefined();
});
it("should exclude reassign action for round robin events with more than one host groups", () => {
const context = createMockContext({
booking: {
...createMockContext().booking,
eventType: {
...createMockContext().booking.eventType,
schedulingType: SchedulingType.ROUND_ROBIN,
hostGroups: [
{ id: "group-1", name: "Group 1" },
{ id: "group-2", name: "Group 2" },
],
},
},
});
const actions = getEditEventActions(context);
const reassignAction = actions.find((a) => a.id === "reassign");
expect(reassignAction).toBeUndefined();
});
it("should exclude reassign action for non-round-robin events", () => {
const context = createMockContext({
booking: {
...createMockContext().booking,
eventType: {
...createMockContext().booking.eventType,
schedulingType: SchedulingType.COLLECTIVE,
hostGroups: [],
},
},
});
const actions = getEditEventActions(context);
const reassignAction = actions.find((a) => a.id === "reassign");
expect(reassignAction).toBeUndefined();
});
it("should exclude add_members when guests are disabled", () => {
const context = createMockContext({
booking: {
@@ -146,8 +146,9 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
icon: "user-plus",
disabled: false,
},
// Reassign (if round robin)
booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN
// Reassign if round robin with no or one host groups
booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN &&
(!booking.eventType.hostGroups || booking.eventType.hostGroups?.length <= 1)
? {
id: "reassign",
label: t("reassign"),
@@ -144,7 +144,7 @@ const PlainContactForm = () => {
};
return (
<div className="absolute bottom-4 right-4 z-50">
<div className="fixed bottom-[1rem] right-[1rem] z-50">
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild className="enabled:hover:bg-subtle bg-subtle shadow-none">
<Button
@@ -156,7 +156,7 @@ const PlainContactForm = () => {
<PopoverContent
style={{ maxWidth: "450px", maxHeight: "650px" }}
className="!bg-muted no-scrollbar mr-8 mb-2 w-[450px] overflow-hidden overflow-y-scroll px-6 py-4">
className="!bg-muted no-scrollbar mb-2 mr-8 w-[450px] overflow-hidden overflow-y-scroll px-6 py-4">
<div className="flex w-full justify-between">
<p className="mb-5 text-lg font-semibold">Contact support</p>
<Button
@@ -4,7 +4,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { showToast } from "@calcom/ui/components/toast";
import PlainContactForm from "../../../lib/plain/PlainContactForm";
import PlainContactForm from "../PlainContactForm";
vi.mock("next-auth/react", () => ({
useSession: vi.fn(),
@@ -1,30 +0,0 @@
import type { GetServerSidePropsContext } from "next";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { req } = context;
const session = await getServerSession({ req });
if (!session?.user?.id) {
return { redirect: { permanent: false, destination: "/auth/login" } };
}
const userRepo = new UserRepository(prisma);
const user = await userRepo.findUserTeams({
id: session.user.id,
});
if (!user) {
throw new Error("User from session not found");
}
return {
props: {
hasPendingInvites: user.teams.find((team) => team.accepted === false) ?? false,
},
};
};
+3 -1
View File
@@ -2,4 +2,6 @@ import dynamic from "next/dynamic";
import { Fragment } from "react";
// Preload caused by dynamic import doesn't seem to add nonce and thus preload fails but the functionality still works - https://github.com/vercel/next.js/issues/81260
export default process.env.NEXT_PUBLIC_PLAIN_CHAT_ID ? dynamic(() => import("./plainChat")) : Fragment;
export default process.env.NEXT_PUBLIC_PLAIN_CHAT_ID
? dynamic(() => import("../../components/plain/plainChat"))
: Fragment;
@@ -18,8 +18,6 @@ import { StepCard } from "@calcom/ui/components/card";
import { Steps } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import type { getServerSideProps } from "@lib/getting-started/[[...step]]/getServerSideProps";
import { ConnectedCalendars } from "@components/getting-started/steps-views/ConnectCalendars";
import { ConnectedVideoStep } from "@components/getting-started/steps-views/ConnectedVideoStep";
import { SetupAvailability } from "@components/getting-started/steps-views/SetupAvailability";
@@ -85,7 +83,9 @@ const stepRouteSchema = z.object({
from: z.string().optional(),
});
export type PageProps = inferSSRProps<typeof getServerSideProps>;
type PageProps = {
hasPendingInvites: boolean;
};
const OnboardingPage = (props: PageProps) => {
const pathname = usePathname();
const params = useParamsWithFallback();
+1 -1
View File
@@ -13,7 +13,7 @@ export const BUTTONS = {
iconPathDarkMode: TRANSCRIPTION_STARTED_ICON,
},
START_TRANSCRIPTION: {
label: "Cal.ai",
label: "Transcribe",
tooltip: "Transcription powered by AI",
iconPath: TRANSCRIPTION_STOPPED_ICON,
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
@@ -85,7 +85,7 @@ export default function JoinCall(props: PageProps) {
...(showTranscriptionButton
? {
transcription: {
label: "Cal.ai",
label: "Transcribe",
tooltip: "Transcription powered by AI",
iconPath: TRANSCRIPTION_STOPPED_ICON,
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.5.11",
"version": "5.5.15",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -78,7 +78,7 @@
"@team-plain/typescript-sdk": "^5.9.0",
"@types/turndown": "^5.0.1",
"@unkey/ratelimit": "^0.1.1",
"@upstash/redis": "^1.21.0",
"@upstash/redis": "^1.35.2",
"@vercel/edge-config": "^0.1.1",
"@vercel/edge-functions-ui": "^0.2.1",
"@vercel/og": "^0.6.3",
@@ -138,6 +138,7 @@
"react-use-intercom": "1.5.1",
"recoil": "^0.7.7",
"remove-markdown": "^0.5.0",
"retell-sdk": "^4.40.0",
"rrule": "^2.7.1",
"sanitize-html": "^2.10.0",
"schema-dts": "^1.1.0",
+2 -1
View File
@@ -7,6 +7,7 @@ import getIP from "@calcom/lib/getIP";
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { CreationSource } from "@calcom/prisma/enums";
import { piiHasher } from "@calcom/lib/server/PiiHasher";
async function handler(req: NextApiRequest & { userId?: number }) {
const userIp = getIP(req);
@@ -20,7 +21,7 @@ async function handler(req: NextApiRequest & { userId?: number }) {
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: userIp,
identifier: piiHasher.hash(userIp),
});
const session = await getServerSession({ req });
+2 -1
View File
@@ -6,13 +6,14 @@ import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowE
import getIP from "@calcom/lib/getIP";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { CreationSource } from "@calcom/prisma/enums";
import { piiHasher } from "@calcom/lib/server/PiiHasher";
async function handler(req: NextApiRequest & { userId?: number }) {
const userIp = getIP(req);
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: `instant.event-${userIp}`,
identifier: `instant.event-${piiHasher.hash(userIp)}`,
});
const session = await getServerSession({ req });
+2 -1
View File
@@ -7,6 +7,7 @@ import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowE
import getIP from "@calcom/lib/getIP";
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { piiHasher } from "@calcom/lib/server/PiiHasher";
// @TODO: Didn't look at the contents of this function in order to not break old booking page.
@@ -37,7 +38,7 @@ async function handler(req: NextApiRequest & RequestMeta) {
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: userIp,
identifier: piiHasher.hash(userIp),
});
const session = await getServerSession({ req });
/* To mimic API behavior and comply with types */
@@ -3,8 +3,8 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { ZGetRetellLLMSchema } from "@calcom/features/ee/cal-ai-phone/zod-utils";
import type { TGetRetellLLMSchema } from "@calcom/features/ee/cal-ai-phone/zod-utils";
import { ZGetRetellLLMSchema } from "@calcom/features/calAIPhone/zod-utils";
import type { TGetRetellLLMSchema } from "@calcom/features/calAIPhone/zod-utils";
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
import { fetcher } from "@calcom/lib/retellAIFetcher";
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
+41 -1
View File
@@ -120,7 +120,7 @@
"already_signed_up_for_this_booking_error": "You are already signed up for this booking.",
"hosts_unavailable_for_booking": "Some of the hosts are unavailable for booking.",
"fixed_hosts_unavailable_for_booking": "Some of the fixed hosts are unavailable for booking.",
"round_robin_hosts_unavailable_for_booking": "No Round Robin hosts is available for booking.",
"round_robin_host_unavailable_for_booking": "Round Robin host is unavailable for booking.",
"help": "Help",
"price": "Price",
"paid": "Paid",
@@ -349,6 +349,7 @@
"available_apps_lower_case": "Available apps",
"available_apps_desc": "View popular apps below and explore more in our <0>App Store</0>",
"fixed_host_helper": "Add anyone who needs to attend the event. <0>Learn more</0>",
"round_robin_groups_helper": "People in a group take turns, one person of each group will show up for the event.",
"round_robin_helper": "People in the group take turns and only one person will show up for the event.",
"check_email_reset_password": "Check your email. We sent you a link to reset your password.",
"finish": "Finish",
@@ -2251,6 +2252,8 @@
"floating_button_trigger_modal": "Puts a floating button on your site that triggers a modal with your event type.",
"pop_up_element_click": "Pop up via element click",
"open_dialog_with_element_click": "Open your calendar as a dialog when someone clicks an element.",
"use_my_own_form": "Use my own form",
"use_our_headless_routing_api": "Use our headless routing API and use your own form for submissions.",
"need_help_embedding": "Need help? See our guides for embedding Cal on Wix, Squarespace, or WordPress, check our common questions, or explore advanced embed options.",
"book_my_cal": "Book my Cal",
"first_name": "First name",
@@ -3286,6 +3289,7 @@
"pbac_resource_booking": "Bookings",
"pbac_resource_insights": "Insights",
"pbac_resource_role": "Roles",
"pbac_resource_workflow": "Workflows",
"pbac_action_all": "All Actions",
"pbac_action_create": "Create",
"pbac_action_read": "View",
@@ -3317,6 +3321,11 @@
"pbac_desc_update_roles": "Update roles",
"pbac_desc_delete_roles": "Delete roles",
"pbac_desc_manage_roles": "All actions on roles across organization teams",
"pbac_desc_create_workflows": "Create and set up new workflows",
"pbac_desc_view_workflows": "View existing workflows and their configurations",
"pbac_desc_update_workflows": "Edit and modify workflow settings",
"pbac_desc_delete_workflows": "Remove workflows from the system",
"pbac_desc_manage_workflows": "Full management access to all workflows",
"pbac_desc_create_event_types": "Create event types",
"pbac_desc_view_event_types": "View event types",
"pbac_desc_update_event_types": "Update event types",
@@ -3354,6 +3363,7 @@
"use_booker_timezone_info": "Apply the selected restriction schedule in the booker's timezone",
"load_balancing_warning": "This will disable Load Balancing on all your Round Robin event types",
"rr_load_balancing_disabled": "Load Balancing is only available when the timestamp basis is set to Booking Creation Time",
"rr_load_balancing_disabled_with_groups": "Load Balancing is not available with Round Robin Groups",
"round_robin_settings_description": "Customize the default round robin settings for this team",
"round_robin_settings_updated_successfully": "Round Robin settings updated successfully",
"booking_creation_time": "Booking Creation Time",
@@ -3370,6 +3380,7 @@
"booking_not_allowed_by_restriction_schedule_error": "Booking outside restriction schedule availability.",
"restriction_schedule_not_found_error": "Restriction schedule not found",
"converted_image_size_limit_exceed": "Image size limit exceeded, please use a smaller image preferably in JPEG format",
"add_group": "Add Group",
"routing_funnel": "Routing Funnel",
"routing_funnel_total_submissions": "Total Submissions",
"routing_funnel_successful_routings": "Successful Routings",
@@ -3430,6 +3441,35 @@
"usage_based_expiration_description": "This link can be used for {{count}} booking",
"usage_based_generic_expiration_description": "This link can be configured to expire after a set number of bookings",
"usage_based_expiration_description_plural": "This link can be used for {{count}} bookings",
"webhook_trigger_event": "The name of the trigger event (e.g., BOOKING_CREATED, BOOKING_CANCELLED)",
"webhook_created_at": "The time of the webhook",
"webhook_type": "The event type slug",
"webhook_title": "The event type name",
"webhook_start_time": "The event's start time",
"webhook_end_time": "The event's end time",
"webhook_description": "The event's description as described in the event type settings",
"webhook_location": "Location of the event",
"webhook_uid": "The UID of the booking",
"webhook_reschedule_uid": "The UID for rescheduling",
"webhook_cancellation_reason": "Reason for cancellation",
"webhook_rejection_reason": "Reason for rejection",
"webhook_organizer_name": "Name of the organizer",
"webhook_organizer_email": "Email of the organizer",
"webhook_organizer_timezone": "Timezone of the organizer (e.g., 'America/New_York', 'Asia/Kolkata')",
"webhook_organizer_locale": "Locale of the organizer (e.g., 'en', 'fr')",
"webhook_attendee_name": "Name of the first attendee",
"webhook_attendee_email": "Email of the first attendee",
"webhook_attendee_timezone": "Timezone of the first attendee",
"webhook_attendee_locale": "Locale of the first attendee",
"webhook_team_name": "Name of the team booked",
"webhook_team_members": "Members of the team booked",
"webhook_video_call_url": "Video call URL for the meeting",
"webhook_hide_variables": "Hide variables",
"webhook_show_variable": "Show available variables",
"webhook_event_and_booking": "Event and Booking",
"webhook_people": "People",
"webhook_teams": "Teams",
"webhook_metadata": "Metadata",
"stats": "Stats",
"booking_status": "Booking status",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
@@ -380,7 +380,7 @@
"connect": "Ühenda",
"try_for_free": "Proovi tasuta",
"create_booking_link_with_calcom": "Looge oma broneerimislink rakendusega {{appName}}",
"who": "WHO",
"who": "Kes",
"what": "Mida",
"when": "Millal",
"where": "Kus",
+6 -5
View File
@@ -1,4 +1,5 @@
import CalendarManagerMock from "../../../../tests/libs/__mocks__/CalendarManager";
import { constantsScenarios } from "@calcom/lib/__mocks__/constants";
import {
getDate,
@@ -23,12 +24,12 @@ import { expect, expectedSlotsForSchedule } from "./getSchedule/expects";
import { setupAndTeardown } from "./getSchedule/setupAndTeardown";
import { timeTravelToTheBeginningOfToday } from "./getSchedule/utils";
vi.mock("@calcom/lib/constants", () => ({
IS_PRODUCTION: true,
constantsScenarios.set({
IS_PRODUCTION: true as any,
WEBAPP_URL: "http://localhost:3000",
RESERVED_SUBDOMAINS: ["auth", "docs"],
SINGLE_ORG_SLUG: "",
}));
RESERVED_SUBDOMAINS: ["auth", "docs"] as any,
SINGLE_ORG_SLUG: "" as any,
});
describe("getSchedule", () => {
const availableSlotsService = getAvailableSlotsService();
@@ -121,6 +121,7 @@ type InputHost = {
userId: number;
isFixed?: boolean;
scheduleId?: number | null;
groupId?: string | null;
};
type InputSelectedSlot = {
@@ -215,6 +216,7 @@ export type InputEventType = {
useEventLevelSelectedCalendars?: boolean;
users?: { id: number }[];
hosts?: InputHost[];
hostGroups?: { id: string; name: string }[];
schedulingType?: SchedulingType;
parent?: { id: number };
beforeEventBuffer?: number;
@@ -290,6 +292,17 @@ export const Timezones = {
async function addHostsToDb(eventTypes: InputEventType[]) {
for (const eventType of eventTypes) {
// Create host groups first if they exist
if (eventType.hostGroups?.length) {
await prismock.hostGroup.createMany({
data: eventType.hostGroups.map((group) => ({
id: group.id, // Preserve the input ID
name: group.name,
eventTypeId: eventType.id,
})),
});
}
if (!eventType.hosts?.length) continue;
for (const host of eventType.hosts) {
const data: Prisma.HostCreateInput = {
@@ -311,6 +324,13 @@ async function addHostsToDb(eventTypes: InputEventType[]) {
},
}
: undefined,
group: host.groupId
? {
connect: {
id: host.groupId,
},
}
: undefined,
};
await prismock.host.create({
@@ -363,6 +383,7 @@ export async function addEventTypesToDb(
workflows: true,
destinationCalendar: true,
schedule: true,
hostGroups: true,
},
});
@@ -473,6 +494,7 @@ export async function addEventTypes(eventTypes: InputEventType[], usersStore: In
workflows: [],
users,
hosts,
hostGroups: eventType.hostGroups || [],
destinationCalendar: eventType.destinationCalendar
? {
create: eventType.destinationCalendar,
+4
View File
@@ -4,6 +4,10 @@
"path": "/api/cron/calendar-cache-cleanup",
"schedule": "0 5 * * *"
},
{
"path": "/api/cron/queuedFormResponseCleanup",
"schedule": "0 */12 * * *"
},
{
"path": "/api/tasks/cron",
"schedule": "* * * * *"
+446 -15
View File
@@ -9893,6 +9893,198 @@
]
}
},
"/v2/event-types/{eventTypeId}/private-links": {
"post": {
"operationId": "EventTypesPrivateLinksController_createPrivateLink",
"summary": "Create a private link for an event type",
"parameters": [
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "Authorization",
"in": "header",
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreatePrivateLinkInput"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreatePrivateLinkOutput"
}
}
}
}
},
"tags": [
"Event Types Private Links"
]
},
"get": {
"operationId": "EventTypesPrivateLinksController_getPrivateLinks",
"summary": "Get all private links for an event type",
"parameters": [
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "Authorization",
"in": "header",
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetPrivateLinksOutput"
}
}
}
}
},
"tags": [
"Event Types Private Links"
]
}
},
"/v2/event-types/{eventTypeId}/private-links/{linkId}": {
"patch": {
"operationId": "EventTypesPrivateLinksController_updatePrivateLink",
"summary": "Update a private link for an event type",
"parameters": [
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "linkId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "Authorization",
"in": "header",
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdatePrivateLinkBody"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdatePrivateLinkOutput"
}
}
}
}
},
"tags": [
"Event Types Private Links"
]
},
"delete": {
"operationId": "EventTypesPrivateLinksController_deletePrivateLink",
"summary": "Delete a private link for an event type",
"parameters": [
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "linkId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "Authorization",
"in": "header",
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeletePrivateLinkOutput"
}
}
}
}
},
"tags": [
"Event Types Private Links"
]
}
},
"/v2/organizations/{orgId}/organizations": {
"post": {
"operationId": "OrganizationsOrganizationsController_createOrganization",
@@ -21856,7 +22048,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
]
},
"recipient": {
@@ -22218,7 +22411,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "email_address",
"description": "Action to perform, send an email to a specific email address",
@@ -22303,7 +22497,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "email_attendee",
"description": "Action to perform, send an email to the attendees of the event",
@@ -22379,7 +22574,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "email_host",
"description": "Action to perform, send an email to the host of the event",
@@ -22474,7 +22670,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "whatsapp_attendee",
"description": "Action to perform",
@@ -22543,7 +22740,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "whatsapp_number",
"description": "Action to perform, send a text message via whatsapp to a specific phone number",
@@ -22621,7 +22819,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "sms_number",
"description": "Action to perform, send a text message to a specific phone number",
@@ -22699,7 +22898,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "sms_attendee",
"description": "Action to perform, send a text message to the phone numbers of the attendees",
@@ -22888,7 +23088,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "email_address",
"description": "Action to perform, send an email to a specific email address",
@@ -22978,7 +23179,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "email_attendee",
"description": "Action to perform, send an email to the attendees of the event",
@@ -23059,7 +23261,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "email_host",
"description": "Action to perform, send an email to the host of the event",
@@ -23140,7 +23343,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "sms_attendee",
"description": "Action to perform, send a text message to the phone numbers of the attendees",
@@ -23218,7 +23422,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "whatsapp_number",
"description": "Action to perform, send a text message via whatsapp to a specific phone number",
@@ -23301,7 +23506,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "whatsapp_attendee",
"description": "Action to perform",
@@ -23375,7 +23581,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "sms_number",
"description": "Action to perform, send a text message to a specific phone number",
@@ -28012,6 +28219,230 @@
"data"
]
},
"CreatePrivateLinkInput": {
"type": "object",
"properties": {
"expiresAt": {
"type": "string",
"description": "Expiration date for time-based links",
"format": "date-time",
"example": "2024-12-31T23:59:59.000Z"
},
"maxUsageCount": {
"type": "number",
"description": "Maximum number of times the link can be used. If omitted and expiresAt is not provided, defaults to 1 (one time use).",
"example": 10,
"minimum": 1,
"default": 1
}
}
},
"TimeBasedPrivateLinkOutput": {
"type": "object",
"properties": {
"linkId": {
"type": "string",
"description": "The private link ID",
"example": "abc123def456"
},
"eventTypeId": {
"type": "number",
"description": "Event type ID this link belongs to",
"example": 123
},
"isExpired": {
"type": "boolean",
"description": "Whether the link is currently expired",
"example": false
},
"bookingUrl": {
"type": "string",
"description": "Full booking URL for this private link",
"format": "uri",
"example": "https://cal.com/d/abc123def456/30min"
},
"expiresAt": {
"type": "string",
"description": "Expiration date for this time-based link",
"format": "date-time",
"example": "2025-12-31T23:59:59.000Z"
}
},
"required": [
"linkId",
"eventTypeId",
"isExpired",
"bookingUrl",
"expiresAt"
]
},
"UsageBasedPrivateLinkOutput": {
"type": "object",
"properties": {
"linkId": {
"type": "string",
"description": "The private link ID",
"example": "abc123def456"
},
"eventTypeId": {
"type": "number",
"description": "Event type ID this link belongs to",
"example": 123
},
"isExpired": {
"type": "boolean",
"description": "Whether the link is currently expired",
"example": false
},
"bookingUrl": {
"type": "string",
"description": "Full booking URL for this private link",
"format": "uri",
"example": "https://cal.com/d/abc123def456/30min"
},
"maxUsageCount": {
"type": "number",
"description": "Maximum number of times this link can be used",
"example": 10
},
"usageCount": {
"type": "number",
"description": "Current usage count for this link",
"example": 3
}
},
"required": [
"linkId",
"eventTypeId",
"isExpired",
"bookingUrl",
"maxUsageCount",
"usageCount"
]
},
"CreatePrivateLinkOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"description": "Created private link data (either time-based or usage-based)",
"oneOf": [
{
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
},
{
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
}
]
}
},
"required": [
"status",
"data"
]
},
"GetPrivateLinksOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"type": "array",
"description": "Array of private links for the event type (mix of time-based and usage-based)",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
},
{
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
}
]
}
}
},
"required": [
"status",
"data"
]
},
"UpdatePrivateLinkBody": {
"type": "object",
"properties": {
"expiresAt": {
"format": "date-time",
"type": "string",
"description": "New expiration date for time-based links",
"example": "2024-12-31T23:59:59.000Z"
},
"maxUsageCount": {
"type": "number",
"description": "New maximum number of times the link can be used",
"example": 10,
"minimum": 1
}
}
},
"UpdatePrivateLinkOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"description": "Updated private link data (either time-based or usage-based)",
"oneOf": [
{
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
},
{
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
}
]
}
},
"required": [
"status",
"data"
]
},
"DeletePrivateLinkOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"type": "object",
"description": "Deleted link information",
"properties": {
"linkId": {
"type": "string",
"example": "abc123def456"
},
"message": {
"type": "string",
"example": "Private link deleted successfully"
}
}
}
},
"required": [
"status",
"data"
]
},
"UserWebhookOutputDto": {
"type": "object",
"properties": {
@@ -61,7 +61,9 @@ UI Component → tRPC Handler → Insights Service → Database Query → Respon
);
};
```
</Step>
<Step title="Add Component to Barrel Export">
Update the booking components index file:
@@ -168,6 +170,7 @@ UI Component → tRPC Handler → Insights Service → Database Query → Respon
}
```
</Step>
</Steps>
## Best Practices
+24 -1
View File
@@ -156,7 +156,16 @@ export function AvailabilityWithValidation() {
};
const handleSubmit = () => {
availabilityRef.current?.handleFormSubmit();
availabilityRef.current?.handleFormSubmit({
onSuccess: () => {
// Additional success handling logic here
console.log('Availability updated successfully');
},
onError: (error) => {
// Additional error handling logic here
console.error('Error updating availability:', error);
}
});
};
return (
@@ -184,6 +193,20 @@ export function AvailabilityWithValidation() {
| validateForm | Validates the current availability form state and returns a promise with validation results. |
| handleFormSubmit | Programmatically submits the availability form, triggering the same validation and submission flow as clicking the save button. Unlike `validateForm`, this method will check required fields and prevent submission unless all required fields are set |
### Callbacks
The `handleFormSubmit` method accepts an optional callbacks object with the following properties:
```typescript
type AvailabilitySettingsFormCallbacks = {
onSuccess?: () => void;
onError?: (error: Error) => void;
};
```
- **onSuccess**: Called when the form submission is successful. This allows you to execute additional logic after a successful update.
- **onError**: Called when an error occurs during form submission. The error parameter contains details about what went wrong, allowing you to handle specific error cases or display custom error messages.
The `validateForm` method returns an `AvailabilityFormValidationResult` object with:
- `isValid`: Boolean indicating if the form passed validation
- `errors`: Object containing any validation errors found
+1
View File
@@ -126,6 +126,7 @@ Below is a list of props that can be passed to the booker atom.
| confirmButtonDisabled | No | Boolean indicating if the submit button should be disabled, defaults to false. |
| timeZones | No | Array of valid IANA timezones to be used in the booker. Eg. ["Asia/Kolkata", "Europe/London"] |
| onTimeslotsLoaded | No | Callback function triggered once the available timeslots have been fetched. |
| roundRobinHideOrgAndTeam | No | Boolean indicating if the organization and team should be hidden in the booker atom sidebar for round robin scheduling type, defaults to false. |
## Styling
+24 -1
View File
@@ -218,7 +218,16 @@ export function EventTypeWithValidation(eventTypeId: number) {
};
const handleSubmit = () => {
eventTypeRef.current?.handleFormSubmit();
eventTypeRef.current?.handleFormSubmit({
onSuccess: () => {
// Additional success handling logic here
console.log('Event type updated successfully');
},
onError: (error) => {
// Additional error handling logic here
console.error('Error updating event type:', error);
}
});
};
return (
@@ -244,6 +253,20 @@ export function EventTypeWithValidation(eventTypeId: number) {
| validateForm | Validates the current event type form state and returns a promise with validation results. |
| handleFormSubmit | Programmatically submits the event type form, triggering the same validation and submission flow as clicking the save button. Unlike `validateForm`, this method will check required fields and prevent submission unless all required fields are set |
### Callbacks
The `handleFormSubmit` method accepts an optional callbacks object with the following properties:
```typescript
type EventTypeFormCallbacks = {
onSuccess?: () => void;
onError?: (error: Error) => void;
};
```
- **onSuccess**: Called when the form submission is successful. This allows you to execute additional logic after a successful update.
- **onError**: Called when an error occurs during form submission. The error parameter contains details about what went wrong, allowing you to handle specific error cases or display custom error messages.
The `validateForm` method returns an `EventTypeFormValidationResult` object with:
- `isValid`: Boolean indicating if the form passed validation
- `errors`: Object containing any validation errors found
+1
View File
@@ -118,6 +118,7 @@
"@daily-co/daily-js": "^0.76.0",
"@evyweb/ioctopus": "^1.2.0",
"@next/third-parties": "^14.2.5",
"@unkey/ratelimit": "^2.1.2",
"@vercel/functions": "^1.4.0",
"city-timezones": "^1.2.1",
"date-fns-tz": "^3.2.0",
@@ -60,10 +60,13 @@ const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({
) : (
requirePayment && (
<>
<div className="mt-4 block items-center justify-start sm:flex sm:space-x-2">
<div className="mt-4 inline-block">
<label className="text-default block text-sm font-medium mb-1" htmlFor="price">
{t("price")}
</label>
<TextField
label={t("price")}
className="text-black"
className="text-black dark:text-white w-auto"
addOnClassname="h-[38px]"
min="1"
type="number"
@@ -88,7 +91,6 @@ const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({
variant="default"
options={currencyOptions}
value={selectedCurrency}
className="text-black"
defaultValue={selectedCurrency}
onChange={(e) => {
if (e) {
@@ -132,3 +134,4 @@ const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({
};
export default EventTypeAppSettingsInterface;
@@ -1,9 +1,35 @@
export const currencyOptions = [
{ label: "SATS", value: "BTC", unit: "SATS" },
{ label: "USD", value: "USD", unit: "USD" },
{ label: "USD- US Dollar", value: "USD", unit: "USD" },
{ label: "EUR- Euro", value: "EUR", unit: "EUR" },
{ label: "JPY- Japanese Yen", value: "JPY", unit: "JPY" },
{ label: "CNY- Yuan Renminbi", value: "CNY", unit: "CNY" },
{ label: "GBP- Pounds Sterling", value: "GBP", unit: "GBP" },
{ label: "AED- UAE Dirham", value: "AED", unit: "AED" },
{ label: "ZAR- South African Rand", value: "ZAR", unit: "ZAR" },
{ label: "HKD- Hong Kong Dollar", value: "HKD", unit: "HKD" },
{ label: "BRL- Brazilian Real", value: "BRL", unit: "BRL" },
{ label: "AUD- Australian Dollar", value: "AUD", unit: "AUD" },
{ label: "CAD- Canadian Dollar", value: "CAD", unit: "CAD" },
{ label: "CZK- Czech Koruna", value: "CZK", unit: "CZK" },
{ label: "DKK- Danish Krone", value: "DKK", unit: "DKK" },
{ label: "NZD- New Zealand Dollar", value: "NZD", unit: "NZD" },
{ label: "MYR- Malaysian Ringgit", value: "MYR", unit: "MYR" },
{ label: "PHP- Philippine Peso", value: "PHP", unit: "PHP" },
{ label: "CHF- Swiss Franc", value: "CHF", unit: "CHF" },
{ label: "NOK- Norwegian Krone", value: "NOK", unit: "NOK" },
{ label: "THB- Thai Baht", value: "THB", unit: "THB" },
{ label: "SEK- Swedish Krona", value: "SEK", unit: "SEK" },
{ label: "SGD- Singapore Dollar", value: "SGD", unit: "SGD" },
{ label: "PLN- Polish Zloty", value: "PLN", unit: "PLN" },
{ label: "TWD- New Taiwan Dollar", value: "TWD", unit: "TWD" },
{ label: "MXN- Mexican Peso", value: "MXN", unit: "MXN" },
{ label: "ILS- New Isreali Shekel", value: "ILS", unit: "ILS" },
{ label: "NGN- Nigerian Naira", value: "NGN", unit: "NGN" }
];
const zeroDecimalCurrencies = ["SATS", "BTC"];
const zeroDecimalCurrencies = ["SATS", "BTC", "JPY"];
export const convertToSmallestCurrencyUnit = (amount: number, currency: string) => {
if (zeroDecimalCurrencies.includes(currency.toUpperCase())) {
@@ -17,4 +43,4 @@ export const convertFromSmallestToPresentableCurrencyUnit = (amount: number, cur
return amount;
}
return amount / 100;
};
};
+6 -1
View File
@@ -97,6 +97,7 @@ export const AppDependencyComponent = ({
{t("this_app_requires_connected_account", {
appName,
dependencyName: dependency.name,
interpolation: { escapeValue: false },
})}
</span>
</div>
@@ -112,7 +113,11 @@ export const AppDependencyComponent = ({
</div>
<div>
<span className="font-semibold">
{t("this_app_requires_connected_account", { appName, dependencyName: dependency.name })}
{t("this_app_requires_connected_account", {
appName,
dependencyName: dependency.name,
interpolation: { escapeValue: false },
})}
</span>
<div>
@@ -1114,4 +1114,18 @@ export default class GoogleCalendarService implements Calendar {
throw error;
}
}
async getMainTimeZone(): Promise<string> {
try {
const primaryCalendar = await this.getPrimaryCalendar();
if (!primaryCalendar?.timeZone) {
this.log.warn("No timezone found in primary calendar, defaulting to UTC");
return "UTC";
}
return primaryCalendar.timeZone;
} catch (error) {
this.log.error("Error getting main timezone from Google Calendar", { error });
throw error;
}
}
}
+2
View File
@@ -61,6 +61,8 @@ export const DailyLocationType = "integrations:daily";
export const MeetLocationType = "integrations:google:meet";
export const MSTeamsLocationType = "integrations:office365_video";
/**
* This isn't an actual location app type. It is a special value that informs to use the Organizer's default conferencing app during booking
*/
@@ -1,8 +1,9 @@
import type { Calendar as OfficeCalendar, User, Event } from "@microsoft/microsoft-graph-types-beta";
import type { DefaultBodyType } from "msw";
import { MSTeamsLocationType } from "@calcom/app-store/locations";
import dayjs from "@calcom/dayjs";
import { getLocation } from "@calcom/lib/CalEventParser";
import { getLocation, getRichDescriptionHTML } from "@calcom/lib/CalEventParser";
import {
CalendarAppDelegationCredentialInvalidGrantError,
CalendarAppDelegationCredentialConfigurationError,
@@ -254,7 +255,13 @@ export default class Office365CalendarService implements Calendar {
body: JSON.stringify(this.translateEvent(event)),
});
const responseJson = await handleErrorsJson<NewCalendarEventType & { iCalUId: string }>(response);
const responseJson = await handleErrorsJson<
NewCalendarEventType & { iCalUId: string; onlineMeeting?: { joinUrl?: string } }
>(response);
if (responseJson?.onlineMeeting?.joinUrl) {
responseJson.url = responseJson?.onlineMeeting?.joinUrl;
}
return { ...responseJson, iCalUID: responseJson.iCalUId };
} catch (error) {
@@ -266,12 +273,28 @@ export default class Office365CalendarService implements Calendar {
async updateEvent(uid: string, event: CalendarServiceEvent): Promise<NewCalendarEventType> {
try {
let rescheduledEvent: Event | undefined;
if (event.location === MSTeamsLocationType) {
// Extract the existing body content to preserve the meeting blob, otherwise it breaks and converts it into non-onlineMeeting
const response = await this.fetcher(`${await this.getUserEndpoint()}/calendar/events/${uid}`, {
method: "GET",
});
rescheduledEvent = await handleErrorsJson<Event>(response);
}
const response = await this.fetcher(`${await this.getUserEndpoint()}/calendar/events/${uid}`, {
method: "PATCH",
body: JSON.stringify(this.translateEvent(event)),
body: JSON.stringify(this.translateEvent(event, rescheduledEvent)),
});
const responseJson = await handleErrorsJson<NewCalendarEventType & { iCalUId: string }>(response);
const responseJson = await handleErrorsJson<
NewCalendarEventType & { iCalUId: string; onlineMeeting?: { joinUrl?: string } }
>(response);
if (responseJson?.onlineMeeting?.joinUrl) {
responseJson.url = responseJson?.onlineMeeting?.joinUrl;
}
return { ...responseJson, iCalUID: responseJson.iCalUId };
} catch (error) {
@@ -401,12 +424,30 @@ export default class Office365CalendarService implements Calendar {
});
}
private translateEvent = (event: CalendarServiceEvent) => {
private translateEvent = (event: CalendarServiceEvent, rescheduledEvent?: Event) => {
const isOnlineMeeting = event.location === MSTeamsLocationType;
const isRescheduledOnlineMeeting = rescheduledEvent ? rescheduledEvent.isOnlineMeeting : false;
const existingBody =
rescheduledEvent?.body?.contentType === "html" ? rescheduledEvent.body.content : undefined;
let content = "";
if (isOnlineMeeting) {
if (isRescheduledOnlineMeeting && existingBody) {
content = `
${getRichDescriptionHTML(event)}<hr>
${existingBody}`.trim();
} else {
content = getRichDescriptionHTML(event);
}
} else {
content = event.calendarDescription;
}
const office365Event: Event = {
subject: event.title,
body: {
contentType: "text",
content: event.calendarDescription,
contentType: isOnlineMeeting ? "html" : "text",
content,
},
start: {
dateTime: dayjs(event.startTime).tz(event.organizer.timeZone).format("YYYY-MM-DDTHH:mm:ss"),
@@ -456,6 +497,17 @@ export default class Office365CalendarService implements Calendar {
if (event.hideCalendarEventDetails) {
office365Event.sensitivity = "private";
}
if (isOnlineMeeting) {
office365Event.isOnlineMeeting = true;
office365Event.allowNewTimeProposals = true;
office365Event.onlineMeetingProvider = "teamsForBusiness";
// MSTeams sets location as 'Microsoft Teams Meeting' by default, if location is undefined.
// For backward compatibility, setting explicitly.
office365Event.location =
rescheduledEvent && !isRescheduledOnlineMeeting
? { displayName: "Microsoft Teams Meeting" }
: undefined;
}
return office365Event;
};
@@ -625,4 +677,21 @@ export default class Office365CalendarService implements Calendar {
return response.json();
};
async getMainTimeZone(): Promise<string> {
try {
const response = await this.fetcher(`${await this.getUserEndpoint()}/mailboxSettings/timeZone`);
const timezone = await handleErrorsJson<string>(response);
if (!timezone) {
this.log.warn("No timezone found in mailbox settings, defaulting to Europe/London");
return "Europe/London";
}
return timezone;
} catch (error) {
this.log.error("Error getting main timezone from Office365 Calendar", { error });
throw error;
}
}
}
@@ -21,6 +21,7 @@ export const metadata = {
publisher: "Cal.com",
slug: "msteams",
dirName: "office365video",
dependencies: ["office365-calendar"],
url: "https://www.microsoft.com/en-ca/microsoft-teams/group-chat-software",
email: "help@cal.com",
isOAuth: true,
@@ -22,6 +22,7 @@
}
},
"dirName": "office365video",
"dependencies": ["office365-calendar"],
"concurrentMeetings": true,
"isOAuth": true
}
@@ -72,7 +72,13 @@ function useBreakPoints() {
* It has the the ongoing changes in the form along with enrichedWithUserProfileForm specific data.
* So, it can be used to test the form in the test preview dialog without saving the changes even.
*/
function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleFormComponentProps) {
function SingleForm({
form,
appUrl,
Page,
enrichedWithUserProfileForm,
permissions,
}: SingleFormComponentProps) {
const utils = trpc.useUtils();
const { t } = useLocale();
const [newFormDialogState, setNewFormDialogState] = useState<NewFormDialogState>(null);
@@ -153,6 +159,7 @@ function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleF
setShowInfoLostDialog={setShowInfoLostDialog}
setIsTestPreviewOpen={setIsTestPreviewOpen}
isTestPreviewOpen={isTestPreviewOpen}
permissions={permissions}
/>
<div
className={classNames(
@@ -59,6 +59,7 @@ const Actions = ({
setIsTestPreviewOpen,
isTestPreviewOpen,
isMobile = false,
permissions,
}: {
form: RoutingFormWithResponseCount;
setIsTestPreviewOpen: (value: boolean) => void;
@@ -66,6 +67,12 @@ const Actions = ({
appUrl: string;
isTestPreviewOpen: boolean;
isMobile?: boolean;
permissions: {
canCreate: boolean;
canRead: boolean;
canEdit: boolean;
canDelete: boolean;
};
}) => {
const { t } = useLocale();
const formContext = useFormContext<RoutingFormWithResponseCount>();
@@ -165,6 +172,7 @@ const Actions = ({
className="w-full"
type="button"
color="destructive"
disabled={!permissions.canDelete}
StartIcon="trash">
{t("delete")}
</FormAction>
@@ -182,6 +190,7 @@ const Actions = ({
<Button
data-testid={isMobile ? "update-form-mobile" : "update-form"}
loading={isSaving}
disabled={!permissions.canEdit}
type="submit"
color="primary">
{t("save")}
@@ -206,6 +215,7 @@ export function Header({
setShowInfoLostDialog,
setIsTestPreviewOpen,
isTestPreviewOpen,
permissions,
}: {
routingForm: RoutingFormWithResponseCount;
isSaving: boolean;
@@ -213,6 +223,12 @@ export function Header({
setShowInfoLostDialog: (value: boolean) => void;
setIsTestPreviewOpen: (value: boolean) => void;
isTestPreviewOpen: boolean;
permissions: {
canCreate: boolean;
canRead: boolean;
canEdit: boolean;
canDelete: boolean;
};
}) {
const { t } = useLocale();
const [isEditing, setIsEditing] = useState(false);
@@ -324,7 +340,8 @@ export function Header({
isSaving={isSaving}
appUrl={appUrl}
isMobile={true}
/>
permissions={permissions}
/>{" "}
</div>
</div>
@@ -357,6 +374,7 @@ export function Header({
isTestPreviewOpen={isTestPreviewOpen}
isSaving={isSaving}
appUrl={appUrl}
permissions={permissions}
/>
</div>
</div>
@@ -1,3 +1,7 @@
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import { MembershipRole } from "@calcom/prisma/enums";
import type { AppGetServerSidePropsContext, AppPrisma, AppUser } from "@calcom/types/AppGetServerSideProps";
import { enrichFormWithMigrationData } from "../enrichFormWithMigrationData";
@@ -30,16 +34,6 @@ export const getServerSidePropsForSingleFormView = async function getServerSideP
};
}
const isFormCreateEditAllowed = (await import("../lib/isFormCreateEditAllowed")).isFormCreateEditAllowed;
if (!(await isFormCreateEditAllowed({ userId: user.id, formId, targetTeamId: null }))) {
return {
redirect: {
permanent: false,
destination: "/403",
},
};
}
const form = await prisma.app_RoutingForms_Form.findUnique({
where: {
id: formId,
@@ -105,12 +99,69 @@ export const getServerSidePropsForSingleFormView = async function getServerSideP
user: await userRepo.enrichUserWithItsProfile({ user: form.user }),
};
// Get PBAC permissions for team-scoped routing forms
let permissions = {
canCreate: false,
canRead: false,
canEdit: false,
canDelete: false,
};
if (!form.teamId) {
// For personal forms (teamId = null),
// check if the form belongs to the current user
if (form.userId !== user.id) {
return {
notFound: true,
};
}
permissions = {
canCreate: true,
canRead: true,
canEdit: true,
canDelete: true,
};
} else {
// team-scoped routing form
// Get user's role in the team
const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({
userId: user.id,
teamId: form.teamId,
});
if (!membership) {
return {
notFound: true,
};
}
permissions = await getResourcePermissions({
userId: user.id,
teamId: form.teamId,
resource: Resource.RoutingForm,
userRole: membership.role,
fallbackRoles: {
read: { roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER] },
create: { roles: [MembershipRole.ADMIN, MembershipRole.OWNER] },
update: { roles: [MembershipRole.ADMIN, MembershipRole.OWNER] },
delete: { roles: [MembershipRole.ADMIN, MembershipRole.OWNER] },
},
});
}
return {
props: {
form: await getSerializableForm({ form: formWithoutProfileInfo }),
enrichedWithUserProfileForm: await getSerializableForm({
form: enrichFormWithMigrationData(formWithUserInfoProfile),
}),
permissions: {
canCreate: permissions.canCreate,
canRead: permissions.canRead,
canEdit: permissions.canEdit,
canDelete: permissions.canDelete,
},
},
};
};
@@ -0,0 +1,47 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import logger from "@calcom/lib/logger";
import { PrismaQueuedFormResponseRepository } from "@calcom/lib/server/repository/routingForm/queuedFormResponse/PrismaQueuedFormResponseRepository";
import { QueuedFormResponseService } from "@calcom/lib/server/service/routingForm/queuedFormResponse/QueuedFormResponseService";
import prisma from "@calcom/prisma";
function validateRequest(request: NextRequest) {
const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey");
if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) {
return NextResponse.json({ message: "Not authenticated" }, { status: 401 });
}
return null;
}
export async function handleQueuedFormResponseCleanup(request: NextRequest) {
const authError = validateRequest(request);
if (authError) {
return authError;
}
try {
const queuedFormResponseRepo = new PrismaQueuedFormResponseRepository(prisma);
const queuedFormResponseService = new QueuedFormResponseService({
logger,
queuedFormResponseRepo,
});
const result = await queuedFormResponseService.cleanupExpiredResponses({
batchSize: 1000,
});
return NextResponse.json({
status: "success",
data: result,
});
} catch (error) {
console.error("Error during queued form response cleanup:", error);
return NextResponse.json(
{ status: "error", message: "Failed to cleanup queued form responses" },
{ status: 500 }
);
}
}
@@ -2,6 +2,5 @@ export const routingFormsComponents = {
"form-edit": () => import("./form-edit/[...appPages]").then((mod) => mod.default),
"route-builder": () => import("./route-builder/[...appPages]").then((mod) => mod.default),
"routing-link": () => import("./routing-link/[...appPages]").then((mod) => mod.default),
reporting: () => import("./reporting/[...appPages]").then((mod) => mod.default),
"incomplete-booking": () => import("./incomplete-booking/[...appPages]").then((mod) => mod.default),
};
@@ -348,6 +348,7 @@ const FormEdit = ({
export default function FormEditPage({
appUrl,
permissions,
...props
}: inferSSRProps<typeof getServerSideProps> & { appUrl: string }) {
return (
@@ -356,6 +357,7 @@ export default function FormEditPage({
<SingleForm
{...props}
appUrl={appUrl}
permissions={permissions}
Page={({ hookForm, form }) => <FormEdit appUrl={appUrl} hookForm={hookForm} form={form} />}
/>
</>
@@ -218,7 +218,7 @@ export default function RoutingForms({ appUrl }: { appUrl: string }) {
<ListLinkItem
href={`${appUrl}/form-edit/${form.id}`}
heading={form.name}
disabled={readOnly}
readOnly={readOnly}
subHeading={description}
className="space-x-2 rtl:space-x-reverse"
actions={
@@ -320,12 +320,14 @@ export default function IncompleteBookingPage({
form,
appUrl,
enrichedWithUserProfileForm,
permissions,
}: inferSSRProps<typeof getServerSideProps> & { appUrl: string }) {
return (
<SingleForm
form={form}
appUrl={appUrl}
enrichedWithUserProfileForm={enrichedWithUserProfileForm}
permissions={permissions}
Page={Page}
/>
);
@@ -1,290 +0,0 @@
"use client";
import React, { useCallback, useRef, useState, useEffect } from "react";
import type {
BuilderProps,
Config,
ImmutableTree,
JsonLogicResult,
JsonTree,
} from "react-awesome-query-builder";
import { Builder, Query, Utils as QbUtils } from "react-awesome-query-builder";
import { downloadAsCsv, sanitizeValue } from "@calcom/lib/csvUtils";
import { useInViewObserver } from "@calcom/lib/hooks/useInViewObserver";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { showToast } from "@calcom/ui/components/toast";
import SingleForm from "../../components/SingleForm";
import type { getServerSidePropsForSingleFormView as getServerSideProps } from "../../components/getServerSidePropsSingleForm";
import {
withRaqbSettingsAndWidgets,
ConfigFor,
} from "../../components/react-awesome-query-builder/config/uiConfig";
import type { JsonLogicQuery } from "../../jsonLogicToPrisma";
import {
getQueryBuilderConfigForFormFields,
type FormFieldsQueryBuilderConfigWithRaqbFields,
} from "../../lib/getQueryBuilderConfig";
const Result = ({
formName,
formId,
jsonLogicQuery,
}: {
formName: string;
formId: string;
jsonLogicQuery: JsonLogicQuery | null;
}) => {
const { t } = useLocale();
const [isDownloading, setIsDownloading] = useState(false);
const { isPending, status, data, isFetching, error, isFetchingNextPage, hasNextPage, fetchNextPage } =
trpc.viewer.appRoutingForms.report.useInfiniteQuery(
{
formId: formId,
// Send jsonLogicQuery only if it's a valid logic, otherwise send a logic with no query.
jsonLogicQuery: jsonLogicQuery?.logic
? jsonLogicQuery
: {
logic: {},
},
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
const exportQuery = trpc.viewer.appRoutingForms.report.useInfiniteQuery(
{
limit: 100, // 100 is max
formId: formId,
// Send jsonLogicQuery only if it's a valid logic, otherwise send a logic with no query.
jsonLogicQuery: jsonLogicQuery?.logic
? jsonLogicQuery
: {
logic: {},
},
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
enabled: false,
}
);
const buttonInView = useInViewObserver(() => {
if (!isFetching && hasNextPage && status === "success") {
fetchNextPage();
}
});
const headers = useRef<string[] | null>(null);
if (!isPending && !data) {
return <div>Error loading report {error?.message} </div>;
}
headers.current = (data?.pages && data?.pages[0]?.headers) || headers.current;
const numberOfRows = data?.pages.reduce((total, page) => total + (page.responses?.length || 0), 0);
const downloadButtonDisabled = !numberOfRows || !data || !headers.current || isDownloading;
const handleDownload = async () => {
try {
setIsDownloading(true);
if (downloadButtonDisabled) {
return;
}
const result = await exportQuery.refetch();
if (!result.data) {
throw new Error("There are no routing forms found.");
}
const allRows = result.data.pages.flatMap((page) =>
page.responses.map((response) => `${response.map((value) => sanitizeValue(value)).join(",")}\n`)
);
let lastPage = result.data.pages[result.data.pages.length - 1];
while (lastPage.nextCursor) {
const nextPage = await exportQuery.fetchNextPage();
if (!nextPage.data) {
break;
}
const latestPageItems = nextPage.data.pages[nextPage.data.pages.length - 1].responses ?? [];
allRows.push(
...latestPageItems.map((response) => `${response.map((value) => sanitizeValue(value)).join(",")}\n`)
);
lastPage = nextPage.data.pages[nextPage.data.pages.length - 1];
}
const header = `${(headers.current ?? []).map((value) => sanitizeValue(value)).join(",")}\n`;
const csvRaw = header + allRows.join("");
const filename = `${formName}_${new Date().toISOString().split("T")[0]}.csv`; // e.g., ${FormName}_2024-10-30.csv
downloadAsCsv(csvRaw, filename);
} catch (error) {
showToast(`Error: ${error}`, "error");
} finally {
setIsDownloading(false);
}
};
return (
<div className="w-full max-w-[2000px] overflow-x-scroll">
{!isPending && (
<div className="mb-4 inline-block flex min-w-full items-center px-3">
<Button
disabled={downloadButtonDisabled}
StartIcon="file-down"
color="secondary"
className="mr-3"
onClick={() => handleDownload()}>
{t("download")}
</Button>
<div className="text-default text-md">
{`${numberOfRows} ${numberOfRows === 1 ? t("row") : t("rows")}`}
</div>
</div>
)}
<table
data-testid="reporting-table"
className="border-default bg-subtle mx-3 mb-4 min-w-full table-fixed border-separate border-spacing-0 overflow-hidden rounded-md border">
<tr
data-testid="reporting-header"
className="border-default text-default bg-emphasis rounded-md border-b">
{headers.current?.map((header, index) => (
<th
className={classNames(
"border-default border-b px-2 py-3 text-left text-base font-medium",
index !== (headers.current?.length || 0) - 1 ? "border-r" : ""
)}
key={index}>
{header}
</th>
))}
</tr>
{!isPending &&
data?.pages.map((page) => {
return page.responses?.map((responses, rowIndex) => {
const isLastRow = page.responses.length - 1 === rowIndex;
return (
<tr
key={rowIndex}
data-testid="reporting-row"
className={classNames(
"text-center text-sm",
rowIndex % 2 ? "" : "bg-default",
isLastRow ? "" : "border-b"
)}>
{responses.map((r, columnIndex) => {
const isLastColumn = columnIndex === responses.length - 1;
return (
<td
className={classNames(
"border-default overflow-x-hidden whitespace-pre-line px-2 py-3 text-left",
isLastRow ? "" : "border-b",
isLastColumn ? "" : "border-r"
)}
key={columnIndex}>
{r}
</td>
);
})}
</tr>
);
});
})}
</table>
{isPending ? <div className="text-default p-2">{t("loading")}</div> : ""}
{hasNextPage && (
<Button
type="button"
color="minimal"
ref={buttonInView.ref}
loading={isFetchingNextPage}
disabled={!hasNextPage}
onClick={() => fetchNextPage()}>
{hasNextPage ? t("load_more_results") : t("no_more_results")}
</Button>
)}
</div>
);
};
const getInitialQuery = (config: ReturnType<typeof getQueryBuilderConfigForFormFields>) => {
const uuid = QbUtils.uuid();
const queryValue: JsonTree = { id: uuid, type: "group" } as JsonTree;
const tree = QbUtils.checkTree(QbUtils.loadTree(queryValue), config as unknown as Config);
return {
state: { tree, config },
queryValue,
};
};
const Reporter = ({ form }: { form: inferSSRProps<typeof getServerSideProps>["form"] }) => {
const config = getQueryBuilderConfigForFormFields(form, true);
const [query, setQuery] = useState(getInitialQuery(config));
const [jsonLogicQuery, setJsonLogicQuery] = useState<JsonLogicResult | null>(null);
const onChange = (immutableTree: ImmutableTree, config: FormFieldsQueryBuilderConfigWithRaqbFields) => {
const jsonTree = QbUtils.getTree(immutableTree);
setQuery(() => {
const newValue = {
state: { tree: immutableTree, config: config },
queryValue: jsonTree,
};
setJsonLogicQuery(QbUtils.jsonLogicFormat(newValue.state.tree, config as unknown as Config));
return newValue;
});
};
const renderBuilder = useCallback(
(props: BuilderProps) => (
<div className="query-builder-container">
<div className="query-builder qb-lite">
<Builder {...props} />
</div>
</div>
),
[]
);
return (
<div className="cal-query-builder">
<Query
{...withRaqbSettingsAndWidgets({
config,
configFor: ConfigFor.FormFields,
})}
value={query.state.tree}
onChange={(immutableTree, config) => {
onChange(immutableTree, config as unknown as FormFieldsQueryBuilderConfigWithRaqbFields);
}}
renderBuilder={renderBuilder}
/>
<Result formName={form.name} formId={form.id} jsonLogicQuery={jsonLogicQuery as JsonLogicQuery} />
</div>
);
};
export default function ReporterWrapper({
...props
}: inferSSRProps<typeof getServerSideProps> & { appUrl: string }) {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
// It isn't possible to render Reporter without hydration errors if it is rendered on the server.
// This is because the RAQB generates some dynamic ids on elements which change b/w client and server.
// This is a workaround to render the Reporter on the client only.
setIsClient(true);
}, []);
return (
<SingleForm
{...props}
Page={({ form }) => (
<div className="route-config bg-default fixed inset-0 w-full overflow-scroll pt-12 ltr:mr-2 rtl:ml-2 sm:pt-0">
{isClient && <Reporter form={form} />}
</div>
)}
/>
);
}
@@ -1429,6 +1429,7 @@ export default function RouteBuilder({
form,
appUrl,
enrichedWithUserProfileForm,
permissions,
}: inferSSRProps<typeof getServerSideProps> & { appUrl: string }) {
return (
<>
@@ -1436,6 +1437,7 @@ export default function RouteBuilder({
form={form}
appUrl={appUrl}
enrichedWithUserProfileForm={enrichedWithUserProfileForm}
permissions={permissions}
Page={Page}
/>
<Toaster position="bottom-right" />
@@ -408,52 +408,6 @@ test.describe("Routing Forms", () => {
// Log back in to view form responses.
await user.apiLogin();
await page.goto(`/routing-forms/reporting/${routingForm.id}`);
const headerEls = page.locator("[data-testid='reporting-header'] th");
// Wait for the headers to be visible(will automaically wait for getting response from backend) along with it the rows are rendered.
await headerEls.first().waitFor();
const numHeaderEls = await headerEls.count();
const headers = [];
for (let i = 0; i < numHeaderEls; i++) {
headers.push(await headerEls.nth(i).innerText());
}
const responses = [];
const responseRows = page.locator("[data-testid='reporting-row']");
const numResponseRows = await responseRows.count();
for (let i = 0; i < numResponseRows; i++) {
const rowLocator = responseRows.nth(i).locator("td");
const numRowEls = await rowLocator.count();
const rowResponses = [];
for (let j = 0; j < numRowEls; j++) {
rowResponses.push(await rowLocator.nth(j).innerText());
}
responses.push(rowResponses);
}
expect(headers).toEqual([
"Test field",
"Multi Select(with Legacy `selectText`)",
"Multi Select",
"Legacy Select",
"Select",
// TODO: Find a way to incorporate Routed To and Booked At into the report
// @see https://github.com/calcom/cal.com/pull/17229
"Routed To",
"Assignment Reason",
"Booked At",
"Submitted At",
]);
/* Last two columns are "Routed To" and "Booked At" */
expect(responses).toEqual([
["custom-page", "Option-2", "Option-2", "Option-2", "Option-2", "", "", "", expect.any(String)],
["external-redirect", "Option-2", "Option-2", "Option-2", "Option-2", "", "", "", expect.any(String)],
["event-routing", "Option-2", "Option-2", "Option-2", "Option-2", "", "", "", expect.any(String)],
]);
await page.goto(`apps/routing-forms/route-builder/${routingForm.id}`);
const downloadPromise = page.waitForEvent("download");
@@ -1,12 +1,13 @@
import { entityPrismaWhereClause } from "@calcom/lib/entityPermissionUtils.server";
import type { PrismaClient } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
import getConnectedForms from "../lib/getConnectedForms";
import { isFormCreateEditAllowed } from "../lib/isFormCreateEditAllowed";
import type { TDeleteFormInputSchema } from "./deleteForm.schema";
import { checkPermissionOnExistingRoutingForm } from "./permissions";
interface DeleteFormHandlerOptions {
ctx: {
@@ -17,11 +18,13 @@ interface DeleteFormHandlerOptions {
}
export const deleteFormHandler = async ({ ctx, input }: DeleteFormHandlerOptions) => {
const { user, prisma } = ctx;
if (!(await isFormCreateEditAllowed({ userId: user.id, formId: input.id, targetTeamId: null }))) {
throw new TRPCError({
code: "FORBIDDEN",
});
}
await checkPermissionOnExistingRoutingForm({
formId: input.id,
userId: user.id,
permission: "routingForm.delete",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
const areFormsUsingIt = (
await getConnectedForms(prisma, {
@@ -1,8 +1,10 @@
import type { App_RoutingForms_Form } from "@prisma/client";
import { Prisma } from "@prisma/client";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { entityPrismaWhereClause, canEditEntity } from "@calcom/lib/entityPermissionUtils.server";
import type { PrismaClient } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
@@ -16,6 +18,7 @@ import isRouterLinkedField from "../lib/isRouterLinkedField";
import type { SerializableForm } from "../types/types";
import { zodFields, zodRouterRoute, zodRoutes } from "../zod";
import type { TFormMutationInputSchema } from "./formMutation.schema";
import { checkPermissionOnExistingRoutingForm } from "./permissions";
interface FormMutationHandlerOptions {
ctx: {
@@ -30,10 +33,36 @@ export const formMutationHandler = async ({ ctx, input }: FormMutationHandlerOpt
const { name, id, description, disabled, addFallback, duplicateFrom, shouldConnect } = input;
let teamId = input.teamId;
const settings = input.settings;
if (!(await isFormCreateEditAllowed({ userId: user.id, formId: id, targetTeamId: teamId }))) {
throw new TRPCError({
code: "FORBIDDEN",
const existingForm = await prisma.app_RoutingForms_Form.findUnique({
where: { id },
select: { id: true },
});
if (existingForm) {
// Check PBAC permissions for updating routing forms only
await checkPermissionOnExistingRoutingForm({
formId: existingForm.id,
userId: user.id,
permission: "routingForm.update",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
} else if (teamId) {
// Check PBAC permissions for creating team-scoped routing forms only
// Personal forms (teamId = null) are always allowed for the user
const permissionService = new PermissionCheckService();
const hasPermission = await permissionService.checkPermission({
userId: user.id,
teamId,
permission: "routingForm.create",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
if (!hasPermission) {
throw new TRPCError({
code: "FORBIDDEN",
message: `You don't have permission to create routing forms for this team`,
});
}
}
let { routes: inputRoutes, fields: inputFields } = input;
@@ -1,9 +1,13 @@
import { entityPrismaWhereClause } from "@calcom/lib/entityPermissionUtils.server";
import type { PrismaClient } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
import { getSerializableForm } from "../lib/getSerializableForm";
import type { TFormQueryInputSchema } from "./formQuery.schema";
import { checkPermissionOnExistingRoutingForm } from "./permissions";
interface FormsHandlerOptions {
ctx: {
@@ -38,6 +42,13 @@ export const formQueryHandler = async ({ ctx, input }: FormsHandlerOptions) => {
return null;
}
await checkPermissionOnExistingRoutingForm({
formId: input.id,
userId: user.id,
permission: "routingForm.read",
fallbackRoles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
});
return await getSerializableForm({ form });
};
@@ -0,0 +1,56 @@
import type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { PrismaRoutingFormRepository } from "@calcom/lib/server/repository/PrismaRoutingFormRepository";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
export async function checkPermissionOnExistingRoutingForm({
formId,
userId,
permission,
fallbackRoles,
}: {
formId: string;
userId: number;
permission: PermissionString;
fallbackRoles: MembershipRole[];
}) {
// First get the form to check its team context
const form = await PrismaRoutingFormRepository.findById(formId, {
select: { teamId: true, userId: true },
});
if (!form) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Form not found",
});
}
// Check PBAC permissions for personal-scoped routing forms only
if (!form.teamId && form.userId !== userId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have permission to this personal-scoped routing form",
});
}
// Check PBAC permissions for team-scoped routing forms only
if (form.teamId) {
const permissionService = new PermissionCheckService();
const hasPermission = await permissionService.checkPermission({
userId: userId,
teamId: form.teamId,
permission,
fallbackRoles,
});
if (!hasPermission) {
throw new TRPCError({
code: "FORBIDDEN",
message: `You don't have "${permission}" permission for this routing form`,
});
}
}
}
@@ -16,4 +16,5 @@ export type SingleFormComponentProps = {
enrichedWithUserProfileForm: inferSSRProps<
typeof getServerSidePropsForSingleFormView
>["enrichedWithUserProfileForm"];
permissions: inferSSRProps<typeof getServerSidePropsForSingleFormView>["permissions"];
};

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