Compare commits

...
27 Commits
Author SHA1 Message Date
emrysal 0e8dde843d chore: release v6.1.8
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-29 15:01:13 +00:00
Benny JooandDevin AI c7ee3984ef refactor: move feature-flags, calendars, and schedules components from packages/features to apps/web/modules (#27222)
* refactor: move feature-flags, calendars, and schedules components from packages/features to apps/web/modules

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix

* fix

* fix

* fix: move DatePicker and DateOverrideInputDialog to apps/web/modules and update imports

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* rename

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* wip

* wip

* wip

* fix unit tests

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-29 16:41:44 +02:00
Eunjae LeeandDevin AI 18049bc1d6 refactor: update i18n keys to match effectiveReason values (#27381)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-29 14:16:12 +00:00
MorganandDevin AI 447a0289d6 fix: parse raw body Buffer in Vercel webhook controller (#27384)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-29 11:54:32 +00:00
2802310268 feat: Add infrastructure for no-show audit integration (#27187)
* feat: Add infrastructure for no-show audit integration

- Add Prisma migrations for SYSTEM source and NO_SHOW_UPDATED audit action
- Add NoShowUpdatedAuditActionService with array-based attendeesNoShow schema
- Update BookingAuditActionServiceRegistry to include NO_SHOW_UPDATED
- Update BookingAuditTaskConsumer and BookingAuditViewerService
- Add AttendeeRepository methods for no-show queries
- Update IAuditActionService interface with values array support
- Update locales with no-show audit translation keys

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

* fix: Add NO_SHOW_UPDATED to BookingAuditAction and SYSTEM to ActionSource types

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

* fix: Remove HOST_NO_SHOW_UPDATED and ATTENDEE_NO_SHOW_UPDATED from BookingAuditAction type to match Prisma schema

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

* fix: Update BookingAuditActionSchema to use NO_SHOW_UPDATED instead of HOST_NO_SHOW_UPDATED and ATTENDEE_NO_SHOW_UPDATED

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

* refactor: Remove deprecated no-show audit services and unify to NoShowUpdatedAuditActionService

- Delete HostNoShowUpdatedAuditActionService and AttendeeNoShowUpdatedAuditActionService
- Update BookingAuditProducerService.interface.ts to use queueNoShowUpdatedAudit
- Update BookingAuditTaskerProducerService.ts to use queueNoShowUpdatedAudit
- Update BookingEventHandlerService.ts to use onNoShowUpdated
- Add integration tests for NoShowUpdatedAuditActionService

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

* fix: Add data migration step for deprecated no-show enum values

Addresses Cubic AI review feedback (confidence 9/10): The migration now
includes an UPDATE statement to convert existing records using the
deprecated 'host_no_show_updated' or 'attendee_no_show_updated' enum
values to the new unified 'no_show_updated' value before the type cast.
This prevents migration failures if any existing data uses the old values.

Co-Authored-By: unknown <>

* fix: Use CASE expression in USING clause for enum migration

Fixes PostgreSQL error 'unsafe use of new value of enum type' by avoiding
the ADD VALUE statement and instead using a CASE expression in the ALTER
TABLE USING clause to convert deprecated enum values (host_no_show_updated,
attendee_no_show_updated) to the new unified value (no_show_updated) during
the type conversion.

Co-Authored-By: unknown <>

* fix: Replace hardcoded color with semantic text-success class

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

* fix: Remove color class completely from display fields

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-29 08:47:26 -03:00
2ec9e903ce feat: implement webhook tasker with async/sync fallback (#27378)
* feat: implement webhook tasker with async/sync fallback

This PR implements a webhook tasker with async/sync fallback architecture
to fix failing E2E tests. The solution follows the existing proration
tasker pattern and uses Dependency Injection with @evyweb/ioctopus.

Key changes:
- Create IWebhookTasker interface for webhook delivery
- Implement WebhookSyncTasker for immediate execution (E2E tests)
- Implement WebhookAsyncTasker for queued execution (production)
- Create main WebhookTasker class extending Tasker<IWebhookTasker>
- Add DI modules and tokens for all tasker components
- Update WebhookTaskerProducerService to use new WebhookTasker
- Add unit tests for sync and async taskers

The ENABLE_ASYNC_TASKER flag automatically selects the appropriate mode:
- Production: Uses WebhookAsyncTasker to queue tasks
- E2E Tests: Uses WebhookSyncTasker for immediate execution

This ensures webhooks are delivered immediately in E2E tests without
requiring the cron job that processes queued tasks.

Co-Authored-By: ali@cal.com <alishahbaz7@gmail.com>

* fix: update WebhookTaskerProducerService tests for new interface

Update tests to use the new deps-based constructor and
mockWebhookTasker.deliverWebhook instead of mockTasker.create

Co-Authored-By: ali@cal.com <alishahbaz7@gmail.com>

* fix: use moduleLoader pattern for WebhookProducerService in container

Co-Authored-By: ali@cal.com <alishahbaz7@gmail.com>

* refactor: replace InternalTasker with Trigger.dev for webhook delivery

This commit refactors the WebhookTasker to use Trigger.dev instead of
InternalTasker, following the pattern established in BookingEmailAndSmsTasker
and PlatformOrganizationBillingTasker (PR #26803).

Changes:
- Replace WebhookAsyncTasker with WebhookTriggerTasker that uses trigger.dev
- Create trigger.dev task files (deliver-webhook.ts, config.ts, schema.ts)
- Update DI modules to use WebhookTriggerTasker
- Remove old InternalTasker-based implementation
- Update unit tests for new implementation

The WebhookSyncTasker continues to execute webhooks immediately for E2E tests
where ENABLE_ASYNC_TASKER is automatically false.

Co-Authored-By: ali@cal.com <alishahbaz7@gmail.com>

* fix: update comments to reflect Trigger.dev usage instead of InternalTasker

Co-Authored-By: unknown <>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-29 15:30:07 +04:00
Volnei Munhoz db7547fd6d Remove noProcessGlobal for atoms (#27382) 2026-01-29 11:25:01 +00:00
sean-brydonandClaude Opus 4.5 3c91703415 feat: add invoice banner + block additions to teams/orgs (#27327)
* feat: add invoice URL to proration and test seed scripts

- Add invoiceUrl field to MonthlyProration schema
- Update billing service to return hosted_invoice_url after finalizing
- Save invoice URL when creating proration invoices
- Add seed script for testing proration with real Stripe data
- Add cleanup script for test data removal

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

* feat: add due invoice banner and invitation blocking for orgs

- Add DueInvoiceBanner component showing overdue prorations
- Add DueInvoiceService for checking blocking status and banner data
- Block invitations when proration invoices are 7+ days overdue
- Allow sub-team invites for existing org members (exception)
- Show banner to users with billing management permissions
- Link directly to Stripe invoice URL when available

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

* fix: set subscriptionId in team metadata in seed script

The seed script was not setting subscriptionId on the team metadata,
causing checkIfOrgNeedsUpgrade to treat the org as needing upgrade
and showing the "trialing" banner.

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

* push trigger test to script

* fix mocks

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 11:23:27 +00:00
ca5d931165 fix: preserve UI config across iframe resets for element-click embeds (#27363)
* fix: preserve UI config across iframe resets for element-click embeds

For element-click embeds, ui() is called before the iframe exists, and
iframeReset() clears the queue when the modal is opened. This causes UI
config like hideEventTypeDetails, cssVarsPerTheme, and styles to be lost.

This fix stores UI config in a persistent property that survives iframe
resets and applies it when the iframe becomes ready.

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

* test: add assertion to verify hideEventTypeDetails config is applied in element-click embed

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

* Add mergeUiConfig function and related tests for UI configuration merging

- Introduced mergeUiConfig function to handle merging of UI configuration, specifically for cssVarsPerTheme at theme level.
- Added comprehensive tests for mergeUiConfig covering various scenarios including merging themes, handling missing properties, and preserving existing configuration values.
- Updated utils.test.ts to include tests for the new function and ensure correct behavior in different merging situations.

* Fixes

* Remove process import as it breaks embed.js

* Simplify

* fix name

* fix name

* fix: use forEach instead of for...of for URLSearchParams iteration

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2026-01-29 08:18:53 -03:00
3b2e3351c4 docs: api v2 and examples app setup (#27290)
* docs: api v2 and examples app setup

* docs: api v2 and examples app setup

* fix: address Cubic AI review feedback

- Fix env file path typo in README.md (apps/api/v2.env -> apps/api/v2/.env)
- Write generated secrets to file instead of logging to console
- Add .generated-secrets to gitignore
- Update README-PLATFORM.md to reflect new secret generation behavior

Co-Authored-By: unknown <>

* chore: update locale

* chore: update docs

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Ryukemeister <sahalrajiv6900@gmail.com>
2026-01-29 11:18:23 +00:00
sean-brydonandClaude Opus 4.5 08a2b0bfb5 feat: add organization.passwordReset PBAC permission (#27377)
* feat: add organization.passwordReset PBAC permission

Allow org admins/owners to reset passwords for members of their
organization via a new PBAC permission. Previously this was only
available to system-level admins.

- Add PasswordReset to CustomAction enum and PERMISSION_REGISTRY
- Create migration to grant permission to admin_role (owner has wildcard)
- Add org-scoped tRPC endpoint using createOrgPbacProcedure
- Handler validates org membership, prevents self-targeting, and blocks
  resetting owner passwords
- Wire permission through MemberPermissions, getOrgMembersPageData, and
  the org members table UI dropdown

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

* fix: use targeted select in org password reset to avoid over-fetching

Replace findById with findForPasswordReset repository method that only
selects email, name, and locale instead of the full userSelect which
includes sensitive fields like twoFactorSecret and backupCodes.

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

* test: add unit tests for sendPasswordReset handler

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 11:18:02 +00:00
sean-brydonandDevin AI 1f8f134854 feat: enable PBAC feature flag globally via migration (#27376)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-29 11:00:55 +00:00
Rajiv Sahal 5427d28c9e fix: bad import (#27380) 2026-01-29 09:45:06 +00:00
sean-brydonandDevin AI 0d5560e0d1 fix: use WEBAPP_URL for team payment redirect instead of req.nextUrl.origin (#27233)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-29 09:30:14 +00:00
Eunjae Lee ea15474edf fix: show bookings-v3 only in the settings but not in the banner (#27330)
## What does this PR do?

Adds a `displayLocations` property to the `OptInFeatureConfig` interface to control where opt-in features are displayed (settings page, banner, or both), and applies this filtering at the callers' side.

## How to test locally

```
update "Feature" set enabled = true where slug='bookings-v3';
```

Run it to globally enable the flag, and then go to the settings page to see the "Features" menu.

---

**Changes:**
- Added `OptInFeatureDisplayLocation` type with values `"settings"` | `"banner"`
- Added optional `displayLocations` property to `OptInFeatureConfig` interface
- Added helper functions:
  - `getFeatureDisplayLocations()` - returns display locations with default of `['settings']`
  - `shouldDisplayFeatureAt()` - checks if a feature should display at a specific location
  - `getOptInFeaturesForLocation()` - filters features by location
- Updated `getOptInFeaturesForScope()` to accept an optional `displayLocation` parameter for filtering
- Simplified `HAS_*_OPT_IN_FEATURES` constants to use `getOptInFeaturesForScope(scope, "settings").length > 0`
- Updated `FeatureOptInService.listFeaturesForUser()` to filter by 'settings' location
- Updated `FeatureOptInService.listFeaturesForTeam()` to filter by 'settings' location
- Updated `useFeatureOptInBanner` hook to check for 'banner' location before showing

**Default behavior:** If `displayLocations` is omitted, features default to `['settings']` only.

## Mandatory Tasks (DO NOT REMOVE)

- [x] I have self-reviewed the code (A decent size PR without self-review might be rejected).
- [x] I have updated the developer docs in /docs if this PR makes changes that would require a [documentation change](https://cal.com/docs). N/A - internal config change only.
- [x] I confirm automated tests are in place that prove my fix is effective or that my feature works.

## How should this be tested?

1. Verify the helper functions work as expected:
   - `getFeatureDisplayLocations({ slug: "test", ... })` should return `["settings"]` (default)
   - `getFeatureDisplayLocations({ slug: "test", displayLocations: ["banner"] })` should return `["banner"]`
   - `shouldDisplayFeatureAt(feature, "settings")` should return `true` for features without `displayLocations`
   - `getOptInFeaturesForScope("user", "banner")` should only return user-scoped features with `"banner"` in their `displayLocations`

2. Verify caller-side filtering:
   - `listFeaturesForUser()` and `listFeaturesForTeam()` should only return features with 'settings' in displayLocations
   - Banner hook should only show features with 'banner' in displayLocations
   - `HAS_*_OPT_IN_FEATURES` constants should only be true if there are features with 'settings' location

## Checklist

- [x] My code follows the style guidelines of this project
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] My changes generate no new warnings

---

### Human Review Checklist
- [ ] Verify the default behavior (defaulting to `['settings']`) matches requirements
- [ ] Confirm the filtering logic is applied correctly in all callers (service methods, banner hook, constants)
- [ ] Verify the banner hook correctly prevents showing features without 'banner' in displayLocations
- [ ] Note: The test mock returns all features regardless of `displayLocation` parameter - the filtering logic in `getOptInFeaturesForScope` isn't directly tested. Consider if this is acceptable or if tests should be added.

**Link to Devin run:** https://app.devin.ai/sessions/a064ee43a56d458caf2892b55959f1ea
**Requested by:** @eunjae-lee
2026-01-29 10:17:50 +01:00
6acc84661d refactor: fix 413 Request Entity too large error when big image files are uploaded (#27285)
* feat: add image resizing endpoint for organization onboarding

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

* chore: add translation keys for image upload errors

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

* refactor: rename endpoint to resizeBase64Image with imageType enum and Zod validation

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

* refactor: use ImageUploader and BannerUploader components for organization brand onboarding

Replace server-side image resizing endpoint with client-side cropping using existing ImageUploader and BannerUploader components. This approach:
- Avoids sending large base64 payloads to the server (preventing 413 errors)
- Provides better UX with cropping functionality
- Aligns with team onboarding which already uses ImageUploader
- Removes unnecessary server-side code

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

* Revert formatting change

* fix: correct max file size from 10mb to 5mb in translations

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

* Add Remove bvutton too

* fix: correct banner dimension, indentation, and import order

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-29 08:55:13 +00:00
5b048dd868 fix: exclude third-party video apps from areCalendarEventsEnabled setting (#27230)
* fix: exclude third-party video apps from areCalendarEventsEnabled setting

When areCalendarEventsEnabled is false, video meeting creation for third-party
video apps (like Daily.co) was being skipped, causing the meetingUrl field in
webhook payloads to contain the location identifier (integrations:daily) instead
of the actual video meeting URL.

This fix adds a skipCalendarEvent option to EventManager.create() that:
- Skips calendar event creation when true
- Skips CRM event creation when true (CRM events are tied to calendar events)
- Still creates video meetings for third-party video apps

The calling code in handleConfirmation.ts and RegularBookingService.ts now uses
this option when areCalendarEventsEnabled is false, ensuring video meetings are
still created while respecting the platform customer's preference to manage
their own calendar events.

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

* test: update E2E tests to verify skipCalendarEvent option is passed when areCalendarEventsEnabled is false

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

* refactor: address PR review feedback - remove comments and update E2E tests to spy on createAllCalendarEvents

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

* test: add spy for createAllCRMEvents in e2e tests when areCalendarEventsEnabled is false

Co-Authored-By: lauris@cal.com <lauris.skraucis@gmail.com>

* test: add missing spy for createAllCRMEvents in beforeEach hook

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

* fix: use videoCallUrl from metadata for webhook payload location

Co-Authored-By: lauris@cal.com <lauris.skraucis@gmail.com>

* fix: simplify webhook location to use metadata?.videoCallUrl || evt.location

Co-Authored-By: lauris@cal.com <lauris.skraucis@gmail.com>

* docs: add description about video call link limitations when calendar events disabled

Co-Authored-By: lauris@cal.com <lauris.skraucis@gmail.com>

* i18n: add translation key for calendar events disabled video limitation

Co-Authored-By: lauris@cal.com <lauris.skraucis@gmail.com>

* fix: update translation text to accurately reflect video app behavior

Co-Authored-By: lauris@cal.com <lauris.skraucis@gmail.com>

* Update common.json

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
2026-01-29 10:23:52 +02:00
b02470e780 feat: add webhook endpoint for Vercel deployment promoted event to promote trigger.dev (#27340)
* feat: add webhook endpoint for Vercel deployment promoted event to promote trigger.dev

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

* refactor: rename TRIGGER_API_KEY to TRIGGER_SECRET_KEY

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-29 16:24:42 +09:00
Hariom Balhara c536445eec docs: clarify bookerReopened and bookerReloaded are for prerendered modals (#27348)
## What does this PR do?

Updates the embed lifecycle documentation to clarify that `bookerReopened` and `bookerReloaded` events are only applicable for prerendered modals that are now visible.

## Mandatory Tasks (DO NOT REMOVE)

- [x] I have self-reviewed the code (A decent size PR without self-review might be rejected).
- [x] I have updated the developer docs in /docs if this PR makes changes that would require a [documentation change](https://cal.com/docs). N/A - this PR is itself a documentation update.
- [x] I confirm automated tests are in place that prove my fix is effective or that my feature works. N/A - documentation only change.

## How should this be tested?

This is a documentation-only change. Review the accuracy of the added "Applicability" notes for both events.

## Checklist

- [x] I have read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- [x] My code follows the style guidelines of this project
- [x] I have checked if my changes generate no new warnings

---

### Reviewer Checklist
- [ ] Verify that the documentation accurately reflects when `bookerReopened` and `bookerReloaded` events fire (only for prerendered modals)

---

> [!NOTE]
> **Link to Devin run**: https://app.devin.ai/sessions/35a0c5d4d5fc4feb9a5af2d8d340b9c5
> **Requested by**: @hariombalhara
2026-01-29 10:13:58 +05:30
09f33a840f chore: trigger.version.ts to set trigger tasks versions in prod (#27333)
* chore: trigger.version.ts instead of .env.production

* chore: trigger.version.ts instead of .env.production

* chore: restore dotenv/config import for local development

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

* fixup! chore: trigger.version.ts instead of .env.production

* fixup! fixup! chore: trigger.version.ts instead of .env.production

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-28 19:36:36 -03:00
16cb9a3126 refactor: join teams with feature enabled directly in findNextSubscriptionBatch query (#27364)
* refactor: join teams with feature enabled directly in findNextSubscriptionBatch query

- Changed findNextSubscriptionBatch to accept featureId instead of teamIds
- Join TeamFeatures table directly in the query instead of separate queries
- Updated CalendarSubscriptionService.checkForNewSubscriptions to pass featureId
- Removed separate getTeamsWithFeatureEnabled call
- Updated tests to reflect the new query structure

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

* refactor: change featureId to featureIds array for flexibility

- Changed findNextSubscriptionBatch to accept featureIds array instead of single featureId
- Updated Prisma query to use 'in' operator for multiple feature IDs
- Updated CalendarSubscriptionService to pass array with single feature
- Updated all tests to use featureIds array

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-28 17:52:00 -03:00
Alex van AndelandDevin AI 844380e265 fix: reduce forcedSlowMode rate limit from 30s to 1s (#27359)
* fix: reduce forcedSlowMode rate limit from 30s to 1s

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

* revert: keep rateLimit.ts unchanged, only update API v2 controllers

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-28 17:39:28 -03:00
034fbd63f1 feat: Add CrmRoutingTraceService and SalesforceRoutingTraceService (#27318)
* Add DB schema

* Init zod schema

* Init RoutingTrace and PendingRoutingTrace repository interfaces

* Create PrismaPendingRoutingTraceRepository

* Init RoutingTraceService

* Create RoutingTraceService container

* User routing trace service in routing

* Create RoutingTraceRepository and PrismaRoutingTraceRepoistory

* Add findByFormResponseId and findByQueuedFormResponseId to PendingRoutingTraceRepository

* Update DI containers

* RoutingTraceService create process booking method

* Use pending routing trace rather than URL params

* Fix schema

* Fix writing assignment reason for routed booking

* Remove  from  service

* Refactor RoutingTraceService to not rely on async local storage

* Pass RoutingTraceService through routing call

* Add attribute-logic-evaluated to routing trace step

* Add routing trace to trpc endpoint

* Add CRM routing trace step

* Fix extracting routing trace to assignment reason

* Add back CRM params to prevent refetching

* test: Add unit tests for RoutingTraceService and repositories

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

* test: Add missing mock for RoutingTraceService in getRoutedUrl tests

Also fix pre-existing lint issues in the test file:
- Add explicit types to mockForm and mockSerializableForm variables
- Add explicit type to url parameter in mockContext
- Replace 'as any' with 'as unknown as InstanceType<typeof UserRepository>'

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

* Link pending form submission to routing trace

* Clean up

* Add lookup field assignment reasons

* Rename to PendingRoutingTrace

* Add migration file

* fix: Update RoutingTraceService tests to use assignmentReasonRepository mock

- Add getStepsCount() method back to RoutingTraceService
- Add queuedFormResponseId support to processForBooking method
- Update tests to use mockAssignmentReasonRepository instead of prisma mock
- Remove test for missing routingTraceRepository (all deps now required)

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

* fix: Remove PII (organizer email) from log payload in RoutingTraceService

Addresses Cubic AI review feedback with confidence 9/10.
Logging PII violates sensitive information logging rules.

Co-Authored-By: unknown <>

* Write field values at the time of routing

* Write attributes used to route

* feat: add CRM routing trace service

Add CrmRoutingTraceService as a reusable wrapper around RoutingTraceService
for CRM-specific tracing. Also adds CrmRoutingTraceServiceInterface type
to support passing trace services through the CRM call chain.

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

* feat: add Salesforce routing trace infrastructure

Add SalesforceRoutingTrace static class with 19 trace methods covering:
- Account resolution (SOQL path): searching by website, contact domain
- Lookup field queries
- Owner lookups (contact, lead, account)
- Validation and skip scenarios
- GraphQL three-tier resolution

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

* feat: wire CRM trace service through call chain

Pass crmTrace parameter through the CRM call chain:
- routerGetCrmContactOwnerEmail creates CrmRoutingTraceService
- Passes to app booking form handlers and CRM round robin skip
- CrmManager.getContacts accepts and forwards crmTrace
- All handlers accept optional crmTrace parameter

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

* feat: add trace instrumentation to Salesforce CRM service

Instrument Salesforce CRM methods with routing trace steps:
- getContacts: trace owner lookups for contact/lead/account
- getAccountIdBasedOnEmailDomainOfContacts: trace website and domain searches
- findUserEmailFromLookupField: trace lookup field queries
- GetAccountRecordsForRRSkip (GraphQL): trace three-tier resolution

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

* refactor: rename SalesforceRoutingTrace to SalesforceRoutingTraceService

Consistent naming with CrmRoutingTraceService.

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

* test: add unit tests for CrmRoutingTraceService and SalesforceRoutingTraceService

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

* refactor: use AsyncLocalStorage for CRM routing trace

Replace explicit crmTrace parameter passing with AsyncLocalStorage:

- Add AsyncLocalStorage to RoutingTraceService with getCurrent() and runAsync()
- Update SalesforceRoutingTraceService to auto-resolve trace from AsyncLocalStorage
- Remove CrmRoutingTraceService wrapper (no longer needed)
- Remove crmTrace parameter from all CRM method signatures
- Wrap CRM operations in routingTraceService.runAsync() context

This is cleaner than threading crmTrace through 5+ function layers.
The trace context is available within the withReporting wrapper.

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

* Use async local storage for `CrmRoutingTraceService`

* fix: correct template literal syntax in RoutingTraceService

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

* fix: use narrowed eventTypeId variable in nested async function

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

* fix: update SalesforceRoutingTraceService tests to use AsyncLocalStorage API

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

* refactor: rename crmTrace to crmRoutingTraceService for consistency

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Volnei Munhoz <volnei@cal.com>
2026-01-28 17:28:35 -03:00
f536100224 feat: Add RoutingFormTrace (#27316)
* Add DB schema

* Init zod schema

* Init RoutingTrace and PendingRoutingTrace repository interfaces

* Create PrismaPendingRoutingTraceRepository

* Init RoutingTraceService

* Create RoutingTraceService container

* User routing trace service in routing

* Create RoutingTraceRepository and PrismaRoutingTraceRepoistory

* Add findByFormResponseId and findByQueuedFormResponseId to PendingRoutingTraceRepository

* Update DI containers

* RoutingTraceService create process booking method

* Use pending routing trace rather than URL params

* Fix schema

* Fix writing assignment reason for routed booking

* Remove  from  service

* Refactor RoutingTraceService to not rely on async local storage

* Pass RoutingTraceService through routing call

* Add attribute-logic-evaluated to routing trace step

* Add routing trace to trpc endpoint

* Add CRM routing trace step

* Fix extracting routing trace to assignment reason

* Add back CRM params to prevent refetching

* test: Add unit tests for RoutingTraceService and repositories

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

* test: Add missing mock for RoutingTraceService in getRoutedUrl tests

Also fix pre-existing lint issues in the test file:
- Add explicit types to mockForm and mockSerializableForm variables
- Add explicit type to url parameter in mockContext
- Replace 'as any' with 'as unknown as InstanceType<typeof UserRepository>'

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

* Link pending form submission to routing trace

* Clean up

* Add lookup field assignment reasons

* Rename to PendingRoutingTrace

* Add migration file

* fix: Update RoutingTraceService tests to use assignmentReasonRepository mock

- Add getStepsCount() method back to RoutingTraceService
- Add queuedFormResponseId support to processForBooking method
- Update tests to use mockAssignmentReasonRepository instead of prisma mock
- Remove test for missing routingTraceRepository (all deps now required)

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

* fix: Remove PII (organizer email) from log payload in RoutingTraceService

Addresses Cubic AI review feedback with confidence 9/10.
Logging PII violates sensitive information logging rules.

Co-Authored-By: unknown <>

* Write field values at the time of routing

* Write attributes used to route

* fix: Add CHECK constraint to ensure at least one response ID is set in routing trace tables

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

* fix: Address PR review comments for routing trace feature

- Add checkedFallback to routing trace step data (Comment 11)
- Extract hardcoded domain/step strings to constants (Comment 13)
- Use @default(now()) for createdAt in PendingRoutingTrace and RoutingTrace (Comment 15)
- Add DEFAULT CURRENT_TIMESTAMP to migration for createdAt fields

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

* fix: Use UTC-safe timezone expression for createdAt defaults in routing trace tables

Addresses Cubic AI review feedback (confidence 9/10) to prevent timezone
issues by using dbgenerated("timezone('UTC', now())") instead of bare now()
for the createdAt fields in PendingRoutingTrace and RoutingTrace models.

Co-Authored-By: unknown <>

* fix: Align migration SQL with Prisma's expected timezone syntax

Use 'UTC'::text cast in timezone() function to match Prisma's generated SQL.

Co-Authored-By: unknown <>

* fix: Revert migration SQL to match Prisma schema timezone syntax

Remove ::text cast from timezone() function to match what Prisma generates
from the schema definition.

Co-Authored-By: unknown <>

* fix: Use explicit ::text cast in timezone() for PostgreSQL compatibility

PostgreSQL normalizes timezone('UTC', now()) to timezone('UTC'::text, now())
internally. Update both schema and migration to use the explicit cast to
ensure they match and pass the migration check.

Co-Authored-By: unknown <>

* feat: Add RoutingFormTraceService domain class for type-safe routing traces

- Create RoutingFormTraceService class with routeMatched, fallbackRouteUsed,
  attributeLogicEvaluated, and attributeFallbackUsed methods
- Add ROUTING_FORM_STEPS constants
- Add unit tests

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

* feat: Integrate RoutingFormTraceService for routing form tracing

- Add routingFormTraceService parameter to findMatchingRoute
- Record route_matched or fallback_route_used step when route is selected
- Use "Default Route" as name for form-level fallback routes
- Refactor findTeamMembersMatchingAttributeLogic to use RoutingFormTraceService
- Update handleResponse to accept and pass routingFormTraceService
- Create RoutingFormTraceService in getRoutedUrl and pass through the flow

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

* test: Add test coverage for RoutingFormTraceService integration

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

* feat: Integrate RoutingFormTraceService for routing form tracing

- Add routingFormTraceService parameter to findMatchingRoute
- Record route_matched or fallback_route_used step when route is selected
- Use "Default Route" as name for form-level fallback routes
- Refactor findTeamMembersMatchingAttributeLogic to use RoutingFormTraceService
- Update handleResponse to accept and pass routingFormTraceService
- Create RoutingFormTraceService in getRoutedUrl and pass through the flow

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

* fix: Restore schema and migration from main branch

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

* fix: Use correct parameter name routingFormTraceService in tests

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

* fix: Use 'default_route' instead of 'Default Route' for fallback route name

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 17:15:22 -03:00
Alex van AndelandDevin AI 906ba99d3c fix: add composite index on SecondaryEmail for email verification queries (#27357)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-28 14:53:31 -03:00
620445d85f feat: Use RoutingTraceService to write assignment reasons (#27225)
* Add DB schema

* Init zod schema

* Init RoutingTrace and PendingRoutingTrace repository interfaces

* Create PrismaPendingRoutingTraceRepository

* Init RoutingTraceService

* Create RoutingTraceService container

* User routing trace service in routing

* Create RoutingTraceRepository and PrismaRoutingTraceRepoistory

* Add findByFormResponseId and findByQueuedFormResponseId to PendingRoutingTraceRepository

* Update DI containers

* RoutingTraceService create process booking method

* Use pending routing trace rather than URL params

* Fix schema

* Fix writing assignment reason for routed booking

* Remove  from  service

* Refactor RoutingTraceService to not rely on async local storage

* Pass RoutingTraceService through routing call

* Add attribute-logic-evaluated to routing trace step

* Add routing trace to trpc endpoint

* Add CRM routing trace step

* Fix extracting routing trace to assignment reason

* Add back CRM params to prevent refetching

* test: Add unit tests for RoutingTraceService and repositories

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

* test: Add missing mock for RoutingTraceService in getRoutedUrl tests

Also fix pre-existing lint issues in the test file:
- Add explicit types to mockForm and mockSerializableForm variables
- Add explicit type to url parameter in mockContext
- Replace 'as any' with 'as unknown as InstanceType<typeof UserRepository>'

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

* Link pending form submission to routing trace

* Clean up

* Add lookup field assignment reasons

* Rename to PendingRoutingTrace

* Add migration file

* fix: Update RoutingTraceService tests to use assignmentReasonRepository mock

- Add getStepsCount() method back to RoutingTraceService
- Add queuedFormResponseId support to processForBooking method
- Update tests to use mockAssignmentReasonRepository instead of prisma mock
- Remove test for missing routingTraceRepository (all deps now required)

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

* fix: Remove PII (organizer email) from log payload in RoutingTraceService

Addresses Cubic AI review feedback with confidence 9/10.
Logging PII violates sensitive information logging rules.

Co-Authored-By: unknown <>

* Write field values at the time of routing

* Write attributes used to route

* fix: Add CHECK constraint to ensure at least one response ID is set in routing trace tables

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

* fix: Address PR review comments for routing trace feature

- Add checkedFallback to routing trace step data (Comment 11)
- Extract hardcoded domain/step strings to constants (Comment 13)
- Use @default(now()) for createdAt in PendingRoutingTrace and RoutingTrace (Comment 15)
- Add DEFAULT CURRENT_TIMESTAMP to migration for createdAt fields

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

* fix: Use UTC-safe timezone expression for createdAt defaults in routing trace tables

Addresses Cubic AI review feedback (confidence 9/10) to prevent timezone
issues by using dbgenerated("timezone('UTC', now())") instead of bare now()
for the createdAt fields in PendingRoutingTrace and RoutingTrace models.

Co-Authored-By: unknown <>

* fix: Align migration SQL with Prisma's expected timezone syntax

Use 'UTC'::text cast in timezone() function to match Prisma's generated SQL.

Co-Authored-By: unknown <>

* fix: Revert migration SQL to match Prisma schema timezone syntax

Remove ::text cast from timezone() function to match what Prisma generates
from the schema definition.

Co-Authored-By: unknown <>

* fix: Use explicit ::text cast in timezone() for PostgreSQL compatibility

PostgreSQL normalizes timezone('UTC', now()) to timezone('UTC'::text, now())
internally. Update both schema and migration to use the explicit cast to
ensure they match and pass the migration check.

Co-Authored-By: unknown <>

* fix: Use CURRENT_TIMESTAMP for createdAt defaults (standard Cal.com pattern)

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

* fix: Use @default(now()) for createdAt in routing trace tables to match migration

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Pedro Castro <pedro@cal.com>
2026-01-28 12:45:15 -05:00
c9bd18e866 chore: Disables syncing of calendarList on overlay calendar fetch (#27020)
* chore: Disables syncing of calendarList on overlay calendar fetch

* Unwrap ToggledConnectedCalendars

* Pick the right type for connectedCalendars

* further type amends

* Type fixes, tons of em

* Some further fixups consistent with what was before

* fix: resolve type incompatibility in getConnectedDestinationCalendars return type

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

* fix: update DestinationCalendarProps to accept tRPC handler return type

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

* fix: use generic type for DestinationCalendarProps to accept tRPC enriched types

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

* fix: simplify DestinationCalendarProps type for better compatibility

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

* fix: export ConnectedCalendar type for consistent type inference

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

* fix: use permissive type for connectedCalendars to accept tRPC enriched types

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

* fix: export ConnectedCalendar and ConnectedDestinationCalendars types from platform-libraries

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

* fix: update ConnectedCalendar type to use boolean | null for primary field

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

* Update ConnectedCalendarItem

* Undo some of Devins fixes, more fixes

* Fixup the destination calendar return type, historically not null

* Change init to undefined to deal with null

* Approach to connect selected calendars with the right credential

* This return type is used way too much everywhere, not refactoring

* Add the selectedCalendars to the type

* Actually fix overlay calendar

* set calendarsToLoad param as required

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

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

* Add new translation for 'Calendar Settings'

---------

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-28 14:20:19 -03:00
205 changed files with 8325 additions and 1963 deletions
+11 -3
View File
@@ -84,9 +84,17 @@ jobs:
- name: Update Env Files
run: |
VERSION="${{ steps.deploy-trigger.outputs.deploymentVersion }}"
echo "TRIGGER_VERSION=$VERSION" > apps/web/.env.production
echo "TRIGGER_VERSION=$VERSION" > apps/api/v2/.env.production
git add -f apps/web/.env.production apps/api/v2/.env.production
# Create the TypeScript content
CONTENT="export const TRIGGER_VERSION = '$VERSION';"
# Write to the specific paths
echo "$CONTENT" > apps/web/trigger.version.ts
echo "$CONTENT" > apps/api/v2/trigger.version.ts
echo "$CONTENT" > apps/api/v1/trigger.version.js
# Force add to git since these are likely in .gitignore
git add -f apps/web/trigger.version.ts apps/api/v2/trigger.version.ts apps/api/v1/trigger.version.js
# 4. Commit everything together
- name: Commit changes
-1
View File
@@ -38,7 +38,6 @@ yarn-error.log*
.env.development.local
.env.test.local
.env.production.local
.env.production
.env.*
!.env.example
!.env.appStore.example
+7 -1
View File
@@ -2,7 +2,7 @@
const { withAxiom } = require("next-axiom");
const { withSentryConfig } = require("@sentry/nextjs");
const { PrismaPlugin } = require("@prisma/nextjs-monorepo-workaround-plugin");
const { TRIGGER_VERSION } = require("./trigger.version.js");
const plugins = [withAxiom];
/** @type {import("next").NextConfig} */
@@ -105,4 +105,10 @@ if (process.env.NEXT_PUBLIC_SENTRY_DSN) {
);
}
const env = process.env;
if (process.env.NODE_ENV === "production" || process.env.CALCOM_ENV === "production") {
env.TRIGGER_VERSION = TRIGGER_VERSION;
}
module.exports = () => plugins.reduce((acc, next) => next(acc), nextConfig);
+1
View File
@@ -0,0 +1 @@
export const TRIGGER_VERSION = '20260129.1';
+2 -1
View File
@@ -17,7 +17,8 @@
"**/*.ts",
"**/*.tsx",
"../../../packages/types/*.d.ts",
"../../../packages/types/next-auth.d.ts"
"../../../packages/types/next-auth.d.ts",
"trigger.version.js"
],
"exclude": ["**/node_modules/**", "templates", "auth"]
}
+36 -10
View File
@@ -1,14 +1,34 @@
NODE_ENV=
API_PORT=
API_URL=
DATABASE_READ_URL=
DATABASE_WRITE_URL=
LOG_LEVEL=
NEXTAUTH_SECRET=
DATABASE_URL=
JWT_SECRET=
# these are the environment variables you absolutely need for api v2 to work
# NODE_ENV, API_PORT, REDIS_URL, DATABASE_URL, DATABASE_READ_URL, DATABASE_WRITE_URL, NEXTAUTH_SECRET, JWT_SECRET, STRIPE_API_KEY, STRIPE_WEBHOOK_SECRET, CALENDSO_ENCRYPTION_KEY
# we set environmnet as development so that right environment file is loaded and swagger is generated
NODE_ENV="development"
API_PORT=5555
API_URL="http://localhost"
# url to Postgres running in Docker. Postgres is setup in packages/prisma/docker-compose.yml
DATABASE_READ_URL="postgresql://postgres:@localhost:5450/calendso"
DATABASE_WRITE_URL="postgresql://postgres:@localhost:5450/calendso"
LOG_LEVEL=DEBUG
# the same value as in the root .env - it is used by api-auth.strategy.ts to validate requests authenticated with next token. If you already have a value
# in the root .env then you can paste it here. It is important that they are the same.
NEXTAUTH_SECRET="RbyBOfiZFt/IsBL2E2Zy2idcKRH+WclnHEiu1FmRdf0="
DATABASE_URL="postgresql://postgres:@localhost:5450/calendso"
DATABASE_DIRECT_URL="postgresql://postgres:@localhost:5450/calendso"
# used by JwtService to sign and decode tokens
JWT_SECRET="asjdijI1JIO12I3O89198jojioSAJDU"
SENTRY_DSN=
# mailhog setup. Make sure its running - see instructions in apps/api/v2/README.md
EMAIL_SERVER_HOST='localhost'
EMAIL_SERVER_PORT=1025
# used to manage encrypt and decrypt third party tokens
CALENDSO_ENCRYPTION_KEY="Lmqy57DlHKPvwwrtkwjL/8QtxJWxC/OV"
# Service Account Encryption Key for encrypting/decrypting service account keys
CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY="LDBFYLEVICRZBYQZUZCSTZRJFZUKZMEI"
# url to redis instance running in Docker and used to cache data. Redis is setup in apps/api/v2/docker-compose.yaml
REDIS_URL="rediss://localhost:6379"
# KEEP THIS EMPTY, DISABLE SENTRY CLIENT INSIDE OF LIBRARIES USED BY APIv2
NEXT_PUBLIC_SENTRY_DSN=
@@ -19,13 +39,19 @@ STRIPE_PRICE_ID_ESSENTIALS=
STRIPE_PRICE_ID_ESSENTIALS_OVERAGE=
STRIPE_PRICE_ID_ENTERPRISE=
STRIPE_PRICE_ID_ENTERPRISE_OVERAGE=
# since these two values are required for api v2 to start, you can put a dummy value here if you don't need Stripe functionality
# this can be any random string value eg: STRIPE_API_KEY="sk_test_1234567890" and STRIPE_WEBHOOK_SECRET="whsec_1234567890"
STRIPE_API_KEY=
STRIPE_WEBHOOK_SECRET=
# used to know where to direct people when managing platform billing
WEB_APP_URL=http://localhost:3000/
# to setup license key see apps/api/v2/README.md
CALCOM_LICENSE_KEY=
# when request is authenticated with an api key we need to remove the prefix. See code in api-auth.strategy.ts
API_KEY_PREFIX=cal_
GET_LICENSE_KEY_URL="https://console.cal.com/api/license"
GET_LICENSE_KEY_URL="https://goblin.cal.com/v1/license"
# we disable workers when running e2e and enable them while running locally
IS_E2E=false
DOCS_URL=
-1
View File
@@ -1 +0,0 @@
TRIGGER_VERSION=20260128.2
+3
View File
@@ -44,3 +44,6 @@ lerna-debug.log*
!.env.appStore.example
/client/*
/generated/prisma/*
# Generated secrets file
.generated-secrets
+156
View File
@@ -0,0 +1,156 @@
# Local development
## Local development - part 1
This setup will allow you to develop with api v2 locally and also test platform atoms with the examples app.
Open `apps/api/v2/README.md` and do all the steps except step 6 where you setup prisma.
## Local development - part 2 (platform variables shared between api v2 and examples app)
We now need to setup platform OAuth client and OAuth 2.0 client that can be tested in the platform example app `packages/platform/examples/base`.
### If you want to create clients by seeding the database automatically
1. Setup root environment that are re-used in the root `.env` and in the platform examples app - open the root `.env` and set values for the platform OAuth client. The id and secret can be random values.
```
SEED_PLATFORM_OAUTH_CLIENT_ID=
SEED_PLATFORM_OAUTH_CLIENT_SECRET=
```
Then you need to copy these values to the platform examples app environment.
- Rename `packages/platform/examples/base/.env.example` to `packages/platform/examples/base/.env`. There will be many environment variables already populated to make it easier for you.
- copy the value of `SEED_PLATFORM_OAUTH_CLIENT_ID` into `packages/platform/examples/base/.env` `NEXT_PUBLIC_X_CAL_ID`
- copy the value of `SEED_PLATFORM_OAUTH_CLIENT_SECRET` into `packages/platform/examples/base/.env` `X_CAL_SECRET_KEY`
Then, we need to setup platform OAuth 2.0 client. We will need an id, and then hashed and plain secrets. To generate them you can:
```
cd apps/api/v2
yarn generate-secrets
```
and then the secrets will be written to `apps/api/v2/.generated-secrets` file containing:
```
plain - <PLAINTEXT_SECRET>
hashed - <HASHED_SECRET>
```
In the root `.env` set the following environment variables:
```
SEED_OAUTH2_CLIENT_ID=
SEED_OAUTH2_CLIENT_SECRET_HASHED=
```
where CLIENT_ID can be random but SECRET_HASHED is hashed secret above.
Then in the `packages/platform/examples/base/.env` set the `NEXT_PUBLIC_OAUTH2_CLIENT_ID` to be equal to `SEED_OAUTH2_CLIENT_ID` and `OAUTH2_CLIENT_SECRET_PLAIN` to the plain secret generated above.
### If you want to create clients manually
1. Create new oauth client in the PlatformOAuthClient table manually. Here is example json. You can set id and secret to random values.
```
{
"id": "clxyyy21o0003sbk7yw5z6tzg",
"name": "Acme",
"secret": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiQWNtZSAiLCJwZXJtaXNzaW9ucyI6MTAyMywicmVkaXJlY3RVcmlzIjpbImh0dHA6Ly9sb2NhbGhvc3Q6NDMyMSJdLCJib29raW5nUmVkaXJlY3RVcmkiOiIiLCJib29raW5nQ2FuY2VsUmVkaXJlY3RVcmkiOiIiLCJib29raW5nUmVzY2hlZHVsZVJlZGlyZWN0VXJpIjoiIiwiYXJlRW1haWxzRW5hYmxlZCI6dHJ1ZSwiaWF0IjoxNzE5NTk1ODA4fQ.L5_jSS14fcKLCD_9_DAOgtGd6lUSZlU5CEpCPaPt41I",
"permissions": 1023,
"logo": null,
"redirectUris": "{http://localhost:4321}",
"organizationId": 1,
"createdAt": "2026-01-27 09:35:27.297",
"areEmailsEnabled": true,
"bookingCancelRedirectUri": null,
"bookingRedirectUri": null,
"bookingRescheduleRedirectUri": null,
"areDefaultEventTypesEnabled": true,
"areCalendarEventsEnabled": true
}
```
Then you need to copy id and secret to the platform examples app environment.
- Rename `packages/platform/examples/base/.env.example` to `packages/platform/examples/base/.env`
- copy the client id into `packages/platform/examples/base/.env` `NEXT_PUBLIC_X_CAL_ID`
- copy the client secret into `packages/platform/examples/base/.env` `X_CAL_SECRET_KEY`
Then, we need to setup platform OAuth 2.0 client. We will need an id, and then hashed and plain secrets. To generate them you can:
```
cd apps/api/v2
yarn generate-secrets
```
and then the secrets will be written to `apps/api/v2/.generated-secrets` file containing:
```
plain - <PLAINTEXT_SECRET>
hashed - <HASHED_SECRET>
```
Then in the `packages/platform/examples/base/.env` set the `NEXT_PUBLIC_OAUTH2_CLIENT_ID` to be equal to a random id and `OAUTH2_CLIENT_SECRET_PLAIN` to the plain secret generated above.
Then create a new entry in the OAuthClient table with the random id and hashed secret. It is important that the redirectUri is like below because that is where the examples app is running.
```
{
"clientId": "1c70be53f35aa480a5e3146d361fd993d265e564d2d86a203df3adbd05186517",
"redirectUri": "http://localhost:4321",
"clientSecret": "970db2cf14112013ba3a510b945294fef8737d42ee58c32031d2351692068ce7",
"name": "atoms examples app oauth 2 client",
"logo": null,
"clientType": "confidential",
"isTrusted": false,
"createdAt": "2026-01-27 09:35:26.731",
"purpose": "test atoms examples app with oauth 2",
"rejectionReason": null,
"status": "approved",
"userId": 10,
"websiteUrl": "http://localhost:4321"
}
```
I point userId to 10 aka `admin@example.com`.
## Local development - part 3
Proceed to `apps/api/v2/README.md` step 6 to setup prisma and seed database if you need to.
## Local development - part 4 (Stripe)
If you want to test platform billing locally then please proceed to `packages/platform/atoms/STRIPE.md` tutorial.
## Local development - part 5
If you followed steps above then examples app environment is setup.
If you also want to test apple connect calendar atom, then you need to create app specific password using your apple email and
set the following variables:
```
ATOMS_E2E_APPLE_ID=
ATOMS_E2E_APPLE_CONNECT_APP_SPECIFIC_PASSCODE=
```
Finally, if you want that the examples app is tested using OAuth 2.0 client then you must set environment variable
`NEXT_PUBLIC_OAUTH2_MODE="true"` in `packages/platform/examples/base/.env`. If you want to test it using platform OAuth client then
comment this variable or set to false.
# Running everything
1. Build atoms for local development:
cd packages/platform/atoms
yarn dev-on
yarn build-npm
```
The dev-on command changes how atoms are built. Remember to run `yarn dev-off` when you are done testing atoms locally. If you update anything related to frontend, for example, Booker, then you need to run `yarn build-npm` again to rebuild atoms.
2. Start api v2:
```
Sometimes it happens that v2 api restarts because some unrelated log of build files changed, and if it makes examples app not wor you can just start it using:
```
cd apps/api/v2
yarn dev:build
yarn start
```
3. Setups examples app sqlite database.
```
cd packages/platform/examples/base
rm -f prisma/dev.db && yarn prisma db push
```
4. Run examples app and open localhost:4321
```
cd packages/platform/examples/base
yarn dev
```
If you have set `NEXT_PUBLIC_OAUTH2_MODE="true"` then here is how to test it:
If you do test OAuth 2.0 using atoms then do below:
1. Start cal web app locally by running `yarn dev` in the root folder and login using any user into cal web app running locally.
2. Using client id and redirect_uri information above create /authorize url. Example http://localhost:3000/auth/oauth2/authorize?client_id=1c70be53f35aa480a5e3146d361fd993d265e564d2d86a203df3adbd05186517&redirect_uri=http://localhost:4321&state=texas or if you have setup localhost:3000 to map to app.cal.local http://app.cal.local:3000/auth/oauth2/authorize?client_id=1c70be53f35aa480a5e3146d361fd993d265e564d2d86a203df3adbd05186517&redirect_uri=http://localhost:4321&state=texas and authorize test OAuth client. You will be redirected to localhost:4321?code=abc and this route will exchange the authorization code for access and refresh tokens for the logged in cal user and store them in the examples app SQLite database.
3. Examples app is ready to use. If you update, let's say an availability, then it will be reflected in the availability of admin@example.com in the web app running locally.
+45 -88
View File
@@ -1,95 +1,68 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://github.com/user-attachments/assets/ce7c2ecf-6097-469a-8512-c846a9fb665d" height="200" alt="Nest Logo" /></a>
</p>
Cal.com api v2 is a [Nest.js](https://github.com/nestjs/nest) project.
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://coveralls.io/github/nestjs/nest?branch=master" target="_blank"><img src="https://coveralls.io/repos/github/nestjs/nest/badge.svg?branch=master#9" alt="Coverage" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
Cal.com is using the [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Installation
# Local development
This setup will allow you to develop with api v2 locally. If you want to also test atoms locally with platform's example app,
then proceed to `apps/api/v2/README-PLATFORM.md` instead.
1. Install dependencies
```bash
$ yarn install
```
## Prisma setup
2. Download and install Docker on your computer and then make sure it is running. You simply need to open Docker dashboard.
3. Make sure that mailhog is running - it is used by v2 to send emails locally. You can start it by:
```bash
$ yarn prisma generate
$ cd packages/emails && yarn dx
```
4. Setup api v2 environment - make a copy of `apps/api/v2/.env.example` and rename it to `apps/api/v2/.env` - it has almost all the required values setup - ones you need to add will be explained below.
- Then copy the value of `NEXTAUTH_SECRET` from there to the root `.env` `NEXTAUTH_SECRET`. If you have `NEXTAUTH_SECRET` already in the root `.env` then you can paste that value in `apps/api/v2/.env`.
```
Note: make sure that the value of `NEXTAUTH_SECRET` is the same in both the root `.env` and in the api v2 `.env`.
```
5. Setup license key. In the Deployment table in database create the following entry:
```
## Env setup
Copy `.env.example` to `.env` and fill values.
## Add license Key to Deployment table in DB
id, logo, theme, licenseKey, agreedLicenseAt:-
1, null, null, '00000000-0000-0000-0000-000000000000', '2023-05-15 21:39:47.611'
Replace with your actual license key.
your CALCOM_LICENSE_KEY env var need to contain the same value
.env
CALCOM_LICENSE_KEY=00000000-0000-0000-0000-000000000000
## Running the app
# Development
```
Then in the `apps/api/v2/.env` set the license key environment variable:
```
CALCOM_LICENSE_KEY="00000000-0000-0000-0000-000000000000"
```
6. (optional) Prisma setup and database seeding - if you need to setup and seed database you can do it:
```bash
$ yarn run start
$ cd packages/prisma
$ yarn prisma generate
$ yarn prisma migrate dev
$ yarn db-seed
```
7. Proceed to the next section to start api v2
# Running api v2
Start api v2 using:
```bash
$ yarn dev
```
OR if you don't want to use docker, you can run following command.
```bash
$ yarn dev:no-docker
Sometimes it happens that v2 api restarts because some unrelated log of build files changed if you are running it while cal web app is running. If it happens and is annoying you, you can just build it and then run without watch mode:
```
cd apps/api/v2
yarn dev:build
yarn start
```
Additionally you can run following command(in different terminal) to ensure that any change in any of the dependencies is rebuilt and detected. It watches platform-libraries, platform-constants, platform-enums, platform-utils, platform-types.
Api v2 depends on various platform packages "platform-libraries, platform-constants, platform-enums, platform-utils, platform-types" so if any of them change you might need to restart api v2 so it rebuild these dependencies and picks up the changes.
Notably, you can run following command(in different terminal) to ensure that any change in any of the dependencies is rebuilt and detected. It watches platform-libraries, platform-constants, platform-enums, platform-utils, platform-types.
```bash
$ yarn run dev:build:watch
```
If you are making changes in packages/platform/libraries, you should run the following command too that would connect your local packages/platform/libraries to the api/v2
OR if you don't want to use docker, you can run following command.
```bash
$ yarn local
$ yarn dev:no-docker
```
# watch mode
$ yarn run start:dev
# production mode
$ yarn run start:prod
```
## Test
```bash
@@ -99,17 +72,14 @@ $ yarn run test
# e2e tests
$ yarn run test:e2e
# e2e tests in watch mode
$ yarn test:e2e:watch
# run specific e2e test file in watch mode
$ yarn test:e2e:watch --testPathPattern=filePath
$ yarn run test:e2e some-file.e2e-spec.ts
# test coverage
$ yarn run test:cov
```
## Conventions
# Conventions
### Guards
1. In case a guard would return "false" for "canActivate" instead throw ForbiddenException with an error message containing guard name and the error.
@@ -117,16 +87,3 @@ $ yarn run test:cov
3. If you use ApiAuthGuard but want that only specific auth method is allowed, for example, api key, then you also need to add `@ApiAuthGuardOnlyAllow(["API_KEY"])` under the `@UseGuards(ApiAuthGuard)`. Shortly, use `ApiAuthGuardOnlyAllow` to specify which auth methods are allowed by `ApiAuthGuard`. If `ApiAuthGuardOnlyAllow` is not used or nothing is passed to it or empty array it means that
all auth methods are allowed.
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://kamilmysliwiec.com)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](LICENSE).
+1
View File
@@ -34,6 +34,7 @@
"generate-schemas": "yarn prisma generate && yarn prisma format",
"copy-swagger-module": "ts-node -r tsconfig-paths/register src/swagger/copy-swagger-module.ts",
"generate-swagger": "yarn copy-swagger-module && yarn build && node ./dist/apps/api/v2/src/swagger/generate-swagger-script.js",
"generate-secrets": "ts-node scripts/generate-secrets.ts",
"prepare": "yarn run snyk-protect",
"snyk-protect": "snyk-protect"
},
+13
View File
@@ -0,0 +1,13 @@
import fs from "node:fs";
import path from "node:path";
import { generateSecret } from "@calcom/platform-libraries";
const [hashed, plain] = generateSecret();
const outputPath = path.join(__dirname, "..", ".generated-secrets");
const content = `plain - ${plain}\nhashed - ${hashed}\n`;
fs.writeFileSync(outputPath, content);
process.stdout.write(`Secrets written to: ${outputPath}\n`);
+1 -3
View File
@@ -1,6 +1,4 @@
import { getEnv } from "@/env";
import { Controller, Get, Version, VERSION_NEUTRAL } from "@nestjs/common";
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { Controller, Get, VERSION_NEUTRAL, Version } from "@nestjs/common";
@Controller()
export class AppController {
+15 -13
View File
@@ -1,3 +1,11 @@
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
import { BullModule } from "@nestjs/bull";
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
import { seconds, ThrottlerModule } from "@nestjs/throttler";
import { SentryGlobalFilter, SentryModule } from "@sentry/nestjs/setup";
import { AppController } from "./app.controller";
import appConfig from "@/config/app";
import { CustomThrottlerGuard } from "@/lib/throttler-guard";
import { AppLoggerMiddleware } from "@/middleware/app.logger.middleware";
@@ -13,23 +21,13 @@ import { JwtModule } from "@/modules/jwt/jwt.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { RedisService } from "@/modules/redis/redis.service";
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
import { BullModule } from "@nestjs/bull";
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { APP_GUARD, APP_INTERCEPTOR, APP_FILTER } from "@nestjs/core";
import { seconds, ThrottlerModule } from "@nestjs/throttler";
import { SentryModule, SentryGlobalFilter } from "@sentry/nestjs/setup";
import { AppController } from "./app.controller";
import { VercelWebhookController } from "@/vercel-webhook.controller";
@Module({
imports: [
SentryModule.forRoot(),
ConfigModule.forRoot({
...(process.env.NODE_ENV === "production"
? { envFilePath: ".env.production" }
: { ignoreEnvFile: true }),
ignoreEnvFile: true,
isGlobal: true,
load: [appConfig],
}),
@@ -61,7 +59,7 @@ import { AppController } from "./app.controller";
AuthModule,
JwtModule,
],
controllers: [AppController],
controllers: [AppController, VercelWebhookController],
providers: [
{
provide: APP_FILTER,
@@ -96,6 +94,10 @@ export class AppModule implements NestModule {
{
path: "/v2/billing/webhook",
method: RequestMethod.POST,
},
{
path: "/v2/webhooks/vercel/deployment-promoted",
method: RequestMethod.POST,
}
)
.apply(JsonBodyMiddleware)
+2 -2
View File
@@ -15,11 +15,11 @@ import type { NestExpressApplication } from "@nestjs/platform-express";
import cookieParser from "cookie-parser";
import { Request } from "express";
import helmet from "helmet";
import { CalendarServiceExceptionFilter } from "./filters/calendar-service-exception.filter";
import { TRPCExceptionFilter } from "./filters/trpc-exception.filter";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
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");
@@ -2667,6 +2667,12 @@ describe("Bookings Endpoints 2024-08-13", () => {
jest
.spyOn(EventManager.prototype, "create")
.mockImplementation(() => Promise.resolve({ results: [], referencesToCreate: [] }));
jest
.spyOn(EventManager.prototype, "createAllCalendarEvents")
.mockImplementation(() => Promise.resolve([]));
jest
.spyOn(EventManager.prototype, "createAllCRMEvents")
.mockImplementation(() => Promise.resolve([]));
});
describe("platform oAuth client has calendar events enabled", () => {
@@ -2837,7 +2843,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(EventManager.prototype.create).toHaveBeenCalledTimes(0);
expect(EventManager.prototype.createAllCalendarEvents).toHaveBeenCalledTimes(0);
expect(EventManager.prototype.createAllCRMEvents).toHaveBeenCalledTimes(0);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
@@ -2867,7 +2874,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(EventManager.prototype.create).toHaveBeenCalledTimes(0);
expect(EventManager.prototype.createAllCalendarEvents).toHaveBeenCalledTimes(0);
expect(EventManager.prototype.createAllCRMEvents).toHaveBeenCalledTimes(0);
});
});
@@ -2901,7 +2909,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(EventManager.prototype.create).toHaveBeenCalledTimes(0);
expect(EventManager.prototype.createAllCalendarEvents).toHaveBeenCalledTimes(0);
expect(EventManager.prototype.createAllCRMEvents).toHaveBeenCalledTimes(0);
bookingThatRequiresConfirmationUid = data.uid;
} else {
throw new Error(
@@ -2926,7 +2935,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(EventManager.prototype.create).toHaveBeenCalledTimes(0);
expect(EventManager.prototype.createAllCalendarEvents).toHaveBeenCalledTimes(0);
expect(EventManager.prototype.createAllCRMEvents).toHaveBeenCalledTimes(0);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
+5 -3
View File
@@ -1,7 +1,6 @@
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";
@@ -9,12 +8,15 @@ 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 { TRIGGER_VERSION } from "../trigger.version";
import { AppModule } from "./app.module";
import { bootstrap } from "./bootstrap";
import { loggerConfig } from "./lib/logger";
import type { AppConfig } from "@/config/type";
if (process.env.NODE_ENV === "production") {
process.env.TRIGGER_VERSION = TRIGGER_VERSION;
}
const logger: Logger = new Logger("App");
/**
@@ -142,7 +142,7 @@ export class OrganizationsOrganizationsController {
@Roles("ORG_ADMIN")
@PlatformPlan(SCALE)
@Delete("/:managedOrganizationId")
@Throttle({ limit: 1, ttl: 30000, blockDuration: 30000, name: "organizations_delete" })
@Throttle({ limit: 1, ttl: 1000, blockDuration: 1000, name: "organizations_delete" })
@ApiOperation({
summary: "Delete an organization within an organization",
description:
@@ -121,7 +121,7 @@ export class OrganizationsTeamsController {
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Delete("/:teamId")
@Throttle({ limit: 1, ttl: 30000, blockDuration: 30000, name: "org_teams_delete" })
@Throttle({ limit: 1, ttl: 1000, blockDuration: 1000, name: "org_teams_delete" })
@ApiOperation({ summary: "Delete a team" })
async deleteTeam(
@Param("orgId", ParseIntPipe) orgId: number,
@@ -98,7 +98,7 @@ export class TeamsController {
@UseGuards(RolesGuard)
@Delete("/:teamId")
@Throttle({ limit: 1, ttl: 30000, blockDuration: 30000, name: "teams_delete" })
@Throttle({ limit: 1, ttl: 1000, blockDuration: 1000, name: "teams_delete" })
@ApiOperation({ summary: "Delete a team" })
@Roles("TEAM_OWNER")
async deleteTeam(@Param("teamId", ParseIntPipe) teamId: number): Promise<OrgTeamOutputResponseDto> {
@@ -0,0 +1,157 @@
import * as crypto from "node:crypto";
import type { INestApplication } from "@nestjs/common";
import type { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import request from "supertest";
import { AppModule } from "@/app.module";
import { bootstrap } from "@/bootstrap";
import { PrismaModule } from "@/modules/prisma/prisma.module";
describe("Vercel Webhook Controller (e2e)", () => {
let app: INestApplication;
const WEBHOOK_SECRET = "test-webhook-secret";
const TRIGGER_VERSION = "test-version-123";
const TRIGGER_SECRET_KEY = "test-trigger-secret-key";
beforeAll(async () => {
// biome-ignore lint/style/noProcessEnv: Test setup requires setting environment variables
process.env.VERCEL_PROMOTE_WEBHOOK_SECRET = WEBHOOK_SECRET;
// biome-ignore lint/style/noProcessEnv: Test setup requires setting environment variables
process.env.TRIGGER_VERSION = TRIGGER_VERSION;
// biome-ignore lint/style/noProcessEnv: Test setup requires setting environment variables
process.env.TRIGGER_SECRET_KEY = TRIGGER_SECRET_KEY;
const moduleRef = await Test.createTestingModule({
imports: [AppModule, PrismaModule],
}).compile();
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
afterAll(async () => {
// biome-ignore lint/style/noProcessEnv: Test cleanup requires deleting environment variables
delete process.env.VERCEL_PROMOTE_WEBHOOK_SECRET;
// biome-ignore lint/style/noProcessEnv: Test cleanup requires deleting environment variables
delete process.env.TRIGGER_VERSION;
// biome-ignore lint/style/noProcessEnv: Test cleanup requires deleting environment variables
delete process.env.TRIGGER_SECRET_KEY;
await app.close();
});
function generateSignature(payload: object): string {
// Compute signature from the JSON stringified payload
// This matches how the guard will re-stringify the parsed object
return crypto.createHmac("sha1", WEBHOOK_SECRET).update(JSON.stringify(payload)).digest("hex");
}
describe("POST /webhooks/vercel/deployment-promoted", () => {
it("should reject requests without x-vercel-signature header", async () => {
await request(app.getHttpServer())
.post("/v2/webhooks/vercel/deployment-promoted")
.send({ type: "deployment.promoted" })
.expect(401);
});
it("should reject requests with invalid signature", async () => {
await request(app.getHttpServer())
.post("/v2/webhooks/vercel/deployment-promoted")
.set("x-vercel-signature", "0000000000000000000000000000000000000000")
.send({ type: "deployment.promoted" })
.expect(401);
});
it("should ignore non-deployment.promoted events with valid signature", async () => {
const payload = { type: "deployment.created" };
const signature = generateSignature(payload);
const response = await request(app.getHttpServer())
.post("/v2/webhooks/vercel/deployment-promoted")
.set("x-vercel-signature", signature)
.send(payload)
.expect(200);
expect(response.body.status).toBe("ignored");
expect(response.body.version).toBe("");
});
it("should process deployment.promoted events with valid signature", async () => {
const payload = { type: "deployment.promoted" };
const signature = generateSignature(payload);
const originalFetch = global.fetch;
let fetchCalled = false;
let fetchUrl = "";
let fetchOptions: RequestInit | undefined;
global.fetch = jest.fn().mockImplementation((url: string, options?: RequestInit) => {
fetchCalled = true;
fetchUrl = url;
fetchOptions = options;
return Promise.resolve({
ok: true,
text: () => Promise.resolve(""),
});
});
try {
const response = await request(app.getHttpServer())
.post("/v2/webhooks/vercel/deployment-promoted")
.set("x-vercel-signature", signature)
.send(payload)
.expect(200);
expect(response.body.status).toBe("success");
expect(response.body.version).toBe(TRIGGER_VERSION);
expect(fetchCalled).toBe(true);
expect(fetchUrl).toBe(`https://api.trigger.dev/api/v1/deployments/${TRIGGER_VERSION}/promote`);
expect(fetchOptions?.method).toBe("POST");
expect(fetchOptions?.headers).toEqual(
expect.objectContaining({
Authorization: `Bearer ${TRIGGER_SECRET_KEY}`,
})
);
} finally {
global.fetch = originalFetch;
}
});
it("should retry on trigger.dev API failure", async () => {
const payload = { type: "deployment.promoted" };
const signature = generateSignature(payload);
const originalFetch = global.fetch;
let fetchCallCount = 0;
global.fetch = jest.fn().mockImplementation(() => {
fetchCallCount++;
if (fetchCallCount < 3) {
return Promise.resolve({
ok: false,
status: 500,
text: () => Promise.resolve("Internal Server Error"),
});
}
return Promise.resolve({
ok: true,
text: () => Promise.resolve(""),
});
});
try {
const response = await request(app.getHttpServer())
.post("/v2/webhooks/vercel/deployment-promoted")
.set("x-vercel-signature", signature)
.send(payload)
.expect(200);
expect(response.body.status).toBe("success");
expect(fetchCallCount).toBe(3);
} finally {
global.fetch = originalFetch;
}
}, 30000);
});
});
@@ -0,0 +1,103 @@
import {
Body,
Controller,
HttpCode,
HttpStatus,
Logger,
Post,
UnauthorizedException,
UseGuards,
} from "@nestjs/common";
import { ApiExcludeController } from "@nestjs/swagger";
import { VercelWebhookGuard } from "./vercel-webhook.guard";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
interface VercelWebhookPayload {
type?: string;
}
@Controller({
path: "/v2/webhooks/vercel",
version: API_VERSIONS_VALUES,
})
@ApiExcludeController(true)
export class VercelWebhookController {
private readonly logger = new Logger(VercelWebhookController.name);
@Post("deployment-promoted")
@UseGuards(VercelWebhookGuard)
@HttpCode(HttpStatus.OK)
async handlePromotion(
@Body() rawBody: Buffer | VercelWebhookPayload
): Promise<{ status: string; version: string }> {
// RawBodyMiddleware provides a Buffer, need to parse it
// In test environment, body may already be parsed as object
let payload: VercelWebhookPayload;
if (Buffer.isBuffer(rawBody)) {
try {
payload = JSON.parse(rawBody.toString("utf-8")) as VercelWebhookPayload;
} catch {
this.logger.error("Failed to parse webhook payload as JSON");
return { status: "error", version: "" };
}
} else {
payload = rawBody;
}
if (payload.type !== "deployment.promoted") {
this.logger.log(`Ignoring webhook event type: ${payload.type}`);
return { status: "ignored", version: "" };
}
// biome-ignore lint/style/noProcessEnv: Environment variable access required for trigger.dev API
const TRIGGER_VERSION = process.env.TRIGGER_VERSION;
if (!TRIGGER_VERSION) {
this.logger.error("TRIGGER_VERSION is not defined");
throw new UnauthorizedException("Trigger.dev configuration error");
}
this.logger.log(`Vercel Promoted! Promoting Trigger.dev to: ${TRIGGER_VERSION}`);
await this.promoteTriggerDeployment(TRIGGER_VERSION);
return { status: "success", version: TRIGGER_VERSION };
}
private async promoteTriggerDeployment(version: string, maxRetries: number = 3): Promise<void> {
// biome-ignore lint/style/noProcessEnv: Environment variable access required for trigger.dev API
const triggerSecretKey = process.env.TRIGGER_SECRET_KEY;
const url = `https://api.trigger.dev/api/v1/deployments/${version}/promote`;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${triggerSecretKey}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Trigger.dev API Error (${response.status}): ${errorText}`);
}
this.logger.log(`Successfully promoted Trigger.dev deployment: ${version}`);
return;
} catch (error) {
let errorMessage = "Unknown error";
if (error instanceof Error) {
errorMessage = error.message;
}
this.logger.error(`Promotion attempt ${attempt} failed: ${errorMessage}`);
if (attempt === maxRetries) throw error;
// Exponential backoff: 2s, 4s, 8s
await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 1000));
}
}
}
}
+65
View File
@@ -0,0 +1,65 @@
import * as crypto from "node:crypto";
import { CanActivate, ExecutionContext, Injectable, Logger, UnauthorizedException } from "@nestjs/common";
@Injectable()
export class VercelWebhookGuard implements CanActivate {
private readonly logger = new Logger(VercelWebhookGuard.name);
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const signature = request.headers["x-vercel-signature"];
// biome-ignore lint/style/noProcessEnv: Environment variable access required for webhook secret
const webhookSecret = process.env.VERCEL_PROMOTE_WEBHOOK_SECRET;
if (!webhookSecret) {
this.logger.error("VERCEL_PROMOTE_WEBHOOK_SECRET is not defined");
throw new UnauthorizedException("Webhook configuration error");
}
if (!signature) {
throw new UnauthorizedException("Missing x-vercel-signature header");
}
// Get raw body from RawBodyMiddleware (Buffer) or parsed body (object in test env)
const rawBody = request.body;
if (!rawBody) {
this.logger.error("Request body is empty. Ensure RawBodyMiddleware is active.");
throw new UnauthorizedException("Invalid request format");
}
// Determine the body bytes for signature verification
// In production: RawBodyMiddleware provides a Buffer
// In tests: body may be a parsed object, convert back to JSON string
let bodyForSignature: Buffer | string;
if (Buffer.isBuffer(rawBody)) {
bodyForSignature = rawBody;
} else if (typeof rawBody === "string") {
bodyForSignature = rawBody;
} else {
// Object from parsed JSON - convert back to string (test environment)
bodyForSignature = JSON.stringify(rawBody);
}
const expectedSignature = crypto.createHmac("sha1", webhookSecret).update(bodyForSignature).digest("hex");
// Check signature length first (timingSafeEqual throws if lengths differ)
if (signature.length !== expectedSignature.length) {
this.logger.warn("Invalid Vercel webhook signature length");
throw new UnauthorizedException("Invalid signature");
}
// Secure comparison to prevent timing attacks
const isSignatureValid = crypto.timingSafeEqual(
Buffer.from(expectedSignature, "utf8"),
Buffer.from(signature, "utf8")
);
if (!isSignatureValid) {
this.logger.warn("Invalid Vercel webhook signature");
throw new UnauthorizedException("Invalid signature");
}
return true;
}
}
+1
View File
@@ -0,0 +1 @@
export const TRIGGER_VERSION = '20260129.1';
-1
View File
@@ -1 +0,0 @@
TRIGGER_VERSION=20260128.2
@@ -1,6 +1,6 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { FlagListingView } from "@calcom/features/flags/pages/flag-listing-view";
import { FlagListingView } from "@calcom/web/modules/feature-flags/views/flag-listing-view";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
export const generateMetadata = async () =>
@@ -5,6 +5,7 @@ import { z, ZodError } from "zod";
import { onSubmissionOfFormResponse } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
import { getResponseToStore } from "@calcom/app-store/routing-forms/lib/getResponseToStore";
import { getSerializableForm } from "@calcom/app-store/routing-forms/lib/getSerializableForm";
import { PrismaPendingRoutingTraceRepository } from "@calcom/features/routing-trace/repositories/PrismaPendingRoutingTraceRepository";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { RoutingFormResponseRepository } from "@calcom/lib/server/repository/formResponse";
@@ -58,6 +59,18 @@ export const queuedResponseHandler = async ({
chosenRouteId: queuedFormResponse.chosenRouteId,
});
// Link the pending routing trace to the new formResponseId so it can be found when booking is created
try {
const pendingTraceRepo = new PrismaPendingRoutingTraceRepository(prisma);
await pendingTraceRepo.linkToFormResponse({
queuedFormResponseId: queuedFormResponse.id,
formResponseId: formResponse.id,
});
} catch (error) {
// Log but don't fail - trace linking is not critical
logger.warn("Failed to link pending routing trace to form response", safeStringify(error));
}
const chosenRoute = serializableForm.routes?.find((r) => r.id === queuedFormResponse.chosenRouteId);
await onSubmissionOfFormResponse({
form: {
+1 -1
View File
@@ -133,7 +133,7 @@ async function getHandler(req: NextRequest) {
// redirect to team screen
return NextResponse.redirect(
new URL(`/settings/teams/${team.id}/onboard-members?event=team_created`, req.nextUrl.origin),
new URL(`/settings/teams/${team.id}/onboard-members?event=team_created`, WEBAPP_URL),
{ status: 302 }
);
}
@@ -50,7 +50,7 @@ export const DestinationCalendarSettingsWebWrapper = ({
};
const validatedReminderValue = reminderSchema.safeParse(
calendars.data.destinationCalendar.customCalendarReminder
calendars.data.destinationCalendar?.customCalendarReminder
);
let reminderValue: ReminderMinutes = null;
if (validatedReminderValue.success) {
@@ -63,7 +63,7 @@ export const DestinationCalendarSettingsWebWrapper = ({
connectedCalendars={calendars.data.connectedCalendars}
isPending={mutation.isPending}
destinationCalendar={calendars.data.destinationCalendar}
value={calendars.data.destinationCalendar.externalId}
value={calendars.data.destinationCalendar?.externalId}
hidePlaceholder
onChange={mutation.mutate}
onReminderChange={handleReminderChange}
@@ -6,7 +6,7 @@ interface IConnectedCalendarItem {
externalId?: string;
integrationType: string;
calendars?: {
primary: true | null;
primary: boolean | null;
isSelected: boolean;
credentialId: number;
name?: string | undefined;
@@ -18,7 +18,7 @@ interface IConnectedCalendarItem {
}[];
}
const ConnectedCalendarItem = (prop: IConnectedCalendarItem) => {
const ConnectedCalendarItem = (prop: IConnectedCalendarItem): JSX.Element => {
const { name, logo, externalId, calendars, integrationType } = prop;
return (
@@ -26,7 +26,7 @@ const ConnectedCalendarItem = (prop: IConnectedCalendarItem) => {
<div className="flex flex-row items-center p-4">
<img src={logo} alt={name} className="m-1 h-8 w-8" />
<div className="mx-4">
<p className="font-sans text-sm font-bold leading-5">
<p className="font-bold font-sans text-sm leading-5">
{name}
{/* Temporarily removed till we use it on another place */}
{/* <span className="mx-1 rounded-[4px] bg-cal-success py-[2px] px-[6px] font-sans text-xs font-medium text-green-600">
@@ -36,7 +36,7 @@ const ConnectedCalendarItem = (prop: IConnectedCalendarItem) => {
<div className="fle-row flex">
<span
title={externalId}
className="max-w-44 text-subtle mt-1 overflow-hidden text-ellipsis whitespace-nowrap font-sans text-sm">
className="mt-1 max-w-44 overflow-hidden text-ellipsis whitespace-nowrap font-sans text-sm text-subtle">
{externalId}{" "}
</span>
</div>
@@ -49,7 +49,7 @@ const ConnectedCalendarItem = (prop: IConnectedCalendarItem) => {
{t("edit")}
</Button> */}
</div>
<div className="border-subtle h-px w-full border-b" />
<div className="h-px w-full border-subtle border-b" />
<div>
<ul className="p-4">
{calendars?.map((calendar, i) => (
@@ -1,4 +1,4 @@
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
import DestinationCalendarSelector from "@calcom/features/calendars/components/DestinationCalendarSelector";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterInputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
@@ -1,6 +1,6 @@
import { useForm } from "react-hook-form";
import Schedule from "@calcom/features/schedules/components/Schedule";
import Schedule from "@calcom/web/modules/schedules/components/Schedule";
import { DEFAULT_SCHEDULE } from "@calcom/lib/availability";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
@@ -231,6 +231,7 @@ export const OAuthClientForm = ({
</div>
</Tooltip>
</div>
<p className="text-subtle mt-2 text-sm">{t("calendar_events_disabled_video_limitation")}</p>
</div>
<div className="mt-6">
<div className="flex items-center">
+1 -1
View File
@@ -11,7 +11,7 @@ import DynamicPostHogProvider from "~/ee/posthog/providerDynamic";
import DynamicPostHogPageView from "~/ee/posthog/pageViewDynamic";
import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider";
import { FeatureProvider } from "@calcom/features/flags/context/provider";
import { useFlags } from "@calcom/features/flags/hooks";
import { useFlags } from "@calcom/web/modules/feature-flags/hooks/useFlags";
import DynamicHelpscoutProvider from "@calcom/web/modules/ee/support/lib/helpscout/providerDynamic";
import DynamicIntercomProvider from "@calcom/web/modules/ee/support/lib/intercom/providerDynamic";
+1 -1
View File
@@ -21,7 +21,7 @@ import { useEffect } from "react";
import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider";
import { FeatureProvider } from "@calcom/features/flags/context/provider";
import { useFlags } from "@calcom/features/flags/hooks";
import { useFlags } from "@calcom/web/modules/feature-flags/hooks/useFlags";
import DynamicHelpscoutProvider from "@calcom/web/modules/ee/support/lib/helpscout/providerDynamic";
import DynamicIntercomProvider from "@calcom/web/modules/ee/support/lib/intercom/providerDynamic";
@@ -9,7 +9,7 @@ import posthog from "posthog-js";
import { BulkEditDefaultForEventsModal } from "@calcom/web/modules/event-types/components/BulkEditDefaultForEventsModal";
import type { BulkUpdatParams } from "@calcom/web/modules/event-types/components/BulkEditDefaultForEventsModal";
import { NewScheduleButton } from "@calcom/features/schedules/components/NewScheduleButton";
import { NewScheduleButton } from "@calcom/web/modules/schedules/components/NewScheduleButton";
import { ScheduleListItem } from "@calcom/features/schedules/components/ScheduleListItem";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -36,7 +36,7 @@ type AuditLog = {
source: string;
displayJson?: Record<string, unknown> | null;
actionDisplayTitle: TranslationWithParams;
displayFields?: Array<{ labelKey: string; valueKey: string }> | null;
displayFields?: Array<{ labelKey: string; valueKey?: string; value?: string; values?: string[] }> | null;
actor: {
type: AuditActorType;
displayName: string | null;
@@ -74,8 +74,7 @@ const ACTION_ICON_MAP: Record<string, IconName> = {
ATTENDEE_ADDED: "user-check",
ATTENDEE_REMOVED: "user-check",
LOCATION_CHANGED: "map-pin",
HOST_NO_SHOW_UPDATED: "ban",
ATTENDEE_NO_SHOW_UPDATED: "ban",
NO_SHOW_UPDATED: "ban",
} as const;
const ACTOR_ROLE_LABEL_MAP: Record<AuditActorType, string | null> = {
@@ -182,6 +181,32 @@ function JsonViewer({ data }: JsonViewerProps) {
);
}
interface DisplayFieldValueProps {
field: {
valueKey?: string;
value?: string;
values?: string[];
};
}
function DisplayFieldValue({ field }: DisplayFieldValueProps) {
const { t } = useLocale();
if (field.values) {
return (
<span className="flex flex-col">
{field.values.map((v, i) => (
<span className="p-0.5" key={i}>
{v}
</span>
))}
</span>
);
}
return <>{field.value ?? (field.valueKey ? t(field.valueKey) : "")}</>;
}
function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
const { t } = useLocale();
const [expandedLogIds, setExpandedLogIds] = useState<Set<string>>(new Set());
@@ -279,10 +304,10 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
<div
key={idx}
className="flex items-start gap-2 py-2 border-b px-3 border-subtle">
<span className="font-medium text-emphasis w-[140px]">
{t(field.labelKey)}
<span className="font-medium text-emphasis w-[140px]">{t(field.labelKey)}</span>
<span className="font-medium">
<DisplayFieldValue field={field} />
</span>
<span className="text-[#096638] font-medium">{t(field.valueKey)}</span>
</div>
))
: null}
@@ -342,15 +367,23 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
function useBookingLogsFilters(auditLogs: AuditLog[], searchTerm: string, actorFilter: string | null) {
const filteredLogs = auditLogs.filter((log) => {
const doesMatchDisplayFields = (): boolean => {
return log.displayFields?.some((field) => {
return field.valueKey?.toLowerCase().includes(searchTerm.toLowerCase()) ||
field.labelKey?.toLowerCase().includes(searchTerm.toLowerCase());
}) ?? false;
return (
log.displayFields?.some((field) => {
return (
field.valueKey?.toLowerCase().includes(searchTerm.toLowerCase()) ||
field.labelKey?.toLowerCase().includes(searchTerm.toLowerCase())
);
}) ?? false
);
};
const doesMatchActionDisplayTitle = (): boolean => {
return log.actionDisplayTitle.key?.toLowerCase().includes(searchTerm.toLowerCase()) ||
Object.values(log.actionDisplayTitle.params ?? {}).some((param) => param?.toString().toLowerCase().includes(searchTerm.toLowerCase()));
return (
log.actionDisplayTitle.key?.toLowerCase().includes(searchTerm.toLowerCase()) ||
Object.values(log.actionDisplayTitle.params ?? {}).some((param) =>
param?.toString().toLowerCase().includes(searchTerm.toLowerCase())
)
);
};
const matchesSearch =
@@ -81,7 +81,7 @@ const mockEvent = {
isPending: false,
};
vi.mock("@calcom/features/calendars/NoAvailabilityDialog", () => ({
vi.mock("@calcom/features/calendars/components/NoAvailabilityDialog", () => ({
default: () => {
return null;
},
+35 -45
View File
@@ -93,7 +93,7 @@ const BookerComponent = ({
roundRobinHideOrgAndTeam,
hideOrgTeamAvatar,
showNoAvailabilityDialog,
}: BookerProps & WrappedBookerProps) => {
}: BookerProps & WrappedBookerProps): JSX.Element | null => {
const searchParams = useCompatSearchParams();
const isPlatformBookerEmbed = useIsPlatformBookerEmbed();
const [bookerState, setBookerState] = useBookerStoreContext(
@@ -180,13 +180,7 @@ const BookerComponent = ({
isVerificationCodeSending,
} = verifyEmail;
const {
overlayBusyDates,
isOverlayCalendarEnabled,
connectedCalendars,
loadingConnectedCalendar,
onToggleCalendar,
} = calendars;
const { overlayBusyDates, isOverlayCalendarEnabled, connectedCalendars, onToggleCalendar } = calendars;
const scrolledToTimeslotsOnce = useRef(false);
const embedUiConfig = useEmbedUiConfig();
@@ -276,7 +270,11 @@ const BookerComponent = ({
renderConfirmNotVerifyEmailButtonCond ? handleBookEvent(timeSlot) : handleVerifyEmail();
const EventBooker = useMemo(() => {
return bookerState === "booking" ? (
if (bookerState !== "booking") {
return null;
}
return (
<BookEventForm
key={key}
timeslot={selectedTimeslot}
@@ -312,22 +310,18 @@ const BookerComponent = ({
backButton: customClassNames?.confirmStep?.backButton,
}}
isPlatform={isPlatform}>
<>
{!isPlatform && (
<RedirectToInstantMeetingModal
expiryTime={expiryTime}
bookingId={parseInt(getQueryParam("bookingId") || "0")}
instantVideoMeetingUrl={instantVideoMeetingUrl}
onGoBack={() => {
onGoBackInstantMeeting();
}}
orgName={event.data?.entity?.name}
/>
)}
</>
{!isPlatform && (
<RedirectToInstantMeetingModal
expiryTime={expiryTime}
bookingId={parseInt(getQueryParam("bookingId") || "0")}
instantVideoMeetingUrl={instantVideoMeetingUrl}
onGoBack={() => {
onGoBackInstantMeeting();
}}
orgName={event.data?.entity?.name}
/>
)}
</BookEventForm>
) : (
<></>
);
}, [
bookerFormErrorRef,
@@ -418,27 +412,23 @@ const BookerComponent = ({
extraDays={layout === BookerLayouts.COLUMN_VIEW ? columnViewExtraDays.current : extraDays}
isMobile={isMobile}
nextSlots={nextSlots}
renderOverlay={() =>
isEmbed ? (
<></>
) : (
<>
<OverlayCalendar
isOverlayCalendarEnabled={isOverlayCalendarEnabled}
connectedCalendars={connectedCalendars}
loadingConnectedCalendar={loadingConnectedCalendar}
overlayBusyDates={overlayBusyDates}
onToggleCalendar={onToggleCalendar}
hasSession={hasSession}
handleClickContinue={onClickOverlayContinue}
handleSwitchStateChange={onOverlaySwitchStateChange}
handleClickNoCalendar={() => {
onOverlayClickNoCalendar();
}}
/>
</>
)
}
renderOverlay={() => {
if (isEmbed) return null;
return (
<OverlayCalendar
isOverlayCalendarEnabled={isOverlayCalendarEnabled}
connectedCalendars={connectedCalendars}
overlayBusyDates={overlayBusyDates}
onToggleCalendar={onToggleCalendar}
hasSession={hasSession}
handleClickContinue={onClickOverlayContinue}
handleSwitchStateChange={onOverlaySwitchStateChange}
handleClickNoCalendar={() => {
onOverlayClickNoCalendar();
}}
/>
);
}}
/>
)}
</BookerSection>
@@ -1,18 +1,12 @@
"use client";
import { useSession } from "next-auth/react";
import { useSearchParams, usePathname, useRouter } from "next/navigation";
import { useMemo, useCallback, useEffect } from "react";
import React from "react";
import { shallow } from "zustand/shallow";
import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { useBookerEmbedEvents } from "@calcom/embed-core/src/embed-iframe/react-hooks";
import type { BookerProps } from "@calcom/features/bookings/Booker";
import {
BookerStoreProvider,
useInitializeBookerStoreContext,
useBookerStoreContext,
useInitializeBookerStoreContext,
} from "@calcom/features/bookings/Booker/BookerStoreProvider";
import { useBookerLayout } from "@calcom/features/bookings/Booker/components/hooks/useBookerLayout";
import { useBookingForm } from "@calcom/features/bookings/Booker/components/hooks/useBookingForm";
@@ -22,27 +16,23 @@ import { useSlots } from "@calcom/features/bookings/Booker/components/hooks/useS
import { useVerifyCode } from "@calcom/features/bookings/Booker/components/hooks/useVerifyCode";
import { useVerifyEmail } from "@calcom/features/bookings/Booker/components/hooks/useVerifyEmail";
import { useInitializeBookerStore } from "@calcom/features/bookings/Booker/store";
import {
useEvent,
useScheduleForEvent,
} from "@calcom/features/bookings/Booker/utils/event";
import { useEvent, useScheduleForEvent } from "@calcom/features/bookings/Booker/utils/event";
import { useBrandColors } from "@calcom/features/bookings/Booker/utils/use-brand-colors";
import type { getPublicEvent } from "@calcom/features/eventtypes/lib/getPublicEvent";
import {
DEFAULT_LIGHT_BRAND_COLOR,
DEFAULT_DARK_BRAND_COLOR,
WEBAPP_URL,
} from "@calcom/lib/constants";
import { DEFAULT_DARK_BRAND_COLOR, DEFAULT_LIGHT_BRAND_COLOR, WEBAPP_URL } from "@calcom/lib/constants";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { localStorage } from "@calcom/lib/webstorage";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useSession } from "next-auth/react";
import { useCallback, useEffect, useMemo } from "react";
import { shallow } from "zustand/shallow";
import { Booker as BookerComponent } from "./Booker";
export type BookerWebWrapperAtomProps = BookerProps & {
eventData?: NonNullable<Awaited<ReturnType<typeof getPublicEvent>>>;
};
const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => {
const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps): JSX.Element => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -64,17 +54,11 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => {
const isRedirect = searchParams?.get("redirected") === "true" || false;
const fromUserNameRedirected = searchParams?.get("username") || "";
const rescheduleUid =
typeof window !== "undefined"
? new URLSearchParams(window.location.search).get("rescheduleUid")
: null;
typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("rescheduleUid") : null;
const rescheduledBy =
typeof window !== "undefined"
? new URLSearchParams(window.location.search).get("rescheduledBy")
: null;
typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("rescheduledBy") : null;
const bookingUid =
typeof window !== "undefined"
? new URLSearchParams(window.location.search).get("bookingUid")
: null;
typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("bookingUid") : null;
const timezone = searchParams?.get("cal.tz") || null;
useEffect(() => {
@@ -102,10 +86,7 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => {
timezone,
});
const [dayCount] = useBookerStoreContext(
(state) => [state.dayCount, state.setDayCount],
shallow
);
const [dayCount] = useBookerStoreContext((state) => [state.dayCount, state.setDayCount], shallow);
const { data: session } = useSession();
const routerQuery = useRouterQuery();
@@ -117,8 +98,7 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => {
.reduce(
(metadata, key) => ({
...metadata,
[key.substring("metadata[".length, key.length - 1)]:
searchParams?.get(key),
[key.substring("metadata[".length, key.length - 1)]: searchParams?.get(key),
}),
{}
);
@@ -126,11 +106,8 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => {
return {
name:
searchParams?.get("name") ||
(firstNameQueryParam
? `${firstNameQueryParam} ${lastNameQueryParam}`
: null),
guests:
(searchParams?.getAll("guests") || searchParams?.getAll("guest")) ?? [],
(firstNameQueryParam ? `${firstNameQueryParam} ${lastNameQueryParam}` : null),
guests: (searchParams?.getAll("guests") || searchParams?.getAll("guest")) ?? [],
};
}, [searchParams, firstNameQueryParam, lastNameQueryParam]);
@@ -147,13 +124,10 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => {
const verifyEmail = useVerifyEmail({
email: bookerForm.formEmail,
name: bookerForm.formName,
requiresBookerEmailVerification:
event?.data?.requiresBookerEmailVerification,
requiresBookerEmailVerification: event?.data?.requiresBookerEmailVerification,
onVerifyEmail: bookerForm.beforeVerifyEmail,
});
const slots = useSlots(
event?.data ? { id: event.data.id, length: event.data.length } : null
);
const slots = useSlots(event?.data ? { id: event.data.id, length: event.data.length } : null);
const isEmbed = useIsEmbed();
@@ -217,8 +191,7 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => {
);
useBrandColors({
brandColor: event.data?.profile.brandColor ?? DEFAULT_LIGHT_BRAND_COLOR,
darkBrandColor:
event.data?.profile.darkBrandColor ?? DEFAULT_DARK_BRAND_COLOR,
darkBrandColor: event.data?.profile.darkBrandColor ?? DEFAULT_DARK_BRAND_COLOR,
theme: event.data?.profile.theme,
});
@@ -2,12 +2,12 @@ import React from "react";
import { vi, afterEach } from "vitest";
import dayjs from "@calcom/dayjs";
import { DatePicker as DatePickerComponent } from "@calcom/features/calendars/DatePicker";
import { DatePicker as DatePickerComponent } from "@calcom/features/calendars/components/DatePicker";
import { render } from "@calcom/features/bookings/Booker/__tests__/test-utils";
import { DatePicker } from "./DatePicker";
vi.mock("@calcom/features/calendars/DatePicker", () => {
vi.mock("@calcom/features/calendars/components/DatePicker", () => {
return {
DatePicker: vi.fn(() => <div data-testid="mock-date-picker" />),
};
@@ -4,7 +4,7 @@ import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import type { DatePickerClassNames } from "@calcom/features/bookings/Booker/types";
import { DatePicker as DatePickerComponent } from "@calcom/features/calendars/DatePicker";
import { DatePicker as DatePickerComponent } from "@calcom/features/calendars/components/DatePicker";
import { useNonEmptyScheduleDays } from "@calcom/features/schedules/lib/use-schedule/useNonEmptyScheduleDays";
import { weekdayToWeekIndex } from "@calcom/lib/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -1,20 +1,14 @@
import { useEffect } from "react";
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
import type { UseCalendarsReturnType } from "@calcom/features/bookings/Booker/components/hooks/useCalendars";
import { useOverlayCalendar } from "@calcom/features/bookings/Booker/components/hooks/useOverlayCalendar";
import type { WrappedBookerPropsMain } from "@calcom/features/bookings/Booker/types";
import { useEffect } from "react";
import { OverlayCalendarContinueModal } from "./OverlayCalendarContinueModal";
import { OverlayCalendarSettingsModal } from "./OverlayCalendarSettingsModal";
import { OverlayCalendarSwitch } from "./OverlayCalendarSwitch";
type OverlayCalendarProps = Pick<
UseCalendarsReturnType,
| "connectedCalendars"
| "overlayBusyDates"
| "onToggleCalendar"
| "loadingConnectedCalendar"
| "isOverlayCalendarEnabled"
WrappedBookerPropsMain["calendars"],
"connectedCalendars" | "overlayBusyDates" | "onToggleCalendar" | "isOverlayCalendarEnabled"
> & {
handleClickNoCalendar: () => void;
hasSession: boolean;
@@ -27,12 +21,11 @@ export const OverlayCalendar = ({
overlayBusyDates,
onToggleCalendar,
isOverlayCalendarEnabled,
loadingConnectedCalendar,
handleClickNoCalendar,
handleSwitchStateChange,
handleClickContinue,
hasSession,
}: OverlayCalendarProps) => {
}: OverlayCalendarProps): JSX.Element | null => {
const isPlatform = useIsPlatform();
const {
handleCloseContinueModal,
@@ -70,10 +63,8 @@ export const OverlayCalendar = ({
/>
)}
<OverlayCalendarSettingsModal
connectedCalendars={connectedCalendars}
open={isOpenOverlaySettingsModal}
onClose={handleCloseSettingsModal}
isLoading={loadingConnectedCalendar}
onToggleConnectedCalendar={handleToggleConnectedCalendar}
onClickNoCalendar={() => {
handleClickNoCalendar();
@@ -1,34 +1,30 @@
import Link from "next/link";
import { Fragment } from "react";
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import classNames from "@calcom/ui/classNames";
import { Alert } from "@calcom/ui/components/alert";
import { DialogContent, DialogClose } from "@calcom/ui/components/dialog";
import { DialogClose, DialogContent } from "@calcom/ui/components/dialog";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import { Switch } from "@calcom/ui/components/form";
import { ListItem, ListItemText, ListItemTitle } from "@calcom/ui/components/list";
import { SkeletonContainer } from "@calcom/ui/components/skeleton";
import { SkeletonText } from "@calcom/ui/components/skeleton";
import { SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
import Image from "next/image";
import Link from "next/link";
import { Fragment } from "react";
import type { UseCalendarsReturnType } from "@calcom/features/bookings/Booker/components/hooks/useCalendars";
interface IOverlayCalendarSettingsModalProps {
type IOverlayCalendarSettingsModalProps = {
open?: boolean;
onClose?: (state: boolean) => void;
onClickNoCalendar?: () => void;
isLoading: boolean;
connectedCalendars: UseCalendarsReturnType["connectedCalendars"];
onToggleConnectedCalendar: (externalCalendarId: string, credentialId: number) => void;
checkIsCalendarToggled: (externalCalendarId: string, credentialId: number) => boolean;
}
};
const SkeletonLoader = () => {
const SkeletonLoader = (): JSX.Element => {
return (
<SkeletonContainer>
<div className="border-subtle mt-3 stack-y-4 rounded-xl border px-4 py-4 ">
<div className="stack-y-4 mt-3 rounded-xl border border-subtle px-4 py-4">
<SkeletonText className="h-4 w-full" />
<SkeletonText className="h-4 w-full" />
<SkeletonText className="h-4 w-full" />
@@ -39,109 +35,97 @@ const SkeletonLoader = () => {
};
export function OverlayCalendarSettingsModal({
connectedCalendars,
isLoading,
open,
onClose,
onClickNoCalendar,
onToggleConnectedCalendar,
checkIsCalendarToggled,
}: IOverlayCalendarSettingsModalProps) {
}: IOverlayCalendarSettingsModalProps): JSX.Element {
const { t } = useLocale();
const isPlatform = useIsPlatform();
const { data, isPending: isLoading } = trpc.viewer.calendars.connectedCalendars.useQuery(undefined, {
enabled: !!open,
});
let content: React.ReactNode;
if (isLoading) {
content = <SkeletonLoader />;
} else if (data?.connectedCalendars.length === 0) {
content = (
<EmptyScreen
Icon="calendar"
headline={t("no_calendar_installed")}
description={t("no_calendar_installed_description")}
buttonText={t("add_a_calendar")}
buttonOnClick={onClickNoCalendar}
/>
);
} else {
content = data?.connectedCalendars.map((item) => (
<Fragment key={item.credentialId}>
{item.error && !item.calendars && <Alert severity="error" title={item.error.message} />}
{item?.error === undefined && item.calendars && (
<ListItem className="flex-col rounded-md">
<div className="flex w-full flex-1 items-center space-x-3 pb-4 rtl:space-x-reverse">
{item.integration.logo && (
<Image
className={classNames(
"h-10 w-10",
item.integration.logo.includes("-dark") && "dark:invert"
)}
width={40}
height={40}
src={isPlatform ? `https://app.cal.com${item.integration.logo}` : item.integration.logo}
alt={`${item.integration.title} logo`}
/>
)}
<div className="grow truncate pl-2">
<ListItemTitle component="h3" className="space-x-2 rtl:space-x-reverse">
<Link href={`/apps/${item.integration.slug}`}>
{item.integration.name || item.integration.title}
</Link>
</ListItemTitle>
<ListItemText component="p">{item.primary?.email}</ListItemText>
</div>
</div>
<div className="border-subtle w-full border-t pt-4">
<ul className="stack-y-4">
{item.calendars.map((cal, index) => {
const id = cal.integrationTitle ?? `calendar-switch-${index}`;
return (
<li className="flex gap-3" key={id}>
<Switch
id={id}
label={cal.name}
checked={checkIsCalendarToggled(cal.externalId, item.credentialId)}
onCheckedChange={() => {
onToggleConnectedCalendar(cal.externalId, item.credentialId);
}}
/>
</li>
);
})}
</ul>
</div>
</ListItem>
)}
</Fragment>
));
}
return (
<>
<Dialog open={open} onOpenChange={onClose}>
<DialogContent
enableOverflow
type="creation"
title="Calendar Settings"
className="pb-4"
description={t("view_overlay_calendar_events")}>
<div className="no-scrollbar max-h-full overflow-y-scroll ">
{isLoading ? (
<SkeletonLoader />
) : (
<>
{connectedCalendars.length === 0 ? (
<EmptyScreen
Icon="calendar"
headline={t("no_calendar_installed")}
description={t("no_calendar_installed_description")}
buttonText={t("add_a_calendar")}
buttonOnClick={onClickNoCalendar}
/>
) : (
<>
{connectedCalendars.map((item) => (
<Fragment key={item.credentialId}>
{item.error && !item.calendars && (
<Alert severity="error" title={item.error.message} />
)}
{item?.error === undefined && item.calendars && (
<ListItem className="flex-col rounded-md">
<div className="flex w-full flex-1 items-center space-x-3 pb-4 rtl:space-x-reverse">
{
// eslint-disable-next-line @next/next/no-img-element
item.integration.logo && (
<img
className={classNames(
"h-10 w-10",
item.integration.logo.includes("-dark") && "dark:invert"
)}
src={
isPlatform
? `https://app.cal.com${item.integration.logo}`
: item.integration.logo
}
alt={`${item.integration.title} logo`}
/>
)
}
<div className="grow truncate pl-2">
<ListItemTitle component="h3" className="space-x-2 rtl:space-x-reverse">
<Link href={`/apps/${item.integration.slug}`}>
{item.integration.name || item.integration.title}
</Link>
</ListItemTitle>
<ListItemText component="p">{item.primary.email}</ListItemText>
</div>
</div>
<div className="border-subtle w-full border-t pt-4">
<ul className="stack-y-4">
{item.calendars.map((cal, index) => {
const id = cal.integrationTitle ?? `calendar-switch-${index}`;
return (
<li className="flex gap-3" key={id}>
<Switch
id={id}
checked={checkIsCalendarToggled(cal.externalId, item.credentialId)}
onCheckedChange={() => {
onToggleConnectedCalendar(cal.externalId, item.credentialId);
}}
/>
<label htmlFor={id}>{cal.name}</label>
</li>
);
})}
</ul>
</div>
</ListItem>
)}
</Fragment>
))}
</>
)}
</>
)}
</div>
<div className="mt-4 flex gap-2 self-end">
<DialogClose>{t("done")}</DialogClose>
</div>
</DialogContent>
</Dialog>
</>
<Dialog open={open} onOpenChange={onClose}>
<DialogContent
enableOverflow
type="creation"
title={t("calendar_settings")}
className="pb-4"
description={t("view_overlay_calendar_events")}>
<div className="no-scrollbar max-h-full overflow-y-scroll">{content}</div>
<div className="mt-4 flex gap-2 self-end">
<DialogClose>{t("done")}</DialogClose>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,42 @@
"use client";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { TopBanner } from "@calcom/ui/components/top-banner";
export type DueInvoiceBannerProps = {
data: RouterOutputs["viewer"]["me"]["getUserTopBanners"]["dueInvoiceBanner"];
};
export function DueInvoiceBanner({ data }: DueInvoiceBannerProps) {
const { t } = useLocale();
if (!data || data.length === 0) return null;
// Get the first proration to display (prioritize blocking ones)
const blockingProrations = data.filter((d) => d.isBlocking);
const displayProration = blockingProrations[0] ?? data[0];
const hasBlocking = blockingProrations.length > 0;
// Use Stripe invoice URL if available, otherwise fall back to billing settings
const paymentUrl =
displayProration.invoiceUrl ?? `/settings/teams/${displayProration.teamId}/billing`;
return (
<TopBanner
variant={hasBlocking ? "error" : "warning"}
text={t(hasBlocking ? "invoice_overdue_blocking" : "invoice_due", {
teamName: displayProration.teamName,
})}
actions={
<a
href={paymentUrl}
target={displayProration.invoiceUrl ? "_blank" : undefined}
rel={displayProration.invoiceUrl ? "noopener noreferrer" : undefined}
className="border-b border-b-black hover:opacity-80">
{t("pay_now")}
</a>
}
/>
);
}
+1 -1
View File
@@ -22,7 +22,7 @@ import {
useEvent,
useScheduleForEvent,
} from "@calcom/features/bookings/Booker/utils/event";
import DatePicker from "@calcom/features/calendars/DatePicker";
import DatePicker from "@calcom/features/calendars/components/DatePicker";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { TimezoneSelect } from "@calcom/web/modules/timezone/components/TimezoneSelect";
import type { Slot } from "@calcom/features/schedules/lib/use-schedule/types";
@@ -13,7 +13,7 @@ import {
} from "@calcom/web/modules/calendars/components/SelectedCalendarsSettingsWebWrapper";
import { Timezone as PlatformTimzoneSelect } from "@calcom/atoms/timezone";
import getLocationsOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect";
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
import DestinationCalendarSelector from "@calcom/features/calendars/components/DestinationCalendarSelector";
import { TimezoneSelect as WebTimezoneSelect } from "@calcom/web/modules/timezone/components/TimezoneSelect";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import {
@@ -1,7 +1,7 @@
"use client";
import type { OptInFeatureConfig } from "@calcom/features/feature-opt-in/config";
import { getOptInFeatureConfig } from "@calcom/features/feature-opt-in/config";
import { getOptInFeatureConfig, shouldDisplayFeatureAt } from "@calcom/features/feature-opt-in/config";
import { trpc } from "@calcom/trpc/react";
import { useCallback, useMemo, useState } from "react";
@@ -122,6 +122,8 @@ function useFeatureOptInBanner(featureId: string): UseFeatureOptInBannerResult {
if (isDismissed) return false;
if (isOptedIn) return false;
if (!featureConfig) return false;
// Only show banner if the feature is configured to be displayed as a banner
if (!shouldDisplayFeatureAt(featureConfig, "banner")) return false;
if (eligibilityQuery.isLoading) return false;
if (!eligibilityQuery.data) return false;
return eligibilityQuery.data.status === "can_opt_in";
@@ -55,7 +55,7 @@ function createTeamBlockedWarningFn(
return (feature: NormalizedFeature): string | null => {
const blockingState = blockingStateMap.get(feature.slug);
if (blockingState?.orgState === "disabled") {
return t("feature_blocked_by_org_warning");
return t("feature_org_disabled");
}
return null;
};
@@ -41,22 +41,18 @@ function normalizeUserFeatures(data: UserFeatureData[] | undefined): NormalizedF
}));
}
const WARNING_REASONS: Set<string> = new Set([
"feature_org_disabled",
"feature_all_teams_disabled",
"feature_any_team_disabled",
"feature_no_explicit_enablement",
"feature_user_only_not_allowed",
]);
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":
return t("feature_no_explicit_enablement_warning");
case "feature_user_only_not_allowed":
return t("feature_user_only_not_allowed_warning");
default:
return null;
}
if (!feature.effectiveReason || !WARNING_REASONS.has(feature.effectiveReason)) return null;
return t(feature.effectiveReason);
};
}
@@ -1,17 +1,15 @@
import { unstable_cache } from "next/cache";
import { redirect } from "next/navigation";
import { PrismaAttributeRepository } from "@calcom/features/attributes/repositories/PrismaAttributeRepository";
import type { Session } from "next-auth";
import { CrudAction, CustomAction, Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import type { MemberPermissions } from "@calcom/features/pbac/lib/team-member-permissions";
import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
import type { MemberPermissions } from "@calcom/features/pbac/lib/team-member-permissions";
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router";
import { createRouterCaller } from "app/_trpc/context";
import { unstable_cache } from "next/cache";
import { redirect } from "next/navigation";
import type { Session } from "next-auth";
const getCachedAttributes = unstable_cache(
async (orgId: number) => {
@@ -60,6 +58,7 @@ export async function getOrgMembersPageData(session: Session) {
CustomAction.ChangeMemberRole,
CustomAction.Remove,
CustomAction.Impersonate,
CustomAction.PasswordReset,
],
fallbackRoles: {
[CustomAction.ListMembers]: {
@@ -80,6 +79,9 @@ export async function getOrgMembersPageData(session: Session) {
[CustomAction.Impersonate]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.PasswordReset]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
}),
getSpecificPermissions({
@@ -107,6 +109,7 @@ export async function getOrgMembersPageData(session: Session) {
canChangeMemberRole: orgPermissions[CustomAction.ChangeMemberRole],
canRemove: orgPermissions[CustomAction.Remove],
canImpersonate: orgPermissions[CustomAction.Impersonate],
canResetPassword: orgPermissions[CustomAction.PasswordReset],
canViewAttributes: attributesPermissions[CrudAction.Read],
canEditAttributesForUser: attributesPermissions[CustomAction.EditUsers],
};
@@ -1,6 +1,6 @@
"use client";
import { useFlags } from "@calcom/features/flags/hooks";
import { useFlags } from "@calcom/web/modules/feature-flags/hooks/useFlags";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { Icon } from "@calcom/ui/components/icon";
@@ -2,11 +2,12 @@
import { useRouter, useSearchParams } from "next/navigation";
import posthog from "posthog-js";
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { ColorPicker, Label } from "@calcom/ui/components/form";
import { BannerUploader, ImageUploader } from "@calcom/ui/components/image-uploader";
import { OnboardingCard } from "../../components/OnboardingCard";
import { OnboardingLayout } from "../../components/OnboardingLayout";
@@ -25,49 +26,24 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
const { organizationDetails, organizationBrand, setOrganizationBrand } = useOnboardingStore();
const { isMigrationFlow, hasTeams } = useMigrationFlow();
const [_logoFile, setLogoFile] = useState<File | null>(null);
const [_bannerFile, setBannerFile] = useState<File | null>(null);
const [logoPreview, setLogoPreview] = useState<string | null>(null);
const [bannerPreview, setBannerPreview] = useState<string | null>(null);
const [brandColor, setBrandColor] = useState("#000000");
// Load from store on mount
useEffect(() => {
setLogoPreview(organizationBrand.logo);
setBannerPreview(organizationBrand.banner);
setBrandColor(organizationBrand.color);
}, [organizationBrand]);
const handleLogoChange = (file: File | null) => {
setLogoFile(file);
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
const base64 = reader.result as string;
setLogoPreview(base64);
setOrganizationBrand({ logo: base64 });
};
reader.readAsDataURL(file);
} else {
setLogoPreview(null);
setOrganizationBrand({ logo: null });
}
const handleLogoChange = (newLogo: string) => {
setLogoPreview(newLogo);
setOrganizationBrand({ logo: newLogo });
};
const handleBannerChange = (file: File | null) => {
setBannerFile(file);
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
const base64 = reader.result as string;
setBannerPreview(base64);
setOrganizationBrand({ banner: base64 });
};
reader.readAsDataURL(file);
} else {
setBannerPreview(null);
setOrganizationBrand({ banner: null });
}
const handleBannerChange = (newBanner: string) => {
setBannerPreview(newBanner);
setOrganizationBrand({ banner: newBanner });
};
const handleColorChange = (color: string) => {
@@ -154,21 +130,28 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
/>
)}
</div>
<div className="flex flex-col gap-2">
<Button
color="secondary"
size="sm"
className="w-fit"
onClick={() => document.getElementById("banner-upload")?.click()}>
{t("upload")}
</Button>
<input
id="banner-upload"
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleBannerChange(e.target.files?.[0] || null)}
<div className="flex gap-2">
<BannerUploader
id="org-banner-upload"
buttonMsg={t("upload")}
handleAvatarChange={handleBannerChange}
imageSrc={bannerPreview || undefined}
target="banner"
triggerButtonColor="secondary"
height={500}
width={1500}
/>
{bannerPreview && (
<Button
color="minimal"
size="sm"
onClick={() => {
setBannerPreview(null);
setOrganizationBrand({ banner: null });
}}>
{t("remove")}
</Button>
)}
</div>
</div>
<p className="text-subtle text-xs font-normal leading-3">
@@ -188,21 +171,26 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
/>
)}
</div>
<div className="flex flex-col gap-2">
<ImageUploader
id="org-logo-upload"
buttonMsg={t("upload")}
handleAvatarChange={handleLogoChange}
imageSrc={logoPreview || undefined}
target="logo"
triggerButtonColor="secondary"
buttonSize="sm"
/>
{logoPreview && (
<Button
color="secondary"
color="minimal"
size="sm"
onClick={() => document.getElementById("logo-upload")?.click()}>
{t("upload")}
onClick={() => {
setLogoPreview(null);
setOrganizationBrand({ logo: null });
}}>
{t("remove")}
</Button>
<input
id="logo-upload"
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleLogoChange(e.target.files?.[0] || null)}
/>
</div>
)}
</div>
<p className="text-subtle text-xs font-normal leading-3">{t("onboarding_logo_size_hint")}</p>
</div>
@@ -6,7 +6,7 @@ import React from "react";
import { useForm, useFieldArray } from "react-hook-form";
import { z } from "zod";
import { useFlags } from "@calcom/features/flags/hooks";
import { useFlags } from "@calcom/web/modules/feature-flags/hooks/useFlags";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { Form } from "@calcom/ui/components/form";
@@ -4,7 +4,7 @@ import { useRouter } from "next/navigation";
import posthog from "posthog-js";
import React from "react";
import { useFlags } from "@calcom/features/flags/hooks";
import { useFlags } from "@calcom/web/modules/feature-flags/hooks/useFlags";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
@@ -4,7 +4,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import posthog from "posthog-js";
import React, { useEffect } from "react";
import { useFlags } from "@calcom/features/flags/hooks";
import { useFlags } from "@calcom/web/modules/feature-flags/hooks/useFlags";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
@@ -0,0 +1,29 @@
import React from "react";
import type { Control, FieldValues } from "react-hook-form";
import {
ScheduleComponent,
type FieldPathByValue,
type ScheduleLabelsType,
} from "@calcom/features/schedules/components/ScheduleComponent";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import type { TimeRange } from "@calcom/types/schedule";
const Schedule = <
TFieldValues extends FieldValues,
TPath extends FieldPathByValue<TFieldValues, TimeRange[][]>
>(props: {
name: TPath;
control: Control<TFieldValues>;
weekStart?: number;
disabled?: boolean;
labels?: ScheduleLabelsType;
userTimeFormat?: number | null;
}) => {
const query = useMeQuery();
const { timeFormat } = query.data || { timeFormat: null };
return <ScheduleComponent userTimeFormat={timeFormat} {...props} />;
};
export default Schedule;
@@ -1,3 +1,7 @@
import {
DueInvoiceBanner,
type DueInvoiceBannerProps,
} from "@calcom/web/modules/ee/billing/components/DueInvoiceBanner";
import ImpersonatingBanner, {
type ImpersonatingBannerProps,
} from "@calcom/web/modules/ee/impersonation/components/ImpersonatingBanner";
@@ -31,6 +35,7 @@ type BannerTypeProps = {
impersonationBanner: ImpersonatingBannerProps;
calendarCredentialBanner: CalendarCredentialBannerProps;
invalidAppCredentialBanners: InvalidAppCredentialBannersProps;
dueInvoiceBanner: DueInvoiceBannerProps;
};
type BannerType = keyof BannerTypeProps;
@@ -51,6 +56,7 @@ export const BannerComponent: BannerComponent = {
invalidAppCredentialBanners: (props: InvalidAppCredentialBannersProps) => (
<InvalidAppCredentialBanners {...props} />
),
dueInvoiceBanner: (props: DueInvoiceBannerProps) => <DueInvoiceBanner {...props} />,
};
interface BannerContainerProps {
@@ -82,6 +88,9 @@ export const BannerContainer: React.FC<BannerContainerProps> = ({ banners }) =>
} else if (key === "invalidAppCredentialBanners") {
const Banner = BannerComponent[key];
return <Banner data={banners[key]} key={key} />;
} else if (key === "dueInvoiceBanner") {
const Banner = BannerComponent[key];
return <Banner data={banners[key]} key={key} />;
}
})}
</div>
@@ -4,7 +4,7 @@ import dayjs from "@calcom/dayjs";
import { TimezoneSelect } from "@calcom/web/modules/timezone/components/TimezoneSelect";
import DateOverrideInputDialog from "@calcom/features/schedules/components/DateOverrideInputDialog";
import DateOverrideList from "@calcom/features/schedules/components/DateOverrideList";
import Schedule from "@calcom/features/schedules/components/Schedule";
import Schedule from "@calcom/web/modules/schedules/components/Schedule";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import type { RouterOutputs } from "@calcom/trpc/react";
@@ -95,10 +95,7 @@ const initalColumnVisibility = {
actions: true,
};
function reducer(
state: UserTableState,
action: UserTableAction
): UserTableState {
function reducer(state: UserTableState, action: UserTableAction): UserTableState {
switch (action.type) {
case "SET_CHANGE_MEMBER_ROLE_ID":
return { ...state, changeMemberRole: action.payload };
@@ -146,11 +143,7 @@ function UserListTable(props: UserListTableProps): JSX.Element | null {
const pathname = usePathname();
if (!pathname) return null;
return (
<DataTableProvider
tableIdentifier={pathname}
useSegments={useSegments}
defaultPageSize={25}
>
<DataTableProvider tableIdentifier={pathname} useSegments={useSegments} defaultPageSize={25}>
<UserListTableContent {...props} />
</DataTableProvider>
);
@@ -203,6 +196,7 @@ function UserListTableContent({
canRemove: permissions?.canRemove ?? adminOrOwner,
canResendInvitation: permissions?.canInvite ?? adminOrOwner,
canImpersonate: permissions?.canImpersonate ?? adminOrOwner,
canResetPassword: permissions?.canResetPassword ?? adminOrOwner,
};
const generateAttributeColumns = (): ColumnDef<UserTableUser>[] => {
if (!attributes?.length) {
@@ -326,14 +320,12 @@ function UserListTableContent({
<div className="">
<div
data-testid={`member-${username}-username`}
className="text-emphasis text-sm font-medium leading-none"
>
className="text-emphasis text-sm font-medium leading-none">
{displayName}
</div>
<div
data-testid={`member-${username}-email`}
className="text-subtle mt-1 text-sm leading-none"
>
className="text-subtle mt-1 text-sm leading-none">
{email}
</div>
</div>
@@ -362,8 +354,7 @@ function UserListTableContent({
variant={roleVariant}
onClick={() => {
table.getColumn("role")?.setFilterValue([role]);
}}
>
}}>
{roleName}
</Badge>
);
@@ -390,8 +381,7 @@ function UserListTableContent({
data-testid={`email-${email.replace("@", "")}-pending`}
onClick={() => {
table.getColumn("role")?.setFilterValue(["PENDING"]);
}}
>
}}>
{t("pending")}
</Badge>
)}
@@ -502,8 +492,7 @@ function UserListTableContent({
const permissionsForUser = {
canEdit:
((permissionsRaw.canEdit ?? false) ||
(permissions?.canEditAttributesForUser ?? false)) &&
((permissionsRaw.canEdit ?? false) || (permissions?.canEditAttributesForUser ?? false)) &&
user.accepted &&
!isSelf,
canRemove: (permissionsRaw.canRemove ?? false) && !isSelf,
@@ -514,8 +503,8 @@ function UserListTableContent({
!!org?.canAdminImpersonate &&
(permissionsRaw.canImpersonate ?? false),
canLeave: user.accepted && isSelf,
canResendInvitation:
(permissionsRaw.canResendInvitation ?? false) && !user.accepted,
canResendInvitation: (permissionsRaw.canResendInvitation ?? false) && !user.accepted,
canResetPassword: (permissionsRaw.canResetPassword ?? false) && user.accepted && !isSelf,
};
return (
@@ -573,9 +562,7 @@ function UserListTableContent({
}))
);
default: {
const attribute = facetedTeamValues.attributes.find(
(attr) => attr.id === columnId
);
const attribute = facetedTeamValues.attributes.find((attr) => attr.id === columnId);
if (attribute) {
return convertFacetedValuesToMap(
attribute?.options.map(({ value }) => ({
@@ -635,19 +622,12 @@ function UserListTableContent({
}
const ATTRIBUTE_IDS = attributes?.map((attr) => attr.id) ?? [];
const csvRaw = generateCsvRawForMembersTable(
headers,
allRows,
ATTRIBUTE_IDS,
domain
);
const csvRaw = generateCsvRawForMembersTable(headers, allRows, ATTRIBUTE_IDS, domain);
if (!csvRaw) {
throw new Error("Generating CSV file failed.");
}
const filename = `${org.name}_${
new Date().toISOString().split("T")[0]
}.csv`;
const filename = `${org.name}_${new Date().toISOString().split("T")[0]}.csv`;
downloadAsCsv(csvRaw, filename);
} catch (error) {
showToast(`Error: ${error}`, "error");
@@ -677,8 +657,7 @@ function UserListTableContent({
<DataTableSegment.SaveButton />
<DataTableSegment.Select />
</>
}
>
}>
{numberOfSelectedRows >= 2 && dynamicLinkVisible && (
<DataTableSelectionBar.Root className="bottom-[7.3rem]! md:bottom-32!">
<DynamicLink table={table} domain={domain} />
@@ -691,23 +670,17 @@ function UserListTableContent({
</p>
{!isPlatformUser && (
<>
{permissions?.canChangeMemberRole && (
<TeamListBulkAction table={table} />
)}
{permissions?.canChangeMemberRole && <TeamListBulkAction table={table} />}
{numberOfSelectedRows >= 2 && (
<DataTableSelectionBar.Button
color="secondary"
onClick={() => setDynamicLinkVisible(!dynamicLinkVisible)}
icon="handshake"
>
icon="handshake">
{t("group_meeting")}
</DataTableSelectionBar.Button>
)}
{(permissions?.canEditAttributesForUser ?? adminOrOwner) && (
<MassAssignAttributesBulkAction
table={table}
filters={columnFilters}
/>
<MassAssignAttributesBulkAction table={table} filters={columnFilters} />
)}
{(permissions?.canChangeMemberRole ?? adminOrOwner) && (
<EventTypesList table={table} orgTeams={teams} />
@@ -716,9 +689,7 @@ function UserListTableContent({
)}
{(permissions?.canRemove ?? adminOrOwner) && (
<DeleteBulkUsers
users={table
.getSelectedRowModel()
.flatRows.map((row) => row.original)}
users={table.getSelectedRowModel().flatRows.map((row) => row.original)}
onRemove={() => table.toggleAllPageRowsSelected(false)}
/>
)}
@@ -726,18 +697,10 @@ function UserListTableContent({
)}
</DataTableWrapper>
{state.deleteMember.showModal && (
<DeleteMemberModal state={state} dispatch={dispatch} />
)}
{state.inviteMember.showModal && (
<InviteMemberModal dispatch={dispatch} />
)}
{state.impersonateMember.showModal && (
<ImpersonationMemberModal dispatch={dispatch} state={state} />
)}
{state.changeMemberRole.showModal && (
<ChangeUserRoleModal dispatch={dispatch} state={state} />
)}
{state.deleteMember.showModal && <DeleteMemberModal state={state} dispatch={dispatch} />}
{state.inviteMember.showModal && <InviteMemberModal dispatch={dispatch} />}
{state.impersonateMember.showModal && <ImpersonationMemberModal dispatch={dispatch} state={state} />}
{state.changeMemberRole.showModal && <ChangeUserRoleModal dispatch={dispatch} state={state} />}
{state.editSheet.showModal && (
<EditUserSheet
dispatch={dispatch}
@@ -757,8 +720,7 @@ function UserListTableContent({
StartIcon="file-down"
loading={isDownloading}
onClick={() => handleDownload()}
data-testid="export-members-button"
>
data-testid="export-members-button">
{t("download")}
</DataTableToolbar.CTA>
{(permissions?.canInvite ?? adminOrOwner) && (
@@ -775,8 +737,7 @@ function UserListTableContent({
});
posthog.capture("add_organization_member_clicked");
}}
data-testid="new-organization-member-button"
>
data-testid="new-organization-member-button">
{t("add")}
</DataTableToolbar.CTA>
)}
@@ -1,5 +1,3 @@
import { useSession } from "next-auth/react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import classNames from "@calcom/ui/classNames";
@@ -7,17 +5,17 @@ import { Button } from "@calcom/ui/components/button";
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
import {
Dropdown,
DropdownMenuTrigger,
DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@calcom/ui/components/dropdown";
import { showToast } from "@calcom/ui/components/toast";
import { Tooltip } from "@calcom/ui/components/tooltip";
import type { UserTableUser, UserTableAction } from "./types";
import { useSession } from "next-auth/react";
import type { UserTableAction, UserTableUser } from "./types";
export function TableActions({
user,
@@ -33,6 +31,7 @@ export function TableActions({
canRemove: boolean;
canImpersonate: boolean;
canResendInvitation: boolean;
canResetPassword: boolean;
};
}) {
const { t, i18n } = useLocale();
@@ -45,6 +44,14 @@ export function TableActions({
showToast(error.message, "error");
},
});
const sendPasswordResetMutation = trpc.viewer.organizations.sendPasswordReset.useMutation({
onSuccess: () => {
showToast(t("password_reset_email_sent"), "success");
},
onError: (error) => {
showToast(error.message, "error");
},
});
const usersProfileUrl = `${domain}/${user.username}`;
@@ -96,26 +103,37 @@ export function TableActions({
</DropdownMenuItem>
)}
{permissionsForUser.canImpersonate && (
<>
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() =>
dispatch({
type: "SET_IMPERSONATE_ID",
payload: {
user,
showModal: true,
},
})
}
StartIcon="lock">
{t("impersonate")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() =>
dispatch({
type: "SET_IMPERSONATE_ID",
payload: {
user,
showModal: true,
},
})
}
StartIcon="lock">
{t("impersonate")}
</DropdownItem>
</DropdownMenuItem>
)}
{permissionsForUser.canResetPassword && (
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() => {
sendPasswordResetMutation.mutate({ userId: user.id });
}}
StartIcon="key">
{t("reset_password")}
</DropdownItem>
</DropdownMenuItem>
)}
{(permissionsForUser.canImpersonate || permissionsForUser.canResetPassword) &&
permissionsForUser.canRemove && <DropdownMenuSeparator />}
{permissionsForUser.canRemove && (
<DropdownMenuItem>
<DropdownItem
@@ -170,24 +188,34 @@ export function TableActions({
</DropdownItem>
</DropdownMenuItem>
{permissionsForUser.canEdit && (
<>
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() =>
dispatch({
type: "EDIT_USER_SHEET",
payload: {
user,
showModal: true,
},
})
}
StartIcon="pencil">
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
</>
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() =>
dispatch({
type: "EDIT_USER_SHEET",
payload: {
user,
showModal: true,
},
})
}
StartIcon="pencil">
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
)}
{permissionsForUser.canResetPassword && (
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() => {
sendPasswordResetMutation.mutate({ userId: user.id });
}}
StartIcon="key">
{t("reset_password")}
</DropdownItem>
</DropdownMenuItem>
)}
{permissionsForUser.canRemove && (
<DropdownMenuItem>
+6 -2
View File
@@ -3,15 +3,15 @@ import { config as dotenvConfig } from "dotenv";
import type { NextConfig } from "next";
import type { RouteHas } from "next/dist/lib/load-custom-routes";
import { withAxiom } from "next-axiom";
import i18nConfig from "./next-i18next.config";
import packageJson from "./package.json";
import {
nextJsOrgRewriteConfig,
orgUserRoutePath,
orgUserTypeRoutePath,
orgUserTypeEmbedRoutePath,
orgUserTypeRoutePath,
} from "./pagesAndRewritePaths";
import { TRIGGER_VERSION } from "./trigger.version"; // adjust path as needed
dotenvConfig({ path: "../../.env" });
@@ -58,6 +58,10 @@ const env = process.env as Record<string, string | undefined>;
env.NEXT_PUBLIC_CALCOM_VERSION = version;
if (process.env.NODE_ENV === "production" || process.env.CALCOM_ENV === "production") {
env.TRIGGER_VERSION = TRIGGER_VERSION;
}
if (process.env.VERCEL_URL && !process.env.NEXT_PUBLIC_WEBAPP_URL) {
env.NEXT_PUBLIC_WEBAPP_URL = `https://${process.env.VERCEL_URL}`;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "6.1.7",
"version": "6.1.8",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -1,4 +1,4 @@
import { OPT_IN_FEATURES } from "@calcom/features/feature-opt-in/config";
import { getOptInFeatureConfig, shouldDisplayFeatureAt } from "@calcom/features/feature-opt-in/config";
import { expect } from "@playwright/test";
import { test } from "./lib/fixtures";
@@ -10,7 +10,8 @@ test.describe("Feature Opt-In Banner", () => {
users,
prisma,
}) => {
if (!OPT_IN_FEATURES.some((item) => item.slug === "bookings-v3")) {
const featureConfig = getOptInFeatureConfig("bookings-v3");
if (!featureConfig || !shouldDisplayFeatureAt(featureConfig, "banner")) {
return;
}
+28 -14
View File
@@ -410,6 +410,7 @@
"info": "Info",
"welcome": "Welcome",
"welcome_back": "Welcome back",
"welcome_back_sign_in": "Welcome back! Sign in to continue",
"welcome_to_calcom": "Welcome to {{appName}}",
"personal_welcome_description": "You're all set! Start scheduling meetings and managing your calendar.",
"unlimited_calendars": "Unlimited calendars",
@@ -1888,6 +1889,9 @@
"calendars": "Calendars",
"2fa_auth": "Two factor authentication",
"invoices": "Invoices",
"invoice_due": "You have an unpaid invoice for \"{{teamName}}\". Please pay to avoid service interruption.",
"invoice_overdue_blocking": "Your invoice for \"{{teamName}}\" is overdue. Member invitations are blocked until payment.",
"invitations_blocked_unpaid_invoice": "Member invitations are blocked due to an unpaid invoice over 7 days past due.",
"embeds": "Embeds",
"impersonation": "Impersonation",
"impersonation_description": "Settings to manage user impersonation",
@@ -2180,8 +2184,8 @@
"member_already_invited": "Member has already been invited",
"already_in_use_error": "Username already in use",
"enter_email_or_username": "Enter an email or username",
"enter_email": "Enter an email",
"enter_emails": "Enter emails",
"enter_email": "Enter an email",
"enter_emails": "Enter emails",
"too_many_invites": "You are limited to inviting a maximum of {{nbUsers}} users at once.",
"team_name_taken": "This name is already taken",
"must_enter_team_name": "Must enter a team name",
@@ -2934,6 +2938,7 @@
"add_new_user": "Add new user",
"this_app_is_not_setup_already": "This app has not been setup yet",
"as_csv": "as CSV",
"calendar_settings": "Calendar Settings",
"overlay_my_calendar": "Overlay my calendar",
"overlay_my_calendar_toc": "By connecting to your calendar, you accept our privacy policy and terms of use. You may revoke access at any time.",
"view_overlay_calendar_events": "View your calendar events to prevent clashed booking.",
@@ -3039,9 +3044,13 @@
"time_range": "Time range",
"automatically_add_all_team_members": "Add all team members, including future members",
"redirect_to": "Redirect to",
"having_trouble_finding_time": "Having trouble finding a time?",
"show_more": "Show more",
"forward_params_redirect": "Forward parameters such as ?email=...&name=.... and more",
"having_trouble_finding_time": "Having trouble finding a time?",
"show_more": "Show more",
"signup_with_google": "Sign up with Google",
"signup_with_microsoft": "Sign up with Microsoft",
"signin_with_microsoft": "Sign in with Microsoft",
"continue_with_microsoft": "Continue with Microsoft",
"forward_params_redirect": "Forward parameters such as ?email=...&name=.... and more",
"assignment_description": "Schedule meetings when everyone is available or rotate through members on your team",
"lowest": "lowest",
"low": "low",
@@ -3877,6 +3886,8 @@
"pbac_action_read_org_booking_audit_logs": "View organization booking audit logs",
"pbac_action_impersonate": "Impersonate",
"pbac_action_edit_users": "Edit users",
"pbac_action_password_reset": "Reset password",
"pbac_desc_reset_password_organization_members": "Send password reset emails to organization members",
"role_created_successfully": "Role created successfully",
"role_updated_successfully": "Role updated successfully",
"delete_role": "Delete role",
@@ -3955,7 +3966,7 @@
"pbac_desc_view_routing_forms": "View routing forms",
"pbac_desc_update_routing_forms": "Update routing forms",
"pbac_desc_delete_routing_forms": "Delete routing forms",
"pbac_opt_in_title":"Enable Roles & Permissions for your organization",
"pbac_opt_in_title": "Enable Roles & Permissions for your organization",
"pbac_opt_in_description": "Take control of your organization with customizable roles and granular permissions for your team members.",
"pbac_opt_in_custom_roles_title": "Custom roles",
"pbac_opt_in_custom_roles_desc": "Create custom roles tailored to your organization's unique workflow",
@@ -4290,8 +4301,8 @@
"onboarding_org_brand_subtitle": "Customize your booking pages with your logo and a custom banner",
"onboarding_primary_color_label": "Primary color",
"onboarding_banner_label": "Banner",
"onboarding_logo_size_hint": "Recommended size 64x64px (max 10mb)",
"onboarding_banner_size_hint": "Recommended size 1500x150px (max 10mb)",
"onboarding_logo_size_hint": "Recommended size 64x64px (max 5mb)",
"onboarding_banner_size_hint": "Recommended size 1500x500px (max 5mb)",
"onboarding_preview_nameless": "Nameless",
"onboarding_preview_example_title": "Enterprise sales",
"onboarding_preview_example_description": "Jump on a call with one of our sales reps to learn more",
@@ -4444,13 +4455,14 @@
"reschedule_requested": "Reschedule requested",
"attendee_added": "Attendee added",
"host_no_show_updated": "Host no-show updated",
"attendee_no_show_updated": "Attendee no-show updated",
"no_show_updated": "No-show updated",
"rejected": "Rejected",
"attendee_removed": "Attendee removed",
"reassignment": "Reassignment",
"booking_reassigned_to_host": "Booking reassigned to {{host}}",
"location_changed": "Location changed",
"location_changed_from_to": "Location changed from {{fromLocation}} to {{toLocation}}",
"attendee_no_show_updated": "Attendee no-show updated",
"seat_booked": "Seat booked",
"seat_rescheduled": "Seat rescheduled {{oldDate}} -> <0>{{newDate}}</0>",
"assignment_type": "Assignment",
@@ -4490,10 +4502,11 @@
"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.",
"feature_user_only_not_allowed_warning": "This feature needs approval from your team or organization.",
"feature_org_disabled": "This feature is disabled by your organization.",
"feature_all_teams_disabled": "This feature is disabled by your team.",
"feature_any_team_disabled": "This feature is disabled by your team.",
"feature_no_explicit_enablement": "Turn it on for yourself, or ask your team or organization admin to enable it for everyone.",
"feature_user_only_not_allowed": "This feature needs approval from your team or organization.",
"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.",
@@ -4516,5 +4529,6 @@
"error_enabling_feature": "Error enabling feature. Please try again.",
"set_organizer_as_contact_owner": "Set booking organizer as contact owner",
"overwrite_existing_contact_owner": "Overwrite existing contact owner",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS":"↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
"calendar_events_disabled_video_limitation": "When calendar events are disabled, meeting links cannot be generated for apps whose link generation is tied to calendar event creation, for example, Google Meet and Microsoft Teams. Other video apps like Cal Video will continue to work.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+1
View File
@@ -0,0 +1 @@
export const TRIGGER_VERSION = '20260129.1';
+3
View File
@@ -358,6 +358,9 @@
"includes": ["packages/platform/atoms/**/*.{ts,tsx,js,jsx,mts,mjs,cjs,cts}"],
"linter": {
"rules": {
"correctness": {
"noProcessGlobal": "off"
},
"style": {
"noRestrictedImports": {
"level": "error",
+1 -1
View File
@@ -1,9 +1,9 @@
import process from "node:process";
import type { AppMeta } from "@calcom/types/App";
import _package from "./package.json";
export const metadata = {
name: "HubSpot CRM",
// biome-ignore lint/correctness/noProcessGlobal: Server-only metadata evaluated at build time
installed: !!process.env.HUBSPOT_CLIENT_ID,
description: _package.description,
type: "hubspot_crm",
@@ -1,18 +1,19 @@
/* eslint-disable @typescript-eslint/triple-slash-reference */
/// <reference path="../../../types/ical.d.ts"/>
import ICAL from "ical.js";
import process from "node:process";
import dayjs from "@calcom/dayjs";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import type {
Calendar,
IntegrationCalendar,
EventBusyDate,
CalendarEvent,
EventBusyDate,
GetAvailabilityParams,
IntegrationCalendar,
NewCalendarEventType,
} from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
import ICAL from "ical.js";
// for Apple's Travel Time feature only (for now)
const getTravelDurationInSeconds = (vevent: ICAL.Component) => {
@@ -301,7 +302,7 @@ class ICSFeedCalendarService implements Calendar {
name,
readOnly: true,
externalId: url,
integrationName: this.integrationName,
integration: this.integrationName,
};
});
}
@@ -1,5 +1,7 @@
import { getCRMContactOwnerForRRLeadSkip } from "@calcom/app-store/_utils/CRMRoundRobinSkip";
import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository";
import { CrmRoutingTraceService } from "@calcom/features/routing-trace/services/CrmRoutingTraceService";
import type { RoutingTraceService } from "@calcom/features/routing-trace/services/RoutingTraceService";
import { prisma } from "@calcom/prisma";
import { SchedulingType } from "@calcom/prisma/enums";
@@ -10,32 +12,39 @@ export default async function routerGetCrmContactOwnerEmail({
attributeRoutingConfig,
identifierKeyedResponse,
action,
routingTraceService,
}: {
attributeRoutingConfig: LocalRoute["attributeRoutingConfig"];
identifierKeyedResponse: Record<string, string | string[]> | null;
action: LocalRoute["action"];
routingTraceService?: RoutingTraceService;
}) {
// Check if route is skipping CRM contact check
if (attributeRoutingConfig?.skipContactOwner) return null;
// Check if email is present
let prospectEmail: string | null = null;
if (identifierKeyedResponse) {
for (const identifier of Object.keys(identifierKeyedResponse)) {
const fieldResponse = identifierKeyedResponse[identifier];
if (identifier === "email") {
prospectEmail = fieldResponse instanceof Array ? fieldResponse[0] : fieldResponse;
prospectEmail =
fieldResponse instanceof Array ? fieldResponse[0] : fieldResponse;
break;
}
}
}
if (!prospectEmail) return null;
// Determine if the action is an event type redirect
if (action.type !== "eventTypeRedirectUrl" || !action.eventTypeId) return null;
if (action.type !== "eventTypeRedirectUrl" || !action.eventTypeId)
return null;
const eventTypeId = action.eventTypeId;
const eventTypeRepo = new EventTypeRepository(prisma);
const eventType = await eventTypeRepo.findByIdIncludeHostsAndTeam({ id: action.eventTypeId });
if (!eventType || eventType.schedulingType !== SchedulingType.ROUND_ROBIN) return null;
const eventType = await eventTypeRepo.findByIdIncludeHostsAndTeam({
id: action.eventTypeId,
});
if (!eventType || eventType.schedulingType !== SchedulingType.ROUND_ROBIN)
return null;
const eventTypeMetadata = eventType.metadata;
if (!eventTypeMetadata) return null;
@@ -51,34 +60,71 @@ export default async function routerGetCrmContactOwnerEmail({
crmAppSlug: null,
recordId: null,
};
// Determine if there is a CRM option enabled in the chosen route
for (const appSlug of enabledAppSlugs) {
const routingOptions = attributeRoutingConfig?.[appSlug as keyof typeof attributeRoutingConfig];
if (!routingOptions) continue;
// See if any options are true
if (Object.values(routingOptions).some((option) => option === true)) {
const appBookingFormHandler = (await import("@calcom/app-store/routing-forms/appBookingFormHandler"))
.default;
const appHandler = appBookingFormHandler[appSlug as keyof typeof appBookingFormHandler];
const runCrmOperations = async () => {
for (const appSlug of enabledAppSlugs) {
const routingOptions =
attributeRoutingConfig?.[appSlug as keyof typeof attributeRoutingConfig];
const ownerQuery = await appHandler(prospectEmail, attributeRoutingConfig, action.eventTypeId);
if (!routingOptions) continue;
if (ownerQuery?.email) {
contactOwner = { ...ownerQuery, crmAppSlug: appSlug };
break;
if (Object.values(routingOptions).some((option) => option === true)) {
const appBookingFormHandler = (
await import("@calcom/app-store/routing-forms/appBookingFormHandler")
).default;
const appHandler =
appBookingFormHandler[appSlug as keyof typeof appBookingFormHandler];
const ownerQuery = await appHandler(
prospectEmail,
attributeRoutingConfig,
eventTypeId
);
if (ownerQuery?.email) {
contactOwner = { ...ownerQuery, crmAppSlug: appSlug };
break;
}
}
}
if (!contactOwner || (!contactOwner.email && !contactOwner.recordType)) {
const ownerQuery = await getCRMContactOwnerForRRLeadSkip(
prospectEmail,
eventTypeMetadata
);
if (ownerQuery?.email) contactOwner = ownerQuery;
}
};
const crmRoutingTraceService = CrmRoutingTraceService.create(routingTraceService);
if (crmRoutingTraceService) {
await crmRoutingTraceService.runAsync(runCrmOperations);
} else {
await runCrmOperations();
}
if (!contactOwner || (!contactOwner.email && !contactOwner.recordType)) {
const ownerQuery = await getCRMContactOwnerForRRLeadSkip(prospectEmail, eventTypeMetadata);
if (ownerQuery?.email) contactOwner = ownerQuery;
}
if (!contactOwner) return null;
// Check that the contact owner is a part of the event type
if (!eventType.hosts.some((host) => host.user.email === contactOwner.email)) return null;
if (!eventType.hosts.some((host) => host.user.email === contactOwner.email))
return null;
if (routingTraceService && contactOwner.email && contactOwner.crmAppSlug) {
const salesforceSettings = attributeRoutingConfig?.salesforce;
routingTraceService.addStep({
domain: contactOwner.crmAppSlug,
step: `${contactOwner.crmAppSlug}_assignment`,
data: {
email: contactOwner.email,
recordType: contactOwner.recordType,
recordId: contactOwner.recordId,
crmAppSlug: contactOwner.crmAppSlug,
rrSkipToAccountLookupField: salesforceSettings?.rrSkipToAccountLookupField,
rrSKipToAccountLookupFieldName: salesforceSettings?.rrSKipToAccountLookupFieldName,
},
});
}
return contactOwner;
}
@@ -0,0 +1,214 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RoutingFormTraceService } from "@calcom/features/routing-trace/domains/RoutingFormTraceService";
import { RaqbLogicResult } from "@calcom/lib/raqb/evaluateRaqbLogic";
import type { FormResponse, SerializableForm } from "../types/types";
import { findMatchingRoute } from "./processRoute";
vi.mock("@calcom/lib/raqb/evaluateRaqbLogic", () => ({
evaluateRaqbLogic: vi.fn(),
RaqbLogicResult: {
MATCH: "MATCH",
NO_MATCH: "NO_MATCH",
LOGIC_NOT_FOUND_SO_MATCHED: "LOGIC_NOT_FOUND_SO_MATCHED",
},
}));
vi.mock("./getQueryBuilderConfig", () => ({
getQueryBuilderConfigForFormFields: vi.fn().mockReturnValue({}),
}));
const { evaluateRaqbLogic } = await import("@calcom/lib/raqb/evaluateRaqbLogic");
describe("findMatchingRoute", () => {
let mockRoutingFormTrace: RoutingFormTraceService;
beforeEach(() => {
vi.clearAllMocks();
mockRoutingFormTrace = {
routeMatched: vi.fn(),
fallbackRouteUsed: vi.fn(),
attributeLogicEvaluated: vi.fn(),
attributeFallbackUsed: vi.fn(),
} as unknown as RoutingFormTraceService;
});
const createMockForm = (
routes: Array<{
id: string;
name?: string;
isFallback?: boolean;
queryValue?: unknown;
}>
): Pick<SerializableForm<never>, "routes" | "fields"> => ({
routes: routes.map((route) => ({
id: route.id,
name: route.name,
isFallback: route.isFallback ?? false,
queryValue: route.queryValue ?? { type: "group" },
action: { type: "customPageMessage", value: "test" },
})) as never,
fields: [],
});
const createMockResponse = (): Record<string, Pick<FormResponse[string], "value">> => ({});
it("should throw error if fallback route is missing", () => {
const form = createMockForm([{ id: "route-1", name: "Route 1" }]);
expect(() =>
findMatchingRoute({
form,
response: createMockResponse(),
})
).toThrow("Fallback route is missing");
});
it("should return null if no route matches", () => {
vi.mocked(evaluateRaqbLogic).mockReturnValue(RaqbLogicResult.NO_MATCH);
const form = createMockForm([
{ id: "route-1", name: "Route 1" },
{ id: "fallback", name: "Fallback", isFallback: true },
]);
const result = findMatchingRoute({
form,
response: createMockResponse(),
});
expect(result).toBeNull();
});
describe("tracing", () => {
it("should call routeMatched when a non-fallback route matches", () => {
vi.mocked(evaluateRaqbLogic).mockReturnValue(RaqbLogicResult.MATCH);
const form = createMockForm([
{ id: "route-1", name: "Sales Route" },
{ id: "fallback", name: "Default", isFallback: true },
]);
const result = findMatchingRoute({
form,
response: createMockResponse(),
routingFormTraceService: mockRoutingFormTrace,
});
expect(result).not.toBeNull();
expect(mockRoutingFormTrace.routeMatched).toHaveBeenCalledWith({
routeId: "route-1",
routeName: "Sales Route",
});
expect(mockRoutingFormTrace.fallbackRouteUsed).not.toHaveBeenCalled();
});
it("should call fallbackRouteUsed when fallback route is used", () => {
vi.mocked(evaluateRaqbLogic)
.mockReturnValueOnce(RaqbLogicResult.NO_MATCH)
.mockReturnValueOnce(RaqbLogicResult.MATCH);
const form = createMockForm([
{ id: "route-1", name: "Sales Route" },
{ id: "fallback", name: "Default Route", isFallback: true },
]);
const result = findMatchingRoute({
form,
response: createMockResponse(),
routingFormTraceService: mockRoutingFormTrace,
});
expect(result).not.toBeNull();
expect(mockRoutingFormTrace.fallbackRouteUsed).toHaveBeenCalledWith({
routeId: "fallback",
routeName: "Default Route",
});
expect(mockRoutingFormTrace.routeMatched).not.toHaveBeenCalled();
});
it("should use 'default_route' as name when fallback route has no name", () => {
vi.mocked(evaluateRaqbLogic)
.mockReturnValueOnce(RaqbLogicResult.NO_MATCH)
.mockReturnValueOnce(RaqbLogicResult.MATCH);
const form = createMockForm([
{ id: "route-1", name: "Sales Route" },
{ id: "fallback", isFallback: true },
]);
const result = findMatchingRoute({
form,
response: createMockResponse(),
routingFormTraceService: mockRoutingFormTrace,
});
expect(result).not.toBeNull();
expect(mockRoutingFormTrace.fallbackRouteUsed).toHaveBeenCalledWith({
routeId: "fallback",
routeName: "default_route",
});
});
it("should use route id as name when route has no name", () => {
vi.mocked(evaluateRaqbLogic).mockReturnValue(RaqbLogicResult.MATCH);
const form = createMockForm([
{ id: "route-123" },
{ id: "fallback", isFallback: true },
]);
const result = findMatchingRoute({
form,
response: createMockResponse(),
routingFormTraceService: mockRoutingFormTrace,
});
expect(result).not.toBeNull();
expect(mockRoutingFormTrace.routeMatched).toHaveBeenCalledWith({
routeId: "route-123",
routeName: "route-123",
});
});
it("should not call trace methods when routingFormTrace is not provided", () => {
vi.mocked(evaluateRaqbLogic).mockReturnValue(RaqbLogicResult.MATCH);
const form = createMockForm([
{ id: "route-1", name: "Sales Route" },
{ id: "fallback", isFallback: true },
]);
const result = findMatchingRoute({
form,
response: createMockResponse(),
});
expect(result).not.toBeNull();
expect(mockRoutingFormTrace.routeMatched).not.toHaveBeenCalled();
expect(mockRoutingFormTrace.fallbackRouteUsed).not.toHaveBeenCalled();
});
it("should handle LOGIC_NOT_FOUND_SO_MATCHED as a match", () => {
vi.mocked(evaluateRaqbLogic).mockReturnValue(RaqbLogicResult.LOGIC_NOT_FOUND_SO_MATCHED);
const form = createMockForm([
{ id: "route-1", name: "Auto Match Route" },
{ id: "fallback", isFallback: true },
]);
const result = findMatchingRoute({
form,
response: createMockResponse(),
routingFormTraceService: mockRoutingFormTrace,
});
expect(result).not.toBeNull();
expect(mockRoutingFormTrace.routeMatched).toHaveBeenCalledWith({
routeId: "route-1",
routeName: "Auto Match Route",
});
});
});
});
@@ -1,11 +1,10 @@
"use client";
import type { JsonTree } from "react-awesome-query-builder";
import type { z } from "zod";
import type { RoutingFormTraceService } from "@calcom/features/routing-trace/domains/RoutingFormTraceService";
import { evaluateRaqbLogic, RaqbLogicResult } from "@calcom/lib/raqb/evaluateRaqbLogic";
import type { App_RoutingForms_Form } from "@calcom/prisma/client";
import type { JsonTree } from "react-awesome-query-builder";
import type { z } from "zod";
import type { FormResponse, Route, SerializableForm } from "../types/types";
import type { zodNonRouterRoute } from "../zod";
import { getQueryBuilderConfigForFormFields } from "./getQueryBuilderConfig";
@@ -15,9 +14,11 @@ import isRouter from "./isRouter";
export function findMatchingRoute({
form,
response,
routingFormTraceService,
}: {
form: Pick<SerializableForm<App_RoutingForms_Form>, "routes" | "fields">;
response: Record<string, Pick<FormResponse[string], "value">>;
routingFormTraceService?: RoutingFormTraceService;
}) {
const queryBuilderConfig = getQueryBuilderConfigForFormFields(form);
@@ -67,5 +68,21 @@ export function findMatchingRoute({
return null;
}
if (routingFormTraceService) {
let routeName: string;
if ("name" in chosenRoute && chosenRoute.name) {
routeName = chosenRoute.name;
} else if (isFallbackRoute(chosenRoute)) {
routeName = "default_route";
} else {
routeName = chosenRoute.id;
}
if (isFallbackRoute(chosenRoute)) {
routingFormTraceService.fallbackRouteUsed({ routeId: chosenRoute.id, routeName });
} else {
routingFormTraceService.routeMatched({ routeId: chosenRoute.id, routeName });
}
}
return chosenRoute;
}
@@ -20,6 +20,7 @@ import type { CRM, Contact, CrmEvent } from "@calcom/types/CrmService";
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import type { ParseRefreshTokenResponse } from "../../_utils/oauth/parseRefreshTokenResponse";
import { SalesforceRoutingTraceService } from "./tracing";
import parseRefreshTokenResponse from "../../_utils/oauth/parseRefreshTokenResponse";
import { findFieldValueByIdentifier } from "../../routing-forms/lib/findFieldValueByIdentifier";
import { default as appMeta } from "../config.json";
@@ -636,9 +637,15 @@ class SalesforceCRMService implements CRM {
}
}
const includeOwnerData =
(includeOwner || forRoundRobinSkip) &&
!(await this.shouldSkipAttendeeIfFreeEmailDomain(emailArray[0]));
const isFreeEmailDomain = await this.shouldSkipAttendeeIfFreeEmailDomain(emailArray[0]);
const includeOwnerData = (includeOwner || forRoundRobinSkip) && !isFreeEmailDomain;
if (isFreeEmailDomain && (includeOwner || forRoundRobinSkip)) {
SalesforceRoutingTraceService.ownerLookupSkipped({
reason: "Free email domain",
email: emailArray[0],
});
}
const includeAccountRecordType = forRoundRobinSkip && recordToSearch === SalesforceRecordEnum.ACCOUNT;
return records.map((record) => {
@@ -649,6 +656,33 @@ class SalesforceCRMService implements CRM {
? record?.Account?.Owner?.Email
: record?.Owner?.Email;
// Trace owner lookup based on record type
if (includeOwnerData && record?.Id && record?.OwnerId) {
const recordType = record?.attributes?.type;
if (recordType === SalesforceRecordEnum.CONTACT) {
SalesforceRoutingTraceService.contactOwnerLookup({
contactId: record.Id,
ownerEmail: ownerEmail ?? null,
ownerId: record.OwnerId ?? null,
});
} else if (recordType === SalesforceRecordEnum.LEAD) {
SalesforceRoutingTraceService.leadOwnerLookup({
leadId: record.Id,
ownerEmail: ownerEmail ?? null,
ownerId: record.OwnerId ?? null,
});
} else if (
recordType === SalesforceRecordEnum.ACCOUNT ||
(includeAccountRecordType && record?.AccountId)
) {
SalesforceRoutingTraceService.accountOwnerLookup({
accountId: record.AccountId || record.Id,
ownerEmail: ownerEmail ?? null,
ownerId: record.OwnerId ?? null,
});
}
}
return {
id: includeAccountRecordType ? record?.AccountId || "" : record?.Id || "",
email: record?.Email || "",
@@ -1102,6 +1136,11 @@ class SalesforceCRMService implements CRM {
const emailDomain = email.split("@")[1];
const log = logger.getSubLogger({ prefix: [`[getAccountIdBasedOnEmailDomainOfContacts]:${email}`] });
log.info("getAccountIdBasedOnEmailDomainOfContacts", safeStringify({ email, emailDomain }));
SalesforceRoutingTraceService.searchingByWebsiteValue({
emailDomain,
});
// First check if an account has the same website as the email domain of the attendee
const accountQuery = await conn.query(
`SELECT Id, Website FROM Account WHERE Website IN (${this.getAllPossibleAccountWebsiteFromEmailDomain(
@@ -1114,6 +1153,10 @@ class SalesforceCRMService implements CRM {
"Found account based on email domain",
safeStringify({ emailDomain, accountWebsite: account.Website, accountId: account.Id })
);
SalesforceRoutingTraceService.accountFoundByWebsite({
accountId: account.Id,
website: account.Website,
});
return account.Id;
}
@@ -1122,12 +1165,28 @@ class SalesforceCRMService implements CRM {
`SELECT Id, Email, AccountId FROM Contact WHERE Email LIKE '%@${emailDomain}' AND AccountId != null`
);
SalesforceRoutingTraceService.searchingByContactEmailDomain({
emailDomain,
contactCount: response.records.length,
});
const accountId = this.getDominantAccountId(response.records as { AccountId: string }[]);
if (accountId) {
log.info("Found account based on other contacts", safeStringify({ accountId }));
const contactsUnderAccount = (response.records as { AccountId: string }[]).filter(
(r) => r.AccountId === accountId
);
SalesforceRoutingTraceService.accountSelectedByMostContacts({
accountId,
contactCount: contactsUnderAccount.length,
});
} else {
log.info("No account found");
SalesforceRoutingTraceService.noAccountFound({
email,
reason: "No account found by website or contact domain",
});
}
return accountId;
@@ -1669,6 +1728,12 @@ class SalesforceCRMService implements CRM {
if (salesforceObject === SalesforceRecordEnum.ACCOUNT) {
const accountId = await this.getAccountIdBasedOnEmailDomainOfContacts(attendeeEmail);
SalesforceRoutingTraceService.lookupFieldQuery({
fieldName,
salesforceObject,
accountId: accountId ?? null,
});
if (!accountId) return;
const accountQuery = (await conn.query(
@@ -1691,6 +1756,11 @@ class SalesforceCRMService implements CRM {
const user = userQuery.records[0] as { Email: string };
SalesforceRoutingTraceService.userQueryFromLookupField({
lookupFieldUserId,
userEmail: user.Email,
});
return { email: user.Email, recordType: RoutingReasons.ACCOUNT_LOOKUP_FIELD };
}
}
@@ -6,6 +6,7 @@ import { safeStringify } from "@calcom/lib/safeStringify";
import type { Contact } from "@calcom/types/CrmService";
import { SalesforceRecordEnum } from "../enums";
import { SalesforceRoutingTraceService } from "../tracing";
import getAllPossibleWebsiteValuesFromEmailDomain from "../utils/getAllPossibleWebsiteValuesFromEmailDomain";
import getDominantAccountId from "../utils/getDominantAccountId";
import { GetAccountRecordsForRRSkip } from "./documents/queries";
@@ -60,6 +61,12 @@ export class SalesforceGraphQLClient {
const emailDomain = email.split("@")[1];
const websites = this.getAllPossibleAccountWebsiteFromEmailDomain(emailDomain);
// Trace query initiation
SalesforceRoutingTraceService.graphqlQueryInitiated({
email,
emailDomain,
});
log.info(`Query against email and email domain of ${emailDomain}`);
const query = await this.client.query(GetAccountRecordsForRRSkip, {
@@ -91,6 +98,11 @@ export class SalesforceGraphQLClient {
if (contact) {
log.info(`Existing contact found with id ${contact.Id}`);
SalesforceRoutingTraceService.graphqlExistingContactFound({
contactId: contact.Id,
accountId: contact.AccountId?.value || contact.Id,
ownerEmail: contact.Account?.Owner?.Email?.value || "",
});
return [
{
id: contact.AccountId?.value || contact.Id,
@@ -110,6 +122,10 @@ export class SalesforceGraphQLClient {
log.info(
`Existing account with website that matches email domain of ${emailDomain} found with id ${account.Id}`
);
SalesforceRoutingTraceService.graphqlAccountFoundByWebsite({
accountId: account.Id,
ownerEmail: account.Owner?.Email?.value || "",
});
return [
{
id: account.Id,
@@ -158,6 +174,12 @@ export class SalesforceGraphQLClient {
return contacts;
}, [] as { id: string; AccountId: string; ownerId: string; ownerEmail: string }[]);
// Trace searching by contact domain
SalesforceRoutingTraceService.graphqlSearchingByContactDomain({
emailDomain,
contactCount: relatedContacts.length,
});
const dominantAccountId = getDominantAccountId(relatedContacts);
if (!dominantAccountId) {
@@ -165,6 +187,10 @@ export class SalesforceGraphQLClient {
"Could not find dominant account id with the following contacts",
safeStringify({ relatedContacts })
);
SalesforceRoutingTraceService.graphqlNoAccountFound({
email,
reason: "Could not find dominant account from related contacts",
});
return [];
}
@@ -180,6 +206,18 @@ export class SalesforceGraphQLClient {
return [];
}
// Trace dominant account selection
const contactsUnderDominantAccount = relatedContacts.filter(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
(contact) => contact.AccountId === dominantAccountId
);
SalesforceRoutingTraceService.graphqlDominantAccountSelected({
accountId: dominantAccountId,
contactCount: contactsUnderDominantAccount.length,
ownerEmail: contactUnderAccount.ownerEmail,
});
log.info(`Account found via related contacts with account id ${dominantAccountId}`);
return [
{
@@ -193,6 +231,10 @@ export class SalesforceGraphQLClient {
}
log.info("No account found for attendee");
SalesforceRoutingTraceService.graphqlNoAccountFound({
email,
reason: "No account found via any tier",
});
return [];
}
@@ -0,0 +1,545 @@
import { CrmRoutingTraceService } from "@calcom/features/routing-trace/services/CrmRoutingTraceService";
import type { RoutingTraceService } from "@calcom/features/routing-trace/services/RoutingTraceService";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SalesforceRoutingTraceService } from "./SalesforceRoutingTraceService";
describe("SalesforceRoutingTraceService", () => {
let mockParentTraceService: RoutingTraceService;
let addStepSpy: ReturnType<typeof vi.fn>;
function createMockParentTraceService(): RoutingTraceService {
return {
addStep: vi.fn(),
getTrace: vi.fn().mockReturnValue([]),
} as unknown as RoutingTraceService;
}
/**
* Helper to run a function within the CRM trace context.
* This sets up AsyncLocalStorage so SalesforceRoutingTraceService methods work.
*/
async function runWithTraceContext<T>(fn: () => T): Promise<T> {
const crmTrace = CrmRoutingTraceService.create(mockParentTraceService);
if (!crmTrace) throw new Error("Failed to create CRM trace service");
return crmTrace.runAsync(async () => fn());
}
beforeEach(() => {
vi.clearAllMocks();
mockParentTraceService = createMockParentTraceService();
addStepSpy = mockParentTraceService.addStep as ReturnType<typeof vi.fn>;
});
describe("when called outside trace context", () => {
it("should not throw when called outside trace context", () => {
expect(() => {
SalesforceRoutingTraceService.searchingByWebsiteValue({ emailDomain: "example.com" });
}).not.toThrow();
});
it("should handle all methods gracefully when called outside trace context", () => {
expect(() => {
SalesforceRoutingTraceService.accountFoundByWebsite({
accountId: "123",
website: "example.com",
});
SalesforceRoutingTraceService.searchingByContactEmailDomain({
emailDomain: "example.com",
contactCount: 5,
});
SalesforceRoutingTraceService.noAccountFound({ email: "test@example.com", reason: "test" });
SalesforceRoutingTraceService.graphqlQueryInitiated({
email: "test@example.com",
emailDomain: "example.com",
});
}).not.toThrow();
});
});
describe("Account Resolution (SOQL path)", () => {
describe("searchingByWebsiteValue", () => {
it("should add step with correct domain and data", async () => {
await runWithTraceContext(() => {
SalesforceRoutingTraceService.searchingByWebsiteValue({ emailDomain: "acme.com" });
});
expect(addStepSpy).toHaveBeenCalledWith({
domain: "salesforce",
step: "searching_by_website_value",
data: { emailDomain: "acme.com" },
});
});
});
describe("accountFoundByWebsite", () => {
it("should add step with correct domain and data", async () => {
await runWithTraceContext(() => {
SalesforceRoutingTraceService.accountFoundByWebsite({
accountId: "001ABC123",
website: "www.acme.com",
});
});
expect(addStepSpy).toHaveBeenCalledWith({
domain: "salesforce",
step: "account_found_by_website",
data: { accountId: "001ABC123", website: "www.acme.com" },
});
});
});
describe("searchingByContactEmailDomain", () => {
it("should add step with correct domain and data", async () => {
await runWithTraceContext(() => {
SalesforceRoutingTraceService.searchingByContactEmailDomain({
emailDomain: "acme.com",
contactCount: 10,
});
});
expect(addStepSpy).toHaveBeenCalledWith({
domain: "salesforce",
step: "searching_by_contact_email_domain",
data: { emailDomain: "acme.com", contactCount: 10 },
});
});
});
describe("accountSelectedByMostContacts", () => {
it("should add step with correct domain and data", async () => {
await runWithTraceContext(() => {
SalesforceRoutingTraceService.accountSelectedByMostContacts({
accountId: "001ABC123",
contactCount: 5,
});
});
expect(addStepSpy).toHaveBeenCalledWith({
domain: "salesforce",
step: "account_selected_by_most_contacts",
data: { accountId: "001ABC123", contactCount: 5 },
});
});
});
describe("noAccountFound", () => {
it("should add step with correct domain and data", async () => {
await runWithTraceContext(() => {
SalesforceRoutingTraceService.noAccountFound({
email: "user@unknown.com",
reason: "No account found by website or contact domain",
});
});
expect(addStepSpy).toHaveBeenCalledWith({
domain: "salesforce",
step: "no_account_found",
data: { email: "user@unknown.com", reason: "No account found by website or contact domain" },
});
});
});
});
describe("Lookup Field", () => {
describe("lookupFieldQuery", () => {
it("should add step with correct domain and data when accountId is present", async () => {
await runWithTraceContext(() => {
SalesforceRoutingTraceService.lookupFieldQuery({
fieldName: "Account_Owner__c",
salesforceObject: "Account",
accountId: "001ABC123",
});
});
expect(addStepSpy).toHaveBeenCalledWith({
domain: "salesforce",
step: "lookup_field_query",
data: { fieldName: "Account_Owner__c", salesforceObject: "Account", accountId: "001ABC123" },
});
});
it("should add step with null accountId", async () => {
await runWithTraceContext(() => {
SalesforceRoutingTraceService.lookupFieldQuery({
fieldName: "Account_Owner__c",
salesforceObject: "Account",
accountId: null,
});
});
expect(addStepSpy).toHaveBeenCalledWith({
domain: "salesforce",
step: "lookup_field_query",
data: { fieldName: "Account_Owner__c", salesforceObject: "Account", accountId: null },
});
});
});
describe("userQueryFromLookupField", () => {
it("should add step with correct domain and data", async () => {
await runWithTraceContext(() => {
SalesforceRoutingTraceService.userQueryFromLookupField({
lookupFieldUserId: "005ABC123",
userEmail: "owner@acme.com",
});
});
expect(addStepSpy).toHaveBeenCalledWith({
domain: "salesforce",
step: "user_query_from_lookup_field",
data: { lookupFieldUserId: "005ABC123", userEmail: "owner@acme.com" },
});
});
it("should add step with null userEmail", async () => {
await runWithTraceContext(() => {
SalesforceRoutingTraceService.userQueryFromLookupField({
lookupFieldUserId: "005ABC123",
userEmail: null,
});
});
expect(addStepSpy).toHaveBeenCalledWith({
domain: "salesforce",
step: "user_query_from_lookup_field",
data: { lookupFieldUserId: "005ABC123", userEmail: null },
});
});
});
});
describe("Owner Lookups", () => {
describe("contactOwnerLookup", () => {
it("should add step with correct domain and data", async () => {
await runWithTraceContext(() => {
SalesforceRoutingTraceService.contactOwnerLookup({
contactId: "003ABC123",
ownerEmail: "owner@acme.com",
ownerId: "005DEF456",
});
});
expect(addStepSpy).toHaveBeenCalledWith({
domain: "salesforce",
step: "contact_owner_lookup",
data: { contactId: "003ABC123", ownerEmail: "owner@acme.com", ownerId: "005DEF456" },
});
});
it("should add step with null owner data", async () => {
await runWithTraceContext(() => {
SalesforceRoutingTraceService.contactOwnerLookup({
contactId: "003ABC123",
ownerEmail: null,
ownerId: null,
});
});
expect(addStepSpy).toHaveBeenCalledWith({
domain: "salesforce",
step: "contact_owner_lookup",
data: { contactId: "003ABC123", ownerEmail: null, ownerId: null },
});
});
});
describe("leadOwnerLookup", () => {
it("should add step with correct domain and data", async () => {
await runWithTraceContext(() => {
SalesforceRoutingTraceService.leadOwnerLookup({
leadId: "00QABC123",
ownerEmail: "owner@acme.com",
ownerId: "005DEF456",
});
});
expect(addStepSpy).toHaveBeenCalledWith({
domain: "salesforce",
step: "lead_owner_lookup",
data: { leadId: "00QABC123", ownerEmail: "owner@acme.com", ownerId: "005DEF456" },
});
});
it("should add step with null owner data", async () => {
await runWithTraceContext(() => {
SalesforceRoutingTraceService.leadOwnerLookup({
leadId: "00QABC123",
ownerEmail: null,
ownerId: null,
});
});
expect(addStepSpy).toHaveBeenCalledWith({
domain: "salesforce",
step: "lead_owner_lookup",
data: { leadId: "00QABC123", ownerEmail: null, ownerId: null },
});
});
});
describe("accountOwnerLookup", () => {
it("should add step with correct domain and data", async () => {
await runWithTraceContext(() => {
SalesforceRoutingTraceService.accountOwnerLookup({
accountId: "001ABC123",
ownerEmail: "owner@acme.com",
ownerId: "005DEF456",
});
});
expect(addStepSpy).toHaveBeenCalledWith({
domain: "salesforce",
step: "account_owner_lookup",
data: { accountId: "001ABC123", ownerEmail: "owner@acme.com", ownerId: "005DEF456" },
});
});
it("should add step with null owner data", async () => {
await runWithTraceContext(() => {
SalesforceRoutingTraceService.accountOwnerLookup({
accountId: "001ABC123",
ownerEmail: null,
ownerId: null,
});
});
expect(addStepSpy).toHaveBeenCalledWith({
domain: "salesforce",
step: "account_owner_lookup",
data: { accountId: "001ABC123", ownerEmail: null, ownerId: null },
});
});
});
});
describe("Validation", () => {
describe("ownerValidated", () => {
it("should add step when owner is a team member", async () => {
await runWithTraceContext(() => {
SalesforceRoutingTraceService.ownerValidated({
ownerEmail: "owner@acme.com",
isTeamMember: true,
});
});
expect(addStepSpy).toHaveBeenCalledWith({
domain: "salesforce",
step: "owner_validated",
data: { ownerEmail: "owner@acme.com", isTeamMember: true },
});
});
it("should add step when owner is not a team member", async () => {
await runWithTraceContext(() => {
SalesforceRoutingTraceService.ownerValidated({
ownerEmail: "external@other.com",
isTeamMember: false,
});
});
expect(addStepSpy).toHaveBeenCalledWith({
domain: "salesforce",
step: "owner_validated",
data: { ownerEmail: "external@other.com", isTeamMember: false },
});
});
});
});
describe("Skip", () => {
describe("ownerLookupSkipped", () => {
it("should add step with reason and email", async () => {
await runWithTraceContext(() => {
SalesforceRoutingTraceService.ownerLookupSkipped({
reason: "Free email domain",
email: "user@gmail.com",
});
});
expect(addStepSpy).toHaveBeenCalledWith({
domain: "salesforce",
step: "owner_lookup_skipped",
data: { reason: "Free email domain", email: "user@gmail.com" },
});
});
it("should add step with reason only (no email)", async () => {
await runWithTraceContext(() => {
SalesforceRoutingTraceService.ownerLookupSkipped({
reason: "No CRM configured",
});
});
expect(addStepSpy).toHaveBeenCalledWith({
domain: "salesforce",
step: "owner_lookup_skipped",
data: { reason: "No CRM configured" },
});
});
});
describe("contactOwnerCheckSkipped", () => {
it("should add step with correct domain and data", async () => {
await runWithTraceContext(() => {
SalesforceRoutingTraceService.contactOwnerCheckSkipped({
reason: "Route config disabled contact owner check",
});
});
expect(addStepSpy).toHaveBeenCalledWith({
domain: "salesforce",
step: "contact_owner_check_skipped",
data: { reason: "Route config disabled contact owner check" },
});
});
});
});
describe("GraphQL-specific", () => {
describe("graphqlQueryInitiated", () => {
it("should add step with correct domain and data", async () => {
await runWithTraceContext(() => {
SalesforceRoutingTraceService.graphqlQueryInitiated({
email: "user@acme.com",
emailDomain: "acme.com",
});
});
expect(addStepSpy).toHaveBeenCalledWith({
domain: "salesforce",
step: "graphql_query_initiated",
data: { email: "user@acme.com", emailDomain: "acme.com" },
});
});
});
describe("graphqlExistingContactFound", () => {
it("should add step with correct domain and data", async () => {
await runWithTraceContext(() => {
SalesforceRoutingTraceService.graphqlExistingContactFound({
contactId: "003ABC123",
accountId: "001DEF456",
ownerEmail: "owner@acme.com",
});
});
expect(addStepSpy).toHaveBeenCalledWith({
domain: "salesforce",
step: "graphql_existing_contact_found",
data: { contactId: "003ABC123", accountId: "001DEF456", ownerEmail: "owner@acme.com" },
});
});
});
describe("graphqlAccountFoundByWebsite", () => {
it("should add step with correct domain and data", async () => {
await runWithTraceContext(() => {
SalesforceRoutingTraceService.graphqlAccountFoundByWebsite({
accountId: "001ABC123",
ownerEmail: "owner@acme.com",
});
});
expect(addStepSpy).toHaveBeenCalledWith({
domain: "salesforce",
step: "graphql_account_found_by_website",
data: { accountId: "001ABC123", ownerEmail: "owner@acme.com" },
});
});
});
describe("graphqlSearchingByContactDomain", () => {
it("should add step with correct domain and data", async () => {
await runWithTraceContext(() => {
SalesforceRoutingTraceService.graphqlSearchingByContactDomain({
emailDomain: "acme.com",
contactCount: 15,
});
});
expect(addStepSpy).toHaveBeenCalledWith({
domain: "salesforce",
step: "graphql_searching_by_contact_domain",
data: { emailDomain: "acme.com", contactCount: 15 },
});
});
});
describe("graphqlDominantAccountSelected", () => {
it("should add step with correct domain and data", async () => {
await runWithTraceContext(() => {
SalesforceRoutingTraceService.graphqlDominantAccountSelected({
accountId: "001ABC123",
contactCount: 8,
ownerEmail: "owner@acme.com",
});
});
expect(addStepSpy).toHaveBeenCalledWith({
domain: "salesforce",
step: "graphql_dominant_account_selected",
data: { accountId: "001ABC123", contactCount: 8, ownerEmail: "owner@acme.com" },
});
});
});
describe("graphqlNoAccountFound", () => {
it("should add step with correct domain and data", async () => {
await runWithTraceContext(() => {
SalesforceRoutingTraceService.graphqlNoAccountFound({
email: "user@unknown.com",
reason: "No account found via any tier",
});
});
expect(addStepSpy).toHaveBeenCalledWith({
domain: "salesforce",
step: "graphql_no_account_found",
data: { email: "user@unknown.com", reason: "No account found via any tier" },
});
});
it("should add step with different reason", async () => {
await runWithTraceContext(() => {
SalesforceRoutingTraceService.graphqlNoAccountFound({
email: "user@example.com",
reason: "Could not find dominant account from related contacts",
});
});
expect(addStepSpy).toHaveBeenCalledWith({
domain: "salesforce",
step: "graphql_no_account_found",
data: { email: "user@example.com", reason: "Could not find dominant account from related contacts" },
});
});
});
});
describe("domain constant", () => {
it("should use 'salesforce' as the domain for all methods", async () => {
await runWithTraceContext(() => {
SalesforceRoutingTraceService.searchingByWebsiteValue({ emailDomain: "test.com" });
SalesforceRoutingTraceService.accountFoundByWebsite({
accountId: "123",
website: "test.com",
});
SalesforceRoutingTraceService.contactOwnerLookup({
contactId: "456",
ownerEmail: "test@test.com",
ownerId: "789",
});
SalesforceRoutingTraceService.graphqlQueryInitiated({
email: "test@test.com",
emailDomain: "test.com",
});
});
const calls = addStepSpy.mock.calls;
calls.forEach((call: [{ domain: string; step: string; data: Record<string, unknown> }]) => {
expect(call[0].domain).toBe("salesforce");
});
});
});
});
@@ -0,0 +1,179 @@
import { CrmRoutingTraceService } from "@calcom/features/routing-trace/services/CrmRoutingTraceService";
const DOMAIN = "salesforce";
/**
* Static class providing trace methods for Salesforce CRM routing operations.
* Uses AsyncLocalStorage to automatically get the current trace context.
* Methods are no-ops if called outside a trace context.
*/
export class SalesforceRoutingTraceService {
private static addStep(step: string, data: Record<string, unknown> = {}): void {
CrmRoutingTraceService.getCurrent()?.addStep(DOMAIN, step, data);
}
// ===== Account Resolution (SOQL path) =====
/**
* Record when searching for account by website field (matching email domain).
*/
static searchingByWebsiteValue(data: { emailDomain: string }): void {
SalesforceRoutingTraceService.addStep("searching_by_website_value", data);
}
/**
* Record when an account is found by website match.
*/
static accountFoundByWebsite(data: { accountId: string; website: string }): void {
SalesforceRoutingTraceService.addStep("account_found_by_website", data);
}
/**
* Record when searching for account via contacts with same email domain.
*/
static searchingByContactEmailDomain(data: { emailDomain: string; contactCount: number }): void {
SalesforceRoutingTraceService.addStep("searching_by_contact_email_domain", data);
}
/**
* Record when account is selected based on having most contacts.
*/
static accountSelectedByMostContacts(data: { accountId: string; contactCount: number }): void {
SalesforceRoutingTraceService.addStep("account_selected_by_most_contacts", data);
}
/**
* Record when no account could be found.
*/
static noAccountFound(data: { email: string; reason: string }): void {
SalesforceRoutingTraceService.addStep("no_account_found", data);
}
// ===== Lookup Field =====
/**
* Record lookup field query execution.
*/
static lookupFieldQuery(data: {
fieldName: string;
salesforceObject: string;
accountId: string | null;
}): void {
SalesforceRoutingTraceService.addStep("lookup_field_query", data);
}
/**
* Record user query from lookup field result.
*/
static userQueryFromLookupField(data: { lookupFieldUserId: string; userEmail: string | null }): void {
SalesforceRoutingTraceService.addStep("user_query_from_lookup_field", data);
}
// ===== Owner Lookups =====
/**
* Record contact owner lookup.
*/
static contactOwnerLookup(data: {
contactId: string;
ownerEmail: string | null;
ownerId: string | null;
}): void {
SalesforceRoutingTraceService.addStep("contact_owner_lookup", data);
}
/**
* Record lead owner lookup.
*/
static leadOwnerLookup(data: { leadId: string; ownerEmail: string | null; ownerId: string | null }): void {
SalesforceRoutingTraceService.addStep("lead_owner_lookup", data);
}
/**
* Record account owner lookup.
*/
static accountOwnerLookup(data: {
accountId: string;
ownerEmail: string | null;
ownerId: string | null;
}): void {
SalesforceRoutingTraceService.addStep("account_owner_lookup", data);
}
// ===== Validation =====
/**
* Record when owner is validated as a team member.
*/
static ownerValidated(data: { ownerEmail: string; isTeamMember: boolean }): void {
SalesforceRoutingTraceService.addStep("owner_validated", data);
}
// ===== Skip =====
/**
* Record when owner lookup is skipped (e.g., free email domain).
*/
static ownerLookupSkipped(data: { reason: string; email?: string }): void {
SalesforceRoutingTraceService.addStep("owner_lookup_skipped", data);
}
/**
* Record when contact owner check is skipped due to route config.
*/
static contactOwnerCheckSkipped(data: { reason: string }): void {
SalesforceRoutingTraceService.addStep("contact_owner_check_skipped", data);
}
// ===== GraphQL-specific (resolution in GetAccountRecordsForRRSkip) =====
/**
* Record when GraphQL query is initiated for account resolution.
*/
static graphqlQueryInitiated(data: { email: string; emailDomain: string }): void {
SalesforceRoutingTraceService.addStep("graphql_query_initiated", data);
}
/**
* Record when existing contact is found and its account is used.
*/
static graphqlExistingContactFound(data: {
contactId: string;
accountId: string;
ownerEmail: string;
}): void {
SalesforceRoutingTraceService.addStep("graphql_existing_contact_found", data);
}
/**
* Record when account found by website match.
*/
static graphqlAccountFoundByWebsite(data: { accountId: string; ownerEmail: string }): void {
SalesforceRoutingTraceService.addStep("graphql_account_found_by_website", data);
}
/**
* Record when searching related contacts by email domain.
*/
static graphqlSearchingByContactDomain(data: { emailDomain: string; contactCount: number }): void {
SalesforceRoutingTraceService.addStep("graphql_searching_by_contact_domain", data);
}
/**
* Record when dominant account is selected from related contacts.
*/
static graphqlDominantAccountSelected(data: {
accountId: string;
contactCount: number;
ownerEmail: string;
}): void {
SalesforceRoutingTraceService.addStep("graphql_dominant_account_selected", data);
}
/**
* Record when no account is found via GraphQL.
*/
static graphqlNoAccountFound(data: { email: string; reason: string }): void {
SalesforceRoutingTraceService.addStep("graphql_no_account_found", data);
}
}
@@ -0,0 +1 @@
export { SalesforceRoutingTraceService } from "./SalesforceRoutingTraceService";
+3 -1
View File
@@ -122,12 +122,14 @@ The embed system carefully manages visibility to prevent visual glitches:
- Indicates: Booker has been reopened after modal was closed
- Triggers: On subsequent linkReady events (viewId > 1) when modal is reopened without reload
- Note: Distinguishes between first view (bookerViewed) and reopen (bookerReopened). Uses viewId to determine if it's a reopen.
- Applicability: Only applicable for prerendered modals that are now visible.
13. **bookerReloaded Event**
- Fired by: Iframe
- Indicates: Booker has been reloaded (full page reload within modal)
- Triggers: On linkReady after fullReload action is taken (when reloadInitiated flag is set)
- Note: Distinguishes between first view (bookerViewed), reopen (bookerReopened), and reload (bookerReloaded). Fires only once per reload.
- Applicability: Only applicable for prerendered modals that are now visible.
14. **bookerReady Event**
- Fired by: Iframe
@@ -223,4 +225,4 @@ The embed system tracks user interactions and page views:
Page Load Errors:
- System monitors CalComPageStatus
- On non-200 status: fires linkFailed event
- Includes error code and URL information
- Includes error code and URL information
@@ -321,6 +321,13 @@ test.describe("Popup Tests", () => {
await expect(embedIframe).toBeEmbedCalLink(calNamespace, embeds.getActionFiredDetails, {
pathname: "/free/30min",
});
if (!embedIframe) {
throw new Error("Embed iframe not found");
}
// Verify that event type details are hidden (hideEventTypeDetails: true was set via ui() call)
await expect(embedIframe.locator('[data-testid="event-meta"]')).toBeHidden();
});
});
+41 -31
View File
@@ -1,33 +1,35 @@
/// <reference types="../env" />
import { FloatingButton } from "./FloatingButton/FloatingButton";
import { Inline } from "./Inline/inline";
import { ModalBox } from "./ModalBox/ModalBox";
import {
EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS,
EMBED_MODAL_IFRAME_SLOT_STALE_TIME,
EMBED_MODAL_PRERENDER_PREVENT_THRESHOLD_MS,
} from "./constants";
import type { InterfaceWithParent, interfaceWithParent } from "./embed-iframe";
import css from "./embed.css?inline";
import type { InterfaceWithParent, interfaceWithParent } from "./embed-iframe";
import { FloatingButton } from "./FloatingButton/FloatingButton";
import { Inline } from "./Inline/inline";
import { getScrollableAncestor } from "./lib/domUtils";
import { getScrollByDistanceHandler } from "./lib/eventHandlers/scrollByDistanceEventHandler";
import {
buildConfigWithPrerenderRelatedFields,
fromEntriesWithDuplicateKeys,
isRouterPath,
generateDataAttributes,
getConfigProp,
isRouterPath,
isSameBookingLink,
buildConfigWithPrerenderRelatedFields,
} from "./lib/utils";
import { SdkActionManager } from "./sdk-action-manager";
import { ModalBox } from "./ModalBox/ModalBox";
import type { EventData, EventDataMap } from "./sdk-action-manager";
import { SdkActionManager } from "./sdk-action-manager";
import tailwindCss from "./tailwindCss";
import type { UiConfig, EmbedPageType, PrefillAndIframeAttrsConfig, ModalPrerenderOptions } from "./types";
import type { EmbedPageType, ModalPrerenderOptions, PrefillAndIframeAttrsConfig, UiConfig } from "./types";
import { getMaxHeightForModal } from "./ui-utils";
// Exporting for consumption by @calcom/embed-core user
export type { EmbedEvent } from "./sdk-action-manager";
export type { PrefillAndIframeAttrsConfig } from "./types";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Rest<T extends any[] | undefined> = T extends [any, ...infer U] ? U : never;
export type Message = {
@@ -177,8 +179,8 @@ type SingleInstructionMap = {
off: ["off", allPossibleCallbacksAndActions];
} & {
[K in Exclude<keyof CalApi, "on" | "off">]: CalApi[K] extends (...args: never[]) => void
? [K, ...Parameters<CalApi[K]>]
: never;
? [K, ...Parameters<CalApi[K]>]
: never;
};
type SingleInstruction = SingleInstructionMap[keyof SingleInstructionMap];
@@ -231,6 +233,11 @@ export class Cal {
api: CalApi;
/**
* Commands for which the queue persists across iframe resets
*/
commandsPersistAcrossIframeResets: DoInIframeArg['method'][] = ["ui"];
isPrerendering?: boolean;
static actionsManagers: Record<Namespace, SdkActionManager>;
@@ -361,14 +368,13 @@ export class Cal {
}
// Merge searchParams from config onto the URL which might have query params already
// @ts-ignore TS2802: URLSearchParams iteration requires downlevelIteration
for (const [key, value] of searchParams) {
searchParams.forEach((value, key) => {
urlInstance.searchParams.append(key, value);
}
});
// Very Important:Reset iframe ready flag and clear queue, as iframe might load a fresh URL and we need to check when it is ready.
// Very Important: Reset iframe ready flag and clear queue, as iframe might load a fresh URL and we need to check correctly when it is ready.
this.iframeReset();
if (iframe.src === urlInstance.toString()) {
// Ensure reload occurs even if the url is same - Though browser normally does it, but would be better to ensure it
// This param has no other purpose except to ensure forced reload.
@@ -408,7 +414,10 @@ export class Cal {
iframeReset() {
this.iframeReady = false;
this.iframeDoQueue = [];
// Only keep UI related instructions in the queue, as we want to ensure that newly loaded iframe has the same UI configuration applied automatically
this.iframeDoQueue = this.iframeDoQueue.filter((doInIframeArg) =>
this.commandsPersistAcrossIframeResets.includes(doInIframeArg.method)
);
}
constructor(namespace: string, q: Queue) {
@@ -604,7 +613,7 @@ export class Cal {
: 0;
const crossedReloadThreshold = previousEmbedRenderStartTime
? timeSinceLastRender >
(prerenderOptions?.iframeForceReloadThresholdMs ?? EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS)
(prerenderOptions?.iframeForceReloadThresholdMs ?? EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS)
: false;
const areSlotsStale = previousEmbedRenderStartTime
@@ -862,7 +871,7 @@ class CalApi {
CalApi.initializedNamespaces.push(this.cal.namespace);
const { calOrigin: calOrigin, origin: origin, ...restConfig } = config;
const { calOrigin, origin, ...restConfig } = config;
this.cal.__config.calOrigin = calOrigin || origin || this.cal.__config.calOrigin;
@@ -950,10 +959,10 @@ class CalApi {
template.innerHTML = `<cal-inline
${generateDataAttributes({
pageType,
theme,
layout,
})}
pageType,
theme,
layout,
})}
style="max-height:inherit;height:inherit;min-height:inherit;display:flex;position:relative;flex-wrap:wrap;width:100%">
</cal-inline>
<style>.cal-inline-container::-webkit-scrollbar{display:none}.cal-inline-container{scrollbar-width:none}</style>`;
@@ -1187,7 +1196,7 @@ class CalApi {
config: enrichedConfig,
});
// Send reloadInitiated message to iframe so it can track it
// Send it after loadInIframe so that new iframe can process it.
// Send it after loadInIframe so that new iframe can process it.
this.cal.doInIframe({ method: "__reloadInitiated", arg: {} });
} else if (actionToTake === "connect" || actionToTake === "connect-no-slots-fetch") {
const paramsToAdd = fromEntriesWithDuplicateKeys(calLinkUrlObject.searchParams.entries());
@@ -1196,8 +1205,8 @@ class CalApi {
...enrichedConfig,
...(actionToTake === "connect-no-slots-fetch"
? {
"cal.embed.noSlotsFetchOnConnect": "true",
}
"cal.embed.noSlotsFetchOnConnect": "true",
}
: {}),
},
params: paramsToAdd,
@@ -1239,10 +1248,10 @@ class CalApi {
template.innerHTML = `<cal-modal-box
${generateDataAttributes({
pageType,
theme,
layout,
})}
pageType,
theme,
layout,
})}
uid="${uid}">
</cal-modal-box>`;
this.cal.modalBox = template.content.children[0];
@@ -1303,9 +1312,9 @@ class CalApi {
/**
* Closes modal-based embeds programmatically.
*
*
* @throws {Error} If called on an inline embed (only works for modal-based embeds)
*
*
* @example
* ```javascript
* // Close the modal after a successful booking
@@ -1487,6 +1496,7 @@ class CalApi {
},
});
this.cal.doInIframe({ method: "ui", arg: uiConfig });
}
}
+3 -3
View File
@@ -51,6 +51,7 @@ The booking audit system uses two complementary fields:
- **API_V1**: API v1 endpoint
- **API_V2**: API v2 endpoint
- **WEBHOOK**: External webhook (e.g., Stripe)
- **SYSTEM**: Background job (e.g., Tasker's task, trigger.dev job for automatic no-show detection)
- **UNKNOWN**: Source cannot be determined
### Actor: The Entity
@@ -75,8 +76,7 @@ The system tracks various booking actions including:
- **ATTENDEE_REMOVED**: Attendee removed
- **REASSIGNMENT**: Booking reassigned to different host
- **LOCATION_CHANGED**: Meeting location updated
- **HOST_NO_SHOW_UPDATED**: Host no-show status changed
- **ATTENDEE_NO_SHOW_UPDATED**: Attendee no-show status changed
- **NO_SHOW_UPDATED**: Host or attendee no-show status changed
- **SEAT_BOOKED**: Seat reserved in group booking
- **SEAT_RESCHEDULED**: Seat rescheduled in group booking
@@ -204,7 +204,7 @@ The audit system works with third-party queue providers without exposing PII:
- `operationId`: Required string for correlating related audit logs
- `data`: Action-specific data
- `timestamp`: Number (milliseconds since epoch)
- `source`: Action source (API_V1, API_V2, WEBAPP, WEBHOOK, UNKNOWN)
- `source`: Action source (API_V1, API_V2, WEBAPP, WEBHOOK, SYSTEM, UNKNOWN)
**BookingAuditTaskConsumer** processes audit records:
- Validates queue payload structure
@@ -2,6 +2,7 @@ import { BookingAuditTaskConsumer } from "@calcom/features/booking-audit/lib/ser
import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/tokens";
import { moduleLoader as bookingAuditRepositoryModuleLoader } from "@calcom/features/booking-audit/di/BookingAuditRepository.module";
import { moduleLoader as auditActorRepositoryModuleLoader } from "@calcom/features/booking-audit/di/AuditActorRepository.module";
import { moduleLoader as attendeeRepositoryModuleLoader } from "@calcom/features/bookings/di/Attendee.module";
import { moduleLoader as featuresRepositoryModuleLoader } from "@calcom/features/di/modules/FeaturesRepository";
import { moduleLoader as userRepositoryModuleLoader } from "@calcom/features/di/modules/User";
@@ -20,6 +21,7 @@ const loadModule = bindModuleToClassOnToken({
bookingAuditRepository: bookingAuditRepositoryModuleLoader,
auditActorRepository: auditActorRepositoryModuleLoader,
featuresRepository: featuresRepositoryModuleLoader,
attendeeRepository: attendeeRepositoryModuleLoader,
userRepository: userRepositoryModuleLoader,
},
});
@@ -1,81 +0,0 @@
import { z } from "zod";
import { BooleanChangeSchema } from "../common/changeSchemas";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService";
/**
* Attendee No-Show Updated Audit Action Service
* Handles ATTENDEE_NO_SHOW_UPDATED action with per-action versioning
*/
// Module-level because it is passed to IAuditActionService type outside the class scope
const fieldsSchemaV1 = z.object({
noShowAttendee: BooleanChangeSchema,
});
export class AttendeeNoShowUpdatedAuditActionService implements IAuditActionService {
readonly VERSION = 1;
public static readonly TYPE = "ATTENDEE_NO_SHOW_UPDATED" as const;
private static dataSchemaV1 = z.object({
version: z.literal(1),
fields: fieldsSchemaV1,
});
private static fieldsSchemaV1 = fieldsSchemaV1;
public static readonly latestFieldsSchema = fieldsSchemaV1;
// Union of all versions
public static readonly storedDataSchema = AttendeeNoShowUpdatedAuditActionService.dataSchemaV1;
// Union of all versions
public static readonly storedFieldsSchema = AttendeeNoShowUpdatedAuditActionService.fieldsSchemaV1;
private helper: AuditActionServiceHelper<
typeof AttendeeNoShowUpdatedAuditActionService.latestFieldsSchema,
typeof AttendeeNoShowUpdatedAuditActionService.storedDataSchema
>;
constructor() {
this.helper = new AuditActionServiceHelper({
latestVersion: this.VERSION,
latestFieldsSchema: AttendeeNoShowUpdatedAuditActionService.latestFieldsSchema,
storedDataSchema: AttendeeNoShowUpdatedAuditActionService.storedDataSchema,
});
}
getVersionedData(fields: unknown) {
return this.helper.getVersionedData(fields);
}
parseStored(data: unknown) {
return this.helper.parseStored(data);
}
getVersion(data: unknown): number {
return this.helper.getVersion(data);
}
migrateToLatest(data: unknown) {
// V1-only: validate and return as-is (no migration needed)
const validated = fieldsSchemaV1.parse(data);
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(_: GetDisplayTitleParams): Promise<TranslationWithParams> {
return { key: "booking_audit_action.attendee_no_show_updated" };
}
getDisplayJson({
storedData,
}: GetDisplayJsonParams): AttendeeNoShowUpdatedAuditDisplayData {
const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields });
return {
noShowAttendee: fields.noShowAttendee.new,
previousNoShowAttendee: fields.noShowAttendee.old ?? null,
};
}
}
export type AttendeeNoShowUpdatedAuditData = z.infer<typeof fieldsSchemaV1>;
export type AttendeeNoShowUpdatedAuditDisplayData = {
noShowAttendee: boolean;
previousNoShowAttendee: boolean | null;
};
@@ -1,81 +0,0 @@
import { z } from "zod";
import { BooleanChangeSchema } from "../common/changeSchemas";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService";
/**
* Host No-Show Updated Audit Action Service
* Handles HOST_NO_SHOW_UPDATED action with per-action versioning
*/
// Module-level because it is passed to IAuditActionService type outside the class scope
const fieldsSchemaV1 = z.object({
noShowHost: BooleanChangeSchema,
});
export class HostNoShowUpdatedAuditActionService implements IAuditActionService {
readonly VERSION = 1;
public static readonly TYPE = "HOST_NO_SHOW_UPDATED" as const;
private static dataSchemaV1 = z.object({
version: z.literal(1),
fields: fieldsSchemaV1,
});
private static fieldsSchemaV1 = fieldsSchemaV1;
public static readonly latestFieldsSchema = fieldsSchemaV1;
// Union of all versions
public static readonly storedDataSchema = HostNoShowUpdatedAuditActionService.dataSchemaV1;
// Union of all versions
public static readonly storedFieldsSchema = HostNoShowUpdatedAuditActionService.fieldsSchemaV1;
private helper: AuditActionServiceHelper<
typeof HostNoShowUpdatedAuditActionService.latestFieldsSchema,
typeof HostNoShowUpdatedAuditActionService.storedDataSchema
>;
constructor() {
this.helper = new AuditActionServiceHelper({
latestVersion: this.VERSION,
latestFieldsSchema: HostNoShowUpdatedAuditActionService.latestFieldsSchema,
storedDataSchema: HostNoShowUpdatedAuditActionService.storedDataSchema,
});
}
getVersionedData(fields: unknown) {
return this.helper.getVersionedData(fields);
}
parseStored(data: unknown) {
return this.helper.parseStored(data);
}
getVersion(data: unknown): number {
return this.helper.getVersion(data);
}
migrateToLatest(data: unknown) {
// V1-only: validate and return as-is (no migration needed)
const validated = fieldsSchemaV1.parse(data);
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(_: GetDisplayTitleParams): Promise<TranslationWithParams> {
return { key: "booking_audit_action.host_no_show_updated" };
}
getDisplayJson({
storedData,
}: GetDisplayJsonParams): HostNoShowUpdatedAuditDisplayData {
const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields });
return {
noShowHost: fields.noShowHost.new,
previousNoShowHost: fields.noShowHost.old ?? null,
};
}
}
export type HostNoShowUpdatedAuditData = z.infer<typeof fieldsSchemaV1>;
export type HostNoShowUpdatedAuditDisplayData = {
noShowHost: boolean;
previousNoShowHost: boolean | null;
};
@@ -3,8 +3,8 @@
* Used with react-i18next Trans component for proper i18n support (RTL, word order, etc.)
*/
export type TranslationComponent = {
type: "link";
href: string;
type: "link";
href: string;
};
/**
@@ -13,117 +13,126 @@ export type TranslationComponent = {
* Components are used for clickable links within translations (e.g., "Rescheduled to <1>New Booking</1>")
*/
export type TranslationWithParams = {
key: string;
params?: Record<string, string | number>;
components?: TranslationComponent[];
key: string;
params?: Record<string, string | number>;
components?: TranslationComponent[];
};
/**
* This is agnostic of the action and is common for all actions
*/
export type BaseStoredAuditData = {
version: number;
fields: Record<string, unknown>;
version: number;
fields: Record<string, unknown>;
};
export type GetDisplayJsonParams = {
storedData: BaseStoredAuditData;
userTimeZone: string;
storedData: BaseStoredAuditData;
userTimeZone: string;
};
export type GetDisplayTitleParams = {
storedData: BaseStoredAuditData;
userTimeZone: string;
storedData: BaseStoredAuditData;
userTimeZone: string;
};
export type GetDisplayFieldsParams = {
storedData: BaseStoredAuditData;
};
/**
* Interface for Audit Action Services
*
*
* Defines the contract that all audit action services must implement.
* Uses composition with AuditActionServiceHelper to provide common functionality
* while maintaining runtime type safety through Zod schema validation.
*
*
* All methods use common base types at the interface level, while implementations
* validate to their specific schemas internally.
*/
export interface IAuditActionService {
/**
* Current version number for this action type
*/
readonly VERSION: number;
/**
* Current version number for this action type
*/
readonly VERSION: number;
/**
* Parse given fields against latest schema and wrap with version
* @param fields - Raw input fields (just the audit fields)
* @returns Parsed data with version wrapper { version, fields }
*/
getVersionedData(fields: unknown): BaseStoredAuditData;
/**
* Parse given fields against latest schema and wrap with version
* @param fields - Raw input fields (just the audit fields)
* @returns Parsed data with version wrapper { version, fields }
*/
getVersionedData(fields: unknown): BaseStoredAuditData;
/**
* Parse stored audit record (includes version wrapper)
* Accepts data from ANY supported version (not just latest) for backward compatibility
* @param data - Stored data from database (can be any version)
* @returns Parsed stored data { version, fields } - version may differ from current VERSION
*/
parseStored(data: unknown): BaseStoredAuditData;
/**
* Parse stored audit record (includes version wrapper)
* Accepts data from ANY supported version (not just latest) for backward compatibility
* @param data - Stored data from database (can be any version)
* @returns Parsed stored data { version, fields } - version may differ from current VERSION
*/
parseStored(data: unknown): BaseStoredAuditData;
/**
* Extract version number from stored data
* @param data - Stored data from database
* @returns Version number
*/
getVersion(data: unknown): number;
/**
* Extract version number from stored data
* @param data - Stored data from database
* @returns Version number
*/
getVersion(data: unknown): number;
/**
* Get flattened JSON data for display (fields only, no version wrapper)
* Optional - implement only if custom display formatting is needed
* @param params - Object containing storedData and userTimeZone
* @param params.storedData - Parsed stored data { version, fields }
* @param params.userTimeZone - User's timezone for datetime formatting (required)
* @returns The fields object without version wrapper and we decide what fields to show to the client
*/
getDisplayJson?(params: GetDisplayJsonParams): Record<string, unknown>;
/**
* Get flattened JSON data for display (fields only, no version wrapper)
* Optional - implement only if custom display formatting is needed
* @param params - Object containing storedData and userTimeZone
* @param params.storedData - Parsed stored data { version, fields }
* @param params.userTimeZone - User's timezone for datetime formatting (required)
* @returns The fields object without version wrapper and we decide what fields to show to the client
*/
getDisplayJson?(params: GetDisplayJsonParams): Record<string, unknown>;
/**
* Get the display title for the audit action
* Returns a translation key with optional interpolation params for dynamic titles
* (e.g., "Booking reassigned to John Doe" instead of just "Reassignment")
* @param params - Object containing storedData and userTimeZone
* @param params.storedData - Parsed stored data { version, fields }
* @param params.userTimeZone - User's timezone for date formatting (required)
* @returns Translation key with optional interpolation params
*/
getDisplayTitle(params: GetDisplayTitleParams): Promise<TranslationWithParams>;
/**
* Get the display title for the audit action
* Returns a translation key with optional interpolation params for dynamic titles
* (e.g., "Booking reassigned to John Doe" instead of just "Reassignment")
* @param params - Object containing storedData and userTimeZone
* @param params.storedData - Parsed stored data { version, fields }
* @param params.userTimeZone - User's timezone for date formatting (required)
* @returns Translation key with optional interpolation params
*/
getDisplayTitle(params: GetDisplayTitleParams): Promise<TranslationWithParams>;
/**
* Returns additional display fields with translation keys for frontend rendering
* Optional - implement only if custom display fields are needed
* @param storedData - Parsed stored data { version, fields }
* @returns Promise of array of field objects with label and value translation keys
*/
getDisplayFields?(storedData: BaseStoredAuditData): Promise<Array<{
labelKey: string; // Translation key for field label
valueKey: string; // Translation key for field value
}>>;
/**
* Returns additional display fields with translation keys for frontend rendering
* Optional - implement only if custom display fields are needed
* @param params - Object containing storedData
* @param params.storedData - Parsed stored data { version, fields }
* @returns Promise of array of field objects with label and value (either translation key or raw value)
*/
getDisplayFields?(params: GetDisplayFieldsParams): Promise<
Array<{
labelKey: string; // Translation key for field label
valueKey?: string; // Translation key for field value (will be translated)
value?: string; // Raw value for field value (will NOT be translated)
values?: string[]; // Array of raw values (will NOT be translated, rendered as separate lines)
}>
>;
/**
* Migrate old version data to latest version
*
* Required method that validates and migrates data to latest schema version.
* For V1-only actions, simply validates and returns with isMigrated=false.
* For multi-version actions, checks version and transforms if needed.
*
* @param data - Data from task payload (any supported version)
* @returns Migration result with status and latest data
*/
migrateToLatest(data: unknown): {
/**
* Migrate old version data to latest version
*
* Required method that validates and migrates data to latest schema version.
* For V1-only actions, simply validates and returns with isMigrated=false.
* For multi-version actions, checks version and transforms if needed.
*
* @param data - Data from task payload (any supported version)
* @returns Migration result with status and latest data
* True if migration was performed, false if already latest
*/
migrateToLatest(data: unknown): {
/**
* True if migration was performed, false if already latest
*/
isMigrated: boolean;
/**
* Always set, either migrated or original
*/
latestData: Record<string, unknown>;
};
isMigrated: boolean;
/**
* Always set, either migrated or original
*/
latestData: Record<string, unknown>;
};
}
@@ -0,0 +1,163 @@
import type { IAttendeeRepository } from "@calcom/features/bookings/repositories/IAttendeeRepository";
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import type { Ensure } from "@calcom/types/utils";
import { z } from "zod";
import { BooleanChangeSchema } from "../common/changeSchemas";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type {
GetDisplayFieldsParams,
GetDisplayJsonParams,
GetDisplayTitleParams,
IAuditActionService,
TranslationWithParams,
} from "./IAuditActionService";
const AttendeeNoShowSchema = z.object({
attendeeEmail: z.string(),
noShow: BooleanChangeSchema,
});
const fieldsSchemaV1 = z
.object({
host: z
.object({
userUuid: z.string(),
noShow: BooleanChangeSchema,
})
.optional(),
attendeesNoShow: z.array(AttendeeNoShowSchema).optional(),
})
.refine((data) => data.host !== undefined || data.attendeesNoShow !== undefined, {
message: "At least one of host or attendeesNoShow must be provided",
});
type Deps = {
attendeeRepository: IAttendeeRepository;
userRepository: UserRepository;
};
export class NoShowUpdatedAuditActionService implements IAuditActionService {
readonly VERSION = 1;
public static readonly TYPE = "NO_SHOW_UPDATED" as const;
private static dataSchemaV1 = z.object({
version: z.literal(1),
fields: fieldsSchemaV1,
});
private static fieldsSchemaV1 = fieldsSchemaV1;
public static readonly latestFieldsSchema = fieldsSchemaV1;
// Union of all versions
public static readonly storedDataSchema = NoShowUpdatedAuditActionService.dataSchemaV1;
// Union of all versions
public static readonly storedFieldsSchema = NoShowUpdatedAuditActionService.fieldsSchemaV1;
private helper: AuditActionServiceHelper<
typeof NoShowUpdatedAuditActionService.latestFieldsSchema,
typeof NoShowUpdatedAuditActionService.storedDataSchema
>;
constructor(private readonly deps: Deps) {
this.helper = new AuditActionServiceHelper({
latestVersion: this.VERSION,
latestFieldsSchema: NoShowUpdatedAuditActionService.latestFieldsSchema,
storedDataSchema: NoShowUpdatedAuditActionService.storedDataSchema,
});
}
getVersionedData(fields: unknown) {
return this.helper.getVersionedData(fields);
}
parseStored(data: unknown) {
return this.helper.parseStored(data);
}
getVersion(data: unknown): number {
return this.helper.getVersion(data);
}
migrateToLatest(data: unknown) {
// V1-only: validate and return as-is (no migration needed)
const validated = fieldsSchemaV1.parse(data);
return { isMigrated: false, latestData: validated };
}
private isHostSet(fields: NoShowUpdatedAuditData): fields is Ensure<NoShowUpdatedAuditData, "host"> {
return fields.host !== undefined;
}
private isAttendeesNoShowSet(
fields: NoShowUpdatedAuditData
): fields is Ensure<NoShowUpdatedAuditData, "attendeesNoShow"> {
return fields.attendeesNoShow !== undefined;
}
async getDisplayTitle({ storedData }: GetDisplayTitleParams): Promise<TranslationWithParams> {
const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields });
const parsedFields = fields as NoShowUpdatedAuditData;
if (this.isHostSet(parsedFields) && this.isAttendeesNoShowSet(parsedFields)) {
return { key: "booking_audit_action.no_show_updated" };
}
if (this.isHostSet(parsedFields)) {
return { key: "booking_audit_action.host_no_show_updated" };
}
if (this.isAttendeesNoShowSet(parsedFields)) {
return { key: "booking_audit_action.attendee_no_show_updated" };
}
throw new Error("Audit action data is invalid");
}
async getDisplayFields({ storedData }: GetDisplayFieldsParams): Promise<
Array<{
labelKey: string;
valueKey?: string;
value?: string;
values?: string[];
}>
> {
const { fields } = this.parseStored(storedData);
const parsedFields = fields as NoShowUpdatedAuditData;
const displayFields: { labelKey: string; valueKey?: string; value?: string; values?: string[] }[] = [];
if (this.isAttendeesNoShowSet(parsedFields)) {
const attendeesFieldValues = parsedFields.attendeesNoShow.map((attendee) => {
const noShowStatus = attendee.noShow.new ? "Yes" : "No";
return `${attendee.attendeeEmail}: ${noShowStatus}`;
});
displayFields.push({ labelKey: "Attendees", values: attendeesFieldValues });
}
if (this.isHostSet(parsedFields)) {
const user = await this.deps.userRepository.findByUuid({ uuid: parsedFields.host.userUuid });
const hostName = user?.name || "Unknown";
const hostFieldValue = `${hostName}:${parsedFields.host.noShow.new ? "Yes" : "No"}`;
displayFields.push({ labelKey: "Host", value: hostFieldValue });
}
return displayFields;
}
getDisplayJson({ storedData }: GetDisplayJsonParams): NoShowUpdatedAuditDisplayData {
const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields });
const parsedFields = fields as NoShowUpdatedAuditData;
return {
...(this.isHostSet(parsedFields)
? {
hostNoShow: parsedFields.host.noShow.new,
previousHostNoShow: parsedFields.host.noShow.old,
}
: {}),
...(this.isAttendeesNoShowSet(parsedFields)
? {
attendeesNoShow: parsedFields.attendeesNoShow,
}
: {}),
};
}
}
export type NoShowUpdatedAuditData = z.infer<typeof fieldsSchemaV1>;
export type NoShowUpdatedAuditDisplayData = {
hostNoShow?: boolean;
previousHostNoShow?: boolean | null;
attendeesNoShow?: Array<{ attendeeEmail: string; noShow: { old: boolean | null; new: boolean } }> | null;
};
@@ -3,7 +3,7 @@ import { z } from "zod";
import { StringChangeSchema } from "../common/changeSchemas";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type {
BaseStoredAuditData,
GetDisplayFieldsParams,
GetDisplayJsonParams,
GetDisplayTitleParams,
IAuditActionService,
@@ -123,7 +123,7 @@ export class ReassignmentAuditActionService implements IAuditActionService {
};
}
async getDisplayFields(storedData: BaseStoredAuditData): Promise<
async getDisplayFields({ storedData }: GetDisplayFieldsParams): Promise<
Array<{
labelKey: string;
valueKey: string;
@@ -279,7 +279,7 @@ describe("ReassignmentAuditActionService", () => {
mockUserRepository.findByUuid.mockResolvedValue({ uuid: "organizer-old", name: "Previous Host" });
const result = await service.getDisplayFields(storedData);
const result = await service.getDisplayFields({ storedData });
expect(result).toEqual([
{
@@ -305,7 +305,7 @@ describe("ReassignmentAuditActionService", () => {
mockUserRepository.findByUuid.mockResolvedValue({ uuid: "organizer-old", name: "Previous Host" });
const result = await service.getDisplayFields(storedData);
const result = await service.getDisplayFields({ storedData });
expect(result).toEqual([
{
@@ -12,7 +12,7 @@ export type BookingAuditType = "RECORD_CREATED" | "RECORD_UPDATED" | "RECORD_DEL
* They are reserved in the enum for potential future use but should not appear in audit logs.
* Use the CREATED action to capture initial booking status instead.
*/
export type BookingAuditAction = "CREATED" | "CANCELLED" | "ACCEPTED" | "REJECTED" | "PENDING" | "AWAITING_HOST" | "RESCHEDULED" | "ATTENDEE_ADDED" | "ATTENDEE_REMOVED" | "REASSIGNMENT" | "LOCATION_CHANGED" | "HOST_NO_SHOW_UPDATED" | "ATTENDEE_NO_SHOW_UPDATED" | "RESCHEDULE_REQUESTED" | "SEAT_BOOKED" | "SEAT_RESCHEDULED"
export type BookingAuditAction = "CREATED" | "CANCELLED" | "ACCEPTED" | "REJECTED" | "PENDING" | "AWAITING_HOST" | "RESCHEDULED" | "ATTENDEE_ADDED" | "ATTENDEE_REMOVED" | "REASSIGNMENT" | "LOCATION_CHANGED" | "NO_SHOW_UPDATED" | "RESCHEDULE_REQUESTED" | "SEAT_BOOKED" | "SEAT_RESCHEDULED"
export type BookingAuditCreateInput = {
bookingUid: string;
actorId: string;
@@ -1,3 +1,4 @@
import type { IAttendeeRepository } from "@calcom/features/bookings/repositories/IAttendeeRepository";
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import type { IAuditActionService } from "../actions/IAuditActionService";
import type { BookingAuditAction } from "../repository/IBookingAuditRepository";
@@ -8,12 +9,11 @@ import { RescheduledAuditActionService, type RescheduledAuditData } from "../act
import { AcceptedAuditActionService, type AcceptedAuditData } from "../actions/AcceptedAuditActionService";
import { RescheduleRequestedAuditActionService, type RescheduleRequestedAuditData } from "../actions/RescheduleRequestedAuditActionService";
import { AttendeeAddedAuditActionService, type AttendeeAddedAuditData } from "../actions/AttendeeAddedAuditActionService";
import { HostNoShowUpdatedAuditActionService, type HostNoShowUpdatedAuditData } from "../actions/HostNoShowUpdatedAuditActionService";
import { NoShowUpdatedAuditActionService, type NoShowUpdatedAuditData } from "../actions/NoShowUpdatedAuditActionService";
import { RejectedAuditActionService, type RejectedAuditData } from "../actions/RejectedAuditActionService";
import { AttendeeRemovedAuditActionService, type AttendeeRemovedAuditData } from "../actions/AttendeeRemovedAuditActionService";
import { ReassignmentAuditActionService, type ReassignmentAuditData } from "../actions/ReassignmentAuditActionService";
import { LocationChangedAuditActionService, type LocationChangedAuditData } from "../actions/LocationChangedAuditActionService";
import { AttendeeNoShowUpdatedAuditActionService, type AttendeeNoShowUpdatedAuditData } from "../actions/AttendeeNoShowUpdatedAuditActionService";
import { SeatBookedAuditActionService, type SeatBookedAuditData } from "../actions/SeatBookedAuditActionService";
import { SeatRescheduledAuditActionService, type SeatRescheduledAuditData } from "../actions/SeatRescheduledAuditActionService";
@@ -28,12 +28,11 @@ export type AuditActionData =
| AcceptedAuditData
| RescheduleRequestedAuditData
| AttendeeAddedAuditData
| HostNoShowUpdatedAuditData
| NoShowUpdatedAuditData
| RejectedAuditData
| AttendeeRemovedAuditData
| ReassignmentAuditData
| LocationChangedAuditData
| AttendeeNoShowUpdatedAuditData
| SeatBookedAuditData
| SeatRescheduledAuditData;
@@ -45,6 +44,7 @@ export type AuditActionData =
* code duplication between consumer and viewer services.
*/
interface BookingAuditActionServiceRegistryDeps {
attendeeRepository: IAttendeeRepository;
userRepository: UserRepository;
}
@@ -59,12 +59,11 @@ export class BookingAuditActionServiceRegistry {
["ACCEPTED", new AcceptedAuditActionService()],
["RESCHEDULE_REQUESTED", new RescheduleRequestedAuditActionService()],
["ATTENDEE_ADDED", new AttendeeAddedAuditActionService()],
["HOST_NO_SHOW_UPDATED", new HostNoShowUpdatedAuditActionService()],
["NO_SHOW_UPDATED", new NoShowUpdatedAuditActionService({ attendeeRepository: deps.attendeeRepository, userRepository: deps.userRepository })],
["REJECTED", new RejectedAuditActionService()],
["ATTENDEE_REMOVED", new AttendeeRemovedAuditActionService()],
["REASSIGNMENT", new ReassignmentAuditActionService(deps.userRepository)],
["LOCATION_CHANGED", new LocationChangedAuditActionService()],
["ATTENDEE_NO_SHOW_UPDATED", new AttendeeNoShowUpdatedAuditActionService()],
["SEAT_BOOKED", new SeatBookedAuditActionService()],
["SEAT_RESCHEDULED", new SeatRescheduledAuditActionService()],
];
@@ -4,12 +4,11 @@ import type { Actor, BookingAuditContext } from "../dto/types";
import type { ActionSource } from "../types/actionSource";
import { AcceptedAuditActionService } from "../actions/AcceptedAuditActionService";
import { AttendeeAddedAuditActionService } from "../actions/AttendeeAddedAuditActionService";
import { AttendeeNoShowUpdatedAuditActionService } from "../actions/AttendeeNoShowUpdatedAuditActionService";
import { AttendeeRemovedAuditActionService } from "../actions/AttendeeRemovedAuditActionService";
import { CancelledAuditActionService } from "../actions/CancelledAuditActionService";
import { CreatedAuditActionService } from "../actions/CreatedAuditActionService";
import { HostNoShowUpdatedAuditActionService } from "../actions/HostNoShowUpdatedAuditActionService";
import { LocationChangedAuditActionService } from "../actions/LocationChangedAuditActionService";
import { NoShowUpdatedAuditActionService } from "../actions/NoShowUpdatedAuditActionService";
import { ReassignmentAuditActionService } from "../actions/ReassignmentAuditActionService";
import { RejectedAuditActionService } from "../actions/RejectedAuditActionService";
import { RescheduleRequestedAuditActionService } from "../actions/RescheduleRequestedAuditActionService";
@@ -91,13 +90,13 @@ export interface BookingAuditProducerService {
context?: BookingAuditContext;
}): Promise<void>;
queueHostNoShowUpdatedAudit(params: {
queueNoShowUpdatedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof HostNoShowUpdatedAuditActionService.latestFieldsSchema>;
data: z.infer<typeof NoShowUpdatedAuditActionService.latestFieldsSchema>;
context?: BookingAuditContext;
}): Promise<void>;
@@ -141,16 +140,6 @@ export interface BookingAuditProducerService {
context?: BookingAuditContext;
}): Promise<void>;
queueAttendeeNoShowUpdatedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof AttendeeNoShowUpdatedAuditActionService.latestFieldsSchema>;
context?: BookingAuditContext;
}): Promise<void>;
queueSeatBookedAudit(params: {
bookingUid: string;
actor: Actor;
@@ -1,6 +1,7 @@
import type { JsonValue } from "@calcom/types/Json";
import logger from "@calcom/lib/logger";
import type { IAttendeeRepository } from "@calcom/features/bookings/repositories/IAttendeeRepository";
import type { IFeaturesRepository } from "@calcom/features/flags/features.repository.interface";
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
@@ -21,6 +22,7 @@ interface BookingAuditTaskConsumerDeps {
bookingAuditRepository: IBookingAuditRepository;
auditActorRepository: IAuditActorRepository;
featuresRepository: IFeaturesRepository;
attendeeRepository: IAttendeeRepository;
userRepository: UserRepository;
}
@@ -69,16 +71,21 @@ export class BookingAuditTaskConsumer {
private readonly bookingAuditRepository: IBookingAuditRepository;
private readonly auditActorRepository: IAuditActorRepository;
private readonly featuresRepository: IFeaturesRepository;
private readonly attendeeRepository: IAttendeeRepository;
private readonly userRepository: UserRepository;
constructor(private readonly deps: BookingAuditTaskConsumerDeps) {
this.bookingAuditRepository = deps.bookingAuditRepository;
this.auditActorRepository = deps.auditActorRepository;
this.featuresRepository = deps.featuresRepository;
this.attendeeRepository = deps.attendeeRepository;
this.userRepository = deps.userRepository;
// Centralized registry for all action services
this.actionServiceRegistry = new BookingAuditActionServiceRegistry({ userRepository: this.userRepository });
this.actionServiceRegistry = new BookingAuditActionServiceRegistry({
attendeeRepository: this.attendeeRepository,
userRepository: this.userRepository,
});
}
/**
@@ -366,8 +373,7 @@ export class BookingAuditTaskConsumer {
case "ATTENDEE_REMOVED":
case "REASSIGNMENT":
case "LOCATION_CHANGED":
case "HOST_NO_SHOW_UPDATED":
case "ATTENDEE_NO_SHOW_UPDATED":
case "NO_SHOW_UPDATED":
case "SEAT_BOOKED":
case "SEAT_RESCHEDULED":
return "RECORD_UPDATED";
@@ -12,12 +12,11 @@ import { makeActorById, buildActorEmail } from "../makeActor";
import type { IAuditActorRepository } from "../repository/IAuditActorRepository";
import { AcceptedAuditActionService } from "../actions/AcceptedAuditActionService";
import { AttendeeAddedAuditActionService } from "../actions/AttendeeAddedAuditActionService";
import { AttendeeNoShowUpdatedAuditActionService } from "../actions/AttendeeNoShowUpdatedAuditActionService";
import { AttendeeRemovedAuditActionService } from "../actions/AttendeeRemovedAuditActionService";
import { CancelledAuditActionService } from "../actions/CancelledAuditActionService";
import { CreatedAuditActionService } from "../actions/CreatedAuditActionService";
import { HostNoShowUpdatedAuditActionService } from "../actions/HostNoShowUpdatedAuditActionService";
import { LocationChangedAuditActionService } from "../actions/LocationChangedAuditActionService";
import { NoShowUpdatedAuditActionService } from "../actions/NoShowUpdatedAuditActionService";
import { ReassignmentAuditActionService } from "../actions/ReassignmentAuditActionService";
import { RejectedAuditActionService } from "../actions/RejectedAuditActionService";
import { RescheduleRequestedAuditActionService } from "../actions/RescheduleRequestedAuditActionService";
@@ -216,17 +215,18 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe
});
}
async queueHostNoShowUpdatedAudit(params: {
async queueNoShowUpdatedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof HostNoShowUpdatedAuditActionService.latestFieldsSchema>;
data: z.infer<typeof NoShowUpdatedAuditActionService.latestFieldsSchema>;
context?: BookingAuditContext;
}): Promise<void> {
await this.queueTask({
...params,
action: HostNoShowUpdatedAuditActionService.TYPE,
action: NoShowUpdatedAuditActionService.TYPE,
});
}
@@ -286,20 +286,6 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe
});
}
async queueAttendeeNoShowUpdatedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof AttendeeNoShowUpdatedAuditActionService.latestFieldsSchema>;
}): Promise<void> {
await this.queueTask({
...params,
action: AttendeeNoShowUpdatedAuditActionService.TYPE,
});
}
async queueSeatBookedAudit(params: {
bookingUid: string;
actor: Actor;
@@ -1,58 +1,63 @@
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import type { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import type { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import type { IAttendeeRepository } from "@calcom/features/bookings/repositories/IAttendeeRepository";
import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service";
import type { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import { BookingAuditActionServiceRegistry } from "./BookingAuditActionServiceRegistry";
import { BookingAuditAccessService } from "./BookingAuditAccessService";
import type { IBookingAuditRepository, BookingAuditWithActor, BookingAuditAction, BookingAuditType } from "../repository/IBookingAuditRepository";
import type { AuditActorType } from "../repository/IAuditActorRepository";
import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service";
import type { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import type { TranslationWithParams } from "../actions/IAuditActionService";
import type { ActionSource } from "../types/actionSource";
import { RescheduledAuditActionService } from "../actions/RescheduledAuditActionService";
import { getAppNameFromSlug } from "../getAppNameFromSlug";
import type { BookingAuditContext } from "../dto/types";
import { getAppNameFromSlug } from "../getAppNameFromSlug";
import type { AuditActorType } from "../repository/IAuditActorRepository";
import type {
BookingAuditAction,
BookingAuditType,
BookingAuditWithActor,
IBookingAuditRepository,
} from "../repository/IBookingAuditRepository";
import type { ActionSource } from "../types/actionSource";
import { BookingAuditAccessService } from "./BookingAuditAccessService";
import { BookingAuditActionServiceRegistry } from "./BookingAuditActionServiceRegistry";
interface BookingAuditViewerServiceDeps {
bookingAuditRepository: IBookingAuditRepository;
userRepository: UserRepository;
bookingRepository: BookingRepository;
membershipRepository: MembershipRepository;
attendeeRepository: IAttendeeRepository;
log: ISimpleLogger;
credentialRepository: CredentialRepository;
bookingAuditRepository: IBookingAuditRepository;
userRepository: UserRepository;
bookingRepository: BookingRepository;
membershipRepository: MembershipRepository;
attendeeRepository: IAttendeeRepository;
log: ISimpleLogger;
credentialRepository: CredentialRepository;
}
type EnrichedAuditLog = {
id: string;
bookingUid: string;
type: BookingAuditType;
action: BookingAuditAction;
timestamp: string;
createdAt: string;
source: ActionSource;
operationId: string;
displayJson?: Record<string, unknown> | null;
actionDisplayTitle: TranslationWithParams;
displayFields?: Array<{ labelKey: string; valueKey?: string; value?: string; values?: string[] }> | null;
actor: {
id: string;
bookingUid: string;
type: BookingAuditType;
action: BookingAuditAction;
timestamp: string;
createdAt: string;
source: ActionSource;
operationId: string;
displayJson?: Record<string, unknown> | null;
actionDisplayTitle: TranslationWithParams;
displayFields?: Array<{ labelKey: string; valueKey: string }> | null;
actor: {
id: string;
type: AuditActorType;
userUuid: string | null;
attendeeId: number | null;
name: string | null;
createdAt: Date;
displayName: string;
displayEmail: string | null;
displayAvatar: string | null;
};
impersonatedBy?: {
displayName: string;
displayEmail: string | null;
displayAvatar: string | null;
} | null;
hasError?: boolean;
type: AuditActorType;
userUuid: string | null;
attendeeId: number | null;
name: string | null;
createdAt: Date;
displayName: string;
displayEmail: string | null;
displayAvatar: string | null;
};
impersonatedBy?: {
displayName: string;
displayEmail: string | null;
displayAvatar: string | null;
} | null;
hasError?: boolean;
};
export type DisplayBookingAuditLog = EnrichedAuditLog;
@@ -61,337 +66,346 @@ export type DisplayBookingAuditLog = EnrichedAuditLog;
* BookingAuditViewerService - Service for viewing and formatting booking audit logs
*/
export class BookingAuditViewerService {
private readonly actionServiceRegistry: BookingAuditActionServiceRegistry;
private readonly bookingAuditRepository: IBookingAuditRepository;
private readonly userRepository: UserRepository;
private readonly bookingRepository: BookingRepository;
private readonly membershipRepository: MembershipRepository;
private readonly attendeeRepository: IAttendeeRepository;
private readonly credentialRepository: CredentialRepository;
private readonly rescheduledAuditActionService: RescheduledAuditActionService;
private readonly accessService: BookingAuditAccessService;
private readonly log: BookingAuditViewerServiceDeps["log"];
private readonly actionServiceRegistry: BookingAuditActionServiceRegistry;
private readonly bookingAuditRepository: IBookingAuditRepository;
private readonly userRepository: UserRepository;
private readonly bookingRepository: BookingRepository;
private readonly membershipRepository: MembershipRepository;
private readonly attendeeRepository: IAttendeeRepository;
private readonly credentialRepository: CredentialRepository;
private readonly rescheduledAuditActionService: RescheduledAuditActionService;
private readonly accessService: BookingAuditAccessService;
private readonly log: BookingAuditViewerServiceDeps["log"];
constructor(private readonly deps: BookingAuditViewerServiceDeps) {
this.bookingAuditRepository = deps.bookingAuditRepository;
this.userRepository = deps.userRepository;
this.bookingRepository = deps.bookingRepository;
this.membershipRepository = deps.membershipRepository;
this.attendeeRepository = deps.attendeeRepository;
this.credentialRepository = deps.credentialRepository;
this.log = deps.log;
this.rescheduledAuditActionService = new RescheduledAuditActionService();
this.accessService = new BookingAuditAccessService({
bookingRepository: this.bookingRepository,
membershipRepository: this.membershipRepository,
});
this.actionServiceRegistry = new BookingAuditActionServiceRegistry({ userRepository: this.userRepository });
}
constructor(private readonly deps: BookingAuditViewerServiceDeps) {
this.bookingAuditRepository = deps.bookingAuditRepository;
this.userRepository = deps.userRepository;
this.bookingRepository = deps.bookingRepository;
this.membershipRepository = deps.membershipRepository;
this.attendeeRepository = deps.attendeeRepository;
this.credentialRepository = deps.credentialRepository;
this.log = deps.log;
this.rescheduledAuditActionService = new RescheduledAuditActionService();
this.accessService = new BookingAuditAccessService({
bookingRepository: this.bookingRepository,
membershipRepository: this.membershipRepository,
});
this.actionServiceRegistry = new BookingAuditActionServiceRegistry({
attendeeRepository: this.attendeeRepository,
userRepository: this.userRepository,
});
}
/**
* Get audit logs for a booking with full enrichment and formatting
* Handles permission checks, fetches logs, enriches actors, and formats display
*
* For bookings created from a reschedule (has fromReschedule field), this also
* fetches the last RESCHEDULED log from the previous booking and includes it
* as the first log entry with "rescheduled from" context.
*/
async getAuditLogsForBooking(params: {
bookingUid: string;
userId: number;
userEmail: string;
userTimeZone: string;
organizationId: number | null;
}): Promise<{ bookingUid: string; auditLogs: DisplayBookingAuditLog[] }> {
const { bookingUid, userId, userTimeZone, organizationId } = params;
await this.accessService.assertPermissions({
bookingUid,
userId,
organizationId
});
/**
* Get audit logs for a booking with full enrichment and formatting
* Handles permission checks, fetches logs, enriches actors, and formats display
*
* For bookings created from a reschedule (has fromReschedule field), this also
* fetches the last RESCHEDULED log from the previous booking and includes it
* as the first log entry with "rescheduled from" context.
*/
async getAuditLogsForBooking(params: {
bookingUid: string;
userId: number;
userEmail: string;
userTimeZone: string;
organizationId: number | null;
}): Promise<{ bookingUid: string; auditLogs: DisplayBookingAuditLog[] }> {
const { bookingUid, userId, userTimeZone, organizationId } = params;
await this.accessService.assertPermissions({
bookingUid,
userId,
organizationId,
});
const auditLogs = await this.bookingAuditRepository.findAllForBooking(bookingUid);
const auditLogs = await this.bookingAuditRepository.findAllForBooking(bookingUid);
const enrichedAuditLogs = await Promise.all(
auditLogs.map(async (log) => {
try {
return await this.enrichAuditLog(log, userTimeZone);
} catch (error) {
this.log.error(`Failed to enrich audit log ${log.id}: ${error instanceof Error ? error.message : String(error)}`);
return this.buildFallbackAuditLog(log);
}
})
);
const fromRescheduleUid = await this.bookingRepository.getFromRescheduleUid(bookingUid);
// Check if this booking was created from a reschedule
if (fromRescheduleUid) {
const rescheduledFromLog = await this.buildRescheduledFromLog({
fromRescheduleUid,
currentBookingUid: bookingUid,
userTimeZone,
});
if (rescheduledFromLog) {
enrichedAuditLogs.push(rescheduledFromLog);
}
const enrichedAuditLogs = await Promise.all(
auditLogs.map(async (log) => {
try {
return await this.enrichAuditLog(log, userTimeZone);
} catch (error) {
this.log.error(
`Failed to enrich audit log ${log.id}: ${error instanceof Error ? error.message : String(error)}`
);
return this.buildFallbackAuditLog(log);
}
})
);
return {
bookingUid: params.bookingUid,
auditLogs: enrichedAuditLogs,
};
}
const fromRescheduleUid = await this.bookingRepository.getFromRescheduleUid(bookingUid);
/**
* Enriches a single audit log with actor information and formatted display data
*/
private async enrichAuditLog(log: BookingAuditWithActor, userTimeZone: string): Promise<EnrichedAuditLog> {
const enrichedActor = await this.enrichActorInformation(log.actor);
const actionService = this.actionServiceRegistry.getActionService(log.action);
const parsedData = actionService.parseStored(log.data);
const actionDisplayTitle = await actionService.getDisplayTitle({ storedData: parsedData, userTimeZone });
const displayJson = actionService.getDisplayJson
? actionService.getDisplayJson({ storedData: parsedData, userTimeZone })
: null;
const displayFields = actionService.getDisplayFields
? await actionService.getDisplayFields(parsedData)
: null;
const impersonatedBy = await this.enrichImpersonator(log.context);
return {
id: log.id,
bookingUid: log.bookingUid,
type: log.type,
action: log.action,
timestamp: log.timestamp.toISOString(),
createdAt: log.createdAt.toISOString(),
source: log.source,
operationId: log.operationId,
displayJson,
actionDisplayTitle,
displayFields,
actor: {
id: log.actor.id,
type: log.actor.type,
userUuid: log.actor.userUuid,
attendeeId: log.actor.attendeeId,
name: log.actor.name,
createdAt: log.actor.createdAt,
displayName: enrichedActor.displayName,
displayEmail: enrichedActor.displayEmail,
displayAvatar: enrichedActor.displayAvatar,
},
impersonatedBy,
};
}
/**
* Builds a minimal fallback audit log when enrichment fails.
* Returns a log entry with hasError: true and basic information from the raw log.
*/
private buildFallbackAuditLog(log: BookingAuditWithActor): EnrichedAuditLog {
return {
id: log.id,
bookingUid: log.bookingUid,
type: log.type,
action: log.action,
timestamp: log.timestamp.toISOString(),
createdAt: log.createdAt.toISOString(),
source: log.source,
operationId: log.operationId,
displayJson: null,
actionDisplayTitle: { key: "booking_audit_action.error_processing", params: { actionType: log.action } },
displayFields: null,
actor: {
id: log.actor.id,
type: log.actor.type,
userUuid: log.actor.userUuid,
attendeeId: log.actor.attendeeId,
name: log.actor.name,
createdAt: log.actor.createdAt,
displayName: log.actor.name || "Unknown",
displayEmail: null,
displayAvatar: null,
},
impersonatedBy: null,
hasError: true,
};
}
/**
* Builds a "rescheduled from" log entry for bookings created from a reschedule.
* Fetches the RESCHEDULED log from the previous booking and transforms it
* to show "rescheduled from" context for the current booking.
*/
private async buildRescheduledFromLog({
// Check if this booking was created from a reschedule
if (fromRescheduleUid) {
const rescheduledFromLog = await this.buildRescheduledFromLog({
fromRescheduleUid,
currentBookingUid,
currentBookingUid: bookingUid,
userTimeZone,
}: {
fromRescheduleUid: string;
currentBookingUid: string;
userTimeZone: string;
}): Promise<EnrichedAuditLog | null> {
const rescheduledLogs = await this.bookingAuditRepository.findRescheduledLogsOfBooking(
fromRescheduleUid
);
// Find the specific log that created this booking by matching rescheduledToUid
const rescheduledLog = this.rescheduledAuditActionService.getMatchingLog({
rescheduledLogs,
rescheduledToBookingUid: currentBookingUid,
});
if (!rescheduledLog) {
this.log.error(`No rescheduled log found for booking ${fromRescheduleUid} -> ${currentBookingUid}`);
// Instead of crashing, we ignore because it is important to be able to access other logs as well.
return null;
}
const enrichedLog = await this.enrichAuditLog(rescheduledLog, userTimeZone);
const parsedData = this.rescheduledAuditActionService.parseStored(rescheduledLog.data);
// Transform the display JSON to show "rescheduled from" instead of "rescheduled to"
// by replacing rescheduledToUid with rescheduledFromUid
const transformedDisplayJson = enrichedLog.displayJson
? {
...enrichedLog.displayJson,
rescheduledFromUid: fromRescheduleUid,
}
: undefined;
return {
...enrichedLog,
// Override bookingUid to associate with the current booking being viewed
bookingUid: currentBookingUid,
displayJson: transformedDisplayJson,
// Use a different translation key to show "Rescheduled from" instead of "Rescheduled"
actionDisplayTitle: this.rescheduledAuditActionService.getDisplayTitleForRescheduledFromLog({
fromRescheduleUid,
userTimeZone,
storedData: parsedData,
}),
};
});
if (rescheduledFromLog) {
enrichedAuditLogs.push(rescheduledFromLog);
}
}
private async enrichImpersonator(context: BookingAuditContext | null): Promise<{
displayName: string;
displayEmail: string | null;
displayAvatar: string | null;
} | null> {
if (!context?.impersonatedBy) {
return null;
}
return {
bookingUid: params.bookingUid,
auditLogs: enrichedAuditLogs,
};
}
const impersonatorUser = await this.userRepository.findByUuid({ uuid: context.impersonatedBy });
if (!impersonatorUser) {
/**
* Enriches a single audit log with actor information and formatted display data
*/
private async enrichAuditLog(log: BookingAuditWithActor, userTimeZone: string): Promise<EnrichedAuditLog> {
const enrichedActor = await this.enrichActorInformation(log.actor);
const actionService = this.actionServiceRegistry.getActionService(log.action);
const parsedData = actionService.parseStored(log.data);
const actionDisplayTitle = await actionService.getDisplayTitle({
storedData: parsedData,
userTimeZone,
});
const displayJson = actionService.getDisplayJson
? actionService.getDisplayJson({ storedData: parsedData, userTimeZone })
: null;
const displayFields = actionService.getDisplayFields
? await actionService.getDisplayFields({ storedData: parsedData })
: null;
const impersonatedBy = await this.enrichImpersonator(log.context);
return {
id: log.id,
bookingUid: log.bookingUid,
type: log.type,
action: log.action,
timestamp: log.timestamp.toISOString(),
createdAt: log.createdAt.toISOString(),
source: log.source,
operationId: log.operationId,
displayJson,
actionDisplayTitle,
displayFields,
actor: {
id: log.actor.id,
type: log.actor.type,
userUuid: log.actor.userUuid,
attendeeId: log.actor.attendeeId,
name: log.actor.name,
createdAt: log.actor.createdAt,
displayName: enrichedActor.displayName,
displayEmail: enrichedActor.displayEmail,
displayAvatar: enrichedActor.displayAvatar,
},
impersonatedBy,
};
}
/**
* Builds a minimal fallback audit log when enrichment fails.
* Returns a log entry with hasError: true and basic information from the raw log.
*/
private buildFallbackAuditLog(log: BookingAuditWithActor): EnrichedAuditLog {
return {
id: log.id,
bookingUid: log.bookingUid,
type: log.type,
action: log.action,
timestamp: log.timestamp.toISOString(),
createdAt: log.createdAt.toISOString(),
source: log.source,
operationId: log.operationId,
displayJson: null,
actionDisplayTitle: {
key: "booking_audit_action.error_processing",
params: { actionType: log.action },
},
displayFields: null,
actor: {
id: log.actor.id,
type: log.actor.type,
userUuid: log.actor.userUuid,
attendeeId: log.actor.attendeeId,
name: log.actor.name,
createdAt: log.actor.createdAt,
displayName: log.actor.name || "Unknown",
displayEmail: null,
displayAvatar: null,
},
impersonatedBy: null,
hasError: true,
};
}
/**
* Builds a "rescheduled from" log entry for bookings created from a reschedule.
* Fetches the RESCHEDULED log from the previous booking and transforms it
* to show "rescheduled from" context for the current booking.
*/
private async buildRescheduledFromLog({
fromRescheduleUid,
currentBookingUid,
userTimeZone,
}: {
fromRescheduleUid: string;
currentBookingUid: string;
userTimeZone: string;
}): Promise<EnrichedAuditLog | null> {
const rescheduledLogs = await this.bookingAuditRepository.findRescheduledLogsOfBooking(fromRescheduleUid);
// Find the specific log that created this booking by matching rescheduledToUid
const rescheduledLog = this.rescheduledAuditActionService.getMatchingLog({
rescheduledLogs,
rescheduledToBookingUid: currentBookingUid,
});
if (!rescheduledLog) {
this.log.error(`No rescheduled log found for booking ${fromRescheduleUid} -> ${currentBookingUid}`);
// Instead of crashing, we ignore because it is important to be able to access other logs as well.
return null;
}
const enrichedLog = await this.enrichAuditLog(rescheduledLog, userTimeZone);
const parsedData = this.rescheduledAuditActionService.parseStored(rescheduledLog.data);
// Transform the display JSON to show "rescheduled from" instead of "rescheduled to"
// by replacing rescheduledToUid with rescheduledFromUid
const transformedDisplayJson = enrichedLog.displayJson
? {
...enrichedLog.displayJson,
rescheduledFromUid: fromRescheduleUid,
}
: undefined;
return {
...enrichedLog,
// Override bookingUid to associate with the current booking being viewed
bookingUid: currentBookingUid,
displayJson: transformedDisplayJson,
// Use a different translation key to show "Rescheduled from" instead of "Rescheduled"
actionDisplayTitle: this.rescheduledAuditActionService.getDisplayTitleForRescheduledFromLog({
fromRescheduleUid,
userTimeZone,
storedData: parsedData,
}),
};
}
private async enrichImpersonator(context: BookingAuditContext | null): Promise<{
displayName: string;
displayEmail: string | null;
displayAvatar: string | null;
} | null> {
if (!context?.impersonatedBy) {
return null;
}
const impersonatorUser = await this.userRepository.findByUuid({ uuid: context.impersonatedBy });
if (!impersonatorUser) {
return {
displayName: "Deleted User",
displayEmail: null,
displayAvatar: null,
};
}
return {
displayName: impersonatorUser.name || impersonatorUser.email,
displayEmail: impersonatorUser.email,
displayAvatar: impersonatorUser.avatarUrl || null,
};
}
/**
* Enrich actor information with user details if userUuid exists
*/
private async enrichActorInformation(actor: BookingAuditWithActor["actor"]): Promise<{
displayName: string;
displayEmail: string | null;
displayAvatar: string | null;
}> {
switch (actor.type) {
case "SYSTEM":
return {
displayName: "Cal.com",
displayEmail: null,
displayAvatar: null,
};
case "GUEST":
return {
displayName: actor.name || "Guest",
displayEmail: null,
displayAvatar: null,
};
case "APP": {
if (actor.credentialId) {
const credential = await this.deps.credentialRepository.findByCredentialId(actor.credentialId);
if (credential) {
return {
displayName: "Deleted User",
displayEmail: null,
displayAvatar: null,
displayName: getAppNameFromSlug({ appSlug: credential.appId }),
displayEmail: null,
displayAvatar: null,
};
} else {
return {
// Expect that on Credential deletion name would have been set
displayName: actor.name ?? "Deleted App",
displayEmail: null,
displayAvatar: null,
};
}
}
// We allow creating App actor without credentialId
return {
displayName: impersonatorUser.name || impersonatorUser.email,
displayEmail: impersonatorUser.email,
displayAvatar: impersonatorUser.avatarUrl || null,
displayName: actor.name ?? "Unknown App",
// We don't want to show email for App actor as that is an internal email with the purpose of giving uniqueness to each app actor
displayEmail: null,
displayAvatar: null,
};
}
}
/**
* Enrich actor information with user details if userUuid exists
*/
private async enrichActorInformation(actor: BookingAuditWithActor["actor"]): Promise<{
displayName: string;
displayEmail: string | null;
displayAvatar: string | null;
}> {
switch (actor.type) {
case "SYSTEM":
return {
displayName: "Cal.com",
displayEmail: null,
displayAvatar: null,
};
case "GUEST":
return {
displayName: actor.name || "Guest",
displayEmail: null,
displayAvatar: null,
};
case "APP": {
if (actor.credentialId) {
const credential = await this.deps.credentialRepository.findByCredentialId(actor.credentialId);
if (credential) {
return {
displayName: getAppNameFromSlug({ appSlug: credential.appId }),
displayEmail: null,
displayAvatar: null,
};
} else {
return {
// Expect that on Credential deletion name would have been set
displayName: actor.name ?? "Deleted App",
displayEmail: null,
displayAvatar: null,
};
}
}
// We allow creating App actor without credentialId
return {
displayName: actor.name ?? "Unknown App",
// We don't want to show email for App actor as that is an internal email with the purpose of giving uniqueness to each app actor
displayEmail: null,
displayAvatar: null,
};
}
case "ATTENDEE": {
if (!actor.attendeeId) {
throw new Error("Attendee ID is required for ATTENDEE actor");
}
const attendee = await this.attendeeRepository.findById(actor.attendeeId);
if (attendee) {
return {
displayName: attendee.name || attendee.email,
displayEmail: attendee.email,
displayAvatar: null,
};
}
return {
displayName: "Deleted Attendee",
displayEmail: null,
displayAvatar: null,
};
}
case "USER": {
if (!actor.userUuid) {
throw new Error("User UUID is required for USER actor");
}
const actorUser = await this.userRepository.findByUuid({ uuid: actor.userUuid });
if (actorUser) {
return {
displayName: actorUser.name || actorUser.email,
displayEmail: actorUser.email,
displayAvatar: actorUser.avatarUrl || null,
};
}
return {
displayName: "Deleted User",
displayEmail: null,
displayAvatar: null,
};
}
case "ATTENDEE": {
if (!actor.attendeeId) {
throw new Error("Attendee ID is required for ATTENDEE actor");
}
const attendee = await this.attendeeRepository.findById(actor.attendeeId);
if (attendee) {
return {
displayName: attendee.name || attendee.email,
displayEmail: attendee.email,
displayAvatar: null,
};
}
return {
displayName: "Deleted Attendee",
displayEmail: null,
displayAvatar: null,
};
}
case "USER": {
if (!actor.userUuid) {
throw new Error("User UUID is required for USER actor");
}
const actorUser = await this.userRepository.findByUuid({ uuid: actor.userUuid });
if (actorUser) {
return {
displayName: actorUser.name || actorUser.email,
displayEmail: actorUser.email,
displayAvatar: actorUser.avatarUrl || null,
};
}
return {
displayName: "Deleted User",
displayEmail: null,
displayAvatar: null,
};
}
}
}
}

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