Compare commits

..
32 Commits
Author SHA1 Message Date
Alex van Andel 546a1510b2 chore: release v6.1.2
Post release / release (push) Canceled after 0s
Release Docker / Prepare Release (push) Canceled after 0s
Release Docker / Release AMD64 (push) Canceled after 0s
Release Docker / Release ARM (push) Canceled after 0s
2026-01-21 02:19:24 -03:00
Alex van AndelandDevin AI 455a91935d fix: enable alby app in E2E tests to fix payment-apps test failures (#27071)
PR #27012 introduced logic to disable apps without valid keys during seeding.
This caused the alby payment app tests to fail because the alby app was
disabled in the App table when its environment keys weren't configured.

This fix adds an ensureAppEnabled helper function that enables the app
in the App table before running the tests, treating it as a mock/test
setup step.

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-21 08:40:31 +05:30
Volnei Munhoz 5ddb4dce3f Add calendar subscription error better management (#27019) 2026-01-20 18:47:03 +02:00
Udit Takkar 6c3b10672c fix: managed event type bug (#27047) 2026-01-20 17:29:12 +01:00
Jagadish MadavalkarandAnik Dhabal Babu cb6b52b334 right fix (#26963)
Co-authored-by: Anik Dhabal Babu <adhabal2002@gmail.com>
2026-01-20 15:31:43 +00:00
Shoaib Akhtar AnsariandAnik Dhabal Babu e8e50b70df fix: resolve search bar overlap with title on tablet viewports (#27050) (#27055)
* fix: resolve search bar overlap with title on tablet viewports (#27050)

* Update ShellMainAppDir.tsx

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2026-01-20 14:36:58 +00:00
Udit Takkar fd130a3fbd chore: don't use object in dependency (#26972) 2026-01-20 18:45:41 +05:30
Hariom Balhara 7ec8601bd6 chore: Integrate edit location booking audit (#26569)
## What does this PR do?

Integrates booking audit logging for the edit location functionality, following the pattern established in PR #26046 (booking creation/rescheduling audit).

This PR adds audit logging when a booking's location is changed through:
1. **Web app** (tRPC handler): `packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts`
2. **API v2**: `apps/api/v2/src/ee/bookings/2024-08-13/services/booking-location.service.ts`

### Changes:
- Added `actionSource` as a **required** parameter to `editLocationHandler` (no fallback)
- Added optional `userUuid` parameter (defaults to logged-in user's uuid)
- Added `ValidActionSource` type that excludes "UNKNOWN" for client-facing APIs
- Captures old location before update for audit data
- Calls `BookingEventHandlerService.onLocationChanged()` after successful location update
- Web app uses `actionSource: "WEBAPP"`, API v2 uses `actionSource: "API_V2"`
- Updated router to explicitly pass `actionSource: "WEBAPP"`
- Updated test to pass `actionSource: "WEBAPP"`
- **API v2**: Uses NestJS dependency injection pattern with `BookingEventHandlerService` injected via constructor

### Updates since last revision:
- **Created `BookingEventHandlerModule`** (`apps/api/v2/src/lib/modules/booking-event-handler.module.ts`) to encapsulate `BookingEventHandlerService` and its dependencies (Logger, TaskerService, HashedLinkService, BookingAuditProducerService)
- Updated both bookings modules (2024-04-15 and 2024-08-13) to import `BookingEventHandlerModule` instead of listing individual providers
- This reduces code duplication and makes dependency management cleaner

## Mandatory Tasks (DO NOT REMOVE)

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

## How should this be tested?

1. Update a booking's location through the web app
2. Update a booking's location through API v2
3. Verify audit logs are created with:
   - Correct `bookingUid`
   - Correct `actor` (user who made the change)
   - Correct `source` ("WEBAPP" or "API_V2")
   - Correct `auditData.location.old` and `auditData.location.new` values

## Checklist

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

## Human Review Checklist

- [ ] Verify all callers of `editLocationHandler` pass `actionSource` (router updated, test updated)
- [ ] Verify `organizationId` derivation is correct in both handlers (tRPC uses `booking.user?.profiles?.[0]?.organizationId`, API v2 uses `existingBookingHost.organizationId`)
- [ ] Confirm audit call placement after location update is intentional (audit failures would fail the operation even though location was already updated)
- [ ] Note: API v2 has its own implementation and does NOT reuse `editLocationHandler` - this is correct
- [ ] Verify `BookingEventHandlerModule` properly exports `BookingEventHandlerService` and is imported in both bookings modules
- [ ] Verify the `updateBookingLocationInDb` return value (`{ updatedLocation }`) is destructured and used correctly for audit data
- [ ] Verify API v2 uses `bookingLocation` for audit `new` value, while tRPC uses `updatedLocation` from DB update

---


Link to Devin run: https://app.devin.ai/sessions/fd1d439779674050a26ea3fa7d799943
Requested by: @hariombalhara
2026-01-20 18:40:48 +05:30
Hariom Balhara 39ae54d682 feat: add auto-accept logic for team memberships based on org email domain (#26860)
## What does this PR do?

Adds auto-accept logic to the `/v2/teams/{teamId}/memberships` endpoint, similar to what exists in the `/v2/organizations/{orgId}/teams/{teamId}/memberships` endpoint.

When a user is added to a sub-team of an organization, the system now checks if the user's email domain matches the organization's `orgAutoAcceptEmail` setting. If it matches and the organization is verified, the membership is automatically accepted (overriding any `accepted: false` in the request).

### Changes:
- Modified `TeamsMembershipsService.createTeamMembership()` to check if the team has a parent organization and apply auto-accept logic using the existing `OrganizationMembershipService.shouldAutoAccept()` method
- Exported `OrganizationMembershipService` from `OrganizationsModule` for dependency injection
- Added `TeamsModule` and `UsersModule` imports to `TeamsMembershipsModule`
- Added required dependencies (`OrganizationMembershipService`, `OAuthClientRepository`, etc.) to `TeamsModule` and `TeamsSchedulesModule` since they provide `TeamsMembershipsService` directly
- Added comprehensive E2E tests for auto-accept scenarios
- Added `createOrgSettings` and `deleteOrgSettings` helper methods to test fixtures

## Mandatory Tasks (DO NOT REMOVE)

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

## How should this be tested?

The E2E tests cover the following scenarios:
1. **Auto-accept when email matches** - User with `@acme.com` email is auto-accepted when org has `orgAutoAcceptEmail: "acme.com"`
2. **Case-insensitive matching** - User with `@ACME.COM` email matches `orgAutoAcceptEmail: "acme.com"`
3. **Override accepted:false** - Even if `accepted: false` is passed, it's overridden to `true` when email matches
4. **No auto-accept for non-matching emails** - User with `@external.com` email is NOT auto-accepted
5. **EventTypes assignment** - Verifies user is added to event types with `assignAllTeamMembers: true`

To run the tests:
```bash
TZ=UTC yarn test:e2e --testPathPattern="teams-memberships.controller.e2e-spec"
```

## Human Review Checklist

- [ ] Verify the auto-accept logic matches the existing implementation in `organizations-teams-memberships.controller.ts`
- [ ] Confirm all modules that provide `TeamsMembershipsService` directly (`TeamsModule`, `TeamsSchedulesModule`, `OrganizationsModule`) have the required dependencies
- [ ] Verify `updateNewTeamMemberEventTypes` is called when auto-accept triggers (controller checks `membership.accepted` after service returns)
- [ ] Consider if organization membership creation is needed when a user is auto-accepted into a sub-team but doesn't have org membership yet (not implemented in this PR)

## Checklist

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

---

Link to Devin run: https://app.devin.ai/sessions/578b8904a5a94385b59031f9616379cc
Requested by: @hariombalhara
2026-01-20 18:40:44 +05:30
Dhairyashil Shinde bea542b310 fix: correct bookingUrl for EU instance in API responses (#27046)
* fix: use WEBAPP_URL for booking URLs when domains differ

For EU deployments where WEBAPP_URL (app.cal.eu) differs from
WEBSITE_URL (cal.com), use WEBAPP_URL for non-org booking URLs.
This fixes incorrect bookingUrl in API responses for EU instance.

* Created new shared utility getTldPlus1 and updated code to use shared function instead of inline copy

* use same comment
2026-01-20 12:48:30 +00:00
Dhairyashil Shinde e7a69d68df fixed the issue with the copy link button not working in the extension (#27048) 2026-01-20 09:47:00 -03:00
BetoandDhairyashil d67bf45527 feat: integrate DateTimePickerAndroid for date/time selection (#26979)
* feat: integrate DateTimePickerAndroid for date/time selection

- Added @react-native-community/datetimepicker dependency for native date/time picker functionality.
- Replaced modal-based date/time picker implementation with DateTimePickerAndroid for improved user experience.
- Updated RescheduleScreen to utilize the new date/time picker methods, enhancing the selection process and ensuring better styling consistency.

* feat: add LimitsTabDatePicker component for date selection

- Introduced a new LimitsTabDatePicker component utilizing DateTimePickerAndroid for improved date selection on Android.
- Implemented date parsing and formatting for user-friendly display.
- Enhanced user interaction with a pressable element to open the date picker.

* feat: add EditAvailabilityOverrideScreen for managing schedule overrides

- Introduced EditAvailabilityOverrideScreen component to allow users to create and edit availability overrides.
- Implemented date and time selection using DateTimePickerAndroid for improved user experience.
- Added functionality to handle existing overrides, including editing and deleting options.
- Enhanced user interaction with clear alerts for success and error states during save operations.

* fixed lint warnings, info

* address cubics comments

---------

Co-authored-by: Dhairyashil <dhairyashil10101010@gmail.com>
2026-01-20 09:46:42 -03:00
Rajiv Sahal 72dc999108 chore: disable dashboard for creating new platform accounts (#27052)
* chore: disable dashboard for creating new platform accounts

* chore: remove page altogether
2026-01-20 12:45:47 +00:00
14e2fa6893 feat: api v2 getSchedules that are applied to team event type (#25912)
* chore: update teams schedules module

* chore: update schedules and team events repository

* chore: input for teams schedules

* feat: add logic to filter schedules based on event type id

* fix: bad import

* chore: add input for get user schedules

* feat: add logic in user schedule to filter schedules based on event type id

* chore: update e2e tests

* fix: add EventTypesModule_2024_06_14 import to SchedulesModule_2024_06_11

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

* fix: import path

* chore: implement cubic feedback

* fix: replace .finally() with try/finally in E2E tests

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

* chore: implement PR feedback

* fix: correct schedule precedence order for team hosts

Fix the effective schedule resolution to follow the intended precedence:
host schedule > user default schedule > event type schedule

This addresses Cubic AI review feedback with confidence 9/10:
- schedules.service.ts: Fix precedence in getUserSchedulesForEventType
- teams-schedules.service.ts: Fix precedence in getTeamSchedulesForEventType

Co-Authored-By: unknown <>

* fixup

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-20 17:55:38 +05:30
Hariom Balhara 2c105aceb8 chore: convert console.log to debug log in raqbUtils (#27008)
## What does this PR do?

Converts a `console.log` statement to use the proper `@calcom/lib/logger` with debug level in the raqbUtils.server.ts file. This ensures the log message only appears when debug logging is enabled, reducing noise in production logs.

## Mandatory Tasks (DO NOT REMOVE)

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

## How should this be tested?

This is a simple logging change. No specific testing required beyond CI passing.

The log message "A group option found. Using all its sub-options instead" will now only appear when the logger level is set to debug (level 2 or lower via `NEXT_PUBLIC_LOGGER_LEVEL`).

## Checklist

- [x] My code follows the style guidelines of this project
- [x] I have checked if my changes generate no new warnings

<!-- Link to Devin run: https://app.devin.ai/sessions/45d95cd7127541ffac96764f251ae1c9 -->
<!-- Requested by: @hariombalhara -->
2026-01-20 13:54:25 +05:30
devin-ai-integration[bot]andali@cal.com 1e0b0d949f feat: add 40-minute duration option for multiple duration event types (#27032)
- Add 40 minutes to the multipleDurationOptions array in EventSetupTab
- Add comprehensive tests for 40-minute slot generation including:
  - Full day slot generation
  - Correct time intervals
  - Default duration handling
  - Same day booking with time constraints
  - Multiple date ranges

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: ali@cal.com <ali@cal.com>
2026-01-20 10:43:55 +04:00
c60addc528 feat: add PlatformOrganizationBillingTasker with sync and trigger.dev versions (#26803)
* feat: add PlatformOrganizationBillingTasker with sync and trigger.dev versions

- Create PlatformOrganizationBillingTasker extending Tasker base class
- Add sync tasker (PlatformOrganizationBillingSyncTasker) for synchronous execution
- Add trigger.dev tasker (PlatformOrganizationBillingTriggerTasker) for async execution
- Create trigger.dev task for incrementing subscription usage
- Add PlatformOrganizationBillingTaskService with business logic
- Add PlatformOrganizationBillingRepository for data access
- Add createSubscriptionUsageRecord method to StripeBillingService
- Follow BookingEmailAndSmsTasker pattern for consistency

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

* refactor: move repository methods per PR feedback

- Move findPlatformOrgByUserId to OrganizationRepository
- Create new PlatformBillingRepository for billing queries
- Remove PlatformOrganizationBillingRepository (consolidated)
- Update task service and trigger.dev task to use new repositories

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

* feat: add DI using @evyweb/ioctopus for platform billing tasker

- Create di/tasker/ directory following BookingEmailAndSmsTasker pattern
- Add tokens.ts with DI tokens for all billing tasker classes
- Add module files for PlatformBillingRepository, TaskService, SyncTasker, TriggerTasker, Tasker
- Add container files with getter functions
- Update trigger.dev task to use DI container instead of manual instantiation

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

* refactor: add select statement to PlatformBillingRepository.findByTeamId

Only select subscriptionId field since that's the only field used by the task service

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

* feat: add NestJS DI modules for platform billing tasker in API v2

- Add platform billing tasker exports to platform-libraries/organizations.ts
- Export IBillingProviderService type from platform-libraries
- Create StripeBillingProviderService wrapper implementing IBillingProviderService
- Create PrismaPlatformBillingRepository extending PlatformBillingRepository
- Create NestJS service wrappers for all platform billing tasker classes
- Create PlatformBillingTaskerModule with all providers and exports
- Update PlatformOrganizationBillingTaskService to use Pick<IBillingProviderService>

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

* feat: handle cancel and reschedule usage

* fix: restore IsUserInBillingOrg to billing module providers

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

* chore: add idempotency key

* fix: just log error]

* fix: logger and typo

* fix: address Cubic AI review feedback (high confidence issues)

- Fix grammar error: 'Delayed task are' -> 'Delayed tasks are' in PlatformOrganizationBillingSyncTasker.ts (3 occurrences)
- Re-throw error after logging in increment-usage.ts to enable trigger.dev retry mechanism

Co-Authored-By: unknown <>

* fix: remove duplicate code and use DI instead

* fix: remove orThrow on find first in findPlatformOrgByUserId

* refactor: prevent usage increment task from re-throwing errors

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-20 15:26:50 +09:00
Hariom Balhara 38a7fcfedd fix: filter segment members to only show those with host entries (#26816)
## What does this PR do?

Fixes a bug where the UI shows team members who match segment criteria even when they don't have host entries in the database. When `assignAllTeamMembers` is `true` and segment filtering is enabled, members would appear in the Assignment tab but wouldn't actually be assigned during booking because they lack host entries.

**The Problem:**
- The filtering logic in `EditWeightsForAllTeamMembers` short-circuited when `assignAllTeamMembers` was true, showing all segment-matching members regardless of whether they had host entries
- The `handleSave` function only updates hosts that already exist in the `value` array, filtering out members without host entries
- This created a misleading UI where members appeared but wouldn't be assigned

**The Fix:**
- Removed the `assignAllTeamMembers` prop from `EditWeightsForAllTeamMembers` component entirely since the filtering should always check for existing host entries
- Updated the filter in `EditWeightsForAllTeamMembers.tsx` to always check for existing host entries in the `value` array
- Added `filterMemberIds` prop to `Segment` component to filter matching team members to only show those who are hosts of the event-type
- `AddMembersWithSwitch` now passes the list of non-fixed host user IDs to the Segment component via `filterMemberIds`

## Mandatory Tasks (DO NOT REMOVE)

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

## How should this be tested?

1. Create a team event type with round-robin scheduling
2. Enable "Assign all team members" toggle
3. Enable segment filtering with criteria that matches some team members
4. Verify that only members who have host entries (are actually assigned to the event type) appear in the "Edit team member weights" sheet
5. Members matching segment criteria but without host entries should NOT be displayed

## Checklist

- [x] My code follows the style guidelines of this project
- [x] I have checked if my changes generate no new warnings

---

### Human Review Checklist
- [ ] Verify the filtering logic correctly shows only members with host entries in both `EditWeightsForAllTeamMembers` and `Segment` components
- [ ] Confirm this aligns with the actual booking behavior (members without host entries won't be assigned)
- [ ] Check that `filterMemberIds` prop is correctly passed through `AddMembersWithSwitch` → `MembersSegmentWithToggle` → `Segment` → `MatchingTeamMembers`
- [ ] Verify the filter `value.filter((host) => !host.isFixed).map((host) => host.userId)` correctly extracts non-fixed host IDs

---

Link to Devin run: https://app.devin.ai/sessions/3333b41226d0452e98ef7ac358511f5b
Requested by: @hariombalhara
2026-01-20 07:01:49 +05:30
Volnei MunhozandDevin AI e620264848 fix: resolve noNonNullAssertion and noImplicitAnyLet lint issues (#27021)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-19 20:07:18 -03:00
Dhairyashil Shinde 12e42427e2 fix(auth): show splash screen during auth check to prevent login flash (#27022) 2026-01-19 21:13:33 +01:00
ad137fbf9c chore: disable apps with missing required keys (#27012)
* ci(companion): add separate typecheck workflow to catch type errors

This adds a new companion-typecheck.yml workflow that runs TypeScript type
checking for the companion app. This would have caught the missing useEffect
import issue in PR #26931.

Changes:
- Add new companion-typecheck.yml workflow file
- Update pr.yml to call the new workflow when companion files change
- Add typecheck-companion to the required jobs list

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

* add lint checks

* test

* remove

* update typecheck

* address review

* chore:- hide apps with missing required keys from app store

* update

* Delete packages/app-store/_utils/hasRequiredAppKeys.test.ts

* Update validateAppKeys.ts

* Update validateAppKeys.ts

* Update validateAppKeys.test.ts

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-19 19:32:30 +00:00
da91152365 feat: standalone page config to use in companion (#27018)
* feat: hide navigation when ?standalone=true URL parameter is present

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

* feat: standalone page config to use in companion

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: peer@cal.com <peer@cal.com>
2026-01-19 19:27:14 +00:00
adc198b37b feat: add feature opt-in banner system (#26625)
* docs: add feature opt-in banner system plan

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

* docs: iterate on feature opt-in banner plan based on feedback

- Use config.ts for feature metadata instead of TRPC response
- Consolidate localStorage dismissal into single key to avoid bloat
- Replace separate success dialog with in-dialog success state for smoother UX

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

* feat: implement feature opt-in banner system

- Add checkFeatureOptInEligibility TRPC procedure to check if user can opt-in
- Create useFeatureOptInBanner hook with localStorage dismissal state
- Create FeatureOptInBanner floating component for bottom-right display
- Create FeatureOptInConfirmDialog with role-based options and success state
- Integrate banner into bookings-view.tsx as example usage

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

* feat: add i18n translation keys for feature opt-in banner

* refactor: create FeatureOptInBannerWrapper for simpler consumer integration

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

* refactor: use @calcom/lib/webstorage and add Zod schema for localStorage

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

* refactor: address PR review comments - use PBAC, move logic to service, improve typing

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

# Conflicts:
#	packages/features/feature-opt-in/services/FeatureOptInService.ts

* test: add comprehensive tests for checkFeatureOptInEligibility method

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

* fix: correct import and Checkbox type errors

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

* update message

* invalidate after dialog dismiss

* feat: simulate user opt-in to determine banner eligibility

When a feature has a strict policy and org/team hasn't explicitly
enabled it, user opt-in alone won't enable the feature. This change
simulates what would happen if the user opts in and only shows the
banner if opting in would actually enable the feature.

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

* fix: add early return for missing featureConfig and clarify simulation comment

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

* fix repository usage

* refactor: simplify FeatureOptInConfirmDialog UI and remove unused translation

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

* refactor: replace radio buttons with multi-select dropdown in FeatureOptInConfirmDialog

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

* resize banner

* refactor: use AnimatedPopover pattern for team selection and rename titleI18nKey to nameI18nKey

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

* fix: change 'Just for me' to 'For me', fix width clipping, remove divider

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

* update style

* feat: make 'Just for me' mutually exclusive with teams/org and store opt-ins in localStorage

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

* refactor: remove barrel imports and update to direct imports

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

* fix: add modal prop to AnimatedPopover to fix scroll lock conflict in dialogs

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

* fix: wrap Popover.Content with Portal to prevent layout interference in dialogs

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

* fix: use flexbox gap instead of space-y to prevent popover wrapper from affecting layout

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

* Revert "fix: use flexbox gap instead of space-y to prevent popover wrapper from affecting layout"

This reverts commit 8385adafa890c2f2674aa3ae3b10ca6e0c55ba5c.

* Revert "fix: wrap Popover.Content with Portal to prevent layout interference in dialogs"

This reverts commit e84301a35a99b3559d47c2d37316af290834ca2d.

* Revert "fix: add modal prop to AnimatedPopover to fix scroll lock conflict in dialogs"

This reverts commit b015af2f1cf31ef7087c02cb8535d96cc4a34125.

* feat: replace Radix Dialog and AnimatedPopover with coss-ui components

This migration uses @coss/ui Dialog and Popover components which are built on
Base UI instead of Radix. Base UI handles scroll locking and positioning
differently, which should fix the gap issue when opening the popover inside
the dialog.

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

* refactor: replace remaining @calcom/ui components with coss-ui

- Replace Divider with Separator from @coss/ui
- Replace Label with Label from @coss/ui
- Replace CheckboxField with Checkbox + Label from @coss/ui
- Replace Icon with direct lucide-react icons (UserIcon, BuildingIcon, UsersIcon, CheckIcon)
- Replace showToast with toastManager.add() from @coss/ui

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

* refactor: replace Popover with Menu component from coss-ui

- Replace Popover + FilterCheckboxField with Menu + MenuCheckboxItem
- MenuCheckboxItem provides built-in checkbox indicator and styling
- MenuSeparator provides proper menu separators
- Cleaner API with onCheckedChange callback
- Better keyboard navigation support

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

* fix: align icon and text on same line in MenuCheckboxItem, widen dialog

- Wrap icon and text in flex container with items-center gap-2
- Change dialog width from sm:max-w-md to sm:max-w-lg to prevent text wrapping

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

* use dimmed bg for coss dialog title

* refactor: change nameI18nKey/descriptionI18nKey to nested i18n object

- Change OptInFeatureConfig interface to use i18n: { name, description }
- Update all component usages to use featureConfig.i18n.name and featureConfig.i18n.description
- Update test mocks to use new structure

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

* feat: add i18n.title to OptInFeatureConfig type definition

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

* update texts

* use title instead of name

* update banner style

* refactor: split FeatureOptInConfirmDialog into separate success and confirmation dialogs

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

* fix: don't record dismissed info in local storage after successful opt-in

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

* fix: add checkFeatureOptInEligibility to IFeatureOptInService interface

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

* update common.json

* refactor: move tRPC usage from features package to apps/web/modules

Addresses Cubic AI review feedback (confidence 9/10):
- Remove @calcom/trpc import from FeatureOptInConfirmDialog.tsx
- Add FeatureOptInMutations type for passing mutation functions via props
- Move tRPC hooks to useFeatureOptInBanner hook in apps/web/modules
- Pass mutations through FeatureOptInBannerWrapper to dialog component

This maintains the architectural constraint that packages/features
should not import from @calcom/trpc to prevent circular dependencies.

Co-Authored-By: unknown <>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-19 18:48:26 +00:00
Joe Au-YeungandDevin AI 2608e136cb fix: use appropriate error codes in chargeCard handler (#27013)
- Change 'Booking not found' to throw NOT_FOUND (404) instead of plain Error
- Preserve ErrorWithCode for ChargeCardFailure to properly map to BAD_REQUEST (400)
- Add specific error handling for 'User is not a member of the team' -> FORBIDDEN (403)
- Add specific error handling for 'User ID is required' and 'No payment credential found' -> BAD_REQUEST (400)
- Keep INTERNAL_SERVER_ERROR (500) only for actual server errors like 'Payment app not implemented'
- Update test to expect ErrorWithCode to be re-thrown instead of wrapped in translated Error

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-19 18:14:13 +00:00
7f46ce1851 feat: add HubSpot contact owner toggle (#26552)
* feat: add HubSpot contact owner toggle

* chore

* Update packages/app-store/hubspot/lib/CrmService.ts

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

* fix: updated test to verify new behavior

* fix: implement cubic suggestions

* refactor: update contact owner strings and enhance meeting creation logic

* refactor: fix failing unit tests

* refactor: streamline contact owner retrieval and error handling

* update tests

* update text

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
2026-01-19 17:43:25 +00:00
a4f8571f59 fix: update transaction timeout for large managed event-types (#25944)
* fix: update transaction timeout for large managed event-types

* test: add findMany mock for batch update optimization in handleChildrenEventTypes tests

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

* fix: reduce batch size to 50 and use skipDuplicates for workflow linkage

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

* test: add comprehensive tests for batch execution and retry behavior

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: use logger instead of console.log and fix type error

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

* test: update test file to mock logger instead of console.log

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

* fix: type issues

* fixup! fix: type issues

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keithwillcode@gmail.com>
2026-01-19 20:37:21 +04:00
Anik Dhabal Babu f319b662c5 fix: skip reminders when scheduled date is in the past (#26942)
* flaky-e2e

* workflow reminder

* remove
2026-01-19 14:54:31 +00:00
37847c0061 feat: redesign team creation flow to match onboarding-v3 design (#26733)
* feat: redesign team creation flow to match onboarding-v3 design

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

* feat: skip invite options and go directly to email invite step

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

* feat: invalidate teams query on submit to refresh teams list

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

* fix: use server action to invalidate server-side teams cache

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

* fix: conditionally render avatar in browser view to remove extra padding

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

* fix browser view

* test: update team creation E2E tests to use new onboarding-v3 flow

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

* test: update auth-index.e2e.ts to use new team creation flow

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

* test: update team-management.e2e.ts to use new team creation flow

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

* fix: add error handling for createTeam and improve CSV parsing for quoted fields

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-19 14:53:51 +00:00
a8abe2cdf1 ci: add separate typecheck and lint workflows for companion (#26961)
* ci(companion): add separate typecheck workflow to catch type errors

This adds a new companion-typecheck.yml workflow that runs TypeScript type
checking for the companion app. This would have caught the missing useEffect
import issue in PR #26931.

Changes:
- Add new companion-typecheck.yml workflow file
- Update pr.yml to call the new workflow when companion files change
- Add typecheck-companion to the required jobs list

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

* add lint checks

* test

* remove

* update typecheck

* address review

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-19 14:40:43 +00:00
ea66a9093c fix(teams): add organization scope to membership operations (#26964)
* refactor(teams): improve membership validation for org admins

- Add organization scope validation to removeMember handler
- Ensure team operations are scoped to users organization context
- Update related tests

* test(teams): align removeMember tests with updated interface

- Add organizationId to test contexts
- Add team.findMany mock for org validation
- Add test cases for org scope validation

* refactor(teams): use TeamRepository for org validation in LegacyRemoveMemberService

- Add findByIdsAndOrgId method to TeamRepository
- Inject TeamRepository into LegacyRemoveMemberService via constructor
- Update RemoveMemberServiceFactory to instantiate and inject TeamRepository
- Update tests to mock TeamRepository instead of direct prisma calls

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

---------

Co-authored-by: keith@cal.com <keithwillcode@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-19 11:26:05 -03:00
62216f2db6 feat: add CalendarsTasker with sync and trigger.dev versions (#26854)
* feat: add CalendarsTasker with sync and trigger.dev versions

This PR implements a CalendarsTasker following the same pattern as
PlatformOrganizationBillingTasker to replace the logic in
apps/api/v2/src/ee/calendars/processors/calendars.processor.ts

New files created:
- CalendarsTasker main orchestrator extending Tasker base class
- CalendarsSyncTasker for sync execution
- CalendarsTriggerTasker for async execution via trigger.dev
- CalendarsTaskService with business logic for ensuring default calendars
- trigger.dev task with queue config and retry settings
- DI modules using @evyweb/ioctopus
- NestJS DI modules for API v2

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

* fix: import and deasync

* style: format trigger.config.ts dirs array

Co-Authored-By: unknown <>

* refactor: move prisma query to UserRepository and set onboarding to true

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

* refactor: remove credential.key from UserRepository method

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-19 16:20:05 +02:00
sean-brydon e0c637b8ec feat: add seat tracking infrastructure for monthly proration (#27001)
Part 1 of monthly proration feature.

Adds seat change tracking infrastructure with operationId for idempotency.

**Dependency chain:**
- This PR (seat tracking)
- PR #27002  depends on this
2026-01-19 13:56:30 +00:00
236 changed files with 8443 additions and 1462 deletions
+33
View File
@@ -0,0 +1,33 @@
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
@@ -0,0 +1,34 @@
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
+22 -2
View File
@@ -299,7 +299,7 @@ jobs:
skip-install-if-cache-hit: "true"
type-check:
name: Type check
name: Type Checks
needs: [prepare]
if: ${{ needs.prepare.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/check-types.yml
@@ -377,6 +377,20 @@ jobs:
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]
@@ -457,6 +471,8 @@ jobs:
build-atoms,
build-docs,
build-companion,
typecheck-companion,
lint-companion,
setup-db,
e2e,
e2e-api-v2,
@@ -506,5 +522,9 @@ jobs:
) ||
(
needs.prepare.outputs.has-companion == 'true' &&
needs.build-companion.result != 'success'
(
needs.build-companion.result != 'success' ||
needs.typecheck-companion.result != 'success' ||
needs.lint-companion.result != 'success'
)
)
@@ -102,6 +102,8 @@ describe("POST /api/selected-calendars", () => {
channelResourceUri: null,
channelExpiration: null,
syncSubscribedAt: null,
syncSubscribedErrorAt: null,
syncSubscribedErrorCount: 0,
syncToken: null,
syncedAt: null,
syncErrorAt: null,
@@ -157,6 +159,8 @@ describe("POST /api/selected-calendars", () => {
channelResourceUri: null,
channelExpiration: null,
syncSubscribedAt: null,
syncSubscribedErrorAt: null,
syncSubscribedErrorCount: 0,
syncToken: null,
syncedAt: null,
syncErrorAt: null,
+2
View File
@@ -47,6 +47,8 @@ const loadConfig = (): AppConfig => {
e2e: getEnv("IS_E2E", "false") === "true",
enableSlotsWorkers: getEnv("ENABLE_SLOTS_WORKERS", "true") === "true",
slotsWorkerPoolSize: Number(getEnv("SLOTS_WORKER_POOL_SIZE", "4")),
vercel: getEnv("VERCEL", "false") === "true",
enableAsyncTasker: getEnv("ENABLE_ASYNC_TASKER", "false") === "true",
};
};
+2
View File
@@ -35,4 +35,6 @@ export type AppConfig = {
e2e: boolean;
enableSlotsWorkers: boolean;
slotsWorkerPoolSize: number;
vercel: boolean;
enableAsyncTasker: boolean;
};
@@ -27,6 +27,7 @@ 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: [
@@ -43,6 +44,7 @@ import { Module } from "@nestjs/common";
RegularBookingModule,
RecurringBookingModule,
InstantBookingModule,
BookingEventHandlerModule,
],
providers: [
TokensRepository,
@@ -50,6 +50,7 @@ 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: [
@@ -71,6 +72,7 @@ import { Module } from "@nestjs/common";
RegularBookingModule,
RecurringBookingModule,
InstantBookingModule,
BookingEventHandlerModule,
],
providers: [
TokensRepository,
@@ -1,18 +1,19 @@
import { makeUserActor } from "@calcom/platform-libraries/bookings";
import type {
BookingInputLocation_2024_08_13,
UpdateBookingInputLocation_2024_08_13,
UpdateBookingLocationInput_2024_08_13,
} from "@calcom/platform-types";
import { Booking } from "@calcom/prisma/client";
import { ForbiddenException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { BookingEventHandlerService } from "@/lib/services/booking-event-handler.service";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { EventTypeAccessService } from "@/modules/event-types/services/event-type-access.service";
import { UsersRepository } from "@/modules/users/users.repository";
import { Injectable, Logger, NotFoundException, ForbiddenException } from "@nestjs/common";
import type {
UpdateBookingLocationInput_2024_08_13,
BookingInputLocation_2024_08_13,
UpdateBookingInputLocation_2024_08_13,
} from "@calcom/platform-types";
import { Booking } from "@calcom/prisma/client";
@Injectable()
export class BookingLocationService_2024_08_13 {
@@ -24,7 +25,8 @@ export class BookingLocationService_2024_08_13 {
private readonly usersRepository: UsersRepository,
private readonly inputService: InputBookingsService_2024_08_13,
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
private readonly eventTypeAccessService: EventTypeAccessService
private readonly eventTypeAccessService: EventTypeAccessService,
private readonly bookingEventHandlerService: BookingEventHandlerService
) {}
async updateBookingLocation(
@@ -66,6 +68,7 @@ export class BookingLocationService_2024_08_13 {
user: ApiAuthGuardUser
) {
const bookingUid = existingBooking.uid;
const oldLocation = existingBooking.location;
const bookingLocation = this.getLocationValue(inputLocation) ?? existingBooking.location;
if (!existingBooking.userId) {
@@ -96,6 +99,19 @@ export class BookingLocationService_2024_08_13 {
responses: updatedBookingResponses,
});
await this.bookingEventHandlerService.onLocationChanged({
bookingUid: existingBooking.uid,
actor: makeUserActor(user.uuid),
organizationId: existingBookingHost.organizationId ?? null,
source: "API_V2",
auditData: {
location: {
old: oldLocation,
new: bookingLocation,
},
},
});
return this.bookingsService.getBooking(updatedBooking.uid, user);
}
@@ -1,3 +1,4 @@
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { SchedulesController_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/controllers/schedules.controller";
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { InputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/input-schedules.service";
@@ -9,7 +10,7 @@ import { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, UsersModule, TokensModule],
imports: [PrismaModule, UsersModule, TokensModule, EventTypesModule_2024_06_14],
providers: [
SchedulesRepository_2024_06_11,
SchedulesService_2024_06_11,
@@ -244,4 +244,31 @@ export class SchedulesRepository_2024_06_11 {
take,
});
}
async getSchedulesByIds(scheduleIds: number[], skip: number, take: number) {
return this.dbRead.prisma.schedule.findMany({
where: {
id: {
in: scheduleIds,
},
},
select: {
id: true,
userId: true,
name: true,
timeZone: true,
availability: {
select: {
days: true,
startTime: true,
endTime: true,
date: true,
},
},
},
orderBy: { id: 'asc' },
skip,
take,
});
}
}
@@ -1,15 +1,15 @@
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { InputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/input-schedules.service";
import { OutputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/output-schedules.service";
import { UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import type {
CreateScheduleInput_2024_06_11,
ScheduleOutput_2024_06_11,
UpdateScheduleInput_2024_06_11,
} from "@calcom/platform-types";
import type { Schedule } from "@calcom/prisma/client";
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { InputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/input-schedules.service";
import { OutputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/output-schedules.service";
import { UsersRepository } from "@/modules/users/users.repository";
@Injectable()
export class SchedulesService_2024_06_11 {
@@ -17,10 +17,11 @@ export class SchedulesService_2024_06_11 {
private readonly schedulesRepository: SchedulesRepository_2024_06_11,
private readonly inputSchedulesService: InputSchedulesService_2024_06_11,
private readonly outputSchedulesService: OutputSchedulesService_2024_06_11,
private readonly usersRepository: UsersRepository
private readonly usersRepository: UsersRepository,
private readonly eventTypesRepository: EventTypesRepository_2024_06_14
) {}
async createUserDefaultSchedule(userId: number, timeZone: string) {
async createUserDefaultSchedule(userId: number, timeZone: string): Promise<ScheduleOutput_2024_06_11> {
const defaultSchedule = {
isDefault: true,
name: "Default schedule",
@@ -45,7 +46,7 @@ export class SchedulesService_2024_06_11 {
return this.outputSchedulesService.getResponseSchedule(createdSchedule);
}
async getUserScheduleDefault(userId: number) {
async getUserScheduleDefault(userId: number): Promise<ScheduleOutput_2024_06_11 | null> {
const user = await this.usersRepository.findById(userId);
if (!user?.defaultScheduleId) return null;
@@ -56,7 +57,7 @@ export class SchedulesService_2024_06_11 {
return this.outputSchedulesService.getResponseSchedule(defaultSchedule);
}
async getUserSchedule(userId: number, scheduleId: number) {
async getUserSchedule(userId: number, scheduleId: number): Promise<ScheduleOutput_2024_06_11> {
const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId);
if (!existingSchedule) {
@@ -68,16 +69,71 @@ export class SchedulesService_2024_06_11 {
return this.outputSchedulesService.getResponseSchedule(existingSchedule);
}
async getUserSchedules(userId: number) {
const schedules = await this.schedulesRepository.getSchedulesByUserId(userId);
return Promise.all(
schedules.map(async (schedule) => {
return this.outputSchedulesService.getResponseSchedule(schedule);
})
);
async getUserSchedules(userId: number, eventTypeId?: number): Promise<ScheduleOutput_2024_06_11[]> {
if (!eventTypeId) {
const schedules = await this.schedulesRepository.getSchedulesByUserId(userId);
return Promise.all(
schedules.map(async (schedule) => {
return this.outputSchedulesService.getResponseSchedule(schedule);
})
);
}
return this.getUserSchedulesForEventType(userId, eventTypeId);
}
async updateUserSchedule(userId: number, scheduleId: number, bodySchedule: UpdateScheduleInput_2024_06_11) {
private async getUserSchedulesForEventType(
userId: number,
eventTypeId: number
): Promise<ScheduleOutput_2024_06_11[]> {
const userEventType = await this.eventTypesRepository.getUserEventType(userId, eventTypeId);
let effectiveScheduleId: number | null = null;
if (userEventType) {
const user = await this.usersRepository.findById(userId);
effectiveScheduleId = userEventType.scheduleId ?? user?.defaultScheduleId ?? null;
} else {
// if its not a user owned event type, check if it's a team event type where user one of the host
const eventType = await this.eventTypesRepository.getEventTypeByIdWithHosts(eventTypeId);
if (!eventType) {
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
}
const userHost = eventType.hosts?.find((host) => host.userId === userId);
if (!userHost) {
throw new NotFoundException(`User ${userId} is not associated with event type ${eventTypeId}`);
}
const user = await this.usersRepository.findById(userId);
effectiveScheduleId = eventType.scheduleId ?? userHost.scheduleId ?? user?.defaultScheduleId ?? null;
}
if (!effectiveScheduleId) {
return [];
}
const schedule = await this.schedulesRepository.getScheduleById(effectiveScheduleId);
if (!schedule) {
return [];
}
if (schedule.userId !== userId) {
throw new ForbiddenException(`User ${userId} does not have access to schedule ${effectiveScheduleId}`);
}
return [await this.outputSchedulesService.getResponseSchedule(schedule)];
}
async updateUserSchedule(
userId: number,
scheduleId: number,
bodySchedule: UpdateScheduleInput_2024_06_11
): Promise<ScheduleOutput_2024_06_11> {
const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId);
if (!existingSchedule) {
@@ -106,7 +162,7 @@ export class SchedulesService_2024_06_11 {
return this.outputSchedulesService.getResponseSchedule(updatedSchedule);
}
async deleteUserSchedule(userId: number, scheduleId: number) {
async deleteUserSchedule(userId: number, scheduleId: number): Promise<Schedule> {
const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId);
if (!existingSchedule) {
@@ -118,7 +174,7 @@ export class SchedulesService_2024_06_11 {
return this.schedulesRepository.deleteScheduleById(scheduleId);
}
checkUserOwnsSchedule(userId: number, schedule: Pick<Schedule, "id" | "userId">) {
checkUserOwnsSchedule(userId: number, schedule: Pick<Schedule, "id" | "userId">): void {
if (userId !== schedule.userId) {
throw new ForbiddenException(`User with ID=${userId} does not own schedule with ID=${schedule.id}`);
}
+2
View File
@@ -41,6 +41,8 @@ export type Environment = {
ENABLE_SLOTS_WORKERS: string;
SLOTS_WORKER_POOL_SIZE: string;
USE_POOL: string;
VERCEL: string;
ENABLE_ASYNC_TASKER: string;
};
export const getEnv = <K extends keyof Environment>(key: K, fallback?: Environment[K]): Environment[K] => {
@@ -0,0 +1,24 @@
import { Logger } from "@/lib/logger.bridge";
import { BookingAuditProducerService } from "@/lib/services/booking-audit-producer.service";
import { BookingEventHandlerService } from "@/lib/services/booking-event-handler.service";
import { HashedLinkService } from "@/lib/services/hashed-link.service";
import { TaskerService } from "@/lib/services/tasker.service";
import { Module, Scope } from "@nestjs/common";
@Module({
providers: [
{
provide: Logger,
useFactory: () => {
return new Logger();
},
scope: Scope.TRANSIENT,
},
TaskerService,
HashedLinkService,
BookingAuditProducerService,
BookingEventHandlerService,
],
exports: [BookingEventHandlerService],
})
export class BookingEventHandlerModule {}
@@ -0,0 +1,26 @@
import { Module, Scope } from "@nestjs/common";
import { Logger } from "@/lib/logger.bridge";
import { CalendarsSyncTaskerService } from "@/lib/services/tasker/calendars-sync-tasker.service";
import { CalendarsTaskService } from "@/lib/services/tasker/calendars-task.service";
import { CalendarsTasker } from "@/lib/services/tasker/calendars-tasker.service";
import { CalendarsTriggerTaskerService } from "@/lib/services/tasker/calendars-trigger-tasker.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
@Module({
imports: [PrismaModule],
providers: [
{
provide: Logger,
useFactory: () => {
return new Logger();
},
scope: Scope.TRANSIENT,
},
CalendarsTaskService,
CalendarsSyncTaskerService,
CalendarsTriggerTaskerService,
CalendarsTasker,
],
exports: [CalendarsTasker],
})
export class CalendarsTaskerModule {}
@@ -0,0 +1,32 @@
import { Module, Scope } from "@nestjs/common";
import { Logger } from "@/lib/logger.bridge";
import { PrismaPlatformBillingRepository } from "@/lib/repositories/prisma-platform-billing.repository";
import { StripeBillingProviderService } from "@/lib/services/stripe-billing-provider.service";
import { PlatformBillingSyncTaskerService } from "@/lib/services/tasker/platform-billing-sync-tasker.service";
import { PlatformBillingTaskService } from "@/lib/services/tasker/platform-billing-task.service";
import { PlatformBillingTasker } from "@/lib/services/tasker/platform-billing-tasker.service";
import { PlatformBillingTriggerTaskerService } from "@/lib/services/tasker/platform-billing-trigger-tasker.service";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
@Module({
imports: [PrismaModule, StripeModule, OrganizationsModule],
providers: [
PrismaPlatformBillingRepository,
StripeBillingProviderService,
{
provide: Logger,
useFactory: () => {
return new Logger();
},
scope: Scope.TRANSIENT,
},
PlatformBillingTaskService,
PlatformBillingSyncTaskerService,
PlatformBillingTriggerTaskerService,
PlatformBillingTasker,
],
exports: [PlatformBillingTasker],
})
export class PlatformBillingTaskerModule {}
@@ -0,0 +1,12 @@
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { PlatformBillingRepository } from "@calcom/platform-libraries/organizations";
import type { PrismaClient } from "@calcom/prisma";
@Injectable()
export class PrismaPlatformBillingRepository extends PlatformBillingRepository {
constructor(dbWrite: PrismaWriteService) {
super(dbWrite.prisma as unknown as PrismaClient);
}
}
@@ -0,0 +1,14 @@
import { StripeBillingService } from "@calcom/platform-libraries";
import type { IBillingProviderService } from "@calcom/platform-libraries/organizations";
import { Injectable } from "@nestjs/common";
import { StripeService } from "@/modules/stripe/stripe.service";
@Injectable()
export class StripeBillingProviderService
extends StripeBillingService
implements Pick<IBillingProviderService, "createSubscriptionUsageRecord">
{
constructor(readonly stripeService: StripeService) {
super(stripeService.getStripe());
}
}
@@ -0,0 +1,15 @@
import { Logger } from "@/lib/logger.bridge";
import { CalendarsTaskService } from "@/lib/services/tasker/calendars-task.service";
import { Injectable } from "@nestjs/common";
import { CalendarsSyncTasker as BaseCalendarsSyncTasker } from "@calcom/platform-libraries/calendars";
@Injectable()
export class CalendarsSyncTaskerService extends BaseCalendarsSyncTasker {
constructor(calendarsTaskService: CalendarsTaskService, logger: Logger) {
super({
logger,
calendarsTaskService,
});
}
}
@@ -0,0 +1,15 @@
import { Logger } from "@/lib/logger.bridge";
import { Injectable } from "@nestjs/common";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { CalendarsTaskService as BaseCalendarsTaskService } from "@calcom/platform-libraries/calendars";
@Injectable()
export class CalendarsTaskService extends BaseCalendarsTaskService {
constructor(prismaReadService: PrismaReadService, logger: Logger) {
super({
logger,
prisma: prismaReadService.prisma,
});
}
}
@@ -0,0 +1,21 @@
import { Logger } from "@/lib/logger.bridge";
import { CalendarsSyncTaskerService } from "@/lib/services/tasker/calendars-sync-tasker.service";
import { CalendarsTriggerTaskerService } from "@/lib/services/tasker/calendars-trigger-tasker.service";
import { Injectable } from "@nestjs/common";
import { CalendarsTasker as BaseCalendarsTasker } from "@calcom/platform-libraries/calendars";
@Injectable()
export class CalendarsTasker extends BaseCalendarsTasker {
constructor(
syncTasker: CalendarsSyncTaskerService,
asyncTasker: CalendarsTriggerTaskerService,
logger: Logger
) {
super({
logger,
asyncTasker: asyncTasker,
syncTasker: syncTasker,
});
}
}
@@ -0,0 +1,13 @@
import { Logger } from "@/lib/logger.bridge";
import { Injectable } from "@nestjs/common";
import { CalendarsTriggerTasker as BaseCalendarsTriggerTasker } from "@calcom/platform-libraries/calendars";
@Injectable()
export class CalendarsTriggerTaskerService extends BaseCalendarsTriggerTasker {
constructor(logger: Logger) {
super({
logger,
});
}
}
@@ -0,0 +1,15 @@
import { Logger } from "@/lib/logger.bridge";
import { PlatformBillingTaskService } from "@/lib/services/tasker/platform-billing-task.service";
import { Injectable } from "@nestjs/common";
import { PlatformOrganizationBillingSyncTasker as BasePlatformOrganizationBillingSyncTasker } from "@calcom/platform-libraries/organizations";
@Injectable()
export class PlatformBillingSyncTaskerService extends BasePlatformOrganizationBillingSyncTasker {
constructor(billingTaskService: PlatformBillingTaskService, logger: Logger) {
super({
logger,
billingTaskService,
});
}
}
@@ -0,0 +1,24 @@
import { Logger } from "@/lib/logger.bridge";
import { PrismaPlatformBillingRepository } from "@/lib/repositories/prisma-platform-billing.repository";
import { StripeBillingProviderService } from "@/lib/services/stripe-billing-provider.service";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { Injectable } from "@nestjs/common";
import { PlatformOrganizationBillingTaskService as BasePlatformOrganizationBillingTaskService } from "@calcom/platform-libraries/organizations";
@Injectable()
export class PlatformBillingTaskService extends BasePlatformOrganizationBillingTaskService {
constructor(
organizationRepository: OrganizationsRepository,
platformBillingRepository: PrismaPlatformBillingRepository,
billingProviderService: StripeBillingProviderService,
logger: Logger
) {
super({
logger,
organizationRepository,
platformBillingRepository,
billingProviderService,
});
}
}
@@ -0,0 +1,20 @@
import { PlatformOrganizationBillingTasker as BasePlatformOrganizationBillingTasker } from "@calcom/platform-libraries/organizations";
import { Injectable } from "@nestjs/common";
import { Logger } from "@/lib/logger.bridge";
import { PlatformBillingSyncTaskerService } from "@/lib/services/tasker/platform-billing-sync-tasker.service";
import { PlatformBillingTriggerTaskerService } from "@/lib/services/tasker/platform-billing-trigger-tasker.service";
@Injectable()
export class PlatformBillingTasker extends BasePlatformOrganizationBillingTasker {
constructor(
syncTasker: PlatformBillingSyncTaskerService,
asyncTasker: PlatformBillingTriggerTaskerService,
logger: Logger
) {
super({
logger,
asyncTasker: asyncTasker,
syncTasker: syncTasker,
});
}
}
@@ -0,0 +1,13 @@
import { Logger } from "@/lib/logger.bridge";
import { Injectable } from "@nestjs/common";
import { PlatformOrganizationBillingTriggerTasker as BasePlatformOrganizationBillingTriggerTasker } from "@calcom/platform-libraries/organizations";
@Injectable()
export class PlatformBillingTriggerTaskerService extends BasePlatformOrganizationBillingTriggerTasker {
constructor(logger: Logger) {
super({
logger,
});
}
}
@@ -1,11 +1,14 @@
import { BullModule } from "@nestjs/bull";
import { Module } from "@nestjs/common";
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
import { PlatformBillingTaskerModule } from "@/lib/modules/platform-billing-tasker.module";
import { BillingProcessor } from "@/modules/billing/billing.processor";
import { BillingRepository } from "@/modules/billing/billing.repository";
import { BillingController } from "@/modules/billing/controllers/billing.controller";
import { IsUserInBillingOrg } from "@/modules/billing/guards/is-user-in-billing-org";
import { BillingServiceCachingProxy } from "@/modules/billing/services/billing-service-caching-proxy";
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
import { BillingService } from "@/modules/billing/services/billing.service";
import { BillingServiceCachingProxy } from "@/modules/billing/services/billing-service-caching-proxy";
import { ManagedOrganizationsBillingService } from "@/modules/billing/services/managed-organizations.billing.service";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
@@ -14,8 +17,6 @@ import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { UsersModule } from "@/modules/users/users.module";
import { BullModule } from "@nestjs/bull";
import { Module } from "@nestjs/common";
@Module({
imports: [
@@ -32,6 +33,7 @@ import { Module } from "@nestjs/common";
},
}),
UsersModule,
PlatformBillingTaskerModule,
],
providers: [
BillingConfigService,
@@ -7,7 +7,7 @@ import { PlatformBilling, Team } from "@calcom/prisma/client";
export type BillingData = {
team: (Team & { platformBilling: PlatformBilling | null }) | null;
status: "valid" | "no_subscription" | "no_billing";
plan: string;
plan: PlatformPlan | "none";
};
export interface IBillingService {
@@ -1,14 +1,3 @@
import { AppConfig } from "@/config/type";
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
import { BILLING_QUEUE, INCREMENT_JOB, IncrementJobDataType } from "@/modules/billing/billing.processor";
import { BillingRepository } from "@/modules/billing/billing.repository";
import { IBillingService } from "@/modules/billing/interfaces/billing-service.interface";
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
import { PlatformPlan } from "@/modules/billing/types";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { StripeService } from "@/modules/stripe/stripe.service";
import { UsersRepository } from "@/modules/users/users.repository";
import { InjectQueue } from "@nestjs/bull";
import {
BadRequestException,
@@ -22,6 +11,16 @@ import { ConfigService } from "@nestjs/config";
import { Queue } from "bull";
import { DateTime } from "luxon";
import Stripe from "stripe";
import { AppConfig } from "@/config/type";
import { PlatformBillingTasker } from "@/lib/services/tasker/platform-billing-tasker.service";
import { BILLING_QUEUE, INCREMENT_JOB, IncrementJobDataType } from "@/modules/billing/billing.processor";
import { BillingRepository } from "@/modules/billing/billing.repository";
import { BillingData, IBillingService } from "@/modules/billing/interfaces/billing-service.interface";
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
import { PlatformPlan } from "@/modules/billing/types";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { StripeService } from "@/modules/stripe/stripe.service";
import { UsersRepository } from "@/modules/users/users.repository";
@Injectable()
export class BillingService implements IBillingService, OnModuleDestroy {
@@ -35,14 +34,13 @@ export class BillingService implements IBillingService, OnModuleDestroy {
private readonly configService: ConfigService<AppConfig>,
private readonly billingConfigService: BillingConfigService,
private readonly usersRepository: UsersRepository,
private readonly oAuthClientRepository: OAuthClientRepository,
private readonly bookingsRepository: BookingsRepository_2024_08_13,
private readonly platformBillingTasker: PlatformBillingTasker,
@InjectQueue(BILLING_QUEUE) private readonly billingQueue: Queue
) {
this.webAppUrl = this.configService.get("app.baseUrl", { infer: true }) ?? "https://app.cal.com";
}
async getBillingData(teamId: number) {
async getBillingData(teamId: number): Promise<BillingData> {
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
if (teamWithBilling?.platformBilling) {
@@ -52,7 +50,7 @@ export class BillingService implements IBillingService, OnModuleDestroy {
return {
team: teamWithBilling,
status: "valid" as const,
plan: teamWithBilling.platformBilling.plan,
plan: teamWithBilling.platformBilling.plan as PlatformPlan,
};
}
} else {
@@ -62,7 +60,7 @@ export class BillingService implements IBillingService, OnModuleDestroy {
async createTeamBilling(teamId: number): Promise<string> {
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
let customerId = teamWithBilling?.platformBilling?.customerId;
let customerId = teamWithBilling?.platformBilling?.customerId ?? "";
if (!teamWithBilling?.platformBilling) {
customerId = await this.teamsRepository.createNewBillingRelation(teamId);
@@ -73,10 +71,14 @@ export class BillingService implements IBillingService, OnModuleDestroy {
});
}
return customerId!;
return customerId;
}
async redirectToSubscribeCheckout(teamId: number, plan: PlatformPlan, customerId?: string) {
async redirectToSubscribeCheckout(
teamId: number,
plan: PlatformPlan,
customerId?: string
): Promise<string> {
const { url } = await this.stripeService.getStripe().checkout.sessions.create({
customer: customerId,
line_items: [
@@ -109,7 +111,7 @@ export class BillingService implements IBillingService, OnModuleDestroy {
return url;
}
async updateSubscriptionForTeam(teamId: number, plan: PlatformPlan) {
async updateSubscriptionForTeam(teamId: number, plan: PlatformPlan): Promise<string> {
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
const customerId = teamWithBilling?.platformBilling?.customerId;
@@ -134,7 +136,11 @@ export class BillingService implements IBillingService, OnModuleDestroy {
return url;
}
async setPerBookingSubscriptionForTeam(teamId: number, subscriptionId: string, plan: PlatformPlan) {
async setPerBookingSubscriptionForTeam(
teamId: number,
subscriptionId: string,
plan: PlatformPlan
): Promise<ReturnType<typeof BillingRepository.prototype.updateTeamBilling>> {
const billingCycleStart = DateTime.now().get("day");
const billingCycleEnd = DateTime.now().plus({ month: 1 }).get("day");
@@ -152,7 +158,7 @@ export class BillingService implements IBillingService, OnModuleDestroy {
subscriptionId: string,
plan: PlatformPlan,
priceId: string
) {
): Promise<ReturnType<typeof BillingRepository.prototype.updateTeamBilling>> {
const billingCycleStart = DateTime.now().get("day");
const billingCycleEnd = DateTime.now().plus({ month: 1 }).get("day");
@@ -166,12 +172,12 @@ export class BillingService implements IBillingService, OnModuleDestroy {
);
}
async handleStripeSubscriptionDeleted(event: Stripe.Event) {
async handleStripeSubscriptionDeleted(event: Stripe.Event): Promise<void> {
const subscription = event.data.object as Stripe.Subscription;
const teamId = subscription?.metadata?.teamId;
const plan = PlatformPlan[subscription?.metadata?.plan?.toUpperCase() as keyof typeof PlatformPlan];
if (teamId && plan) {
const currentBilling = await this.billingRepository.getBillingForTeam(Number.parseInt(teamId));
const currentBilling = await this.billingRepository.getBillingForTeam(Number.parseInt(teamId, 10));
if (currentBilling?.subscriptionId === subscription.id) {
await this.billingRepository.deleteBilling(currentBilling.id);
this.logger.log(`Stripe Subscription deleted`, {
@@ -207,7 +213,7 @@ export class BillingService implements IBillingService, OnModuleDestroy {
}
}
async handleStripePaymentSuccess(event: Stripe.Event) {
async handleStripePaymentSuccess(event: Stripe.Event): Promise<void> {
const invoice = event.data.object as Stripe.Invoice;
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
const customerId = this.getCustomerIdFromInvoice(invoice);
@@ -216,7 +222,7 @@ export class BillingService implements IBillingService, OnModuleDestroy {
}
}
async handleStripePaymentFailed(event: Stripe.Event) {
async handleStripePaymentFailed(event: Stripe.Event): Promise<void> {
const invoice = event.data.object as Stripe.Invoice;
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
const customerId = this.getCustomerIdFromInvoice(invoice);
@@ -225,7 +231,7 @@ export class BillingService implements IBillingService, OnModuleDestroy {
}
}
async handleStripePaymentPastDue(event: Stripe.Event) {
async handleStripePaymentPastDue(event: Stripe.Event): Promise<void> {
const invoice = event.data.object as Stripe.Invoice;
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
const customerId = this.getCustomerIdFromInvoice(invoice);
@@ -249,14 +255,14 @@ export class BillingService implements IBillingService, OnModuleDestroy {
}
}
async handleStripeCheckoutEvents(event: Stripe.Event) {
async handleStripeCheckoutEvents(event: Stripe.Event): Promise<void> {
const checkoutSession = event.data.object as Stripe.Checkout.Session;
if (!checkoutSession.metadata?.teamId) {
return;
}
const teamId = Number.parseInt(checkoutSession.metadata.teamId);
const teamId = Number.parseInt(checkoutSession.metadata.teamId, 10);
const plan = checkoutSession.metadata.plan;
if (!plan || !teamId) {
this.logger.log("Webhook received but not pertaining to Platform, discarding.");
@@ -288,7 +294,7 @@ export class BillingService implements IBillingService, OnModuleDestroy {
return;
}
async handleStripeSubscriptionForActiveManagedUsers(event: Stripe.Event) {
async handleStripeSubscriptionForActiveManagedUsers(event: Stripe.Event): Promise<void> {
const invoice = event.data.object as Stripe.Invoice;
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
@@ -299,12 +305,16 @@ export class BillingService implements IBillingService, OnModuleDestroy {
const teamWithBilling = await this.billingRepository.getBillingForTeamBySubscriptionId(subscriptionId);
if (teamWithBilling?.plan === "PER_ACTIVE_USER") {
const activeManagedUsersCount = await this.getActiveManagedUsersCount(
let activeManagedUsersCount = await this.getActiveManagedUsersCount(
subscriptionId,
new Date(invoice.period_start * 1000),
new Date(invoice.period_end * 1000)
);
if (activeManagedUsersCount < 0) {
activeManagedUsersCount = 1;
}
const existingSubscription = await this.stripeService
.getStripe()
.subscriptions.retrieve(subscriptionId);
@@ -321,17 +331,18 @@ export class BillingService implements IBillingService, OnModuleDestroy {
}
await this.stripeService.getStripe().subscriptions.update(subscriptionId, {
items: [
{ id: subscriptionItem.id, quantity: activeManagedUsersCount > 0 ? activeManagedUsersCount : 1 },
],
items: [{ id: subscriptionItem.id, quantity: activeManagedUsersCount }],
});
}
}
async getActiveManagedUsersCount(subscriptionId: string, invoiceStart: Date, invoiceEnd: Date) {
const managedUsersEmails = await this.usersRepository.getOrgsManagedUserEmailsBySubscriptionId(
subscriptionId
);
async getActiveManagedUsersCount(
subscriptionId: string,
invoiceStart: Date,
invoiceEnd: Date
): Promise<number> {
const managedUsersEmails =
await this.usersRepository.getOrgsManagedUserEmailsBySubscriptionId(subscriptionId);
if (!managedUsersEmails) return 0;
@@ -362,7 +373,7 @@ export class BillingService implements IBillingService, OnModuleDestroy {
return activeManagedUserEmailsAsAttendee.length + activeManagedUserEmailsAsHost.length;
}
async updateStripeSubscriptionForTeam(teamId: number, plan: PlatformPlan) {
async updateStripeSubscriptionForTeam(teamId: number, plan: PlatformPlan): Promise<void> {
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
if (!teamWithBilling?.platformBilling || !teamWithBilling?.platformBilling.subscriptionId) {
@@ -424,13 +435,37 @@ export class BillingService implements IBillingService, OnModuleDestroy {
startTime: Date;
fromReschedule?: string | null;
}
) {
): Promise<boolean | undefined> {
if (this.configService.get("e2e")) {
return true;
}
const { uid, startTime, fromReschedule } = booking;
const delay = startTime.getTime() - Date.now();
if (this.configService.get("enableAsyncTasker")) {
if (fromReschedule) {
this.platformBillingTasker.rescheduleUsageIncrement({
payload: { bookingUid: uid },
options: { delay: startTime },
});
return true;
}
this.platformBillingTasker.incrementUsage({
payload: { userId },
options: {
delay: startTime,
tags: [`platform.billing.usage.${uid}`],
idempotencyKey: `platform.billing.usage.${uid}-${userId}`,
},
});
return true;
}
let delay = startTime.getTime() - Date.now();
if (delay < 0) {
delay = 0;
}
if (fromReschedule) {
// cancel the usage increment job for the booking that is being rescheduled
await this.cancelUsageByBookingUid(fromReschedule);
@@ -441,7 +476,7 @@ export class BillingService implements IBillingService, OnModuleDestroy {
{
userId,
} satisfies IncrementJobDataType,
{ delay: delay > 0 ? delay : 0, jobId: `increment-${uid}`, removeOnComplete: true }
{ delay: delay, jobId: `increment-${uid}`, removeOnComplete: true }
);
this.logger.log(`Added stripe usage increment job for booking ${uid} and user ${userId}`);
}
@@ -451,10 +486,16 @@ export class BillingService implements IBillingService, OnModuleDestroy {
* Cancels the usage increment job for a booking when it is cancelled.
* Removing an attendee from a booking does not cancel the usage increment job.
*/
async cancelUsageByBookingUid(bookingUid: string) {
async cancelUsageByBookingUid(bookingUid: string): Promise<boolean | undefined> {
if (this.configService.get("e2e")) {
return true;
}
if (this.configService.get("enableAsyncTasker")) {
await this.platformBillingTasker.cancelUsageIncrement({ payload: { bookingUid } });
return true;
}
const job = await this.billingQueue.getJob(`increment-${bookingUid}`);
if (job) {
await job.remove();
@@ -462,7 +503,7 @@ export class BillingService implements IBillingService, OnModuleDestroy {
}
}
async cancelTeamSubscription(teamId: number) {
async cancelTeamSubscription(teamId: number): Promise<void> {
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
const customerId = teamWithBilling?.platformBilling?.customerId;
@@ -484,7 +525,7 @@ export class BillingService implements IBillingService, OnModuleDestroy {
}
}
async onModuleDestroy() {
async onModuleDestroy(): Promise<void> {
try {
await this.billingQueue.close();
} catch (err) {
@@ -1,9 +1,11 @@
import { Logger, Module } from "@nestjs/common";
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { OrganizationsEventTypesPrivateLinksController } from "@/ee/event-types-private-links/controllers/organizations-event-types-private-links.controller";
import { EventTypesPrivateLinksModule } from "@/ee/event-types-private-links/event-types-private-links.module";
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
import { InputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/input-schedules.service";
import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service";
import { OrganizationMembershipService } from "@/lib/services/organization-membership.service";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
@@ -13,7 +15,6 @@ import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.ser
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { EmailModule } from "@/modules/email/email.module";
import { EmailService } from "@/modules/email/email.service";
import { OrganizationMembershipService } from "@/lib/services/organization-membership.service";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { UserOOORepository } from "@/modules/ooo/repositories/ooo.repository";
@@ -37,8 +38,8 @@ import { OrganizationsRepository } from "@/modules/organizations/index/organizat
import { OrganizationsService } from "@/modules/organizations/index/organizations.service";
import { OrganizationsMembershipsController } from "@/modules/organizations/memberships/organizations-membership.controller";
import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository";
import { OrganizationsMembershipOutputService } from "@/modules/organizations/memberships/services/organizations-membership-output.service";
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
import { OrganizationsMembershipOutputService } from "@/modules/organizations/memberships/services/organizations-membership-output.service";
import { OrganizationsOrganizationsModule } from "@/modules/organizations/organizations/organizations-organizations.module";
import { OrganizationsRolesModule } from "@/modules/organizations/roles/organizations-roles.module";
import { OrganizationsSchedulesController } from "@/modules/organizations/schedules/organizations-schedules.controller";
@@ -84,7 +85,6 @@ import { TeamRoutingFormWorkflowsService } from "@/modules/workflows/services/te
import { WorkflowsInputService } from "@/modules/workflows/services/workflows.input.service";
import { WorkflowsOutputService } from "@/modules/workflows/services/workflows.output.service";
import { WorkflowsRepository } from "@/modules/workflows/workflows.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [
@@ -162,6 +162,7 @@ import { Module } from "@nestjs/common";
TeamsMembershipsService,
TeamsMembershipsRepository,
OAuthClientRepository,
Logger,
],
exports: [
OrganizationsService,
@@ -184,6 +185,7 @@ import { Module } from "@nestjs/common";
OrganizationsEventTypesService,
OrganizationsConferencingService,
OrganizationsStripeService,
OrganizationMembershipService,
],
controllers: [
OrganizationsTeamsController,
@@ -202,4 +204,4 @@ import { Module } from "@nestjs/common";
OrganizationsEventTypesPrivateLinksController,
],
})
export class OrganizationsModule { }
export class OrganizationsModule {}
@@ -0,0 +1,27 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsNumber, IsOptional } from "class-validator";
import { SkipTakePagination } from "@calcom/platform-types";
export class GetTeamSchedulesQuery extends SkipTakePagination {
@ApiPropertyOptional({
description: "Filter schedules by event type ID",
example: 1,
})
@Transform(({ value }: { value: string }) => value && parseInt(value))
@IsNumber()
@IsOptional()
eventTypeId?: number;
}
export class GetUserSchedulesQuery {
@ApiPropertyOptional({
description: "Filter schedules by event type ID",
example: 1,
})
@Transform(({ value }: { value: string }) => value && parseInt(value))
@IsNumber()
@IsOptional()
eventTypeId?: number;
}
@@ -14,12 +14,16 @@ import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { IsUserInOrgTeam } from "@/modules/auth/guards/users/is-user-in-org-team.guard";
import {
GetTeamSchedulesQuery,
GetUserSchedulesQuery,
} from "@/modules/organizations/teams/schedules/inputs/teams-schedules.input";
import { TeamsSchedulesService } from "@/modules/teams/schedules/services/teams-schedules.service";
import { Controller, UseGuards, Get, Param, ParseIntPipe, Query } from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { GetSchedulesOutput_2024_06_11, SkipTakePagination } from "@calcom/platform-types";
import { GetSchedulesOutput_2024_06_11 } from "@calcom/platform-types";
@Controller({
path: "/v2/organizations/:orgId/teams/:teamId",
@@ -43,14 +47,14 @@ export class OrganizationsTeamsSchedulesController {
@DocsTags("Orgs / Teams / Schedules")
@ApiOperation({ summary: "Get all team member schedules" })
async getTeamSchedules(
// note(Lauirs): putting orgId so swagger is generacted correctly
// note(Lauris): putting orgId so swagger is generated correctly
@Param("orgId", ParseIntPipe) _orgId: number,
@Param("teamId", ParseIntPipe) teamId: number,
@Query() queryParams: SkipTakePagination
@Query() queryParams: GetTeamSchedulesQuery
): Promise<GetSchedulesOutput_2024_06_11> {
const { skip, take } = queryParams;
const { skip, take, eventTypeId } = queryParams;
const schedules = await this.teamsSchedulesService.getTeamSchedules(teamId, skip, take);
const schedules = await this.teamsSchedulesService.getTeamSchedules(teamId, skip, take, eventTypeId);
return {
status: SUCCESS_STATUS,
@@ -67,12 +71,14 @@ export class OrganizationsTeamsSchedulesController {
summary: "Get schedules of a team member",
})
async getUserSchedules(
// note(Lauirs): putting orgId and teamId so swagger is generacted correctly
// note(Lauris): putting orgId and teamId so swagger is generated correctly
@Param("orgId", ParseIntPipe) _orgId: number,
@Param("teamId", ParseIntPipe) _teamId: number,
@Param("userId", ParseIntPipe) userId: number
@Param("userId", ParseIntPipe) userId: number,
@Query() queryParams: GetUserSchedulesQuery
): Promise<GetSchedulesOutput_2024_06_11> {
const schedules = await this.schedulesService.getUserSchedules(userId);
const { eventTypeId } = queryParams;
const schedules = await this.schedulesService.getUserSchedules(userId, eventTypeId);
return {
status: SUCCESS_STATUS,
@@ -4,11 +4,13 @@ import type {
GetSchedulesOutput_2024_06_11,
ScheduleOutput_2024_06_11,
} from "@calcom/platform-types";
import type { Schedule, Team, User } from "@calcom/prisma/client";
import type { EventType, Schedule, 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 { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
@@ -19,6 +21,7 @@ import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
import { AppModule } from "@/app.module";
import { bootstrap } from "@/bootstrap";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
@@ -32,6 +35,9 @@ describe("Organizations Teams Schedules Endpoints", () => {
let teamsRepositoryFixture: TeamRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
let scheduleRepositoryFixture: SchedulesRepositoryFixture;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let hostsRepositoryFixture: HostsRepositoryFixture;
let prismaWriteService: PrismaWriteService;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
@@ -41,6 +47,11 @@ describe("Organizations Teams Schedules Endpoints", () => {
let userSchedule: Schedule;
let user2Schedule: Schedule;
let userEventTypeSchedule: Schedule;
let teamEventTypeSchedule: Schedule;
let userEventType: EventType;
let teamEventType: EventType;
const userEmail = `organizations-teams-schedules-admin-${randomString()}@api.com`;
const userEmail2 = `organizations-teams-schedules-member-${randomString()}@api.com`;
@@ -66,6 +77,9 @@ describe("Organizations Teams Schedules Endpoints", () => {
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
scheduleRepositoryFixture = new SchedulesRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef);
prismaWriteService = moduleRef.get(PrismaWriteService);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
@@ -97,6 +111,26 @@ describe("Organizations Teams Schedules Endpoints", () => {
timeZone: "America/New_York",
});
userEventTypeSchedule = await scheduleRepositoryFixture.create({
user: {
connect: {
id: user2.id,
},
},
name: `organizations-teams-schedules-user-event-type-schedule-${randomString()}`,
timeZone: "America/New_York",
});
teamEventTypeSchedule = await scheduleRepositoryFixture.create({
user: {
connect: {
id: user2.id,
},
},
name: `organizations-teams-schedules-team-event-type-schedule-${randomString()}`,
timeZone: "America/New_York",
});
nonOrgUser = await userRepositoryFixture.create({
email: nonOrgUserEmail,
username: nonOrgUserEmail,
@@ -204,6 +238,50 @@ describe("Organizations Teams Schedules Endpoints", () => {
},
});
userEventType = await eventTypesRepositoryFixture.create(
{
title: `User Event Type ${randomString()}`,
slug: `user-event-type-${randomString()}`,
length: 30,
schedule: {
connect: {
id: userEventTypeSchedule.id,
},
},
},
user2.id
);
teamEventType = await eventTypesRepositoryFixture.createTeamEventType({
title: `Team Event Type ${randomString()}`,
slug: `team-event-type-${randomString()}`,
length: 30,
team: {
connect: {
id: orgTeam.id,
},
},
});
await hostsRepositoryFixture.create({
user: {
connect: {
id: user2.id,
},
},
eventType: {
connect: {
id: teamEventType.id,
},
},
schedule: {
connect: {
id: teamEventTypeSchedule.id,
},
},
isFixed: false,
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
@@ -236,7 +314,8 @@ describe("Organizations Teams Schedules Endpoints", () => {
const responseBody: GetSchedulesOutput_2024_06_11 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(Array.isArray(responseBody.data)).toBe(true);
expect(responseBody.data.length).toEqual(2);
expect(responseBody.data.length).toBeGreaterThanOrEqual(2);
const userOneSchedule = responseBody.data.find((schedule) => schedule.id === userSchedule.id);
const userTwoSchedule = responseBody.data.find((schedule) => schedule.id === user2Schedule.id);
@@ -254,7 +333,173 @@ describe("Organizations Teams Schedules Endpoints", () => {
});
});
describe("eventTypeId query parameter", () => {
describe("GET /users/:userId/schedules", () => {
it("should filter user schedules by user-owned event type", async () => {
return request(app.getHttpServer())
.get(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/users/${user2.id}/schedules?eventTypeId=${userEventType.id}`
)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<ScheduleOutput_2024_06_11[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(Array.isArray(responseBody.data)).toBe(true);
expect(responseBody.data.length).toEqual(1);
expect(responseBody.data[0].id).toEqual(userEventTypeSchedule.id);
expect(responseBody.data[0].name).toEqual(userEventTypeSchedule.name);
});
});
it("should filter user schedules by team event type where user is a host", async () => {
return request(app.getHttpServer())
.get(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/users/${user2.id}/schedules?eventTypeId=${teamEventType.id}`
)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<ScheduleOutput_2024_06_11[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(Array.isArray(responseBody.data)).toBe(true);
expect(responseBody.data.length).toEqual(1);
expect(responseBody.data[0].id).toEqual(teamEventTypeSchedule.id);
expect(responseBody.data[0].name).toEqual(teamEventTypeSchedule.name);
});
});
it("should return 404 when event type does not exist", async () => {
return request(app.getHttpServer())
.get(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/users/${user2.id}/schedules?eventTypeId=999999`
)
.expect(404);
});
it("should return 404 when user is not associated with event type", async () => {
const otherUserEventType = await eventTypesRepositoryFixture.create(
{
title: `Other User Event Type ${randomString()}`,
slug: `other-user-event-type-${randomString()}`,
length: 30,
},
user.id
);
try {
await request(app.getHttpServer())
.get(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/users/${user2.id}/schedules?eventTypeId=${otherUserEventType.id}`
)
.expect(404);
} finally {
await eventTypesRepositoryFixture.delete(otherUserEventType.id);
}
});
it("should return empty array when event type has no schedule and user has no default schedule", async () => {
const eventTypeWithoutSchedule = await eventTypesRepositoryFixture.create(
{
title: `Event Type Without Schedule ${randomString()}`,
slug: `event-type-no-schedule-${randomString()}`,
length: 30,
},
user2.id
);
const user2Original = await prismaWriteService.prisma.user.findUnique({
where: { id: user2.id },
select: { defaultScheduleId: true },
});
const originalDefaultScheduleId = user2Original?.defaultScheduleId ?? null;
await prismaWriteService.prisma.user.update({
where: { id: user2.id },
data: { defaultScheduleId: null },
});
try {
const response = await request(app.getHttpServer())
.get(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/users/${user2.id}/schedules?eventTypeId=${eventTypeWithoutSchedule.id}`
)
.expect(200);
const responseBody: ApiSuccessResponse<ScheduleOutput_2024_06_11[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(Array.isArray(responseBody.data)).toBe(true);
expect(responseBody.data.length).toEqual(0);
} finally {
await prismaWriteService.prisma.user.update({
where: { id: user2.id },
data: { defaultScheduleId: originalDefaultScheduleId },
});
await eventTypesRepositoryFixture.delete(eventTypeWithoutSchedule.id);
}
});
});
describe("GET /schedules (team schedules)", () => {
it("should filter team schedules by event type", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/schedules?eventTypeId=${teamEventType.id}`)
.expect(200)
.then((response) => {
const responseBody: GetSchedulesOutput_2024_06_11 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(Array.isArray(responseBody.data)).toBe(true);
expect(responseBody.data.length).toBeGreaterThanOrEqual(1);
const user2ScheduleFound = responseBody.data.find(
(schedule) => schedule.id === teamEventTypeSchedule.id
);
expect(user2ScheduleFound).toBeDefined();
expect(user2ScheduleFound?.id).toEqual(teamEventTypeSchedule.id);
});
});
it("should return 404 when team event type does not exist", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/schedules?eventTypeId=999999`)
.expect(404);
});
it("should return empty array when event type has no hosts with schedules", async () => {
const teamEventTypeNoHosts = await eventTypesRepositoryFixture.createTeamEventType({
title: `Team Event Type No Hosts ${randomString()}`,
slug: `team-event-type-no-hosts-${randomString()}`,
length: 30,
team: {
connect: {
id: orgTeam.id,
},
},
});
try {
const response = await request(app.getHttpServer())
.get(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/schedules?eventTypeId=${teamEventTypeNoHosts.id}`
)
.expect(200);
const responseBody: GetSchedulesOutput_2024_06_11 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(Array.isArray(responseBody.data)).toBe(true);
expect(responseBody.data.length).toEqual(0);
} finally {
await eventTypesRepositoryFixture.delete(teamEventTypeNoHosts.id);
}
});
});
});
afterAll(async () => {
if (userEventType) {
await eventTypesRepositoryFixture.delete(userEventType.id);
}
if (teamEventType) {
await eventTypesRepositoryFixture.delete(teamEventType.id);
}
await userRepositoryFixture.deleteByEmail(user.email);
await userRepositoryFixture.deleteByEmail(userToInviteViaApi.email);
await userRepositoryFixture.deleteByEmail(nonOrgUser.email);
@@ -157,4 +157,28 @@ export class TeamsEventTypesRepository {
},
});
}
async getByIdIncludeHostsAndUserDefaultSchedule(eventTypeId: number, teamId: number) {
return this.dbRead.prisma.eventType.findUnique({
where: {
id: eventTypeId,
teamId,
},
select: {
id: true,
scheduleId: true,
hosts: {
select: {
scheduleId: true,
userId: true,
user: {
select: {
defaultScheduleId: true,
},
},
},
},
},
});
}
}
@@ -505,6 +505,226 @@ describe("Teams Memberships Endpoints", () => {
.expect(400);
});
// Auto-accept tests for sub-teams of organizations
describe("auto-accept based on email domain for org sub-teams", () => {
let orgWithAutoAccept: Team;
let subteamWithAutoAccept: Team;
let subteamEventType: EventType;
let userWithMatchingEmail: User;
let userWithUppercaseEmail: User;
let userWithMatchingEmailForOverride: User;
let userWithNonMatchingEmail: User;
beforeAll(async () => {
// Create org with auto-accept settings
orgWithAutoAccept = await teamsRepositoryFixture.create({
name: `auto-accept-org-${randomString()}`,
isOrganization: true,
});
// Create organization settings with orgAutoAcceptEmail
await teamsRepositoryFixture.createOrgSettings(orgWithAutoAccept.id, {
orgAutoAcceptEmail: "acme.com",
isOrganizationVerified: true,
isOrganizationConfigured: true,
isAdminAPIEnabled: true,
});
// Create subteam
subteamWithAutoAccept = await teamsRepositoryFixture.create({
name: `auto-accept-subteam-${randomString()}`,
isOrganization: false,
parent: { connect: { id: orgWithAutoAccept.id } },
});
// Create event type with assignAllTeamMembers
subteamEventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: { connect: { id: subteamWithAutoAccept.id } },
title: "Auto Accept Event Type",
slug: "auto-accept-event-type",
length: 30,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
// Create users with different email domains
userWithMatchingEmail = await userRepositoryFixture.create({
email: `alice-${randomString()}@acme.com`,
username: `alice-${randomString()}`,
});
userWithUppercaseEmail = await userRepositoryFixture.create({
email: `bob-${randomString()}@ACME.COM`,
username: `bob-${randomString()}`,
});
userWithMatchingEmailForOverride = await userRepositoryFixture.create({
email: `david-${randomString()}@acme.com`,
username: `david-${randomString()}`,
});
userWithNonMatchingEmail = await userRepositoryFixture.create({
email: `charlie-${randomString()}@external.com`,
username: `charlie-${randomString()}`,
});
// Add users to org
await membershipsRepositoryFixture.create({
role: "MEMBER",
accepted: true,
user: { connect: { id: userWithMatchingEmail.id } },
team: { connect: { id: orgWithAutoAccept.id } },
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
accepted: true,
user: { connect: { id: userWithUppercaseEmail.id } },
team: { connect: { id: orgWithAutoAccept.id } },
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
accepted: true,
user: { connect: { id: userWithMatchingEmailForOverride.id } },
team: { connect: { id: orgWithAutoAccept.id } },
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
accepted: true,
user: { connect: { id: userWithNonMatchingEmail.id } },
team: { connect: { id: orgWithAutoAccept.id } },
});
// Create profiles for users
await profileRepositoryFixture.create({
uid: `usr-${userWithMatchingEmail.id}`,
username: userWithMatchingEmail.username || `user-${userWithMatchingEmail.id}`,
organization: { connect: { id: orgWithAutoAccept.id } },
user: { connect: { id: userWithMatchingEmail.id } },
});
await profileRepositoryFixture.create({
uid: `usr-${userWithUppercaseEmail.id}`,
username: userWithUppercaseEmail.username || `user-${userWithUppercaseEmail.id}`,
organization: { connect: { id: orgWithAutoAccept.id } },
user: { connect: { id: userWithUppercaseEmail.id } },
});
await profileRepositoryFixture.create({
uid: `usr-${userWithMatchingEmailForOverride.id}`,
username:
userWithMatchingEmailForOverride.username || `user-${userWithMatchingEmailForOverride.id}`,
organization: { connect: { id: orgWithAutoAccept.id } },
user: { connect: { id: userWithMatchingEmailForOverride.id } },
});
await profileRepositoryFixture.create({
uid: `usr-${userWithNonMatchingEmail.id}`,
username: userWithNonMatchingEmail.username || `user-${userWithNonMatchingEmail.id}`,
organization: { connect: { id: orgWithAutoAccept.id } },
user: { connect: { id: userWithNonMatchingEmail.id } },
});
// Make teamAdmin an admin of the org and subteam for API access
await membershipsRepositoryFixture.create({
role: "ADMIN",
accepted: true,
user: { connect: { id: teamAdmin.id } },
team: { connect: { id: orgWithAutoAccept.id } },
});
await membershipsRepositoryFixture.create({
role: "ADMIN",
accepted: true,
user: { connect: { id: teamAdmin.id } },
team: { connect: { id: subteamWithAutoAccept.id } },
});
await profileRepositoryFixture.create({
uid: `usr-org-${teamAdmin.id}`,
username: teamAdmin.username || `admin-${teamAdmin.id}`,
organization: { connect: { id: orgWithAutoAccept.id } },
user: { connect: { id: teamAdmin.id } },
});
});
it("should auto-accept when email matches orgAutoAcceptEmail for sub-team", async () => {
const response = await request(app.getHttpServer())
.post(`/v2/teams/${subteamWithAutoAccept.id}/memberships`)
.send({
userId: userWithMatchingEmail.id,
role: "MEMBER",
} satisfies CreateTeamMembershipInput)
.expect(201);
const responseBody: CreateTeamMembershipOutput = response.body;
expect(responseBody.data.accepted).toBe(true);
// Verify EventTypes assignment
const eventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(subteamWithAutoAccept.id);
const eventTypeWithAssignAll = eventTypes.find((et) => et.assignAllTeamMembers);
expect(eventTypeWithAssignAll).toBeTruthy();
const userIsHost = eventTypeWithAssignAll?.hosts.some((h) => h.userId === userWithMatchingEmail.id);
expect(userIsHost).toBe(true);
});
it("should handle case-insensitive email domain matching for sub-team", async () => {
// User with email="bob@ACME.COM" should match orgAutoAcceptEmail="acme.com"
const response = await request(app.getHttpServer())
.post(`/v2/teams/${subteamWithAutoAccept.id}/memberships`)
.send({
userId: userWithUppercaseEmail.id,
role: "MEMBER",
} satisfies CreateTeamMembershipInput)
.expect(201);
const responseBody: CreateTeamMembershipOutput = response.body;
expect(responseBody.data.accepted).toBe(true);
});
it("should ALWAYS auto-accept when email matches, even if accepted:false for sub-team", async () => {
const response = await request(app.getHttpServer())
.post(`/v2/teams/${subteamWithAutoAccept.id}/memberships`)
.send({
userId: userWithMatchingEmailForOverride.id,
role: "MEMBER",
accepted: false,
} satisfies CreateTeamMembershipInput)
.expect(201);
const responseBody: CreateTeamMembershipOutput = response.body;
// Should override to true because email matches
expect(responseBody.data.accepted).toBe(true);
});
it("should NOT auto-accept when email does not match orgAutoAcceptEmail for sub-team", async () => {
const response = await request(app.getHttpServer())
.post(`/v2/teams/${subteamWithAutoAccept.id}/memberships`)
.send({
userId: userWithNonMatchingEmail.id,
role: "MEMBER",
} satisfies CreateTeamMembershipInput)
.expect(201);
const responseBody: CreateTeamMembershipOutput = response.body;
expect(responseBody.data.accepted).toBe(false);
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(userWithMatchingEmail.email);
await userRepositoryFixture.deleteByEmail(userWithUppercaseEmail.email);
await userRepositoryFixture.deleteByEmail(userWithMatchingEmailForOverride.email);
await userRepositoryFixture.deleteByEmail(userWithNonMatchingEmail.email);
await teamsRepositoryFixture.deleteOrgSettings(orgWithAutoAccept.id);
await teamsRepositoryFixture.delete(subteamWithAutoAccept.id);
await teamsRepositoryFixture.delete(orgWithAutoAccept.id);
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(teamAdmin.email);
await userRepositoryFixture.deleteByEmail(teammateInvitedViaApi.email);
@@ -1,3 +1,22 @@
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { updateNewTeamMemberEventTypes } from "@calcom/platform-libraries/event-types";
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Logger,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UseGuards,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { API_KEY_HEADER } from "@/lib/docs/headers";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
@@ -13,26 +32,6 @@ import { GetTeamMembershipsOutput } from "@/modules/teams/memberships/outputs/ge
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { UpdateTeamMembershipOutput } from "@/modules/teams/memberships/outputs/update-team-membership.output";
import { TeamsMembershipsService } from "@/modules/teams/memberships/services/teams-memberships.service";
import {
Controller,
UseGuards,
Get,
Param,
ParseIntPipe,
Query,
Delete,
Patch,
Post,
Body,
HttpCode,
HttpStatus,
Logger,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { updateNewTeamMemberEventTypes } from "@calcom/platform-libraries/event-types";
@Controller({
path: "/v2/teams/:teamId/memberships",
@@ -55,13 +54,6 @@ export class TeamsMembershipsController {
@Body() body: CreateTeamMembershipInput
): Promise<CreateTeamMembershipOutput> {
const membership = await this.teamsMembershipsService.createTeamMembership(teamId, body);
if (membership.accepted) {
try {
await updateNewTeamMemberEventTypes(body.userId, teamId);
} catch (err) {
this.logger.error("Could not update new team member eventTypes", err);
}
}
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
@@ -1,10 +1,13 @@
import { TeamService } from "@calcom/platform-libraries";
import { updateNewTeamMemberEventTypes } from "@calcom/platform-libraries/event-types";
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { OrganizationMembershipService } from "@/lib/services/organization-membership.service";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/create-team-membership.input";
import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input";
import { TeamsMembershipsRepository } from "@/modules/teams/memberships/teams-memberships.repository";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { TeamService } from "@calcom/platform-libraries";
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
import { UsersRepository } from "@/modules/users/users.repository";
export const PLATFORM_USER_BEING_ADDED_TO_REGULAR_TEAM_ERROR = `Can't add user to team - the user is platform managed user but team is not because team probably was not created using OAuth credentials.`;
export const REGULAR_USER_BEING_ADDED_TO_PLATFORM_TEAM_ERROR = `Can't add user to team - the user is not platform managed user but team is platform managed. Both have to be created using OAuth credentials.`;
@@ -14,11 +17,44 @@ export const PLATFORM_USER_AND_PLATFORM_TEAM_CREATED_WITH_DIFFERENT_OAUTH_CLIENT
export class TeamsMembershipsService {
constructor(
private readonly teamsMembershipsRepository: TeamsMembershipsRepository,
private readonly oAuthClientsRepository: OAuthClientRepository
private readonly oAuthClientsRepository: OAuthClientRepository,
private readonly teamsRepository: TeamsRepository,
private readonly usersRepository: UsersRepository,
private readonly orgMembershipService: OrganizationMembershipService,
private readonly logger: Logger
) {}
private async shouldAutoAccept({ teamId, userId }: { teamId: number; userId: number }): Promise<boolean> {
const team = await this.teamsRepository.getById(teamId);
if (team?.parentId) {
const user = await this.usersRepository.findById(userId);
if (user) {
const shouldAutoAccept = await this.orgMembershipService.shouldAutoAccept({
organizationId: team.parentId,
userEmail: user.email,
});
return shouldAutoAccept;
}
}
return false;
}
async createTeamMembership(teamId: number, data: CreateTeamMembershipInput) {
await this.canUserBeAddedToTeam(data.userId, teamId);
const shouldAutoAccept = await this.shouldAutoAccept({ teamId, userId: data.userId });
if (shouldAutoAccept) {
data = { ...data, accepted: true };
}
if (data.accepted) {
try {
await updateNewTeamMemberEventTypes(data.userId, teamId);
} catch (err) {
this.logger.error("Could not update new team member eventTypes", err);
}
}
const teamMembership = await this.teamsMembershipsRepository.createTeamMembership(teamId, data);
return teamMembership;
}
@@ -6,11 +6,21 @@ import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-t
import { TeamsMembershipsController } from "@/modules/teams/memberships/controllers/teams-memberships.controller";
import { TeamsMembershipsService } from "@/modules/teams/memberships/services/teams-memberships.service";
import { TeamsMembershipsRepository } from "@/modules/teams/memberships/teams-memberships.repository";
import { Module } from "@nestjs/common";
import { TeamsModule } from "@/modules/teams/teams/teams.module";
import { UsersModule } from "@/modules/users/users.module";
import { Logger, Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, RedisModule, OrganizationsModule, MembershipsModule, TeamsEventTypesModule],
providers: [TeamsMembershipsRepository, TeamsMembershipsService],
imports: [
PrismaModule,
RedisModule,
OrganizationsModule,
MembershipsModule,
TeamsEventTypesModule,
TeamsModule,
UsersModule,
],
providers: [TeamsMembershipsRepository, TeamsMembershipsService, Logger],
controllers: [TeamsMembershipsController],
exports: [TeamsMembershipsService],
})
@@ -1,20 +1,73 @@
import type { ScheduleOutput_2024_06_11 } from "@calcom/platform-types";
import type { Availability, Schedule } from "@calcom/prisma/client";
import { Injectable, NotFoundException } from "@nestjs/common";
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { OutputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/output-schedules.service";
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
import { Injectable } from "@nestjs/common";
@Injectable()
export class TeamsSchedulesService {
constructor(
private readonly teamsRepository: TeamsRepository,
private readonly schedulesRepository: SchedulesRepository_2024_06_11,
private readonly outputSchedulesService: OutputSchedulesService_2024_06_11
private readonly outputSchedulesService: OutputSchedulesService_2024_06_11,
private readonly teamsEventTypesRepository: TeamsEventTypesRepository
) {}
async getTeamSchedules(teamId: number, skip = 0, take = 250) {
const userIds = await this.teamsRepository.getTeamUsersIds(teamId);
const schedules = await this.schedulesRepository.getSchedulesByUserIds(userIds, skip, take);
async getTeamSchedules(
teamId: number,
skip = 0,
take = 250,
eventTypeId?: number
): Promise<ScheduleOutput_2024_06_11[]> {
if (!eventTypeId) {
const userIds = await this.teamsRepository.getTeamUsersIds(teamId);
const schedules = await this.schedulesRepository.getSchedulesByUserIds(userIds, skip, take);
return this.outputSchedulesService.getResponseSchedules(schedules);
return this.outputSchedulesService.getResponseSchedules(schedules);
}
return this.getTeamSchedulesForEventType(teamId, eventTypeId, skip, take);
}
private async getTeamSchedulesForEventType(
teamId: number,
eventTypeId: number,
skip: number,
take: number
): Promise<ScheduleOutput_2024_06_11[]> {
const eventType = await this.teamsEventTypesRepository.getByIdIncludeHostsAndUserDefaultSchedule(
eventTypeId,
teamId
);
if (!eventType) {
throw new NotFoundException(`Event type with id ${eventTypeId} not found in team ${teamId}`);
}
const effectiveScheduleIds = new Set<number>();
for (const host of eventType.hosts) {
const effectiveScheduleId = eventType.scheduleId ?? host.scheduleId ?? host.user.defaultScheduleId;
if (effectiveScheduleId) {
effectiveScheduleIds.add(effectiveScheduleId);
}
}
if (effectiveScheduleIds.size === 0) {
return [];
}
const schedules = await this.schedulesRepository.getSchedulesByIds(
Array.from(effectiveScheduleIds),
skip,
take
);
return this.outputSchedulesService.getResponseSchedules(
schedules as (Schedule & { availability: Availability[] })[]
);
}
}
@@ -8,8 +8,8 @@ import { OrganizationSchedulesRepository } from "@/modules/organizations/schedul
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
import { StripeService } from "@/modules/stripe/stripe.service";
import { TeamsMembershipsService } from "@/modules/teams/memberships/services/teams-memberships.service";
import { TeamsMembershipsRepository } from "@/modules/teams/memberships/teams-memberships.repository";
import { TeamsSchedulesController } from "@/modules/teams/schedules/controllers/teams-schedules.controller";
import { TeamsSchedulesService } from "@/modules/teams/schedules/services/teams-schedules.service";
@@ -20,12 +20,11 @@ import { UsersRepository } from "@/modules/users/users.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, MembershipsModule, RedisModule],
imports: [PrismaModule, MembershipsModule, RedisModule, TeamsEventTypesModule],
providers: [
TeamsRepository,
TeamsService,
TeamsMembershipsRepository,
TeamsMembershipsService,
OutputSchedulesService_2024_06_11,
OrganizationSchedulesRepository,
StripeService,
@@ -2,7 +2,6 @@ import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { TeamsMembershipsService } from "@/modules/teams/memberships/services/teams-memberships.service";
import { TeamsMembershipsRepository } from "@/modules/teams/memberships/teams-memberships.repository";
import { TeamsController } from "@/modules/teams/teams/controllers/teams.controller";
import { TeamsService } from "@/modules/teams/teams/services/teams.service";
@@ -11,7 +10,7 @@ import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, MembershipsModule, RedisModule, StripeModule],
providers: [TeamsRepository, TeamsService, TeamsMembershipsRepository, TeamsMembershipsService],
providers: [TeamsRepository, TeamsService, TeamsMembershipsRepository],
controllers: [TeamsController],
exports: [TeamsRepository],
})
@@ -33,4 +33,30 @@ export class TeamRepositoryFixture {
},
});
}
async createOrgSettings(
organizationId: number,
settings: {
orgAutoAcceptEmail: string;
isOrganizationVerified?: boolean;
isOrganizationConfigured?: boolean;
isAdminAPIEnabled?: boolean;
}
) {
return this.prismaWriteClient.organizationSettings.create({
data: {
organizationId,
orgAutoAcceptEmail: settings.orgAutoAcceptEmail,
isOrganizationVerified: settings.isOrganizationVerified,
isOrganizationConfigured: settings.isOrganizationConfigured,
isAdminAPIEnabled: settings.isAdminAPIEnabled,
},
});
}
async deleteOrgSettings(organizationId: number) {
return this.prismaWriteClient.organizationSettings.deleteMany({
where: { organizationId },
});
}
}
@@ -17,11 +17,14 @@ export function ShellMainAppDir(props: LayoutProps) {
{!!props.backPath && <ShellMainAppDirBackButton backPath={props.backPath} />}
{props.heading && (
<header
className={classNames(props.large && "py-8", "flex w-full max-w-full items-center truncate")}>
className={classNames(
props.large && "py-8",
"flex w-full max-w-full items-center flex-wrap md:flex-nowrap gap-2 md:gap-0"
)}>
{props.HeadingLeftIcon && <div className="ltr:mr-4">{props.HeadingLeftIcon}</div>}
<div
className={classNames(
"hidden w-full truncate ltr:mr-4 rtl:ml-4 md:block",
"hidden min-w-0 flex-1 ltr:mr-4 rtl:ml-4 md:block",
props.headerClassName
)}>
{props.heading && (
@@ -22,6 +22,7 @@ import { HOSTED_CAL_FEATURES, IS_CALCOM, WEBAPP_URL } from "@calcom/lib/constant
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useIsStandalone } from "@calcom/lib/hooks/useIsStandalone";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { IdentityProvider, UserPermissionRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
@@ -1012,6 +1013,9 @@ const SettingsSidebarContainer = ({
const MobileSettingsContainer = (props: { onSideContainerOpen?: () => void }) => {
const { t } = useLocale();
const router = useRouter();
const isStandalone = useIsStandalone();
if (isStandalone) return null;
return (
<>
@@ -1,39 +0,0 @@
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import LicenseRequired from "~/ee/common/components/LicenseRequired";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import LegacyPage, { LayoutWrapper } from "~/settings/platform/new/create-new-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("set_up_your_platform_organization"),
(t) => t("platform_organization_description"),
undefined,
undefined,
"/settings/platform/new"
);
const ServerPage = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const callbackUrl = `${WEBAPP_URL}/settings/platform/new`;
if (!session?.user) {
redirect(`/login?callbackUrl=${callbackUrl}`);
}
return (
<LayoutWrapper>
<LicenseRequired>
<LegacyPage />
</LicenseRequired>
</LayoutWrapper>
);
};
export default ServerPage;
@@ -0,0 +1,34 @@
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { APP_NAME } from "@calcom/lib/constants";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { TeamInviteEmailView } from "~/settings/teams/new/invite/email/team-invite-email-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => `${APP_NAME} - ${t("invite")}`,
() => "",
true,
undefined,
"/settings/teams/new/invite/email"
);
};
const ServerPage = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
return redirect("/auth/login");
}
const userEmail = session.user.email || "";
return <TeamInviteEmailView userEmail={userEmail} />;
};
export default ServerPage;
@@ -0,0 +1,34 @@
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { APP_NAME } from "@calcom/lib/constants";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { TeamInviteView } from "~/settings/teams/new/invite/team-invite-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => `${APP_NAME} - ${t("invite")}`,
() => "",
true,
undefined,
"/settings/teams/new/invite"
);
};
const ServerPage = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
return redirect("/auth/login");
}
const userEmail = session.user.email || "";
return <TeamInviteView userEmail={userEmail} />;
};
export default ServerPage;
@@ -1,6 +1,12 @@
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import CreateNewTeamView, { LayoutWrapper } from "~/settings/teams/new/create-new-team-view";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { CreateNewTeamView, LayoutWrapper } from "~/settings/teams/new/create-new-team-view";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -12,9 +18,17 @@ export const generateMetadata = async () =>
);
const ServerPage = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
return redirect("/auth/login");
}
const userEmail = session.user.email || "";
return (
<LayoutWrapper>
<CreateNewTeamView />
<CreateNewTeamView userEmail={userEmail} />
</LayoutWrapper>
);
};
@@ -3,6 +3,7 @@ import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getAppWithMetadata } from "@calcom/app-store/_appRegistry";
import { shouldEnableApp } from "@calcom/app-store/_utils/validateAppKeys";
import logger from "@calcom/lib/logger";
import { prisma } from "@calcom/prisma";
import type { AppCategories, Prisma } from "@calcom/prisma/client";
@@ -48,6 +49,17 @@ async function postHandler(request: NextRequest) {
updates["dirName"] = app.dirName ?? app.slug;
}
// Ensure app is only enabled if it has valid keys (or doesn't require keys)
const shouldBeEnabled = shouldEnableApp(dbApp.dirName, dbApp.keys);
if (dbApp.enabled !== shouldBeEnabled) {
updates["enabled"] = shouldBeEnabled;
if (!shouldBeEnabled && dbApp.enabled) {
log.warn(
`⚠️ Disabling app ${dbApp.slug} - required keys are missing or invalid. Please configure keys in admin settings.`
);
}
}
if (Object.keys(updates).length > 0) {
log.info(`🔨 Updating app ${dbApp.slug} with ${Object.keys(updates).join(", ")}`);
if (!isDryRun) {
+48 -34
View File
@@ -1,29 +1,40 @@
import process from "node:process";
import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
import { useIsPlatformBookerEmbed } from "@calcom/atoms/hooks/useIsPlatformBookerEmbed";
import dayjs from "@calcom/dayjs";
import { useEmbedUiConfig } from "@calcom/embed-core/embed-iframe";
import { updateEmbedBookerState } from "@calcom/embed-core/src/embed-iframe";
import TurnstileCaptcha from "@calcom/features/auth/Turnstile";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import { useIsQuickAvailabilityCheckFeatureEnabled } from "@calcom/features/bookings/Booker/components/hooks/useIsQuickAvailabilityCheckFeatureEnabled";
import useSkipConfirmStep from "@calcom/features/bookings/Booker/components/hooks/useSkipConfirmStep";
import {
fadeInLeft,
getBookerSizeClassNames,
useBookerResizeAnimation,
} from "@calcom/features/bookings/Booker/config";
import framerFeatures from "@calcom/features/bookings/Booker/framer-features";
import type { BookerProps, WrappedBookerProps } from "@calcom/features/bookings/Booker/types";
import { isBookingDryRun } from "@calcom/features/bookings/Booker/utils/isBookingDryRun";
import { isTimeSlotAvailable } from "@calcom/features/bookings/Booker/utils/isTimeslotAvailable";
import { getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param";
import { useNonEmptyScheduleDays } from "@calcom/features/schedules/lib/use-schedule/useNonEmptyScheduleDays";
import { scrollIntoViewSmooth } from "@calcom/lib/browser/browser.utils";
import {
CLOUDFLARE_SITE_ID,
CLOUDFLARE_USE_TURNSTILE_IN_BOOKER,
PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM,
} from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { BookerLayouts } from "@calcom/prisma/zod-utils";
import classNames from "@calcom/ui/classNames";
import { UnpublishedEntity } from "@calcom/ui/components/unpublished-entity";
import PoweredBy from "@calcom/web/modules/ee/common/components/PoweredBy";
import { AnimatePresence, LazyMotion, m } from "framer-motion";
import { useEffect, useMemo, useRef } from "react";
import StickyBox from "react-sticky-box";
import { Toaster } from "sonner";
import { shallow } from "zustand/shallow";
import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
import { useIsPlatformBookerEmbed } from "@calcom/atoms/hooks/useIsPlatformBookerEmbed";
import dayjs from "@calcom/dayjs";
import PoweredBy from "@calcom/web/modules/ee/common/components/PoweredBy";
import { useEmbedUiConfig } from "@calcom/embed-core/embed-iframe";
import { updateEmbedBookerState } from "@calcom/embed-core/src/embed-iframe";
import TurnstileCaptcha from "@calcom/features/auth/Turnstile";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import useSkipConfirmStep from "@calcom/features/bookings/Booker/components/hooks/useSkipConfirmStep";
import { getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param";
import { useNonEmptyScheduleDays } from "@calcom/features/schedules/lib/use-schedule/useNonEmptyScheduleDays";
import { scrollIntoViewSmooth } from "@calcom/lib/browser/browser.utils";
import { PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM } from "@calcom/lib/constants";
import { CLOUDFLARE_SITE_ID, CLOUDFLARE_USE_TURNSTILE_IN_BOOKER } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { BookerLayouts } from "@calcom/prisma/zod-utils";
import classNames from "@calcom/ui/classNames";
import { UnpublishedEntity } from "@calcom/ui/components/unpublished-entity";
import { VerifyCodeDialog } from "./VerifyCodeDialog";
import { AvailableTimeSlots } from "./AvailableTimeSlots";
import { BookEventForm } from "./BookEventForm";
import { BookFormAsModal } from "./BookEventForm/BookFormAsModal";
@@ -38,12 +49,7 @@ import { OverlayCalendar } from "./OverlayCalendar/OverlayCalendar";
import { RedirectToInstantMeetingModal } from "./RedirectToInstantMeetingModal";
import { BookerSection } from "./Section";
import { NotFound } from "./Unavailable";
import { useIsQuickAvailabilityCheckFeatureEnabled } from "@calcom/features/bookings/Booker/components/hooks/useIsQuickAvailabilityCheckFeatureEnabled";
import { fadeInLeft, getBookerSizeClassNames, useBookerResizeAnimation } from "@calcom/features/bookings/Booker/config";
import framerFeatures from "@calcom/features/bookings/Booker/framer-features";
import type { BookerProps, WrappedBookerProps } from "@calcom/features/bookings/Booker/types";
import { isBookingDryRun } from "@calcom/features/bookings/Booker/utils/isBookingDryRun";
import { isTimeSlotAvailable } from "@calcom/features/bookings/Booker/utils/isTimeslotAvailable";
import { VerifyCodeDialog } from "./VerifyCodeDialog";
const BookerComponent = ({
username,
@@ -219,16 +225,24 @@ const BookerComponent = ({
if (selectedTimeslot && skipConfirmStep && isSkipConfirmStepSupported)
return setBookerState("selecting_time");
return setBookerState("booking");
}, [event, selectedDate, selectedTimeslot, setBookerState, skipConfirmStep, layout, isInstantMeeting]);
}, [
event.isPending,
selectedDate,
selectedTimeslot,
setBookerState,
skipConfirmStep,
layout,
isInstantMeeting,
]);
const unavailableTimeSlots = isQuickAvailabilityCheckFeatureEnabled
? allSelectedTimeslots.filter((slot) => {
return !isTimeSlotAvailable({
scheduleData: schedule?.data ?? null,
slotToCheckInIso: slot,
quickAvailabilityChecks: slots.quickAvailabilityChecks,
});
})
return !isTimeSlotAvailable({
scheduleData: schedule?.data ?? null,
slotToCheckInIso: slot,
quickAvailabilityChecks: slots.quickAvailabilityChecks,
});
})
: [];
const slot = getQueryParam("slot");
@@ -1,14 +1,15 @@
"use client";
import { ColumnFilterType, DataTableProvider, type SystemFilterSegment } from "@calcom/features/data-table";
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
import FeatureOptInBannerWrapper from "@calcom/features/feature-opt-in/components/FeatureOptInBannerWrapper";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import classNames from "@calcom/ui/classNames";
import dynamic from "next/dynamic";
import { usePathname } from "next/navigation";
import { useMemo } from "react";
import { DataTableProvider, type SystemFilterSegment, ColumnFilterType } from "@calcom/features/data-table";
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import classNames from "@calcom/ui/classNames";
import { useFeatureOptInBanner } from "../../feature-opt-in/hooks/useFeatureOptInBanner";
import { BookingListContainer } from "../components/BookingListContainer";
import { useBookingsShellHeadingVisibility } from "../hooks/useBookingsShellHeadingVisibility";
import { useBookingsView } from "../hooks/useBookingsView";
@@ -71,6 +72,7 @@ export default function Bookings(props: BookingsProps) {
function BookingsContent({ status, permissions, bookingsV3Enabled, bookingAuditEnabled }: BookingsProps) {
const [view] = useBookingsView({ bookingsV3Enabled });
const optInBanner = useFeatureOptInBanner("bookings-v3");
useBookingsShellHeadingVisibility({ visible: view === "list" });
@@ -91,6 +93,7 @@ function BookingsContent({ status, permissions, bookingsV3Enabled, bookingAuditE
bookingsV3Enabled={bookingsV3Enabled}
/>
)}
<FeatureOptInBannerWrapper state={optInBanner} />
</div>
);
}
@@ -1,12 +1,11 @@
"use client";
import type { ReactElement } from "react";
import { FeaturesSettings } from "@calcom/features/feature-opt-in/components/FeaturesSettings";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { ReactElement } from "react";
import { useOrganizationFeatureOptIn } from "~/feature-opt-in/hooks";
import { useOrganizationFeatureOptIn } from "~/feature-opt-in/hooks/useOrganizationFeatureOptIn";
interface OrganizationFeaturesViewProps {
canEdit: boolean;
@@ -1,26 +1,23 @@
import { useMemo, type ComponentProps, type Dispatch, type SetStateAction } from "react";
import { useFormContext } from "react-hook-form";
import { Controller } from "react-hook-form";
import type { Options } from "react-select";
import { AddMembersWithSwitchPlatformWrapper } from "@calcom/atoms/add-members-switch/AddMembersWithSwitchPlatformWrapper";
import { AddMembersWithSwitchWebWrapper } from "./AddMembersWithSwitchWebWrapper";
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
import { Segment } from "@calcom/features/Segment";
import type {
FormValues,
Host,
SettingsToggleClassNames,
TeamMember,
} from "@calcom/features/eventtypes/lib/types";
import { Segment } from "@calcom/features/Segment";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { AttributesQueryValue } from "@calcom/lib/raqb/types";
import { Label } from "@calcom/ui/components/form";
import { SettingsToggle } from "@calcom/ui/components/form";
import { Label, SettingsToggle } from "@calcom/ui/components/form";
import { type ComponentProps, type Dispatch, type SetStateAction, useMemo } from "react";
import { Controller, useFormContext } from "react-hook-form";
import type { Options } from "react-select";
import { AddMembersWithSwitchWebWrapper } from "./AddMembersWithSwitchWebWrapper";
import AssignAllTeamMembers from "./AssignAllTeamMembers";
import CheckedTeamSelect from "./CheckedTeamSelect";
import type { CheckedSelectOption, CheckedTeamSelectCustomClassNames } from "./CheckedTeamSelect";
import CheckedTeamSelect from "./CheckedTeamSelect";
interface IUserToValue {
id: number | null;
@@ -130,6 +127,7 @@ function MembersSegmentWithToggle({
rrSegmentQueryValue,
setRrSegmentQueryValue,
className,
filterMemberIds,
}: {
teamId: number;
assignRRMembersUsingSegment: boolean;
@@ -137,6 +135,7 @@ function MembersSegmentWithToggle({
rrSegmentQueryValue: AttributesQueryValue | null;
setRrSegmentQueryValue: (value: AttributesQueryValue) => void;
className?: string;
filterMemberIds?: number[];
}) {
const { t } = useLocale();
const onQueryValueChange = ({ queryValue }: { queryValue: AttributesQueryValue }) => {
@@ -162,6 +161,7 @@ function MembersSegmentWithToggle({
queryValue={rrSegmentQueryValue}
onQueryValueChange={onQueryValueChange}
className={className}
filterMemberIds={filterMemberIds}
/>
)}
</SettingsToggle>
@@ -193,7 +193,7 @@ export type AddMembersWithSwitchProps = {
customClassNames?: AddMembersWithSwitchCustomClassNames;
};
const enum AssignmentState {
enum AssignmentState {
TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_NOT_APPLICABLE = "TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_NOT_APPLICABLE",
TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_APPLICABLE = "TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_APPLICABLE",
ALL_TEAM_MEMBERS_ENABLED_AND_SEGMENT_APPLICABLE = "ALL_TEAM_MEMBERS_ENABLED_AND_SEGMENT_APPLICABLE",
@@ -303,6 +303,7 @@ export function AddMembersWithSwitch({
setAssignRRMembersUsingSegment={setAssignRRMembersUsingSegment}
rrSegmentQueryValue={rrSegmentQueryValue}
setRrSegmentQueryValue={setRrSegmentQueryValue}
filterMemberIds={value.filter((host) => !host.isFixed).map((host) => host.userId)}
/>
</div>
)}
@@ -1,8 +1,6 @@
"use client";
import Link from "next/link";
import { useState, useEffect, useRef, useMemo } from "react";
import { useTeamMembersWithSegmentPlatform } from "@calcom/atoms/event-types/hooks/useTeamMembersWithSegmentPlatform";
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
import type { Host, TeamMember } from "@calcom/features/eventtypes/lib/types";
import ServerTrans from "@calcom/lib/components/ServerTrans";
@@ -10,8 +8,7 @@ import { downloadAsCsv } from "@calcom/lib/csvUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { AttributesQueryValue } from "@calcom/lib/raqb/types";
import { Avatar } from "@calcom/ui/components/avatar";
import { buttonClasses } from "@calcom/ui/components/button";
import { Button } from "@calcom/ui/components/button";
import { Button, buttonClasses } from "@calcom/ui/components/button";
import { TextField } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import {
@@ -24,11 +21,9 @@ import {
SheetTitle,
} from "@calcom/ui/components/sheet";
import { showToast } from "@calcom/ui/components/toast";
import {
useTeamMembersWithSegmentPlatform,
} from "@calcom/atoms/event-types/hooks/useTeamMembersWithSegmentPlatform";
import { useTeamMembersWithSegment } from "@calcom/web/modules/event-types/hooks/useTeamMembersWithSegment";
import Link from "next/link";
import { useEffect, useMemo, useRef, useState } from "react";
type TeamMemberItemProps = {
member: Omit<TeamMember, "defaultScheduleId"> & { weight?: number };
@@ -102,7 +97,6 @@ interface Props {
teamMembers: TeamMember[];
value: Host[];
onChange: (hosts: Host[]) => void;
assignAllTeamMembers: boolean;
assignRRMembersUsingSegment: boolean;
teamId?: number;
queryValue?: AttributesQueryValue | null;
@@ -112,7 +106,6 @@ export const EditWeightsForAllTeamMembers = ({
teamMembers: initialTeamMembers,
value,
onChange,
assignAllTeamMembers,
assignRRMembersUsingSegment,
teamId,
queryValue,
@@ -240,13 +233,9 @@ export const EditWeightsForAllTeamMembers = ({
member.email.toLowerCase().includes(searchQuery.toLowerCase())
)
.filter((member) => {
// When assignAllTeamMembers is false, only include members that exist in value array
return (
assignAllTeamMembers ||
value.some((host) => !host.isFixed && host.userId === parseInt(member.value, 10))
);
return value.some((host) => !host.isFixed && host.userId === parseInt(member.value, 10));
});
}, [teamMembers, localWeights, searchQuery, assignAllTeamMembers, value]);
}, [teamMembers, localWeights, searchQuery, value]);
return (
<>
@@ -1,11 +1,21 @@
import type { TFunction } from "i18next";
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import type { ComponentProps, Dispatch, SetStateAction } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import type { Options } from "react-select";
import { v4 as uuidv4 } from "uuid";
import type {
EventTypeSetupProps,
FormValues,
Host,
SelectClassNames,
SettingsToggleClassNames,
TeamMember,
} from "@calcom/features/eventtypes/lib/types";
import { sortHosts } from "@calcom/lib/bookings/hostGroupUtils";
import ServerTrans from "@calcom/lib/components/ServerTrans";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { RRTimestampBasis, SchedulingType } from "@calcom/prisma/enums";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { Label, Select, SettingsToggle } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { RadioAreaGroup as RadioArea } from "@calcom/ui/components/radio";
import { Tooltip } from "@calcom/ui/components/tooltip";
import type { AddMembersWithSwitchCustomClassNames } from "@calcom/web/modules/event-types/components/AddMembersWithSwitch";
import AddMembersWithSwitch, {
mapUserToValue,
@@ -14,28 +24,15 @@ import AssignAllTeamMembers from "@calcom/web/modules/event-types/components/Ass
import type { ChildrenEventTypeSelectCustomClassNames } from "@calcom/web/modules/event-types/components/ChildrenEventTypeSelect";
import ChildrenEventTypeSelect from "@calcom/web/modules/event-types/components/ChildrenEventTypeSelect";
import { EditWeightsForAllTeamMembers } from "@calcom/web/modules/event-types/components/EditWeightsForAllTeamMembers";
import { sortHosts } from "@calcom/lib/bookings/hostGroupUtils";
import { LearnMoreLink } from "@calcom/web/modules/event-types/components/LearnMoreLink";
import WeightDescription from "@calcom/web/modules/event-types/components/WeightDescription";
import type {
FormValues,
TeamMember,
EventTypeSetupProps,
Host,
SelectClassNames,
SettingsToggleClassNames,
} from "@calcom/features/eventtypes/lib/types";
import ServerTrans from "@calcom/lib/components/ServerTrans";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { RRTimestampBasis, SchedulingType } from "@calcom/prisma/enums";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { Label } from "@calcom/ui/components/form";
import { Select } from "@calcom/ui/components/form";
import { SettingsToggle } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { RadioAreaGroup as RadioArea } from "@calcom/ui/components/radio";
import { Tooltip } from "@calcom/ui/components/tooltip";
import type { TFunction } from "i18next";
import Link from "next/link";
import type { ComponentProps, Dispatch, SetStateAction } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import type { Options } from "react-select";
import { v4 as uuidv4 } from "uuid";
export type EventTeamAssignmentTabCustomClassNames = {
assignmentType?: {
@@ -506,7 +503,6 @@ const RoundRobinHosts = ({
teamMembers={teamMembers}
value={value}
onChange={handleWeightsChange}
assignAllTeamMembers={assignAllTeamMembers}
assignRRMembersUsingSegment={assignRRMembersUsingSegment}
teamId={teamId}
queryValue={rrSegmentQueryValue}
@@ -666,10 +662,10 @@ const Hosts = ({
return existingHost
? {
...newValue,
scheduleId: existingHost.scheduleId,
groupId: existingHost.groupId,
}
...newValue,
scheduleId: existingHost.scheduleId,
groupId: existingHost.groupId,
}
: newValue;
});
};
@@ -724,7 +720,7 @@ const Hosts = ({
),
MANAGED: <></>,
};
return !!schedulingType ? schedulingTypeRender[schedulingType] : <></>;
return schedulingType ? schedulingTypeRender[schedulingType] : <></>;
}}
/>
);
@@ -745,17 +741,17 @@ export const EventTeamAssignmentTab = ({
label: string;
// description: string;
}[] = [
{
value: "COLLECTIVE",
label: t("collective"),
// description: t("collective_description"),
},
{
value: "ROUND_ROBIN",
label: t("round_robin"),
// description: t("round_robin_description"),
},
];
{
value: "COLLECTIVE",
label: t("collective"),
// description: t("collective_description"),
},
{
value: "ROUND_ROBIN",
label: t("round_robin"),
// description: t("round_robin_description"),
},
];
const pendingMembers = (member: (typeof teamMembers)[number]) =>
!!eventType.team?.parentId || !!member.username;
const teamMembersOptions = teamMembers
@@ -887,13 +883,11 @@ export const EventTeamAssignmentTab = ({
</RadioArea.Item>
{(eventType.team?.rrTimestampBasis &&
eventType.team?.rrTimestampBasis !== RRTimestampBasis.CREATED_AT) ||
hostGroups?.length > 1 ? (
hostGroups?.length > 1 ? (
<Tooltip
content={
!!(
eventType.team?.rrTimestampBasis &&
eventType.team?.rrTimestampBasis &&
eventType.team?.rrTimestampBasis !== RRTimestampBasis.CREATED_AT
)
? t("rr_load_balancing_disabled")
: t("rr_load_balancing_disabled_with_groups")
}>
@@ -1,18 +1,13 @@
import { useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import type { UseFormGetValues, UseFormSetValue, Control, FormState } from "react-hook-form";
import type { MultiValue } from "react-select";
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import type { LocationCustomClassNames } from "../../locations/types";
import type {
EventTypeSetupProps,
FormValues,
InputClassNames,
LocationFormValues,
SelectClassNames,
SettingsToggleClassNames,
} from "@calcom/features/eventtypes/lib/types";
import type { FormValues, LocationFormValues } from "@calcom/features/eventtypes/lib/types";
import { MAX_EVENT_DURATION_MINUTES, MIN_EVENT_DURATION_MINUTES } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt";
@@ -20,14 +15,14 @@ import { slugify } from "@calcom/lib/slugify";
import turndown from "@calcom/lib/turndownService";
import classNames from "@calcom/ui/classNames";
import { Editor } from "@calcom/ui/components/editor";
import { TextAreaField } 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 { SettingsToggle } from "@calcom/ui/components/form";
import { Label, Select, SettingsToggle, TextAreaField, TextField } from "@calcom/ui/components/form";
import { Skeleton } from "@calcom/ui/components/skeleton";
import Locations from "@calcom/web/modules/event-types/components/locations/Locations";
import { useState } from "react";
import type { Control, FormState, UseFormGetValues, UseFormSetValue } from "react-hook-form";
import { Controller, useFormContext } from "react-hook-form";
import type { MultiValue } from "react-select";
import type { LocationCustomClassNames } from "../../locations/types";
export type EventSetupTabCustomClassNames = {
wrapper?: string;
@@ -80,7 +75,7 @@ export const EventSetupTab = (
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
const multipleDurationOptions = [
5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 150, 180, 240, 300, 360, 420, 480,
5, 10, 15, 20, 25, 30, 40, 45, 50, 60, 75, 80, 90, 120, 150, 180, 240, 300, 360, 420, 480,
].map((mins) => ({
value: mins,
label: t("multiple_duration_mins", { count: mins }),
@@ -109,7 +104,7 @@ export const EventSetupTab = (
<div className={classNames("stack-y-4", customClassNames?.wrapper)}>
<div
className={classNames(
"border-subtle stack-y-6 rounded-lg border p-6",
"stack-y-6 rounded-lg border border-subtle p-6",
customClassNames?.titleSection?.container
)}>
<TextField
@@ -169,7 +164,7 @@ export const EventSetupTab = (
className={classNames("pl-0", customClassNames?.titleSection?.urlInput?.input)}
addOnLeading={
isPlatform ? undefined : (
<span className="max-w-24 md:max-w-56 inline-block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">
<span className="inline-block min-w-0 max-w-24 overflow-hidden text-ellipsis whitespace-nowrap md:max-w-56">
{urlPrefix}/
{!isManagedEventType
? team
@@ -187,7 +182,7 @@ export const EventSetupTab = (
</div>
<div
className={classNames(
"border-subtle rounded-lg border p-6",
"rounded-lg border border-subtle p-6",
customClassNames?.durationSection?.container
)}>
{multipleDuration ? (
@@ -212,7 +207,7 @@ export const EventSetupTab = (
isSearchable={false}
isDisabled={lengthLockedProps.disabled}
className={classNames(
"min-h-[36px]! h-auto text-sm",
"h-auto min-h-[36px]! text-sm",
customClassNames?.durationSection?.multipleDuration?.availableDurationsSelect?.select
)}
innerClassNames={
@@ -306,7 +301,7 @@ export const EventSetupTab = (
message: t("duration_max_error", { max: MAX_EVENT_DURATION_MINUTES }),
},
})}
addOnSuffix={<>{t("minutes")}</>}
addOnSuffix={t("minutes")}
min={MIN_EVENT_DURATION_MINUTES}
max={MAX_EVENT_DURATION_MINUTES}
/>
@@ -341,7 +336,7 @@ export const EventSetupTab = (
</div>
<div
className={classNames(
"border-subtle rounded-lg border p-6",
"rounded-lg border border-subtle p-6",
customClassNames?.locationSection?.container
)}>
<div>
@@ -1,4 +0,0 @@
export { useUserFeatureOptIn } from "./useUserFeatureOptIn";
export { useTeamFeatureOptIn } from "./useTeamFeatureOptIn";
export { useOrganizationFeatureOptIn } from "./useOrganizationFeatureOptIn";
export type { UseFeatureOptInResult, NormalizedFeature, ToggleLabels } from "@calcom/features/feature-opt-in/types";
@@ -0,0 +1,164 @@
"use client";
import type { OptInFeatureConfig } from "@calcom/features/feature-opt-in/config";
import { getOptInFeatureConfig } from "@calcom/features/feature-opt-in/config";
import { localStorage } from "@calcom/lib/webstorage";
import { trpc } from "@calcom/trpc/react";
import { useCallback, useMemo, useState } from "react";
import { z } from "zod";
const DISMISSED_STORAGE_KEY = "feature-opt-in-dismissed";
const OPTED_IN_STORAGE_KEY = "feature-opt-in-enabled";
const featuresMapSchema = z.record(z.string(), z.boolean());
type FeaturesMap = z.infer<typeof featuresMapSchema>;
type UserRoleContext = {
isOrgAdmin: boolean;
orgId: number | null;
adminTeamIds: number[];
adminTeamNames: { id: number; name: string }[];
};
export type FeatureOptInMutations = {
setUserState: (params: { slug: string; state: "enabled" }) => Promise<unknown>;
setTeamState: (params: { teamId: number; slug: string; state: "enabled" }) => Promise<unknown>;
setOrganizationState: (params: { slug: string; state: "enabled" }) => Promise<unknown>;
setUserAutoOptIn: (params: { autoOptIn: boolean }) => Promise<unknown>;
setTeamAutoOptIn: (params: { teamId: number; autoOptIn: boolean }) => Promise<unknown>;
setOrganizationAutoOptIn: (params: { autoOptIn: boolean }) => Promise<unknown>;
invalidateQueries: () => void;
};
export type UseFeatureOptInBannerResult = {
shouldShow: boolean;
isLoading: boolean;
featureConfig: OptInFeatureConfig | null;
canOptIn: boolean;
blockingReason: string | null;
userRoleContext: UserRoleContext | null;
isDialogOpen: boolean;
openDialog: () => void;
closeDialog: () => void;
dismiss: () => void;
markOptedIn: () => void;
mutations: FeatureOptInMutations;
};
function getFeaturesMap(storageKey: string): FeaturesMap {
const stored = localStorage.getItem(storageKey);
if (!stored) return {};
try {
const parsed = JSON.parse(stored);
const result = featuresMapSchema.safeParse(parsed);
if (result.success) {
return result.data;
}
return {};
} catch {
return {};
}
}
function setFeatureInMap(storageKey: string, featureId: string): void {
const current = getFeaturesMap(storageKey);
current[featureId] = true;
localStorage.setItem(storageKey, JSON.stringify(current));
}
function isFeatureInMap(storageKey: string, featureId: string): boolean {
const features = getFeaturesMap(storageKey);
return features[featureId] === true;
}
export function useFeatureOptInBanner(featureId: string): UseFeatureOptInBannerResult {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isDismissed, setIsDismissed] = useState(() => isFeatureInMap(DISMISSED_STORAGE_KEY, featureId));
const [isOptedIn, setIsOptedIn] = useState(() => isFeatureInMap(OPTED_IN_STORAGE_KEY, featureId));
const featureConfig = useMemo(() => getOptInFeatureConfig(featureId) ?? null, [featureId]);
const utils = trpc.useUtils();
const eligibilityQuery = trpc.viewer.featureOptIn.checkFeatureOptInEligibility.useQuery(
{ featureId },
{
enabled: !isDismissed && !isOptedIn && featureConfig !== null,
refetchOnWindowFocus: false,
staleTime: 1000 * 60 * 5,
}
);
const setUserStateMutation = trpc.viewer.featureOptIn.setUserState.useMutation();
const setTeamStateMutation = trpc.viewer.featureOptIn.setTeamState.useMutation();
const setOrganizationStateMutation = trpc.viewer.featureOptIn.setOrganizationState.useMutation();
const setUserAutoOptInMutation = trpc.viewer.featureOptIn.setUserAutoOptIn.useMutation();
const setTeamAutoOptInMutation = trpc.viewer.featureOptIn.setTeamAutoOptIn.useMutation();
const setOrganizationAutoOptInMutation = trpc.viewer.featureOptIn.setOrganizationAutoOptIn.useMutation();
const mutations: FeatureOptInMutations = useMemo(
() => ({
setUserState: (params) => setUserStateMutation.mutateAsync(params),
setTeamState: (params) => setTeamStateMutation.mutateAsync(params),
setOrganizationState: (params) => setOrganizationStateMutation.mutateAsync(params),
setUserAutoOptIn: (params) => setUserAutoOptInMutation.mutateAsync(params),
setTeamAutoOptIn: (params) => setTeamAutoOptInMutation.mutateAsync(params),
setOrganizationAutoOptIn: (params) => setOrganizationAutoOptInMutation.mutateAsync(params),
invalidateQueries: () => {
utils.viewer.featureOptIn.checkFeatureOptInEligibility.invalidate();
utils.viewer.featureOptIn.listForUser.invalidate();
},
}),
[
setUserStateMutation,
setTeamStateMutation,
setOrganizationStateMutation,
setUserAutoOptInMutation,
setTeamAutoOptInMutation,
setOrganizationAutoOptInMutation,
utils,
]
);
const dismiss = useCallback(() => {
setFeatureInMap(DISMISSED_STORAGE_KEY, featureId);
setIsDismissed(true);
}, [featureId]);
const markOptedIn = useCallback(() => {
setFeatureInMap(OPTED_IN_STORAGE_KEY, featureId);
setIsOptedIn(true);
}, [featureId]);
const openDialog = useCallback(() => {
setIsDialogOpen(true);
}, []);
const closeDialog = useCallback(() => {
setIsDialogOpen(false);
}, []);
const shouldShow = useMemo(() => {
if (isDismissed) return false;
if (isOptedIn) return false;
if (!featureConfig) return false;
if (eligibilityQuery.isLoading) return false;
if (!eligibilityQuery.data) return false;
return eligibilityQuery.data.status === "can_opt_in";
}, [isDismissed, isOptedIn, featureConfig, eligibilityQuery.isLoading, eligibilityQuery.data]);
return {
shouldShow,
isLoading: eligibilityQuery.isLoading,
featureConfig,
canOptIn: eligibilityQuery.data?.canOptIn ?? false,
blockingReason: eligibilityQuery.data?.blockingReason ?? null,
userRoleContext: eligibilityQuery.data?.userRoleContext ?? null,
isDialogOpen,
openDialog,
closeDialog,
dismiss,
markOptedIn,
mutations,
};
}
@@ -1,12 +1,11 @@
"use client";
import { useCallback, useMemo } from "react";
import type { NormalizedFeature, UseFeatureOptInResult } from "@calcom/features/feature-opt-in/types";
import type { FeatureState } from "@calcom/features/flags/config";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { showToast } from "@calcom/ui/components/toast";
import { useCallback, useMemo } from "react";
function useMutationCallbacks(onSuccessCallback: () => void): { onSuccess: () => void; onError: () => void } {
const { t } = useLocale();
@@ -56,7 +55,10 @@ export function useOrganizationFeatureOptIn(): UseFeatureOptInResult {
refetchOnWindowFocus: false,
});
const invalidateFeatures = useCallback(() => utils.viewer.featureOptIn.listForOrganization.invalidate(), [utils]);
const invalidateFeatures = useCallback(
() => utils.viewer.featureOptIn.listForOrganization.invalidate(),
[utils]
);
const invalidateFeaturesAndAutoOptIn = useCallback(() => {
utils.viewer.featureOptIn.getOrganizationAutoOptIn.invalidate();
utils.viewer.featureOptIn.listForOrganization.invalidate();
@@ -65,13 +67,15 @@ export function useOrganizationFeatureOptIn(): UseFeatureOptInResult {
const setStateMutationCallbacks = useMutationCallbacks(invalidateFeatures);
const setAutoOptInMutationCallbacks = useMutationCallbacks(invalidateFeaturesAndAutoOptIn);
const setStateMutation = trpc.viewer.featureOptIn.setOrganizationState.useMutation(setStateMutationCallbacks);
const setStateMutation =
trpc.viewer.featureOptIn.setOrganizationState.useMutation(setStateMutationCallbacks);
const setAutoOptInMutation = trpc.viewer.featureOptIn.setOrganizationAutoOptIn.useMutation(
setAutoOptInMutationCallbacks
);
const features = normalizeFeatures(featuresQuery.data);
const setFeatureState = (slug: string, state: FeatureState): void => setStateMutation.mutate({ slug, state });
const setFeatureState = (slug: string, state: FeatureState): void =>
setStateMutation.mutate({ slug, state });
const setAutoOptIn = (checked: boolean): void => setAutoOptInMutation.mutate({ autoOptIn: checked });
return {
@@ -1,16 +1,20 @@
"use client";
import type { TFunction } from "i18next";
import { useCallback, useMemo } from "react";
import type { NormalizedFeature, UseFeatureOptInResult } from "@calcom/features/feature-opt-in/types";
import type { FeatureState } from "@calcom/features/flags/config";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { showToast } from "@calcom/ui/components/toast";
import type { TFunction } from "i18next";
import { useCallback, useMemo } from "react";
type FeatureBlockingState = { orgState?: FeatureState };
type TeamFeatureData = { featureId: string; globalEnabled: boolean; teamState: FeatureState; orgState: FeatureState };
type TeamFeatureData = {
featureId: string;
globalEnabled: boolean;
teamState: FeatureState;
orgState: FeatureState;
};
function useMutationCallbacks(onSuccessCallback: () => void): { onSuccess: () => void; onError: () => void } {
const { t } = useLocale();
@@ -73,8 +77,14 @@ export function useTeamFeatureOptIn(teamId: number): UseFeatureOptInResult {
const { t } = useLocale();
const utils = trpc.useUtils();
const featuresQuery = trpc.viewer.featureOptIn.listForTeam.useQuery({ teamId }, { refetchOnWindowFocus: false });
const autoOptInQuery = trpc.viewer.featureOptIn.getTeamAutoOptIn.useQuery({ teamId }, { refetchOnWindowFocus: false });
const featuresQuery = trpc.viewer.featureOptIn.listForTeam.useQuery(
{ teamId },
{ refetchOnWindowFocus: false }
);
const autoOptInQuery = trpc.viewer.featureOptIn.getTeamAutoOptIn.useQuery(
{ teamId },
{ refetchOnWindowFocus: false }
);
const invalidateFeatures = useCallback(
() => utils.viewer.featureOptIn.listForTeam.invalidate({ teamId }),
@@ -89,12 +99,16 @@ export function useTeamFeatureOptIn(teamId: number): UseFeatureOptInResult {
const setAutoOptInMutationCallbacks = useMutationCallbacks(invalidateFeaturesAndAutoOptIn);
const setStateMutation = trpc.viewer.featureOptIn.setTeamState.useMutation(setStateMutationCallbacks);
const setAutoOptInMutation = trpc.viewer.featureOptIn.setTeamAutoOptIn.useMutation(setAutoOptInMutationCallbacks);
const setAutoOptInMutation = trpc.viewer.featureOptIn.setTeamAutoOptIn.useMutation(
setAutoOptInMutationCallbacks
);
const featureBlockingState = useMemo(() => buildBlockingStateMap(featuresQuery.data), [featuresQuery.data]);
const features = normalizeTeamFeatures(featuresQuery.data);
const setFeatureState = (slug: string, state: FeatureState): void => setStateMutation.mutate({ teamId, slug, state });
const setAutoOptIn = (checked: boolean): void => setAutoOptInMutation.mutate({ teamId, autoOptIn: checked });
const setFeatureState = (slug: string, state: FeatureState): void =>
setStateMutation.mutate({ teamId, slug, state });
const setAutoOptIn = (checked: boolean): void =>
setAutoOptInMutation.mutate({ teamId, autoOptIn: checked });
const getBlockedWarning = createTeamBlockedWarningFn(featureBlockingState, t);
const isBlockedByHigherLevel = createTeamIsBlockedByHigherLevelFn(featureBlockingState);
@@ -1,15 +1,13 @@
"use client";
import type { TFunction } from "i18next";
import { useCallback, useMemo } from "react";
import type { EffectiveStateReason } from "@calcom/features/feature-opt-in/lib/computeEffectiveState";
import type { NormalizedFeature, UseFeatureOptInResult } from "@calcom/features/feature-opt-in/types";
import type { FeatureState } from "@calcom/features/flags/config";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { showToast } from "@calcom/ui/components/toast";
import type { TFunction } from "i18next";
import { useCallback, useMemo } from "react";
type UserFeatureData = {
featureId: string;
@@ -53,8 +51,9 @@ function createUserBlockedWarningFn(t: TFunction): (feature: NormalizedFeature)
case "feature_any_team_disabled":
return t("feature_blocked_by_team_warning");
case "feature_no_explicit_enablement":
case "feature_user_only_not_allowed":
return t("feature_no_explicit_enablement_warning");
case "feature_user_only_not_allowed":
return t("feature_user_only_not_allowed_warning");
default:
return null;
}
@@ -76,8 +75,12 @@ export function useUserFeatureOptIn(): UseFeatureOptInResult {
const { t } = useLocale();
const utils = trpc.useUtils();
const featuresQuery = trpc.viewer.featureOptIn.listForUser.useQuery(undefined, { refetchOnWindowFocus: false });
const autoOptInQuery = trpc.viewer.featureOptIn.getUserAutoOptIn.useQuery(undefined, { refetchOnWindowFocus: false });
const featuresQuery = trpc.viewer.featureOptIn.listForUser.useQuery(undefined, {
refetchOnWindowFocus: false,
});
const autoOptInQuery = trpc.viewer.featureOptIn.getUserAutoOptIn.useQuery(undefined, {
refetchOnWindowFocus: false,
});
const invalidateFeatures = useCallback(() => utils.viewer.featureOptIn.listForUser.invalidate(), [utils]);
const invalidateFeaturesAndAutoOptIn = useCallback(() => {
@@ -89,10 +92,13 @@ export function useUserFeatureOptIn(): UseFeatureOptInResult {
const setAutoOptInMutationCallbacks = useMutationCallbacks(invalidateFeaturesAndAutoOptIn);
const setStateMutation = trpc.viewer.featureOptIn.setUserState.useMutation(setStateMutationCallbacks);
const setAutoOptInMutation = trpc.viewer.featureOptIn.setUserAutoOptIn.useMutation(setAutoOptInMutationCallbacks);
const setAutoOptInMutation = trpc.viewer.featureOptIn.setUserAutoOptIn.useMutation(
setAutoOptInMutationCallbacks
);
const features = useMemo(() => normalizeUserFeatures(featuresQuery.data), [featuresQuery.data]);
const setFeatureState = (slug: string, state: FeatureState): void => setStateMutation.mutate({ slug, state });
const setFeatureState = (slug: string, state: FeatureState): void =>
setStateMutation.mutate({ slug, state });
const setAutoOptIn = (checked: boolean): void => setAutoOptInMutation.mutate({ autoOptIn: checked });
const getBlockedWarning = createUserBlockedWarningFn(t);
@@ -134,12 +134,14 @@ export const OnboardingBrowserView = ({
{/* Profile Header */}
<div className="border-subtle flex flex-col gap-4 border-b p-4">
<div className="flex flex-col items-start gap-4">
<Avatar
size="lg"
imageSrc={avatar || undefined}
alt={name || ""}
className="border-2 border-white"
/>
{avatar && (
<Avatar
size="lg"
imageSrc={avatar}
alt={name || ""}
className="border-2 border-white"
/>
)}
<div className="flex w-full flex-col gap-2">
<h2 className="text-emphasis w-full truncate text-xl font-semibold leading-tight">
{name || t("your_name")}
@@ -8,6 +8,7 @@ import { trpc } from "@calcom/trpc/react";
import { Avatar } from "@calcom/ui/components/avatar";
import { useOnboardingStore, type Invite } from "../store/onboarding-store";
import classNames from "@calcom/ui/classNames";
type OnboardingInviteBrowserViewProps = {
teamName?: string;
@@ -35,8 +36,14 @@ export const OnboardingInviteBrowserView = ({
}: OnboardingInviteBrowserViewProps) => {
const pathname = usePathname();
const { data: user } = trpc.viewer.me.get.useQuery();
const { teamBrand, teamInvites, invites, teamDetails, organizationBrand, organizationDetails } =
useOnboardingStore();
const {
teamBrand,
teamInvites,
invites,
teamDetails,
organizationBrand,
organizationDetails,
} = useOnboardingStore();
const { t } = useLocale();
// Animation variants for entry and exit
@@ -57,14 +64,19 @@ export const OnboardingInviteBrowserView = ({
// Use default values if not provided
const rawInviterName = user?.name || user?.username || "Alex";
const displayInviterName = rawInviterName.charAt(0).toUpperCase() + rawInviterName.slice(1);
const displayInviterName =
rawInviterName.charAt(0).toUpperCase() + rawInviterName.slice(1);
// Use organization or team data based on context
const displayName = useOrganizationInvites
? organizationDetails.name || teamName || "Deel"
: teamName || teamDetails.name || "Deel";
const displayBio = useOrganizationInvites ? organizationDetails.bio || "" : teamDetails.bio || "";
const avatar = useOrganizationInvites ? organizationBrand.logo || null : teamBrand.logo || null;
const displayBio = useOrganizationInvites
? organizationDetails.bio || ""
: teamDetails.bio || "";
const avatar = useOrganizationInvites
? organizationBrand.logo || null
: teamBrand.logo || null;
// Get invites based on context - use watched invites if provided, otherwise fall back to store
let actualInvites: Invite[] = [];
@@ -81,7 +93,9 @@ export const OnboardingInviteBrowserView = ({
}
// Filter out empty invites (where email is empty or just whitespace)
const validInvites = actualInvites.filter((invite) => invite.email && invite.email.trim().length > 0);
const validInvites = actualInvites.filter(
(invite) => invite.email && invite.email.trim().length > 0
);
// Create empty state items
const emptyStateItem = {
@@ -132,31 +146,44 @@ export const OnboardingInviteBrowserView = ({
transition={{
duration: 0.5,
ease: "backOut",
}}>
}}
>
<div className="bg-default border-subtle flex flex-col rounded-2xl border">
<div className="relative p-1">
{/* Banner Image */}
{organizationBrand.banner && (
<div className="border-subtle relative h-36 w-full overflow-hidden rounded-xl border">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={organizationBrand.banner}
alt={displayName}
className="h-full w-full object-cover"
/>
</div>
)}
{organizationBrand.banner || avatar ? (
<div className="relative p-1">
{/* Banner Image */}
{organizationBrand.banner && (
<div className="border-subtle relative h-36 w-full overflow-hidden rounded-xl border">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={organizationBrand.banner}
alt={displayName}
className="h-full w-full object-cover"
/>
</div>
)}
{/* Organization Avatar - Overlaying the banner */}
{organizationBrand.banner && avatar && (
<div className="absolute -bottom-6 left-4">
<Avatar size="lg" imageSrc={avatar} alt={displayName} className="h-12 w-12 border" />
</div>
)}
</div>
{/* Organization Avatar - Overlaying the banner */}
{organizationBrand.banner && avatar && (
<div className="absolute -bottom-6 left-4">
<Avatar
size="lg"
imageSrc={avatar}
alt={displayName}
className="h-12 w-12 border"
/>
</div>
)}
</div>
) : null}
{/* Organization Info */}
<div className={`flex flex-col items-start gap-1 px-4 pb-4 pt-8`}>
<div
className={classNames(
`flex flex-col items-start gap-1 px-4 pb-4 pt-8`,
!organizationBrand.banner && !avatar ? "pt-4" : "pt-8"
)}
>
{!organizationBrand.banner && avatar && (
<Avatar
size="lg"
@@ -181,8 +208,14 @@ export const OnboardingInviteBrowserView = ({
key={`${item.email}-${index}`}
className={`bg-default border-subtle flex aspect-square w-full min-w-0 flex-col items-center justify-center gap-2 overflow-hidden rounded-lg border p-4 ${
!item.isReal ? "opacity-60" : ""
}`}>
<Avatar size="mdLg" imageSrc={undefined} alt={item.name} className="mt-4" />
}`}
>
<Avatar
size="mdLg"
imageSrc={undefined}
alt={item.name}
className="mt-4"
/>
<div className="flex w-full min-w-0 flex-col items-center gap-4">
<div className="flex w-full min-w-0 flex-col items-center">
<p className="text-default w-full truncate text-center text-sm font-semibold leading-tight">
@@ -9,7 +9,12 @@ import { trpc } from "@calcom/trpc/react";
import type { OnboardingState } from "../store/onboarding-store";
import { useOnboardingStore } from "../store/onboarding-store";
export function useCreateTeam() {
type UseCreateTeamOptions = {
redirectBasePath?: string;
};
export function useCreateTeam(options: UseCreateTeamOptions = {}) {
const { redirectBasePath = "/onboarding/teams" } = options;
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const flags = useFlagMap();
@@ -28,7 +33,7 @@ export function useCreateTeam() {
// Validate team details - if empty, redirect back to team details step
if (!teamDetails.name || !teamDetails.name.trim() || !teamDetails.slug || !teamDetails.slug.trim()) {
router.push("/onboarding/teams/details");
router.push(`${redirectBasePath}/details`);
setIsSubmitting(false);
return;
}
@@ -51,7 +56,7 @@ export function useCreateTeam() {
if (result.team) {
// Store the teamId and redirect to invite flow after team creation
setTeamId(result.team.id);
router.push(`/onboarding/teams/invite?teamId=${result.team.id}`);
router.push(`${redirectBasePath}/invite/email?teamId=${result.team.id}`);
}
} catch (error) {
console.error("Failed to create team:", error);
@@ -204,7 +204,7 @@ export const PersonalSettingsView = ({
{/* Bio */}
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis mb-0 text-sm font-medium leading-4">{t("bio")}</Label>
<TextArea {...form.register("bio")} className="min-h-[108px]" />
<TextArea {...form.register("bio")} className="min-h-[108px] max-h-[150px]" />
{form.formState.errors.bio && (
<p className="text-error text-sm">{form.formState.errors.bio.message}</p>
)}
@@ -1,12 +1,11 @@
"use client";
import type { ReactElement } from "react";
import { FeaturesSettings } from "@calcom/features/feature-opt-in/components/FeaturesSettings";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { ReactElement } from "react";
import { useUserFeatureOptIn } from "~/feature-opt-in/hooks";
import { useUserFeatureOptIn } from "~/feature-opt-in/hooks/useUserFeatureOptIn";
const FeaturesView = (): ReactElement => {
const { t } = useLocale();
@@ -1,30 +0,0 @@
"use client";
import { CreateANewPlatformForm } from "~/ee/platform/components/CreateANewPlatformForm";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert } from "@calcom/ui/components/alert";
import { WizardLayout } from "@calcom/ui/components/layout";
import { useGetUserAttributes } from "@calcom/web/components/settings/platform/hooks/useGetUserAttributes";
export const LayoutWrapper = ({ children }: { children: React.ReactNode }) => {
const { t } = useLocale();
const { isPlatformUser, userOrgId } = useGetUserAttributes();
if (!isPlatformUser && userOrgId) {
return (
<div className="flex h-full min-h-screen items-center justify-center">
<div className="max-w-lg">
<Alert severity="warning" title={t("organization_customer_cant_create_platform")} />
</div>
</div>
);
}
return (
<WizardLayout currentStep={1} maxSteps={1}>
{children}
</WizardLayout>
);
};
export default CreateANewPlatformForm;
@@ -1,12 +1,11 @@
"use client";
import type { ReactElement } from "react";
import { FeaturesSettings } from "@calcom/features/feature-opt-in/components/FeaturesSettings";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { ReactElement } from "react";
import { useTeamFeatureOptIn } from "~/feature-opt-in/hooks";
import { useTeamFeatureOptIn } from "~/feature-opt-in/hooks/useTeamFeatureOptIn";
interface TeamFeaturesViewProps {
teamId: number;
@@ -1,58 +1,210 @@
"use client";
import { useRouter } from "next/navigation";
import React from "react";
import { z } from "zod";
import posthog from "posthog-js";
import React, { useEffect, useRef, useState, type FormEvent } from "react";
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
import type { RouterOutputs } from "@calcom/trpc/react";
import { WizardLayout } from "@calcom/ui/components/layout";
import { CreateANewTeamForm } from "@calcom/web/modules/ee/teams/components/CreateANewTeamForm";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import slugify from "@calcom/lib/slugify";
import { Avatar } from "@calcom/ui/components/avatar";
import { Button } from "@calcom/ui/components/button";
import { Label, TextField, TextArea } from "@calcom/ui/components/form";
import { ImageUploader } from "@calcom/ui/components/image-uploader";
import { showToast } from "@calcom/ui/components/toast";
const querySchema = z.object({
returnTo: z.string().optional(),
slug: z.string().optional(),
});
import { OnboardingCard } from "~/onboarding/components/OnboardingCard";
import { OnboardingLayout } from "~/onboarding/components/OnboardingLayout";
import { OnboardingBrowserView } from "~/onboarding/components/onboarding-browser-view";
import { useCreateTeam } from "~/onboarding/hooks/useCreateTeam";
import { useOnboardingStore } from "~/onboarding/store/onboarding-store";
import { ValidatedTeamSlug } from "~/onboarding/teams/details/validated-team-slug";
const CreateNewTeamPage = () => {
const params = useParamsWithFallback();
const parsedQuery = querySchema.safeParse(params);
type CreateNewTeamViewProps = {
userEmail: string;
};
export const CreateNewTeamView = ({ userEmail }: CreateNewTeamViewProps) => {
const router = useRouter();
const { t } = useLocale();
const store = useOnboardingStore();
const { teamDetails, teamBrand, setTeamDetails, setTeamBrand, resetOnboardingPreservingPlan } = store;
const { createTeam, isSubmitting } = useCreateTeam({ redirectBasePath: "/settings/teams/new" });
const isTeamBillingEnabledClient = !!process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY && HOSTED_CAL_FEATURES;
const flag = isTeamBillingEnabledClient
? {
submitLabel: "checkout",
}
: {
submitLabel: "continue",
};
const logoRef = useRef<HTMLInputElement>(null);
const [teamName, setTeamName] = useState("");
const [teamSlug, setTeamSlug] = useState("");
const [teamBio, setTeamBio] = useState("");
const [teamLogo, setTeamLogo] = useState<string>("");
const [isSlugValid, setIsSlugValid] = useState(false);
const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false);
const returnToParam =
(parsedQuery.success ? getSafeRedirectUrl(parsedQuery.data.returnTo) : "/teams") || "/teams";
// Reset onboarding store when entering the team creation flow from settings
useEffect(() => {
resetOnboardingPreservingPlan();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onSuccess = (data: RouterOutputs["viewer"]["teams"]["create"]) => {
// telemetry.event(flag.telemetryEvent);
router.push(data.url);
useEffect(() => {
setTeamName(teamDetails.name);
setTeamSlug(teamDetails.slug);
setTeamBio(teamDetails.bio);
setTeamLogo(teamBrand.logo || "");
if (teamDetails.slug) {
setIsSlugManuallyEdited(true);
}
}, [teamDetails, teamBrand]);
useEffect(() => {
if (!isSlugManuallyEdited && teamName) {
const slugifiedName = slugify(teamName);
setTeamSlug(slugifiedName);
}
}, [teamName, isSlugManuallyEdited]);
const handleSlugChange = (value: string) => {
setTeamSlug(value);
setIsSlugManuallyEdited(true);
};
const handleLogoChange = (newLogo: string) => {
if (logoRef.current) {
logoRef.current.value = newLogo;
}
setTeamLogo(newLogo);
};
const handleContinue = async (e?: FormEvent) => {
e?.preventDefault();
if (!isSlugValid) {
return;
}
posthog.capture("settings_team_details_continue_clicked", {
has_logo: !!teamLogo,
has_bio: !!teamBio,
});
setTeamDetails({
name: teamName,
slug: teamSlug,
bio: teamBio,
});
setTeamBrand({
logo: teamLogo || null,
});
try {
await createTeam();
} catch (error) {
const message = error instanceof Error ? error.message : t("something_went_wrong");
showToast(message, "error");
}
};
const handleCancel = () => {
posthog.capture("settings_team_details_cancel_clicked");
resetOnboardingPreservingPlan();
router.push("/teams");
};
return (
<CreateANewTeamForm
slug={parsedQuery.success ? parsedQuery.data.slug : ""}
onCancel={() => router.push(returnToParam)}
submitLabel={flag.submitLabel}
onSuccess={onSuccess}
/>
);
};
export const LayoutWrapper = ({ children }: { children: React.ReactNode }) => {
return (
<WizardLayout currentStep={1} maxSteps={3}>
{children}
</WizardLayout>
<OnboardingLayout userEmail={userEmail} currentStep={1} totalSteps={3}>
{/* Left column - Main content */}
<div className="flex h-full w-full flex-col gap-4">
<form onSubmit={handleContinue} className="flex h-full w-full flex-col gap-4">
<OnboardingCard
title={t("create_your_team")}
subtitle={t("team_onboarding_details_subtitle")}
footer={
<div className="flex w-full items-center justify-end gap-4">
<Button
type="button"
color="minimal"
className="rounded-[10px]"
onClick={handleCancel}
disabled={isSubmitting}>
{t("cancel")}
</Button>
<Button
type="submit"
color="primary"
className="rounded-[10px]"
disabled={!isSlugValid || !teamName || !teamSlug || isSubmitting}>
{t("continue")}
</Button>
</div>
}>
<div className="flex h-full w-full flex-col gap-4">
{/* Team Profile Picture */}
<div className="flex w-full flex-col gap-2">
<Label className="text-emphasis text-sm font-medium leading-4">{t("team_logo")}</Label>
<div className="flex flex-row items-center justify-start gap-2 rtl:justify-end">
<div className="relative shrink-0">
<Avatar
size="lg"
imageSrc={teamLogo || undefined}
alt={teamName || "Team"}
className="border-2 border-white"
/>
</div>
<input ref={logoRef} type="hidden" name="logo" id="logo" defaultValue={teamLogo} />
<ImageUploader
target="avatar"
id="team-logo-upload"
buttonMsg={t("upload")}
handleAvatarChange={handleLogoChange}
imageSrc={teamLogo}
/>
</div>
<p className="text-subtle text-xs font-normal leading-3">{t("onboarding_logo_size_hint")}</p>
</div>
{/* Team Name */}
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis mb-0 text-sm font-medium leading-4">{t("team_name")}</Label>
<TextField
name="name"
data-testid="team-name-input"
value={teamName}
onChange={(e) => setTeamName(e.target.value)}
placeholder="Acme Inc."
className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
/>
</div>
{/* Team Slug */}
<ValidatedTeamSlug
value={teamSlug}
onChange={handleSlugChange}
onValidationChange={setIsSlugValid}
/>
{/* Team Bio */}
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis mb-0 text-sm font-medium leading-4">{t("team_bio")}</Label>
<TextArea
value={teamBio}
onChange={(e) => setTeamBio(e.target.value)}
placeholder={t("team_bio_placeholder")}
className="border-default max-h-[150px] min-h-[80px] rounded-[10px] border px-2 py-1.5 text-sm"
rows={3}
/>
</div>
</div>
</OnboardingCard>
</form>
</div>
{/* Right column - Browser view */}
<OnboardingBrowserView teamSlug={teamSlug} name={teamName} bio={teamBio} avatar={teamLogo || null} />
</OnboardingLayout>
);
};
export default CreateNewTeamPage;
export const LayoutWrapper = ({ children }: { children: React.ReactNode }) => {
return <>{children}</>;
};
export default CreateNewTeamView;
@@ -0,0 +1,263 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useRef, useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { Dialog, DialogContent } from "@calcom/ui/components/dialog";
import { Icon } from "@calcom/ui/components/icon";
import { Logo } from "@calcom/ui/components/logo";
import { showToast } from "@calcom/ui/components/toast";
import { useOnboardingStore, type Invite } from "~/onboarding/store/onboarding-store";
/**
* Parse a CSV line handling quoted fields that may contain commas.
* Example: '"Name, Inc",admin' -> ['Name, Inc', 'admin']
*/
function parseCSVLine(line: string): string[] {
const result: string[] = [];
let current = "";
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === "," && !inQuotes) {
result.push(current.trim());
current = "";
} else {
current += char;
}
}
result.push(current.trim());
return result;
}
type CSVUploadModalProps = {
isOpen: boolean;
onClose: () => void;
};
export const CSVUploadModal = ({ isOpen, onClose }: CSVUploadModalProps) => {
const { t } = useLocale();
const router = useRouter();
const searchParams = useSearchParams();
const store = useOnboardingStore();
const { setTeamInvites, teamDetails, teamId } = store;
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
if (!file.name.endsWith(".csv")) {
showToast(t("please_upload_csv_file"), "error");
return;
}
setSelectedFile(file);
}
};
const handleDownloadTemplate = () => {
const headers = ["email", "role"];
const exampleRow = ["example@email.com", "MEMBER"];
const csvContent = [headers.join(","), exampleRow.join(",")].join("\n");
const blob = new Blob([csvContent], { type: "text/csv" });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "team_invite_template.csv";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
showToast(t("template_downloaded"), "success");
};
const handleUpload = async () => {
if (!selectedFile) {
showToast(t("please_select_file"), "error");
return;
}
setIsUploading(true);
try {
const text = await selectedFile.text();
const lines = text.split("\n").filter((line) => line.trim());
if (lines.length < 2) {
showToast(t("csv_file_empty"), "error");
setIsUploading(false);
return;
}
const headers = parseCSVLine(lines[0]).map((h) => h.toLowerCase());
const emailIndex = headers.indexOf("email");
const roleIndex = headers.indexOf("role");
if (emailIndex === -1) {
showToast(t("csv_missing_email_column"), "error");
setIsUploading(false);
return;
}
const parsedInvites = lines
.slice(1)
.map((line) => {
const values = parseCSVLine(line);
return {
email: values[emailIndex]?.trim(),
role: (roleIndex !== -1 ? values[roleIndex]?.trim().toUpperCase() : "MEMBER") as
| "MEMBER"
| "ADMIN",
};
})
.filter((invite) => invite.email && invite.email.length > 0);
if (parsedInvites.length === 0) {
showToast(t("csv_file_empty"), "error");
setIsUploading(false);
return;
}
const invites: Invite[] = parsedInvites.map((invite) => ({
email: invite.email,
team: teamDetails.name,
role: invite.role === "ADMIN" ? "ADMIN" : "MEMBER",
}));
setTeamInvites(invites);
showToast(t("csv_uploaded_successfully", { count: invites.length }), "success");
onClose();
// Navigate to email page to display the invites - use settings path
const teamIdParam = searchParams?.get("teamId") || teamId;
router.push(`/settings/teams/new/invite/email?teamId=${teamIdParam}`);
} catch (error) {
console.error("Error uploading CSV:", error);
showToast(t("error_uploading_csv"), "error");
} finally {
setIsUploading(false);
}
};
const handleClose = () => {
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent size="default" className="!p-0">
<div className="flex flex-col gap-4 p-6">
<div className="flex flex-col items-center gap-1">
<Logo className="h-10 w-auto" />
</div>
<div
className="relative mx-auto flex items-center justify-center"
style={{ width: 320, height: 180 }}>
<div
className="from-default to-muted border-subtle flex items-center justify-center rounded-2xl border bg-gradient-to-br p-8 shadow-sm"
style={{ width: 200, height: 150 }}>
{selectedFile ? (
<div className="flex flex-col items-center gap-3">
<div className="from-default to-muted border-subtle flex items-center justify-center rounded-full border bg-gradient-to-b p-4 shadow-sm">
<Icon name="file-text" className="text-emphasis" style={{ width: 32, height: 32 }} />
</div>
<div className="flex flex-col items-center gap-1">
<p className="text-emphasis text-sm font-medium">{selectedFile.name}</p>
<p className="text-subtle text-xs">{(selectedFile.size / 1024).toFixed(2)} KB</p>
</div>
</div>
) : (
<div className="flex flex-col items-center gap-3">
<div className="from-default to-muted border-subtle flex items-center justify-center rounded-full border bg-gradient-to-b p-4 shadow-sm">
<Icon
name="upload"
className="text-emphasis opacity-70"
style={{ width: 32, height: 32 }}
/>
</div>
<p className="text-subtle text-center text-sm">{t("upload_csv_subtitle")}</p>
</div>
)}
</div>
</div>
<div className="mb-2 flex flex-col gap-2 text-center">
<h2 className="font-heading text-emphasis text-2xl leading-none">{t("upload_csv_file")}</h2>
<p className="text-default text-sm leading-normal">{t("upload_csv_description")}</p>
</div>
<div className="mb-2 flex flex-col gap-3">
<div className="bg-cal-muted border-subtle flex items-center gap-3 rounded-lg border p-4">
<div className="from-default to-muted border-subtle flex items-center justify-center rounded-full border bg-gradient-to-b p-2 shadow-sm">
<Icon name="download" className="text-emphasis" style={{ width: 16, height: 16 }} />
</div>
<div className="flex-1">
<p className="text-emphasis text-sm font-medium">{t("need_template")}</p>
<p className="text-subtle text-xs">{t("download_csv_template_description")}</p>
</div>
<Button color="secondary" StartIcon="download" size="sm" onClick={handleDownloadTemplate}>
{t("download_template")}
</Button>
</div>
<div className="bg-cal-muted border-subtle flex items-center gap-3 rounded-lg border p-4">
<div className="from-default to-muted border-subtle flex items-center justify-center rounded-full border bg-gradient-to-b p-2 shadow-sm">
<Icon name="upload" className="text-emphasis" style={{ width: 16, height: 16 }} />
</div>
<div className="flex-1">
<p className="text-emphasis text-sm font-medium">{t("upload_your_file")}</p>
<p className="text-subtle text-xs">
{selectedFile ? selectedFile.name : t("upload_csv_description")}
</p>
</div>
<input
ref={fileInputRef}
type="file"
accept=".csv"
onChange={handleFileSelect}
className="hidden"
/>
<Button
color="secondary"
StartIcon={selectedFile ? "file-text" : "upload"}
size="sm"
onClick={() => fileInputRef.current?.click()}>
{t("choose_file")}
</Button>
</div>
</div>
</div>
<div className="bg-cal-muted border-subtle mt-6 flex items-center justify-between rounded-b-2xl border-t px-8 py-6">
<Button color="minimal" onClick={handleClose} disabled={isUploading}>
{t("cancel")}
</Button>
<Button
color="primary"
onClick={handleUpload}
disabled={!selectedFile || isUploading}
loading={isUploading}>
{t("upload")}
</Button>
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,208 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useEffect } from "react";
import { useForm, useFieldArray } from "react-hook-form";
import { z } from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { Form } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";
import { revalidateTeamsList } from "@calcom/web/app/(use-page-wrapper)/(main-nav)/teams/actions";
import { EmailInviteForm } from "~/onboarding/components/EmailInviteForm";
import { OnboardingCard } from "~/onboarding/components/OnboardingCard";
import { OnboardingLayout } from "~/onboarding/components/OnboardingLayout";
import { RoleSelector } from "~/onboarding/components/RoleSelector";
import { OnboardingInviteBrowserView } from "~/onboarding/components/onboarding-invite-browser-view";
import { useCreateTeam } from "~/onboarding/hooks/useCreateTeam";
import { useOnboardingStore, type InviteRole } from "~/onboarding/store/onboarding-store";
type TeamInviteEmailViewProps = {
userEmail: string;
};
type FormValues = {
invites: {
email: string;
role: InviteRole;
}[];
};
export const TeamInviteEmailView = ({ userEmail }: TeamInviteEmailViewProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const { t, i18n } = useLocale();
const utils = trpc.useUtils();
const store = useOnboardingStore();
const { teamInvites, setTeamInvites, teamDetails, setTeamId, teamId, resetOnboardingPreservingPlan } = store;
const [inviteRole, setInviteRole] = React.useState<InviteRole>("MEMBER");
const { inviteMembers, isSubmitting } = useCreateTeam({ redirectBasePath: "/settings/teams/new" });
// Read teamId from query params and store it (from payment callback or redirect)
useEffect(() => {
const teamIdParam = searchParams?.get("teamId");
if (teamIdParam && !teamId) {
const parsedTeamId = parseInt(teamIdParam, 10);
if (!isNaN(parsedTeamId)) {
setTeamId(parsedTeamId);
}
}
}, [searchParams, setTeamId, teamId]);
const formSchema = z.object({
invites: z.array(
z.object({
email: z.union([z.literal(""), z.string().email(t("invalid_email_address"))]),
role: z.enum(["MEMBER", "ADMIN"]),
})
),
});
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
invites:
teamInvites.length > 0
? teamInvites.map((inv) => ({ email: inv.email, role: inv.role }))
: [{ email: "", role: inviteRole }],
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "invites",
});
const handleContinue = async (data: FormValues) => {
const teamIdParam = searchParams?.get("teamId");
const parsedTeamId = !teamId ? parseInt(teamIdParam || "", 10) : teamId;
if (!parsedTeamId) {
console.log("Team ID is missing. Please go back and create your team first.");
showToast(
t("team_id_missing") || "Team ID is missing. Please go back and create your team first.",
"error"
);
return;
}
const invitesWithTeam = data.invites.map((invite) => ({
email: invite.email,
role: invite.role,
team: teamDetails.name,
}));
setTeamInvites(invitesWithTeam);
// Filter out empty emails and invite members
const validInvites = data.invites.filter((invite) => invite.email && invite.email.trim().length > 0);
if (validInvites.length > 0) {
try {
await inviteMembers(
validInvites.map((invite) => ({
email: invite.email,
role: invite.role,
})),
i18n.language
);
// Invalidate both server-side cache and client-side tRPC cache
await revalidateTeamsList();
await utils.viewer.teams.list.invalidate();
resetOnboardingPreservingPlan();
router.replace("/teams");
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : t("something_went_wrong") || "Something went wrong";
showToast(errorMessage, "error");
}
} else {
// No invites, skip to teams page
await revalidateTeamsList();
await utils.viewer.teams.list.invalidate();
resetOnboardingPreservingPlan();
router.replace("/teams");
}
};
const handleSkip = async () => {
setTeamInvites([]);
await revalidateTeamsList();
await utils.viewer.teams.list.invalidate();
resetOnboardingPreservingPlan();
router.replace("/teams");
};
// Watch form values to pass to browser view for real-time updates
const watchedInvites = form.watch("invites");
const hasValidInvites = watchedInvites.some((invite) => {
return invite.email && invite.email.trim().length > 0;
});
return (
<OnboardingLayout userEmail={userEmail} currentStep={3} totalSteps={3}>
{/* Left column - Main content */}
<div className="flex h-full w-full flex-col gap-4">
<Form form={form} handleSubmit={handleContinue} className="flex h-full w-full flex-col gap-4">
<OnboardingCard
title={t("invite")}
subtitle={t("team_invite_subtitle")}
footer={
<div className="flex w-full items-center justify-end gap-4">
<div className="flex items-center gap-2">
<Button
type="button"
color="minimal"
className="rounded-[10px]"
onClick={handleSkip}
disabled={isSubmitting}
data-testid="skip-invite-button">
{t("onboarding_skip_for_now")}
</Button>
<Button
type="submit"
color="primary"
className="rounded-[10px]"
disabled={!hasValidInvites || isSubmitting}
loading={isSubmitting}>
{t("continue")}
</Button>
</div>
</div>
}>
<div className="flex w-full flex-col gap-4 px-1">
<div className="flex w-full flex-col gap-4">
<EmailInviteForm
fields={fields}
append={append}
remove={remove}
defaultRole={inviteRole}
emailPlaceholder="rick@cal.com"
/>
<RoleSelector
value={inviteRole}
onValueChange={(value) => {
setInviteRole(value);
fields.forEach((_, index) => {
form.setValue(`invites.${index}.role`, value);
});
}}
showInfoBadge
/>
</div>
</div>
</OnboardingCard>
</Form>
</div>
{/* Right column - Browser view */}
<OnboardingInviteBrowserView teamName={teamDetails.name} watchedInvites={watchedInvites} />
</OnboardingLayout>
);
};
@@ -0,0 +1,125 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import posthog from "posthog-js";
import React, { useEffect } from "react";
import { useFlags } from "@calcom/features/flags/hooks";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { InviteOptions } from "~/onboarding/components/InviteOptions";
import { OnboardingCard } from "~/onboarding/components/OnboardingCard";
import { OnboardingLayout } from "~/onboarding/components/OnboardingLayout";
import { OnboardingInviteBrowserView } from "~/onboarding/components/onboarding-invite-browser-view";
import { useCreateTeam } from "~/onboarding/hooks/useCreateTeam";
import { useOnboardingStore } from "~/onboarding/store/onboarding-store";
import { CSVUploadModal } from "./csv-upload-modal";
type TeamInviteViewProps = {
userEmail: string;
};
export const TeamInviteView = ({ userEmail }: TeamInviteViewProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useLocale();
const flags = useFlags();
const store = useOnboardingStore();
const { setTeamInvites, teamDetails, setTeamId, teamId, resetOnboardingPreservingPlan } = store;
const { isSubmitting } = useCreateTeam({ redirectBasePath: "/settings/teams/new" });
const [isCSVModalOpen, setIsCSVModalOpen] = React.useState(false);
// Read teamId from query params and store it (from payment callback)
useEffect(() => {
const teamIdParam = searchParams?.get("teamId");
if (teamIdParam) {
const parsedTeamId = parseInt(teamIdParam, 10);
if (!isNaN(parsedTeamId)) {
setTeamId(parsedTeamId);
}
}
}, [searchParams, setTeamId]);
const googleWorkspaceEnabled = flags["google-workspace-directory"];
const handleGoogleWorkspaceConnect = () => {
// TODO: Implement Google Workspace connection
console.log("Connect Google Workspace");
};
const handleInviteViaEmail = () => {
router.push(`/settings/teams/new/invite/email?teamId=${teamId}`);
};
const handleUploadCSV = () => {
setIsCSVModalOpen(true);
};
const handleCopyInviteLink = () => {
// Disabled for now as per requirements
console.log("Copy invite link - disabled");
};
const handleSkip = async () => {
posthog.capture("settings_team_invite_skip_clicked");
setTeamInvites([]);
resetOnboardingPreservingPlan();
// Redirect to teams page after skipping
router.push("/teams");
};
const handleBack = () => {
posthog.capture("settings_team_invite_back_clicked");
router.push("/settings/teams/new");
};
return (
<>
<OnboardingLayout userEmail={userEmail} currentStep={2} totalSteps={3}>
{/* Left column - Main content */}
<div className="flex w-full flex-col gap-4">
<OnboardingCard
title={t("invite")}
subtitle={t("onboarding_invite_subtitle")}
footer={
<div className="flex w-full items-center justify-between gap-4">
<Button
color="minimal"
className="rounded-[10px]"
onClick={handleBack}
disabled={isSubmitting}>
{t("back")}
</Button>
<div className="flex items-center gap-2">
<Button
color="minimal"
className="rounded-[10px]"
onClick={handleSkip}
disabled={isSubmitting}>
{t("onboarding_skip_for_now")}
</Button>
</div>
</div>
}>
<InviteOptions
onInviteViaEmail={handleInviteViaEmail}
onUploadCSV={handleUploadCSV}
onCopyInviteLink={handleCopyInviteLink}
onConnectGoogleWorkspace={googleWorkspaceEnabled ? handleGoogleWorkspaceConnect : undefined}
isSubmitting={isSubmitting}
/>
</OnboardingCard>
</div>
{/* Right column - Browser view */}
<OnboardingInviteBrowserView teamName={teamDetails.name} />
</OnboardingLayout>
{/* CSV Upload Modal */}
<CSVUploadModal isOpen={isCSVModalOpen} onClose={() => setIsCSVModalOpen(false)} />
</>
);
};
+3
View File
@@ -8,6 +8,7 @@ import { getBookerBaseUrlSync } from "@calcom/features/ee/organizations/lib/getB
import { useFlagMap } from "@calcom/features/flags/context/provider";
import { IS_VISUAL_REGRESSION_TESTING, ENABLE_PROFILE_SWITCHER } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useIsStandalone } from "@calcom/lib/hooks/useIsStandalone";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { UserPermissionRole } from "@calcom/prisma/enums";
import classNames from "@calcom/ui/classNames";
@@ -43,11 +44,13 @@ export type SideBarProps = {
export function SideBarContainer({ bannersHeight, isPlatformUser = false }: SideBarContainerProps) {
const { status, data } = useSession();
const isStandalone = useIsStandalone();
// Make sure that Sidebar is rendered optimistically so that a refresh of pages when logged in have SideBar from the beginning.
// This improves the experience of refresh on app store pages(when logged in) which are SSG.
// Though when logged out, app store pages would temporarily show SideBar until session status is confirmed.
if (status !== "loading" && status !== "authenticated") return null;
if (isStandalone) return null;
return <SideBar isPlatformUser={isPlatformUser} bannersHeight={bannersHeight} user={data?.user} />;
}
+3 -1
View File
@@ -2,6 +2,7 @@ import { useSession } from "next-auth/react";
import Link from "next/link";
import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { useIsStandalone } from "@calcom/lib/hooks/useIsStandalone";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Icon } from "@calcom/ui/components/icon";
import { Logo } from "@calcom/ui/components/logo";
@@ -11,7 +12,8 @@ import { UserDropdown } from "./user-dropdown/UserDropdown";
export function TopNavContainer() {
const { status } = useSession();
if (status !== "authenticated") return null;
const isStandalone = useIsStandalone();
if (status !== "authenticated" || isStandalone) return null;
return <TopNav />;
}
@@ -6,6 +6,7 @@ import {
useOrgBranding,
type OrganizationBranding,
} from "@calcom/features/ee/organizations/context/provider";
import { useIsStandalone } from "@calcom/lib/hooks/useIsStandalone";
import classNames from "@calcom/ui/classNames";
import { useHasPaidPlan } from "@calcom/web/modules/billing/hooks/useHasPaidPlan";
@@ -230,7 +231,8 @@ export function MobileNavigationContainer({
isPlatformNavigation?: boolean;
}) {
const { status } = useSession();
if (status !== "authenticated") return null;
const isStandalone = useIsStandalone();
if (status !== "authenticated" || isStandalone) return null;
return <MobileNavigation isPlatformNavigation={isPlatformNavigation} />;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "6.1.1",
"version": "6.1.2",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+9 -7
View File
@@ -26,16 +26,18 @@ test.describe("Can signup from a team invite", async () => {
await page.goto("/settings/teams/new");
await page.waitForLoadState("networkidle");
// Create a new team
await page.locator('input[name="name"]').fill(teamName);
await page.locator('input[name="slug"]').fill(teamName);
// Create a new team (new onboarding-v3 style flow)
await page.locator('[data-testid="team-name-input"]').fill(teamName);
await page.locator('button[type="submit"]').click();
// Add new member to team
await page.click('[data-testid="new-member-button"]');
await page.fill('input[id="inviteUser"]', testUser.email);
// Wait for the invite email page
await page.waitForURL(/\/settings\/teams\/new\/invite\/email.*$/i);
await page.waitForLoadState("networkidle");
// Add new member to team via email invite form
await page.locator('input[type="email"]').first().fill(testUser.email);
const submitPromise = page.waitForResponse("/api/trpc/teams/inviteMember?batch=1");
await page.getByTestId("invite-new-member-button").click();
await page.locator('button[type="submit"]').click();
const response = await submitPromise;
expect(response.status()).toBe(200);
@@ -29,51 +29,30 @@ test.describe("Teams", () => {
// Click text=Create Team
await page.locator("text=Create a new Team").click();
await page.waitForLoadState("networkidle");
// Fill input[name="name"]
await page.locator('input[name="name"]').fill(`${user.username}'s Team`);
// Fill team name input (new onboarding-v3 style flow)
await page.locator('[data-testid="team-name-input"]').fill(`${user.username}'s Team`);
// Click text=Continue
await page.click("[type=submit]");
// TODO: Figure out a way to make this more reliable
// eslint-disable-next-line playwright/no-conditional-in-test
if (IS_TEAM_BILLING_ENABLED) await fillStripeTestCheckout(page);
await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/onboard-members.*$/i);
await page.waitForSelector('[data-testid="pending-member-list"]');
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
// Wait for the invite email page (new flow)
await expect(page).toHaveURL(/\/settings\/teams\/new\/invite\/email.*$/i);
await page.waitForLoadState("networkidle");
});
await test.step("Can add members", async () => {
await page.getByTestId("new-member-button").click();
await page.locator('[placeholder="email\\@example\\.com"]').fill(inviteeEmail);
await page.getByTestId("invite-new-member-button").click();
await expect(page.getByTestId("pending-member-item").filter({ hasText: inviteeEmail })).toBeVisible();
// locator.count() does not await for the expected number of elements
// https://github.com/microsoft/playwright/issues/14278
// using toHaveCount() is more reliable
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
await test.step("Can add members via email invite", async () => {
// Add member via email invite form
await page.locator('input[type="email"]').first().fill(inviteeEmail);
await page.click("[type=submit]");
await page.waitForLoadState("networkidle");
// Wait for redirect to teams page
await expect(page).toHaveURL(/\/teams$/i);
});
await test.step("Can remove members", async () => {
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
const lastRemoveMemberButton = page.getByTestId("remove-member-button").last();
await lastRemoveMemberButton.click();
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
// Cleanup here since this user is created without our fixtures.
await prisma.user.delete({ where: { email: inviteeEmail } });
});
await test.step("Can finish team invitation", async () => {
await page.getByTestId("publish-button").click();
await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/event-type$/i);
});
await test.step("Can finish team creation", async () => {
const locator = page.locator('div:has(input[value="ROUND_ROBIN"]) > button');
await expect(locator).toBeVisible();
await locator.click();
await page.fill("[name=title]", "roundRobin");
await page.getByTestId("finish-button").click();
await test.step("Can navigate to team settings", async () => {
// Click on the team to go to settings
await page.locator(`text=${user.username}'s Team`).click();
await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i);
});
@@ -82,6 +61,13 @@ test.describe("Teams", () => {
await page.getByTestId("dialog-confirmation").click();
await page.waitForURL("/teams");
expect(await page.locator(`text=${user.username}'s Team`).count()).toEqual(0);
// Cleanup the invited user since they were created without our fixtures
const invitedUser = await prisma.user.findUnique({ where: { email: inviteeEmail } });
// eslint-disable-next-line playwright/no-conditional-in-test
if (invitedUser) {
await prisma.user.delete({ where: { email: inviteeEmail } });
}
});
});
});
+86 -49
View File
@@ -1,61 +1,92 @@
import { expect } from "@playwright/test";
import type { Page } from "@playwright/test";
import prisma from "@calcom/prisma";
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { test } from "./lib/fixtures";
import { selectFirstAvailableTimeSlotNextMonth, submitAndWaitForResponse } from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
test.afterEach(({ users }) => users.deleteAll());
async function goToAppsTab(page: Page, eventTypeId?: number) {
async function goToAppsTab(page: Page, eventTypeId?: number): Promise<void> {
await page.goto(`event-types/${eventTypeId}?tabName=apps`);
await expect(page.getByTestId("vertical-tab-apps").first()).toHaveAttribute("aria-current", "page");
}
/**
* Ensures an app is enabled in the App table and returns a cleanup function.
* This is needed because apps without valid keys are now disabled during seeding.
* For E2E tests, we need to enable the app to test the UI behavior.
* The cleanup function restores the original enabled state to avoid side-effects.
*/
async function ensureAppEnabled(appSlug: string): Promise<() => Promise<void>> {
const app = await prisma.app.findUnique({
where: { slug: appSlug },
select: { enabled: true },
});
const originalEnabled = app?.enabled ?? false;
await prisma.app.update({
where: { slug: appSlug },
data: { enabled: true },
});
return async () => {
await prisma.app.update({
where: { slug: appSlug },
data: { enabled: originalEnabled },
});
};
}
// biome-ignore lint/complexity/noExcessiveLinesPerFunction: E2E test suites naturally have many test cases
test.describe("Payment app", () => {
test("Should be able to edit alby price, currency", async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
const paymentEvent = user.eventTypes.find((item) => item.slug === "paid");
expect(paymentEvent).not.toBeNull();
await prisma.credential.create({
data: {
type: "alby_payment",
appId: "alby",
userId: user.id,
key: {
account_id: "random",
account_email: "random@example.com",
webhook_endpoint_id: "ep_randomString",
webhook_endpoint_secret: "whsec_randomString",
account_lightning_address: "random@getalby.com",
// Ensure alby app is enabled (it may be disabled if keys are not configured)
const cleanupAlbyApp = await ensureAppEnabled("alby");
try {
await prisma.credential.create({
data: {
type: "alby_payment",
appId: "alby",
userId: user.id,
key: {
account_id: "random",
account_email: "random@example.com",
webhook_endpoint_id: "ep_randomString",
webhook_endpoint_secret: "whsec_randomString",
account_lightning_address: "random@getalby.com",
},
},
},
});
});
await goToAppsTab(page, paymentEvent?.id);
await goToAppsTab(page, paymentEvent?.id);
await page.locator("#event-type-form").getByRole("switch").click();
await page.getByPlaceholder("Price").click();
await page.getByPlaceholder("Price").fill("200");
await page.getByText("SatoshissatsCurrencyBTCPayment optionCollect payment on booking").click();
await submitAndWaitForResponse(page, "/api/trpc/eventTypesHeavy/update?batch=1", {
action: () => page.locator("[data-testid=update-eventtype]").click(),
});
await page.locator("#event-type-form").getByRole("switch").click();
await page.getByPlaceholder("Price").click();
await page.getByPlaceholder("Price").fill("200");
await page.getByText("SatoshissatsCurrencyBTCPayment optionCollect payment on booking").click();
await submitAndWaitForResponse(page, "/api/trpc/eventTypesHeavy/update?batch=1", {
action: () => page.locator("[data-testid=update-eventtype]").click(),
});
await page.goto(`${user.username}/${paymentEvent?.slug}`);
await page.goto(`${user.username}/${paymentEvent?.slug}`);
// expect 200 sats to be displayed in page
await expect(page.locator("text=200 sats").first()).toBeVisible();
// expect 200 sats to be displayed in page
await expect(page.locator("text=200 sats").first()).toBeVisible();
await selectFirstAvailableTimeSlotNextMonth(page);
await expect(page.locator("text=200 sats").first()).toBeVisible();
await selectFirstAvailableTimeSlotNextMonth(page);
await expect(page.locator("text=200 sats").first()).toBeVisible();
// go to /event-types and check if the price is 200 sats
await page.goto(`event-types/`);
await expect(page.locator("text=200 sats").first()).toBeVisible();
// go to /event-types and check if the price is 200 sats
await page.goto(`event-types/`);
await expect(page.locator("text=200 sats").first()).toBeVisible();
} finally {
await cleanupAlbyApp();
}
});
test("Should be able to edit stripe price, currency", async ({ page, users }) => {
@@ -155,26 +186,32 @@ test.describe("Payment app", () => {
await user.apiLogin();
const paymentEvent = user.eventTypes.find((item) => item.slug === "paid");
expect(paymentEvent).not.toBeNull();
await prisma.credential.create({
data: {
type: "alby_payment",
appId: "alby",
userId: user.id,
key: {},
},
});
// Ensure alby app is enabled (it may be disabled if keys are not configured)
const cleanupAlbyApp = await ensureAppEnabled("alby");
try {
await prisma.credential.create({
data: {
type: "alby_payment",
appId: "alby",
userId: user.id,
key: {},
},
});
await goToAppsTab(page, paymentEvent?.id);
await goToAppsTab(page, paymentEvent?.id);
await page.locator("#event-type-form").getByRole("switch").click();
await page.locator("#event-type-form").getByRole("switch").click();
// expect text "This app has not been setup yet" to be displayed
expect(await page.locator("text=This app has not been setup yet").first()).toBeTruthy();
// expect text "This app has not been setup yet" to be displayed
expect(await page.locator("text=This app has not been setup yet").first()).toBeTruthy();
await page.getByRole("button", { name: "Setup" }).click();
await page.getByRole("button", { name: "Setup" }).click();
// Expect "Connect with Alby" to be displayed
expect(await page.locator("text=Connect with Alby").first()).toBeTruthy();
// Expect "Connect with Alby" to be displayed
expect(await page.locator("text=Connect with Alby").first()).toBeTruthy();
} finally {
await cleanupAlbyApp();
}
});
test("Should display App is not setup already for paypal", async ({ page, users }) => {
+14 -14
View File
@@ -139,11 +139,11 @@ test.describe("Teams - NonOrg", () => {
const uniqueName = "test-unique-team-name";
// Go directly to the create team page
// Go directly to the create team page (new onboarding-v3 style flow)
await page.goto("/settings/teams/new");
await page.waitForLoadState("networkidle");
// Fill input[name="name"]
await page.locator('input[name="name"]').fill(uniqueName);
// Fill team name input
await page.locator('[data-testid="team-name-input"]').fill(uniqueName);
await page.click("[type=submit]");
// cleanup
@@ -163,22 +163,22 @@ test.describe("Teams - NonOrg", () => {
// Click text=Create Team
await page.locator("text=Create Team").click();
await page.waitForLoadState("networkidle");
// Fill input[name="name"]
await page.locator('input[name="name"]').fill(uniqueName);
// Click text=Continue
// Fill team name input (new onboarding-v3 style flow)
await page.locator('[data-testid="team-name-input"]').fill(uniqueName);
// Click Continue
await page.click("[type=submit]");
// TODO: Figure out a way to make this more reliable
// eslint-disable-next-line playwright/no-conditional-in-test
if (IS_TEAM_BILLING_ENABLED) await fillStripeTestCheckout(page);
await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members.*$/i);
// Wait for the page to fully load and the publish button to be visible
// Wait for the invite email page
await page.waitForURL(/\/settings\/teams\/new\/invite\/email.*$/i);
await page.waitForLoadState("networkidle");
const publishButton = page.locator("[data-testid=publish-button]");
await publishButton.waitFor({ state: "visible", timeout: 10000 });
await publishButton.click();
await page.waitForURL(/\/settings\/teams\/(\d+)\/event-type*$/i);
await page.locator("[data-testid=handle-later-button]").click();
await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i);
// Skip the invite step
const skipButton = page.locator("[data-testid=skip-invite-button]");
await skipButton.waitFor({ state: "visible", timeout: 10000 });
await skipButton.click();
// Wait for redirect to teams page
await page.waitForURL(/\/teams$/i);
});
await test.step("Can access user and team with same slug", async () => {
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+21 -4
View File
@@ -130,8 +130,9 @@
"please_try_again_later_or_book_another_slot": "You've just tried connecting now. Please try again later in {{remaining}} minutes or book another slot from the booking page.",
"unavailable_timeslot_title": "Unavailable timeslot",
"booking_time_out_of_bounds_error": "The event type cannot be booked at this time. Could you try another time slot?",
"bookings_v3_title": "Enhanced bookings",
"bookings_v3_description": "Switch between list and calendar views with details in a slide-over.",
"bookings_v3_title": "Try the new bookings page",
"bookings_v3_name": "Enhanced bookings",
"bookings_v3_description": "A redesigned booking page including a calendar view.",
"request_body_end_time_internal_error": "Internal Error. Request body does not contain end time",
"create_calendar_event_error": "Unable to create calendar event in organizer's calendar",
"update_calendar_event_error": "Unable to update calendar event.",
@@ -3865,11 +3866,11 @@
"license_key": "License key",
"license_selection_title": "Choose your license",
"license_selection_description": "Select how you want to license your Cal.com deployment",
"agplv3_license_description": "Use Cal.com under the AGPLv3 open source license",
"agplv3_license_description": "Use Cal.com under the AGPLv3 open source license",
"enter_license_key": "Enter license key",
"enter_your_license_key": "Access to all enterprise features",
"enter_existing_license": "Starting at $15/user per month",
"enter_license_key_description": "Access to all enterprise features",
"enter_license_key_description": "Access to all enterprise features",
"license_key_placeholder": "Enter your license key",
"save_license_key": "Save license key",
"signature_token_optional": "Signature token (optional)",
@@ -4333,6 +4334,7 @@
"feature_blocked_by_team_warning": "This feature is disabled by your team.",
"feature_blocked_by_org_warning": "This feature is disabled by your organization.",
"feature_no_explicit_enablement_warning": "Turn it on for yourself, or ask your team or organization admin to enable it for everyone.",
"feature_user_only_not_allowed_warning": "This feature needs approval from your team or organization.",
"no_opt_in_features_available": "No opt-in features are currently available",
"enabled_via_auto_opt_in": "Enabled via auto opt-in",
"account_already_exists_please_login": "An account with this email already exists. Please log in to accept the invitation.",
@@ -4340,5 +4342,20 @@
"pbac_resource_feature_opt_in": "Feature Opt-In",
"pbac_desc_view_feature_opt_in": "View feature opt-in settings",
"pbac_desc_update_feature_opt_in": "Manage feature opt-in settings",
"try_it": "Try it",
"enable_for": "Enable for",
"entire_organization": "Entire organization",
"my_teams_count": "My teams ({{count}})",
"just_for_me": "Just for me",
"for_me": "For me",
"auto_opt_in_future_features_org": "Automatically opt into future features for my organization",
"auto_opt_in_future_features_teams": "Automatically opt into future features for my teams",
"auto_opt_in_future_features_user": "Automatically opt into future features",
"feature_enabled_successfully": "Feature enabled successfully!",
"feature_enabled_description": "You can manage this feature in your settings.",
"view_settings": "View Settings",
"error_enabling_feature": "Error enabling feature. Please try again.",
"set_organizer_as_contact_owner": "Set booking organizer as contact owner",
"overwrite_existing_contact_owner": "Overwrite existing contact owner",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS":"↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -1,18 +1,24 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import prismaMock from "@calcom/testing/lib/__mocks__/prismaMock";
import updateChildrenEventTypes from "@calcom/features/ee/managed-event-types/lib/handleChildrenEventTypes";
import logger from "@calcom/lib/logger";
import { buildEventType } from "@calcom/lib/test/builder";
import type { EventType, Prisma, User, WorkflowsOnEventTypes } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
import { describe, expect, it, vi } from "vitest";
import updateChildrenEventTypes from "@calcom/features/ee/managed-event-types/lib/handleChildrenEventTypes";
import { buildEventType } from "@calcom/lib/test/builder";
import type { EventType, User, WorkflowsOnEventTypes } from "@calcom/prisma/client";
import type { Prisma } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
// Mock the logger module
vi.mock("@calcom/lib/logger", () => ({
default: {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
},
}));
// Helper to setup transaction mock that executes the callback with the prisma mock
const setupTransactionMock = () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prismaMock.$transaction.mockImplementation(async (callback) => {
if (typeof callback === "function") {
return await callback(prismaMock);
@@ -98,7 +104,7 @@ describe("handleChildrenEventTypes", () => {
it("Returns message 'Missing event type'", async () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// @ts-expect-error
prismaMock.eventType.findFirst.mockImplementation(() => {
return new Promise((resolve) => {
resolve(null);
@@ -124,8 +130,6 @@ describe("handleChildrenEventTypes", () => {
describe("Happy paths", () => {
it("Adds new users", async () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const {
schedulingType,
id,
@@ -209,8 +213,6 @@ describe("handleChildrenEventTypes", () => {
});
it("Updates old users", async () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const {
schedulingType,
id,
@@ -233,6 +235,14 @@ describe("handleChildrenEventTypes", () => {
metadata: { managedEventConfig: {} },
locations: [],
});
// Mock findMany for existing records lookup (new batch update optimization)
// @ts-expect-error - partial mock for test purposes
prismaMock.eventType.findMany.mockResolvedValue([{ userId: 4, metadata: {} }]);
// @ts-expect-error - partial mock for test purposes
prismaMock.eventType.update.mockResolvedValue({ id: 100 });
const result = await updateChildrenEventTypes({
eventTypeId: 1,
oldEventType: { children: [{ userId: 4 }], team: { name: "" } },
@@ -301,6 +311,14 @@ describe("handleChildrenEventTypes", () => {
metadata: { managedEventConfig: {} },
locations: [],
});
// Mock findMany for existing records lookup (new batch update optimization)
// @ts-expect-error - partial mock for test purposes
prismaMock.eventType.findMany.mockResolvedValue([{ userId: 4, metadata: {} }]);
// @ts-expect-error - partial mock for test purposes
prismaMock.eventType.update.mockResolvedValue({ id: 101 });
const result = await updateChildrenEventTypes({
eventTypeId: 1,
oldEventType: { children: [{ userId: 4 }, { userId: 1 }], team: { name: "" } },
@@ -324,8 +342,6 @@ describe("handleChildrenEventTypes", () => {
describe("Slug conflicts", () => {
it("Deletes existent event types for new users added", async () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const {
schedulingType,
id,
@@ -412,8 +428,6 @@ describe("handleChildrenEventTypes", () => {
expect(result.deletedExistentEventTypes).toEqual([123]);
});
it("Deletes existent event types for old users updated", async () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const {
schedulingType,
id,
@@ -437,6 +451,14 @@ describe("handleChildrenEventTypes", () => {
metadata: { managedEventConfig: {} },
locations: [],
});
// Mock findMany for existing records lookup (new batch update optimization)
// @ts-expect-error - partial mock for test purposes
prismaMock.eventType.findMany.mockResolvedValue([{ userId: 4, metadata: {} }]);
// @ts-expect-error - partial mock for test purposes
prismaMock.eventType.update.mockResolvedValue({ id: 102 });
prismaMock.eventType.deleteMany.mockResolvedValue([123] as unknown as Prisma.BatchPayload);
const result = await updateChildrenEventTypes({
eventTypeId: 1,
@@ -535,6 +557,9 @@ describe("handleChildrenEventTypes", () => {
};
prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]);
// Mock findMany for existing records lookup (new batch update optimization)
prismaMock.eventType.findMany.mockResolvedValue([{ userId: 4, metadata: {} } as EventType]);
// Mock the event type that will be returned for existing users
const mockUpdatedEventType = {
id: 2,
@@ -657,8 +682,385 @@ describe("handleChildrenEventTypes", () => {
// Since findMany returned empty array, all workflow-eventType pairs should be created
expect(prismaMock.workflowsOnEventTypes.createMany).toHaveBeenCalledWith({
data: [{ eventTypeId: 2, workflowId: 11 }],
skipDuplicates: false,
skipDuplicates: true,
});
});
});
describe("Batch execution and retry behavior", () => {
it("processes updates in batches of 50", async () => {
// Create 120 old users to test batching (should result in 3 batches: 50, 50, 20)
const oldUserIds = Array.from({ length: 120 }, (_, i) => i + 1);
const children = oldUserIds.map((id) => ({
hidden: false,
owner: { id, name: `User ${id}`, email: `user${id}@test.com`, eventTypeSlugs: [] },
}));
mockFindFirstEventType({
metadata: { managedEventConfig: {} },
locations: [],
});
// Mock findMany for existing records lookup
// @ts-expect-error - partial mock for test purposes
prismaMock.eventType.findMany.mockResolvedValue(oldUserIds.map((userId) => ({ userId, metadata: {} })));
// Track update calls to verify batching
let updateCallCount = 0;
// @ts-expect-error - partial mock for test purposes
prismaMock.eventType.update.mockImplementation(async () => {
updateCallCount++;
return {
id: updateCallCount,
userId: updateCallCount,
title: "Test",
slug: "test",
length: 30,
hidden: false,
position: 0,
teamId: null,
schedulingType: SchedulingType.MANAGED,
scheduleId: null,
price: 0,
currency: "usd",
slotInterval: null,
metadata: {},
successRedirectUrl: null,
bookingFields: [],
createdAt: new Date(),
updatedAt: new Date(),
};
});
const result = await updateChildrenEventTypes({
eventTypeId: 1,
oldEventType: { children: oldUserIds.map((userId) => ({ userId })), team: { name: "" } },
children,
updatedEventType: { schedulingType: "MANAGED", slug: "something" },
currentUserId: 1,
prisma: prismaMock,
profileId: null,
updatedValues: {},
});
// Verify all 120 users were processed
expect(result.oldUserIds).toHaveLength(120);
expect(updateCallCount).toBe(120);
// Verify findMany was called once for bulk fetch optimization
expect(prismaMock.eventType.findMany).toHaveBeenCalledTimes(1);
});
it("retries failed updates once", async () => {
// Reset logger mocks before test
vi.mocked(logger.info).mockClear();
vi.mocked(logger.error).mockClear();
mockFindFirstEventType({
metadata: { managedEventConfig: {} },
locations: [],
});
prismaMock.eventType.findMany.mockResolvedValue([
{ userId: 1, metadata: {} } as EventType,
{ userId: 2, metadata: {} } as EventType,
{ userId: 3, metadata: {} } as EventType,
]);
// Track calls per userId to simulate failure on first attempt, success on retry
const callCountByUser: Record<number, number> = {};
// @ts-expect-error - partial mock for test purposes
prismaMock.eventType.update.mockImplementation(async (args) => {
const userId = (args.where as { userId_parentId: { userId: number } }).userId_parentId.userId;
callCountByUser[userId] = (callCountByUser[userId] || 0) + 1;
// User 2 fails on first attempt, succeeds on retry
if (userId === 2 && callCountByUser[userId] === 1) {
throw new Error("Simulated database error");
}
return {
id: userId,
userId,
title: "Test",
slug: "test",
length: 30,
hidden: false,
position: 0,
teamId: null,
schedulingType: SchedulingType.MANAGED,
scheduleId: null,
price: 0,
currency: "usd",
slotInterval: null,
metadata: {},
successRedirectUrl: null,
bookingFields: [],
createdAt: new Date(),
updatedAt: new Date(),
} as unknown as EventType;
});
const result = await updateChildrenEventTypes({
eventTypeId: 1,
oldEventType: { children: [{ userId: 1 }, { userId: 2 }, { userId: 3 }], team: { name: "" } },
children: [
{ hidden: false, owner: { id: 1, name: "", email: "", eventTypeSlugs: [] } },
{ hidden: false, owner: { id: 2, name: "", email: "", eventTypeSlugs: [] } },
{ hidden: false, owner: { id: 3, name: "", email: "", eventTypeSlugs: [] } },
],
updatedEventType: { schedulingType: "MANAGED", slug: "something" },
currentUserId: 1,
prisma: prismaMock,
profileId: null,
updatedValues: {},
});
// All 3 users should be in oldUserIds (successful after retry)
expect(result.oldUserIds).toEqual([1, 2, 3]);
// User 2 should have been called twice (initial + retry)
expect(callCountByUser[2]).toBe(2);
// Verify retry log was called
expect(logger.info).toHaveBeenCalledWith("Retrying 1 failed updates...");
});
it("continues processing when some updates fail permanently", async () => {
// Reset logger mocks before test
vi.mocked(logger.info).mockClear();
vi.mocked(logger.error).mockClear();
mockFindFirstEventType({
metadata: { managedEventConfig: {} },
locations: [],
});
// Mock findMany for existing records lookup
prismaMock.eventType.findMany.mockResolvedValue([
{ userId: 1, metadata: {} } as EventType,
{ userId: 2, metadata: {} } as EventType,
{ userId: 3, metadata: {} } as EventType,
]);
// @ts-expect-error - partial mock for test purposes
prismaMock.eventType.update.mockImplementation(async (args: Prisma.EventTypeUpdateArgs) => {
const userId = (args.where as { userId_parentId: { userId: number } }).userId_parentId.userId;
// User 2 always fails (permanent failure)
if (userId === 2) {
throw new Error("Permanent database error");
}
return {
id: userId,
userId,
title: "Test",
slug: "test",
length: 30,
hidden: false,
position: 0,
teamId: null,
schedulingType: SchedulingType.MANAGED,
scheduleId: null,
price: 0,
currency: "usd",
slotInterval: null,
metadata: {},
successRedirectUrl: null,
bookingFields: [],
createdAt: new Date(),
updatedAt: new Date(),
} as unknown as EventType;
});
const result = await updateChildrenEventTypes({
eventTypeId: 1,
oldEventType: { children: [{ userId: 1 }, { userId: 2 }, { userId: 3 }], team: { name: "" } },
children: [
{ hidden: false, owner: { id: 1, name: "", email: "", eventTypeSlugs: [] } },
{ hidden: false, owner: { id: 2, name: "", email: "", eventTypeSlugs: [] } },
{ hidden: false, owner: { id: 3, name: "", email: "", eventTypeSlugs: [] } },
],
updatedEventType: { schedulingType: "MANAGED", slug: "something" },
currentUserId: 1,
prisma: prismaMock,
profileId: null,
updatedValues: {},
});
// oldUserIds should still contain all 3 (the function returns the input, not successful ones)
expect(result.oldUserIds).toEqual([1, 2, 3]);
// Verify permanent failure was logged
expect(logger.error).toHaveBeenCalledWith(
"handleChildrenEventType - Could not update managed event-type",
{
parentId: 1,
userIds: [2],
}
);
});
it("handles large scale scenarios with 100+ users efficiently", async () => {
// Create 150 old users to test large scale handling
const oldUserIds = Array.from({ length: 150 }, (_, i) => i + 1);
const children = oldUserIds.map((id) => ({
hidden: false,
owner: { id, name: `User ${id}`, email: `user${id}@test.com`, eventTypeSlugs: [] },
}));
mockFindFirstEventType({
metadata: { managedEventConfig: {} },
locations: [],
});
// Mock findMany for existing records lookup - should be called ONCE (bulk optimization)
// @ts-expect-error - partial mock for test purposes
prismaMock.eventType.findMany.mockResolvedValue(oldUserIds.map((userId) => ({ userId, metadata: {} })));
// Track timing to ensure batching doesn't cause sequential delays
const updateResults: number[] = [];
// @ts-expect-error - partial mock for test purposes
prismaMock.eventType.update.mockImplementation(async (args) => {
const userId = (args.where as { userId_parentId: { userId: number } }).userId_parentId.userId;
updateResults.push(userId);
return {
id: userId,
userId,
title: "Test",
slug: "test",
length: 30,
hidden: false,
position: 0,
teamId: null,
schedulingType: SchedulingType.MANAGED,
scheduleId: null,
price: 0,
currency: "usd",
slotInterval: null,
metadata: {},
successRedirectUrl: null,
bookingFields: [],
createdAt: new Date(),
updatedAt: new Date(),
} as unknown as EventType;
});
const result = await updateChildrenEventTypes({
eventTypeId: 1,
oldEventType: { children: oldUserIds.map((userId) => ({ userId })), team: { name: "" } },
children,
updatedEventType: { schedulingType: "MANAGED", slug: "something" },
currentUserId: 1,
prisma: prismaMock,
profileId: null,
updatedValues: {},
});
// Verify all 150 users were processed
expect(result.oldUserIds).toHaveLength(150);
expect(updateResults).toHaveLength(150);
// Verify bulk fetch optimization: findMany should be called only ONCE
// (not 150 individual findUnique calls as in the old implementation)
expect(prismaMock.eventType.findMany).toHaveBeenCalledTimes(1);
expect(prismaMock.eventType.findMany).toHaveBeenCalledWith({
where: { parentId: 1, userId: { in: oldUserIds } },
select: { userId: true, metadata: true },
});
});
});
describe("CalVideoSettings propagation", () => {
it("Creates CalVideoSettings for new children when parent has settings", async () => {
const { schedulingType, teamId, timeZone, ...evType } = mockFindFirstEventType({
id: 123,
metadata: { managedEventConfig: {} },
locations: [],
});
setupTransactionMock();
prismaMock.eventType.createManyAndReturn.mockResolvedValue([
{ ...evType, id: 456, userId: 4, schedulingType, teamId, timeZone } as unknown as EventType,
]);
await updateChildrenEventTypes({
eventTypeId: 1,
oldEventType: { children: [], team: { name: "" } },
children: [{ hidden: false, owner: { id: 4, name: "", email: "", eventTypeSlugs: [] } }],
updatedEventType: { schedulingType: "MANAGED", slug: "something" },
currentUserId: 1,
prisma: prismaMock,
profileId: null,
updatedValues: {},
calVideoSettings: {
enableAutomaticRecordingForOrganizer: true,
enableAutomaticTranscription: true,
},
});
expect(prismaMock.calVideoSettings.createMany).toHaveBeenCalledWith({
data: [
expect.objectContaining({
eventTypeId: 456,
enableAutomaticRecordingForOrganizer: true,
enableAutomaticTranscription: true,
}),
],
skipDuplicates: true,
});
});
it("Syncs/deletes CalVideoSettings for existing children based on parent settings", async () => {
const mockExistingChildUpdate = () => {
mockFindFirstEventType({ metadata: { managedEventConfig: {} }, locations: [] });
// @ts-expect-error - partial mock
prismaMock.eventType.findMany.mockResolvedValue([{ userId: 4, metadata: {} }]);
prismaMock.eventType.update.mockResolvedValue({
id: 789,
userId: 4,
schedulingType: SchedulingType.MANAGED,
} as unknown as EventType);
};
const baseParams = {
eventTypeId: 1,
oldEventType: { children: [{ userId: 4 }], team: { name: "" } },
children: [{ hidden: false, owner: { id: 4, name: "", email: "", eventTypeSlugs: [] } }],
updatedEventType: { schedulingType: "MANAGED" as const, slug: "something" },
currentUserId: 1,
prisma: prismaMock,
profileId: null,
updatedValues: {},
};
// Test upsert when calVideoSettings provided
mockExistingChildUpdate();
await updateChildrenEventTypes({
...baseParams,
calVideoSettings: { enableAutomaticRecordingForOrganizer: true },
});
expect(prismaMock.calVideoSettings.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { eventTypeId: 789 },
update: expect.objectContaining({ enableAutomaticRecordingForOrganizer: true }),
create: expect.objectContaining({ eventTypeId: 789, enableAutomaticRecordingForOrganizer: true }),
})
);
// Test deleteMany when calVideoSettings is null
vi.clearAllMocks();
mockExistingChildUpdate();
await updateChildrenEventTypes({ ...baseParams, calVideoSettings: null });
expect(prismaMock.calVideoSettings.deleteMany).toHaveBeenCalledWith({
where: { eventTypeId: { in: [789] } },
});
// Test no-op when calVideoSettings is undefined
vi.clearAllMocks();
mockExistingChildUpdate();
await updateChildrenEventTypes({ ...baseParams, calVideoSettings: undefined });
expect(prismaMock.calVideoSettings.upsert).not.toHaveBeenCalled();
expect(prismaMock.calVideoSettings.deleteMany).not.toHaveBeenCalled();
});
});
});
+21
View File
@@ -57,6 +57,27 @@
"image": "./assets/splash-icon.png",
"imageWidth": 200
}
],
[
"@react-native-community/datetimepicker",
{
"android": {
"datePicker": {
"colorAccent": {
"light": "#000000"
},
"textColorPrimary": {
"light": "#000000"
}
},
"timePicker": {
"background": { "light": "#ffffff" },
"numbersBackgroundColor": { "light": "#ffffff" },
"headerBackground": { "light": "#000000" },
"numbersSelectorColor": { "light": "#000000" }
}
}
}
]
],
"experiments": {
+19 -1
View File
@@ -2,6 +2,7 @@ import { PortalHost } from "@rn-primitives/portal";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Stack } from "expo-router";
import { Platform, StatusBar, View } from "react-native";
import { CalComLogo } from "@/components/CalComLogo";
import LoginScreenComponent from "@/components/LoginScreen";
import { NetworkStatusBanner } from "@/components/NetworkStatusBanner";
import { GlobalToast } from "@/components/ui/GlobalToast";
@@ -11,7 +12,24 @@ import { ToastProvider } from "@/contexts/ToastContext";
import "../global.css";
function RootLayoutContent() {
const { isAuthenticated } = useAuth();
const { isAuthenticated, loading } = useAuth();
// Show Cal.com logo while checking auth state to prevent login flash
if (loading) {
return (
<View
style={{
flex: 1,
backgroundColor: "#FFFFFF",
justifyContent: "center",
alignItems: "center",
}}
>
<StatusBar barStyle="dark-content" backgroundColor="#ffffff" />
<CalComLogo width={120} height={26} />
</View>
);
}
const content = isAuthenticated ? (
<Stack>
+3
View File
@@ -7,6 +7,7 @@
"@expo/ui": "0.2.0-canary-20251230-fc48ddc",
"@expo/vector-icons": "15.0.3",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/datetimepicker": "8.4.4",
"@react-native-community/netinfo": "11.4.1",
"@react-native-segmented-control/segmented-control": "2.5.7",
"@rn-primitives/alert-dialog": "1.2.0",
@@ -548,6 +549,8 @@
"@react-native-async-storage/async-storage": ["@react-native-async-storage/async-storage@2.2.0", "", { "dependencies": { "merge-options": "^3.0.4" }, "peerDependencies": { "react-native": "^0.0.0-0 || >=0.65 <1.0" } }, "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw=="],
"@react-native-community/datetimepicker": ["@react-native-community/datetimepicker@8.4.4", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "expo": ">=52.0.0", "react": "*", "react-native": "*", "react-native-windows": "*" }, "optionalPeers": ["expo", "react-native-windows"] }, "sha512-bc4ZixEHxZC9/qf5gbdYvIJiLZ5CLmEsC3j+Yhe1D1KC/3QhaIfGDVdUcid0PdlSoGOSEq4VlB93AWyetEyBSQ=="],
"@react-native-community/netinfo": ["@react-native-community/netinfo@11.4.1", "", { "peerDependencies": { "react-native": ">=0.59" } }, "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg=="],
"@react-native-segmented-control/segmented-control": ["@react-native-segmented-control/segmented-control@2.5.7", "", { "peerDependencies": { "react": ">=16.0", "react-native": ">=0.62" } }, "sha512-l84YeVX8xAU3lvOJSvV4nK/NbGhIm2gBfveYolwaoCbRp+/SLXtc6mYrQmM9ScXNwU14mnzjQTpTHWl5YPnkzQ=="],
@@ -0,0 +1,60 @@
import { useCallback, useMemo } from "react";
import { Pressable, Text } from "react-native";
import { DateTimePickerAndroid } from "@react-native-community/datetimepicker";
interface LimitsTabDatePickerProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
export function LimitsTabDatePicker({ value, onChange, placeholder }: LimitsTabDatePickerProps) {
// Parse value string to Date object
const dateValue = useMemo(() => {
if (!value) return new Date();
const parsed = new Date(`${value}T00:00:00`);
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
}, [value]);
// Format date for display (compact, human-readable)
const displayText = useMemo(() => {
if (!value) return placeholder || "Select date";
return dateValue.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}, [value, dateValue, placeholder]);
// Handle date selection - convert to YYYY-MM-DD format
const handleDateChange = useCallback(
(date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
onChange(`${year}-${month}-${day}`);
},
[onChange]
);
// Open native Android date picker
const openDatePicker = useCallback(() => {
DateTimePickerAndroid.open({
value: dateValue,
mode: "date",
onChange: (event, date) => {
if (event.type === "set" && date) {
handleDateChange(date);
}
},
});
}, [dateValue, handleDateChange]);
return (
<Pressable onPress={openDatePicker} className="rounded-lg bg-[#F2F2F7] px-3 py-2">
<Text className={`text-[15px] ${value ? "text-black" : "text-[#8E8E93]"}`}>
{displayText}
</Text>
</Pressable>
);
}
@@ -0,0 +1,482 @@
import { Ionicons } from "@expo/vector-icons";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react";
import { Alert, Pressable, ScrollView, Switch, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { DateTimePickerAndroid } from "@react-native-community/datetimepicker";
import { useUpdateSchedule } from "@/hooks/useSchedules";
import type { Schedule } from "@/services/calcom";
import { showErrorAlert, showSuccessAlert } from "@/utils/alerts";
// Convert 24-hour time to 12-hour format with AM/PM
const formatTime12Hour = (time24: string): string => {
const [hours, minutes] = time24.split(":");
const hour = parseInt(hours, 10);
const min = minutes || "00";
const period = hour >= 12 ? "PM" : "AM";
const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
return `${hour12}:${min} ${period}`;
};
// Parse time string to Date object (for DatePicker)
const timeStringToDate = (timeStr: string): Date => {
const [hours, minutes] = timeStr.split(":");
const date = new Date();
date.setHours(parseInt(hours, 10));
date.setMinutes(parseInt(minutes || "0", 10));
date.setSeconds(0);
date.setMilliseconds(0);
return date;
};
// Convert Date to time string (HH:mm)
const dateToTimeString = (date: Date): string => {
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${hours}:${minutes}`;
};
// Normalize time string to HH:mm format (API may return HH:mm:ss)
const normalizeTimeString = (time: string): string => {
return time.substring(0, 5);
};
// Convert Date to date string (YYYY-MM-DD)
const dateToDateString = (date: Date): string => {
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const day = date.getDate().toString().padStart(2, "0");
return `${year}-${month}-${day}`;
};
// Parse date string to Date object
const dateStringToDate = (dateStr: string): Date => {
const [year, month, day] = dateStr.split("-").map(Number);
return new Date(year, month - 1, day);
};
// Format date for display
const formatDateForDisplay = (date: Date): string => {
return date.toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
};
export interface EditAvailabilityOverrideScreenProps {
schedule: Schedule | null;
overrideIndex?: number;
onSuccess: () => void;
onSavingChange?: (isSaving: boolean) => void;
onEditOverride?: (index: number) => void;
transparentBackground?: boolean;
}
export interface EditAvailabilityOverrideScreenHandle {
submit: () => void;
}
export const EditAvailabilityOverrideScreen = forwardRef<
EditAvailabilityOverrideScreenHandle,
EditAvailabilityOverrideScreenProps
>(function EditAvailabilityOverrideScreen(
{
schedule,
overrideIndex,
onSuccess,
onSavingChange,
onEditOverride,
transparentBackground = false,
},
ref
) {
const insets = useSafeAreaInsets();
const backgroundStyle = transparentBackground ? "bg-transparent" : "bg-[#F2F2F7]";
// Use mutation hook for cache-synchronized updates
const { mutate: updateSchedule, isPending: isMutating } = useUpdateSchedule();
const isEditing = overrideIndex !== undefined;
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
const [isUnavailable, setIsUnavailable] = useState(false);
const [startTime, setStartTime] = useState<Date>(timeStringToDate("09:00"));
const [endTime, setEndTime] = useState<Date>(timeStringToDate("17:00"));
// Initialize from existing override if editing
useEffect(() => {
if (schedule && isEditing && schedule.overrides) {
const override = schedule.overrides[overrideIndex];
if (override) {
setSelectedDate(dateStringToDate(override.date ?? ""));
// Normalize time strings to HH:mm (API may return HH:mm:ss)
const start = normalizeTimeString(override.startTime ?? "00:00");
const end = normalizeTimeString(override.endTime ?? "00:00");
setIsUnavailable(start === "00:00" && end === "00:00");
setStartTime(timeStringToDate(start === "00:00" ? "09:00" : start));
setEndTime(timeStringToDate(end === "00:00" ? "17:00" : end));
}
}
}, [schedule, isEditing, overrideIndex]);
// Notify parent of saving state
useEffect(() => {
onSavingChange?.(isMutating);
}, [isMutating, onSavingChange]);
// Open native Android date picker
const openDatePicker = useCallback(() => {
DateTimePickerAndroid.open({
value: selectedDate,
mode: "date",
onChange: (event, date) => {
if (event.type === "set" && date) {
setSelectedDate(date);
}
},
});
}, [selectedDate]);
// Open native Android time picker for start time
const openStartTimePicker = useCallback(() => {
DateTimePickerAndroid.open({
value: startTime,
mode: "time",
is24Hour: false,
onChange: (event, date) => {
if (event.type === "set" && date) {
setStartTime(date);
}
},
});
}, [startTime]);
// Open native Android time picker for end time
const openEndTimePicker = useCallback(() => {
DateTimePickerAndroid.open({
value: endTime,
mode: "time",
is24Hour: false,
onChange: (event, date) => {
if (event.type === "set" && date) {
setEndTime(date);
}
},
});
}, [endTime]);
const saveOverrides = useCallback(
(
newOverrides: { date: string; startTime: string; endTime: string }[],
successMessage: string
) => {
if (!schedule) return;
updateSchedule(
{ id: schedule.id, updates: { overrides: newOverrides } },
{
onSuccess: () => {
showSuccessAlert("Success", successMessage);
onSuccess();
},
onError: () => {
showErrorAlert("Error", "Failed to save override. Please try again.");
},
}
);
},
[schedule, onSuccess, updateSchedule]
);
const handleDeleteOverride = useCallback(
(indexToDelete: number) => {
if (!schedule || !schedule.overrides) return;
const override = schedule.overrides[indexToDelete];
const dateDisplay = formatDateForDisplay(dateStringToDate(override?.date ?? ""));
Alert.alert(
"Delete Override",
`Are you sure you want to delete the override for ${dateDisplay}?`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: async () => {
const newOverrides = schedule.overrides
? schedule.overrides
.filter((_, idx) => idx !== indexToDelete)
.map((o) => ({
date: o.date ?? "",
startTime: (o.startTime ?? "00:00").substring(0, 5),
endTime: (o.endTime ?? "00:00").substring(0, 5),
}))
: [];
await saveOverrides(newOverrides, "Override deleted successfully");
},
},
]
);
},
[schedule, saveOverrides]
);
const handleSubmit = useCallback(() => {
if (!schedule || isMutating) return;
const dateStr = dateToDateString(selectedDate);
// Validate end time is after start time (only when not marking as unavailable)
// Compare time strings to avoid issues with Date object day components
const startTimeStr = dateToTimeString(startTime);
const endTimeStr = dateToTimeString(endTime);
if (!isUnavailable && endTimeStr <= startTimeStr) {
showErrorAlert("Error", "End time must be after start time");
return;
}
// Build the new override
const newOverride = {
date: dateStr,
startTime: isUnavailable ? "00:00" : dateToTimeString(startTime),
endTime: isUnavailable ? "00:00" : dateToTimeString(endTime),
};
// Build new overrides array
let newOverrides: { date: string; startTime: string; endTime: string }[] = [];
if (schedule.overrides && Array.isArray(schedule.overrides)) {
newOverrides = schedule.overrides.map((o) => ({
date: o.date ?? "",
startTime: (o.startTime ?? "00:00").substring(0, 5),
endTime: (o.endTime ?? "00:00").substring(0, 5),
}));
}
const successMessage = isEditing
? "Override updated successfully"
: "Override added successfully";
if (isEditing && overrideIndex !== undefined) {
// Check if date was changed to one that already exists (exclude current override)
const existingIndex = newOverrides.findIndex(
(o, idx) => o.date === dateStr && idx !== overrideIndex
);
if (existingIndex >= 0) {
Alert.alert(
"Date Already Exists",
"An override for this date already exists. Do you want to replace it?",
[
{ text: "Cancel", style: "cancel" },
{
text: "Replace",
onPress: () => {
// Remove the conflicting override and update the current one
const filteredOverrides = newOverrides.filter((_, idx) => idx !== existingIndex);
// Adjust index if the removed item was before our edit index
const adjustedIndex =
existingIndex < overrideIndex ? overrideIndex - 1 : overrideIndex;
filteredOverrides[adjustedIndex] = newOverride;
saveOverrides(filteredOverrides, "Override updated successfully");
},
},
]
);
return;
}
// Update existing override
newOverrides[overrideIndex] = newOverride;
} else {
// Check if date already exists
const existingIndex = newOverrides.findIndex((o) => o.date === dateStr);
if (existingIndex >= 0) {
Alert.alert(
"Date Already Exists",
"An override for this date already exists. Do you want to replace it?",
[
{ text: "Cancel", style: "cancel" },
{
text: "Replace",
onPress: () => {
newOverrides[existingIndex] = newOverride;
saveOverrides(newOverrides, "Override replaced successfully");
},
},
]
);
return;
}
// Add new override
newOverrides.push(newOverride);
}
saveOverrides(newOverrides, successMessage);
}, [
schedule,
selectedDate,
isUnavailable,
startTime,
endTime,
isEditing,
overrideIndex,
saveOverrides,
isMutating,
]);
// Expose submit to parent via ref
useImperativeHandle(
ref,
() => ({
submit: handleSubmit,
}),
[handleSubmit]
);
if (!schedule) {
return (
<View className={`flex-1 items-center justify-center ${backgroundStyle}`}>
<Text className="text-[#8E8E93]">No schedule data</Text>
</View>
);
}
return (
<ScrollView
className={`flex-1 ${backgroundStyle}`}
contentContainerStyle={{
padding: 16,
paddingBottom: insets.bottom + 16,
}}
showsVerticalScrollIndicator={!transparentBackground}
>
{/* Date Picker */}
<Text className="mb-2 px-1 text-[13px] font-medium text-[#8E8E93]">Date</Text>
<Pressable
onPress={openDatePicker}
className={`mb-4 rounded-xl px-4 py-3.5 ${
transparentBackground ? "border border-gray-300/40 bg-white/60" : "bg-white"
}`}
>
<View className="flex-row items-center justify-between">
<View className="flex-row items-center">
<View className="mr-3 h-9 w-9 items-center justify-center rounded-lg bg-[#007AFF]/10">
<Ionicons name="calendar" size={20} color="#007AFF" />
</View>
<Text className="text-[17px] text-black">{formatDateForDisplay(selectedDate)}</Text>
</View>
<Ionicons name="chevron-forward" size={18} color="#C7C7CC" />
</View>
</Pressable>
{/* Unavailable Toggle */}
<View
className={`mb-4 flex-row items-center justify-between rounded-xl px-4 py-3.5 ${
transparentBackground ? "border border-gray-300/40 bg-white/60" : "bg-white"
}`}
>
<View className="flex-row items-center">
<View className="mr-3 h-9 w-9 items-center justify-center rounded-lg bg-[#FF3B30]/10">
<Ionicons name="moon-outline" size={20} color="#FF3B30" />
</View>
<View>
<Text className="text-[17px] text-black">Mark as unavailable</Text>
<Text className="text-[13px] text-[#8E8E93]">Block this entire day</Text>
</View>
</View>
<Switch
value={isUnavailable}
onValueChange={setIsUnavailable}
trackColor={{ false: "#E5E5EA", true: "#000000" }}
thumbColor="#fff"
/>
</View>
{/* Time Range (only when available) */}
{!isUnavailable && (
<>
<Text className="mb-2 px-1 text-[13px] font-medium text-[#8E8E93]">Available Hours</Text>
<View
className={`mb-4 rounded-xl px-4 py-3.5 ${
transparentBackground ? "border border-gray-300/40 bg-white/60" : "bg-white"
}`}
>
<View className="flex-row items-center">
<Pressable onPress={openStartTimePicker} className="flex-1">
<Text className="mb-1 text-[13px] text-[#8E8E93]">Start Time</Text>
<Text className="text-[17px] text-black">
{formatTime12Hour(dateToTimeString(startTime))}
</Text>
</Pressable>
<Text className="mx-4 text-[17px] text-[#8E8E93]">–</Text>
<Pressable onPress={openEndTimePicker} className="flex-1">
<Text className="mb-1 text-[13px] text-[#8E8E93]">End Time</Text>
<Text className="text-[17px] text-black">
{formatTime12Hour(dateToTimeString(endTime))}
</Text>
</Pressable>
</View>
</View>
</>
)}
{/* Existing Overrides (if any) */}
{!isEditing && schedule.overrides && schedule.overrides.length > 0 && (
<>
<Text className="mb-2 mt-4 px-1 text-[13px] font-medium text-[#8E8E93]">
Existing Overrides
</Text>
<View
className={`overflow-hidden rounded-xl ${
transparentBackground ? "border border-gray-300/40 bg-white/60" : "bg-white"
}`}
>
{schedule.overrides.map((override, index) => (
<Pressable
key={override.date}
className={`flex-row items-center justify-between px-4 py-3 ${
index > 0 ? "border-t border-[#E5E5EA]" : ""
}`}
onPress={() => onEditOverride?.(index)}
>
<View className="flex-1">
<Text className="text-[15px] text-black">
{formatDateForDisplay(dateStringToDate(override.date ?? ""))}
</Text>
{normalizeTimeString(override.startTime ?? "00:00") === "00:00" &&
normalizeTimeString(override.endTime ?? "00:00") === "00:00" ? (
<Text className="text-[13px] text-[#FF3B30]">Unavailable</Text>
) : (
<Text className="text-[13px] text-[#8E8E93]">
{formatTime12Hour(normalizeTimeString(override.startTime ?? "00:00"))} –{" "}
{formatTime12Hour(normalizeTimeString(override.endTime ?? "00:00"))}
</Text>
)}
</View>
<View className="flex-row items-center">
<Pressable
className="rounded-full bg-[#FF3B30]/10 p-2"
onPress={() => handleDeleteOverride(index)}
>
<Ionicons name="trash-outline" size={18} color="#FF3B30" />
</Pressable>
<Pressable
className="ml-3 rounded-full bg-[#8E8E93]/10 p-2"
onPress={() => onEditOverride?.(index)}
>
<Ionicons name="chevron-forward" size={18} color="#8E8E93" />
</Pressable>
</View>
</Pressable>
))}
</View>
</>
)}
</ScrollView>
);
});
export default EditAvailabilityOverrideScreen;
@@ -1,17 +1,16 @@
/**
* RescheduleScreen Component (Android)
*
* Android-specific implementation with modal-based date/time picker.
* Uses custom modal dialogs with Material Design 3 inspired styling.
* Android-specific implementation using the DateTimePickerAndroid imperative API
* for native Material Design date/time picker dialogs.
*/
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
Modal,
Pressable,
ScrollView,
Text,
@@ -24,6 +23,7 @@ import { useRescheduleBooking } from "@/hooks/useBookings";
import type { Booking } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
import { safeLogError, safeLogInfo } from "@/utils/safeLogger";
import { DateTimePickerAndroid } from "@react-native-community/datetimepicker";
export interface RescheduleScreenProps {
booking: Booking | null;
@@ -50,8 +50,6 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
const router = useRouter();
const insets = useSafeAreaInsets();
const [selectedDateTime, setSelectedDateTime] = useState<Date>(new Date());
const [showDatePicker, setShowDatePicker] = useState(false);
const [showTimePicker, setShowTimePicker] = useState(false);
const [reason, setReason] = useState("");
// Use React Query mutation for automatic cache invalidation
@@ -100,6 +98,40 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
);
}, [booking, selectedDateTime, reason, onSuccess, isSaving, rescheduleBooking]);
const openDatePicker = useCallback(() => {
DateTimePickerAndroid.open({
value: selectedDateTime,
mode: "date",
onChange: (event, date) => {
if (event.type === "set" && date) {
// Preserve the current time when changing date
const newDate = new Date(date);
newDate.setHours(selectedDateTime.getHours());
newDate.setMinutes(selectedDateTime.getMinutes());
setSelectedDateTime(newDate);
}
},
minimumDate: new Date(), // Prevent selecting past dates
});
}, [selectedDateTime]);
const openTimePicker = useCallback(() => {
DateTimePickerAndroid.open({
value: selectedDateTime,
mode: "time",
is24Hour: false, // Match current 12-hour format
onChange: (event, date) => {
if (event.type === "set" && date) {
// Preserve the current date when changing time
const newDate = new Date(selectedDateTime);
newDate.setHours(date.getHours());
newDate.setMinutes(date.getMinutes());
setSelectedDateTime(newDate);
}
},
});
}, [selectedDateTime]);
// Format date for display
const formattedDate = selectedDateTime.toLocaleDateString(undefined, {
weekday: "short",
@@ -111,73 +143,6 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
// Format time for display (12-hour)
const formattedTime = formatTime12Hour(selectedDateTime);
// Generate date options for picker (next 90 days)
const dateOptions = React.useMemo(() => {
const options: { label: string; sublabel: string; value: Date }[] = [];
const today = new Date();
for (let i = 0; i < 90; i++) {
const date = new Date(today);
date.setDate(today.getDate() + i);
const isToday = i === 0;
const isTomorrow = i === 1;
options.push({
label: isToday
? "Today"
: isTomorrow
? "Tomorrow"
: date.toLocaleDateString(undefined, {
weekday: "long",
}),
sublabel: date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
}),
value: date,
});
}
return options;
}, []);
// Extract booking times for stable dependency references
const bookingStartTime = booking?.startTime;
const bookingEndTime = booking?.endTime;
// Calculate booking duration in minutes
const bookingDurationMinutes = React.useMemo(() => {
if (!bookingStartTime || !bookingEndTime) return 30; // Default to 30 min
const start = new Date(bookingStartTime);
const end = new Date(bookingEndTime);
const durationMs = end.getTime() - start.getTime();
const durationMin = Math.round(durationMs / (1000 * 60));
// Ensure slot interval is reasonable (minimum 5 min, maximum 60 min)
if (durationMin < 5) return 5;
if (durationMin > 60) return 60;
return durationMin;
}, [bookingStartTime, bookingEndTime]);
// Generate time options based on booking duration
const timeOptions = React.useMemo(() => {
const options: {
label: string;
value: { hour: number; minute: number };
}[] = [];
const interval = bookingDurationMinutes;
for (let totalMinutes = 0; totalMinutes < 24 * 60; totalMinutes += interval) {
const hour = Math.floor(totalMinutes / 60);
const minute = totalMinutes % 60;
const ampm = hour >= 12 ? "PM" : "AM";
const displayHour = hour % 12 || 12;
const time = `${displayHour}:${String(minute).padStart(2, "0")} ${ampm}`;
options.push({
label: time,
value: { hour, minute },
});
}
return options;
}, [bookingDurationMinutes]);
useImperativeHandle(
ref,
() => ({
@@ -195,344 +160,161 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
}
return (
<>
<KeyboardAvoidingView behavior="height" className="flex-1 bg-[#F2F2F7]">
{/* Header - only shown when not using native header */}
{!useNativeHeader && (
<KeyboardAvoidingView behavior="height" className="flex-1 bg-[#F2F2F7]">
{/* Header - only shown when not using native header */}
{!useNativeHeader && (
<View
style={{
backgroundColor: "#fff",
paddingTop: insets.top,
paddingBottom: 12,
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: "#E5E5EA",
elevation: 2,
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
}}
>
<View
style={{
backgroundColor: "#fff",
paddingTop: insets.top,
paddingBottom: 12,
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: "#E5E5EA",
elevation: 2,
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
minHeight: 44,
}}
>
<View
<AppPressable
onPress={() => router.back()}
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
minHeight: 44,
width: 40,
height: 40,
alignItems: "flex-start",
justifyContent: "center",
}}
>
<AppPressable
onPress={() => router.back()}
style={{
width: 40,
height: 40,
alignItems: "flex-start",
justifyContent: "center",
}}
>
<Ionicons name="chevron-back" size={24} color="#000" />
</AppPressable>
<Ionicons name="chevron-back" size={24} color="#000" />
</AppPressable>
<Text
style={{
flex: 1,
textAlign: "center",
fontSize: 18,
fontWeight: "600",
color: "#000",
marginHorizontal: 10,
}}
numberOfLines={1}
>
Reschedule
</Text>
<AppPressable
onPress={handleSubmit}
disabled={isSaving}
style={{
paddingHorizontal: 16,
paddingVertical: 8,
opacity: isSaving ? 0.5 : 1,
}}
>
<Text
style={{
flex: 1,
textAlign: "center",
fontSize: 18,
fontSize: 16,
fontWeight: "600",
color: "#000",
marginHorizontal: 10,
color: "#007AFF",
}}
numberOfLines={1}
>
Reschedule
Save
</Text>
<AppPressable
onPress={handleSubmit}
disabled={isSaving}
style={{
paddingHorizontal: 16,
paddingVertical: 8,
opacity: isSaving ? 0.5 : 1,
}}
>
<Text
style={{
fontSize: 16,
fontWeight: "600",
color: "#007AFF",
}}
>
Save
</Text>
</AppPressable>
</View>
</AppPressable>
</View>
)}
</View>
)}
<ScrollView
className="flex-1"
contentContainerStyle={{
padding: 16,
paddingBottom: insets.bottom + 16,
}}
keyboardShouldPersistTaps="handled"
>
{/* Booking Title Card */}
<View className="mb-4 flex-row items-start rounded-xl bg-white p-4">
<View className="mr-3 h-10 w-10 items-center justify-center rounded-full bg-[#E8E8ED]">
<Ionicons name="calendar" size={20} color="#6B7280" />
</View>
<ScrollView
className="flex-1"
contentContainerStyle={{
padding: 16,
paddingBottom: insets.bottom + 16,
}}
keyboardShouldPersistTaps="handled"
>
{/* Booking Title Card */}
<View className="mb-4 flex-row items-start rounded-xl bg-white p-4">
<View className="mr-3 h-10 w-10 items-center justify-center rounded-full bg-[#E8E8ED]">
<Ionicons name="calendar" size={20} color="#6B7280" />
</View>
<View className="flex-1">
<Text className="text-[13px] font-medium text-gray-500">Rescheduling</Text>
<Text className="mt-0.5 text-[17px] font-medium text-[#000]" numberOfLines={2}>
{booking.title}
</Text>
</View>
</View>
{/* Form Card */}
<View className="mb-4 overflow-hidden rounded-xl bg-white">
{/* Date picker trigger */}
<Pressable
className="flex-row items-center justify-between border-b border-gray-100 px-4 py-3"
style={({ pressed }) => ({
backgroundColor: pressed ? "#F9F9F9" : "transparent",
})}
onPress={() => {
safeLogInfo("[RescheduleScreen] Opening date picker");
openDatePicker();
}}
disabled={isSaving}
>
<View className="flex-1">
<Text className="text-[13px] font-medium text-gray-500">Rescheduling</Text>
<Text className="mt-0.5 text-[17px] font-medium text-[#000]" numberOfLines={2}>
{booking.title}
</Text>
<Text className="mb-1 text-[13px] font-medium text-gray-500">New Date</Text>
<Text className="text-[17px] text-[#000]">{formattedDate}</Text>
</View>
</View>
<Ionicons name="chevron-forward" size={18} color="#C7C7CC" />
</Pressable>
{/* Form Card */}
<View className="mb-4 overflow-hidden rounded-xl bg-white">
{/* Date picker trigger */}
<Pressable
className="flex-row items-center justify-between border-b border-gray-100 px-4 py-3"
style={({ pressed }) => ({
backgroundColor: pressed ? "#F9F9F9" : "transparent",
})}
onPress={() => {
safeLogInfo("[RescheduleScreen] Opening date picker");
setShowDatePicker(true);
}}
disabled={isSaving}
>
<View className="flex-1">
<Text className="mb-1 text-[13px] font-medium text-gray-500">New Date</Text>
<Text className="text-[17px] text-[#000]">{formattedDate}</Text>
</View>
<Ionicons name="chevron-forward" size={18} color="#C7C7CC" />
</Pressable>
{/* Time picker trigger */}
<Pressable
className="flex-row items-center justify-between border-b border-gray-100 px-4 py-3"
style={({ pressed }) => ({
backgroundColor: pressed ? "#F9F9F9" : "transparent",
})}
onPress={() => {
safeLogInfo("[RescheduleScreen] Opening time picker");
setShowTimePicker(true);
}}
disabled={isSaving}
>
<View className="flex-1">
<Text className="mb-1 text-[13px] font-medium text-gray-500">New Time</Text>
<Text className="text-[17px] text-[#000]">{formattedTime}</Text>
</View>
<Ionicons name="chevron-forward" size={18} color="#C7C7CC" />
</Pressable>
{/* Reason input */}
<View className="px-4 py-3">
<Text className="mb-1.5 text-[13px] font-medium text-gray-500">
Reason (optional)
</Text>
<TextInput
className="min-h-[80px] text-[17px] text-[#000]"
placeholder="Enter reason for rescheduling..."
placeholderTextColor="#9CA3AF"
value={reason}
onChangeText={setReason}
multiline
textAlignVertical="top"
editable={!isSaving}
/>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
{/* Date Picker Modal */}
<Modal
visible={showDatePicker}
transparent
animationType="slide"
onRequestClose={() => setShowDatePicker(false)}
>
<View
style={{
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.4)",
justifyContent: "flex-end",
}}
>
{/* Time picker trigger */}
<Pressable
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
onPress={() => setShowDatePicker(false)}
/>
<View
style={{
backgroundColor: "#fff",
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
maxHeight: "70%",
paddingBottom: insets.bottom,
className="flex-row items-center justify-between border-b border-gray-100 px-4 py-3"
style={({ pressed }) => ({
backgroundColor: pressed ? "#F9F9F9" : "transparent",
})}
onPress={() => {
safeLogInfo("[RescheduleScreen] Opening time picker");
openTimePicker();
}}
disabled={isSaving}
>
{/* Header */}
<View className="flex-row items-center justify-between border-b border-gray-200 px-4 py-3">
<Pressable onPress={() => setShowDatePicker(false)}>
<Text className="text-[17px] text-gray-500">Cancel</Text>
</Pressable>
<Text className="text-[17px] font-semibold text-[#000]">Select Date</Text>
<Pressable onPress={() => setShowDatePicker(false)}>
<Text className="text-[17px] font-semibold text-[#007AFF]">Done</Text>
</Pressable>
<View className="flex-1">
<Text className="mb-1 text-[13px] font-medium text-gray-500">New Time</Text>
<Text className="text-[17px] text-[#000]">{formattedTime}</Text>
</View>
<Ionicons name="chevron-forward" size={18} color="#C7C7CC" />
</Pressable>
{/* Date List */}
<ScrollView showsVerticalScrollIndicator={false}>
{dateOptions.map((option, index) => {
const isSelected =
option.value.toDateString() === selectedDateTime.toDateString();
return (
<Pressable
key={option.value.toISOString()}
className="flex-row items-center justify-between px-4 py-3.5"
style={({ pressed }) => ({
backgroundColor: isSelected
? "rgba(0, 122, 255, 0.1)"
: pressed
? "#F5F5F5"
: "#fff",
borderBottomWidth: index < dateOptions.length - 1 ? 1 : 0,
borderBottomColor: "#F2F2F7",
})}
onPress={() => {
const newDate = new Date(option.value);
newDate.setHours(selectedDateTime.getHours());
newDate.setMinutes(selectedDateTime.getMinutes());
setSelectedDateTime(newDate);
}}
>
<View>
<Text
className={`text-[17px] ${
isSelected ? "font-semibold text-[#007AFF]" : "font-medium text-[#000]"
}`}
>
{option.label}
</Text>
<Text
className={`mt-0.5 text-[13px] ${isSelected ? "text-[#007AFF]" : "text-gray-500"}`}
>
{option.sublabel}
</Text>
</View>
{isSelected && <Ionicons name="checkmark" size={22} color="#007AFF" />}
</Pressable>
);
})}
</ScrollView>
{/* Reason input */}
<View className="px-4 py-3">
<Text className="mb-1.5 text-[13px] font-medium text-gray-500">
Reason (optional)
</Text>
<TextInput
className="min-h-[80px] text-[17px] text-[#000]"
placeholder="Enter reason for rescheduling..."
placeholderTextColor="#9CA3AF"
value={reason}
onChangeText={setReason}
multiline
textAlignVertical="top"
editable={!isSaving}
/>
</View>
</View>
</Modal>
{/* Time Picker Modal */}
<Modal
visible={showTimePicker}
transparent
animationType="slide"
onRequestClose={() => setShowTimePicker(false)}
>
<View
style={{
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.4)",
justifyContent: "flex-end",
}}
>
<Pressable
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
onPress={() => setShowTimePicker(false)}
/>
<View
style={{
backgroundColor: "#fff",
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
maxHeight: "60%",
paddingBottom: insets.bottom,
}}
>
{/* Header */}
<View className="flex-row items-center justify-between border-b border-gray-200 px-4 py-3">
<Pressable onPress={() => setShowTimePicker(false)}>
<Text className="text-[17px] text-gray-500">Cancel</Text>
</Pressable>
<Text className="text-[17px] font-semibold text-[#000]">Select Time</Text>
<Pressable onPress={() => setShowTimePicker(false)}>
<Text className="text-[17px] font-semibold text-[#007AFF]">Done</Text>
</Pressable>
</View>
{/* Time Grid */}
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{
flexDirection: "row",
flexWrap: "wrap",
padding: 16,
}}
>
{timeOptions.map((option) => {
const isSelected =
selectedDateTime.getHours() === option.value.hour &&
selectedDateTime.getMinutes() === option.value.minute;
return (
<Pressable
key={option.label}
style={{ width: "25%", padding: 4 }}
onPress={() => {
const newDate = new Date(selectedDateTime);
newDate.setHours(option.value.hour);
newDate.setMinutes(option.value.minute);
setSelectedDateTime(newDate);
}}
>
<View
className={`items-center justify-center rounded-lg py-3 ${
isSelected ? "bg-[#007AFF]" : "bg-[#F2F2F7]"
}`}
>
<Text
className={`text-[13px] ${
isSelected ? "font-semibold text-white" : "font-medium text-[#000]"
}`}
>
{option.label}
</Text>
</View>
</Pressable>
);
})}
</ScrollView>
</View>
</View>
</Modal>
</>
</ScrollView>
</KeyboardAvoidingView>
);
}
);
@@ -69,6 +69,9 @@ export default defineContentScript({
const COMPANION_URL =
(import.meta.env.EXPO_PUBLIC_COMPANION_DEV_URL as string) || "https://companion.cal.com";
iframe.src = COMPANION_URL;
// Enable clipboard access for the cross-origin iframe
// This allows the companion app to use navigator.clipboard.writeText()
iframe.allow = "clipboard-write; clipboard-read";
// Use explicit dimensions - Brave has issues with percentage-based sizing
iframe.style.cssText = `
position: absolute !important;
+1
View File
@@ -52,6 +52,7 @@
"@expo/ui": "0.2.0-canary-20251230-fc48ddc",
"@expo/vector-icons": "15.0.3",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/datetimepicker": "8.4.4",
"@react-native-community/netinfo": "11.4.1",
"@react-native-segmented-control/segmented-control": "2.5.7",
"@rn-primitives/alert-dialog": "1.2.0",
+44 -1
View File
@@ -6,6 +6,8 @@
*/
import * as WebBrowser from "expo-web-browser";
import { Platform } from "react-native";
import { showErrorAlert } from "./alerts";
/**
@@ -18,6 +20,41 @@ export interface BrowserOptions {
controlsColor?: string;
}
/**
* Appends ?standalone=true to app.cal.com URLs on iOS.
* This hides navigation elements when pages are opened in the in-app browser,
* which is required for Apple App Store compliance.
*
* @param url - The URL to process
* @returns The URL with standalone=true appended if it's an app.cal.com URL on iOS
*/
const appendStandaloneParam = (url: string): string => {
// Only apply to iOS
if (Platform.OS !== "ios") {
return url;
}
try {
const urlObj = new URL(url);
// Only apply to app.cal.com URLs
if (urlObj.hostname !== "app.cal.com") {
return url;
}
// Don't add if already present
if (urlObj.searchParams.has("standalone")) {
return url;
}
urlObj.searchParams.set("standalone", "true");
return urlObj.toString();
} catch {
// If URL parsing fails, return original
return url;
}
};
/**
* Open a URL in the in-app browser with session sharing enabled.
*
@@ -25,6 +62,9 @@ export interface BrowserOptions {
* and Safari (iOS) or Chrome (Android). This means users who authenticate
* via OAuth will remain logged in when opening Cal.com links.
*
* On iOS, app.cal.com URLs automatically get ?standalone=true appended
* to hide navigation elements for Apple App Store compliance.
*
* @param url - The URL to open
* @param fallbackMessage - Optional message to show in error alert (defaults to "link")
* @param options - Optional browser customization options
@@ -47,6 +87,9 @@ export const openInAppBrowser = async (
options?: BrowserOptions
): Promise<void> => {
try {
// Append standalone=true for app.cal.com URLs on iOS
const processedUrl = appendStandaloneParam(url);
// Configure browser options
// Session sharing happens automatically when using Safari View Controller (iOS)
// or Chrome Custom Tabs (Android) - no special configuration needed
@@ -55,7 +98,7 @@ export const openInAppBrowser = async (
...(options?.controlsColor && { controlsColor: options.controlsColor }),
};
await WebBrowser.openBrowserAsync(url, browserOptions);
await WebBrowser.openBrowserAsync(processedUrl, browserOptions);
} catch (error) {
console.error("Failed to open link");
if (__DEV__) {
+42 -6
View File
@@ -7,7 +7,8 @@
*/
import * as Linking from "expo-linking";
import * as WebBrowser from "expo-web-browser";
import { Alert } from "react-native";
import { Alert, Platform } from "react-native";
import { showErrorAlert } from "@/utils/alerts";
// Default Cal.com web URL - can be overridden for self-hosted instances
@@ -22,6 +23,41 @@ function getCalWebUrl(): string {
return DEFAULT_CAL_WEB_URL;
}
/**
* Appends ?standalone=true to Cal.com URLs on iOS.
* This hides navigation elements when pages are opened in the in-app browser,
* which is required for Apple App Store compliance.
*
* @param url - The URL to process
* @returns The URL with standalone=true appended if it's a Cal.com URL on iOS
*/
function appendStandaloneParam(url: string): string {
// Only apply to iOS
if (Platform.OS !== "ios") {
return url;
}
try {
const urlObj = new URL(url);
// Only apply to app.cal.com URLs
if (urlObj.hostname !== "app.cal.com") {
return url;
}
// Don't add if already present
if (urlObj.searchParams.has("standalone")) {
return url;
}
urlObj.searchParams.set("standalone", "true");
return urlObj.toString();
} catch {
// If URL parsing fails, return original
return url;
}
}
/**
* Open a booking detail page in the web browser.
* This is used for actions that require the full web experience.
@@ -30,7 +66,7 @@ function getCalWebUrl(): string {
*/
export async function openBookingInWeb(bookingUid: string): Promise<void> {
const webUrl = getCalWebUrl();
const url = `${webUrl}/booking/${bookingUid}`;
const url = appendStandaloneParam(`${webUrl}/booking/${bookingUid}`);
try {
await WebBrowser.openBrowserAsync(url, {
@@ -86,9 +122,9 @@ export async function openReschedulePage(
rescheduleUid?: string
): Promise<void> {
const webUrl = getCalWebUrl();
const url = rescheduleUid
? `${webUrl}/reschedule/${rescheduleUid}`
: `${webUrl}/booking/${bookingUid}`;
const url = appendStandaloneParam(
rescheduleUid ? `${webUrl}/reschedule/${rescheduleUid}` : `${webUrl}/booking/${bookingUid}`
);
try {
await WebBrowser.openBrowserAsync(url, {
@@ -112,7 +148,7 @@ export async function openReschedulePage(
*/
export async function openCancelBookingInWeb(bookingUid: string): Promise<void> {
const webUrl = getCalWebUrl();
const url = `${webUrl}/booking/${bookingUid}`;
const url = appendStandaloneParam(`${webUrl}/booking/${bookingUid}`);
try {
await WebBrowser.openBrowserAsync(url, {
@@ -14,10 +14,13 @@ import type {
AttributeOptionValue,
} from "@calcom/app-store/routing-forms/types/types";
import type { dynamicFieldValueOperands } from "@calcom/lib/raqb/types";
import logger from "@calcom/lib/logger";
import { caseInsensitive } from "@calcom/lib/raqb/utils";
import { safeStringify } from "@calcom/lib/safeStringify";
import { AttributeType } from "@calcom/prisma/enums";
const log = logger.getSubLogger({ prefix: ["raqbUtils"] });
function ensureArray(value: string | string[]) {
return typeof value === "string" ? [value] : value;
}
@@ -89,7 +92,7 @@ export function getValueOfAttributeOption(
) {
if (attributeOption.isGroup) {
const subOptions = attributeOption.contains.map((option) => option.value);
console.log("A group option found. Using all its sub-options instead", safeStringify(subOptions));
log.debug("A group option found. Using all its sub-options instead", safeStringify(subOptions));
return subOptions;
}
return attributeOption.value;
@@ -0,0 +1,167 @@
import { describe, expect, it } from "vitest";
import { shouldEnableApp } from "./validateAppKeys";
describe("shouldEnableApp", () => {
describe("Apps without key schemas", () => {
it("should return true for apps that don't require keys", () => {
// Use a dirName that doesn't have a schema (e.g., a non-existent app)
const result = shouldEnableApp("non-existent-app", null);
expect(result).toBe(true);
});
it("should return true even if keys are null for apps without schemas", () => {
const result = shouldEnableApp("non-existent-app", null);
expect(result).toBe(true);
});
it("should return true even if keys are empty object for apps without schemas", () => {
const result = shouldEnableApp("non-existent-app", {});
expect(result).toBe(true);
});
});
describe("Apps with key schemas", () => {
it("should return false when keys are null", () => {
// dailyvideo requires api_key and has scale_plan with default
const result = shouldEnableApp("dailyvideo", null);
expect(result).toBe(false);
});
it("should return false when keys are undefined", () => {
const result = shouldEnableApp("dailyvideo", undefined);
expect(result).toBe(false);
});
it("should return false when keys is an empty object", () => {
// Empty object doesn't have required api_key field
const result = shouldEnableApp("dailyvideo", {});
expect(result).toBe(false);
});
it("should return false when keys is an array", () => {
const result = shouldEnableApp("dailyvideo", [] as any);
expect(result).toBe(false);
});
it("should return false when keys is a string", () => {
const result = shouldEnableApp("dailyvideo", "invalid" as any);
expect(result).toBe(false);
});
it("should return false when keys is a number", () => {
const result = shouldEnableApp("dailyvideo", 123 as any);
expect(result).toBe(false);
});
it("should return false when required keys are missing", () => {
// Missing required api_key field
const result = shouldEnableApp("dailyvideo", {
scale_plan: "true",
});
expect(result).toBe(false);
});
it("should return false when required keys are empty strings", () => {
// api_key is empty string, which violates .min(1)
const result = shouldEnableApp("dailyvideo", {
api_key: "",
scale_plan: "false",
});
expect(result).toBe(false);
});
it("should return true when all required keys are present and valid", () => {
const result = shouldEnableApp("dailyvideo", {
api_key: "valid-api-key",
scale_plan: "false",
});
expect(result).toBe(true);
});
it("should return true when required keys are present and optional fields use defaults", () => {
// scale_plan has a default, so we can omit it
const result = shouldEnableApp("dailyvideo", {
api_key: "valid-api-key",
});
expect(result).toBe(true);
});
it("should return false when keys have wrong types", () => {
// api_key should be string, not number
const result = shouldEnableApp("dailyvideo", {
api_key: 123 as any,
scale_plan: "false",
});
expect(result).toBe(false);
});
});
describe("Apps with multiple required fields", () => {
it("should return false when any required field is missing", () => {
// vital requires mode, region, api_key, and webhook_secret
const result = shouldEnableApp("vital", {
mode: "sandbox",
region: "us",
api_key: "test-key",
// Missing webhook_secret
});
expect(result).toBe(false);
});
it("should return true when all required fields are present", () => {
const result = shouldEnableApp("vital", {
mode: "sandbox",
region: "us",
api_key: "test-key",
webhook_secret: "test-secret",
});
expect(result).toBe(true);
});
it("should return false when any required field is empty", () => {
const result = shouldEnableApp("vital", {
mode: "",
region: "us",
api_key: "test-key",
webhook_secret: "test-secret",
});
expect(result).toBe(false);
});
});
describe("Apps with empty key schemas (user-configured apps like PayPal, GTM)", () => {
// These apps have `appKeysSchema = z.object({})` - they don't need server-side keys
// Users configure them after installation, so they should always be enabled
it("should return true for PayPal when keys are null", () => {
const result = shouldEnableApp("paypal", null);
expect(result).toBe(true);
});
it("should return true for PayPal when keys are undefined", () => {
const result = shouldEnableApp("paypal", undefined);
expect(result).toBe(true);
});
it("should return true for PayPal when keys are empty object", () => {
const result = shouldEnableApp("paypal", {});
expect(result).toBe(true);
});
it("should return true for GTM when keys are null", () => {
const result = shouldEnableApp("gtm", null);
expect(result).toBe(true);
});
it("should return true for GTM when keys are undefined", () => {
const result = shouldEnableApp("gtm", undefined);
expect(result).toBe(true);
});
it("should return true for GA4 when keys are null", () => {
const result = shouldEnableApp("ga4", null);
expect(result).toBe(true);
});
});
});
@@ -0,0 +1,28 @@
import { appKeysSchemas } from "@calcom/app-store/apps.keys-schemas.generated";
import type { Prisma } from "@calcom/prisma/client";
/**
* Determines if an app should be enabled based on whether it has valid keys.
* This is used by app registration scripts to prevent enabling apps without proper configuration.
*
* @param dirName - The directory name of the app
* @param keys - The keys object (can be undefined/null)
*/
export function shouldEnableApp(dirName: string, keys?: Prisma.JsonValue | null): boolean {
const keySchema = appKeysSchemas[dirName as keyof typeof appKeysSchemas];
// If no schema, the app doesn't require keys - can be enabled
if (!keySchema) {
return true;
}
// If keys is null/undefined, check if the schema accepts an empty object.
if (keys === null || keys === undefined) {
const emptyObjectResult = keySchema.safeParse({});
return emptyObjectResult.success;
}
// Validate keys against schema
const result = keySchema.safeParse(keys);
return result.success;
}
@@ -28,6 +28,8 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
const ignoreGuests = getAppData("ignoreGuests") ?? false;
const skipContactCreation = getAppData("skipContactCreation") ?? false;
const setOrganizerAsOwner = getAppData("setOrganizerAsOwner") ?? false;
const overwriteContactOwner = getAppData("overwriteContactOwner") ?? false;
const onBookingWriteToEventObject = getAppData("onBookingWriteToEventObject") ?? false;
const onBookingWriteToEventObjectFields = getAppData("onBookingWriteToEventObjectFields") ?? {};
@@ -37,7 +39,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
returnTo={`${WEBAPP_URL}${pathname}?tabName=apps`}
app={app}
teamId={eventType.team?.id || undefined}
switchOnClick={(e) => {
switchOnClick={(e: boolean): void => {
updateEnabled(e);
}}
switchChecked={enabled}
@@ -52,7 +54,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
size="sm"
labelOnLeading
checked={ignoreGuests}
onCheckedChange={(checked) => {
onCheckedChange={(checked: boolean): void => {
setAppData("ignoreGuests", checked);
}}
/>
@@ -67,12 +69,47 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
size="sm"
labelOnLeading
checked={skipContactCreation}
onCheckedChange={(checked) => {
onCheckedChange={(checked: boolean): void => {
setAppData("skipContactCreation", checked);
}}
/>
</Section.SubSectionHeader>
</Section.SubSection>
<Section.SubSection>
<Section.SubSectionHeader
icon="user-check"
title={t("set_organizer_as_contact_owner")}
labelFor="set-organizer-as-owner">
<Switch
size="sm"
labelOnLeading
checked={setOrganizerAsOwner}
onCheckedChange={(checked: boolean): void => {
setAppData("setOrganizerAsOwner", checked);
if (!checked) {
setAppData("overwriteContactOwner", false);
}
}}
/>
</Section.SubSectionHeader>
</Section.SubSection>
{setOrganizerAsOwner && (
<Section.SubSection>
<Section.SubSectionHeader
icon="refresh-cw"
title={t("overwrite_existing_contact_owner")}
labelFor="overwrite-contact-owner">
<Switch
size="sm"
labelOnLeading
checked={overwriteContactOwner}
onCheckedChange={(checked: boolean): void => {
setAppData("overwriteContactOwner", checked);
}}
/>
</Section.SubSectionHeader>
</Section.SubSection>
)}
<Section.SubSection>
<WriteToObjectSettings
@@ -80,10 +117,10 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
optionLabel={t("on_booking_write_to_event_object")}
optionEnabled={onBookingWriteToEventObject}
writeToObjectData={onBookingWriteToEventObjectFields}
optionSwitchOnChange={(checked) => {
optionSwitchOnChange={(checked: boolean): void => {
setAppData("onBookingWriteToEventObject", checked);
}}
updateWriteToObjectData={(data) => setAppData("onBookingWriteToEventObjectFields", data)}
updateWriteToObjectData={(data): void => setAppData("onBookingWriteToEventObjectFields", data)}
supportedFieldTypes={[
CrmFieldType.TEXT,
CrmFieldType.DATE,
@@ -24,7 +24,11 @@ const {
properties: { coreApi: { getAll: ReturnType<typeof vi.fn> } };
contacts: {
searchApi: { doSearch: ReturnType<typeof vi.fn> };
basicApi: { create: ReturnType<typeof vi.fn> };
basicApi: {
create: ReturnType<typeof vi.fn>;
getById: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
};
};
objects: {
meetings: {
@@ -58,6 +62,8 @@ const {
},
basicApi: {
create: vi.fn(),
getById: vi.fn(),
update: vi.fn(),
},
},
objects: {
@@ -670,15 +676,26 @@ describe("HubspotCalendarService", () => {
}
);
});
it("should set hubspot owner when organizer email matches", async () => {
mockAppOptions({});
it("should set contact owner when setOrganizerAsOwner is enabled and organizer email matches", async () => {
mockAppOptions({ setOrganizerAsOwner: true });
// Mock owner lookup - return matching owner
mockHubspotClient.crm.owners.ownersApi.getPage.mockResolvedValue({
results: [{ id: "owner-123", email: "organizer@example.com" }],
});
// Mock contact owner lookup - return no existing owner
mockHubspotClient.crm.contacts.basicApi.getById.mockResolvedValue({
id: "contact-1",
properties: { hubspot_owner_id: null },
});
// Mock contact update
mockHubspotClient.crm.contacts.basicApi.update.mockResolvedValue({
id: "contact-1",
properties: { hubspot_owner_id: "owner-123" },
});
mockHubspotClient.crm.objects.meetings.basicApi.create.mockResolvedValue({
id: "meeting-123",
properties: {},
@@ -693,10 +710,106 @@ describe("HubspotCalendarService", () => {
await service.createEvent(event, contacts);
// Verify contact owner was set
expect(mockHubspotClient.crm.contacts.basicApi.update).toHaveBeenCalledWith("contact-1", {
properties: { hubspot_owner_id: "owner-123" },
});
// Verify meeting was created with hubspot_owner_id (meeting owner is always set from organizer)
const createCall = mockHubspotClient.crm.objects.meetings.basicApi.create.mock.calls[0][0];
expect(createCall.properties.hubspot_owner_id).toBe("owner-123");
});
it("should overwrite existing contact owner when overwriteContactOwner is true", async () => {
mockAppOptions({ setOrganizerAsOwner: true, overwriteContactOwner: true });
mockHubspotClient.crm.owners.ownersApi.getPage.mockResolvedValue({
results: [{ id: "owner-123", email: "organizer@example.com" }],
});
mockHubspotClient.crm.contacts.basicApi.update.mockResolvedValue({
id: "contact-1",
properties: { hubspot_owner_id: "owner-123" },
});
mockHubspotClient.crm.objects.meetings.basicApi.create.mockResolvedValue({
id: "meeting-123",
properties: {},
});
mockHubspotClient.crm.associations.batchApi.create.mockResolvedValue({
results: [],
});
const event = createMockEvent();
const contacts = [{ id: "contact-1", email: "attendee@example.com" }];
await service.createEvent(event, contacts);
expect(mockHubspotClient.crm.contacts.basicApi.update).toHaveBeenCalledWith("contact-1", {
properties: { hubspot_owner_id: "owner-123" },
});
expect(mockHubspotClient.crm.contacts.basicApi.getById).not.toHaveBeenCalled();
});
it("should not overwrite existing contact owner when overwriteContactOwner is false", async () => {
mockAppOptions({ setOrganizerAsOwner: true, overwriteContactOwner: false });
mockHubspotClient.crm.owners.ownersApi.getPage.mockResolvedValue({
results: [{ id: "owner-123", email: "organizer@example.com" }],
});
mockHubspotClient.crm.contacts.basicApi.getById.mockResolvedValue({
id: "contact-1",
properties: { hubspot_owner_id: "existing-owner-456" },
});
mockHubspotClient.crm.objects.meetings.basicApi.create.mockResolvedValue({
id: "meeting-123",
properties: {},
});
mockHubspotClient.crm.associations.batchApi.create.mockResolvedValue({
results: [],
});
const event = createMockEvent();
const contacts = [{ id: "contact-1", email: "attendee@example.com" }];
await service.createEvent(event, contacts);
expect(mockHubspotClient.crm.contacts.basicApi.getById).toHaveBeenCalledWith("contact-1", [
"hubspot_owner_id",
]);
expect(mockHubspotClient.crm.contacts.basicApi.update).not.toHaveBeenCalled();
});
it("should not set contact owner when organizer has no matching HubSpot owner", async () => {
mockAppOptions({ setOrganizerAsOwner: true });
mockHubspotClient.crm.owners.ownersApi.getPage.mockResolvedValue({ results: [] });
mockHubspotClient.crm.objects.meetings.basicApi.create.mockResolvedValue({
id: "meeting-123",
properties: {},
});
mockHubspotClient.crm.associations.batchApi.create.mockResolvedValue({
results: [],
});
const event = createMockEvent();
const contacts = [{ id: "contact-1", email: "attendee@example.com" }];
await service.createEvent(event, contacts);
expect(mockHubspotClient.crm.contacts.basicApi.getById).not.toHaveBeenCalled();
expect(mockHubspotClient.crm.contacts.basicApi.update).not.toHaveBeenCalled();
const createCall = mockHubspotClient.crm.objects.meetings.basicApi.create.mock.calls[0][0];
expect(createCall.properties.hubspot_owner_id).toBeUndefined();
});
it("should create meeting successfully when owner lookup fails due to missing scope", async () => {
mockAppOptions({});
+79 -37
View File
@@ -69,15 +69,17 @@ class HubspotCalendarService implements CRM {
.join("<br><br>");
const organizerName = event.organizer.name || event.organizer.email;
const organizerInfo = `<b>${event.organizer.language.translate("organizer")}:</b> ${organizerName} (${event.organizer.email
})`;
const organizerInfo = `<b>${event.organizer.language.translate("organizer")}:</b> ${organizerName} (${event.organizer.email})`;
return `${organizerInfo}<br><br><b>${event.organizer.language.translate("invitee_timezone")}:</b> ${event.attendees[0].timeZone
}<br><br>${event.additionalNotes
? `<b>${event.organizer.language.translate("share_additional_notes")}</b><br>${event.additionalNotes
}<br><br>`
return `${organizerInfo}<br><br><b>${event.organizer.language.translate("invitee_timezone")}:</b> ${
event.attendees[0].timeZone
}<br><br>${
event.additionalNotes
? `<b>${event.organizer.language.translate("share_additional_notes")}</b><br>${
event.additionalNotes
}<br><br>`
: ""
}
}
${userFieldsHtml}<br><br>
<b>${event.organizer.language.translate("where")}:</b> ${location}<br><br>
${plainText ? `<b>${event.organizer.language.translate("description")}</b><br>${plainText}` : ""}
@@ -87,7 +89,7 @@ class HubspotCalendarService implements CRM {
private async ensureFieldsExistOnMeeting(fieldsToTest: string[]) {
const log = logger.getSubLogger({ prefix: [`[ensureFieldsExistOnMeeting]`] });
const fieldSet = new Set(fieldsToTest);
const foundFields: Array<{ name: string; type: string;[key: string]: any }> = [];
const foundFields: Array<{ name: string; type: string; [key: string]: any }> = [];
try {
const properties = await this.hubspotClient.crm.properties.coreApi.getAll("meetings");
@@ -362,33 +364,6 @@ class HubspotCalendarService implements CRM {
return this.hubspotClient.crm.objects.meetings.basicApi.archive(uid);
};
private getHubspotOwnerIdByEmail = async (email: string): Promise<string | undefined> => {
try {
const ownersResponse = await this.hubspotClient.crm.owners.ownersApi.getPage(
email,
undefined,
100,
false
);
if (ownersResponse.results && ownersResponse.results.length > 0) {
const matchingOwner = ownersResponse.results.find(
(owner) => owner.email?.toLowerCase() === email.toLowerCase()
);
if (matchingOwner) {
this.log.debug("hubspot:owner:found", { ownerId: matchingOwner.id });
return matchingOwner.id;
}
}
this.log.debug("hubspot:owner:not_found");
return undefined;
} catch (error) {
this.log.error("hubspot:owner:lookup_error", { error });
return undefined;
}
};
private hubspotAuth = async (credential: CredentialPayload) => {
const appKeys = await getAppKeysFromSlug("hubspot");
@@ -455,14 +430,25 @@ class HubspotCalendarService implements CRM {
const organizerEmail = event.organizer.email;
this.log.debug("hubspot:meeting:fetching_owner");
const hubspotOwnerId = await this.getHubspotOwnerIdByEmail(organizerEmail);
const hubspotOwnerId = await this.getHubspotOwnerIdFromEmail(organizerEmail);
const meetingEvent = await this.hubspotCreateMeeting(event, hubspotOwnerId);
const meetingEvent = await this.hubspotCreateMeeting(event, hubspotOwnerId ?? undefined);
if (meetingEvent) {
this.log.debug("meeting:creation:ok", { meetingId: meetingEvent.id, hubspotOwnerId });
const associatedMeeting = await this.hubspotAssociate(meetingEvent, contactIds as any);
if (associatedMeeting) {
this.log.debug("association:creation:ok", { associatedMeeting });
const firstContact = contacts[0];
const appOptions = this.getAppOptions();
if (hubspotOwnerId && firstContact?.id && appOptions?.setOrganizerAsOwner) {
await this.setContactOwnerIfAllowed(
firstContact.id,
hubspotOwnerId,
appOptions?.overwriteContactOwner ?? false
);
}
return Promise.resolve({
uid: meetingEvent.id,
id: meetingEvent.id,
@@ -581,6 +567,62 @@ class HubspotCalendarService implements CRM {
async handleAttendeeNoShow() {
console.log("Not implemented");
}
private async getHubspotOwnerIdFromEmail(email: string): Promise<string | null> {
try {
const ownersResponse = await this.hubspotClient.crm.owners.ownersApi.getPage(
email,
undefined,
100,
false
);
if (ownersResponse.results && ownersResponse.results.length > 0) {
const owner = ownersResponse.results.find((o) => o.email?.toLowerCase() === email.toLowerCase());
return owner?.id ?? null;
}
return null;
} catch (error) {
this.log.error("Error fetching HubSpot owner:", error);
return null;
}
}
private async setContactOwner(contactId: string, ownerId: string): Promise<void> {
try {
await this.hubspotClient.crm.contacts.basicApi.update(contactId, {
properties: { hubspot_owner_id: ownerId },
});
} catch (error) {
this.log.error("Error setting contact owner:", error);
}
}
private async getContactOwnerId(contactId: string): Promise<string | null> {
const contact = await this.hubspotClient.crm.contacts.basicApi.getById(contactId, [
"hubspot_owner_id",
]);
return contact.properties.hubspot_owner_id ?? null;
}
private async setContactOwnerIfAllowed(
contactId: string,
ownerId: string,
overwriteContactOwner: boolean
): Promise<void> {
if (overwriteContactOwner) {
await this.setContactOwner(contactId, ownerId);
return;
}
try {
const currentOwnerId = await this.getContactOwnerId(contactId);
if (!currentOwnerId) {
await this.setContactOwner(contactId, ownerId);
}
} catch (error) {
this.log.error("Error fetching contact owner, skipping owner update:", error);
}
}
}
/**
+2
View File
@@ -9,6 +9,8 @@ export { CrmFieldType, WhenToWrite, DateFieldType } from "../_lib/crm-enums";
export const appDataSchema = eventTypeAppCardZod.extend({
ignoreGuests: z.boolean().optional(),
skipContactCreation: z.boolean().optional(),
setOrganizerAsOwner: z.boolean().optional(),
overwriteContactOwner: z.boolean().optional(),
onBookingWriteToEventObject: z.boolean().optional(),
onBookingWriteToEventObjectFields: z.record(z.string(), writeToBookingEntry).optional(),
});

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