Compare commits

..
Author SHA1 Message Date
Rajiv Sahal eae2711f21 chore: update docs to make sure to inform users to pass in cal-api-version header (#24364)
Post release / release (push) Canceled after 0s
2025-10-10 11:00:27 +00:00
emrysal fac956b761 chore: release v5.7.11 2025-10-10 10:54:23 +00:00
b286244aab fix: api v2 logic for slots reservation endpoint (#23222)
* init logic for handling reservation for round robin event

* remove unused comments

* add function to get already reserved slots

* fixup: add correct logic for round robin slots reservation

* fixup: implement PR feedback

* fixup: update logic to handle fixed and non fixed round robin hosts

* update slots repository

* fixup: implement PR feedback

* fixup: implement PR feedback

* add tests for round robin slot reservation with non fixed hosts

* add tests for round robin slot reservation with fixed and non fixed hosts

* fix: implement PR feedback

* fix: e2e test

* fix: move validateRoundRobinSlotAvailability to core libraries

* fix: merge conflicts

* fix: merge conflicts

* update slots.ts for platform libraries

* fix: import logic from platform libraries

* cleanup

* fix: import path

* fix: missing import

* fix: test code and handle error thrown

---------

Co-authored-by: supalarry <laurisskraucis@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-10-10 12:35:21 +02:00
emrysal c46f824e3f chore: release vv5.7.11 2025-10-10 10:31:11 +00:00
Benny Joo dbe55f0804 refactor: Break videoClient into two separate files in app-store and features (#23992)
* getVideoAdapters

* fix
2025-10-10 07:11:46 -03:00
Hariom BalharaandVolnei Munhoz 3bcce02b92 chore: [Booking flow refactor - 2] Integrate Booking services (#23156)
* Integrate booking services

* Fix imports

---------

Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
2025-10-09 18:21:55 -03:00
Anik Dhabal Babu 2a23911a5b fix: extra 30-minute buffer was added to 60-minute slots without it being set (#24087)
* fix: 30 min buffer added on 60 min slots

* update

* fix test

* Update restrictionSchedule.test.ts

* addressed test

* Update getSchedule.test.ts
2025-10-09 18:37:52 +01:00
sean-brydonandcoderabbitai[bot] 5aa24c8472 fix: event-type permissions (#24318)
* New permission function

* Update permission check service to have fall backs

Update event type create permission procedure to also have org heigharchy

* Update get eventType handler to use new procedure

* Type check

* Type fix

* Update packages/features/pbac/lib/event-type-permissions.ts

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

* Fix type errorr

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-10-09 12:30:12 -03:00
Benny Joo 8277ce934b fix: ESLint config in app-store and features (#24387)
* make eslint config in app-store

* remove the one in features
2025-10-09 14:09:23 +00:00
Benny Joo bb68cd73ef refactor: circular deps between app store and lib [6] (#23971)
* move delegation credential repository to features

* mv credential repository to features

* update imports

* mv

* fix

* fix

* fix

* fix

* fix

* update imports

* update imports

* update eslint rule

* fix

* fix

* mv getConnectedDestinationCalendars

* fix import errors

* mv getCalendarsEvents

* remove getUsersCredentials

* wip

* revert eslint rule change for now

* fix type checks

* fix

* format

* cleanup

* fix

* fix

* fix

* fix

* fix tests

* migrate getUserAvailability

* migrate

* fix tests

* fix type checks

* fix

* fix

* migrate crmManager

* update imports

* migrate raqbUtils to appstore

* migrate getLuckyUser to features

* migrate findTeamMembersMatchingAttributeLogic to appstore

* update imports

* fix

* fix

* fix test

* fix unit tests

* fix

* fix

* add eslint config
2025-10-09 14:02:12 +00:00
Rajiv Sahal 445b307972 fix: week view and column view slots not fetching at end of month bug (#24374)
* fix: dont pick date value from url, use selectedDate value from booker store instead

* fix: replace month count logic with usePrefectch hook

* intialise booker layout as week view
2025-10-09 13:56:59 +01:00
Calcom BotandLingo.dev d2d86a4079 feat: update translations via @LingoDotDev (#24383)
Co-authored-by: Lingo.dev <support@lingo.dev>
2025-10-09 21:43:34 +09:00
chauhan_s 7601cc2194 fix: PER_ACTIVE_USER Plan should be able to access everything above SCALE (#24352)
* Update managed-organizations.billing.service.ts

* test: add plan restriction check for managed organization creation

* test: add pay-per-user plan validation for managed organization creation

* refactor: extract test setup to helpers

* refactor: move helper functions below beforeAll for better test organization
2025-10-09 09:26:22 -03:00
Udit TakkarandCarina Wollendorfer aa48e72c12 feat: inbound calls in cal ai (#23890)
* feat: lang support

* fix: type errors

* feat: select voice agent

* refactor: address feedback

* refactor: address feedback

* refactor: missing import

* fix: types

* feat: add inbound calls

* chore: formatting

* chore

* feat: finish inbound call

* chore: formatting

* fix: update bug

* fix: types

* refactor: Agent Configuration Sheet (#23930)

* refactor: agent configuration sheet

* chore: use default phone numbre

* refactor: improvements

* refactor: improvements

* fix: types

* fix: feedback

* chore:

* fix: feedback

* fix: prompt

* fix: review

* fix: review

* refactor: class

* refactor: class

* refactor: rename

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

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

* chore: update set value

* fix: remove index

* fix: type error

* fix: update tetss

* fix: use logger

* refactor: don't use static

* fix: type

* fix: schema

* refactor:

---------

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2025-10-09 11:40:37 +01:00
Hariom BalharaandDevin AI bec49ef417 fix: provide meetingStartTime for test preview to prevent 'Meeting start time is required' crash (#24379)
Post release / release (push) Canceled after 0s
* fix: provide meetingStartTime for test preview to prevent RR timestamp error

When Round Robin timestamp basis is set to START_TIME, the system requires
a meeting start time to calculate the interval for load balancing. Test
Preview doesn't have a real meeting time since it's just simulating the
routing logic.

This fix provides the current time (new Date()) as the meetingStartTime
when calling getOrderedListOfLuckyUsers in test preview mode. This allows
the Round Robin calculation to proceed using the current interval for
realistic ordering while still being in preview mode.

Fixes the 'Meeting start time is required' error that occurs when users
click Test Preview on routing forms with Round Robin timestamp basis set
to START_TIME.

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

* add comment

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-09 08:58:57 +00:00
Kartik LabhshetwarandDevanshu Sharma c13aa69565 fix: improve cross button alignment and spacing in FormBuilder Options (#24247)
* fix: improve cross button alignment and spacing in FormBuilder Options

* fix: revert eslint errors

---------

Co-authored-by: Devanshu Sharma <devanshusharma658@gmail.com>
2025-10-09 08:18:19 +00:00
emrysal 1470adb159 chore: release v5.7.10 2025-10-09 08:17:36 +00:00
emrysal bf89991eef chore: release v5.9.10 2025-10-09 00:22:21 +00:00
iqram magdon-ismail 4dfab874e0 refactor: update JellyPhone icon (#24341)
change the icon for jellyphone
2025-10-08 14:38:54 +00:00
Joe Au-Yeung 4303fb8726 feat: Salesforce - add urql/retry-exchange (#24302)
* Add .env variables

* Add `urql/exchange-retry`

* Add retry mechanic to graphql client

* Only add retries if .env variables are set

* Remove console.log
2025-10-08 14:03:40 +00:00
64f811ed0b feat: show organization username in impersonation banner (#24313)
- Update ImpersonatingBanner to use orgAwareUsername field from session
- Falls back to regular username for non-organization users
- Uses existing session data without additional queries

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-10-08 11:31:59 +00:00
4d51873140 feat: remove Cal.ai tab from navigation while preserving URL access (#23894)
Co-authored-by: peer@cal.com <peer@cal.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-07 20:38:05 +00:00
3c0fc2e88b fix: reschedule issue when limited booking is enabled in the event settings. (#23717)
* fix

* fix fix

* cleanup

* Update useBookings.ts

* Update store.ts

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
Co-authored-by: Devanshu Sharma <devanshusharma658@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-10-07 19:23:43 +00:00
c702da0334 feat: Add team billing tables (#24148)
* Init billing tables

* Create `IBillingRepository` types

* Create billing repositories

* Create billingRepositoryFactory

* Eslint fix - remove unused organizationOnboarding

* internal-team-billing create saveTeamBilling method using repositories

* On new teams write to team billing table

* On new org write to org billing table

* Change fields to organizationId

* Add todo comment

* Revert "Change fields to organizationId"

This reverts commit bbb2e5dfa6b4c20a8a395f5730848a492cd70d68.

* test: add comprehensive tests for team billing tables

- Fix credit-service.test.ts Prisma mock to export prisma object
- Replace any types with proper TypeScript types in credit-service.test.ts
- Add unit tests for PrismaTeamBillingRepository covering record creation, enum casting, and error handling
- Add unit tests for PrismaOrganizationBillingRepository with same coverage
- Add unit tests for BillingRepositoryFactory to verify correct repository selection
- Add unit tests for InternalTeamBilling.saveTeamBilling() method testing delegation to correct repositories
- All 53 tests pass with TZ=UTC yarn test
- Type checking passes with yarn type-check:ci --force

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* refactor: update saveTeamBilling tests to mock repository interface

- Replace prismaMock usage with BillingRepositoryFactory mock
- Mock IBillingRepository interface instead of Prisma directly
- Follow repository mocking pattern from handleResponse.test.ts
- All tests passing (53 total)

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Remove log statement

* Remove repository tests

* Address feedback

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-07 14:25:31 -04:00
Bhavya Bhardwaj 92169eb0d2 fix: resolve AI assistant button overlap and mobile padding on Teams page (#24286)
* Fix padding in UpgradeTip component

Adjusted padding in UpgradeTip component.

* Adjust bottom positioning of main navigation component

* Adjust bottom positioning for main navigation
2025-10-07 23:11:39 +05:30
kusiewicz 3d74357e96 fix: polish sign-in translation (#24263) 2025-10-07 23:06:07 +05:30
Parship Chowdhury 6593291d00 Proper error handling in PrismaAppRepository.findAppStore with Sentry logging (#24212)
Signed-off-by: Parship Chowdhury <i.am.parship@gmail.com>
2025-10-07 21:23:27 +05:30
Kartik LabhshetwarandKartik Saini c48fb44a8d fix: make empty screen template grid mobile responsive (#24259)
* fix: make empty screen template grid mobile responsive

* refactor: adjust EmptyScreen layout classes for responsive grid

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-10-07 21:19:58 +05:30
Peer Richelsen e7c908bc80 feat: framer app (#24204)
* added framer app

* new 1.jpeg

* Update packages/app-store/framer/config.json
2025-10-07 15:12:22 +00:00
Peer RichelsenandRodrigo Ehlers ad35e19f8e feat: fonio.ai app (#23874)
* added fonio

* small hitpay update

* added fonio screenshots

* added description

* fix: rename 1.jpg -> 1.jpeg in fonio-ai app

---------

Co-authored-by: Rodrigo Ehlers <rodrigoehlers@outlook.com>
2025-10-07 14:42:29 +00:00
Keith Williams 3fc8c6c84e chore: Remove unused next dependency in @calcom/ui (#24311)
* chore: Remove unused Next.js package

* update yarn.lock
2025-10-07 01:34:53 +00:00
emrysal da7aeb4a21 chore: release v5.7.9
Post release / release (push) Canceled after 0s
2025-10-06 23:57:19 +00:00
Keith Williams 6197a2a5c5 chore: Upgrade Next.js to 15.5.4 (#24307) 2025-10-06 21:39:26 +00:00
Syed Ali ShahbazandAlex van Andel de186b95c1 fix: create missing migration for dropping obsolete indexes (#24304)
* Create migration.sql

* Drop index if exists, otherwise do nada

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-10-06 21:33:00 +00:00
Syed Ali Shahbaz 6b24513073 Update how-to-setup-api-in-a-local-instance.mdx (#24300) 2025-10-06 17:39:55 +00:00
Kinbaum 55ea18128a fix: timeZone variable name does not match webhook payload (#24279) 2025-10-06 22:49:28 +05:30
Calcom BotandLingo.dev d5557fa512 feat: update translations via @LingoDotDev (#24301)
Co-authored-by: Lingo.dev <support@lingo.dev>
2025-10-06 12:59:10 -03:00
Hariom BalharaandDevin AI 9c97b2aa6d feat: add usernameInOrg field to webhook organizer payload for organization users (#23246)
* feat: add usernameInOrg field to webhook organizer payload for organization users

- Add usernameInOrg field to CalendarEventBuilder organizer interface
- Update handleNewBooking to pass organizerOrganizationProfile.username as usernameInOrg
- Include usernameInOrg in webhook payload generation (sendPayload.ts)
- Add webhook form variable for usernameInOrg with translation
- Update Person type to include usernameInOrg field
- Add test case for organization user webhook verification
- Maintain backward compatibility by keeping existing username field unchanged

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

* feat: add usernameInOrg to additional webhook sending locations

- Update handleCancelBooking.ts to include usernameInOrg in organizer payload
- Update confirm.handler.ts to include usernameInOrg for booking confirmations
- Update getBooking.ts to include usernameInOrg in payment-related webhooks
- Maintain backward compatibility with existing username field
- All webhook sending locations now include organization profile username

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

* fixes

* Add subteam event test for usernameInOrg

* fix eslitn issues

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-06 19:51:04 +05:30
sean-brydonandClaude 1e226a40d1 Add next-auth-options.ts to Foundation code owners (#24295)
Added packages/features/auth/lib/next-auth-options.ts to CODEOWNERS for the Foundation team to ensure proper review and oversight of authentication configuration changes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-06 11:55:58 +00:00
Hariom Balhara bbe46eaa7d Restrict the length of emails as per RFC (#24269) 2025-10-06 10:22:58 +00:00
sean-brydon 7799b191ec feat: botid enabled on api/book/event api route (#24207)
* wip

* WIP

* restore event

* testing without instrument client

* Add conditional for botID init

* Bump BotID version + pass in header

* botID yarn lock changes

* feat: Add feature flag checks + tidy up into service

* rely on env var also

* use eventType repo instead of passing in prisma

* remove slug from audit

* rename botId feature to botid

* add unit tests for bot service
2025-10-06 11:16:13 +01:00
Anik Dhabal Babu f3269a3ddf fix: contentType is added in worng format (#24284) 2025-10-06 06:29:54 +00:00
emrysal a4be822636 chore: release v5.7.8
Post release / release (push) Canceled after 0s
2025-10-04 14:20:17 +00:00
Syed Ali Shahbaz 7cd8dbf7d8 chore: Watchlist schema update (#24246)
* watchlist schema update

* changes addressed

* no backfill audit

* fix type

* fix flow of migration

* fix err

* type err fix

* add if exists check in migration
2025-10-04 01:44:37 +01:00
654a865007 refactor: Salesforce getContacts (#24235)
* lint fixes

* Create getContactOrLeadFromEmail method

* Use getContactOrLeadFromEmail in getContacts

* Move normal getContact query

* fix: correct return type in getContactOrLeadFromEmail and add search mock

- Fix getContactOrLeadFromEmail to return single ContactRecord instead of array
- Add conn.search() implementation to salesforceMock for integration tests
- Ensure id field is always string with proper fallbacks for AccountId and Id
- Fix Owner.Email to use undefined instead of null in mock

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* test: add comprehensive tests for getContactOrLeadFromEmail method

- Add 6 new unit tests covering all key scenarios
- Test contact lookup, lead fallback, and preference logic
- Test both roundRobinSkipFallbackToLeadOwner and createEventOnLeadCheckForContact code paths
- Add search mock support to mockConnection in beforeEach
- All 34 tests passing, type-check clean

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Implement change to error from #24090

* Address feedback

* Type fix

* test: update SOQL query expectations to include Account.OwnerId

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-03 14:53:00 -04:00
Alex van AndelandDevin AI 409a0277fb fix: Resolve BillingPlan tree-shaking issue causing runtime error (#24229)
* fix: Convert BillingPlan enum to const object to prevent webpack tree-shaking

TypeScript enums compile to IIFEs which can be incorrectly tree-shaken by webpack
when 'sideEffects: false' is set in package.json. Converting to a const object
with 'as const' avoids the IIFE pattern while maintaining full type safety.

This fixes the 'TRPCError: BillingPlan is not defined' error that occurred in
the hasTeamPlan tRPC handler.

Follows the existing CHECKOUT_SESSION_TYPES pattern in the same file.

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* fix: Re-export BillingPlan from billing-plans to prevent tree-shaking

The issue was a module initialization order problem. When BillingPlan was
imported from constants.ts into billing-plans.ts and used inside the
BillingPlanService class, webpack's tree-shaker (with sideEffects: false)
couldn't properly track the value dependency across the package boundary.

By re-exporting BillingPlan from the same module that exports
BillingPlanService, we ensure the constant is bundled together with the
class that uses it, preventing tree-shaking.

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* revert: Restore BillingPlan enum format in constants.ts

The const object conversion didn't fix the tree-shaking issue. The real
fix is re-exporting BillingPlan from billing-plans.ts to ensure it's
part of the same module as BillingPlanService.

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* fix: Add BillingPlan as private static member to prevent tree-shaking

By making BillingPlan a private static member of BillingPlanService, webpack
now sees it as part of the class definition rather than just used inside
methods. This creates a strong reference that prevents webpack's tree-shaker
from removing the enum initialization IIFE when 'sideEffects: false' is set.

With the previous approach where BillingPlan was only imported and used inside
methods, webpack's static analysis couldn't properly track the enum usage
across package boundaries (@calcom/features -> @calcom/trpc), causing it to
incorrectly determine the enum initialization was unused code.

Fixes the TRPCError: BillingPlan is not defined runtime error.

Thread: https://calendso.slack.com/archives/C08LT9BLEET/p1759420015428149
Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* fix: Clean up BillingPlan references and add explanatory comment

- Remove unnecessary re-export statement
- Use direct BillingPlan references instead of BillingPlanService.BillingPlan
- Add detailed comment explaining webpack tree-shaking workaround
- Keep import from constants.ts and private static member for webpack reference

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* fix: Remove Slack reference from comment and restore static usage

- Remove Slack URL from explanatory comment
- Restore BillingPlanService.BillingPlan usage throughout class methods
- This static member usage is essential for webpack to track the enum reference

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* Fix BillingPlan issue by finding root cause

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-03 16:37:45 +01:00
Anik Dhabal Babu 7adab1f65f fix: event not showing up on outlook through ics file (#24249) 2025-10-03 13:52:25 +00:00
Calcom BotandLingo.dev 3b92c86582 feat: update translations via @LingoDotDev (#24248)
Co-authored-by: Lingo.dev <support@lingo.dev>
2025-10-03 12:42:14 +00:00
Devanshu Sharma de195a1c0f feat: add email filtering to team memberships endpoint (#23923)
* Controller Layer updates.

* adding email filtering and pagination to team memberships endpoint..

* Minor enhancements.

* Improve addressed.

* refactor: update team memberships input to handle comma-separated emails

- Replace array format with comma-separated string handling
- Add proper email validation with BadRequestException
- Remove ArrayMaxSize constraint for better flexibility
- Update API documentation and examples
- Align with codebase patterns from get-managed-users.input.ts

* Morgan suggestions addressed.

* More improvements......
2025-10-03 18:09:47 +05:30
an_ifrah24andDevanshu Sharma d7d2487f6d fix: hydration error warning in scheduleListItem component (#24240)
Co-authored-by: Devanshu Sharma <devanshusharma658@gmail.com>
2025-10-03 17:52:32 +05:30
sean-brydon 3e31945157 Fix nits (#24244) 2025-10-03 11:03:07 +00:00
Lauris Skraucis c11814c342 fix: managed user timeZone validation (#24226)
* fix: managed user timeZone validation

* fix: managed user timeZone validation

* fix: managed user timeZone validation
2025-10-03 08:23:54 +00:00
Syed Ali Shahbaz 60e3244088 Migrate watchlist severity to action(#24195) 2025-10-03 11:18:14 +04:00
Rajiv Sahal a1ac5f36f0 feat: add atoms section inside developing guides (#24220) 2025-10-02 21:59:43 +05:30
Lauris SkraucisandMorgan 6f38d40613 refactor: dont allow scheduling team event type without hosts (#24206)
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-10-02 15:58:42 +00:00
Lauris Skraucis 6b7d22de3f refactor: fail breaking change check on error only (#24221) 2025-10-02 14:42:49 +00:00
Anik Dhabal Babu 2f763dceda fix: Getting an unauthorized error when accessing the workflow tab for a child-managed event (#24219)
* Update get.handler.ts

* fix: Ignore userIds form filter segment if no permission

* add tests

* Update booking-filters.e2e.ts

* Update booking-filters.e2e.ts

* fix test

* fix: got unautorized error when viewing workflows

* fix
2025-10-02 13:38:21 +00:00
Benny Joo 96468c4083 refactor: move @calcom/lib/di folder to @calcom/features (#24199)
* mv di folder

* update imports

* fix

* fix

* fix test
2025-10-02 08:12:06 -03:00
sean-brydonandBenny Joo 858d52563f feat: pbac - org pages check update permissions (#24164)
* pbac - org pages check update permissions

* restore permision service

* fix type check

* more type fixes

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-10-02 20:04:21 +09:00
7505b3b271 feat: add missing sensitive fields to redactSensitiveData (#24121)
- Add private_key, encrypted_credentials, tenant_id, client_email, serviceAccountKey to SENSITIVE_FIELDS array
- Add comprehensive test cases for delegation credential sensitive fields
- Prevents logging of sensitive delegation credential data including private keys

Fixes security issue where delegation credential sensitive fields were not being redacted from logs

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-10-01 20:26:58 -03:00
Calcom BotandLingo.dev 758dc88b55 feat: update translations via @LingoDotDev (#24203)
Co-authored-by: Lingo.dev <support@lingo.dev>
2025-10-01 23:24:17 +00:00
Udit Takkar 9be418dc65 chore: update description (#24189) 2025-10-01 23:35:41 +05:30
Volnei Munhoz 2ac310456e fix: Selected calendar delegation credentials (#24190)
* Fix selected calendar delegation credentials
2025-10-01 14:20:36 +00:00
ff264d6f7a fix: allow team with same slug for diff cases (#24029)
* fix: aalow team with slug for diff cases

* addressed review

* fix type error

* update test

* addressed review

* fix test

* Update team.ts

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-10-01 14:04:20 +00:00
8019c414e1 chore: CSRF protect forgot-password functionality (#22361)
* chore: CSRF protect forgot-password functionality

* feat: implement conditional sameSite cookie setting for CSRF tokens

- Use WEBAPP_URL to determine secure cookie settings
- Set sameSite to 'none' for HTTPS environments to support cross-origin scenarios
- Fall back to 'lax' for HTTP development environments
- Follows patterns from PRs #23439 and #23556

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* update

* change

* NIT

* minor

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: unknown <adhabal2002@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-10-01 13:38:29 +00:00
chauhan_s a3fbe3731e feat: add host validation for team event types (#24170)
* feat: add host validation for team event types

* refactor: remove hosts validator in favor of documentation-based validation approach

* fix: remove HostsOrAssignAllValidator from event type update inputs

* docs: improve clarity of hosts and assignAllTeamMembers field descriptions in event type inputs
2025-10-01 16:15:40 +05:30
sean-brydonandcoderabbitai[bot] e3742dbc66 fix: Always enforce one owner with pbac (#24144)
* Always enforce one owner

* updates from merge

* remove redudant eslint rules

* Update packages/features/pbac/services/pbac-role-manager.service.ts

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-10-01 11:06:18 +01:00
Rajiv SahalandMorgan 83bf717d6d fix: customReplyEmailTo feedback (#23738)
* fix: move validateRoundRobinSlotAvailability to core libraries

* fix: implement PR feedback

* fix: merge conflicts

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-10-01 15:32:32 +05:30
Peer RichelsenandDevin AI 7fcdaa1c46 feat: add created_at field display to admin user edit page (#23805)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-01 09:58:20 +00:00
Kartik LabhshetwarandKartik Saini a8154f9405 feat(ui/api-keys): add confirmation dialog for API key deletion (#24118)
* feat(api-keys): add confirmation dialog for API key deletion

* fix: changed text color to subtle

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-10-01 09:56:55 +00:00
chauhan_sandLauris Skraucis 52a5afeba5 fix: different calendar hosts (#24000)
* fis: pass the missing `platformClientId` to `handleNewBooking` in api/v2

* Update formatCalendarEvent.ts

* only add the externald in case of same calendar, otherwise add if from the .ics file sent in email

* Revert "fis: pass the missing `platformClientId` to `handleNewBooking` in api/v2"

This reverts commit 030ac0f0981c1135d4973fbdafa62d58e8985831.

* fix: alow the host change logic to run for collective envets as well

* fix: run the changedOrganizer logic for both collective and round-robin events

* Update handleNewBooking.ts

* fix: failing unit test

---------

Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
2025-10-01 12:15:34 +03:00
Anik Dhabal Babu 43cc40d7fe fix: add permissions to workflows (#24192) 2025-10-01 06:53:08 +00:00
Hariom Balhara 42a98f000e fix: 405 method not allowed error for queuedFormResponseCleanup (#24191) 2025-10-01 04:55:48 +00:00
Joe Au-Yeung f003ef935b Remove safeStringify from graphql errors (#24188) 2025-10-01 09:50:15 +05:30
Volnei Munhoz e2b2a184c8 fix: Calendar Cache sync page (#24182)
* fix

* fix calendar sync page
2025-09-30 17:10:51 +01:00
Calcom BotandLingo.dev 094595c435 feat: update translations via @LingoDotDev (#24178)
Co-authored-by: Lingo.dev <support@lingo.dev>
2025-09-30 13:51:13 +00:00
Benny Joo 4118a86c0d refactor: do a permission check in removeHostsFromEventTypes trpc handler (#24176) 2025-09-30 13:30:12 +00:00
sean-brydon 928d6f3821 chore: PBAC routingform entity permissions to favour pbac (#24130)
* Remove routingform entity permissions to favour pbac

* push typo

* fix types

* update returns

* Remove unused function

* nits

* fix hariom feedback

* fix correct write permissions
2025-09-30 18:55:43 +05:30
Benny Joo b2ade568c8 refactor: more pbac replacements (#24135)
Post release / release (push) Canceled after 0s
* getUserConnectedApps

* create teams

* use permission check in team deletion

* team read

* revert

* publish handler

* wip

* revert

* wip

* wip
2025-09-30 13:11:02 +00:00
27820ce897 feat: auto-accept team invitations for existing users (#24091)
* feat: auto-accept team invitations for existing users

- Change email button text from 'View Invitation' to 'Accept Invite'
- Implement auto-accept flow when clicking email CTA
- Update TeamService.inviteMemberByToken to support auto-acceptance
- Add new autoAcceptInvite tRPC endpoint for handling auto-acceptance
- Update invitation link generation to include autoAccept parameter
- Handle both team and organization invitation scenarios
- Maintain payment/billing flow integration with TeamBilling.updateQuantity
- Preserve backward compatibility with existing manual flow
- Update all locale files with new 'Accept Invite' button text

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

* revert: locale changes except English

- Keep only English 'Accept Invite' translation
- Revert all other locale files to original 'View Invitation' translations
- Maintain core auto-accept invitation functionality

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

* simplify: remove autoAccept parameter and make auto-acceptance default

- Remove autoAccept parameter from TeamService.inviteMemberByToken
- Always auto-accept invitations for existing users clicking email links
- Remove autoAccept logic from teams server-page.tsx
- Remove autoAccept=true from invitation URLs
- Delete autoAcceptInvite handler and schema files
- Remove autoAcceptInvite endpoint from tRPC router
- Simplify invitation flow to match new user pattern

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

* test: update teamService test to expect auto-accepted memberships

- Change expectation from accepted: false to accepted: true
- Update test description to reflect auto-accept behavior
- Fix TypeScript type casting to use Pick<TeamRepository, 'deleteById'>
- Aligns with new default auto-acceptance for team invitations

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

* update

* Update utils.ts

* fix type error

* delete token

* add prisma transaction

* update

* update param

* test: fix mock objects in teamService tests with realistic data

- Fix duplicate property assignments in mock user objects
- Use proper email format (user@example.com) for email fields
- Use proper username format (testuser) for username fields
- Fix logic error in acceptInvitationByToken (|| to &&)
- Add autoAccept parameter to resendInvitation.handler.ts
- All 16 tests passing with proper TypeScript types

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

* test: add e2e tests for team invitation auto-accept flow

- Add test for existing user auto-accepting team invitation via email link
- Add test for error handling when wrong user tries to use invitation link
- Verify proper user identity validation and database state changes
- Follow existing e2e test patterns with browser context isolation
- Fix ESLint warnings: replace conditional with assertion and remove unused browser parameter

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

* fix

* fix

* fix: update team owner creation in e2e tests to include proper names

- Fix email subject mismatch in auto-accept invitation tests
- Team owners now created with explicit names instead of undefined
- Matches pattern used in other working team invitation tests

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

* fix: update organization invitation test helper to match new auto-accept link format

- Change expectExistingUserToBeInvitedToOrganization to look for 'teams?token' instead of 'settings/team'
- Fixes 'Invite link not found' error in organization booking e2e test
- Aligns with auto-accept invitation URL changes that use /teams?token= format
- Fix eslint disable comment for playwright rule

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

* address coderrabit review

* fix failing test

* addressed review

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-30 13:07:21 +00:00
emrysal fea7e66996 chore: release v5.7.7 2025-09-30 12:57:27 +00:00
sean-brydon 474d1571ae Remove checkAdminOrOwner from settings layout client (#24162) 2025-09-30 12:08:05 +01:00
Rajiv Sahal 2ef627b87e fix: css issue for 12/24 styles (#24141) 2025-09-30 08:38:17 +00:00
sean-brydon 2dfb82b623 Bring back correct routing form teams (#24158) 2025-09-30 08:37:16 +00:00
sean-brydon ccd7fdfd52 refactor role manage (#24146) 2025-09-30 08:35:26 +01:00
Keith Williams f92e59ed34 chore: Update yarn.lock (#24153) 2025-09-30 02:19:30 +00:00
Keith WilliamsandDevin AI d29477349a test: replace prismaMock with BookingRepository mock in checkBookingLimits tests (#24152)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-29 23:34:20 +00:00
Alex van Andel 259e480baa chore: Handle stripe refunds slightly differently to reduce hard errors (#24150)
* chore: Handle stripe refunds slightly differently to reduce hard errors

* Remove 'Received and discarded' error when a credential is not found for a triggered subscription

* Fix handling of no credential found (do show in stripe debugger)

* Minimal overhaul of delete handling

* Fix doc typo
2025-09-29 23:07:08 +00:00
Benny Joo 0055dd1142 perf: dynamically import sentry in Next.js api routes' wrapper (#23710)
* dynamically import sentry in nextjs api routes wrapper

* refactor

* same for pages router

* fixes

* address feedback

* refactor

* address feedback
2025-09-30 02:13:50 +05:30
Volnei Munhoz 61c44780f0 chore: Optional skip warnings for lint-staged (#24102)
* add optional skip warnings

* remove test changes

* allow underscore variables pattern being ignored by eslint (#24103)
2025-09-29 19:51:18 +00:00
Calcom BotandLingo.dev 9710d3b15c feat: update translations via @LingoDotDev (#23991)
Co-authored-by: Lingo.dev <support@lingo.dev>
2025-09-29 19:23:59 +00:00
sean-brydon 04cefebd75 add permissions.canUpdateOrganization to show/hide items in org navbar (#24142) 2025-09-29 15:11:39 +00:00
Amit Sharma dbd927c002 fix: intercom zindex (#24096) 2025-09-29 11:43:11 -03:00
e6b2116a2b feat: Calendar Cache and Sync (#23876)
* feat: calendar cache and sync - wip

* Add env.example

* refactor on CalendarCacheEventService

* remove test console.log

* Fix type checks errors

* chore: remove pt comment

* add route.ts

* chore: fix tests

* Improve cache impl

* chore: update recurring event id

* chore: small improvements

* calendar cache improvements

* Fix remove dynamic imports

* Add cleanup stale cache

* Fix tests

* add event update

* type fixes

* feat: add comprehensive tests for new calendar subscription API routes

- Add tests for /api/cron/calendar-subscriptions-cleanup route (9 tests)
- Add tests for /api/cron/calendar-subscriptions route (10 tests)
- Add tests for /api/webhooks/calendar-subscription/[provider] route (11 tests)
- Add missing feature flags for calendar-subscription-cache and calendar-subscription-sync
- All 30 tests pass with comprehensive coverage of authentication, feature flags, error handling, and service instantiation

Tests cover:
- Authentication scenarios (API key validation, Bearer tokens, query parameters)
- Feature flag combinations (cache/sync enabled/disabled states)
- Success and error handling (including non-Error exceptions)
- Service instantiation with proper dependency injection
- Provider validation for webhook endpoints

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* feat: add comprehensive tests for calendar subscription services, repositories, and adapters

- Add unit tests for CalendarSubscriptionService with subscription, webhook, and event processing
- Add unit tests for CalendarCacheEventService with cache operations and cleanup
- Add unit tests for CalendarSyncService with Cal.com event filtering and booking operations
- Add unit tests for CalendarCacheEventRepository with CRUD operations
- Add unit tests for SelectedCalendarRepository with calendar selection management
- Add unit tests for GoogleCalendarSubscriptionAdapter with subscription and event fetching
- Add unit tests for Office365CalendarSubscriptionAdapter with placeholder implementation
- Add unit tests for AdaptersFactory with provider management and adapter creation
- Fix lint issues by removing explicit 'any' type casting and unused variables
- All tests follow Cal.com conventions using Vitest framework with proper mocking

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* fix: improve calendar-subscriptions-cleanup test performance by adding missing mocks

- Add comprehensive mocks for defaultResponderForAppDir, logger, performance monitoring, and Sentry
- Fix slow test execution (933ms -> <100ms) caused by missing dependency mocks
- Ensure consistent test performance across different environments

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* Fix tests

* Fix tests

* type fix

* Fix coderabbit comments

* Fix types

* Fix test

* Update apps/web/app/api/cron/calendar-subscriptions/route.ts

Co-authored-by: Alex van Andel <me@alexvanandel.com>

* Fixes by first review

* feat: add database migrations for calendar cache and sync fields

- Add CalendarCacheEventStatus enum with confirmed, tentative, cancelled values
- Add new fields to SelectedCalendar: channelId, channelKind, channelResourceId, channelResourceUri, channelExpiration, syncSubscribedAt, syncToken, syncedAt, syncErrorAt, syncErrorCount
- Create CalendarCacheEvent table with foreign key to SelectedCalendar
- Add necessary indexes and constraints for performance and data integrity

Fixes database schema issues causing e2e test failures with 'column does not exist' errors.

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* only google-calendar for now

* docs: add Calendar Cache and Sync feature documentation

- Add comprehensive feature overview and motivation
- Document feature flags with SQL examples
- Include SQL examples for enabling features for users and teams
- Reference technical documentation files

Addresses PR #23876 documentation requirements

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* docs: update calendar subscription README with comprehensive documentation

- Undo incorrect changes to main README.md
- Update packages/features/calendar-subscription/README.md with:
  - Feature overview and motivation
  - Environment variables section
  - Complete feature flags documentation with SQL examples
  - SQL examples for enabling features for users and teams
  - Detailed architecture documentation

Addresses PR #23876 documentation requirements

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* fix docs

* Fix test to available calendars

* Fix test to available calendars

* add migration and sync boilerplate

* fix typo

* remove double log

* sync boilerplate

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2025-09-29 14:26:14 +00:00
Udit Takkar 855a02091f chore: update tooltip text (#24143) 2025-09-29 11:20:28 -03:00
Volnei MunhozandDevin AI 358434429e feat: add booking flags for skipAvailabilityCheck, skipEventLimitsCheck, skipCalendarSyncTaskCreation (#24122)
- Add three optional boolean flags to BookingHandlerInput interface
- Implement conditional logic to skip availability checks when skipAvailabilityCheck is true
- Implement conditional logic to skip event limits checks when skipEventLimitsCheck is true
- Implement conditional logic to skip calendar sync when skipCalendarSyncTaskCreation is true
- Add comprehensive tests for all three flags and combined scenarios
- Maintain backward compatibility with default false values
- Fix linting issues (unused variables and expressions)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-29 14:02:31 +00:00
sean-brydonandhbjORbj b00061d85e chore: Add addMembersToTeams logic from 24099 (#24140)
* Add addMembersToTeams logic from 24099

* remove import

---------

Co-authored-by: hbjORbj <sldisek783@gmail.com>
2025-09-29 12:54:35 +00:00
Udit Takkarandsean-brydon 022f08c05e feat: add 10 minute cooldown in instant meetings (#24119)
* feat: add 10 minute

* feat: add 10 minute

* chore: change name

* chore: change name

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2025-09-29 16:03:03 +04:00
emrysal 6ff5c8ce5f chore: release v5.7.6
Post release / release (push) Canceled after 0s
2025-09-29 11:22:43 +00:00
Lauris Skraucis 3be7fb9dd8 fix: v2 create org user with time zone (#24133)
* fix: tzdata undefined import

* refactor: capitalize org user timezone

* test: org user creation with time zone
2025-09-29 11:12:18 +00:00
Benny Joo 72dd970b6e refactor: use permission check service in /organizations endpoints (#24099)
* getUserConnectedApps

* create teams

* use permission check in team deletion

* team read

* revert

* publish handler

* wip
2025-09-29 11:07:19 +00:00
sean-brydon f4d09ece43 chore: (PBAC) readonly eventTypes (#24131)
* Chore(PBAC): readonly eventTypes

* Set readonly value
2025-09-29 11:06:55 +00:00
sean-brydonandEunjae Lee 239e6085ba feat: pbac - private teams (#23998)
* Add permission + migration for listMembersPrivate + depends on lsitMember

* Add legacy list members PBAC private logic

* Add checks in team pages + handler

* add list memeber checks for private orgs

* Add permision visibility scope to loading permissions

* getMembers handler

* fix fallback permission

* Verify private teams work with private org

* Apply suggestion from @eunjae-lee

Co-authored-by: Eunjae Lee <hey@eunjae.dev>

* Remove and private scope function and add param to old

---------

Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-09-29 11:30:54 +01:00
sean-brydon b17eab3aeb chore: Use enums instead of hardcoded strings (#24132)
* Use enums instead of hardcoded strings

* use membership role
2025-09-29 10:57:36 +01:00
752f7fcde0 refactor: replace checkAdminOrOwner with team.create permission in teams page (#24116)
* refactor: replace checkAdminOrOwner with team.create permission in teams page

- Replace role-based access control with PBAC using team.create permission
- Follow PBAC refactoring guide pattern for server-side permission checking
- Use permission-specific variable naming (canCreateTeam)
- Maintain existing TeamsListing component interface

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

* refactor: implement permissions object pattern for TeamsListing

- Replace isOrgAdmin prop with permissions object containing canCreateTeam
- Follow PBAC refactoring guide pattern for UI components
- Update TeamsListing component to use permissions.canCreateTeam
- Maintain existing functionality while improving code structure
- All type checking and linting verification passed

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

* use different method

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2025-09-29 14:11:42 +05:30
f6b061f787 fix: update handler to not delete banner (#24106)
* Fix update handler to not delete banner

* code cleanup

* add banner-upload.e2e

* fix: add slug fallback in banner-upload e2e test

- Use org.slug as fallback when requestedSlug is undefined
- Addresses GitHub comment about guarding against undefined slug
- Prevents test failures when requestedSlug is not set
- Fix ESLint warnings: remove unnecessary escape characters and unused variable

Co-Authored-By: sean@cal.com <Sean@brydon.io>

* test: add banner preservation test for profile updates

- Verify that updating organization profile without banner changes preserves existing banner
- Ensures update handler doesn't overwrite banner when undefined is passed
- Addresses user request to test banner preservation during profile saves

Co-Authored-By: sean@cal.com <Sean@brydon.io>

* remove banner e2e wip

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-29 09:31:49 +01:00
Eunjae LeeandDevin AI cc8a1290ee docs: add comprehensive DataTable usage guide (#24107)
* docs: add comprehensive DataTable usage guide

- Document DataTableProvider, DataTableWrapper, and DataTable components
- Cover filter system with all filter types and operators
- Explain segment system (system vs user segments)
- Document pagination modes (infinite vs standard)
- Include real-world usage examples from UserListTable, BookingsListingView, MemberList
- Add TypeScript types reference and best practices

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

* docs: update DataTable guide to recommend standard pagination

- De-emphasize virtualized rendering and infinite mode
- Position standard pagination as the recommended approach
- Document known issues with infinite loading mode
- Reorder pagination section to prioritize standard mode
- Update best practices to recommend standard pagination

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

* docs: add missing DataTable guide topics

- Document getFacetedUniqueValues for faceted filters with real examples
- Add useSegments prop requirement for filter segment enablement
- Enhance server-side filtering documentation with comprehensive patterns
- Include examples from useFacetedUniqueValues and useInsightsRoutingParameters

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

* docs: add missing DataTable guide topics

- Document getFacetedUniqueValues for faceted filters with real examples
- Add useSegments prop requirement for filter segment enablement
- Enhance server-side filtering documentation with comprehensive patterns
- Include examples from useFacetedUniqueValues and useInsightsRoutingParameters
- Correct server-side vs client-side filtering rationale

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

* docs: enhance DataTable guide with troubleshooting and testing

- Remove duplicate Key Hooks section (lines 783-803)
- Add visual ASCII diagrams for architecture and component hierarchy
- Add comprehensive troubleshooting section with 7 common issues and solutions
- Add testing patterns section with Vitest examples and best practices
- Update table of contents to include new sections
- Maintain existing high-quality documentation standards

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

* remove some sections

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-29 07:59:55 +00:00
Eunjae LeeandDevin AI b7cf893169 refactor: replace checkAdminOrOwner with PBAC availability.read permission (#24115)
- Replace role-based check with PermissionCheckService
- Use availability.read permission with OWNER/ADMIN fallback roles
- Maintain existing privacy logic for organizations
- Follow established PBAC pattern from bookings implementation

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-29 07:59:23 +00:00
Lauris Skraucis 1458cacdbb fix: v2 api event type splitName booking field (#24111)
* fix: v2 api event type splitName booking field

* fix: test
2025-09-29 09:19:24 +02:00
629 changed files with 20032 additions and 5027 deletions
+13
View File
@@ -144,6 +144,12 @@ GOOGLE_WEBHOOK_TOKEN=
# Optional URL to override for tunelling webhooks. Defaults to NEXT_PUBLIC_WEBAPP_URL.
GOOGLE_WEBHOOK_URL=
# Token to verify incoming webhooks from Microsoft Calendar
MICROSOFT_WEBHOOK_TOKEN=
# Optional URL to override for tunelling webhooks. Defaults to NEXT_PUBLIC_WEBAPP_URL.
MICROSOFT_WEBHOOK_URL=
# Inbox to send user feedback
SEND_FEEDBACK_EMAIL=
@@ -261,6 +267,9 @@ NEXT_PUBLIC_CLOUDFLARE_SITEKEY=
NEXT_PUBLIC_CLOUDFLARE_USE_TURNSTILE_IN_BOOKER=
CLOUDFLARE_TURNSTILE_SECRET=
# 0 = false, 1=true
NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER=
# Set the following value to true if you wish to enable Team Impersonation
NEXT_PUBLIC_TEAM_IMPERSONATION=false
@@ -473,3 +482,7 @@ DATABASE_CHUNK_SIZE=
# Service Account Encryption Key for encrypting/decrypting service account keys
# You can use: `openssl rand -base64 24` to generate one
CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY=
SALESFORCE_GRAPHQL_DELAY_MS=500
SALESFORCE_GRAPHQL_MAX_DELAY_MS=2000
SALESFORCE_GRAPHQL_MAX_RETRIES=3
+1
View File
@@ -23,6 +23,7 @@
/packages/app-store/office365calendar/**/* @calcom/Foundation
/packages/app-store/zohocalendar/**/* @calcom/Foundation
/packages/embeds/**/* @calcom/Foundation
/packages/features/auth/lib/next-auth-options.ts @calcom/Foundation
/packages/features/bookings/lib/**/* @calcom/Foundation
/packages/lib/getAggregatedAvailability.ts @calcom/Foundation
/packages/lib/getUserAvailability.ts @calcom/Foundation
+1 -1
View File
@@ -82,7 +82,7 @@ jobs:
with:
base: https://raw.githubusercontent.com/calcom/cal.com/refs/heads/main/docs/api-reference/v2/openapi.json
revision: docs/api-reference/v2/openapi.json
fail-on: WARN
fail-on: ERR
- name: Run Tests
working-directory: apps/api/v2
View File
View File
View File
View File
View File
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- calcom-monorepo
View File
+1 -1
View File
@@ -1,7 +1,7 @@
import type { NextApiRequest } from "next";
import { z } from "zod";
import { getUserAvailabilityService } from "@calcom/lib/di/containers/GetUserAvailability";
import { getUserAvailabilityService } from "@calcom/features/di/containers/GetUserAvailability";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
@@ -3,7 +3,7 @@ import type { NextApiRequest } from "next";
import {
getRecordingsOfCalVideoByRoomName,
getDownloadLinkOfCalVideoByRecordingId,
} from "@calcom/app-store/videoClient";
} from "@calcom/features/conferencing/lib/videoClient";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
@@ -3,7 +3,7 @@ import type { NextApiRequest } from "next";
import {
getTranscriptsAccessLinkFromRecordingId,
checkIfRoomNameMatchesInRecording,
} from "@calcom/app-store/videoClient";
} from "@calcom/features/conferencing/lib/videoClient";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
@@ -1,6 +1,6 @@
import type { NextApiRequest } from "next";
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/app-store/videoClient";
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/features/conferencing/lib/videoClient";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
+8 -6
View File
@@ -1,7 +1,7 @@
import type { NextApiRequest } from "next";
import { getRegularBookingService } from "@calcom/features/bookings/di/RegularBookingService.container";
import getBookingDataSchemaForApi from "@calcom/features/bookings/lib/getBookingDataSchemaForApi";
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
@@ -239,15 +239,17 @@ async function handler(req: NextApiRequest) {
}
try {
return await handleNewBooking(
{
bookingData: req.body,
const regularBookingService = getRegularBookingService();
return await regularBookingService.createBookingForApiV1({
bookingData: req.body,
bookingMeta: {
userId,
hostname: req.headers.host || "",
forcedSlug: req.headers["x-cal-force-slug"] as string | undefined,
},
getBookingDataSchemaForApi
);
bookingDataSchemaGetter: getBookingDataSchemaForApi,
});
} catch (error: unknown) {
const knownError = error as Error;
if (knownError?.message === ErrorCode.NoAvailableUsersFound) {
@@ -1,7 +1,7 @@
import type { NextApiRequest } from "next";
import type { UserWithCalendars } from "@calcom/lib/getConnectedDestinationCalendars";
import { getConnectedDestinationCalendarsAndEnsureDefaultsInDb } from "@calcom/lib/getConnectedDestinationCalendars";
import type { UserWithCalendars } from "@calcom/features/calendars/lib/getConnectedDestinationCalendars";
import { getConnectedDestinationCalendarsAndEnsureDefaultsInDb } from "@calcom/features/calendars/lib/getConnectedDestinationCalendars";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { UserRepository } from "@calcom/lib/server/repository/user";
+1 -1
View File
@@ -4,7 +4,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
import dayjs from "@calcom/dayjs";
import { isSupportedTimeZone } from "@calcom/lib/dayjs";
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
import { getAvailableSlotsService } from "@calcom/features/di/containers/AvailableSlots";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { createContext } from "@calcom/trpc/server/createContext";
+20 -19
View File
@@ -4,6 +4,7 @@ import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/pay
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
@@ -61,24 +62,24 @@ export async function patchHandler(req: NextApiRequest) {
const { teamId } = schemaQueryTeamId.parse(req.query);
/** Only OWNERS and ADMINS can edit teams */
const _team = await prisma.team.findFirst({
const team = await prisma.team.findFirst({
// eslint-disable-next-line @calcom/eslint/no-prisma-include-true
include: { members: true },
where: { id: teamId, members: { some: { userId, role: { in: ["OWNER", "ADMIN"] } } } },
});
if (!_team) throw new HttpError({ statusCode: 401, message: "Unauthorized: OWNER or ADMIN required" });
if (!team) throw new HttpError({ statusCode: 401, message: "Unauthorized: OWNER or ADMIN required" });
const slugAlreadyExists = await prisma.team.findFirst({
where: {
slug: {
mode: "insensitive",
equals: data.slug,
},
},
});
if (slugAlreadyExists && data.slug !== _team.slug)
throw new HttpError({ statusCode: 409, message: "Team slug already exists" });
if (data.slug) {
const teamRepository = new TeamRepository(prisma);
const isSlugAvailable = await teamRepository.isSlugAvailableForUpdate({
slug: data.slug,
teamId: team.id,
parentId: team.parentId,
});
if (!isSlugAvailable) {
throw new HttpError({ statusCode: 409, message: "Team slug already exists" });
}
}
// Check if parentId is related to this user
if (data.parentId && data.parentId === teamId) {
@@ -99,16 +100,16 @@ export async function patchHandler(req: NextApiRequest) {
}
let paymentUrl;
if (_team.slug === null && data.slug) {
if (team.slug === null && data.slug) {
data.metadata = {
...(_team.metadata as Prisma.JsonObject),
...(team.metadata as Prisma.JsonObject),
requestedSlug: data.slug,
};
delete data.slug;
if (IS_TEAM_BILLING_ENABLED) {
const checkoutSession = await purchaseTeamOrOrgSubscription({
teamId: _team.id,
seatsUsed: _team.members.length,
teamId: team.id,
seatsUsed: team.members.length,
userId,
pricePerSeat: null,
});
@@ -131,9 +132,9 @@ export async function patchHandler(req: NextApiRequest) {
bookingLimits: data.bookingLimits === null ? {} : data.bookingLimits,
metadata: data.metadata === null ? {} : data.metadata || undefined,
};
const team = await prisma.team.update({ where: { id: teamId }, data: cloneData });
const updatedTeam = await prisma.team.update({ where: { id: teamId }, data: cloneData });
const result = {
team: schemaTeamReadPublic.parse(team),
team: schemaTeamReadPublic.parse(updatedTeam),
paymentUrl,
};
if (!paymentUrl) {
@@ -9,7 +9,7 @@ import { buildBooking } from "@calcom/lib/test/builder";
import {
getRecordingsOfCalVideoByRoomName,
getDownloadLinkOfCalVideoByRecordingId,
} from "@calcom/app-store/videoClient";
} from "@calcom/features/conferencing/lib/videoClient";
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
@@ -22,7 +22,7 @@ type CustomNextApiResponse = NextApiResponse & Response;
const adminUserId = 1;
const memberUserId = 10;
vi.mock("@calcom/app-store/videoClient", () => {
vi.mock("@calcom/features/conferencing/lib/videoClient", () => {
return {
getRecordingsOfCalVideoByRoomName: vi.fn(),
getDownloadLinkOfCalVideoByRecordingId: vi.fn(),
@@ -8,7 +8,7 @@ import { describe, expect, test, vi, afterEach } from "vitest";
import {
getTranscriptsAccessLinkFromRecordingId,
checkIfRoomNameMatchesInRecording,
} from "@calcom/app-store/videoClient";
} from "@calcom/features/conferencing/lib/videoClient";
import { buildBooking } from "@calcom/lib/test/builder";
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
@@ -19,7 +19,7 @@ import handler from "../../../../../../pages/api/bookings/[id]/transcripts/[reco
type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
vi.mock("@calcom/app-store/videoClient", () => {
vi.mock("@calcom/features/conferencing/lib/videoClient", () => {
return {
getTranscriptsAccessLinkFromRecordingId: vi.fn(),
checkIfRoomNameMatchesInRecording: vi.fn(),
@@ -6,7 +6,7 @@ import { createMocks } from "node-mocks-http";
import { describe, expect, test, vi, afterEach } from "vitest";
import { buildBooking } from "@calcom/lib/test/builder";
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/app-store/videoClient";
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/features/conferencing/lib/videoClient";
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
@@ -16,7 +16,7 @@ import handler from "../../../../../pages/api/bookings/[id]/transcripts/_get";
type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
vi.mock("@calcom/app-store/videoClient", () => {
vi.mock("@calcom/features/conferencing/lib/videoClient", () => {
return {
getAllTranscriptsAccessLinkFromRoomName: vi.fn(),
};
+1 -1
View File
@@ -76,7 +76,7 @@ vi.mock("@calcom/features/watchlist/operations/check-if-users-are-blocked.contro
checkIfUsersAreBlocked: vi.fn().mockResolvedValue(false),
}));
vi.mock("@calcom/lib/di/containers/QualifiedHosts", () => ({
vi.mock("@calcom/features/di/containers/QualifiedHosts", () => ({
getQualifiedHostsService: vi.fn().mockReturnValue({
findQualifiedHostsWithDelegationCredentials: vi.fn().mockResolvedValue({
qualifiedRRHosts: [],
@@ -6,6 +6,7 @@ import { createMocks } from "node-mocks-http";
import { describe, expect, test } from "vitest";
import { HttpError } from "@calcom/lib/http-error";
import type { User } from "@calcom/prisma/client";
import handler from "../../../pages/api/selected-calendars/_post";
@@ -72,7 +73,7 @@ describe("POST /api/selected-calendars", () => {
prismaMock.user.findFirstOrThrow.mockResolvedValue({
id: 444444,
} as any);
} as User);
prismaMock.selectedCalendar.create.mockResolvedValue({
credentialId: 1,
@@ -95,6 +96,16 @@ describe("POST /api/selected-calendars", () => {
unwatchAttempts: 0,
createdAt: new Date(),
updatedAt: new Date(),
channelId: null,
channelKind: null,
channelResourceId: null,
channelResourceUri: null,
channelExpiration: null,
syncSubscribedAt: null,
syncToken: null,
syncedAt: null,
syncErrorAt: null,
syncErrorCount: null,
});
await handler(req, res);
@@ -140,6 +151,16 @@ describe("POST /api/selected-calendars", () => {
unwatchAttempts: 0,
createdAt: new Date(),
updatedAt: new Date(),
channelId: null,
channelKind: null,
channelResourceId: null,
channelResourceUri: null,
channelExpiration: null,
syncSubscribedAt: null,
syncToken: null,
syncedAt: null,
syncErrorAt: null,
syncErrorCount: null,
});
await handler(req, res);
@@ -1,9 +1,8 @@
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
import type { NextApiRequest } from "next";
import { describe, expect, it, beforeEach } from "vitest";
import { WatchlistSeverity } from "@calcom/prisma/enums";
import { isLockedOrBlocked } from "../../../lib/utils/isLockedOrBlocked";
describe("isLockedOrBlocked", () => {
@@ -18,7 +17,6 @@ describe("isLockedOrBlocked", () => {
{
type: "DOMAIN",
value: "blocked.com",
severity: WatchlistSeverity.CRITICAL,
createdById: 1,
},
],
@@ -26,13 +24,13 @@ describe("isLockedOrBlocked", () => {
});
it("should return false if no user in request", async () => {
const req = { userId: null, user: null } as any;
const req = { userId: null, user: null } as unknown as NextApiRequest;
const result = await isLockedOrBlocked(req);
expect(result).toBe(false);
});
it("should return false if user has no email", async () => {
const req = { userId: 123, user: { email: null } } as any;
const req = { userId: 123, user: { email: null } } as unknown as NextApiRequest;
const result = await isLockedOrBlocked(req);
expect(result).toBe(false);
});
@@ -44,7 +42,7 @@ describe("isLockedOrBlocked", () => {
locked: true,
email: "test@example.com",
},
} as any;
} as unknown as NextApiRequest;
const result = await isLockedOrBlocked(req);
expect(result).toBe(true);
@@ -57,7 +55,7 @@ describe("isLockedOrBlocked", () => {
locked: false,
email: "test@blocked.com",
},
} as any;
} as unknown as NextApiRequest;
const result = await isLockedOrBlocked(req);
expect(result).toBe(true);
@@ -70,7 +68,7 @@ describe("isLockedOrBlocked", () => {
locked: false,
email: "test@example.com",
},
} as any;
} as unknown as NextApiRequest;
const result = await isLockedOrBlocked(req);
expect(result).toBe(false);
@@ -83,7 +81,7 @@ describe("isLockedOrBlocked", () => {
locked: false,
email: "test@BLOCKED.COM",
},
} as any;
} as unknown as NextApiRequest;
const result = await isLockedOrBlocked(req);
expect(result).toBe(true);
@@ -6,6 +6,9 @@ import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { InstantBookingModule } from "@/lib/modules/instant-booking.module";
import { RecurringBookingModule } from "@/lib/modules/recurring-booking.module";
import { RegularBookingModule } from "@/lib/modules/regular-booking.module";
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { BillingModule } from "@/modules/billing/billing.module";
@@ -35,6 +38,9 @@ import { Module } from "@nestjs/common";
SchedulesModule_2024_04_15,
EventTypesModule_2024_06_14,
ProfilesModule,
RegularBookingModule,
RecurringBookingModule,
InstantBookingModule,
],
providers: [
TokensRepository,
@@ -21,7 +21,7 @@ import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { handleNewBooking } from "@calcom/platform-libraries";
import { type RegularBookingCreateResult } from "@calcom/platform-libraries/bookings";
import type { ApiSuccessResponse, ApiErrorResponse } from "@calcom/platform-types";
import type { User } from "@calcom/prisma/client";
@@ -41,7 +41,7 @@ describe("Bookings Endpoints 2024-04-15", () => {
let eventTypeId: number;
let createdBooking: Awaited<ReturnType<typeof handleNewBooking>>;
let createdBooking: RegularBookingCreateResult;
beforeAll(async () => {
const moduleRef = await withApiAuth(
@@ -131,8 +131,7 @@ describe("Bookings Endpoints 2024-04-15", () => {
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<Awaited<ReturnType<typeof handleNewBooking>>> =
response.body;
const responseBody: ApiSuccessResponse<RegularBookingCreateResult> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.userPrimaryEmail).toBeDefined();
@@ -231,8 +230,7 @@ describe("Bookings Endpoints 2024-04-15", () => {
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<Awaited<ReturnType<typeof handleNewBooking>>> =
response.body;
const responseBody: ApiSuccessResponse<RegularBookingCreateResult> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.userPrimaryEmail).toBeDefined();
@@ -289,8 +287,7 @@ describe("Bookings Endpoints 2024-04-15", () => {
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<Awaited<ReturnType<typeof handleNewBooking>>> =
response.body;
const responseBody: ApiSuccessResponse<RegularBookingCreateResult> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.userPrimaryEmail).toBeDefined();
@@ -387,8 +384,7 @@ describe("Bookings Endpoints 2024-04-15", () => {
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<Awaited<ReturnType<typeof handleNewBooking>>> =
response.body;
const responseBody: ApiSuccessResponse<RegularBookingCreateResult> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.userPrimaryEmail).toBeDefined();
@@ -7,6 +7,9 @@ import { MarkNoShowOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/ma
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key";
import { VERSION_2024_04_15, VERSION_2024_06_11, VERSION_2024_06_14 } from "@/lib/api-versions";
import { InstantBookingCreateService } from "@/lib/services/instant-booking-create.service";
import { RecurringBookingService } from "@/lib/services/recurring-booking.service";
import { RegularBookingService } from "@/lib/services/regular-booking.service";
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
@@ -45,11 +48,8 @@ import { v4 as uuidv4 } from "uuid";
import { X_CAL_CLIENT_ID, X_CAL_PLATFORM_EMBED } from "@calcom/platform-constants";
import { BOOKING_READ, SUCCESS_STATUS, BOOKING_WRITE } from "@calcom/platform-constants";
import {
handleNewRecurringBooking,
handleNewBooking,
BookingResponse,
HttpError,
handleInstantMeeting,
handleMarkNoShow,
getAllUserBookings,
getBookingInfo,
@@ -58,6 +58,7 @@ import {
ErrorCode,
} from "@calcom/platform-libraries";
import { CreationSource } from "@calcom/platform-libraries";
import { type InstantBookingCreateResult } from "@calcom/platform-libraries/bookings";
import {
GetBookingsInput_2024_04_15,
CancelBookingInput_2024_04_15,
@@ -109,7 +110,10 @@ export class BookingsController_2024_04_15 {
private readonly apiKeyRepository: ApiKeysRepository,
private readonly platformBookingsService: PlatformBookingsService,
private readonly usersRepository: UsersRepository,
private readonly usersService: UsersService
private readonly usersService: UsersService,
private readonly regularBookingService: RegularBookingService,
private readonly recurringBookingService: RecurringBookingService,
private readonly instantBookingCreateService: InstantBookingCreateService
) {}
@Get("/")
@@ -187,17 +191,19 @@ export class BookingsController_2024_04_15 {
const { orgSlug, locationUrl } = body;
try {
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl, isEmbed);
const booking = await handleNewBooking({
const booking = await this.regularBookingService.createBooking({
bookingData: bookingRequest.body,
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
forcedSlug: orgSlug,
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
bookingMeta: {
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
forcedSlug: orgSlug,
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
},
});
if (booking.userId && booking.uid && booking.startTime) {
void (await this.billingService.increaseUsageByUserId(booking.userId, {
@@ -315,15 +321,17 @@ export class BookingsController_2024_04_15 {
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
const createdBookings: BookingResponse[] = await handleNewRecurringBooking({
const createdBookings: BookingResponse[] = await this.recurringBookingService.createBooking({
bookingData: bookingRequest.body,
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
bookingMeta: {
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
},
});
createdBookings.forEach(async (booking) => {
@@ -351,14 +359,15 @@ export class BookingsController_2024_04_15 {
@Body() body: CreateBookingInput_2024_04_15,
@Headers(X_CAL_CLIENT_ID) clientId?: string,
@Headers(X_CAL_PLATFORM_EMBED) isEmbed?: string
): Promise<ApiResponse<Awaited<ReturnType<typeof handleInstantMeeting>>>> {
): Promise<ApiResponse<InstantBookingCreateResult>> {
const oAuthClientId =
clientId?.toString() || (await this.getOAuthClientIdFromEventType(body.eventTypeId));
req.userId = (await this.getOwnerId(req)) ?? -1;
try {
const instantMeeting = await handleInstantMeeting(
await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed)
);
const bookingReq = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
const instantMeeting = await this.instantBookingCreateService.createBooking({
bookingData: bookingReq.body,
});
if (instantMeeting.userId && instantMeeting.bookingUid) {
const now = new Date();
@@ -16,6 +16,9 @@ import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_0
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { InstantBookingModule } from "@/lib/modules/instant-booking.module";
import { RecurringBookingModule } from "@/lib/modules/recurring-booking.module";
import { RegularBookingModule } from "@/lib/modules/regular-booking.module";
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { BillingModule } from "@/modules/billing/billing.module";
@@ -58,6 +61,9 @@ import { Module } from "@nestjs/common";
TeamsEventTypesModule,
MembershipsModule,
ProfilesModule,
RegularBookingModule,
RecurringBookingModule,
InstantBookingModule,
],
providers: [
TokensRepository,
@@ -87,7 +87,7 @@ import {
@DocsTags("Bookings")
@ApiHeader({
name: "cal-api-version",
description: `Must be set to ${VERSION_2024_08_13}`,
description: `Must be set to ${VERSION_2024_08_13}. If not set to this value, the endpoint will default to an older version.`,
example: VERSION_2024_08_13,
required: true,
schema: {
@@ -133,6 +133,8 @@ export class BookingsController_2024_08_13 {
If you are creating a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or
you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
@ApiBody({
@@ -188,6 +190,8 @@ export class BookingsController_2024_08_13 {
If you are fetching a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or
you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
async getBooking(
@@ -206,7 +210,10 @@ export class BookingsController_2024_08_13 {
@UseGuards(BookingUidGuard)
@ApiOperation({
summary: "Get all the recordings for the booking",
description: `Fetches all the recordings for the booking \`:bookingUid\``,
description: `Fetches all the recordings for the booking \`:bookingUid\`
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
async getBookingRecordings(@Param("bookingUid") bookingUid: string): Promise<GetBookingRecordingsOutput> {
const recordings = await this.calVideoService.getRecordings(bookingUid);
@@ -221,7 +228,10 @@ export class BookingsController_2024_08_13 {
@UseGuards(BookingUidGuard)
@ApiOperation({
summary: "Get all the transcripts download links for the booking",
description: `Fetches all the transcripts download links for the booking \`:bookingUid\``,
description: `Fetches all the transcripts download links for the booking \`:bookingUid\`
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
async getBookingTranscripts(@Param("bookingUid") bookingUid: string): Promise<GetBookingTranscriptsOutput> {
const transcripts = await this.calVideoService.getTranscripts(bookingUid);
@@ -236,7 +246,10 @@ export class BookingsController_2024_08_13 {
@UseGuards(ApiAuthGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Permissions([BOOKING_READ])
@ApiOperation({ summary: "Get all bookings" })
@ApiOperation({
summary: "Get all bookings",
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
})
async getBookings(
@Query() queryParams: GetBookingsInput_2024_08_13,
@GetUser() user: ApiAuthGuardUser
@@ -263,7 +276,10 @@ export class BookingsController_2024_08_13 {
@ApiHeader(OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Reschedule a booking",
description: "Reschedule a booking or seated booking",
description: `Reschedule a booking or seated booking
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
@ApiBody({
schema: {
@@ -315,6 +331,8 @@ export class BookingsController_2024_08_13 {
If you are cancelling a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or
you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
@ApiBody({
@@ -350,7 +368,10 @@ export class BookingsController_2024_08_13 {
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Mark a booking absence",
description: "The provided authorization header refers to the owner of the booking.",
description: `The provided authorization header refers to the owner of the booking.
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
async markNoShow(
@Param("bookingUid") bookingUid: string,
@@ -372,8 +393,10 @@ export class BookingsController_2024_08_13 {
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Reassign a booking to auto-selected host",
description:
"Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.",
description: `Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
async reassignBooking(
@Param("bookingUid") bookingUid: string,
@@ -394,8 +417,10 @@ export class BookingsController_2024_08_13 {
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Reassign a booking to a specific host",
description:
"Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.",
description: `Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
async reassignBookingToUser(
@Param("bookingUid") bookingUid: string,
@@ -423,7 +448,10 @@ export class BookingsController_2024_08_13 {
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Confirm a booking",
description: "The provided authorization header refers to the owner of the booking.",
description: `The provided authorization header refers to the owner of the booking.
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
async confirmBooking(
@Param("bookingUid") bookingUid: string,
@@ -444,7 +472,10 @@ export class BookingsController_2024_08_13 {
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Decline a booking",
description: "The provided authorization header refers to the owner of the booking.",
description: `The provided authorization header refers to the owner of the booking.
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
async declineBooking(
@Param("bookingUid") bookingUid: string,
@@ -465,8 +496,10 @@ export class BookingsController_2024_08_13 {
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Get 'Add to Calendar' links for a booking",
description:
"Retrieve calendar links for a booking that can be used to add the event to various calendar services. Returns links for Google Calendar, Microsoft Office, Microsoft Outlook, and a downloadable ICS file.",
description: `Retrieve calendar links for a booking that can be used to add the event to various calendar services. Returns links for Google Calendar, Microsoft Office, Microsoft Outlook, and a downloadable ICS file.
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
@HttpCode(HttpStatus.OK)
async getCalendarLinks(@Param("bookingUid") bookingUid: string): Promise<CalendarLinksOutput_2024_08_13> {
@@ -485,6 +518,7 @@ export class BookingsController_2024_08_13 {
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Get booking references",
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
})
@HttpCode(HttpStatus.OK)
async getBookingReferences(
@@ -65,6 +65,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
let team1EventTypeId: number;
let team2EventTypeId: number;
let phoneOnlyEventTypeId: number;
let collectiveEventWithoutHostsId: number;
let roundRobinEventWithoutHostsId: number;
const team1EventTypeSlug = `team-bookings-event-type-${randomString()}`;
const team2EventTypeSlug = `team-bookings-event-type-${randomString()}`;
@@ -223,6 +225,28 @@ describe("Bookings Endpoints 2024-08-13", () => {
team1EventTypeId = team1EventType.id;
const team1CollectiveEventTypeWithoutHosts = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: {
connect: { id: team1.id },
},
title: `team-bookings-2024-08-13-collective-event-type-without-hosts-${randomString()}`,
slug: `team-bookings-2024-08-13-collective-event-type-without-hosts-${randomString()}`,
length: 60,
});
collectiveEventWithoutHostsId = team1CollectiveEventTypeWithoutHosts.id;
const team1RoundRobinEventTypeWithoutHosts = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "ROUND_ROBIN",
team: {
connect: { id: team1.id },
},
title: `team-bookings-2024-08-13-round-robin-event-type-without-hosts-${randomString()}`,
slug: `team-bookings-2024-08-13-round-robin-event-type-without-hosts-${randomString()}`,
length: 60,
});
roundRobinEventWithoutHostsId = team1RoundRobinEventTypeWithoutHosts.id;
const phoneOnlyEventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "ROUND_ROBIN",
team: {
@@ -363,6 +387,52 @@ describe("Bookings Endpoints 2024-08-13", () => {
await app.init();
});
describe("cant book event types without hosts", () => {
it("should fail to book a collective event type without hosts", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId: collectiveEventWithoutHostsId,
attendee: {
name: "alice",
timeZone: "Europe/Madrid",
email: "alice@gmail.com",
},
};
const response = await request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(response.status).toBe(422);
expect(response.body.error.message).toBe(
`Can't book this team event type because it has no hosts. Please, add at least 1 host to event type with id=${collectiveEventWithoutHostsId} belonging to team with id=${team1.id} and try again.`
);
});
it("should fail to book a round robin event type without hosts", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId: roundRobinEventWithoutHostsId,
attendee: {
name: "alice",
timeZone: "Europe/Madrid",
email: "alice@gmail.com",
},
};
const response = await request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(response.status).toBe(422);
expect(response.body.error.message).toBe(
`Can't book this team event type because it has no hosts. Please, add at least 1 host to event type with id=${roundRobinEventWithoutHostsId} belonging to team with id=${team1.id} and try again.`
);
});
});
describe("create team bookings", () => {
it("should create a team 1 booking", async () => {
const body: CreateBookingInput_2024_08_13 = {
@@ -765,8 +835,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
return client;
}
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
function responseDataIsBooking(data: unknown): data is BookingOutput_2024_08_13 {
return !Array.isArray(data) && typeof data === "object" && data !== null && data && "id" in data;
}
afterAll(async () => {
@@ -6,6 +6,9 @@ import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/servi
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { getPagination } from "@/lib/pagination/pagination";
import { InstantBookingCreateService } from "@/lib/services/instant-booking-create.service";
import { RecurringBookingService } from "@/lib/services/recurring-booking.service";
import { RegularBookingService } from "@/lib/services/regular-booking.service";
import { AuthOptionalUser } from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { BillingService } from "@/modules/billing/services/billing.service";
@@ -29,6 +32,7 @@ import {
Logger,
NotFoundException,
UnauthorizedException,
UnprocessableEntityException,
} from "@nestjs/common";
import { BadRequestException } from "@nestjs/common";
import { Request } from "express";
@@ -36,10 +40,8 @@ import { DateTime } from "luxon";
import { z } from "zod";
import {
handleNewRecurringBooking,
getTranslation,
getAllUserBookings,
handleInstantMeeting,
handleCancelBooking,
roundRobinReassignment,
roundRobinManualReassignment,
@@ -47,7 +49,6 @@ import {
confirmBookingHandler,
getCalendarLinks,
} from "@calcom/platform-libraries";
import { handleNewBooking } from "@calcom/platform-libraries";
import {
CreateBookingInput_2024_08_13,
CreateBookingInput,
@@ -108,7 +109,10 @@ export class BookingsService_2024_08_13 {
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
private readonly membershipsRepository: MembershipsRepository,
private readonly membershipsService: MembershipsService,
private readonly errorsBookingsService: ErrorsBookingsService_2024_08_13
private readonly errorsBookingsService: ErrorsBookingsService_2024_08_13,
private readonly regularBookingService: RegularBookingService,
private readonly recurringBookingService: RecurringBookingService,
private readonly instantBookingCreateService: InstantBookingCreateService
) {}
async createBooking(request: Request, body: CreateBookingInput, authUser: AuthOptionalUser) {
@@ -132,6 +136,10 @@ export class BookingsService_2024_08_13 {
);
}
if (eventType.schedulingType === "COLLECTIVE" || eventType.schedulingType === "ROUND_ROBIN") {
await this.checkEventTypeHasHosts(eventType.id);
}
body.eventTypeId = eventType.id;
if ("instant" in body && body.instant) {
@@ -159,6 +167,15 @@ export class BookingsService_2024_08_13 {
}
}
async checkEventTypeHasHosts(eventTypeId: number) {
const eventType = await this.eventTypesRepository.getEventTypeWithHosts(eventTypeId);
if (!eventType?.hosts?.length) {
throw new UnprocessableEntityException(
`Can't book this team event type because it has no hosts. Please, add at least 1 host to event type with id=${eventTypeId} belonging to team with id=${eventType?.teamId} and try again.`
);
}
}
async checkBookingRequiresAuthenticationSetting(
eventType: EventTypeWithOwnerAndTeam,
authUser: AuthOptionalUser,
@@ -450,7 +467,9 @@ export class BookingsService_2024_08_13 {
}
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
const booking = await handleInstantMeeting(bookingRequest);
const booking = await this.instantBookingCreateService.createBooking({
bookingData: bookingRequest.body,
});
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(
booking.bookingId
@@ -468,17 +487,19 @@ export class BookingsService_2024_08_13 {
eventType: EventTypeWithOwnerAndTeam
) {
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body, eventType);
const bookings = await handleNewRecurringBooking({
const bookings = await this.recurringBookingService.createBooking({
bookingData: bookingRequest.body,
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
noEmail: bookingRequest.noEmail,
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
bookingMeta: {
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
noEmail: bookingRequest.noEmail,
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
},
});
const ids = bookings.map((booking) => booking.id || 0);
return this.outputService.getOutputRecurringBookings(ids);
@@ -491,16 +512,18 @@ export class BookingsService_2024_08_13 {
userIsEventTypeAdminOrOwner: boolean
) {
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body, eventType);
const bookings = await handleNewRecurringBooking({
const bookings = await this.recurringBookingService.createBooking({
bookingData: bookingRequest.body,
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
bookingMeta: {
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
},
});
return this.outputService.getOutputCreateRecurringSeatedBookings(
bookings.map((booking) => ({ uid: booking.uid || "", seatUid: booking.seatReferenceUid || "" })),
@@ -514,16 +537,18 @@ export class BookingsService_2024_08_13 {
eventType: EventTypeWithOwnerAndTeam
) {
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
const booking = await handleNewBooking({
const booking = await this.regularBookingService.createBooking({
bookingData: bookingRequest.body,
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
bookingMeta: {
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
},
});
if (!booking.uid) {
@@ -546,16 +571,18 @@ export class BookingsService_2024_08_13 {
) {
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
try {
const booking = await handleNewBooking({
const booking = await this.regularBookingService.createBooking({
bookingData: bookingRequest.body,
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
bookingMeta: {
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
},
});
if (!booking.uid) {
@@ -741,16 +768,18 @@ export class BookingsService_2024_08_13 {
bookingUid,
body
);
const booking = await handleNewBooking({
const booking = await this.regularBookingService.createBooking({
bookingData: bookingRequest.body,
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
bookingMeta: {
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
},
});
if (!booking.uid) {
throw new Error("Booking missing uid");
@@ -38,6 +38,7 @@ import type {
GuestsDefaultFieldOutput_2024_06_14,
NameDefaultFieldInput_2024_06_14,
NotesDefaultFieldInput_2024_06_14,
SplitNameDefaultFieldOutput_2024_06_14,
UpdateEventTypeInput_2024_06_14,
} from "@calcom/platform-types";
import type { PlatformOAuthClient, Team, User, Schedule, EventType } from "@calcom/prisma/client";
@@ -1447,23 +1448,23 @@ describe("Event types Endpoints", () => {
try {
await eventTypesRepositoryFixture.delete(eventType.id);
} catch (e) {
// Event type might have been deleted by the test
console.log(e);
}
try {
await userRepositoryFixture.delete(user.id);
} catch (e) {
// User might have been deleted by the test
console.log(e);
}
try {
await userRepositoryFixture.delete(falseTestUser.id);
} catch (e) {
// User might have been deleted by the test
console.log(e);
}
try {
await userRepositoryFixture.delete(orgUser.id);
} catch (e) {
// User might have been deleted by the test
console.log(e);
}
await app.close();
});
@@ -2187,6 +2188,47 @@ describe("Event types Endpoints", () => {
});
});
describe("split name booking field", () => {
it("should create an event type with split name booking field", async () => {
const splitNameBookingField: SplitNameDefaultFieldOutput_2024_06_14 = {
isDefault: true,
type: "splitName",
slug: "splitName",
firstNameLabel: "First name",
firstNamePlaceholder: "",
lastNameLabel: "last name",
lastNamePlaceholder: "",
lastNameRequired: false,
disableOnPrefill: false,
};
const body: CreateEventTypeInput_2024_06_14 = {
title: "Coding class 9",
slug: "coding-class-9",
description: "Let's learn how to code like a pro.",
lengthInMinutes: 60,
bookingFields: [splitNameBookingField],
};
return request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const createdEventType = responseBody.data;
const splitNameBookingFieldResponse = createdEventType.bookingFields.find(
(field) => field.type === "splitName"
) as SplitNameDefaultFieldOutput_2024_06_14 | undefined;
expect(splitNameBookingFieldResponse).toEqual(splitNameBookingField);
await eventTypesRepositoryFixture.delete(createdEventType.id);
});
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
@@ -2194,12 +2236,12 @@ describe("Event types Endpoints", () => {
await eventTypesRepositoryFixture.delete(legacyEventTypeId1);
await eventTypesRepositoryFixture.delete(legacyEventTypeId2);
} catch (e) {
// Event type might have been deleted by the test
console.log(e);
}
try {
await userRepositoryFixture.delete(user.id);
} catch (e) {
// User might have been deleted by the test
console.log(e);
}
await app.close();
});
@@ -2379,7 +2421,7 @@ describe("Event types Endpoints", () => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const updatedEventType = responseBody.data;
+expect(updatedEventType.hidden).toBe(false);
expect(updatedEventType.hidden).toBe(false);
});
});
@@ -2389,12 +2431,12 @@ describe("Event types Endpoints", () => {
try {
await eventTypesRepositoryFixture.delete(legacyEventTypeId1);
} catch (e) {
// Event type might have been deleted by the test
console.log(e);
}
try {
await userRepositoryFixture.delete(user.id);
} catch (e) {
// User might have been deleted by the test
console.log(e);
}
await app.close();
});
@@ -61,7 +61,7 @@ import {
@DocsTags("Event Types")
@ApiHeader({
name: "cal-api-version",
description: `Must be set to ${VERSION_2024_06_14}`,
description: `Must be set to ${VERSION_2024_06_14}. If not set to this value, the endpoint will default to an older version.`,
example: VERSION_2024_06_14,
required: true,
schema: {
@@ -80,7 +80,10 @@ export class EventTypesController_2024_06_14 {
@Permissions([EVENT_TYPE_WRITE])
@UseGuards(ApiAuthGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({ summary: "Create an event type" })
@ApiOperation({
summary: "Create an event type",
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
})
async createEventType(
@Body() body: CreateEventTypeInput_2024_06_14,
@GetUser() user: UserWithProfile
@@ -102,7 +105,10 @@ export class EventTypesController_2024_06_14 {
@Permissions([EVENT_TYPE_READ])
@UseGuards(ApiAuthGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({ summary: "Get an event type" })
@ApiOperation({
summary: "Get an event type",
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
})
async getEventTypeById(
@Param("eventTypeId") eventTypeId: string,
@GetUser() user: UserWithProfile
@@ -122,8 +128,10 @@ export class EventTypesController_2024_06_14 {
@Get("/")
@ApiOperation({
summary: "Get all event types",
description:
"Hidden event types are returned only if authentication is provided and it belongs to the event type owner.",
description: `Hidden event types are returned only if authentication is provided and it belongs to the event type owner.
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
@UseGuards(OptionalApiAuthGuard)
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
@@ -149,7 +157,10 @@ export class EventTypesController_2024_06_14 {
@UseGuards(ApiAuthGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Update an event type" })
@ApiOperation({
summary: "Update an event type",
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
})
async updateEventType(
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Body() body: UpdateEventTypeInput_2024_06_14,
@@ -173,7 +184,10 @@ export class EventTypesController_2024_06_14 {
@Permissions([EVENT_TYPE_WRITE])
@UseGuards(ApiAuthGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({ summary: "Delete an event type" })
@ApiOperation({
summary: "Delete an event type",
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
})
async deleteEventType(
@Param("eventTypeId") eventTypeId: number,
@GetUser("id") userId: number
@@ -40,7 +40,7 @@ import {
@DocsTags("Schedules")
@ApiHeader({
name: "cal-api-version",
description: `Must be set to ${VERSION_2024_06_11}`,
description: `Must be set to ${VERSION_2024_06_11}. If not set to this value, the endpoint will default to an older version.`,
example: VERSION_2024_06_11,
required: true,
schema: {
@@ -69,6 +69,8 @@ export class SchedulesController_2024_06_11 {
After creating a non-default schedule, you can update an event type to point to that schedule via the PATCH \`event-types/{eventTypeId}\` endpoint.
When specifying start time and end time for each day use the 24 hour format e.g. 08:00, 15:00 etc.
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
async createSchedule(
@@ -91,7 +93,10 @@ export class SchedulesController_2024_06_11 {
})
@ApiOperation({
summary: "Get default schedule",
description: "Get the default schedule of the authenticated user.",
description: `Get the default schedule of the authenticated user.
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
async getDefaultSchedule(@GetUser() user: UserWithProfile): Promise<GetScheduleOutput_2024_06_11> {
const schedule = await this.schedulesService.getUserScheduleDefault(user.id);
@@ -104,7 +109,10 @@ export class SchedulesController_2024_06_11 {
@Get("/:scheduleId")
@Permissions([SCHEDULE_READ])
@ApiOperation({ summary: "Get a schedule" })
@ApiOperation({
summary: "Get a schedule",
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
})
async getSchedule(
@GetUser() user: UserWithProfile,
@Param("scheduleId") scheduleId: number
@@ -121,7 +129,10 @@ export class SchedulesController_2024_06_11 {
@Permissions([SCHEDULE_READ])
@ApiOperation({
summary: "Get all schedules",
description: "Get all schedules of the authenticated user.",
description: `Get all schedules of the authenticated user.
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
async getSchedules(@GetUser() user: UserWithProfile): Promise<GetSchedulesOutput_2024_06_11> {
const schedules = await this.schedulesService.getUserSchedules(user.id);
@@ -134,7 +145,10 @@ export class SchedulesController_2024_06_11 {
@Patch("/:scheduleId")
@Permissions([SCHEDULE_WRITE])
@ApiOperation({ summary: "Update a schedule" })
@ApiOperation({
summary: "Update a schedule",
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
})
async updateSchedule(
@GetUser() user: UserWithProfile,
@Body() bodySchedule: UpdateScheduleInput_2024_06_11,
@@ -155,7 +169,10 @@ export class SchedulesController_2024_06_11 {
@Delete("/:scheduleId")
@HttpCode(HttpStatus.OK)
@Permissions([SCHEDULE_WRITE])
@ApiOperation({ summary: "Delete a schedule" })
@ApiOperation({
summary: "Delete a schedule",
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
})
async deleteSchedule(
@GetUser("id") userId: number,
@Param("scheduleId") scheduleId: number
@@ -0,0 +1,8 @@
import { InstantBookingCreateService } from "@/lib/services/instant-booking-create.service";
import { Module } from "@nestjs/common";
@Module({
providers: [InstantBookingCreateService],
exports: [InstantBookingCreateService],
})
export class InstantBookingModule {}
@@ -0,0 +1,10 @@
import { RegularBookingModule } from "@/lib/modules/regular-booking.module";
import { RecurringBookingService } from "@/lib/services/recurring-booking.service";
import { Module } from "@nestjs/common";
@Module({
imports: [RegularBookingModule],
providers: [RecurringBookingService],
exports: [RecurringBookingService],
})
export class RecurringBookingModule {}
@@ -0,0 +1,32 @@
import { PrismaAttributeRepository } from "@/lib/repositories/prisma-attribute.repository";
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
import { PrismaHostRepository } from "@/lib/repositories/prisma-host.repository";
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
import { CacheService } from "@/lib/services/cache.service";
import { CheckBookingAndDurationLimitsService } from "@/lib/services/check-booking-and-duration-limits.service";
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { LuckyUserService } from "@/lib/services/lucky-user.service";
import { RegularBookingService } from "@/lib/services/regular-booking.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule],
providers: [
PrismaAttributeRepository,
PrismaBookingRepository,
PrismaFeaturesRepository,
PrismaHostRepository,
PrismaOOORepository,
PrismaUserRepository,
CacheService,
CheckBookingAndDurationLimitsService,
CheckBookingLimitsService,
LuckyUserService,
RegularBookingService,
],
exports: [RegularBookingService],
})
export class RegularBookingModule {}
@@ -0,0 +1,6 @@
import { Injectable } from "@nestjs/common";
import { InstantBookingCreateService as BaseInstantBookingCreateService } from "@calcom/platform-libraries/bookings";
@Injectable()
export class InstantBookingCreateService extends BaseInstantBookingCreateService {}
@@ -0,0 +1,13 @@
import { RegularBookingService } from "@/lib/services/regular-booking.service";
import { Injectable } from "@nestjs/common";
import { RecurringBookingService as BaseRecurringBookingService } from "@calcom/platform-libraries/bookings";
@Injectable()
export class RecurringBookingService extends BaseRecurringBookingService {
constructor(regularBookingService: RegularBookingService) {
super({
regularBookingService,
});
}
}
@@ -0,0 +1,46 @@
import { PrismaAttributeRepository } from "@/lib/repositories/prisma-attribute.repository";
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
import { PrismaHostRepository } from "@/lib/repositories/prisma-host.repository";
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
import { CacheService } from "@/lib/services/cache.service";
import { CheckBookingAndDurationLimitsService } from "@/lib/services/check-booking-and-duration-limits.service";
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { LuckyUserService } from "@/lib/services/lucky-user.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { RegularBookingService as BaseRegularBookingService } from "@calcom/platform-libraries/bookings";
import type { PrismaClient } from "@calcom/prisma";
@Injectable()
export class RegularBookingService extends BaseRegularBookingService {
constructor(
cacheService: CacheService,
checkBookingAndDurationLimitsService: CheckBookingAndDurationLimitsService,
prismaWriteService: PrismaWriteService,
bookingRepository: PrismaBookingRepository,
featuresRepository: PrismaFeaturesRepository,
checkBookingLimitsService: CheckBookingLimitsService,
luckyUserService: LuckyUserService,
hostRepository: PrismaHostRepository,
oooRepository: PrismaOOORepository,
userRepository: PrismaUserRepository,
attributeRepository: PrismaAttributeRepository
) {
super({
cacheService,
checkBookingAndDurationLimitsService,
prismaClient: prismaWriteService.prisma as unknown as PrismaClient,
bookingRepository,
featuresRepository,
checkBookingLimitsService,
luckyUserService,
hostRepository,
oooRepository,
userRepository,
attributeRepository,
});
}
}
@@ -0,0 +1,51 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class AtomsSecondaryEmailsRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async getSecondaryEmailsVerified(userId: number) {
return await this.dbRead.prisma.secondaryEmail.findMany({
where: {
userId,
emailVerified: {
not: null,
},
},
});
}
async getExistingSecondaryEmailByUserAndEmail(userId: number, email: string) {
const existingSecondaryEmailRecord = await this.dbRead.prisma.secondaryEmail.findUnique({
where: {
userId_email: { userId, email },
},
});
return existingSecondaryEmailRecord?.email;
}
async getExistingSecondaryEmail(email: string) {
const existingSecondaryEmailRecord = await this.dbRead.prisma.secondaryEmail.findUnique({
where: {
email,
},
});
return existingSecondaryEmailRecord?.email;
}
async addSecondaryEmailVerified(userId: number, email: string) {
const existingSecondaryEmailRecord = await this.dbWrite.prisma.secondaryEmail.create({
data: {
userId,
email,
emailVerified: new Date(),
},
});
return existingSecondaryEmailRecord?.email;
}
}
@@ -1,4 +1,5 @@
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { AtomsSecondaryEmailsRepository } from "@/modules/atoms/atoms-secondary-emails.repository";
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
import { AtomsConferencingAppsController } from "@/modules/atoms/controllers/atoms.conferencing-apps.controller";
import { AtomsController } from "@/modules/atoms/controllers/atoms.controller";
@@ -33,6 +34,7 @@ import { Module } from "@nestjs/common";
CredentialsRepository,
UsersRepository,
AtomsRepository,
AtomsSecondaryEmailsRepository,
UsersService,
SchedulesAtomsService,
VerificationAtomsService,
@@ -64,47 +64,4 @@ export class AtomsRepository {
return userTeams;
}
async getSecondaryEmails(userId: number) {
return await this.dbRead.prisma.secondaryEmail.findMany({
where: {
userId,
emailVerified: {
not: null,
},
},
});
}
async getExistingSecondaryEmailByUserAndEmail(userId: number, email: string) {
const existingSecondaryEmailRecord = await this.dbRead.prisma.secondaryEmail.findUnique({
where: {
userId_email: { userId, email },
},
});
return existingSecondaryEmailRecord?.email;
}
async getExistingSecondaryEmail(email: string) {
const existingSecondaryEmailRecord = await this.dbRead.prisma.secondaryEmail.findUnique({
where: {
email,
},
});
return existingSecondaryEmailRecord?.email;
}
async addSecondaryEmail(userId: number, email: string) {
const existingSecondaryEmailRecord = await this.dbWrite.prisma.secondaryEmail.create({
data: {
userId,
email,
emailVerified: new Date(),
},
});
return existingSecondaryEmailRecord?.email;
}
}
@@ -1,10 +1,10 @@
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
import { AtomsSecondaryEmailsRepository } from "@/modules/atoms/atoms-secondary-emails.repository";
import { CheckEmailVerificationRequiredParams } from "@/modules/atoms/inputs/check-email-verification-required-params";
import { GetVerifiedEmailsInput } from "@/modules/atoms/inputs/get-verified-emails-params";
import { SendVerificationEmailInput } from "@/modules/atoms/inputs/send-verification-email.input";
import { VerifyEmailCodeInput } from "@/modules/atoms/inputs/verify-email-code.input";
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { Injectable, BadRequestException, UnauthorizedException } from "@nestjs/common";
import {
@@ -17,8 +17,8 @@ import {
@Injectable()
export class VerificationAtomsService {
constructor(
private readonly atomsRepository: AtomsRepository,
private readonly teamsRepository: TeamsRepository
private readonly atomsSecondaryEmailsRepository: AtomsSecondaryEmailsRepository,
private readonly usersRepository: UsersRepository
) {}
async checkEmailVerificationRequired(input: CheckEmailVerificationRequiredParams) {
@@ -76,7 +76,8 @@ export class VerificationAtomsService {
if (teamId) {
const verifiedEmails: string[] = [];
const teamMembers = await this.teamsRepository.getTeamMemberEmails(teamId);
const teamMembers = await this.usersRepository.getUserEmailsVerifiedForTeam(teamId);
if (teamMembers.length === 0) {
return verifiedEmails;
@@ -96,7 +97,7 @@ export class VerificationAtomsService {
let verifiedEmails = [userEmailWithoutOauthClientId];
const secondaryEmails = await this.atomsRepository.getSecondaryEmails(userId);
const secondaryEmails = await this.atomsSecondaryEmailsRepository.getSecondaryEmailsVerified(userId);
verifiedEmails = verifiedEmails.concat(
secondaryEmails.map((secondaryEmail) => this.removeClientIdFromEmail(secondaryEmail.email))
);
@@ -113,11 +114,9 @@ export class VerificationAtomsService {
existingPrimaryEmail: string;
email: string;
}): Promise<boolean> {
const existingSecondaryEmail = await this.atomsRepository.getExistingSecondaryEmailByUserAndEmail(
userId,
email
);
const alreadyExistingEmail = await this.atomsRepository.getExistingSecondaryEmail(email);
const existingSecondaryEmail =
await this.atomsSecondaryEmailsRepository.getExistingSecondaryEmailByUserAndEmail(userId, email);
const alreadyExistingEmail = await this.atomsSecondaryEmailsRepository.getExistingSecondaryEmail(email);
if (alreadyExistingEmail) {
throw new BadRequestException("Email already exists");
@@ -127,7 +126,7 @@ export class VerificationAtomsService {
return true;
}
await this.atomsRepository.addSecondaryEmail(userId, email);
await this.atomsSecondaryEmailsRepository.addSecondaryEmailVerified(userId, email);
return true;
}
@@ -1,7 +1,8 @@
import { PlatformPlan } from "@/modules/billing/types";
import { hasMinimumPlan } from "@/modules/auth/guards/billing/platform-plan.guard";
import { orderedPlans, PlatformPlan } from "@/modules/billing/types";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable, NotFoundException } from "@nestjs/common";
import { ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
@Injectable()
export class ManagedOrganizationsBillingService {
@@ -14,8 +15,18 @@ export class ManagedOrganizationsBillingService {
if (!managerOrgBilling) {
throw new NotFoundException("Manager organization billing not found.");
}
if (managerOrgBilling.plan !== PlatformPlan.SCALE) {
throw new NotFoundException("Manager organization billing plan is not SCALE.");
if (
!hasMinimumPlan({
currentPlan: managerOrgBilling.plan as PlatformPlan,
minimumPlan: PlatformPlan.SCALE,
plans: orderedPlans,
})
) {
throw new ForbiddenException(
`organization with id=${managerOrgId} does not have required plan for this operation. Minimum plan is ${
PlatformPlan.SCALE
} while the organization has ${managerOrgBilling.plan || "undefined"}.`
);
}
return this.dbWrite.prisma.platformBilling.create({
@@ -705,6 +705,111 @@ describe("OAuth Client Users Endpoints", () => {
.expect(200);
});
describe("managed user time zone", () => {
describe("negative tests", () => {
it("should not allow '' time zone", async () => {
const requestBody = {
email: "whatever2@gmail.com",
timeZone: "",
name: "Bob Smithson",
};
await request(app.getHttpServer())
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users`)
.set("x-cal-secret-key", oAuthClient.secret)
.send(requestBody)
.expect(400);
});
it("should not allow 'invalid-timezone' time zone", async () => {
const requestBody = {
email: "whatever2@gmail.com",
timeZone: "invalid-timezone",
name: "Bob Smithson",
};
await request(app.getHttpServer())
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users`)
.set("x-cal-secret-key", oAuthClient.secret)
.send(requestBody)
.expect(400);
});
});
describe("positive tests", () => {
it("should allow null timezone", async () => {
const requestBody = {
email: "whatever1@gmail.com",
timeZone: null,
name: "Bob Smithson",
};
const response = await request(app.getHttpServer())
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users`)
.set("x-cal-secret-key", oAuthClient.secret)
.send(requestBody)
.expect(201);
const responseBody: CreateManagedUserOutput = response.body;
expect(responseBody.data.user.timeZone).toEqual("Europe/London");
await userRepositoryFixture.delete(responseBody.data.user.id);
});
it("should allow undefined time zone", async () => {
const requestBody = {
email: "whatever3@gmail.com",
timeZone: undefined,
name: "Bob Smithson",
};
const response = await request(app.getHttpServer())
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users`)
.set("x-cal-secret-key", oAuthClient.secret)
.send(requestBody)
.expect(201);
const responseBody: CreateManagedUserOutput = response.body;
expect(responseBody.data.user.timeZone).toEqual("Europe/London");
await userRepositoryFixture.delete(responseBody.data.user.id);
});
it("should allow valid time zone", async () => {
const requestBody = {
email: "whatever4@gmail.com",
timeZone: "Europe/Rome",
name: "Bob Smithson",
};
const response = await request(app.getHttpServer())
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users`)
.set("x-cal-secret-key", oAuthClient.secret)
.send(requestBody)
.expect(201);
const responseBody: CreateManagedUserOutput = response.body;
expect(responseBody.data.user.timeZone).toBe("Europe/Rome");
await userRepositoryFixture.delete(responseBody.data.user.id);
});
it("should allow without any time zone", async () => {
const requestBody = {
email: "whatever5@gmail.com",
name: "Bob Smithson",
};
const response = await request(app.getHttpServer())
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users`)
.set("x-cal-secret-key", oAuthClient.secret)
.send(requestBody)
.expect(201);
const responseBody: CreateManagedUserOutput = response.body;
expect(responseBody.data.user.timeZone).toEqual("Europe/London");
await userRepositoryFixture.delete(responseBody.data.user.id);
});
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await oauthClientRepositoryFixture.delete(oAuthClientEventTypesDisabled.id);
@@ -712,17 +817,17 @@ describe("OAuth Client Users Endpoints", () => {
try {
await userRepositoryFixture.delete(postResponseData.user.id);
} catch (e) {
// User might have been deleted by the test
console.log(e);
}
try {
await userRepositoryFixture.delete(postResponseData2.user.id);
} catch (e) {
// User might have been deleted by the test
console.log(e);
}
try {
await userRepositoryFixture.delete(platformAdmin.id);
} catch (e) {
// User might have been deleted by the test
console.log(e);
}
await app.close();
});
@@ -931,7 +1036,7 @@ describe("OAuth Client Users Endpoints", () => {
try {
await userRepositoryFixture.delete(postResponseData.user.id);
} catch (e) {
// User might have been deleted by the test
console.log(e);
}
await app.close();
});
@@ -61,11 +61,24 @@ describe("Organizations Organizations Endpoints", () => {
let profilesRepositoryFixture: ProfileRepositoryFixture;
let managerOrg: Team;
let payPerUserPlanManagerOrg: Team;
let essentialsPlanManagerOrg: Team;
let managedOrg: ManagedOrganizationWithApiKeyOutput;
let managedOrg2: ManagedOrganizationWithApiKeyOutput;
const managerOrgAdminEmail = `organizations-organizations-admin-${randomString()}@api.com`;
const payPerUserPlanManagerOrgAdminEmail = `organizations-organizations-admin-${randomString()}@api.com`;
const essentialsPlanManagerOrgAdminEmail = `organizations-organizations-admin-${randomString()}@api.com`;
let managerOrgAdmin: User;
let payPerUserPlanManagerOrgAdmin: User;
let essentialsPlanManagerOrgAdmin: User;
let managerOrgAdminApiKey: string;
let payPerUserPlanManagerOrgAdminApiKey: string;
let essentialsPlanManagerOrgAdminApiKey: string;
let managerOrgBilling: PlatformBilling;
let managedOrgApiKey: string;
@@ -93,40 +106,36 @@ describe("Organizations Organizations Endpoints", () => {
oAuthClientsRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
managerOrgAdmin = await userRepositoryFixture.create({
email: managerOrgAdminEmail,
username: managerOrgAdminEmail,
});
managerOrg = await organizationsRepositoryFixture.create({
name: `organizations-organizations-organization-${randomString()}`,
isOrganization: true,
isPlatform: true,
});
await profilesRepositoryFixture.create({
uid: "asd-asd",
username: managerOrgAdminEmail,
user: { connect: { id: managerOrgAdmin.id } },
organization: { connect: { id: managerOrg.id } },
movedFromUser: { connect: { id: managerOrgAdmin.id } },
});
managerOrgBilling = await platformBillingRepositoryFixture.create(managerOrg.id, "SCALE");
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: managerOrgAdmin.id } },
team: { connect: { id: managerOrg.id } },
accepted: true,
});
const { keyString } = await apiKeysRepositoryFixture.createApiKey(
managerOrgAdmin.id,
null,
managerOrg.id
// Setup manager organization with SCALE plan
const managerSetup = await setupTestOrganization(
managerOrgAdminEmail,
`organizations-organizations-organization-${randomString()}`,
"SCALE"
);
managerOrgAdminApiKey = `cal_test_${keyString}`;
managerOrgAdmin = managerSetup.admin;
managerOrg = managerSetup.org;
managerOrgBilling = managerSetup.billing;
managerOrgAdminApiKey = managerSetup.apiKey;
// Setup pay-per-user organization
const payPerUserSetup = await setupTestOrganization(
payPerUserPlanManagerOrgAdminEmail,
`pay-per-user-plan-organization-${randomString()}`,
"PER_ACTIVE_USER"
);
payPerUserPlanManagerOrgAdmin = payPerUserSetup.admin;
payPerUserPlanManagerOrg = payPerUserSetup.org;
payPerUserPlanManagerOrgAdminApiKey = payPerUserSetup.apiKey;
// Setup essentials organization
const essentialsSetup = await setupTestOrganization(
essentialsPlanManagerOrgAdminEmail,
`essentials-per-user-plan-organization-${randomString()}`,
"ESSENTIALS"
);
essentialsPlanManagerOrgAdmin = essentialsSetup.admin;
essentialsPlanManagerOrg = essentialsSetup.org;
essentialsPlanManagerOrgAdminApiKey = essentialsSetup.apiKey;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
@@ -136,6 +145,57 @@ describe("Organizations Organizations Endpoints", () => {
await app.init();
});
async function setupTestOrganization(
adminEmail: string,
orgName: string,
plan: "SCALE" | "PER_ACTIVE_USER" | "ESSENTIALS"
) {
const admin = await userRepositoryFixture.create({
email: adminEmail,
username: adminEmail,
});
const org = await organizationsRepositoryFixture.create({
name: orgName,
isOrganization: true,
isPlatform: true,
});
await profilesRepositoryFixture.create({
uid: `${randomString()}-uid`,
username: adminEmail,
user: { connect: { id: admin.id } },
organization: { connect: { id: org.id } },
movedFromUser: { connect: { id: admin.id } },
});
const billing = await platformBillingRepositoryFixture.create(org.id, plan);
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: admin.id } },
team: { connect: { id: org.id } },
accepted: true,
});
const { keyString } = await apiKeysRepositoryFixture.createApiKey(admin.id, null, org.id);
const apiKey = `cal_test_${keyString}`;
return { admin, org, billing, apiKey };
}
function createManagedOrgInput(
namePrefix: string,
metadata?: Record<string, string | number | boolean>
): CreateOrganizationInput {
const suffix = randomString(5);
return {
name: `${namePrefix} ${suffix}`,
slug: `${namePrefix.toLowerCase().replace(/\s+/g, "-")}-${suffix}`,
metadata: metadata || { key: "value" },
};
}
afterAll(() => {
clear();
});
@@ -155,21 +215,44 @@ describe("Organizations Organizations Endpoints", () => {
.expect(400);
});
const suffix = randomString(5);
const metadataKey = "first-org-metadata-key";
const metadataValue = "first-org-metadata-value";
const createManagedOrganizationBody: CreateOrganizationInput = {
name: `org ${suffix}`,
slug: `org-${suffix}`,
metadata: { [metadataKey]: metadataValue },
};
const createManagedOrganizationBody: CreateOrganizationInput = createManagedOrgInput("org", {
[metadataKey]: metadataValue,
});
const suffix2 = randomString(5);
const createManagedOrganizationBodySecond: CreateOrganizationInput = {
name: `org2 ${suffix2}`,
slug: `org2-${suffix2}`,
metadata: { key: "value" },
};
const createManagedOrganizationBodySecond: CreateOrganizationInput = createManagedOrgInput("org2");
const createManagedOrganizationBodyThird: CreateOrganizationInput = createManagedOrgInput("org3");
it("should not allow to create managed organization if plan is below SCALE", async () => {
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${essentialsPlanManagerOrg.id}/organizations`)
.set("Authorization", `Bearer ${essentialsPlanManagerOrgAdminApiKey}`)
.send(createManagedOrganizationBody)
.expect(403);
expect(response.body.error.message).toBe(
`PlatformPlanGuard - organization with id=${essentialsPlanManagerOrg.id} does not have required plan for this operation. Minimum plan is SCALE while the organization has ESSENTIALS.`
);
});
it("should allow to create managed organization if plan is SCALE or above", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${payPerUserPlanManagerOrg.id}/organizations`)
.set("Authorization", `Bearer ${payPerUserPlanManagerOrgAdminApiKey}`)
.send(createManagedOrganizationBodyThird)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<ManagedOrganizationWithApiKeyOutput> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const managedOrg3 = responseBody.data;
expect(managedOrg3?.id).toBeDefined();
expect(managedOrg3?.name).toEqual(createManagedOrganizationBodyThird.name);
expect(managedOrg3?.slug).toEqual(createManagedOrganizationBodyThird.slug);
expect(managedOrg3?.metadata).toEqual(createManagedOrganizationBodyThird.metadata);
expect(managedOrg3?.apiKey).toBeDefined();
});
});
it("should create managed organization", async () => {
return request(app.getHttpServer())
@@ -625,7 +708,11 @@ describe("Organizations Organizations Endpoints", () => {
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(managerOrgAdmin.email);
await userRepositoryFixture.deleteByEmail(payPerUserPlanManagerOrgAdmin.email);
await userRepositoryFixture.deleteByEmail(essentialsPlanManagerOrgAdmin.email);
await organizationsRepositoryFixture.delete(managerOrg.id);
await organizationsRepositoryFixture.delete(payPerUserPlanManagerOrg.id);
await organizationsRepositoryFixture.delete(essentialsPlanManagerOrg.id);
await app.close();
});
});
@@ -351,6 +351,7 @@ describe("Organizations Users Endpoints", () => {
email: `organizations-users-new-member-${randomString()}@api.com`,
bio,
metadata,
timeZone: "Europe/Rome",
};
const emailSpy = jest
@@ -367,6 +368,7 @@ describe("Organizations Users Endpoints", () => {
expect(userData.email).toBe(newOrgUser.email);
expect(userData.bio).toBe(newOrgUser.bio);
expect(userData.metadata).toEqual(newOrgUser.metadata);
expect(userData.timeZone).toBe(newOrgUser.timeZone);
expect(emailSpy).toHaveBeenCalledWith({
usernameOrEmail: newOrgUser.email,
orgName: org.name,
@@ -25,7 +25,12 @@ import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_09_04 } from "@calcom/platform-constants";
import {
CAL_API_VERSION_HEADER,
SUCCESS_STATUS,
VERSION_2024_09_04,
ERROR_STATUS,
} from "@calcom/platform-constants";
import type {
CreateScheduleInput_2024_06_11,
ReserveSlotOutput_2024_09_04 as ReserveSlotOutputData_2024_09_04,
@@ -48,6 +53,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
const teammateEmailOne = `slots-2024-09-04-user-1-team-slots-${randomString()}`;
let teammateApiKeyString: string;
const teammateEmailTwo = `slots-2024-09-04-user-2-team-slots-${randomString()}`;
let teammateTwoApiKeyString: string;
const outsiderEmail = `slots-2024-09-04-unrelated-team-slots-${randomString()}`;
let outsider: User;
@@ -61,6 +67,8 @@ describe("Slots 2024-09-04 Endpoints", () => {
let collectiveEventTypeSlug: string;
let collectiveEventTypeWithoutHostsId: number;
let roundRobinEventTypeId: number;
let roundRobinEventTypeWithoutFixedHostsId: number;
let roundRobinEventTypeWithFixedAndNonFixedHostsId: number;
let collectiveBookingId: number;
let roundRobinBookingId: number;
let fullyBookedRoundRobinBookingIdOne: number;
@@ -115,6 +123,12 @@ describe("Slots 2024-09-04 Endpoints", () => {
const { keyString } = await apiKeysRepositoryFixture.createApiKey(teammateOne.id, null);
teammateApiKeyString = keyString;
const { keyString: keyStringForTeammateTwo } = await apiKeysRepositoryFixture.createApiKey(
teammateTwo.id,
null
);
teammateTwoApiKeyString = keyStringForTeammateTwo;
const { keyString: unrelatedUserKeyString } = await apiKeysRepositoryFixture.createApiKey(
outsider.id,
null
@@ -217,6 +231,67 @@ describe("Slots 2024-09-04 Endpoints", () => {
});
roundRobinEventTypeId = roundRobinEventType.id;
const roundRobinEventTypeWithoutFixedHosts = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "ROUND_ROBIN",
team: {
connect: { id: team.id },
},
title: "RR Event Type Without Fixed Hosts",
slug: `slots-2024-09-04-round-robin-event-type-${randomString()}`,
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
users: {
connect: [{ id: teammateOne.id }, { id: teammateTwo.id }],
},
hosts: {
create: [
{
userId: teammateOne.id,
isFixed: false,
},
{
userId: teammateTwo.id,
isFixed: false,
},
],
},
});
roundRobinEventTypeWithoutFixedHostsId = roundRobinEventTypeWithoutFixedHosts.id;
const roundRobinEventTypeWithFixedAndNonFixedHosts =
await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "ROUND_ROBIN",
team: {
connect: { id: team.id },
},
title: "RR Event Type With Fixed and Non-Fixed Hosts",
slug: `slots-2024-09-04-round-robin-event-type-${randomString()}`,
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
users: {
connect: [{ id: teammateOne.id }, { id: teammateTwo.id }],
},
hosts: {
create: [
{
userId: teammateOne.id,
isFixed: true,
},
{
userId: teammateTwo.id,
isFixed: false,
},
],
},
});
roundRobinEventTypeWithFixedAndNonFixedHostsId = roundRobinEventTypeWithFixedAndNonFixedHosts.id;
const userSchedule: CreateScheduleInput_2024_06_11 = {
name: "working time",
timeZone: "Europe/Rome",
@@ -541,6 +616,135 @@ describe("Slots 2024-09-04 Endpoints", () => {
bookingsRepositoryFixture.deleteById(bookingTwo.id);
});
it("should reserve all available slots for round robin event type with non-fixed hosts", async () => {
const now = "2049-09-05T12:00:00.000Z";
const newDate = DateTime.fromISO(now, { zone: "UTC" }).toJSDate();
advanceTo(newDate);
const slotStartTime = "2050-09-05T10:00:00.000Z";
const reserveResponseOne = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.set({ Authorization: `Bearer cal_test_${teammateApiKeyString}` })
.send({
eventTypeId: roundRobinEventTypeWithoutFixedHostsId,
slotStart: slotStartTime,
reservationDuration: 10,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(201);
const reserveResponseOneBody: ReserveSlotOutputResponse_2024_09_04 = reserveResponseOne.body;
expect(reserveResponseOneBody.status).toEqual(SUCCESS_STATUS);
const responseReservedSlotOne: ReserveSlotOutputData_2024_09_04 = reserveResponseOneBody.data;
expect(responseReservedSlotOne.reservationUid).toBeDefined();
if (!responseReservedSlotOne.reservationUid) {
throw new Error("Reserved slot one uid is undefined");
}
const dbSlotOne = await selectedSlotRepositoryFixture.getByUid(responseReservedSlotOne.reservationUid);
expect(dbSlotOne).toBeDefined();
if (dbSlotOne) {
const dbReleaseAt = DateTime.fromJSDate(dbSlotOne.releaseAt, { zone: "UTC" }).toISO();
const expectedReleaseAt = DateTime.fromISO(now, { zone: "UTC" }).plus({ minutes: 10 }).toISO();
expect(dbReleaseAt).toEqual(expectedReleaseAt);
}
const reserveResponseTwo = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.set({ Authorization: `Bearer cal_test_${teammateTwoApiKeyString}` })
.send({
eventTypeId: roundRobinEventTypeWithoutFixedHostsId,
slotStart: slotStartTime,
reservationDuration: 10,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(201);
const reserveResponseTwoBody: ReserveSlotOutputResponse_2024_09_04 = reserveResponseTwo.body;
expect(reserveResponseTwoBody.status).toEqual(SUCCESS_STATUS);
const responseReservedSlotTwo: ReserveSlotOutputData_2024_09_04 = reserveResponseTwoBody.data;
expect(responseReservedSlotTwo.reservationUid).toBeDefined();
if (!responseReservedSlotTwo.reservationUid) {
throw new Error("Reserved slot two uid is undefined");
}
const dbSlotTwo = await selectedSlotRepositoryFixture.getByUid(responseReservedSlotTwo.reservationUid);
expect(dbSlotTwo).toBeDefined();
if (dbSlotTwo) {
const dbReleaseAt = DateTime.fromJSDate(dbSlotTwo.releaseAt, { zone: "UTC" }).toISO();
const expectedReleaseAt = DateTime.fromISO(now, { zone: "UTC" }).plus({ minutes: 10 }).toISO();
expect(dbReleaseAt).toEqual(expectedReleaseAt);
}
const reserveResponseThree = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.set({ Authorization: `Bearer cal_test_${outsiderApiKeyString}` })
.send({
eventTypeId: roundRobinEventTypeWithoutFixedHostsId,
slotStart: slotStartTime,
reservationDuration: 10,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04);
expect(reserveResponseThree.status).toEqual(403);
expect(reserveResponseThree.body.status).toEqual(ERROR_STATUS);
await selectedSlotRepositoryFixture.deleteByUId(responseReservedSlotOne.reservationUid);
await selectedSlotRepositoryFixture.deleteByUId(responseReservedSlotTwo.reservationUid);
clear();
});
it("should reserve available slot for round robin event type with fixed and non-fixed hosts and should not be able to reserve another slot", async () => {
const now = "2049-09-05T12:00:00.000Z";
const newDate = DateTime.fromISO(now, { zone: "UTC" }).toJSDate();
advanceTo(newDate);
const slotStartTime = "2050-09-05T10:00:00.000Z";
const reserveResponseOne = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.set({ Authorization: `Bearer cal_test_${teammateApiKeyString}` })
.send({
eventTypeId: roundRobinEventTypeWithFixedAndNonFixedHostsId,
slotStart: slotStartTime,
reservationDuration: 10,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(201);
const reserveResponseBodyOne: ReserveSlotOutputResponse_2024_09_04 = reserveResponseOne.body;
expect(reserveResponseBodyOne.status).toEqual(SUCCESS_STATUS);
const responseReservedSlotOne: ReserveSlotOutputData_2024_09_04 = reserveResponseBodyOne.data;
expect(responseReservedSlotOne.reservationUid).toBeDefined();
if (!responseReservedSlotOne.reservationUid) {
throw new Error("Reserved slot uid is undefined");
}
const dbSlotOne = await selectedSlotRepositoryFixture.getByUid(responseReservedSlotOne.reservationUid);
expect(dbSlotOne).toBeDefined();
if (dbSlotOne) {
const dbReleaseAt = DateTime.fromJSDate(dbSlotOne.releaseAt, { zone: "UTC" }).toISO();
const expectedReleaseAt = DateTime.fromISO(now, { zone: "UTC" }).plus({ minutes: 10 }).toISO();
expect(dbReleaseAt).toEqual(expectedReleaseAt);
}
const reserveResponseTwo = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({
eventTypeId: roundRobinEventTypeWithFixedAndNonFixedHostsId,
slotStart: slotStartTime,
})
.set({ Authorization: `Bearer cal_test_${teammateTwoApiKeyString}` })
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04);
expect(reserveResponseTwo.status).toEqual(422);
expect(reserveResponseTwo.body.status).toEqual(ERROR_STATUS);
await selectedSlotRepositoryFixture.deleteByUId(responseReservedSlotOne.reservationUid);
clear();
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(teammateOne.email);
await userRepositoryFixture.deleteByEmail(teammateTwo.email);
@@ -48,7 +48,7 @@ import { ApiResponse } from "@calcom/platform-types";
@DocsTags("Slots")
@ApiHeader({
name: "cal-api-version",
description: `Must be set to ${VERSION_2024_09_04}`,
description: `Must be set to ${VERSION_2024_09_04}. If not set to this value, the endpoint will default to an older version.`,
example: VERSION_2024_09_04,
required: true,
schema: {
@@ -86,6 +86,8 @@ export class SlotsController_2024_09_04 {
- duration: Only use for event types that allow multiple durations or for dynamic event types. If not passed for multiple duration event types defaults to default duration. For dynamic event types defaults to 30 aka each returned slot is 30 minutes long. So duration=60 means that returned slots will be each 60 minutes long.
- format: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use "range" as value.
- bookingUidToReschedule: When rescheduling an existing booking, provide the booking's unique identifier to exclude its time slot from busy time calculations. This ensures the original booking time appears as available for rescheduling.
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
@ApiQuery({
@@ -256,7 +258,10 @@ export class SlotsController_2024_09_04 {
@ApiOperation({
summary: "Reserve a slot",
description: `Make a slot not available for others to book for a certain period of time. If you authenticate using oAuth credentials, api key or access token
then you can also specify custom duration for how long the slot should be reserved for (defaults to 5 minutes).`,
then you can also specify custom duration for how long the slot should be reserved for (defaults to 5 minutes).
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
@@ -278,6 +283,7 @@ export class SlotsController_2024_09_04 {
@Get("/reservations/:uid")
@ApiOperation({
summary: "Get reserved slot",
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
})
async getReservedSlot(@Param("uid") uid: string): Promise<GetReservedSlotOutput_2024_09_04> {
const reservedSlot = await this.slotsService.getReservedSlot(uid);
@@ -293,6 +299,7 @@ export class SlotsController_2024_09_04 {
@Patch("/reservations/:uid")
@ApiOperation({
summary: "Update a reserved slot",
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
})
@HttpCode(HttpStatus.OK)
async updateReservedSlot(
@@ -312,6 +319,7 @@ export class SlotsController_2024_09_04 {
@Delete("/reservations/:uid")
@ApiOperation({
summary: "Delete a reserved slot",
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
})
@HttpCode(HttpStatus.OK)
@DocsResponse({
@@ -23,6 +23,8 @@ import { DateTime } from "luxon";
import { z } from "zod";
import { SlotFormat } from "@calcom/platform-enums";
import { SchedulingType } from "@calcom/platform-libraries";
import { validateRoundRobinSlotAvailability } from "@calcom/platform-libraries/slots";
import type {
GetSlotsInput_2024_09_04,
GetSlotsInputWithRouting_2024_09_04,
@@ -147,13 +149,26 @@ export class SlotsService_2024_09_04 {
}
const nonSeatedEventAlreadyBooked = !eventType.seatsPerTimeSlot && booking;
if (nonSeatedEventAlreadyBooked) {
const isRoundRobinEvent = eventType.schedulingType === SchedulingType.ROUND_ROBIN;
if (nonSeatedEventAlreadyBooked && !isRoundRobinEvent) {
throw new UnprocessableEntityException(`Can't reserve a slot if the event is already booked.`);
}
const reservationDuration = input.reservationDuration ?? DEFAULT_RESERVATION_DURATION;
if (isRoundRobinEvent) {
try {
await validateRoundRobinSlotAvailability(input.eventTypeId, startDate, endDate, eventType.hosts);
} catch (error) {
if (error instanceof Error) {
throw new UnprocessableEntityException(error?.message);
}
throw error;
}
} else {
await this.checkSlotOverlap(input.eventTypeId, startDate.toISO(), endDate.toISO());
}
await this.checkSlotOverlap(input.eventTypeId, startDate.toISO(), endDate.toISO());
const reservationDuration = input.reservationDuration ?? DEFAULT_RESERVATION_DURATION;
if (eventType.userId) {
const slot = await this.slotsRepository.createSlot(
@@ -328,6 +328,131 @@ describe("Teams Memberships Endpoints", () => {
return request(app.getHttpServer()).get(`/v2/teams/${team.id}/memberships/123132145`).expect(404);
});
it("should filter memberships by single email", async () => {
return request(app.getHttpServer())
.get(`/v2/teams/${team.id}/memberships?emails=${teamAdminEmail}`)
.expect(200)
.then((response) => {
const responseBody: GetTeamMembershipsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.length).toEqual(1);
expect(responseBody.data[0].user.email).toEqual(teamAdminEmail);
expect(responseBody.data[0].userId).toEqual(teamAdmin.id);
expect(responseBody.data[0].role).toEqual("ADMIN");
});
});
it("should filter memberships by multiple emails", async () => {
return request(app.getHttpServer())
.get(`/v2/teams/${team.id}/memberships?emails=${teamAdminEmail},${teamMemberEmail}`)
.expect(200)
.then((response) => {
const responseBody: GetTeamMembershipsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.length).toEqual(2);
const emails = responseBody.data.map((membership) => membership.user.email);
expect(emails).toContain(teamAdminEmail);
expect(emails).toContain(teamMemberEmail);
const adminMembership = responseBody.data.find((m) => m.user.email === teamAdminEmail);
const memberMembership = responseBody.data.find((m) => m.user.email === teamMemberEmail);
expect(adminMembership).toBeDefined();
expect(memberMembership).toBeDefined();
expect(adminMembership?.role).toEqual("ADMIN");
expect(memberMembership?.role).toEqual("MEMBER");
});
});
it("should return empty array when filtering by non-existent email", async () => {
const nonExistentEmail = `nonexistent-${randomString()}@test.com`;
return request(app.getHttpServer())
.get(`/v2/teams/${team.id}/memberships?emails=${nonExistentEmail}`)
.expect(200)
.then((response) => {
const responseBody: GetTeamMembershipsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.length).toEqual(0);
});
});
it("should return partial results when filtering by mix of existing and non-existent emails", async () => {
const nonExistentEmail = `nonexistent-${randomString()}@test.com`;
return request(app.getHttpServer())
.get(`/v2/teams/${team.id}/memberships?emails=${teamAdminEmail},${nonExistentEmail}`)
.expect(200)
.then((response) => {
const responseBody: GetTeamMembershipsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.length).toEqual(1);
expect(responseBody.data[0].user.email).toEqual(teamAdminEmail);
});
});
it("should work with pagination and email filtering combined", async () => {
return request(app.getHttpServer())
.get(`/v2/teams/${team.id}/memberships?emails=${teamAdminEmail},${teamMemberEmail}&skip=1&take=1`)
.expect(200)
.then((response) => {
const responseBody: GetTeamMembershipsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.length).toEqual(1);
const returnedEmail = responseBody.data[0].user.email;
expect([teamAdminEmail, teamMemberEmail]).toContain(returnedEmail);
});
});
it("should handle empty emails array gracefully", async () => {
return request(app.getHttpServer())
.get(`/v2/teams/${team.id}/memberships?emails=`)
.expect(200)
.then((response) => {
const responseBody: GetTeamMembershipsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.length).toEqual(2);
});
});
it("should handle URL encoded email addresses in filter", async () => {
const encodedEmail = encodeURIComponent(teamAdminEmail);
return request(app.getHttpServer())
.get(`/v2/teams/${team.id}/memberships?emails=${encodedEmail}`)
.expect(200)
.then((response) => {
const responseBody: GetTeamMembershipsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.length).toEqual(1);
expect(responseBody.data[0].user.email).toEqual(teamAdminEmail);
});
});
it("should filter by email and maintain all user properties", async () => {
return request(app.getHttpServer())
.get(`/v2/teams/${team.id}/memberships?emails=${teamMemberEmail}`)
.expect(200)
.then((response) => {
const responseBody: GetTeamMembershipsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.length).toEqual(1);
const membership = responseBody.data[0];
expect(membership.user.email).toEqual(teamMemberEmail);
expect(membership.user.bio).toEqual(teamMember.bio);
expect(membership.user.metadata).toEqual(teamMember.metadata);
expect(membership.user.username).toEqual(teamMember.username);
expect(membership.teamId).toEqual(team.id);
expect(membership.userId).toEqual(teamMember.id);
expect(membership.role).toEqual("MEMBER");
});
});
it("should validate email array size limits", async () => {
const tooManyEmails = Array.from({ length: 21 }, (_, i) => `test${i}@example.com`).join(",");
return request(app.getHttpServer())
.get(`/v2/teams/${team.id}/memberships?emails=${tooManyEmails}`)
.expect(400);
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(teamAdmin.email);
await userRepositoryFixture.deleteByEmail(teammateInvitedViaApi.email);
@@ -4,6 +4,7 @@ import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/create-team-membership.input";
import { GetTeamMembershipsInput } from "@/modules/teams/memberships/inputs/get-team-memberships.input";
import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input";
import { CreateTeamMembershipOutput } from "@/modules/teams/memberships/outputs/create-team-membership.output";
import { DeleteTeamMembershipOutput } from "@/modules/teams/memberships/outputs/delete-team-membership.output";
@@ -32,7 +33,6 @@ import { plainToClass } from "class-transformer";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { updateNewTeamMemberEventTypes } from "@calcom/platform-libraries/event-types";
import { SkipTakePagination } from "@calcom/platform-types";
@Controller({
path: "/v2/teams/:teamId/memberships",
@@ -85,16 +85,20 @@ export class TeamsMembershipsController {
}
@Get("/")
@ApiOperation({ summary: "Get all memberships" })
@ApiOperation({
summary: "Get all memberships",
description: "Retrieve team memberships with optional filtering by email addresses. Supports pagination.",
})
@Roles("TEAM_ADMIN")
@HttpCode(HttpStatus.OK)
async getTeamMemberships(
@Param("teamId", ParseIntPipe) teamId: number,
@Query() queryParams: SkipTakePagination
@Query() queryParams: GetTeamMembershipsInput
): Promise<GetTeamMembershipsOutput> {
const { skip, take } = queryParams;
const { skip, take, emails } = queryParams;
const orgTeamMemberships = await this.teamsMembershipsService.getPaginatedTeamMemberships(
teamId,
emails,
skip ?? 0,
take ?? 250
);
@@ -0,0 +1,32 @@
import { GetUsersInput } from "@/modules/users/inputs/get-users.input";
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { ArrayMaxSize, ArrayNotEmpty, IsEmail, IsOptional } from "class-validator";
export class GetTeamMembershipsInput extends GetUsersInput {
@IsOptional()
@Transform(({ value }) => {
if (value == null) return undefined;
const rawValues = (Array.isArray(value) ? value : [value]).flatMap((entry) =>
typeof entry === "string" ? entry.split(",") : []
);
const normalized = rawValues
.map((email) => email.trim())
.filter((email) => email.length > 0)
.map((email) => email.toLowerCase());
const deduplicated = [...new Set(normalized)];
return deduplicated.length > 0 ? deduplicated : undefined;
})
@ArrayNotEmpty({ message: "emails cannot be empty." })
@ArrayMaxSize(20, {
message: "emails array cannot contain more than 20 email addresses for team membership filtering",
})
@IsEmail({}, { each: true, message: "Each email must be a valid email address" })
@ApiPropertyOptional({
type: [String],
description:
"Filter team memberships by email addresses. If you want to filter by multiple emails, separate them with a comma (max 20 emails for performance).",
example: "?emails=user1@example.com,user2@example.com",
})
emails?: string[];
}
@@ -23,13 +23,15 @@ export class TeamsMembershipsService {
return teamMembership;
}
async getPaginatedTeamMemberships(teamId: number, skip = 0, take = 250) {
const teamMemberships = await this.teamsMembershipsRepository.findTeamMembershipsPaginated(
async getPaginatedTeamMemberships(teamId: number, emails?: string[], skip = 0, take = 250) {
const emailArray = !emails ? [] : emails;
return await this.teamsMembershipsRepository.findTeamMembershipsPaginatedWithFilters(
teamId,
{ emails: emailArray },
skip,
take
);
return teamMemberships;
}
async getTeamMembership(teamId: number, membershipId: number) {
@@ -6,6 +6,10 @@ import { Injectable } from "@nestjs/common";
import type { Prisma } from "@calcom/prisma/client";
export interface TeamMembershipFilters {
emails?: string[];
}
export const MembershipUserSelect: Prisma.UserSelect = {
username: true,
email: true,
@@ -41,6 +45,30 @@ export class TeamsMembershipsRepository {
});
}
async findTeamMembershipsPaginatedWithFilters(
teamId: number,
filters: TeamMembershipFilters,
skip: number,
take: number
) {
const whereClause: Prisma.MembershipWhereInput = {
teamId: teamId,
};
if (filters.emails && filters.emails.length > 0) {
whereClause.user = {
email: { in: filters.emails },
};
}
return await this.dbRead.prisma.membership.findMany({
where: whereClause,
include: { user: { select: MembershipUserSelect } },
skip,
take,
});
}
async findTeamMembership(teamId: number, membershipId: number) {
return this.dbRead.prisma.membership.findUnique({
where: {
@@ -120,30 +120,4 @@ export class TeamsRepository {
},
});
}
async getTeamMemberEmails(teamId: number) {
return this.dbRead.prisma.user.findMany({
where: {
teams: {
some: {
teamId,
},
},
},
select: {
id: true,
email: true,
secondaryEmails: {
where: {
emailVerified: {
not: null,
},
},
select: {
email: true,
},
},
},
});
}
}
@@ -1,6 +1,7 @@
import { Locales } from "@/lib/enums/locales";
import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsOptional, IsTimeZone, IsString, IsEnum, IsIn, IsUrl, IsObject } from "class-validator";
import { ValidateMetadata } from "@calcom/platform-types";
@@ -29,6 +30,7 @@ export class CreateManagedUserInput {
})
weekStart?: WeekDay;
@Transform(({ value }) => (value === null ? undefined : value))
@IsTimeZone()
@IsOptional()
@CapitalizeTimeZone()
@@ -1,3 +1,4 @@
import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Expose, Transform } from "class-transformer";
import {
@@ -112,6 +113,7 @@ export class CreateUserInput {
@ApiProperty({ type: String, required: false, description: "Time zone", example: "America/New_York" })
@IsOptional()
@IsString()
@CapitalizeTimeZone()
@Validate(TimeZoneValidator)
@Expose()
timeZone?: string;
@@ -28,6 +28,7 @@ export class GetUsersInput {
@ApiPropertyOptional({
type: [String],
description: "The email address or an array of email addresses to filter by",
example: ["user1@example.com", "user2@example.com"],
})
emails?: string[];
}
@@ -408,4 +408,30 @@ export class UsersRepository {
},
});
}
async getUserEmailsVerifiedForTeam(teamId: number) {
return this.dbRead.prisma.user.findMany({
where: {
teams: {
some: {
teamId,
},
},
},
select: {
id: true,
email: true,
secondaryEmails: {
where: {
emailVerified: {
not: null,
},
},
select: {
email: true,
},
},
},
});
}
}
@@ -1,6 +1,6 @@
import type { ValidatorConstraintInterface } from "class-validator";
import { ValidatorConstraint } from "class-validator";
import tzdata from "tzdata";
import * as tzdata from "tzdata";
@ValidatorConstraint({ name: "timezoneValidator", async: false })
export class TimeZoneValidator implements ValidatorConstraintInterface {
-8
View File
@@ -636,10 +636,6 @@ select:focus {
@apply !border-r-default left-0.5 !border-y-0 !border-l-0;
}
.intercom-lightweight-app {
@apply z-40 !important;
}
@import "../../web/styles/tokens.css";
@layer base {
@@ -1027,10 +1023,6 @@ select:focus {
@apply !border-r-default left-0.5 !border-y-0 !border-l-0;
}
.intercom-lightweight-app {
@apply z-40 !important;
}
.prose :where(a:not([data-card])):not(:where([class~="not-prose"] *)) {
text-decoration: none;
}
@@ -45,7 +45,7 @@ export function ShellMainAppDir(props: LayoutProps) {
className={classNames(
props.backPath
? "relative"
: "pwa:bottom-[max(7rem,_calc(5rem_+_env(safe-area-inset-bottom)))] fixed bottom-20 z-40 ltr:right-4 rtl:left-4 md:z-auto md:ltr:right-0 md:rtl:left-0",
: "pwa:bottom-[max(7rem,_calc(5rem_+_env(safe-area-inset-bottom)))] fixed bottom-32 z-40 ltr:right-4 rtl:left-4 md:z-auto md:ltr:right-0 md:rtl:left-0",
"flex-shrink-0 [-webkit-app-region:no-drag] md:relative md:bottom-auto md:right-auto"
)}>
{props.CTA}
@@ -5,10 +5,11 @@ import { unstable_cache } from "next/cache";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
import { MembershipRole } from "@calcom/prisma/enums";
import { availabilityRouter } from "@calcom/trpc/server/routers/viewer/availability/_router";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -72,8 +73,14 @@ const Page = async ({ searchParams: _searchParams }: PageProps) => {
orgId: organizationId,
})
: false;
const isOrgAdminOrOwner = checkAdminOrOwner(session?.user?.org?.role);
const canViewTeamAvailability = isOrgAdminOrOwner || !isOrgPrivate;
const permissionService = new PermissionCheckService();
const teamIdsWithPermission = await permissionService.getTeamIdsWithPermission({
userId: session.user.id,
permission: "availability.read",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
const canViewTeamAvailability = teamIdsWithPermission.length > 0 || !isOrgPrivate;
return (
<ShellMainAppDir
@@ -23,7 +23,8 @@ const ServerPage = async ({ searchParams: _searchParams }: ServerPageProps) => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const searchParams = await _searchParams;
const token = Array.isArray(searchParams?.token) ? searchParams.token[0] : searchParams?.token;
const callbackUrl = token ? `/teams?token=${encodeURIComponent(token)}` : null;
const autoAccept = Array.isArray(searchParams?.autoAccept) ? searchParams.autoAccept[0] : searchParams?.autoAccept;
const callbackUrl = token ? `/teams?token=${encodeURIComponent(token)}${autoAccept ? `&autoAccept=${encodeURIComponent(autoAccept)}` : ''}` : null;
if (!session) {
redirect(callbackUrl ? `/auth/login?callbackUrl=${callbackUrl}` : "/auth/login");
@@ -2,11 +2,12 @@ import type { SearchParams } from "app/_types";
import type { Session } from "next-auth";
import { unstable_cache } from "next/cache";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import { TeamsListing } from "@calcom/features/ee/teams/components/TeamsListing";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { TeamService } from "@calcom/lib/server/service/teamService";
import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
@@ -32,14 +33,21 @@ export const ServerTeamsListing = async ({
session: Session;
}) => {
const token = Array.isArray(searchParams?.token) ? searchParams.token[0] : searchParams?.token;
const autoAccept = Array.isArray(searchParams?.autoAccept) ? searchParams.autoAccept[0] : searchParams?.autoAccept;
const userId = session.user.id;
let invitationAccepted = false;
let teamNameFromInvite,
errorMsgFromInvite = null;
if (token) {
try {
teamNameFromInvite = await TeamService.inviteMemberByToken(token, userId);
if (autoAccept === "true") {
await TeamService.acceptInvitationByToken(token, userId);
invitationAccepted = true;
} else {
teamNameFromInvite = await TeamService.inviteMemberByToken(token, userId);
}
} catch (e) {
errorMsgFromInvite = "Error while fetching teams";
if (e instanceof TRPCError) errorMsgFromInvite = e.message;
@@ -49,21 +57,30 @@ export const ServerTeamsListing = async ({
const teams = await getCachedTeams(userId);
const userProfile = session?.user?.profile;
const orgId = userProfile?.organizationId ?? session?.user.org?.id;
const orgRole =
session?.user?.org?.role ??
userProfile?.organization?.members.find((m: { userId: number }) => m.userId === userId)?.role;
const isOrgAdminOrOwner = checkAdminOrOwner(orgRole);
const permissionCheckService = new PermissionCheckService();
const canCreateTeam = orgId
? await permissionCheckService.checkPermission({
userId: session.user.id,
teamId: orgId,
permission: "team.create",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
})
: false;
return {
Main: (
<TeamsListing
invitationAccepted={invitationAccepted}
teams={teams}
orgId={orgId ?? null}
isOrgAdmin={isOrgAdminOrOwner}
permissions={{
canCreateTeam: canCreateTeam,
}}
teamNameFromInvite={teamNameFromInvite ?? null}
errorMsgFromInvite={errorMsgFromInvite}
/>
),
CTA: !orgId || isOrgAdminOrOwner ? <TeamsCTA /> : null,
CTA: !orgId || canCreateTeam ? <TeamsCTA /> : null,
};
};
@@ -10,6 +10,7 @@ import type { UseFormReturn } from "react-hook-form";
import { Toaster } from "sonner";
import type { z } from "zod";
import { buildEmptyQueryValue, raqbQueryValueUtils } from "@calcom/app-store/_utils/raqb/raqbUtils";
import { routingFormAppComponents } from "@calcom/app-store/routing-forms/appComponents";
import DynamicAppComponent from "@calcom/app-store/routing-forms/components/DynamicAppComponent";
import { EmptyState } from "@calcom/app-store/routing-forms/components/_components/EmptyState";
@@ -44,7 +45,6 @@ import { useOrgBranding } from "@calcom/features/ee/organizations/context/provid
import type { EventTypesByViewer } from "@calcom/features/eventtypes/lib/getEventTypesByViewer";
import { areTheySiblingEntities } from "@calcom/lib/entityPermissionUtils.shared";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { buildEmptyQueryValue, raqbQueryValueUtils } from "@calcom/lib/raqb/raqbUtils";
import type { Prisma } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
@@ -1190,7 +1190,7 @@ const Routes = ({
}) || [];
// const isConnectedForm = (id: string) => form.connectedForms.map((f) => f.id).includes(id);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// const routers: any[] = [];
/* Disable this feature for new forms till we get it fully working with Routing Form with Attributes. This isn't much used feature */
// const routers = availableRouters.map((r) => {
@@ -1235,7 +1235,7 @@ const Routes = ({
});
setRoutes((routes) => {
// Even though it's obvious that fallbackRoute is defined here but TypeScript just can't figure it out.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return [...routes, fallbackRoute!];
});
return null;
@@ -1243,7 +1243,7 @@ const Routes = ({
// Ensure fallback is last
setRoutes((routes) => {
// Even though it's obvious that fallbackRoute is defined here but TypeScript just can't figure it out.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return [...routes.filter((route) => route.id !== fallbackRoute!.id), fallbackRoute!];
});
}
@@ -18,6 +18,7 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
import { useHasPaidPlan } from "@calcom/lib/hooks/useHasPaidPlan";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import { ArrowButton } from "@calcom/ui/components/arrow-button";
import { Badge } from "@calcom/ui/components/badge";
@@ -46,6 +47,10 @@ function NewFormButton({ setNewFormDialogState }: { setNewFormDialogState: SetNe
createFunction={(teamId) => {
setNewFormDialogState({ action: "new", target: teamId ? String(teamId) : "" });
}}
withPermission={{
permission: "routingForm.create",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
}}
/>
);
}
@@ -8,10 +8,7 @@ import { z } from "zod";
import { EventTypeWebWrapper } from "@calcom/atoms/event-types/wrappers/EventTypeWebWrapper";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { getEventTypePermissions } from "@calcom/features/pbac/lib/event-type-permissions";
import { eventTypesRouter } from "@calcom/trpc/server/routers/viewer/eventTypes/_router";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -44,95 +41,6 @@ const getCachedEventType = unstable_cache(
{ revalidate: 3600 } // Cache for 1 hour
);
const getEventPermissions = async (userId: number, teamId: number | null) => {
// Personal event - has all perms
if (!teamId)
return {
eventTypes: {
canRead: true,
canCreate: true,
canUpdate: true,
canDelete: true,
},
workflows: {
canRead: true,
canCreate: true,
canUpdate: true,
canDelete: true,
},
};
const membership = await prisma.membership.findFirst({
where: {
userId,
teamId,
},
select: {
role: true,
},
});
if (!membership) throw new Error("Membership not found");
const [eventTypePermissions, workflowPermissions] = await Promise.all([
getResourcePermissions({
userId,
teamId,
resource: Resource.EventType,
userRole: membership.role,
fallbackRoles: {
read: {
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
},
update: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
delete: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
create: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
}),
getResourcePermissions({
userId,
teamId,
resource: Resource.Workflow,
userRole: membership.role,
fallbackRoles: {
read: {
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
},
update: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
delete: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
create: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
}),
]);
return {
eventTypes: {
canRead: eventTypePermissions.canRead,
canCreate: eventTypePermissions.canCreate,
canUpdate: eventTypePermissions.canEdit,
canDelete: eventTypePermissions.canDelete,
},
workflows: {
canRead: workflowPermissions.canRead,
canCreate: workflowPermissions.canCreate,
canUpdate: workflowPermissions.canEdit,
canDelete: workflowPermissions.canDelete,
},
};
};
const ServerPage = async ({ params }: PageProps) => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
@@ -153,7 +61,7 @@ const ServerPage = async ({ params }: PageProps) => {
}
// Fetch permissions for the event type's team
const permissions = await getEventPermissions(session.user.id, data.eventType.teamId);
const permissions = await getEventTypePermissions(session.user.id, data.eventType.teamId);
return <EventTypeWebWrapper data={data} id={eventTypeId} permissions={permissions} />;
};
@@ -175,6 +175,7 @@ const organizationAdminKeys = ["privacy", "OAuth Clients", "SSO", "directory_syn
export interface SettingsPermissions {
canViewRoles?: boolean;
canViewOrganizationBilling?: boolean;
canUpdateOrganization?: boolean;
}
const useTabs = ({
@@ -190,7 +191,6 @@ const useTabs = ({
const { data: user } = trpc.viewer.me.get.useQuery({ includePasswordAdded: true });
const orgBranding = useOrgBranding();
const isAdmin = session.data?.user.role === UserPermissionRole.ADMIN;
const isOrgAdminOrOwner = checkAdminOrOwner(orgBranding?.role);
const processTabsMemod = useMemo(() => {
const processedTabs = getTabs(orgBranding).map((tab) => {
@@ -203,10 +203,10 @@ const useTabs = ({
};
} else if (tab.href === "/settings/organizations") {
const newArray = (tab?.children ?? []).filter(
(child) => isOrgAdminOrOwner || !organizationAdminKeys.includes(child.name)
(child) => permissions?.canUpdateOrganization || !organizationAdminKeys.includes(child.name)
);
if (isOrgAdminOrOwner) {
if (permissions?.canUpdateOrganization) {
newArray.splice(4, 0, {
name: "attributes",
href: "/settings/organizations/attributes",
@@ -238,7 +238,7 @@ const useTabs = ({
});
}
} else {
if (isOrgAdminOrOwner) {
if (permissions?.canUpdateOrganization) {
newArray.push({
name: "billing",
href: "/settings/organizations/billing",
@@ -264,7 +264,7 @@ const useTabs = ({
return { ...tab, children: filtered };
} else if (tab.href === "/settings/developer") {
const filtered = tab?.children?.filter(
(childTab) => isOrgAdminOrOwner || childTab.name !== "admin_api"
(childTab) => permissions?.canUpdateOrganization || childTab.name !== "admin_api"
);
return { ...tab, children: filtered };
}
@@ -274,12 +274,12 @@ const useTabs = ({
// check if name is in adminRequiredKeys
return processedTabs.filter((tab) => {
if (organizationRequiredKeys.includes(tab.name)) return !!orgBranding;
if (tab.name === "other_teams" && !isOrgAdminOrOwner) return false;
if (tab.name === "other_teams" && !permissions?.canUpdateOrganization) return false;
if (isAdmin) return true;
return !adminRequiredKeys.includes(tab.name);
});
}, [isAdmin, orgBranding, isOrgAdminOrOwner, user, isDelegationCredentialEnabled, isPbacEnabled, permissions]);
}, [isAdmin, orgBranding, user, isDelegationCredentialEnabled, isPbacEnabled, permissions]);
return processTabsMemod;
};
@@ -560,8 +560,6 @@ const SettingsSidebarContainer = ({
}
}, [searchParams?.get("id"), otherTeams]);
const isOrgAdminOrOwner = checkAdminOrOwner(orgBranding?.role);
return (
<nav
style={{ maxHeight: `calc(100vh - ${bannersHeight}px)`, top: `${bannersHeight}px` }}
@@ -638,12 +636,12 @@ const SettingsSidebarContainer = ({
as="p"
className="text-subtle truncate text-sm font-medium leading-5"
loadingClassName="ms-3">
{t(isOrgAdminOrOwner ? "my_teams" : tab.name)}
{t("my_teams")}
</Skeleton>
</div>
</Link>
<TeamListCollapsible teamFeatures={teamFeatures} />
{(!orgBranding?.id || isOrgAdminOrOwner) && (
{(!orgBranding?.id || permissions?.canUpdateOrganization) && (
<VerticalTabItem
name={t("add_a_team")}
href={`${WEBAPP_URL}/settings/teams/new`}
@@ -47,6 +47,7 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
let teamFeatures: Record<number, TeamFeatures> | null = null;
let canViewRoles = false;
let canViewOrganizationBilling = false;
let canUpdateOrganization = false;
const orgId = session?.user?.profile?.organizationId ?? session?.user.org?.id;
// For now we only grab organization features but it would be nice to fetch these on the server side for specific team feature flags
@@ -68,6 +69,11 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
canViewRoles = roleActions[CrudAction.Read] ?? false;
const orgActions = PermissionMapper.toActionMap(organizationPermissions, Resource.Organization);
canViewOrganizationBilling = orgActions[CustomAction.ManageBilling] ?? isOrgAdminOrOwner;
canUpdateOrganization = orgActions[CrudAction.Update] ?? isOrgAdminOrOwner;
} else {
canViewRoles = false;
canViewOrganizationBilling = isOrgAdminOrOwner;
canUpdateOrganization = isOrgAdminOrOwner;
}
}
@@ -76,7 +82,7 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
<SettingsLayoutAppDirClient
{...props}
teamFeatures={teamFeatures ?? {}}
permissions={{ canViewRoles, canViewOrganizationBilling }}
permissions={{ canViewRoles, canViewOrganizationBilling, canUpdateOrganization }}
/>
</>
);
@@ -1,8 +1,9 @@
import { _generateMetadata } from "app/_utils";
import OrgAttributesEditPage from "@calcom/ee/organizations/pages/settings/attributes/attributes-edit-view";
import { MembershipRole } from "@calcom/prisma/enums";
import { validateUserHasOrgAdmin } from "../../../../actions/validateUserHasOrgAdmin";
import { validateUserHasOrgPerms } from "../../../../actions/validateUserHasOrgPerms";
export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) =>
await _generateMetadata(
@@ -14,7 +15,10 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
);
const OrgAttributesEditPageWrapper = async () => {
await validateUserHasOrgAdmin();
await validateUserHasOrgPerms({
permission: "organization.attributes.update",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
return <OrgAttributesEditPage />;
};
@@ -1,8 +1,9 @@
import { _generateMetadata } from "app/_utils";
import OrgAttributesCreatePage from "@calcom/ee/organizations/pages/settings/attributes/attributes-create-view";
import { MembershipRole } from "@calcom/prisma/enums";
import { validateUserHasOrgAdmin } from "../../../actions/validateUserHasOrgAdmin";
import { validateUserHasOrgPerms } from "../../../actions/validateUserHasOrgPerms";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -14,7 +15,10 @@ export const generateMetadata = async () =>
);
const OrgAttributesCreatePageWrapper = async () => {
await validateUserHasOrgAdmin();
await validateUserHasOrgPerms({
permission: "organization.attributes.create",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
return <OrgAttributesCreatePage />;
};
@@ -7,7 +7,7 @@ import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permi
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { MembershipRole } from "@calcom/prisma/enums";
import { validateUserHasOrgAdmin } from "../../actions/validateUserHasOrgAdmin";
import { validateUserHasOrg } from "../../actions/validateUserHasOrg";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -20,7 +20,7 @@ export const generateMetadata = async () =>
const Page = async () => {
const t = await getTranslate();
const session = await validateUserHasOrgAdmin();
const session = await validateUserHasOrg();
const { canRead, canEdit, canDelete, canCreate } = await getResourcePermissions({
userId: session.user.id,
@@ -2,8 +2,9 @@ import { _generateMetadata, getTranslate } from "app/_utils";
import DelegationCredentialList from "@calcom/features/ee/organizations/pages/settings/delegationCredential";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { MembershipRole } from "@calcom/prisma/enums";
import { validateUserHasOrgAdmin } from "../../actions/validateUserHasOrgAdmin";
import { validateUserHasOrgPerms } from "../../actions/validateUserHasOrgPerms";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -17,7 +18,10 @@ export const generateMetadata = async () =>
const Page = async () => {
const t = await getTranslate();
await validateUserHasOrgAdmin();
await validateUserHasOrgPerms({
permission: "organization.update",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
return (
<SettingsHeader
@@ -6,7 +6,7 @@ import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permi
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { MembershipRole } from "@calcom/prisma/enums";
import { validateUserHasOrgAdmin } from "../../actions/validateUserHasOrgAdmin";
import { validateUserHasOrg } from "../../actions/validateUserHasOrg";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -19,7 +19,7 @@ export const generateMetadata = async () =>
const Page = async () => {
const t = await getTranslate();
const session = await validateUserHasOrgAdmin();
const session = await validateUserHasOrg();
const { canEdit } = await getResourcePermissions({
userId: session.user.id,
@@ -7,7 +7,7 @@ import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permi
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { MembershipRole } from "@calcom/prisma/enums";
import { validateUserHasOrgAdmin } from "../../actions/validateUserHasOrgAdmin";
import { validateUserHasOrg } from "../../actions/validateUserHasOrg";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -21,7 +21,7 @@ export const generateMetadata = async () =>
const Page = async () => {
const t = await getTranslate();
const session = await validateUserHasOrgAdmin();
const session = await validateUserHasOrg();
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
return redirect("/settings/profile");
@@ -7,7 +7,7 @@ import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permi
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { MembershipRole } from "@calcom/prisma/enums";
import { validateUserHasOrgAdmin } from "../../actions/validateUserHasOrgAdmin";
import { validateUserHasOrg } from "../../actions/validateUserHasOrg";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -20,7 +20,7 @@ export const generateMetadata = async () =>
const Page = async () => {
const t = await getTranslate();
const session = await validateUserHasOrgAdmin();
const session = await validateUserHasOrg();
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
return redirect("/settings/organizations/general");
@@ -22,6 +22,7 @@ interface AdvancedPermissionGroupProps {
onChange: (permissions: string[]) => void;
disabled?: boolean;
scope?: Scope;
isPrivate?: boolean;
}
const INTERNAL_DATAACCESS_KEY = "_resource";
@@ -32,10 +33,11 @@ export function AdvancedPermissionGroup({
onChange,
disabled,
scope = Scope.Organization,
isPrivate = false,
}: AdvancedPermissionGroupProps) {
const { t } = useLocale();
const { toggleSinglePermission, toggleResourcePermissionLevel } = usePermissions(scope);
const scopedRegistry = getPermissionsForScope(scope);
const scopedRegistry = getPermissionsForScope(scope, isPrivate);
const resourceConfig = scopedRegistry[resource];
const [isExpanded, setIsExpanded] = useState(false);
@@ -56,9 +56,17 @@ interface RoleSheetProps {
onOpenChange: (open: boolean) => void;
teamId: number;
scope?: Scope;
isPrivate?: boolean; // Add isPrivate prop to control permission visibility
}
export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Organization }: RoleSheetProps) {
export function RoleSheet({
role,
open,
onOpenChange,
teamId,
scope = Scope.Organization,
isPrivate = false,
}: RoleSheetProps) {
const { t } = useLocale();
const router = useRouter();
const isEditing = Boolean(role);
@@ -108,7 +116,8 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
const { isAdvancedMode, permissions, color } = form.watch();
const { filteredResources, scopedRegistry } = useMemo(() => {
const scopedRegistry = getPermissionsForScope(scope);
// Use privacy-aware filtering if we have privacy information
const scopedRegistry = getPermissionsForScope(scope, isPrivate);
const filteredResources = Object.keys(scopedRegistry).filter((resource) =>
t(
scopedRegistry[resource as Resource][CrudAction.All as keyof (typeof scopedRegistry)[Resource]]
@@ -118,7 +127,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
.includes(searchQuery.toLowerCase())
);
return { filteredResources, scopedRegistry };
}, [searchQuery, t, scope]);
}, [searchQuery, t, scope, isPrivate]);
const createMutation = trpc.viewer.pbac.createRole.useMutation({
onSuccess: async () => {
@@ -182,6 +191,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
{...form.register("name")}
placeholder={t("role_name_placeholder")}
disabled={isSystemRole}
maxLength={50}
/>
</div>
<RoleColorPicker
@@ -199,10 +209,13 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
<Label className="mb-0">{t("permissions")}</Label>
<div className="flex items-center gap-2">
<Checkbox
id="advanced_mode_checkbox"
checked={form.watch("isAdvancedMode")}
onCheckedChange={(checked: boolean) => form.setValue("isAdvancedMode", checked)}
/>
<span className="text-sm">{t("advanced")}</span>
<label htmlFor="advanced_mode_checkbox" className="text-sm">
{t("advanced")}
</label>
</div>
</div>
<TextField
@@ -221,6 +234,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
onChange={(newPermissions) => form.setValue("permissions", newPermissions)}
disabled={isSystemRole}
scope={scope}
isPrivate={isPrivate}
/>
))}{" "}
</div>
@@ -245,6 +259,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
onChange={(newPermissions) => form.setValue("permissions", newPermissions)}
disabled={isSystemRole}
scope={scope}
isPrivate={isPrivate}
/>
))}
</div>{" "}
@@ -48,6 +48,7 @@ interface RolesListProps {
initialSheetOpen?: boolean;
teamId: number;
scope?: Scope;
isPrivate?: boolean;
}
export function RolesList({
@@ -57,6 +58,7 @@ export function RolesList({
initialSheetOpen,
teamId,
scope,
isPrivate,
}: RolesListProps) {
const { t } = useLocale();
const { isOpen, setIsOpen, selectedRoleId, setSelectedRoleId, handleSheetOpenChange } = useRoleStates(
@@ -113,6 +115,7 @@ export function RolesList({
onOpenChange={handleSheetOpenChange}
teamId={teamId}
scope={scope}
isPrivate={isPrivate}
/>
</>
);
@@ -18,6 +18,7 @@ interface SimplePermissionItemProps {
onChange: (permissions: string[]) => void;
disabled?: boolean;
scope?: Scope;
isPrivate?: boolean;
}
export function SimplePermissionItem({
@@ -26,10 +27,11 @@ export function SimplePermissionItem({
onChange,
disabled,
scope = Scope.Organization,
isPrivate = false,
}: SimplePermissionItemProps) {
const { t } = useLocale();
const { getResourcePermissionLevel, toggleResourcePermissionLevel } = usePermissions(scope);
const scopedRegistry = getPermissionsForScope(scope);
const scopedRegistry = getPermissionsForScope(scope, isPrivate);
const registry = scopedRegistry || PERMISSION_REGISTRY;
const isAllResources = resource === "*";
@@ -44,6 +44,18 @@ const getCachedResourcePermissions = unstable_cache(
{ revalidate: 3600 }
);
const getCachedTeamPrivacy = unstable_cache(
async (teamId: number) => {
const team = await prisma.team.findUnique({
where: { id: teamId },
select: { isPrivate: true },
});
return team?.isPrivate ?? false;
},
["team-privacy"],
{ revalidate: 3600 }
);
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("roles_and_permissions"),
@@ -69,9 +81,10 @@ const Page = async ({ searchParams }: { searchParams: Record<string, string | st
roleSearchParamsCache.parse(searchParams);
const [roles, rolePermissions] = await Promise.all([
const [roles, rolePermissions, isPrivate] = await Promise.all([
getCachedTeamRoles(session.user.org.id),
getCachedResourcePermissions(session.user.id, session.user.org.id, Resource.Role),
getCachedTeamPrivacy(session.user.org.id),
]);
// NOTE: this approach of fetching permssions per resource does not account for fall back roles.
@@ -108,6 +121,7 @@ const Page = async ({ searchParams }: { searchParams: Record<string, string | st
}}
initialSelectedRole={selectedRole}
initialSheetOpen={isSheetOpen}
isPrivate={isPrivate}
/>
</SettingsHeader>
);
@@ -102,6 +102,7 @@ const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
CustomAction.ChangeMemberRole,
CustomAction.Remove,
CustomAction.ListMembers,
CustomAction.ListMembersPrivate,
CustomAction.Impersonate,
],
fallbackRoles: {
@@ -120,12 +121,17 @@ const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
[CustomAction.Impersonate]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.ListMembersPrivate]: {
roles: fallbackRolesCanListMembers,
},
},
});
// Map specific permissions to member actions
const memberPermissions = {
canListMembers: permissions[CustomAction.ListMembers],
canListMembers: team.isPrivate
? permissions[CustomAction.ListMembersPrivate]
: permissions[CustomAction.ListMembers],
canInvite: permissions[CustomAction.Invite],
canChangeMemberRole: permissions[CustomAction.ChangeMemberRole],
canRemove: permissions[CustomAction.Remove],
@@ -4,6 +4,7 @@ import { cookies, headers } from "next/headers";
import { notFound } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getTeamWithMembers } from "@calcom/features/ee/teams/lib/queries";
import type { AppFlags } from "@calcom/features/flags/config";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { PermissionMapper } from "@calcom/features/pbac/domain/mappers/PermissionMapper";
@@ -11,7 +12,6 @@ import { Resource, CrudAction, Scope } from "@calcom/features/pbac/domain/types/
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { RoleService } from "@calcom/features/pbac/services/role.service";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { getTeamWithMembers } from "@calcom/features/ee/teams/lib/queries";
import { prisma } from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -51,6 +51,19 @@ const getCachedResourcePermissions = (userId: number, teamId: number, resource:
{ revalidate: 3600, tags: [`resource-permissions-${teamId}`] }
);
const getCachedTeamPrivacy = (teamId: number) =>
unstable_cache(
async () => {
const team = await prisma.team.findUnique({
where: { id: teamId },
select: { isPrivate: true },
});
return team?.isPrivate ?? false;
},
[`team-privacy-${teamId}`],
{ revalidate: 3600, tags: [`team-privacy-${teamId}`] }
);
const getCachedTeam = (teamId: string, userId: number) =>
unstable_cache(
async () => {
@@ -111,9 +124,10 @@ const Page = async ({
roleSearchParamsCache.parse(searchParams);
const [roles, rolePermissions] = await Promise.all([
const [roles, rolePermissions, isPrivate] = await Promise.all([
getCachedTeamRoles(team.id)(),
getCachedResourcePermissions(session.user.id, team.id, Resource.Role)(),
getCachedTeamPrivacy(team.id)(),
]);
// NOTE: this approach of fetching permssions per resource does not account for fall back roles.
@@ -153,6 +167,7 @@ const Page = async ({
initialSelectedRole={selectedRole}
initialSheetOpen={isSheetOpen}
scope={Scope.Team}
isPrivate={isPrivate}
/>
</SettingsHeader>
);
@@ -70,6 +70,7 @@ const Page = async () => {
userRole: session.user.org.role,
actions: [
CustomAction.ListMembers,
CustomAction.ListMembersPrivate,
CustomAction.Invite,
CustomAction.ChangeMemberRole,
CustomAction.Remove,
@@ -79,6 +80,9 @@ const Page = async () => {
[CustomAction.ListMembers]: {
roles: fallbackRolesThatCanSeeMembers,
},
[CustomAction.ListMembersPrivate]: {
roles: fallbackRolesThatCanSeeMembers,
},
[CustomAction.Invite]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
@@ -96,7 +100,9 @@ const Page = async () => {
// Map specific permissions to member actions
const memberPermissions = {
canListMembers: permissions[CustomAction.ListMembers],
canListMembers: org.isPrivate
? permissions[CustomAction.ListMembersPrivate]
: permissions[CustomAction.ListMembers],
canInvite: permissions[CustomAction.Invite],
canChangeMemberRole: permissions[CustomAction.ChangeMemberRole],
canRemove: permissions[CustomAction.Remove],
+17 -1
View File
@@ -1,5 +1,6 @@
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import { parseRequestData } from "app/api/parseRequestData";
import { cookies } from "next/headers";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { z } from "zod";
@@ -10,6 +11,7 @@ import prisma from "@calcom/prisma";
import { IdentityProvider } from "@calcom/prisma/enums";
const passwordResetRequestSchema = z.object({
csrfToken: z.string(),
password: z.string().refine(validPassword, () => ({
message: "Password does not meet the requirements",
})),
@@ -18,8 +20,22 @@ const passwordResetRequestSchema = z.object({
async function handler(req: NextRequest) {
const body = await parseRequestData(req);
const {
password: rawPassword,
requestId: rawRequestId,
csrfToken: submittedToken,
} = passwordResetRequestSchema.parse(body);
const cookieStore = await cookies();
const cookieToken = cookieStore.get("calcom.csrf_token")?.value;
if (submittedToken !== cookieToken) {
return NextResponse.json({ error: "Invalid CSRF token" }, { status: 403 });
}
// token verified, delete the cookie / a resubmit on failure requires a new csrf token.
cookieStore.delete("calcom.csrf_token");
const { password: rawPassword, requestId: rawRequestId } = passwordResetRequestSchema.parse(body);
// rate-limited there is a low, very low chance that a password request stays valid long enough
// to brute force 3.8126967e+40 options.
const maybeRequest = await prisma.resetPasswordRequest.findFirstOrThrow({
@@ -0,0 +1,213 @@
import { NextRequest } from "next/server";
import { describe, test, expect, vi, beforeEach } from "vitest";
import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService";
vi.mock("next/server", () => ({
NextRequest: class MockNextRequest {
url: string;
method: string;
nextUrl: { searchParams: URLSearchParams };
private _headers: Map<string, string>;
constructor(url: string, options: { method?: string } = {}) {
this.url = url;
this.method = options.method || "GET";
this._headers = new Map();
this.nextUrl = { searchParams: new URLSearchParams(url.split("?")[1] || "") };
}
headers = {
get: (key: string): string | null => this._headers.get(key.toLowerCase()) || null,
set: (key: string, value: string): void => {
this._headers.set(key.toLowerCase(), value);
},
has: (key: string): boolean => this._headers.has(key.toLowerCase()),
};
},
NextResponse: {
json: vi.fn((body, init) => ({
json: vi.fn().mockResolvedValue(body),
status: init?.status || 200,
})),
},
}));
vi.mock("@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService");
vi.mock("@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository");
vi.mock("@calcom/lib/logger", () => ({
default: {
getSubLogger: vi.fn(() => ({
info: vi.fn(),
error: vi.fn(),
})),
},
}));
vi.mock("@calcom/lib/server/perfObserver", () => ({
performance: {
mark: vi.fn(),
measure: vi.fn(),
},
}));
vi.mock("@sentry/nextjs", () => ({
wrapApiHandlerWithSentry: vi.fn((handler) => handler),
captureException: vi.fn(),
}));
vi.mock("@calcom/lib/server/getServerErrorFromUnknown", () => ({
getServerErrorFromUnknown: vi.fn((error) => ({
message: error instanceof Error ? error.message : "Unknown error",
statusCode: 500,
url: "test-url",
method: "GET",
})),
}));
vi.mock("../../defaultResponderForAppDir", () => ({
defaultResponderForAppDir: vi.fn((handler) => handler),
}));
vi.mock("@calcom/prisma", () => ({
prisma: {},
}));
const mockCalendarCacheEventService = vi.mocked(CalendarCacheEventService);
describe("/api/cron/calendar-subscriptions-cleanup", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv("CRON_API_KEY", "test-cron-key");
vi.stubEnv("CRON_SECRET", "test-cron-secret");
});
describe("Authentication", () => {
test("should return 403 when no API key is provided", async () => {
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
const { GET } = await import("../route");
const response = await GET(request, { params: Promise.resolve({}) });
expect(response.status).toBe(403);
const body = await response.json();
expect(body.message).toBe("Forbidden");
});
test("should return 403 when invalid API key is provided", async () => {
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
request.headers.set("authorization", "invalid-key");
const { GET } = await import("../route");
const response = await GET(request, { params: Promise.resolve({}) });
expect(response.status).toBe(403);
const body = await response.json();
expect(body.message).toBe("Forbidden");
});
test("should accept CRON_API_KEY in authorization header", async () => {
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
request.headers.set("authorization", "test-cron-key");
const mockCleanupStaleCache = vi.fn().mockResolvedValue(undefined);
mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache;
const { GET } = await import("../route");
const response = await GET(request, { params: Promise.resolve({}) });
expect(response.status).toBe(200);
expect(mockCleanupStaleCache).toHaveBeenCalled();
});
test("should accept CRON_SECRET as Bearer token", async () => {
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
request.headers.set("authorization", "Bearer test-cron-secret");
const mockCleanupStaleCache = vi.fn().mockResolvedValue(undefined);
mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache;
const { GET } = await import("../route");
const response = await GET(request, { params: Promise.resolve({}) });
expect(response.status).toBe(200);
expect(mockCleanupStaleCache).toHaveBeenCalled();
});
test("should accept API key as query parameter", async () => {
const request = new NextRequest(
"http://localhost/api/cron/calendar-subscriptions-cleanup?apiKey=test-cron-key"
);
const mockCleanupStaleCache = vi.fn().mockResolvedValue(undefined);
mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache;
const { GET } = await import("../route");
const response = await GET(request, { params: Promise.resolve({}) });
expect(response.status).toBe(200);
expect(mockCleanupStaleCache).toHaveBeenCalled();
});
});
describe("Cleanup functionality", () => {
test("should successfully cleanup stale cache", async () => {
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
request.headers.set("authorization", "test-cron-key");
const mockCleanupStaleCache = vi.fn().mockResolvedValue(undefined);
mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache;
const { GET } = await import("../route");
const response = await GET(request, { params: Promise.resolve({}) });
expect(response.status).toBe(200);
const body = await response.json();
expect(body.ok).toBe(true);
expect(mockCleanupStaleCache).toHaveBeenCalledOnce();
});
test("should handle cleanup errors gracefully", async () => {
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
request.headers.set("authorization", "test-cron-key");
const mockError = new Error("Database connection failed");
const mockCleanupStaleCache = vi.fn().mockRejectedValue(mockError);
mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache;
const { GET } = await import("../route");
const response = await GET(request, { params: Promise.resolve({}) });
expect(response.status).toBe(500);
const body = await response.json();
expect(body.message).toBe("Database connection failed");
});
test("should handle non-Error exceptions", async () => {
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
request.headers.set("authorization", "test-cron-key");
const mockCleanupStaleCache = vi.fn().mockRejectedValue("String error");
mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache;
const { GET } = await import("../route");
const response = await GET(request, { params: Promise.resolve({}) });
expect(response.status).toBe(500);
const body = await response.json();
expect(body.message).toBe("Unknown error");
});
});
describe("Service instantiation", () => {
test("should instantiate CalendarCacheEventService with correct dependencies", async () => {
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
request.headers.set("authorization", "test-cron-key");
const mockCleanupStaleCache = vi.fn().mockResolvedValue(undefined);
mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache;
const { GET } = await import("../route");
await GET(request, { params: Promise.resolve({}) });
expect(mockCalendarCacheEventService).toHaveBeenCalledWith({
calendarCacheEventRepository: expect.any(Object),
});
});
});
});
@@ -0,0 +1,39 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { CalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository";
import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService";
import { prisma } from "@calcom/prisma";
import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir";
/**
* Cron webhook
* Cleanup stale calendar cache
*
* @param request
* @returns
*/
async function getHandler(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: "Forbidden" }, { status: 403 });
}
// instantiate dependencies
const calendarCacheEventRepository = new CalendarCacheEventRepository(prisma);
const calendarCacheEventService = new CalendarCacheEventService({
calendarCacheEventRepository,
});
try {
await calendarCacheEventService.cleanupStaleCache();
return NextResponse.json({ ok: true });
} catch (e: unknown) {
const message = e instanceof Error ? e.message : "Unknown error";
console.error(`[calendar-subscriptions-cleanup] ${message}:`, e);
return NextResponse.json({ message }, { status: 500 });
}
}
export const GET = defaultResponderForAppDir(getHandler);
@@ -0,0 +1,229 @@
import { NextRequest } from "next/server";
import { describe, test, expect, vi, beforeEach } from "vitest";
import { CalendarSubscriptionService } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionService";
vi.mock("next/server", () => ({
NextRequest: class MockNextRequest {
url: string;
method: string;
nextUrl: { searchParams: URLSearchParams };
private _headers: Map<string, string>;
constructor(url: string, options: { method?: string } = {}) {
this.url = url;
this.method = options.method || "GET";
this._headers = new Map();
this.nextUrl = { searchParams: new URLSearchParams(url.split("?")[1] || "") };
}
headers = {
get: (key: string): string | null => this._headers.get(key.toLowerCase()) || null,
set: (key: string, value: string): void => {
this._headers.set(key.toLowerCase(), value);
},
has: (key: string): boolean => this._headers.has(key.toLowerCase()),
};
},
NextResponse: {
json: vi.fn((body, init) => ({
json: vi.fn().mockResolvedValue(body),
status: init?.status || 200,
})),
},
}));
vi.mock("@calcom/features/calendar-subscription/lib/CalendarSubscriptionService");
vi.mock("@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService");
vi.mock("@calcom/features/calendar-subscription/lib/sync/CalendarSyncService");
vi.mock("@calcom/prisma", () => ({
prisma: {},
}));
const mockCalendarSubscriptionService = vi.mocked(CalendarSubscriptionService);
describe("/api/cron/calendar-subscriptions", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
vi.stubEnv("CRON_API_KEY", "test-cron-key");
vi.stubEnv("CRON_SECRET", "test-cron-secret");
});
describe("Authentication", () => {
test("should return 403 when no API key is provided", async () => {
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
const { GET } = await import("../route");
const response = await GET(request, { params: Promise.resolve({}) });
expect(response.status).toBe(403);
const body = await response.json();
expect(body.message).toBe("Forbiden");
}, 10000);
test("should return 403 when invalid API key is provided", async () => {
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
request.headers.set("authorization", "invalid-key");
const { GET } = await import("../route");
const response = await GET(request, { params: Promise.resolve({}) });
expect(response.status).toBe(403);
const body = await response.json();
expect(body.message).toBe("Forbiden");
});
test("should accept valid API key", async () => {
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
request.headers.set("authorization", "test-cron-key");
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
const mockIsSyncEnabled = vi.fn().mockResolvedValue(true);
const mockCheckForNewSubscriptions = vi.fn().mockResolvedValue(undefined);
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions;
const { GET } = await import("../route");
const response = await GET(request, { params: Promise.resolve({}) });
expect(response.status).toBe(200);
});
});
describe("Feature flag checks", () => {
test("should return early when cache AND sync are disabled", async () => {
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
request.headers.set("authorization", "test-cron-key");
const mockIsCacheEnabled = vi.fn().mockResolvedValue(false);
const mockIsSyncEnabled = vi.fn().mockResolvedValue(false);
const mockCheckForNewSubscriptions = vi.fn();
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions;
const { GET } = await import("../route");
const response = await GET(request, { params: Promise.resolve({}) });
expect(response.status).toBe(200);
const body = await response.json();
expect(body.ok).toBe(true);
expect(mockCheckForNewSubscriptions).not.toHaveBeenCalled();
});
test("should proceed when both cache and sync are enabled", async () => {
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
request.headers.set("authorization", "test-cron-key");
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
const mockIsSyncEnabled = vi.fn().mockResolvedValue(true);
const mockCheckForNewSubscriptions = vi.fn().mockResolvedValue(undefined);
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions;
const { GET } = await import("../route");
const response = await GET(request, { params: Promise.resolve({}) });
expect(response.status).toBe(200);
const body = await response.json();
expect(body.ok).toBe(true);
expect(mockCheckForNewSubscriptions).toHaveBeenCalledOnce();
});
});
describe("Subscription checking functionality", () => {
test("should successfully check for new subscriptions", async () => {
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
request.headers.set("authorization", "test-cron-key");
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
const mockIsSyncEnabled = vi.fn().mockResolvedValue(true);
const mockCheckForNewSubscriptions = vi.fn().mockResolvedValue(undefined);
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions;
const { GET } = await import("../route");
const response = await GET(request, { params: Promise.resolve({}) });
expect(response.status).toBe(200);
const body = await response.json();
expect(body.ok).toBe(true);
expect(mockCheckForNewSubscriptions).toHaveBeenCalledOnce();
});
test("should handle subscription checking errors gracefully", async () => {
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
request.headers.set("authorization", "test-cron-key");
const mockError = new Error("Subscription service unavailable");
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
const mockIsSyncEnabled = vi.fn().mockResolvedValue(true);
const mockCheckForNewSubscriptions = vi.fn().mockRejectedValue(mockError);
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions;
const { GET } = await import("../route");
const response = await GET(request, { params: Promise.resolve({}) });
expect(response.status).toBe(500);
const body = await response.json();
expect(body.message).toBe("Subscription service unavailable");
});
test("should handle non-Error exceptions", async () => {
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
request.headers.set("authorization", "test-cron-key");
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
const mockIsSyncEnabled = vi.fn().mockResolvedValue(true);
const mockCheckForNewSubscriptions = vi.fn().mockRejectedValue("String error");
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions;
const { GET } = await import("../route");
const response = await GET(request, { params: Promise.resolve({}) });
expect(response.status).toBe(500);
const body = await response.json();
expect(body.message).toBe("Unknown error");
});
});
describe("Service instantiation", () => {
test("should instantiate all services with correct dependencies", async () => {
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
request.headers.set("authorization", "test-cron-key");
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
const mockIsSyncEnabled = vi.fn().mockResolvedValue(true);
const mockCheckForNewSubscriptions = vi.fn().mockResolvedValue(undefined);
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions;
const { GET } = await import("../route");
await GET(request, { params: Promise.resolve({}) });
expect(mockCalendarSubscriptionService).toHaveBeenCalledWith({
adapterFactory: expect.any(Object),
selectedCalendarRepository: expect.any(Object),
featuresRepository: expect.any(Object),
calendarSyncService: expect.any(Object),
calendarCacheEventService: expect.any(Object),
});
});
});
});
@@ -0,0 +1,66 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { DefaultAdapterFactory } from "@calcom/features/calendar-subscription/adapters/AdaptersFactory";
import { CalendarSubscriptionService } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionService";
import { CalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository";
import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService";
import { CalendarSyncService } from "@calcom/features/calendar-subscription/lib/sync/CalendarSyncService";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository";
import { BookingRepository } from "@calcom/lib/server/repository/booking";
import { prisma } from "@calcom/prisma";
import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir";
/**
* Cron webhook
* Checks for new calendar subscriptions (rollouts)
*
* @param request
* @returns
*/
async function getHandler(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: "Forbiden" }, { status: 403 });
}
// instantiate dependencies
const bookingRepository = new BookingRepository(prisma);
const calendarSyncService = new CalendarSyncService({
bookingRepository,
});
const calendarCacheEventRepository = new CalendarCacheEventRepository(prisma);
const calendarCacheEventService = new CalendarCacheEventService({
calendarCacheEventRepository,
});
const calendarSubscriptionService = new CalendarSubscriptionService({
adapterFactory: new DefaultAdapterFactory(),
selectedCalendarRepository: new SelectedCalendarRepository(prisma),
featuresRepository: new FeaturesRepository(prisma),
calendarSyncService,
calendarCacheEventService,
});
// are features globally enabled
const [isCacheEnabled, isSyncEnabled] = await Promise.all([
calendarSubscriptionService.isCacheEnabled(),
calendarSubscriptionService.isSyncEnabled(),
]);
if (!isCacheEnabled && !isSyncEnabled) {
return NextResponse.json({ ok: true });
}
try {
await calendarSubscriptionService.checkForNewSubscriptions();
return NextResponse.json({ ok: true });
} catch (e) {
const message = e instanceof Error ? e.message : "Unknown error";
return NextResponse.json({ message }, { status: 500 });
}
}
export const GET = defaultResponderForAppDir(getHandler);
@@ -2,4 +2,4 @@ import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import { handleQueuedFormResponseCleanup } from "@calcom/app-store/routing-forms/cron/queuedFormResponseCleanup";
export const POST = defaultResponderForAppDir(handleQueuedFormResponseCleanup);
export const GET = defaultResponderForAppDir(handleQueuedFormResponseCleanup);
@@ -8,7 +8,7 @@ import { NextResponse } from "next/server";
import GoogleCalendarService from "@calcom/app-store/googlecalendar/lib/CalendarService";
import { CalendarAppDelegationCredentialInvalidGrantError } from "@calcom/lib/CalendarAppError";
import { findUniqueDelegationCalendarCredential } from "@calcom/lib/delegationCredential/server";
import { findUniqueDelegationCalendarCredential } from "@calcom/app-store/delegationCredential";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
+13 -7
View File
@@ -3,19 +3,25 @@ import { NextResponse } from "next/server";
import { WEBAPP_URL } from "@calcom/lib/constants";
export async function GET() {
const token = randomBytes(32).toString("hex");
export async function GET(req: Request) {
const url = new URL(req.url);
const sameSiteParam = url.searchParams.get("sameSite");
const useSecureCookies = WEBAPP_URL.startsWith("https://");
// Validate the param, default to "lax"
let sameSite: "lax" | "strict" | "none" = "lax";
if (sameSiteParam === "strict" || (sameSiteParam === "none" && useSecureCookies)) {
sameSite = sameSiteParam;
}
const token = randomBytes(32).toString("hex");
const res = NextResponse.json({ csrfToken: token });
// We need this cookie to be accessible from embeds where the booking flow is displayed within an iframe on a different origin.
// For third‑party iframe contexts (embeds on other sites), browsers require SameSite=None and Secure to make the cookie available.
// For local development on http://localhost we fall back to SameSite=Lax to avoid requiring https during development.
const useSecureCookies = WEBAPP_URL.startsWith("https://");
res.cookies.set("calcom.csrf_token", token, {
httpOnly: true,
secure: useSecureCookies,
sameSite: useSecureCookies ? "none" : "lax",
sameSite,
path: "/",
});
@@ -1,5 +1,3 @@
import { wrapApiHandlerWithSentry } from "@sentry/nextjs";
import { captureException } from "@sentry/nextjs";
import type { Params } from "app/_types";
import { ApiError } from "next/dist/server/api-utils";
import type { NextRequest } from "next/server";
@@ -21,10 +19,13 @@ export const defaultResponderForAppDir = <T extends NextResponse | Response = Ne
let ok = false;
try {
performance.mark("Start");
const result = endpointRoute
? await wrapApiHandlerWithSentry(async () => await handler(req, { params }), endpointRoute)()
: await handler(req, { params });
let result: T | undefined;
if (process.env.NODE_ENV === "development" || !endpointRoute) {
result = await handler(req, { params });
} else {
const { wrapApiHandlerWithSentry } = await import("@sentry/nextjs");
result = await wrapApiHandlerWithSentry(async () => await handler(req, { params }), endpointRoute)();
}
ok = true;
if (result) {
@@ -49,6 +50,7 @@ export const defaultResponderForAppDir = <T extends NextResponse | Response = Ne
// Don't report 400-499 errors to Sentry/console
if (!(serverError.statusCode >= 400 && serverError.statusCode < 500)) {
console.error(serverError);
const { captureException } = await import("@sentry/nextjs");
captureException(error);
}
@@ -17,7 +17,7 @@ import { safeStringify } from "@calcom/lib/safeStringify";
import {
getAllTranscriptsAccessLinkFromMeetingId,
submitBatchProcessorTranscriptionJob,
} from "@calcom/app-store/videoClient";
} from "@calcom/features/conferencing/lib/videoClient";
import { generateVideoToken } from "@calcom/lib/videoTokens";
import prisma from "@calcom/prisma";
import { getBooking } from "@calcom/web/lib/daily-webhook/getBooking";
+16 -1
View File
@@ -4,9 +4,11 @@ import { NextResponse } from "next/server";
import type Stripe from "stripe";
import { z } from "zod";
import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/IBillingRepository";
import { InternalTeamBilling } from "@calcom/features/ee/billing/teams/internal-team-billing";
import stripe from "@calcom/features/ee/payments/server/stripe";
import { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { MembershipSchema } from "@calcom/prisma/zod/modelSchema/MembershipSchema";
import { TeamSchema } from "@calcom/prisma/zod/modelSchema/TeamSchema";
@@ -54,6 +56,19 @@ async function handler(request: NextRequest) {
include: { members: true },
});
if (checkoutSessionSubscription) {
const internalBillingService = new InternalTeamBilling(finalizedTeam);
await internalBillingService.saveTeamBilling({
teamId: finalizedTeam.id,
subscriptionId: checkoutSessionSubscription.id,
subscriptionItemId: checkoutSessionSubscription.items.data[0].id,
customerId: checkoutSessionSubscription.customer as string,
// TODO: Implement true subscription status when webhook events are implemented
status: SubscriptionStatus.ACTIVE,
planName: Plan.TEAM,
});
}
const response = {
message: `Team created successfully. We also made user with ID=${checkoutSessionMetadata.ownerId} the owner of this team.`,
team: schemaTeamReadPublic.parse(finalizedTeam),
+15
View File
@@ -4,6 +4,8 @@ import { NextResponse } from "next/server";
import type Stripe from "stripe";
import { z } from "zod";
import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/IBillingRepository";
import { InternalTeamBilling } from "@calcom/features/ee/billing/teams/internal-team-billing";
import stripe from "@calcom/features/ee/payments/server/stripe";
import { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
@@ -84,6 +86,19 @@ async function getHandler(req: NextRequest) {
},
});
if (checkoutSession && subscription) {
const internalBillingService = new InternalTeamBilling(team);
await internalBillingService.saveTeamBilling({
teamId: team.id,
subscriptionId: subscription.id,
subscriptionItemId: subscription.items.data[0].id,
customerId: subscription.customer as string,
// TODO: Implement true subscription status when webhook events are implemented
status: SubscriptionStatus.ACTIVE,
planName: Plan.TEAM,
});
}
// redirect to team screen
return NextResponse.redirect(
new URL(`/settings/teams/${team.id}/onboard-members?event=team_created`, req.nextUrl.origin),

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