Compare commits

...
22 Commits
Author SHA1 Message Date
Alex van Andel 02d61da9b0 chore: release v6.0.12
Post release / release (push) Canceled after 0s
Release Docker / Prepare Release (push) Canceled after 0s
Release Docker / Release AMD64 (push) Canceled after 0s
Release Docker / Release ARM (push) Canceled after 0s
2026-01-14 11:15:04 +00:00
Dhairyashil ShindeandMorgan c0017114ef fix(api): return empty bookingUrl for managed users in API v2 (#26826)
Managed users (isPlatformManaged: true) don't have public booking pages -
their scheduling is handled through the platform's app.

- Fetch isPlatformManaged in event-types repository
- Return empty string for managed users in buildBookingUrl
- Added unit test for managed users case

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2026-01-14 15:28:25 +05:30
Eunjae LeeandDevin AI 4b99072bcc feat: add scope configuration for feature opt-in (#26801)
* feat: add scope configuration for feature opt-in

Add scope field to OptInFeatureConfig that allows features to be scoped
to specific levels (org, team, user). This enables features to be shown
only at certain settings pages rather than all three.

Changes:
- Add OptInFeatureScope type with values 'org', 'team', 'user'
- Add optional scope field to OptInFeatureConfig interface
- Add getOptInFeaturesForScope helper function to filter features by scope
- Update FeatureOptInService to filter features based on scope
- Update tRPC router to pass scope parameter for org/team endpoints

Features without a scope field default to all scopes for backward compatibility.

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

* feat: add scope validation to setUserFeatureState and setTeamFeatureState

- Add isFeatureAllowedForScope helper function to check if a feature is allowed for a scope
- Update setUserFeatureState to reject if feature is not scoped to 'user'
- Update setTeamFeatureState to accept scope parameter and reject if feature is not allowed
- Update tRPC router to pass scope parameter for team and org endpoints
- Fix unit test mock to include new config exports

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

* feat: use ErrorWithCode for scope validation and add tests

- Replace raw Error with ErrorWithCode using ErrorCode.BadRequest
- Add comprehensive tests for setUserFeatureState scope validation
- Add comprehensive tests for setTeamFeatureState scope validation
- Test both enabled/disabled and inherit state scenarios
- Test error messages include feature ID and scope name

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

* feat: improve Features menu visibility to use scope configuration

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

* refactor: remove unused hasOptInFeaturesForScope function

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

* fix: allow features not in config at all scopes (permissive default)

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

* fix: revert permissive default and mock scope validation in integration tests

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-14 09:21:56 +00:00
Dhairyashil ShindeandMorgan 821dce6a04 fix(api): skip platform org subdomain in bookingUrl for API v2 (#26812)
Platform organizations don't have public-facing subdomains, so non-managed
users in platform orgs should get cal.com URLs instead of the platform
org subdomain.

- Updated EventTypeUser type to include isPlatform field
- Modified buildBookingUrl to check isPlatform before using org slug
- Added unit test for platform org users

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2026-01-14 09:04:45 +00:00
3860717ae3 fix: workflow locale (#26509)
* fix: workflow locale

* fix: workflow locale

* fix: check if tmeplate is changed

* fix: regression

* fix: also check subject when determining if template is customized

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

* refactor: extract template matching logic into testable function

- Create detectMatchedTemplate function in separate file
- Add comprehensive unit tests (20 test cases)
- Refactor EmailWorkflowService to use the new function

Addresses review comment from hariombalhara requesting
abstraction and tests for the template matching logic.

Co-Authored-By: udit@cal.com <udit222001@gmail.com>

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
2026-01-14 13:01:40 +05:30
Syed Ali Shahbaz f4303d8d60 icalsequence fix (#26815) 2026-01-14 10:08:19 +05:30
Rajiv Sahal 851aa30829 fix: respect hideCalendarNotes setting during booking reschedule (#25945)
Add hideCalendarNotes support to reschedule flow

The hideCalendarNotes flag was only handled in CalendarManager.createEvent(), not in updateEvent(). This caused notes to become visible again in ICS files after rescheduling.

Changes:
- Add hideCalendarNotes handling to CalendarManager.updateEvent()
- Add hideCalendarNotes checks to reschedule email functions
- Move description reset before reschedule call (required for cloned event)
2026-01-13 20:31:38 -03:00
2664eb459b feat: Init Cal.com Salesforce package (#26330)
* Add db schema

* Add `CredentialRepository.findByTeamIdAndSlugs`

* Add enabled app slugs for attribute syncing

* Create repository for `IntegrationAttributeSync`

* Create zod schemas

* Create `AttributeSyncUserRuleOutputMapper`

* Create `IntegrationAttributeSyncService`

* Create DI contianer

* Create trpc endpoints

* Create page

* Include team name in `CredentialRepository.findByTeamIdAndSlugs`

* Update schema and relations

* Update types and schemas

* Add more methods to IntegrationAttributeSyncRepository

* Add more services to `IntegrationAttributeSyncService`
- getById
- Init updateIncludeRulesAndMappings

* Refactor `getTeams.handler` to use repository

* Create `createAttributeSync` trpc endpoint

* Create `updateAttributeSync` trpc endpoint

* Add router to trpc

* Create attribute sync child components

* Pass custom actions to `FormCard`

* Create `IntegrationAttributeSyncCard`

* Pass inital props via server side

* Fix prop

* Only refetch on mutation

* Fixes

* Add form error when duplicate field and attribute combo

* Add `updateTransactionWithRUleAndMappings` logic

* Adjust zod schemas

* Service add `updateIncludeRulesAndMappings`

* Pass orgId from server to component

* Rename types

* Add deleteById method to repository

* Add name to integrationAttributeSync

* Add deleteById method to service

* Rename method

* Add deleteAttributeSync trpc endpoint

* Make the IntegrationAttributeSyncCard a dummy component

* test: add tests for IntegrationAttributeSync feature

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

* Move creating a attribute sync record to the service

* Add i18n strings

* Safe select credential in find by id and team

* Fix default credentialId value in form

* Update repository return types

* Add i18n string

* Make credentialId optional for form schema

* Fix label

* Add cascade deletes

* Add verification that syncs belong to org

* Create mapper for repository output

* Type fixes

* Remove old test file

* Pass `attributeOptions` from parent to children

* Infer types from zod schema

* Type fixes

* Type fix

* Clean up

* Add i18n strings

* Remove unused file

* Address feedback

* Add migration file

* Address feedback

* Add validation for new integration values

* Remove unused router

* Move away from z.infer to z.ZodType

* Clean up comments

* Type fix

* Type fixes

* Type fix

* fix: add passthrough to syncFormDataSchema to preserve extra fields

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

* fix: remove incorrect test that expected extra fields to pass through syncFormDataSchema

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

* Add endpoint for SF to call

* Create scratch org config

* Create sf cli scripts

* Create package logic

* Update README

* Remove unused file

* Add indexes

* Add aria label

* Address feedback - consistent validation

* Fix import paths for attribute types

* refactor: change attributeSyncRules array to singular attributeSyncRule

The database schema enforces a one-to-one relationship between
IntegrationAttributeSync and AttributeSyncRule (via @unique constraint),
and the UI only supports a single rule. This change makes the TypeScript
type match the database schema and UI behavior.

Changes:
- Update IntegrationAttributeSync interface to use attributeSyncRule: AttributeSyncRule | null
- Update mapper to return singular rule instead of wrapping in array
- Update UI component to access sync.attributeSyncRule directly
- Update IIntegrationAttributeSyncUpdateParams Omit type
- Update tests to use attributeSyncRule: null instead of attributeSyncRules: []

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

* feat: implement FeatureOptInService (#25805)

* feat: implement FeatureOptInService WIP

* clean up

* feat: consolidate feature repositories and add updateFeatureForUser

- Implement updateFeatureForUser in FeaturesRepository (similar to updateFeatureForTeam)
- Move getUserFeatureState and getTeamFeatureState from PrismaFeatureOptInRepository to FeaturesRepository
- Update FeatureOptInService to use only FeaturesRepository
- Add setUserFeatureState and setTeamFeatureState methods to FeatureOptInService
- Update _router.ts to remove PrismaFeatureOptInRepository usage
- Remove PrismaFeatureOptInRepository.ts and FeatureOptInRepositoryInterface.ts
- Update features.repository.interface.ts and features.repository.mock.ts
- Add integration tests for updateFeatureForUser, getUserFeatureState, getTeamFeatureState
- Update service.integration-test.ts to use FeaturesRepository

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

* refactor: rename updateFeatureForUser to setUserFeatureState

Rename to match the convention used for setTeamFeatureState

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

* refactor: return FeatureState type from getUserFeatureState and getTeamFeatureState

* fix integration tests

* clean up logics

* update services and router

* refactor: change getUserFeatureState and getTeamFeatureState to accept featureIds array

- Renamed getUserFeatureState to getUserFeatureStates
- Renamed getTeamFeatureState to getTeamFeatureStates
- Changed parameter from featureId: string to featureIds: string[]
- Changed return type from FeatureState to Record<string, FeatureState>
- Updated FeatureOptInService to use the new batch methods
- Added tests for querying multiple features in a single call
- Optimized listFeaturesForTeam to fetch all feature states in one query

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

* feat: add getFeatureStateForTeams for batch querying multiple teams

- Added getFeatureStateForTeams method to query a single feature across multiple teams in one call
- Updated FeatureOptInService.resolveFeatureStateAcrossTeams to use the new batch method
- Replaces N+1 queries with a single database query for team states
- Added comprehensive integration tests for the new method

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

* refactor: combine org and team state queries into single call

- Include orgId in the teamIds array passed to getFeatureStateForTeams
- Extract org state and team states from the combined result
- Reduces database queries from 3 to 2 in resolveFeatureStateAcrossTeams

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

* refactor: use team.isOrganization and clarify computeEffectiveState comment

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

* refactor: use MembershipRepository.findAllByUserId with isOrganization

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

* feat: add featureId validation using isOptInFeature type guard

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

* less queries

* add fallback value

* fix type error

* move files

* add autoOptInFeatures column

* use autoOptInFeatures flag within FeatureOptInService

* add setUserAutoOptIn and setTeamAutoOptIn

* fix computeEffectiveState logic

* rewrite computeEffectiveState

* clean up integration tests

* clean up in afterEach

* fix type error

* refactor: use FeaturesRepository methods instead of direct Prisma calls

Replace all manual userFeatures and teamFeatures Prisma operations with
the new setUserFeatureState and setTeamFeatureState repository methods.

Changes include:
- Admin handlers (assignFeatureToTeam, unassignFeatureFromTeam)
- Test fixtures and integration tests
- Playwright fixtures
- Development scripts

This ensures consistent feature flag management through the repository
pattern and supports the new tri-state semantics (enabled/disabled/inherit).

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

* clean up

* fix the logic

* extract some logic into applyAutoOptIn()

* remove wrong code

* refactor: convert setUserFeatureState and setTeamFeatureState to object params with discriminated union

- Convert multiple positional parameters to single object parameter
- Use discriminated union types: assignedBy required for enabled/disabled, omitted for inherit
- Update all callers across repository, service, handlers, fixtures, and tests

* fix type error

* use Promise.all

* fix

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* Prevent duplicate field and attribute mappings

* Add validation that attribute belongs to the org

* fix: address Cubic AI review feedback

- Add @@index([credentialId]) to IntegrationAttributeSync model for efficient cascade deletes and credential-based queries (confidence: 9/10)
- Fix translation key from 'credential_required' to 'attribute_sync_credential_required' to match existing locale definition (confidence: 10/10)

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

* fix: address remaining cubic review feedback

- RuleBuilder.tsx: Use unique IDs for React keys instead of array index to prevent reconciliation bugs when removing conditions
- updateAttributeSync.handler.ts: Add early organization check before service calls for consistency
- createAttributeSync.handler.ts: Add CredentialNotFoundError class for type-safe error handling instead of string matching
- IntegrationAttributeSyncService.test.ts: Replace expect.fail() with proper Vitest rejects.toSatisfy() pattern

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

* Pull test file from main

* fix: address Cubic AI review feedback (confidence >= 9/10)

- Remove sensitive fields (email, changedFields) from logging in user-sync.ts
- Rename scratch-org:open to scratch-org:start in package.json to match README

Co-Authored-By: unknown <>

* Update README

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2026-01-13 20:01:21 -03:00
ce997447a4 fix: fix email exclusion validation for full emails and and @-prefixed domains (#26727)
* fix(bookings): fix email exclusion validation for full emails and @-prefixed domains

* NIT

* make case insensitive

* fix: make domain matching case-insensitive in doesEmailMatchEntry

Co-Authored-By: unknown <>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-13 16:56:28 -03:00
Abhay MishraandAnik Dhabal Babu 5fa76f943b fix(phone-input): add Austria mask and prevent double plus prefix (#26626)
* fix(phone-input): add Austria mask and prevent double plus prefix (#24193)

* updated code

* Update Austria phone mask format

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2026-01-13 19:22:15 +00:00
Pedro Castro 4226231b8a perf: batch user queries in schedules output service (#25901)
Replace N sequential queries with single batch query for fetching
user default schedule IDs

Changes:
- Add getUsersScheduleDefaultIds batch method to UsersRepository
- Add getResponseSchedules batch method to OutputSchedulesService
- Extract transformScheduleToOutput for reuse
- Simplify OrganizationsSchedulesService and TeamsSchedulesService
- Remove dead code (formatInput no-op method)

Reduces database round-trips from O(n) to O(1) for schedule lookups
2026-01-13 19:21:17 +00:00
Dhairyashil Shinde 69695d3022 feat(companion): manifest id for safari extension & add delete account link (#26802)
* feat: manifest id for safari

* add delete account button
2026-01-13 16:12:43 -03:00
c13fce4483 fix: changes to error on schedule name empty save (#24044)
* fix #21709: change to error on schedule name empty save

* fix comments

* minor enahncemnts

---------

Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2026-01-13 18:29:58 +00:00
f32ea8b4c5 fix: Booker reschedule behaviour for org admin (#26530)
* fix: reschedule behaviour for org admin

* fix: use pbac for permissions check

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2026-01-13 18:12:32 +00:00
Anshumancanrock 36cf6e3703 fix: Yarn installation fails in windows due to mixed path separators … (#26771)
* fix: Yarn installation fails in windows due to mixed path separators in app-store-cli

* fix: normalize paths for cross-platform Biome compatibility in app-store-cli
2026-01-13 18:07:51 +00:00
455973b394 fix(ui): improve Variables Dropdown styling consistency (#26675)
* fix(ui): improve Variables Dropdown styling consistency

- Change focus ring from brand-800 to subtle for consistent design
- Add rounded-md to dropdown container for visual consistency
- Add rounded-md to DropdownMenuItem for rounded corners
- Remove redundant hover:bg-muted and conditional bg-muted styling

Fixes #25848

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: restore keyboard navigation active state indicator

Keep the conditional bg-muted styling for the selected item during
keyboard navigation, while still removing the redundant hover:bg-muted
since DropdownMenuItem already handles hover styling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: simiondolha <simiondolha@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2026-01-13 14:53:11 -03:00
0b73491367 refactor: split large org creation transaction into smaller batches (#25688)
* refactor: split large org creation transaction into smaller batches

The prisma.team.create call at L418 was creating the organization along with
all orgProfiles and memberships in a single large transaction, which could
timeout when there are many members.

This change splits the operation into smaller, separate transactions:
1. Create the organization (team) with just metadata and organizationSettings
2. Create org profiles in batches of 50 using Promise.all
3. Create memberships in batches of 100 using createMany
4. Fetch created profiles to rebuild the member-profile mapping

This approach avoids transaction timeouts while maintaining the same
functionality and data integrity.

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

* Apply suggestion from @cubic-dev-ai[bot]

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2026-01-13 14:56:46 +00:00
573756eaa2 docs: fix CALENDSO_ENCRYPTION_KEY generation command (#26658)
The AES256 encryption used for 2FA requires exactly 32 bytes.
The previous command `openssl rand -base64 32` generates a 44-character
base64 string (32 bytes encoded in base64), which is too long.

Changed to `openssl rand -base64 24` which generates exactly 32
characters (24 bytes encoded in base64 = 32 characters).

This was causing 2FA setup failures with "Something went wrong" errors
and RangeError: Invalid key length in self-hosted Docker installations.

Fixes #22365

Co-authored-by: simiondolha <simiondolha@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2026-01-13 11:46:39 -03:00
5c686d90f9 feat: add tri-state UI settings pages and auto opt-in preference (#25892)
* feat: add tri-state UI settings pages and auto opt-in preference

TASK 5: Tri-state UI with ToggleGroup
- Create shared TeamOrgFeaturesSettings component
- Create user features settings page (features-view.tsx)
- Create team features settings page
- Create org features settings page

TASK 6: Add auto opt-in preference
- Add autoOptInExperimentalFeatures field to User and Team models
- Add migration for new fields
- Add TRPC endpoints for auto opt-in preference (using repository pattern)
- Add auto opt-in checkboxes to settings pages

Also adds i18n keys for feature opt-in UI.

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

refactor: rename autoOptInExperimentalFeatures to autoOptInFeatures

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

refactor: apply PBAC procedure pattern to featureOptIn router

- Create createTeamPbacProcedure for team-scoped endpoints
- Create createOrgPbacProcedure for organization-scoped endpoints
- Refactor all team/org endpoints to use the new PBAC procedures
- Significantly reduces boilerplate code for permission checks

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

add features pages

implement hooks

update style

update style

rename slug to featureId

show Features page on the side bar only if OPT_IN_FEATURES.length > 0

revert unncessary renaming

revert some changes

remove some changes

* fix repository usage

* test: add unit tests for PBAC utility procedures

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

* feat: add 'disabled by organization' badge for Team Level Features page

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

* a little clean up

* feat: add effectiveReason to computeEffectiveStateAcrossTeams for better UI feedback

- Update computeEffectiveStateAcrossTeams to return both enabled state and reason
- Add EffectiveStateReason type with 6 possible values
- Update FeatureOptInService to include effectiveReason in resolved state
- Simplify useUserFeatureOptIn.getBlockedWarning to use effectiveReason directly
- Add feature_no_explicit_enablement_warning translation
- Invalidate feature list when auto-opt-in setting changes in all hooks

* test: add tests for FeatureOptInService.listFeaturesForTeam and refactor router to use TeamRepository

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

* make it clearer about auto opt in

* move hooks to apps/web/module

* fix

* fix: move types to packages/features/feature-opt-in, fix imports and test mocks

- Move types.ts from apps/web/modules/feature-opt-in/hooks to packages/features/feature-opt-in
- Update FeaturesSettings.tsx to import types from new location
- Update hooks to import types from @calcom/features/feature-opt-in/types
- Fix organization features-view import path (~/settings/organizations -> ~/ee/organizations)
- Fix PermissionCheckService mock in util.test.ts to use class syntax

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

* fix: add validation for team ID and remove isPublic from featureOptIn handler

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

* fix: resolve biome lint errors in feature-opt-in files

- Add explicit return types to functions and methods
- Replace ternary operators with if-else statements
- Replace forEach with for...of loops to avoid useIterableCallbackReturn
- Extract helper functions to reduce function complexity
- Add explicit type annotations to variables
- Move exports to end of file to satisfy useExportsLast rule

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

* fix: resolve type errors in featureOptIn router

- Replace ctx.organizationId with ctx.user.organization.id
- Add null checks for organizationId in org procedures
- Remove incorrect type annotation from featureOptInRouter

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

* refactor: address PR feedback on feature opt-in implementation

- Refactor createMutationCallbacks to useMutationCallbacks hook with useLocale internally
- Restore comment on featureOptIn prop in FeaturesSettings.tsx
- Revert getAllTeamIds helper, use ternary operator instead
- Use ctx.user.organizationId with proper guard clauses

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

* fix type error

* feat: disable toggle when feature is blocked by higher level

- Add isBlockedByHigherLevel function to UseFeatureOptInResult interface
- Implement blocking detection for user level (org/team disabled)
- Implement blocking detection for team level (org disabled)
- Organization level never blocked (top level)
- Update FeaturesSettings to disable ToggleGroup when blocked

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

* change order of toggle buttons

* feat: add policy field (permissive/strict) to feature opt-in config

- Add OptInFeaturePolicy type with 'permissive' and 'strict' modes
- Update computeEffectiveStateAcrossTeams to handle both policies:
  - Permissive: user opt-in can activate; disables only win if ALL teams disable
  - Strict: user opt-in alone not enough; ANY explicit disable blocks
- Add new EffectiveStateReason values for strict policy
- Update FeatureOptInService to read policy from config
- Add unit tests for all 9 policy scenarios from spec

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

* refactor: rewrite test file with scenario tables and make policy required

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

* fix type

* fix types

* make policy required

* disable toggle group if blocked by higher level

* refactor: move PBAC procedures to packages/trpc/server/procedures

- Move util.ts to pbacProcedures.ts in procedures folder for better reusability
- Move util.test.ts to pbacProcedures.test.ts alongside the main file
- Update imports in _router.ts to use new location

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

* fix: remove redundant null checks in featureOptIn router

The createOrgPbacProcedure middleware already validates organizationId
and throws if null. Use non-null assertion (!) instead of redundant
runtime checks since the middleware guarantees the value exists.

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

* refactor: improve middleware typing to avoid non-null assertions

Remove explicit return type from createOrgPbacProcedure to let TypeScript
infer the extended context type. This allows handlers to use ctx.organizationId
directly (typed as number) instead of ctx.user.organizationId! assertions.

The middleware validates organizationId and adds it to the context, so
downstream handlers can safely access ctx.organizationId as a number.

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

* feat(pbac): use featureOptIn permissions instead of generic team/org permissions

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

* feat(pbac): add PBAC to team features page and pass canEdit to views

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

* fix: add effectiveReason to IFeatureOptInService and update listFeaturesForTeam interface

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

* fix: add feature_any_team_disabled to isUserBlockedByHigherLevel check

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

* fix: add strict-policy reasons to warning helper function

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

---------

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>
2026-01-13 15:37:53 +01:00
9ad8aa981a chore: deploy api v2 on vercel (#26735)
* chore: deploy api v2 on vercel

* fix: replace console.log with logger.log in Vercel handler

Address Cubic AI review feedback to use the logging framework
consistently instead of console.log in the serverless handler.

Co-Authored-By: unknown <>

* chore: enable esModuleInterop

* chore: deploy api v2 on vercel

* chore: deploy api v2 on vercel

* Update apps/api/v2/src/bootstrap.ts

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

* fixup! Merge branch 'main' into deploy-api-v2-vercel

* Revert "chore: deploy api v2 on vercel"

This reverts commit 45c704a48e8396c46118069e1a25d8d7a5ee84be.

* chore: deploy api v2 on vercel

* fix: address Cubic AI review feedback in main.ts

- Replace console.log with logger.log for consistent logging
- Replace console.error with logger.error for consistent error logging
- Restore comma: true option in qs.parse to support comma-separated arrays

Co-Authored-By: unknown <>

* fix: remove comma: true from qs.parse to maintain backward compatibility

The main branch does not have comma: true in the query parser, so adding
it would be a breaking change for existing API consumers. Removing it to
maintain consistency with the current production behavior.

Co-Authored-By: unknown <>

* chore: deploy api v2 on vercel

* small fixes

* chore: add try catch around bootstrap.ts

* fix: use NestJS Logger and throw error instead of process.exit in bootstrap

- Replace console.error with logger.error for consistent logging
- Replace process.exit(1) with throw error to avoid breaking Vercel serverless instance reuse

Addresses Cubic AI review feedback (confidence 10/10 for both issues)

Co-Authored-By: unknown <>

* chore: try log redis url

* fix: sanitize REDIS_URL logging to avoid exposing credentials

Replace full REDIS_URL logging with a boolean check that only indicates
whether Redis is configured, without exposing the connection string.

Addresses Cubic AI review feedback (confidence 9/10)

Co-Authored-By: unknown <>

* chore: remove unnecessary logs

* fix: prisma adapter

* chore: handle USE_POOL platform libraries

* fix: use JSON.stringify for Vite define value

Wrap usePool with JSON.stringify() to properly serialize the string value.
Without this, Vite injects the raw value as an identifier instead of a
string literal, breaking runtime behavior.

Addresses Cubic AI review feedback (confidence 9/10)

Co-Authored-By: unknown <>

* fix: docker file builds

* fix: correct Dockerfile build order for platform packages

Reorder builds to match the dependency graph from dev:build script:
constants → enums → utils → types → libraries → trpc → api-v2

platform-libraries depends on the other platform packages, so they
must be built first.

Addresses Cubic AI review feedback (confidence 9/10)

Co-Authored-By: unknown <>

* fix: docker file builds

* chore: add docker build

* chore: upgrade nest/bull

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-01-13 11:31:21 -03:00
Carina WollendorferandCarinaWolli bb9581d0d9 fix: oauth flow for safari extension (companion) (#26795)
* oauth flow for safair extension

* code clean up

* fix type and lint errors

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2026-01-13 19:44:26 +05:30
Hariom BalharaandDevin AI 75d611c2e8 chore: Integrate creation/rescheduling booking audit for Recurring/regular booking/seated bookings (#26046)
* Integrate creation/rescheduling booking audit

* fix: add missing hostUserUuid to booking audit test data

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

* fix-ci

* feat: enhance booking audit with seat reference

- Added support for seat reference in booking audit actions.
- Updated localization for booking creation to include seat information.
- Modified relevant services to pass attendee seat ID during booking creation.

* fix: update test data to match schema requirements

- Add seatReferenceUid: null to default mock audit log data
- Add seatReferenceUid: null to multiple audit logs test case
- Convert SEAT_RESCHEDULED test data to use numeric timestamps instead of ISO strings

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

* Allow nullish seatReferenceUid

* feat: enhance booking audit to support rescheduledBy information

- Updated booking audit actions to include rescheduledBy details, allowing tracking of who rescheduled a booking.
- Refactored related services to accommodate the new rescheduledBy parameter in booking events.
- Adjusted type definitions and function signatures to reflect the changes in the booking audit context.

* Avoid possible run time issue

* Fix imoport path

* fix failing test due to merge from main\

* Pass useruuid

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-13 13:16:16 +00:00
121 changed files with 5485 additions and 1289 deletions
+1 -1
View File
@@ -94,7 +94,7 @@ CRON_ENABLE_APP_SYNC=false
# Application Key for symmetric encryption and decryption
# must be 32 bytes for AES256 encryption algorithm
# You can use: `openssl rand -base64 24` to generate one
# You can use: `openssl rand -base64 24` to generate a 32-character key
CALENDSO_ENCRYPTION_KEY=
# Intercom Config
+2 -1
View File
@@ -13,7 +13,8 @@ Development
- Install dependencies: `yarn`
- Set up environment:
- Copy `.env.example` to `.env`
- Generate keys with `openssl rand -base64 32` for both `NEXTAUTH_SECRET` and `CALENDSO_ENCRYPTION_KEY`
- Generate NEXTAUTH_SECRET with `openssl rand -base64 32`
- Generate CALENDSO_ENCRYPTION_KEY with `openssl rand -base64 24` (must be 32 characters for AES256)
- Configure Postgres database URL in `.env`
- Set DATABASE_DIRECT_URL to the same value as DATABASE_URL
+2 -1
View File
@@ -12,12 +12,13 @@ ENV NODE_ENV="production"
ENV NODE_OPTIONS="--max-old-space-size=8192"
ENV DATABASE_DIRECT_URL=${DATABASE_DIRECT_URL}
ENV DATABASE_URL=${DATABASE_URL}
ENV USE_POOL="true"
COPY . .
RUN yarn install
RUN yarn workspace @calcom/api-v2 run generate-schemas
RUN yarn workspace @calcom/platform-libraries run build
RUN yarn workspace @calcom/api-v2 run build:docker
RUN yarn workspace @calcom/api-v2 run build
EXPOSE 80
+3 -2
View File
@@ -6,7 +6,8 @@
"license": "UNLICENSED",
"private": true,
"scripts": {
"build": "yarn dev:build && nest build",
"build": "nest build",
"build:docker": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-enums build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build && yarn workspace @calcom/platform-libraries build && yarn workspace @calcom/trpc build:server",
"format": "biome format --write src test",
"start": "nest start",
"dev:build:watch": "concurrently --names \"libraries,lru-fix,constants,enums,utils,types\" \"yarn _dev:build:watch:libraries\" \"yarn _dev:build:watch:libraries:lru-fix\" \"yarn _dev:build:watch:constants\" \"yarn _dev:build:watch:enums\" \"yarn _dev:build:watch:utils\" \"yarn _dev:build:watch:types\"",
@@ -47,7 +48,7 @@
"@microsoft/microsoft-graph-types-beta": "0.42.0-preview",
"@nest-lab/throttler-storage-redis": "1.0.0",
"@nestjs/axios": "4.0.0",
"@nestjs/bull": "10.1.1",
"@nestjs/bull": "11.0.4",
"@nestjs/common": "10.4.20",
"@nestjs/config": "3.2.0",
"@nestjs/core": "10.4.20",
+60 -54
View File
@@ -10,7 +10,7 @@ import {
X_CAL_SECRET_KEY,
} from "@calcom/platform-constants";
import type { ValidationError } from "@nestjs/common";
import { BadRequestException, ValidationPipe, VersioningType } from "@nestjs/common";
import { BadRequestException, Logger, ValidationPipe, VersioningType } from "@nestjs/common";
import type { NestExpressApplication } from "@nestjs/platform-express";
import cookieParser from "cookie-parser";
import { Request } from "express";
@@ -21,62 +21,68 @@ import { ZodExceptionFilter } from "@/filters/zod-exception.filter";
import { CalendarServiceExceptionFilter } from "./filters/calendar-service-exception.filter";
import { TRPCExceptionFilter } from "./filters/trpc-exception.filter";
const logger: Logger = new Logger("Bootstrap");
export const bootstrap = (app: NestExpressApplication): NestExpressApplication => {
app.enableShutdownHooks();
app.enableVersioning({
type: VersioningType.CUSTOM,
extractor: (request: unknown) => {
const headerVersion = (request as Request)?.headers[CAL_API_VERSION_HEADER] as string | undefined;
if (headerVersion && API_VERSIONS.includes(headerVersion as API_VERSIONS_ENUM)) {
return headerVersion;
}
return VERSION_2024_04_15;
},
defaultVersion: VERSION_2024_04_15,
});
app.use(helmet());
app.enableCors({
origin: "*",
methods: ["GET", "PATCH", "DELETE", "HEAD", "POST", "PUT", "OPTIONS"],
allowedHeaders: [
X_CAL_CLIENT_ID,
X_CAL_SECRET_KEY,
X_CAL_PLATFORM_EMBED,
CAL_API_VERSION_HEADER,
"Accept",
"Authorization",
"Content-Type",
"Origin",
],
maxAge: 86_400,
});
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
validationError: {
target: true,
value: true,
try {
if (!process.env.VERCEL) {
app.enableShutdownHooks();
}
app.enableVersioning({
type: VersioningType.CUSTOM,
extractor: (request: unknown) => {
const headerVersion = (request as Request)?.headers[CAL_API_VERSION_HEADER] as string | undefined;
if (headerVersion && API_VERSIONS.includes(headerVersion as API_VERSIONS_ENUM)) {
return headerVersion;
}
return VERSION_2024_04_15;
},
exceptionFactory(errors: ValidationError[]): BadRequestException {
return new BadRequestException({ errors });
},
})
);
defaultVersion: VERSION_2024_04_15,
});
app.use(helmet());
app.enableCors({
origin: "*",
methods: ["GET", "PATCH", "DELETE", "HEAD", "POST", "PUT", "OPTIONS"],
allowedHeaders: [
X_CAL_CLIENT_ID,
X_CAL_SECRET_KEY,
X_CAL_PLATFORM_EMBED,
CAL_API_VERSION_HEADER,
"Accept",
"Authorization",
"Content-Type",
"Origin",
],
maxAge: 86_400,
});
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
validationError: {
target: true,
value: true,
},
exceptionFactory(errors: ValidationError[]): BadRequestException {
return new BadRequestException({ errors });
},
})
);
// Exception filters, new filters go at the bottom, keep the order
app.useGlobalFilters(new PrismaExceptionFilter());
app.useGlobalFilters(new ZodExceptionFilter());
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalFilters(new TRPCExceptionFilter());
app.useGlobalFilters(new CalendarServiceExceptionFilter());
app.use(cookieParser());
// Exception filters, new filters go at the bottom, keep the order
app.useGlobalFilters(new PrismaExceptionFilter());
app.useGlobalFilters(new ZodExceptionFilter());
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalFilters(new TRPCExceptionFilter());
app.useGlobalFilters(new CalendarServiceExceptionFilter());
if (process?.env?.API_GLOBAL_PREFIX) {
app.setGlobalPrefix(process?.env?.API_GLOBAL_PREFIX);
}
app.use(cookieParser());
if (process?.env?.API_GLOBAL_PREFIX) {
app.setGlobalPrefix(process?.env?.API_GLOBAL_PREFIX);
return app;
} catch (error) {
logger.error("Error starting NestJS app:", error);
throw error;
}
return app;
};
@@ -349,6 +349,7 @@ export class BookingsController_2024_04_15 {
platformBookingLocation: bookingRequest.platformBookingLocation,
noEmail: bookingRequest.body.noEmail,
},
creationSource: "API_V2",
});
createdBookings.forEach(async (booking) => {
@@ -37,7 +37,6 @@ import { BadRequestException } from "@nestjs/common";
import { Request } from "express";
import { DateTime } from "luxon";
import { z } from "zod";
export const BOOKING_REASSIGN_PERMISSION_ERROR = "You do not have permission to reassign this booking";
import {
@@ -116,7 +115,7 @@ export class BookingsService_2024_08_13 {
private readonly recurringBookingService: RecurringBookingService,
private readonly instantBookingCreateService: InstantBookingCreateService,
private readonly eventTypeAccessService: EventTypeAccessService
) {}
) { }
async createBooking(request: Request, body: CreateBookingInput, authUser: AuthOptionalUser) {
let bookingTeamEventType = false;
@@ -322,8 +321,7 @@ export class BookingsService_2024_08_13 {
const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value);
if (!this.isValidSingleOptionValue(submittedValue, allowedOptionValues)) {
throw new BadRequestException(
`Invalid option '${submittedValue}' for booking field '${
eventTypeBookingField.name
`Invalid option '${submittedValue}' for booking field '${eventTypeBookingField.name
}'. Allowed options are: ${allowedOptionValues.join(", ")}.`
);
}
@@ -337,8 +335,7 @@ export class BookingsService_2024_08_13 {
const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value);
if (!this.areValidMultipleOptionValues(submittedValues, allowedOptionValues)) {
throw new BadRequestException(
`One or more invalid options for booking field '${
eventTypeBookingField.name
`One or more invalid options for booking field '${eventTypeBookingField.name
}'. Allowed options are: ${allowedOptionValues.join(", ")}.`
);
}
@@ -352,8 +349,7 @@ export class BookingsService_2024_08_13 {
const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value);
if (!this.areValidMultipleOptionValues(submittedValues, allowedOptionValues)) {
throw new BadRequestException(
`One or more invalid options for booking field '${
eventTypeBookingField.name
`One or more invalid options for booking field '${eventTypeBookingField.name
}'. Allowed options are: ${allowedOptionValues.join(", ")}.`
);
}
@@ -368,8 +364,7 @@ export class BookingsService_2024_08_13 {
const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value);
if (!this.isValidSingleOptionValue(submittedValue, allowedOptionValues)) {
throw new BadRequestException(
`Invalid option '${submittedValue}' for booking field '${
eventTypeBookingField.name
`Invalid option '${submittedValue}' for booking field '${eventTypeBookingField.name
}'. Allowed options are: ${allowedOptionValues.join(", ")}.`
);
}
@@ -465,6 +460,7 @@ export class BookingsService_2024_08_13 {
noEmail: bookingRequest.noEmail,
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
},
creationSource: "API_V2",
});
const ids = bookings.map((booking) => booking.id || 0);
return this.outputService.getOutputRecurringBookings(ids);
@@ -489,6 +485,7 @@ export class BookingsService_2024_08_13 {
platformBookingLocation: bookingRequest.platformBookingLocation,
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
},
creationSource: "API_V2",
});
return this.outputService.getOutputCreateRecurringSeatedBookings(
bookings.map((booking) => ({ uid: booking.uid || "", seatUid: booking.seatReferenceUid || "" })),
@@ -65,6 +65,7 @@ export class EventTypesRepository_2024_06_14 {
id: true,
name: true,
username: true,
isPlatformManaged: true,
avatarUrl: true,
brandColor: true,
darkBrandColor: true,
@@ -82,6 +82,58 @@ describe("OutputEventTypesService_2024_06_14", () => {
expect(result).toBe("https://acme.cal.com/owner1/30min");
});
it("should use cal.com for non-managed users in platform orgs", () => {
const user = {
id: 1,
name: "Dhairyashil Shinde",
username: "dhairyashil10101010-gmail-com",
avatarUrl: null,
brandColor: null,
darkBrandColor: null,
weekStart: "Monday",
metadata: {},
organizationId: 1,
organization: { slug: "gmail-platform-9041df0e" },
movedToProfile: null,
profiles: [
{
id: 100,
username: "dhairyashil",
organizationId: 1,
organization: { id: 1, slug: "gmail-platform-9041df0e", isPlatform: true },
},
],
};
const slug = "secret";
const result = service.buildBookingUrl(user, slug);
expect(result).toBe("https://cal.com/dhairyashil/secret");
});
it("should return empty string for managed users", () => {
const user = {
id: 1,
name: "Managed User",
username: "managed-user-abc123",
isPlatformManaged: true,
avatarUrl: null,
brandColor: null,
darkBrandColor: null,
weekStart: "Monday",
metadata: {},
organizationId: 1,
organization: { slug: "platform-org" },
movedToProfile: null,
profiles: [],
};
const slug = "30min";
const result = service.buildBookingUrl(user, slug);
expect(result).toBe("");
});
it("should fall back to user username when profile has no username", () => {
const user = {
id: 1,
@@ -47,13 +47,14 @@ type EventTypeUser = {
id: number;
name: string | null;
username: string | null;
isPlatformManaged?: boolean;
avatarUrl: string | null;
brandColor: string | null;
darkBrandColor: string | null;
weekStart: string;
metadata: Prisma.JsonValue;
organizationId: number | null;
organization?: { slug: string | null } | null;
organization?: { slug: string | null; isPlatform?: boolean } | null;
movedToProfile?: ProfileMinimal | null;
profiles?: ProfileMinimal[];
};
@@ -441,6 +442,11 @@ export class OutputEventTypesService_2024_06_14 {
return "";
}
// Managed users don't have public booking pages
if (user.isPlatformManaged) {
return "";
}
const profile = this.usersService.getUserMainProfile(user);
const username = profile?.username ?? user.username;
@@ -448,7 +454,9 @@ export class OutputEventTypesService_2024_06_14 {
return "";
}
const orgSlug = profile?.organization?.slug ?? null;
const org = profile?.organization;
// Don't use org subdomain for platform organizations - they don't have public-facing subdomains
const orgSlug = org && !org.isPlatform ? org.slug : null;
const webAppUrl = this.configService.get<string>("app.baseUrl", "https://app.cal.com");
const baseUrl = orgSlug ? getBookerBaseUrlSync(orgSlug) : webAppUrl;
const normalizedBaseUrl = baseUrl.replace(/\/$/, "");
@@ -4,18 +4,34 @@ import { Injectable } from "@nestjs/common";
import type { WeekDay } from "@calcom/platform-types";
import type { Availability, Schedule } from "@calcom/prisma/client";
type DatabaseSchedule = Schedule & { availability: Availability[] };
@Injectable()
export class OutputSchedulesService_2024_06_11 {
constructor(private readonly usersRepository: UsersRepository) {}
async getResponseSchedule(databaseSchedule: Schedule & { availability: Availability[] }) {
if (!databaseSchedule.timeZone) {
databaseSchedule.timeZone = "Europe/London";
}
async getResponseSchedules(databaseSchedules: DatabaseSchedule[]) {
if (databaseSchedules.length === 0) return [];
const userIds = [...new Set(databaseSchedules.map((schedule) => schedule.userId))];
const defaultScheduleIds = await this.usersRepository.getUsersScheduleDefaultIds(userIds);
return databaseSchedules.map((schedule) =>
this.transformScheduleToOutput(schedule, defaultScheduleIds.get(schedule.userId) ?? null)
);
}
async getResponseSchedule(databaseSchedule: DatabaseSchedule) {
const ownerDefaultScheduleId = await this.usersRepository.getUserScheduleDefaultId(
databaseSchedule.userId
);
return this.transformScheduleToOutput(databaseSchedule, ownerDefaultScheduleId);
}
private transformScheduleToOutput(
databaseSchedule: DatabaseSchedule,
ownerDefaultScheduleId: number | null
) {
const timeZone = databaseSchedule.timeZone || "Europe/London";
const createdScheduleAvailabilities = databaseSchedule.availability.filter(
(availability) => !!availability.days.length
@@ -26,7 +42,7 @@ export class OutputSchedulesService_2024_06_11 {
id: databaseSchedule.id,
ownerId: databaseSchedule.userId,
name: databaseSchedule.name,
timeZone: databaseSchedule.timeZone,
timeZone,
availability: createdScheduleAvailabilities.map((availability) => ({
days: availability.days.map(transformNumberToDay),
startTime: this.padHoursMinutesWithZeros(
@@ -54,13 +70,9 @@ export class OutputSchedulesService_2024_06_11 {
};
}
padHoursMinutesWithZeros(hhMM: string) {
private padHoursMinutesWithZeros(hhMM: string) {
const [hours, minutes] = hhMM.split(":");
const formattedHours = hours.padStart(2, "0");
const formattedMinutes = minutes.padStart(2, "0");
return `${formattedHours}:${formattedMinutes}`;
return `${hours.padStart(2, "0")}:${minutes.padStart(2, "0")}`;
}
}
@@ -1,10 +1,30 @@
import { RegularBookingModule } from "@/lib/modules/regular-booking.module";
import { RecurringBookingService } from "@/lib/services/recurring-booking.service";
import { Module } from "@nestjs/common";
import { BookingEventHandlerService } from "@/lib/services/booking-event-handler.service";
import { Logger } from "@/lib/logger.bridge";
import { Scope } from "@nestjs/common";
import { BookingAuditProducerService } from "@/lib/services/booking-audit-producer.service";
import { HashedLinkService } from "@/lib/services/hashed-link.service";
import { TaskerService } from "@/lib/services/tasker.service";
@Module({
imports: [RegularBookingModule],
providers: [RecurringBookingService],
providers: [
RecurringBookingService,
BookingEventHandlerService,
/** Required by BookingEventHandlerService - Starts **/
HashedLinkService,
{
provide: Logger,
useFactory: () => {
return new Logger();
},
scope: Scope.TRANSIENT,
},
BookingAuditProducerService,
TaskerService,
/** Required by BookingEventHandlerService - Ends **/
],
exports: [RecurringBookingService],
})
export class RecurringBookingModule {}
export class RecurringBookingModule { }
@@ -1,13 +1,18 @@
import { RegularBookingService } from "@/lib/services/regular-booking.service";
import { BookingEventHandlerService } from "@/lib/services/booking-event-handler.service";
import { Injectable } from "@nestjs/common";
import { RecurringBookingService as BaseRecurringBookingService } from "@calcom/platform-libraries/bookings";
@Injectable()
export class RecurringBookingService extends BaseRecurringBookingService {
constructor(regularBookingService: RegularBookingService) {
constructor(
regularBookingService: RegularBookingService,
bookingEventHandler: BookingEventHandlerService
) {
super({
regularBookingService,
bookingEventHandler,
});
}
}
+76 -11
View File
@@ -1,26 +1,64 @@
import "dotenv/config";
import { IncomingMessage, Server, ServerResponse } from "node:http";
import { Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { NestFactory } from "@nestjs/core";
import type { NestExpressApplication } from "@nestjs/platform-express";
import type { Express, Request, Response } from "express";
import { WinstonModule } from "nest-winston";
import qs from "qs";
import type { AppConfig } from "@/config/type";
import { AppModule } from "./app.module";
import { bootstrap } from "./bootstrap";
import { loggerConfig } from "./lib/logger";
run().catch((error: Error) => {
console.error("Failed to start Cal Platform API", { error: error.stack });
process.exit(1);
});
const logger: Logger = new Logger("App");
/**
* Singleton Class to manage the NestJS App instance.
* Ensures we only initialize the app once per container lifecycle.
*/
class NestServer {
private static server: Express; // The underlying Express instance
private constructor() {}
/**
* Returns the cached server instance.
* If it doesn't exist, it creates, bootstraps, and initializes it.
*/
public static async getInstance(): Promise<Express> {
if (!NestServer.server) {
const app = await createNestApp();
// Execute bootstrap (Pipes, Interceptors, CORS, etc.)
bootstrap(app);
// Initialize the app (connects to DB, resolves modules)
await app.init();
// extract the Express instance to pass to Vercel
NestServer.server = app.getHttpAdapter().getInstance();
}
return NestServer.server;
}
}
// -----------------------------------------------------------------------------
// LOCAL DEVELOPMENT STARTUP
// -----------------------------------------------------------------------------
if (!process.env.VERCEL) {
run().catch((error: Error) => {
logger.error("Failed to start Cal Platform API", { error: error.stack });
process.exit(1);
});
}
async function run(): Promise<void> {
const app = await createNestApp();
const logger = new Logger("App");
try {
bootstrap(app);
const config = app.get(ConfigService<AppConfig, true>);
@@ -32,23 +70,50 @@ async function run(): Promise<void> {
}
await app.listen(port);
logger.log(`Application started on port: ${port}`);
logger.log(`Application started locally on port: ${port}`);
} catch (error) {
console.error(error);
logger.error("Application crashed", {
error,
});
logger.error("Application crashed during local startup", { error });
}
}
// -----------------------------------------------------------------------------
// VERCEL SERVERLESS HANDLER
// -----------------------------------------------------------------------------
export default async (req: Request, res: Response): Promise<void> => {
try {
const server = await NestServer.getInstance();
// Vercel/AWS specific: Re-parse query strings to support array formats
// (e.g., ?ids[]=1&ids[]=2) which Vercel's native parser might simplify.
if (req.url) {
const [_path, queryString] = req.url.split("?");
if (queryString) {
req.query = qs.parse(queryString, { arrayLimit: 1000 });
}
}
// Delegate request to the cached Express instance
return server(req, res);
} catch (error) {
logger.error("Critical: Failed to initialize NestJS Serverless instance", error);
res.statusCode = 500;
res.end("Internal Server Error: Initialization Failed");
}
};
// -----------------------------------------------------------------------------
// APP FACTORY
// -----------------------------------------------------------------------------
export async function createNestApp(): Promise<
NestExpressApplication<Server<typeof IncomingMessage, typeof ServerResponse>>
> {
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
logger: WinstonModule.createLogger(loggerConfig()),
// Preserved as requested:
bodyParser: false,
});
// Custom query parser configuration for the underlying Express app
app.set("query parser", (str: string) => qs.parse(str, { arrayLimit: 1000 }));
return app;
@@ -3,8 +3,6 @@ import { OutputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024
import { UsersRepository } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import { ScheduleOutput_2024_06_11 } from "@calcom/platform-types";
@Injectable()
export class OrganizationsSchedulesService {
constructor(
@@ -19,12 +17,6 @@ export class OrganizationsSchedulesService {
const schedules = await this.schedulesRepository.getSchedulesByUserIds(usersIds, skip, take);
const responseSchedules: ScheduleOutput_2024_06_11[] = [];
for (const schedule of schedules) {
responseSchedules.push(await this.outputSchedulesService.getResponseSchedule(schedule));
}
return responseSchedules;
return this.outputSchedulesService.getResponseSchedules(schedules);
}
}
@@ -54,8 +54,9 @@ export class PrismaReadService implements OnModuleInit, OnModuleDestroy {
const adapter = new PrismaPg(this.pool);
this.prisma = new PrismaClient({ adapter });
} else {
const adapter = new PrismaPg({ connectionString: dbUrl });
this.prisma = new PrismaClient({
datasourceUrl: dbUrl,
adapter,
});
}
}
@@ -60,8 +60,9 @@ export class PrismaWriteService implements OnModuleInit, OnModuleDestroy {
const adapter = new PrismaPg(this.pool);
this.prisma = new PrismaClient({ adapter });
} else {
const adapter = new PrismaPg({ connectionString: dbUrl });
this.prisma = new PrismaClient({
datasourceUrl: dbUrl,
adapter,
});
}
}
@@ -3,8 +3,6 @@ import { OutputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
import { Injectable } from "@nestjs/common";
import { ScheduleOutput_2024_06_11 } from "@calcom/platform-types";
@Injectable()
export class TeamsSchedulesService {
constructor(
@@ -17,12 +15,6 @@ export class TeamsSchedulesService {
const userIds = await this.teamsRepository.getTeamUsersIds(teamId);
const schedules = await this.schedulesRepository.getSchedulesByUserIds(userIds, skip, take);
const responseSchedules: ScheduleOutput_2024_06_11[] = [];
for (const schedule of schedules) {
responseSchedules.push(await this.outputSchedulesService.getResponseSchedule(schedule));
}
return responseSchedules;
return this.outputSchedulesService.getResponseSchedules(schedules);
}
}
@@ -22,8 +22,6 @@ export class UsersRepository {
oAuthClientId: string,
isPlatformManaged: boolean
) {
this.formatInput(user);
return this.dbWrite.prisma.user.create({
data: {
...user,
@@ -227,8 +225,6 @@ export class UsersRepository {
}
async update(userId: number, updateData: UpdateManagedUserInput) {
this.formatInput(updateData);
return this.dbWrite.prisma.user.update({
where: { id: userId },
data: updateData,
@@ -257,12 +253,6 @@ export class UsersRepository {
});
}
formatInput(userInput: CreateManagedUserInput | UpdateManagedUserInput) {
if (userInput.weekStart) {
userInput.weekStart = userInput.weekStart;
}
}
setDefaultSchedule(userId: number, scheduleId: number) {
return this.dbWrite.prisma.user.update({
where: { id: userId },
@@ -280,6 +270,14 @@ export class UsersRepository {
return user?.defaultScheduleId;
}
async getUsersScheduleDefaultIds(userIds: number[]): Promise<Map<number, number | null>> {
const users = await this.dbRead.prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, defaultScheduleId: true },
});
return new Map(users.map((user) => [user.id, user.defaultScheduleId]));
}
async getOrganizationUsers(organizationId: number) {
const profiles = await this.dbRead.prisma.profile.findMany({
where: {
@@ -11,6 +11,11 @@ import React, { useEffect, useState, useMemo } from "react";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import type { OrganizationBranding } from "@calcom/features/ee/organizations/context/provider";
import {
HAS_ORG_OPT_IN_FEATURES,
HAS_TEAM_OPT_IN_FEATURES,
HAS_USER_OPT_IN_FEATURES,
} from "@calcom/features/feature-opt-in/config";
import type { TeamFeatures } from "@calcom/features/flags/config";
import { useIsFeatureEnabledForTeam } from "@calcom/features/flags/hooks/useIsFeatureEnabledForTeam";
import { HOSTED_CAL_FEATURES, IS_CALCOM, WEBAPP_URL } from "@calcom/lib/constants";
@@ -73,6 +78,15 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
href: "/settings/my-account/push-notifications",
trackingMetadata: { section: "my_account", page: "push_notifications" },
},
...(HAS_USER_OPT_IN_FEATURES
? [
{
name: "features",
href: "/settings/my-account/features",
trackingMetadata: { section: "my_account", page: "features" },
},
]
: []),
// TODO
// { name: "referrals", href: "/settings/my-account/referrals" },
],
@@ -190,6 +204,15 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
isExternalLink: true,
trackingMetadata: { section: "organization", page: "admin_api" },
},
...(HAS_ORG_OPT_IN_FEATURES
? [
{
name: "features",
href: "/settings/organizations/features",
trackingMetadata: { section: "organization", page: "features" },
},
]
: []),
],
},
{
@@ -637,6 +660,16 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
className="px-2! me-5 h-7 w-auto"
disableChevron
/>
{HAS_TEAM_OPT_IN_FEATURES && (
<VerticalTabItem
name={t("features")}
href={`/settings/teams/${team.id}/features`}
textClassNames="px-3 text-emphasis font-medium text-sm"
trackingMetadata={{ section: "team", page: "features", teamId: team.id }}
className="px-2! me-5 h-7 w-auto"
disableChevron
/>
)}
{/* Hide if there is a parent ID */}
{!team.parentId ? (
<>
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import type { ReactElement } from "react";
import { _generateMetadata } from "app/_utils";
import FeaturesView from "~/settings/my-account/features-view";
const generateMetadata = async (): Promise<Metadata> =>
await _generateMetadata(
(t) => t("features"),
(t) => t("feature_opt_in_description"),
undefined,
undefined,
"/settings/my-account/features"
);
const Page = (): ReactElement => {
return <FeaturesView />;
};
export { generateMetadata };
export default Page;
@@ -0,0 +1,48 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { _generateMetadata } from "app/_utils";
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import { MembershipRole } from "@calcom/prisma/enums";
import OrganizationFeaturesView from "~/ee/organizations/features-view";
import { validateUserHasOrg } from "../../actions/validateUserHasOrg";
export const generateMetadata = async (): Promise<Metadata> =>
await _generateMetadata(
(t) => t("features"),
(t) => t("feature_opt_in_org_description"),
undefined,
undefined,
"/settings/organizations/features"
);
const Page = async () => {
const session = await validateUserHasOrg();
const { canRead, canEdit } = await getResourcePermissions({
userId: session.user.id,
teamId: session.user.profile.organizationId,
resource: Resource.FeatureOptIn,
userRole: session.user.org?.role,
fallbackRoles: {
read: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
update: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
if (!canRead) {
return redirect("/settings/organizations/profile");
}
return <OrganizationFeaturesView canEdit={canEdit} />;
};
export default Page;
@@ -0,0 +1,79 @@
import type { Metadata } from "next";
import { cookies, headers } from "next/headers";
import { notFound, redirect } from "next/navigation";
import { _generateMetadata } from "app/_utils";
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 { MembershipRole } from "@calcom/prisma/enums";
import prisma from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import TeamFeaturesView from "~/settings/teams/[id]/features-view";
type PageParams = { params: Promise<{ id: string }> };
export const generateMetadata = async ({ params }: PageParams): Promise<Metadata> =>
await _generateMetadata(
(t) => t("features"),
(t) => t("feature_opt_in_team_description"),
undefined,
undefined,
`/settings/teams/${(await params).id}/features`
);
const Page = async ({ params }: PageParams) => {
const { id } = await params;
const teamId = Number(id);
if (Number.isNaN(teamId)) {
return notFound();
}
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
return notFound();
}
const membership = await prisma.membership.findFirst({
where: {
teamId,
userId: session.user.id,
accepted: true,
},
select: {
role: true,
},
});
if (!membership) {
return notFound();
}
const { canRead, canEdit } = await getResourcePermissions({
userId: session.user.id,
teamId,
resource: Resource.FeatureOptIn,
userRole: membership.role,
fallbackRoles: {
read: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
update: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
if (!canRead) {
return redirect(`/settings/teams/${teamId}/profile`);
}
return <TeamFeaturesView teamId={teamId} canEdit={canEdit} />;
};
export default Page;
@@ -120,6 +120,10 @@ export const AvailabilitySettingsWebWrapper = ({
scheduleId && deleteMutation.mutate({ scheduleId });
}}
handleSubmit={async ({ dateOverrides, ...values }) => {
if (!values.name.trim()) {
showToast(t("schedule_name_cannot_be_empty"), "error");
return;
}
scheduleId &&
updateMutation.mutate({
scheduleId,
@@ -0,0 +1,29 @@
"use client";
import type { ReactElement } from "react";
import { FeaturesSettings } from "@calcom/features/feature-opt-in/components/FeaturesSettings";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useOrganizationFeatureOptIn } from "~/feature-opt-in/hooks";
interface OrganizationFeaturesViewProps {
canEdit: boolean;
}
const OrganizationFeaturesView = ({ canEdit }: OrganizationFeaturesViewProps): ReactElement => {
const { t } = useLocale();
const featureOptIn = useOrganizationFeatureOptIn();
return (
<SettingsHeader
title={t("features")}
description={t("feature_opt_in_org_description")}
borderInShellHeader={true}>
<FeaturesSettings featureOptIn={featureOptIn} canEdit={canEdit} />
</SettingsHeader>
);
};
export default OrganizationFeaturesView;
@@ -0,0 +1,4 @@
export { useUserFeatureOptIn } from "./useUserFeatureOptIn";
export { useTeamFeatureOptIn } from "./useTeamFeatureOptIn";
export { useOrganizationFeatureOptIn } from "./useOrganizationFeatureOptIn";
export type { UseFeatureOptInResult, NormalizedFeature, ToggleLabels } from "@calcom/features/feature-opt-in/types";
@@ -0,0 +1,90 @@
"use client";
import { useCallback, useMemo } from "react";
import type { NormalizedFeature, UseFeatureOptInResult } from "@calcom/features/feature-opt-in/types";
import type { FeatureState } from "@calcom/features/flags/config";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { showToast } from "@calcom/ui/components/toast";
function useMutationCallbacks(onSuccessCallback: () => void): { onSuccess: () => void; onError: () => void } {
const { t } = useLocale();
return useMemo(
() => ({
onSuccess: (): void => {
onSuccessCallback();
showToast(t("settings_updated_successfully"), "success");
},
onError: (): void => {
showToast(t("error_updating_settings"), "error");
},
}),
[onSuccessCallback, t]
);
}
function normalizeFeatures(
data: Array<{ featureId: string; globalEnabled: boolean; teamState: FeatureState }> | undefined
): NormalizedFeature[] {
return (data ?? []).map((feature) => ({
slug: feature.featureId,
globalEnabled: feature.globalEnabled,
currentState: feature.teamState,
}));
}
function getOrgBlockedWarning(): string | null {
return null;
}
function isOrgBlockedByHigherLevel(): boolean {
return false;
}
/**
* Hook for managing feature opt-in at the organization level.
*/
export function useOrganizationFeatureOptIn(): UseFeatureOptInResult {
const { t } = useLocale();
const utils = trpc.useUtils();
const featuresQuery = trpc.viewer.featureOptIn.listForOrganization.useQuery(undefined, {
refetchOnWindowFocus: false,
});
const autoOptInQuery = trpc.viewer.featureOptIn.getOrganizationAutoOptIn.useQuery(undefined, {
refetchOnWindowFocus: false,
});
const invalidateFeatures = useCallback(() => utils.viewer.featureOptIn.listForOrganization.invalidate(), [utils]);
const invalidateFeaturesAndAutoOptIn = useCallback(() => {
utils.viewer.featureOptIn.getOrganizationAutoOptIn.invalidate();
utils.viewer.featureOptIn.listForOrganization.invalidate();
}, [utils]);
const setStateMutationCallbacks = useMutationCallbacks(invalidateFeatures);
const setAutoOptInMutationCallbacks = useMutationCallbacks(invalidateFeaturesAndAutoOptIn);
const setStateMutation = trpc.viewer.featureOptIn.setOrganizationState.useMutation(setStateMutationCallbacks);
const setAutoOptInMutation = trpc.viewer.featureOptIn.setOrganizationAutoOptIn.useMutation(
setAutoOptInMutationCallbacks
);
const features = normalizeFeatures(featuresQuery.data);
const setFeatureState = (slug: string, state: FeatureState): void => setStateMutation.mutate({ slug, state });
const setAutoOptIn = (checked: boolean): void => setAutoOptInMutation.mutate({ autoOptIn: checked });
return {
features,
autoOptIn: autoOptInQuery.data?.autoOptIn ?? false,
isLoading: featuresQuery.isLoading || autoOptInQuery.isLoading,
setFeatureState,
setAutoOptIn,
isStateMutationPending: setStateMutation.isPending,
isAutoOptInMutationPending: setAutoOptInMutation.isPending,
toggleLabels: { enabled: t("allow"), disabled: t("block"), inherit: t("let_users_decide") },
autoOptInDescription: t("auto_opt_in_experimental_description_org"),
getBlockedWarning: getOrgBlockedWarning,
isBlockedByHigherLevel: isOrgBlockedByHigherLevel,
};
}
@@ -0,0 +1,114 @@
"use client";
import type { TFunction } from "i18next";
import { useCallback, useMemo } from "react";
import type { NormalizedFeature, UseFeatureOptInResult } from "@calcom/features/feature-opt-in/types";
import type { FeatureState } from "@calcom/features/flags/config";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { showToast } from "@calcom/ui/components/toast";
type FeatureBlockingState = { orgState?: FeatureState };
type TeamFeatureData = { featureId: string; globalEnabled: boolean; teamState: FeatureState; orgState: FeatureState };
function useMutationCallbacks(onSuccessCallback: () => void): { onSuccess: () => void; onError: () => void } {
const { t } = useLocale();
return useMemo(
() => ({
onSuccess: (): void => {
onSuccessCallback();
showToast(t("settings_updated_successfully"), "success");
},
onError: (): void => {
showToast(t("error_updating_settings"), "error");
},
}),
[onSuccessCallback, t]
);
}
function normalizeTeamFeatures(data: TeamFeatureData[] | undefined): NormalizedFeature[] {
return (data ?? []).map((feature) => ({
slug: feature.featureId,
globalEnabled: feature.globalEnabled,
currentState: feature.teamState,
}));
}
function buildBlockingStateMap(data: TeamFeatureData[] | undefined): Map<string, FeatureBlockingState> {
const map = new Map<string, FeatureBlockingState>();
for (const feature of data ?? []) {
map.set(feature.featureId, { orgState: feature.orgState });
}
return map;
}
function createTeamBlockedWarningFn(
blockingStateMap: Map<string, FeatureBlockingState>,
t: TFunction
): (feature: NormalizedFeature) => string | null {
return (feature: NormalizedFeature): string | null => {
const blockingState = blockingStateMap.get(feature.slug);
if (blockingState?.orgState === "disabled") {
return t("feature_blocked_by_org_warning");
}
return null;
};
}
function createTeamIsBlockedByHigherLevelFn(
blockingStateMap: Map<string, FeatureBlockingState>
): (feature: NormalizedFeature) => boolean {
return (feature: NormalizedFeature): boolean => {
const blockingState = blockingStateMap.get(feature.slug);
return blockingState?.orgState === "disabled";
};
}
/**
* Hook for managing feature opt-in at the team level.
*/
export function useTeamFeatureOptIn(teamId: number): UseFeatureOptInResult {
const { t } = useLocale();
const utils = trpc.useUtils();
const featuresQuery = trpc.viewer.featureOptIn.listForTeam.useQuery({ teamId }, { refetchOnWindowFocus: false });
const autoOptInQuery = trpc.viewer.featureOptIn.getTeamAutoOptIn.useQuery({ teamId }, { refetchOnWindowFocus: false });
const invalidateFeatures = useCallback(
() => utils.viewer.featureOptIn.listForTeam.invalidate({ teamId }),
[utils, teamId]
);
const invalidateFeaturesAndAutoOptIn = useCallback(() => {
utils.viewer.featureOptIn.getTeamAutoOptIn.invalidate({ teamId });
utils.viewer.featureOptIn.listForTeam.invalidate({ teamId });
}, [utils, teamId]);
const setStateMutationCallbacks = useMutationCallbacks(invalidateFeatures);
const setAutoOptInMutationCallbacks = useMutationCallbacks(invalidateFeaturesAndAutoOptIn);
const setStateMutation = trpc.viewer.featureOptIn.setTeamState.useMutation(setStateMutationCallbacks);
const setAutoOptInMutation = trpc.viewer.featureOptIn.setTeamAutoOptIn.useMutation(setAutoOptInMutationCallbacks);
const featureBlockingState = useMemo(() => buildBlockingStateMap(featuresQuery.data), [featuresQuery.data]);
const features = normalizeTeamFeatures(featuresQuery.data);
const setFeatureState = (slug: string, state: FeatureState): void => setStateMutation.mutate({ teamId, slug, state });
const setAutoOptIn = (checked: boolean): void => setAutoOptInMutation.mutate({ teamId, autoOptIn: checked });
const getBlockedWarning = createTeamBlockedWarningFn(featureBlockingState, t);
const isBlockedByHigherLevel = createTeamIsBlockedByHigherLevelFn(featureBlockingState);
return {
features,
autoOptIn: autoOptInQuery.data?.autoOptIn ?? false,
isLoading: featuresQuery.isLoading || autoOptInQuery.isLoading,
setFeatureState,
setAutoOptIn,
isStateMutationPending: setStateMutation.isPending,
isAutoOptInMutationPending: setAutoOptInMutation.isPending,
toggleLabels: { enabled: t("allow"), disabled: t("block"), inherit: t("let_users_decide") },
autoOptInDescription: t("auto_opt_in_experimental_description_team"),
getBlockedWarning,
isBlockedByHigherLevel,
};
}
@@ -0,0 +1,112 @@
"use client";
import type { TFunction } from "i18next";
import { useCallback, useMemo } from "react";
import type { EffectiveStateReason } from "@calcom/features/feature-opt-in/lib/computeEffectiveState";
import type { NormalizedFeature, UseFeatureOptInResult } from "@calcom/features/feature-opt-in/types";
import type { FeatureState } from "@calcom/features/flags/config";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { showToast } from "@calcom/ui/components/toast";
type UserFeatureData = {
featureId: string;
globalEnabled: boolean;
userState: FeatureState | undefined;
effectiveReason: EffectiveStateReason;
};
function useMutationCallbacks(onSuccessCallback: () => void): { onSuccess: () => void; onError: () => void } {
const { t } = useLocale();
return useMemo(
() => ({
onSuccess: (): void => {
onSuccessCallback();
showToast(t("settings_updated_successfully"), "success");
},
onError: (): void => {
showToast(t("error_updating_settings"), "error");
},
}),
[onSuccessCallback, t]
);
}
function normalizeUserFeatures(data: UserFeatureData[] | undefined): NormalizedFeature[] {
return (data ?? []).map((feature) => ({
slug: feature.featureId,
globalEnabled: feature.globalEnabled,
currentState: feature.userState ?? "inherit",
effectiveReason: feature.effectiveReason,
}));
}
function createUserBlockedWarningFn(t: TFunction): (feature: NormalizedFeature) => string | null {
return (feature: NormalizedFeature): string | null => {
if (!feature.effectiveReason) return null;
switch (feature.effectiveReason) {
case "feature_org_disabled":
return t("feature_blocked_by_org_warning");
case "feature_all_teams_disabled":
case "feature_any_team_disabled":
return t("feature_blocked_by_team_warning");
case "feature_no_explicit_enablement":
case "feature_user_only_not_allowed":
return t("feature_no_explicit_enablement_warning");
default:
return null;
}
};
}
function isUserBlockedByHigherLevel(feature: NormalizedFeature): boolean {
return (
feature.effectiveReason === "feature_org_disabled" ||
feature.effectiveReason === "feature_all_teams_disabled" ||
feature.effectiveReason === "feature_any_team_disabled"
);
}
/**
* Hook for managing feature opt-in at the user (personal) level.
*/
export function useUserFeatureOptIn(): UseFeatureOptInResult {
const { t } = useLocale();
const utils = trpc.useUtils();
const featuresQuery = trpc.viewer.featureOptIn.listForUser.useQuery(undefined, { refetchOnWindowFocus: false });
const autoOptInQuery = trpc.viewer.featureOptIn.getUserAutoOptIn.useQuery(undefined, { refetchOnWindowFocus: false });
const invalidateFeatures = useCallback(() => utils.viewer.featureOptIn.listForUser.invalidate(), [utils]);
const invalidateFeaturesAndAutoOptIn = useCallback(() => {
utils.viewer.featureOptIn.getUserAutoOptIn.invalidate();
utils.viewer.featureOptIn.listForUser.invalidate();
}, [utils]);
const setStateMutationCallbacks = useMutationCallbacks(invalidateFeatures);
const setAutoOptInMutationCallbacks = useMutationCallbacks(invalidateFeaturesAndAutoOptIn);
const setStateMutation = trpc.viewer.featureOptIn.setUserState.useMutation(setStateMutationCallbacks);
const setAutoOptInMutation = trpc.viewer.featureOptIn.setUserAutoOptIn.useMutation(setAutoOptInMutationCallbacks);
const features = useMemo(() => normalizeUserFeatures(featuresQuery.data), [featuresQuery.data]);
const setFeatureState = (slug: string, state: FeatureState): void => setStateMutation.mutate({ slug, state });
const setAutoOptIn = (checked: boolean): void => setAutoOptInMutation.mutate({ autoOptIn: checked });
const getBlockedWarning = createUserBlockedWarningFn(t);
return {
features,
autoOptIn: autoOptInQuery.data?.autoOptIn ?? false,
isLoading: featuresQuery.isLoading || autoOptInQuery.isLoading,
setFeatureState,
setAutoOptIn,
isStateMutationPending: setStateMutation.isPending,
isAutoOptInMutationPending: setAutoOptInMutation.isPending,
toggleLabels: { enabled: t("feature_on"), disabled: t("feature_off"), inherit: t("use_default") },
autoOptInDescription: t("auto_opt_in_experimental_description_personal"),
getBlockedWarning,
isBlockedByHigherLevel: isUserBlockedByHigherLevel,
};
}
@@ -0,0 +1,25 @@
"use client";
import type { ReactElement } from "react";
import { FeaturesSettings } from "@calcom/features/feature-opt-in/components/FeaturesSettings";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useUserFeatureOptIn } from "~/feature-opt-in/hooks";
const FeaturesView = (): ReactElement => {
const { t } = useLocale();
const featureOptIn = useUserFeatureOptIn();
return (
<SettingsHeader
title={t("features")}
description={t("feature_opt_in_description")}
borderInShellHeader={true}>
<FeaturesSettings featureOptIn={featureOptIn} />
</SettingsHeader>
);
};
export default FeaturesView;
@@ -0,0 +1,30 @@
"use client";
import type { ReactElement } from "react";
import { FeaturesSettings } from "@calcom/features/feature-opt-in/components/FeaturesSettings";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useTeamFeatureOptIn } from "~/feature-opt-in/hooks";
interface TeamFeaturesViewProps {
teamId: number;
canEdit: boolean;
}
const TeamFeaturesView = ({ teamId, canEdit }: TeamFeaturesViewProps): ReactElement => {
const { t } = useLocale();
const featureOptIn = useTeamFeatureOptIn(teamId);
return (
<SettingsHeader
title={t("features")}
description={t("feature_opt_in_team_description")}
borderInShellHeader={true}>
<FeaturesSettings featureOptIn={featureOptIn} canEdit={canEdit} />
</SettingsHeader>
);
};
export default TeamFeaturesView;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "6.0.11",
"version": "6.0.12",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+1 -18
View File
@@ -58,24 +58,7 @@ async function handler(req: NextApiRequest & { userId?: number; traceContext: Tr
},
});
// const booking = await createBookingThroughFactory();
return booking;
// To be added in the follow-up PR
// async function createBookingThroughFactory() {
// console.log("Creating booking through factory");
// const regularBookingService = getRegularBookingService();
// const booking = await regularBookingService.createBooking({
// bookingData: req.body,
// bookingMeta: {
// userId: session?.user?.id || -1,
// hostname: req.headers.host || "",
// forcedSlug: req.headers["x-cal-force-slug"] as string | undefined,
// },
// });
// return booking;
// }
}
export default defaultResponder(handler, "/api/book/event");
export default defaultResponder(handler, "/api/book/event");
+2 -2
View File
@@ -1,5 +1,4 @@
import type { NextApiRequest } from "next";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getRecurringBookingService } from "@calcom/features/bookings/di/RecurringBookingService.container";
import type { BookingResponse } from "@calcom/features/bookings/types";
@@ -55,6 +54,7 @@ async function handler(req: NextApiRequest & RequestMeta) {
platformBookingLocation: req.platformBookingLocation,
noEmail: req.noEmail,
},
creationSource: "WEBAPP",
});
return createdBookings;
@@ -62,4 +62,4 @@ async function handler(req: NextApiRequest & RequestMeta) {
export const handleRecurringEventBooking = handler;
export default defaultResponder(handler);
export default defaultResponder(handler);
@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { featureOptInRouter } from "@calcom/trpc/server/routers/viewer/featureOptIn/_router";
export default createNextApiHandler(featureOptInRouter);
+34 -6
View File
@@ -130,6 +130,8 @@
"please_try_again_later_or_book_another_slot": "You've just tried connecting now. Please try again later in {{remaining}} minutes or book another slot from the booking page.",
"unavailable_timeslot_title": "Unavailable timeslot",
"booking_time_out_of_bounds_error": "The event type cannot be booked at this time. Could you try another time slot?",
"bookings_v3_title": "Enhanced bookings",
"bookings_v3_description": "Switch between list and calendar views with details in a slide-over.",
"request_body_end_time_internal_error": "Internal Error. Request body does not contain end time",
"create_calendar_event_error": "Unable to create calendar event in organizer's calendar",
"update_calendar_event_error": "Unable to update calendar event.",
@@ -1106,6 +1108,7 @@
"add_input": "Add an input",
"disable_notes": "Hide notes in calendar",
"disable_notes_description": "For privacy reasons, additional inputs and notes will be hidden in the calendar entry. They will still be sent to your email. <0>Learn more</0>",
"notes_hidden_by_organizer": "Notes have been hidden by the organizer",
"requires_confirmation_description": "The booking needs to be manually confirmed before it is pushed to your calendar and a confirmation is sent. <0>Learn more</0>",
"requires_confirmation_will_block_slot_description": "Unconfirmed bookings still block calendar slots.",
"recurring_event": "Recurring event",
@@ -3321,7 +3324,7 @@
"only_if_field_is_empty": "Only if field is empty",
"booking_start_date": "Booking start date",
"booking_created_date": "Booking created date",
"booking_reassigned_to_host": "Booking reassigned to {{host}}",
"booking_reassigned_to_host": "Reassigned to {{host}}",
"no_contact_owner": "No contact owner",
"routing_forms_created": "Routing forms created",
"routing_forms_total_responses": "Total Responses",
@@ -3718,13 +3721,15 @@
"pbac_action_delete": "Delete",
"pbac_action_manage": "Manage",
"pbac_action_invite": "Invite",
"pbac_action_remove": "Remove",
"pbac_action_remove": "Remove",
"pbac_action_change_member_role": "Change member role",
"pbac_action_list_members": "List members",
"pbac_action_manage_billing": "Manage billing",
"pbac_action_read_team_bookings": "View team bookings",
"pbac_action_read_org_bookings": "View organization bookings",
"pbac_action_read_recordings": "View recordings",
"pbac_action_read_team_booking_audit_logs": "View team booking audit logs",
"pbac_action_read_org_booking_audit_logs": "View organization booking audit logs",
"pbac_action_impersonate": "Impersonate",
"pbac_action_edit_users": "Edit users",
"role_created_successfully": "Role created successfully",
@@ -3795,6 +3800,8 @@
"pbac_desc_view_team_bookings": "View team bookings",
"pbac_desc_view_organization_bookings": "View organization bookings",
"pbac_desc_view_booking_recordings": "View booking recordings",
"pbac_desc_read_team_booking_audit_logs": "View audit logs for team bookings",
"pbac_desc_read_org_booking_audit_logs": "View audit logs for organization bookings",
"pbac_desc_update_bookings": "Update bookings",
"pbac_desc_manage_bookings": "All actions on bookings",
"pbac_desc_view_team_insights": "View team insights",
@@ -4004,6 +4011,7 @@
"choose_a_voice_for_your_agent": "Choose a voice for your agent",
"trait": "Trait",
"voice_id": "Voice ID",
"schedule_name_cannot_be_empty": "Schedule name cannot be empty",
"use_voice": "Use voice",
"current_voice": "Current voice",
"setup_incoming_agent": "Set up incoming agent",
@@ -4263,7 +4271,8 @@
"booking_history": "Booking history",
"booking_history_description": "View the history of actions performed on this booking",
"booking_audit_action": {
"created": "Created",
"created": "Booked with {{host}}",
"created_with_seat": "Seat Booked with {{host}}",
"cancelled": "Cancelled",
"rescheduled": "Rescheduled {{oldDate}} -> <0>{{newDate}}</0>",
"rescheduled_from": "Rescheduled <0>{{oldDate}}</0> -> {{newDate}}",
@@ -4278,9 +4287,11 @@
"location_changed": "Location changed",
"location_changed_from_to": "Location changed from {{fromLocation}} to {{toLocation}}",
"attendee_no_show_updated": "Attendee no-show updated",
"type": "Assignment type",
"assignmentType_manual": "Manual assignment",
"assignmentType_roundRobin": "Round robin assignment",
"seat_booked": "Seat booked",
"seat_rescheduled": "Seat rescheduled {{oldDate}} -> <0>{{newDate}}</0>",
"assignment_type": "Assignment",
"assignment_type_manual": "Manual",
"assignment_type_round_robin": "Round robin",
"actor_impersonated_by": "Impersonator",
"source": "Source"
},
@@ -4299,6 +4310,23 @@
"audit_logs_owner_not_in_organization": "The booking owner is not a member of your organization.",
"audit_logs_permission_denied": "You do not have permission to view audit logs for this booking.",
"audit_logs_permission_check_error": "An error occurred while checking permissions.",
"feature_opt_in": "Feature Opt-In",
"feature_opt_in_description": "Manage experimental features for your account",
"feature_opt_in_team_description": "Manage experimental features for your team",
"feature_opt_in_org_description": "Manage experimental features for your organization",
"let_users_decide": "Let users decide",
"use_default": "Use default",
"feature_on": "On",
"feature_off": "Off",
"auto_opt_in_experimental": "Automatically opt-in for future experimental features",
"auto_opt_in_experimental_description_personal": "Automatically opt into new experimental features, unless disabled by your team or organization",
"auto_opt_in_experimental_description_team": "Automatically opt team members into new experimental features, unless configured by the organization or opted out by members",
"auto_opt_in_experimental_description_org": "Automatically opt organization members into new experimental features, unless configured at team or user level",
"feature_blocked_by_team_warning": "This feature is disabled by your team.",
"feature_blocked_by_org_warning": "This feature is disabled by your organization.",
"feature_no_explicit_enablement_warning": "Turn it on for yourself, or ask your team or organization admin to enable it for everyone.",
"no_opt_in_features_available": "No opt-in features are currently available",
"enabled_via_auto_opt_in": "Enabled via auto opt-in",
"account_already_exists_please_login": "An account with this email already exists. Please log in to accept the invitation.",
"unverified_email_oauth_error": "Please verify your email address before signing in with Google or SAML. Sign in with your password to resend the verification email.",
"pbac_resource_feature_opt_in": "Feature Opt-In",
+4 -11
View File
@@ -164,6 +164,7 @@
{
"includes": [
"apps/api/v2/src/config/app.ts",
"apps/api/v2/src/main.ts",
"apps/api/v2/src/bootstrap.ts",
"apps/api/v2/src/config/env.ts",
"apps/api/v2/src/env.ts",
@@ -325,9 +326,7 @@
}
},
{
"includes": [
"packages/platform/atoms/**/*.{ts,tsx,js,jsx,mts,mjs,cjs,cts}"
],
"includes": ["packages/platform/atoms/**/*.{ts,tsx,js,jsx,mts,mjs,cjs,cts}"],
"linter": {
"rules": {
"style": {
@@ -336,17 +335,11 @@
"options": {
"patterns": [
{
"group": [
"@calcom/trpc",
"@calcom/trpc/**"
],
"group": ["@calcom/trpc", "@calcom/trpc/**"],
"message": "atoms package should not import from @calcom/trpc."
},
{
"group": [
"../../trpc",
"../../trpc/**"
],
"group": ["../../trpc", "../../trpc/**"],
"message": "atoms package should not import from trpc."
}
]
+17 -1
View File
@@ -138,8 +138,24 @@ export default function More() {
))}
</View>
{/* Sign Out Button */}
{/* Delete Account Link */}
<View className="mt-6 overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
<TouchableOpacity
onPress={() =>
openInAppBrowser("https://app.cal.com/settings/my-account/profile", "Delete Account")
}
className="flex-row items-center justify-between bg-white px-5 py-4 active:bg-red-50"
>
<View className="flex-1 flex-row items-center">
<Ionicons name="trash-outline" size={20} color="#991B1B" />
<Text className="ml-3 text-base font-medium text-[#991B1B]">Delete Account</Text>
</View>
<Ionicons name="open-outline" size={20} color="#C7C7CC" />
</TouchableOpacity>
</View>
{/* Sign Out Button */}
<View className="mt-4 overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
<TouchableOpacity
onPress={handleSignOut}
className="flex-row items-center justify-center bg-white px-5 py-4 active:bg-red-50"
+17 -1
View File
@@ -112,8 +112,24 @@ export default function More() {
))}
</View>
{/* Sign Out Button */}
{/* Delete Account Link */}
<View className="mt-6 overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
<TouchableOpacity
onPress={() =>
openInAppBrowser("https://app.cal.com/settings/my-account/profile", "Delete Account")
}
className="flex-row items-center justify-between bg-white px-5 py-4 active:bg-red-50"
>
<View className="flex-1 flex-row items-center">
<Ionicons name="trash-outline" size={20} color="#991B1B" />
<Text className="ml-3 text-base font-medium text-[#991B1B]">Delete Account</Text>
</View>
<Ionicons name="open-outline" size={20} color="#C7C7CC" />
</TouchableOpacity>
</View>
{/* Sign Out Button */}
<View className="mt-4 overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
<TouchableOpacity
onPress={handleSignOut}
className="flex-row items-center justify-center bg-white px-5 py-4 active:bg-red-50"
@@ -242,6 +242,15 @@ function getBrowserDisplayName(): string {
* These provide unified access to browser extension APIs across Chrome, Firefox, Safari, and Edge
*/
/**
* Extended browser API interface that includes browserAction (Manifest V2)
* Safari uses browserAction instead of action
*/
interface BrowserAPIWithLegacyAction {
action?: typeof chrome.action;
browserAction?: typeof chrome.action;
}
// Get the appropriate browser API namespace
function getBrowserAPI(): typeof chrome {
if (typeof browser !== "undefined" && browser?.runtime) {
@@ -278,8 +287,8 @@ function getTabsAPI(): typeof chrome.tabs | null {
function getActionAPI(): typeof chrome.action | null {
const api = getBrowserAPI();
// Safari uses browserAction (Manifest V2), Chrome uses action (Manifest V3)
// biome-ignore lint/suspicious/noExplicitAny: Safari's browserAction API is not in Chrome types
return api?.action || (api as any)?.browserAction || null;
const apiWithLegacyAction = api as unknown as BrowserAPIWithLegacyAction;
return api?.action || apiWithLegacyAction?.browserAction || null;
}
// Check if the URL is a restricted page where content scripts can't run
@@ -363,16 +372,24 @@ export default defineBackground(() => {
const authUrl = message.authUrl as string;
const state = new URL(authUrl).searchParams.get("state");
if (state && storageAPI?.local) {
storageAPI.local.set({ oauth_state: state }, () => {
const runtime = getRuntimeAPI();
if (runtime?.lastError) {
devLog.warn("Failed to store OAuth state:", runtime.lastError.message);
}
});
}
// Store state before starting OAuth to prevent race conditions
const storeState = async () => {
if (!state) {
throw new Error("No state parameter in auth URL");
}
if (!storageAPI?.local) {
throw new Error("Storage API not available");
}
try {
await storageAPI.local.set({ oauth_state: state });
} catch (error) {
devLog.error("Failed to store OAuth state:", error);
throw new Error("Failed to initialize OAuth flow - cannot store state");
}
};
handleExtensionOAuth(authUrl)
storeState()
.then(() => handleExtensionOAuth(authUrl))
.then((responseUrl) => sendResponse({ success: true, responseUrl }))
.catch((error) => sendResponse({ success: false, error: error.message }));
return true;
@@ -493,9 +510,15 @@ export default defineBackground(() => {
});
async function handleExtensionOAuth(authUrl: string): Promise<string> {
const identityAPI = getIdentityAPI();
const browserType = detectBrowser();
// Safari uses tabs-based OAuth flow
if (browserType === BrowserType.Safari) {
return handleSafariTabsOAuth(authUrl);
}
const identityAPI = getIdentityAPI();
if (!identityAPI) {
throw new Error(`Identity API not available in ${getBrowserDisplayName()}`);
}
@@ -521,8 +544,8 @@ async function handleExtensionOAuth(authUrl: string): Promise<string> {
}
return new Promise((resolve, reject) => {
// Firefox and Safari use Promise-based API
if (browserType === BrowserType.Firefox || browserType === BrowserType.Safari) {
// Firefox uses Promise-based API
if (browserType === BrowserType.Firefox) {
try {
const result = identityAPI.launchWebAuthFlow({
url: authUrl,
@@ -568,6 +591,168 @@ async function handleExtensionOAuth(authUrl: string): Promise<string> {
});
}
/**
* Handle Safari OAuth using tabs-based flow
* Opens OAuth in a new tab and captures the redirect callback
*/
async function handleSafariTabsOAuth(authUrl: string): Promise<string> {
// Extract the redirect URI to know what to watch for
const redirectUri = new URL(authUrl).searchParams.get("redirect_uri");
if (!redirectUri) {
throw new Error("redirect_uri not found in auth URL");
}
const redirectUrlObj = new URL(redirectUri);
const redirectOrigin = redirectUrlObj.origin; // scheme + host + port
const redirectPath = redirectUrlObj.pathname;
return new Promise((resolve, reject) => {
const tabsAPI = getTabsAPI();
if (!tabsAPI) {
reject(new Error("Tabs API not available"));
return;
}
let oauthTabId: number | null = null;
let isResolved = false;
const timeoutId = setTimeout(
() => {
if (!isResolved) {
cleanup();
reject(new Error("OAuth flow timed out after 5 minutes"));
}
},
5 * 60 * 1000
);
const tabUpdateListener = (tabId: number, _changeInfo: unknown, tab: chrome.tabs.Tab) => {
// Only process updates for our OAuth tab
if (tabId !== oauthTabId) return;
// Check if the tab navigated to the redirect URI
if (tab.url) {
try {
const tabUrl = new URL(tab.url);
// Check if this is our OAuth callback URL - exact origin and path match
if (tabUrl.origin === redirectOrigin && tabUrl.pathname === redirectPath) {
if (!isResolved) {
const error = tabUrl.searchParams.get("error");
if (error) {
isResolved = true;
cleanup();
const errorDescription = tabUrl.searchParams.get("error_description") || error;
// Close the tab after capturing error
if (oauthTabId !== null) {
tabsAPI.remove(oauthTabId).catch(() => {
// Ignore errors when closing tab
});
}
reject(new Error(`OAuth error: ${errorDescription}`));
return;
}
const code = tabUrl.searchParams.get("code");
if (!code) {
// No code yet, keep listening (might be an intermediate redirect)
return;
}
const state = tabUrl.searchParams.get("state");
if (!state) {
isResolved = true;
cleanup();
// Close the tab
if (oauthTabId !== null) {
tabsAPI.remove(oauthTabId).catch(() => {
// Ignore errors when closing tab
});
}
reject(new Error("No state parameter in OAuth callback"));
return;
}
// State validation - only close tab after validation succeeds
isResolved = true;
cleanup();
validateOAuthStateWithoutCleanup(state)
.then(() => {
// State is valid, close the tab and resolve
// Note: State is NOT cleaned up here, token exchange will clean it up
if (oauthTabId !== null) {
tabsAPI.remove(oauthTabId).catch(() => {
// Ignore errors when closing tab
});
}
if (!tab.url) {
reject(new Error("Tab URL is undefined"));
return;
}
resolve(tab.url);
})
.catch((error) => {
// State validation failed, close the tab and reject
if (oauthTabId !== null) {
tabsAPI.remove(oauthTabId).catch(() => {
// Ignore errors when closing tab
});
}
reject(error);
});
}
}
} catch (_error) {
// Invalid URL, ignore
}
}
};
// Listen for tab removal (user closed the OAuth tab)
const tabRemovedListener = (tabId: number) => {
if (tabId === oauthTabId && !isResolved) {
isResolved = true;
cleanup();
reject(new Error("OAuth cancelled by user"));
}
};
const cleanup = () => {
clearTimeout(timeoutId);
tabsAPI.onUpdated.removeListener(tabUpdateListener);
tabsAPI.onRemoved.removeListener(tabRemovedListener);
};
// Register listeners
tabsAPI.onUpdated.addListener(tabUpdateListener);
tabsAPI.onRemoved.addListener(tabRemovedListener);
// Open OAuth URL in a new tab
tabsAPI
.create({
url: authUrl,
active: true,
})
.then((tab) => {
if (tab.id) {
oauthTabId = tab.id;
} else {
cleanup();
reject(new Error("Failed to create OAuth tab"));
}
})
.catch((error) => {
cleanup();
reject(new Error(`Failed to open OAuth tab: ${error.message}`));
});
});
}
async function handleTokenExchange(
tokenRequest: Record<string, string>,
tokenEndpoint: string,
@@ -617,31 +802,61 @@ async function handleTokenExchange(
return tokens;
}
/**
* Validates OAuth state without cleaning it up (for Safari flow)
* Token exchange will clean up the state after successful validation
*/
async function validateOAuthStateWithoutCleanup(state: string): Promise<void> {
const storageAPI = getStorageAPI();
if (!storageAPI?.local) {
// Fail closed: if we can't access storage, we can't validate state
throw new Error("Storage API not available - cannot validate OAuth state");
}
const result = await storageAPI.local.get(["oauth_state"]);
const storedState = result.oauth_state as string | undefined;
if (!storedState) {
throw new Error("No stored OAuth state found - possible CSRF attack");
}
if (storedState !== state) {
devLog.error("State parameter mismatch - possible CSRF attack");
throw new Error("Invalid state parameter - possible CSRF attack");
}
}
async function validateOAuthState(state: string): Promise<void> {
const storageAPI = getStorageAPI();
if (!storageAPI?.local) {
devLog.warn("Storage API not available for state validation");
return;
// Fail closed: if we can't access storage, we can't validate state
throw new Error("Storage API not available - cannot validate OAuth state");
}
try {
const result = await storageAPI.local.get(["oauth_state"]);
const storedState = result.oauth_state as string | undefined;
if (storedState && storedState !== state) {
// Fail closed: state must exist and match exactly
if (!storedState) {
throw new Error("No stored OAuth state found");
}
if (storedState !== state) {
await storageAPI.local.remove("oauth_state");
devLog.error("State parameter mismatch - possible CSRF attack");
throw new Error("Invalid state parameter - possible CSRF attack");
}
if (storedState === state) {
await storageAPI.local.remove("oauth_state");
}
await storageAPI.local.remove("oauth_state");
} catch (error) {
if (error instanceof Error && error.message.includes("Invalid state parameter")) {
throw error;
// Clean up on any error
try {
await storageAPI.local.remove("oauth_state");
} catch {
// Ignore cleanup errors
}
devLog.warn("State validation warning:", error);
throw error;
}
}
+1 -1
View File
@@ -59,7 +59,7 @@ export default defineConfig({
publicDir: "extension/public",
outDir: ".output",
manifest: {
name: "Cal.com Companion",
name: browserTarget === "safari" ? "Cal.com" : "Cal.com Companion",
version: "1.7.5",
description: "Your calendar companion for quick booking and scheduling",
permissions: [
+30 -18
View File
@@ -2936,7 +2936,9 @@
"watchlist.create",
"watchlist.read",
"watchlist.update",
"watchlist.delete"
"watchlist.delete",
"featureOptIn.read",
"featureOptIn.update"
]
}
}
@@ -6659,7 +6661,9 @@
"webhook.create",
"webhook.read",
"webhook.update",
"webhook.delete"
"webhook.delete",
"featureOptIn.read",
"featureOptIn.update"
]
}
}
@@ -29645,7 +29649,9 @@
"watchlist.create",
"watchlist.read",
"watchlist.update",
"watchlist.delete"
"watchlist.delete",
"featureOptIn.read",
"featureOptIn.update"
]
}
},
@@ -29758,7 +29764,9 @@
"watchlist.create",
"watchlist.read",
"watchlist.update",
"watchlist.delete"
"watchlist.delete",
"featureOptIn.read",
"featureOptIn.update"
]
}
},
@@ -29920,7 +29928,9 @@
"watchlist.create",
"watchlist.read",
"watchlist.update",
"watchlist.delete"
"watchlist.delete",
"featureOptIn.read",
"featureOptIn.update"
]
}
},
@@ -30039,7 +30049,9 @@
"watchlist.create",
"watchlist.read",
"watchlist.update",
"watchlist.delete"
"watchlist.delete",
"featureOptIn.read",
"featureOptIn.update"
]
}
}
@@ -30123,7 +30135,9 @@
"webhook.create",
"webhook.read",
"webhook.update",
"webhook.delete"
"webhook.delete",
"featureOptIn.read",
"featureOptIn.update"
]
}
},
@@ -30215,7 +30229,9 @@
"webhook.create",
"webhook.read",
"webhook.update",
"webhook.delete"
"webhook.delete",
"featureOptIn.read",
"featureOptIn.update"
]
}
},
@@ -30356,7 +30372,9 @@
"webhook.create",
"webhook.read",
"webhook.update",
"webhook.delete"
"webhook.delete",
"featureOptIn.read",
"featureOptIn.update"
]
}
},
@@ -30454,7 +30472,9 @@
"webhook.create",
"webhook.read",
"webhook.update",
"webhook.delete"
"webhook.delete",
"featureOptIn.read",
"featureOptIn.update"
]
}
}
@@ -33058,10 +33078,6 @@
"error"
]
},
"message": {
"type": "string",
"example": "This endpoint will require authentication in a future release."
},
"error": {
"type": "object"
},
@@ -33098,10 +33114,6 @@
"type": "string"
}
},
"message": {
"type": "string",
"example": "This endpoint will require authentication in a future release."
},
"error": {
"type": "object"
}
+5 -2
View File
@@ -15,12 +15,15 @@ const isInWatchMode = process.argv[2] === "--watch";
const repoRoot = path.resolve(__dirname, "../../..");
const formatFileWithBiome = (filePath: string) => {
// Normalize to forward slashes for cross-platform Biome compatibility
const normalizedPath = filePath.replace(/\\/g, "/");
const { status } = spawnSync(
"yarn",
["biome", "format", "--write", "--no-errors-on-unmatched", filePath],
["biome", "format", "--write", "--no-errors-on-unmatched", normalizedPath],
{
stdio: "inherit",
cwd: repoRoot,
shell: true,
}
);
@@ -561,7 +564,7 @@ function generateFiles() {
["video.adapters.generated.ts", videoOutput],
];
filesToGenerate.forEach(([fileName, output]) => {
const filePath = `${APP_STORE_PATH}/${fileName}`;
const filePath = path.join(APP_STORE_PATH, fileName);
fs.writeFileSync(filePath, formatOutput(`${banner}${output.join("\n")}`));
formatFileWithBiome(filePath);
});
+14 -2
View File
@@ -1,8 +1,20 @@
## Working With GraphQL
# Creating a Salesforce test org
You must have the [Salesforce CLI](https://developer.salesforce.com/tools/salesforcecli) installed to create a test org. Once installed, you can create a test org using the following command `yarn scratch-org:create`
This will create a scratch org with the configuration specified in the `project-scratch-def.json` file.
To open a browser tab to the org, run `yarn scratch-org:start`
# Working With GraphQL
This package utilizes [`GraphQL Codegen`](https://the-guild.dev/graphql/codegen#graphql-codegen) to generate types and queries from the Salesforce schema.
### Generating GraphQL Schema
Currently v63 of the Salesforce graphql endpoint throws an error when trying to generate files. This is due to the `Setup__JoinInput` type not generating any fields. To work around this, the `schema.json` file comes from the [Salesforce graphql introspection query](https://www.postman.com/salesforce-developers/salesforce-developers/request/sy8qaf9/introspection-query). This file is then converted to a SDL file using the [graphql-introspection-json-to-sdl](https://github.com/Calcom/graphql-introspection-json-to-sdl) package. You can generate the SDL file by running `yarn generate:schema`.
### Generating Queries
When working with graphql files ensure that `yarn codegen:watch` is running in the background. This will generate the types and queries from the SDL file.
When working with graphql files ensure that `yarn codegen:watch` is running in the background. This will generate the types and queries from the SDL file.
# Developing the SFDC package
The SFDC package is written using Apex. To develop this package, you need to have the Salesforce CLI installed. Then you can run `yarn sfdc:deploy:preview` to see what changes will be deployed to the scratch org. Running `yarn sfdc:deploy` will deploy the changes to the scratch org.
Note that if you want to call your local development instances you need to change the "Named Credential" on the scratch org settings to point the `CalCom_Development` credential to the local instance.
@@ -1,2 +1,3 @@
export { default as add } from "./add";
export { default as callback } from "./callback";
export { default as "user-sync" } from "./user-sync";
@@ -0,0 +1,25 @@
import type { NextApiRequest, NextApiResponse } from "next";
import logger from "@calcom/lib/logger";
const log = logger.getSubLogger({ prefix: ["[salesforce/user-sync]"] });
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
const { instanceUrl, orgId, salesforceUserId, email, changedFields, timestamp } = req.body;
log.info("Received user sync request", {
instanceUrl,
orgId,
salesforceUserId,
timestamp,
});
// TODO: Validate instanceUrl + orgId against stored credentials
// TODO: Sync changedFields to Cal.com user
return res.status(200).json({ success: true });
}
+5 -1
View File
@@ -7,7 +7,11 @@
"scripts": {
"generate:schema": "graphql-introspection-json-to-sdl ./schema.json > ./schema.graphql",
"codegen": "graphql-codegen --config codegen.ts",
"codegen:watch": "graphql-codegen --config codegen.ts --watch"
"codegen:watch": "graphql-codegen --config codegen.ts --watch",
"scratch-org:create": "sf org create scratch --definition-file scratch-org-def.json --alias calcom-test --duration-days 7 --set-default",
"scratch-org:start": "sf org open --target-org calcom-test",
"sfdc:deploy": "cd sfdc-package && sf project deploy start",
"sfdc:deploy:preview": "cd sfdc-package && sf project deploy preview"
},
"dependencies": {
"@calcom/lib": "workspace:*",
@@ -0,0 +1,6 @@
{
"orgName": "Cal.com Test Org",
"edition": "Developer",
"features": [],
"settings": {}
}
@@ -0,0 +1,12 @@
# List files or directories below to ignore them when running force:source:push, force:source:pull, and force:source:status
# More information: https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_exclude_source.htm
#
package.xml
# LWC configuration files
**/jsconfig.json
**/.eslintrc.json
# LWC Jest
**/__tests__/**
@@ -0,0 +1,54 @@
public with sharing class CalComCalloutQueueable implements Queueable, Database.AllowsCallouts {
private static final String ENDPOINT_PATH = '/api/integrations/salesforce/user-sync';
private List<UserChangePayload> payloads;
public CalComCalloutQueueable(List<UserChangePayload> payloads) {
this.payloads = payloads;
}
public void execute(QueueableContext context) {
String namedCredential = getNamedCredential();
for (UserChangePayload payload : payloads) {
sendPayload(namedCredential, payload);
}
}
private String getNamedCredential() {
Boolean isSandbox = [SELECT IsSandbox FROM Organization LIMIT 1].IsSandbox;
return isSandbox ? 'callout:CalCom_Development' : 'callout:CalCom_Production';
}
private void sendPayload(String namedCredential, UserChangePayload payload) {
try {
HttpRequest req = new HttpRequest();
req.setEndpoint(namedCredential + ENDPOINT_PATH);
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setBody(JSON.serialize(payload));
req.setTimeout(30000);
Http http = new Http();
HttpResponse res = http.send(req);
if (res.getStatusCode() >= 200 && res.getStatusCode() < 300) {
System.debug(LoggingLevel.INFO, 'Successfully sent user update to Cal.com for user: ' + payload.salesforceUserId);
} else {
System.debug(LoggingLevel.ERROR, 'Failed to send user update to Cal.com. Status: ' + res.getStatusCode() + ' Body: ' + res.getBody());
}
} catch (Exception e) {
System.debug(LoggingLevel.ERROR, 'Exception sending user update to Cal.com: ' + e.getMessage());
}
}
public class UserChangePayload {
public Id salesforceUserId;
public String email;
public String instanceUrl;
public String orgId;
public Map<String, Object> changedFields;
public String timestamp;
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>64.0</apiVersion>
<status>Active</status>
</ApexClass>
@@ -0,0 +1,46 @@
public with sharing class UserUpdateHandler {
public static void handleAfterUpdate(Map<Id, User> oldMap, Map<Id, User> newMap) {
List<CalComCalloutQueueable.UserChangePayload> payloads = new List<CalComCalloutQueueable.UserChangePayload>();
for (Id userId : newMap.keySet()) {
User oldUser = oldMap.get(userId);
User newUser = newMap.get(userId);
Map<String, Object> changedFields = getChangedFields(oldUser, newUser);
if (!changedFields.isEmpty()) {
CalComCalloutQueueable.UserChangePayload payload = new CalComCalloutQueueable.UserChangePayload();
payload.salesforceUserId = userId;
payload.email = newUser.Email;
payload.instanceUrl = URL.getOrgDomainUrl().toExternalForm();
payload.orgId = UserInfo.getOrganizationId();
payload.changedFields = changedFields;
payload.timestamp = Datetime.now().formatGMT('yyyy-MM-dd\'T\'HH:mm:ss.SSS\'Z\'');
payloads.add(payload);
}
}
if (!payloads.isEmpty()) {
System.enqueueJob(new CalComCalloutQueueable(payloads));
}
}
private static Map<String, Object> getChangedFields(User oldUser, User newUser) {
Map<String, Object> changedFields = new Map<String, Object>();
Map<String, Object> oldFields = oldUser.getPopulatedFieldsAsMap();
Map<String, Object> newFields = newUser.getPopulatedFieldsAsMap();
for (String fieldName : newFields.keySet()) {
Object oldValue = oldFields.get(fieldName);
Object newValue = newFields.get(fieldName);
if (oldValue != newValue) {
changedFields.put(fieldName, newValue);
}
}
return changedFields;
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>64.0</apiVersion>
<status>Active</status>
</ApexClass>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<NamedCredential xmlns="http://soap.sforce.com/2006/04/metadata">
<fullName>CalCom_Development</fullName>
<label>Cal.com Development</label>
<endpoint>https://app.cal.com</endpoint>
<principalType>Anonymous</principalType>
<protocol>Custom</protocol>
<allowMergeFieldsInBody>false</allowMergeFieldsInBody>
<allowMergeFieldsInHeader>true</allowMergeFieldsInHeader>
<generateAuthorizationHeader>false</generateAuthorizationHeader>
<calloutStatus>Enabled</calloutStatus>
</NamedCredential>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<NamedCredential xmlns="http://soap.sforce.com/2006/04/metadata">
<fullName>CalCom_Production</fullName>
<label>Cal.com Production</label>
<endpoint>https://app.cal.com</endpoint>
<principalType>Anonymous</principalType>
<protocol>Custom</protocol>
<allowMergeFieldsInBody>false</allowMergeFieldsInBody>
<allowMergeFieldsInHeader>true</allowMergeFieldsInHeader>
<generateAuthorizationHeader>false</generateAuthorizationHeader>
<calloutStatus>Enabled</calloutStatus>
</NamedCredential>
@@ -0,0 +1,3 @@
trigger UserUpdateTrigger on User (after update) {
UserUpdateHandler.handleAfterUpdate(Trigger.oldMap, Trigger.newMap);
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<ApexTrigger xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>64.0</apiVersion>
<status>Active</status>
</ApexTrigger>
@@ -0,0 +1,12 @@
{
"packageDirectories": [
{
"path": "force-app",
"default": true
}
],
"name": "calcom-user-sync",
"namespace": "",
"sfdcLoginUrl": "https://login.salesforce.com",
"sourceApiVersion": "64.0"
}
+34 -3
View File
@@ -212,7 +212,18 @@ export const sendRoundRobinRescheduledEmailsAndSMS = async (
if (
!shouldSkipAttendeeEmailWithSettings(eventTypeMetadata, organizationSettings, EmailType.RESCHEDULED)
) {
emailsAndSMSToSend.push(sendEmail(() => new AttendeeRescheduledEmail(calendarEvent, person)));
emailsAndSMSToSend.push(
sendEmail(
() =>
new AttendeeRescheduledEmail(
{
...calendarEvent,
...(calendarEvent.hideCalendarNotes && { additionalNotes: undefined }),
},
person
)
)
);
if (person.phoneNumber) {
emailsAndSMSToSend.push(successfullyReScheduledSMS.sendSMSToAttendee(person));
}
@@ -322,7 +333,16 @@ const _sendRescheduledEmailsAndSMS = async (
if (!shouldSkipAttendeeEmailWithSettings(eventTypeMetadata, organizationSettings, EmailType.RESCHEDULED)) {
emailsToSend.push(
...calendarEvent.attendees.map((attendee) => {
return sendEmail(() => new AttendeeRescheduledEmail(calendarEvent, attendee));
return sendEmail(
() =>
new AttendeeRescheduledEmail(
{
...calendarEvent,
...(calendarEvent.hideCalendarNotes && { additionalNotes: undefined }),
},
attendee
)
);
})
);
}
@@ -350,7 +370,18 @@ export const sendRescheduledSeatEmailAndSMS = async (
if (!eventTypeDisableHostEmail(eventTypeMetadata))
emailsToSend.push(sendEmail(() => new OrganizerRescheduledEmail({ calEvent: calendarEvent })));
if (!shouldSkipAttendeeEmailWithSettings(eventTypeMetadata, organizationSettings, EmailType.RESCHEDULED))
emailsToSend.push(sendEmail(() => new AttendeeRescheduledEmail(clonedCalEvent, attendee)));
emailsToSend.push(
sendEmail(
() =>
new AttendeeRescheduledEmail(
{
...clonedCalEvent,
...(clonedCalEvent.hideCalendarNotes && { additionalNotes: undefined }),
},
attendee
)
)
);
const successfullyReScheduledSMS = new EventSuccessfullyReScheduledSMS(calEvent);
await successfullyReScheduledSMS.sendSMSToAttendee(attendee);
@@ -1,5 +1,6 @@
import { z } from "zod";
import { BookingStatus } from "@calcom/prisma/enums";
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService";
@@ -15,8 +16,14 @@ const fieldsSchemaV1 = z.object({
startTime: z.number(),
endTime: z.number(),
status: z.nativeEnum(BookingStatus),
hostUserUuid: z.string().nullable(),
// Allowing it to be optional because most of the time(non-seated booking) it won't be there
seatReferenceUid: z.string().nullish(),
});
type Deps = {
userRepository: UserRepository;
};
export class CreatedAuditActionService implements IAuditActionService {
readonly VERSION = 1;
public static readonly TYPE = "CREATED" as const;
@@ -32,7 +39,7 @@ export class CreatedAuditActionService implements IAuditActionService {
public static readonly storedFieldsSchema = CreatedAuditActionService.fieldsSchemaV1;
private helper: AuditActionServiceHelper<typeof CreatedAuditActionService.latestFieldsSchema, typeof CreatedAuditActionService.storedDataSchema>;
constructor() {
constructor(private readonly deps: Deps) {
this.helper = new AuditActionServiceHelper({
latestVersion: this.VERSION,
latestFieldsSchema: CreatedAuditActionService.latestFieldsSchema,
@@ -58,8 +65,14 @@ export class CreatedAuditActionService implements IAuditActionService {
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(_: GetDisplayTitleParams): Promise<TranslationWithParams> {
return { key: "booking_audit_action.created" };
async getDisplayTitle({ storedData }: GetDisplayTitleParams): Promise<TranslationWithParams> {
const { fields } = this.parseStored(storedData);
const hostUser = fields.hostUserUuid ? await this.deps.userRepository.findByUuid({ uuid: fields.hostUserUuid }) : null;
const hostName = hostUser?.name || "Unknown";
if (fields.seatReferenceUid) {
return { key: "booking_audit_action.created_with_seat", params: { host: hostName } };
}
return { key: "booking_audit_action.created", params: { host: hostName } };
}
getDisplayJson({
@@ -73,6 +86,7 @@ export class CreatedAuditActionService implements IAuditActionService {
startTime: AuditActionServiceHelper.formatDateTimeInTimeZone(fields.startTime, timeZone),
endTime: AuditActionServiceHelper.formatDateTimeInTimeZone(fields.endTime, timeZone),
status: fields.status,
...(fields.seatReferenceUid ? { seatReferenceUid: fields.seatReferenceUid } : {}),
};
}
}
@@ -83,5 +97,6 @@ export type CreatedAuditDisplayData = {
startTime: string;
endTime: string;
status: BookingStatus;
seatReferenceUid?: string;
};
@@ -88,12 +88,16 @@ export class ReassignmentAuditActionService implements IAuditActionService {
labelKey: string;
valueKey: string;
}> {
const { fields } = storedData;
const typeTranslationKey = `booking_audit_action.assignmentType_${fields.reassignmentType}`;
const { fields } = this.parseStored(storedData);
const map = {
manual: "manual",
roundRobin: "round_robin",
}
const typeTranslationKey = `booking_audit_action.assignment_type_${map[fields.reassignmentType]}`;
return [
{
labelKey: "booking_audit_action.type",
labelKey: "booking_audit_action.assignment_type",
valueKey: typeTranslationKey,
}
];
@@ -1,6 +1,6 @@
import { z } from "zod";
import { StringChangeSchema } from "../common/changeSchemas";
import { NumberChangeSchema, StringChangeSchema } from "../common/changeSchemas";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams, BaseStoredAuditData } from "./IAuditActionService";
@@ -11,8 +11,8 @@ import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams,
// Module-level because it is passed to IAuditActionService type outside the class scope
const fieldsSchemaV1 = z.object({
startTime: StringChangeSchema,
endTime: StringChangeSchema,
startTime: NumberChangeSchema,
endTime: NumberChangeSchema,
rescheduledToUid: StringChangeSchema,
});
@@ -1,6 +1,6 @@
import { z } from "zod";
import { StringChangeSchema } from "../common/changeSchemas";
import { StringChangeSchema, NumberChangeSchema } from "../common/changeSchemas";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, GetDisplayTitleParams, GetDisplayJsonParams, TranslationWithParams } from "./IAuditActionService";
/**
@@ -12,8 +12,8 @@ import type { IAuditActionService, GetDisplayTitleParams, GetDisplayJsonParams,
const fieldsSchemaV1 = z.object({
seatReferenceUid: z.string(),
attendeeEmail: z.string(),
startTime: StringChangeSchema,
endTime: StringChangeSchema,
startTime: NumberChangeSchema,
endTime: NumberChangeSchema,
rescheduledToBookingUid: StringChangeSchema,
});
@@ -2,7 +2,6 @@ import type { UserRepository } from "@calcom/features/users/repositories/UserRep
import type { IAuditActionService } from "../actions/IAuditActionService";
import type { BookingAuditAction } from "../repository/IBookingAuditRepository";
// Import all action services
import { CreatedAuditActionService, type CreatedAuditData } from "../actions/CreatedAuditActionService";
import { CancelledAuditActionService, type CancelledAuditData } from "../actions/CancelledAuditActionService";
import { RescheduledAuditActionService, type RescheduledAuditData } from "../actions/RescheduledAuditActionService";
@@ -54,7 +53,7 @@ export class BookingAuditActionServiceRegistry {
constructor(private deps: BookingAuditActionServiceRegistryDeps) {
const services: Array<[BookingAuditAction, IAuditActionService]> = [
["CREATED", new CreatedAuditActionService()],
["CREATED", new CreatedAuditActionService(deps)],
["CANCELLED", new CancelledAuditActionService()],
["RESCHEDULED", new RescheduledAuditActionService()],
["ACCEPTED", new AcceptedAuditActionService()],
@@ -201,6 +201,28 @@ export interface BookingAuditProducerService {
context?: BookingAuditContext;
}): Promise<void>;
queueBulkCreatedAudit(params: {
bookings: Array<{
bookingUid: string;
data: z.infer<typeof CreatedAuditActionService.latestFieldsSchema>;
}>;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
}): Promise<void>;
queueBulkRescheduledAudit(params: {
bookings: Array<{
bookingUid: string;
data: z.infer<typeof RescheduledAuditActionService.latestFieldsSchema>;
}>;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
}): Promise<void>;
queueBulkRejectedAudit(params: {
bookings: Array<{
bookingUid: string;
@@ -271,7 +271,7 @@ export class BookingAuditTaskConsumer {
const { organizationId, action, bookingUids } = params;
if (organizationId === null) {
logger.info(`Skipping audit for non-organization booking: action=${action}, bookingUids=${bookingUids.join(",")}`);
logger.debug(`Skipping audit for non-organization booking: action=${action}, bookingUids=${bookingUids.join(",")}`);
return false;
}
@@ -281,7 +281,7 @@ export class BookingAuditTaskConsumer {
);
if (!isFeatureEnabled) {
logger.info(
logger.debug(
`booking-audit feature is disabled for organization: action=${action}, bookingUids=${bookingUids.join(",")}, organizationId=${organizationId}`
);
return false;
@@ -402,6 +402,38 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe
});
}
async queueBulkCreatedAudit(params: {
bookings: Array<{
bookingUid: string;
data: z.infer<typeof CreatedAuditActionService.latestFieldsSchema>;
}>;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
}): Promise<void> {
await this.queueBulkTask({
...params,
action: CreatedAuditActionService.TYPE,
});
}
async queueBulkRescheduledAudit(params: {
bookings: Array<{
bookingUid: string;
data: z.infer<typeof RescheduledAuditActionService.latestFieldsSchema>;
}>;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
}): Promise<void> {
await this.queueBulkTask({
...params,
action: RescheduledAuditActionService.TYPE,
});
}
async queueBulkRejectedAudit(params: {
bookings: Array<{
bookingUid: string;
@@ -125,9 +125,7 @@ export class BookingAuditViewerService {
userTimeZone,
});
if (rescheduledFromLog) {
// Add the rescheduled log from the previous booking as the first entry
// (appears last chronologically since logs are ordered by timestamp DESC)
enrichedAuditLogs.unshift(rescheduledFromLog);
enrichedAuditLogs.push(rescheduledFromLog);
}
}
@@ -25,29 +25,74 @@ vi.mock("@calcom/features/bookings/repositories/BookingRepository");
vi.mock("@calcom/features/membership/repositories/MembershipRepository");
vi.mock("@calcom/features/credentials/repositories/CredentialRepository");
const createMockTeamBooking = (overrides?: {
userId?: number;
teamId?: number | null;
parentTeamId?: number;
}) => ({
userId: overrides?.userId ?? 456,
type MockBooking = {
userId: number;
user: {
id: overrides?.userId ?? 456,
email: "test@example.com",
},
id: number;
email: string;
};
eventType: {
teamId: (overrides && "teamId" in overrides ? overrides.teamId : (overrides?.teamId ?? 100)) ?? null,
parent: (overrides?.parentTeamId ? { teamId: overrides.parentTeamId } : undefined) ?? null,
hosts: [],
users: [],
},
attendees: [],
});
teamId: number | null;
parent: { teamId: number } | null;
hosts: unknown[];
users: unknown[];
} | null;
attendees: unknown[];
fromReschedule?: string | null;
};
type MockAttendee = {
id: number;
name: string | null;
email: string;
};
type MockMembership = {
userId: number;
teamId: number;
};
const DB = {
users: {} as Record<string, MockUser>,
bookings: {} as Record<string, MockBooking>,
auditLogs: {} as Record<string, BookingAuditWithActor[]>,
attendees: {} as Record<number, MockAttendee>,
memberships: {} as Record<string, boolean>, // key: "userId-teamId"
};
const createMockTeamBooking = (
bookingUid: string,
overrides?: {
userId?: number;
teamId?: number | null;
parentTeamId?: number;
fromReschedule?: string | null;
}
) => {
const booking: MockBooking = {
userId: overrides?.userId ?? 456,
user: {
id: overrides?.userId ?? 456,
email: "test@example.com",
},
eventType: {
teamId: (overrides && "teamId" in overrides ? overrides.teamId : (overrides?.teamId ?? 100)) ?? null,
parent: (overrides?.parentTeamId ? { teamId: overrides.parentTeamId } : undefined) ?? null,
hosts: [],
users: [],
},
attendees: [],
fromReschedule: overrides?.fromReschedule,
};
DB.bookings[bookingUid] = booking;
return booking;
};
const createMockAuditLog = (
bookingUid: string,
overrides?: Partial<{
id: string;
bookingUid: string;
action: BookingAuditAction;
type: BookingAuditType;
timestamp: Date;
@@ -58,45 +103,92 @@ const createMockAuditLog = (
actorType: AuditActorType;
actorUserUuid: string | null;
actorName: string | null;
actorAttendeeId: number | null;
context: BookingAuditContext | null;
}>
): BookingAuditWithActor => ({
id: overrides?.id ?? "audit-log-1",
bookingUid: overrides?.bookingUid ?? "booking-uid-123",
action: overrides?.action ?? "CREATED",
type: overrides?.type ?? "RECORD_CREATED",
timestamp: overrides?.timestamp ?? new Date("2024-01-15T10:00:00Z"),
createdAt: overrides?.createdAt ?? new Date("2024-01-15T10:00:00Z"),
updatedAt: overrides?.updatedAt ?? new Date("2024-01-15T10:00:00Z"),
actorId: overrides?.actorId ?? "actor-1",
data: overrides?.data ?? {
version: 1,
fields: { startTime: 1705315200000, endTime: 1705318800000, status: "ACCEPTED" },
},
source: "WEBAPP" as const,
operationId: "operation-id-123",
context: (overrides && "context" in overrides ? overrides.context : null) as BookingAuditContext | null,
actor: {
id: overrides?.actorId ?? "actor-1",
type: overrides?.actorType ?? ("USER" as const),
userUuid: (overrides && "actorUserUuid" in overrides ? overrides.actorUserUuid : "user-uuid-123") as
| string
| null,
attendeeId: null,
credentialId: null,
name: (overrides && "actorName" in overrides ? overrides.actorName : "John Doe") as string | null,
createdAt: new Date("2024-01-01T00:00:00Z"),
},
});
): BookingAuditWithActor => {
const log: BookingAuditWithActor = {
id: overrides?.id ?? "audit-log-1",
bookingUid,
action: overrides?.action ?? "CREATED",
type: overrides?.type ?? "RECORD_CREATED",
timestamp: overrides?.timestamp ?? new Date("2024-01-15T10:00:00Z"),
createdAt: overrides?.createdAt ?? new Date("2024-01-15T10:00:00Z"),
updatedAt: overrides?.updatedAt ?? new Date("2024-01-15T10:00:00Z"),
actorId: overrides?.actorId ?? "actor-1",
data: overrides?.data ?? {
version: 1,
fields: {
startTime: 1705315200000,
endTime: 1705318800000,
status: "ACCEPTED",
hostUserUuid: "host-uuid",
},
},
source: "WEBAPP" as const,
operationId: "operation-id-123",
context: (overrides && "context" in overrides ? overrides.context : null) as BookingAuditContext | null,
actor: {
id: overrides?.actorId ?? "actor-1",
type: overrides?.actorType ?? ("USER" as const),
userUuid: (overrides && "actorUserUuid" in overrides ? overrides.actorUserUuid : "user-uuid-123") as
| string
| null,
attendeeId: (overrides && "actorAttendeeId" in overrides ? overrides.actorAttendeeId : null) as
| number
| null,
credentialId: null,
name: (overrides && "actorName" in overrides ? overrides.actorName : "John Doe") as string | null,
createdAt: new Date("2024-01-01T00:00:00Z"),
},
};
if (!DB.auditLogs[bookingUid]) {
DB.auditLogs[bookingUid] = [];
}
DB.auditLogs[bookingUid].push(log);
return log;
};
type MockUser = {
id: number;
name: string | null;
email: string;
avatarUrl: string | null;
};
const createMockUser = (
uuid?: string,
overrides?: Partial<{ id: number; name: string | null; email: string; avatarUrl: string | null }>
) => ({
id: overrides?.id ?? 123,
name: (overrides && "name" in overrides ? overrides.name : "John Doe") as string | null,
email: overrides?.email ?? "john@example.com",
avatarUrl: (overrides && "avatarUrl" in overrides ? overrides.avatarUrl : null) as string | null,
});
) => {
const user: MockUser = {
id: overrides?.id ?? 123,
name: (overrides && "name" in overrides ? overrides.name : "John Doe") as string | null,
email: overrides?.email ?? "john@example.com",
avatarUrl: (overrides && "avatarUrl" in overrides ? overrides.avatarUrl : null) as string | null,
};
if (uuid) {
DB.users[uuid] = user;
}
return user;
};
const createMockAttendee = (id: number, overrides?: Partial<{ name: string | null; email: string }>) => {
const attendee: MockAttendee = {
id,
name: overrides?.name ?? null,
email: overrides?.email ?? `attendee-${id}@example.com`,
};
DB.attendees[id] = attendee;
return attendee;
};
const createMockMembership = ({ userId, teamId }: { userId: number; teamId: number }) => {
const key = `${userId}-${teamId}`;
DB.memberships[key] = true;
};
describe("BookingAuditViewerService - Integration Tests", () => {
let service: BookingAuditViewerService;
@@ -131,25 +223,45 @@ describe("BookingAuditViewerService - Integration Tests", () => {
beforeEach(() => {
vi.clearAllMocks();
DB.users = {};
DB.bookings = {};
DB.auditLogs = {};
DB.attendees = {};
DB.memberships = {};
mockBookingRepository = {
findByUidIncludeEventType: vi.fn(),
getFromRescheduleUid: vi.fn(),
findByUidIncludeEventType: vi.fn().mockImplementation(({ bookingUid }: { bookingUid: string }) => {
return Promise.resolve(DB.bookings[bookingUid] ?? null);
}),
getFromRescheduleUid: vi.fn().mockImplementation((bookingUid: string) => {
const booking = DB.bookings[bookingUid];
return Promise.resolve(booking?.fromReschedule ?? null);
}),
};
mockUserRepository = {
getUserOrganizationAndTeams: vi.fn(),
findByUuid: vi.fn(),
findByUuid: vi.fn().mockImplementation(({ uuid }: { uuid: string }) => {
return Promise.resolve(DB.users[uuid] ?? null);
}),
};
mockBookingAuditRepository = {
create: vi.fn(),
findAllForBooking: vi.fn().mockResolvedValue([]),
findRescheduledLogsOfBooking: vi.fn(),
findAllForBooking: vi.fn().mockImplementation((bookingUid: string) => {
return Promise.resolve(DB.auditLogs[bookingUid] ?? []);
}),
findRescheduledLogsOfBooking: vi.fn().mockImplementation((bookingUid: string) => {
const logs = DB.auditLogs[bookingUid] ?? [];
return Promise.resolve(logs.filter((log) => log.action === "RESCHEDULED"));
}),
};
mockMembershipRepository = {
hasMembership: vi.fn(),
hasMembership: vi.fn().mockImplementation(({ userId, teamId }: { userId: number; teamId: number }) => {
const key = `${userId}-${teamId}`;
return Promise.resolve(DB.memberships[key] ?? false);
}),
};
mockPermissionCheckService = {
@@ -157,7 +269,9 @@ describe("BookingAuditViewerService - Integration Tests", () => {
};
mockAttendeeRepository = {
findById: vi.fn(),
findById: vi.fn().mockImplementation((id: number) => {
return Promise.resolve(DB.attendees[id] ?? null);
}),
};
mockCredentialRepository = {
@@ -198,18 +312,13 @@ describe("BookingAuditViewerService - Integration Tests", () => {
describe("getAuditLogsForBooking", () => {
describe("when user has permission to view audit logs", () => {
beforeEach(() => {
mockBookingRepository.findByUidIncludeEventType.mockResolvedValue(
createMockTeamBooking({ teamId: 100 })
);
createMockTeamBooking("booking-uid-123", { teamId: 100 });
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
});
it("should return enriched audit logs with actor information", async () => {
const mockLog = createMockAuditLog();
const mockUser = createMockUser();
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
mockUserRepository.findByUuid.mockResolvedValue(mockUser);
createMockAuditLog("booking-uid-123");
createMockUser("user-uuid-123");
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
@@ -236,13 +345,12 @@ describe("BookingAuditViewerService - Integration Tests", () => {
});
it("should format timestamps as ISO strings", async () => {
const mockLog = createMockAuditLog({
createMockAuditLog("booking-uid-123", {
timestamp: new Date("2024-01-15T10:00:00Z"),
createdAt: new Date("2024-01-15T10:00:00Z"),
});
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
createMockUser("user-uuid-123");
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
@@ -257,10 +365,9 @@ describe("BookingAuditViewerService - Integration Tests", () => {
});
it("should include action display title with translation key", async () => {
const mockLog = createMockAuditLog({ action: "CREATED" });
createMockAuditLog("booking-uid-123", { action: "CREATED" });
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
createMockUser("user-uuid-123");
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
@@ -272,43 +379,48 @@ describe("BookingAuditViewerService - Integration Tests", () => {
expect(result.auditLogs[0].actionDisplayTitle).toEqual({
key: "booking_audit_action.created",
params: {
host: "Unknown",
},
});
});
it("should handle multiple audit logs in correct order", async () => {
const logs = [
createMockAuditLog({
id: "log-1",
action: "CREATED",
timestamp: new Date("2024-01-15T10:00:00Z"),
data: {
version: 1,
fields: { startTime: 1705315200000, endTime: 1705318800000, status: "ACCEPTED" },
createMockAuditLog("booking-uid-123", {
id: "log-1",
action: "CREATED",
timestamp: new Date("2024-01-15T10:00:00Z"),
data: {
version: 1,
fields: {
startTime: 1705315200000,
endTime: 1705318800000,
status: "ACCEPTED",
hostUserUuid: null,
},
}),
createMockAuditLog({
id: "log-2",
action: "ACCEPTED",
timestamp: new Date("2024-01-15T11:00:00Z"),
data: { version: 1, fields: { status: { old: "PENDING", new: "ACCEPTED" } } },
}),
createMockAuditLog({
id: "log-3",
action: "CANCELLED",
timestamp: new Date("2024-01-15T12:00:00Z"),
data: {
version: 1,
fields: {
status: { old: "ACCEPTED", new: "CANCELLED" },
cancellationReason: "User requested",
cancelledBy: "user-123",
},
},
});
createMockAuditLog("booking-uid-123", {
id: "log-2",
action: "ACCEPTED",
timestamp: new Date("2024-01-15T11:00:00Z"),
data: { version: 1, fields: { status: { old: "PENDING", new: "ACCEPTED" } } },
});
createMockAuditLog("booking-uid-123", {
id: "log-3",
action: "CANCELLED",
timestamp: new Date("2024-01-15T12:00:00Z"),
data: {
version: 1,
fields: {
status: { old: "ACCEPTED", new: "CANCELLED" },
cancellationReason: "User requested",
cancelledBy: "user-123",
},
}),
];
},
});
mockBookingAuditRepository.findAllForBooking.mockResolvedValue(logs);
mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
createMockUser("user-uuid-123");
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
@@ -325,7 +437,7 @@ describe("BookingAuditViewerService - Integration Tests", () => {
});
it("should return empty array when no audit logs exist", async () => {
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([]);
// Don't create any audit logs in DB
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
@@ -342,26 +454,21 @@ describe("BookingAuditViewerService - Integration Tests", () => {
describe("when enriching actor information", () => {
beforeEach(() => {
mockBookingRepository.findByUidIncludeEventType.mockResolvedValue(
createMockTeamBooking({ teamId: 100 })
);
createMockTeamBooking("booking-uid-123", { teamId: 100 });
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
});
it("should enrich USER actor with user details from repository", async () => {
const mockLog = createMockAuditLog({
createMockAuditLog("booking-uid-123", {
actorType: "USER",
actorUserUuid: "user-uuid-456",
});
const mockUser = createMockUser({
createMockUser("user-uuid-456", {
name: "Jane Smith",
email: "jane@example.com",
avatarUrl: "https://example.com/avatar.jpg",
});
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
mockUserRepository.findByUuid.mockResolvedValue(mockUser);
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
@@ -379,14 +486,11 @@ describe("BookingAuditViewerService - Integration Tests", () => {
});
it("should use email as display name when user name is null", async () => {
const mockLog = createMockAuditLog({
createMockAuditLog("booking-uid-123", {
actorType: "USER",
actorName: null,
});
const mockUser = createMockUser({ name: null, email: "user@example.com" });
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
mockUserRepository.findByUuid.mockResolvedValue(mockUser);
createMockUser("user-uuid-123", { name: null, email: "user@example.com" });
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
@@ -400,10 +504,7 @@ describe("BookingAuditViewerService - Integration Tests", () => {
});
it("should show 'Deleted User' when user not found in repository", async () => {
const mockLog = createMockAuditLog({ actorType: "USER" });
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
mockUserRepository.findByUuid.mockResolvedValue(null);
createMockAuditLog("booking-uid-123", { actorType: "USER" });
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
@@ -421,13 +522,20 @@ describe("BookingAuditViewerService - Integration Tests", () => {
});
it("should show 'Cal.com' for SYSTEM actor", async () => {
const mockLog = createMockAuditLog({
createMockAuditLog("booking-uid-123", {
actorType: "SYSTEM",
actorUserUuid: null,
data: {
version: 1,
fields: {
startTime: 1705315200000,
endTime: 1705318800000,
status: "ACCEPTED",
hostUserUuid: null,
},
},
});
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
@@ -446,14 +554,12 @@ describe("BookingAuditViewerService - Integration Tests", () => {
});
it("should show 'Guest' for GUEST actor without name", async () => {
const mockLog = createMockAuditLog({
createMockAuditLog("booking-uid-123", {
actorType: "GUEST",
actorUserUuid: null,
actorName: null,
});
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
@@ -471,14 +577,12 @@ describe("BookingAuditViewerService - Integration Tests", () => {
});
it("should use provided name for GUEST actor", async () => {
const mockLog = createMockAuditLog({
createMockAuditLog("booking-uid-123", {
actorType: "GUEST",
actorUserUuid: null,
actorName: "External Guest",
});
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
userId: 123,
@@ -491,27 +595,14 @@ describe("BookingAuditViewerService - Integration Tests", () => {
});
it("should show 'Attendee' for ATTENDEE actor without name", async () => {
const mockLog = createMockAuditLog({
createMockAuditLog("booking-uid-123", {
actorType: "ATTENDEE",
actorUserUuid: null,
actorName: null,
actorAttendeeId: 999,
});
// Update the mock to include attendeeId in the actor
const logWithAttendeeId = {
...mockLog,
actor: {
...mockLog.actor,
attendeeId: 999,
},
};
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([logWithAttendeeId]);
mockAttendeeRepository.findById.mockResolvedValue({
id: 999,
name: null,
email: "attendee@example.com",
});
createMockAttendee(999, { name: null, email: "attendee@example.com" });
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
@@ -531,27 +622,14 @@ describe("BookingAuditViewerService - Integration Tests", () => {
});
it("should use provided name for ATTENDEE actor", async () => {
const mockLog = createMockAuditLog({
createMockAuditLog("booking-uid-123", {
actorType: "ATTENDEE",
actorUserUuid: null,
actorName: "Meeting Participant",
actorAttendeeId: 888,
});
// Update the mock to include attendeeId in the actor
const logWithAttendeeId = {
...mockLog,
actor: {
...mockLog.actor,
attendeeId: 888,
},
};
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([logWithAttendeeId]);
mockAttendeeRepository.findById.mockResolvedValue({
id: 888,
name: "Meeting Participant",
email: "participant@example.com",
});
createMockAttendee(888, { name: "Meeting Participant", email: "participant@example.com" });
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
@@ -567,23 +645,14 @@ describe("BookingAuditViewerService - Integration Tests", () => {
});
it("should show 'Deleted Attendee' when attendee not found in repository", async () => {
const mockLog = createMockAuditLog({
createMockAuditLog("booking-uid-123", {
actorType: "ATTENDEE",
actorUserUuid: null,
actorName: null,
actorAttendeeId: 777,
});
// Update the mock to include attendeeId in the actor
const logWithAttendeeId = {
...mockLog,
actor: {
...mockLog.actor,
attendeeId: 777,
},
};
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([logWithAttendeeId]);
mockAttendeeRepository.findById.mockResolvedValue(null);
// Don't create attendee in DB - should return null
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
@@ -605,37 +674,31 @@ describe("BookingAuditViewerService - Integration Tests", () => {
describe("when handling rescheduled bookings", () => {
beforeEach(() => {
mockBookingRepository.findByUidIncludeEventType.mockResolvedValue(
createMockTeamBooking({ teamId: 100 })
);
createMockTeamBooking("new-booking-uid", { teamId: 100 });
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
});
it("should include rescheduled from log when booking was created from reschedule", async () => {
const currentBookingLog = createMockAuditLog({
createMockAuditLog("new-booking-uid", {
id: "current-log",
bookingUid: "new-booking-uid",
action: "CREATED",
});
const rescheduledLog = createMockAuditLog({
createMockAuditLog("old-booking-uid", {
id: "rescheduled-log",
bookingUid: "old-booking-uid",
action: "RESCHEDULED",
data: {
version: 1,
fields: {
startTime: { old: "2024-01-15T10:00:00Z", new: "2024-01-16T10:00:00Z" },
endTime: { old: "2024-01-15T11:00:00Z", new: "2024-01-16T11:00:00Z" },
startTime: { old: 1705315200000, new: 1705401600000 },
endTime: { old: 1705318800000, new: 1705405200000 },
rescheduledToUid: { old: null, new: "new-booking-uid" },
},
},
});
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([currentBookingLog]);
mockBookingRepository.getFromRescheduleUid.mockResolvedValue("old-booking-uid");
mockBookingAuditRepository.findRescheduledLogsOfBooking.mockResolvedValue([rescheduledLog]);
mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
createMockTeamBooking("new-booking-uid", { teamId: 100, fromReschedule: "old-booking-uid" });
createMockUser("user-uuid-123");
const result = await service.getAuditLogsForBooking({
bookingUid: "new-booking-uid",
@@ -646,19 +709,18 @@ describe("BookingAuditViewerService - Integration Tests", () => {
});
expect(result.auditLogs).toHaveLength(2);
expect(result.auditLogs[0].id).toBe("rescheduled-log");
expect(result.auditLogs[0].bookingUid).toBe("new-booking-uid");
expect(result.auditLogs[0].actionDisplayTitle.key).toBe("booking_audit_action.rescheduled_from");
expect(result.auditLogs[0].displayJson).toHaveProperty("rescheduledFromUid", "old-booking-uid");
expect(result.auditLogs[1].id).toBe("current-log");
expect(result.auditLogs[1].id).toBe("rescheduled-log");
expect(result.auditLogs[1].bookingUid).toBe("new-booking-uid");
expect(result.auditLogs[1].actionDisplayTitle.key).toBe("booking_audit_action.rescheduled_from");
expect(result.auditLogs[1].displayJson).toHaveProperty("rescheduledFromUid", "old-booking-uid");
expect(result.auditLogs[0].id).toBe("current-log");
});
it("should not include rescheduled from log when booking was not rescheduled", async () => {
const mockLog = createMockAuditLog();
createMockAuditLog("booking-uid-123");
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
mockBookingRepository.getFromRescheduleUid.mockResolvedValue(null);
mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
createMockTeamBooking("booking-uid-123", { teamId: 100 });
createMockUser("user-uuid-123");
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
@@ -673,28 +735,23 @@ describe("BookingAuditViewerService - Integration Tests", () => {
});
it("should continue without rescheduled from log when matching log not found", async () => {
const currentBookingLog = createMockAuditLog({
bookingUid: "new-booking-uid",
});
createMockAuditLog("new-booking-uid");
const rescheduledLog = createMockAuditLog({
createMockAuditLog("old-booking-uid", {
id: "rescheduled-log",
bookingUid: "old-booking-uid",
action: "RESCHEDULED",
data: {
version: 1,
fields: {
startTime: { old: "2024-01-15T10:00:00Z", new: "2024-01-16T10:00:00Z" },
endTime: { old: "2024-01-15T11:00:00Z", new: "2024-01-16T11:00:00Z" },
startTime: { old: 1705315200000, new: 1705401600000 },
endTime: { old: 1705318800000, new: 1705405200000 },
rescheduledToUid: { old: null, new: "different-booking-uid" },
},
},
});
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([currentBookingLog]);
mockBookingRepository.getFromRescheduleUid.mockResolvedValue("old-booking-uid");
mockBookingAuditRepository.findRescheduledLogsOfBooking.mockResolvedValue([rescheduledLog]);
mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
createMockTeamBooking("new-booking-uid", { teamId: 100, fromReschedule: "old-booking-uid" });
createMockUser("user-uuid-123");
const result = await service.getAuditLogsForBooking({
bookingUid: "new-booking-uid",
@@ -714,11 +771,9 @@ describe("BookingAuditViewerService - Integration Tests", () => {
describe("when permission check fails", () => {
it("should throw error when user lacks permission", async () => {
mockBookingRepository.findByUidIncludeEventType.mockResolvedValue(
createMockTeamBooking({ teamId: 100 })
);
createMockTeamBooking("booking-uid-123", { teamId: 100 });
mockPermissionCheckService.checkPermission.mockResolvedValue(false);
mockMembershipRepository.hasMembership.mockResolvedValue(true);
createMockMembership({ userId: 123, teamId: 100 });
await expect(
service.getAuditLogsForBooking({
@@ -748,7 +803,7 @@ describe("BookingAuditViewerService - Integration Tests", () => {
});
it("should throw error when booking not found", async () => {
mockBookingRepository.findByUidIncludeEventType.mockResolvedValue(null);
// Don't create booking in DB - should return null
await expect(
service.getAuditLogsForBooking({
@@ -766,27 +821,24 @@ describe("BookingAuditViewerService - Integration Tests", () => {
describe("when handling different timezones", () => {
beforeEach(() => {
mockBookingRepository.findByUidIncludeEventType.mockResolvedValue(
createMockTeamBooking({ teamId: 100 })
);
createMockTeamBooking("booking-uid-123", { teamId: 100 });
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
});
it("should pass user timezone to action service for display formatting", async () => {
const mockLog = createMockAuditLog({
createMockAuditLog("booking-uid-123", {
action: "RESCHEDULED",
data: {
version: 1,
fields: {
startTime: { old: "2024-01-15T10:00:00Z", new: "2024-01-16T10:00:00Z" },
endTime: { old: "2024-01-15T11:00:00Z", new: "2024-01-16T11:00:00Z" },
startTime: { old: 1705315200000, new: 1705401600000 },
endTime: { old: 1705318800000, new: 1705405200000 },
rescheduledToUid: { old: null, new: "new-booking-uid" },
},
},
});
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
createMockUser("user-uuid-123");
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
@@ -803,14 +855,12 @@ describe("BookingAuditViewerService - Integration Tests", () => {
describe("when handling seat audit actions", () => {
beforeEach(() => {
mockBookingRepository.findByUidIncludeEventType.mockResolvedValue(
createMockTeamBooking({ teamId: 100 })
);
createMockTeamBooking("booking-uid-123", { teamId: 100 });
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
});
it("should handle SEAT_BOOKED action with seat information", async () => {
const mockLog = createMockAuditLog({
createMockAuditLog("booking-uid-123", {
id: "seat-booked-log",
action: "SEAT_BOOKED",
data: {
@@ -825,8 +875,7 @@ describe("BookingAuditViewerService - Integration Tests", () => {
},
});
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
createMockUser("user-uuid-123");
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
@@ -843,7 +892,7 @@ describe("BookingAuditViewerService - Integration Tests", () => {
});
it("should handle SEAT_RESCHEDULED action with time changes", async () => {
const mockLog = createMockAuditLog({
createMockAuditLog("booking-uid-123", {
id: "seat-rescheduled-log",
action: "SEAT_RESCHEDULED",
data: {
@@ -852,12 +901,12 @@ describe("BookingAuditViewerService - Integration Tests", () => {
seatReferenceUid: "seat-ref-123",
attendeeEmail: "attendee@example.com",
startTime: {
old: "2024-01-15T10:00:00Z",
new: "2024-01-16T10:00:00Z",
old: new Date("2024-01-15T10:00:00Z").getTime(),
new: new Date("2024-01-16T10:00:00Z").getTime(),
},
endTime: {
old: "2024-01-15T11:00:00Z",
new: "2024-01-16T11:00:00Z",
old: new Date("2024-01-15T11:00:00Z").getTime(),
new: new Date("2024-01-16T11:00:00Z").getTime(),
},
rescheduledToBookingUid: {
old: null,
@@ -867,8 +916,7 @@ describe("BookingAuditViewerService - Integration Tests", () => {
},
});
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
createMockUser("user-uuid-123");
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
@@ -887,7 +935,7 @@ describe("BookingAuditViewerService - Integration Tests", () => {
});
it("should handle SEAT_RESCHEDULED action without moving to different booking", async () => {
const mockLog = createMockAuditLog({
createMockAuditLog("booking-uid-123", {
id: "seat-rescheduled-same-booking",
action: "SEAT_RESCHEDULED",
data: {
@@ -896,12 +944,12 @@ describe("BookingAuditViewerService - Integration Tests", () => {
seatReferenceUid: "seat-ref-123",
attendeeEmail: "attendee@example.com",
startTime: {
old: "2024-01-15T10:00:00Z",
new: "2024-01-15T11:00:00Z",
old: new Date("2024-01-15T10:00:00Z").getTime(),
new: new Date("2024-01-15T11:00:00Z").getTime(),
},
endTime: {
old: "2024-01-15T11:00:00Z",
new: "2024-01-15T12:00:00Z",
old: new Date("2024-01-15T11:00:00Z").getTime(),
new: new Date("2024-01-15T12:00:00Z").getTime(),
},
rescheduledToBookingUid: {
old: null,
@@ -911,8 +959,7 @@ describe("BookingAuditViewerService - Integration Tests", () => {
},
});
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
createMockUser("user-uuid-123");
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
@@ -930,26 +977,22 @@ describe("BookingAuditViewerService - Integration Tests", () => {
describe("when handling impersonatedBy context", () => {
beforeEach(() => {
mockBookingRepository.findByUidIncludeEventType.mockResolvedValue(
createMockTeamBooking({ teamId: 100 })
);
createMockTeamBooking("booking-uid-123", { teamId: 100 });
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
});
it("should enrich impersonatedBy when user exists", async () => {
const mockLog = createMockAuditLog({
createMockAuditLog("booking-uid-123", {
context: { impersonatedBy: "impersonator-uuid-456" },
});
const impersonatorUser = createMockUser({
createMockUser("user-uuid-123");
createMockUser("impersonator-uuid-456", {
id: 456,
name: "Admin User",
email: "admin@example.com",
avatarUrl: "https://example.com/admin-avatar.jpg",
});
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
mockUserRepository.findByUuid.mockResolvedValueOnce(createMockUser()); // For actor
mockUserRepository.findByUuid.mockResolvedValueOnce(impersonatorUser); // For impersonator
// Host lookup should return null - don't create host-uuid in DB
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
@@ -968,19 +1011,17 @@ describe("BookingAuditViewerService - Integration Tests", () => {
});
it("should use email as displayName when impersonator name is null", async () => {
const mockLog = createMockAuditLog({
createMockAuditLog("booking-uid-123", {
context: { impersonatedBy: "impersonator-uuid-456" },
});
const impersonatorUser = createMockUser({
createMockUser("user-uuid-123");
createMockUser("impersonator-uuid-456", {
id: 456,
name: null,
email: "admin@example.com",
avatarUrl: null,
});
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
mockUserRepository.findByUuid.mockResolvedValueOnce(createMockUser()); // For actor
mockUserRepository.findByUuid.mockResolvedValueOnce(impersonatorUser); // For impersonator
// Host lookup should return null - don't create host-uuid in DB
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
@@ -998,13 +1039,11 @@ describe("BookingAuditViewerService - Integration Tests", () => {
});
it("should show 'Deleted User' when impersonator user not found", async () => {
const mockLog = createMockAuditLog({
createMockAuditLog("booking-uid-123", {
context: { impersonatedBy: "impersonator-uuid-456" },
});
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
mockUserRepository.findByUuid.mockResolvedValueOnce(createMockUser()); // For actor
mockUserRepository.findByUuid.mockResolvedValueOnce(null); // For impersonator (not found)
createMockUser("user-uuid-123");
// Don't create impersonator-uuid-456 in DB - should return null
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
@@ -1023,12 +1062,11 @@ describe("BookingAuditViewerService - Integration Tests", () => {
});
it("should return null when context is null", async () => {
const mockLog = createMockAuditLog({
createMockAuditLog("booking-uid-123", {
context: null,
});
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
createMockUser("user-uuid-123");
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
@@ -1039,17 +1077,14 @@ describe("BookingAuditViewerService - Integration Tests", () => {
});
expect(result.auditLogs[0].impersonatedBy).toBeNull();
// Should not call findByUuid for impersonator when context is null
expect(mockUserRepository.findByUuid).toHaveBeenCalledTimes(1); // Only for actor
});
it("should return null when impersonatedBy is not in context", async () => {
const mockLog = createMockAuditLog({
createMockAuditLog("booking-uid-123", {
context: {},
});
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
createMockUser("user-uuid-123");
const result = await service.getAuditLogsForBooking({
bookingUid: "booking-uid-123",
@@ -1060,8 +1095,6 @@ describe("BookingAuditViewerService - Integration Tests", () => {
});
expect(result.auditLogs[0].impersonatedBy).toBeNull();
// Should not call findByUuid for impersonator when impersonatedBy is not in context
expect(mockUserRepository.findByUuid).toHaveBeenCalledTimes(1); // Only for actor
});
});
});
@@ -92,6 +92,7 @@ describe("Created Action Integration", () => {
startTime: testData.booking.startTime.getTime(),
endTime: testData.booking.endTime.getTime(),
status: testData.booking.status,
hostUserUuid: testData.owner.uuid,
},
timestamp: Date.now(),
});
@@ -138,6 +139,7 @@ describe("Created Action Integration", () => {
startTime: testData.booking.startTime.getTime(),
endTime: testData.booking.endTime.getTime(),
status: testData.booking.status,
hostUserUuid: testData.owner.uuid,
},
timestamp: Date.now(),
});
@@ -172,6 +174,7 @@ describe("Created Action Integration", () => {
startTime: testData.booking.startTime.getTime(),
endTime: testData.booking.endTime.getTime(),
status: testData.booking.status,
hostUserUuid: testData.owner.uuid,
},
timestamp: Date.now(),
context: {
@@ -211,6 +214,7 @@ describe("Created Action Integration", () => {
startTime: testData.booking.startTime.getTime(),
endTime: testData.booking.endTime.getTime(),
status: testData.booking.status,
hostUserUuid: testData.owner.uuid,
},
timestamp: Date.now(),
});
@@ -275,6 +279,7 @@ describe("Created Action Integration", () => {
startTime: testData.booking.startTime.getTime(),
endTime: testData.booking.endTime.getTime(),
status: testData.booking.status,
hostUserUuid: testData.owner.uuid,
},
},
{
@@ -283,6 +288,7 @@ describe("Created Action Integration", () => {
startTime: booking2.startTime.getTime(),
endTime: booking2.endTime.getTime(),
status: booking2.status,
hostUserUuid: testData.owner.uuid,
},
},
{
@@ -291,6 +297,7 @@ describe("Created Action Integration", () => {
startTime: booking3.startTime.getTime(),
endTime: booking3.endTime.getTime(),
status: booking3.status,
hostUserUuid: testData.owner.uuid,
},
},
],
@@ -3,6 +3,7 @@ import { createModule, bindModuleToClassOnToken } from "@calcom/features/di/di";
import { DI_TOKENS } from "@calcom/features/di/tokens";
import { moduleLoader as regularBookingServiceModuleLoader } from "./RegularBookingService.module";
import { moduleLoader as bookingEventHandlerModuleLoader } from "./BookingEventHandlerService.module";
const token = DI_TOKENS.RECURRING_BOOKING_SERVICE;
const moduleToken = DI_TOKENS.RECURRING_BOOKING_SERVICE_MODULE;
@@ -15,6 +16,7 @@ const loadModule = bindModuleToClassOnToken({
classs: RecurringBookingService,
depsMap: {
regularBookingService: regularBookingServiceModuleLoader,
bookingEventHandler: bookingEventHandlerModuleLoader,
},
});
+1
View File
@@ -34,6 +34,7 @@ export type PlatformParams = {
export type CreateBookingMeta = {
userId?: number;
userUuid?: string;
// These used to come from headers but now we're passing them as params
hostname?: string;
forcedSlug?: string;
+70 -13
View File
@@ -1,8 +1,10 @@
import { bookingResponsesDbSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import slugify from "@calcom/lib/slugify";
import type { PrismaClient } from "@calcom/prisma";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
type BookingSelect = {
description: true;
@@ -24,7 +26,8 @@ function getResponsesFromOldBooking(
) {
const customInputs = rawBooking.customInputs || {};
const responses = Object.keys(customInputs).reduce((acc, label) => {
acc[slugify(label) as keyof typeof acc] = customInputs[label as keyof typeof customInputs];
acc[slugify(label) as keyof typeof acc] =
customInputs[label as keyof typeof customInputs];
return acc;
}, {});
return {
@@ -43,7 +46,11 @@ function getResponsesFromOldBooking(
};
}
async function getBooking(prisma: PrismaClient, uid: string, isSeatedEvent?: boolean) {
async function getBooking(
prisma: PrismaClient,
uid: string,
isSeatedEvent?: boolean
) {
const rawBooking = await prisma.booking.findUnique({
where: {
uid,
@@ -80,6 +87,7 @@ async function getBooking(prisma: PrismaClient, uid: string, isSeatedEvent?: boo
user: {
select: {
id: true,
username: true,
},
},
},
@@ -94,8 +102,12 @@ async function getBooking(prisma: PrismaClient, uid: string, isSeatedEvent?: boo
if (booking) {
// @NOTE: had to do this because Server side cant return [Object objects]
// probably fixable with json.stringify -> json.parse
booking["startTime"] = (booking?.startTime as Date)?.toISOString() as unknown as Date;
booking["endTime"] = (booking?.endTime as Date)?.toISOString() as unknown as Date;
booking["startTime"] = (
booking?.startTime as Date
)?.toISOString() as unknown as Date;
booking["endTime"] = (
booking?.endTime as Date
)?.toISOString() as unknown as Date;
}
return booking;
@@ -115,7 +127,9 @@ export const getBookingWithResponses = <
) => {
return {
...booking,
responses: isSeatedEvent ? booking.responses : booking.responses || getResponsesFromOldBooking(booking),
responses: isSeatedEvent
? booking.responses
: booking.responses || getResponsesFromOldBooking(booking),
} as Omit<T, "responses"> & { responses: Record<string, any> };
};
@@ -130,9 +144,15 @@ export const getBookingForReschedule = async (uid: string, userId?: number) => {
select: {
id: true,
userId: true,
user: {
select: {
organizationId: true,
},
},
eventType: {
select: {
seatsPerTimeSlot: true,
teamId: true,
hosts: {
select: {
userId: true,
@@ -152,7 +172,10 @@ export const getBookingForReschedule = async (uid: string, userId?: number) => {
// If no booking is found via the uid, it's probably a booking seat
// that its being rescheduled, which we query next.
let attendeeEmail: string | null = null;
let bookingSeatData: { description?: string; responses: Prisma.JsonValue } | null = null;
let bookingSeatData: {
description?: string;
responses: Prisma.JsonValue;
} | null = null;
if (!theBooking) {
const bookingSeat = await prisma.bookingSeat.findFirst({
where: {
@@ -175,7 +198,10 @@ export const getBookingForReschedule = async (uid: string, userId?: number) => {
},
});
if (bookingSeat) {
bookingSeatData = bookingSeat.data as unknown as { description?: string; responses: Prisma.JsonValue };
bookingSeatData = bookingSeat.data as unknown as {
description?: string;
responses: Prisma.JsonValue;
};
bookingSeatReferenceUid = bookingSeat.id;
rescheduleUid = bookingSeat.booking.uid;
attendeeEmail = bookingSeat.attendee.email;
@@ -185,14 +211,37 @@ export const getBookingForReschedule = async (uid: string, userId?: number) => {
// If we have the booking and not bookingSeat, we need to make sure the booking belongs to the userLoggedIn
// Otherwise, we return null here.
let hasOwnershipOnBooking = false;
if (theBooking && theBooking?.eventType?.seatsPerTimeSlot && bookingSeatReferenceUid === null) {
if (
theBooking &&
theBooking?.eventType?.seatsPerTimeSlot &&
bookingSeatReferenceUid === null
) {
const isOwnerOfBooking = theBooking.userId === userId;
const isHostOfEventType = theBooking?.eventType?.hosts.some((host) => host.userId === userId);
const isHostOfEventType = theBooking?.eventType?.hosts.some(
(host) => host.userId === userId
);
const isUserIdInBooking = theBooking.userId === userId;
if (!isOwnerOfBooking && !isHostOfEventType && !isUserIdInBooking) return null;
let hasOrgAccess = false;
if (userId && theBooking.user?.organizationId) {
const permissionCheckService = new PermissionCheckService();
hasOrgAccess = await permissionCheckService.checkPermission({
userId,
teamId: theBooking.user.organizationId,
permission: "booking.readOrgBookings",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
}
if (
!isOwnerOfBooking &&
!isHostOfEventType &&
!isUserIdInBooking &&
!hasOrgAccess
)
return null;
hasOwnershipOnBooking = true;
}
@@ -200,13 +249,19 @@ export const getBookingForReschedule = async (uid: string, userId?: number) => {
// and we return null here.
if (!theBooking && !rescheduleUid) return null;
const booking = await getBooking(prisma, rescheduleUid || uid, bookingSeatReferenceUid ? true : false);
const booking = await getBooking(
prisma,
rescheduleUid || uid,
bookingSeatReferenceUid ? true : false
);
if (!booking) return null;
if (bookingSeatReferenceUid) {
booking["description"] = bookingSeatData?.description ?? null;
booking["responses"] = bookingResponsesDbSchema.parse(bookingSeatData?.responses ?? {});
booking["responses"] = bookingResponsesDbSchema.parse(
bookingSeatData?.responses ?? {}
);
}
return {
...booking,
@@ -244,6 +299,7 @@ export const getBookingForSeatedEvent = async (uid: string) => {
user: {
select: {
id: true,
username: true,
},
},
},
@@ -293,6 +349,7 @@ export const getMultipleDurationValue = (
defaultValue: number
) => {
if (!multipleDurationConfig) return null;
if (multipleDurationConfig.includes(Number(queryDuration))) return Number(queryDuration);
if (multipleDurationConfig.includes(Number(queryDuration)))
return Number(queryDuration);
return defaultValue;
};
@@ -1536,6 +1536,126 @@ describe("excluded email/domain validation", () => {
email: "blocked.com@allowed.com",
});
});
test("should block full email match when exact email is excluded", async () => {
const excludedEmails = "anik@cal.com";
const schema = getBookingResponsesSchema({
bookingFields: [
{
name: "name",
type: "name",
required: true,
},
{
name: "email",
type: "email",
required: true,
excludeEmails: excludedEmails,
},
] as z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">,
view: "ALL_VIEWS",
});
// anik@cal.com should be blocked
const parsedResponses = await schema.safeParseAsync({
name: "test",
email: "anik@cal.com",
});
expect(parsedResponses.success).toBe(false);
if (parsedResponses.success) {
throw new Error("Should not reach here");
}
expect(parsedResponses.error.issues[0]).toEqual(
expect.objectContaining({
code: "custom",
message: `{email}${CUSTOM_EMAIL_EXCLUDED_ERROR_MSG}`,
})
);
});
test("should block emails with domain when domain starts with @", async () => {
const excludedEmails = "@gmail.com";
const schema = getBookingResponsesSchema({
bookingFields: [
{
name: "name",
type: "name",
required: true,
},
{
name: "email",
type: "email",
required: true,
excludeEmails: excludedEmails,
},
] as z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">,
view: "ALL_VIEWS",
});
// anik@gmail.com should be blocked when @gmail.com is excluded
const parsedResponses = await schema.safeParseAsync({
name: "test",
email: "anik@gmail.com",
});
expect(parsedResponses.success).toBe(false);
if (parsedResponses.success) {
throw new Error("Should not reach here");
}
expect(parsedResponses.error.issues[0]).toEqual(
expect.objectContaining({
code: "custom",
message: `{email}${CUSTOM_EMAIL_EXCLUDED_ERROR_MSG}`,
})
);
});
test("should block emails with domain when excluded email is just the domain without @", async () => {
const excludedEmails = "gmail.com";
const schema = getBookingResponsesSchema({
bookingFields: [
{
name: "name",
type: "name",
required: true,
},
{
name: "email",
type: "email",
required: true,
excludeEmails: excludedEmails,
},
] as z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">,
view: "ALL_VIEWS",
});
// test@gmail.com should be blocked when gmail.com is excluded
const parsedResponses = await schema.safeParseAsync({
name: "test",
email: "test@gmail.com",
});
expect(parsedResponses.success).toBe(false);
if (parsedResponses.success) {
throw new Error("Should not reach here");
}
expect(parsedResponses.error.issues[0]).toEqual(
expect.objectContaining({
code: "custom",
message: `{email}${CUSTOM_EMAIL_EXCLUDED_ERROR_MSG}`,
})
);
});
});
describe("require email/domain validation", () => {
@@ -24,6 +24,28 @@ const ensureValidPhoneNumber = (value: string) => {
// Replace the space(s) in the beginning with + as it is supposed to be provided in the beginning only
return value.replace(/^ +/, "+");
};
/**
* Checks if a booker email matches an email/domain entry.
* Supports three formats:
* - Full email: "user@example.com" - matches exactly
* - Domain with @ prefix: "@example.com" - matches any email ending with "@example.com"
* - Domain without @ prefix: "example.com" - matches any email ending with "@example.com"
*/
const doesEmailMatchEntry = (bookerEmail: string, entry: string): boolean => {
const bookerEmailLower = bookerEmail.toLowerCase();
if (entry.startsWith("@")) {
const domain = entry.slice(1).toLowerCase();
return bookerEmailLower.endsWith("@" + domain);
}
if (entry.includes("@")) {
return bookerEmailLower === entry.toLowerCase();
}
return bookerEmailLower.endsWith("@" + entry.toLowerCase());
};
export const getBookingResponsesPartialSchema = ({ bookingFields, view, translateFn }: CommonParams) => {
const schema = bookingResponses.unwrap().partial().and(catchAllSchema);
@@ -208,7 +230,7 @@ function preprocess<T extends z.ZodType>({
const excludedEmails =
bookingField.excludeEmails?.split(",").map((domain) => domain.trim()) || [];
const match = excludedEmails.find((email) => bookerEmail.endsWith("@" + email));
const match = excludedEmails.find((excludedEntry) => doesEmailMatchEntry(bookerEmail, excludedEntry));
if (match) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@@ -220,7 +242,9 @@ function preprocess<T extends z.ZodType>({
?.split(",")
.map((domain) => domain.trim())
.filter(Boolean) || [];
const requiredEmailsMatch = requiredEmails.find((email) => bookerEmail.endsWith("@" + email));
const requiredEmailsMatch = requiredEmails.find((requiredEntry) =>
doesEmailMatchEntry(bookerEmail, requiredEntry)
);
if (requiredEmails.length > 0 && !requiredEmailsMatch) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@@ -0,0 +1,52 @@
import type { BookingStatus } from "@calcom/prisma/enums";
export const buildBookingCreatedAuditData = ({
booking,
attendeeSeatId,
}: {
attendeeSeatId: string | null;
booking: {
startTime: Date;
endTime: Date;
status: BookingStatus;
userUuid: string | null;
};
}) => {
return {
startTime: booking.startTime.getTime(),
endTime: booking.endTime.getTime(),
status: booking.status,
hostUserUuid: booking.userUuid,
seatReferenceUid: attendeeSeatId,
};
};
export const buildBookingRescheduledAuditData = ({
oldBooking,
newBooking,
}: {
oldBooking: {
startTime: Date;
endTime: Date;
};
newBooking: {
startTime: Date;
endTime: Date;
uid: string;
};
}) => {
return {
startTime: {
old: oldBooking.startTime.getTime(),
new: newBooking.startTime.getTime(),
},
endTime: {
old: oldBooking.endTime.getTime(),
new: newBooking.endTime.getTime(),
},
rescheduledToUid: {
old: null,
new: newBooking.uid,
},
};
};
@@ -143,7 +143,7 @@ async function saveBooking(
const createBookingObj = {
include: {
user: {
select: { email: true, name: true, timeZone: true, username: true },
select: { uuid: true, email: true, name: true, timeZone: true, username: true },
},
attendees: true,
payment: true,
@@ -182,7 +182,7 @@ async function saveBooking(
await tx.app_RoutingForms_FormResponse.update(reroutingFormResponseUpdateData);
}
return booking;
return { ...booking, userUuid: booking.user?.uuid ?? null };
});
}
@@ -258,8 +258,8 @@ function buildNewBookingData(params: CreateBookingParams) {
destinationCalendar:
evt.destinationCalendar && evt.destinationCalendar.length > 0
? {
connect: { id: evt.destinationCalendar[0].id },
}
connect: { id: evt.destinationCalendar[0].id },
}
: undefined,
routedFromRoutingFormReponse: routingFormResponseId
@@ -19,6 +19,7 @@ const _findBookingQuery = async (bookingId: number) => {
metadata: true,
user: {
select: {
uuid: true,
name: true,
email: true,
timeZone: true,
@@ -0,0 +1,15 @@
import { CreationSource } from "@calcom/prisma/enums";
import type { ActionSource } from "@calcom/features/booking-audit/lib/types/actionSource";
import { criticalLogger } from "@calcom/lib/logger.server";
export const getAuditActionSource = ({ creationSource, eventTypeId, rescheduleUid }: { creationSource: CreationSource | null | undefined, eventTypeId: number, rescheduleUid: string | null }): ActionSource => {
if (creationSource === CreationSource.API_V1 || creationSource === CreationSource.API_V2 || creationSource === CreationSource.WEBAPP) {
return creationSource;
}
// Unknown creationSource - log for tracking and fix
criticalLogger.warn("Unknown booking creationSource detected", {
eventTypeId,
rescheduleUid,
});
return "UNKNOWN";
};
@@ -0,0 +1,67 @@
import {
makeAttendeeActor,
makeUserActor,
makeGuestActor,
} from "@calcom/features/booking-audit/lib/makeActor";
import { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service";
import { safeStringify } from "@calcom/lib/safeStringify";
/**
* Used to create actor for new booking/reschedule booking scenarios
*/
export function getBookingAuditActorForNewBooking({
bookerAttendeeId,
actorUserUuid,
rescheduledBy,
bookerEmail,
logger,
bookerName,
}: {
bookerAttendeeId: number | null;
actorUserUuid: string | null;
bookerEmail: string;
bookerName: string;
logger: ISimpleLogger;
rescheduledBy:
| {
attendeeId: number | null;
email: string;
}
| {
userUuid: string | null;
email: string;
}
| null;
}) {
if (actorUserUuid) {
return makeUserActor(actorUserUuid);
}
if (rescheduledBy) {
// If rescheduledBy is available we prefer that above considering booker as the actor
if ("attendeeId" in rescheduledBy && rescheduledBy.attendeeId) {
return makeAttendeeActor(rescheduledBy.attendeeId);
}
if ("userUuid" in rescheduledBy && rescheduledBy.userUuid) {
// We consider that a user actor did it without verifying the authorization, so whn taking the action we could record in the context that this action was taken through rescheduledBy contex, similar to how we would do impersonatedBy in context
// as introduced in PR: https://github.com/calcom/cal.com/pull/26014
return makeUserActor(rescheduledBy.userUuid);
}
return makeGuestActor({ email: rescheduledBy.email, name: rescheduledBy.email });
}
if (bookerAttendeeId) {
return makeAttendeeActor(bookerAttendeeId);
}
// Ideally we should have attendeeId atleast but for some unforeseen case we don't, we must create a guest actor.
logger.warn(
"Creating guest actor for booking audit",
safeStringify({
email: bookerEmail,
})
);
return makeGuestActor({ email: bookerEmail, name: bookerName });
}
@@ -7,11 +7,123 @@ import { ErrorCode } from "@calcom/lib/errorCodes";
import { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import { safeStringify } from "@calcom/lib/safeStringify";
import { createLoggerWithEventDetails } from "../handleNewBooking/logger";
import createNewSeat from "./create/createNewSeat";
import rescheduleSeatedBooking from "./reschedule/rescheduleSeatedBooking";
import type { NewSeatedBookingObject, SeatedBooking, HandleSeatsResultBooking } from "./types";
import { getBookingAuditActorForNewBooking } from "../handleNewBooking/getBookingAuditActorForNewBooking";
import type { BookingEventHandlerService } from "../onBookingEvents/BookingEventHandlerService";
import type { ActionSource } from "@calcom/features/booking-audit/lib/types/actionSource";
import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service";
const fireBookingEvents = async ({
previousSeatedBooking,
newBooking,
originalRescheduledBooking,
rescheduleUid,
organizationId,
bookerEmail,
bookerName,
rescheduledBy,
actionSource,
actorUserUuid,
deps,
}: {
previousSeatedBooking: SeatedBooking;
newBooking: NonNullable<HandleSeatsResultBooking>;
originalRescheduledBooking: NewSeatedBookingObject["originalRescheduledBooking"];
rescheduleUid: string | undefined;
organizationId: number | null | undefined;
bookerEmail: string;
bookerName: string;
rescheduledBy: string | undefined;
actionSource: ActionSource;
actorUserUuid: string | null;
deps: {
bookingEventHandler: BookingEventHandlerService;
logger: ISimpleLogger;
};
}) => {
try {
const bookerAttendeeId = newBooking.attendees?.find((attendee) => attendee.email === bookerEmail)?.id;
const rescheduledByAttendeeId = newBooking.attendees?.find(
(attendee) => attendee.email === rescheduledBy
)?.id;
const rescheduledByUserUuid = newBooking.user?.email === rescheduledBy ? newBooking.user?.uuid : null;
const auditActor = getBookingAuditActorForNewBooking({
bookerAttendeeId: bookerAttendeeId ?? null,
actorUserUuid,
bookerEmail,
bookerName,
rescheduledBy: rescheduledBy
? {
attendeeId: rescheduledByAttendeeId ?? null,
userUuid: rescheduledByUserUuid ?? null,
email: rescheduledBy,
}
: null,
logger: deps.logger,
});
const seatReferenceUid = newBooking.seatReferenceUid;
if (!seatReferenceUid) {
return;
}
if (rescheduleUid && originalRescheduledBooking) {
const movedToDifferentBooking = newBooking.uid && newBooking.uid !== previousSeatedBooking.uid;
const newBookingStartTimeMs =
movedToDifferentBooking && newBooking.startTime
? newBooking.startTime.getTime()
: previousSeatedBooking.startTime.getTime();
const newBookingEndTimeMs =
movedToDifferentBooking && newBooking.endTime
? newBooking.endTime.getTime()
: previousSeatedBooking.endTime.getTime();
await deps.bookingEventHandler.onSeatRescheduled({
bookingUid: previousSeatedBooking.uid,
actor: auditActor,
organizationId: organizationId ?? null,
auditData: {
seatReferenceUid,
attendeeEmail: bookerEmail,
startTime: {
old: originalRescheduledBooking.startTime.getTime(),
new: newBookingStartTimeMs,
},
endTime: {
old: originalRescheduledBooking.endTime.getTime(),
new: newBookingEndTimeMs,
},
rescheduledToBookingUid: {
old: null,
new: movedToDifferentBooking ? newBooking.uid || null : null,
},
},
source: actionSource,
});
} else {
await deps.bookingEventHandler.onSeatBooked({
bookingUid: previousSeatedBooking.uid,
actor: auditActor,
organizationId: organizationId ?? null,
auditData: {
seatReferenceUid,
attendeeEmail: bookerEmail,
attendeeName: bookerName,
startTime: previousSeatedBooking.startTime.getTime(),
endTime: previousSeatedBooking.endTime.getTime(),
},
source: actionSource,
});
}
} catch (error) {
deps.logger.error("Error while firing booking events", safeStringify(error));
}
};
const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => {
const {
@@ -23,6 +135,7 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => {
bookerEmail,
smsReminderNumber,
eventTypeInfo,
reqUserUuid,
uid,
originalRescheduledBooking,
reqBodyMetadata,
@@ -34,7 +147,11 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => {
rescheduledBy,
rescheduleReason,
isDryRun = false,
organizationId,
fullName,
traceContext,
actionSource,
deps: { bookingEventHandler },
} = newSeatedBookingObject;
// TODO: We could allow doing more things to support good dry run for seats
if (isDryRun) return;
@@ -104,6 +221,19 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => {
// If the resultBooking is defined we should trigger workflows else, trigger in handleNewBooking
if (resultBooking) {
await fireBookingEvents({
previousSeatedBooking: seatedBooking,
newBooking: resultBooking,
originalRescheduledBooking,
rescheduleUid,
organizationId,
bookerEmail,
bookerName: fullName,
rescheduledBy,
actionSource,
actorUserUuid: reqUserUuid ?? null,
deps: { bookingEventHandler, logger: loggerWithEventDetails },
});
const metadata = {
...(typeof resultBooking.metadata === "object" && resultBooking.metadata),
...reqBodyMetadata,
@@ -1,4 +1,4 @@
import { cloneDeep } from "lodash";
import { sendRescheduledEmailsAndSMS } from "@calcom/emails/email-manager";
@@ -13,6 +13,26 @@ import { handleAppsStatus } from "../../../handleNewBooking/handleAppsStatus";
import type { createLoggerWithEventDetails } from "../../../handleNewBooking/logger";
import type { SeatedBooking, RescheduleSeatedBookingObject } from "../../types";
async function updateBooking({ bookingId, startTime, endTime, cancellationReason }: { bookingId: number, startTime: string, endTime: string, cancellationReason: string }): Promise<(Booking & { appsStatus?: AppsStatus[] })> {
const booking = await prisma.booking.update({
where: {
id: bookingId,
},
data: {
startTime,
endTime,
cancellationReason,
},
include: {
user: true,
references: true,
payment: true,
attendees: true,
},
});
return { ...booking, userUuid: booking.user?.uuid ?? null };
}
const moveSeatedBookingToNewTimeSlot = async (
rescheduleSeatedBookingObject: RescheduleSeatedBookingObject,
seatedBooking: SeatedBooking,
@@ -31,22 +51,7 @@ const moveSeatedBookingToNewTimeSlot = async (
} = rescheduleSeatedBookingObject;
let { evt } = rescheduleSeatedBookingObject;
const newBooking: (Booking & { appsStatus?: AppsStatus[] }) | null = await prisma.booking.update({
where: {
id: seatedBooking.id,
},
data: {
startTime: evt.startTime,
endTime: evt.endTime,
cancellationReason: rescheduleReason,
},
include: {
user: true,
references: true,
payment: true,
attendees: true,
},
});
const newBooking = await updateBooking({ bookingId: seatedBooking.id, startTime: evt.startTime, endTime: evt.endTime, cancellationReason: rescheduleReason });
evt = { ...addVideoCallDataToEvent(newBooking.references, evt), bookerUrl: evt.bookerUrl };
+14 -6
View File
@@ -5,6 +5,8 @@ import type { TraceContext } from "@calcom/lib/tracing";
import type { Prisma } from "@calcom/prisma/client";
import type { AppsStatus, CalendarEvent } from "@calcom/types/Calendar";
import type { BookingEventHandlerService } from "../../onBookingEvents/BookingEventHandlerService";
import type { ActionSource } from "@calcom/features/booking-audit/lib/types/actionSource";
import type { Booking } from "../handleNewBooking/createBooking";
import type { NewBookingEventType } from "../handleNewBooking/getEventTypesFromDB";
import type { OriginalRescheduledBooking } from "../handleNewBooking/originalRescheduledBookingUtils";
@@ -39,6 +41,7 @@ export type NewSeatedBookingObject = {
tAttendees: TFunction;
bookingSeat: BookingSeat;
reqUserId: number | undefined;
reqUserUuid?: string | null;
rescheduleReason: RescheduleReason;
reqBodyUser: string | string[] | undefined;
noEmail: NoEmail;
@@ -59,7 +62,12 @@ export type NewSeatedBookingObject = {
rescheduledBy?: string;
workflows: Workflow[];
isDryRun?: boolean;
organizationId?: number | null;
actionSource: ActionSource;
traceContext: TraceContext;
deps: {
bookingEventHandler: BookingEventHandlerService;
}
};
export type RescheduleSeatedBookingObject = NewSeatedBookingObject & { rescheduleUid: string };
@@ -81,12 +89,12 @@ export type SeatedBooking = Prisma.BookingGetPayload<{
export type HandleSeatsResultBooking =
| (Partial<Booking> & {
appsStatus?: AppsStatus[];
seatReferenceUid?: string;
paymentUid?: string;
message?: string;
paymentId?: number;
})
appsStatus?: AppsStatus[];
seatReferenceUid?: string;
paymentUid?: string;
message?: string;
paymentId?: number;
})
| null;
export type NewTimeSlotBooking = Prisma.BookingGetPayload<{
@@ -61,9 +61,9 @@ const isWithinRRHostSubset = <T extends { isFixed: boolean; user: { id: number }
rrHostSubsetEnabled,
schedulingType,
}: { rrHostSubsetEnabled: boolean; schedulingType?: SchedulingType } = {
rrHostSubsetEnabled: false,
schedulingType: undefined,
}
rrHostSubsetEnabled: false,
schedulingType: undefined,
}
): host is T & { isFixed: false } => {
if (rrHostSubsetIds.length === 0 || !rrHostSubsetEnabled || schedulingType !== SchedulingType.ROUND_ROBIN) {
return true;
@@ -72,12 +72,13 @@ const isWithinRRHostSubset = <T extends { isFixed: boolean; user: { id: number }
};
export class QualifiedHostsService {
constructor(public readonly dependencies: IQualifiedHostsService) {}
constructor(public readonly dependencies: IQualifiedHostsService) { }
async _findQualifiedHostsWithDelegationCredentials<
T extends {
email: string;
id: number;
uuid: string;
credentials: CredentialPayload[];
userLevelSelectedCalendars: SelectedCalendar[];
} & Record<string, unknown>
@@ -357,6 +357,52 @@ export class BookingEventHandlerService {
});
}
async onBulkBookingsCreated(params: {
bookings: Array<{
bookingUid: string;
auditData: CreatedAuditData;
}>;
actor: Actor;
organizationId: number | null;
operationId?: string | null;
source: ActionSource;
}) {
const { bookings, actor, organizationId, operationId, source } = params;
await this.bookingAuditProducerService.queueBulkCreatedAudit({
bookings: bookings.map((booking) => ({
bookingUid: booking.bookingUid,
data: booking.auditData,
})),
actor,
organizationId,
source,
operationId,
});
}
async onBulkBookingsRescheduled(params: {
bookings: Array<{
bookingUid: string;
auditData: RescheduledAuditData;
}>;
actor: Actor;
organizationId: number | null;
operationId?: string | null;
source: ActionSource;
}) {
const { bookings, actor, organizationId, operationId, source } = params;
await this.bookingAuditProducerService.queueBulkRescheduledAudit({
bookings: bookings.map((booking) => ({
bookingUid: booking.bookingUid,
data: booking.auditData,
})),
actor,
organizationId,
source,
operationId,
});
}
async onBulkBookingsRejected(params: {
bookings: Array<{
bookingUid: string;
@@ -158,6 +158,7 @@ describe("handleNewRecurringBooking", () => {
bookingMeta: {
userId: -1, // Simulating anonymous user like in the API test
},
creationSource: "WEBAPP",
});
expect(createdBookings.length).toBe(numOfSlotsToBeBooked);
@@ -1,19 +1,36 @@
import type { CreateBookingMeta, CreateRecurringBookingData } from "@calcom/features/bookings/lib/dto/types";
import type { BookingResponse } from "@calcom/features/bookings/types";
import { SchedulingType } from "@calcom/prisma/enums";
import { CreationSource, SchedulingType } from "@calcom/prisma/enums";
import type { AppsStatus } from "@calcom/types/Calendar";
import { v4 as uuidv4 } from "uuid";
import type { BookingStatus } from "@calcom/prisma/enums";
import type { IBookingService } from "../interfaces/IBookingService";
import type { RegularBookingService } from "./RegularBookingService";
import type { BookingEventHandlerService } from "../onBookingEvents/BookingEventHandlerService";
import { getBookingAuditActorForNewBooking } from "../handleNewBooking/getBookingAuditActorForNewBooking";
import { criticalLogger } from "@calcom/lib/logger.server";
import { getAuditActionSource } from "../handleNewBooking/getAuditActionSource";
import { safeStringify } from "@calcom/lib/safeStringify";
import {
buildBookingCreatedAuditData,
buildBookingRescheduledAuditData,
} from "../handleNewBooking/buildBookingEventAuditData";
export type BookingHandlerInput = {
bookingData: CreateRecurringBookingData;
} & CreateBookingMeta;
export const handleNewRecurringBooking = async (
input: BookingHandlerInput,
deps: IRecurringBookingServiceDependencies
): Promise<BookingResponse[]> => {
export const handleNewRecurringBooking = async function (
this: RecurringBookingService,
{
input,
deps,
creationSource,
}: {
input: BookingHandlerInput;
deps: IRecurringBookingServiceDependencies;
creationSource: CreationSource;
}
): Promise<BookingResponse[]> {
const data = input.bookingData;
const { regularBookingService } = deps;
const createdBookings: BookingResponse[] = [];
@@ -121,11 +138,24 @@ export const handleNewRecurringBooking = async (
}
}
}
if (createdBookings.length > 0) {
await this.fireBookingEvents({
createdBookings,
eventTypeId: firstBooking.eventTypeId,
rescheduleUid: firstBooking.rescheduleUid ?? null,
actorUserUuid: input.userUuid ?? null,
rescheduledBy: firstBooking.rescheduledBy ?? null,
creationSource,
});
}
return createdBookings;
};
export interface IRecurringBookingServiceDependencies {
regularBookingService: RegularBookingService;
bookingEventHandler: BookingEventHandlerService;
}
/**
@@ -134,19 +164,144 @@ export interface IRecurringBookingServiceDependencies {
export class RecurringBookingService implements IBookingService {
constructor(private readonly deps: IRecurringBookingServiceDependencies) {}
async fireBookingEvents({
createdBookings,
eventTypeId,
rescheduleUid,
actorUserUuid,
rescheduledBy,
creationSource,
}: {
createdBookings: BookingResponse[];
eventTypeId: number;
rescheduleUid: string | null;
actorUserUuid: string | null;
rescheduledBy: string | null;
creationSource: CreationSource | undefined;
}) {
try {
type ValidBooking = BookingResponse & {
uid: string;
startTime: Date;
endTime: Date;
status: BookingStatus;
userUuid: string | null;
};
type ValidRescheduledBooking = ValidBooking & {
previousBooking: ValidBooking & { status: BookingStatus };
};
const isReschedule = !!rescheduleUid;
const firstCreatedBooking = createdBookings[0];
const eventOrganizationId = firstCreatedBooking.organizationId;
const bookerAttendee = firstCreatedBooking.attendees?.[0];
const bookerAttendeeId = bookerAttendee?.id;
const bookerName = bookerAttendee?.name || "";
const bookerEmail = bookerAttendee?.email || "";
const rescheduledByAttendeeId = firstCreatedBooking.attendees?.find(
(attendee) => attendee.email === rescheduledBy
)?.id;
// TODO: Note that user.email is always null here as RegularBookingService intentionally sets it to null. To fix, we need to separate out external facing .createBooking and one that is used by RecurringBookingService, so that if we expose something there it doesn't get exposed elsewhere
const rescheduledByUserUuid =
firstCreatedBooking.user?.email === rescheduledBy ? firstCreatedBooking.userUuid : null;
const auditActor = getBookingAuditActorForNewBooking({
bookerAttendeeId: bookerAttendeeId ?? null,
actorUserUuid,
bookerEmail,
bookerName,
rescheduledBy: rescheduledBy
? {
attendeeId: rescheduledByAttendeeId ?? null,
userUuid: rescheduledByUserUuid ?? null,
email: rescheduledBy,
}
: null,
logger: criticalLogger,
});
const actionSource = getAuditActionSource({ creationSource, eventTypeId, rescheduleUid });
const operationId = uuidv4();
const isValidBooking = (booking: BookingResponse): booking is ValidBooking => {
return !!(booking.uid && booking.startTime && booking.endTime && booking.status);
};
const isValidRescheduledBooking = (booking: BookingResponse): booking is ValidRescheduledBooking => {
return !!(
isValidBooking(booking) &&
booking.previousBooking &&
booking.previousBooking.uid &&
booking.previousBooking.startTime &&
booking.previousBooking.endTime
);
};
if (isReschedule) {
const bulkRescheduledBookings = createdBookings.filter(isValidRescheduledBooking).map((booking) => ({
bookingUid: booking.previousBooking.uid,
auditData: buildBookingRescheduledAuditData({
oldBooking: booking.previousBooking,
newBooking: booking,
}),
}));
if (bulkRescheduledBookings.length > 0) {
await this.deps.bookingEventHandler.onBulkBookingsRescheduled({
bookings: bulkRescheduledBookings,
actor: auditActor,
organizationId: eventOrganizationId,
operationId,
source: actionSource,
});
}
} else {
// For new bookings
const bulkCreatedBookings = createdBookings.filter(isValidBooking).map((booking) => ({
bookingUid: booking.uid,
auditData: buildBookingCreatedAuditData({ booking, attendeeSeatId: null }),
}));
if (bulkCreatedBookings.length > 0) {
await this.deps.bookingEventHandler.onBulkBookingsCreated({
bookings: bulkCreatedBookings,
actor: auditActor,
organizationId: eventOrganizationId,
operationId,
source: actionSource,
});
}
}
} catch (error) {
criticalLogger.error("Error while firing booking events", safeStringify(error));
}
}
async createBooking(input: {
bookingData: CreateRecurringBookingData;
bookingMeta?: CreateBookingMeta;
creationSource: CreationSource;
}): Promise<BookingResponse[]> {
const handlerInput = { bookingData: input.bookingData, ...(input.bookingMeta || {}) };
return handleNewRecurringBooking(handlerInput, this.deps);
return handleNewRecurringBooking.bind(this)({
input: handlerInput,
deps: this.deps,
creationSource: input.creationSource,
});
}
async rescheduleBooking(input: {
bookingData: CreateRecurringBookingData;
bookingMeta?: CreateBookingMeta;
creationSource: CreationSource;
}): Promise<BookingResponse[]> {
const handlerInput = { bookingData: input.bookingData, ...(input.bookingMeta || {}) };
return handleNewRecurringBooking(handlerInput, this.deps);
return handleNewRecurringBooking.bind(this)({
input: handlerInput,
deps: this.deps,
creationSource: input.creationSource,
});
}
}
@@ -1,6 +1,11 @@
import short, { uuid } from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import { getAuditActionSource } from "../handleNewBooking/getAuditActionSource";
import {
buildBookingCreatedAuditData,
buildBookingRescheduledAuditData,
} from "../handleNewBooking/buildBookingEventAuditData";
import type { ActionSource } from "@calcom/features/booking-audit/lib/types/actionSource";
import processExternalId from "@calcom/app-store/_utils/calendars/processExternalId";
import { getPaymentAppData } from "@calcom/app-store/_utils/payments/getPaymentAppData";
import {
@@ -127,8 +132,8 @@ import { validateBookingTimeIsNotOutOfBounds } from "../handleNewBooking/validat
import { validateEventLength } from "../handleNewBooking/validateEventLength";
import handleSeats from "../handleSeats/handleSeats";
import type { IBookingService } from "../interfaces/IBookingService";
import { getBookingAuditActorForNewBooking } from "../handleNewBooking/getBookingAuditActorForNewBooking";
import { isWithinMinimumRescheduleNotice } from "../reschedule/isWithinMinimumRescheduleNotice";
import { makeGuestActor } from "@calcom/features/booking-audit/lib/makeActor";
const translator = short();
@@ -175,6 +180,7 @@ export const buildDryRunBooking = ({
eventTypeId: number;
organizerUser: {
id: number;
uuid: string;
name: string | null;
username: string | null;
email: string;
@@ -190,6 +196,7 @@ export const buildDryRunBooking = ({
}) => {
const sanitizedOrganizerUser = {
id: organizerUser.id,
uuid: organizerUser.uuid,
name: organizerUser.name,
username: organizerUser.username,
email: organizerUser.email,
@@ -203,6 +210,7 @@ export const buildDryRunBooking = ({
eventTypeId: eventTypeId,
user: sanitizedOrganizerUser,
userId: sanitizedOrganizerUser.id,
userUuid: sanitizedOrganizerUser.uuid,
title: eventName,
startTime: new Date(startTime),
endTime: new Date(endTime),
@@ -418,6 +426,7 @@ function formatAvailabilitySnapshot(data: {
function buildBookingCreatedPayload({
booking,
organizerUserId,
organizerUserUuid,
hashedLink,
isDryRun,
organizationId,
@@ -431,6 +440,7 @@ function buildBookingCreatedPayload({
userId: number | null;
};
organizerUserId: number;
organizerUserUuid: string | null;
hashedLink: string | null;
isDryRun: boolean;
organizationId: number | null;
@@ -449,6 +459,7 @@ function buildBookingCreatedPayload({
endTime: booking.endTime,
status: booking.status,
userId: booking.userId,
userUuid: organizerUserUuid,
user: {
id: organizerUserId,
},
@@ -560,6 +571,7 @@ async function getEventOrganizationId({
}
async function handler(
this: RegularBookingService,
input: BookingHandlerInput,
deps: IBookingServiceDependencies,
bookingDataSchemaGetter: BookingDataSchemaGetter = getBookingDataSchema
@@ -567,6 +579,7 @@ async function handler(
const {
bookingData: rawBookingData,
userId,
userUuid,
platformClientId,
platformCancelUrl,
platformBookingUrl,
@@ -605,9 +618,9 @@ async function handler(
userId: userId ?? null,
eventType: eventType
? {
seatsPerTimeSlot: eventType.seatsPerTimeSlot,
minimumRescheduleNotice: eventType.minimumRescheduleNotice ?? null,
}
seatsPerTimeSlot: eventType.seatsPerTimeSlot,
minimumRescheduleNotice: eventType.minimumRescheduleNotice ?? null,
}
: null,
});
@@ -788,6 +801,14 @@ async function handler(
...(isDryRun ? { troubleshooterData } : {}),
paymentUid: firstPayment?.uid,
paymentId: firstPayment?.id,
organizationId: eventOrganizationId,
previousBooking: originalRescheduledBooking
? {
uid: originalRescheduledBooking.uid,
startTime: originalRescheduledBooking.startTime,
endTime: originalRescheduledBooking.endTime,
}
: null,
};
}
}
@@ -1259,7 +1280,7 @@ async function handler(
// If the team member is requested then they should be the organizer
const organizerUser = reqBody.teamMemberEmail
? users.find((user) => user.email === reqBody.teamMemberEmail) ?? users[0]
? (users.find((user) => user.email === reqBody.teamMemberEmail) ?? users[0])
: users[0];
const tOrganizer = await getTranslation(organizerUser?.locale ?? "en", "common");
@@ -1377,9 +1398,9 @@ async function handler(
// This ensures that createMeeting isn't called for static video apps as bookingLocation becomes just a regular value for them.
const { bookingLocation, conferenceCredentialId } = organizerOrFirstDynamicGroupMemberDefaultLocationUrl
? {
bookingLocation: organizerOrFirstDynamicGroupMemberDefaultLocationUrl,
conferenceCredentialId: undefined,
}
bookingLocation: organizerOrFirstDynamicGroupMemberDefaultLocationUrl,
conferenceCredentialId: undefined,
}
: getLocationValueForDB(locationBodyString, eventType.locations);
tracingLogger.info("locationBodyString", locationBodyString);
@@ -1502,7 +1523,7 @@ async function handler(
platformBookingUrl,
})
.withOrganization(organizerOrganizationId)
.withHashedLink(hasHashedBookingLink ? reqBody.hashedLink ?? null : null)
.withHashedLink(hasHashedBookingLink ? (reqBody.hashedLink ?? null) : null)
.build();
if (!builtEvt) {
@@ -1627,6 +1648,7 @@ async function handler(
email: null,
},
userId: null,
userUuid: null,
title: eventName,
startTime: new Date(reqBody.start),
endTime: new Date(reqBody.end),
@@ -1681,10 +1703,24 @@ async function handler(
luckyUsers: [],
paymentId: undefined,
seatReferenceUid: undefined,
isShortCircuitedBooking: true, // Renamed from isSpamDecoy to avoid exposing spam detection to blocked users
isShortCircuitedBooking: true,
organizationId: eventOrganizationId,
previousBooking: originalRescheduledBooking
? {
uid: originalRescheduledBooking.uid,
startTime: originalRescheduledBooking.startTime,
endTime: originalRescheduledBooking.endTime,
}
: null,
};
}
const actionSource = getAuditActionSource({
creationSource: input.bookingData.creationSource,
eventTypeId,
rescheduleUid: originalRescheduledBooking?.uid ?? null,
});
// For seats, if the booking already exists then we want to add the new attendee to the existing booking
if (eventType.seatsPerTimeSlot) {
const newBooking = await handleSeats({
@@ -1701,6 +1737,7 @@ async function handler(
tAttendees,
bookingSeat,
reqUserId: input.userId,
reqUserUuid: userUuid,
rescheduleReason,
reqBodyUser: reqBody.user,
noEmail,
@@ -1721,7 +1758,10 @@ async function handler(
workflows,
rescheduledBy: reqBody.rescheduledBy,
isDryRun,
organizationId: eventOrganizationId,
actionSource,
traceContext,
deps,
});
if (newBooking) {
@@ -1738,6 +1778,14 @@ async function handler(
return {
...bookingResponse,
...luckyUserResponse,
organizationId: eventOrganizationId,
previousBooking: originalRescheduledBooking
? {
uid: originalRescheduledBooking.uid,
startTime: originalRescheduledBooking.startTime,
endTime: originalRescheduledBooking.endTime,
}
: null,
};
} else {
// Rescheduling logic for the original seated event was handled in handleSeats
@@ -2046,20 +2094,21 @@ async function handler(
});
}
}
const updateManager = !skipCalendarSyncTaskCreation
? await eventManager.reschedule(
evt,
originalRescheduledBooking.uid,
undefined,
changedOrganizer,
previousHostDestinationCalendar,
isBookingRequestedReschedule,
skipDeleteEventsAndMeetings
)
: placeholderCreatedEvent;
// This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back
// to the default description when we are sending the emails.
evt.description = eventType.description ?? evt.description;
evt.description = eventType.description;
const updateManager = !skipCalendarSyncTaskCreation
? await eventManager.reschedule(
evt,
originalRescheduledBooking.uid,
undefined,
changedOrganizer,
previousHostDestinationCalendar,
isBookingRequestedReschedule,
skipDeleteEventsAndMeetings
)
: placeholderCreatedEvent;
results = updateManager.results;
referencesToCreate = updateManager.referencesToCreate;
@@ -2119,7 +2168,7 @@ async function handler(
const googleHangoutLink = Array.isArray(googleCalResult?.updatedEvent)
? googleCalResult.updatedEvent[0]?.hangoutLink
: googleCalResult?.updatedEvent?.hangoutLink ?? googleCalResult?.createdEvent?.hangoutLink;
: (googleCalResult?.updatedEvent?.hangoutLink ?? googleCalResult?.createdEvent?.hangoutLink);
if (googleHangoutLink) {
results.push({
@@ -2149,7 +2198,7 @@ async function handler(
}
const createdOrUpdatedEvent = Array.isArray(results[0]?.updatedEvent)
? results[0]?.updatedEvent[0]
: results[0]?.updatedEvent ?? results[0]?.createdEvent;
: (results[0]?.updatedEvent ?? results[0]?.createdEvent);
metadata.hangoutLink = createdOrUpdatedEvent?.hangoutLink;
metadata.conferenceData = createdOrUpdatedEvent?.conferenceData;
metadata.entryPoints = createdOrUpdatedEvent?.entryPoints;
@@ -2356,68 +2405,31 @@ async function handler(
const metadata = videoCallUrl
? {
videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl,
}
videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl,
}
: undefined;
const bookingCreatedPayload = buildBookingCreatedPayload({
booking,
organizerUserId: organizerUser.id,
await this.fireBookingEvents({
booking: {
...booking,
userEmail: booking.user?.email ?? null,
},
organizerUser,
// FIXME: It looks like hasHashedBookingLink is set to true based on the value of hashedLink when sending the request. So, technically we could remove hasHashedBookingLink usage completely
hashedLink: hasHashedBookingLink ? reqBody.hashedLink ?? null : null,
hashedLink: hasHashedBookingLink ? (reqBody.hashedLink ?? null) : null,
isDryRun,
organizationId: eventOrganizationId,
eventOrganizationId,
bookerEmail,
bookerName: fullName,
actorUserUuid: userUuid ?? null,
originalRescheduledBooking,
rescheduledBy: reqBody.rescheduledBy ?? null,
actionSource,
isRecurringBooking: !!input.bookingData.allRecurringDates,
attendeeSeatId: evt.attendeeSeatId ?? null,
tracingLogger,
});
const bookingEventHandler = deps.bookingEventHandler;
// TODO: Identify action source correctly
const actionSource = 'WEBAPP';
// TODO: We need to check session in booking flow and accordingly create USER actor if applicable.
const auditActor = makeGuestActor({ email: bookerEmail, name: fullName });
if (originalRescheduledBooking) {
const bookingRescheduledPayload: BookingRescheduledPayload = {
...bookingCreatedPayload,
oldBooking: {
uid: originalRescheduledBooking.uid,
startTime: originalRescheduledBooking.startTime,
endTime: originalRescheduledBooking.endTime,
},
};
await bookingEventHandler.onBookingRescheduled({
payload: bookingRescheduledPayload,
actor: auditActor,
auditData: {
startTime: {
old: bookingRescheduledPayload.oldBooking?.startTime.toISOString() ?? null,
new: bookingRescheduledPayload.booking.startTime.toISOString(),
},
endTime: {
old: bookingRescheduledPayload.oldBooking?.endTime.toISOString() ?? null,
new: bookingRescheduledPayload.booking.endTime.toISOString(),
},
rescheduledToUid: {
old: null,
new: bookingRescheduledPayload.booking.uid,
},
},
source: actionSource,
operationId: null,
});
} else {
await bookingEventHandler.onBookingCreated({
payload: bookingCreatedPayload,
actor: auditActor,
auditData: {
startTime: bookingCreatedPayload.booking.startTime.getTime(),
endTime: bookingCreatedPayload.booking.endTime.getTime(),
status: bookingCreatedPayload.booking.status,
},
source: actionSource,
operationId: null,
});
}
const webhookData: EventPayloadType = {
...evt,
...eventTypeInfo,
@@ -2480,9 +2492,9 @@ async function handler(
...eventType,
metadata: eventType.metadata
? {
...eventType.metadata,
apps: eventType.metadata?.apps as Prisma.JsonValue,
}
...eventType.metadata,
apps: eventType.metadata?.apps as Prisma.JsonValue,
}
: {},
},
paymentAppCredentials: eventTypePaymentAppCredential as IEventTypePaymentCredentialType,
@@ -2569,6 +2581,14 @@ async function handler(
paymentId: payment?.id,
isDryRun,
...(isDryRun ? { troubleshooterData } : {}),
organizationId: eventOrganizationId,
previousBooking: originalRescheduledBooking
? {
uid: originalRescheduledBooking.uid,
startTime: originalRescheduledBooking.startTime,
endTime: originalRescheduledBooking.endTime,
}
: null,
};
}
@@ -2811,6 +2831,14 @@ async function handler(
references: referencesToCreate,
seatReferenceUid: evt.attendeeSeatId,
videoCallUrl: metadata?.videoCallUrl,
organizationId: eventOrganizationId,
previousBooking: originalRescheduledBooking
? {
uid: originalRescheduledBooking.uid,
startTime: originalRescheduledBooking.startTime,
endTime: originalRescheduledBooking.endTime,
}
: null,
};
}
@@ -2820,14 +2848,122 @@ async function handler(
* We are open to renaming it to something more descriptive.
*/
export class RegularBookingService implements IBookingService {
constructor(private readonly deps: IBookingServiceDependencies) { }
constructor(private readonly deps: IBookingServiceDependencies) {}
async fireBookingEvents({
booking,
organizerUser,
hashedLink,
isDryRun,
eventOrganizationId,
bookerEmail,
bookerName,
actorUserUuid,
originalRescheduledBooking,
rescheduledBy,
actionSource,
isRecurringBooking,
attendeeSeatId,
tracingLogger,
}: {
booking: {
id: number;
uid: string;
startTime: Date;
endTime: Date;
status: BookingStatus;
userId: number | null;
attendees?: Array<{ id: number; email: string }>;
userUuid: string | null;
userEmail: string | null;
};
organizerUser: { id: number; uuid: string };
hashedLink: string | null;
isDryRun: boolean;
eventOrganizationId: number | null;
bookerEmail: string;
bookerName: string;
rescheduledBy: string | null;
actorUserUuid: string | null;
originalRescheduledBooking: BookingType | null;
actionSource: ActionSource;
isRecurringBooking: boolean;
tracingLogger: ReturnType<typeof distributedTracing.getTracingLogger>;
attendeeSeatId: string | null;
}) {
try {
const bookingCreatedPayload = buildBookingCreatedPayload({
booking,
organizerUserId: organizerUser.id,
organizerUserUuid: organizerUser.uuid,
hashedLink,
isDryRun,
organizationId: eventOrganizationId,
});
const bookingEventHandler = this.deps.bookingEventHandler;
const bookerAttendeeId = booking.attendees?.find((attendee) => attendee.email === bookerEmail)?.id;
const rescheduledByAttendeeId = booking.attendees?.find(
(attendee) => attendee.email === rescheduledBy
)?.id;
const rescheduledByUserUuid = booking.userEmail === rescheduledBy ? booking.userUuid : null;
const auditActor = getBookingAuditActorForNewBooking({
bookerAttendeeId: bookerAttendeeId ?? null,
actorUserUuid,
bookerEmail,
bookerName,
rescheduledBy: rescheduledBy
? {
attendeeId: rescheduledByAttendeeId ?? null,
userUuid: rescheduledByUserUuid ?? null,
email: rescheduledBy,
}
: null,
logger: tracingLogger,
});
// For recurring bookings we fire the events in the RecurringBookingService
if (!isRecurringBooking) {
if (originalRescheduledBooking) {
const bookingRescheduledPayload: BookingRescheduledPayload = {
...bookingCreatedPayload,
oldBooking: {
uid: originalRescheduledBooking.uid,
startTime: originalRescheduledBooking.startTime,
endTime: originalRescheduledBooking.endTime,
},
};
await bookingEventHandler.onBookingRescheduled({
payload: bookingRescheduledPayload,
actor: auditActor,
auditData: buildBookingRescheduledAuditData({
oldBooking: originalRescheduledBooking,
newBooking: booking,
}),
source: actionSource,
operationId: null,
});
} else {
await bookingEventHandler.onBookingCreated({
payload: bookingCreatedPayload,
actor: auditActor,
auditData: buildBookingCreatedAuditData({ booking, attendeeSeatId }),
source: actionSource,
operationId: null,
});
}
}
} catch (error) {
tracingLogger.error("Error while firing booking events", safeStringify(error));
}
}
async createBooking(input: { bookingData: CreateRegularBookingData; bookingMeta?: CreateBookingMeta }) {
return handler({ bookingData: input.bookingData, ...input.bookingMeta }, this.deps);
return handler.bind(this)({ bookingData: input.bookingData, ...input.bookingMeta }, this.deps);
}
async rescheduleBooking(input: { bookingData: CreateRegularBookingData; bookingMeta?: CreateBookingMeta }) {
return handler({ bookingData: input.bookingData, ...input.bookingMeta }, this.deps);
return handler.bind(this)({ bookingData: input.bookingData, ...input.bookingMeta }, this.deps);
}
/**
@@ -2839,7 +2975,7 @@ export class RegularBookingService implements IBookingService {
bookingDataSchemaGetter: BookingDataSchemaGetter;
}) {
const bookingMeta = input.bookingMeta ?? {};
return handler(
return handler.bind(this)(
{
bookingData: input.bookingData,
...bookingMeta,
@@ -388,6 +388,11 @@ export const updateEvent = async (
externalCalendarId: string | null
): Promise<EventResult<NewCalendarEventType>> => {
const formattedEvent = formatCalEvent(rawCalEvent);
if (formattedEvent.hideCalendarNotes) {
formattedEvent.additionalNotes = "Notes have been hidden by the organizer"; // TODO: i18n this string?
}
const calEvent = processEvent(formattedEvent);
const uid = getUid(calEvent);
const calendar = await getCalendar(credential, "booking");
@@ -500,7 +505,7 @@ export const deleteEvent = async ({
* Process the calendar event by generating description and removing attendees if needed
*/
const processEvent = (calEvent: CalendarEvent): CalendarServiceEvent => {
if (calEvent.seatsPerTimeSlot){
if (calEvent.seatsPerTimeSlot) {
calEvent.responses = null;
calEvent.userFieldsResponses = null;
calEvent.additionalNotes = null;
@@ -14,6 +14,7 @@ import classNames from "@calcom/ui/classNames";
const CUSTOM_PHONE_MASKS = {
ci: ".. .. .. .. ..",
bj: ".. .. .. .. ..",
at: "... ..........",
};
export type PhoneInputProps = {
@@ -80,7 +81,7 @@ function BasePhoneInput({
autoComplete: "tel",
}}
onChange={(val: string) => {
onChange(`+${val}`);
onChange(val.startsWith("+") ? val : `+${val}`);
}}
containerClass={classNames(
"hover:border-emphasis focus-within:border-emphasis border-default !bg-default rounded-md border focus-within:outline-none focus-within:ring-0 focus-within:ring-brand-default disabled:cursor-not-allowed",
@@ -132,7 +133,7 @@ function BasePhoneInputWeb({
autoComplete: "tel",
}}
onChange={(val: string) => {
onChange(`+${val}`);
onChange(val.startsWith("+") ? val : `+${val}`);
}}
containerClass={classNames(
"hover:border-emphasis focus-within:border-emphasis border-default !bg-default rounded-md border focus-within:outline-none focus-within:ring-0 focus-within:ring-brand-default disabled:cursor-not-allowed",
@@ -145,6 +145,29 @@ export function getTemplateBodyForAction({
return templateFunction({ isEditingMode: true, locale, t, action, timeFormat }).emailBody;
}
export function getTemplateSubjectForAction({
action,
locale,
t,
template,
timeFormat,
}: {
action: WorkflowActions;
locale: string;
t: TFunction;
template: WorkflowTemplates;
timeFormat: TimeFormat;
}): string | null {
// SMS and WhatsApp don't have subjects
if (isSMSAction(action) || isWhatsappAction(action)) {
return null;
}
// For email actions, get the subject from the template
const templateFunction = getEmailTemplateFunction(template);
return templateFunction({ isEditingMode: true, locale, t, action, timeFormat }).emailSubject;
}
export function isFormTrigger(trigger: WorkflowTriggerEvents) {
return FORM_TRIGGER_WORKFLOW_EVENTS.includes(trigger);
}
@@ -0,0 +1,64 @@
import { WorkflowTemplates } from "@calcom/prisma/enums";
import compareReminderBodyToTemplate from "./compareReminderBodyToTemplate";
export type DefaultTemplates = {
reminder: { body: string | null; subject: string | null };
rating: { body: string | null; subject: string | null };
};
export type DetectMatchedTemplateParams = {
emailBody: string;
emailSubject: string;
template?: WorkflowTemplates;
defaultTemplates: DefaultTemplates;
};
/**
* Detects if the email body and subject match a default template (REMINDER or RATING).
*
* Logic:
* 1. If emailBody is empty but template is specified as REMINDER or RATING, return that template
* 2. If emailBody exists, check if both body AND subject match the REMINDER default template
* 3. If not matched, check if both body AND subject match the RATING default template
* 4. Return null if no match (indicating custom content that should be preserved)
*
* This is used to determine whether to regenerate the template with the recipient's locale
* for proper translation, or to preserve user customizations.
*/
export function detectMatchedTemplate({
emailBody,
emailSubject,
template,
defaultTemplates,
}: DetectMatchedTemplateParams): WorkflowTemplates | null {
if (!emailBody && template === WorkflowTemplates.REMINDER) {
return WorkflowTemplates.REMINDER;
}
if (!emailBody && template === WorkflowTemplates.RATING) {
return WorkflowTemplates.RATING;
}
if (emailBody) {
const { reminder, rating } = defaultTemplates;
const bodyMatchesReminder =
reminder.body && compareReminderBodyToTemplate({ reminderBody: emailBody, template: reminder.body });
const subjectMatchesReminder = reminder.subject && emailSubject === reminder.subject;
if (bodyMatchesReminder && subjectMatchesReminder) {
return WorkflowTemplates.REMINDER;
}
const bodyMatchesRating =
rating.body && compareReminderBodyToTemplate({ reminderBody: emailBody, template: rating.body });
const subjectMatchesRating = rating.subject && emailSubject === rating.subject;
if (bodyMatchesRating && subjectMatchesRating) {
return WorkflowTemplates.RATING;
}
}
return null;
}
@@ -71,9 +71,9 @@ const emailReminderTemplate = ({
"date_and_time"
)}: </strong></div>${eventDate} - ${endTime} (${timeZone})<br><br>`;
const attendeeHtml = `<div><strong class="editor-text-bold">${t("attendees")}: </strong></div>${t(
"you_and_conjunction"
)} ${otherPerson}<br><br>`;
const attendeeHtml = `<div><strong class="editor-text-bold">${t(
"attendees"
)}: </strong></div>${t("you_and_conjunction")} ${otherPerson}<br><br>`;
const locationHtml = `<div><strong class="editor-text-bold">${t(
"location"
@@ -12,6 +12,7 @@ import { UserRepository } from "@calcom/features/users/repositories/UserReposito
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
import { SENDER_NAME, WEBSITE_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { TimeFormat } from "@calcom/lib/timeFormat";
import { getTranslation } from "@calcom/lib/server/i18n";
import { prisma } from "@calcom/prisma";
import { WorkflowActions, WorkflowTemplates } from "@calcom/prisma/enums";
@@ -20,7 +21,8 @@ import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import { CalendarEvent } from "@calcom/types/Calendar";
import type { WorkflowReminderRepository } from "../../repositories/WorkflowReminderRepository";
import { isEmailAction } from "../actionHelperFunctions";
import { isEmailAction, getTemplateBodyForAction, getTemplateSubjectForAction } from "../actionHelperFunctions";
import { detectMatchedTemplate } from "../detectMatchedTemplate";
import { getWorkflowRecipientEmail } from "../getWorkflowReminders";
import type { VariablesType } from "../reminders/templates/customTemplate";
import customTemplate, {
@@ -51,9 +53,8 @@ export class EmailWorkflowService {
evt: CalendarEvent;
workflowReminderId: number;
}) {
const workflowReminder = await this.workflowReminderRepository.findByIdIncludeStepAndWorkflow(
workflowReminderId
);
const workflowReminder =
await this.workflowReminderRepository.findByIdIncludeStepAndWorkflow(workflowReminderId);
if (!workflowReminder) {
throw new Error(`Workflow reminder not found with id ${workflowReminderId}`);
@@ -94,7 +95,9 @@ export class EmailWorkflowService {
const emailWorkflowContentParams = await this.generateParametersToBuildEmailWorkflowContent({
evt,
workflowStep: workflowReminder.workflowStep as WorkflowStep & { action: ScheduleEmailReminderAction },
workflowStep: workflowReminder.workflowStep as WorkflowStep & {
action: ScheduleEmailReminderAction;
},
workflow: workflowReminder.workflowStep.workflow,
emailAttendeeSendToOverride,
commonScheduleFunctionParams,
@@ -286,9 +289,8 @@ export class EmailWorkflowService {
case WorkflowActions.EMAIL_ATTENDEE: {
// For seated events, get the correct attendee based on seatReferenceUid
if (seatReferenceUid) {
const seatAttendeeData = await this.bookingSeatRepository.getByReferenceUidWithAttendeeDetails(
seatReferenceUid
);
const seatAttendeeData =
await this.bookingSeatRepository.getByReferenceUidWithAttendeeDetails(seatReferenceUid);
if (seatAttendeeData?.attendee) {
const nameParts = seatAttendeeData.attendee.name.split(" ").map((part: string) => part.trim());
const firstName = nameParts[0];
@@ -326,14 +328,105 @@ export class EmailWorkflowService {
throw new Error("Failed to determine attendee email");
}
const isEmailAttendeeAction = action === WorkflowActions.EMAIL_ATTENDEE;
const locale = isEmailAttendeeAction
? attendeeToBeUsedInMail.language?.locale || "en"
: evt.organizer.language.locale || "en";
let emailContent = {
emailSubject,
emailBody: `<body style="white-space: pre-wrap;">${emailBody}</body>`,
};
const bookerUrl = evt.bookerUrl ?? WEBSITE_URL;
// Detect if the email content matches a default template for locale-based regeneration
const timeFormat = evt.organizer.timeFormat || TimeFormat.TWELVE_HOUR;
let defaultTemplates = {
reminder: { body: null as string | null, subject: null as string | null },
rating: { body: null as string | null, subject: null as string | null },
};
if (emailBody) {
const isEmailAttendeeAction = action === WorkflowActions.EMAIL_ATTENDEE;
const tEn = await getTranslation("en", "common");
defaultTemplates = {
reminder: {
body: getTemplateBodyForAction({
action,
template: WorkflowTemplates.REMINDER,
locale: "en",
t: tEn,
timeFormat,
}),
subject: getTemplateSubjectForAction({
action,
template: WorkflowTemplates.REMINDER,
locale: "en",
t: tEn,
timeFormat,
}),
},
rating: {
body: getTemplateBodyForAction({
action,
template: WorkflowTemplates.RATING,
locale: "en",
t: tEn,
timeFormat,
}),
subject: getTemplateSubjectForAction({
action,
template: WorkflowTemplates.RATING,
locale: "en",
t: tEn,
timeFormat,
}),
},
};
}
const matchedTemplate = detectMatchedTemplate({
emailBody,
emailSubject,
template,
defaultTemplates,
});
if (matchedTemplate === WorkflowTemplates.REMINDER) {
const t = await getTranslation(locale, "common");
emailContent = emailReminderTemplate({
isEditingMode: false,
locale,
t,
action,
timeFormat: evt.organizer.timeFormat,
startTime,
endTime,
eventName: evt.title,
timeZone,
location: evt.location || "",
meetingUrl:
evt.videoCallData?.url || bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl || "",
otherPerson: attendeeName,
name,
});
} else if (matchedTemplate === WorkflowTemplates.RATING) {
emailContent = emailRatingTemplate({
isEditingMode: false,
locale,
action,
t: await getTranslation(locale, "common"),
timeFormat: evt.organizer.timeFormat,
startTime,
endTime,
eventName: evt.title,
timeZone,
organizer: evt.organizer.name,
name,
ratingUrl: `${bookerUrl}/booking/${evt.uid}?rating`,
noShowUrl: `${bookerUrl}/booking/${evt.uid}?noShow=true`,
});
} else if (emailBody) {
const recipientEmail = getWorkflowRecipientEmail({
action,
attendeeEmail: attendeeToBeUsedInMail.email,
@@ -368,8 +461,8 @@ export class EmailWorkflowService {
: ""
}`
: isEmailAttendeeAction && seatReferenceUid
? `?seatReferenceUid=${encodeURIComponent(seatReferenceUid)}`
: ""
? `?seatReferenceUid=${encodeURIComponent(seatReferenceUid)}`
: ""
}`,
rescheduleReason: evt.rescheduleReason,
@@ -380,10 +473,6 @@ export class EmailWorkflowService {
eventEndTimeInAttendeeTimezone: dayjs(endTime).tz(attendeeToBeUsedInMail.timeZone),
};
const locale = isEmailAttendeeAction
? attendeeToBeUsedInMail.language?.locale
: evt.organizer.language.locale;
const emailSubjectTemplate = customTemplate(emailSubject, variables, locale, evt.organizer.timeFormat);
emailContent.emailSubject = emailSubjectTemplate.text;
emailContent.emailBody = customTemplate(
@@ -393,39 +482,6 @@ export class EmailWorkflowService {
evt.organizer.timeFormat,
hideBranding
).html;
} else if (template === WorkflowTemplates.REMINDER) {
emailContent = emailReminderTemplate({
isEditingMode: false,
locale: evt.organizer.language.locale,
t: await getTranslation(evt.organizer.language.locale || "en", "common"),
action,
timeFormat: evt.organizer.timeFormat,
startTime,
endTime,
eventName: evt.title,
timeZone,
location: evt.location || "",
meetingUrl:
evt.videoCallData?.url || bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl || "",
otherPerson: attendeeName,
name,
});
} else if (template === WorkflowTemplates.RATING) {
emailContent = emailRatingTemplate({
isEditingMode: true,
locale: evt.organizer.language.locale,
action,
t: await getTranslation(evt.organizer.language.locale || "en", "common"),
timeFormat: evt.organizer.timeFormat,
startTime,
endTime,
eventName: evt.title,
timeZone,
organizer: evt.organizer.name,
name,
ratingUrl: `${bookerUrl}/booking/${evt.uid}?rating`,
noShowUrl: `${bookerUrl}/booking/${evt.uid}?noShow=true`,
});
}
// Allows debugging generated email content without waiting for sendgrid to send emails
@@ -450,7 +506,10 @@ export class EmailWorkflowService {
const emailEvent = {
...evt,
type: evt.eventType?.slug || "",
organizer: { ...evt.organizer, language: { ...evt.organizer.language, translate: organizerT } },
organizer: {
...evt.organizer,
language: { ...evt.organizer.language, translate: organizerT },
},
attendees: processedAttendees,
location: bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl || evt.location,
};
@@ -0,0 +1,307 @@
import { describe, expect, test } from "vitest";
import { WorkflowTemplates } from "@calcom/prisma/enums";
import { detectMatchedTemplate, type DefaultTemplates } from "../detectMatchedTemplate";
const createDefaultTemplates = (overrides?: Partial<DefaultTemplates>): DefaultTemplates => ({
reminder: {
body: "Default reminder body content",
subject: "Default reminder subject",
},
rating: {
body: "Default rating body content",
subject: "Default rating subject",
},
...overrides,
});
describe("detectMatchedTemplate", () => {
describe("when emailBody is empty", () => {
test("returns REMINDER when template is REMINDER", () => {
const result = detectMatchedTemplate({
emailBody: "",
emailSubject: "Any subject",
template: WorkflowTemplates.REMINDER,
defaultTemplates: createDefaultTemplates(),
});
expect(result).toBe(WorkflowTemplates.REMINDER);
});
test("returns RATING when template is RATING", () => {
const result = detectMatchedTemplate({
emailBody: "",
emailSubject: "Any subject",
template: WorkflowTemplates.RATING,
defaultTemplates: createDefaultTemplates(),
});
expect(result).toBe(WorkflowTemplates.RATING);
});
test("returns null when template is CUSTOM", () => {
const result = detectMatchedTemplate({
emailBody: "",
emailSubject: "Any subject",
template: WorkflowTemplates.CUSTOM,
defaultTemplates: createDefaultTemplates(),
});
expect(result).toBeNull();
});
test("returns null when template is undefined", () => {
const result = detectMatchedTemplate({
emailBody: "",
emailSubject: "Any subject",
template: undefined,
defaultTemplates: createDefaultTemplates(),
});
expect(result).toBeNull();
});
});
describe("when emailBody exists", () => {
describe("REMINDER template matching", () => {
test("returns REMINDER when both body AND subject match default", () => {
const defaultTemplates = createDefaultTemplates();
const result = detectMatchedTemplate({
emailBody: defaultTemplates.reminder.body!,
emailSubject: defaultTemplates.reminder.subject!,
template: WorkflowTemplates.REMINDER,
defaultTemplates,
});
expect(result).toBe(WorkflowTemplates.REMINDER);
});
test("returns null when body matches but subject is customized", () => {
const defaultTemplates = createDefaultTemplates();
const result = detectMatchedTemplate({
emailBody: defaultTemplates.reminder.body!,
emailSubject: "Custom subject",
template: WorkflowTemplates.REMINDER,
defaultTemplates,
});
expect(result).toBeNull();
});
test("returns null when subject matches but body is customized", () => {
const defaultTemplates = createDefaultTemplates();
const result = detectMatchedTemplate({
emailBody: "Custom body content",
emailSubject: defaultTemplates.reminder.subject!,
template: WorkflowTemplates.REMINDER,
defaultTemplates,
});
expect(result).toBeNull();
});
test("returns null when both body and subject are customized", () => {
const defaultTemplates = createDefaultTemplates();
const result = detectMatchedTemplate({
emailBody: "Custom body content",
emailSubject: "Custom subject",
template: WorkflowTemplates.REMINDER,
defaultTemplates,
});
expect(result).toBeNull();
});
test("matches body with HTML stripped (body has HTML tags)", () => {
const defaultTemplates = createDefaultTemplates({
reminder: {
body: "Plain text reminder body",
subject: "Default reminder subject",
},
});
const result = detectMatchedTemplate({
emailBody: "<p>Plain text reminder body</p>",
emailSubject: defaultTemplates.reminder.subject!,
template: WorkflowTemplates.REMINDER,
defaultTemplates,
});
expect(result).toBe(WorkflowTemplates.REMINDER);
});
test("matches body with HTML stripped (template has HTML tags)", () => {
const defaultTemplates = createDefaultTemplates({
reminder: {
body: "<div>Plain text reminder body</div>",
subject: "Default reminder subject",
},
});
const result = detectMatchedTemplate({
emailBody: "Plain text reminder body",
emailSubject: defaultTemplates.reminder.subject!,
template: WorkflowTemplates.REMINDER,
defaultTemplates,
});
expect(result).toBe(WorkflowTemplates.REMINDER);
});
});
describe("RATING template matching", () => {
test("returns RATING when both body AND subject match default", () => {
const defaultTemplates = createDefaultTemplates();
const result = detectMatchedTemplate({
emailBody: defaultTemplates.rating.body!,
emailSubject: defaultTemplates.rating.subject!,
template: WorkflowTemplates.RATING,
defaultTemplates,
});
expect(result).toBe(WorkflowTemplates.RATING);
});
test("returns null when body matches but subject is customized", () => {
const defaultTemplates = createDefaultTemplates();
const result = detectMatchedTemplate({
emailBody: defaultTemplates.rating.body!,
emailSubject: "Custom rating subject",
template: WorkflowTemplates.RATING,
defaultTemplates,
});
expect(result).toBeNull();
});
test("returns null when subject matches but body is customized", () => {
const defaultTemplates = createDefaultTemplates();
const result = detectMatchedTemplate({
emailBody: "Custom rating body",
emailSubject: defaultTemplates.rating.subject!,
template: WorkflowTemplates.RATING,
defaultTemplates,
});
expect(result).toBeNull();
});
});
describe("template detection regardless of template field value", () => {
test("detects REMINDER even when template field is CUSTOM", () => {
const defaultTemplates = createDefaultTemplates();
const result = detectMatchedTemplate({
emailBody: defaultTemplates.reminder.body!,
emailSubject: defaultTemplates.reminder.subject!,
template: WorkflowTemplates.CUSTOM,
defaultTemplates,
});
expect(result).toBe(WorkflowTemplates.REMINDER);
});
test("detects RATING even when template field is CUSTOM", () => {
const defaultTemplates = createDefaultTemplates();
const result = detectMatchedTemplate({
emailBody: defaultTemplates.rating.body!,
emailSubject: defaultTemplates.rating.subject!,
template: WorkflowTemplates.CUSTOM,
defaultTemplates,
});
expect(result).toBe(WorkflowTemplates.RATING);
});
test("detects REMINDER even when template field is RATING", () => {
const defaultTemplates = createDefaultTemplates();
const result = detectMatchedTemplate({
emailBody: defaultTemplates.reminder.body!,
emailSubject: defaultTemplates.reminder.subject!,
template: WorkflowTemplates.RATING,
defaultTemplates,
});
expect(result).toBe(WorkflowTemplates.REMINDER);
});
});
describe("edge cases", () => {
test("returns null when default body is null", () => {
const defaultTemplates = createDefaultTemplates({
reminder: { body: null, subject: "Default reminder subject" },
rating: { body: null, subject: "Default rating subject" },
});
const result = detectMatchedTemplate({
emailBody: "Some body content",
emailSubject: "Default reminder subject",
template: WorkflowTemplates.REMINDER,
defaultTemplates,
});
expect(result).toBeNull();
});
test("returns null when default subject is null", () => {
const defaultTemplates = createDefaultTemplates({
reminder: { body: "Default reminder body", subject: null },
rating: { body: "Default rating body", subject: null },
});
const result = detectMatchedTemplate({
emailBody: "Default reminder body",
emailSubject: "Some subject",
template: WorkflowTemplates.REMINDER,
defaultTemplates,
});
expect(result).toBeNull();
});
test("handles &amp; entity in body comparison", () => {
const defaultTemplates = createDefaultTemplates({
reminder: {
body: "Date & Time",
subject: "Default reminder subject",
},
});
const result = detectMatchedTemplate({
emailBody: "Date &amp; Time",
emailSubject: defaultTemplates.reminder.subject!,
template: WorkflowTemplates.REMINDER,
defaultTemplates,
});
expect(result).toBe(WorkflowTemplates.REMINDER);
});
test("prioritizes REMINDER over RATING when both could match", () => {
const defaultTemplates = createDefaultTemplates({
reminder: { body: "Same body", subject: "Same subject" },
rating: { body: "Same body", subject: "Same subject" },
});
const result = detectMatchedTemplate({
emailBody: "Same body",
emailSubject: "Same subject",
template: WorkflowTemplates.RATING,
defaultTemplates,
});
expect(result).toBe(WorkflowTemplates.REMINDER);
});
});
});
});
@@ -38,6 +38,7 @@ const user: User & { credentials: CredentialPayload[] } = {
bufferTime: 0,
availability: [],
id: 0,
uuid: "00000000-0000-0000-0000-000000000000",
startTime: 0,
endTime: 0,
allSelectedCalendars: [],
@@ -0,0 +1,192 @@
"use client";
import type { FeatureState } from "@calcom/features/flags/config";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import classNames from "@calcom/ui/classNames";
import { Alert } from "@calcom/ui/components/alert";
import { SettingsToggle, ToggleGroup } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { SkeletonText } from "@calcom/ui/components/skeleton";
import { Tooltip } from "@calcom/ui/components/tooltip";
import type { ReactElement } from "react";
import { useMemo } from "react";
import { getOptInFeatureConfig } from "../config";
import type { NormalizedFeature, UseFeatureOptInResult } from "../types";
interface FeaturesSettingsProps {
/** The hook result - can be from useUserFeatureOptIn, useTeamFeatureOptIn, or useOrganizationFeatureOptIn */
featureOptIn: UseFeatureOptInResult;
/** Whether the user can edit the feature settings. If false, all toggles will be disabled. */
canEdit?: boolean;
}
interface ToggleOption {
value: string;
label: string;
}
function isEnabledViaAutoOptIn(feature: NormalizedFeature, autoOptIn: boolean): boolean {
return feature.currentState === "inherit" && autoOptIn && feature.globalEnabled;
}
function handleValueChange(
val: string | undefined,
slug: string,
setFeatureState: (slug: string, state: FeatureState) => void
): void {
if (!val) return;
setFeatureState(slug, val as FeatureState);
}
function LoadingSkeleton(): ReactElement {
return (
<div className="border-subtle rounded-b-lg border-x border-b px-4 py-8 sm:px-6">
<div className="space-y-4">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
</div>
</div>
);
}
function EmptyState({ message }: { message: string }): ReactElement {
return <Alert severity="neutral" title={message} />;
}
function FeatureItem({
feature,
toggleOptions,
getBlockedWarning,
isBlockedByHigherLevel,
autoOptIn,
setFeatureState,
canEdit,
t,
}: {
feature: NormalizedFeature;
toggleOptions: ToggleOption[];
getBlockedWarning: (feature: NormalizedFeature) => string | null;
isBlockedByHigherLevel: (feature: NormalizedFeature) => boolean;
autoOptIn: boolean;
setFeatureState: (slug: string, state: FeatureState) => void;
canEdit: boolean;
t: (key: string) => string;
}): ReactElement | null {
const config = getOptInFeatureConfig(feature.slug);
const blockedWarning = getBlockedWarning(feature);
const enabledViaAutoOptInFlag = isEnabledViaAutoOptIn(feature, autoOptIn);
const blockedByHigherLevel = isBlockedByHigherLevel(feature);
const isDisabled = blockedByHigherLevel || !canEdit;
const finalToggleOptions = useMemo(() => {
if (!isDisabled) {
return toggleOptions;
}
return toggleOptions.map((option) => ({
...option,
disabled: true,
}));
}, [toggleOptions, isDisabled]);
if (!config) return null;
return (
<div key={feature.slug}>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3
className={classNames(
"text-emphasis flex items-center text-sm font-medium",
blockedWarning && "text-subtle line-through"
)}>
{t(config.titleI18nKey)}
{blockedWarning && (
<Tooltip side="top" content={blockedWarning}>
<span>
<Icon name="circle-alert" className="text-error ml-1 h-4 w-4" />
</span>
</Tooltip>
)}
{enabledViaAutoOptInFlag && (
<Tooltip side="top" content={t("enabled_via_auto_opt_in")}>
<span>
<Icon name="circle-check" className="text-success ml-1 h-4 w-4" />
</span>
</Tooltip>
)}
</h3>
<p className="text-subtle text-sm">{t(config.descriptionI18nKey)}</p>
</div>
<ToggleGroup
value={feature.currentState}
onValueChange={(val: string | undefined): void =>
handleValueChange(val, feature.slug, setFeatureState)
}
options={finalToggleOptions}
/>
</div>
</div>
);
}
export function FeaturesSettings({ featureOptIn, canEdit = true }: FeaturesSettingsProps): ReactElement {
const { t } = useLocale();
const {
features,
autoOptIn,
isLoading,
setFeatureState,
setAutoOptIn,
isAutoOptInMutationPending,
toggleLabels,
autoOptInDescription,
getBlockedWarning,
isBlockedByHigherLevel,
} = featureOptIn;
if (isLoading) return <LoadingSkeleton />;
const toggleOptions: ToggleOption[] = [
{ value: "disabled", label: toggleLabels.disabled },
{ value: "enabled", label: toggleLabels.enabled },
{ value: "inherit", label: toggleLabels.inherit },
];
return (
<>
<div className="border-subtle rounded-b-lg border-x border-b px-4 py-8 sm:px-6">
{features.length === 0 && <EmptyState message={t("no_opt_in_features_available")} />}
{features.length > 0 && (
<div className="space-y-6">
{features.map((feature) => (
<FeatureItem
key={feature.slug}
feature={feature}
toggleOptions={toggleOptions}
getBlockedWarning={getBlockedWarning}
isBlockedByHigherLevel={isBlockedByHigherLevel}
autoOptIn={autoOptIn}
setFeatureState={setFeatureState}
canEdit={canEdit}
t={t}
/>
))}
</div>
)}
</div>
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("auto_opt_in_experimental")}
description={autoOptInDescription}
disabled={isAutoOptInMutationPending || !canEdit}
checked={autoOptIn}
onCheckedChange={setAutoOptIn}
switchContainerClassName="mt-6"
/>
</>
);
}
export default FeaturesSettings;
+43 -1
View File
@@ -1,11 +1,18 @@
import type { FeatureId } from "@calcom/features/flags/config";
import type { OptInFeaturePolicy, OptInFeatureScope } from "./types";
export interface OptInFeatureConfig {
slug: FeatureId;
titleI18nKey: string;
descriptionI18nKey: string;
policy: OptInFeaturePolicy;
/** Scopes where this feature can be configured. Defaults to all scopes if not specified. */
scope?: OptInFeatureScope[];
}
/** All available scopes for feature opt-in configuration */
export const ALL_SCOPES: OptInFeatureScope[] = ["org", "team", "user"];
/**
* Features that appear in opt-in settings.
* Add new features here to make them available for user/team opt-in.
@@ -16,6 +23,8 @@ export const OPT_IN_FEATURES: OptInFeatureConfig[] = [
// slug: "bookings-v3",
// titleI18nKey: "bookings_v3_title",
// descriptionI18nKey: "bookings_v3_description",
// policy: "permissive",
// scope: ["org", "team", "user"], // Optional: defaults to all scopes if not specified
// },
];
@@ -27,9 +36,42 @@ export function getOptInFeatureConfig(slug: string): OptInFeatureConfig | undefi
}
/**
* Check if a feature slug is in the opt-in allowlist.
* Check if a slug is in the opt-in allowlist.
* Acts as a type guard, narrowing the slug to FeatureId when true.
*/
export function isOptInFeature(slug: string): slug is FeatureId {
return OPT_IN_FEATURES.some((f) => f.slug === slug);
}
/**
* Check if there are any opt-in features available.
*/
export const HAS_OPT_IN_FEATURES: boolean = OPT_IN_FEATURES.length > 0;
/** Whether there are opt-in features available for the user scope */
export const HAS_USER_OPT_IN_FEATURES: boolean = OPT_IN_FEATURES.some((f) => !f.scope || f.scope.includes("user"));
/** Whether there are opt-in features available for the team scope */
export const HAS_TEAM_OPT_IN_FEATURES: boolean = OPT_IN_FEATURES.some((f) => !f.scope || f.scope.includes("team"));
/** Whether there are opt-in features available for the org scope */
export const HAS_ORG_OPT_IN_FEATURES: boolean = OPT_IN_FEATURES.some((f) => !f.scope || f.scope.includes("org"));
/**
* Get opt-in features that are available for a specific scope.
* Features without a scope field are available for all scopes.
*/
export function getOptInFeaturesForScope(scope: OptInFeatureScope): OptInFeatureConfig[] {
return OPT_IN_FEATURES.filter((f) => !f.scope || f.scope.includes(scope));
}
/**
* Check if a feature is allowed for a specific scope.
* Features without a scope field are allowed for all scopes.
* Features not in the config are NOT allowed (must be explicitly configured).
*/
export function isFeatureAllowedForScope(slug: string, scope: OptInFeatureScope): boolean {
const config = getOptInFeatureConfig(slug);
if (!config) return false;
return !config.scope || config.scope.includes(scope);
}
@@ -1,352 +1,467 @@
import { describe, it, expect } from "vitest";
import type { FeatureState } from "@calcom/features/flags/config";
import { computeEffectiveStateAcrossTeams } from "./computeEffectiveState";
import type { OptInFeaturePolicy } from "../types";
/**
* Feature Opt-In Effective State Computation
* ==========================================
*
* This module computes whether a feature is effectively enabled for a user
* based on global, organization, team, and user-level settings.
*
* Two policies are supported:
* - `permissive` (default): User opt-in can activate the feature; disables only win if ALL teams disable
* - `strict`: User opt-in alone is NOT enough; requires explicit enable from org/team; ANY disable blocks
*
*
* SCENARIO TABLE - PERMISSIVE POLICY
* ============================================
*
* | # | Global | Org | Teams | User | Result | Reason |
* |---|--------|----------|--------------------------|----------|---------|-------------------------------|
* | 1 | true | inherit | [inherit, inherit] | enabled | ALLOWED | User opt-in activates |
* | 2 | true | inherit | [enabled, inherit] | inherit | ALLOWED | Team enablement propagates |
* | 3 | true | inherit | [inherit, inherit] | inherit | BLOCKED | No explicit enablement |
* | 4 | true | inherit | [enabled, disabled] | inherit | ALLOWED | At least one team enabled |
* | 5 | true | enabled | [inherit] | inherit | ALLOWED | Org enablement propagates |
* | 6 | true | enabled | [disabled, disabled] | enabled | BLOCKED | All teams disabled |
* | 7 | true | disabled | [enabled] | enabled | BLOCKED | Org disabled blocks all |
* | 8 | true | inherit | [disabled] | enabled | BLOCKED | All teams disabled |
* | 9 | true | inherit | [enabled] | disabled | BLOCKED | User disabled |
* |10 | false | enabled | [enabled] | enabled | BLOCKED | Global disabled |
*
*
* SCENARIO TABLE - STRICT POLICY
* ==============================
*
* | # | Global | Org | Teams | User | Result | Reason |
* |---|--------|----------|--------------------------|----------|---------|-------------------------------|
* | 1 | true | inherit | [inherit, inherit] | inherit | BLOCKED | No explicit enablement |
* | 2 | true | inherit | [inherit, inherit] | enabled | BLOCKED | User-only not allowed |
* | 3 | true | inherit | [enabled, inherit] | inherit | ALLOWED | Team enablement works |
* | 4 | true | inherit | [enabled, inherit] | enabled | ALLOWED | Team + user enabled |
* | 5 | true | inherit | [enabled, disabled] | enabled | BLOCKED | Any team disabled blocks |
* | 6 | true | enabled | [inherit] | inherit | ALLOWED | Org enablement works |
* | 7 | true | enabled | [disabled] | enabled | BLOCKED | Any team disabled blocks |
* | 8 | true | disabled | [enabled] | enabled | BLOCKED | Org disabled blocks all |
* | 9 | true | inherit | [enabled] | disabled | BLOCKED | User disabled |
* |10 | false | enabled | [enabled] | enabled | BLOCKED | Global disabled |
*
*
* KEY DIFFERENCES BETWEEN POLICIES
* ================================
*
* | Aspect | Permissive | Strict |
* |-----------------------------|-------------------------------|----------------------------------|
* | User opt-in alone | Can activate feature | NOT enough, needs org/team |
* | Team disable behavior | Only blocks if ALL disabled | ANY disable blocks |
* | Org/team enablement needed | No (user can self-enable) | Yes (required for activation) |
*
*/
describe("computeEffectiveStateAcrossTeams", () => {
describe("when global is disabled", () => {
it("returns false regardless of other states", () => {
describe("Global Kill Switch", () => {
it.each([
{ policy: "permissive" as OptInFeaturePolicy, orgState: "enabled" as FeatureState, teamStates: ["enabled"] as FeatureState[], userState: "enabled" as FeatureState },
{ policy: "strict" as OptInFeaturePolicy, orgState: "enabled" as FeatureState, teamStates: ["enabled"] as FeatureState[], userState: "enabled" as FeatureState },
])("blocks feature when global is disabled (policy: $policy)", ({ policy, orgState, teamStates, userState }) => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: false,
orgState: "enabled",
teamStates: ["enabled"],
userState: "enabled",
orgState,
teamStates,
userState,
policy,
})
).toBe(false);
).toEqual({ enabled: false, reason: "feature_global_disabled" });
});
});
describe("when org is disabled", () => {
it("returns false regardless of team and user state", () => {
describe("Organization Level Blocking", () => {
it.each([
{ policy: "permissive" as OptInFeaturePolicy, teamStates: ["enabled"] as FeatureState[], userState: "enabled" as FeatureState },
{ policy: "strict" as OptInFeaturePolicy, teamStates: ["enabled"] as FeatureState[], userState: "enabled" as FeatureState },
])("blocks feature when org is disabled (policy: $policy)", ({ policy, teamStates, userState }) => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "disabled",
teamStates: ["enabled"],
userState: "enabled",
teamStates,
userState,
policy,
})
).toBe(false);
).toEqual({ enabled: false, reason: "feature_org_disabled" });
});
});
describe("when org is enabled", () => {
describe("when all teams are disabled", () => {
it("returns false regardless of user state", () => {
describe("Permissive Policy", () => {
describe("User Opt-In Behavior", () => {
it("allows user to self-enable even when org and teams only inherit", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "enabled",
orgState: "inherit",
teamStates: ["inherit", "inherit"],
userState: "enabled",
policy: "permissive",
})
).toEqual({ enabled: true, reason: "feature_enabled" });
});
it("blocks when no explicit enablement exists anywhere", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["inherit", "inherit"],
userState: "inherit",
policy: "permissive",
})
).toEqual({ enabled: false, reason: "feature_no_explicit_enablement" });
});
});
describe("Team Enablement Propagation", () => {
it("allows feature when at least one team is enabled", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["enabled", "inherit", "inherit"],
userState: "inherit",
policy: "permissive",
})
).toEqual({ enabled: true, reason: "feature_enabled" });
});
it("allows feature when one team enabled and another disabled (mixed)", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["enabled", "disabled", "inherit"],
userState: "inherit",
policy: "permissive",
})
).toEqual({ enabled: true, reason: "feature_enabled" });
});
});
describe("Team Disable Behavior (ALL must disable)", () => {
it("blocks only when ALL teams have explicitly disabled", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["disabled", "disabled"],
userState: "enabled",
policy: "permissive",
})
).toBe(false);
});
});
describe("when at least one team is enabled", () => {
it("returns true when user is enabled", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "enabled",
teamStates: ["enabled", "disabled"],
userState: "enabled",
})
).toBe(true);
).toEqual({ enabled: false, reason: "feature_all_teams_disabled" });
});
it("returns false when user is disabled", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "enabled",
teamStates: ["enabled", "disabled"],
userState: "disabled",
})
).toBe(false);
});
it("returns true when user inherits", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "enabled",
teamStates: ["enabled", "disabled"],
userState: "inherit",
})
).toBe(true);
});
});
describe("when teams inherit from enabled org", () => {
it("returns true when user is enabled or inherits", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "enabled",
teamStates: ["inherit"],
userState: "enabled",
})
).toBe(true);
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "enabled",
teamStates: ["inherit"],
userState: "inherit",
})
).toBe(true);
});
});
});
describe("when org inherits (or no org)", () => {
describe("when all teams are disabled", () => {
it("returns false regardless of user state", () => {
it("blocks when single team is disabled (user in one team)", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["disabled"],
userState: "enabled",
policy: "permissive",
})
).toBe(false);
).toEqual({ enabled: false, reason: "feature_all_teams_disabled" });
});
});
describe("when at least one team is enabled", () => {
it("returns true when user is enabled or inherits", () => {
describe("Organization Enablement", () => {
it("allows feature when org is enabled and teams inherit", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["enabled", "disabled"],
userState: "enabled",
})
).toBe(true);
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["enabled"],
orgState: "enabled",
teamStates: ["inherit"],
userState: "inherit",
policy: "permissive",
})
).toBe(true);
).toEqual({ enabled: true, reason: "feature_enabled" });
});
it("returns false when user is disabled", () => {
it("blocks when org enabled but all teams disabled", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "enabled",
teamStates: ["disabled", "disabled"],
userState: "enabled",
policy: "permissive",
})
).toEqual({ enabled: false, reason: "feature_all_teams_disabled" });
});
});
describe("User Disable Behavior", () => {
it("blocks when user explicitly disables (even with team enabled)", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["enabled"],
userState: "disabled",
policy: "permissive",
})
).toBe(false);
).toEqual({ enabled: false, reason: "feature_user_disabled" });
});
});
describe("when teams only inherit (no org enabled)", () => {
it("returns true when user explicitly opts in", () => {
describe("No Teams Scenario", () => {
it("allows when org enabled and user inherits (no teams)", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["inherit"],
userState: "enabled",
orgState: "enabled",
teamStates: [],
userState: "inherit",
policy: "permissive",
})
).toBe(true);
).toEqual({ enabled: true, reason: "feature_enabled" });
});
it("returns false when user inherits because no explicit enablement above", () => {
it("allows when user self-enables (no teams, org inherits)", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: [],
userState: "enabled",
policy: "permissive",
})
).toEqual({ enabled: true, reason: "feature_enabled" });
});
it("blocks when no enablement anywhere (no teams)", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: [],
userState: "inherit",
policy: "permissive",
})
).toEqual({ enabled: false, reason: "feature_no_explicit_enablement" });
});
});
});
describe("Strict Policy", () => {
describe("User Opt-In Alone NOT Sufficient", () => {
it("blocks when user enables but no org/team enablement exists", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["inherit", "inherit"],
userState: "enabled",
policy: "strict",
})
).toEqual({ enabled: false, reason: "feature_user_only_not_allowed" });
});
it("blocks when no explicit enablement exists anywhere", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["inherit", "inherit"],
userState: "inherit",
policy: "strict",
})
).toEqual({ enabled: false, reason: "feature_no_explicit_enablement" });
});
});
describe("Team Enablement Required", () => {
it("allows when team is enabled and user inherits", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["enabled", "inherit", "inherit"],
userState: "inherit",
policy: "strict",
})
).toEqual({ enabled: true, reason: "feature_enabled" });
});
it("allows when team is enabled and user also enables", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["enabled", "inherit", "inherit"],
userState: "enabled",
policy: "strict",
})
).toEqual({ enabled: true, reason: "feature_enabled" });
});
});
describe("ANY Team Disable Blocks", () => {
it("blocks when any team is disabled (even if another is enabled)", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["enabled", "disabled", "inherit"],
userState: "enabled",
policy: "strict",
})
).toEqual({ enabled: false, reason: "feature_any_team_disabled" });
});
it("blocks when single team is disabled", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["disabled"],
userState: "enabled",
policy: "strict",
})
).toEqual({ enabled: false, reason: "feature_any_team_disabled" });
});
it("blocks when org enabled but any team disabled", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "enabled",
teamStates: ["disabled"],
userState: "enabled",
policy: "strict",
})
).toEqual({ enabled: false, reason: "feature_any_team_disabled" });
});
});
describe("Organization Enablement", () => {
it("allows when org is enabled and teams inherit", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "enabled",
teamStates: ["inherit"],
userState: "inherit",
policy: "strict",
})
).toBe(false);
).toEqual({ enabled: true, reason: "feature_enabled" });
});
});
describe("User Disable Behavior", () => {
it("blocks when user explicitly disables (even with team enabled)", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["enabled"],
userState: "disabled",
policy: "strict",
})
).toEqual({ enabled: false, reason: "feature_user_disabled" });
});
});
describe("No Teams Scenario", () => {
it("allows when org enabled (no teams)", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "enabled",
teamStates: [],
userState: "inherit",
policy: "strict",
})
).toEqual({ enabled: true, reason: "feature_enabled" });
});
it("blocks when user self-enables but no org enablement (no teams)", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: [],
userState: "enabled",
policy: "strict",
})
).toEqual({ enabled: false, reason: "feature_user_only_not_allowed" });
});
});
});
describe("when user has no teams", () => {
it("returns true when org is enabled and user is enabled/inherits", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "enabled",
teamStates: [],
userState: "enabled",
})
).toBe(true);
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "enabled",
teamStates: [],
userState: "inherit",
})
).toBe(true);
});
it("returns false when org inherits and user has no explicit enablement", () => {
describe("Permissive Policy Additional Cases", () => {
it("allows user to self-enable with single inheriting team", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: [],
userState: "inherit",
teamStates: ["inherit"],
userState: "enabled",
policy: "permissive",
})
).toBe(false); // No explicit enablement in chain, feature should be disabled
).toEqual({ enabled: true, reason: "feature_enabled" });
});
it("returns true when org inherits but user explicitly enables", () => {
it("allows feature with mixed team states (one enabled, one disabled)", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: [],
userState: "enabled",
teamStates: ["enabled", "disabled"],
userState: "inherit",
policy: "permissive",
})
).toBe(true); // User explicit enablement is sufficient
).toEqual({ enabled: true, reason: "feature_enabled" });
});
});
describe("user opt-in behavior", () => {
it("allows user to opt-in regardless of org/team inheritance state", () => {
// User can opt-in even when org and all teams are just inheriting
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["inherit", "inherit"],
userState: "enabled",
})
).toBe(true);
describe("Policy Comparison (same inputs, different outcomes)", () => {
it("user-only enablement: allowed in permissive, blocked in strict", () => {
const input = {
globalEnabled: true,
orgState: "inherit" as FeatureState,
teamStates: ["inherit", "inherit"] as FeatureState[],
userState: "enabled" as FeatureState,
};
expect(computeEffectiveStateAcrossTeams({ ...input, policy: "permissive" })).toEqual({
enabled: true,
reason: "feature_enabled",
});
expect(computeEffectiveStateAcrossTeams({ ...input, policy: "strict" })).toEqual({
enabled: false,
reason: "feature_user_only_not_allowed",
});
});
it("blocks user opt-in when all teams have explicitly disabled", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["disabled", "disabled"],
userState: "enabled",
})
).toBe(false);
});
it("mixed team states: allowed in permissive, blocked in strict", () => {
const input = {
globalEnabled: true,
orgState: "inherit" as FeatureState,
teamStates: ["enabled", "disabled"] as FeatureState[],
userState: "enabled" as FeatureState,
};
it("blocks user opt-in when org has explicitly disabled", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "disabled",
teamStates: ["inherit"],
userState: "enabled",
})
).toBe(false);
});
});
expect(computeEffectiveStateAcrossTeams({ ...input, policy: "permissive" })).toEqual({
enabled: true,
reason: "feature_enabled",
});
describe("truth table from design doc", () => {
it("org disabled, any teams, any user → false", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "disabled",
teamStates: ["enabled"],
userState: "enabled",
})
).toBe(false);
});
it("org enabled, all teams disabled, any user → false", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "enabled",
teamStates: ["disabled", "disabled"],
userState: "enabled",
})
).toBe(false);
});
it("org enabled, at least one team enabled/inherit, user disabled → false", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "enabled",
teamStates: ["enabled"],
userState: "disabled",
})
).toBe(false);
});
it("org enabled, at least one team enabled/inherit, user enabled/inherit → true", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "enabled",
teamStates: ["enabled"],
userState: "enabled",
})
).toBe(true);
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "enabled",
teamStates: ["inherit"],
userState: "inherit",
})
).toBe(true);
});
it("org inherit/null, all teams disabled, any user → false", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["disabled"],
userState: "enabled",
})
).toBe(false);
});
it("org inherit/null, at least one team enabled, user disabled → false", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["enabled"],
userState: "disabled",
})
).toBe(false);
});
it("org inherit/null, at least one team enabled, user enabled/inherit → true", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["enabled"],
userState: "enabled",
})
).toBe(true);
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["enabled"],
userState: "inherit",
})
).toBe(true);
expect(computeEffectiveStateAcrossTeams({ ...input, policy: "strict" })).toEqual({
enabled: false,
reason: "feature_any_team_disabled",
});
});
});
});
@@ -1,19 +1,44 @@
import type { FeatureState } from "@calcom/features/flags/config";
import type { OptInFeaturePolicy } from "../types";
export type EffectiveStateReason =
| "feature_global_disabled"
| "feature_org_disabled"
| "feature_all_teams_disabled"
| "feature_any_team_disabled"
| "feature_user_disabled"
| "feature_no_explicit_enablement"
| "feature_user_only_not_allowed"
| "feature_enabled";
export type EffectiveStateResult = {
enabled: boolean;
reason: EffectiveStateReason;
};
/**
* Computes the effective enabled state based on global, org, teams, and user settings.
*
* The logic follows these rules:
* - Any level set to "disabled" blocks the feature (org blocks all, team blocks that team's users, user blocks self)
* - User can explicitly opt-in to enable the feature for themselves
* - "enabled" at org/team level provides enablement that users can inherit from
* - "inherit" passes through to the level above
* The logic depends on the policy:
*
* **Permissive policy (default):**
* - User opt-in can activate the feature
* - Any explicit enable above is sufficient
* - Disables only win if ALL teams disable
*
* **Strict policy:**
* - User opt-in alone is NOT enough - requires explicit enable from org/team
* - ANY explicit disable blocks
*
* Returns both the enabled state and the reason for that state.
*/
export function computeEffectiveStateAcrossTeams({
globalEnabled,
orgState,
teamStates,
userState,
policy,
}: {
/**
* Acts as a global kill switch. When false, the feature is disabled for everyone.
@@ -24,11 +49,16 @@ export function computeEffectiveStateAcrossTeams({
orgState: FeatureState;
teamStates: FeatureState[];
userState: FeatureState;
}): boolean {
/**
* Policy that determines how feature opt-in states are evaluated.
*/
policy: OptInFeaturePolicy;
}): EffectiveStateResult {
// Derive all conditions upfront
const orgEnabled = orgState === "enabled";
const orgDisabled = orgState === "disabled";
const anyTeamEnabled = teamStates.some((s) => s === "enabled");
const anyTeamDisabled = teamStates.some((s) => s === "disabled");
const allTeamsDisabled = teamStates.length > 0 && teamStates.every((s) => s === "disabled");
const userEnabled = userState === "enabled";
const userDisabled = userState === "disabled";
@@ -36,13 +66,49 @@ export function computeEffectiveStateAcrossTeams({
// Explicit enablement exists above user level
const hasExplicitEnablementAboveUser = orgEnabled || anyTeamEnabled;
// Define when feature is enabled (whitelist approach)
const isEnabled =
globalEnabled &&
!userDisabled &&
!orgDisabled &&
!allTeamsDisabled &&
(userEnabled || hasExplicitEnablementAboveUser); // User can opt-in directly, or inherit from org/team
// Check conditions in order of precedence and return with reason
if (!globalEnabled) {
return { enabled: false, reason: "feature_global_disabled" };
}
return isEnabled;
if (orgDisabled) {
return { enabled: false, reason: "feature_org_disabled" };
}
if (policy === "strict") {
// Strict policy: ANY explicit disable blocks
if (anyTeamDisabled) {
return { enabled: false, reason: "feature_any_team_disabled" };
}
// Strict policy: User opt-in alone is NOT enough - requires explicit enable from org/team
if (userEnabled && !hasExplicitEnablementAboveUser) {
return { enabled: false, reason: "feature_user_only_not_allowed" };
}
if (userDisabled) {
return { enabled: false, reason: "feature_user_disabled" };
}
if (!hasExplicitEnablementAboveUser) {
return { enabled: false, reason: "feature_no_explicit_enablement" };
}
return { enabled: true, reason: "feature_enabled" };
}
// Permissive policy (default): disables only win if ALL teams disable
if (allTeamsDisabled) {
return { enabled: false, reason: "feature_all_teams_disabled" };
}
if (userDisabled) {
return { enabled: false, reason: "feature_user_disabled" };
}
if (!userEnabled && !hasExplicitEnablementAboveUser) {
return { enabled: false, reason: "feature_no_explicit_enablement" };
}
return { enabled: true, reason: "feature_enabled" };
}

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