Compare commits

..
87 Commits
Author SHA1 Message Date
hausgeist b34ef206f8 cc: gate on DB readiness + real healthchecks (start.sh has no set -e and wait-for-it passes mid-initdb)
- database: pg_isready healthcheck; calcom depends_on service_healthy
- calcom: healthcheck probes /auth/login with redirects (upstream wget --spider / fails on 307)
2026-09-28 08:16:12 -07:00
hausgeist 1c3c661726 cc: fix DB wiring — base compose hardcodes unicorn_user/magical_password and builds DATABASE_URL from raw creds (Prisma URL-parse crash with base64 pw)
- docker-compose.cc.yml: database env + calcom DATABASE_URL/DIRECT_URL from .env
- cc.env.example: require HEX (URL-safe) secrets; hex generation commands
2026-09-28 06:44:44 -07:00
hausgeist 3dbad3113d cc: build-based deploy — cal.diy publishes no prebuilt release images
Verified: Docker Hub calcom/cal.diy has 0 tags; scarf/ghcr mirrors serve none.
The upstream Dockerfile + compose build args are the deploy path.
2026-09-28 00:23:53 -07:00
hausgeist b1173fc8c3 cc: CC deployment source-of-truth pinned at v6.2.0 (issue #11)
- docker-compose.cc.yml: pin calcom v6.2.0 + postgres 16-alpine + redis 7-alpine;
  studio + calcom-api off (reserved profiles)
- cc.env.example: CC production env spec (webapp URL, telemetry off, branding)
- cc/README.md: mirror manifest + upstream-merge process
2026-09-27 23:36:33 -07:00
khushal-winnerandSahitya Chandra 1c193cca86 docs: document Windows setup fix for prisma .env symlink error (#28101)
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
* docs: add Windows fix for prisma .env symlink issue

* Remove 'link-as-an-app' from redirect apps list

Removed 'link-as-an-app' from the list of apps.

* Update README.md

removed option 2

* Update README.md

* Update README.md

---------

Co-authored-by: Sahitya Chandra <sahityajb@gmail.com>
2026-03-01 12:42:42 +00:00
17af50bbdc feat: add per-host locations seed data in Acme Org for QA (#27832)
* fix: host location option

* feat: add per-host locations seed data for Round Robin event type

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

* refactor: move per-host locations seed to Acme Org with dedicated event type

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

* fix: check app existence before creating credentials in per-host location seed

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

* refactor: remove credential-based app seeding, use simple location types instead

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

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2026-02-26 18:51:11 +00:00
5242e419d9 feat: Calendar Sync (#24124)
* feat: calendar cache and sync - wip

* Add env.example

* refactor on CalendarCacheEventService

* remove test console.log

* Fix type checks errors

* chore: remove pt comment

* add route.ts

* chore: fix tests

* Improve cache impl

* chore: update recurring event id

* chore: small improvements

* calendar cache improvements

* Fix remove dynamic imports

* Add cleanup stale cache

* Fix tests

* add event update

* type fixes

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

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

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

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

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

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

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

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

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

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

* Fix tests

* Fix tests

* type fix

* Fix coderabbit comments

* Fix types

* Fix test

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

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

* Fixes by first review

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

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

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

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

* only google-calendar for now

* docs: add Calendar Cache and Sync feature documentation

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

Addresses PR #23876 documentation requirements

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

* docs: update calendar subscription README with comprehensive documentation

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

Addresses PR #23876 documentation requirements

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

* fix docs

* Fix test to available calendars

* Fix test to available calendars

* add migration and sync boilerplate

* fix typo

* remove double log

* sync boilerplate

* remove console.log

* only subscribe for google calendar

* adjust for 3 months fetch

* only subscribe for teams that have feature enabled

* adjust tests

* chore: safe increment error count

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

* calendar sync

* test: add comprehensive tests for CalendarSyncService

- Add comprehensive test coverage for CalendarSyncService methods
- Test handleEvents, cancelBooking, and rescheduleBooking functionality
- Cover edge cases like missing UIDs, malformed UIDs, and error handling
- Fix dynamic import usage in CalendarSyncService to use .default
- Remove invalid properties from handleNewBooking call to fix type errors

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

* fix: add skipCalendarSyncTaskCreation flag to handleCancelBooking to prevent infinite loops

- Add skipCalendarSyncTaskCreation field to bookingCancelSchema in zod-utils.ts
- Update handleCancelBooking to skip EventManager.cancelEvent when flag is true
- Update CalendarSyncService.cancelBooking to pass skipCalendarSyncTaskCreation: true
- Update CalendarSyncService.rescheduleBooking to pass skipCalendarSyncTaskCreation: true
- Update tests to verify the flags are passed correctly

This prevents infinite loops when calendar events are cancelled/rescheduled from
external calendars (Google/Office365) which trigger webhooks to Cal.com, which
would otherwise try to update the external calendar again.

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

* refactor: rename flag to skipCalendarSyncTaskCancellation and use static imports

- Rename skipCalendarSyncTaskCreation to skipCalendarSyncTaskCancellation in handleCancelBooking
- Convert dynamic imports to static imports in CalendarSyncService
- Update tests to use vi.hoisted for proper mock hoisting with static imports

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

* feat: add error handling and static import for handleNewBooking

- Create index.ts entry point for handleNewBooking directory
- Add try-catch error handling to cancelBooking and rescheduleBooking
- Log errors but don't block calendar sync operations
- Update tests to verify errors are caught and logged, not thrown

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

* refactor: use RegularBookingService directly and fix safeStringify usage

- Remove handleNewBooking/index.ts and call getRegularBookingService().createBooking() directly in CalendarSyncService
- Fix safeStringify usage: apply to error itself, not wrapper object
- Update tests to mock getRegularBookingService instead of handleNewBooking

Addresses PR comments from Volnei and Cubic-dev-ai

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

* fix: restore handleNewBooking/index.ts and fix bookingData structure for calendar sync

- Restore handleNewBooking/index.ts as entry point for static imports
- Fix CalendarSyncService.rescheduleBooking to use correct bookingData structure
  with required fields (eventTypeId, start, end, timeZone, language, metadata)
- Use rescheduleUid to indicate this is a reschedule operation
- Fix safeStringify usage in error logging (wrap only the error, not the whole object)
- Update tests to match new bookingData structure

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

* refactor: address PR comments - use RegularBookingService directly, remove unnecessary flags, fix booking reference update

- Remove handleNewBooking/index.ts wrapper and use getRegularBookingService().createBooking() directly in CalendarSyncService
- Remove allRemainingBookings and cancelSubsequentBookings flags from cancelBooking (only cancel the specific booking, not the entire series)
- Move bookingReference update outside skipCalendarSyncTaskCancellation block for data consistency
- Update tests to match new implementation

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

* chore: remove unnecessary comment from zod-utils.ts

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

* feat: add Sentry metrics telemetry and fix null assertions in CalendarSyncService

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

* fix: use dynamic import for getRegularBookingService to avoid RAQB import in server context

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

* fix: use dynamic import for findTeamMembersMatchingAttributeLogic to avoid RAQB import in server context

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

* improve booking update

* add more tests and edge cases

* fix: add required actionSource and Sentry mocks to calendar sync

Add actionSource: "SYSTEM" to handleCancelBooking call after it became
required, and mock @sentry/nextjs in test files.

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-26 13:12:52 -03:00
5d65a0f091 fix: hide cal branding for orgs/teams (#27643)
* fix: hide branding for teams

* fix: remove unused organizationId and username fields from profiles select

Addresses Cubic AI review feedback (confidence 9/10) to select only
the profile fields that are actually used. The organizationId and
username fields were fetched but never referenced in this function.

Co-Authored-By: unknown <>

* fix: unit tests

* fix: add prisma named export to test mock

Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com>

* Add tests: packages/features/profile/lib/hideBranding.test.ts

Generated by Paragon from proposal for PR #27643

* chore: implement cubic feedback

* fix: merge conflicts

* fix: unit tests

* fixup

* refactor: implement DI pattern for event type service

* fix: atoms build

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-25 20:53:01 +09:00
4081d11fbe feat: workflow auto translation (#27087)
* feat: workflow auto translation

* tests: add unit tests

* refactor: tests and workflow

* fix: type err

* fix: type err

* fix: remove redundant index on WorkflowStepTranslation

The @@index on [workflowStepId, field, targetLocale] duplicates the @@unique
constraint on the same columns. A unique index already provides efficient
lookups, so the separate @@index adds storage overhead and write latency
without benefit.

Addresses Cubic AI review feedback (confidence 9/10).

Co-Authored-By: unknown <>

* fix: correct locale mapping when translation API returns null

Map translations with their corresponding locales before filtering to
preserve correct locale-to-translation associations. Previously, filtering
out null translations would reindex the array, causing incorrect locale
mappings when any translation in the batch failed.

Also fixes pre-existing lint warnings:
- Move exports to end of file
- Add explicit return type to processTranslations
- Replace ternary with if-else for upsertMany selection

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

* fix: address review feedback for workflow auto-translation

- Add change detection before creating translation tasks
- Rename userLocale to sourceLocale in task props for clarity
- Show source language in UI with new translation key
- Extract SUPPORTED_LOCALES to shared translationConstants.ts
- Fix locale mapping bug in translateEventTypeData.ts
- Add WhatsApp translation support
- Abstract translation lookup into shared translationLookup.ts helper
- Restore if-else readability for SCANNING_WORKFLOW_STEPS

Co-authored-by: Udit Takkar <udit.takkar@cal.com>
Co-Authored-By: unknown <>

* fix: update test to use sourceLocale instead of userLocale

Co-Authored-By: unknown <>

* refactor: feedback

* fix: handle first time

* fix: tests

* fix: tests

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

- WhatsApp translation: Apply variable substitution using getSMSMessageWithVariables
  and clear contentSid when using translated body to ensure Twilio uses the
  translated text instead of the original template

- update.handler.ts: Change sourceLocale assignment from ?? to || for consistency
  with tasker payload behavior (line 481)

- ITranslationService.ts: Rename methods from plural to singular naming:
  - getWorkflowStepTranslations -> getWorkflowStepTranslation
  - getEventTypeTranslations -> getEventTypeTranslation
  Updated all call sites and tests accordingly

Co-Authored-By: unknown <>

* fix: address Cubic AI review feedback (confidence 9/10+ issues)

- Fix getSMSMessageWithVariables to handle WHATSAPP_ATTENDEE action for
  locale and timezone (confidence 9/10)
- Remove WhatsApp translation feature that set contentSid to undefined
  since Twilio ignores body parameter for WhatsApp and requires
  pre-approved Message Templates (confidence 10/10)

Co-Authored-By: unknown <>

* fix: translatio

* Add tests: packages/features/eventTypeTranslation/repositories/EventTypeTranslationRepository.test.ts

Generated by Paragon from proposal for PR #27087

* Add tests: packages/features/tasker/tasks/translateWorkflowStepData.test.ts

Generated by Paragon from proposal for PR #27087

* chore: nit

* chore: verfied atg

* fix: set sourceLocale for new steps, add shouldDirty to checkbox, remove spec docs

- Set sourceLocale fallback in addedSteps mapping to fix stale detection mismatch
- Add { shouldDirty: true } to autoTranslateEnabled checkbox onChange
- Remove specs/workflow-translation/ directory (planning docs, not for repo)

Co-authored-by: Udit Takkar <udit.07.takkar@gmail.com>
Co-Authored-By: unknown <>

* chore: add specs back

* fix: type error

* fix: type error

* fix: type err

* fix: tests

* refactor: feedback

* fix: type err

* refactor

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit.takkar@cal.com>
Co-authored-by: Udit Takkar <udit.07.takkar@gmail.com>
2026-02-25 01:03:55 +05:30
f883dee084 perf: improve getTotalBookingDuration query (#27383)
* perf: improve getTotalBookingDuration query

* perf: improve getTotalBookingDuration query

* test: add unit tests for getTotalBookingDurationForUsers and yearly duration pre-fetching

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

* fix: integration tests

* fix: update tests

* fix: use vi.stubEnv instead of direct process.env modification for test isolation

Replace direct deletion of process.env variables with vi.stubEnv() to ensure
automatic restoration after tests, preventing flaky tests and side effects
in parallel execution.

Issue identified by Cubic AI (confidence: 9/10)

Co-Authored-By: unknown <>

* chore: add log

* refactor: imports

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-24 22:55:33 +05:30
e3a9f54ba5 feat: Configure cancellation reason (#26872)
* feat: Configure cancellation reason

* fix: use enums

* tests: add unit tests

* fix: type error

* chore: remove duplicate dialog

* fix: type erro

* refator: improvements

* refator: improvements

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-24 13:35:51 -03:00
c9abc556ba fix: improve getIP header resolution for CF → Vercel setup (#28152)
Co-Authored-By: unknown <>

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-24 14:47:14 +00:00
Benny Joo 9855176948 refactor: Remove all TrpcSessionUser usages in @calcom/features (#27853)
* sessionUser in features

* update sessionMiddleware

* update

* format changes

* update import paths

* fix

* fix ts errors

* refactor

* fix

* fix

* fix

* fix

* rename

* rename

* rename

* use error with code objs
2026-02-24 09:29:54 +00:00
48cbb94af2 feat: add webhook trigger when routing form fallback route is hit (#27588)
* feat: add no users found fallback action for routing forms

- Add NoUsersFoundFallbackActionType enum with CustomPageMessage and ExternalRedirectUrl options
- Add noUsersFoundFallbackAction field to zodNonRouterRoute schema
- Add NoUsersFoundFallback UI component in RouteBuilder for configuring fallback action
- Update handleResponse to return noUsersFoundFallbackAction when no team members match
- Add i18n translation keys for new UI strings

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

* refactor: change fallback to use same action options as main route

- Replace noUsersFoundFallbackAction with fallbackAction that has same structure as main action
- Add action dropdown and input fields to fallback attributes query builder section
- Support Event redirect, External URL, and Custom page options in fallback
- Remove separate NoUsersFoundFallback toggle component
- Update handleResponse to return fallbackAction

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

* fix: ensure fallbackAction type is always defined in onChange handlers

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

* fix: add guards for route.fallbackAction in onChange handlers

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

* fix: only return fallbackAction when no users are found

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

* refactor: restructure fallback section to mirror main route structure

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

* fix: ensure backwards compatibility for existing routes with fallbackAttributesQueryValue

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

* fix: initialize fallbackAction with main event type for backwards compatibility

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

* fix: include fallbackAction in getRoutesToSave to persist changes

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

* fix: use fallbackAction in getRoutedUrl when no team members found

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

* fix: use i18n for fallback label and add tests for fallback action

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

* fix: do not trigger fallback action when CRM contact owner is found

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

* fix: prioritize fallbackAction over fallbackAttributesQueryValue

When a route has a fallbackAction configured, skip the
fallbackAttributesQueryValue to ensure the fallbackAction is triggered
when no team members are found. This maintains backwards compatibility
by only using fallbackAttributesQueryValue when fallbackAction is not set.

Also treats teamMemberIdsMatchingAttributeLogic being null (routing couldn't
run, e.g., missing orgId) the same as an empty array for the purpose of
triggering the fallbackAction.

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

* refactor: extract RouteActionSelector shared component for action selection UI

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

* fix: add i18n string for default custom page message

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

* feat: add webhook trigger when routing form fallback route is hit

- Add ROUTING_FORM_FALLBACK_HIT to WebhookTriggerEvents enum in Prisma schema
- Add new trigger to routing-forms webhook triggers in constants
- Add translation string for the new webhook trigger
- Implement triggerFallbackWebhook function in formSubmissionUtils.ts
- Call webhook trigger from handleResponse.ts when fallback action is used

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

* fix: remove webhook object from error log to avoid exposing secrets

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

* revert: remove webhook trigger changes (to be moved to separate PR)

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

* feat: add webhook trigger when routing form fallback route is hit

- Add ROUTING_FORM_FALLBACK_HIT to WebhookTriggerEvents enum in Prisma schema
- Add new trigger to routing-forms webhook triggers in constants
- Add translation string for the new webhook trigger
- Implement triggerFallbackWebhook function in formSubmissionUtils.ts
- Call webhook trigger from handleResponse.ts when fallback action is used

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

* fix: remove webhook object from error log to avoid exposing secrets

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

* fix: add ROUTING_FORM_FALLBACK_HIT to PayloadBuilderFactory trigger mapping

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

* style: apply biome formatting fixes

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

* fix: restore removed comments in RouteBuilder

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

* chore: add prisma migration for ROUTING_FORM_FALLBACK_HIT enum value

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

* fix: add ROUTING_FORM_FALLBACK_HIT to FormTriggerEvents type union

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

* test: add test coverage for triggerFallbackWebhook function

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

* refactor: consolidate fallback webhook firing with form submission webhooks

Move ROUTING_FORM_FALLBACK_HIT webhooks into the same Promise.all() batch
as FORM_SUBMITTED / FORM_SUBMITTED_NO_EVENT, eliminating redundant
getWebhooks/getWebhookTargetEntity/getOrgIdFromMemberOrTeamId calls.

- Add fallbackAction parameter to _onFormSubmission and onSubmissionOfFormResponse
- Remove triggerFallbackWebhook function and all its usages/imports
- Move getFallbackAction() earlier in handleResponse.ts and pass result through
- Update all tests to verify batched webhook behavior

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

* Add UI option for trigger

* feat: persist fallbackAction on queued form responses

When a routing form response is queued, the fallbackAction determined
during routing is now stored on the QueuedFormResponse record. When
the queued response is later converted to a real FormResponse, the
stored fallbackAction is passed to onSubmissionOfFormResponse so the
ROUTING_FORM_FALLBACK_HIT webhook fires correctly.

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

* test: add coverage for fallbackAction in queued form response flow

Verify that fallbackAction is persisted when queuing and passed through
to onSubmissionOfFormResponse when the queued response is converted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.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-02-23 16:44:41 -05:00
8238d4ffcd fix: use WEBAPP_URL for booking confirmation redirects to fix localhost behind proxy (#28144)
Replace request.url with WEBAPP_URL from @calcom/lib/constants as the base URL
for NextResponse.redirect() in booking confirmation API routes. Behind a reverse
proxy, request.url resolves to http://localhost:3000 instead of the public domain.

Fixes #20358

Co-Authored-By: unknown <>

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-23 14:19:54 -03:00
0a84ce5c73 fix: use smsReminderNumber fallback for attendee phone in SMS reminders (#27942)
* SMS reminders not trigger

* addressed devin review

* Add tests: packages/features/ee/workflows/lib/service/scheduleLazySMSWorkflow.test.ts

Generated by Paragon from proposal for PR #27942

* Add tests: packages/features/ee/workflows/lib/reminders/reminderScheduler.smsLazy.test.ts

Generated by Paragon from proposal for PR #27942

* Add tests: packages/features/tasker/tasks/sendWorkflowSMS.test.ts

Generated by Paragon from proposal for PR #27942

* Add tests: packages/features/ee/workflows/lib/reminders/smsReminderManager.test.ts

Generated by Paragon from proposal for PR #27942

* Delete packages/features/ee/workflows/lib/reminders/reminderScheduler.smsLazy.test.ts

* fix: update sendWorkflowSMS tests to match refactored implementation

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

* fix: use smsReminderNumber fallback for attendee phone in SMS workflows

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

* revert: keep only scheduleSMSReminders.ts changes, revert all other files

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

* fix: restore reminderScheduler.ts and smsReminderManager.ts to original branch state

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

* fix: use smsReminderNumber fallback for attendee phone in WhatsApp reminders

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-23 17:35:51 +01:00
7e73d675d7 refactor: improve invoice.upcoming logging and add strategyName to billing strategies (#28143)
- Add strategyName property to ISeatBillingStrategy interface and all implementations
- Log which strategy handled the invoice.upcoming webhook (not just whether it applied)
- Always log strategy result (not just when applied=true) for better observability
- Rename highWaterMarkApplied to strategyApplied since applied covers multiple strategies

Co-Authored-By: unknown <>

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-23 15:57:13 +00:00
Benny Joo 648ad72a54 refactor: extract dedicated @calcom/i18n package (#28141) 2026-02-23 13:30:12 +00:00
f00be08e8c fix: order custom questions by event type bookingFields in BookingDetailsSheet (#28140)
Co-Authored-By: unknown <>

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-23 22:25:02 +09:00
Yuvraj Angad SinghandAnik Dhabal Babu f1e8e3fa58 fix(caldav): consistent UIDs and VTIMEZONE in iCalendar output (#28115)
* fix(caldav): use consistent UIDs and inject VTIMEZONE in iCalendar output

Two remaining CalDAV interop issues from #9485:

1. UID consistency: use the booking's canonical UID (event.uid) instead
   of always generating a new random UUID. CalDAV servers use UID as the
   event identifier — different UIDs cause duplicate calendar entries.

2. VTIMEZONE injection: the ics library generates UTC times with no
   VTIMEZONE block. CalDAV servers like Fastmail read this as UTC and
   send scheduling emails with wrong times. Per RFC 5545 §3.6.5,
   DTSTART with TZID requires a matching VTIMEZONE component. We now
   build a proper VTIMEZONE using binary-searched DST transitions for
   the event's year, handling Northern/Southern hemisphere correctly.

* fix: use pre-transition offset for VTIMEZONE DTSTART per RFC 5545

The DTSTART in VTIMEZONE components must represent the local time
interpreted with the pre-transition offset (TZOFFSETFROM), not the
post-transition offset. For example, US Eastern spring forward DTSTART
should be 02:00 (EST), not 03:00 (EDT).

* Remove comments on UID handling in createEvent

Removed comments about UID handling for calendar events.

* Revise injectVTimezone documentation

Update injectVTimezone function documentation to clarify UTC handling.

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2026-02-21 14:38:56 +00:00
Anirban SinghaandRomit 4dbe044ecc refactor: remove unused imports and stale TODO comment from caleventparser.ts (#28096)
* refactor: replace manual provider logic with getAppFromLocationValue

* chore: revert changes and remove stale comment

---------

Co-authored-by: Romit <85230081+romitg2@users.noreply.github.com>
2026-02-21 19:38:14 +05:30
Romit f1ae5f727a fix: update fast-xml-parser and ignore audit 1113407 (#28117) 2026-02-21 12:22:57 +00:00
Romit 7ee01c0487 fix (#28119) 2026-02-21 09:16:13 -03:00
05537780b8 fix: add biome overrides for embed-core to prevent auto-import of node:process (#27387)
* fix: use import.meta.env instead of process.env in embed.ts

Using import.meta.env is Vite's native way to handle environment variables
in browser bundles. This prevents biome from auto-adding 'import process
from node:process' which breaks the embed.js in browsers.

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

* Remove unncessar comment

* fix: migrate remaining process.env to import.meta.env in embed.ts

Complete the migration started by @hariombalhara by also converting
process.env usages at lines 1681, 1682, and 1700 to import.meta.env.
Update env.d.ts to include EMBED_PUBLIC_EMBED_FINGER_PRINT,
EMBED_PUBLIC_EMBED_VERSION, and INTEGRATION_TEST_MODE types.

This ensures no process.env references remain in embed.ts, fully
preventing Biome from auto-adding import process from node:process.

Co-Authored-By: unknown <>

* fix: add biome overrides for embed-core to prevent auto-import of node:process

Reverts import.meta.env back to process.env since API V2's tsconfig uses
module: commonjs which doesn't support import.meta. Instead, adds biome
overrides for embed-core to disable useNodejsImportProtocol, noProcessEnv,
and noProcessGlobal rules that were causing biome to auto-add
'import process from node:process' on save.

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-20 14:38:11 -03:00
Hariom BalharaandDevin AI 3e29904d7d test: add tests for __reloadInitiated behavior to ensure correct bookerViewed vs bookerReloaded events (#27379)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-20 14:35:18 -03:00
06f4b25080 fix: update routing form identifier hint text with learn more link (#27837)
* fix: update routing form identifier hint text with learn more link

Co-Authored-By: carina@cal.com <c.wollendorfer@me.com>

* UI improvements

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2026-02-20 13:09:09 +01:00
Deepanshu 79fc0c8437 fix: toolbar dropdown toggle (#28097) 2026-02-20 11:20:38 +00:00
a2b25fa615 fix: prevent deselecting active option in booker layout toggle (#27748)
* fix: prevent deselecting active option in ToggleGroup

Radix UI's single-type ToggleGroup allows deselection by default,
calling onValueChange("") when the active item is clicked.

Fix by converting to controlled mode with proper dual-mode support:
- Controlled (value prop): parent owns state, component just filters
  empty values from onValueChange. Parent can still reject changes.
- Uncontrolled (defaultValue prop): internal useState prevents
  deselection by only updating state for non-empty values.

* Update ToggleGroup.tsx

---------

Co-authored-by: Sahitya Chandra <sahityajb@gmail.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2026-02-20 10:41:54 +00:00
14c151be76 fix: add CSRF protection to OAuth callback via HMAC-signed nonce (#28083)
* fix: add CSRF protection to OAuth callback via HMAC-signed nonce

The OAuth state parameter was used only for passing application data
(returnTo, teamId) with no cryptographic binding to the user session.
An attacker could authorize their own account on a provider, capture the
authorization code, and trick a logged-in user into visiting the callback
URL to link the attacker's account to the victim's Cal.com profile.

Changes:
- encodeOAuthState: generate a random nonce and HMAC-sign it with
  NEXTAUTH_SECRET + userId, injecting both into the OAuth state
- decodeOAuthState: verify the HMAC on callback using timingSafeEqual;
  skip verification when nonce is absent (backwards compatible with apps
  that don't yet use encodeOAuthState)
- Stripe callback: replace raw state.returnTo redirect with
  getSafeRedirectUrl to prevent open redirect, remove redundant
  getReturnToValueFromQueryState, add missing return on access_denied

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

* fix: make CSRF nonce verification mandatory with allowlist for exempt apps

Makes nonce/HMAC verification mandatory by default in decodeOAuthState,
preventing attackers from bypassing CSRF protection by omitting nonce
fields from the state parameter.

Apps not yet migrated to encodeOAuthState (stripe, basecamp3, dub,
webex, tandem) are explicitly allowlisted and pass their slug to
decodeOAuthState to skip verification.

Addresses review feedback (identified by cubic) about the conditional
check being trivially bypassable.

Co-Authored-By: unknown <>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-20 10:35:01 +00:00
1aae57daae refactor(booking-audit): discriminated union for displayFields and i18n param support (#27373)
* 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>

* feat: Add valuesWithParams support for translatable complex field values

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

* refactor(booking-audit): use discriminated union for displayFields and update consumers

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

* fix: add explicit return type to getBookingHistoryHandler to bust stale tRPC build cache

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

* refactor: replace $t() nested interpolation with separate translation keys and add translationsWithParams tests

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-20 14:34:12 +05:30
Abir RoyandSahitya Chandra 12e95a1f47 feat: Add meta pixel conversion events (#25638)
* feat(meta-pixel): add trackingEvent to zod schema

* fix: do not allow trackingEvent to be optional

* feat(meta-pixel): add trackingEvent selection dropdown to UI

* feat(meta-pixel): add trackingEvent to Event Type App Data

* fix(meta-pixel): implement history API hook for SPA conversion tracking

* fix(meta-pixel): check for cancel in URL and prevent Re-wrapping of pushState

* Update EventTypeAppSettingsInterface.tsx

* fix: make trackingEvent optional in zod schema to fix type-check

---------

Co-authored-by: Sahitya Chandra <sahityajb@gmail.com>
2026-02-20 00:17:42 +05:30
60b6086221 feat: add keyboard shortcuts and tooltips to booking slideover buttons (#27428)
* feat: add keyboard shortcuts and tooltips to booking slideover buttons

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

* fix: remove duplicate useBookingLocation import and fix import ordering

- Removed duplicate import of useBookingLocation from non-existent
  @calcom/web/modules/bookings/hooks/useBookingLocation path
- Fixed import ordering to satisfy biome organizeImports rules
- Removed unnecessary code comment
- Original feature by @PeerRich via Devin AI

Co-Authored-By: unknown <>

* use same style arrows for both button and remove bydefault focus from them

* fix join button tooltip hover

* fix: disable keyboard shortcuts when overlays/dialogs are open on BookingDetailsSheet

Co-Authored-By: unknown <>

* fix: use focus-based detection instead of selector-based overlay detection for keyboard shortcuts

Co-Authored-By: unknown <>

* fix: allow keyboard shortcuts when focus is on sheet ancestors

Co-Authored-By: unknown <>

* fix: use capture phase for keyboard handler to prevent Enter from activating focused buttons

Co-Authored-By: unknown <>

* fix: allow shortcuts when focus is on page elements outside any Radix portal

Co-Authored-By: unknown <>

* fix: handle calendar event clicks in onInteractOutside to prevent sheet close/reopen

Co-Authored-By: unknown <>

* fix: stop arrow key propagation to prevent Radix dropdown from opening during booking navigation

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

* fix: always stop arrow key propagation when sheet is active, even at first/last booking

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

* refactor: extract keyboard handler into testable utility with tests

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

* fix: resolve type errors in keyboard handler config and test mocks

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

* test: add e2e tests for booking sheet keyboard shortcuts

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

* fix: replace text= locators with data-testid selectors in e2e tests

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

* made 3 fixes: Fix 1 — Stabilize handleNext/handlePrevious/handleClose with useCallback
In BookingDetailsSheet.tsx, all three handler functions were plain arrow functions recreated on every render, causing the useEffect to tear down and re-attach the document keydown listener unnecessarily. Wrapped all three in useCallback with proper dependency arrays (the Zustand store functions they call).
Fix 2 — data-booking-list-item verified (no change needed)
Confirmed that data-booking-list-item is rendered on BookingListItem.tsx and data-booking-calendar-event is rendered on Event.tsx. The onInteractOutside handler in the final merged state correctly checks both selectors. No code change required.
Fix 3 — Removed dead code from JoinMeetingButton
Reverted JoinMeetingButton back to a plain function component:
Removed forwardRef wrapping (no caller passes a ref)
Removed showTooltip prop (unused — tooltip is handled by the parent BookingDetailsSheet)
Removed ref prop from the inner Button
Removed unused Tod forwardRef imports

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Dhairyashil <dhairyashil10101010@gmail.com>
Co-authored-by: eunjae@cal.com <hey@eunjae.dev>
Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com>
2026-02-19 22:32:43 +05:30
5d65df9c05 chore: migrate booking requested webhook trigger (#27546)
* init

* wiring up

* fix type

* feat: implement DI pattern for webhook producer in API v2

- Export IWebhookProducerService and getWebhookProducer from platform-libraries
- Add WEBHOOK_PRODUCER token and useFactory provider in RegularBookingModule
- Inject webhookProducer in RegularBookingService and pass to base class

This follows the composition root pattern where only the NestJS module
knows about getWebhookProducer(), and all consumers depend only on the
IWebhookProducerService interface via constructor injection.

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

* test: migrate BOOKING_REQUESTED tests to new webhook architecture

- Remove failing BOOKING_REQUESTED tests from fresh-booking.test.ts (4 tests)
- Remove failing BOOKING_REQUESTED tests from reschedule.test.ts (2 tests)
- Remove failing BOOKING_REQUESTED test from collective-scheduling.test.ts (1 test)
- Replace WebhookTaskConsumer.test.ts with placeholder (constructor changed)
- Create new webhook architecture test suite:
  - producer/WebhookTaskerProducerService.test.ts (14 tests)
  - consumer/WebhookTaskConsumer.test.ts (8 tests)
  - consumer/triggers/booking-requested.test.ts (8 tests)

The new test suite is organized by trigger type for extensibility as more
triggers are migrated to the producer/consumer pattern.

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

* test: remove paid events BOOKING_REQUESTED test (moved to new architecture)

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

* wrap webhook in own try-catch

* wire datafetcher

* fix

* fix v2

* fix circular dependency

* --

* merge-conflict-resolve

* mreg-conflict-resolve

* remove early return

* test: add integration tests for BOOKING_REQUESTED webhook producer invocation

Cover all 8 scenarios verifying the booking flow correctly invokes
the webhook producer for BOOKING_REQUESTED:

1. Basic confirmation → queueBookingRequestedWebhook called
2. Booker-is-organizer + confirmation → still called
3. Confirmation threshold NOT met → not called (BOOKING_CREATED instead)
4. Confirmation threshold IS met → called
5. Paid event + confirmation → called after payment succeeds
6. Reschedule + confirmation (non-organizer) → called (not BOOKING_RESCHEDULED)
7. Reschedule + confirmation (organizer) → not called (BOOKING_RESCHEDULED instead)
8. Collective scheduling + confirmation → called

Adds reusable MockWebhookProducer helper in @calcom/testing for
extendable use as more webhook triggers migrate to the new architecture.

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

* fix bug

* fix conditional check

* remove unnecessary comment

* add missing expect

* remove empty test

* clean up

* tasker config

* --

* fix missing metadata

* remove faulty if else

* test: add payload content verification tests for BOOKING_REQUESTED webhook

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

* remove unnecessary tests

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Hariom Balhara <1780212+hariombalhara@users.noreply.github.com>
2026-02-19 20:21:26 +04:00
Peer RichelsenandDevin AI 66ce20245c docs: declare credentials and environment variables in calcom-api skill (#28047)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-19 14:32:41 +00:00
c321a6c07a feat: owner can test non accepted OAuth client (#27525)
* refactor: combine exchange and refresh into token endpoint

* refactor: controller error handling

* refactor: use snake_case

* refactor: use snake_case

* refactor: use snake case

* refactor: token endpoint accepts application/x-www-form-urlencoded

* refactor: token endpoint accepts application/x-www-form-urlencoded

* refactor: flat token response data

* refactor: error structure

* refactor: client_id in the body

* fix: address Cubic AI review feedback on OAuth2 endpoints

- Fix getClient endpoint to use proper REST API error format instead of OAuth token error format (confidence 9/10)
- Add missing space after comma in error format string in token.input.pipe.ts (confidence 9/10)
- Support both camelCase and snake_case inputs in authorize endpoint for backward compatibility (confidence 10/10)
- Restore legacy /exchange and /refresh endpoints alongside new /token endpoint for backward compatibility (confidence 10/10)
- Add OAuth2TokensResponseDto for legacy endpoint wrapped responses
- Add OAuth2LegacyExchangeInput and OAuth2LegacyRefreshInput for legacy endpoints

Co-Authored-By: unknown <>

* fix: address additional Cubic AI feedback on OAuth2 endpoints

- Log errors when status code >= 500 in handleClientError (confidence 9/10)
- Add Cache-Control: no-store and Pragma: no-cache headers to legacy /exchange and /refresh endpoints (confidence 9/10)

Co-Authored-By: unknown <>

* docs

* Revert "fix: address additional Cubic AI feedback on OAuth2 endpoints"

This reverts commit 39cc4aa3ebb9e59a171541d7010398425995ed89.

* Revert "fix: address Cubic AI review feedback on OAuth2 endpoints"

This reverts commit 97bf593186db04c0859f9ca30950c9e3e524019d.

* docs

* fix: address Cubic AI review feedback on OAuth2 endpoints

- Fix getClient to use handleClientError instead of handleTokenError (confidence 10)
- Restore legacy /exchange and /refresh endpoints for backward compatibility (confidence 9)
- Fix RFC 6749 error format: use human-readable messages in error_description (confidence 9)
- Fix errorDescription in OAuthService to use OAUTH_ERROR_REASONS mapping (confidence 9)

Co-Authored-By: unknown <>

* fix: address additional Cubic AI feedback on OAuth2 endpoints

- Fix security issue: Replace 'CALENDSO_ENCRYPTION_KEY is not set' with generic 'Internal server configuration error' message (confidence 10/10)
- Fix backward compatibility: Create OAuth2LegacyTokensDto with camelCase properties for legacy /exchange and /refresh endpoints (confidence 9/10)
- Skipped: RFC 6749 error field issue (confidence 8/10, below threshold)

Co-Authored-By: unknown <>

* e2e

* Revert "fix: address additional Cubic AI feedback on OAuth2 endpoints"

This reverts commit a080e93f07aaf5a7dcf81fe605012cb7ebcdc192.

* Revert "fix: address Cubic AI review feedback on OAuth2 endpoints"

This reverts commit 04986a16c981521ca97069152457bf521a9ee45f.

* fix: re-apply Cubic AI review feedback on OAuth2 endpoints

- Restore OAuth2LegacyExchangeInput and OAuth2LegacyRefreshInput classes
- Restore legacy /exchange and /refresh endpoints in OAuth2Controller
- Restore OAuth2LegacyTokensDto and OAuth2TokensResponseDto classes
- Restore OAUTH_ERROR_DESCRIPTIONS mapping in oauth2-error.service.ts
- Restore OAUTH_ERROR_REASONS lookup in OAuthService.ts mapErrorToOAuthError
- Fix encryption_key_missing error to not expose internal env var name

Addresses Cubic AI feedback with confidence >= 9/10:
- Comment 32 (9/10): Legacy endpoints and input classes
- Comment 34 (9/10): Error description mapping in OAuthService
- Comment 35 (10/10): OAUTH_ERROR_DESCRIPTIONS in error service

Skipped (confidence < 9/10):
- Comment 33 (8/10): getClient handleTokenError vs handleClientError

Co-Authored-By: unknown <>

* Revert "fix: re-apply Cubic AI review feedback on OAuth2 endpoints"

This reverts commit 416bef9c931d9a7ed78c65a70a3425550d61b151.

* delete unused file

* fix: e2e tests

* address cubic review

* fix: address Cubic AI review feedback on OAuth2 exception filter

- Fix header case sensitivity: use lowercase 'x-request-id' instead of 'X-Request-Id' since Express lowercases all request headers
- Redact request body in error logs to prevent exposing sensitive OAuth2 credentials like client_secret, password, and refresh_token

Co-Authored-By: unknown <>

* docs: api v2 oauth controller docs

* chore: remove authorize endpoint

* feat: owner can test non accepted OAuth client

* fix: remove sensitive data from OAuth2 exception logs

Remove Authorization header and userEmail from error logs in
OAuth2HttpExceptionFilter to avoid logging sensitive information.

Addresses Cubic AI review feedback (confidence 9/10).

Co-Authored-By: unknown <>

* fix: e2e

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-19 08:11:46 -03:00
0eb2c15b39 fix: handle JSON string name field from URL prefill for firstAndLastName variant (#28039)
* fix: handle JSON string name field from URL prefill for firstAndLastName variant

* refactoring logic

* improve test coverage

* remove unnecessary comment

---------

Co-authored-by: Maks Pikov <mixelburg@users.noreply.github.com>
Co-authored-by: Romit <romitgabani1.work@gmail.com>
Co-authored-by: Romit <85230081+romitg2@users.noreply.github.com>
2026-02-19 05:14:51 +05:30
9d29055808 feat: replace admin API settings page with link to API docs (#28046)
* feat: replace admin API settings page with link to API docs

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

* chore: remove unused AdminAPIView component

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

* feat: rename admin API tab to API Docs

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-18 17:12:24 -05:00
74f3608b42 fix: Billings page reorganised (#28053)
* billings page reorganised

* Update redirect-apps.generated.ts

* small border update

* udpate for organization billing page

---------

Co-authored-by: Romit <romitgabani1.work@gmail.com>
Co-authored-by: Romit <85230081+romitg2@users.noreply.github.com>
2026-02-18 21:50:38 +00:00
9bfa4165b9 fix: prevent sending raw errors in responses (#24282)
* fix: prevent sending raw errors in responses

* correct already existing typo

* style: fix import ordering per biome lint

Co-Authored-By: unknown <>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-19 02:43:14 +05:30
cal-com-ci[bot]andLingo.dev 8a96a45424 feat: update translations via @LingoDotDev (#27639)
Co-authored-by: Lingo.dev <support@lingo.dev>
2026-02-18 16:32:10 -03:00
Deepanshu 0787c4890e fix: add toggle in workflow form (#27983) 2026-02-18 18:39:11 +01:00
Eunjae Lee 51e902f03e chore: pause syncing rules to devin (#28041)
* chore: pause syncing rules to devin

* Revert "chore: pause syncing rules to devin"

This reverts commit dd0e55322fe079b62207c9a4b58c9d24ce1c0dda.

* disable temporarily
2026-02-18 16:35:45 +00:00
Anirban SinghaandSahitya Chandra 9b1bf29ecf chore: remove stale Vitest workspace TODO (#27667)
Co-authored-by: Sahitya Chandra <sahityajb@gmail.com>
2026-02-18 14:50:58 +00:00
Hariom BalharaandDevin AI ad65fbbd81 fix: change URL prefill behavior to skip only invalid fields (#26982)
* fix: change URL prefill behavior to skip only invalid fields

Previously, when prefilling booking form fields from URL query parameters,
if any single field failed validation, all prefilling was skipped and an
empty object was returned.

This change modifies the behavior so that only invalid fields are skipped,
while valid fields are still prefilled. The implementation:

- Adds validateSingleFieldForPrefill() to validate individual fields
- Adds parseFieldByField() to collect valid fields when full parse fails
- Modifies getBookingResponsesPartialSchema to use field-by-field parsing
  as a fallback when full schema validation fails
- Logs which specific fields were skipped and why

This improves user experience by preserving valid prefill data even when
some fields have invalid values.

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

* refactor: reduce code duplication in partial prefill validation

Simplify the implementation by integrating field-by-field error handling
directly into the existing preprocess() function instead of using separate
helper functions.

Changes:
- Remove validateSingleFieldForPrefill() and parseFieldByField() functions
- Simplify getBookingResponsesPartialSchema to directly use preprocess()
- Add try-catch wrapper in preprocess() when isPartialSchema=true
- Continue processing other fields even if one fails during preprocessing
- Log skipped fields with reasons at debug level

This reduces code duplication while maintaining the same behavior of
skipping only invalid fields during URL prefill.

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

* fixes

* refactor: rename getRefinedValue to superRefineField and add edge case tests

- Rename getRefinedValue to superRefineField for clarity
- Remove duplicate multiselect test block
- Add test for empty object name field during partial prefill
- Add test for invalid URL format during partial prefill
- Add test for null responses during partial prefill

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

* Simplifu

* Simplif more

* Enhance booking responses schema tests to validate required fields and name variants. Updated error handling for empty names and added tests for first and last name requirements. Refactored schema validation logic to improve clarity and maintainability.

* fix types

* test: add test for email validation behavior during partial prefill

Documents that email validation is relaxed to z.string() during partial prefill,
consistent with phone field behavior where partial values like '+91' are accepted.
This addresses Cubic AI feedback (confidence 9/10) to verify email handling.

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

* fix

* Add tests: packages/features/bookings/lib/getBookingResponsesSchema.test.ts

Generated by Paragon from proposal for PR #26982

* Revert "Add tests: packages/features/bookings/lib/getBookingResponsesSchema.test.ts"

This reverts commit fc2b7ad7ca8099bc4f68e97b383ea16b41cdd772.

* Simplify

* fix: sanitize partial prefill log to avoid exposing user input data

Replaced raw Zod issues array in console.warn with a count of
validation errors to prevent sensitive user data from being logged.

Issue identified by cubic (confidence 9/10).

Co-Authored-By: unknown <>

* fix

* Remove throw Error

* Refactor booking responses schema tests to use helper functions for assertions

- Introduced `expectResponsesToBe` and `expectParsingToFail` helper functions to streamline test assertions.
- Replaced direct assertions with these helper functions for improved readability and maintainability.
- Removed redundant error handling code to simplify test logic.

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-18 20:01:23 +05:30
eef47ddefd feat: add duplicate functionality for managed event types (#26792)
* feat: add duplicate functionality for managed event types

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

* feat: add disclaimer and redirect for managed event type duplication

- Add info alert disclaimer in duplicate modal for managed event types
- Pass schedulingType to duplicate dialog via query params
- Redirect to assignment tab after duplicating managed event types
- Add translation key for managed event type duplicate disclaimer

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

* test: add tests for managed event type duplication

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

* refactor: replace PrismaMock unit test with Prisma integration test for duplicate handler (#27973)

* refactor: replace PrismaMock unit test with Prisma integration test for duplicate handler

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

* fix: re-throw TRPCErrors in duplicate handler catch block and assert specific error codes in tests

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

---------

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Hariom Balhara <1780212+hariombalhara@users.noreply.github.com>
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
2026-02-18 16:49:23 +04:00
8908a66c23 fix: allow booking rejection via GET for email clients that do not support forms (#28029)
* fix: allow booking rejection via GET for email clients that do not support forms

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

* Refactor test cases for GET handler in route.test.ts

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-18 17:23:42 +05:30
Alex van AndelandDevin AI a78a3ffe39 refactor: optimize team booking limits query with in-app filtering (#28021)
Replace 3 sequential queries (with expensive attendee email IN clauses)
with 2 parallel queries using indexed eventType.teamId filtering and
in-application user filtering with Sets for O(1) lookups.

Fix managed events booking-limit behavior: managed bookings now only
match by organizer userId (not attendee email), preserving the original
query semantics where managed bookings used userId-only matching.

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-18 11:47:30 +00:00
a6a428c8cb feat: make actionSource required with ValidActionSource type across booking handlers (#27869)
* feat: make actionSource required with ValidActionSource type across booking handlers

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

* fix: update getAuditActionSource to return ValidActionSource, default to SYSTEM

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

* fix: revert getAuditActionSource, handleSeats, RegularBookingService back to ActionSource

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: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2026-02-18 07:34:25 -03:00
Pasquale Vitiello 217c6e6a76 chore: re-pull coss-ui components (#28032) 2026-02-18 10:08:35 +00:00
b1eb5a2809 feat: api v2 POST booking attendees endpoint (#27759)
* feat: add booking attendee endpoint

* chore: add attendee added email for add guests handler

* cleanup

* fix: restore BookingPbacGuard to prevent IDOR vulnerability in booking attendees endpoint

Co-Authored-By: unknown <>

* chore: implement review feedback, add a core service for booking attendees endpoint

* chore: implement PR feedback

* fix: dont reuse add guests handler instead add logic to add attendee function

* fix: revert previous changes

* chore: implement cubic feedback

* fix: e2e tests

* fix: e2e tests

* fix: remove convertTRPCErrorToErrorWithCode not needed

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-17 20:46:18 +00:00
Anirban Singha bd5d94a4ba fix: localize remaining hardcoded strings in PayPal setup (#27908)
* chore: add en locale key-value for paypal setup

* fix: add translation function for paypal setup

* chore: avoid merge conflicts
2026-02-17 17:36:45 +00:00
Shrey-SutariyaandRomit 3cfe295ed5 Commit 1 (#28011)
Co-authored-by: Romit <85230081+romitg2@users.noreply.github.com>
2026-02-17 16:51:21 +00:00
20dcef6680 fix: validate schedule title input to block invalid characters (#27818)
* fix(availability): validate schedule title input to block invalid characters

* fix: add english translation for invalid_characters_in_name

* fix(schedule): allow Unicode characters in schedule name validation

* fix(schedule): update validation message to match regex

* fix: tsconfig target

* fix(schedule): add required validation in NewScheduleButton using translation

* fix(schedule): allow ASCII apostrophe in schedule name validation

* Update ScheduleListItem.tsx

* fix: remove unused @ts-expect-error directives

* fix: remove tsconfig target change

* Revert "fix: remove tsconfig target change"

This reverts commit d4992caf9b71cdfe32ac9db9a509128548097a30.

* Revert "fix: remove unused @ts-expect-error directives"

This reverts commit 913eda500aeccdd67b15cf1898f6db376bf3dda9.

* fix: update validation regex to support unicode characters

* fix: remove es6 from tsconfig

* Update NewScheduleButton.tsx

---------

Co-authored-by: Deepanshu Verma <deepanshuverma186@email.com>
Co-authored-by: Sahitya Chandra <sahityajb@gmail.com>
2026-02-17 22:12:38 +05:30
11b65b295b test: add missing negation operator tests for TEXT, NUMBER, and compound rules (#27690)
* Fix exclusion filter - include all team members

* Fix display when members aren't saved in the DB

* Update tests

* test: add missing negation operator tests for TEXT, NUMBER, and compound rules

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

* fix: revert non-intentional changes to AddMembersWithSwitch.tsx

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

---------

Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2026-02-17 15:45:36 +00:00
4c73695d3a fix: refresh slots on timezone change for booker timezone restrictions (#27491)
* fix: refresh slots on timezone change for booker timezone restrictions

* refactor: use useMemo for timezone change detection

* revert: remove unnecessary formatting changes

* feat: add timezone refresh for platform components

Add timezone change detection and slot refresh to BookerPlatformWrapper
and EventTypeCalendarViewComponent to handle restriction schedules with
useBookerTimezone enabled.

* refactor: extract timezone slot refresh logic into reusable hook

* Update packages/platform/atoms/calendar-view/EventTypeCalendarViewComponent.tsx

Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>

* Update packages/platform/atoms/calendar-view/EventTypeCalendarViewComponent.tsx

Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>

* fix

* fix: prevent unnecessary getSchedule calls when useBookerTimezone is disabled

* fix: add timezone fields to BookerEvent type

* fix: add missing properties to BookerEvent and BookerEventProfile types

* trying to fix type errors

* Add restrictionScheduleId and useBookerTimezone fields

* fix: correct import path for useBookerTime hook

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

* fix: explicitly include restrictionScheduleId and useBookerTimezone in getPublicEvent return

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

* chore: trigger fresh CI build

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

* fix: cast event.data to BookerEvent for timezone fields access

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

* refactor: address review feedback for timezone slot refresh

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

* refactor: add explicit return type to event handler to ensure type propagation

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

* refactor: extract useStableTimezone hook and remove dead timezone detection code

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

---------

Co-authored-by: hackice20 <yashkam431@gmail.com>
Co-authored-by: Yash <116657771+hackice20@users.noreply.github.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-17 07:46:40 -03:00
Raihanullah Shamsi ab4eff1fe1 fix: translate hardcoded 'mins' on payment page (#27998) 2026-02-17 09:26:25 +00:00
38b43f75ba refactor: remove circular dependencies from CalendarView atom (#27850)
* fix: decouple large calendar from features

* fix: move calendar to features since this is the better approach

* chore: move OOO slots to features

* feat: add DefaultOutOfOfficeSlot fallback for calendar OOO rendering

Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com>

* chore: cleanup unused LargeCalendar component

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-17 08:14:33 +00:00
Hariom Balhara 9603831def refactor: update BookingHistory component to use date-fns for timestamp formatting (#27851)
Replaced dayjs with date-fns for better performance and consistency in date handling. Added Tooltip for displaying formatted timestamps and improved search functionality by translating field labels and values. Cleaned up code for better readability.
2026-02-17 13:22:16 +05:30
2bc17313fd fix: deep link reschedule audit log to booking drawer history tab (#27709)
* fix: deep link reschedule audit log to booking drawer history tab

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

* fix: make booking drawer tab-agnostic for cross-tab deep links

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

* fix: use status-agnostic /bookings URL for audit log deep links

- Update audit service URLs from /bookings/upcoming?uid=... to /bookings?uid=...
- Add /bookings/page.tsx redirect that routes to /bookings/upcoming preserving query params
- Update tests to expect new URL format

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

* fixes

* refactor: use client-side replaceState instead of server redirect for booking deep links

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

* fix: use router.replace instead of replaceState to update tab and booking list

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

* improvements

* refactor: extract deep link logic from BookingListContainer into usePreSelectedBooking hook

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

* fix: use proper BookingOutput status type in test helper

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

* fix: decouple getTabForBooking from BookingOutput type for simpler testing

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

* fix: accept Date | string for endTime in BookingForTabResolution interface

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

* refactor: eliminate initialBookingUid prop drilling and revert formatting-only changes

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

* fix: use AuditDeepLink wrapper to preserve target=_blank through ServerTrans cloneElement

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

* Improve code organization

* refactor: rename usePreSelectedBooking to useSwitchToCorrectStatusTab

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

* fix: push preSelectedBooking into store so drawer opens on direct navigation

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

* fixes

* fixes

* fixes

* fix

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2026-02-17 13:20:06 +05:30
3a7122d613 fix: revert assignmentReason breaking change in webhook payloads (#27891)
* fix: revert assignmentReason breaking change in webhook payloads

Remove the new { category, details } format from EventPayloadType to
maintain backward compatibility for webhook consumers. The new format
is stripped at all webhook payload construction sites by destructuring
assignmentReason out of CalendarEvent before spreading into the payload.

A sanitizeAssignmentReasonForWebhook function provides an additional
safety net in sendPayload itself. Emails and booking single view
continue to use the new format via CalendarEvent.

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

* fix: strip assignmentReason from handlePaymentSuccess webhook payload

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

* fix: strip assignmentReason from triggerWebhooks and handleSeats webhook payloads

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

* refactor: use zod safeParse instead of type assertion in sanitizeAssignmentReasonForWebhook

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-16 21:55:10 -03:00
Eunjae LeeandClaude Opus 4.6 21d28c9747 refactor: apply biome formatting to packages/trpc (#27928)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 12:46:42 -03:00
Volnei MunhozandDevin AI f8a93414a5 chore: remove companion app (moved to calcom/companion) (#27957)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-16 10:08:24 -03:00
DeepanshuandDeepanshu Verma 85e1ed3ab5 refactor: move cancel logic from inline onClick handler to dedicated function (#27972)
* refactor: move cancel booking logic into handleCancel function

* chore: remove unused comments per review feedback

---------

Co-authored-by: Deepanshu Verma <deepanshuverma186@email.com>
2026-02-16 09:46:42 +00:00
emrysal 4647333839 chore: release v6.2.0 2026-02-16 01:36:08 +00:00
Aritra Dey 9d4cb08c55 fix: Correct hours-to-days conversion in convertToNewDurationType (#27964)
Signed-off-by: Aritra Dey <adey01027@gmail.com>
2026-02-15 21:01:00 +05:30
Peer RichelsenandDevin AI 773fab858f fix: hide bookings opt-in banner on mobile viewport (#27965)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-15 14:24:21 +00:00
Aritra DeyandRomit ea0c92a267 fix: use maxLength parameter in truncateOnWord instead of hardcoded value (#27961)
Signed-off-by: Aritra Dey <adey01027@gmail.com>
Co-authored-by: Romit <85230081+romitg2@users.noreply.github.com>
2026-02-15 09:51:53 +00:00
Dhairyashil Shinde 2abefc412c update version (#27952) 2026-02-14 14:36:43 +00:00
Volnei MunhozandDevin AI 9d4b12b8d3 fix: lazy-load companion iframe to avoid requests on every page visit (#27946)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-14 11:41:43 +01:00
Anik Dhabal Babu c21281a55c preserve customReplyToEmail (#27941) 2026-02-13 18:09:39 +00:00
sean-brydon f5a813f531 Revert "revert (#27933)" (#27938)
This reverts commit 227ed64b49.
2026-02-13 23:36:50 +05:30
Dhairyashil Shinde fa53f54815 feat(companion): add configurable landing page (#27267)
* feat(companion): add configurable landing page feature

- Add useUserPreferences hook for persistent storage of landing page preference
- Add LandingPagePicker component for both iOS and Android/web platforms
- Update tabs index to redirect based on user preference
- Update bookings index to accept initial filter from URL params
- Add App Settings section in More screen with landing page selector
- Clear user preferences on logout for fresh state

* fix(companion): remove try-finally for React Compiler compatibility

* fix(companion): use router.replace for dynamic landing page redirect

Replace Redirect component with router.replace() to fix TypeScript
strict typing issue with expo-router's Href type for dynamic routes.

* fix(companion): use literal route strings for TypeScript strict typing

Use switch statement with literal route strings instead of dynamic
string variable to satisfy expo-router's strict Href type checking.

* working fix

* better css
2026-02-13 11:28:42 -03:00
Pedro Castro f07bed1a2a chore(deps): bump axios to 1.13.5 (#27864)
* chore: bump axios to 1.13.5

* chore: bump axios in apps/api/v2

* chore: dedupe follow-redirects to 1.15.11
2026-02-13 22:08:49 +09:00
sean-brydon 227ed64b49 revert (#27933) 2026-02-13 12:14:08 +00:00
Alex van AndelandDevin AI 237d7e95c4 fix: inline package.json descriptions in app-store _metadata.ts to fix Vitest RPC errors (#27931)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-13 08:41:39 -03:00
Eunjae LeeandDevin AI 896dfd50fd fix: prevent BookingDetailsSheet flicker when switching bookings (#27894)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-13 11:53:22 +01:00
Eunjae LeeandClaude Opus 4.6 e2119879ae refactor: apply biome formatting to packages/sms, prisma, emails, lib (#27880)
* refactor: apply biome formatting to small packages + packages/lib

Format packages/sms, packages/prisma, packages/platform/libraries,
packages/platform/examples, packages/platform/types, packages/emails,
and packages/lib.

Excludes packages/platform/examples/base/src/pages/[bookingUid].tsx
due to pre-existing lint errors.

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

* revert: remove packages/platform formatting changes

Revert biome formatting for packages/platform as requested.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 07:39:01 -03:00
7aefefc31f feat: enable onboarding-v3 globally and update e2e tests (#27922)
* feat: enable onboarding-v3 globally and update e2e tests

- Add migration to set onboarding-v3 feature flag enabled=true
- Rewrite onboarding.e2e.ts for v3 flow (Plan Selection → Personal Settings → Calendar)
- Update URL assertions in signup, team-invitation, org-invitation, ab-tests-redirect, and auth tests to accept both old and new onboarding paths

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

* fix: use pathname-only matching in waitForURL to prevent false matches on callbackUrl query param

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

* use test IDs

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2026-02-13 10:37:47 +00:00
1b6b91b0ad fix: display phone numbers and localized timezone in BookingDetailsSheet (#27909)
* fix: display phone numbers and localized timezone in BookingDetailsSheet

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

* refactor: replace selectAll with explicit column selects for Attendee query

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

* Update apps/web/modules/bookings/components/BookingDetailsSheet.tsx

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

* Add tests: packages/lib/dayjs/formatToLocalizedTimezone.test.ts

Generated by Paragon from proposal for PR #27909

* Revert "refactor: replace selectAll with explicit column selects for Attendee query"

This reverts commit f810ba801c900c8f065e5e1ce1d02a1700322257.

---------

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-02-13 10:27:01 +00:00
50c7210d6b fix: resolve signup watchlist review issues and auto-unlock on SIGNUP entry removal (#27923)
* fix: resolve signup watchlist review issues with deleteEntry, email verification ordering, and unlock flow

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

* fix: scope sendEmailVerification to non-invite signups only

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

* feat: auto-unlock users when SIGNUP-source watchlist entries are removed

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

* fix: remove PII from error logging in unlockSignupUser

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-13 10:13:33 +00:00
Eunjae LeeandDevin AI 1bb4b206c6 fix: default icon size to 16px in createIcon wrapper and fix download button alignment (#27924)
* fix: resolve insights page UI regressions for plus button size and download alignment

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

* fix: default icon size to 16px in createIcon wrapper

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-13 10:00:16 +00:00
Anik Dhabal Babu 6386b2ec95 fix: add e2e-spec pattern to CODEOWNERS test file exclusions (#27926)
* add e2e-spec pattern to CODEOWNERS

* Add pattern for test-suite files in CODEOWNERS
2026-02-13 15:18:32 +05:30
RomitandAnik Dhabal Babu 68abc63fe4 fix (#27902)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2026-02-13 09:46:37 +00:00
Syed Ali ShahbazandDevin AI bdeb4b1dc9 fix: use randomString() for booking uid in slots e2e tests to avoid unique constraint flake (#27916)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-13 06:18:52 -03:00
Devanshu SharmaandSahitya Chandra 1541730886 fix(app-store): preserve existing enabled state during seed (#27918)
* fix(app-store): preserve existing enabled state during seed

* Update seed-app-store.ts

---------

Co-authored-by: Sahitya Chandra <sahityajb@gmail.com>
2026-02-13 08:34:06 +00:00
4c6af5659a fix: exclude test files from CODEOWNERS foundation approval (#27919)
* fix: exclude test files from CODEOWNERS foundation approval

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

* fix: add *.test.* to CODEOWNERS test file exclusions

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

* fix: add *.integration-test.* to CODEOWNERS test file exclusions

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-13 14:01:51 +05:30
Anik Dhabal Babu a9951df501 show invite link settings modal (#27917) 2026-02-13 04:13:17 +00:00
1059 changed files with 24977 additions and 66999 deletions
-57
View File
@@ -1,57 +0,0 @@
# EAS Build ignore file for monorepo
# Only upload the companion app folder
# ===========================================
# IGNORE EVERYTHING AT ROOT LEVEL
# ===========================================
/*
# ===========================================
# EXCEPT THE COMPANION APP
# ===========================================
!companion
# ===========================================
# BUT IGNORE THESE INSIDE COMPANION
# ===========================================
# Native folders - EAS generates these during builds (~2.1 GB)
companion/ios
companion/android
# Dependencies - EAS installs these during builds (~1.6 GB)
companion/node_modules
# Expo build cache
companion/.expo
companion/dist
companion/web-build
# Metro
companion/.metro-health-check*
# WXT Chrome Extension outputs (not needed for mobile builds)
companion/.output
companion/.wxt
companion/dev
# Kotlin cache
companion/.kotlin
# Build artifacts
companion/*.jks
companion/*.p8
companion/*.p12
companion/*.key
companion/*.mobileprovision
# Debug logs
companion/*.log
# TypeScript build info
companion/*.tsbuildinfo
# Environment files
companion/.env
companion/.env.*
!companion/.env.example
+8
View File
@@ -37,3 +37,11 @@
/packages/trpc/server/routers/viewer/bookings/get.handler.ts @calcom/Foundation
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
/packages/ui/**/* @calcom/Consumer @calcom/Foundation
# Exclude test files from requiring foundation/consumer approval
**/*.spec.*
**/*.e2e.*
**/*.e2e-spec.*
**/*.test.*
**/*.test-suite.*
**/*.integration-test.*
-120
View File
@@ -1,120 +0,0 @@
---
name: Companion (iOS/Android/Extension)
about: Report bugs or request features for mobile apps and browser extension
title: ""
labels: ["companion", "🚨 needs approval"]
assignees: ""
---
### Issue Type
<!--
Select one:
- Bug Report
- Feature Request
-->
- [ ] 🐛 Bug Report
- [ ] ✨ Feature Request
### Platform
<!--
Select all that apply:
-->
- [ ] 📱 iOS App
- [ ] 🤖 Android App
- [ ] 🧩 Browser Extension
### Platform Details
<!--
Fill in the details for your selected platform(s).
Delete sections that don't apply.
-->
#### iOS
- **iOS Version**: (e.g., 17.2)
- **Device Model**: (e.g., iPhone 15 Pro)
- **App Version**: (e.g., 1.0.0)
#### Android
- **Android Version**: (e.g., 14)
- **Device Model**: (e.g., Pixel 8)
- **App Version**: (e.g., 1.0.0)
#### Browser Extension
- **Browser & Version**: (e.g., Chrome 120, Firefox 121)
- **Extension Version**: (e.g., 1.0.0)
---
### Description
<!--
Provide a clear and concise description of the issue or feature.
-->
(Write your description here.)
### Steps to Reproduce (Bug Reports Only)
<!--
If this is a bug report, provide steps to reproduce the issue.
Delete this section for feature requests.
-->
1. Go to...
2. Click on...
3. See error...
### Expected Behavior
<!--
What did you expect to happen?
-->
(Describe what you expected.)
### Actual Behavior (Bug Reports Only)
<!--
What actually happened? Delete this section for feature requests.
-->
(Describe what actually happened.)
### Proposed Solution (Feature Requests Only)
<!--
If this is a feature request, describe your proposed solution.
Delete this section for bug reports.
-->
(Describe your proposed solution.)
### Screenshots / Evidence
<!--
Add screenshots, screen recordings, or console logs if applicable.
You can use tools like Bird Eats Bug for recordings.
-->
(Attach evidence here.)
### Additional Context
<!--
Any other information that might be helpful.
-->
(Add any additional context here.)
---
##### House rules
- If this issue has a `🚨 needs approval` label, don't start coding yet. Wait until a core member approves the request by removing this label.
- Your ideas are invaluable! However, they undergo review to ensure alignment with the product's direction.
- Follow best practices in our [Contributor Docs](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
-44
View File
@@ -1,44 +0,0 @@
name: Companion Builds
on:
workflow_call:
permissions:
contents: read
env:
EXPO_PUBLIC_CAL_API_KEY: ${{ secrets.CI_EXPO_PUBLIC_CAL_API_KEY || vars.CI_EXPO_PUBLIC_CAL_API_KEY || vars.EXPO_PUBLIC_CAL_API_KEY }}
jobs:
build:
name: Build Companion App & Extension
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: .github
- uses: ./.github/actions/cache-checkout
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.3.0
cache: true
cache-dependency-path: companion/bun.lock
- name: Install dependencies
working-directory: companion
run: bun install --frozen-lockfile
- name: Build Expo web app
working-directory: companion
env:
EXPO_NO_TELEMETRY: 1
CI: true
run: bunx expo export --platform web
- name: Build browser extension
working-directory: companion
run: bun run ext:build
-33
View File
@@ -1,33 +0,0 @@
name: Companion Lint
on:
workflow_call:
permissions:
contents: read
jobs:
lint:
name: Lint Companion App
runs-on: blacksmith-2vcpu-ubuntu-2404
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: .github
- uses: ./.github/actions/cache-checkout
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.3.0
cache: false
- name: Install dependencies
working-directory: companion
run: bun install --frozen-lockfile
- name: Run lint
working-directory: companion
run: bun run lint:all
-34
View File
@@ -1,34 +0,0 @@
name: Companion Type Check
on:
workflow_call:
permissions:
contents: read
jobs:
typecheck:
name: Type Check Companion App
runs-on: blacksmith-2vcpu-ubuntu-2404
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: .github
- uses: ./.github/actions/cache-checkout
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.3.0
cache: true
cache-dependency-path: companion/bun.lock
- name: Install dependencies
working-directory: companion
run: bun install --frozen-lockfile
- name: Type check
working-directory: companion
run: bun run typecheck:all
+1 -37
View File
@@ -167,7 +167,6 @@ jobs:
pull-requests: read
outputs:
has-files-requiring-all-checks: ${{ steps.filter-exclusions.outputs.has-files-requiring-all-checks }}
has-companion: ${{ steps.filter-inclusions.outputs.has-companion }}
has-api-v2-changes: ${{ steps.filter-inclusions.outputs.has-api-v2-changes }}
has-prisma-changes: ${{ steps.filter-inclusions.outputs.has-prisma-changes }}
has-agents-changes: ${{ steps.filter-inclusions.outputs.has-agents-changes }}
@@ -197,14 +196,13 @@ jobs:
filters: |
has-files-requiring-all-checks:
- '**'
- '!companion/**'
- '!.vscode/**'
- '!**/*.md'
- '!**/*.mdx'
- '!.github/CODEOWNERS'
- '!docs/**'
- '!help/**'
- '!apps/web/public/static/locales/**/common.json'
- '!packages/i18n/locales/**/common.json'
- '!i18n.lock'
- name: Check for specific path changes
uses: dorny/paths-filter@v3
@@ -213,8 +211,6 @@ jobs:
filters: |
has-agents-changes:
- "agents/**"
has-companion:
- "companion/**"
has-api-v2-changes:
- "apps/api/v2/**"
- "packages/platform-constants/**"
@@ -387,27 +383,6 @@ jobs:
uses: ./.github/workflows/docs-build.yml
secrets: inherit
build-companion:
name: Companion builds
needs: [prepare]
if: needs.prepare.outputs.has-companion == 'true'
uses: ./.github/workflows/companion-build.yml
secrets: inherit
typecheck-companion:
name: Type Checks
needs: [prepare]
if: needs.prepare.outputs.has-companion == 'true'
uses: ./.github/workflows/companion-typecheck.yml
secrets: inherit
lint-companion:
name: Linters
needs: [prepare]
if: needs.prepare.outputs.has-companion == 'true'
uses: ./.github/workflows/companion-lint.yml
secrets: inherit
build:
name: Production builds
needs: [prepare]
@@ -489,9 +464,6 @@ jobs:
build-api-v2,
build-atoms,
build-docs,
build-companion,
typecheck-companion,
lint-companion,
setup-db,
e2e,
e2e-api-v2,
@@ -540,14 +512,6 @@ jobs:
needs.e2e-app-store.result != 'success'
)
) ||
(
needs.prepare.outputs.has-companion == 'true' &&
(
needs.build-companion.result != 'success' ||
needs.typecheck-companion.result != 'success' ||
needs.lint-companion.result != 'success'
)
) ||
(
needs.prepare.outputs.has-agents-changes == 'true' &&
needs.validate-agents-format.result != 'success'
@@ -12,6 +12,7 @@ permissions:
jobs:
sync:
if: false
name: Sync to Devin Knowledge
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
+6
View File
@@ -4,4 +4,10 @@ enableGlobalCache: false
nodeLinker: node-modules
# fast-xml-parser 4.4.1 via @boxyhq/saml-jackson → @aws-sdk/core@3.816.0 (transitive).
# Only parses trusted AWS API responses, not user input. No practical attack vector.
# Upstream fix pending: ory/polis (saml-jackson) has bumped to @aws-sdk@3.994.0 on main but hasn't released yet.
npmAuditIgnoreAdvisories:
- "1113407"
yarnPath: .yarn/releases/yarn-4.12.0.cjs
+2 -2
View File
@@ -12,7 +12,7 @@ You are a senior Cal.com engineer working in a Yarn/Turbo monorepo. You prioriti
- Create PRs in draft mode by default
- Run `yarn type-check:ci --force` before concluding CI failures are unrelated to your changes
- Import directly from source files, not barrel files (e.g., `@calcom/ui/components/button` not `@calcom/ui`)
- Add translations to `apps/web/public/static/locales/en/common.json` for all UI strings
- Add translations to `packages/i18n/locales/en/common.json` for all UI strings
- Use `date-fns` or native `Date` instead of Day.js when timezone awareness isn't needed
- Put permission checks in `page.tsx`, never in `layout.tsx`
- Use `ast-grep` for searching if available; otherwise use `rg` (ripgrep), then fall back to `grep`
@@ -126,7 +126,7 @@ packages/lib/ # Shared utilities
- Routes: `apps/web/app/` (App Router)
- Database schema: `packages/prisma/schema.prisma`
- tRPC routers: `packages/trpc/server/routers/`
- Translations: `apps/web/public/static/locales/en/common.json`
- Translations: `packages/i18n/locales/en/common.json`
- Workflow constants: `packages/features/ee/workflows/lib/constants.ts`
## Tech Stack
+7
View File
@@ -149,6 +149,13 @@ Here is what you need to be able to run Cal.com.
- Duplicate `.env.example` to `.env`
- Use `openssl rand -base64 32` to generate a key and add it under `NEXTAUTH_SECRET` in the `.env` file.
- Use `openssl rand -base64 24` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file.
> **Windows users:** Replace the `packages/prisma/.env` symlink with a real copy to avoid a Prisma error (`unexpected character / in variable name`):
>
> ```sh
> # Git Bash / WSL
> rm packages/prisma/.env && cp .env packages/prisma/.env
> ```
5. Setup Node
If your Node version does not meet the project's requirements as instructed by the docs, "nvm" (Node Version Manager) allows using Node at the version required by the project:
+1 -1
View File
@@ -33,7 +33,7 @@ tags: reference, navigation, file-locations
## Translations
- English: `apps/web/public/static/locales/en/common.json`
- English: `packages/i18n/locales/en/common.json`
## App Store
+22
View File
@@ -1,6 +1,19 @@
---
name: calcom-api
description: Interact with the Cal.com API v2 to manage scheduling, bookings, event types, availability, and calendars. Use this skill when building integrations that need to create or manage bookings, check availability, configure event types, or sync calendars with Cal.com's scheduling infrastructure.
env:
CAL_API_KEY:
description: "Cal.com API key (prefixed with cal_live_ or cal_test_). Required for all API requests."
required: true
CAL_CLIENT_ID:
description: "OAuth client ID for platform integrations managing users on behalf of others. Sent as x-cal-client-id header."
required: false
CAL_SECRET_KEY:
description: "OAuth client secret for platform integrations. Sent as x-cal-secret-key header."
required: false
CAL_WEBHOOK_SECRET:
description: "Secret used to verify webhook payload signatures via X-Cal-Signature-256 header."
required: false
---
# Cal.com API v2
@@ -14,6 +27,15 @@ All API requests should be made to:
https://api.cal.com/v2
```
## Required Credentials
| Environment Variable | Required | Description |
|---------------------|----------|-------------|
| `CAL_API_KEY` | Yes | Cal.com API key (prefixed with `cal_live_` or `cal_test_`). Used as Bearer token for all API requests. Generate from Settings > Developer > API Keys. |
| `CAL_CLIENT_ID` | No | OAuth client ID for platform integrations that manage users on behalf of others. Sent as `x-cal-client-id` header. |
| `CAL_SECRET_KEY` | No | OAuth client secret for platform integrations. Sent as `x-cal-secret-key` header. |
| `CAL_WEBHOOK_SECRET` | No | Secret for verifying webhook payload signatures via the `X-Cal-Signature-256` header. |
## Authentication
All API requests require authentication via Bearer token:
-10
View File
@@ -1,10 +0,0 @@
const path = require("node:path");
const i18nConfig = require("@calcom/config/next-i18next.config");
/** @type {import("next-i18next").UserConfig} */
const config = {
...i18nConfig,
localePath: path.resolve("../../web/public/static/locales"),
};
module.exports = config;
@@ -82,6 +82,7 @@ async function handler(req: NextApiRequest) {
return await handleCancelBooking({
bookingData: { id, allRemainingBookings, cancellationReason, cancelledBy },
userId: req.userId,
actionSource: "API_V1",
});
}
+1 -2
View File
@@ -1,4 +1,3 @@
// TODO: Fix tests (These test were never running due to the vitest workspace config)
import prismaMock from "@calcom/testing/lib/__mocks__/prismaMock";
import type { Request, Response } from "express";
@@ -174,7 +173,7 @@ vi.mock("@calcom/features/ee/workflows/lib/getAllWorkflowsFromEventType", () =>
getAllWorkflowsFromEventType: vi.fn().mockResolvedValue([]),
}));
vi.mock("@calcom/lib/server/i18n", () => {
vi.mock("@calcom/i18n/server", () => {
const mockT = (key: string, options?: any) => {
if (key === "event_between_users") {
return `${options?.eventName} between ${options?.host} and ${options?.attendeeName}`;
+1 -1
View File
@@ -14,7 +14,7 @@ import handler from "../../../pages/api/users/_post";
type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
vi.mock("@calcom/lib/server/i18n", () => {
vi.mock("@calcom/i18n/server", () => {
return {
getTranslation: (key: string) => {
return () => key;
+1 -1
View File
@@ -1 +1 @@
export const TRIGGER_VERSION = '20260212.3';
export const TRIGGER_VERSION = '20260216.1';
-11
View File
@@ -1,11 +0,0 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const path = require("node:path");
const i18nConfig = require("@calcom/config/next-i18next.config");
/** @type {import("next-i18next").UserConfig} */
const config = {
...i18nConfig,
localePath: path.resolve("../../web/public/static/locales"),
};
module.exports = config;
+1 -1
View File
@@ -62,7 +62,7 @@
"@sentry/node": "9.46.0",
"@sentry/profiling-node": "9.46.0",
"@snyk/protect": "latest",
"axios": "1.13.2",
"axios": "1.13.5",
"body-parser": "1.20.3",
"bull": "4.15.1",
"class-transformer": "0.5.1",
@@ -279,6 +279,7 @@ export class BookingsController_2024_04_15 {
platformCancelUrl: bookingRequest.platformCancelUrl,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
actionSource: "API_V2",
});
if (!res.onlyRemovedAttendee && res.isPlatformManagedUserBooking) {
void (await this.billingService.cancelUsageByBookingUid(res.bookingUid));
@@ -1,9 +1,12 @@
import { Module } from "@nestjs/common";
import { BookingAttendeesController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/booking-attendees.controller";
import { BookingGuestsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/booking-guests.controller";
import { BookingLocationController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/booking-location.controller";
import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller";
import { BookingPbacGuard } from "@/ee/bookings/2024-08-13/guards/booking-pbac.guard";
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
import { BookingAttendeesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-attendees.service";
import { BookingGuestsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-guests.service";
import { BookingLocationService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-location.service";
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
@@ -12,17 +15,18 @@ import { CalVideoOutputService } from "@/ee/bookings/2024-08-13/services/cal-vid
import { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.service";
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { BookingEventHandlerModule } from "@/lib/modules/booking-event-handler.module";
import { InstantBookingModule } from "@/lib/modules/instant-booking.module";
import { RecurringBookingModule } from "@/lib/modules/recurring-booking.module";
import { RegularBookingModule } from "@/lib/modules/regular-booking.module";
@@ -50,8 +54,6 @@ import { TeamsModule } from "@/modules/teams/teams/teams.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common";
import { BookingEventHandlerModule } from "@/lib/modules/booking-event-handler.module";
@Module({
imports: [
@@ -81,6 +83,7 @@ import { BookingEventHandlerModule } from "@/lib/modules/booking-event-handler.m
OAuthClientRepository,
OAuthClientUsersService,
BookingsService_2024_08_13,
BookingAttendeesService_2024_08_13,
BookingGuestsService_2024_08_13,
InputBookingsService_2024_08_13,
OutputBookingsService_2024_08_13,
@@ -110,6 +113,7 @@ import { BookingEventHandlerModule } from "@/lib/modules/booking-event-handler.m
],
controllers: [
BookingsController_2024_08_13,
BookingAttendeesController_2024_08_13,
BookingGuestsController_2024_08_13,
BookingLocationController_2024_08_13,
],
@@ -0,0 +1,71 @@
import { BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import { AddAttendeeInput_2024_08_13 } from "@calcom/platform-types";
import { Body, Controller, HttpCode, HttpStatus, Param, Post, UseGuards } from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { BookingPbacGuard } from "@/ee/bookings/2024-08-13/guards/booking-pbac.guard";
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
import { AddAttendeeOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/add-attendee.output";
import { BookingAttendeesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-attendees.service";
import { VERSION_2024_08_13, VERSION_2024_08_13_VALUE } from "@/lib/api-versions";
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
import { Throttle } from "@/lib/endpoint-throttler-decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
@Controller({
path: "/v2/bookings/:bookingUid/attendees",
version: VERSION_2024_08_13_VALUE,
})
@UseGuards(PermissionsGuard)
@DocsTags("Bookings / Attendees")
@ApiHeader({
name: "cal-api-version",
description: `Must be set to ${VERSION_2024_08_13}. This header is required as this endpoint does not exist in older API versions.`,
example: VERSION_2024_08_13,
required: true,
})
export class BookingAttendeesController_2024_08_13 {
constructor(private readonly bookingAttendeesService: BookingAttendeesService_2024_08_13) {}
@Post("/")
@HttpCode(HttpStatus.CREATED)
@Permissions([BOOKING_WRITE])
@UseGuards(ApiAuthGuard, BookingUidGuard, BookingPbacGuard)
@Throttle({
limit: 5,
ttl: 60000,
blockDuration: 60000,
name: "booking_attendees_add",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Add an attendee to a booking",
description: `Add a new attendee to an existing booking by its UID.
**Side effects:**
- The booking's attendee list is updated in the database
- The calendar event is updated on connected calendars (Google Calendar, Outlook, etc.) to include the new attendee
- An email notification is sent to the new attendee with the booking details
**Permissions:**
- The authenticated user must be either the booking organizer, an existing attendee, or have the \`booking.update\` permission for the team
<Note>The cal-api-version header is required for this endpoint. Without it, the request will fail with a 404 error.</Note>
`,
})
async addAttendee(
@Param("bookingUid") bookingUid: string,
@Body() body: AddAttendeeInput_2024_08_13,
@GetUser() user: ApiAuthGuardUser
): Promise<AddAttendeeOutput_2024_08_13> {
const attendee = await this.bookingAttendeesService.addAttendee(bookingUid, body, user);
return {
status: SUCCESS_STATUS,
data: attendee,
};
}
}
@@ -0,0 +1,415 @@
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import {
AttendeeAddGuestsEmail,
AttendeeScheduledEmail,
OrganizerAddGuestsEmail,
} from "@calcom/platform-libraries/emails";
import type { BookingOutput_2024_08_13, CreateBookingInput_2024_08_13 } from "@calcom/platform-types";
import type { Team, User } from "@calcom/prisma/client";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import { mockThrottlerGuard } from "test/utils/withNoThrottler";
import { AppModule } from "@/app.module";
import { bootstrap } from "@/bootstrap";
import { AddAttendeeOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/add-attendee.output";
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersModule } from "@/modules/users/users.module";
const attendeeAddGuestsEmailSpy = jest
.spyOn(AttendeeAddGuestsEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
const organizerAddGuestsEmailSpy = jest
.spyOn(OrganizerAddGuestsEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
const attendeeScheduledEmailSpy = jest
.spyOn(AttendeeScheduledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
type TestUser = {
user: User;
accessToken: string;
refreshToken: string;
};
type TestSetup = {
organizer: TestUser;
unrelatedUser: TestUser;
eventTypeId: number;
bookingUid: string;
bookingId: number;
};
describe("Bookings Endpoints 2024-08-13 add attendee", () => {
let app: INestApplication;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let tokensRepositoryFixture: TokensRepositoryFixture;
let testSetup: TestSetup;
beforeAll(async () => {
mockThrottlerGuard();
const moduleRef = await Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
tokensRepositoryFixture = new TokensRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({
name: `add-attendee-2024-08-13-organization-${randomString()}`,
});
await setupTestData();
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
async function setupTestData() {
const oAuthClient = await createOAuthClient(organization.id, true);
const organizerUser = await userRepositoryFixture.create({
email: `add-attendee-2024-08-13-organizer-${randomString()}@api.com`,
platformOAuthClients: {
connect: { id: oAuthClient.id },
},
});
const unrelatedUserData = await userRepositoryFixture.create({
email: `add-attendee-2024-08-13-unrelated-${randomString()}@api.com`,
platformOAuthClients: {
connect: { id: oAuthClient.id },
},
});
const organizerTokens = await tokensRepositoryFixture.createTokens(organizerUser.id, oAuthClient.id);
const unrelatedUserTokens = await tokensRepositoryFixture.createTokens(
unrelatedUserData.id,
oAuthClient.id
);
const schedule: CreateScheduleInput_2024_04_15 = {
name: `add-attendee-2024-08-13-schedule-${randomString()}`,
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(organizerUser.id, schedule);
const eventType = await eventTypesRepositoryFixture.create(
{
title: `add-attendee-2024-08-13-event-type-${randomString()}`,
slug: `add-attendee-2024-08-13-event-type-${randomString()}`,
length: 60,
},
organizerUser.id
);
testSetup = {
organizer: {
user: organizerUser,
accessToken: organizerTokens.accessToken,
refreshToken: organizerTokens.refreshToken,
},
unrelatedUser: {
user: unrelatedUserData,
accessToken: unrelatedUserTokens.accessToken,
refreshToken: unrelatedUserTokens.refreshToken,
},
eventTypeId: eventType.id,
bookingUid: "",
bookingId: 0,
};
}
async function createOAuthClient(organizationId: number, emailsEnabled: boolean) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
areEmailsEnabled: emailsEnabled,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
beforeEach(async () => {
attendeeAddGuestsEmailSpy.mockClear();
organizerAddGuestsEmailSpy.mockClear();
attendeeScheduledEmailSpy.mockClear();
});
describe("POST /v2/bookings/:bookingUid/attendees", () => {
beforeAll(async () => {
const createBookingBody: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 10, 13, 0, 0)).toISOString(),
eventTypeId: testSetup.eventTypeId,
attendee: {
name: "Original Attendee",
email: "original.attendee@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
userId: "100",
},
};
const createBookingResponse = await request(app.getHttpServer())
.post("/v2/bookings")
.send(createBookingBody)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201);
const createBookingResponseBody: CreateBookingOutput_2024_08_13 = createBookingResponse.body;
expect(createBookingResponseBody.status).toEqual(SUCCESS_STATUS);
if (!responseDataIsBooking(createBookingResponseBody.data)) {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
testSetup.bookingUid = createBookingResponseBody.data.uid;
testSetup.bookingId = createBookingResponseBody.data.id;
});
describe("Authentication", () => {
it("should return 401 when adding attendee without authentication", async () => {
const addAttendeeBody = {
email: "unauthenticated.attendee@example.com",
name: "Unauthenticated Attendee",
timeZone: "Europe/London",
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings/${testSetup.bookingUid}/attendees`)
.send(addAttendeeBody)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(response.status).toBe(401);
});
});
describe("Authorization", () => {
it("should allow booking organizer to add an attendee", async () => {
const addAttendeeBody = {
email: "new.attendee@example.com",
name: "New Attendee",
timeZone: "Europe/London",
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings/${testSetup.bookingUid}/attendees`)
.send(addAttendeeBody)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
.expect(201);
const responseBody: AddAttendeeOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.email).toEqual(addAttendeeBody.email);
expect(responseBody.data.name).toEqual(addAttendeeBody.name);
expect(responseBody.data.timeZone).toEqual(addAttendeeBody.timeZone);
expect(responseBody.data.bookingId).toEqual(testSetup.bookingId);
expect(responseBody.data.id).toBeDefined();
});
it("should return 403 when unrelated user tries to add attendee", async () => {
const addAttendeeBody = {
email: "unauthorized.attendee@example.com",
name: "Unauthorized Attendee",
timeZone: "America/New_York",
};
await request(app.getHttpServer())
.post(`/v2/bookings/${testSetup.bookingUid}/attendees`)
.send(addAttendeeBody)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set("Authorization", `Bearer ${testSetup.unrelatedUser.accessToken}`)
.expect(403);
});
});
describe("Response format", () => {
it("should return attendee with correct structure", async () => {
const addAttendeeBody = {
email: "structure.test@example.com",
name: "Structure Test Attendee",
timeZone: "Asia/Tokyo",
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings/${testSetup.bookingUid}/attendees`)
.send(addAttendeeBody)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
.expect(201);
const responseBody: AddAttendeeOutput_2024_08_13 = response.body;
expect(responseBody).toHaveProperty("status", SUCCESS_STATUS);
expect(responseBody).toHaveProperty("data");
expect(responseBody.data).toHaveProperty("id");
expect(responseBody.data).toHaveProperty("bookingId");
expect(responseBody.data).toHaveProperty("name");
expect(responseBody.data).toHaveProperty("email");
expect(responseBody.data).toHaveProperty("timeZone");
expect(typeof responseBody.data.id).toBe("number");
expect(typeof responseBody.data.bookingId).toBe("number");
expect(typeof responseBody.data.name).toBe("string");
expect(typeof responseBody.data.email).toBe("string");
expect(typeof responseBody.data.timeZone).toBe("string");
});
});
describe("Validation", () => {
it("should return 400 when email is missing", async () => {
const addAttendeeBody = {
name: "No Email Attendee",
timeZone: "Europe/London",
};
await request(app.getHttpServer())
.post(`/v2/bookings/${testSetup.bookingUid}/attendees`)
.send(addAttendeeBody)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
.expect(400);
});
it("should return 400 when email is invalid", async () => {
const addAttendeeBody = {
email: "invalid-email",
name: "Invalid Email Attendee",
timeZone: "Europe/London",
};
await request(app.getHttpServer())
.post(`/v2/bookings/${testSetup.bookingUid}/attendees`)
.send(addAttendeeBody)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
.expect(400);
});
it("should return 400 when name is missing", async () => {
const addAttendeeBody = {
email: "no.name@example.com",
timeZone: "Europe/London",
};
await request(app.getHttpServer())
.post(`/v2/bookings/${testSetup.bookingUid}/attendees`)
.send(addAttendeeBody)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
.expect(400);
});
it("should return 400 when timeZone is missing", async () => {
const addAttendeeBody = {
email: "no.timezone@example.com",
name: "No TimeZone Attendee",
};
await request(app.getHttpServer())
.post(`/v2/bookings/${testSetup.bookingUid}/attendees`)
.send(addAttendeeBody)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
.expect(400);
});
it("should return 400 when timeZone is invalid", async () => {
const addAttendeeBody = {
email: "invalid.timezone@example.com",
name: "Invalid TimeZone Attendee",
timeZone: "Invalid/TimeZone",
};
await request(app.getHttpServer())
.post(`/v2/bookings/${testSetup.bookingUid}/attendees`)
.send(addAttendeeBody)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
.expect(400);
});
it("should return 403 when booking does not exist", async () => {
const addAttendeeBody = {
email: "nonexistent.booking@example.com",
name: "Nonexistent Booking Attendee",
timeZone: "Europe/London",
};
await request(app.getHttpServer())
.post(`/v2/bookings/non-existent-booking-uid/attendees`)
.send(addAttendeeBody)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
.expect(403);
});
});
});
afterAll(async () => {
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(testSetup.organizer.user.email);
await userRepositoryFixture.deleteByEmail(testSetup.unrelatedUser.user.email);
await bookingsRepositoryFixture.deleteAllBookings(
testSetup.organizer.user.id,
testSetup.organizer.user.email
);
await app.close();
});
function responseDataIsBooking(data: unknown): data is BookingOutput_2024_08_13 {
return !Array.isArray(data) && typeof data === "object" && data !== null && "id" in data;
}
});
@@ -1053,7 +1053,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
.set("Authorization", `Bearer ${apiKeyString}`)
.expect(400);
expect(response.body.message).toEqual("Cancellation reason is required when you are the host");
expect(response.body.message).toEqual("Cancellation reason is required");
});
it("should cancel seated booking", async () => {
@@ -0,0 +1,28 @@
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
import { BookingAttendee } from "@calcom/platform-types";
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsEnum, IsNumber, ValidateNested } from "class-validator";
export class BookingAttendeeOutput_2024_08_13 extends BookingAttendee {
@ApiProperty({ type: Number, example: 251 })
@IsNumber()
@Expose()
id!: number;
@ApiProperty({ type: Number, example: 313 })
@IsNumber()
@Expose()
bookingId!: number;
}
export class AddAttendeeOutput_2024_08_13 {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({ type: BookingAttendeeOutput_2024_08_13 })
@ValidateNested()
@Type(() => BookingAttendeeOutput_2024_08_13)
data!: BookingAttendeeOutput_2024_08_13;
}
@@ -0,0 +1,76 @@
import { BookingAttendeesService } from "@calcom/platform-libraries/bookings";
import type { AddAttendeeInput_2024_08_13 } from "@calcom/platform-types";
import { HttpException, Injectable, NotFoundException } from "@nestjs/common";
import { plainToClass } from "class-transformer";
import { BookingAttendeeOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/add-attendee.output";
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
import type { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
@Injectable()
export class BookingAttendeesService_2024_08_13 {
private bookingAttendeesService: BookingAttendeesService;
constructor(
private readonly bookingsRepository: BookingsRepository_2024_08_13,
private readonly platformBookingsService: PlatformBookingsService
) {
this.bookingAttendeesService = new BookingAttendeesService();
}
async addAttendee(
bookingUid: string,
input: AddAttendeeInput_2024_08_13,
user: ApiAuthGuardUser
): Promise<BookingAttendeeOutput_2024_08_13> {
const booking = await this.bookingsRepository.getByUidWithEventType(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid ${bookingUid} not found`);
}
const platformClientParams = booking.eventTypeId
? await this.platformBookingsService.getOAuthClientParams(booking.eventTypeId)
: undefined;
const emailsEnabled = platformClientParams ? platformClientParams.arePlatformEmailsEnabled : true;
const createdAttendee = await this.bookingAttendeesService.addAttendee({
bookingId: booking.id,
attendee: {
email: input.email,
name: input.name,
timeZone: input.timeZone,
phoneNumber: input.phoneNumber,
language: input.language,
},
user: {
id: user.id,
email: user.email,
organizationId: user.organizationId,
uuid: user.uuid,
},
emailsEnabled,
actionSource: "API_V2",
});
return plainToClass(
BookingAttendeeOutput_2024_08_13,
{
id: createdAttendee.id,
bookingId: createdAttendee.bookingId,
name: createdAttendee.name,
email: createdAttendee.email,
displayEmail: this.getDisplayEmail(createdAttendee.email),
timeZone: createdAttendee.timeZone,
language: createdAttendee.locale ?? undefined,
absent: false,
phoneNumber: createdAttendee.phoneNumber ?? undefined,
},
{ strategy: "excludeAll" }
);
}
private getDisplayEmail(email: string): string {
return email.replace(/\+[a-zA-Z0-9]{25}/, "");
}
}
@@ -21,6 +21,10 @@ import { TaskerService } from "@/lib/services/tasker.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { Module, Scope } from "@nestjs/common";
import { getWebhookProducer } from "@calcom/platform-libraries/bookings";
import { WEBHOOK_PRODUCER } from "./regular-booking.tokens";
@Module({
imports: [PrismaModule],
providers: [
@@ -37,6 +41,10 @@ import { Module, Scope } from "@nestjs/common";
},
scope: Scope.TRANSIENT,
},
{
provide: WEBHOOK_PRODUCER,
useFactory: () => getWebhookProducer(),
},
BookingAuditProducerService,
BookingEventHandlerService,
CheckBookingAndDurationLimitsService,
@@ -51,6 +59,6 @@ import { Module, Scope } from "@nestjs/common";
TaskerService,
RegularBookingService,
],
exports: [RegularBookingService],
exports: [RegularBookingService, WEBHOOK_PRODUCER],
})
export class RegularBookingModule {}
@@ -0,0 +1,8 @@
/**
* Injection token for IWebhookProducerService.
* Used to bridge the Cal.com DI container into NestJS.
*
* Defined in a separate file to avoid a circular import between
* regular-booking.module.ts and regular-booking.service.ts.
*/
export const WEBHOOK_PRODUCER = "WEBHOOK_PRODUCER";
@@ -1,3 +1,10 @@
import {
RegularBookingService as BaseRegularBookingService,
type IWebhookProducerService,
} from "@calcom/platform-libraries/bookings";
import type { PrismaClient } from "@calcom/prisma";
import { Inject, Injectable } from "@nestjs/common";
import { WEBHOOK_PRODUCER } from "@/lib/modules/regular-booking.tokens";
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
@@ -7,10 +14,6 @@ import { HashedLinkService } from "@/lib/services/hashed-link.service";
import { LuckyUserService } from "@/lib/services/lucky-user.service";
import { BookingEmailAndSmsTasker } from "@/lib/services/tasker/booking-emails-sms-tasker.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { RegularBookingService as BaseRegularBookingService } from "@calcom/platform-libraries/bookings";
import type { PrismaClient } from "@calcom/prisma";
@Injectable()
export class RegularBookingService extends BaseRegularBookingService {
@@ -23,7 +26,8 @@ export class RegularBookingService extends BaseRegularBookingService {
userRepository: PrismaUserRepository,
bookingEmailAndSmsTasker: BookingEmailAndSmsTasker,
featuresRepository: PrismaFeaturesRepository,
bookingEventHandler: BookingEventHandlerService
bookingEventHandler: BookingEventHandlerService,
@Inject(WEBHOOK_PRODUCER) webhookProducer: IWebhookProducerService
) {
super({
checkBookingAndDurationLimitsService,
@@ -35,6 +39,7 @@ export class RegularBookingService extends BaseRegularBookingService {
bookingEmailAndSmsTasker,
featuresRepository,
bookingEventHandler,
webhookProducer,
});
}
}
@@ -1,7 +1,6 @@
import { getTasker, type Tasker } from "@calcom/platform-libraries/tasker";
import { Injectable } from "@nestjs/common";
import { getTasker, type Tasker } from "@calcom/platform-libraries";
@Injectable()
export class TaskerService {
private readonly tasker: Tasker;
@@ -14,5 +13,3 @@ export class TaskerService {
return this.tasker;
}
}
@@ -1,11 +1,12 @@
import { generateSecret } from "@calcom/platform-libraries";
import type { Membership, Team, User } from "@calcom/prisma/client";
import { AccessScope, OAuthClientType } from "@calcom/prisma/enums";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test, TestingModule } from "@nestjs/testing";
import { getToken } from "next-auth/jwt";
import { AccessScope, OAuthClientStatus, OAuthClientType } from "@calcom/prisma/enums";
import type { INestApplication } from "@nestjs/common";
import type { NestExpressApplication } from "@nestjs/platform-express";
import type { TestingModule } from "@nestjs/testing";
import { Test } from "@nestjs/testing";
import request from "supertest";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuth2ClientRepositoryFixture } from "test/fixtures/repository/oauth2-client.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
@@ -21,20 +22,16 @@ import { AuthModule } from "@/modules/auth/auth.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersModule } from "@/modules/users/users.module";
// Mock next-auth/jwt getToken for ApiAuthStrategy NEXT_AUTH authentication
// Mock next-auth/jwt getToken so the NextAuth fallback in ApiAuthStrategy doesn't crash in tests
jest.mock("next-auth/jwt", () => ({
getToken: jest.fn(),
getToken: jest.fn().mockResolvedValue(null),
}));
const mockGetToken = getToken as jest.MockedFunction<typeof getToken>;
describe("OAuth2 Controller Endpoints", () => {
describe("User Not Authenticated", () => {
let appWithoutAuth: INestApplication;
beforeAll(async () => {
// Mock getToken to return null for unauthenticated tests
mockGetToken.mockResolvedValue(null);
const moduleRef = await Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter, ZodExceptionFilter],
imports: [AppModule, UsersModule, AuthModule, PrismaModule],
@@ -69,20 +66,23 @@ describe("OAuth2 Controller Endpoints", () => {
});
});
describe("User Authenticated", () => {
describe("User Authenticated (non-owner)", () => {
let app: INestApplication;
let moduleRef: TestingModule;
let userRepositoryFixture: UserRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let membershipRepositoryFixture: MembershipRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let oAuthClientFixture: OAuth2ClientRepositoryFixture;
let oAuthService: OAuthService;
let user: User;
let clientOwner: User;
let authenticatedUser: User;
let team: Team;
let membership: Membership;
let oAuthClient: { clientId: string };
let apiKeyString: string;
let refreshToken: string;
const testClientId = `test-oauth-client-${randomString()}`;
@@ -96,7 +96,7 @@ describe("OAuth2 Controller Endpoints", () => {
): Promise<string> {
const result = await oAuthService.generateAuthorizationCode(
testClientId,
user.id,
authenticatedUser.id,
testRedirectUri,
scopes,
undefined,
@@ -107,12 +107,6 @@ describe("OAuth2 Controller Endpoints", () => {
}
beforeAll(async () => {
const userEmail = `oauth2-e2e-user-${randomString()}@api.com`;
// Mock getToken to return the user email for authenticated tests
// This is needed because ApiAuthGuard uses ApiAuthStrategy which calls getToken from next-auth/jwt
mockGetToken.mockResolvedValue({ email: userEmail });
moduleRef = await Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter, ZodExceptionFilter],
imports: [AppModule, UsersModule, AuthModule, PrismaModule],
@@ -125,20 +119,28 @@ describe("OAuth2 Controller Endpoints", () => {
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
oAuthClientFixture = new OAuth2ClientRepositoryFixture(moduleRef);
oAuthService = moduleRef.get(OAuthService);
user = await userRepositoryFixture.create({
email: userEmail,
clientOwner = await userRepositoryFixture.create({
email: `oauth2-e2e-owner-${randomString()}@api.com`,
});
authenticatedUser = await userRepositoryFixture.create({
email: `oauth2-e2e-user-${randomString()}@api.com`,
});
const { keyString } = await apiKeysRepositoryFixture.createApiKey(authenticatedUser.id, null);
apiKeyString = keyString;
team = await teamRepositoryFixture.create({
name: `oauth2-e2e-team-${randomString()}`,
slug: `oauth2-e2e-team-${randomString()}`,
});
membership = await membershipRepositoryFixture.create({
user: { connect: { id: user.id } },
user: { connect: { id: authenticatedUser.id } },
team: { connect: { id: team.id } },
role: "OWNER",
accepted: true,
@@ -151,6 +153,7 @@ describe("OAuth2 Controller Endpoints", () => {
redirectUri: testRedirectUri,
clientSecret: hashedSecret,
clientType: OAuthClientType.CONFIDENTIAL,
userId: clientOwner.id,
});
});
@@ -159,6 +162,7 @@ describe("OAuth2 Controller Endpoints", () => {
it("should return OAuth client info for valid client ID", async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/auth/oauth2/clients/${testClientId}`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200);
expect(response.body.status).toBe("success");
@@ -174,6 +178,7 @@ describe("OAuth2 Controller Endpoints", () => {
it("should return 404 for non-existent client ID", async () => {
await request(app.getHttpServer())
.get("/api/v2/auth/oauth2/clients/non-existent-client-id")
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(404);
});
});
@@ -434,7 +439,238 @@ describe("OAuth2 Controller Endpoints", () => {
await oAuthClientFixture.delete(oAuthClient.clientId);
await membershipRepositoryFixture.delete(membership.id);
await teamRepositoryFixture.delete(team.id);
await userRepositoryFixture.delete(user.id);
await userRepositoryFixture.delete(authenticatedUser.id);
await userRepositoryFixture.delete(clientOwner.id);
await app.close();
});
});
describe("Owner can use non-approved OAuth client", () => {
let app: INestApplication;
let moduleRef: TestingModule;
let userRepositoryFixture: UserRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let membershipRepositoryFixture: MembershipRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let oAuthClientFixture: OAuth2ClientRepositoryFixture;
let oAuthService: OAuthService;
let owner: User;
let team: Team;
let membership: Membership;
let pendingClient: { clientId: string };
let rejectedClient: { clientId: string };
let apiKeyString: string;
const pendingClientId = `test-pending-client-${randomString()}`;
const rejectedClientId = `test-rejected-client-${randomString()}`;
const testClientSecret = "test-secret-456";
const testRedirectUri = "https://example.com/callback";
/** Generate a user-scoped authorization code (no teamSlug) so the owner's userId is in the token. */
async function generateAuthCodeForClient(
clientId: string,
scopes: AccessScope[] = [AccessScope.READ_BOOKING]
): Promise<string> {
const result = await oAuthService.generateAuthorizationCode(
clientId,
owner.id,
testRedirectUri,
scopes
);
const redirectUrl = new URL(result.redirectUrl);
return redirectUrl.searchParams.get("code") as string;
}
beforeAll(async () => {
moduleRef = await Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter, ZodExceptionFilter],
imports: [AppModule, UsersModule, AuthModule, PrismaModule],
}).compile();
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
oAuthClientFixture = new OAuth2ClientRepositoryFixture(moduleRef);
oAuthService = moduleRef.get(OAuthService);
owner = await userRepositoryFixture.create({
email: `oauth2-owner-e2e-${randomString()}@api.com`,
});
const { keyString } = await apiKeysRepositoryFixture.createApiKey(owner.id, null);
apiKeyString = keyString;
team = await teamRepositoryFixture.create({
name: `oauth2-owner-team-${randomString()}`,
slug: `oauth2-owner-team-${randomString()}`,
});
membership = await membershipRepositoryFixture.create({
user: { connect: { id: owner.id } },
team: { connect: { id: team.id } },
role: "OWNER",
accepted: true,
});
const [hashedSecret] = generateSecret(testClientSecret);
pendingClient = await oAuthClientFixture.create({
clientId: pendingClientId,
name: "Pending OAuth Client",
redirectUri: testRedirectUri,
clientSecret: hashedSecret,
clientType: OAuthClientType.CONFIDENTIAL,
status: OAuthClientStatus.PENDING,
userId: owner.id,
});
rejectedClient = await oAuthClientFixture.create({
clientId: rejectedClientId,
name: "Rejected OAuth Client",
redirectUri: testRedirectUri,
clientSecret: hashedSecret,
clientType: OAuthClientType.CONFIDENTIAL,
status: OAuthClientStatus.REJECTED,
userId: owner.id,
});
});
it("should return PENDING client info when authenticated as owner", async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/auth/oauth2/clients/${pendingClientId}`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200);
expect(response.body.status).toBe("success");
expect(response.body.data.client_id).toBe(pendingClientId);
expect(response.body.data.name).toBe("Pending OAuth Client");
});
it("should exchange authorization code for tokens with PENDING client owned by user", async () => {
const code = await generateAuthCodeForClient(pendingClientId);
const response = await request(app.getHttpServer())
.post("/api/v2/auth/oauth2/token")
.type("form")
.send({
client_id: pendingClientId,
grant_type: "authorization_code",
code,
client_secret: testClientSecret,
redirect_uri: testRedirectUri,
})
.expect(200);
expect(response.body.access_token).toBeDefined();
expect(response.body.refresh_token).toBeDefined();
expect(response.body.token_type).toBe("bearer");
});
it("should refresh tokens with PENDING client owned by user", async () => {
const code = await generateAuthCodeForClient(pendingClientId);
const tokenResponse = await request(app.getHttpServer())
.post("/api/v2/auth/oauth2/token")
.type("form")
.send({
client_id: pendingClientId,
grant_type: "authorization_code",
code,
client_secret: testClientSecret,
redirect_uri: testRedirectUri,
})
.expect(200);
const response = await request(app.getHttpServer())
.post("/api/v2/auth/oauth2/token")
.type("form")
.send({
client_id: pendingClientId,
grant_type: "refresh_token",
refresh_token: tokenResponse.body.refresh_token,
client_secret: testClientSecret,
})
.expect(200);
expect(response.body.access_token).toBeDefined();
expect(response.body.refresh_token).toBeDefined();
expect(response.body.token_type).toBe("bearer");
});
it("should reject authorization code generation for REJECTED client even as owner", async () => {
await expect(generateAuthCodeForClient(rejectedClientId)).rejects.toThrow();
});
it("should reject token exchange when client becomes rejected", async () => {
const code = await generateAuthCodeForClient(pendingClientId);
await oAuthClientFixture.updateStatus(pendingClientId, OAuthClientStatus.REJECTED);
const response = await request(app.getHttpServer())
.post("/api/v2/auth/oauth2/token")
.type("form")
.send({
client_id: pendingClientId,
grant_type: "authorization_code",
code,
client_secret: testClientSecret,
redirect_uri: testRedirectUri,
})
.expect(401);
expect(response.body.error).toBe("unauthorized_client");
expect(response.body.error_description).toBe("client_rejected");
await oAuthClientFixture.updateStatus(pendingClientId, OAuthClientStatus.PENDING);
});
it("should reject token refresh when client becomes rejected", async () => {
const code = await generateAuthCodeForClient(pendingClientId);
const tokenResponse = await request(app.getHttpServer())
.post("/api/v2/auth/oauth2/token")
.type("form")
.send({
client_id: pendingClientId,
grant_type: "authorization_code",
code,
client_secret: testClientSecret,
redirect_uri: testRedirectUri,
})
.expect(200);
await oAuthClientFixture.updateStatus(pendingClientId, OAuthClientStatus.REJECTED);
const response = await request(app.getHttpServer())
.post("/api/v2/auth/oauth2/token")
.type("form")
.send({
client_id: pendingClientId,
grant_type: "refresh_token",
refresh_token: tokenResponse.body.refresh_token,
client_secret: testClientSecret,
})
.expect(401);
expect(response.body.error).toBe("unauthorized_client");
expect(response.body.error_description).toBe("client_rejected");
await oAuthClientFixture.updateStatus(pendingClientId, OAuthClientStatus.PENDING);
});
afterAll(async () => {
await oAuthClientFixture.delete(pendingClient.clientId);
await oAuthClientFixture.delete(rejectedClient.clientId);
await membershipRepositoryFixture.delete(membership.id);
await teamRepositoryFixture.delete(team.id);
await userRepositoryFixture.delete(owner.id);
await app.close();
});
});
@@ -16,15 +16,16 @@ export class OAuth2HttpExceptionFilter implements ExceptionFilter<OAuth2HttpExce
const requestId = request.headers["x-request-id"] ?? "unknown-request-id";
response.setHeader("X-Request-Id", requestId.toString());
const userContext = extractUserContext(request);
const { userEmail: _userEmail, ...safeUserContext } = extractUserContext(request);
const { Authorization: _auth, ...safeHeaders } = filterReqHeaders(request.headers);
this.logger.error(`OAuth2 Http Exception: ${exception.oAuthErrorData.error}`, {
exception,
body: "[REDACTED]",
headers: filterReqHeaders(request.headers),
headers: safeHeaders,
url: request.url,
method: request.method,
requestId,
...userContext,
...safeUserContext,
});
response.status(exception.getStatus()).json(exception.oAuthErrorData);
@@ -515,7 +515,7 @@ describe("Slots 2024-04-15 Endpoints", () => {
it("should do a booking for seated event and slot should show attendees count and bookingUid", async () => {
const startTime = "2050-09-05T11:00:00.000Z";
const booking = await bookingsRepositoryFixture.create({
uid: `booking-uid-${seatedEventTypeId}`,
uid: `booking-uid-seated-${seatedEventTypeId}-${randomString()}`,
title: "booking title",
startTime,
endTime: "2050-09-05T12:00:00.000Z",
@@ -549,7 +549,7 @@ describe("Slots 2024-04-15 Endpoints", () => {
});
bookingSeatsRepositoryFixture.create({
referenceUid: randomString(),
referenceUid: `seat-${randomString()}`,
data: {},
booking: {
connect: {
@@ -598,7 +598,7 @@ describe("Slots 2024-04-15 Endpoints", () => {
it("should do a booking for seated event and slot should show attendees count and bookingUid in range format", async () => {
const startTime = "2050-09-05T11:00:00.000Z";
const booking = await bookingsRepositoryFixture.create({
uid: `booking-uid-${seatedEventTypeId}`,
uid: `booking-uid-seated-${seatedEventTypeId}-${randomString()}`,
title: "booking title",
startTime,
endTime: "2050-09-05T12:00:00.000Z",
@@ -632,7 +632,7 @@ describe("Slots 2024-04-15 Endpoints", () => {
});
bookingSeatsRepositoryFixture.create({
referenceUid: randomString(),
referenceUid: `seat-${randomString()}`,
data: {},
booking: {
connect: {
@@ -832,7 +832,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
it("should do a booking for seated event and slot should show attendees count and bookingUid", async () => {
const startTime = "2050-09-05T11:00:00.000Z";
const booking = await bookingsRepositoryFixture.create({
uid: `booking-uid-${seatedEventType.id}`,
uid: `booking-uid-seated-${seatedEventType.id}-${randomString()}`,
title: "booking title",
startTime,
endTime: "2050-09-05T12:00:00.000Z",
@@ -866,7 +866,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
});
bookingSeatsRepositoryFixture.create({
referenceUid: randomString(),
referenceUid: `seat-${randomString()}`,
data: {},
booking: {
connect: {
@@ -961,7 +961,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
it("should do a booking for seated event and slot should show attendees count and bookingUid and return range format", async () => {
const startTime = "2050-09-05T11:00:00.000Z";
const booking = await bookingsRepositoryFixture.create({
uid: `booking-uid-${seatedEventType.id}`,
uid: `booking-uid-seated-range-${seatedEventType.id}-${randomString()}`,
title: "booking title",
startTime,
endTime: "2050-09-05T12:00:00.000Z",
@@ -995,7 +995,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
});
bookingSeatsRepositoryFixture.create({
referenceUid: randomString(),
referenceUid: `seat-${randomString()}`,
data: {},
booking: {
connect: {
@@ -1348,7 +1348,8 @@ describe("Slots 2024-09-04 Endpoints", () => {
advanceTo(newDate);
const slotDuration = 60;
const slotStartTime = "2050-09-05T10:00:00.000Z";
// Use 2050-09-06 to avoid overlap with other tests that reserve 2050-09-05
const slotStartTime = "2050-09-06T09:00:00.000Z";
const reserveResponse = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({
@@ -1377,7 +1378,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
const response = await request(app.getHttpServer())
.get(
`/v2/slots?eventTypeId=${variableLengthEventType.id}&start=2050-09-05&end=2050-09-09&duration=60`
`/v2/slots?eventTypeId=${variableLengthEventType.id}&start=2050-09-05&end=2050-09-10&duration=60`
)
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(200);
@@ -1390,10 +1391,10 @@ describe("Slots 2024-09-04 Endpoints", () => {
const days = Object.keys(slots);
expect(days.length).toEqual(5);
const expectedSlotsUTC2050_09_05 = expectedSlotsUTC["2050-09-05"].filter(
const expectedSlotsUTC2050_09_06 = expectedSlotsUTC["2050-09-06"].filter(
(slot) => slot.start !== slotStartTime
);
expect(slots).toEqual({ ...expectedSlotsUTC, "2050-09-05": expectedSlotsUTC2050_09_05 });
expect(slots).toEqual({ ...expectedSlotsUTC, "2050-09-06": expectedSlotsUTC2050_09_06 });
const dbSlot = await selectedSlotRepositoryFixture.getByUid(
responseReservedVariableSlot.reservationUid
@@ -1409,7 +1410,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
});
it("request slot contains already existing reserved slot", async () => {
// Try to reserve 9:45-12:45 when 10:00-11:00 is taken
// Try to reserve 8:45-11:45 when 09:00-10:00 on 2050-09-06 is taken
const newSlotStart = DateTime.fromISO(responseReservedVariableSlot.slotStart)
.minus({ minutes: 15 })
.toISO();
@@ -114,7 +114,11 @@ export class WorkflowsRepository {
) {
await updateWorkflow({
ctx: {
user: { ...user, locale: user?.locale ?? "en" },
user: {
...user,
locale: user?.locale ?? "en",
organizationId: user.profiles?.[0]?.organization?.id ?? null,
},
prisma: this.dbWrite.prisma as unknown as PrismaClient,
},
input: data,
@@ -132,7 +136,11 @@ export class WorkflowsRepository {
) {
await updateWorkflow({
ctx: {
user: { ...user, locale: user?.locale ?? "en" },
user: {
...user,
locale: user?.locale ?? "en",
organizationId: user.profiles?.[0]?.organization?.id ?? null,
},
prisma: this.dbWrite.prisma as unknown as PrismaClient,
},
input: data,
@@ -21,6 +21,7 @@ export class OAuth2ClientRepositoryFixture {
status?: OAuthClientStatus;
logo?: string;
isTrusted?: boolean;
userId?: number;
}) {
return this.prismaWriteClient.oAuthClient.create({
data: {
@@ -32,10 +33,18 @@ export class OAuth2ClientRepositoryFixture {
status: data.status || OAuthClientStatus.APPROVED,
logo: data.logo,
isTrusted: data.isTrusted || false,
...(data.userId && { user: { connect: { id: data.userId } } }),
},
});
}
async updateStatus(clientId: string, status: OAuthClientStatus) {
return this.prismaWriteClient.oAuthClient.update({
where: { clientId },
data: { status },
});
}
async delete(clientId: string) {
return this.prismaWriteClient.oAuthClient.delete({
where: { clientId },
+1 -1
View File
@@ -1 +1 @@
export const TRIGGER_VERSION = '20260212.3';
export const TRIGGER_VERSION = '20260216.1';
@@ -3,7 +3,7 @@ import withEmbedSsrAppDir from "app/WithEmbedSSR";
import type { PageProps as ServerPageProps } from "app/_types";
import { cookies, headers } from "next/headers";
import { loadTranslations } from "@calcom/lib/server/i18n";
import { loadTranslations } from "@calcom/i18n/server";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
@@ -5,7 +5,7 @@ import { generateMeetingMetadata } from "app/_utils";
import { headers, cookies } from "next/headers";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { loadTranslations } from "@calcom/lib/server/i18n";
import { loadTranslations } from "@calcom/i18n/server";
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
@@ -3,7 +3,7 @@ import withEmbedSsrAppDir from "app/WithEmbedSSR";
import type { PageProps as ServerPageProps } from "app/_types";
import { cookies, headers } from "next/headers";
import { loadTranslations } from "@calcom/lib/server/i18n";
import { loadTranslations } from "@calcom/i18n/server";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
@@ -5,7 +5,7 @@ import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { loadTranslations } from "@calcom/lib/server/i18n";
import { loadTranslations } from "@calcom/i18n/server";
import { BookingStatus } from "@calcom/prisma/enums";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
@@ -3,7 +3,7 @@ import withEmbedSsrAppDir from "app/WithEmbedSSR";
import type { PageProps as ServerPageProps } from "app/_types";
import { cookies, headers } from "next/headers";
import { loadTranslations } from "@calcom/lib/server/i18n";
import { loadTranslations } from "@calcom/i18n/server";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/org/[orgSlug]/[user]/[type]/getServerSideProps";
@@ -5,7 +5,7 @@ import { generateMeetingMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { loadTranslations } from "@calcom/lib/server/i18n";
import { loadTranslations } from "@calcom/i18n/server";
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/org/[orgSlug]/[user]/[type]/getServerSideProps";
@@ -3,7 +3,7 @@ import withEmbedSsrAppDir from "app/WithEmbedSSR";
import type { PageProps as ServerPageProps } from "app/_types";
import { cookies, headers } from "next/headers";
import { loadTranslations } from "@calcom/lib/server/i18n";
import { loadTranslations } from "@calcom/i18n/server";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
@@ -6,7 +6,7 @@ import { cookies, headers } from "next/headers";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { loadTranslations } from "@calcom/lib/server/i18n";
import { loadTranslations } from "@calcom/i18n/server";
import { prisma } from "@calcom/prisma";
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
@@ -11,7 +11,7 @@ import { getOrgFullOrigin, orgDomainConfig } from "@calcom/features/ee/organizat
import { getOrganizationSEOSettings } from "@calcom/features/ee/organizations/lib/orgSettings";
import type { TeamData } from "@calcom/features/ee/teams/lib/getTeamData";
import { shouldHideBrandingForTeamEvent } from "@calcom/features/profile/lib/hideBranding";
import { loadTranslations } from "@calcom/lib/server/i18n";
import { loadTranslations } from "@calcom/i18n/server";
import slugify from "@calcom/lib/slugify";
import { BookingStatus, RedirectType } from "@calcom/prisma/enums";
@@ -2,6 +2,7 @@
import { FieldTypes } from "@calcom/app-store/routing-forms/lib/FieldTypes";
import type { RoutingFormWithResponseCount } from "@calcom/app-store/routing-forms/types/types";
import { LearnMoreLink } from "@calcom/features/eventtypes/components/LearnMoreLink";
import { getFieldIdentifier } from "@calcom/features/form-builder/utils/getFieldIdentifier";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import classNames from "@calcom/ui/classNames";
@@ -132,7 +133,13 @@ function Field({
<TextField
disabled={!!router}
label={t("identifier_url_parameter")}
hint={t("identifier_url_parameter_hint")}
hint={
<LearnMoreLink
t={t}
i18nKey="identifier_url_parameter_hint"
href="https://cal.com/help/routing/connect-routing-form-to-booking-questions"
/>
}
name={`${hookFieldNamespace}.identifier`}
required
placeholder={t("identifies_name_field")}
@@ -124,7 +124,7 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
<div className="col-span-2 mb-6">
{date.locale(i18n.language ?? "en").format("dddd, DD MMMM YYYY")}
<br />
{date.format(is24h ? "H:mm" : "h:mma")} - {props.eventType.length} mins{" "}
{date.format(is24h ? "H:mm" : "h:mma")} - {props.eventType.length} {t("minute_timeUnit")}{" "}
<span className="text-subtle">({timezone})</span>
</div>
{props.booking.location && (
@@ -3,7 +3,7 @@
import { useState } from "react";
import dayjs from "@calcom/dayjs";
import { Calendar } from "@calcom/web/modules/calendars/weeklyview/components/Calendar";
import { Calendar } from "@calcom/features/calendars/weeklyview/components/Calendar";
import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events";
import type { CalendarComponentProps, Hours } from "@calcom/features/calendars/weeklyview/types/state";
@@ -154,9 +154,10 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
trackingMetadata: { section: "developer", page: "api_keys" },
},
{
name: "admin_api",
href: "/settings/organizations/admin-api",
trackingMetadata: { section: "developer", page: "admin_api" },
name: "api_docs",
href: "https://cal.com/docs/api-reference/v2/introduction",
isExternalLink: true,
trackingMetadata: { section: "developer", page: "api_docs" },
},
// TODO: Add profile level for embeds
// { name: "embeds", href: "/v2/settings/developer/embeds" },
@@ -206,10 +207,10 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
trackingMetadata: { section: "organization", page: "directory_sync" },
},
{
name: "admin_api",
href: "https://cal.com/docs/enterprise-features/api/api-reference/bookings#admin-access",
name: "api_docs",
href: "https://cal.com/docs/api-reference/v2/introduction",
isExternalLink: true,
trackingMetadata: { section: "organization", page: "admin_api" },
trackingMetadata: { section: "organization", page: "api_docs" },
},
...(HAS_ORG_OPT_IN_FEATURES
? [
@@ -450,7 +451,7 @@ const useTabs = ({
return { ...tab, children: filtered };
} else if (tab.href === "/settings/developer") {
const filtered = tab?.children?.filter(
(childTab) => permissions?.canUpdateOrganization || childTab.name !== "admin_api"
(childTab) => permissions?.canUpdateOrganization || childTab.name !== "api_docs"
);
return { ...tab, children: filtered };
}
@@ -824,7 +825,7 @@ const SettingsSidebarContainer = ({
href={child.href || "/"}
trackingMetadata={child.trackingMetadata}
textClassNames="text-emphasis font-medium text-sm"
className={`px-2! py-1! min-h-7 h-auto w-fit ${
className={`px-2! py-1! min-h-7 h-auto w-full ${
tab.children && index === tab.children?.length - 1 && "mb-3!"
}`}
disableChevron
@@ -21,7 +21,7 @@ const Page = async () => {
<SettingsHeader
title={t("billing")}
description={t("manage_billing_description")}
borderInShellHeader={false}>
borderInShellHeader={true}>
<BillingView />
</SettingsHeader>
);
@@ -29,7 +29,7 @@ const Page = async () => {
<SettingsHeader
title={t("billing")}
description={t("manage_billing_description")}
borderInShellHeader={false}>
borderInShellHeader={true}>
<BillingView />
</SettingsHeader>
);
@@ -1,33 +1,7 @@
import { getTranslate, _generateMetadata } from "app/_utils";
import { redirect } from "next/navigation";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { AdminAPIView } from "~/ee/organizations/admin-api";
import { validateUserHasOrg } from "../actions/validateUserHasOrg";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => `${t("admin")} ${t("api_reference")}`,
(t) => t("leverage_our_api"),
undefined,
undefined,
"/settings/organizations/admin-api"
);
const Page = async () => {
const t = await getTranslate();
await validateUserHasOrg();
return (
<SettingsHeader
title={`${t("admin")} ${t("api_reference")}`}
description={t("leverage_our_api")}
borderInShellHeader={false}>
<AdminAPIView />
</SettingsHeader>
);
const Page = () => {
redirect("https://cal.com/docs/api-reference/v2/introduction");
};
export default Page;
+1 -1
View File
@@ -7,7 +7,7 @@ import { constructAppImage, constructGenericImage, constructMeetingImage } from
import { IS_CALCOM, WEBAPP_URL, APP_NAME, SEO_IMG_OGIMG, CAL_URL } from "@calcom/lib/constants";
import { getCalcomUrl } from "@calcom/lib/getCalcomUrl";
import { buildCanonical } from "@calcom/lib/next-seo.config";
import { getTranslation } from "@calcom/lib/server/i18n";
import { getTranslation } from "@calcom/i18n/server";
import { truncateOnWord } from "@calcom/lib/text";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -17,11 +17,7 @@ async function handler(req: NextRequest) {
return NextResponse.json({ message: "email is required" }, { status: 400 });
}
let ip = getIP(req) ?? email.data;
const forwardedFor = req.headers.get("x-forwarded-for") as string;
if (!ip && forwardedFor) {
ip = forwardedFor?.split(",").at(0) ?? email.data;
}
const ip = getIP(req) ?? email.data;
await checkRateLimitAndThrowError({
rateLimitingType: "core",
@@ -12,8 +12,8 @@ import {
validateAndGetCorrectedUsernameForTeam,
} from "@calcom/features/auth/signup/utils/token";
import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/features/auth/signup/utils/validateUsername";
import { getBillingProviderService } from "@calcom/features/ee/billing/di/containers/Billing";
import { getFeatureRepository } from "@calcom/features/di/containers/FeatureRepository";
import { getBillingProviderService } from "@calcom/features/ee/billing/di/containers/Billing";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { GlobalWatchlistRepository } from "@calcom/features/watchlist/lib/repository/GlobalWatchlistRepository";
import { sentrySpan } from "@calcom/features/watchlist/lib/telemetry";
@@ -287,15 +287,6 @@ const handler: CustomNextApiHandler = async (body, usernameStatus, query) => {
if (process.env.AVATARAPI_USERNAME && process.env.AVATARAPI_PASSWORD) {
await prefillAvatar({ email });
}
// Only send verification email for non-premium usernames
// Premium usernames will get a magic link after payment in paymentCallback
if (!checkoutSessionId) {
sendEmailVerification({
email,
language: await getLocaleFromRequest(buildLegacyRequest(await headers(), await cookies())),
username: username || "",
});
}
}
const featureRepository = getFeatureRepository();
@@ -326,6 +317,14 @@ const handler: CustomNextApiHandler = async (body, usernameStatus, query) => {
);
}
if (!checkoutSessionId && !token) {
sendEmailVerification({
email,
language: await getLocaleFromRequest(buildLegacyRequest(await headers(), await cookies())),
username: username || "",
});
}
if (checkoutSessionId) {
console.log("Created user but missing payment", checkoutSessionId);
return NextResponse.json(
@@ -7,7 +7,7 @@ import { sendOrganizerRequestReminderEmail } from "@calcom/emails/email-manager"
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
import { getTranslation } from "@calcom/lib/server/i18n";
import { getTranslation } from "@calcom/i18n/server";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import { BookingStatus, ReminderType } from "@calcom/prisma/enums";
import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";
@@ -6,7 +6,7 @@ import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { sendMonthlyDigestEmail } from "@calcom/emails/workflow-email-service";
import { EventsInsights } from "@calcom/features/insights/server/events";
import { getTranslation } from "@calcom/lib/server/i18n";
import { getTranslation } from "@calcom/i18n/server";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
+1 -1
View File
@@ -3,7 +3,7 @@ import { NextResponse } from "next/server";
import renderEmail from "@calcom/emails/src/renderEmail";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { getTranslation } from "@calcom/lib/server/i18n";
import { getTranslation } from "@calcom/i18n/server";
/**
* This API endpoint is used for development purposes to preview email templates
+1 -1
View File
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import getIP from "@calcom/lib/getIP";
import { NextResponse } from "next/server";
export async function GET(req: Request) {
const requestorIp = getIP(req);
+18 -17
View File
@@ -1,7 +1,7 @@
import type { NextRequest } from "next/server";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { confirmHandler } from "@calcom/trpc/server/routers/viewer/bookings/confirm.handler";
import type { NextRequest } from "next/server";
import type { Mock } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockConfirmHandler = confirmHandler as unknown as Mock<typeof confirmHandler>;
@@ -82,9 +82,9 @@ vi.mock("@calcom/features/booking-audit/lib/makeActor", () => ({
makeUserActor: vi.fn().mockReturnValue({ type: "user", id: "test-uuid" }),
}));
import prisma from "@calcom/prisma";
// Import after mocks are set up
import { GET } from "../route";
import prisma from "@calcom/prisma";
const createMockRequest = (url: string): NextRequest => {
const urlObj = new URL(url);
@@ -97,13 +97,16 @@ const createMockRequest = (url: string): NextRequest => {
} as unknown as NextRequest;
};
// Vitest sets NEXT_PUBLIC_WEBAPP_URL to http://app.cal.local:3000 (see vitest.config.mts)
const EXPECTED_REDIRECT_ORIGIN = "http://app.cal.local:3000";
describe("link route", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET handler - redirect URL construction", () => {
it("should redirect to booking page with the same origin as the request", async () => {
it("should redirect to booking page using WEBAPP_URL (fixes localhost redirect when behind proxy)", async () => {
const baseUrl = "https://app.example.com/api/link?action=accept&token=encrypted-token";
const req = createMockRequest(baseUrl);
@@ -113,11 +116,11 @@ describe("link route", () => {
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe("https://app.example.com");
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
expect(redirectUrl.pathname).toBe("/booking/test-booking-uid");
});
it("should preserve custom domain origin in redirect URL", async () => {
it("should use WEBAPP_URL for redirects, not request.url (avoids localhost when proxy sends localhost)", async () => {
const baseUrl = "https://custom-domain.company.com/api/link?action=accept&token=encrypted-token";
const req = createMockRequest(baseUrl);
@@ -127,11 +130,11 @@ describe("link route", () => {
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe("https://custom-domain.company.com");
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
expect(location).not.toContain("localhost");
});
it("should preserve self-hosted domain origin in redirect URL", async () => {
it("should use WEBAPP_URL for self-hosted deployments", async () => {
const baseUrl = "https://calcom.internal.company.net/api/link?action=reject&token=encrypted-token";
const req = createMockRequest(baseUrl);
@@ -141,11 +144,11 @@ describe("link route", () => {
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe("https://calcom.internal.company.net");
expect(location).not.toContain("localhost");
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
expect(redirectUrl.pathname).toBe("/booking/test-booking-uid");
});
it("should construct redirect URLs relative to the request URL for various origins", async () => {
it("should construct redirect URLs using WEBAPP_URL regardless of request origin", async () => {
const testOrigins = [
"https://app.cal.com",
"https://acme.cal.com",
@@ -164,7 +167,7 @@ describe("link route", () => {
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe(origin);
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
expect(redirectUrl.pathname).toBe("/booking/test-booking-uid");
}
});
@@ -174,7 +177,6 @@ describe("link route", () => {
it("should redirect with error message when confirmHandler throws a TRPCError", async () => {
const { TRPCError } = await import("@trpc/server");
// Mock confirmHandler to throw a TRPCError
mockConfirmHandler.mockRejectedValueOnce(
new TRPCError({ code: "BAD_REQUEST", message: "Custom error" })
);
@@ -188,15 +190,14 @@ describe("link route", () => {
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe("https://app.example.com");
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
expect(redirectUrl.pathname).toBe("/booking/test-booking-uid");
expect(redirectUrl.searchParams.get("error")).toBe("Custom error");
});
it("should preserve origin in error redirect URL", async () => {
it("should use WEBAPP_URL for error redirects (not localhost when behind proxy)", async () => {
const { TRPCError } = await import("@trpc/server");
// Mock confirmHandler to throw a TRPCError
mockConfirmHandler.mockRejectedValueOnce(new TRPCError({ code: "INTERNAL_SERVER_ERROR" }));
const baseUrl = "https://self-hosted.company.org/api/link?action=accept&token=encrypted-token";
@@ -208,7 +209,7 @@ describe("link route", () => {
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe("https://self-hosted.company.org");
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
expect(location).not.toContain("localhost");
});
});
+9 -8
View File
@@ -1,14 +1,15 @@
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { z } from "zod";
import process from "node:process";
import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import { distributedTracing } from "@calcom/lib/tracing/factory";
import prisma from "@calcom/prisma";
import { confirmHandler } from "@calcom/trpc/server/routers/viewer/bookings/confirm.handler";
import { TRPCError } from "@trpc/server";
import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor";
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { z } from "zod";
enum DirectAction {
ACCEPT = "accept",
@@ -99,11 +100,11 @@ async function handler(request: NextRequest) {
let message = "Error confirming booking";
if (e instanceof TRPCError) message = (e as TRPCError).message;
return NextResponse.redirect(
new URL(`/booking/${bookingUid}?error=${encodeURIComponent(message)}`, request.url)
new URL(`/booking/${bookingUid}?error=${encodeURIComponent(message)}`, WEBAPP_URL)
);
}
return NextResponse.redirect(new URL(`/booking/${bookingUid}`, request.url));
return NextResponse.redirect(new URL(`/booking/${bookingUid}`, WEBAPP_URL));
}
export const GET = defaultResponderForAppDir(handler);
@@ -42,6 +42,7 @@ const mockQueuedFormResponse = {
disabled: false,
},
chosenRouteId: "mock-chosen-route-id",
fallbackAction: null,
createdAt: new Date(),
updatedAt: new Date(),
response: {},
@@ -126,6 +127,57 @@ describe("queuedResponseHandler", () => {
});
});
it("should pass fallbackAction to onSubmissionOfFormResponse when present", async () => {
const fallbackAction = {
type: "eventTypeRedirectUrl" as const,
value: "team/fallback-event",
eventTypeId: 42,
};
vi.mocked(mockRoutingFormResponseRepository.getQueuedFormResponseFromId).mockResolvedValue({
...mockQueuedFormResponse,
fallbackAction,
});
vi.mocked(getSerializableForm).mockResolvedValue({
id: "mock-form-id",
name: "Test Form",
description: "Test Form Description",
fields: [],
routes: [],
userId: 1,
teamId: null,
position: 1,
updatedById: null,
disabled: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
settings: {},
} as unknown as Awaited<ReturnType<typeof getSerializableForm>>);
vi.mocked(getResponseToStore).mockReturnValue({} as ReturnType<typeof getResponseToStore>);
vi.mocked(onSubmissionOfFormResponse).mockResolvedValue(undefined as unknown as Awaited<
ReturnType<typeof onSubmissionOfFormResponse>
>);
vi.mocked(mockRoutingFormResponseRepository.recordFormResponse).mockResolvedValue({
id: 1,
response: {},
} as Awaited<ReturnType<typeof mockRoutingFormResponseRepository.recordFormResponse>>);
await queuedResponseHandler({
queuedFormResponseId: "1",
params: {},
});
expect(onSubmissionOfFormResponse).toHaveBeenCalledWith(
expect.objectContaining({
fallbackAction,
})
);
});
it("if no queued form response is found, should return early", async () => {
vi.mocked(mockRoutingFormResponseRepository.getQueuedFormResponseFromId).mockResolvedValue(null);
const response = await queuedResponseHandler({
@@ -72,6 +72,12 @@ export const queuedResponseHandler = async ({
}
const chosenRoute = serializableForm.routes?.find((r) => r.id === queuedFormResponse.chosenRouteId);
const fallbackAction = queuedFormResponse.fallbackAction as {
type: "customPageMessage" | "externalRedirectUrl" | "eventTypeRedirectUrl";
value: string;
eventTypeId?: number;
} | null;
await onSubmissionOfFormResponse({
form: {
...queuedFormResponse.form,
@@ -79,6 +85,7 @@ export const queuedResponseHandler = async ({
},
formResponseInDb: formResponse,
chosenRouteAction: chosenRoute ? ("action" in chosenRoute ? chosenRoute.action : null) : null,
fallbackAction,
});
return {
@@ -1,7 +1,8 @@
import type { NextRequest } from "next/server";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { confirmHandler } from "@calcom/trpc/server/routers/viewer/bookings/confirm.handler";
import type { NextRequest } from "next/server";
import type { Mock } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockConfirmHandler = confirmHandler as unknown as Mock<typeof confirmHandler>;
vi.mock("app/api/defaultResponderForAppDir", () => ({
@@ -80,6 +81,9 @@ function setMockRequestBody(body: Record<string, unknown>) {
mockRequestBody = body;
}
// Vitest sets NEXT_PUBLIC_WEBAPP_URL to http://app.cal.local:3000 (see vitest.config.mts)
const EXPECTED_REDIRECT_ORIGIN = "http://app.cal.local:3000";
function expectErrorRedirect(res: Response, path: string, error: string) {
const location = res.headers.get("location");
expect(location).toBeTruthy();
@@ -88,8 +92,10 @@ function expectErrorRedirect(res: Response, path: string, error: string) {
expect(redirectUrl.searchParams.get("error")).toBe(error);
}
import process from "node:process";
// Import after mocks are set up
import { GET, POST } from "../route";
const DB = {
bookings: {} as Record<
string,
@@ -179,7 +185,22 @@ describe("verify-booking-token route", () => {
});
describe("GET handler", () => {
it("should redirect to booking page with error when action is reject (requires POST)", async () => {
it("should process rejection via GET (for email clients that don't support POST)", async () => {
createMockBooking({
id: 1,
uid: "abc123",
oneTimePassword: "test-token",
recurringEventId: null,
});
createMockUser({
id: 42,
uuid: "user-uuid",
email: "test@example.com",
username: "testuser",
role: "USER",
destinationCalendar: null,
});
const baseUrl =
"https://app.example.com/api/verify-booking-token?action=reject&token=test-token&bookingUid=abc123&userId=42";
const req = createMockRequest(baseUrl, "GET");
@@ -189,9 +210,18 @@ describe("verify-booking-token route", () => {
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe("https://app.example.com");
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
expect(redirectUrl.pathname).toBe("/booking/abc123");
expect(redirectUrl.searchParams.get("error")).toBe("Rejection requires POST method");
expect(redirectUrl.searchParams.get("error")).toBeNull();
expect(mockConfirmHandler).toHaveBeenCalledWith(
expect.objectContaining({
input: expect.objectContaining({
bookingId: 1,
confirmed: false,
actionSource: "MAGIC_LINK",
}),
})
);
});
it("should redirect with error when query params are invalid (missing action)", async () => {
@@ -205,7 +235,7 @@ describe("verify-booking-token route", () => {
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe("https://app.example.com");
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
expect(redirectUrl.pathname).toBe("/booking/abc123");
expect(redirectUrl.searchParams.get("error")).toBe("Error confirming booking");
});
@@ -221,12 +251,12 @@ describe("verify-booking-token route", () => {
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe("https://app.example.com");
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
expect(redirectUrl.pathname).toBe("/booking/abc123");
expect(redirectUrl.searchParams.get("error")).toBe("Error confirming booking");
});
it("should preserve the request origin in redirect URL (not hardcode localhost)", async () => {
it("should use WEBAPP_URL for redirects (fixes localhost when behind proxy)", async () => {
const baseUrl =
"https://custom-domain.example.org/api/verify-booking-token?action=reject&token=t&bookingUid=booking-uid&userId=1";
const req = createMockRequest(baseUrl, "GET");
@@ -237,7 +267,7 @@ describe("verify-booking-token route", () => {
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe("https://custom-domain.example.org");
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
expect(location).not.toContain("localhost");
});
@@ -270,12 +300,12 @@ describe("verify-booking-token route", () => {
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe("https://app.example.com");
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
expect(redirectUrl.pathname).toBe("/booking/abc123");
expect(redirectUrl.searchParams.get("error")).toBe("Error confirming booking");
});
it("should preserve the request origin in POST redirect URL", async () => {
it("should use WEBAPP_URL for POST redirects (fixes localhost when behind proxy)", async () => {
const baseUrl =
"https://self-hosted.company.com/api/verify-booking-token?bookingUid=uid123&token=t&userId=1&action=reject";
const req = createMockRequest(baseUrl, "POST");
@@ -286,13 +316,13 @@ describe("verify-booking-token route", () => {
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe("https://self-hosted.company.com");
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
expect(location).not.toContain("localhost");
});
});
describe("redirect URL construction", () => {
it("should construct redirect URLs relative to the request URL, not hardcoded origins", async () => {
it("should construct redirect URLs using WEBAPP_URL regardless of request origin", async () => {
const testOrigins = [
"https://app.cal.com",
"https://acme.cal.com",
@@ -311,7 +341,7 @@ describe("verify-booking-token route", () => {
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe(origin);
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
expect(redirectUrl.pathname).toBe("/booking/test-uid");
}
});
+12 -22
View File
@@ -1,15 +1,15 @@
import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { distributedTracing } from "@calcom/lib/tracing/factory";
import prisma from "@calcom/prisma";
import { confirmHandler } from "@calcom/trpc/server/routers/viewer/bookings/confirm.handler";
import { TRPCError } from "@trpc/server";
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import { parseRequestData } from "app/api/parseRequestData";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { z } from "zod";
import { distributedTracing } from "@calcom/lib/tracing/factory";
import prisma from "@calcom/prisma";
import { confirmHandler } from "@calcom/trpc/server/routers/viewer/bookings/confirm.handler";
import { TRPCError } from "@trpc/server";
import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor";
enum DirectAction {
ACCEPT = "accept",
REJECT = "reject",
@@ -28,21 +28,11 @@ async function getHandler(request: NextRequest) {
try {
const { action, token, bookingUid, userId } = querySchema.parse(queryParams);
if (action === DirectAction.REJECT) {
// Rejections should use POST method
return NextResponse.redirect(
new URL(
`/booking/${bookingUid}?error=${encodeURIComponent("Rejection requires POST method")}`,
request.url
)
);
}
return await handleBookingAction(action, token, bookingUid, userId, request, undefined);
} catch {
const bookingUid = queryParams.bookingUid || "";
return NextResponse.redirect(
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, request.url)
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, WEBAPP_URL)
);
}
}
@@ -59,7 +49,7 @@ async function postHandler(request: NextRequest) {
} catch {
const bookingUid = queryParams.bookingUid || "";
return NextResponse.redirect(
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, request.url),
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, WEBAPP_URL),
{ status: 303 }
);
}
@@ -70,7 +60,7 @@ async function handleBookingAction(
token: string,
bookingUid: string,
userId: string,
request: NextRequest,
_request: NextRequest,
reason?: string
) {
const booking = await prisma.booking.findUnique({
@@ -79,7 +69,7 @@ async function handleBookingAction(
if (!booking) {
return NextResponse.redirect(
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, request.url),
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, WEBAPP_URL),
{ status: 303 }
);
}
@@ -124,7 +114,7 @@ async function handleBookingAction(
let message = "Error confirming booking";
if (e instanceof TRPCError) message = (e as TRPCError).message;
return NextResponse.redirect(
new URL(`/booking/${booking.uid}?error=${encodeURIComponent(message)}`, request.url),
new URL(`/booking/${booking.uid}?error=${encodeURIComponent(message)}`, WEBAPP_URL),
{ status: 303 }
);
}
@@ -134,7 +124,7 @@ async function handleBookingAction(
data: { oneTimePassword: null },
});
return NextResponse.redirect(new URL(`/booking/${booking.uid}`, request.url), { status: 303 });
return NextResponse.redirect(new URL(`/booking/${booking.uid}`, WEBAPP_URL), { status: 303 });
}
export const GET = defaultResponderForAppDir(getHandler);
+1 -1
View File
@@ -1,5 +1,5 @@
import { getLocale } from "@calcom/features/auth/lib/getLocale";
import { loadTranslations } from "@calcom/lib/server/i18n";
import { loadTranslations } from "@calcom/i18n/server";
import { IconSprites } from "@calcom/ui/components/icon";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { dir } from "i18next";
+44 -42
View File
@@ -15,10 +15,17 @@ export default function PayPalSetup() {
const [newSecretKey, setNewSecretKey] = useState("");
const router = useRouter();
const { t } = useLocale();
const integrations = trpc.viewer.apps.integrations.useQuery({ variant: "payment", appId: "paypal" });
const integrations = trpc.viewer.apps.integrations.useQuery({
variant: "payment",
appId: "paypal",
});
const [paypalPaymentAppCredentials] = integrations.data?.items || [];
const [credentialId] = paypalPaymentAppCredentials?.userCredentialIds || [-1];
const showContent = !!integrations.data && integrations.isSuccess && !!credentialId;
const saveKeysMutation = trpc.viewer.apps.updateAppCredentials.useMutation({
onSuccess: () => {
showToast(t("keys_have_been_saved"), "success");
@@ -39,12 +46,13 @@ export default function PayPalSetup() {
<div className="bg-default border-subtle m-auto max-w-[43em] overflow-auto rounded border pb-10 md:p-10">
<div className="ml-2 ltr:mr-2 rtl:ml-2 md:ml-5">
<div className="invisible md:visible">
<img className="h-11" src="/api/app-store/paypal/icon.svg" alt="Paypal Payment Logo" />
<p className="text-default mt-5 text-lg">Paypal</p>
<img className="h-11" src="/api/app-store/paypal/icon.svg" alt={t("paypal_payment_logo")} />
<p className="text-default mt-5 text-lg">{t("paypal")}</p>
</div>
<form autoComplete="off" className="mt-5">
<TextField
label="Client Id"
label={t("client_id")}
type="text"
name="client_id"
id="client_id"
@@ -55,7 +63,7 @@ export default function PayPalSetup() {
/>
<TextField
label="Secret Key"
label={t("secret_key")}
type="password"
name="access_token"
id="access_token"
@@ -65,7 +73,6 @@ export default function PayPalSetup() {
onChange={(e) => setNewSecretKey(e.target.value)}
/>
{/* Button to submit */}
<div className="mt-5 flex flex-row justify-end">
<Button
color="primary"
@@ -82,43 +89,39 @@ export default function PayPalSetup() {
</Button>
</div>
</form>
<div>
<p className="text-lgf text-default mt-5 font-bold">Getting started with Paypal APP</p>
<p className="text-default font-semi mt-2">
Here in Cal.com we offer Paypal as one of our payment gateway. You can use your own Paypal
Business account to receive payments from your customers enabling and setting up price and
currency for each of your event types.
</p>
<p className="text-lgf text-default mt-5 font-bold">{t("paypal_getting_started")}</p>
<p className="text-default font-semi mt-2">{t("paypal_description")}</p>
<p className="text-lgf text-default mt-5 inline-flex font-bold">
<CircleAlertIcon className="mr-2 mt-1 h-4 w-4" /> Important requirements:
<CircleAlertIcon className="mr-2 mt-1 h-4 w-4" />
{t("important_requirements")}
</p>
<ul className="text-default ml-1 mt-2 list-disc pl-2">
<li>Paypal Business account</li>
<li>Paypal Developer account</li>
<li>{t("paypal_business_account")}</li>
<li>{t("paypal_developer_account")}</li>
</ul>
<p className="text-default mb-2 mt-5 font-bold">Resources:</p>
<p className="text-default mb-2 mt-5 font-bold">{t("resources")}</p>
<a
className="text-orange-600 underline"
target="_blank"
href="https://developer.paypal.com/api/rest/#link-getclientidandclientsecret"
rel="noreferrer">
Link to Paypal developer API REST Setup Guide:
https://developer.paypal.com/api/rest/#link-getclientidandclientsecret
{t("paypal_rest_setup_guide")}
</a>
<p className="text-lgf text-default mt-5 font-bold">Setup instructions</p>
<p className="text-default font-semi mt-2">
Remember to only proceed with the following steps if your account has already been upgraded to
a business account. Also keep in mind that some of the following steps might be different
since Paypal offers different experiences based on your country.
</p>
<p className="text-lgf text-default mt-5 font-bold">{t("paypal_setup_instructions")}</p>
<p className="text-default font-semi mt-2">{t("paypal_setup_note")}</p>
<ol className="text-default ml-1 mt-5 list-decimal pl-2">
{/* @TODO: translate */}
<li>
Log into your Paypal Developer account and create a new app{" "}
{t("paypal_step_1")}{" "}
<a
target="_blank"
href="https://developer.paypal.com/dashboard/applications/live"
@@ -128,39 +131,38 @@ export default function PayPalSetup() {
</a>
.
</li>
<li>Choose a name for your application.</li>
<li>Click on the Create App button</li>
<li>{t("paypal_step_2")}</li>
<li>{t("paypal_step_3")}</li>
<li>
Go back to{" "}
{t("paypal_step_4_part_1")}{" "}
<a
className="text-orange-600 underline"
href="https://developer.paypal.com/dashboard/applications/live">
dashboard
{t("dashboard")}
</a>
, click on new app created.
, {t("paypal_step_4_part_2")}
</li>
<li>Copy the Client ID and Secret Key using copy buttons one by one.</li>
<li>Paste them on the required field and save them.</li>
<li>You should be all setup after this.</li>
<li>{t("paypal_step_5")}</li>
<li>{t("paypal_step_6")}</li>
<li>{t("paypal_step_7")}</li>
</ol>
<p className="text-default mt-5 inline-flex font-bold">
<CircleAlertIcon className="mr-2 mt-1 h-4 w-4" />
Reminder:
</p>
<p className="text-default mt-2">
Our integration creates a specific webhook on your Paypal account that we use to report back
transactions to our system. If you delete this webhook, we will not be able to report back and
you should Uninstall and Install the app again for this to work again. Uninstalling the app
won&apos;t delete your current event type price/currency configuration but you would not be
able to receive bookings.
{t("reminder")}:
</p>
<p className="text-default mt-2">{t("paypal_webhook_reminder")}</p>
</div>
</div>
</div>
) : (
<AppNotInstalledMessage appName="paypal" />
)}
<Toaster position="bottom-right" />
</div>
);
+76 -64
View File
@@ -1,17 +1,18 @@
"use client";
import { useCallback, useState } from "react";
import { sdkActionManager } from "@calcom/embed-core/embed-iframe";
import { isCancellationReasonRequired } from "@calcom/features/bookings/lib/cancellationReason";
import { shouldChargeNoShowCancellationFee } from "@calcom/features/bookings/lib/payment/shouldChargeNoShowCancellationFee";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRefreshData } from "@calcom/lib/hooks/useRefreshData";
import type { CancellationReasonRequirement } from "@calcom/prisma/enums";
import type { RecurringEvent } from "@calcom/types/Calendar";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { Label, Select, TextArea, CheckboxField } from "@calcom/ui/components/form";
import { CheckboxField, Label, Select, TextArea } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";
import { InfoIcon, XIcon } from "@coss/ui/icons";
import { useCallback, useState } from "react";
interface InternalNotePresetsSelectProps {
internalNotePresets: { id: number; name: string }[];
@@ -46,6 +47,7 @@ const InternalNotePresetsSelect = ({
}
};
return (
<div className="mb-4 flex flex-col">
<Label>{t("internal_booking_note")}</Label>
@@ -109,6 +111,7 @@ type Props = {
internalNotePresets: { id: number; name: string; cancellationReason: string | null }[];
renderContext: "booking-single-view" | "dialog";
eventTypeMetadata?: Record<string, unknown> | null;
requiresCancellationReason?: CancellationReasonRequirement | null;
showErrorAsToast?: boolean;
onCanceled?: () => void;
};
@@ -162,11 +165,18 @@ export default function CancelBooking(props: Props) {
const isCancellationUserHost =
props.isHost || bookingCancelledEventProps.organizer.email === currentUserEmail;
const hostMissingCancellationReason =
isCancellationUserHost &&
(!cancellationReason?.trim() || (props.internalNotePresets.length > 0 && !internalNote?.id));
const isReasonRequired = isCancellationReasonRequired(
props.requiresCancellationReason,
isCancellationUserHost
);
const missingRequiredReason = isReasonRequired && !cancellationReason?.trim();
const hostMissingInternalNote =
isCancellationUserHost && props.internalNotePresets.length > 0 && !internalNote?.id;
const cancellationNoShowFeeNotAcknowledged =
!props.isHost && cancellationNoShowFeeWarning && !acknowledgeCancellationNoShowFee;
const canCancel =
!missingRequiredReason && !hostMissingInternalNote && !cancellationNoShowFeeNotAcknowledged;
const cancelBookingRef = useCallback((node: HTMLTextAreaElement) => {
if (node !== null) {
// eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- CancelBooking is not usually used in embed mode
@@ -177,6 +187,63 @@ export default function CancelBooking(props: Props) {
const isRenderedAsCancelDialog = props.renderContext === "dialog";
const handleCancel =async()=>{
try{
setLoading(true);
const response = await fetch("/api/csrf?sameSite=none", { cache: "no-store" });
const { csrfToken } = await response.json();
const res = await fetch("/api/cancel", {
body: JSON.stringify({
uid: booking?.uid,
cancellationReason: cancellationReason,
allRemainingBookings,
// @NOTE: very important this shouldn't cancel with number ID use uid instead
seatReferenceUid,
cancelledBy: currentUserEmail,
internalNote: internalNote,
csrfToken,
}),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
const bookingWithCancellationReason = {
...(bookingCancelledEventProps.booking as object),
cancellationReason,
} as unknown;
if (res.status >= 200 && res.status < 300) {
sdkActionManager?.fire("bookingCancelled", {
...bookingCancelledEventProps,
booking: bookingWithCancellationReason,
});
refreshData();
if (props.onCanceled) {
props.onCanceled();
}
} else {
const data = await res.json();
const errorMessage =
data.message ||
`${t("error_with_status_code_occured", { status: res.status })} ${t(
"please_try_again"
)}`;
if (props.showErrorAsToast) {
showToast(errorMessage, "error");
} else {
setError(errorMessage);
}
}
} finally{
setLoading(false);
}
}
return (
<>
{error && !props.showErrorAsToast && (
@@ -217,7 +284,7 @@ export default function CancelBooking(props: Props) {
</>
)}
<Label>{isCancellationUserHost ? t("cancellation_reason_host") : t("cancellation_reason")}</Label>
<Label>{t(isReasonRequired ? "cancellation_reason" : "cancellation_reason_optional_label")}</Label>
<TextArea
data-testid="cancel_reason"
@@ -267,63 +334,8 @@ export default function CancelBooking(props: Props) {
</Button>
<Button
data-testid="confirm_cancel"
disabled={hostMissingCancellationReason || cancellationNoShowFeeNotAcknowledged}
onClick={async () => {
setLoading(true);
// telemetry.event(telemetryEventTypes.bookingCancelled, collectPageParameters());
const response = await fetch("/api/csrf?sameSite=none", { cache: "no-store" });
const { csrfToken } = await response.json();
const res = await fetch("/api/cancel", {
body: JSON.stringify({
uid: booking?.uid,
cancellationReason: cancellationReason,
allRemainingBookings,
// @NOTE: very important this shouldn't cancel with number ID use uid instead
seatReferenceUid,
cancelledBy: currentUserEmail,
internalNote: internalNote,
csrfToken,
}),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
const bookingWithCancellationReason = {
...(bookingCancelledEventProps.booking as object),
cancellationReason,
} as unknown;
if (res.status >= 200 && res.status < 300) {
// tested by apps/web/playwright/booking-pages.e2e.ts
sdkActionManager?.fire("bookingCancelled", {
...bookingCancelledEventProps,
booking: bookingWithCancellationReason,
});
refreshData();
if (props.onCanceled) {
props.onCanceled();
}
} else {
const data = await res.json();
setLoading(false);
const errorMessage =
data.message ||
`${t("error_with_status_code_occured", { status: res.status })} ${t(
"please_try_again"
)}`;
if (props.showErrorAsToast) {
showToast(errorMessage, "error");
} else {
setError(errorMessage);
}
}
}}
disabled={!canCancel}
onClick={handleCancel}
loading={loading}>
{props.allRemainingBookings ? t("cancel_all_remaining") : t("cancel_event")}
</Button>
@@ -2,6 +2,7 @@ import type { Dispatch, SetStateAction } from "react";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { CancellationReasonRequirement } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import type { RecurringEvent } from "@calcom/types/Calendar";
import { DialogContent, DialogHeader } from "@calcom/ui/components/dialog";
@@ -48,6 +49,7 @@ interface ICancelBookingDialog {
isHost: boolean;
internalNotePresets?: { id: number; name: string; cancellationReason: string | null }[];
eventTypeMetadata?: Record<string, unknown> | null;
requiresCancellationReason?: CancellationReasonRequirement | null;
}
export const CancelBookingDialog = (props: ICancelBookingDialog) => {
@@ -67,6 +69,7 @@ export const CancelBookingDialog = (props: ICancelBookingDialog) => {
isHost,
internalNotePresets = [],
eventTypeMetadata,
requiresCancellationReason,
} = props;
const utils = trpc.useUtils();
@@ -113,6 +116,7 @@ export const CancelBookingDialog = (props: ICancelBookingDialog) => {
isHost={isHost}
internalNotePresets={internalNotePresets}
eventTypeMetadata={eventTypeMetadata}
requiresCancellationReason={requiresCancellationReason}
showErrorAsToast={true}
onCanceled={handleCanceled}
/>
+1
View File
@@ -44,6 +44,7 @@ export const getEventTypesFromDB = async (id: number) => {
hideOrganizerEmail: true,
disableCancelling: true,
disableRescheduling: true,
requiresCancellationReason: true,
minimumRescheduleNotice: true,
disableGuests: true,
timeZone: true,
@@ -1,4 +1,4 @@
import { getTranslation } from "@calcom/lib/server/i18n";
import { getTranslation } from "@calcom/i18n/server";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { getBookingResponse } from "./getBooking";
@@ -54,8 +54,9 @@ export const triggerRecordingReadyWebhook = async ({
})
);
const { assignmentReason: _emailAssignmentReason, ...evtWithoutAssignmentReason } = evt;
const payload: EventPayloadType = {
...evt,
...evtWithoutAssignmentReason,
downloadLink,
};
@@ -94,8 +95,9 @@ export const triggerTranscriptionGeneratedWebhook = async ({
})
);
const { assignmentReason: _emailAssignmentReason2, ...evtWithoutAssignmentReason2 } = evt;
const payload: EventPayloadType = {
...evt,
...evtWithoutAssignmentReason2,
downloadLinks,
};
@@ -1,6 +1,5 @@
"use client";
import dayjs from "@calcom/dayjs";
import type { AuditActorType } from "@calcom/features/booking-audit/lib/repository/IAuditActorRepository";
import ServerTrans from "@calcom/lib/components/ServerTrans";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -10,6 +9,8 @@ import { Button } from "@calcom/ui/components/button";
import { FilterSearchField, Select } from "@calcom/ui/components/form";
import { Icon, type IconName } from "@calcom/ui/components/icon";
import { SkeletonText } from "@calcom/ui/components/skeleton";
import { Tooltip } from "@calcom/ui/components/tooltip";
import { format, formatDistanceToNow } from "date-fns";
import Link from "next/link";
import { useState } from "react";
@@ -28,6 +29,17 @@ type TranslationWithParams = {
components?: TranslationComponent[];
};
type DisplayFieldValue =
| { type: "translationKey"; valueKey: string }
| { type: "rawValue"; value: string }
| { type: "rawValues"; values: string[] }
| { type: "translationsWithParams"; valuesWithParams: TranslationWithParams[] };
type DisplayField = {
labelKey: string;
fieldValue: DisplayFieldValue;
};
type AuditLog = {
id: string;
action: string;
@@ -36,7 +48,7 @@ type AuditLog = {
source: string;
displayJson?: Record<string, unknown> | null;
actionDisplayTitle: TranslationWithParams;
displayFields?: Array<{ labelKey: string; valueKey?: string; value?: string; values?: string[] }> | null;
displayFields?: DisplayField[] | null;
actor: {
type: AuditActorType;
displayName: string | null;
@@ -139,7 +151,13 @@ function ActionTitle({ actionDisplayTitle }: { actionDisplayTitle: TranslationWi
values={actionDisplayTitle.params}
components={actionDisplayTitle.components.map((comp) =>
comp.type === "link" ? (
<Link key={comp.href} href={comp.href} className="text-emphasis underline hover:no-underline" />
<Link
key={comp.href}
href={comp.href}
target="_blank"
rel="noopener noreferrer"
className="text-emphasis underline hover:no-underline"
/>
) : (
<span key={comp.href} />
)
@@ -181,30 +199,39 @@ function JsonViewer({ data }: JsonViewerProps) {
);
}
interface DisplayFieldValueProps {
field: {
valueKey?: string;
value?: string;
values?: string[];
};
interface DisplayFieldValueComponentProps {
fieldValue: DisplayFieldValue;
}
function DisplayFieldValue({ field }: DisplayFieldValueProps) {
function DisplayFieldValueComponent({ fieldValue }: DisplayFieldValueComponentProps) {
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>
);
switch (fieldValue.type) {
case "translationsWithParams":
return (
<span className="flex flex-col">
{fieldValue.valuesWithParams.map((v, i) => (
<span className="p-0.5" key={i}>
{t(v.key, v.params)}
</span>
))}
</span>
);
case "rawValues":
return (
<span className="flex flex-col">
{fieldValue.values.map((v, i) => (
<span className="p-0.5" key={i}>
{v}
</span>
))}
</span>
);
case "rawValue":
return <>{fieldValue.value}</>;
case "translationKey":
return <>{t(fieldValue.valueKey)}</>;
}
return <>{field.value ?? (field.valueKey ? t(field.valueKey) : "")}</>;
}
function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
@@ -280,7 +307,9 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
{actorRole && <span>{` (${t(actorRole)})`}</span>}
</span>
<span>•</span>
<span>{dayjs(log.timestamp).fromNow()}</span>
<Tooltip content={format(new Date(log.timestamp), "yyyy-MM-dd HH:mm:ss")}>
<span>{formatDistanceToNow(new Date(log.timestamp), { addSuffix: true })}</span>
</Tooltip>
</div>
</div>
</div>
@@ -304,15 +333,15 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
{/* Render displayFields if available, otherwise show type */}
{log.displayFields && log.displayFields.length > 0
? log.displayFields.map((field, idx) => (
<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>
<span className="font-medium">
<DisplayFieldValue field={field} />
</span>
</div>
))
<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>
<span className="font-medium">
<DisplayFieldValueComponent fieldValue={field.fieldValue} />
</span>
</div>
))
: null}
<div className="flex items-start gap-2 py-2 border-b px-3 border-subtle">
<span className="font-medium text-emphasis w-[140px]">{t("actor")}</span>
@@ -337,7 +366,7 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
<div className="flex items-start gap-2 py-2 px-3 border-b border-subtle">
<span className="font-medium text-emphasis w-[140px]">{t("timestamp")}</span>
<span className="text-default">
{dayjs(log.timestamp).format("YYYY-MM-DD HH:mm:ss")}
{format(new Date(log.timestamp), "yyyy-MM-dd HH:mm:ss")}
</span>
</div>
{log.displayJson && Object.keys(log.displayJson).length > 0 && (
@@ -368,28 +397,44 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
}
function useBookingLogsFilters(auditLogs: AuditLog[], searchTerm: string, actorFilter: string | null) {
const { t } = useLocale();
const filteredLogs = auditLogs.filter((log) => {
const doesMatchDisplayFields = (): boolean => {
return (
log.displayFields?.some((field) => {
const searchLower = searchTerm.toLowerCase();
return (
field.valueKey?.toLowerCase().includes(searchLower) ||
field.labelKey?.toLowerCase().includes(searchLower) ||
field.value?.toLowerCase().includes(searchLower) ||
field.values?.some((v) => v.toLowerCase().includes(searchLower))
);
const translatedLabel = field.labelKey ? t(field.labelKey) : "";
if (translatedLabel.toLowerCase().includes(searchLower)) {
return true;
}
const { fieldValue } = field;
switch (fieldValue.type) {
case "translationKey":
return t(fieldValue.valueKey).toLowerCase().includes(searchLower);
case "rawValue":
return fieldValue.value.toLowerCase().includes(searchLower);
case "rawValues":
return fieldValue.values.some((v) => v.toLowerCase().includes(searchLower));
case "translationsWithParams":
return fieldValue.valuesWithParams.some(
(v) =>
t(v.key, v.params).toLowerCase().includes(searchLower) ||
Object.values(v.params ?? {}).some((param) =>
param?.toString().toLowerCase().includes(searchLower)
)
);
}
}) ?? 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())
)
);
const translatedTitle = log.actionDisplayTitle.key
? t(log.actionDisplayTitle.key, log.actionDisplayTitle.params ?? {})
: "";
return translatedTitle.toLowerCase().includes(searchTerm.toLowerCase());
};
const matchesSearch =
@@ -19,7 +19,7 @@ import { useInitializeBookerStore } from "@calcom/features/bookings/Booker/store
import { useEvent, useScheduleForEvent } from "@calcom/web/modules/schedules/hooks/useEvent";
import { useBrandColors } from "@calcom/features/bookings/Booker/utils/use-brand-colors";
import type { getPublicEvent } from "@calcom/features/eventtypes/lib/getPublicEvent";
import { DEFAULT_DARK_BRAND_COLOR, DEFAULT_LIGHT_BRAND_COLOR, WEBAPP_URL } from "@calcom/lib/constants";
import { DEFAULT_LIGHT_BRAND_COLOR, DEFAULT_DARK_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";
@@ -42,11 +42,11 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps): JSX.Elemen
});
const event = props.eventData
? {
data: props.eventData,
isSuccess: true,
isError: false,
isPending: false,
}
data: props.eventData,
isSuccess: true,
isError: false,
isPending: false,
}
: clientFetchedEvent;
const bookerLayout = useBookerLayout(event.data?.profile?.bookerLayouts);
@@ -126,6 +126,7 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps): JSX.Elemen
name: bookerForm.formName,
requiresBookerEmailVerification: event?.data?.requiresBookerEmailVerification,
onVerifyEmail: bookerForm.beforeVerifyEmail,
eventTypeId: event?.data?.id,
});
const slots = useSlots(event?.data ? { id: event.data.id, length: event.data.length } : null);
@@ -149,6 +150,9 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps): JSX.Elemen
useApiV2: props.useApiV2,
bookerLayout,
...(props.entity.orgSlug ? { orgSlug: props.entity.orgSlug } : {}),
restrictionSchedule: event.data?.restrictionScheduleId
? { id: event.data.restrictionScheduleId, useBookerTimezone: event.data.useBookerTimezone }
: undefined,
});
const bookings = useBookings({
event,
@@ -4,7 +4,7 @@ import dayjs from "@calcom/dayjs";
import { useTimePreferences } from "@calcom/features/bookings/lib";
import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events";
import { useGetTheme } from "@calcom/lib/hooks/useTheme";
import { Calendar } from "@calcom/web/modules/calendars/weeklyview/components/Calendar";
import { Calendar } from "@calcom/features/calendars/weeklyview/components/Calendar";
import { useBanners } from "@calcom/web/modules/shell/banners/useBanners";
import { useEffect, useMemo } from "react";
import { useBookingDetailsSheetStore } from "../store/bookingDetailsSheetStore";
@@ -0,0 +1,144 @@
import { act, renderHook } from "@testing-library/react";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createStore, useStore } from "zustand";
import type { BookingOutput } from "../types";
type BookingDetailsSheetStore = {
selectedBookingUid: string | null;
bookings: BookingOutput[];
setSelectedBookingUid: (uid: string | null) => void;
setBookings: (bookings: BookingOutput[]) => void;
getSelectedBooking: () => BookingOutput | null;
};
function createTestStore(initialBookings: BookingOutput[] = []) {
return createStore<BookingDetailsSheetStore>((set, get) => ({
selectedBookingUid: null,
bookings: initialBookings,
setSelectedBookingUid: (uid) => set({ selectedBookingUid: uid }),
setBookings: (bookings) => set({ bookings }),
getSelectedBooking: () => {
const state = get();
if (!state.selectedBookingUid) return null;
return state.bookings.find((booking) => booking.uid === state.selectedBookingUid) ?? null;
},
}));
}
const makeBooking = (uid: string, title: string): BookingOutput =>
({
uid,
id: Number(uid),
title,
startTime: new Date().toISOString(),
endTime: new Date().toISOString(),
status: "ACCEPTED",
attendees: [],
metadata: null,
location: null,
rescheduled: false,
recurringEventId: null,
fromReschedule: null,
responses: {},
payment: [],
eventType: null,
user: null,
assignmentReasonSortedByCreatedAt: [],
}) as unknown as BookingOutput;
describe("BookingDetailsSheet transition behavior", () => {
const bookingA = makeBooking("uid-a", "Booking A");
const bookingB = makeBooking("uid-b", "Booking B");
it("getSelectedBooking returns null when no uid is selected", () => {
const store = createTestStore([bookingA, bookingB]);
expect(store.getState().getSelectedBooking()).toBeNull();
});
it("getSelectedBooking returns the correct booking when uid is set", () => {
const store = createTestStore([bookingA, bookingB]);
store.getState().setSelectedBookingUid("uid-a");
expect(store.getState().getSelectedBooking()?.uid).toBe("uid-a");
});
it("getSelectedBooking returns null when uid does not match any booking", () => {
const store = createTestStore([bookingA, bookingB]);
store.getState().setSelectedBookingUid("uid-nonexistent");
expect(store.getState().getSelectedBooking()).toBeNull();
});
it("switching between bookings updates getSelectedBooking immediately", () => {
const store = createTestStore([bookingA, bookingB]);
store.getState().setSelectedBookingUid("uid-a");
expect(store.getState().getSelectedBooking()?.title).toBe("Booking A");
store.getState().setSelectedBookingUid("uid-b");
expect(store.getState().getSelectedBooking()?.title).toBe("Booking B");
});
describe("lastBookingRef fallback logic", () => {
it("preserves last booking when getSelectedBooking returns null during transition", () => {
let lastBooking: BookingOutput | null = null;
let selectedBookingUid: string | null = null;
const store = createTestStore([bookingA, bookingB]);
store.getState().setSelectedBookingUid("uid-a");
const booking = store.getState().getSelectedBooking();
if (booking) {
lastBooking = booking;
}
store.getState().setSelectedBookingUid("uid-nonexistent");
selectedBookingUid = store.getState().selectedBookingUid;
const currentBooking = store.getState().getSelectedBooking();
const displayBooking = currentBooking ?? lastBooking;
expect(currentBooking).toBeNull();
expect(selectedBookingUid).toBe("uid-nonexistent");
expect(displayBooking?.uid).toBe("uid-a");
});
it("clears lastBooking when selectedBookingUid is explicitly null", () => {
let lastBooking: BookingOutput | null = null;
const store = createTestStore([bookingA]);
store.getState().setSelectedBookingUid("uid-a");
const booking = store.getState().getSelectedBooking();
if (booking) {
lastBooking = booking;
}
store.getState().setSelectedBookingUid(null);
const selectedBookingUid = store.getState().selectedBookingUid;
if (!selectedBookingUid) {
lastBooking = null;
}
const currentBooking = store.getState().getSelectedBooking();
const displayBooking = currentBooking ?? lastBooking;
expect(displayBooking).toBeNull();
});
it("updates lastBooking when new booking is found", () => {
let lastBooking: BookingOutput | null = null;
const store = createTestStore([bookingA, bookingB]);
store.getState().setSelectedBookingUid("uid-a");
const bookingAResult = store.getState().getSelectedBooking();
if (bookingAResult) lastBooking = bookingAResult;
expect(lastBooking?.uid).toBe("uid-a");
store.getState().setSelectedBookingUid("uid-b");
const bookingBResult = store.getState().getSelectedBooking();
if (bookingBResult) lastBooking = bookingBResult;
expect(lastBooking?.uid).toBe("uid-b");
});
});
});
@@ -4,6 +4,7 @@ import dayjs from "@calcom/dayjs";
import { useBookingLocation } from "@calcom/features/bookings/hooks";
import { shouldShowFieldInCustomResponses } from "@calcom/lib/bookings/SystemField";
import { formatPrice } from "@calcom/lib/currencyConversions";
import { formatToLocalizedTimezone } from "@calcom/lib/dayjs";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -29,11 +30,12 @@ import {
SheetHeader,
SheetTitle,
} from "@calcom/ui/components/sheet";
import { Tooltip } from "@calcom/ui/components/tooltip";
import { ExternalLinkIcon, RepeatIcon } from "@coss/ui/icons";
import { BookingHistory } from "@calcom/web/modules/booking-audit/components/BookingHistory";
import assignmentReasonBadgeTitleMap from "@lib/booking/assignmentReasonBadgeTitleMap";
import Link from "next/link";
import { useMemo } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import type { z } from "zod";
import { AcceptBookingButton } from "../../../components/booking/AcceptBookingButton";
import { BookingActionsDropdown } from "../../../components/booking/actions/BookingActionsDropdown";
@@ -43,6 +45,10 @@ import type { BookingListingStatus } from "../../../components/booking/types";
import { usePaymentStatus } from "../hooks/usePaymentStatus";
import { useBookingDetailsSheetStore } from "../store/bookingDetailsSheetStore";
import type { BookingOutput } from "../types";
import {
checkSheetActive,
createBookingSheetKeydownHandler,
} from "../lib/bookingSheetKeyboardHandler";
import { JoinMeetingButton } from "./JoinMeetingButton";
type BookingMetaData = z.infer<typeof bookingMetadataSchema>;
@@ -63,14 +69,25 @@ export function BookingDetailsSheet({
bookingAuditEnabled = false,
}: BookingDetailsSheetProps) {
const booking = useBookingDetailsSheetStore((state) => state.getSelectedBooking());
const selectedBookingUid = useBookingDetailsSheetStore((state) => state.selectedBookingUid);
const lastBookingRef = useRef<BookingOutput | null>(null);
// Return null if no booking is selected (sheet is closed)
if (!booking) return null;
if (booking) {
lastBookingRef.current = booking;
}
if (!selectedBookingUid) {
lastBookingRef.current = null;
}
const displayBooking = booking ?? lastBookingRef.current;
if (!displayBooking) return null;
return (
<BookingActionsStoreProvider>
<BookingDetailsSheetInner
booking={booking}
booking={displayBooking}
userTimeZone={userTimeZone}
userTimeFormat={userTimeFormat}
userId={userId}
@@ -91,10 +108,9 @@ interface BookingDetailsSheetInnerProps {
}
function useActiveSegment(bookingAuditEnabled: boolean) {
const [activeSegment, setActiveSegmentInStore] = useBookingDetailsSheetStore((state) => [
state.activeSegment,
state.setActiveSegment,
]);
const [activeSegment, setActiveSegmentInStore] = useBookingDetailsSheetStore(
(state) => [state.activeSegment, state.setActiveSegment]
);
const getDerivedActiveSegment = ({
activeSegment,
@@ -109,10 +125,15 @@ function useActiveSegment(bookingAuditEnabled: boolean) {
return activeSegment ?? "info";
};
const derivedActiveSegment = getDerivedActiveSegment({ activeSegment, bookingAuditEnabled });
const derivedActiveSegment = getDerivedActiveSegment({
activeSegment,
bookingAuditEnabled,
});
const setDerivedActiveSegment = (segment: "info" | "history") => {
setActiveSegmentInStore(getDerivedActiveSegment({ activeSegment: segment, bookingAuditEnabled }));
setActiveSegmentInStore(
getDerivedActiveSegment({ activeSegment: segment, bookingAuditEnabled })
);
};
return [derivedActiveSegment, setDerivedActiveSegment] as const;
@@ -127,16 +148,18 @@ function BookingDetailsSheetInner({
bookingAuditEnabled = false,
}: BookingDetailsSheetInnerProps) {
const { t } = useLocale();
const [activeSegment, setActiveSegment] = useActiveSegment(bookingAuditEnabled);
const [activeSegment, setActiveSegment] =
useActiveSegment(bookingAuditEnabled);
// Fetch additional booking details for reschedule information
const { data: bookingDetails } = trpc.viewer.bookings.getBookingDetails.useQuery(
{ uid: booking.uid },
{
// Keep data fresh but don't refetch too aggressively
staleTime: 5 * 60 * 1000, // 5 minutes
}
);
const { data: bookingDetails } =
trpc.viewer.bookings.getBookingDetails.useQuery(
{ uid: booking.uid },
{
// Keep data fresh but don't refetch too aggressively
staleTime: 5 * 60 * 1000, // 5 minutes
}
);
// Get navigation state from the store in a single selector
const navigation = useBookingDetailsSheetStore((state) => {
@@ -151,23 +174,57 @@ function BookingDetailsSheetInner({
isTransitioning: state.isTransitioning,
setSelectedBookingUid: state.setSelectedBookingUid,
setActiveSegment: state.setActiveSegment,
canGoNext: hasNextInArray || (isLastInArray && state.capabilities?.canNavigateToNextPeriod()),
canGoPrev: hasPreviousInArray || (isFirstInArray && state.capabilities?.canNavigateToPreviousPeriod()),
canGoNext:
hasNextInArray ||
(isLastInArray && state.capabilities?.canNavigateToNextPeriod()),
canGoPrev:
hasPreviousInArray ||
(isFirstInArray && state.capabilities?.canNavigateToPreviousPeriod()),
};
});
const handleClose = () => {
const handleClose = useCallback(() => {
navigation.setSelectedBookingUid(null);
navigation.setActiveSegment(null);
};
}, [navigation.setSelectedBookingUid, navigation.setActiveSegment]);
const handleNext = () => {
const handleNext = useCallback(() => {
navigation.navigateNext();
};
}, [navigation.navigateNext]);
const handlePrevious = () => {
const handlePrevious = useCallback(() => {
navigation.navigatePrevious();
};
}, [navigation.navigatePrevious]);
const joinButtonWrapperRef = useRef<HTMLDivElement>(null);
const sheetContentRef = useRef<HTMLDivElement>(null);
const isSheetActive = useCallback(
() => checkSheetActive(sheetContentRef.current, document.activeElement),
[]
);
useEffect(() => {
const handleKeyDown = createBookingSheetKeydownHandler({
isSheetActive,
canGoPrev: navigation.canGoPrev,
canGoNext: navigation.canGoNext,
isTransitioning: navigation.isTransitioning,
handlePrevious,
handleNext,
getJoinLink: () => joinButtonWrapperRef.current?.querySelector("a"),
});
document.addEventListener("keydown", handleKeyDown, true);
return () => document.removeEventListener("keydown", handleKeyDown, true);
}, [
navigation.canGoPrev,
navigation.canGoNext,
navigation.isTransitioning,
handleNext,
handlePrevious,
isSheetActive,
]);
const startTime = dayjs(booking.startTime).tz(userTimeZone);
const endTime = dayjs(booking.endTime).tz(userTimeZone);
@@ -196,22 +253,20 @@ function BookingDetailsSheetInner({
const isPending = booking.status === BookingStatus.PENDING;
const parsedMetadata = bookingMetadataSchema.safeParse(booking.metadata ?? null);
const parsedMetadata = bookingMetadataSchema.safeParse(
booking.metadata ?? null
);
const bookingMetadata = parsedMetadata.success ? parsedMetadata.data : null;
const recurringInfo =
booking.recurringEventId && booking.eventType?.recurringEvent
? {
count: booking.eventType.recurringEvent.count,
recurringEvent: booking.eventType.recurringEvent,
}
count: booking.eventType.recurringEvent.count,
recurringEvent: booking.eventType.recurringEvent,
}
: null;
const customResponses = booking.responses
? Object.entries(booking.responses as Record<string, unknown>)
.filter(([fieldName]) => shouldShowFieldInCustomResponses(fieldName))
.map(([question, answer]) => [question, answer] as [string, unknown])
: [];
const responses = (booking.responses ?? {}) as Record<string, unknown>;
const reason =
booking.assignmentReasonSortedByCreatedAt?.[booking.assignmentReasonSortedByCreatedAt.length - 1];
@@ -220,19 +275,17 @@ function BookingDetailsSheetInner({
return (
<Sheet open={true} onOpenChange={handleClose} modal={false}>
<SheetContent
ref={sheetContentRef}
className="overflow-y-auto pb-0 sm:pb-0"
hideOverlay
onInteractOutside={(e) => {
// Check if the click is on a booking list item
const target = e.target as HTMLElement;
const isBookingListItem = target.closest("[data-booking-list-item]");
const isBookingItem =
target.closest("[data-booking-list-item]") || target.closest("[data-booking-calendar-event]");
if (isBookingListItem) {
// Prevent closing when clicking a booking list item
// The item's onClick will handle opening the sheet with the new booking
if (isBookingItem) {
e.preventDefault();
}
// If clicking elsewhere, allow the default behavior (close the sheet)
}}>
<SheetHeader showCloseButton={false} className="mt-0 w-full">
<div className="flex items-center justify-between gap-x-4">
@@ -245,38 +298,65 @@ function BookingDetailsSheetInner({
/>
</div>
<div className="flex gap-2">
<Button
variant="icon"
size="sm"
color="secondary"
StartIcon="chevron-up"
disabled={!navigation.canGoPrev || navigation.isTransitioning}
onClick={(e) => {
e.preventDefault();
handlePrevious();
}}
/>
<Button
variant="icon"
size="sm"
color="secondary"
StartIcon="chevron-down"
disabled={!navigation.canGoNext || navigation.isTransitioning}
onClick={(e) => {
e.preventDefault();
handleNext();
}}
/>
<Button
variant="icon"
size="sm"
color="secondary"
StartIcon="x"
onClick={(e) => {
e.preventDefault();
handleClose();
}}
/>
<Tooltip
content={
<div className="flex items-center gap-1.5">
<span>{t("previous_shortcut")}</span>
</div>
}
>
<Button
variant="icon"
size="sm"
color="secondary"
StartIcon="chevron-up"
tabIndex={-1}
disabled={!navigation.canGoPrev || navigation.isTransitioning}
onClick={(e) => {
e.preventDefault();
handlePrevious();
}}
/>
</Tooltip>
<Tooltip
content={
<div className="flex items-center gap-1.5">
<span>{t("next_shortcut")}</span>
</div>
}
>
<Button
variant="icon"
size="sm"
color="secondary"
StartIcon="chevron-down"
tabIndex={-1}
disabled={!navigation.canGoNext || navigation.isTransitioning}
onClick={(e) => {
e.preventDefault();
handleNext();
}}
/>
</Tooltip>
<Tooltip
content={
<div className="flex items-center gap-1.5">
<span>{t("close_shortcut")}</span>
</div>
}
>
<Button
variant="icon"
size="sm"
color="secondary"
StartIcon="x"
tabIndex={-1}
onClick={(e) => {
e.preventDefault();
handleClose();
}}
/>
</Tooltip>
</div>
</div>
</SheetHeader>
@@ -284,9 +364,9 @@ function BookingDetailsSheetInner({
<SheetBody className="-mt-3">
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-1">
<SheetTitle className="text-emphasis flex items-center gap-3 text-xl font-semibold">
<div className="bg-emphasis w-0.5 shrink-0 self-stretch rounded-lg"></div>
<span>{booking.title}</span>
<SheetTitle className="flex items-center gap-3 font-semibold text-emphasis text-xl">
<div className="w-0.5 shrink-0 self-stretch rounded-lg bg-emphasis"></div>
<span data-testid="booking-sheet-title">{booking.title}</span>
</SheetTitle>
</div>
@@ -328,14 +408,19 @@ function BookingDetailsSheetInner({
<AssignmentReasonSection booking={booking} />
{booking.payment?.[0] && <PaymentSection booking={booking} payment={booking.payment[0]} />}
{booking.payment?.[0] && (
<PaymentSection
booking={booking}
payment={booking.payment[0]}
/>
)}
<SlotsSection booking={booking} />
<AdditionalNotesSection booking={booking} />
<CustomQuestionsSection
customResponses={customResponses}
responses={responses}
bookingFields={booking.eventType?.bookingFields}
/>
@@ -368,18 +453,29 @@ function BookingDetailsSheetInner({
</>
) : (
!booking.rescheduled && (
<JoinMeetingButton
location={booking.location}
metadata={booking.metadata}
bookingStatus={booking.status}
/>
<Tooltip
content={
<div className="flex items-center gap-1.5">
<span>{t("join_shortcut")}</span>
</div>
}
>
<div ref={joinButtonWrapperRef}>
<JoinMeetingButton
location={booking.location}
metadata={booking.metadata}
bookingStatus={booking.status}
/>
</div>
</Tooltip>
)
)}
<BookingActionsDropdown
booking={{
...booking,
listingStatus: booking.status.toLowerCase() as BookingListingStatus,
listingStatus:
booking.status.toLowerCase() as BookingListingStatus,
recurringInfo: undefined,
loggedInUser: {
userId,
@@ -417,7 +513,7 @@ function WhenSection({
return (
<Section title={t("when")}>
{previousBooking?.startTime && previousBooking?.endTime && (
<div className="text-default flex flex-col text-sm line-through opacity-60">
<div className="flex flex-col text-default text-sm line-through opacity-60">
<DisplayTimestamp
startTime={previousBooking.startTime}
endTime={previousBooking.endTime}
@@ -427,10 +523,15 @@ function WhenSection({
)}
<div
className={classNames(
"text-emphasis flex flex-col text-sm font-medium",
"flex flex-col font-medium text-emphasis text-sm",
rescheduled && "line-through"
)}>
<DisplayTimestamp startTime={startTime} endTime={endTime} timeZone={timeZone} />
)}
>
<DisplayTimestamp
startTime={startTime}
endTime={endTime}
timeZone={timeZone}
/>
</div>
</Section>
);
@@ -445,14 +546,20 @@ function DisplayTimestamp({
endTime: Date | dayjs.Dayjs;
timeZone?: string;
}) {
const {
i18n: { language },
} = useLocale();
const start = startTime instanceof Date ? dayjs(startTime).tz(timeZone) : startTime;
const end = endTime instanceof Date ? dayjs(endTime).tz(timeZone) : endTime;
const localizedTimezone = timeZone
? (formatToLocalizedTimezone(start, language, timeZone) ?? timeZone)
: start.format("Z");
return (
<>
<span>{start.format("dddd, MMMM D, YYYY")}</span>
<span>
{start.format("h:mma")} - {end.format("h:mma")} ({timeZone || start.format("Z")})
{start.format("h:mma")} - {end.format("h:mma")} ({localizedTimezone})
</span>
</>
);
@@ -470,13 +577,16 @@ function WhoSection({ booking }: { booking: BookingOutput }) {
imageSrc={
booking.user.avatarUrl
? getUserAvatarUrl(booking.user)
: getPlaceholderAvatar(null, booking.user.name || booking.user.email)
: getPlaceholderAvatar(
null,
booking.user.name || booking.user.email
)
}
alt={booking.user.name || booking.user.email || ""}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-emphasis truncate text-sm leading-[1.2]">
<p className="truncate text-emphasis text-sm leading-[1.2]">
{booking.eventType?.hideOrganizerEmail
? booking.user.name || t("organizer")
: booking.user.name || booking.user.email}
@@ -486,14 +596,20 @@ function WhoSection({ booking }: { booking: BookingOutput }) {
</Badge>
</div>
{!booking.eventType?.hideOrganizerEmail && (
<p className="text-default truncate text-sm leading-[1.2]">{booking.user.email}</p>
<p className="truncate text-default text-sm leading-[1.2]">
{booking.user.email}
</p>
)}
</div>
</div>
)}
{booking.attendees.map((attendee, idx) => {
const name = attendee.user?.name || attendee.name || attendee.user?.email || attendee.email;
const name =
attendee.user?.name ||
attendee.name ||
attendee.user?.email ||
attendee.email;
return (
<div key={idx} className="flex items-center gap-4">
<Avatar
@@ -507,6 +623,9 @@ function WhoSection({ booking }: { booking: BookingOutput }) {
/>
<div className="min-w-0 flex-1">
<p className="text-emphasis truncate text-sm leading-[1.2]">{name}</p>
{attendee.phoneNumber && (
<p className="text-default truncate text-sm leading-[1.2]">{attendee.phoneNumber}</p>
)}
<p className="text-default truncate text-sm leading-[1.2]">{attendee.email}</p>
</div>
</div>
@@ -517,7 +636,13 @@ function WhoSection({ booking }: { booking: BookingOutput }) {
);
}
function WhereSection({ booking, meta }: { booking: BookingOutput; meta: BookingMetaData | null }) {
function WhereSection({
booking,
meta,
}: {
booking: BookingOutput;
meta: BookingMetaData | null;
}) {
const { t } = useLocale();
const { locationToDisplay, provider, isLocationURL } = useBookingLocation({
@@ -555,12 +680,15 @@ function WhereSection({ booking, meta }: { booking: BookingOutput; meta: Booking
/>
)}
<div className="flex min-w-0 items-baseline gap-1">
<span className="text-emphasis shrink-0 font-medium">{provider?.label}:</span>
<span className="shrink-0 font-medium text-emphasis">
{provider?.label}:
</span>
<a
href={locationToDisplay}
target="_blank"
rel="noopener noreferrer"
className="truncate text-blue-600 hover:underline">
className="truncate text-blue-600 hover:underline"
>
{locationToDisplay}
</a>
</div>
@@ -582,7 +710,7 @@ function RecurringInfoSection({
return (
<Section title={t("recurring_event")}>
<p className="text-emphasis text-sm font-medium">
<p className="font-medium text-emphasis text-sm">
{getEveryFreqFor({
t,
recurringEvent: recurringInfo.recurringEvent,
@@ -596,19 +724,26 @@ function RecurringInfoSection({
function AssignmentReasonSection({ booking }: { booking: BookingOutput }) {
const { t } = useLocale();
if (!booking.assignmentReasonSortedByCreatedAt || booking.assignmentReasonSortedByCreatedAt.length === 0) {
if (
!booking.assignmentReasonSortedByCreatedAt ||
booking.assignmentReasonSortedByCreatedAt.length === 0
) {
return null;
}
const reason =
booking.assignmentReasonSortedByCreatedAt[booking.assignmentReasonSortedByCreatedAt.length - 1];
booking.assignmentReasonSortedByCreatedAt[
booking.assignmentReasonSortedByCreatedAt.length - 1
];
if (!reason.reasonString) {
return null;
}
return (
<Section title={t("assignment_reason")}>
<div className="text-emphasis text-sm font-medium">{reason.reasonString}</div>
<div className="font-medium text-emphasis text-sm">
{reason.reasonString}
</div>
</Section>
);
}
@@ -626,7 +761,9 @@ function PaymentSection({
const parsedEventTypeMetadata = booking.eventType?.metadata
? EventTypeMetaDataSchema.safeParse(booking.eventType.metadata)
: null;
const eventTypeMetadata = parsedEventTypeMetadata?.success ? parsedEventTypeMetadata.data : null;
const eventTypeMetadata = parsedEventTypeMetadata?.success
? parsedEventTypeMetadata.data
: null;
const refundPolicy = eventTypeMetadata?.apps?.stripe?.refundPolicy;
const refundDaysCount = eventTypeMetadata?.apps?.stripe?.refundDaysCount;
@@ -645,8 +782,10 @@ function PaymentSection({
return (
<Section title={t("payment")}>
<p className="text-emphasis text-sm font-medium">{formattedPrice}</p>
{paymentStatusMessage && <p className="text-subtle text-xs">{paymentStatusMessage}</p>}
<p className="font-medium text-emphasis text-sm">{formattedPrice}</p>
{paymentStatusMessage && (
<p className="text-subtle text-xs">{paymentStatusMessage}</p>
)}
</Section>
);
}
@@ -667,61 +806,49 @@ function SlotsSection({ booking }: { booking: BookingOutput }) {
return (
<Section title={t("slots")}>
<p className="text-emphasis text-sm font-medium">{t("slots_taken", { takenSeats, totalSeats })}</p>
<p className="font-medium text-emphasis text-sm">
{t("slots_taken", { takenSeats, totalSeats })}
</p>
</Section>
);
}
function CustomQuestionsSection({
customResponses,
responses,
bookingFields: rawBookingFields,
}: {
customResponses: [string, unknown][];
responses: Record<string, unknown>;
bookingFields?: unknown;
}) {
const { t } = useLocale();
// Parse and memoize booking fields
const bookingFields = useMemo(() => {
if (!rawBookingFields) return undefined;
const parsed = eventTypeBookingFields.safeParse(rawBookingFields);
return parsed.success ? parsed.data : undefined;
}, [rawBookingFields]);
// Filter out responses with falsy answers or empty arrays
const validResponses = useMemo(
(): [string, unknown][] =>
customResponses.filter(([, answer]) => {
if (!answer) return false;
if (Array.isArray(answer) && answer.length === 0) return false;
return true;
}),
[customResponses]
);
// Memoize field label lookups
const fieldLabels = useMemo(() => {
if (!bookingFields) return new Map<string, string>();
return new Map<string, string>(
bookingFields.map(
(field) => [field.name, field.label || t(field.defaultLabel || field.name)] as [string, string]
)
);
}, [bookingFields, t]);
if (validResponses.length === 0) {
if (!bookingFields) {
return null;
}
return (
<>
{validResponses.map(([fieldName, answer], idx) => {
const fieldNameStr = String(fieldName);
const title: string = fieldLabels.get(fieldNameStr) ?? fieldNameStr;
{bookingFields.map((field) => {
if (!field) return null;
if (!shouldShowFieldInCustomResponses(field.name)) return null;
const answer = responses[field.name];
if (!answer) return null;
if (Array.isArray(answer) && answer.length === 0) return null;
const label = field.label || t(field.defaultLabel || field.name);
return (
<Section key={idx} title={title}>
<p className="text-emphasis text-sm font-medium">{String(answer)}</p>
<Section key={field.name} title={label}>
<p className="font-medium text-emphasis text-sm">
{field.type === "boolean" ? (answer ? t("yes") : t("no")) : String(answer)}
</p>
</Section>
);
})}
@@ -744,7 +871,8 @@ function OldRescheduledBookingInfo({
return null;
}
const cancellationReason = booking.cancellationReason || booking.rejectionReason;
const cancellationReason =
booking.cancellationReason || booking.rejectionReason;
const rescheduledBy = booking.rescheduledBy;
const cancelledBy = booking.cancelledBy;
@@ -753,7 +881,7 @@ function OldRescheduledBookingInfo({
{rescheduledToBooking?.uid && (
<Section title={t("rescheduled")}>
<Link href={`/booking/${rescheduledToBooking.uid}`}>
<div className="text-default flex items-center gap-1 text-sm underline">
<div className="flex items-center gap-1 text-default text-sm underline">
{t("view_booking")}
<ExternalLinkIcon className="h-4 w-4" />
</div>
@@ -762,17 +890,19 @@ function OldRescheduledBookingInfo({
)}
{rescheduledBy && (
<Section title={t("rescheduled_by")}>
<p className="text-emphasis text-sm font-medium">{rescheduledBy}</p>
<p className="font-medium text-emphasis text-sm">{rescheduledBy}</p>
</Section>
)}
{cancellationReason && (
<Section title={t("reason")}>
<p className="text-emphasis whitespace-pre-wrap text-sm font-medium">{cancellationReason}</p>
<p className="whitespace-pre-wrap font-medium text-emphasis text-sm">
{cancellationReason}
</p>
</Section>
)}
{cancelledBy && (
<Section title={t("cancelled_by")}>
<p className="text-emphasis text-sm font-medium">{cancelledBy}</p>
<p className="font-medium text-emphasis text-sm">{cancelledBy}</p>
</Section>
)}
</>
@@ -788,15 +918,18 @@ function NewRescheduledBookingInfo({ booking }: { booking: BookingOutput }) {
return null;
}
const cancellationReason = booking.cancellationReason || booking.rejectionReason;
const cancellationReason =
booking.cancellationReason || booking.rejectionReason;
const rescheduledBy = booking.rescheduler;
return (
<>
<Section title={t("rescheduled_by")}>
{rescheduledBy && <p className="text-emphasis text-sm font-medium">{rescheduledBy}</p>}
{rescheduledBy && (
<p className="font-medium text-emphasis text-sm">{rescheduledBy}</p>
)}
<Link href={`/booking/${booking.fromReschedule}`}>
<div className="text-default flex items-center gap-1 text-sm underline">
<div className="flex items-center gap-1 text-default text-sm underline">
{t("original_booking")}
<ExternalLinkIcon className="h-4 w-4" />
</div>
@@ -804,7 +937,9 @@ function NewRescheduledBookingInfo({ booking }: { booking: BookingOutput }) {
</Section>
{cancellationReason && (
<Section title={t("reschedule_reason")}>
<p className="text-emphasis whitespace-pre-wrap text-sm font-medium">{cancellationReason}</p>
<p className="whitespace-pre-wrap font-medium text-emphasis text-sm">
{cancellationReason}
</p>
</Section>
)}
</>
@@ -816,14 +951,17 @@ function CancelledBookingInfo({ booking }: { booking: BookingOutput }) {
const { t } = useLocale();
// Only show for cancelled/rejected bookings that were NOT rescheduled
const isCancelled = booking.status === BookingStatus.CANCELLED || booking.status === BookingStatus.REJECTED;
const isCancelled =
booking.status === BookingStatus.CANCELLED ||
booking.status === BookingStatus.REJECTED;
const wasRescheduled = booking.rescheduled === true;
if (!isCancelled || wasRescheduled) {
return null;
}
const cancellationReason = booking.cancellationReason || booking.rejectionReason;
const cancellationReason =
booking.cancellationReason || booking.rejectionReason;
const cancelledBy = booking.cancelledBy;
if (!cancellationReason && !cancelledBy) {
@@ -834,12 +972,14 @@ function CancelledBookingInfo({ booking }: { booking: BookingOutput }) {
<>
{cancelledBy && (
<Section title={t("cancelled_by")}>
<p className="text-emphasis text-sm font-medium">{cancelledBy}</p>
<p className="font-medium text-emphasis text-sm">{cancelledBy}</p>
</Section>
)}
{cancellationReason && (
<Section title={t("reason")}>
<p className="text-emphasis whitespace-pre-wrap text-sm font-medium">{cancellationReason}</p>
<p className="whitespace-pre-wrap font-medium text-emphasis text-sm">
{cancellationReason}
</p>
</Section>
)}
</>
@@ -855,7 +995,9 @@ function AdditionalNotesSection({ booking }: { booking: BookingOutput }) {
return (
<Section title={t("additional_notes")}>
<p className="text-emphasis whitespace-pre-wrap text-sm font-medium">{booking.description}</p>
<p className="whitespace-pre-wrap font-medium text-emphasis text-sm">
{booking.description}
</p>
</Section>
);
}
@@ -884,7 +1026,9 @@ function BookingHeaderBadges({
{reasonTitle}
</Badge>
)}
{booking.eventType.team && <Badge variant="gray">{booking.eventType.team.name}</Badge>}
{booking.eventType.team && (
<Badge variant="gray">{booking.eventType.team.name}</Badge>
)}
{booking.paid && !payment ? (
<Badge variant="orange">{t("error_collecting_card")}</Badge>
) : booking.paid ? (
@@ -919,7 +1063,9 @@ function TrackingSection({
return null;
}
const utmEntries = Object.entries(tracking).filter(([_, value]) => Boolean(value));
const utmEntries = Object.entries(tracking).filter(([_, value]) =>
Boolean(value)
);
if (utmEntries.length === 0) {
return null;
@@ -931,7 +1077,9 @@ function TrackingSection({
{utmEntries.map(([key, value]) => (
<div key={key} className="mb-1 last:mb-0">
<span className="font-medium">{key}</span>:{" "}
<code className="bg-subtle text-default rounded px-1 py-0.5 font-mono text-xs">{value}</code>
<code className="rounded bg-subtle px-1 py-0.5 font-mono text-default text-xs">
{value}
</code>
</div>
))}
</div>
@@ -950,7 +1098,7 @@ function Section({
}) {
return (
<div className={classNames("flex flex-col gap-1", className)}>
<h3 className="text-subtle text-xs font-medium">{title}</h3>
<h3 className="font-medium text-subtle text-xs">{title}</h3>
{children}
</div>
);
@@ -13,13 +13,14 @@ import { ToggleGroup } from "@calcom/ui/components/form";
import { WipeMyCalActionButton } from "@calcom/web/components/apps/wipemycalother/wipeMyCalActionButton";
import { getCoreRowModel, getSortedRowModel, useReactTable } from "@tanstack/react-table";
import { useRouter } from "next/navigation";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useBookingFilters } from "~/bookings/hooks/useBookingFilters";
import { useBookingListColumns } from "~/bookings/hooks/useBookingListColumns";
import { useBookingListData } from "~/bookings/hooks/useBookingListData";
import { useBookingStatusTab } from "~/bookings/hooks/useBookingStatusTab";
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
import { useListAutoSelector } from "~/bookings/hooks/useListAutoSelector";
import { useSwitchToCorrectStatusTab } from "~/bookings/hooks/useSwitchToCorrectStatusTab";
import { DataTableFilters, DataTableSegment } from "~/data-table/components";
import {
BookingDetailsSheetStoreProvider,
@@ -231,17 +232,21 @@ function BookingListInner({
}
export function BookingListContainer(props: BookingListContainerProps) {
const { limit, offset, setPageIndex, isValidatorPending } = useDataTable();
const { limit, offset, isValidatorPending } = useDataTable();
const { eventTypeIds, teamIds, userIds, dateRange, attendeeName, attendeeEmail, bookingUid } =
useBookingFilters();
const { resolvedTabStatus, isResolvingTabStatus, preSelectedBooking } = useSwitchToCorrectStatusTab({
defaultStatus: props.status,
});
// Build query input once - shared between query and prefetching
const queryInput = useMemo(
() => ({
limit,
offset,
filters: {
statuses: [props.status],
statuses: [resolvedTabStatus],
eventTypeIds,
teamIds,
userIds,
@@ -257,7 +262,7 @@ export function BookingListContainer(props: BookingListContainerProps) {
[
limit,
offset,
props.status,
resolvedTabStatus,
eventTypeIds,
teamIds,
userIds,
@@ -271,10 +276,19 @@ export function BookingListContainer(props: BookingListContainerProps) {
const query = trpc.viewer.bookings.get.useQuery(queryInput, {
staleTime: 5 * 60 * 1000, // 5 minutes - data is considered fresh
gcTime: 30 * 60 * 1000, // 30 minutes - cache retention time
enabled: !isValidatorPending, // Wait for validator to be ready before fetching
// We wait for tab status to be resolved before fetching, so that we can fetch the correct bookings as per resolved tab status
enabled: !isValidatorPending && !isResolvingTabStatus, // Wait for validator to be ready before fetching
});
const bookings = useMemo(() => query.data?.bookings ?? [], [query.data?.bookings]);
const bookings = useMemo(() => {
const queryBookings = query.data?.bookings ?? [];
if (!preSelectedBooking) return queryBookings;
if (queryBookings.some((b) => b.uid === preSelectedBooking.uid)) return queryBookings;
// It ensures that the drawer opens for a booking that isn't even in the bookings list
// Note that, bookings list doesn't use this so, it won't be visible in the list view but drawer will open for it
// We don't want to show this booking in the list view, as it might not match the filters/pagination applied
return [...queryBookings, preSelectedBooking];
}, [query.data?.bookings, preSelectedBooking]);
// Always call the hook and provide navigation capabilities
// The BookingDetailsSheet is only rendered when bookingsV3Enabled is true (see line 212)
@@ -290,6 +304,7 @@ export function BookingListContainer(props: BookingListContainerProps) {
<BookingDetailsSheetStoreProvider bookings={bookings}>
<BookingListInner
{...props}
status={resolvedTabStatus}
data={query.data}
isPending={query.isPending}
hasError={!!query.error}
@@ -4,7 +4,6 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { BookingStatus } from "@calcom/prisma/enums";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { useJoinableLocation } from "./useJoinableLocation";
interface JoinMeetingButtonProps {
@@ -54,9 +53,15 @@ export function JoinMeetingButton({
onClick={handleClick}>
{provider?.iconUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img src={provider.iconUrl} className="h-4 w-4 shrink-0 rounded-sm" alt={`${provider.label} logo`} />
<img
src={provider.iconUrl}
className="h-4 w-4 shrink-0 rounded-sm"
alt={`${provider.label} logo`}
/>
)}
{provider?.label ? t("join_event_location", { eventLocationType: provider.label }) : t("join_meeting")}
{provider?.label
? t("join_event_location", { eventLocationType: provider.label })
: t("join_meeting")}
</Button>
);
}
@@ -1,17 +1,19 @@
import { useMemo, useEffect } from "react";
import { useEffect, useMemo } from "react";
import dayjs from "@calcom/dayjs";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import { useOverlayCalendarStore } from "@calcom/features/bookings/Booker/components/OverlayCalendar/store";
import { useAvailableTimeSlots } from "@calcom/features/bookings/Booker/hooks/useAvailableTimeSlots";
import { useBookerTime } from "@calcom/features/bookings/Booker/hooks/useBookerTime";
import { getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param";
import type { BookerEvent } from "@calcom/features/bookings/types";
import { Calendar } from "@calcom/web/modules/calendars/weeklyview/components/Calendar";
import { Calendar } from "@calcom/features/calendars/weeklyview/components/Calendar";
import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events";
import { localStorage } from "@calcom/lib/webstorage";
import type { useScheduleForEventReturnType } from "@calcom/web/modules/schedules/hooks/useEvent";
import { getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param";
import { useOverlayCalendarStore } from "@calcom/features/bookings/Booker/components/OverlayCalendar/store";
import { OutOfOfficeInSlots } from "./OutOfOfficeInSlots";
export const LargeCalendar = ({
extraDays,
@@ -78,6 +80,7 @@ export const LargeCalendar = ({
hoverEventDuration={eventDuration}
hideHeader
timezone={timezone}
renderOutOfOffice={(props) => <OutOfOfficeInSlots {...props} />}
/>
</div>
);
@@ -0,0 +1,60 @@
import { describe, it, expect } from "vitest";
import { getTabForBooking } from "./useSwitchToCorrectStatusTab";
function makeBooking(overrides: {
status: string;
endTime: Date;
recurringEventId?: string | null;
}){
return {
status: overrides.status,
endTime: overrides.endTime,
recurringEventId: overrides.recurringEventId ?? null,
};
}
const future = new Date(Date.now() + 24 * 60 * 60 * 1000);
const past = new Date(Date.now() - 24 * 60 * 60 * 1000);
describe("getTabForBooking", () => {
it("returns 'cancelled' for CANCELLED bookings", () => {
expect(getTabForBooking(makeBooking({ status: "CANCELLED", endTime: future }))).toBe("cancelled");
});
it("returns 'cancelled' for REJECTED bookings", () => {
expect(getTabForBooking(makeBooking({ status: "REJECTED", endTime: future }))).toBe("cancelled");
});
it("returns 'cancelled' for CANCELLED bookings even if past", () => {
expect(getTabForBooking(makeBooking({ status: "CANCELLED", endTime: past }))).toBe("cancelled");
});
it("returns 'unconfirmed' for PENDING bookings in the future", () => {
expect(getTabForBooking(makeBooking({ status: "PENDING", endTime: future }))).toBe("unconfirmed");
});
it("returns 'past' for PENDING bookings that have ended", () => {
expect(getTabForBooking(makeBooking({ status: "PENDING", endTime: past }))).toBe("past");
});
it("returns 'past' for ACCEPTED bookings that have ended", () => {
expect(getTabForBooking(makeBooking({ status: "ACCEPTED", endTime: past }))).toBe("past");
});
it("returns 'recurring' for future ACCEPTED bookings with recurringEventId", () => {
expect(
getTabForBooking(makeBooking({ status: "ACCEPTED", endTime: future, recurringEventId: "rec-123" }))
).toBe("recurring");
});
it("returns 'upcoming' for future ACCEPTED bookings without recurringEventId", () => {
expect(getTabForBooking(makeBooking({ status: "ACCEPTED", endTime: future }))).toBe("upcoming");
});
it("returns 'past' for recurring bookings that have ended", () => {
expect(
getTabForBooking(makeBooking({ status: "ACCEPTED", endTime: past, recurringEventId: "rec-123" }))
).toBe("past");
});
});
@@ -0,0 +1,103 @@
import { trpc } from "@calcom/trpc/react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { validStatuses } from "../lib/validStatuses";
import type { BookingListingStatus, BookingOutput } from "../types";
interface BookingForTabResolution {
status: string;
endTime: Date | string;
recurringEventId: string | null;
}
interface UseSwitchToCorrectTabResult {
resolvedTabStatus: BookingListingStatus;
isResolvingTabStatus: boolean;
preSelectedBooking: BookingOutput | null;
}
export function getTabForBooking(booking: BookingForTabResolution): BookingListingStatus {
const isPast = new Date(booking.endTime) <= new Date();
if (booking.status === "CANCELLED" || booking.status === "REJECTED") {
return "cancelled";
}
if (booking.status === "PENDING" && !isPast) {
return "unconfirmed";
}
if (isPast) {
return "past";
}
if (booking.recurringEventId) {
return "recurring";
}
return "upcoming";
}
/**
* Ensures that the correct status tab is selected based on the pre-selected booking through query params
*/
export function useSwitchToCorrectStatusTab({
defaultStatus,
}: {
defaultStatus: BookingListingStatus;
}): UseSwitchToCorrectTabResult {
const {
preSelectedBookingUid,
preSelectedBooking,
preSelectedBookingFull,
isPending: isFetchingPreSelectedBooking,
} = usePreSelectedBooking();
const pathname = usePathname();
const router = useRouter();
const [resolvedTabStatus, setResolvedTab] = useState<BookingListingStatus>(defaultStatus);
const [isNavigatingToCorrectTab, startNavigationToCorrectTab] = useTransition();
useEffect(() => {
if (!preSelectedBooking) return;
const correctTab = getTabForBooking(preSelectedBooking);
const currentTab = pathname?.match(/\/bookings\/(\w+)/)?.[1];
const shouldNavigate = correctTab && currentTab && correctTab !== currentTab;
if (!shouldNavigate) return;
startNavigationToCorrectTab(() => {
const newPath = pathname.replace(`/bookings/${currentTab}`, `/bookings/${correctTab}`);
router.replace(`${newPath}${window.location.search}`);
});
setResolvedTab(correctTab);
}, [preSelectedBooking, pathname, router]);
const isResolvingTabStatus = preSelectedBookingUid
? isFetchingPreSelectedBooking || isNavigatingToCorrectTab
: false;
return { resolvedTabStatus, isResolvingTabStatus, preSelectedBooking: preSelectedBookingFull };
}
export function usePreSelectedBooking(): {
preSelectedBookingUid: string | undefined;
preSelectedBooking: BookingForTabResolution | null;
preSelectedBookingFull: BookingOutput | null;
isPending: boolean;
}{
const searchParams = useSearchParams();
const preSelectedBookingUid = searchParams?.get("uid") ?? undefined;
const { data: preSelectedBookingData, isPending } = trpc.viewer.bookings.get.useQuery(
{
limit: 1,
offset: 0,
filters: {
bookingUid: preSelectedBookingUid,
statuses: [...validStatuses],
},
},
{
enabled: !!preSelectedBookingUid,
staleTime: 5 * 60 * 1000,
}
);
const preSelectedBookingFull = preSelectedBookingData?.bookings?.[0] ?? null;
const preSelectedBooking: BookingForTabResolution | null = preSelectedBookingFull;
return { preSelectedBookingUid, preSelectedBooking, preSelectedBookingFull, isPending };
}
@@ -12,6 +12,7 @@ export interface IUseVerifyEmailProps {
onVerifyEmail?: () => void;
name?: string | { firstName: string; lastname?: string };
requiresBookerEmailVerification?: boolean;
eventTypeId?: number;
}
export type UseVerifyEmailReturnType = ReturnType<typeof useVerifyEmail>;
export const useVerifyEmail = ({
@@ -19,6 +20,7 @@ export const useVerifyEmail = ({
name,
requiresBookerEmailVerification,
onVerifyEmail,
eventTypeId,
}: IUseVerifyEmailProps) => {
const [isEmailVerificationModalVisible, setEmailVerificationModalVisible] = useState(false);
const verifiedEmail = useBookerStore((state) => state.verifiedEmail);
@@ -56,6 +58,7 @@ export const useVerifyEmail = ({
email,
username: typeof name === "string" ? name : name?.firstName,
language: i18n.language || "en",
eventTypeId,
});
};
@@ -0,0 +1,302 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
isEditableTarget,
checkSheetActive,
createBookingSheetKeydownHandler,
type BookingSheetKeyboardConfig,
} from "./bookingSheetKeyboardHandler";
function createMockKeyboardEvent(key: string, target?: EventTarget | null): KeyboardEvent {
const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
if (target) {
Object.defineProperty(event, "target", { value: target });
}
return event;
}
describe("isEditableTarget", () => {
it("returns true for input elements", () => {
const input = document.createElement("input");
expect(isEditableTarget(input)).toBe(true);
});
it("returns true for textarea elements", () => {
const textarea = document.createElement("textarea");
expect(isEditableTarget(textarea)).toBe(true);
});
it("returns true for contenteditable elements", () => {
const div = document.createElement("div");
div.contentEditable = "true";
expect(isEditableTarget(div)).toBe(true);
});
it("returns false for regular div elements", () => {
const div = document.createElement("div");
expect(isEditableTarget(div)).toBe(false);
});
it("returns false for button elements", () => {
const button = document.createElement("button");
expect(isEditableTarget(button)).toBe(false);
});
it("returns false for null", () => {
expect(isEditableTarget(null)).toBe(false);
});
});
describe("checkSheetActive", () => {
it("returns true when no element has focus", () => {
expect(checkSheetActive(document.createElement("div"), null)).toBe(true);
});
it("returns true when document.body is focused", () => {
expect(checkSheetActive(document.createElement("div"), document.body)).toBe(true);
});
it("returns false when sheetContent is null", () => {
const focusedElement = document.createElement("button");
document.body.appendChild(focusedElement);
expect(checkSheetActive(null, focusedElement)).toBe(false);
document.body.removeChild(focusedElement);
});
it("returns true when focused element is inside sheetContent", () => {
const sheetContent = document.createElement("div");
const button = document.createElement("button");
sheetContent.appendChild(button);
document.body.appendChild(sheetContent);
expect(checkSheetActive(sheetContent, button)).toBe(true);
document.body.removeChild(sheetContent);
});
it("returns true when focused element is outside any Radix portal", () => {
const sheetContent = document.createElement("div");
const outsideButton = document.createElement("button");
document.body.appendChild(sheetContent);
document.body.appendChild(outsideButton);
expect(checkSheetActive(sheetContent, outsideButton)).toBe(true);
document.body.removeChild(sheetContent);
document.body.removeChild(outsideButton);
});
it("returns false when focused element is inside a Radix portal (overlay)", () => {
const sheetContent = document.createElement("div");
const portal = document.createElement("div");
portal.setAttribute("data-radix-portal", "");
const dropdownButton = document.createElement("button");
portal.appendChild(dropdownButton);
document.body.appendChild(sheetContent);
document.body.appendChild(portal);
expect(checkSheetActive(sheetContent, dropdownButton)).toBe(false);
document.body.removeChild(sheetContent);
document.body.removeChild(portal);
});
});
describe("createBookingSheetKeydownHandler", () => {
let handlePrevious: () => void;
let handleNext: () => void;
let mockJoinLink: HTMLAnchorElement;
let config: BookingSheetKeyboardConfig;
beforeEach(() => {
handlePrevious = vi.fn();
handleNext = vi.fn();
mockJoinLink = document.createElement("a");
mockJoinLink.click = vi.fn();
config = {
isSheetActive: () => true,
canGoPrev: true,
canGoNext: true,
isTransitioning: false,
handlePrevious,
handleNext,
getJoinLink: () => null,
};
});
describe("ArrowDown", () => {
it("navigates to next when canGoNext is true", () => {
const handler = createBookingSheetKeydownHandler(config);
const event = createMockKeyboardEvent("ArrowDown");
const preventDefaultSpy = vi.spyOn(event, "preventDefault");
const stopPropagationSpy = vi.spyOn(event, "stopPropagation");
handler(event);
expect(preventDefaultSpy).toHaveBeenCalled();
expect(stopPropagationSpy).toHaveBeenCalled();
expect(handleNext).toHaveBeenCalled();
});
it("stops propagation but does NOT navigate when canGoNext is false", () => {
config.canGoNext = false;
const handler = createBookingSheetKeydownHandler(config);
const event = createMockKeyboardEvent("ArrowDown");
const preventDefaultSpy = vi.spyOn(event, "preventDefault");
const stopPropagationSpy = vi.spyOn(event, "stopPropagation");
handler(event);
expect(preventDefaultSpy).toHaveBeenCalled();
expect(stopPropagationSpy).toHaveBeenCalled();
expect(handleNext).not.toHaveBeenCalled();
});
it("stops propagation but does NOT navigate when transitioning", () => {
config.isTransitioning = true;
const handler = createBookingSheetKeydownHandler(config);
const event = createMockKeyboardEvent("ArrowDown");
const stopPropagationSpy = vi.spyOn(event, "stopPropagation");
handler(event);
expect(stopPropagationSpy).toHaveBeenCalled();
expect(handleNext).not.toHaveBeenCalled();
});
});
describe("ArrowUp", () => {
it("navigates to previous when canGoPrev is true", () => {
const handler = createBookingSheetKeydownHandler(config);
const event = createMockKeyboardEvent("ArrowUp");
const preventDefaultSpy = vi.spyOn(event, "preventDefault");
const stopPropagationSpy = vi.spyOn(event, "stopPropagation");
handler(event);
expect(preventDefaultSpy).toHaveBeenCalled();
expect(stopPropagationSpy).toHaveBeenCalled();
expect(handlePrevious).toHaveBeenCalled();
});
it("stops propagation but does NOT navigate when canGoPrev is false", () => {
config.canGoPrev = false;
const handler = createBookingSheetKeydownHandler(config);
const event = createMockKeyboardEvent("ArrowUp");
const preventDefaultSpy = vi.spyOn(event, "preventDefault");
const stopPropagationSpy = vi.spyOn(event, "stopPropagation");
handler(event);
expect(preventDefaultSpy).toHaveBeenCalled();
expect(stopPropagationSpy).toHaveBeenCalled();
expect(handlePrevious).not.toHaveBeenCalled();
});
it("stops propagation but does NOT navigate when transitioning", () => {
config.isTransitioning = true;
const handler = createBookingSheetKeydownHandler(config);
const event = createMockKeyboardEvent("ArrowUp");
const stopPropagationSpy = vi.spyOn(event, "stopPropagation");
handler(event);
expect(stopPropagationSpy).toHaveBeenCalled();
expect(handlePrevious).not.toHaveBeenCalled();
});
});
describe("Enter", () => {
it("clicks join link when available", () => {
config.getJoinLink = () => mockJoinLink;
const handler = createBookingSheetKeydownHandler(config);
const event = createMockKeyboardEvent("Enter");
const preventDefaultSpy = vi.spyOn(event, "preventDefault");
const stopPropagationSpy = vi.spyOn(event, "stopPropagation");
handler(event);
expect(preventDefaultSpy).toHaveBeenCalled();
expect(stopPropagationSpy).toHaveBeenCalled();
expect(mockJoinLink.click).toHaveBeenCalled();
});
it("does nothing when no join link is available", () => {
config.getJoinLink = () => null;
const handler = createBookingSheetKeydownHandler(config);
const event = createMockKeyboardEvent("Enter");
const preventDefaultSpy = vi.spyOn(event, "preventDefault");
handler(event);
expect(preventDefaultSpy).not.toHaveBeenCalled();
});
});
describe("sheet inactive", () => {
it("does not handle any keys when sheet is inactive", () => {
config.isSheetActive = () => false;
const handler = createBookingSheetKeydownHandler(config);
const downEvent = createMockKeyboardEvent("ArrowDown");
const downSpy = vi.spyOn(downEvent, "stopPropagation");
handler(downEvent);
expect(downSpy).not.toHaveBeenCalled();
expect(handleNext).not.toHaveBeenCalled();
const upEvent = createMockKeyboardEvent("ArrowUp");
const upSpy = vi.spyOn(upEvent, "stopPropagation");
handler(upEvent);
expect(upSpy).not.toHaveBeenCalled();
expect(handlePrevious).not.toHaveBeenCalled();
});
});
describe("editable targets", () => {
it("ignores events from input elements", () => {
const handler = createBookingSheetKeydownHandler(config);
const input = document.createElement("input");
const event = createMockKeyboardEvent("ArrowDown", input);
const stopPropagationSpy = vi.spyOn(event, "stopPropagation");
handler(event);
expect(stopPropagationSpy).not.toHaveBeenCalled();
expect(handleNext).not.toHaveBeenCalled();
});
it("ignores events from textarea elements", () => {
const handler = createBookingSheetKeydownHandler(config);
const textarea = document.createElement("textarea");
const event = createMockKeyboardEvent("ArrowDown", textarea);
const stopPropagationSpy = vi.spyOn(event, "stopPropagation");
handler(event);
expect(stopPropagationSpy).not.toHaveBeenCalled();
expect(handleNext).not.toHaveBeenCalled();
});
it("ignores events from contenteditable elements", () => {
const handler = createBookingSheetKeydownHandler(config);
const div = document.createElement("div");
div.contentEditable = "true";
const event = createMockKeyboardEvent("ArrowDown", div);
const stopPropagationSpy = vi.spyOn(event, "stopPropagation");
handler(event);
expect(stopPropagationSpy).not.toHaveBeenCalled();
expect(handleNext).not.toHaveBeenCalled();
});
});
describe("unhandled keys", () => {
it("does not preventDefault or stopPropagation for unhandled keys", () => {
const handler = createBookingSheetKeydownHandler(config);
const event = createMockKeyboardEvent("Tab");
const preventDefaultSpy = vi.spyOn(event, "preventDefault");
const stopPropagationSpy = vi.spyOn(event, "stopPropagation");
handler(event);
expect(preventDefaultSpy).not.toHaveBeenCalled();
expect(stopPropagationSpy).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,71 @@
export interface BookingSheetKeyboardConfig {
isSheetActive: () => boolean;
canGoPrev: boolean | undefined;
canGoNext: boolean | undefined;
isTransitioning: boolean;
handlePrevious: () => void;
handleNext: () => void;
getJoinLink: () => HTMLAnchorElement | null | undefined;
}
export function isEditableTarget(target: EventTarget | null): boolean {
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
return true;
}
if (target instanceof HTMLElement) {
return target.contentEditable === "true";
}
return false;
}
export function checkSheetActive(
sheetContent: HTMLElement | null,
activeElement: Element | null
): boolean {
if (!activeElement || activeElement === document.body) {
return true;
}
if (!sheetContent) return false;
if (sheetContent.contains(activeElement)) {
return true;
}
return !activeElement.closest("[data-radix-portal]");
}
export function createBookingSheetKeydownHandler(config: BookingSheetKeyboardConfig) {
return (e: KeyboardEvent) => {
if (isEditableTarget(e.target)) {
return;
}
if (!config.isSheetActive()) {
return;
}
switch (e.key) {
case "ArrowUp":
e.preventDefault();
e.stopPropagation();
if (config.canGoPrev && !config.isTransitioning) {
config.handlePrevious();
}
break;
case "ArrowDown":
e.preventDefault();
e.stopPropagation();
if (config.canGoNext && !config.isTransitioning) {
config.handleNext();
}
break;
case "Enter": {
const joinLink = config.getJoinLink();
if (joinLink) {
e.preventDefault();
e.stopPropagation();
joinLink.click();
}
break;
}
}
};
}
@@ -938,6 +938,7 @@ export default function Success(props: PageProps) {
payment: props.paymentStatus,
}}
eventTypeMetadata={eventType.metadata}
requiresCancellationReason={eventType.requiresCancellationReason}
profile={{ name: props.profile.name, slug: props.profile.slug }}
recurringEvent={eventType.recurringEvent}
team={eventType?.team?.name}
@@ -1,59 +0,0 @@
"use client";
import LicenseRequired from "~/ee/common/components/LicenseRequired";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
import { FolderIcon, SparklesIcon, TerminalIcon } from "@coss/ui/icons";
import { UpgradeTip } from "~/shell/UpgradeTip";
export const AdminAPIView = () => {
const { t } = useLocale();
const features = [
{
icon: <TerminalIcon className="h-5 w-5 text-pink-500" />,
title: t("admin_api"),
description: t("leverage_our_api"),
},
{
icon: <FolderIcon className="h-5 w-5 text-red-500" />,
title: `SCIM & ${t("directory_sync")}`,
description: t("directory_sync_description"),
},
{
icon: <SparklesIcon className="h-5 w-5 text-blue-500" />,
title: "Cal.ai",
description: t("use_cal_ai_to_make_call_description"),
},
];
return (
<LicenseRequired>
<div className="mt-8">
<UpgradeTip
plan="enterprise"
title={t("enterprise_license")}
description={t("create_your_enterprise_description")}
features={features}
background="/tips/enterprise"
buttons={
<div className="stack-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="primary" href="https://cal.com/sales" target="_blank">
{t("contact_sales")}
</Button>
<Button color="minimal" href="https://cal.com/enterprise" target="_blank">
{t("learn_more")}
</Button>
</ButtonGroup>
</div>
}>
<>Create Org</>
</UpgradeTip>
</div>
</LicenseRequired>
);
};
export default AdminAPIView;
@@ -8,6 +8,7 @@ import type { RouterOutputs } from "@calcom/trpc/react";
import type { MemberPermissions } from "@calcom/features/pbac/lib/team-member-permissions";
import { MemberInvitationModalWithoutMembers } from "~/ee/teams/components/MemberInvitationModal";
import InviteLinkSettingsModal from "~/ee/teams/components/InviteLinkSettingsModal";
import MemberList from "../components/MemberList";
@@ -37,7 +38,7 @@ interface TeamMembersViewProps {
export const TeamMembersView = ({ team, facetedTeamValues, permissions }: TeamMembersViewProps) => {
const { t } = useLocale();
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
const [_showInviteLinkSettingsModal, setShowInviteLinkSettingsModal] = useState(false);
const [showInviteLinkSettingsModal, setShowInviteLinkSettingsModal] = useState(false);
// Use PBAC permissions - server-side permission check should be done in parent component
const canLoggedInUserSeeMembers = permissions?.canListMembers ?? false;
@@ -70,6 +71,18 @@ export const TeamMembersView = ({ team, facetedTeamValues, permissions }: TeamMe
onSettingsOpen={() => setShowInviteLinkSettingsModal(true)}
/>
)}
{team?.inviteToken && (
<InviteLinkSettingsModal
isOpen={showInviteLinkSettingsModal}
teamId={team.id}
token={team.inviteToken.token}
expiresInDays={team.inviteToken.expiresInDays ?? undefined}
onExit={() => {
setShowInviteLinkSettingsModal(false);
setShowMemberInvitationModal(true);
}}
/>
)}
</div>
</LicenseRequired>
);
@@ -178,6 +178,8 @@ export default function WorkflowDetailsPage(props: Props) {
verifiedAt: SCANNING_WORKFLOW_STEPS ? null : new Date(),
agentId: null,
inboundAgentId: null,
autoTranslateEnabled: false,
sourceLocale: null,
};
steps?.push(step);
form.setValue("steps", steps);
@@ -1,23 +1,22 @@
import { type TFunction } from "i18next";
import type { TFunction } from "i18next";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import type { Dispatch, SetStateAction } from "react";
import { useEffect, useRef, useState, useCallback } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { UseFormReturn } from "react-hook-form";
import { Controller, useWatch } from "react-hook-form";
import "react-phone-number-input/style.css";
import type { RetellAgentWithDetails } from "@calcom/features/calAIPhone/providers/retellAI";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import PhoneInput from "@calcom/web/components/phone-input";
import {
isSMSAction,
isWhatsappAction,
getTemplateBodyForAction,
shouldScheduleEmailReminder,
isSMSOrWhatsappAction,
hasCalAIAction,
isCalAIAction,
isFormTrigger,
hasCalAIAction,
isSMSAction,
isSMSOrWhatsappAction,
isWhatsappAction,
shouldScheduleEmailReminder,
} from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
import { DYNAMIC_TEXT_VARIABLES } from "@calcom/features/ee/workflows/lib/constants";
import {
@@ -27,6 +26,7 @@ import {
import emailRatingTemplate from "@calcom/features/ee/workflows/lib/reminders/templates/emailRatingTemplate";
import emailReminderTemplate from "@calcom/features/ee/workflows/lib/reminders/templates/emailReminderTemplate";
import type { FormValues } from "@calcom/features/ee/workflows/lib/types";
import PhoneInput from "@calcom/web/components/phone-input";
import "@calcom/features/ee/workflows/style/styles.css";
import { SENDER_ID, SENDER_NAME } from "@calcom/lib/constants";
import { formatPhoneNumber } from "@calcom/lib/formatPhoneNumber";
@@ -48,7 +48,7 @@ import { trpc } from "@calcom/trpc/react";
import classNames from "@calcom/ui/classNames";
import { Badge, InfoBadge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
import { DialogClose, DialogContent, DialogFooter } from "@calcom/ui/components/dialog";
import {
Dropdown,
DropdownItem,
@@ -56,26 +56,26 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@calcom/ui/components/dropdown";
import { AddVariablesDropdown } from "@calcom/ui/components/editor";
import { Editor } from "@calcom/ui/components/editor";
import { CheckboxField } from "@calcom/ui/components/form";
import { EmailField } from "@calcom/ui/components/form";
import { TextArea } from "@calcom/ui/components/form";
import { Input } from "@calcom/ui/components/form";
import { Label } from "@calcom/ui/components/form";
import { TextField } from "@calcom/ui/components/form";
import { Select } from "@calcom/ui/components/form";
import { MultiSelectCheckbox } from "@calcom/ui/components/form";
import { AddVariablesDropdown, Editor } from "@calcom/ui/components/editor";
import type { MultiSelectCheckboxesOptionType as Option } from "@calcom/ui/components/form";
import {
CheckboxField,
EmailField,
Input,
Label,
MultiSelectCheckbox,
Select,
TextArea,
TextField,
} from "@calcom/ui/components/form";
import { CircleHelpIcon, InfoIcon, PhoneIcon } from "@coss/ui/icons";
import { SkeletonText } from "@calcom/ui/components/skeleton";
import { showToast } from "@calcom/ui/components/toast";
import { useHasPaidPlan, useHasActiveTeamPlan } from "@calcom/web/modules/billing/hooks/useHasPaidPlan";
import { useHasActiveTeamPlan, useHasPaidPlan } from "@calcom/web/modules/billing/hooks/useHasPaidPlan";
import { AgentConfigurationSheet }from "./agent-configuration/AgentConfigurationSheet";
import { TestPhoneCallDialog } from "./TestPhoneCallDialog";
import { TimeTimeUnitInput } from "./TimeTimeUnitInput";
import { WebCallDialog } from "./WebCallDialog";
import { AgentConfigurationSheet } from "./agent-configuration/AgentConfigurationSheet";
type User = RouterOutputs["viewer"]["me"]["get"];
@@ -477,7 +477,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
const verifyEmailCodeMutation = trpc.viewer.workflows.verifyEmailCode.useMutation({
onSuccess: (isVerified) => {
showToast(isVerified ? t("verified_successfully") : t("wrong_code"), "success");
setEmailVerified(true);
setEmailVerified(isVerified);
if (
step &&
form?.formState?.errors?.steps &&
@@ -800,6 +800,15 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
form.setValue(`steps.${step.stepNumber - 1}.agentId`, null);
}
if (
val.value !== WorkflowActions.EMAIL_ATTENDEE &&
val.value !== WorkflowActions.SMS_ATTENDEE
) {
form.setValue(`steps.${step.stepNumber - 1}.autoTranslateEnabled`, false, {
shouldDirty: true,
});
}
form.setValue(`steps.${step.stepNumber - 1}.sendTo`, null);
form.clearErrors(`steps.${step.stepNumber - 1}.sendTo`);
form.setValue(`steps.${step.stepNumber - 1}.action`, val.value, {
@@ -1433,6 +1442,49 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
/>
</div>
)}
{(step.action === WorkflowActions.EMAIL_ATTENDEE ||
step.action === WorkflowActions.SMS_ATTENDEE) && (
<div className="mt-2">
<div className="flex items-center gap-2">
<Controller
name={`steps.${step.stepNumber - 1}.autoTranslateEnabled`}
control={form.control}
render={() => (
<CheckboxField
disabled={props.readOnly || !props.user.organizationId}
defaultChecked={
form.getValues(`steps.${step.stepNumber - 1}.autoTranslateEnabled`) || false
}
description={t("auto_translate_for_attendees")}
descriptionClassName="ml-0"
onChange={(e) =>
form.setValue(
`steps.${step.stepNumber - 1}.autoTranslateEnabled`,
e.target.checked,
{ shouldDirty: true }
)
}
/>
)}
/>
{!props.user.organizationId && (
<Badge variant="gray" size="sm">
{t("upgrade_to_organizations")}
</Badge>
)}
</div>
{props.user.organizationId &&
form.watch(`steps.${step.stepNumber - 1}.autoTranslateEnabled`) && (
<p className="text-subtle ml-6 mt-1 text-xs">
{t("auto_translate_source_language_hint", {
language: new Intl.DisplayNames([i18n.language], { type: "language" }).of(
props.user.locale || "en"
),
})}
</p>
)}
</div>
)}
{!props.readOnly && (
<div className="ml-1 mt-2">
<button type="button" onClick={() => setIsAdditionalInputsDialogOpen(true)}>
@@ -15,6 +15,8 @@ import { md } from "@calcom/lib/markdownIt";
import slugify from "@calcom/lib/slugify";
import turndown from "@calcom/lib/turndownService";
import { trpc } from "@calcom/trpc/react";
import { SchedulingType } from "@calcom/prisma/enums";
import { Alert } from "@calcom/ui/components/alert";
import { Button } from "@calcom/ui/components/button";
import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
import { Editor } from "@calcom/ui/components/editor";
@@ -32,6 +34,7 @@ const querySchema = z.object({
pageSlug: z.string(),
teamId: z.coerce.number().optional().nullable(),
parentId: z.coerce.number().optional().nullable(),
schedulingType: z.nativeEnum(SchedulingType).optional().nullable(),
});
const DuplicateDialog = () => {
@@ -42,8 +45,9 @@ const DuplicateDialog = () => {
const router = useRouter();
const [firstRender, setFirstRender] = useState(true);
const {
data: { pageSlug, slug, ...defaultValues },
data: { pageSlug, slug, schedulingType, ...defaultValues },
} = useTypedQuery(querySchema);
const isManagedEventType = schedulingType === SchedulingType.MANAGED;
const [searchTerm, setSearchTerm] = useState("");
const debouncedSearchTerm = useDebounce(searchTerm, 500);
@@ -72,7 +76,11 @@ const DuplicateDialog = () => {
const duplicateMutation = trpc.viewer.eventTypesHeavy.duplicate.useMutation({
onSuccess: async ({ eventType }) => {
await router.replace(`/event-types/${eventType.id}`);
// For managed event types, redirect to the assignment tab so the user can add members
const redirectUrl = isManagedEventType
? `/event-types/${eventType.id}?tabName=team`
: `/event-types/${eventType.id}`;
await router.replace(redirectUrl);
await utils.viewer.eventTypes.getUserEventGroups.invalidate();
revalidateEventTypesList();
@@ -117,7 +125,7 @@ const DuplicateDialog = () => {
return (
<Dialog
name="duplicate"
clearQueryParamsOnClose={["description", "title", "length", "slug", "name", "id", "pageSlug"]}>
clearQueryParamsOnClose={["description", "title", "length", "slug", "name", "id", "pageSlug", "schedulingType"]}>
<DialogContent type="creation" className="overflow-y-auto" title={t("duplicate_event_type")}>
<Form
form={form}
@@ -125,6 +133,12 @@ const DuplicateDialog = () => {
duplicateMutation.mutate(values);
}}>
<div className="-mt-2 stack-y-5">
{isManagedEventType && (
<Alert
severity="info"
message={t("managed_event_type_duplicate_disclaimer")}
/>
)}
<TextField
label={t("title")}
placeholder={t("quick_chat")}
@@ -1,16 +1,6 @@
import { useState, Suspense, useMemo, useEffect } from "react";
import type { Dispatch, SetStateAction } from "react";
import { Controller, useFormContext } from "react-hook-form";
import type { z } from "zod";
import { getPaymentAppData } from "@calcom/app-store/_utils/payments/getPaymentAppData";
import { useAtomsContext } from "@calcom/atoms/hooks/useAtomsContext";
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
import {
SelectedCalendarsSettingsWebWrapper,
SelectedCalendarSettingsScope,
SelectedCalendarsSettingsWebWrapperSkeleton,
} 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/components/DestinationCalendarSelector";
@@ -20,24 +10,20 @@ import {
allowDisablingAttendeeConfirmationEmails,
allowDisablingHostConfirmationEmails,
} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails";
import { MultiplePrivateLinksController } from "@calcom/web/modules/event-types/components";
import AddVerifiedEmail from "@calcom/web/modules/event-types/components/AddVerifiedEmail";
import { LearnMoreLink } from "@calcom/features/eventtypes/components/LearnMoreLink";
import type { EventNameObjectType } from "@calcom/features/eventtypes/lib/eventNaming";
import { getEventName } from "@calcom/features/eventtypes/lib/eventNaming";
import type {
FormValues,
EventTypeSetupProps,
SelectClassNames,
CheckboxClassNames,
EventTypeSetupProps,
FormValues,
InputClassNames,
SelectClassNames,
SettingsToggleClassNames,
} from "@calcom/features/eventtypes/lib/types";
import { FormBuilder } from "./FormBuilder";
import { BookerLayoutSelector } from "@calcom/web/modules/settings/components/BookerLayoutSelector";
import {
DEFAULT_LIGHT_BRAND_COLOR,
DEFAULT_DARK_BRAND_COLOR,
DEFAULT_LIGHT_BRAND_COLOR,
MAX_SEATS_PER_TIME_SLOT,
} from "@calcom/lib/constants";
import { generateHashedLink } from "@calcom/lib/generateHashedLink";
@@ -45,25 +31,36 @@ import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours";
import { extractHostTimezone } from "@calcom/lib/hashedLinksUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { Prisma } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
import type { EditableSchema } from "@calcom/prisma/zod-utils";
import type { fieldSchema } from "@calcom/prisma/zod-utils";
import { CancellationReasonRequirement, SchedulingType } from "@calcom/prisma/enums";
import type { EditableSchema, fieldSchema } from "@calcom/prisma/zod-utils";
import type { RouterOutputs } from "@calcom/trpc/react";
import classNames from "@calcom/ui/classNames";
import { Alert } from "@calcom/ui/components/alert";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import {
SelectField,
ColorPicker,
TextField,
Label,
CheckboxField,
Switch,
SettingsToggle,
ColorPicker,
Label,
Select,
SelectField,
SettingsToggle,
Switch,
TextField,
} from "@calcom/ui/components/form";
import { InfoIcon, PencilIcon } from "@coss/ui/icons";
import {
SelectedCalendarSettingsScope,
SelectedCalendarsSettingsWebWrapper,
SelectedCalendarsSettingsWebWrapperSkeleton,
} from "@calcom/web/modules/calendars/components/SelectedCalendarsSettingsWebWrapper";
import { MultiplePrivateLinksController } from "@calcom/web/modules/event-types/components";
import AddVerifiedEmail from "@calcom/web/modules/event-types/components/AddVerifiedEmail";
import { LearnMoreLink } from "@calcom/features/eventtypes/components/LearnMoreLink";
import type { Dispatch, SetStateAction } from "react";
import { Suspense, useEffect, useMemo, useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import type { z } from "zod";
import type { CustomEventTypeModalClassNames } from "./CustomEventTypeModal";
import CustomEventTypeModal from "./CustomEventTypeModal";
@@ -71,6 +68,7 @@ import type { EmailNotificationToggleCustomClassNames } from "./DisableAllEmails
import { DisableAllEmailsSetting } from "./DisableAllEmailsSetting";
import type { DisableReschedulingCustomClassNames } from "./DisableReschedulingController";
import DisableReschedulingController from "./DisableReschedulingController";
import { FormBuilder } from "./FormBuilder";
import type { RequiresConfirmationCustomClassNames } from "./RequiresConfirmationController";
import RequiresConfirmationController from "./RequiresConfirmationController";
@@ -676,6 +674,43 @@ export const EventAdvancedTab = ({
/>
</div>
</div>
{!isPlatform && (
<Controller
name="requiresCancellationReason"
render={({ field: { value, onChange } }) => {
const cancellationReasonOptions = [
{ value: CancellationReasonRequirement.MANDATORY_BOTH, label: t("mandatory_for_both") },
{
value: CancellationReasonRequirement.MANDATORY_HOST_ONLY,
label: t("mandatory_for_host_only"),
},
{
value: CancellationReasonRequirement.MANDATORY_ATTENDEE_ONLY,
label: t("mandatory_for_attendee_only"),
},
{ value: CancellationReasonRequirement.OPTIONAL_BOTH, label: t("optional_for_both") },
];
return (
<div className="border-subtle rounded-lg border px-4 py-6 sm:px-6">
<div className="flex items-center justify-between">
<div>
<p className="text-default text-sm font-semibold">{t("require_cancellation_reason")}</p>
<p className="text-default text-sm">{t("require_cancellation_reason_description")}</p>
</div>
<Select
value={cancellationReasonOptions.find(
(opt) => opt.value === (value || CancellationReasonRequirement.MANDATORY_HOST_ONLY)
)}
options={cancellationReasonOptions}
onChange={(selected) => onChange(selected?.value)}
className="w-52"
/>
</div>
</div>
);
}}
/>
)}
<RequiresConfirmationController
eventType={eventType}
seatsEnabled={seatsEnabled}
@@ -432,14 +432,15 @@ export const InfiniteEventTypeList = ({
if (value) newSearchParams.set(key, value.toString());
if (value === null) newSearchParams.delete(key);
}
setParamsIfDefined("dialog", "duplicate");
setParamsIfDefined("title", eventType.title);
setParamsIfDefined("description", eventType.description);
setParamsIfDefined("slug", eventType.slug);
setParamsIfDefined("id", eventType.id);
setParamsIfDefined("length", eventType.length);
setParamsIfDefined("pageSlug", group.profile.slug);
router.push(`${pathname}?${newSearchParams.toString()}`);
setParamsIfDefined("dialog", "duplicate");
setParamsIfDefined("title", eventType.title);
setParamsIfDefined("description", eventType.description);
setParamsIfDefined("slug", eventType.slug);
setParamsIfDefined("id", eventType.id);
setParamsIfDefined("length", eventType.length);
setParamsIfDefined("pageSlug", group.profile.slug);
setParamsIfDefined("schedulingType", eventType.schedulingType);
router.push(`${pathname}?${newSearchParams.toString()}`);
};
const deleteMutation = trpc.viewer.eventTypes.delete.useMutation({
@@ -695,18 +696,16 @@ export const InfiniteEventTypeList = ({
</DropdownMenuItem>
)}
{/* readonly is only set when we are on a team - if we are on a user event type null will be the value. */}
{!readOnly && !isManagedEventType && !isChildrenManagedEventType && (
<>
<DropdownMenuItem className="outline-none">
<DropdownItem
type="button"
data-testid={`event-type-duplicate-${type.id}`}
StartIcon="copy"
onClick={() => openDuplicateModal(type, group)}>
{t("duplicate")}
</DropdownItem>
</DropdownMenuItem>
</>
{!readOnly && !isChildrenManagedEventType && (
<DropdownMenuItem className="outline-none">
<DropdownItem
type="button"
data-testid={`event-type-duplicate-${type.id}`}
StartIcon="copy"
onClick={() => openDuplicateModal(type, group)}>
{t("duplicate")}
</DropdownItem>
</DropdownMenuItem>
)}
{!isManagedEventType && (
<DropdownMenuItem className="outline-none">
@@ -812,7 +811,7 @@ export const InfiniteEventTypeList = ({
</DropdownItem>
</DropdownMenuItem>
)}
{!readOnly && !isManagedEventType && !isChildrenManagedEventType && (
{!readOnly && !isChildrenManagedEventType && (
<DropdownMenuItem className="outline-none">
<DropdownItem
onClick={() => openDuplicateModal(type, group)}
@@ -22,7 +22,7 @@ export function FeatureOptInBanner({
return (
<div
data-testid="feature-opt-in-banner"
className="bg-default border-subtle fixed bottom-5 right-5 z-50 max-w-xs rounded-lg border shadow-lg group">
className="group fixed right-5 bottom-5 z-50 hidden max-w-xs rounded-lg border border-subtle bg-default shadow-lg md:block">
<div className="p-4">
<h3 data-testid="feature-opt-in-banner-title" className="text-emphasis text-lg font-semibold">
{t(featureConfig.i18n.title)}
@@ -99,7 +99,7 @@ const Download = () => {
EndIcon="file-down"
color="secondary"
loading={isDownloading}
className="h-full self-end sm:self-baseline">
className="h-full">
{t("download")}
</Button>
</DropdownMenuTrigger>
@@ -5,7 +5,7 @@ import { usePathname } from "next/navigation";
import { useMemo } from "react";
import dayjs from "@calcom/dayjs";
import { Calendar } from "@calcom/web/modules/calendars/weeklyview/components/Calendar";
import { Calendar } from "@calcom/features/calendars/weeklyview/components/Calendar";
import type { CalendarComponentProps, Hours } from "@calcom/features/calendars/weeklyview/types/state";
import { weekdayDates } from "@calcom/features/calendars/weeklyview/utils";
import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants";
@@ -151,6 +151,7 @@ export const OnboardingView = ({ userEmail }: OnboardingViewProps) => {
footer={
<div className="flex w-full justify-end gap-2">
<Button
data-testid="onboarding-continue-btn"
color="primary"
className="rounded-[10px]"
onClick={handleContinue}
@@ -117,6 +117,7 @@ export const PersonalCalendarView = ({ userEmail }: PersonalCalendarViewProps) =
{t("onboarding_skip_for_now")}
</Button>
<Button
data-testid="onboarding-continue-btn"
color="primary"
className="rounded-[10px]"
onClick={handleContinue}

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