Compare commits

...
Author SHA1 Message Date
keithwillcode 9d65b6f794 chore: release v5.5.3
Post release / release (push) Canceled after 0s
2025-07-18 22:30:47 +00:00
4874c6c2ee feat: add 2FA status column to organization members table (#22564)
* feat: add 2FA status column to organization members table

- Add twoFactorEnabled column to UserListTable with admin-only visibility
- Modify backend handler to conditionally include 2FA data for org admins
- Set column as hidden by default using initalColumnVisibility
- Use Badge component to display enabled/disabled status
- Implement proper permission checks using adminOrOwner variable

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

* fix: hide 2FA column from display options for non-admin users

- Change enableHiding from true to adminOrOwner
- Prevents non-admin users from seeing 2FA column in column visibility menu
- Maintains existing cell-level permission checks for data access

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

* fix: resolve CI workflow dependency mismatch for integration-test job

- Remove build job dependencies from integration-test job that were causing skipped job failures
- integration-test now depends on [changes, check-label, deps] instead of build jobs
- This fixes the 'required' check failure where skipped conditional jobs were treated as failures

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

* revert: restore integration-test job dependencies as requested by keithwillcode

- Restore integration-test dependencies to [changes, check-label, build, build-api-v1, build-api-v2]
- Add conditional logic to only run integration-test when run-e2e == 'true'
- This prevents the 'required' check from failing due to skipped conditional jobs
- Addresses GitHub comment from keithwillcode requesting workflow revert

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

* revert: remove conditional logic from integration-test job as requested

- Remove run-e2e condition from integration-test job
- Restore original workflow exactly as it was before any modifications
- Addresses GitHub comment from keithwillcode requesting no CI changes

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-18 20:39:59 +00:00
30a92a4d66 chore(eslint): add no-this-in-static-method rule to prevent context loss (#22410)
Co-authored-by: eunjae@cal.com <hey@eunjae.dev>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
2025-07-18 13:05:53 -07:00
Udit Takkar 04ce054cf7 fix: remove team plan check (#22628) 2025-07-18 18:14:58 +00:00
Alex van Andel e901e8d189 perf: Prevent parse when object is empty, resulting in a truthy value (#22625) 2025-07-18 17:32:45 +00:00
0aa595ddac fix: update enterprise sales URL from i.cal.com to go.cal.com (#22627)
* fix: update enterprise sales URL from i.cal.com to go.cal.com

- Update EnterprisePage contact sales button URL
- Update TeamList admin tools learn more URL
- Update platform pricing enterprise redirect URL
- Update documentation example URL
- All URLs now point to go.cal.com/enterprise

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

* Apply suggestions from code review

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-18 16:05:24 +00:00
Lauris Skraucis 2115a045ec fix: v2 ignore permissions check for 3rd party oauth (#22588) 2025-07-18 13:53:51 +00:00
Omar López f67522dbbd Fix API handler response termination logic (#22575) 2025-07-18 12:21:54 +00:00
sean-brydonandAlex van Andel dc5aef0dd4 feat: preview default roles permissions (#22621)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-07-18 12:20:19 +00:00
Romit e1d7631215 fix: Editing RR weights adds all hosts (#22623)
* fixed

* chore

* chore
2025-07-18 12:55:52 +01:00
05e1ad0c91 fix: Code Quality improvements to response record endpoint and added unit tests (#22264)
* chore: remove unnecessary logs and fix documentation

* refactor: extract GetSlotsInputWithRouting type and eliminate code duplication

- Move GetSlotsInputWithRouting_2024_09_04 type to platform-types package for reuse
- Refactor slots service to eliminate duplicate error handling logic
- Fix TypeScript errors in slots service tests by adding missing type property
- Update test expectations to match new implementation

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

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

* refactor: improve type safety in slots service

- Export explicit types from slots-input.service for transformed queries
- Replace 'any' type with proper TransformedSlotsQuery union type
- Re-implement fetchAndFormatSlots abstraction to eliminate code duplication
- Revert unrelated console.log in router.controller.ts

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

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

* refactor: simplify slots service implementation

- Remove intermediate variable assignment in getAvailableSlotsWithRouting
- Update test to match simplified routing parameters structure

* test: add comprehensive error handling and edge case tests for slots service

- Add error scenario tests for NotFoundException, invalid time range, and generic errors
- Add edge case tests for null/undefined parameters and empty arrays
- Improve test coverage for getAvailableSlotsWithRouting method
- Mock SlotsInputService properly to enable isolated unit testing

* 📝 CodeRabbit Chat: Rename TransformedGetSlotsQuery types to InternalGetSlotsQuery in slot services

* fix: correct import path for AvailableSlotsService in slots service test

- Fix import path from '@/lib/services/AvailableSlots' to '@/lib/services/available-slots.service'
- Resolves unit test failure due to case sensitivity/naming mismatch
- All API v2 tests now pass (9 test suites, 142 tests)

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-18 16:26:53 +05:30
sean-brydonandEunjae Lee cf392562da feat: team roles ui pbac (#22585)
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-07-18 10:12:25 +00:00
Anik Dhabal Babu c8de8ca681 fix: don't use unpublished org in any credit context (#22600)
* fix: don't use unpublished org for any credit context

* type error

* fix test

* update
2025-07-18 10:03:50 +00:00
Joe Au-Yeung 2c91c0e309 Use org slug in link (#22612) 2025-07-18 07:02:04 +00:00
Hariom BalharaandDevin AI cef007d852 docs: add BrowserStack attribution to README (#22615)
Add required attribution 'This project is tested with browserstack' to README.md
for BrowserStack open source account eligibility

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-18 05:51:46 +00:00
keithwillcode 97b81d6f6b chore: release v5.5.2
Post release / release (push) Canceled after 0s
2025-07-18 01:17:02 +00:00
Alex van Andel ac117a14eb perf: Faster logic by preventing instanceof Dayjs in slots.ts (#22590)
* perf: Faster logic by preventing instanceof Dayjs in slots.ts

* Flip isSameOrAfter to isBefore as .. before
2025-07-17 17:53:34 -04:00
f85f226982 fix: Create RoutingFormResponseService to get field value from identifier (#22396)
* Make identifier required

* Fallback to null if identifier isn't present

* Type fix

* Type fixes

* Type fix

* Create `RoutingFormResponseRepository`

* Create `RoutingFormResponseService`

* Use repsotiories to find form value

* Delete console.logs

* Undo change in `ZResponseInputSchema` schema

* Type fix

* Undo changes

* fix: correct import path in RoutingFormResponseService to resolve runtime errors

- Change relative import path to use @calcom alias
- Prevents import resolution failures that cause app startup issues

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

* Undo changes

* Update type

* Update typing

* Address feedback

* Address feedback

* chore: Provide a suggestion for pr 22396, new structure (#22491)

* chore: Provide a suggestion for pr 22396, new structure

* Refactor to create two create methods with bookingUid and id

* Use `createWithBookingUid`

* Extract routing form response parser to seperate util

* Added more and improved test cases

* Fix

---------

Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>

* Factory included wrong calls

* Fix test for findFieldValueByIdentifier

* Add tests

* Fix test

* Fix test

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-07-17 22:40:18 +01:00
Eunjae LeeandDevin AI f4b48e863b fix: add missing ref parameter to SatSymbol forwardRef component (#22274)
- Fixed forwardRef render function to accept both props and ref parameters
- Added ref prop to SVG element to properly forward the ref
- Resolves React error: 'forwardRef render functions accept exactly two parameters: props and ref'

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-17 20:52:00 +00:00
Anik Dhabal Babu 4dc22606d9 fix: create new meeting room (#22591) 2025-07-17 16:22:16 +00:00
292073eb4a feat: make Salesforce WriteToObjectSettings entries editable with save/cancel functionality (#22440)
* feat: make Salesforce WriteToObjectSettings entries editable with save/cancel functionality

- Add editingRows and editingData state to track which rows are being edited
- Implement startEditing, cancelEditing, and saveEditing functions for row-level edit management
- Replace read-only existing entries with editable fields when in edit mode
- Add save/cancel buttons with appropriate icons (check/x) for each row
- Include field name validation to prevent duplicate entries
- Maintain existing delete functionality alongside new edit capabilities
- Update action column width to accommodate save/cancel buttons

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

* fix: address Code Rabbit review feedback for WriteToObjectSettings

- Fix spread operator usage to use editData instead of prev[key]
- Add validation for empty field names
- Implement single-row editing for better UX

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-17 10:48:54 -04:00
Eunjae Lee 1878802213 fix: update styles of charts on /insights (#22557)
* fix: update styles of charts on /insights

* clean up

* clean up

* share the same styles with ChartCardItem

* margin for event trends

* fix type error
2025-07-17 14:42:35 +00:00
9cfc74a74f fix: don't schedule reminder from the past (#22353)
* only schedule future reminders

* use new Date() instead

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

* use new Date() instead

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

* use new Date() instead

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

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-07-17 14:33:56 +00:00
Lauris Skraucis c472bce54f fix: v2 managed event type creation hosts (#22584)
* fix: v2 managed event type creation hosts

* refactor

* fix: test

* remove uncecessary call
2025-07-17 09:02:12 +00:00
Joe Au-Yeung a498641b61 Use WEBAPP_URL (#22568) 2025-07-17 07:29:58 +00:00
Hariom Balhara ec58e59865 fix: Add new route to create team routing-form response again(after revert earlier) (#22407)
* Revert "revert: "fix: Add new route to create team routing-form response (#22347)" (#22399)"

This reverts commit e6c36af3b4.

* Update the documentation

* Remove versioning
2025-07-17 11:33:51 +05:30
emrysal 4f95cff70e chore: release v5.5.1
Post release / release (push) Canceled after 0s
2025-07-16 22:58:49 +00:00
Benny Joo fa66f2531f fix: 400 is correct error code for computing slot for past booking (#22574)
* fix

* add test
2025-07-16 22:46:03 +00:00
Benny Joo b40d448e07 feat: migration for team booking page cache feature flag (#22566) 2025-07-16 19:34:42 +00:00
Alex van AndelandDevin AI 21add18ad8 perf: Remove isAfter/isBefore in date-ranges:subtract (#22549)
* perf: Remove isAfter/isBefore in date-ranges:subtract

* Small amend to also use valueOf here

* Addressed issue with utc offset in non-utc mode

* test: add failing test demonstrating timezone offset bug in subtract function

This test shows that when mixing UTC and timezone-aware dayjs objects,
the timezone offset adjustment (date.valueOf() + date.utcOffset() * 60000)
creates incorrect chronological comparisons that prevent proper exclusion.

The test expects 0 results but gets 6, reproducing the issue causing
futureLimit.timezone.test.ts to fail with extra time slots.

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

* Fixed failing tests that proved the logic wasn't working correctly

* test: add scheduling pipeline reproduction test for futureLimit.timezone.test.ts failures

This test reproduces the exact scenario from getBusyTimes.ts that causes
the 6 extra slots (12:30-17:30 UTC) to appear in futureLimit.timezone.test.ts.

The test simulates:
- calendarBusyTimes from external calendar sources (converted to dayjs objects)
- openSeatsDateRanges from booking data with mixed timezone contexts
- The specific timezone mixing pattern that causes exclusion to fail

This demonstrates the remaining issue in the subtract function after
the timezone offset bug was fixed.

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

* test: add getUserAvailability reproduction test showing subtract extends ranges instead of excluding busy times

This test reproduces the exact scenario from futureLimit.timezone.test.ts where the subtract function incorrectly extends available range end times to match busy time start times instead of properly subtracting the busy times from the available ranges.

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

* test: fix getUserAvailability reproduction test to expect actual buggy behavior

The test now expects the current buggy behavior (5 ranges with 2024-06-02 missing)
instead of correct behavior, so it passes with current logic and would fail once
the subtract function is fixed to properly handle non-overlapping busy times.

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

* test: fix getUserAvailability reproduction test to expect correct behavior and fail with buggy logic

The test now expects the correct behavior (ranges should remain at 12:30:00.000Z and all 6 ranges should be present) so it fails with the current buggy subtract function logic that incorrectly extends ranges to 18:30:00.000Z. Once the subtract function is fixed, this test will pass.

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

* Push the valid fix for the futureLimits test failure

* test: update getUserAvailability test console output to match corrected behavior

The test now correctly expects 5 ranges (with June 2 properly excluded due to overlapping busy time) and the console output reflects this corrected behavior.

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

* Remove console.log calls

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-16 12:50:35 -04:00
Eunjae Leeandcubic-dev-ai[bot] 10fca86ddc feat: add drop off funnel chart for /insights/routing (#22106)
* feat: add Drop-off funnel chart for /insights/routing

* convert insights services to use kysely

* WIP

* implement drop-off data WIP

* updates

* revert services back to Prisma

* do not expose findMany

* WIP

* revert usage

* fix type errors

* updates

* clean up

* update playground

* update styles

* support columnFilters

* update tooltip

* add skeleton

* remove dynamic loading

* avoid using Prisma.raw

* update condition for user scope

* Update packages/features/insights/components/RoutingFunnelContent.tsx

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

* playground fix

* fix type error

* update playground

* update styles

* implement subtitle to ChartCard

* revert insights booking service

* update border

* update ChartCard style

* style updates

* convert the playground to a client component

* clean up

* apply feedback

* fix tests

* fix unit test

* hide scrollbar

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-07-16 16:36:41 +02:00
Morgan c2fc8774df Revert "refactor: filter out admins and owners from platform event types host…" (#22325)
This reverts commit c90fded6b7.
2025-07-16 13:59:44 +00:00
969b58f17d docs: clarify bookingFields replacement behavior in update event type input (#22554)
* docs: clarify bookingFields replacement behavior in update event type input

Update description to explain that bookingFields array completely replaces
existing fields rather than adding to them. Users must fetch existing fields
first before making modifications.

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

* Update update-event-type.input.ts

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: lauris@cal.com <lauris@cal.com>
Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
2025-07-16 13:58:01 +00:00
a0e25ff270 fix: eliminate flaky E2E tests by adding proper test isolation (#22488)
* fix: eliminate flaky E2E tests by adding proper test isolation

- Add targeted afterEach hooks to clean up bookings between cancel tests
- Modify cancel booking tests to create fresh data instead of relying on shared state
- Follow existing pattern from recurring-bookings.e2e-spec.ts for proper test isolation
- Fix recurring booking expectation from 4 to 3 bookings to match API behavior
- Fixes race conditions and data contamination issues in API V2 E2E suite

The specific 'should cancel recurring booking' test that was failing with 400 Bad Request
now passes consistently. Test suite improved from 34 passing to 66 passing tests.

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

* fix: replace strict host order assertion with order-agnostic matcher

- Replace exact array equality check with expect.arrayContaining()
- Fixes non-deterministic host ordering in 'should create a managed team event-type' test
- Prevents flaky failures when tests run together due to different host order

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

* fix: ensure roundRobinEventType is initialized before dependent test runs

- Add initialization check in 'should update round robin event type' test
- Creates roundRobinEventType if not already set by previous test
- Eliminates dependency on test execution order
- Fixes undefined variable errors when tests run together with other suites

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

* fix: add validation for roundRobinEventType and improve error handling in assign-all-team-members.e2e-spec.ts

- Add check to ensure roundRobinEventType is defined before dependent test runs
- Improve error handling in evaluateHost function with better error messages
- Prevents undefined variable errors when tests run together with other suites

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

* fix: remove hardcoded mandatory/priority values from evaluateHost calls

- Remove hardcoded mandatory: false, priority: 'medium' from all evaluateHost calls
- Update evaluateHost function to accept Partial<Host> for expected values
- Only validate userId field to prevent test failures when mandatory/priority are undefined
- Fixes user ID mismatch errors and mandatory field assertion failures

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

* refactor: extract managedEventType setup into reusable utility function

- Create ensureManagedEventType() utility function to eliminate code duplication
- Replace 5 instances of repetitive managedEventType setup code with utility function calls
- Maintain same functionality while reducing code duplication in teams-event-types.controller.e2e-spec.ts
- All tests continue to pass after refactoring

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

* fix: restore mandatory/priority props and exact equality assertions per review feedback

- Restore mandatory: false, priority: 'medium' props in evaluateHost calls in assign-all-team-members.e2e-spec.ts
- Change toBeGreaterThanOrEqual back to toEqual for exact count assertions in teams-event-types.controller.e2e-spec.ts
- Addresses review feedback from supalarry on PR #22488

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

* Update Schedule.tsx

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: anik@cal.com <anik@cal.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: anik@cal.com <adhabal2002@gmail.com>
2025-07-16 13:43:06 +00:00
Lauris Skraucis df30f5c703 fix: v2 dev (#22552) 2025-07-16 12:53:05 +00:00
Anik Dhabal Babu fb59342de4 fix: Show the Stripe payment error message and change the status to 400 for charge card issues (#22463)
* fix: show stripe payment error message

* Update PaymentService.ts

* Update chargeCard.handler.ts

* Update chargeCard.handler.ts

* Update PaymentService.ts

* update

* update
2025-07-16 09:45:49 +00:00
Joe Au-Yeung a84e8987cb Add identifiers to log (#22537) 2025-07-16 02:42:04 +00:00
Benny Joo c4eda5f21d perf: caching + modularize data fetching for team booking pages (#22172)
* wip

* wip

* wip

* refactor

* refactor

* refactor

* refactor

* refactor

* fix

* better

* fix

* fix

* use notFound

* refactor

* wip

* refactor

* wip

* refactor

* wip

* wip

* finalize

* wip

* fix

* lots of refactors

* better code

* clean up

* fix

* fix type checks

* finalize

* select more fields

* select more fields

* fix

* fix

* fix

* fix

* better

* better

* fix

* fix

* fix

* fix

* add back comment

* fix

* better

* fix

* refactor

* fix

* fix type check

* add comment

* fix

* wip

* wip

* add more comment

* refactor

* rename

* refactors

* fix reschedule logic

* remove hard-coded true from isCachedEnabled

* better

* use revalidateTeamDataCache in team profile update

* fix invalidation

* better comment

* refactors

* add better comments

* add revalidations

* add comments

* fix

* remove hard code

* fix

* update tags

* use features package

* fix type check
2025-07-15 21:42:55 -03:00
Benny Joo e6da5c85e0 perf: remove me fetching from teams by refactoring (#22444) 2025-07-15 23:23:34 +00:00
Joe Au-Yeung bfc6d6e1e9 Change log (#22534) 2025-07-15 17:45:20 +00:00
10bf982028 feat: add setting to automatic start recording (#21853)
* feat: disable transcription setting in cal video

* feat: add enable automatic recording feature

* chore: only when organizer joins

* chore: update name of variable

* fix: improvements

* chore: improvements

* chore: add badge

* refactor: cal video premium

* tests: add tests for cal video

* fix: use isOrganizer

* Simplify type to remove 'as SessionUser' from new code

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-07-15 16:54:23 +01:00
Eunjae Lee 8ee7b12c2b docs: add review rules for agents (#22527) 2025-07-15 13:40:01 +00:00
Carina WollendorferandCarinaWolli afe5fea6eb fix: improve restriction schedule description (#22517)
* fix description

* add back isRestrictionScheduleEnabled

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-07-15 14:22:56 +02:00
emrysal 5854de3c23 chore: release v5.5.0
Post release / release (push) Canceled after 0s
2025-07-15 09:57:23 +00:00
Eunjae LeeandDevin AI 967442892a feat: convert InsightsBookingService to use Prisma.sql raw queries (#22345)
* fix: use raw query at InsightsBookingService

* feat: convert InsightsBookingService to use Prisma.sql raw queries

- Convert auth conditions from Prisma object notation to Prisma.sql
- Convert filter conditions from Prisma object notation to Prisma.sql
- Update return types from Prisma.BookingTimeStatusDenormalizedWhereInput to Prisma.Sql
- Fix type error in isOrgOwnerOrAdmin method
- Follow same pattern as InsightsRoutingService conversion

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

* feat: convert InsightsBookingService to use Prisma.sql raw queries

- Convert auth conditions from Prisma object notation to Prisma.sql
- Convert filter conditions from Prisma object notation to Prisma.sql
- Update return types from Prisma.BookingTimeStatusDenormalizedWhereInput to Prisma.Sql
- Fix type error in isOrgOwnerOrAdmin method
- Follow same pattern as InsightsRoutingService conversion

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

* fix: update InsightsBookingService integration tests for Prisma.sql format

- Replace Prisma object notation expectations with Prisma.sql template literals
- Add NOTHING_CONDITION constant for consistency with InsightsRoutingService
- Update all test cases to use direct Prisma.sql comparisons
- Use $queryRaw for actual database integration testing
- Follow same testing patterns as InsightsRoutingService

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

* fix: exclude intentionally skipped jobs from required CI check failure

- Remove 'skipped' from failure condition in pr.yml and all-checks.yml
- Allow E2E jobs to be skipped without failing the required check
- Only actual failures and cancelled jobs will cause required check to fail

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

* fix tests

* Revert "fix: exclude intentionally skipped jobs from required CI check failure"

This reverts commit 6ff44fc9a8f14ad657f7bba7c2e454e192b66c8f.

* clean up tests

* address feedback

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-15 11:09:06 +02:00
Omar LópezandDevin AI 202d248ae4 fix: resolve unique constraint violations in listWithTeam integration test (#22493)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-15 07:51:51 +05:30
Hariom Balhara f667417d84 test: Add e2e for prerendering headless router with attribute routing (#22080)
* test: Add e2e for prerender

* wip

* fix tests

* conflict resolution

* Fix unit tests and e2e failure

* chore: Reorganize
2025-07-14 22:47:12 -03:00
emrysal 750e6315dc chore: release v5.4.20
Post release / release (push) Canceled after 0s
2025-07-15 00:37:37 +00:00
Omar LópezandDevin AI f117b2df32 fix: CalDAV credential verification between multiple instances (#22331)
Co-authored-by: zomars@cal.com <zomars@me.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-15 01:32:52 +01:00
Omar López 9631f083b8 Revert "feat: add race condition test for calendar-cache-serve booking flow (…" (#22507) 2025-07-14 16:58:19 -07:00
Benny Joo c8a52b78e1 Revert "refactor: replace i18n HTTP requests with build-time bundling (#22422)" (#22496)
This reverts commit 163c7ff791.
2025-07-14 22:49:25 +00:00
Parteek malik fe4946a269 fix: added custom filter for time select (#21717)
* added custom filter

* handling spaces
2025-07-14 20:39:07 +00:00
Anik Dhabal Babu 3de9c2bda0 fix: uncheck requires Confirmation For FreeEmail (#22486) 2025-07-14 16:30:11 +00:00
cee30c53b8 feat: remove Event Types from team settings navigation (#22472)
- Remove VerticalTabItem for Event Types from team settings sidebar
- Event Types link no longer appears in team settings navigation
- Other team settings options remain unchanged and functional

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-07-14 15:51:27 +00:00
randAnik Dhabal Babu f22b5d9daa Fix typo "{l" in event-type causing syntax error (#22474)
* Fix typo "{l" in event-type causing syntax error

* Update event-type.mdx

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-07-14 15:51:07 +00:00
88f8753329 feat: make Members tab open in new tab with external link icon (#22471)
- Add isExternalLink: true to Members tab configuration
- Change href to full URL https://app.cal.com/settings/organizations/i/members
- VerticalTabItem component will automatically show external link icon and open in new tab

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-07-14 21:14:09 +05:30
Lauris Skraucis fe02a97fa1 fix: increase v2 e2e instance size (#22481) 2025-07-14 15:29:18 +00:00
a9172fd832 feat: added cancelled by to booking detail view (#18521)
* feat: added cancelled by to booking detail view

* fix: var naming

* handled case for guest user

* move cancelled by outside reason

* Update bookings-single-view.tsx

* Update apps/web/modules/bookings/views/bookings-single-view.tsx

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

* fix

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: unknown <adhabal2002@gmail.com>
2025-07-14 15:15:38 +00:00
Lauris Skraucis 96a659ae5a feat: v2 managed orgs filters (#22420)
* fix: don't allow managed orgs with same slug

* feat: filter managed orgs by slug or metadata

* test

* fix: tests
2025-07-14 16:37:15 +02:00
Anik Dhabal Babu 13739dabd8 Revert "feat: Add cancelledBy input to cancellation page (#18770)" (#22479)
This reverts commit 2838053ba8.
2025-07-14 13:13:27 +00:00
Ashish manhas 22acfbae28 fix(ui): interpolate appName in invalid_credential message (#22448) 2025-07-14 10:37:48 +00:00
Lauris Skraucis adf50b2419 docs: v2 workflows and disable on prefill booking fields (#22445)
* wip

* docs: booking fields prefilling

* docs: wofklows

* fix: guests docs
2025-07-14 15:16:21 +05:30
Lauris Skraucis 35022a0478 fix: get v2/calendars existing record conflict (#22286)
* refactor: upsert destination calendar

* test: v2 connected calendars

* chore: bump libraries

* refactor: have only createIfNotExistsForUser

* refactor: exclude eventTypeId from user calendar search

* chore: bump libraries
2025-07-14 08:01:43 +00:00
2838053ba8 feat: Add cancelledBy input to cancellation page (#18770)
* feat: Add cancelledBy input to cancellation page

* fix: Update email verification error message

* Update safe-parse.ts

* fix: check attendees and booking owner email

* fix: minor ui

* Update CancelBooking.tsx

* Update common.json

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-07-14 07:14:18 +00:00
Benny Joo 66397a6626 fix: translation bundler logic (#22459)
* fix logic in translationBundler

* remove tsnode
2025-07-14 02:09:23 +00:00
Omar LópezandDevin AI dfe997dce2 feat: add race condition test for calendar-cache-serve booking flow (#22401)
Co-authored-by: zomars@cal.com <zomars@me.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-13 20:37:22 +05:30
163c7ff791 refactor: replace i18n HTTP requests with build-time bundling (#22422)
* refactor: replace i18n HTTP requests with build-time bundling

- Create translationBundler.ts for build-time translation loading
- Replace HTTP fetch in loadTranslations with file system reads
- Add CalComVersion cache invalidation to prevent stale translations
- Fix TypeScript errors in booking page components
- Eliminate 60s timeout issues by removing network dependency

Resolves translation timeout issues by bundling translations at build time
instead of making runtime HTTP requests to /static/locales/ endpoints.

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

* fix: move i18n files back to packages/lib/server with proper imports

- Move i18n.ts and translationBundler.ts back to packages/lib/server/
- Replace all relative imports with @calcom/lib/server/i18n pattern
- Fix LOCALES_PATH to point to correct directory
- Maintain optimized serverless-friendly translation loading

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

* refactor: move locale files to packages/lib/server to eliminate circular deps

- Move all locale files from apps/web/public/static/locales to packages/lib/server/locales
- Create copy-locales-static.js script to copy files during build
- Update all references to use new location for build-time access
- Maintain public folder copying for Next.js runtime access
- Update platform atoms, scripts, and config files
- Fix copy script relative path issue

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

* fix: update test imports after locale refactor

- Fix import paths in test files updated by pre-commit hooks
- Ensure all tests use correct locale import paths

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

* fix: revert import paths from @calcom/web/lib/i18n to @calcom/lib/server/i18n

- Revert all test file imports back to @calcom/lib/server/i18n as requested
- Addresses GitHub comment feedback to stick with packages/lib/server location
- Fixes import paths in 6 test files that were incorrectly changed

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

* fix: update vite config alias to use new locale path

- Update @calcom/web/public/static/locales/en/common.json to @calcom/lib/server/locales/en/common.json
- Addresses GitHub comment about updating platform atoms vite config
- Maintains correct path resolution after locale files moved to packages/lib/server

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

* fix: update translationBundler path resolution for production builds

- Use process.cwd() instead of __dirname for locale file path resolution
- Ensures locale files can be found in both development and production environments
- Fixes E2E test failures caused by missing locale files in .next/server/chunks/

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

* fix: use relative path resolution for locale files in translationBundler

- Change from process.cwd() to __dirname with relative paths
- Ensures locale files can be found in both development and production environments
- Fixes E2E test failures caused by incorrect path resolution in Next.js builds

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

* fix: use process.cwd() for locale path resolution in production builds

- Change from __dirname to process.cwd() with relative paths
- Ensures locale files can be found when bundled into Next.js server chunks
- Fixes E2E test failures caused by incorrect path resolution in production environment
- Follows same pattern used in getStaticProps.tsx for cross-package file access

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

* fix: use path.resolve pattern from sendVerificationRequest for locale access

- Change from process.cwd() to path.resolve(process.cwd(), '..', '..', 'packages/lib/server/locales')
- Follows same pattern used in sendVerificationRequest.ts for cross-environment file access
- Should resolve E2E test failures by ensuring locale files can be found when bundled into Next.js server chunks
- Pattern navigates up from current working directory to reach packages directory consistently

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

* fix: use dynamic monorepo root detection for locale path resolution

- Replace relative path resolution with dynamic monorepo root finder
- Ensures locale files can be found from any working directory (root, apps/web, apps/api/v2)
- Update API v2 i18n config to use new locale path
- Fixes remaining E2E test failures in API v2 and E2E (1/4) test suites

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

* fix: use require.resolve(__filename) for robust path resolution in all contexts

- Replace __dirname with require.resolve(__filename) in monorepo root detection
- Ensures locale files can be found when running from any working directory
- Fixes E2E API v2 test failures where __dirname resolves to '.' instead of actual file path

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

* Revert "fix: use require.resolve(__filename) for robust path resolution in all contexts"

This reverts commit b37d8226000da8f7d5fb98b83dd0e95a53d45372.

* fix: update copied locale files after translationBundler path resolution fix

- Copy script updated all locale files in public directory
- Ensures E2E tests have access to latest locale files
- Fixes regression where all E2E tests were failing

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

* feat: remove existing locale files to establish copy script as single source of truth

- Delete all common.json files from apps/web/public/static/locales/
- Eliminates developer confusion about which files are authoritative
- copy-locales-static.js script now clearly the only mechanism for populating public folder
- packages/lib/server/locales/ remains the definitive source of truth for translations

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

* add comment

* refactor: simplify findMonorepoRoot by removing redundant fallback loop

- Remove unnecessary second while loop using process.cwd()
- The first loop from __dirname will always find the monorepo root
- Add clear error message for fail-fast behavior if repo structure is corrupted
- Improves code clarity and maintainability

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

* feat: integrate updated translations from main branch

- Restore all common.json files from main branch to apps/web/public/static/locales/
- Overwrite packages/lib/server/locales/ with up-to-date translation content
- Resolve merge conflicts using Benny's safer 2-step approach
- Ensure translation source of truth remains in packages/lib/server/locales/
- Complete safer conflict resolution to eliminate merge conflicts on PR #22422

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

* chore: update package.json and yarn.lock after translation integration

- Update dependencies after ts-node installation for pre-commit hooks
- Ensure yarn.lock reflects current dependency state
- Complete translation integration process

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

* feat: remove duplicate locale files from apps/web to establish single source of truth

- Delete all common.json files from apps/web/public/static/locales/
- Maintain packages/lib/server/locales/ as the single source of truth for translations
- copy-locales-static.js script will populate public folder during build process
- Complete Benny's safer 2-step approach: restore from main, then remove duplicates
- Resolve merge conflicts and eliminate developer confusion about translation file locations

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: hbjORbj <sldisek783@gmail.com>
2025-07-12 07:49:27 +00:00
Alex van AndelandhbjORbj bd5e14b488 perf: Remove me.get call in event-types / availability (#22336)
* perf: Event types page remove me.get call

* Update ts definition listing view

* fix type check

* get rid of me call from availability page

---------

Co-authored-by: hbjORbj <sldisek783@gmail.com>
2025-07-12 06:49:29 +00:00
Keith Williams 8cd16ee3dc chore: Loosen Platform CODEOWNERS files (#22432) 2025-07-12 09:20:47 +05:30
Keith WilliamsandDevin AI 57564381cb fix: validate eventTypeId is number in API v1 bookings endpoint (#22433)
* fix: validate eventTypeId is number in API v1 bookings endpoint

- Add validation to return 400 when eventTypeId is string instead of integer
- Prevents string values from reaching Prisma and causing 500 errors
- Follows existing validation patterns in the codebase
- Add test case to verify string eventTypeId returns 400 Bad Request

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

* test: add focused test for eventTypeId validation

- Create separate test file for string eventTypeId validation
- Ensures validation logic is properly tested without being blocked by pre-existing issues
- Addresses cubic-dev-ai bot feedback about skipped tests
- Both string and number eventTypeId scenarios are tested

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

* fix: improve eventTypeId validation to only check defined values

- Only validate eventTypeId when it's defined and not a number
- Prevents interference with schema validation for missing/undefined values
- Maintains 400 error response for string eventTypeId as required
- Fixes test compatibility issues while preserving validation logic

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

* test: move eventTypeId validation test outside of skipped block

- Create dedicated test block for eventTypeId validation
- Remove duplicate test from skipped block
- Ensures validation logic is properly tested
- Addresses PR feedback from keithwillcode

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

* cleanup: remove duplicate eventTypeId validation test file

- Remove apps/api/v1/test/lib/bookings/eventTypeId-validation.test.ts
- Tests are now properly located in _post.test.ts outside skipped block
- Addresses PR feedback from keithwillcode about duplicate tests

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-11 19:16:27 -03:00
0207109612 fix: add validation for empty hosts in round robin reassignment (#22429)
* fix: add validation for empty hosts in round robin reassignment

- Add check for empty availableUsers array before calling getLuckyUser
- Throw RoundRobinHostsUnavailableForBooking error instead of 500 error
- Use existing error infrastructure with 409 status code and localized message
- Prevents runtime errors when no round robin hosts are available for reassignment

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

* fix: check eventType.hosts.length directly for empty hosts validation

- Move validation to check eventType.hosts.length === 0 immediately after hosts array setup
- Remove later validation after ensureAvailableUsers to catch issue earlier
- Follows user feedback to check hosts array directly instead of availableUsers

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

* fix: update translation message for round robin hosts unavailable error

- Make error message clearer about event type having no hosts
- Change from 'No Round Robin hosts is available for booking' to 'No hosts are available in this event type for round robin booking'

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

* Change error for event type no hosts

* fix: add ErrorCode.EventTypeNoHosts to switch statement for 400 status code

Addresses GitHub comment from keithwillcode to ensure EventTypeNoHosts
error returns proper 400 Bad Request status instead of defaulting to 500.

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: joe@cal.com <joe@cal.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2025-07-11 18:03:36 -03:00
235 changed files with 18231 additions and 3354 deletions
+21
View File
@@ -0,0 +1,21 @@
# Review Instructions
- Prefer early returns. It is recommended to throw/return early so we can ensure null-checks and prevent further nesting.
- Prefer Composition over Prop Drilling. Instead of relying on prop drilling, let's try to take advantage of react children feature.
- For Prisma queries:
- Only select data you need
1) Increased performance overhead: When you select all columns from a table, Prisma retrieves all the data associated with each row, which can result in a significant performance impact. This includes reading unnecessary columns and transferring a large volume of data between your application and the database.
2) Unnecessary data exposure: Selecting all columns exposes sensitive data that might not be required by your application. This can pose security risks, especially if the table contains sensitive user information or proprietary data. (In our case app credentials and booking information)
- TLDR: Never use Includes - use select
- Select selects only the fields you specify explicitly
- Include selects the relationship AND all the fields of the table the relationship belongs to
- Make sure the credential.key field is never returned back from tRPC endpoints or APIs
- Always use t() for text localization in frontend code; direct text embedding should trigger a warning.
- Check if there's any O(n^2) logic in backend code; we should aim for O(n log n) or O(n) ideally.
- Check if there are circular references introduced. We should never allow these.
- Flag excessive Day.js use in performance-critical code. Functions like `.add`, `.diff`, `.isBefore`, and `.isAfter` are slow, especially in timezone mode. Prefer `.utc()` for better performance. Where possible, replace with native Date and direct `.valueOf()` comparisons for faster execution. Recommend using native methods or Day.js `.utc()` consistently in hot paths like loops.
- When docs changes are made in /docs/api-reference/v2/openapi.json, ensure the following
- "summary" fields are short and concise
- "summary" fields do not end with a period
- "summary" fields are written in proper American english
- When changes to API v2 or v1 are made, ensure there are no breaking changes on existing endpoints. Instead, there needs to be a newly versioned endpoint with the updated functionality but the old one must remain functional
+1
View File
@@ -23,6 +23,7 @@
/packages/lib/getUserAvailability.ts @calcom/Foundation
/packages/lib/server/getLuckyUser.ts @calcom/Foundation
/packages/lib/slots.ts @calcom/Foundation
/packages/platform/atoms/**/*WebWrapper.tsx @calcom/Consumer
/packages/platform/**/* @calcom/Platform @calcom/Foundation
/packages/prisma/**/* @calcom/DBA
/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @calcom/Foundation
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
e2e:
timeout-minutes: 20
name: E2E API v2
runs-on: buildjet-8vcpu-ubuntu-2204
runs-on: buildjet-16vcpu-ubuntu-2204
services:
postgres:
image: postgres:13
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
View File
View File
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
View File
+2
View File
@@ -80,6 +80,8 @@ That's where Cal.com comes in. Self-hosted or hosted by us. White-label by desig
<a href="https://producthunt.com/posts/calendso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-calendso" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=291910&theme=light&period=monthly" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://producthunt.com/posts/calendso?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-calendso" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=291910&theme=light" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://producthunt.com/stories/how-this-open-source-calendly-alternative-rocketed-to-product-of-the-day" target="_blank"><img src="https://cal.com/maker-grant.svg" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
This project is tested with browserstack
### Built With
- [Next.js](https://nextjs.org/?ref=cal.com)
+7
View File
@@ -222,6 +222,13 @@ async function handler(req: NextApiRequest) {
};
if (isSystemWideAdmin) userId = req.body.userId || userId;
if (req.body.eventTypeId !== undefined && typeof req.body.eventTypeId !== "number") {
throw new HttpError({
statusCode: 400,
message: "Bad request, eventTypeId must be a number",
});
}
if (isOrganizationOwnerOrAdmin) {
const accessibleUsersIds = await getAccessibleUsers({
adminUserId: userId,
@@ -25,6 +25,44 @@ vi.mock("@calcom/lib/server/i18n", () => {
};
});
describe("POST /api/bookings - eventTypeId validation", () => {
test("String eventTypeId should return 400", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {
eventTypeId: "invalid-string",
},
});
await handler(req, res);
expect(res._getStatusCode()).toBe(400);
expect(JSON.parse(res._getData())).toEqual(
expect.objectContaining({
message: "Bad request, eventTypeId must be a number",
})
);
});
test("Number eventTypeId should not trigger validation error", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {
eventTypeId: 123,
},
});
await handler(req, res);
const statusCode = res._getStatusCode();
const responseData = JSON.parse(res._getData());
if (statusCode === 400) {
expect(responseData.message).not.toBe("Bad request, eventTypeId must be a number");
}
});
});
describe.skipIf(true)("POST /api/bookings", () => {
describe("Errors", () => {
test("Missing required data", async () => {
+1 -1
View File
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.257",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.258",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
@@ -1581,17 +1581,55 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
describe("cancel bookings", () => {
afterEach(async () => {
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
});
it("should cancel booking", async () => {
const createBody: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(),
eventTypeId,
attendee: {
name: "Mr Proper Cancel",
email: "mr_proper_cancel@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
userId: "100",
},
};
const createResponse = await request(app.getHttpServer())
.post("/v2/bookings")
.send(createBody)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.expect(201);
const createResponseBody: CreateBookingOutput_2024_08_13 = createResponse.body;
expect(responseDataIsBooking(createResponseBody.data)).toBe(true);
if (!responseDataIsBooking(createResponseBody.data)) {
throw new Error("Failed to create booking for test");
}
const testBooking: BookingOutput_2024_08_13 = createResponseBody.data;
const booking = await bookingsRepositoryFixture.getByUid(testBooking.uid);
expect(booking).toBeDefined();
expect(booking?.status).toEqual("ACCEPTED");
const body: CancelBookingInput_2024_08_13 = {
cancellationReason: "Going on a vacation",
};
const booking = await bookingsRepositoryFixture.getByUid(rescheduledBooking.uid);
expect(booking).toBeDefined();
expect(booking?.status).toEqual("ACCEPTED");
return request(app.getHttpServer())
.post(`/v2/bookings/${rescheduledBooking.uid}/cancel`)
.post(`/v2/bookings/${testBooking.uid}/cancel`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set(X_CAL_CLIENT_ID, oAuthClient.id)
@@ -1610,27 +1648,61 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(data.hosts[0].email).toEqual(user.email);
expect(data.status).toEqual("cancelled");
expect(data.cancellationReason).toEqual(body.cancellationReason);
expect(data.start).toEqual(rescheduledBooking.start);
expect(data.end).toEqual(rescheduledBooking.end);
expect(data.duration).toEqual(rescheduledBooking.duration);
expect(data.eventTypeId).toEqual(rescheduledBooking.eventTypeId);
expect(data.attendees[0]).toEqual(rescheduledBooking.attendees[0]);
expect(data.location).toEqual(rescheduledBooking.location);
expect(data.absentHost).toEqual(rescheduledBooking.absentHost);
expect(data.start).toEqual(testBooking.start);
expect(data.end).toEqual(testBooking.end);
expect(data.duration).toEqual(testBooking.duration);
expect(data.eventTypeId).toEqual(testBooking.eventTypeId);
expect(data.attendees[0]).toEqual(testBooking.attendees[0]);
expect(data.location).toEqual(testBooking.location);
expect(data.absentHost).toEqual(testBooking.absentHost);
const cancelledBooking = await bookingsRepositoryFixture.getByUid(rescheduledBooking.uid);
const cancelledBooking = await bookingsRepositoryFixture.getByUid(testBooking.uid);
expect(cancelledBooking).toBeDefined();
expect(cancelledBooking?.status).toEqual("CANCELLED");
});
});
it("should cancel recurring booking", async () => {
const createBody: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
eventTypeId: recurringEventTypeId,
attendee: {
name: "Mr Proper Recurring Cancel",
email: "mr_proper_recurring_cancel@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
userId: "100",
},
};
const createResponse = await request(app.getHttpServer())
.post("/v2/bookings")
.send(createBody)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.expect(201);
const createResponseBody: CreateBookingOutput_2024_08_13 = createResponse.body;
expect(responseDataIsRecurringBooking(createResponseBody.data)).toBe(true);
if (!responseDataIsRecurringBooking(createResponseBody.data)) {
throw new Error("Failed to create recurring booking for test");
}
const testRecurringBooking: RecurringBookingOutput_2024_08_13[] = createResponseBody.data;
const body: CancelBookingInput_2024_08_13 = {
cancellationReason: "Going on a vacation",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${createdRecurringBooking[1].recurringBookingUid}/cancel`)
.post(`/v2/bookings/${testRecurringBooking[1].recurringBookingUid}/cancel`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set(X_CAL_CLIENT_ID, oAuthClient.id)
@@ -1643,7 +1715,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
if (responseDataIsRecurringBooking(responseBody.data)) {
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(4);
expect(data.length).toEqual(3);
const firstBooking = data[0];
expect(firstBooking.status).toEqual("cancelled");
@@ -1653,9 +1725,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
const thirdBooking = data[2];
expect(thirdBooking.status).toEqual("cancelled");
const fourthBooking = data[3];
expect(fourthBooking.status).toEqual("cancelled");
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
@@ -1,6 +1,7 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateIcsFeedOutput, CreateIcsFeedOutputResponseDto } from "@/ee/calendars/input/create-ics.output";
import { ConnectedCalendarsData } from "@/ee/calendars/outputs/connected-calendars.output";
import { DeletedCalendarCredentialsOutputResponseDto } from "@/ee/calendars/outputs/delete-calendar-credentials.output";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
@@ -30,7 +31,7 @@ import {
GOOGLE_CALENDAR_ID,
} from "@calcom/platform-constants";
import { OFFICE_365_CALENDAR_ID, OFFICE_365_CALENDAR_TYPE } from "@calcom/platform-constants";
import { ICS_CALENDAR } from "@calcom/platform-constants/apps";
import { ICS_CALENDAR, ICS_CALENDAR_TYPE } from "@calcom/platform-constants/apps";
import { IcsFeedCalendarService } from "@calcom/platform-libraries/app-store";
const CLIENT_REDIRECT_URI = "http://localhost:5555";
@@ -247,6 +248,35 @@ describe("Platform Calendars Endpoints", () => {
.expect(200);
});
it(`/GET/v2/calendars with access token`, async () => {
const response = await request(app.getHttpServer())
.get(`/v2/calendars`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.expect(200);
const data: ConnectedCalendarsData = response.body.data;
expect(data.connectedCalendars).toBeDefined();
expect(data.connectedCalendars).toHaveLength(3);
const googleConnectedCalendar = data.connectedCalendars.find(
(calendar) => calendar.integration.type === GOOGLE_CALENDAR_TYPE
);
const office365ConnectedCalendar = data.connectedCalendars.find(
(calendar) => calendar.integration.type === OFFICE_365_CALENDAR_TYPE
);
const icsConnectedCalendar = data.connectedCalendars.find(
(calendar) => calendar.integration.type === ICS_CALENDAR_TYPE
);
expect(googleConnectedCalendar).toBeDefined();
expect(office365ConnectedCalendar).toBeDefined();
expect(icsConnectedCalendar).toBeDefined();
expect(data.destinationCalendar).toBeDefined();
expect(data.destinationCalendar.integration).toEqual(GOOGLE_CALENDAR_TYPE);
});
it.skip(`/POST/v2/calendars/${OFFICE_365_CALENDAR}/disconnect: it should respond with a 201 returning back the user deleted calendar credentials`, async () => {
const body = {
id: 10,
@@ -95,9 +95,7 @@ export class EventTypesAtomService {
}
// note (Lauris): don't show platform owner as one of the people that can be assigned to managed team event type
const onlyManagedTeamMembers = eventType.teamMembers.filter(
(user) => user.isPlatformManaged && user.membership === "MEMBER"
);
const onlyManagedTeamMembers = eventType.teamMembers.filter((user) => user.isPlatformManaged);
eventType.teamMembers = onlyManagedTeamMembers;
return eventType;
@@ -0,0 +1 @@
export { Or } from "./or.guard";
@@ -0,0 +1,104 @@
import { ExecutionContext, CanActivate } from "@nestjs/common";
import { Or } from "./or.guard";
// Mock guards for testing
class MockGuard1 implements CanActivate {
constructor(private shouldPass: boolean) {}
async canActivate(): Promise<boolean> {
return this.shouldPass;
}
}
class MockGuard2 implements CanActivate {
constructor(private shouldPass: boolean) {}
async canActivate(): Promise<boolean> {
return this.shouldPass;
}
}
class MockGuard3 implements CanActivate {
constructor(private shouldThrow: boolean = false) {}
async canActivate(): Promise<boolean> {
if (this.shouldThrow) {
throw new Error("Guard failed");
}
return false;
}
}
describe("OrGuard", () => {
let guard: InstanceType<ReturnType<typeof Or>>;
let mockExecutionContext: ExecutionContext;
let mockModuleRef: any;
beforeEach(() => {
mockModuleRef = {
get: jest.fn(),
};
const OrGuardClass = Or([MockGuard1, MockGuard2]);
guard = new OrGuardClass(mockModuleRef);
mockExecutionContext = {} as ExecutionContext;
});
it("should be defined", () => {
expect(guard).toBeDefined();
});
it("should grant access when first guard passes", async () => {
const mockGuard1 = new MockGuard1(true);
const mockGuard2 = new MockGuard2(false);
mockModuleRef.get.mockReturnValueOnce(mockGuard1).mockReturnValueOnce(mockGuard2);
const result = await guard.canActivate(mockExecutionContext);
expect(result).toBe(true);
});
it("should grant access when second guard passes", async () => {
const mockGuard1 = new MockGuard1(false);
const mockGuard2 = new MockGuard2(true);
mockModuleRef.get.mockReturnValueOnce(mockGuard1).mockReturnValueOnce(mockGuard2);
const result = await guard.canActivate(mockExecutionContext);
expect(result).toBe(true);
});
it("should deny access when all guards fail", async () => {
const mockGuard1 = new MockGuard1(false);
const mockGuard2 = new MockGuard2(false);
mockModuleRef.get.mockReturnValueOnce(mockGuard1).mockReturnValueOnce(mockGuard2);
const result = await guard.canActivate(mockExecutionContext);
expect(result).toBe(false);
});
it("should continue checking other guards when one throws an error", async () => {
const mockGuard1 = new MockGuard3(true); // throws error
const mockGuard2 = new MockGuard2(true); // passes
mockModuleRef.get.mockReturnValueOnce(mockGuard1).mockReturnValueOnce(mockGuard2);
const result = await guard.canActivate(mockExecutionContext);
expect(result).toBe(true);
});
});
describe("Or decorator", () => {
it("should create a guard class with the specified guards", () => {
const OrGuardClass = Or([MockGuard1, MockGuard2]);
expect(OrGuardClass).toBeDefined();
expect(typeof OrGuardClass).toBe("function");
});
});
@@ -0,0 +1,49 @@
import { Injectable, CanActivate, ExecutionContext, Type, Logger } from "@nestjs/common";
import { ModuleRef } from "@nestjs/core";
/**
* Decorator function that creates an Or guard with the specified guards
* @param guards Array of guard classes to evaluate with OR logic
* @returns A guard class that grants access if ANY of the provided guards return true
*/
export function Or(guards: Type<CanActivate>[]) {
@Injectable()
class OrGuard implements CanActivate {
public readonly logger = new Logger("OrGuard");
constructor(public readonly moduleRef: ModuleRef) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
let lastError: unknown | null = null;
for (const Guard of guards) {
try {
const guardInstance = this.moduleRef.get(Guard, { strict: false });
const result = await Promise.resolve(guardInstance.canActivate(context));
if (result === true) {
this.logger.log(`OrGuard - Guard ${Guard.name} granted access`);
return true; // Access granted if any guard returns true
}
} catch (error) {
lastError = error;
// If a guard throws an exception, it implies failure for that specific guard.
// We catch it and continue checking other guards in the OR chain.
// If an exception should stop the entire chain immediately, re-throw it here.
this.logger.log(
`OrGuard - Guard ${Guard.name} failed: ${
error instanceof Error ? error.message : "Unknown error"
}`
);
}
}
this.logger.log("OrGuard - All guards failed, access denied");
if (lastError) {
throw lastError;
}
return false;
}
}
return OrGuard;
}
@@ -0,0 +1,39 @@
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
import { Request } from "express";
import { Team } from "@calcom/prisma/client";
@Injectable()
export class IsUserRoutingForm implements CanActivate {
constructor(private readonly dbRead: PrismaReadService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { organization: Team }>();
const routingFormId: string = request.params.routingFormId;
const user = request.user as ApiAuthGuardUser;
if (!routingFormId) {
throw new ForbiddenException("IsUserRoutingForm - No routing form id found in request params.");
}
const userRoutingForm = await this.dbRead.prisma.app_RoutingForms_Form.findFirst({
where: {
id: routingFormId,
userId: Number(user.id),
teamId: null,
},
select: {
id: true,
},
});
if (!userRoutingForm) {
throw new ForbiddenException(
`Routing Form with id=${routingFormId} is not a user Routing Form owned by user with id=${user.id}.`
);
}
return true;
}
}
@@ -1,6 +1,7 @@
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthClientsOutputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-output.service";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { TokensService } from "@/modules/tokens/tokens.service";
import { createMock } from "@golevelup/ts-jest";
import { ExecutionContext } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
@@ -19,6 +20,7 @@ describe("PermissionsGuard", () => {
guard = new PermissionsGuard(
reflector,
createMock<TokensRepository>(),
createMock<TokensService>(),
createMock<ConfigService>({
get: jest.fn().mockImplementation((key: string) => {
switch (key) {
@@ -118,6 +120,7 @@ describe("PermissionsGuard", () => {
jest
.spyOn(guard, "getOAuthClientByAccessToken")
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
jest.spyOn(guard, "getDecodedThirdPartyAccessToken").mockReturnValue(null);
await expect(guard.canActivate(mockContext)).rejects.toThrow(
"PermissionsGuard - oAuth client with id=100 does not have the required permissions=SCHEDULE_WRITE"
@@ -133,11 +136,22 @@ describe("PermissionsGuard", () => {
jest
.spyOn(guard, "getOAuthClientByAccessToken")
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
jest.spyOn(guard, "getDecodedThirdPartyAccessToken").mockReturnValue(null);
await expect(guard.canActivate(mockContext)).rejects.toThrow(
"PermissionsGuard - oAuth client with id=100 does not have the required permissions=SCHEDULE_WRITE, SCHEDULE_READ"
);
});
it("should return true for 3rd party access token", async () => {
const mockContext = createMockExecutionContext({ Authorization: "Bearer token" });
jest.spyOn(guard, "getDecodedThirdPartyAccessToken").mockReturnValue({
scope: ["scope"],
token_type: "Bearer",
});
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
});
describe("when OAuth id is provided", () => {
@@ -3,6 +3,7 @@ import { Permissions } from "@/modules/auth/decorators/permissions/permissions.d
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthClientsOutputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-output.service";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { TokensService } from "@/modules/tokens/tokens.service";
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Reflector } from "@nestjs/core";
@@ -17,6 +18,7 @@ export class PermissionsGuard implements CanActivate {
constructor(
private reflector: Reflector,
private tokensRepository: TokensRepository,
private tokensService: TokensService,
private readonly config: ConfigService,
private readonly oAuthClientRepository: OAuthClientRepository,
private readonly oAuthClientsOutputService: OAuthClientsOutputService
@@ -35,9 +37,10 @@ export class PermissionsGuard implements CanActivate {
const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret });
const oAuthClientId = request.params?.clientId || request.get(X_CAL_CLIENT_ID);
const apiKey = bearerToken && isApiKey(bearerToken, this.config.get("api.apiKeyPrefix") ?? "cal_");
const isThirdPartyBearerToken = bearerToken && this.getDecodedThirdPartyAccessToken(bearerToken);
// only check permissions for accessTokens attached to an oAuth Client or oAuth credentials, not for next token or api key
if (nextAuthToken || apiKey) {
// only check permissions for accessTokens attached to platform oAuth Client or platform oAuth credentials, not for next token or api key or third party oauth client
if (nextAuthToken || apiKey || isThirdPartyBearerToken) {
return true;
}
@@ -87,4 +90,8 @@ export class PermissionsGuard implements CanActivate {
}
return oAuthClient;
}
getDecodedThirdPartyAccessToken(bearerToken: string) {
return this.tokensService.getDecodedThirdPartyAccessToken(bearerToken);
}
}
@@ -9,7 +9,7 @@ import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.se
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { ProfilesModule } from "@/modules/profiles/profiles.module";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersService } from "@/modules/users/services/users.service";
import { UsersRepository } from "@/modules/users/users.repository";
import { ExecutionContext, HttpException } from "@nestjs/common";
@@ -64,6 +64,7 @@ describe("ApiAuthStrategy", () => {
load: [appConfig],
}),
ProfilesModule,
TokensModule,
],
providers: [
MockedRedisService,
@@ -77,7 +78,6 @@ describe("ApiAuthStrategy", () => {
OAuthClientRepository,
PrismaReadService,
PrismaWriteService,
TokensRepository,
JwtService,
DeploymentsRepository,
NestJwtService,
@@ -8,32 +8,20 @@ import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repo
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { TokensService } from "@/modules/tokens/tokens.service";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
import {
HttpException,
Injectable,
InternalServerErrorException,
UnauthorizedException,
} from "@nestjs/common";
import { Injectable, InternalServerErrorException, UnauthorizedException } from "@nestjs/common";
import { Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PassportStrategy } from "@nestjs/passport";
import type { Request } from "express";
import * as jwt from "jsonwebtoken";
import { getToken } from "next-auth/jwt";
import { INVALID_ACCESS_TOKEN, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
import type { AllowedAuthMethod } from "../../decorators/api-auth-guard-only-allow.decorator";
interface OAuthTokenPayload {
userId?: number;
teamId?: number;
scope: string[];
token_type: string;
}
export type ApiAuthGuardUser = UserWithProfile & { isSystemAdmin: boolean };
export type ApiAuthGuardRequest = Request & {
authMethod: AuthMethods;
@@ -53,6 +41,7 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
private readonly config: ConfigService,
private readonly oauthFlowService: OAuthFlowService,
private readonly tokensRepository: TokensRepository,
private readonly tokensService: TokensService,
private readonly userRepository: UsersRepository,
private readonly apiKeyRepository: ApiKeysRepository,
private readonly oauthRepository: OAuthClientRepository,
@@ -323,20 +312,8 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
token: string,
request: ApiAuthGuardRequest
): Promise<{ success: true; data: UserWithProfile } | { success: false }> {
// Removed requiredScopes parameter
const encryptionKey = this.config.get<string>("CALENDSO_ENCRYPTION_KEY");
if (!encryptionKey) {
throw new InternalServerErrorException("CALENDSO_ENCRYPTION_KEY environment variable is not set.");
}
let decodedToken: OAuthTokenPayload;
try {
decodedToken = jwt.verify(token, encryptionKey) as OAuthTokenPayload;
} catch (e) {
return { success: false };
}
if (!decodedToken || decodedToken.token_type !== "Access Token") {
const decodedToken = this.tokensService.getDecodedThirdPartyAccessToken(token);
if (!decodedToken) {
return { success: false };
}
@@ -9,12 +9,12 @@ import { CredentialsRepository } from "@/modules/credentials/credentials.reposit
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersRepository } from "@/modules/users/users.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [],
imports: [TokensModule],
providers: [
GCalService,
GoogleCalendarService,
@@ -22,7 +22,6 @@ import { Module } from "@nestjs/common";
BookingReferencesRepository_2024_08_13,
CredentialsRepository,
CalendarsService,
TokensRepository,
SelectedCalendarsRepository,
PrismaReadService,
PrismaWriteService,
@@ -63,6 +63,7 @@ describe("Assign all team members", () => {
let firstManagedUser: CreateManagedUserData;
let secondManagedUser: CreateManagedUserData;
let collectiveEventType: TeamEventTypeOutput_2024_06_14;
let roundRobinEventType: TeamEventTypeOutput_2024_06_14;
beforeAll(async () => {
@@ -315,6 +316,7 @@ describe("Assign all team members", () => {
const secondHost = eventTypeHosts.find((host) => host.userId === secondManagedUser.user.id);
expect(firstHost).toBeDefined();
expect(secondHost).toBeDefined();
collectiveEventType = data;
});
});
@@ -365,6 +367,28 @@ describe("Assign all team members", () => {
});
it("should update round robin event type", async () => {
if (!roundRobinEventType) {
const setupBody: CreateTeamEventTypeInput_2024_06_14 = {
title: "Coding consultation round robin",
slug: `assign-all-team-members-round-robin-${randomString()}`,
lengthInMinutes: 60,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
schedulingType: "roundRobin",
assignAllTeamMembers: true,
};
const setupResponse = await request(app.getHttpServer())
.post(`/v2/organizations/${organization.id}/teams/${managedTeam.id}/event-types`)
.send(setupBody)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.expect(201);
const setupResponseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = setupResponse.body;
roundRobinEventType = setupResponseBody.data;
}
const body: UpdateTeamEventTypeInput_2024_06_14 = {
title: "Coding consultation round robin updated",
};
@@ -467,10 +491,17 @@ describe("Assign all team members", () => {
});
});
function evaluateHost(expected: Host, received: Host | undefined) {
expect(expected.userId).toEqual(received?.userId);
expect(expected.mandatory).toEqual(received?.mandatory);
expect(expected.priority).toEqual(received?.priority);
function evaluateHost(expected: Partial<Host>, received: Host | undefined) {
if (!received) {
throw new Error(`Host is undefined. Expected userId: ${expected.userId}`);
}
expect(expected.userId).toEqual(received.userId);
if (expected.mandatory !== undefined) {
expect(expected.mandatory).toEqual(received.mandatory);
}
if (expected.priority !== undefined) {
expect(expected.priority).toEqual(received.priority);
}
}
afterAll(async () => {
@@ -1158,6 +1158,56 @@ describe("Organizations Event Types Endpoints", () => {
});
});
it("should create a managed team event-type without hosts", async () => {
const body: CreateTeamEventTypeInput_2024_06_14 = {
title: "Coding consultation managed without hosts",
slug: "coding-consultation-managed-without-hosts",
description: "Our team will review your codebase.",
lengthInMinutes: 60,
locations: [
{
type: "integration",
integration: "cal-video",
},
],
schedulingType: "MANAGED",
};
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${team.id}/event-types`)
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data.length).toEqual(1);
const teammate1EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teammate1.id);
const teammate2EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teammate2.id);
const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(team.id);
const teammate1HasThisEvent = teammate1EventTypes.some((eventType) => eventType.slug === body.slug);
const teammate2HasThisEvent = teammate2EventTypes.some((eventType) => eventType.slug === body.slug);
expect(teammate1HasThisEvent).toBe(false);
expect(teammate2HasThisEvent).toBe(false);
expect(
teamEventTypes.filter(
(eventType) => eventType.schedulingType === "MANAGED" && eventType.slug === body.slug
).length
).toEqual(1);
const responseTeamEvent = responseBody.data.find((event) => event.teamId === team.id);
expect(responseTeamEvent).toBeDefined();
expect(responseTeamEvent?.hosts).toEqual([]);
if (!responseTeamEvent) {
throw new Error("Team event not found");
}
});
});
function evaluateHost(expected: Host, received: Host | undefined) {
expect(expected.userId).toEqual(received?.userId);
expect(expected.mandatory).toEqual(received?.mandatory);
@@ -121,7 +121,7 @@ export class InputOrganizationsEventTypesService {
},
];
const children = await this.getChildEventTypesForManagedEventType(null, inputEventType, teamId);
const children = await this.getChildEventTypesForManagedEventTypeCreate(inputEventType, teamId);
const metadata =
rest.schedulingType === "MANAGED"
@@ -159,7 +159,11 @@ export class InputOrganizationsEventTypesService {
throw new BadRequestException("Event type to update not found");
}
const children = await this.getChildEventTypesForManagedEventType(eventTypeId, inputEventType, teamId);
const children =
dbEventType.schedulingType === "MANAGED"
? await this.getChildEventTypesForManagedEventTypeUpdate(eventTypeId, inputEventType, teamId)
: undefined;
const teamEventType = {
...eventType,
// note(Lauris): we don't populate hosts for managed event-types because they are handled by the children
@@ -176,20 +180,17 @@ export class InputOrganizationsEventTypesService {
return teamEventType;
}
async getChildEventTypesForManagedEventType(
eventTypeId: number | null,
async getChildEventTypesForManagedEventTypeUpdate(
eventTypeId: number,
inputEventType: UpdateTeamEventTypeInput_2024_06_14,
teamId: number
) {
let eventType = null;
if (eventTypeId) {
eventType = await this.teamsEventTypesRepository.getEventTypeByIdWithChildren(eventTypeId);
if (!eventType || eventType.schedulingType !== "MANAGED") {
return undefined;
}
const eventType = await this.teamsEventTypesRepository.getEventTypeByIdWithChildren(eventTypeId);
if (!eventType || eventType.schedulingType !== "MANAGED") {
return undefined;
}
const ownersIds = await this.getOwnersIdsForManagedEventType(teamId, inputEventType, eventType);
const ownersIds = await this.getOwnersIdsForManagedEventTypeUpdate(teamId, inputEventType, eventType);
const owners = await this.getOwnersForManagedEventType(ownersIds);
return owners.map((owner) => {
@@ -200,22 +201,58 @@ export class InputOrganizationsEventTypesService {
});
}
async getOwnersIdsForManagedEventType(
async getOwnersIdsForManagedEventTypeUpdate(
teamId: number,
inputEventType: UpdateTeamEventTypeInput_2024_06_14,
eventType: { children: { userId: number | null }[] } | null
eventType: { children: { userId: number | null }[] }
) {
if (inputEventType.assignAllTeamMembers) {
return await this.getTeamUsersIds(teamId);
}
// note(Lauris): when API user updates managed event type users
if (inputEventType.hosts) {
return inputEventType.hosts.map((host) => host.userId);
}
// note(Lauris): when API user DOES NOT update managed event type users, but we still need existing managed event type users to know which event-types to update
return eventType?.children.map((child) => child.userId).filter((id) => !!id) as number[];
// e.g if managed event type title is changed then all children managed event types should be updated as well.
const childrenOwnersIds: number[] = [];
for (const child of eventType.children) {
if (child.userId) {
childrenOwnersIds.push(child.userId);
}
}
return childrenOwnersIds;
}
async getChildEventTypesForManagedEventTypeCreate(
inputEventType: UpdateTeamEventTypeInput_2024_06_14,
teamId: number
) {
const ownersIds = await this.getOwnersIdsForManagedEventTypeCreate(teamId, inputEventType);
const owners = await this.getOwnersForManagedEventType(ownersIds);
return owners.map((owner) => {
return {
hidden: false,
owner,
};
});
}
async getOwnersIdsForManagedEventTypeCreate(
teamId: number,
inputEventType: UpdateTeamEventTypeInput_2024_06_14
) {
if (inputEventType.assignAllTeamMembers) {
return await this.getTeamUsersIds(teamId);
}
if (inputEventType.hosts) {
return inputEventType.hosts.map((host) => host.userId);
}
return [];
}
async getTeamUsersIds(teamId: number) {
@@ -234,7 +271,7 @@ export class InputOrganizationsEventTypesService {
}
async getOwnersForManagedEventType(userIds: number[]) {
const users = await this.usersRepository.findByIdsWithEventTypes(userIds);
const users = userIds.length ? await this.usersRepository.findByIdsWithEventTypes(userIds) : [];
return users.map((user) => {
const nonManagedEventTypes = user.eventTypes.filter((eventType) => !eventType.parentId);
@@ -0,0 +1,29 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsOptional, IsString } from "class-validator";
import { SkipTakePagination } from "@calcom/platform-types";
export class GetManagedOrganizationsInput_2024_08_13 extends SkipTakePagination {
@IsOptional()
@IsString()
@ApiProperty({ example: "organization-slug", description: "The slug of the managed organization" })
slug?: string;
@IsOptional()
@IsString()
@ApiProperty({
example: "metadata-key",
description:
"The key of the metadata - it is case sensitive so provide exactly as stored. If you provide it then you must also provide metadataValue",
})
metadataKey?: string;
@IsOptional()
@IsString()
@ApiProperty({
example: "metadata-value",
description:
"The value of the metadata - it is case sensitive so provide exactly as stored. If you provide it then you must also provide metadataKey",
})
metadataValue?: string;
}
@@ -1,8 +1,8 @@
import { GetManagedOrganizationsInput_2024_08_13 } from "@/modules/organizations/organizations/inputs/get-managed-organizations.input";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { SkipTakePagination } from "@calcom/platform-types";
import type { Prisma } from "@calcom/prisma/client";
@Injectable()
@@ -24,6 +24,17 @@ export class ManagedOrganizationsRepository {
});
}
async getManagedOrganizationBySlug(managerOrganizationId: number, managedOrganizationSlug: string) {
return this.dbRead.prisma.managedOrganization.findFirst({
where: {
managerOrganizationId,
managedOrganization: {
slug: managedOrganizationSlug,
},
},
});
}
async getByManagerManagedIds(managerOrganizationId: number, managedOrganizationId: number) {
return this.dbRead.prisma.managedOrganization.findUnique({
where: {
@@ -35,11 +46,26 @@ export class ManagedOrganizationsRepository {
});
}
async getByManagerOrganizationIdPaginated(managerOrganizationId: number, pagination: SkipTakePagination) {
const { skip, take } = pagination;
async getByManagerOrganizationIdPaginated(
managerOrganizationId: number,
query: GetManagedOrganizationsInput_2024_08_13
) {
const { skip, take, slug, metadataKey, metadataValue } = query;
const managedOrganizationFilter: Prisma.TeamWhereInput = {
slug,
};
if (metadataKey && metadataValue) {
managedOrganizationFilter.metadata = {
path: [metadataKey],
equals: metadataValue,
};
}
const where: Prisma.ManagedOrganizationWhereInput = {
managerOrganizationId,
managedOrganization: managedOrganizationFilter,
};
const [totalItems, linkRows] = await this.dbRead.prisma.$transaction([
@@ -66,6 +66,7 @@ describe("Organizations Organizations Endpoints", () => {
let managerOrg: Team;
let managedOrg: ManagedOrganizationWithApiKeyOutput;
let managedOrg2: ManagedOrganizationWithApiKeyOutput;
const managerOrgAdminEmail = `organizations-organizations-admin-${randomString()}@api.com`;
let managerOrgAdmin: User;
let managerOrgAdminApiKey: string;
@@ -159,27 +160,36 @@ describe("Organizations Organizations Endpoints", () => {
.expect(400);
});
const suffix = randomString(5);
const metadataKey = "first-org-metadata-key";
const metadataValue = "first-org-metadata-value";
const createManagedOrganizationBody: CreateOrganizationInput = {
name: `org ${suffix}`,
slug: `org-${suffix}`,
metadata: { [metadataKey]: metadataValue },
};
const suffix2 = randomString(5);
const createManagedOrganizationBodySecond: CreateOrganizationInput = {
name: `org2 ${suffix2}`,
slug: `org2-${suffix2}`,
metadata: { key: "value" },
};
it("should create managed organization", async () => {
const suffix = randomString();
const body: CreateOrganizationInput = {
name: `organizations organizations org ${suffix}`,
metadata: { key: "value" },
};
return request(app.getHttpServer())
.post(`/v2/organizations/${managerOrg.id}/organizations`)
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
.send(body)
.send(createManagedOrganizationBody)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<ManagedOrganizationWithApiKeyOutput> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
managedOrg = responseBody.data;
expect(managedOrg?.id).toBeDefined();
expect(managedOrg?.name).toEqual(body.name);
expect(managedOrg?.slug).toEqual(slugify(body.name));
expect(managedOrg?.metadata).toEqual(body.metadata);
expect(managedOrg?.name).toEqual(createManagedOrganizationBody.name);
expect(managedOrg?.slug).toEqual(createManagedOrganizationBody.slug);
expect(managedOrg?.metadata).toEqual(createManagedOrganizationBody.metadata);
expect(managedOrg?.apiKey).toBeDefined();
// note(Lauris): check that managed organization is correctly setup in database
@@ -284,6 +294,36 @@ describe("Organizations Organizations Endpoints", () => {
});
});
it("should not create managed organization if slug already exists", async () => {
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${managerOrg.id}/organizations`)
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
.send(createManagedOrganizationBody)
.expect(409);
expect(response.body.error.message).toBe(
`Organization with slug '${createManagedOrganizationBody.slug}' already exists. Please, either provide a different slug or change name so that the automatically generated slug is different.`
);
});
it("should create second managed organization", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${managerOrg.id}/organizations`)
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
.send(createManagedOrganizationBodySecond)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<ManagedOrganizationWithApiKeyOutput> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
managedOrg2 = responseBody.data;
expect(managedOrg2?.id).toBeDefined();
expect(managedOrg2?.name).toEqual(createManagedOrganizationBodySecond.name);
expect(managedOrg2?.slug).toEqual(createManagedOrganizationBodySecond.slug);
expect(managedOrg2?.metadata).toEqual(createManagedOrganizationBodySecond.metadata);
expect(managedOrg2?.apiKey).toBeDefined();
});
});
it("should get managed organization", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${managerOrg.id}/organizations/${managedOrg.id}`)
@@ -304,12 +344,69 @@ describe("Organizations Organizations Endpoints", () => {
.get(`/v2/organizations/${managerOrg.id}/organizations`)
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
.expect(200)
.then(async (response) => {
const responseBody: GetManagedOrganizationsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const responseManagedOrgs = responseBody.data;
expect(responseManagedOrgs?.length).toEqual(2);
const responseManagedOrg = responseManagedOrgs.find((org) => org.id === managedOrg.id);
expect(responseManagedOrg?.id).toBeDefined();
expect(responseManagedOrg?.name).toEqual(managedOrg.name);
expect(responseManagedOrg?.metadata).toEqual(managedOrg.metadata);
const responseManagedOrg2 = responseManagedOrgs.find((org) => org.id === managedOrg2.id);
expect(responseManagedOrg2?.id).toBeDefined();
expect(responseManagedOrg2?.name).toEqual(managedOrg2.name);
expect(responseManagedOrg2?.metadata).toEqual(managedOrg2.metadata);
expect(responseBody.pagination).toBeDefined();
expect(responseBody.pagination.totalItems).toEqual(2);
expect(responseBody.pagination.remainingItems).toEqual(0);
expect(responseBody.pagination.returnedItems).toEqual(2);
expect(responseBody.pagination.itemsPerPage).toEqual(250);
expect(responseBody.pagination.currentPage).toEqual(1);
expect(responseBody.pagination.totalPages).toEqual(1);
});
});
it("should get managed organization by slug", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${managerOrg.id}/organizations?slug=${managedOrg.slug}`)
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
.expect(200)
.then(async (response) => {
const responseBody: GetManagedOrganizationsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const responseManagedOrgs = responseBody.data;
expect(responseManagedOrgs?.length).toEqual(1);
const responseManagedOrg = responseManagedOrgs[0];
const responseManagedOrg = responseManagedOrgs.find((org) => org.id === managedOrg.id);
expect(responseManagedOrg?.id).toBeDefined();
expect(responseManagedOrg?.name).toEqual(managedOrg.name);
expect(responseManagedOrg?.metadata).toEqual(managedOrg.metadata);
expect(responseBody.pagination).toBeDefined();
expect(responseBody.pagination.totalItems).toEqual(1);
expect(responseBody.pagination.remainingItems).toEqual(0);
expect(responseBody.pagination.returnedItems).toEqual(1);
expect(responseBody.pagination.itemsPerPage).toEqual(250);
expect(responseBody.pagination.currentPage).toEqual(1);
expect(responseBody.pagination.totalPages).toEqual(1);
});
});
it("should get managed organization by metadata key", async () => {
return request(app.getHttpServer())
.get(
`/v2/organizations/${managerOrg.id}/organizations?metadataKey=${metadataKey}&metadataValue=${metadataValue}`
)
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
.expect(200)
.then(async (response) => {
const responseBody: GetManagedOrganizationsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const responseManagedOrgs = responseBody.data;
expect(responseManagedOrgs?.length).toEqual(1);
const responseManagedOrg = responseManagedOrgs.find((org) => org.id === managedOrg.id);
expect(responseManagedOrg?.id).toBeDefined();
expect(responseManagedOrg?.name).toEqual(managedOrg.name);
expect(responseManagedOrg?.metadata).toEqual(managedOrg.metadata);
@@ -465,7 +562,40 @@ describe("Organizations Organizations Endpoints", () => {
});
});
it("should delete managed organization ", async () => {
it("should delete managed organization", async () => {
return request(app.getHttpServer())
.delete(`/v2/organizations/${managerOrg.id}/organizations/${managedOrg2.id}`)
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<ManagedOrganizationWithApiKeyOutput> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const responseManagedOrg = responseBody.data;
expect(responseManagedOrg?.id).toBeDefined();
expect(responseManagedOrg?.id).toEqual(managedOrg2.id);
expect(responseManagedOrg?.name).toEqual(managedOrg2.name);
const managedOrgInDb =
await managedOrganizationsRepositoryFixture.getOrganizationWithManagedOrganizations(managedOrg2.id);
expect(managedOrgInDb).toEqual(null);
const billings = await platformBillingRepositoryFixture.getByCustomerSubscriptionIds(
managerOrgBilling.customerId,
managerOrgBilling.subscriptionId!
);
expect(billings).toBeDefined();
// note(Lauris): manager billing is left and other managed org
expect(billings?.length).toEqual(2);
const managerOrgInDb =
await managedOrganizationsRepositoryFixture.getOrganizationWithManagedOrganizations(managerOrg.id);
expect(managerOrgInDb).toBeDefined();
expect(managerOrgInDb?.id).toEqual(managerOrg.id);
expect(managerOrgInDb?.managedOrganizations?.length).toEqual(1);
});
});
it("should delete managed organization", async () => {
return request(app.getHttpServer())
.delete(`/v2/organizations/${managerOrg.id}/organizations/${managedOrg.id}`)
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
@@ -11,6 +11,7 @@ import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { CreateOrganizationInput } from "@/modules/organizations/organizations/inputs/create-managed-organization.input";
import { GetManagedOrganizationsInput_2024_08_13 } from "@/modules/organizations/organizations/inputs/get-managed-organizations.input";
import { UpdateOrganizationInput } from "@/modules/organizations/organizations/inputs/update-managed-organization.input";
import { CreateManagedOrganizationOutput } from "@/modules/organizations/organizations/outputs/create-managed-organization.output";
import { GetManagedOrganizationOutput } from "@/modules/organizations/organizations/outputs/get-managed-organization.output";
@@ -102,10 +103,10 @@ export class OrganizationsOrganizationsController {
})
async getOrganizations(
@Param("orgId", ParseIntPipe) managerOrganizationId: number,
@Query() queryPagination: SkipTakePagination
@Query() query: GetManagedOrganizationsInput_2024_08_13
): Promise<GetManagedOrganizationsOutput> {
const { organizations, pagination: responsePagination } =
await this.managedOrganizationsService.getManagedOrganizations(managerOrganizationId, queryPagination);
await this.managedOrganizationsService.getManagedOrganizations(managerOrganizationId, query);
return {
status: SUCCESS_STATUS,
data: organizations,
@@ -5,14 +5,14 @@ import { ManagedOrganizationsBillingService } from "@/modules/billing/services/m
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
import { CreateOrganizationInput } from "@/modules/organizations/organizations/inputs/create-managed-organization.input";
import { GetManagedOrganizationsInput_2024_08_13 } from "@/modules/organizations/organizations/inputs/get-managed-organizations.input";
import { UpdateOrganizationInput } from "@/modules/organizations/organizations/inputs/update-managed-organization.input";
import { ManagedOrganizationsRepository } from "@/modules/organizations/organizations/managed-organizations.repository";
import { ManagedOrganizationsOutputService } from "@/modules/organizations/organizations/services/managed-organizations-output.service";
import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
import { ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { ConflictException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { slugify } from "@calcom/platform-libraries";
import { SkipTakePagination } from "@calcom/platform-types";
@Injectable()
export class ManagedOrganizationsService {
@@ -40,8 +40,22 @@ export class ManagedOrganizationsService {
const { apiKeyDaysValid, apiKeyNeverExpires, ...organizationData } = organizationInput;
const effectiveSlug = organizationData.slug || slugify(organizationData.name);
if (!organizationData.slug) {
organizationData.slug = slugify(organizationData.name);
organizationData.slug = effectiveSlug;
}
const existingManagedOrganization =
await this.managedOrganizationsRepository.getManagedOrganizationBySlug(
managerOrganizationId,
effectiveSlug
);
if (existingManagedOrganization) {
throw new ConflictException(
`Organization with slug '${organizationData.slug}' already exists. Please, either provide a different slug or change name so that the automatically generated slug is different.`
);
}
const organization = await this.managedOrganizationsRepository.createManagedOrganization(
@@ -101,11 +115,14 @@ export class ManagedOrganizationsService {
return this.managedOrganizationsOutputService.getOutputManagedOrganization(organization);
}
async getManagedOrganizations(managerOrganizationId: number, pagination: SkipTakePagination) {
async getManagedOrganizations(
managerOrganizationId: number,
query: GetManagedOrganizationsInput_2024_08_13
) {
const { items: managedOrganizations, totalItems } =
await this.managedOrganizationsRepository.getByManagerOrganizationIdPaginated(
managerOrganizationId,
pagination
query
);
return {
@@ -113,7 +130,7 @@ export class ManagedOrganizationsService {
this.managedOrganizationsOutputService.getOutputManagedOrganization(managedOrganization)
),
pagination: getPagination({
...pagination,
...query,
totalCount: totalItems,
}),
};
@@ -23,27 +23,48 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
let app: INestApplication;
let prismaWriteService: PrismaWriteService;
let org: Team;
let team: Team;
let apiKeyString: string;
let routingForm: App_RoutingForms_Form;
let routingFormResponse: App_RoutingForms_FormResponse;
let routingFormResponse2: App_RoutingForms_FormResponse;
// Grouped data structures
let orgAdminData: {
user: User;
apiKey: string;
eventType: {
id: number;
slug: string | null;
teamId: number | null;
userId: number | null;
title: string;
};
routingForm: App_RoutingForms_Form;
};
let teamData: {
team: Team;
routingForm: App_RoutingForms_Form;
routingFormResponse1: App_RoutingForms_FormResponse;
routingFormResponse2: App_RoutingForms_FormResponse;
};
let nonOrgAdminData: {
user: User;
apiKey: string;
eventType: {
id: number;
slug: string | null;
teamId: number | null;
userId: number | null;
title: string;
};
routingForm: App_RoutingForms_Form;
};
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let user: User;
const userEmail = `OrganizationsRoutingFormsResponsesController-key-bookings-2024-08-13-user-${randomString()}@api.com`;
let profileRepositoryFixture: ProfileRepositoryFixture;
let routingEventType: {
id: number;
slug: string | null;
teamId: number | null;
userId: number | null;
title: string;
};
let membershipsRepositoryFixture: MembershipRepositoryFixture;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
@@ -64,25 +85,34 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
isOrganization: true,
});
team = await teamRepositoryFixture.create({
// Initialize grouped data structures
orgAdminData = {} as any;
teamData = {} as any;
nonOrgAdminData = {} as any;
teamData.team = await teamRepositoryFixture.create({
name: "OrganizationsRoutingFormsResponsesController orgs booking 1",
isOrganization: false,
parent: { connect: { id: org.id } },
});
user = await userRepositoryFixture.create({
orgAdminData.user = await userRepositoryFixture.create({
email: userEmail,
});
nonOrgAdminData.user = await userRepositoryFixture.create({
email: `non-org-admin-user-${randomString()}@api.com`,
});
await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: user.id } },
user: { connect: { id: orgAdminData.user.id } },
team: { connect: { id: org.id } },
accepted: true,
});
await profileRepositoryFixture.create({
uid: `usr-${user.id}`,
uid: `usr-${orgAdminData.user.id}`,
username: userEmail,
organization: {
connect: {
@@ -91,98 +121,166 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
},
user: {
connect: {
id: user.id,
id: orgAdminData.user.id,
},
},
});
const now = new Date();
now.setDate(now.getDate() + 1);
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
apiKeyString = `${keyString}`;
const { keyString } = await apiKeysRepositoryFixture.createApiKey(orgAdminData.user.id, null);
orgAdminData.apiKey = `${keyString}`;
const { keyString: _nonOrgAdminUserApiKey } = await apiKeysRepositoryFixture.createApiKey(
nonOrgAdminData.user.id,
null
);
nonOrgAdminData.apiKey = `${_nonOrgAdminUserApiKey}`;
// Create an event type for routing form to route to
routingEventType = await prismaWriteService.prisma.eventType.create({
orgAdminData.eventType = await prismaWriteService.prisma.eventType.create({
data: {
title: "Test Event Type",
slug: "test-event-type",
length: 30,
userId: user.id,
teamId: team.id,
userId: orgAdminData.user.id,
teamId: null,
},
});
routingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
nonOrgAdminData.eventType = await prismaWriteService.prisma.eventType.create({
data: {
name: "Test Routing Form",
description: "Test Description",
disabled: false,
routes: [
{
title: "Test Event Type",
slug: "test-event-type",
length: 30,
userId: nonOrgAdminData.user.id,
teamId: null,
},
});
const routingFormData = {
name: "Test Routing Form",
description: "Test Description",
disabled: false,
routes: [
{
id: "route-1",
queryValue: {
id: "route-1",
queryValue: {
id: "route-1",
type: "group",
children1: {
"rule-1": {
type: "rule",
properties: {
field: "question1",
operator: "equal",
value: ["answer1"],
valueSrc: ["value"],
valueType: ["text"],
},
type: "group",
children1: {
"rule-1": {
type: "rule",
properties: {
field: "question1",
operator: "equal",
value: ["answer1"],
valueSrc: ["value"],
valueType: ["text"],
},
},
},
},
action: null,
isFallback: false,
},
{
id: "fallback-route",
action: { type: "customPageMessage", value: "Fallback Message" },
isFallback: true,
queryValue: { id: "fallback-route", type: "group" },
},
],
fields: [
{
id: "question1",
type: "text",
label: "Question 1",
required: true,
identifier: "question1",
},
{
id: "question2",
type: "text",
label: "Question 2",
required: false,
identifier: "question2",
},
],
settings: {
emailOwnerOnSubmission: false,
},
};
orgAdminData.routingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
data: {
...routingFormData,
routes: [
{
...routingFormData.routes[0],
action: {
type: "eventTypeRedirectUrl",
eventTypeId: routingEventType.id,
value: `team/${team.slug}/${routingEventType.slug}`,
eventTypeId: orgAdminData.eventType.id,
value: `${orgAdminData.user.username}/${orgAdminData.eventType.slug}`,
},
isFallback: false,
},
{
id: "fallback-route",
action: { type: "customPageMessage", value: "Fallback Message" },
isFallback: true,
queryValue: { id: "fallback-route", type: "group" },
},
routingFormData.routes[1],
],
fields: [
{
id: "question1",
type: "text",
label: "Question 1",
required: true,
identifier: "question1",
},
{
id: "question2",
type: "text",
label: "Question 2",
required: false,
identifier: "question2",
},
],
settings: {
emailOwnerOnSubmission: false,
},
teamId: team.id,
userId: user.id,
userId: orgAdminData.user.id,
// User Routing Form has teamId=null
teamId: null,
},
});
routingFormResponse = await prismaWriteService.prisma.app_RoutingForms_FormResponse.create({
nonOrgAdminData.routingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
data: {
formId: routingForm.id,
...routingFormData,
routes: [
{
...routingFormData.routes[0],
action: {
type: "eventTypeRedirectUrl",
eventTypeId: nonOrgAdminData.eventType.id,
value: `${nonOrgAdminData.user.username}/${nonOrgAdminData.eventType.slug}`,
},
},
routingFormData.routes[1],
],
userId: nonOrgAdminData.user.id,
teamId: null,
},
});
// Patch response and get Responses endpoints right now work for teams only
// We need to fix them in a followup PR
teamData.routingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
data: {
...routingFormData,
routes: [
{
...routingFormData.routes[0],
action: {
type: "eventTypeRedirectUrl",
eventTypeId: orgAdminData.eventType.id,
value: `team/${teamData.team.slug}/${orgAdminData.eventType.slug}`,
},
},
routingFormData.routes[1],
],
userId: orgAdminData.user.id,
teamId: teamData.team.id,
},
});
teamData.routingFormResponse1 = await prismaWriteService.prisma.app_RoutingForms_FormResponse.create({
data: {
formId: teamData.routingForm.id,
response: JSON.stringify({ question1: "answer1", question2: "answer2" }),
},
});
routingFormResponse2 = await prismaWriteService.prisma.app_RoutingForms_FormResponse.create({
teamData.routingFormResponse2 = await prismaWriteService.prisma.app_RoutingForms_FormResponse.create({
data: {
formId: routingForm.id,
formId: teamData.routingForm.id,
response: { question1: "answer1", question2: "answer2" },
},
});
@@ -196,33 +294,33 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
describe(`GET /v2/organizations/:orgId/routing-forms/:routingFormId/responses`, () => {
it("should not get routing form responses for non existing org", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/99999/routing-forms/${routingForm.id}/responses`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.get(`/v2/organizations/99999/routing-forms/${teamData.routingForm.id}/responses`)
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.expect(403);
});
it("should not get routing form responses for non existing form", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/routing-forms/non-existent-id/responses`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.expect(404);
});
it("should not get routing form responses without authentication", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses`)
.get(`/v2/organizations/${org.id}/routing-forms/${teamData.routingForm.id}/responses`)
.expect(401);
});
it("should get routing form responses", async () => {
const createdAt = new Date(routingFormResponse.createdAt);
const createdAt = new Date(teamData.routingFormResponse1.createdAt);
createdAt.setHours(createdAt.getHours() - 1);
const isoStringCreatedAt = createdAt.toISOString();
return request(app.getHttpServer())
.get(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?skip=0&take=2&sortUpdatedAt=asc&sortCreatedAt=desc&afterCreatedAt=${isoStringCreatedAt}`
`/v2/organizations/${org.id}/routing-forms/${teamData.routingForm.id}/responses?skip=0&take=2&sortUpdatedAt=asc&sortCreatedAt=desc&afterCreatedAt=${isoStringCreatedAt}`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.expect(200)
.then((response) => {
const responseBody = response.body;
@@ -230,14 +328,18 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
const responses = responseBody.data as App_RoutingForms_FormResponse[];
expect(responses).toBeDefined();
expect(responses.length).toBeGreaterThan(0);
expect(responses.find((response) => response.id === routingFormResponse.id)).toBeDefined();
expect(responses.find((response) => response.id === routingFormResponse.id)?.formId).toEqual(
routingFormResponse.formId
);
expect(responses.find((response) => response.id === routingFormResponse2.id)).toBeDefined();
expect(responses.find((response) => response.id === routingFormResponse2.id)?.formId).toEqual(
routingFormResponse2.formId
);
expect(
responses.find((response) => response.id === teamData.routingFormResponse1.id)
).toBeDefined();
expect(
responses.find((response) => response.id === teamData.routingFormResponse1.id)?.formId
).toEqual(teamData.routingFormResponse1.formId);
expect(
responses.find((response) => response.id === teamData.routingFormResponse2.id)
).toBeDefined();
expect(
responses.find((response) => response.id === teamData.routingFormResponse2.id)?.formId
).toEqual(teamData.routingFormResponse2.formId);
});
});
});
@@ -246,9 +348,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
it("should return 403 when organization does not exist", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/99999/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
`/v2/organizations/99999/routing-forms/${orgAdminData.routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.send({
question1: "answer1",
})
@@ -260,7 +362,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
.post(
`/v2/organizations/${org.id}/routing-forms/non-existent-id/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.send({
question1: "answer1",
})
@@ -270,7 +372,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
it("should return 401 when authentication token is missing", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
`/v2/organizations/${org.id}/routing-forms/${orgAdminData.routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
)
.send({
question1: "answer1",
@@ -281,9 +383,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
it("should create response and return available slots when routing to event type", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
`/v2/organizations/${org.id}/routing-forms/${orgAdminData.routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.send({
question1: "answer1", // This matches the route condition
question2: "answer2",
@@ -296,7 +398,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
expect(data).toBeDefined();
expect(data.routing?.responseId).toBeDefined();
expect(typeof data.routing?.responseId).toBe("number");
expect(data.eventTypeId).toEqual(routingEventType.id);
expect(data.eventTypeId).toEqual(orgAdminData.eventType.id);
expect(data.slots).toBeDefined();
expect(typeof data.slots).toBe("object");
});
@@ -305,9 +407,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
it("should return 400 when required form fields are missing", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
`/v2/organizations/${org.id}/routing-forms/${orgAdminData.routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.send({
question2: "answer2", // Missing required question1
})
@@ -317,9 +419,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
it("should create response and return custom message if the routing is to custom page", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
`/v2/organizations/${org.id}/routing-forms/${orgAdminData.routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.send({
question1: "different-answer", // This won't match any route
question2: "answer2",
@@ -338,8 +440,10 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
it("should return 400 when required slot query parameters are missing", async () => {
// Missing start parameter
await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?end=2050-09-06`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.post(
`/v2/organizations/${org.id}/routing-forms/${orgAdminData.routingForm.id}/responses?end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.send({
question1: "answer1",
question2: "answer2",
@@ -348,8 +452,10 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
// Missing end parameter
await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.post(
`/v2/organizations/${org.id}/routing-forms/${orgAdminData.routingForm.id}/responses?start=2050-09-05`
)
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.send({
question1: "answer1",
question2: "answer2",
@@ -360,9 +466,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
it("should return 400 when date parameters have invalid format", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=invalid-date&end=2050-09-06`
`/v2/organizations/${org.id}/routing-forms/${orgAdminData.routingForm.id}/responses?start=invalid-date&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.send({
question1: "answer1",
})
@@ -372,9 +478,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
it("should return 400 when end date is before start date", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-10&end=2050-09-05`
`/v2/organizations/${org.id}/routing-forms/${orgAdminData.routingForm.id}/responses?start=2050-09-10&end=2050-09-05`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.send({
question1: "answer1",
})
@@ -394,7 +500,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
const response = await request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
`/v2/organizations/${org.id}/routing-forms/${orgAdminData.routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${unauthorizedApiKey}` })
.send({
@@ -412,9 +518,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
it("should handle queued response creation", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06&queueResponse=true`
`/v2/organizations/${org.id}/routing-forms/${orgAdminData.routingForm.id}/responses?start=2050-09-05&end=2050-09-06&queueResponse=true`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.send({
question1: "answer1",
question2: "answer2",
@@ -430,67 +536,69 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
it("should return 500 when event type is not found", async () => {
// Create a routing form with an invalid eventTypeId
const routingFormWithInvalidEventType = await prismaWriteService.prisma.app_RoutingForms_Form.create({
data: {
name: "Test Routing Form with Invalid Event Type",
description: "Test Description",
disabled: false,
routes: [
{
id: "route-1",
queryValue: {
const orgAdminUserRoutingFormWithInvalidEventType =
await prismaWriteService.prisma.app_RoutingForms_Form.create({
data: {
name: "Test Routing Form with Invalid Event Type",
description: "Test Description",
disabled: false,
routes: [
{
id: "route-1",
type: "group",
children1: {
"rule-1": {
type: "rule",
properties: {
field: "question1",
operator: "equal",
value: ["answer1"],
valueSrc: ["value"],
valueType: ["text"],
queryValue: {
id: "route-1",
type: "group",
children1: {
"rule-1": {
type: "rule",
properties: {
field: "question1",
operator: "equal",
value: ["answer1"],
valueSrc: ["value"],
valueType: ["text"],
},
},
},
},
action: {
type: "eventTypeRedirectUrl",
eventTypeId: 99999, // Invalid event type ID
value: `team/${teamData.team.slug}/non-existent-event-type`,
},
isFallback: false,
},
action: {
type: "eventTypeRedirectUrl",
eventTypeId: 99999, // Invalid event type ID
value: `team/${team.slug}/non-existent-event-type`,
{
id: "fallback-route",
action: { type: "customPageMessage", value: "Fallback Message" },
isFallback: true,
queryValue: { id: "fallback-route", type: "group" },
},
isFallback: false,
],
fields: [
{
id: "question1",
type: "text",
label: "Question 1",
required: true,
identifier: "question1",
},
],
settings: {
emailOwnerOnSubmission: false,
},
{
id: "fallback-route",
action: { type: "customPageMessage", value: "Fallback Message" },
isFallback: true,
queryValue: { id: "fallback-route", type: "group" },
},
],
fields: [
{
id: "question1",
type: "text",
label: "Question 1",
required: true,
identifier: "question1",
},
],
settings: {
emailOwnerOnSubmission: false,
teamId: null,
userId: orgAdminData.user.id,
},
teamId: team.id,
userId: user.id,
},
});
});
// Try to create a response for the form with invalid event type
const response = await request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/routing-forms/${routingFormWithInvalidEventType.id}/responses?start=2050-09-05&end=2050-09-06`
`/v2/organizations/${org.id}/routing-forms/${orgAdminUserRoutingFormWithInvalidEventType.id}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.send({
question1: "answer1",
});
@@ -499,37 +607,10 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
// Clean up the form
await prismaWriteService.prisma.app_RoutingForms_Form.delete({
where: { id: routingFormWithInvalidEventType.id },
where: { id: orgAdminUserRoutingFormWithInvalidEventType.id },
});
});
it("should handle routing with team member assignments", async () => {
// This test verifies that routing forms can handle team member assignments
// and that the routing returns the correct team member information
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1", // This matches the route condition
question2: "answer2",
})
.expect(201)
.then((response) => {
const responseBody = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data).toBeDefined();
expect(data.routing?.responseId).toBeDefined();
expect(typeof data.routing?.responseId).toBe("number");
expect(data.eventTypeId).toEqual(routingEventType.id);
expect(data.slots).toBeDefined();
// Team member assignments would be part of the routing data
// This test validates the basic routing functionality works
});
});
it("should return external redirect URL when routing to external URL", async () => {
// Create a routing form with external redirect action
const externalRoutingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
@@ -581,8 +662,8 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
settings: {
emailOwnerOnSubmission: false,
},
teamId: team.id,
userId: user.id,
teamId: null,
userId: orgAdminData.user.id,
},
});
@@ -590,7 +671,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
.post(
`/v2/organizations/${org.id}/routing-forms/${externalRoutingForm.id}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.send({
question1: "external", // This matches the route condition for external redirect
})
@@ -617,11 +698,161 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
});
});
describe(`POST /v2/organizations/:orgId/routing-forms/:routingFormId/responses - restrictions check`, () => {
// Helper functions to centralize API version header setting
const createAuthenticatedRequest = (method: "get" | "post" | "patch", url: string, apiKey: string) => {
return request(app.getHttpServer())
[method](url)
.set({
Authorization: `Bearer cal_test_${apiKey}`,
});
};
it("should allow non-org-admin user to access their own user routing form", async () => {
return createAuthenticatedRequest(
"post",
`/v2/organizations/${org.id}/routing-forms/${nonOrgAdminData.routingForm.id}/responses?start=2050-09-05&end=2050-09-06`,
nonOrgAdminData.apiKey
)
.send({
question1: "answer1",
question2: "answer2",
})
.expect(201)
.then((response) => {
const responseBody = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data).toBeDefined();
expect(data.routing?.responseId).toBeDefined();
expect(typeof data.routing?.responseId).toBe("number");
expect(data.eventTypeId).toEqual(nonOrgAdminData.eventType.id);
expect(data.slots).toBeDefined();
expect(typeof data.slots).toBe("object");
});
});
it("should return 403 when non-org-admin user tries to access routing form they don't own", async () => {
// Create a second user
const otherUser = await userRepositoryFixture.create({
email: `other-user-${randomString()}@api.com`,
});
// Create a routing form that belongs to the other user
const otherorgAdminUserRoutingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
data: {
name: "Other User's Routing Form",
description: "Test Description",
disabled: false,
routes: [
{
id: "route-1",
queryValue: {
id: "route-1",
type: "group",
children1: {
"rule-1": {
type: "rule",
properties: {
field: "question1",
operator: "equal",
value: ["answer1"],
valueSrc: ["value"],
valueType: ["text"],
},
},
},
},
action: {
type: "eventTypeRedirectUrl",
eventTypeId: orgAdminData.eventType.id,
value: `team/${teamData.team.slug}/${orgAdminData.eventType.slug}`,
},
isFallback: false,
},
],
fields: [
{
id: "question1",
type: "text",
label: "Question 1",
required: true,
identifier: "question1",
},
],
settings: {
emailOwnerOnSubmission: false,
},
// User Routing Form has teamId=null
teamId: null,
userId: otherUser.id, // This form belongs to otherUser, not the authenticated user
},
});
// Try to access the routing form that belongs to the other user
const response = await createAuthenticatedRequest(
"post",
`/v2/organizations/${org.id}/routing-forms/${otherorgAdminUserRoutingForm.id}/responses?start=2050-09-05&end=2050-09-06`,
nonOrgAdminData.apiKey
).send({
question1: "answer1",
});
expect(response.status).toBe(403);
expect(response.body.error.message).toContain(
`Routing Form with id=${otherorgAdminUserRoutingForm.id} is not a user Routing Form owned by user with id=${nonOrgAdminData.user.id}.`
);
// Clean up
await prismaWriteService.prisma.app_RoutingForms_Form.delete({
where: { id: otherorgAdminUserRoutingForm.id },
});
await prismaWriteService.prisma.user.delete({
where: { id: otherUser.id },
});
});
it("should allow org admin to access any routing form using RolesGuard", async () => {
return createAuthenticatedRequest(
"post",
`/v2/organizations/${org.id}/routing-forms/${orgAdminData.routingForm.id}/responses?start=2050-09-05&end=2050-09-06`,
orgAdminData.apiKey
)
.send({
question1: "answer1",
question2: "answer2",
})
.expect(201)
.then((response) => {
const responseBody = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data).toBeDefined();
expect(data.routing?.responseId).toBeDefined();
expect(typeof data.routing?.responseId).toBe("number");
});
});
afterAll(async () => {
// Clean up user routing form
await prismaWriteService.prisma.app_RoutingForms_Form.delete({
where: { id: orgAdminData.routingForm.id },
});
// Clean up non-org-admin user
await prismaWriteService.prisma.user.delete({
where: { id: nonOrgAdminData.user.id },
});
});
});
describe(`PATCH /v2/organizations/:orgId/routing-forms/:routingFormId/responses/:responseId`, () => {
it("should not update routing form response for non existing org", async () => {
return request(app.getHttpServer())
.patch(`/v2/organizations/99999/routing-forms/${routingForm.id}/responses/${routingFormResponse.id}`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.patch(
`/v2/organizations/99999/routing-forms/${teamData.routingForm.id}/responses/${teamData.routingFormResponse1.id}`
)
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
.expect(403);
});
@@ -629,17 +860,17 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
it("should not update routing form response for non existing form", async () => {
return request(app.getHttpServer())
.patch(
`/v2/organizations/${org.id}/routing-forms/non-existent-id/responses/${routingFormResponse.id}`
`/v2/organizations/${org.id}/routing-forms/non-existent-id/responses/${teamData.routingFormResponse1.id}`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
.expect(404);
});
it("should not update routing form response for non existing response", async () => {
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses/99999`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.patch(`/v2/organizations/${org.id}/routing-forms/${teamData.routingForm.id}/responses/99999`)
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
.expect(404);
});
@@ -647,7 +878,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
it("should not update routing form response without authentication", async () => {
return request(app.getHttpServer())
.patch(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses/${routingFormResponse.id}`
`/v2/organizations/${org.id}/routing-forms/${teamData.routingForm.id}/responses/${teamData.routingFormResponse1.id}`
)
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
.expect(401);
@@ -657,9 +888,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
const updatedResponse = { question1: "updated_answer1", question2: "updated_answer2" };
return request(app.getHttpServer())
.patch(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses/${routingFormResponse.id}`
`/v2/organizations/${org.id}/routing-forms/${teamData.routingForm.id}/responses/${teamData.routingFormResponse1.id}`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.send({ response: updatedResponse })
.expect(200)
.then((response) => {
@@ -667,8 +898,8 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data).toBeDefined();
expect(data.id).toEqual(routingFormResponse.id);
expect(data.formId).toEqual(routingFormResponse.formId);
expect(data.id).toEqual(teamData.routingFormResponse1.id);
expect(data.formId).toEqual(teamData.routingFormResponse1.formId);
expect(data.response).toEqual(updatedResponse);
});
});
@@ -677,12 +908,12 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
afterAll(async () => {
await prismaWriteService.prisma.app_RoutingForms_FormResponse.delete({
where: {
id: routingFormResponse.id,
id: teamData.routingFormResponse1.id,
},
});
await prismaWriteService.prisma.app_RoutingForms_FormResponse.delete({
where: {
id: routingFormResponse2.id,
id: teamData.routingFormResponse2.id,
},
});
await prismaWriteService.prisma.app_RoutingForms_Form.deleteMany({
@@ -697,7 +928,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
});
await prismaWriteService.prisma.team.delete({
where: {
id: team.id,
id: teamData.team.id,
},
});
await prismaWriteService.prisma.team.delete({
@@ -707,7 +938,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
});
await prismaWriteService.prisma.user.delete({
where: {
id: user.id,
id: orgAdminData.user.id,
},
});
@@ -4,8 +4,10 @@ import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.de
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { Or } from "@/modules/auth/guards/or-guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { IsUserRoutingForm } from "@/modules/auth/guards/organizations/is-user-routing-form.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { GetRoutingFormResponsesOutput } from "@/modules/organizations/routing-forms/outputs/get-routing-form-responses.output";
import { OrganizationsRoutingFormsResponsesService } from "@/modules/organizations/routing-forms/services/organizations-routing-forms-responses.service";
@@ -20,12 +22,12 @@ import {
UseGuards,
ParseIntPipe,
Req,
Version,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags } from "@nestjs/swagger";
import { Request } from "express";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { GetAvailableSlotsInput_2024_09_04 } from "@calcom/platform-types";
import { CreateRoutingFormResponseInput } from "../inputs/create-routing-form-response.input";
import { GetRoutingFormResponsesParams } from "../inputs/get-routing-form-responses-params.input";
@@ -37,7 +39,7 @@ import { UpdateRoutingFormResponseOutput } from "../outputs/update-routing-form-
path: "/v2/organizations/:orgId/routing-forms/:routingFormId/responses",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@UseGuards(ApiAuthGuard, IsOrgGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@ApiTags("Orgs / Routing forms")
@ApiHeader(API_KEY_HEADER)
export class OrganizationsRoutingFormsResponsesController {
@@ -48,6 +50,7 @@ export class OrganizationsRoutingFormsResponsesController {
@Get("/")
@ApiOperation({ summary: "Get routing form responses" })
@Roles("ORG_ADMIN")
@UseGuards(RolesGuard)
@PlatformPlan("ESSENTIALS")
async getRoutingFormResponses(
@Param("orgId", ParseIntPipe) orgId: number,
@@ -74,6 +77,7 @@ export class OrganizationsRoutingFormsResponsesController {
@Post("/")
@ApiOperation({ summary: "Create routing form response and get available slots" })
@Roles("ORG_ADMIN")
@UseGuards(Or([RolesGuard, IsUserRoutingForm]))
@PlatformPlan("ESSENTIALS")
async createRoutingFormResponse(
@Param("orgId", ParseIntPipe) orgId: number,
@@ -82,7 +86,6 @@ export class OrganizationsRoutingFormsResponsesController {
@Req() request: Request
): Promise<CreateRoutingFormResponseOutput> {
const result = await this.organizationsRoutingFormsResponsesService.createRoutingFormResponseWithSlots(
orgId,
routingFormId,
query,
request
@@ -97,6 +100,7 @@ export class OrganizationsRoutingFormsResponsesController {
@Patch("/:responseId")
@ApiOperation({ summary: "Update routing form response" })
@Roles("ORG_ADMIN")
@UseGuards(RolesGuard)
@PlatformPlan("ESSENTIALS")
async updateRoutingFormResponse(
@Param("orgId", ParseIntPipe) orgId: number,
@@ -1,3 +1,5 @@
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { IsUserRoutingForm } from "@/modules/auth/guards/organizations/is-user-routing-form.guard";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { OrganizationsTeamsRoutingFormsResponsesOutputService } from "@/modules/organizations/teams/routing-forms/services/organizations-teams-routing-forms-responses-output.service";
@@ -14,17 +16,21 @@ import { OrganizationsRoutingFormsController } from "./controllers/organizations
import { OrganizationsRoutingFormsRepository } from "./organizations-routing-forms.repository";
import { OrganizationsRoutingFormsResponsesService } from "./services/organizations-routing-forms-responses.service";
import { OrganizationsRoutingFormsService } from "./services/organizations-routing-forms.service";
import { SharedRoutingFormResponseService } from "./services/shared-routing-form-response.service";
@Module({
imports: [PrismaModule, StripeModule, RedisModule, RoutingFormsModule, SlotsModule_2024_09_04],
providers: [
IsUserRoutingForm,
MembershipsRepository,
OrganizationsRepository,
OrganizationsRoutingFormsRepository,
OrganizationsRoutingFormsService,
OrganizationsRoutingFormsResponsesService,
SharedRoutingFormResponseService,
OrganizationsTeamsRoutingFormsResponsesOutputService,
TeamsEventTypesRepository,
EventTypesRepository_2024_06_14,
],
controllers: [OrganizationsRoutingFormsController, OrganizationsRoutingFormsResponsesController],
})
@@ -86,11 +86,10 @@ export class CreateRoutingFormResponseOutputData {
eventTypeId?: number;
@ValidateNested()
@ApiProperty({ type: Routing })
@Type(() => Routing)
@ApiPropertyOptional({
type: Routing,
description: "The routing information.",
description: "The routing information that could be passed as is to the booking API.",
example: {
eventTypeId: 123,
routing: {
@@ -100,8 +99,6 @@ export class CreateRoutingFormResponseOutputData {
},
},
})
@ValidateNested()
@Type(() => Routing)
routing?: Routing;
@IsString()
@@ -1,18 +1,9 @@
import { OrganizationsRoutingFormsRepository } from "@/modules/organizations/routing-forms/organizations-routing-forms.repository";
import { SharedRoutingFormResponseService } from "@/modules/organizations/routing-forms/services/shared-routing-form-response.service";
import { OrganizationsTeamsRoutingFormsResponsesOutputService } from "@/modules/organizations/teams/routing-forms/services/organizations-teams-routing-forms-responses-output.service";
import { SlotsService_2024_09_04 } from "@/modules/slots/slots-2024-09-04/services/slots.service";
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
import {
BadRequestException,
Injectable,
NotFoundException,
InternalServerErrorException,
} from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { Request } from "express";
import { getRoutedUrl } from "@calcom/platform-libraries";
import { ById_2024_09_04_type } from "@calcom/platform-types";
import type { CreateRoutingFormResponseInput } from "../inputs/create-routing-form-response.input";
import type { CreateRoutingFormResponseOutputData } from "../outputs/create-routing-form-response.output";
@@ -21,8 +12,7 @@ export class OrganizationsRoutingFormsResponsesService {
constructor(
private readonly organizationsRoutingFormsRepository: OrganizationsRoutingFormsRepository,
private readonly outputService: OrganizationsTeamsRoutingFormsResponsesOutputService,
private readonly slotsService: SlotsService_2024_09_04,
private readonly teamsEventTypesRepository: TeamsEventTypesRepository
private readonly sharedRoutingFormResponseService: SharedRoutingFormResponseService
) {}
async getOrganizationRoutingFormResponses(
@@ -51,6 +41,18 @@ export class OrganizationsRoutingFormsResponsesService {
return this.outputService.getRoutingFormResponses(responses);
}
async createRoutingFormResponseWithSlots(
routingFormId: string,
query: CreateRoutingFormResponseInput,
request: Request
): Promise<CreateRoutingFormResponseOutputData> {
return this.sharedRoutingFormResponseService.createRoutingFormResponseWithSlots(
routingFormId,
query,
request
);
}
async updateRoutingFormResponse(
orgId: number,
routingFormId: string,
@@ -68,201 +70,4 @@ export class OrganizationsRoutingFormsResponsesService {
return this.outputService.getRoutingFormResponses([updatedResponse])[0];
}
async createRoutingFormResponseWithSlots(
orgId: number,
routingFormId: string,
query: CreateRoutingFormResponseInput,
request: Request
): Promise<CreateRoutingFormResponseOutputData> {
const { queueResponse, ...slotsQuery } = query;
this.validateDateRange(slotsQuery.start, slotsQuery.end);
const { redirectUrl, customMessage } = await this.getRoutingUrl(
request,
routingFormId,
queueResponse ?? false
);
// If there is no redirect URL, then we have to show the message as that would be custom page message to be shown as per the route chosen
if (!redirectUrl) {
return {
routingCustomMessage: customMessage,
};
}
if (!this.isEventTypeRedirectUrl(redirectUrl)) {
return {
routingExternalRedirectUrl: redirectUrl.toString(),
};
}
// Extract event type information from the routed URL
const { eventTypeId, crmParams } = await this.extractEventTypeAndCrmParams(redirectUrl);
const paramsForGetAvailableSlots = {
type: ById_2024_09_04_type,
eventTypeId,
...slotsQuery,
...crmParams,
} as const;
// Get available slots using the slots service with CRM parameters
const slots = await this.slotsService.getAvailableSlots(paramsForGetAvailableSlots);
const teamMemberIds = crmParams.routedTeamMemberIds ?? [];
const teamMemberEmail = crmParams.teamMemberEmail ?? undefined;
const skipContactOwner = crmParams.skipContactOwner ?? undefined;
const queuedResponseId = crmParams.queuedFormResponseId ?? null;
const responseId = crmParams.routingFormResponseId ?? null;
const crmAppSlug = crmParams.crmAppSlug ?? undefined;
const crmOwnerRecordType = crmParams.crmOwnerRecordType ?? undefined;
if (responseId) {
return {
routing: {
responseId,
teamMemberEmail,
teamMemberIds,
skipContactOwner,
crmAppSlug,
crmOwnerRecordType,
},
eventTypeId,
slots,
};
}
if (!queuedResponseId) {
throw new InternalServerErrorException(
"No routing form response ID or queued form response ID could be found."
);
}
return {
routing: {
queuedResponseId,
teamMemberEmail,
teamMemberIds,
skipContactOwner,
crmAppSlug,
crmOwnerRecordType,
},
eventTypeId,
slots,
};
}
private validateDateRange(start: string, end: string) {
const startDate = new Date(start);
const endDate = new Date(end);
if (endDate < startDate) {
throw new BadRequestException("End date cannot be before start date.");
}
}
private async getRoutingUrl(request: Request, formId: string, queueResponse: boolean) {
const params = Object.fromEntries(new URLSearchParams(request.body));
const routedUrlData = await getRoutedUrl(
{
req: request,
query: { ...params, form: formId, ...(queueResponse && { "cal.queueFormResponse": "true" }) },
},
true
);
if (routedUrlData.notFound) {
throw new NotFoundException("Routing form not found.");
}
if (routedUrlData?.props?.errorMessage) {
throw new BadRequestException(routedUrlData.props.errorMessage);
}
const destination = routedUrlData?.redirect?.destination;
if (!destination) {
if (routedUrlData?.props?.message) {
return {
redirectUrl: null,
customMessage: routedUrlData.props.message,
};
}
// This should never happen because there is always a fallback route
throw new InternalServerErrorException("No route found.");
}
return {
redirectUrl: new URL(destination),
customMessage: null,
};
}
private async extractEventTypeAndCrmParams(routingUrl: URL) {
// Extract team and event type information
const { teamId, eventTypeSlug } = this.extractTeamIdAndEventTypeSlugFromRedirectUrl(routingUrl);
const eventType = await this.teamsEventTypesRepository.getEventTypeByTeamIdAndSlug(teamId, eventTypeSlug);
if (!eventType?.id) {
// This could only happen if the event-type earlier selected as route action was deleted
throw new InternalServerErrorException("Chosen event type not found.");
}
// Extract CRM parameters from URL
const urlParams = routingUrl.searchParams;
const crmParams = {
teamMemberEmail: urlParams.get("cal.crmContactOwnerEmail") || undefined,
routedTeamMemberIds: urlParams.get("cal.routedTeamMemberIds")
? urlParams
.get("cal.routedTeamMemberIds")!
.split(",")
.map((id) => parseInt(id))
: undefined,
routingFormResponseId: urlParams.get("cal.routingFormResponseId")
? parseInt(urlParams.get("cal.routingFormResponseId")!)
: undefined,
queuedFormResponseId: urlParams.get("cal.queuedFormResponseId")
? (urlParams.get("cal.queuedFormResponseId") as string)
: undefined,
skipContactOwner: urlParams.get("cal.skipContactOwner") === "true" ? true : false,
crmAppSlug: urlParams.get("cal.crmAppSlug") || undefined,
crmOwnerRecordType: urlParams.get("cal.crmContactOwnerRecordType") || undefined,
};
return {
eventTypeId: eventType.id,
crmParams,
};
}
private isEventTypeRedirectUrl(routingUrl: URL) {
const routingSearchParams = routingUrl.searchParams;
return routingSearchParams.get("cal.action") === "eventTypeRedirectUrl";
}
private extractTeamIdAndEventTypeSlugFromRedirectUrl(routingUrl: URL) {
const eventTypeSlug = this.extractEventTypeFromRoutedUrl(routingUrl);
const teamId = this.extractTeamIdFromRoutedUrl(routingUrl);
if (!teamId) {
throw new NotFoundException("Team ID not found in the routed URL.");
}
if (!eventTypeSlug) {
throw new NotFoundException("Event type slug not found in the routed URL.");
}
return { teamId, eventTypeSlug };
}
private extractTeamIdFromRoutedUrl(routingUrl: URL) {
const routingSearchParams = routingUrl.searchParams;
return Number(routingSearchParams.get("cal.teamId"));
}
private extractEventTypeFromRoutedUrl(routingUrl: URL) {
const pathNameParams = routingUrl.pathname.split("/");
return pathNameParams[pathNameParams.length - 1];
}
}
@@ -0,0 +1,226 @@
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { CreateRoutingFormResponseInput } from "@/modules/organizations/routing-forms/inputs/create-routing-form-response.input";
import { CreateRoutingFormResponseOutputData } from "@/modules/organizations/routing-forms/outputs/create-routing-form-response.output";
import { SlotsService_2024_09_04 } from "@/modules/slots/slots-2024-09-04/services/slots.service";
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
import {
Injectable,
BadRequestException,
InternalServerErrorException,
NotFoundException,
} from "@nestjs/common";
import { Request } from "express";
import { getRoutedUrl } from "@calcom/platform-libraries";
import { ById_2024_09_04_type } from "@calcom/platform-types";
@Injectable()
export class SharedRoutingFormResponseService {
constructor(
private readonly slotsService: SlotsService_2024_09_04,
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
private readonly eventTypesRepository: EventTypesRepository_2024_06_14
) {}
async createRoutingFormResponseWithSlots(
routingFormId: string,
query: CreateRoutingFormResponseInput,
request: Request
): Promise<CreateRoutingFormResponseOutputData> {
const { queueResponse, ...slotsQuery } = query;
const user = request.user as ApiAuthGuardUser;
this.validateDateRange(slotsQuery.start, slotsQuery.end);
const { redirectUrl, customMessage } = await this.getRoutingUrl(
request,
routingFormId,
queueResponse ?? false
);
// If there is no redirect URL, then we have to show the message as that would be custom page message to be shown as per the route chosen
if (!redirectUrl) {
return {
routingCustomMessage: customMessage,
};
}
if (!this.isEventTypeRedirectUrl(redirectUrl)) {
return {
routingExternalRedirectUrl: redirectUrl.toString(),
};
}
// Extract event type information from the routed URL
const { eventTypeId, crmParams } = await this.extractEventTypeAndCrmParams(user.id, redirectUrl);
// Get available slots using the slots service with CRM parameters
const slots = await this.slotsService.getAvailableSlotsWithRouting({
type: ById_2024_09_04_type,
eventTypeId,
...slotsQuery,
...crmParams,
});
const teamMemberIds = crmParams.routedTeamMemberIds ?? [];
const teamMemberEmail = crmParams.teamMemberEmail ?? undefined;
const skipContactOwner = crmParams.skipContactOwner ?? undefined;
const queuedResponseId = crmParams.queuedFormResponseId ?? null;
const responseId = crmParams.routingFormResponseId ?? null;
const crmAppSlug = crmParams.crmAppSlug ?? undefined;
const crmOwnerRecordType = crmParams.crmOwnerRecordType ?? undefined;
if (responseId) {
return {
routing: {
responseId,
teamMemberEmail,
teamMemberIds,
skipContactOwner,
crmAppSlug,
crmOwnerRecordType,
},
eventTypeId,
slots,
};
}
if (!queuedResponseId) {
throw new InternalServerErrorException(
"No routing form response ID or queued form response ID could be found."
);
}
return {
routing: {
queuedResponseId,
teamMemberEmail,
teamMemberIds,
skipContactOwner,
crmAppSlug,
crmOwnerRecordType,
},
eventTypeId,
slots,
};
}
private validateDateRange(start: string, end: string) {
const startDate = new Date(start);
const endDate = new Date(end);
if (endDate < startDate) {
throw new BadRequestException("End date cannot be before start date.");
}
}
private async getRoutingUrl(request: Request, formId: string, queueResponse: boolean) {
const params = Object.fromEntries(new URLSearchParams(request.body));
const routedUrlData = await getRoutedUrl(
{
req: request,
query: { ...params, form: formId, ...(queueResponse && { "cal.queueFormResponse": "true" }) },
},
true
);
if (routedUrlData.notFound) {
throw new NotFoundException("Routing form not found.");
}
if (routedUrlData?.props?.errorMessage) {
throw new BadRequestException(routedUrlData.props.errorMessage);
}
const destination = routedUrlData?.redirect?.destination;
if (!destination) {
if (routedUrlData?.props?.message) {
return {
redirectUrl: null,
customMessage: routedUrlData.props.message,
};
}
// This should never happen because there is always a fallback route
throw new InternalServerErrorException("No route found.");
}
return {
redirectUrl: new URL(destination),
customMessage: null,
};
}
private async extractEventTypeAndCrmParams(userId: number, routingUrl: URL) {
// Extract team and event type information
// TODO: Route action also has eventTypeId directly now and instead of using this brittle approach for getting event type by slug, we should get by eventTypeId
const { teamId, eventTypeSlug } = this.extractTeamIdAndEventTypeSlugFromRedirectUrl(routingUrl);
const eventType = teamId
? await this.teamsEventTypesRepository.getEventTypeByTeamIdAndSlug(teamId, eventTypeSlug)
: await this.eventTypesRepository.getUserEventTypeBySlug(userId, eventTypeSlug);
if (!eventType?.id) {
// This could only happen if the event-type earlier selected as route action was deleted
throw new InternalServerErrorException(
`Chosen event type identified by slug ${eventTypeSlug} not found.`
);
}
// Extract CRM parameters from URL
const urlParams = routingUrl.searchParams;
const crmParams = {
teamMemberEmail: urlParams.get("cal.crmContactOwnerEmail") || undefined,
routedTeamMemberIds: urlParams.get("cal.routedTeamMemberIds")
? urlParams
.get("cal.routedTeamMemberIds")!
.split(",")
.map((id) => parseInt(id))
: undefined,
routingFormResponseId: urlParams.get("cal.routingFormResponseId")
? parseInt(urlParams.get("cal.routingFormResponseId")!)
: undefined,
queuedFormResponseId: urlParams.get("cal.queuedFormResponseId")
? (urlParams.get("cal.queuedFormResponseId") as string)
: undefined,
skipContactOwner: urlParams.get("cal.skipContactOwner") === "true" ? true : false,
crmAppSlug: urlParams.get("cal.crmAppSlug") || undefined,
crmOwnerRecordType: urlParams.get("cal.crmContactOwnerRecordType") || undefined,
};
return {
eventTypeId: eventType.id,
crmParams,
};
}
private isEventTypeRedirectUrl(routingUrl: URL) {
const routingSearchParams = routingUrl.searchParams;
return routingSearchParams.get("cal.action") === "eventTypeRedirectUrl";
}
private extractTeamIdAndEventTypeSlugFromRedirectUrl(routingUrl: URL) {
const eventTypeSlug = this.extractEventTypeFromRoutedUrl(routingUrl);
const teamId = this.extractTeamIdFromRoutedUrl(routingUrl);
if (!eventTypeSlug) {
throw new InternalServerErrorException("Event type slug not found in the routed URL.");
}
return { teamId, eventTypeSlug };
}
private extractTeamIdFromRoutedUrl(routingUrl: URL) {
const routingSearchParams = routingUrl.searchParams;
const teamId = Number(routingSearchParams.get("cal.teamId"));
if (isNaN(teamId)) {
return null;
}
return teamId;
}
private extractEventTypeFromRoutedUrl(routingUrl: URL) {
const pathNameParams = routingUrl.pathname.split("/");
return pathNameParams[pathNameParams.length - 1];
}
}
@@ -1,6 +1,7 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { GetRoutingFormResponsesOutput } from "@/modules/organizations/teams/routing-forms/outputs/get-routing-form-responses.output";
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";
@@ -23,6 +24,7 @@ import { Team } from "@calcom/prisma/client";
describe("Organizations Teams Routing Forms Responses", () => {
let app: INestApplication;
let prismaWriteService: PrismaWriteService;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
@@ -41,6 +43,13 @@ describe("Organizations Teams Routing Forms Responses", () => {
let apiKeyString: string;
let routingFormId: string;
let routingEventType: {
id: number;
slug: string | null;
teamId: number | null;
userId: number | null;
title: string;
};
const routingFormResponses = [
{
formFillerId: `${randomString()}`,
@@ -59,6 +68,7 @@ describe("Organizations Teams Routing Forms Responses", () => {
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
}).compile();
prismaWriteService = moduleRef.get<PrismaWriteService>(PrismaWriteService);
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
@@ -98,6 +108,17 @@ describe("Organizations Teams Routing Forms Responses", () => {
team: { connect: { id: orgTeam.id } },
});
// Create an event type for routing form to route to
routingEventType = await prismaWriteService.prisma.eventType.create({
data: {
title: "Test Event Type",
slug: "test-event-type",
length: 30,
userId: user.id,
teamId: orgTeam.id,
},
});
await profileRepositoryFixture.create({
uid: `usr-${user.id}`,
username: authEmail,
@@ -118,18 +139,59 @@ describe("Organizations Teams Routing Forms Responses", () => {
description: null,
position: 0,
disabled: false,
fields: JSON.stringify([
fields: [
{
id: "participant-field",
type: "text",
label: "participant",
required: true,
identifier: "participant",
},
]),
routes: JSON.stringify([
{
action: { type: "customPageMessage", value: "Thank you for your response" },
id: "question2-field",
type: "text",
label: "question2",
required: false,
identifier: "question2",
},
]),
],
routes: [
{
id: "route-1",
queryValue: {
id: "route-1",
type: "group",
children1: {
"rule-1": {
type: "rule",
properties: {
field: "question1",
operator: "equal",
value: ["answer1"],
valueSrc: ["value"],
valueType: ["text"],
},
},
},
},
action: {
type: "eventTypeRedirectUrl",
eventTypeId: routingEventType.id,
value: `team/${orgTeam.slug}/${routingEventType.slug}`,
},
isFallback: false,
},
{
id: "fallback-route",
queryValue: {
id: "fallback-route",
type: "group",
children1: {},
},
action: { type: "customPageMessage", value: "Thank you for your response" },
isFallback: true,
},
],
user: {
connect: {
id: user.id,
@@ -190,6 +252,455 @@ describe("Organizations Teams Routing Forms Responses", () => {
});
});
describe(`POST /v2/organizations/:orgId/teams/:teamId/routing-forms/:routingFormId/responses`, () => {
describe("permissions", () => {
it("should return 403 when organization does not exist", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/99999/teams/${orgTeam.id}/routing-forms/${routingFormId}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1",
})
.expect(403);
});
it("should return 404 when team does not exist", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/teams/99999/routing-forms/${routingFormId}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1",
})
.expect(404)
.then((response) => {
expect(response.body.error.message).toContain(`IsTeamInOrg - Team (99999) not found.`);
});
});
it("should return 403 when routing form does not exist in the team", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/non-existent-id/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1",
})
.expect(403)
.then((response) => {
expect(response.body.error.message).toContain(
`IsRoutingFormInTeam - team with id=(${orgTeam.id}) does not own routing form with id=(non-existent-id).`
);
});
});
it("should return 403 when routing form belongs to different team", async () => {
// Create a second team within the same organization
const otherTeam = await teamsRepositoryFixture.create({
name: `other-team-${randomString()}`,
isOrganization: false,
parent: { connect: { id: org.id } },
});
// Create a routing form that belongs to the other team
const otherTeamRoutingForm = await routingFormsRepositoryFixture.create({
name: "Other Team's Routing Form",
description: "Test Description",
position: 0,
disabled: false,
fields: [
{
type: "text",
label: "Question 1",
required: true,
},
],
routes: [
{
action: { type: "customPageMessage", value: "Thank you for your response" },
},
],
user: {
connect: {
id: user.id,
},
},
team: {
connect: {
id: otherTeam.id,
},
},
});
// Try to access the routing form that belongs to the other team
const response = await request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${otherTeamRoutingForm.id}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1",
});
expect(response.status).toBe(403);
expect(response.body.error.message).toContain(
`IsRoutingFormInTeam - team with id=(${orgTeam.id}) does not own routing form with id=(${otherTeamRoutingForm.id}).`
);
// Clean up
await routingFormsRepositoryFixture.delete(otherTeamRoutingForm.id);
await teamsRepositoryFixture.delete(otherTeam.id);
});
it("should return 401 when authentication token is missing", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${routingFormId}/responses?start=2050-09-05&end=2050-09-06`
)
.send({
question1: "answer1",
})
.expect(401);
});
});
it("should return 400 when required form fields are missing", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${routingFormId}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
// Missing required participant field
})
.expect(400);
});
it("should return 400 when required slot query parameters are missing", async () => {
// Missing start parameter
await request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${routingFormId}/responses?end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
participant: "test-participant",
})
.expect(400);
// Missing end parameter
await request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${routingFormId}/responses?start=2050-09-05`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
participant: "test-participant",
})
.expect(400);
});
it("should return 400 when date parameters have invalid format", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${routingFormId}/responses?start=invalid-date&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
participant: "test-participant",
})
.expect(400);
});
it("should return 400 when end date is before start date", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${routingFormId}/responses?start=2050-09-10&end=2050-09-05`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
participant: "test-participant",
})
.expect(400);
});
it("should handle queued response creation", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${routingFormId}/responses?start=2050-09-05&end=2050-09-06&queueResponse=true`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
participant: "test-participant",
})
.expect(201)
.then((response) => {
const responseBody = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
console.log("responseBody", responseBody.data);
expect(data.routing?.queuedResponseId).toBeDefined();
});
});
it("should create response and return available slots when routing to event type", async () => {
// Create a routing form with event type routing
const eventTypeRoutingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
data: {
name: "Test Event Type Routing Form",
description: "Test Description",
disabled: false,
routes: [
{
id: "route-1",
queryValue: {
id: "route-1",
type: "group",
children1: {
"rule-1": {
type: "rule",
properties: {
field: "question1",
operator: "equal",
value: ["answer1"],
valueSrc: ["value"],
valueType: ["text"],
},
},
},
},
action: {
type: "eventTypeRedirectUrl",
eventTypeId: routingEventType.id,
value: `team/${orgTeam.slug}/${routingEventType.slug}`,
},
isFallback: false,
},
{
id: "fallback-route",
action: { type: "customPageMessage", value: "Fallback Message" },
isFallback: true,
queryValue: { id: "fallback-route", type: "group" },
},
],
fields: [
{
id: "question1",
type: "text",
label: "Question 1",
required: true,
identifier: "question1",
},
{
id: "question2",
type: "text",
label: "Question 2",
required: false,
identifier: "question2",
},
],
settings: {
emailOwnerOnSubmission: false,
},
teamId: orgTeam.id,
userId: user.id,
},
});
const response = await request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${eventTypeRoutingForm.id}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1", // This matches the route condition
question2: "answer2",
})
.expect(201);
const responseBody = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data).toBeDefined();
expect(data.routing?.responseId).toBeDefined();
expect(typeof data.routing?.responseId).toBe("number");
expect(data.eventTypeId).toEqual(routingEventType.id);
expect(data.slots).toBeDefined();
expect(typeof data.slots).toBe("object");
expect(data.routing?.teamMemberIds).toBeDefined();
// Clean up
await prismaWriteService.prisma.app_RoutingForms_Form.delete({
where: { id: eventTypeRoutingForm.id },
});
});
it("should return 500 when event type is not found", async () => {
// Create a routing form with an invalid eventTypeId
const routingFormWithInvalidEventType = await prismaWriteService.prisma.app_RoutingForms_Form.create({
data: {
name: "Test Routing Form with Invalid Event Type",
description: "Test Description",
disabled: false,
routes: [
{
id: "route-1",
queryValue: {
id: "route-1",
type: "group",
children1: {
"rule-1": {
type: "rule",
properties: {
field: "question1",
operator: "equal",
value: ["answer1"],
valueSrc: ["value"],
valueType: ["text"],
},
},
},
},
action: {
type: "eventTypeRedirectUrl",
eventTypeId: 99999, // Invalid event type ID
value: `team/${orgTeam.slug}/non-existent-event-type`,
},
isFallback: false,
},
{
id: "fallback-route",
action: { type: "customPageMessage", value: "Fallback Message" },
isFallback: true,
queryValue: { id: "fallback-route", type: "group" },
},
],
fields: [
{
id: "question1",
type: "text",
label: "Question 1",
required: true,
identifier: "question1",
},
],
settings: {
emailOwnerOnSubmission: false,
},
teamId: orgTeam.id,
userId: user.id,
},
});
// Try to create a response for the form with invalid event type
const response = await request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${routingFormWithInvalidEventType.id}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1",
});
expect(response.status).toBe(500);
// Clean up the form
await prismaWriteService.prisma.app_RoutingForms_Form.delete({
where: { id: routingFormWithInvalidEventType.id },
});
});
it("should return external redirect URL when routing to external URL", async () => {
// Create a routing form with external redirect action
const externalRoutingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
data: {
name: "Test External Routing Form",
description: "Test Description for External Redirect",
disabled: false,
routes: [
{
id: "external-route-1",
queryValue: {
id: "external-route-1",
type: "group",
children1: {
"rule-1": {
type: "rule",
properties: {
field: "question1",
operator: "equal",
value: ["external"],
valueSrc: ["value"],
valueType: ["text"],
},
},
},
},
action: {
type: "externalRedirectUrl",
value: "https://example.com/external-booking",
},
isFallback: false,
},
{
id: "fallback-route",
action: { type: "customPageMessage", value: "Fallback Message" },
isFallback: true,
queryValue: { id: "fallback-route", type: "group" },
},
],
fields: [
{
id: "question1",
type: "text",
label: "Question 1",
required: true,
identifier: "question1",
},
],
settings: {
emailOwnerOnSubmission: false,
},
teamId: orgTeam.id,
userId: user.id,
},
});
const response = await request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${externalRoutingForm.id}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "external", // This matches the route condition for external redirect
})
.expect(201);
const responseBody = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data).toBeDefined();
expect(data.routingExternalRedirectUrl).toBeDefined();
expect(data.routingExternalRedirectUrl).toContain("https://example.com/external-booking");
expect(data.routingExternalRedirectUrl).toContain("cal.action=externalRedirectUrl");
// Verify that it doesn't contain event type routing data
expect(data.eventTypeId).toBeUndefined();
expect(data.slots).toBeUndefined();
expect(data.routing).toBeUndefined();
expect(data.routingCustomMessage).toBeUndefined();
// Clean up the external routing form
await prismaWriteService.prisma.app_RoutingForms_Form.delete({
where: { id: externalRoutingForm.id },
});
});
});
describe(`PATCH /v2/organizations/:orgId/routing-forms/:routingFormId/responses/:responseId`, () => {
it("should not update routing form response for non existing org", async () => {
return request(app.getHttpServer())
@@ -246,8 +757,13 @@ describe("Organizations Teams Routing Forms Responses", () => {
});
afterAll(async () => {
await routingFormsRepositoryFixture.deleteResponse(routingFormResponseId);
if (routingFormResponseId) {
await routingFormsRepositoryFixture.deleteResponse(routingFormResponseId);
}
await routingFormsRepositoryFixture.delete(routingFormId);
await prismaWriteService.prisma.eventType.delete({
where: { id: routingEventType.id },
});
await userRepositoryFixture.deleteByEmail(user.email);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
@@ -9,13 +9,27 @@ import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsRoutingFormInTeam } from "@/modules/auth/guards/routing-forms/is-routing-form-in-team.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { CreateRoutingFormResponseInput } from "@/modules/organizations/routing-forms/inputs/create-routing-form-response.input";
import { GetRoutingFormResponsesParams } from "@/modules/organizations/routing-forms/inputs/get-routing-form-responses-params.input";
import { UpdateRoutingFormResponseInput } from "@/modules/organizations/routing-forms/inputs/update-routing-form-response.input";
import { CreateRoutingFormResponseOutput } from "@/modules/organizations/routing-forms/outputs/create-routing-form-response.output";
import { UpdateRoutingFormResponseOutput } from "@/modules/organizations/routing-forms/outputs/update-routing-form-response.output";
import { GetRoutingFormResponsesOutput } from "@/modules/organizations/teams/routing-forms/outputs/get-routing-form-responses.output";
import { OrganizationsTeamsRoutingFormsResponsesService } from "@/modules/organizations/teams/routing-forms/services/organizations-teams-routing-forms-responses.service";
import { Controller, Get, Patch, Param, ParseIntPipe, Query, UseGuards, Body } from "@nestjs/common";
import {
Controller,
Get,
Post,
Patch,
Param,
ParseIntPipe,
Query,
UseGuards,
Body,
Req,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags } from "@nestjs/swagger";
import { Request } from "express";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
@@ -66,6 +80,30 @@ export class OrganizationsTeamsRoutingFormsResponsesController {
};
}
@Post("/")
@ApiOperation({ summary: "Create routing form response and get available slots" })
@Roles("TEAM_MEMBER")
@PlatformPlan("ESSENTIALS")
async createRoutingFormResponse(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("teamId", ParseIntPipe) teamId: number,
@Param("routingFormId") routingFormId: string,
@Query() query: CreateRoutingFormResponseInput,
@Req() request: Request
): Promise<CreateRoutingFormResponseOutput> {
const result =
await this.organizationsTeamsRoutingFormsResponsesService.createRoutingFormResponseWithSlots(
routingFormId,
query,
request
);
return {
status: SUCCESS_STATUS,
data: result,
};
}
@Patch("/:responseId")
@ApiOperation({ summary: "Update routing form response" })
@Roles("TEAM_ADMIN")
@@ -1,7 +1,9 @@
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { OrganizationsRoutingFormsRepository } from "@/modules/organizations/routing-forms/organizations-routing-forms.repository";
import { OrganizationsRoutingFormsResponsesService } from "@/modules/organizations/routing-forms/services/organizations-routing-forms-responses.service";
import { SharedRoutingFormResponseService } from "@/modules/organizations/routing-forms/services/shared-routing-form-response.service";
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
@@ -31,6 +33,7 @@ import { OrganizationsTeamsRoutingFormsService } from "./services/organizations-
providers: [
OrganizationsTeamsRoutingFormsService,
OrganizationsTeamsRoutingFormsResponsesService,
SharedRoutingFormResponseService,
OrganizationsTeamsRoutingFormsResponsesOutputService,
OrganizationsTeamsRoutingFormsResponsesRepository,
OrganizationsTeamsRoutingFormsRepository,
@@ -39,6 +42,7 @@ import { OrganizationsTeamsRoutingFormsService } from "./services/organizations-
MembershipsRepository,
OrganizationsRoutingFormsResponsesService,
OrganizationsRoutingFormsRepository,
EventTypesRepository_2024_06_14,
],
controllers: [OrganizationsTeamsRoutingFormsResponsesController, OrganizationsTeamsRoutingFormsController],
})
@@ -1,5 +1,9 @@
import { CreateRoutingFormResponseInput } from "@/modules/organizations/routing-forms/inputs/create-routing-form-response.input";
import { CreateRoutingFormResponseOutputData } from "@/modules/organizations/routing-forms/outputs/create-routing-form-response.output";
import { SharedRoutingFormResponseService } from "@/modules/organizations/routing-forms/services/shared-routing-form-response.service";
import { OrganizationsTeamsRoutingFormsResponsesOutputService } from "@/modules/organizations/teams/routing-forms/services/organizations-teams-routing-forms-responses-output.service";
import { Injectable } from "@nestjs/common";
import { Request } from "express";
import { OrganizationsTeamsRoutingFormsResponsesRepository } from "../repositories/organizations-teams-routing-forms-responses.repository";
@@ -7,7 +11,8 @@ import { OrganizationsTeamsRoutingFormsResponsesRepository } from "../repositori
export class OrganizationsTeamsRoutingFormsResponsesService {
constructor(
private readonly routingFormsRepository: OrganizationsTeamsRoutingFormsResponsesRepository,
private readonly routingFormsResponsesOutputService: OrganizationsTeamsRoutingFormsResponsesOutputService
private readonly routingFormsResponsesOutputService: OrganizationsTeamsRoutingFormsResponsesOutputService,
private readonly sharedRoutingFormResponseService: SharedRoutingFormResponseService
) {}
async getTeamRoutingFormResponses(
@@ -36,6 +41,18 @@ export class OrganizationsTeamsRoutingFormsResponsesService {
return this.routingFormsResponsesOutputService.getRoutingFormResponses(responses);
}
async createRoutingFormResponseWithSlots(
routingFormId: string,
query: CreateRoutingFormResponseInput,
request: Request
): Promise<CreateRoutingFormResponseOutputData> {
return this.sharedRoutingFormResponseService.createRoutingFormResponseWithSlots(
routingFormId,
query,
request
);
}
async updateTeamRoutingFormResponse(
teamId: number,
routingFormId: string,
@@ -93,7 +93,7 @@ export class OrganizationTeamWorkflowsController {
@Patch("/:workflowId")
@UseGuards(IsWorkflowInTeam)
@ApiOperation({ summary: "Update workflow" })
@ApiOperation({ summary: "Update organization team workflow" })
@Roles("TEAM_ADMIN")
@PlatformPlan("SCALE")
async updateWorkflow(
@@ -13,11 +13,32 @@ import {
ByUsernameAndEventTypeSlug_2024_09_04,
ByTeamSlugAndEventTypeSlug_2024_09_04,
GetSlotsInput_2024_09_04,
GetSlotsInputWithRouting_2024_09_04,
ById_2024_09_04_type,
ByUsernameAndEventTypeSlug_2024_09_04_type,
ByTeamSlugAndEventTypeSlug_2024_09_04_type,
} from "@calcom/platform-types";
export type InternalGetSlotsQuery = {
isTeamEvent: boolean;
startTime: string;
endTime: string;
duration?: number;
eventTypeId: number;
eventTypeSlug: string;
usernameList: string[];
timeZone: string | undefined;
orgSlug: string | null | undefined;
rescheduleUid: string | null;
};
export type InternalGetSlotsQueryWithRouting = InternalGetSlotsQuery & {
routedTeamMemberIds: number[] | null;
skipContactOwner: boolean;
teamMemberEmail: string | null;
routingFormResponseId: number | undefined;
};
@Injectable()
export class SlotsInputService_2024_09_04 {
constructor(
@@ -30,7 +51,7 @@ export class SlotsInputService_2024_09_04 {
private readonly teamsEventTypesRepository: TeamsEventTypesRepository
) {}
async transformGetSlotsQuery(query: GetSlotsInput_2024_09_04) {
async transformGetSlotsQuery(query: GetSlotsInput_2024_09_04): Promise<InternalGetSlotsQuery> {
const eventType = await this.getEventType(query);
if (!eventType) {
throw new NotFoundException(`Event Type not found`);
@@ -46,10 +67,6 @@ export class SlotsInputService_2024_09_04 {
const timeZone = query.timeZone;
const orgSlug = "organizationSlug" in query ? query.organizationSlug : null;
const rescheduleUid = query.bookingUidToReschedule || null;
const routedTeamMemberIds = query.routedTeamMemberIds || null;
const skipContactOwner = query.skipContactOwner || false;
const teamMemberEmail = query.teamMemberEmail || null;
const routingFormResponseId = query.routingFormResponseId || null;
return {
isTeamEvent,
@@ -62,10 +79,23 @@ export class SlotsInputService_2024_09_04 {
timeZone,
orgSlug,
rescheduleUid,
routedTeamMemberIds,
skipContactOwner,
teamMemberEmail,
routingFormResponseId,
};
}
async transformRoutingGetSlotsQuery(
query: GetSlotsInputWithRouting_2024_09_04
): Promise<InternalGetSlotsQueryWithRouting> {
const { routedTeamMemberIds, skipContactOwner, teamMemberEmail, routingFormResponseId, ...baseQuery } =
query;
const baseTransformation = await this.transformGetSlotsQuery(baseQuery);
return {
...baseTransformation,
routedTeamMemberIds: routedTeamMemberIds || null,
skipContactOwner: skipContactOwner || false,
teamMemberEmail: teamMemberEmail || null,
routingFormResponseId: routingFormResponseId ?? undefined,
};
}
@@ -0,0 +1,397 @@
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { AvailableSlotsService } from "@/lib/services/available-slots.service";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { MembershipsService } from "@/modules/memberships/services/memberships.service";
import { SlotsInputService_2024_09_04 } from "@/modules/slots/slots-2024-09-04/services/slots-input.service";
import { SlotsOutputService_2024_09_04 } from "@/modules/slots/slots-2024-09-04/services/slots-output.service";
import { SlotsRepository_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.repository";
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
import { BadRequestException, NotFoundException } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing";
import { SlotsService_2024_09_04 } from "./slots.service";
describe("SlotsService_2024_09_04", () => {
let service: SlotsService_2024_09_04;
let eventTypesRepository: EventTypesRepository_2024_06_14;
let availableSlotsService: AvailableSlotsService;
let slotsInputService: SlotsInputService_2024_09_04;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SlotsService_2024_09_04,
{
provide: SlotsInputService_2024_09_04,
useValue: {
transformGetSlotsQuery: jest.fn(),
transformRoutingGetSlotsQuery: jest.fn(),
},
},
{
provide: EventTypesRepository_2024_06_14,
useValue: {
getEventTypeById: jest.fn(),
},
},
{
provide: SlotsRepository_2024_09_04,
useValue: {},
},
{
provide: SlotsOutputService_2024_09_04,
useValue: {
getAvailableSlots: jest.fn().mockResolvedValue({
slots: {
"2024-01-15": [{ time: "2024-01-15T10:00:00.000Z" }, { time: "2024-01-15T11:00:00.000Z" }],
},
}),
},
},
{
provide: AvailableSlotsService,
useValue: {
getAvailableSlots: jest.fn(),
},
},
{
provide: MembershipsService,
useValue: {},
},
{
provide: MembershipsRepository,
useValue: {},
},
{
provide: TeamsRepository,
useValue: {},
},
],
}).compile();
service = module.get<SlotsService_2024_09_04>(SlotsService_2024_09_04);
slotsInputService = module.get<SlotsInputService_2024_09_04>(SlotsInputService_2024_09_04);
eventTypesRepository = module.get<EventTypesRepository_2024_06_14>(EventTypesRepository_2024_06_14);
availableSlotsService = module.get<AvailableSlotsService>(AvailableSlotsService);
jest.clearAllMocks();
});
describe("getAvailableSlotsWithRouting", () => {
const sharedTestData = {
eventTypeId: 123,
start: "2024-01-15T00:00:00.000Z",
end: "2024-01-16T00:00:00.000Z",
timeZone: "America/New_York",
mockEventType: {
id: 123,
slug: "test-event",
teamId: null, // Not a team event
length: 30,
},
mockSlotsResponse: {
slots: {},
},
baseInputQuery: {
type: "byEventTypeId" as const,
isTeamEvent: false,
start: "2024-01-15T00:00:00.000Z",
end: "2024-01-16T00:00:00.000Z",
duration: undefined,
eventTypeId: 123,
eventTypeSlug: "test-event",
usernameList: [],
timeZone: "America/New_York",
orgSlug: null,
rescheduleUid: null,
},
routingParams: {
routedTeamMemberIds: [456, 789],
skipContactOwner: true,
teamMemberEmail: "test@example.com",
routingFormResponseId: 999,
},
};
beforeEach(() => {
// Setup shared mocks
(eventTypesRepository.getEventTypeById as jest.Mock).mockResolvedValue(sharedTestData.mockEventType);
(availableSlotsService.getAvailableSlots as jest.Mock).mockResolvedValue(
sharedTestData.mockSlotsResponse
);
});
it("should call getAvailableSlots with correct routing parameters", async () => {
const inputQuery = {
...sharedTestData.baseInputQuery,
...sharedTestData.routingParams,
};
// Mock the transform method to return the expected transformed query
const transformedQuery = {
isTeamEvent: false,
startTime: "2024-01-15T00:00:00.000Z",
endTime: "2024-01-16T23:59:59.000Z",
eventTypeId: 123,
eventTypeSlug: "test-event",
usernameList: [],
timeZone: "America/New_York",
orgSlug: null,
rescheduleUid: null,
...sharedTestData.routingParams,
};
(slotsInputService.transformRoutingGetSlotsQuery as jest.Mock).mockResolvedValue(transformedQuery);
await service.getAvailableSlotsWithRouting({
...inputQuery,
});
const { start: _1, end: _2, type: _3, ...queryWithoutStartEndAndType } = sharedTestData.baseInputQuery;
expect(availableSlotsService.getAvailableSlots).toHaveBeenCalledWith({
input: {
...queryWithoutStartEndAndType,
startTime: "2024-01-15T00:00:00.000Z",
endTime: "2024-01-16T23:59:59.000Z",
...sharedTestData.routingParams,
},
ctx: {},
});
});
});
describe("getAvailableSlotsWithRouting - Error Scenarios", () => {
const baseInputQuery = {
type: "byEventTypeId" as const,
eventTypeId: 123,
start: "2024-01-15T00:00:00.000Z",
end: "2024-01-16T00:00:00.000Z",
timeZone: "America/New_York",
};
it("should handle when event type is not found", async () => {
const notFoundError = new NotFoundException("Event Type not found");
(slotsInputService.transformRoutingGetSlotsQuery as jest.Mock).mockRejectedValue(notFoundError);
await expect(
service.getAvailableSlotsWithRouting({
...baseInputQuery,
teamMemberEmail: "test@example.com",
})
).rejects.toThrow(NotFoundException);
expect(slotsInputService.transformRoutingGetSlotsQuery).toHaveBeenCalledWith({
...baseInputQuery,
teamMemberEmail: "test@example.com",
});
});
it("should handle invalid time range error and throw BadRequestException", async () => {
const transformedQuery = {
isTeamEvent: false,
startTime: "2024-01-16T00:00:00.000Z",
endTime: "2024-01-15T23:59:59.000Z",
eventTypeId: 123,
eventTypeSlug: "test-event",
usernameList: [],
timeZone: "America/New_York",
orgSlug: null,
rescheduleUid: null,
routedTeamMemberIds: null,
skipContactOwner: false,
teamMemberEmail: null,
routingFormResponseId: undefined,
};
(slotsInputService.transformRoutingGetSlotsQuery as jest.Mock).mockResolvedValue(transformedQuery);
(availableSlotsService.getAvailableSlots as jest.Mock).mockRejectedValue(
new Error("Invalid time range given - start time must be before end time")
);
await expect(service.getAvailableSlotsWithRouting(baseInputQuery)).rejects.toThrow(BadRequestException);
await expect(service.getAvailableSlotsWithRouting(baseInputQuery)).rejects.toThrow(
"Invalid time range given - check the 'start' and 'end' query parameters."
);
});
it("should re-throw non-time-range errors without modification", async () => {
const genericError = new Error("Database connection failed");
const transformedQuery = {
isTeamEvent: false,
startTime: "2024-01-15T00:00:00.000Z",
endTime: "2024-01-16T23:59:59.000Z",
eventTypeId: 123,
eventTypeSlug: "test-event",
usernameList: [],
timeZone: "America/New_York",
orgSlug: null,
rescheduleUid: null,
routedTeamMemberIds: null,
skipContactOwner: false,
teamMemberEmail: null,
routingFormResponseId: undefined,
};
(slotsInputService.transformRoutingGetSlotsQuery as jest.Mock).mockResolvedValue(transformedQuery);
(availableSlotsService.getAvailableSlots as jest.Mock).mockRejectedValue(genericError);
await expect(service.getAvailableSlotsWithRouting(baseInputQuery)).rejects.toThrow(genericError);
});
it("should handle non-Error objects thrown by dependencies", async () => {
const transformedQuery = {
isTeamEvent: false,
startTime: "2024-01-15T00:00:00.000Z",
endTime: "2024-01-16T23:59:59.000Z",
eventTypeId: 123,
eventTypeSlug: "test-event",
usernameList: [],
timeZone: "America/New_York",
orgSlug: null,
rescheduleUid: null,
routedTeamMemberIds: null,
skipContactOwner: false,
teamMemberEmail: null,
routingFormResponseId: undefined,
};
(slotsInputService.transformRoutingGetSlotsQuery as jest.Mock).mockResolvedValue(transformedQuery);
const nonErrorObject = { message: "Something went wrong", code: "UNKNOWN_ERROR" };
(availableSlotsService.getAvailableSlots as jest.Mock).mockRejectedValue(nonErrorObject);
await expect(service.getAvailableSlotsWithRouting(baseInputQuery)).rejects.toEqual(nonErrorObject);
});
});
describe("getAvailableSlotsWithRouting - Edge Cases", () => {
it("should handle null and undefined routing parameters", async () => {
const input = {
type: "byEventTypeId" as const,
eventTypeId: 123,
start: "2024-01-15T00:00:00.000Z",
end: "2024-01-16T00:00:00.000Z",
timeZone: "America/New_York",
teamMemberEmail: undefined,
routedTeamMemberIds: undefined,
skipContactOwner: undefined,
routingFormResponseId: undefined,
};
const transformedQuery = {
isTeamEvent: false,
startTime: "2024-01-15T00:00:00.000Z",
endTime: "2024-01-16T23:59:59.000Z",
eventTypeId: 123,
eventTypeSlug: "test-event",
usernameList: [],
timeZone: "America/New_York",
orgSlug: null,
rescheduleUid: null,
teamMemberEmail: null,
routedTeamMemberIds: null,
skipContactOwner: false,
routingFormResponseId: undefined,
};
(slotsInputService.transformRoutingGetSlotsQuery as jest.Mock).mockResolvedValue(transformedQuery);
(availableSlotsService.getAvailableSlots as jest.Mock).mockResolvedValue({ slots: {} });
await service.getAvailableSlotsWithRouting(input);
expect(availableSlotsService.getAvailableSlots).toHaveBeenCalledWith({
input: expect.objectContaining({
teamMemberEmail: null,
routedTeamMemberIds: null,
skipContactOwner: false,
routingFormResponseId: undefined,
}),
ctx: {},
});
});
it("should handle empty routedTeamMemberIds array", async () => {
const input = {
type: "byEventTypeId" as const,
eventTypeId: 123,
start: "2024-01-15T00:00:00.000Z",
end: "2024-01-16T00:00:00.000Z",
timeZone: "America/New_York",
routedTeamMemberIds: [],
};
const transformedQuery = {
isTeamEvent: false,
startTime: "2024-01-15T00:00:00.000Z",
endTime: "2024-01-16T23:59:59.000Z",
eventTypeId: 123,
eventTypeSlug: "test-event",
usernameList: [],
timeZone: "America/New_York",
orgSlug: null,
rescheduleUid: null,
routedTeamMemberIds: [],
skipContactOwner: false,
teamMemberEmail: null,
routingFormResponseId: undefined,
};
(slotsInputService.transformRoutingGetSlotsQuery as jest.Mock).mockResolvedValue(transformedQuery);
(availableSlotsService.getAvailableSlots as jest.Mock).mockResolvedValue({ slots: {} });
await service.getAvailableSlotsWithRouting(input);
expect(availableSlotsService.getAvailableSlots).toHaveBeenCalledWith({
input: expect.objectContaining({
routedTeamMemberIds: [],
}),
ctx: {},
});
});
it("should handle all routing parameters being provided", async () => {
const input = {
type: "byEventTypeId" as const,
eventTypeId: 123,
start: "2024-01-15T00:00:00.000Z",
end: "2024-01-16T00:00:00.000Z",
timeZone: "America/New_York",
routedTeamMemberIds: [1, 2, 3],
skipContactOwner: true,
teamMemberEmail: "team@example.com",
routingFormResponseId: 999,
};
const transformedQuery = {
isTeamEvent: false,
startTime: "2024-01-15T00:00:00.000Z",
endTime: "2024-01-16T23:59:59.000Z",
eventTypeId: 123,
eventTypeSlug: "test-event",
usernameList: [],
timeZone: "America/New_York",
orgSlug: null,
rescheduleUid: null,
routedTeamMemberIds: [1, 2, 3],
skipContactOwner: true,
teamMemberEmail: "team@example.com",
routingFormResponseId: 999,
};
(slotsInputService.transformRoutingGetSlotsQuery as jest.Mock).mockResolvedValue(transformedQuery);
(availableSlotsService.getAvailableSlots as jest.Mock).mockResolvedValue({ slots: {} });
await service.getAvailableSlotsWithRouting(input);
expect(availableSlotsService.getAvailableSlots).toHaveBeenCalledWith({
input: expect.objectContaining({
routedTeamMemberIds: [1, 2, 3],
skipContactOwner: true,
teamMemberEmail: "team@example.com",
routingFormResponseId: 999,
}),
ctx: {},
});
});
});
});
@@ -3,7 +3,11 @@ import { AvailableSlotsService } from "@/lib/services/available-slots.service";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { MembershipsService } from "@/modules/memberships/services/memberships.service";
import { TimeSlots } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
import { SlotsInputService_2024_09_04 } from "@/modules/slots/slots-2024-09-04/services/slots-input.service";
import {
SlotsInputService_2024_09_04,
InternalGetSlotsQuery,
InternalGetSlotsQueryWithRouting,
} from "@/modules/slots/slots-2024-09-04/services/slots-input.service";
import { SlotsOutputService_2024_09_04 } from "@/modules/slots/slots-2024-09-04/services/slots-output.service";
import { SlotsRepository_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.repository";
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
@@ -18,8 +22,13 @@ import {
import { DateTime } from "luxon";
import { z } from "zod";
import { GetSlotsInput_2024_09_04, ReserveSlotInput_2024_09_04 } from "@calcom/platform-types";
import { Booking, EventType } from "@calcom/prisma/client";
import { SlotFormat } from "@calcom/platform-enums";
import {
GetSlotsInput_2024_09_04,
GetSlotsInputWithRouting_2024_09_04,
ReserveSlotInput_2024_09_04,
} from "@calcom/platform-types";
import { EventType } from "@calcom/prisma/client";
const eventTypeMetadataSchema = z
.object({
@@ -29,6 +38,7 @@ const eventTypeMetadataSchema = z
const DEFAULT_RESERVATION_DURATION = 5;
type InternalSlotsQuery = InternalGetSlotsQuery | InternalGetSlotsQueryWithRouting;
@Injectable()
export class SlotsService_2024_09_04 {
constructor(
@@ -42,21 +52,18 @@ export class SlotsService_2024_09_04 {
private readonly availableSlotsService: AvailableSlotsService
) {}
async getAvailableSlots(query: GetSlotsInput_2024_09_04) {
private async fetchAndFormatSlots(queryTransformed: InternalSlotsQuery, format?: SlotFormat) {
try {
const queryTransformed = await this.slotsInputService.transformGetSlotsQuery(query);
const availableSlots: TimeSlots = await this.availableSlotsService.getAvailableSlots({
input: {
...queryTransformed,
routingFormResponseId: queryTransformed.routingFormResponseId ?? undefined,
},
input: queryTransformed,
ctx: {},
});
const formatted = await this.slotsOutputService.getAvailableSlots(
availableSlots,
queryTransformed.eventTypeId,
queryTransformed.duration,
query.format,
format,
queryTransformed.timeZone
);
@@ -73,6 +80,16 @@ export class SlotsService_2024_09_04 {
}
}
async getAvailableSlots(query: GetSlotsInput_2024_09_04) {
const queryTransformed = await this.slotsInputService.transformGetSlotsQuery(query);
return this.fetchAndFormatSlots(queryTransformed, query.format);
}
async getAvailableSlotsWithRouting(query: GetSlotsInputWithRouting_2024_09_04) {
const queryTransformed = await this.slotsInputService.transformRoutingGetSlotsQuery(query);
return this.fetchAndFormatSlots(queryTransformed, query.format);
}
async reserveSlot(input: ReserveSlotInput_2024_09_04, authUserId?: number) {
if (input.reservationDuration && !authUserId) {
throw new UnauthorizedException(
@@ -61,6 +61,48 @@ describe("Organizations Event Types Endpoints", () => {
let collectiveEventType: TeamEventTypeOutput_2024_06_14;
let managedEventType: TeamEventTypeOutput_2024_06_14;
async function ensureManagedEventType(): Promise<TeamEventTypeOutput_2024_06_14> {
if (!managedEventType) {
const setupBody: CreateTeamEventTypeInput_2024_06_14 = {
title: `teams-event-types-managed-${randomString()}`,
slug: `teams-event-types-managed-${randomString()}`,
description: "Our team will review your codebase.",
lengthInMinutes: 60,
locations: [
{
type: "integration",
integration: "cal-video",
},
],
schedulingType: "MANAGED",
hosts: [
{
userId: teamMember1.id,
mandatory: true,
priority: "high",
},
{
userId: teamMember2.id,
mandatory: false,
priority: "low",
},
],
};
const setupResponse = await request(app.getHttpServer())
.post(`/v2/teams/${team.id}/event-types`)
.send(setupBody)
.expect(201);
const setupResponseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14[]> = setupResponse.body;
const responseTeamEvent = setupResponseBody.data.find((event) => event.teamId === team.id);
if (responseTeamEvent) {
managedEventType = responseTeamEvent;
}
}
return managedEventType;
}
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
@@ -396,29 +438,36 @@ describe("Organizations Event Types Endpoints", () => {
const teammate2EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teamMember2.id);
const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(team.id);
expect(teammate1EventTypes.length).toEqual(1);
expect(teammate1EventTypes[0].title).toEqual(body.title);
expect(teammate2EventTypes.length).toEqual(1);
expect(teamEventTypes.filter((eventType) => eventType.schedulingType === "MANAGED").length).toEqual(
1
const teammate1ManagedEvents = teammate1EventTypes.filter((et) => et.title === body.title);
const teammate2ManagedEvents = teammate2EventTypes.filter((et) => et.title === body.title);
const managedTeamEvents = teamEventTypes.filter(
(eventType) => eventType.schedulingType === "MANAGED" && eventType.title === body.title
);
expect(teammate1ManagedEvents.length).toEqual(1);
expect(teammate1ManagedEvents[0].title).toEqual(body.title);
expect(teammate2ManagedEvents.length).toEqual(1);
expect(managedTeamEvents.length).toEqual(1);
const responseTeamEvent = responseBody.data.find((event) => event.teamId === team.id);
expect(responseTeamEvent).toBeDefined();
expect(responseTeamEvent?.hosts).toEqual([
{
userId: teamMember1.id,
name: teamMember1.name,
username: teamMember1.username,
avatarUrl: teamMember1.avatarUrl,
},
{
userId: teamMember2.id,
name: teamMember2.name,
username: teamMember2.username,
avatarUrl: teamMember2.avatarUrl,
},
]);
expect(responseTeamEvent?.hosts).toHaveLength(2);
expect(responseTeamEvent?.hosts).toEqual(
expect.arrayContaining([
{
userId: teamMember1.id,
name: teamMember1.name,
username: teamMember1.username,
avatarUrl: teamMember1.avatarUrl,
},
{
userId: teamMember2.id,
name: teamMember2.name,
username: teamMember2.username,
avatarUrl: teamMember2.avatarUrl,
},
])
);
if (!responseTeamEvent) {
throw new Error("Team event not found");
@@ -437,6 +486,8 @@ describe("Organizations Event Types Endpoints", () => {
});
it("managed team event types should be returned when fetching event types of users", async () => {
await ensureManagedEventType();
return request(app.getHttpServer())
.get(`/v2/event-types?username=${teamMember1.username}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
@@ -446,16 +497,19 @@ describe("Organizations Event Types Endpoints", () => {
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data.length).toEqual(1);
expect(data[0].slug).toEqual(managedEventType.slug);
expect(data[0].ownerId).toEqual(teamMember1.id);
expect(data[0].id).not.toEqual(managedEventType.id);
const managedEvents = data.filter((event) => event.slug === managedEventType.slug);
expect(managedEvents.length).toEqual(1);
expect(managedEvents[0].slug).toEqual(managedEventType.slug);
expect(managedEvents[0].ownerId).toEqual(teamMember1.id);
expect(managedEvents[0].id).not.toEqual(managedEventType.id);
});
});
it("managed team event type should be returned when fetching event types of users", async () => {
await ensureManagedEventType();
return request(app.getHttpServer())
.get(`/v2/event-types?username=${teamMember1.username}&eventSlug=${managedEventType.slug}`)
.get(`/v2/event-types?username=${teamMember1.username}&eventSlug=${managedEventType?.slug}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.expect(200)
.then(async (response) => {
@@ -463,10 +517,11 @@ describe("Organizations Event Types Endpoints", () => {
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data.length).toEqual(1);
expect(data[0].slug).toEqual(managedEventType.slug);
expect(data[0].ownerId).toEqual(teamMember1.id);
expect(data[0].id).not.toEqual(managedEventType.id);
const managedEventTypes = data.filter((et) => et.slug === managedEventType?.slug);
expect(managedEventTypes.length).toEqual(1);
expect(managedEventTypes[0].slug).toEqual(managedEventType?.slug);
expect(managedEventTypes[0].ownerId).toEqual(teamMember1.id);
expect(managedEventTypes[0].id).not.toEqual(managedEventType?.id);
});
});
@@ -570,6 +625,8 @@ describe("Organizations Event Types Endpoints", () => {
});
it("should update managed event-type", async () => {
await ensureManagedEventType();
const newTitle = `teams-event-types-managed-updated-${randomString()}`;
const newHosts: UpdateTeamEventTypeInput_2024_06_14["hosts"] = [
{
@@ -585,7 +642,7 @@ describe("Organizations Event Types Endpoints", () => {
};
return request(app.getHttpServer())
.patch(`/v2/teams/${team.id}/event-types/${managedEventType.id}`)
.patch(`/v2/teams/${team.id}/event-types/${managedEventType?.id}`)
.send(body)
.expect(200)
.then(async (response) => {
@@ -629,12 +686,14 @@ describe("Organizations Event Types Endpoints", () => {
});
it("should assign all members to managed event-type", async () => {
await ensureManagedEventType();
const body: UpdateTeamEventTypeInput_2024_06_14 = {
assignAllTeamMembers: true,
};
return request(app.getHttpServer())
.patch(`/v2/teams/${team.id}/event-types/${managedEventType.id}`)
.patch(`/v2/teams/${team.id}/event-types/${managedEventType?.id}`)
.send(body)
.expect(200)
.then(async (response) => {
@@ -693,8 +752,10 @@ describe("Organizations Event Types Endpoints", () => {
});
it("should delete managed event-type", async () => {
await ensureManagedEventType();
return request(app.getHttpServer())
.delete(`/v2/teams/${team.id}/event-types/${managedEventType.id}`)
.delete(`/v2/teams/${team.id}/event-types/${managedEventType?.id}`)
.expect(200);
});
@@ -1,10 +1,12 @@
import { JwtModule } from "@/modules/jwt/jwt.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { TokensService } from "@/modules/tokens/tokens.service";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule],
providers: [TokensRepository],
exports: [TokensRepository],
imports: [PrismaModule, JwtModule],
providers: [TokensRepository, TokensService],
exports: [TokensRepository, TokensService],
})
export class TokensModule {}
@@ -0,0 +1,38 @@
import { Injectable, InternalServerErrorException } from "@nestjs/common";
import { Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import * as jwt from "jsonwebtoken";
type OAuthTokenPayload = {
userId?: number;
teamId?: number;
scope: string[];
token_type: string;
};
@Injectable()
export class TokensService {
private readonly logger = new Logger("TokensService");
constructor(private readonly config: ConfigService) {}
getDecodedThirdPartyAccessToken(token: string): OAuthTokenPayload | null {
const encryptionKey = this.config.get<string>("CALENDSO_ENCRYPTION_KEY");
if (!encryptionKey) {
throw new InternalServerErrorException("CALENDSO_ENCRYPTION_KEY environment variable is not set.");
}
let decodedToken: OAuthTokenPayload;
try {
decodedToken = jwt.verify(token, encryptionKey) as OAuthTokenPayload;
} catch (e) {
return null;
}
if (!decodedToken || decodedToken.token_type !== "Access Token") {
return null;
}
return decodedToken;
}
}
@@ -1,4 +1,4 @@
import { ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsBoolean,
@@ -74,6 +74,24 @@ export type TriggerDtoType =
| OnCancelTriggerDto
| OnAfterCalVideoGuestsNoShowTriggerDto
| OnAfterCalVideoHostsNoShowTriggerDto;
@ApiExtraModels(
OnBeforeEventTriggerDto,
OnAfterEventTriggerDto,
OnCancelTriggerDto,
OnCreationTriggerDto,
OnRescheduleTriggerDto,
OnAfterCalVideoGuestsNoShowTriggerDto,
OnAfterCalVideoHostsNoShowTriggerDto,
WorkflowEmailAddressStepDto,
WorkflowEmailAttendeeStepDto,
WorkflowEmailHostStepDto,
WorkflowPhoneWhatsAppAttendeeStepDto,
WorkflowPhoneWhatsAppNumberStepDto,
WorkflowPhoneNumberStepDto,
WorkflowPhoneAttendeeStepDto,
BaseWorkflowTriggerDto
)
export class CreateWorkflowDto {
@ApiProperty({ description: "Name of the workflow", example: "Platform Test Workflow" })
@IsString()
@@ -86,17 +104,15 @@ export class CreateWorkflowDto {
@ApiProperty({
description: "Trigger configuration for the workflow",
items: {
oneOf: [
{ $ref: getSchemaPath(OnBeforeEventTriggerDto) },
{ $ref: getSchemaPath(OnAfterEventTriggerDto) },
{ $ref: getSchemaPath(OnCancelTriggerDto) },
{ $ref: getSchemaPath(OnCreationTriggerDto) },
{ $ref: getSchemaPath(OnRescheduleTriggerDto) },
{ $ref: getSchemaPath(OnAfterCalVideoGuestsNoShowTriggerDto) },
{ $ref: getSchemaPath(OnAfterCalVideoHostsNoShowTriggerDto) },
],
},
oneOf: [
{ $ref: getSchemaPath(OnBeforeEventTriggerDto) },
{ $ref: getSchemaPath(OnAfterEventTriggerDto) },
{ $ref: getSchemaPath(OnCancelTriggerDto) },
{ $ref: getSchemaPath(OnCreationTriggerDto) },
{ $ref: getSchemaPath(OnRescheduleTriggerDto) },
{ $ref: getSchemaPath(OnAfterCalVideoGuestsNoShowTriggerDto) },
{ $ref: getSchemaPath(OnAfterCalVideoHostsNoShowTriggerDto) },
],
})
@ValidateNested()
@Type(() => BaseWorkflowTriggerDto, {
@@ -124,17 +140,16 @@ export class CreateWorkflowDto {
@ApiProperty({
description: "Steps to execute as part of the workflow",
items: {
oneOf: [
{ $ref: getSchemaPath(WorkflowEmailAddressStepDto) },
{ $ref: getSchemaPath(WorkflowEmailAttendeeStepDto) },
{ $ref: getSchemaPath(WorkflowEmailHostStepDto) },
{ $ref: getSchemaPath(WorkflowPhoneWhatsAppAttendeeStepDto) },
{ $ref: getSchemaPath(WorkflowPhoneWhatsAppNumberStepDto) },
{ $ref: getSchemaPath(WorkflowPhoneNumberStepDto) },
{ $ref: getSchemaPath(WorkflowPhoneAttendeeStepDto) },
],
},
oneOf: [
{ $ref: getSchemaPath(WorkflowEmailAddressStepDto) },
{ $ref: getSchemaPath(WorkflowEmailAttendeeStepDto) },
{ $ref: getSchemaPath(WorkflowEmailHostStepDto) },
{ $ref: getSchemaPath(WorkflowPhoneWhatsAppAttendeeStepDto) },
{ $ref: getSchemaPath(WorkflowPhoneWhatsAppNumberStepDto) },
{ $ref: getSchemaPath(WorkflowPhoneNumberStepDto) },
{ $ref: getSchemaPath(WorkflowPhoneAttendeeStepDto) },
],
type: "array",
})
@ValidateNested({ each: true })
@ArrayMinSize(1, { message: "Your workflow must contain at least one step." })
@@ -1,4 +1,4 @@
import { ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsNumber, IsString, IsOptional, ValidateNested, ArrayMinSize } from "class-validator";
@@ -113,6 +113,23 @@ export class UpdateWhatsAppAttendeePhoneWorkflowStepDto extends WorkflowPhoneWha
id?: number;
}
@ApiExtraModels(
OnBeforeEventTriggerDto,
OnAfterEventTriggerDto,
OnCancelTriggerDto,
OnCreationTriggerDto,
OnRescheduleTriggerDto,
OnAfterCalVideoGuestsNoShowTriggerDto,
OnAfterCalVideoHostsNoShowTriggerDto,
UpdateEmailAddressWorkflowStepDto,
UpdateEmailAttendeeWorkflowStepDto,
UpdateEmailHostWorkflowStepDto,
UpdatePhoneAttendeeWorkflowStepDto,
UpdatePhoneWhatsAppNumberWorkflowStepDto,
UpdateWhatsAppAttendeePhoneWorkflowStepDto,
UpdatePhoneNumberWorkflowStepDto,
BaseWorkflowTriggerDto
)
export class UpdateWorkflowDto {
@ApiPropertyOptional({ description: "Name of the workflow", example: "Platform Test Workflow" })
@IsString()
@@ -128,6 +145,20 @@ export class UpdateWorkflowDto {
@IsOptional()
activation?: WorkflowActivationDto;
@ApiPropertyOptional({
description: "Trigger configuration for the workflow",
oneOf: [
{ $ref: getSchemaPath(OnBeforeEventTriggerDto) },
{ $ref: getSchemaPath(OnAfterEventTriggerDto) },
{ $ref: getSchemaPath(OnCancelTriggerDto) },
{ $ref: getSchemaPath(OnCreationTriggerDto) },
{ $ref: getSchemaPath(OnRescheduleTriggerDto) },
{ $ref: getSchemaPath(OnAfterCalVideoGuestsNoShowTriggerDto) },
{ $ref: getSchemaPath(OnAfterCalVideoHostsNoShowTriggerDto) },
],
})
@IsOptional()
@ValidateNested()
@Type(() => BaseWorkflowTriggerDto, {
discriminator: {
property: "type",
@@ -142,7 +173,7 @@ export class UpdateWorkflowDto {
],
},
})
trigger!:
trigger?:
| OnAfterEventTriggerDto
| OnBeforeEventTriggerDto
| OnCreationTriggerDto
@@ -150,19 +181,19 @@ export class UpdateWorkflowDto {
| OnCancelTriggerDto
| OnAfterCalVideoGuestsNoShowTriggerDto
| OnAfterCalVideoHostsNoShowTriggerDto;
@ApiPropertyOptional({
description: "Steps to execute as part of the workflow",
items: {
oneOf: [
{ $ref: getSchemaPath(UpdateEmailAddressWorkflowStepDto) },
{ $ref: getSchemaPath(UpdateEmailAttendeeWorkflowStepDto) },
{ $ref: getSchemaPath(UpdateEmailHostWorkflowStepDto) },
{ $ref: getSchemaPath(UpdatePhoneAttendeeWorkflowStepDto) },
{ $ref: getSchemaPath(UpdatePhoneWhatsAppNumberWorkflowStepDto) },
{ $ref: getSchemaPath(UpdateWhatsAppAttendeePhoneWorkflowStepDto) },
{ $ref: getSchemaPath(UpdatePhoneNumberWorkflowStepDto) },
],
},
oneOf: [
{ $ref: getSchemaPath(UpdateEmailAddressWorkflowStepDto) },
{ $ref: getSchemaPath(UpdateEmailAttendeeWorkflowStepDto) },
{ $ref: getSchemaPath(UpdateEmailHostWorkflowStepDto) },
{ $ref: getSchemaPath(UpdatePhoneAttendeeWorkflowStepDto) },
{ $ref: getSchemaPath(UpdatePhoneWhatsAppNumberWorkflowStepDto) },
{ $ref: getSchemaPath(UpdateWhatsAppAttendeePhoneWorkflowStepDto) },
{ $ref: getSchemaPath(UpdatePhoneNumberWorkflowStepDto) },
],
type: "array",
})
@ValidateNested({ each: true })
@ArrayMinSize(1, { message: "Your workflow must contain at least one step." })
File diff suppressed because it is too large Load Diff
+1
View File
@@ -35,4 +35,5 @@ process.env = {
VAPID_PRIVATE_KEY: "6cJtkASCar5sZWguIAW7OjvyixpBw9p8zL8WDDwk9Jk",
CALENDSO_ENCRYPTION_KEY: "22gfxhWUlcKliUeXcu8xNah2+HP/29ZX",
INTEGRATION_TEST_MODE: "true",
e2e: "true",
};
@@ -0,0 +1,27 @@
"use server";
import { revalidateTag } from "next/cache";
// Coupled with `getCachedTeamData` in `queries.ts`
export async function revalidateTeamDataCache({
teamSlug,
orgSlug,
}: {
teamSlug: string;
orgSlug: string | null;
}) {
revalidateTag(`team:${orgSlug ? `${orgSlug}:` : ""}${teamSlug}`);
}
// Coupled with `getCachedTeamEventType` in `queries.ts`
export async function revalidateTeamEventTypeCache({
teamSlug,
meetingSlug,
orgSlug,
}: {
teamSlug: string;
meetingSlug: string;
orgSlug: string | null;
}) {
revalidateTag(`event-type:${orgSlug ? `${orgSlug}:` : ""}${teamSlug}:${meetingSlug}`);
}
@@ -1,10 +1,11 @@
import { CustomI18nProvider } from "app/CustomI18nProvider";
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import type { PageProps, SearchParams } from "app/_types";
import { generateMeetingMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { loadTranslations } from "@calcom/lib/server/i18n";
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
@@ -13,7 +14,20 @@ import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
import LegacyPage from "~/team/type-view";
import type { PageProps as LegacyPageProps } from "~/team/type-view";
import CachedTeamBooker, { generateMetadata as generateCachedMetadata } from "./pageWithCachedData";
async function isCachedTeamBookingEnabled(searchParams: SearchParams): Promise<boolean> {
const featuresRepository = new FeaturesRepository();
const isGloballyEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
"team-booking-page-cache"
);
return isGloballyEnabled && searchParams.experimentalTeamBookingPageCache === "true";
}
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
if (await isCachedTeamBookingEnabled(await searchParams)) {
return await generateCachedMetadata({ params, searchParams });
}
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
const props = await getData(legacyCtx);
const { booking, isSEOIndexable, eventData, isBrandingHidden } = props;
@@ -53,6 +67,10 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const getData = withAppDirSsr<LegacyPageProps>(getServerSideProps);
const ServerPage = async ({ params, searchParams }: PageProps) => {
if (await isCachedTeamBookingEnabled(await searchParams)) {
return await CachedTeamBooker({ params, searchParams });
}
const props = await getData(
buildLegacyCtx(await headers(), await cookies(), await params, await searchParams)
);
@@ -70,4 +88,5 @@ const ServerPage = async ({ params, searchParams }: PageProps) => {
return <LegacyPage {...props} />;
};
export default ServerPage;
@@ -0,0 +1,218 @@
import { CustomI18nProvider } from "app/CustomI18nProvider";
import type { PageProps, Params } from "app/_types";
import { generateMeetingMetadata } from "app/_utils";
import { headers, cookies } from "next/headers";
import { notFound, redirect } from "next/navigation";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getBookingForReschedule, type GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { getOrgFullOrigin, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { getOrganizationSEOSettings } from "@calcom/features/ee/organizations/lib/orgSettings";
import type { TeamData } from "@calcom/features/ee/teams/lib/getTeamData";
import { shouldHideBrandingForTeamEvent } from "@calcom/lib/hideBranding";
import { loadTranslations } from "@calcom/lib/server/i18n";
import slugify from "@calcom/lib/slugify";
import { BookingStatus, RedirectType } from "@calcom/prisma/enums";
import { buildLegacyCtx, buildLegacyRequest } from "@lib/buildLegacyCtx";
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
import CachedClientView, { type TeamBookingPageProps } from "~/team/type-view-cached";
import { getCachedTeamData, getEnrichedEventType, getCRMData, shouldUseApiV2ForTeamSlots } from "./queries";
const paramsSchema = z.object({
slug: z.string().transform((s) => slugify(s)),
type: z.string().transform((s) => slugify(s)),
});
const _getTeamMetadataForBooking = (teamData: NonNullable<TeamData>, eventTypeId: number) => {
const organizationSettings = getOrganizationSEOSettings(teamData);
const allowSEOIndexing = organizationSettings?.allowSEOIndexing ?? false;
return {
orgBannerUrl: teamData.parent?.bannerUrl ?? "",
hideBranding: shouldHideBrandingForTeamEvent({
eventTypeId,
team: teamData,
}),
isSEOIndexable: allowSEOIndexing,
};
};
async function _getOrgContext(params: Params) {
const result = paramsSchema.safeParse({
slug: params?.slug,
type: params?.type,
});
if (!result.success) return notFound(); // should never happen
const { slug: teamSlug, type: meetingSlug } = result.data;
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(
buildLegacyRequest(await headers(), await cookies()),
params?.orgSlug ?? undefined
);
return {
currentOrgDomain,
isValidOrgDomain,
teamSlug,
meetingSlug,
};
}
const _getMultipleDurationValue = (
multipleDurationConfig: number[] | undefined,
queryDuration: string | string[] | null | undefined,
defaultValue: number
) => {
if (!multipleDurationConfig) return null;
if (multipleDurationConfig.includes(Number(queryDuration))) return Number(queryDuration);
return defaultValue;
};
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const { currentOrgDomain, isValidOrgDomain, teamSlug, meetingSlug } = await _getOrgContext(await params);
const teamData = await getCachedTeamData(teamSlug, currentOrgDomain);
if (!teamData) return {}; // should never happen
const enrichedEventType = await getEnrichedEventType({
teamSlug,
meetingSlug,
orgSlug: isValidOrgDomain ? currentOrgDomain : null,
fromRedirectOfNonOrgLink: (await searchParams).orgRedirection === "true",
});
if (!enrichedEventType) return {}; // should never happen
const title = enrichedEventType.title;
const profileName = enrichedEventType.profile.name ?? "";
const profileImage = enrichedEventType.profile.image;
const meeting = {
title,
profile: { name: profileName, image: profileImage },
users: [
...(enrichedEventType?.subsetOfUsers || []).map((user) => ({
name: `${user.name}`,
username: `${user.username}`,
})),
],
};
const { hideBranding, isSEOIndexable } = _getTeamMetadataForBooking(teamData, enrichedEventType.id);
const metadata = await generateMeetingMetadata(
meeting,
() => `${title} | ${profileName}`,
() => title,
hideBranding,
getOrgFullOrigin(enrichedEventType.entity.orgSlug ?? null),
`/team/${teamSlug}/${meetingSlug}`
);
return {
...metadata,
robots: {
follow: !(enrichedEventType.hidden || !isSEOIndexable),
index: !(enrichedEventType.hidden || !isSEOIndexable),
},
};
};
const CachedTeamBooker = async ({ params, searchParams }: PageProps) => {
const { currentOrgDomain, isValidOrgDomain, teamSlug, meetingSlug } = await _getOrgContext(await params);
const isOrgContext = currentOrgDomain && isValidOrgDomain;
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
// Handle org redirects for non-org contexts
if (!isOrgContext) {
const redirectResult = await getTemporaryOrgRedirect({
slugs: teamSlug,
redirectType: RedirectType.Team,
eventTypeSlug: meetingSlug,
currentQuery: legacyCtx.query,
});
if (redirectResult) return redirect(redirectResult.redirect.destination);
}
const teamData = await getCachedTeamData(teamSlug, currentOrgDomain);
if (!teamData) return notFound();
const enrichedEventType = await getEnrichedEventType({
teamSlug,
meetingSlug,
orgSlug: isValidOrgDomain ? currentOrgDomain : null,
fromRedirectOfNonOrgLink: legacyCtx.query.orgRedirection === "true",
});
if (!enrichedEventType) return notFound();
// Handle rescheduling
const { rescheduleUid } = legacyCtx.query;
let bookingForReschedule: GetBookingType | null = null;
if (rescheduleUid) {
const session = await getServerSession({ req: legacyCtx.req });
bookingForReschedule = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
if (enrichedEventType.disableRescheduling) return redirect(`/booking/${rescheduleUid}`);
if (
bookingForReschedule?.status === BookingStatus.CANCELLED &&
legacyCtx.query.allowRescheduleForCancelledBooking !== "true" &&
!enrichedEventType.allowReschedulingCancelledBookings
) {
// redirecting to the same booking page without `rescheduleUid` search param
return redirect(`/team/${teamSlug}/${meetingSlug}`);
}
}
const [crmData, useApiV2] = await Promise.all([
getCRMData(legacyCtx.query, {
id: enrichedEventType.id,
isInstantEvent: enrichedEventType.isInstantEvent,
schedulingType: enrichedEventType.schedulingType,
metadata: enrichedEventType.metadata,
length: enrichedEventType.length,
}),
shouldUseApiV2ForTeamSlots(teamData.id),
]);
const props: TeamBookingPageProps = {
..._getTeamMetadataForBooking(teamData, enrichedEventType.id),
...crmData,
useApiV2,
isInstantMeeting: legacyCtx.query.isInstantMeeting === "true",
eventSlug: meetingSlug,
username: teamSlug,
eventData: {
...enrichedEventType,
},
entity: { ...enrichedEventType.entity },
bookingData: bookingForReschedule,
isTeamEvent: true,
durationConfig: enrichedEventType.metadata?.multipleDuration,
duration: _getMultipleDurationValue(
enrichedEventType.metadata?.multipleDuration,
legacyCtx.query.duration,
enrichedEventType.length
),
};
const Booker = <CachedClientView {...props} />;
const eventLocale = enrichedEventType.interfaceLanguage;
if (eventLocale) {
const ns = "common";
const translations = await loadTranslations(eventLocale, ns);
return (
<CustomI18nProvider translations={translations} locale={eventLocale} ns={ns}>
{Booker}
</CustomI18nProvider>
);
}
return Booker;
};
export default CachedTeamBooker;
@@ -0,0 +1,155 @@
import type { Prisma } from "@prisma/client";
import type { GetServerSidePropsContext } from "next";
import { unstable_cache } from "next/cache";
import { getTeamData } from "@calcom/features/ee/teams/lib/getTeamData";
import {
getEventTypeHosts,
getProfileFromEvent,
getUsersFromEvent,
processEventDataShared,
} from "@calcom/features/eventtypes/lib/getPublicEvent";
import { getTeamEventType } from "@calcom/features/eventtypes/lib/getTeamEventType";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { NEXTJS_CACHE_TTL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { prisma } from "@calcom/prisma";
import type { SchedulingType } from "@calcom/prisma/enums";
import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/prisma/zod-utils";
export async function getCachedTeamData(teamSlug: string, orgSlug: string | null) {
return unstable_cache(async () => getTeamData(teamSlug, orgSlug), ["team-data", teamSlug, orgSlug ?? ""], {
revalidate: NEXTJS_CACHE_TTL,
tags: [`team:${orgSlug ? `${orgSlug}:` : ""}${teamSlug}`],
})();
}
export async function getCachedTeamEventType(teamSlug: string, meetingSlug: string, orgSlug: string | null) {
return unstable_cache(
async () => getTeamEventType(teamSlug, meetingSlug, orgSlug),
["team-event-type", teamSlug, meetingSlug, orgSlug ?? ""],
{
revalidate: NEXTJS_CACHE_TTL,
tags: [`event-type:${orgSlug ? `${orgSlug}:` : ""}${teamSlug}:${meetingSlug}`],
}
)();
}
export async function getEnrichedEventType({
teamSlug,
meetingSlug,
orgSlug,
fromRedirectOfNonOrgLink,
}: {
teamSlug: string;
meetingSlug: string;
orgSlug: string | null;
fromRedirectOfNonOrgLink: boolean;
}) {
const [teamData, eventType] = await Promise.all([
getCachedTeamData(teamSlug, orgSlug),
getCachedTeamEventType(teamSlug, meetingSlug, orgSlug),
]);
if (!teamData || !eventType) {
return null;
}
const { subsetOfHosts, hosts } = await getEventTypeHosts({
hosts: eventType.hosts,
prisma,
});
const enrichedOwner = eventType.owner
? await new UserRepository(prisma).enrichUserWithItsProfile({
user: eventType.owner,
})
: null;
const users =
(await getUsersFromEvent({ ...eventType, owner: enrichedOwner, subsetOfHosts, hosts }, prisma)) ?? [];
const name = teamData.parent?.name ?? teamData.name ?? null;
const isUnpublished = teamData.parent ? !teamData.parent.slug : !teamData.slug;
const eventMetaData = eventTypeMetaDataSchemaWithTypedApps.parse(eventType.metadata);
const eventDataShared = await processEventDataShared({
eventData: eventType,
metadata: eventMetaData,
prisma,
});
return {
...eventDataShared,
owner: enrichedOwner,
subsetOfHosts,
hosts,
profile: getProfileFromEvent({ ...eventType, owner: enrichedOwner, subsetOfHosts, hosts }),
subsetOfUsers: users,
users,
entity: {
fromRedirectOfNonOrgLink,
considerUnpublished: isUnpublished && !fromRedirectOfNonOrgLink,
orgSlug,
teamSlug: teamData.slug ?? null,
name,
hideProfileLink: false,
logoUrl: teamData.parent
? getPlaceholderAvatar(teamData.parent.logoUrl, teamData.parent.name)
: getPlaceholderAvatar(teamData.logoUrl, teamData.name),
},
};
}
export async function shouldUseApiV2ForTeamSlots(teamId: number): Promise<boolean> {
const featureRepo = new FeaturesRepository();
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(teamId, "use-api-v2-for-team-slots");
const useApiV2 = teamHasApiV2Route && Boolean(process.env.NEXT_PUBLIC_API_V2_URL);
return useApiV2;
}
export async function getCRMData(
query: GetServerSidePropsContext["query"],
eventData: {
id: number;
isInstantEvent: boolean;
schedulingType: SchedulingType | null;
metadata: Prisma.JsonValue | null;
length: number;
}
) {
const crmContactOwnerEmail = query["cal.crmContactOwnerEmail"];
const crmContactOwnerRecordType = query["cal.crmContactOwnerRecordType"];
const crmAppSlugParam = query["cal.crmAppSlug"];
let teamMemberEmail = Array.isArray(crmContactOwnerEmail) ? crmContactOwnerEmail[0] : crmContactOwnerEmail;
let crmOwnerRecordType = Array.isArray(crmContactOwnerRecordType)
? crmContactOwnerRecordType[0]
: crmContactOwnerRecordType;
let crmAppSlug = Array.isArray(crmAppSlugParam) ? crmAppSlugParam[0] : crmAppSlugParam;
if (!teamMemberEmail || !crmOwnerRecordType || !crmAppSlug) {
const { getTeamMemberEmailForResponseOrContactUsingUrlQuery } = await import(
"@calcom/lib/server/getTeamMemberEmailFromCrm"
);
const {
email,
recordType,
crmAppSlug: crmAppSlugQuery,
} = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query,
eventData,
});
teamMemberEmail = email ?? undefined;
crmOwnerRecordType = recordType ?? undefined;
crmAppSlug = crmAppSlugQuery ?? undefined;
}
return {
teamMemberEmail,
crmOwnerRecordType,
crmAppSlug,
};
}
@@ -3,11 +3,15 @@ import type { PageProps, ReadonlyHeaders, ReadonlyRequestCookies } from "app/_ty
import { _generateMetadata, getTranslate } from "app/_utils";
import { unstable_cache } from "next/cache";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
import { availabilityRouter } from "@calcom/trpc/server/routers/viewer/availability/_router";
import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { AvailabilityList, AvailabilityCTA } from "~/availability/availability-view";
@@ -23,15 +27,6 @@ export const generateMetadata = async () => {
);
};
const getCachedMe = unstable_cache(
async (headers: ReadonlyHeaders, cookies: ReadonlyRequestCookies) => {
const meCaller = await createRouterCaller(meRouter, await getTRPCContext(headers, cookies));
return await meCaller.get();
},
["viewer.me.get"],
{ revalidate: 3600 } // Cache for 1 hour
);
const getCachedAvailabilities = unstable_cache(
async (headers: ReadonlyHeaders, cookies: ReadonlyRequestCookies) => {
const availabilityCaller = await createRouterCaller(
@@ -49,11 +44,12 @@ const Page = async ({ searchParams: _searchParams }: PageProps) => {
const t = await getTranslate();
const _headers = await headers();
const _cookies = await cookies();
const session = await getServerSession({ req: buildLegacyRequest(_headers, _cookies) });
if (!session?.user?.id) {
return redirect("/auth/login");
}
const [me, cachedAvailabilities] = await Promise.all([
getCachedMe(_headers, _cookies),
getCachedAvailabilities(_headers, _cookies),
]);
const cachedAvailabilities = await getCachedAvailabilities(_headers, _cookies);
// Transform the data to ensure startTime, endTime, and date are Date objects
// This is because the data is cached and as a result the data is converted to a string
@@ -70,13 +66,14 @@ const Page = async ({ searchParams: _searchParams }: PageProps) => {
})),
};
const organizationId = me.organization?.id ?? me.profiles[0]?.organizationId;
const organizationId = session?.user?.profile?.organizationId ?? session?.user.org?.id;
const isOrgPrivate = organizationId
? await OrganizationRepository.checkIfPrivate({
orgId: organizationId,
})
: false;
const canViewTeamAvailability = me.organization?.isOrgAdmin || !isOrgPrivate;
const isOrgAdminOrOwner = checkAdminOrOwner(session?.user?.org?.role);
const canViewTeamAvailability = isOrgAdminOrOwner || !isOrgPrivate;
return (
<ShellMainAppDir
@@ -91,9 +88,9 @@ const Page = async ({ searchParams: _searchParams }: PageProps) => {
/>
}>
{searchParams?.type === "team" && canViewTeamAvailability ? (
<AvailabilitySliderTable userTimeFormat={me?.timeFormat ?? null} isOrg={!!organizationId} />
<AvailabilitySliderTable isOrg={!!organizationId} />
) : (
<AvailabilityList availabilities={availabilities ?? { schedules: [] }} me={me} />
<AvailabilityList availabilities={availabilities ?? { schedules: [] }} />
)}
</ShellMainAppDir>
);
@@ -9,9 +9,8 @@ import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
import { eventTypesRouter } from "@calcom/trpc/server/routers/viewer/eventTypes/_router";
import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import EventTypes, { EventTypesCTA } from "~/event-types/views/event-types-listing-view";
@@ -24,15 +23,6 @@ export const generateMetadata = async () =>
"/event-types"
);
const getCachedMe = unstable_cache(
async (headers: ReadonlyHeaders, cookies: ReadonlyRequestCookies) => {
const meCaller = await createRouterCaller(meRouter, await getTRPCContext(headers, cookies));
return await meCaller.get();
},
["viewer.me.get"],
{ revalidate: 3600 } // seconds
);
const getCachedEventGroups = unstable_cache(
async (
headers: ReadonlyHeaders,
@@ -53,30 +43,26 @@ const getCachedEventGroups = unstable_cache(
{ revalidate: 3600 } // seconds
);
const Page = async ({ params, searchParams }: PageProps) => {
const Page = async ({ searchParams }: PageProps) => {
const _searchParams = await searchParams;
const _headers = await headers();
const _cookies = await cookies();
const context = buildLegacyCtx(_headers, _cookies, await params, _searchParams);
const session = await getServerSession({ req: context.req });
const session = await getServerSession({ req: buildLegacyRequest(_headers, _cookies) });
if (!session?.user?.id) {
redirect("/auth/login");
return redirect("/auth/login");
}
const t = await getTranslate();
const filters = getTeamsFiltersFromQuery(_searchParams);
const [me, userEventGroupsData] = await Promise.all([
getCachedMe(_headers, _cookies),
getCachedEventGroups(_headers, _cookies, filters),
]);
const userEventGroupsData = await getCachedEventGroups(_headers, _cookies, filters);
return (
<ShellMainAppDir
heading={t("event_types_page_title")}
subtitle={t("event_types_page_subtitle")}
CTA={<EventTypesCTA userEventGroupsData={userEventGroupsData} />}>
<EventTypes userEventGroupsData={userEventGroupsData} user={me} />
<EventTypes userEventGroupsData={userEventGroupsData} user={session.user} />
</ShellMainAppDir>
);
};
@@ -1,13 +1,12 @@
import { createRouterCaller } from "app/_trpc/context";
import type { SearchParams } from "app/_types";
import type { Session } from "next-auth";
import { unstable_cache } from "next/cache";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import { TeamsListing } from "@calcom/features/ee/teams/components/TeamsListing";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { TeamService } from "@calcom/lib/server/service/team";
import prisma from "@calcom/prisma";
import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
import { TRPCError } from "@trpc/server";
@@ -47,18 +46,24 @@ export const ServerTeamsListing = async ({
}
}
const meCaller = await createRouterCaller(meRouter);
const [user, teams] = await Promise.all([meCaller.get(), getCachedTeams(userId)]);
const teams = await getCachedTeams(userId);
const userProfile = session?.user?.profile;
const orgId = userProfile?.organizationId ?? session?.user.org?.id;
const orgRole =
session?.user?.org?.role ??
userProfile?.organization?.members.find((m: { userId: number }) => m.userId === userId)?.role;
const isOrgAdminOrOwner = checkAdminOrOwner(orgRole);
return {
Main: (
<TeamsListing
teams={teams}
user={user}
orgId={orgId ?? null}
isOrgAdmin={isOrgAdminOrOwner}
teamNameFromInvite={teamNameFromInvite ?? null}
errorMsgFromInvite={errorMsgFromInvite}
/>
),
CTA: !user.organizationId || user.organization.isOrgAdmin ? <TeamsCTA /> : null,
CTA: !orgId || isOrgAdminOrOwner ? <TeamsCTA /> : null,
};
};
@@ -0,0 +1,22 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
export default function PlaygroundLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const isPlaygroundRoot = pathname === "/settings/admin/playground";
return isPlaygroundRoot ? (
children
) : (
<div>
<Link href="/settings/admin/playground" className="text-sm underline">
← Playground
</Link>
<div className="h-8" />
<div>{children}</div>
</div>
);
}
@@ -0,0 +1,26 @@
import Link from "next/link";
const LINKS = [
{
title: "Routing Funnel",
href: "/settings/admin/playground/routing-funnel",
},
];
export default function Page() {
return (
<div>
<h1 className="text-3xl font-bold">Playground</h1>
<ul className="mt-8">
{LINKS.map((link) => (
<li key={link.title}>
<Link href={link.href} className="list-item list-disc font-medium underline">
{link.title} →
</Link>
</li>
))}
</ul>
</div>
);
}
@@ -0,0 +1,88 @@
"use client";
import { ChartCard } from "@calcom/features/insights/components/ChartCard";
import { RoutingFunnelContent, legend } from "@calcom/features/insights/components/RoutingFunnelContent";
import { useLocale } from "@calcom/lib/hooks/useLocale";
// Random sample data for playground testing
const sampleRoutingFunnelData = [
{
name: "Week 1",
formattedDateFull: "Week 1",
totalSubmissions: 150,
successfulRoutings: 120,
acceptedBookings: 95,
},
{
name: "Week 2",
formattedDateFull: "Week 2",
totalSubmissions: 180,
successfulRoutings: 145,
acceptedBookings: 110,
},
{
name: "Week 3",
formattedDateFull: "Week 3",
totalSubmissions: 200,
successfulRoutings: 160,
acceptedBookings: 125,
},
{
name: "Week 4",
formattedDateFull: "Week 4",
totalSubmissions: 170,
successfulRoutings: 135,
acceptedBookings: 105,
},
{
name: "Week 5",
formattedDateFull: "Week 5",
totalSubmissions: 220,
successfulRoutings: 175,
acceptedBookings: 140,
},
{
name: "Week 6",
formattedDateFull: "Week 6",
totalSubmissions: 190,
successfulRoutings: 155,
acceptedBookings: 120,
},
];
export default function RoutingFunnelPlayground() {
const { t } = useLocale();
return (
<div className="space-y-6 p-6">
<div className="mb-6">
<h1 className="text-3xl font-bold">Routing Funnel Playground</h1>
<p className="mt-2 text-gray-600">
This page demonstrates the RoutingFunnelContent component with sample data.
</p>
</div>
<div className="max-w-4xl">
<ChartCard
title={t("routing_funnel")}
subtitle="Hello world!"
legend={legend}
legendSize="sm"
cta={{
label: "Show all",
onClick: () => {
alert("hello!");
},
}}>
<RoutingFunnelContent data={sampleRoutingFunnelData} />
</ChartCard>
</div>
<div className="mt-8 rounded-lg bg-gray-50 p-4">
<h2 className="mb-2 text-lg font-semibold">Sample Data Used:</h2>
<pre className="overflow-auto text-sm text-gray-700">
{JSON.stringify(sampleRoutingFunnelData, null, 2)}
</pre>
</div>
</div>
);
}
@@ -92,7 +92,8 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
? [
{
name: "members",
href: `/settings/organizations/${orgBranding.slug}/members`,
href: `${WEBAPP_URL}/settings/organizations/${orgBranding.slug}/members`,
isExternalLink: true,
},
]
: []),
@@ -146,6 +147,7 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
{ name: "lockedSMS", href: "/settings/admin/lockedSMS" },
{ name: "oAuth", href: "/settings/admin/oAuth" },
{ name: "Workspace Platforms", href: "/settings/admin/workspace-platforms" },
{ name: "Playground", href: "/settings/admin/playground" },
],
},
];
@@ -291,7 +293,36 @@ interface SettingsSidebarContainerProps {
teamFeatures?: Record<number, TeamFeatures>;
}
const TeamListCollapsible = () => {
const TeamRolesNavItem = ({
team,
teamFeatures,
}: {
team: { id: number; parentId?: number | null };
teamFeatures?: Record<number, TeamFeatures>;
}) => {
const { t } = useLocale();
// Always call the hook first (Rules of Hooks)
const isPbacEnabled = useIsFeatureEnabledForTeam({
teamFeatures,
teamId: team.parentId || 0, // Use 0 as fallback when no parentId
feature: "pbac",
});
// Only show for sub-teams (teams with parentId) AND when parent has PBAC enabled
if (!team.parentId || !isPbacEnabled) return null;
return (
<VerticalTabItem
name={t("roles_and_permissions")}
href={`/settings/teams/${team.id}/roles`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
);
};
const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, TeamFeatures> }) => {
const { data: teams } = trpc.viewer.teams.list.useQuery();
const { t } = useLocale();
const [teamMenuState, setTeamMenuState] =
@@ -400,12 +431,8 @@ const TeamListCollapsible = () => {
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
<VerticalTabItem
name={t("event_types_page_title")}
href={`/event-types?teamId=${team.id}`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
{/* Show roles only for sub-teams with PBAC-enabled parent */}
<TeamRolesNavItem team={team} teamFeatures={teamFeatures} />
{(checkAdminOrOwner(team.role) ||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore this exists wtf?
@@ -592,7 +619,7 @@ const SettingsSidebarContainer = ({
</Skeleton>
</div>
</Link>
<TeamListCollapsible />
<TeamListCollapsible teamFeatures={teamFeatures} />
{(!orgBranding?.id || isOrgAdminOrOwner) && (
<VerticalTabItem
name={t("add_a_team")}
@@ -8,7 +8,12 @@ import { buildLegacyRequest } from "@lib/buildLegacyCtx";
const OrgAdminOnlyLayout = async ({ children }: { children: React.ReactNode }) => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const isOrgAdminOrOwner = checkAdminOrOwner(session?.user?.org?.role);
const userProfile = session?.user?.profile;
const userId = session?.user?.id;
const orgRole =
session?.user?.org?.role ??
userProfile?.organization?.members.find((m: { userId: number }) => m.userId === userId)?.role;
const isOrgAdminOrOwner = checkAdminOrOwner(orgRole);
if (!isOrgAdminOrOwner) {
return redirect("/settings/organizations/profile");
@@ -16,6 +16,7 @@ interface AdvancedPermissionGroupProps {
resource: Resource;
selectedPermissions: string[];
onChange: (permissions: string[]) => void;
disabled?: boolean;
}
const INTERNAL_DATAACCESS_KEY = "_resource";
@@ -24,6 +25,7 @@ export function AdvancedPermissionGroup({
resource,
selectedPermissions,
onChange,
disabled,
}: AdvancedPermissionGroupProps) {
const { t } = useLocale();
const { toggleSinglePermission, toggleResourcePermissionLevel } = usePermissions();
@@ -47,7 +49,9 @@ export function AdvancedPermissionGroup({
const handleToggleAll = (e: React.MouseEvent) => {
e.stopPropagation(); // Stop event from triggering parent click
onChange(toggleResourcePermissionLevel(resource, isAllSelected ? "none" : "all", selectedPermissions));
if (!disabled) {
onChange(toggleResourcePermissionLevel(resource, isAllSelected ? "none" : "all", selectedPermissions));
}
};
// Helper function to check if read permission is auto-enabled
@@ -77,6 +81,7 @@ export function AdvancedPermissionGroup({
checked={isAllSelected}
onCheckedChange={() => handleToggleAll}
onClick={handleToggleAll}
disabled={disabled}
/>
<span className="text-default text-sm font-medium leading-none">
{t(resourceConfig._resource?.i18nKey || "")}
@@ -107,9 +112,12 @@ export function AdvancedPermissionGroup({
checked={isChecked}
className="mr-2"
onCheckedChange={(checked) => {
onChange(toggleSinglePermission(permission, !!checked, selectedPermissions));
if (!disabled) {
onChange(toggleSinglePermission(permission, !!checked, selectedPermissions));
}
}}
onClick={(e) => e.stopPropagation()} // Stop checkbox clicks from affecting parent
disabled={disabled}
/>
<div
className="flex items-center gap-2"
@@ -28,7 +28,7 @@ export function DeleteRoleModal({ roleId, roleName, teamId, onDeleted }: DeleteR
onSuccess: async () => {
showToast(t("role_deleted_successfully"), "success");
setIsModalOpen(false);
await revalidateTeamRoles();
await revalidateTeamRoles(teamId);
router.refresh();
onDeleted?.();
},
@@ -7,9 +7,10 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
export interface RoleColorPickerProps {
value: string;
onChange: (color: string) => void;
disabled?: boolean;
}
export default function RoleColorPicker({ value, onChange }: RoleColorPickerProps) {
export default function RoleColorPicker({ value, onChange, disabled }: RoleColorPickerProps) {
const { t } = useLocale();
const [, setCustomColor] = useState(value);
const colorInputRef = useRef<HTMLInputElement>(null);
@@ -21,7 +22,9 @@ export default function RoleColorPicker({ value, onChange }: RoleColorPickerProp
};
const handleColorSquareClick = () => {
colorInputRef.current?.click();
if (!disabled) {
colorInputRef.current?.click();
}
};
return (
@@ -29,7 +32,8 @@ export default function RoleColorPicker({ value, onChange }: RoleColorPickerProp
<button
type="button"
onClick={handleColorSquareClick}
className="bg-default text-default border-default hover:bg-muted hover:text-emphasis focus:bg-subtle focus-visible:shadow-outline-gray-focused shadow-outline-gray-rested enabled:hover:shadow-outline-gray-hover enabled:active:shadow-outline-gray-active flex h-8 w-8 items-center justify-center gap-2 rounded-[10px] border text-sm transition-shadow duration-200 focus-visible:outline-none focus-visible:ring-0">
disabled={disabled}
className="bg-default text-default border-default hover:bg-muted hover:text-emphasis focus:bg-subtle focus-visible:shadow-outline-gray-focused shadow-outline-gray-rested enabled:hover:shadow-outline-gray-hover enabled:active:shadow-outline-gray-active flex h-8 w-8 items-center justify-center gap-2 rounded-[10px] border text-sm transition-shadow duration-200 focus-visible:outline-none focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50">
<div
className="h-5 w-5 rounded border shadow-sm"
style={{ backgroundColor: value }}
@@ -7,7 +7,11 @@ import { useForm } from "react-hook-form";
import { z } from "zod";
import type { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { PERMISSION_REGISTRY, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
import {
CrudAction,
Scope,
getPermissionsForScope,
} from "@calcom/features/pbac/domain/types/permission-registry";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/button";
@@ -51,12 +55,14 @@ interface RoleSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
teamId: number;
scope?: Scope;
}
export function RoleSheet({ role, open, onOpenChange, teamId }: RoleSheetProps) {
export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Organization }: RoleSheetProps) {
const { t } = useLocale();
const router = useRouter();
const isEditing = Boolean(role);
const isSystemRole = role?.type === "SYSTEM";
const [searchQuery, setSearchQuery] = useState("");
const defaultValues = useMemo(
@@ -102,23 +108,23 @@ export function RoleSheet({ role, open, onOpenChange, teamId }: RoleSheetProps)
const { isAdvancedMode, permissions, color } = form.watch();
const filteredResources = useMemo(() => {
return Object.keys(PERMISSION_REGISTRY).filter((resource) =>
const scopedRegistry = getPermissionsForScope(scope);
return Object.keys(scopedRegistry).filter((resource) =>
t(
PERMISSION_REGISTRY[resource as Resource][
CrudAction.All as keyof (typeof PERMISSION_REGISTRY)[Resource]
]?.i18nKey || ""
scopedRegistry[resource as Resource][CrudAction.All as keyof (typeof scopedRegistry)[Resource]]
?.i18nKey || ""
)
.toLowerCase()
.includes(searchQuery.toLowerCase())
);
}, [searchQuery, t]);
}, [searchQuery, t, scope]);
const createMutation = trpc.viewer.pbac.createRole.useMutation({
onSuccess: async () => {
showToast(t("role_created_successfully"), "success");
form.reset();
onOpenChange(false);
await revalidateTeamRoles();
await revalidateTeamRoles(teamId);
router.refresh();
},
onError: (error) => {
@@ -130,7 +136,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId }: RoleSheetProps)
onSuccess: async () => {
showToast(t("role_updated_successfully"), "success");
onOpenChange(false);
await revalidateTeamRoles();
await revalidateTeamRoles(teamId);
router.refresh();
},
onError: (error) => {
@@ -166,7 +172,9 @@ export function RoleSheet({ role, open, onOpenChange, teamId }: RoleSheetProps)
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent>
<SheetHeader>
<SheetTitle>{isEditing ? t("edit_role") : t("create_role")}</SheetTitle>
<SheetTitle>
{isSystemRole ? t("view_role") : isEditing ? t("edit_role") : t("create_role")}
</SheetTitle>
</SheetHeader>
<Form form={form} handleSubmit={onSubmit}>
<div className="space-y-4 py-5">
@@ -176,11 +184,13 @@ export function RoleSheet({ role, open, onOpenChange, teamId }: RoleSheetProps)
label={t("role_name")}
{...form.register("name")}
placeholder={t("role_name_placeholder")}
disabled={isSystemRole}
/>
</div>
<RoleColorPicker
value={color}
onChange={(value) => form.setValue("color", value, { shouldDirty: true })}
disabled={isSystemRole}
/>
</div>
@@ -203,6 +213,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId }: RoleSheetProps)
placeholder={t("search_permissions")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
disabled={isSystemRole}
/>
</div>
@@ -212,6 +223,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId }: RoleSheetProps)
resource={resource as Resource}
selectedPermissions={permissions}
onChange={(newPermissions) => form.setValue("permissions", newPermissions)}
disabled={isSystemRole}
/>
))}
</div>
@@ -228,12 +240,13 @@ export function RoleSheet({ role, open, onOpenChange, teamId }: RoleSheetProps)
</div>
</div>
<div className="bg-default border-subtle divide-subtle divide-y rounded-[10px] border">
{Object.keys(PERMISSION_REGISTRY).map((resource) => (
{Object.keys(getPermissionsForScope(scope)).map((resource) => (
<SimplePermissionItem
key={resource}
resource={resource}
permissions={permissions}
onChange={(newPermissions) => form.setValue("permissions", newPermissions)}
disabled={isSystemRole}
/>
))}
</div>
@@ -242,18 +255,27 @@ export function RoleSheet({ role, open, onOpenChange, teamId }: RoleSheetProps)
</div>
</div>
<SheetFooter>
<Button
type="button"
color="secondary"
onClick={() => onOpenChange(false)}
disabled={createMutation.isPending || updateMutation.isPending}>
{t("cancel")}
</Button>
<Button type="submit" loading={createMutation.isPending || updateMutation.isPending}>
{isEditing ? t("save") : t("create")}
</Button>
</SheetFooter>
{!isSystemRole && (
<SheetFooter>
<Button
type="button"
color="secondary"
onClick={() => onOpenChange(false)}
disabled={createMutation.isPending || updateMutation.isPending}>
{t("cancel")}
</Button>
<Button type="submit" loading={createMutation.isPending || updateMutation.isPending}>
{isEditing ? t("save") : t("create")}
</Button>
</SheetFooter>
)}
{isSystemRole && (
<SheetFooter>
<Button type="button" color="secondary" onClick={() => onOpenChange(false)}>
{t("close")}
</Button>
</SheetFooter>
)}
</Form>
</SheetContent>
</Sheet>
@@ -1,5 +1,6 @@
"use client";
import type { Scope } from "@calcom/features/pbac/domain/types/permission-registry";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Badge } from "@calcom/ui/badge";
import { Button } from "@calcom/ui/button";
@@ -46,6 +47,7 @@ interface RolesListProps {
initialSelectedRole?: Role;
initialSheetOpen?: boolean;
teamId: number;
scope?: Scope;
}
export function RolesList({
@@ -54,6 +56,7 @@ export function RolesList({
initialSelectedRole,
initialSheetOpen,
teamId,
scope,
}: RolesListProps) {
const { t } = useLocale();
const { isOpen, setIsOpen, selectedRoleId, setSelectedRoleId, handleSheetOpenChange } = useRoleStates(
@@ -78,8 +81,10 @@ export function RolesList({
role={role}
key={role.id}
onClick={() => {
// Cant edit system roles
// For system roles, open in view-only mode
if (role.type === "SYSTEM") {
setSelectedRoleId(role.id);
setIsOpen(true);
return;
}
@@ -107,6 +112,7 @@ export function RolesList({
open={isOpen ?? false}
onOpenChange={handleSheetOpenChange}
teamId={teamId}
scope={scope}
/>
</>
);
@@ -132,7 +138,9 @@ function RoleItem({
<div
className={classNames(
"border-subtle flex p-3",
canUpdate && role.type !== "SYSTEM" && "hover:bg-subtle cursor-pointer"
(canUpdate && role.type !== "SYSTEM") || role.type === "SYSTEM"
? "hover:bg-subtle cursor-pointer"
: ""
)}
onClick={onClick}>
<div className="flex w-full items-center gap-3 truncate">
@@ -12,9 +12,15 @@ interface SimplePermissionItemProps {
resource: string;
permissions: string[];
onChange: (permissions: string[]) => void;
disabled?: boolean;
}
export function SimplePermissionItem({ resource, permissions, onChange }: SimplePermissionItemProps) {
export function SimplePermissionItem({
resource,
permissions,
onChange,
disabled,
}: SimplePermissionItemProps) {
const { t } = useLocale();
const { getResourcePermissionLevel, toggleResourcePermissionLevel } = usePermissions();
@@ -37,10 +43,12 @@ export function SimplePermissionItem({ resource, permissions, onChange }: Simple
</span>
<ToggleGroup
onValueChange={(val) => {
if (val) onChange(toggleResourcePermissionLevel(resource, val as PermissionLevel, permissions));
if (val && !disabled)
onChange(toggleResourcePermissionLevel(resource, val as PermissionLevel, permissions));
}}
value={getResourcePermissionLevel(resource, permissions)}
options={options}
disabled={disabled}
/>
</div>
);
@@ -1,7 +1,17 @@
"use server";
import { revalidatePath } from "next/cache";
import { revalidatePath, revalidateTag } from "next/cache";
export async function revalidateTeamRoles() {
export async function revalidateTeamRoles(teamId: number) {
// Revalidate organization roles path
revalidatePath("/settings/organizations/roles");
// Revalidate team roles paths (dynamic routes)
revalidatePath("/settings/teams/[id]/roles", "page");
// Invalidate team-specific cache tags
revalidateTag(`team-roles-${teamId}`);
revalidateTag(`resource-permissions-${teamId}`);
revalidateTag(`team-members-${teamId}`);
revalidateTag(`team-features-${teamId}`);
}
@@ -0,0 +1,160 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { unstable_cache } from "next/cache";
import { cookies, headers } from "next/headers";
import { notFound } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import type { AppFlags } from "@calcom/features/flags/config";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { PermissionMapper } from "@calcom/features/pbac/domain/mappers/PermissionMapper";
import { Resource, CrudAction, Scope } from "@calcom/features/pbac/domain/types/permission-registry";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { RoleService } from "@calcom/features/pbac/services/role.service";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { CreateRoleCTA } from "../../../organizations/roles/_components/CreateRoleCta";
import { RolesList } from "../../../organizations/roles/_components/RolesList";
import { roleSearchParamsCache } from "../../../organizations/roles/_components/searchParams";
const getCachedTeamRoles = (teamId: number) =>
unstable_cache(
async () => {
const roleService = new RoleService();
return roleService.getTeamRoles(teamId);
},
[`team-roles-for-team-${teamId}`],
{ revalidate: 3600, tags: [`team-roles-${teamId}`] }
);
const getCachedTeamFeature = (teamId: number, feature: keyof AppFlags) =>
unstable_cache(
async () => {
const featureRepo = new FeaturesRepository();
const res = await featureRepo.checkIfTeamHasFeature(teamId, feature);
return res;
},
[`team-feature-for-roles-${teamId}-${feature}`],
{ revalidate: 3600, tags: [`team-features-${teamId}`] }
);
const getCachedResourcePermissions = (userId: number, teamId: number, resource: Resource) =>
unstable_cache(
async () => {
const permissionService = new PermissionCheckService();
return permissionService.getResourcePermissions({ userId, teamId, resource });
},
[`resource-permissions-for-team-roles-${userId}-${teamId}-${resource}`],
{ revalidate: 3600, tags: [`resource-permissions-${teamId}`] }
);
const getCachedTeam = (teamId: string, userId: number) =>
unstable_cache(
async () => {
return getTeamWithMembers({
id: Number(teamId),
userId: userId,
isTeamView: true,
});
},
[`team-with-members-for-roles-${teamId}-${userId}`],
{ revalidate: 3600, tags: [`team-members-${teamId}`] }
);
export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) => {
const { id } = await params;
return await _generateMetadata(
(t) => t("roles_and_permissions"),
(t) => t("roles_and_permissions_description"),
undefined,
undefined,
`/settings/teams/${id}/roles`
);
};
const Page = async ({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Record<string, string | string[] | undefined>;
}) => {
const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const { id: teamId } = await params;
if (!session?.user?.id) {
return notFound();
}
// Get team information including parent
const team = await getCachedTeam(teamId, session.user.id)();
if (!team) {
return notFound();
}
// Check if team has a parent (is a sub-team)
if (!team.parent?.id) {
return notFound();
}
// Check if parent team has PBAC feature enabled
const parentTeamHasPBACFeature = await getCachedTeamFeature(team.parent.id, "pbac")();
if (!parentTeamHasPBACFeature) {
return notFound();
}
roleSearchParamsCache.parse(searchParams);
const [roles, rolePermissions] = await Promise.all([
getCachedTeamRoles(team.id)(),
getCachedResourcePermissions(session.user.id, team.id, Resource.Role)(),
]);
// NOTE: this approach of fetching permssions per resource does not account for fall back roles.
const roleActions = PermissionMapper.toActionMap(rolePermissions, Resource.Role);
const canCreate = roleActions[CrudAction.Create] ?? false;
const canRead = roleActions[CrudAction.Read] ?? false;
const canUpdate = roleActions[CrudAction.Update] ?? false;
const canDelete = roleActions[CrudAction.Delete] ?? false;
if (!canRead) {
return notFound();
}
// Get sheet state from server cache
const isSheetOpen = roleSearchParamsCache.get("role-sheet");
const selectedRoleId = roleSearchParamsCache.get("role");
const selectedRole = roles.find((role) => role.id === selectedRoleId);
// Use Team scope for team roles (this will automatically filter appropriate resources)
return (
<SettingsHeader
title={t("roles_and_permissions")}
description={t("roles_and_permissions_description")}
borderInShellHeader={false}
CTA={canCreate ? <CreateRoleCTA /> : null}>
<RolesList
teamId={team.id}
roles={roles}
permissions={{
canCreate: canCreate,
canRead: canRead,
canUpdate: canUpdate,
canDelete: canDelete,
}}
initialSelectedRole={selectedRole}
initialSheetOpen={isSheetOpen}
scope={Scope.Team}
/>
</SettingsHeader>
);
};
export default Page;
@@ -8,7 +8,12 @@ import { buildLegacyRequest } from "@lib/buildLegacyCtx";
const OrgAdminOnlyLayout = async ({ children }: { children: React.ReactNode }) => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const isOrgAdminOrOwner = checkAdminOrOwner(session?.user?.org?.role);
const userProfile = session?.user?.profile;
const userId = session?.user?.id;
const orgRole =
session?.user?.org?.role ??
userProfile?.organization?.members.find((m: { userId: number }) => m.userId === userId)?.role;
const isOrgAdminOrOwner = checkAdminOrOwner(orgRole);
if (!isOrgAdminOrOwner) {
return redirect("/settings/organizations/profile");
+1 -1
View File
@@ -54,7 +54,7 @@ export default function EnterprisePage() {
buttons={
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="primary" href="https://i.cal.com/sales/enterprise?duration=25" target="_blank">
<Button color="primary" href="https://go.cal.com/quote" target="_blank">
{t("contact_sales")}
</Button>
<Button color="minimal" href="https://cal.com/enterprise" target="_blank">
@@ -21,15 +21,17 @@ export const ChargeCardDialog = (props: IRescheduleDialog) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const { isOpenDialog, setIsOpenDialog, bookingId } = props;
const [chargeError, setChargeError] = useState(false);
const [chargeError, setChargeError] = useState<string | null>(null);
const chargeCardMutation = trpc.viewer.payments.chargeCard.useMutation({
onSuccess: () => {
utils.viewer.bookings.invalidate();
setIsOpenDialog(false);
setChargeError(null);
showToast("Charge successful", "success");
},
onError: () => {
setChargeError(true);
onError: (error) => {
setChargeError(error.message || t("error_charging_card"));
},
});
@@ -52,7 +54,7 @@ export const ChargeCardDialog = (props: IRescheduleDialog) => {
{chargeError && (
<div className="mt-4 flex text-red-500">
<Icon name="triangle-alert" className="mr-2 h-5 w-5 " aria-hidden="true" />
<p className="text-sm">{t("error_charging_card")}</p>
<p className="text-sm">{chargeError}</p>
</div>
)}
@@ -60,12 +62,13 @@ export const ChargeCardDialog = (props: IRescheduleDialog) => {
<DialogClose />
<Button
data-testid="send_request"
disabled={chargeCardMutation.isPending || chargeError}
onClick={() =>
disabled={chargeCardMutation.isPending}
onClick={() => {
setChargeError(null);
chargeCardMutation.mutate({
bookingId,
})
}>
});
}}>
{t("charge_attendee", currencyStringParams)}
</Button>
</DialogFooter>
@@ -41,7 +41,7 @@ export const PlatformPricing = ({ teamId, teamPlan, heading }: PlatformPricingPr
const handleStripeSubscription = async (plan: string) => {
if (plan === "Enterprise") {
router.push("https://i.cal.com/sales/exploration");
return router.push("https://go.cal.com/quote");
}
if (currentPage === "platform") {
+26 -2
View File
@@ -23,6 +23,7 @@ type CalVideoSettings = {
disableRecordingForGuests: boolean;
disableRecordingForOrganizer: boolean;
enableAutomaticTranscription: boolean;
enableAutomaticRecordingForOrganizer: boolean;
disableTranscriptionForGuests: boolean;
disableTranscriptionForOrganizer: boolean;
};
@@ -59,6 +60,21 @@ const shouldEnableAutomaticTranscription = ({
return !!calVideoSettings.enableAutomaticTranscription;
};
const shouldEnableAutomaticRecording = ({
hasTeamPlan,
calVideoSettings,
isOrganizer,
}: {
hasTeamPlan: boolean;
calVideoSettings?: CalVideoSettings | null;
isOrganizer: boolean;
}) => {
if (!hasTeamPlan || !isOrganizer) return false;
if (!calVideoSettings) return false;
return !!calVideoSettings.enableAutomaticRecordingForOrganizer;
};
const shouldEnableTranscriptionButton = ({
hasTeamPlan,
calVideoSettings,
@@ -239,17 +255,24 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const showRecordingButton = shouldEnableRecordButton({
hasTeamPlan: !!hasTeamPlan,
calVideoSettings: bookingObj.eventType?.calVideoSettings,
isOrganizer: sessionUserId === bookingObj.user?.id,
isOrganizer,
});
const enableAutomaticTranscription = shouldEnableAutomaticTranscription({
hasTeamPlan: !!hasTeamPlan,
calVideoSettings: bookingObj.eventType?.calVideoSettings,
});
const enableAutomaticRecordingForOrganizer = shouldEnableAutomaticRecording({
hasTeamPlan: !!hasTeamPlan,
calVideoSettings: bookingObj.eventType?.calVideoSettings,
isOrganizer,
});
const showTranscriptionButton = shouldEnableTranscriptionButton({
hasTeamPlan: !!hasTeamPlan,
calVideoSettings: bookingObj.eventType?.calVideoSettings,
isOrganizer: sessionUserId === bookingObj.user?.id,
isOrganizer,
});
return {
@@ -274,6 +297,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
loggedInUserName: sessionUserId ? session?.user?.name : undefined,
showRecordingButton,
enableAutomaticTranscription,
enableAutomaticRecordingForOrganizer,
showTranscriptionButton,
rediectAttendeeToOnExit: isOrganizer
? undefined
@@ -15,19 +15,20 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import { ToggleGroup } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";
type AvailabilityListProps = {
me: RouterOutputs["viewer"]["me"]["get"];
availabilities: RouterOutputs["viewer"]["availability"]["list"];
};
export function AvailabilityList({ availabilities, me }: AvailabilityListProps) {
export function AvailabilityList({ availabilities }: AvailabilityListProps) {
const { t } = useLocale();
const [bulkUpdateModal, setBulkUpdateModal] = useState(false);
const utils = trpc.useUtils();
const router = useRouter();
const { data: user } = useMeQuery();
const deleteMutation = trpc.viewer.availability.schedule.delete.useMutation({
onMutate: async ({ scheduleId }) => {
@@ -141,9 +142,9 @@ export function AvailabilityList({ availabilities, me }: AvailabilityListProps)
{availabilities.schedules.map((schedule) => (
<ScheduleListItem
displayOptions={{
hour12: me?.timeFormat ? me.timeFormat === 12 : undefined,
timeZone: me?.timeZone,
weekStart: me?.weekStart || "Sunday",
hour12: user?.timeFormat ? user.timeFormat === 12 : undefined,
timeZone: user?.timeZone,
weekStart: user?.weekStart || "Sunday",
}}
key={schedule.id}
schedule={schedule}
@@ -558,6 +558,14 @@ export default function Success(props: PageProps) {
<div className="col-span-2 mb-6 last:mb-0">{cancellationReason}</div>
</>
)}
{isCancelled && bookingInfo?.cancelledBy && (
<>
<div className="font-medium">{t("cancelled_by")}</div>
<div className="col-span-2 mb-6 last:mb-0">
<p className="break-words">{bookingInfo?.cancelledBy}</p>
</div>
</>
)}
{previousBooking && (
<>
<div className="font-medium">{t("rescheduled_by")}</div>
@@ -613,7 +621,7 @@ export default function Success(props: PageProps) {
<Badge variant="blue">{t("Host")}</Badge>
</div>
{!bookingInfo.eventType?.hideOrganizerEmail && (
<p className="text-default">
<p className="text-default" data-testid="booking-host-email">
{bookingInfo?.userPrimaryEmail ?? bookingInfo.user.email}
</p>
)}
@@ -928,7 +928,10 @@ const InfiniteScrollMain = ({
type Props = {
userEventGroupsData: GetUserEventGroupsResponse;
user: RouterOutputs["viewer"]["me"]["get"];
user: {
id: number;
completedOnboarding?: boolean;
} | null;
};
export const EventTypesCTA = ({ userEventGroupsData }: Omit<Props, "user">) => {
@@ -6,6 +6,7 @@ import {
RoutingFormResponsesTable,
FailedBookingsByField,
RoutedToPerPeriod,
RoutingFunnel,
} from "@calcom/features/insights/components";
import { InsightsOrgTeamsProvider } from "@calcom/features/insights/context/InsightsOrgTeamsProvider";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -19,9 +20,12 @@ export default function InsightsRoutingFormResponsesPage() {
<div className="mb-4 space-y-4">
<RoutingFormResponsesTable />
<RoutedToPerPeriod />
<RoutingFunnel />
<FailedBookingsByField />
<div className="flex flex-col gap-4 md:flex-row">
<RoutedToPerPeriod />
<FailedBookingsByField />
</div>
<small className="text-default block text-center">
{t("looking_for_more_insights")}{" "}
+7 -7
View File
@@ -71,22 +71,22 @@ function InsightsPageContent() {
<BookingStatusLineChart />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<PopularEventsTable />
<AverageEventDurationChart />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
<MostBookedTeamMembersTable />
<LeastBookedTeamMembersTable />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<MostCancelledBookingsTables />
</div>
<RecentFeedbackTable />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<HighestNoShowHostTable />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
<HighestRatedMembersTable />
<LowestRatedMembersTable />
<div className="sm:col-span-2">
<RecentFeedbackTable />
</div>
</div>
<small className="text-default block text-center">
{t("looking_for_more_insights")}{" "}
@@ -0,0 +1,19 @@
"use client";
import { BookerWebWrapper, type BookerWebWrapperAtomProps } from "@calcom/atoms/booker";
import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
import BookingPageErrorBoundary from "@components/error/BookingPageErrorBoundary";
export type TeamBookingPageProps = Omit<BookerWebWrapperAtomProps, "eventData"> &
Required<Pick<BookerWebWrapperAtomProps, "eventData">> & { isEmbed?: boolean };
export default function TeamBookingPage(props: TeamBookingPageProps) {
return (
<BookingPageErrorBoundary>
<main className={getBookerWrapperClasses({ isEmbed: !!props.isEmbed })}>
<BookerWebWrapper {...props} />
</main>
</BookingPageErrorBoundary>
);
}
@@ -0,0 +1,492 @@
import type { DailyCall } from "@daily-co/daily-js";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { BUTTONS } from "../button-states";
import { createCalVideoCallbacks } from "../cal-video-premium-features";
vi.mock("@calcom/lib/constants", () => ({
TRANSCRIPTION_STARTED_ICON: "/transcription-started-icon.svg",
RECORDING_IN_PROGRESS_ICON: "/recording-in-progress-icon.svg",
TRANSCRIPTION_STOPPED_ICON: "/transcription-stopped-icon.svg",
RECORDING_DEFAULT_ICON: "/recording-default-icon.svg",
}));
const mockDaily: DailyCall = {
startRecording: vi.fn(),
stopRecording: vi.fn(),
startTranscription: vi.fn(),
stopTranscription: vi.fn(),
updateCustomTrayButtons: vi.fn(),
} as unknown as DailyCall;
const createMockRecording = (isRecording = false) => ({
isRecording,
});
const createMockTranscription = (isTranscribing = false) => ({
isTranscribing,
});
describe("CalVideoPremiumFeatures - End-to-End Callback Tests", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("onMeetingJoined", () => {
it("should start transcription automatically when enabled and not already transcribing", () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(),
transcription: createMockTranscription(false), // not transcribing
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: true,
enableAutomaticRecordingForOrganizer: false,
});
callbacks.onMeetingJoined();
expect(mockDaily.startTranscription).toHaveBeenCalledTimes(1);
expect(mockDaily.startRecording).not.toHaveBeenCalled();
});
it("should start recording automatically when enabled and not already recording", () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(false), // not recording
transcription: createMockTranscription(),
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: true,
});
callbacks.onMeetingJoined();
expect(mockDaily.startRecording).toHaveBeenCalledTimes(1);
expect(mockDaily.startRecording).toHaveBeenCalledWith({
videoBitrate: 2000,
});
expect(mockDaily.startTranscription).not.toHaveBeenCalled();
});
it("should start both transcription and recording when both are enabled", () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(false),
transcription: createMockTranscription(false),
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: true,
enableAutomaticRecordingForOrganizer: true,
});
callbacks.onMeetingJoined();
expect(mockDaily.startTranscription).toHaveBeenCalledTimes(1);
expect(mockDaily.startRecording).toHaveBeenCalledTimes(1);
});
it("should not start transcription when already transcribing", () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(),
transcription: createMockTranscription(true), // already transcribing
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: true,
enableAutomaticRecordingForOrganizer: false,
});
callbacks.onMeetingJoined();
expect(mockDaily.startTranscription).not.toHaveBeenCalled();
});
it("should not start recording when already recording", () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(true), // already recording
transcription: createMockTranscription(),
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: true,
});
callbacks.onMeetingJoined();
expect(mockDaily.startRecording).not.toHaveBeenCalled();
});
});
describe("onCustomButtonClick - Recording Button", () => {
it("should start recording when click occurs on Recording button and recording is stopped", async () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(false), // not recording
transcription: createMockTranscription(),
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: false,
});
await callbacks.onCustomButtonClick({ button_id: "recording" });
expect(mockDaily.startRecording).toHaveBeenCalledTimes(1);
expect(mockDaily.startRecording).toHaveBeenCalledWith({
videoBitrate: 2000,
});
expect(mockDaily.stopRecording).not.toHaveBeenCalled();
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
recording: BUTTONS.WAIT_FOR_RECORDING_TO_START,
transcription: BUTTONS.START_TRANSCRIPTION, // current state included
});
});
it("should stop recording when click occurs on Recording button and recording is started", async () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(true), // currently recording
transcription: createMockTranscription(),
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: false,
});
await callbacks.onCustomButtonClick({ button_id: "recording" });
expect(mockDaily.stopRecording).toHaveBeenCalledTimes(1);
expect(mockDaily.startRecording).not.toHaveBeenCalled();
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
recording: BUTTONS.WAIT_FOR_RECORDING_TO_STOP,
transcription: BUTTONS.START_TRANSCRIPTION, // current state included
});
});
});
describe("onCustomButtonClick - Transcription Button", () => {
it("should start transcription when click occurs on Transcription button and transcription is stopped", async () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(),
transcription: createMockTranscription(false), // not transcribing
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: false,
});
await callbacks.onCustomButtonClick({ button_id: "transcription" });
expect(mockDaily.startTranscription).toHaveBeenCalledTimes(1);
expect(mockDaily.stopTranscription).not.toHaveBeenCalled();
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
recording: BUTTONS.START_RECORDING, // current state included
transcription: BUTTONS.WAIT_FOR_TRANSCRIPTION_TO_START,
});
});
it("should stop transcription when click occurs on Transcription button and transcription is started", async () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(),
transcription: createMockTranscription(true), // currently transcribing
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: false,
});
await callbacks.onCustomButtonClick({ button_id: "transcription" });
expect(mockDaily.stopTranscription).toHaveBeenCalledTimes(1);
expect(mockDaily.startTranscription).not.toHaveBeenCalled();
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
recording: BUTTONS.START_RECORDING, // current state included
transcription: BUTTONS.WAIT_FOR_TRANSCRIPTION_TO_STOP,
});
});
});
describe("onRecordingStarted", () => {
it("should update custom tray buttons to show stop recording button", () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(),
transcription: createMockTranscription(),
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: false,
});
callbacks.onRecordingStarted();
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
recording: BUTTONS.STOP_RECORDING,
transcription: BUTTONS.START_TRANSCRIPTION, // current state included
});
});
});
describe("onRecordingStopped", () => {
it("should update custom tray buttons to show start recording button", () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(),
transcription: createMockTranscription(),
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: false,
});
callbacks.onRecordingStopped();
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
recording: BUTTONS.START_RECORDING,
transcription: BUTTONS.START_TRANSCRIPTION, // current state included
});
});
});
describe("onTranscriptionStarted", () => {
it("should update custom tray buttons to show stop transcription button", () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(),
transcription: createMockTranscription(),
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: false,
});
callbacks.onTranscriptionStarted();
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
recording: BUTTONS.START_RECORDING, // current state included
transcription: BUTTONS.STOP_TRANSCRIPTION,
});
});
});
describe("onTranscriptionStopped", () => {
it("should update custom tray buttons to show start transcription button", () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(),
transcription: createMockTranscription(),
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: false,
});
callbacks.onTranscriptionStopped();
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
recording: BUTTONS.START_RECORDING, // current state included
transcription: BUTTONS.START_TRANSCRIPTION,
});
});
});
describe("toggleRecording", () => {
it("should start recording when not currently recording", async () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(false), // not recording
transcription: createMockTranscription(),
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: false,
});
await callbacks.toggleRecording();
expect(mockDaily.startRecording).toHaveBeenCalledTimes(1);
expect(mockDaily.startRecording).toHaveBeenCalledWith({
videoBitrate: 2000,
});
expect(mockDaily.stopRecording).not.toHaveBeenCalled();
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
recording: BUTTONS.WAIT_FOR_RECORDING_TO_START,
transcription: BUTTONS.START_TRANSCRIPTION, // current state included
});
});
it("should stop recording when currently recording", async () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(true), // currently recording
transcription: createMockTranscription(),
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: false,
});
await callbacks.toggleRecording();
expect(mockDaily.stopRecording).toHaveBeenCalledTimes(1);
expect(mockDaily.startRecording).not.toHaveBeenCalled();
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
recording: BUTTONS.WAIT_FOR_RECORDING_TO_STOP,
transcription: BUTTONS.START_TRANSCRIPTION, // current state included
});
});
});
describe("toggleTranscription", () => {
it("should start transcription when not currently transcribing", async () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(),
transcription: createMockTranscription(false), // not transcribing
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: false,
});
await callbacks.toggleTranscription();
expect(mockDaily.startTranscription).toHaveBeenCalledTimes(1);
expect(mockDaily.stopTranscription).not.toHaveBeenCalled();
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
recording: BUTTONS.START_RECORDING, // current state included
transcription: BUTTONS.WAIT_FOR_TRANSCRIPTION_TO_START,
});
});
it("should stop transcription when currently transcribing", async () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(),
transcription: createMockTranscription(true), // currently transcribing
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: false,
});
await callbacks.toggleTranscription();
expect(mockDaily.stopTranscription).toHaveBeenCalledTimes(1);
expect(mockDaily.startTranscription).not.toHaveBeenCalled();
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
recording: BUTTONS.START_RECORDING, // current state included
transcription: BUTTONS.WAIT_FOR_TRANSCRIPTION_TO_STOP,
});
});
});
describe("updateCustomTrayButtons", () => {
it("should update both recording and transcription buttons when both are shown", () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(false),
transcription: createMockTranscription(false),
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: false,
});
callbacks.updateCustomTrayButtons({
recording: BUTTONS.STOP_RECORDING,
transcription: BUTTONS.STOP_TRANSCRIPTION,
});
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
recording: BUTTONS.STOP_RECORDING,
transcription: BUTTONS.STOP_TRANSCRIPTION,
});
});
it("should update only recording button when transcription button is not shown", () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(false),
transcription: createMockTranscription(false),
showRecordingButton: true,
showTranscriptionButton: false,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: false,
});
callbacks.updateCustomTrayButtons({
recording: BUTTONS.STOP_RECORDING,
});
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
recording: BUTTONS.STOP_RECORDING,
});
});
it("should update only transcription button when recording button is not shown", () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(false),
transcription: createMockTranscription(false),
showRecordingButton: false,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: false,
});
callbacks.updateCustomTrayButtons({
transcription: BUTTONS.STOP_TRANSCRIPTION,
});
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
transcription: BUTTONS.STOP_TRANSCRIPTION,
});
});
it("should use current state when no override is provided", () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(true), // currently recording
transcription: createMockTranscription(false), // not transcribing
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: false,
});
callbacks.updateCustomTrayButtons({});
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
recording: BUTTONS.STOP_RECORDING, // because isRecording is true
transcription: BUTTONS.START_TRANSCRIPTION, // because isTranscribing is false
});
});
});
describe("startRecording", () => {
it("should call daily.startRecording with correct parameters", () => {
const callbacks = createCalVideoCallbacks({
daily: mockDaily,
recording: createMockRecording(),
transcription: createMockTranscription(),
showRecordingButton: true,
showTranscriptionButton: true,
enableAutomaticTranscription: false,
enableAutomaticRecordingForOrganizer: false,
});
callbacks.startRecording();
expect(mockDaily.startRecording).toHaveBeenCalledTimes(1);
expect(mockDaily.startRecording).toHaveBeenCalledWith({
videoBitrate: 2000,
});
});
});
});
+57
View File
@@ -0,0 +1,57 @@
import {
TRANSCRIPTION_STARTED_ICON,
RECORDING_IN_PROGRESS_ICON,
TRANSCRIPTION_STOPPED_ICON,
RECORDING_DEFAULT_ICON,
} from "@calcom/lib/constants";
export const BUTTONS = {
STOP_TRANSCRIPTION: {
label: "Stop",
tooltip: "Stop transcription",
iconPath: TRANSCRIPTION_STARTED_ICON,
iconPathDarkMode: TRANSCRIPTION_STARTED_ICON,
},
START_TRANSCRIPTION: {
label: "Cal.ai",
tooltip: "Transcription powered by AI",
iconPath: TRANSCRIPTION_STOPPED_ICON,
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
},
WAIT_FOR_TRANSCRIPTION_TO_START: {
label: "Starting..",
tooltip: "Please wait while we start transcription",
iconPath: TRANSCRIPTION_STOPPED_ICON,
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
},
WAIT_FOR_TRANSCRIPTION_TO_STOP: {
label: "Stopping..",
tooltip: "Please wait while we stop transcription",
iconPath: TRANSCRIPTION_STOPPED_ICON,
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
},
START_RECORDING: {
label: "Record",
tooltip: "Start recording",
iconPath: RECORDING_DEFAULT_ICON,
iconPathDarkMode: RECORDING_DEFAULT_ICON,
},
WAIT_FOR_RECORDING_TO_START: {
label: "Starting..",
tooltip: "Please wait while we start recording",
iconPath: RECORDING_DEFAULT_ICON,
iconPathDarkMode: RECORDING_DEFAULT_ICON,
},
WAIT_FOR_RECORDING_TO_STOP: {
label: "Stopping..",
tooltip: "Please wait while we stop recording",
iconPath: RECORDING_DEFAULT_ICON,
iconPathDarkMode: RECORDING_DEFAULT_ICON,
},
STOP_RECORDING: {
label: "Stop",
tooltip: "Stop recording",
iconPath: RECORDING_IN_PROGRESS_ICON,
iconPathDarkMode: RECORDING_IN_PROGRESS_ICON,
},
};
@@ -1,65 +1,9 @@
import type { DailyCall } from "@daily-co/daily-js";
import { useTranscription, useRecording } from "@daily-co/daily-react";
import { useDaily, useDailyEvent } from "@daily-co/daily-react";
import React, { Fragment, useCallback, useRef, useState, useLayoutEffect, useEffect } from "react";
import {
TRANSCRIPTION_STARTED_ICON,
RECORDING_IN_PROGRESS_ICON,
TRANSCRIPTION_STOPPED_ICON,
RECORDING_DEFAULT_ICON,
} from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
const BUTTONS = {
STOP_TRANSCRIPTION: {
label: "Stop",
tooltip: "Stop transcription",
iconPath: TRANSCRIPTION_STARTED_ICON,
iconPathDarkMode: TRANSCRIPTION_STARTED_ICON,
},
START_TRANSCRIPTION: {
label: "Cal.ai",
tooltip: "Transcription powered by AI",
iconPath: TRANSCRIPTION_STOPPED_ICON,
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
},
WAIT_FOR_TRANSCRIPTION_TO_START: {
label: "Starting..",
tooltip: "Please wait while we start transcription",
iconPath: TRANSCRIPTION_STOPPED_ICON,
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
},
WAIT_FOR_TRANSCRIPTION_TO_STOP: {
label: "Stopping..",
tooltip: "Please wait while we stop transcription",
iconPath: TRANSCRIPTION_STOPPED_ICON,
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
},
START_RECORDING: {
label: "Record",
tooltip: "Start recording",
iconPath: RECORDING_DEFAULT_ICON,
iconPathDarkMode: RECORDING_DEFAULT_ICON,
},
WAIT_FOR_RECORDING_TO_START: {
label: "Starting..",
tooltip: "Please wait while we start recording",
iconPath: RECORDING_DEFAULT_ICON,
iconPathDarkMode: RECORDING_DEFAULT_ICON,
},
WAIT_FOR_RECORDING_TO_STOP: {
label: "Stopping..",
tooltip: "Please wait while we stop recording",
iconPath: RECORDING_DEFAULT_ICON,
iconPathDarkMode: RECORDING_DEFAULT_ICON,
},
STOP_RECORDING: {
label: "Stop",
tooltip: "Stop recording",
iconPath: RECORDING_IN_PROGRESS_ICON,
iconPathDarkMode: RECORDING_IN_PROGRESS_ICON,
},
};
import { BUTTONS } from "./button-states";
export type DailyCustomTrayButtonVisualState = "default" | "sidebar-open" | "active";
@@ -70,46 +14,180 @@ export interface DailyCustomTrayButton {
tooltip: string;
visualState?: DailyCustomTrayButtonVisualState;
}
export const CalAiTranscribe = ({
type RecordingState = {
isRecording: boolean;
};
type TranscriptionState = {
isTranscribing: boolean;
};
type CalVideoCallbacksParams = {
daily: DailyCall | null;
recording: RecordingState | null;
transcription: TranscriptionState | null;
showRecordingButton: boolean;
showTranscriptionButton: boolean;
enableAutomaticTranscription: boolean;
enableAutomaticRecordingForOrganizer: boolean;
};
export const createCalVideoCallbacks = (params: CalVideoCallbacksParams) => {
const {
daily,
recording,
transcription,
showRecordingButton,
showTranscriptionButton,
enableAutomaticTranscription,
enableAutomaticRecordingForOrganizer,
} = params;
const startRecording = () => {
daily?.startRecording({
// 480p
videoBitrate: 2000,
});
};
const updateCustomTrayButtons = ({
recording: overrideRecording,
transcription: overrideTranscription,
}: {
recording?: DailyCustomTrayButton;
transcription?: DailyCustomTrayButton;
}) => {
const currentRecordingState = recording?.isRecording ? BUTTONS.STOP_RECORDING : BUTTONS.START_RECORDING;
const currentTranscriptionState = transcription?.isTranscribing
? BUTTONS.STOP_TRANSCRIPTION
: BUTTONS.START_TRANSCRIPTION;
daily?.updateCustomTrayButtons({
...(showRecordingButton
? {
recording: overrideRecording ?? currentRecordingState,
}
: {}),
...(showTranscriptionButton
? {
transcription: overrideTranscription ?? currentTranscriptionState,
}
: {}),
});
};
const onMeetingJoined = () => {
if (enableAutomaticTranscription && !transcription?.isTranscribing) {
daily?.startTranscription();
}
if (enableAutomaticRecordingForOrganizer && !recording?.isRecording) {
startRecording();
}
};
const onRecordingStarted = () => {
updateCustomTrayButtons({
recording: BUTTONS.STOP_RECORDING,
});
};
const onRecordingStopped = () => {
updateCustomTrayButtons({
recording: BUTTONS.START_RECORDING,
});
};
const onTranscriptionStarted = () => {
updateCustomTrayButtons({
transcription: BUTTONS.STOP_TRANSCRIPTION,
});
};
const onTranscriptionStopped = () => {
updateCustomTrayButtons({
transcription: BUTTONS.START_TRANSCRIPTION,
});
};
const toggleRecording = async () => {
if (recording?.isRecording) {
updateCustomTrayButtons({
recording: BUTTONS.WAIT_FOR_RECORDING_TO_STOP,
});
daily?.stopRecording();
} else {
updateCustomTrayButtons({
recording: BUTTONS.WAIT_FOR_RECORDING_TO_START,
});
startRecording();
}
};
const toggleTranscription = async () => {
if (transcription?.isTranscribing) {
updateCustomTrayButtons({
transcription: BUTTONS.WAIT_FOR_TRANSCRIPTION_TO_STOP,
});
daily?.stopTranscription();
} else {
updateCustomTrayButtons({
transcription: BUTTONS.WAIT_FOR_TRANSCRIPTION_TO_START,
});
daily?.startTranscription();
}
};
const onCustomButtonClick = async (ev: { button_id: string }) => {
if (ev?.button_id === "recording") {
toggleRecording();
} else if (ev?.button_id === "transcription") {
toggleTranscription();
}
};
return {
onMeetingJoined,
onRecordingStarted,
onRecordingStopped,
onTranscriptionStarted,
onTranscriptionStopped,
onCustomButtonClick,
toggleRecording,
toggleTranscription,
updateCustomTrayButtons,
startRecording,
};
};
export const CalVideoPremiumFeatures = ({
showRecordingButton,
enableAutomaticTranscription,
enableAutomaticRecordingForOrganizer,
showTranscriptionButton,
}: {
showRecordingButton: boolean;
enableAutomaticTranscription: boolean;
enableAutomaticRecordingForOrganizer: boolean;
showTranscriptionButton: boolean;
}) => {
const daily = useDaily();
const { t } = useLocale();
const [transcript, setTranscript] = useState("");
const [transcriptHeight, setTranscriptHeight] = useState(0);
const transcriptRef = useRef<HTMLDivElement | null>(null);
const transcription = useTranscription();
const recording = useRecording();
const updateCustomTrayButtons = ({
const callbacks = createCalVideoCallbacks({
daily,
recording,
transcription,
}: {
recording: DailyCustomTrayButton;
transcription: DailyCustomTrayButton;
}) => {
daily?.updateCustomTrayButtons({
...(showRecordingButton
? {
recording,
}
: {}),
...(showTranscriptionButton
? {
transcription,
}
: {}),
});
};
showRecordingButton,
showTranscriptionButton,
enableAutomaticTranscription,
enableAutomaticRecordingForOrganizer,
});
useDailyEvent(
"app-message",
@@ -119,94 +197,12 @@ export const CalAiTranscribe = ({
}, [])
);
useDailyEvent("joined-meeting", (ev) => {
if (enableAutomaticTranscription) {
daily?.startTranscription();
updateCustomTrayButtons({
recording: BUTTONS.START_RECORDING,
transcription: transcription?.isTranscribing
? BUTTONS.STOP_TRANSCRIPTION
: BUTTONS.START_TRANSCRIPTION,
});
}
});
useDailyEvent("transcription-started", (ev) => {
updateCustomTrayButtons({
recording: recording?.isRecording ? BUTTONS.STOP_RECORDING : BUTTONS.START_RECORDING,
transcription: BUTTONS.STOP_TRANSCRIPTION,
});
});
useDailyEvent("recording-started", (ev) => {
updateCustomTrayButtons({
recording: BUTTONS.STOP_RECORDING,
transcription: transcription?.isTranscribing ? BUTTONS.STOP_TRANSCRIPTION : BUTTONS.START_TRANSCRIPTION,
});
});
useDailyEvent("transcription-stopped", (ev) => {
updateCustomTrayButtons({
recording: recording?.isRecording ? BUTTONS.STOP_RECORDING : BUTTONS.START_RECORDING,
transcription: BUTTONS.START_TRANSCRIPTION,
});
});
useDailyEvent("recording-stopped", (ev) => {
updateCustomTrayButtons({
recording: BUTTONS.START_RECORDING,
transcription: transcription?.isTranscribing ? BUTTONS.STOP_TRANSCRIPTION : BUTTONS.START_TRANSCRIPTION,
});
});
const toggleRecording = async () => {
if (recording?.isRecording) {
updateCustomTrayButtons({
recording: BUTTONS.WAIT_FOR_RECORDING_TO_STOP,
transcription: transcription?.isTranscribing
? BUTTONS.STOP_TRANSCRIPTION
: BUTTONS.START_TRANSCRIPTION,
});
await daily?.stopRecording();
} else {
updateCustomTrayButtons({
recording: BUTTONS.WAIT_FOR_RECORDING_TO_START,
transcription: transcription?.isTranscribing
? BUTTONS.STOP_TRANSCRIPTION
: BUTTONS.START_TRANSCRIPTION,
});
await daily?.startRecording({
// 480p
videoBitrate: 2000,
});
}
};
const toggleTranscription = async () => {
if (transcription?.isTranscribing) {
updateCustomTrayButtons({
recording: recording?.isRecording ? BUTTONS.STOP_RECORDING : BUTTONS.START_RECORDING,
transcription: BUTTONS.WAIT_FOR_TRANSCRIPTION_TO_STOP,
});
daily?.stopTranscription();
} else {
updateCustomTrayButtons({
recording: recording?.isRecording ? BUTTONS.STOP_RECORDING : BUTTONS.START_RECORDING,
transcription: BUTTONS.WAIT_FOR_TRANSCRIPTION_TO_START,
});
daily?.startTranscription();
}
};
useDailyEvent("custom-button-click", async (ev) => {
if (ev?.button_id === "recording") {
toggleRecording();
} else if (ev?.button_id === "transcription") {
toggleTranscription();
}
});
useDailyEvent("joined-meeting", callbacks.onMeetingJoined);
useDailyEvent("transcription-started", callbacks.onTranscriptionStarted);
useDailyEvent("recording-started", callbacks.onRecordingStarted);
useDailyEvent("transcription-stopped", callbacks.onTranscriptionStopped);
useDailyEvent("recording-stopped", callbacks.onRecordingStopped);
useDailyEvent("custom-button-click", callbacks.onCustomButtonClick);
useLayoutEffect(() => {
const observer = new ResizeObserver((entries) => {
@@ -21,7 +21,7 @@ import { Icon } from "@calcom/ui/components/icon";
import type { getServerSideProps } from "@lib/video/[uid]/getServerSideProps";
import { CalAiTranscribe } from "~/videos/ai/ai-transcribe";
import { CalVideoPremiumFeatures } from "../cal-video-premium-features";
export type PageProps = inferSSRProps<typeof getServerSideProps>;
@@ -37,6 +37,7 @@ export default function JoinCall(props: PageProps) {
overrideName,
showRecordingButton,
enableAutomaticTranscription,
enableAutomaticRecordingForOrganizer,
showTranscriptionButton,
rediectAttendeeToOnExit,
} = props;
@@ -116,8 +117,9 @@ export default function JoinCall(props: PageProps) {
<div
className="mx-auto hidden sm:block"
style={{ zIndex: 2, left: "30%", position: "absolute", bottom: 100, width: "auto" }}>
<CalAiTranscribe
<CalVideoPremiumFeatures
showRecordingButton={showRecordingButton}
enableAutomaticRecordingForOrganizer={enableAutomaticRecordingForOrganizer}
enableAutomaticTranscription={enableAutomaticTranscription}
showTranscriptionButton={showTranscriptionButton}
/>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.4.19",
"version": "5.5.3",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+2 -2
View File
@@ -71,8 +71,8 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const redirectUrl = handler.redirect?.url ?? undefined;
res.json({ url: redirectUrl, newTab: handler.redirect?.newTab });
}
if (!res.writableEnded) return res.status(200);
return res;
if (!res.writableEnded) res.status(200);
return;
} catch (error) {
console.error(error);
if (error instanceof HttpError) {
+3
View File
@@ -84,6 +84,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
where: {
userId: parsedUserId,
accepted: true,
team: {
slug: { not: null },
},
},
select: {
teamId: true,
+91 -188
View File
@@ -17,6 +17,7 @@ import { MembershipRole, SchedulingType, TimeUnit, WorkflowTriggerEvents } from
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import type { Schedule } from "@calcom/types/schedule";
import { createRoutingForm } from "../lib/test-helpers/routingFormHelpers";
import { selectFirstAvailableTimeSlotNextMonth, teamEventSlug, teamEventTitle } from "../lib/testUtils";
import type { createEmailsFixture } from "./emails";
import { TimeZoneEnum } from "./types";
@@ -274,6 +275,7 @@ export const createUsersFixture = (
| null,
scenario: {
seedRoutingForms?: boolean;
seedRoutingFormWithAttributeRouting?: boolean;
hasTeam?: true;
numberOfTeams?: number;
teamRole?: MembershipRole;
@@ -341,192 +343,6 @@ export const createUsersFixture = (
});
}
if (scenario.seedRoutingForms) {
const multiSelectOption2Uuid = "d1302635-9f12-17b1-9153-c3a854649182";
const multiSelectOption1Uuid = "d1292635-9f12-17b1-9153-c3a854649182";
const selectOption1Uuid = "d0292635-9f12-17b1-9153-c3a854649182";
const selectOption2Uuid = "d0302635-9f12-17b1-9153-c3a854649182";
const multiSelectLegacyFieldUuid = "d4292635-9f12-17b1-9153-c3a854649182";
const multiSelectFieldUuid = "d9892635-9f12-17b1-9153-c3a854649182";
const selectFieldUuid = "d1302635-9f12-17b1-9153-c3a854649182";
const legacySelectFieldUuid = "f0292635-9f12-17b1-9153-c3a854649182";
await prisma.app_RoutingForms_Form.create({
data: {
routes: [
{
id: "8a898988-89ab-4cde-b012-31823f708642",
action: { type: "eventTypeRedirectUrl", value: "pro/30min" },
queryValue: {
id: "8a898988-89ab-4cde-b012-31823f708642",
type: "group",
children1: {
"8988bbb8-0123-4456-b89a-b1823f70c5ff": {
type: "rule",
properties: {
field: "c4296635-9f12-47b1-8153-c3a854649182",
value: ["event-routing"],
operator: "equal",
valueSrc: ["value"],
valueType: ["text"],
},
},
},
},
},
{
id: "aa8aaba9-cdef-4012-b456-71823f70f7ef",
action: { type: "customPageMessage", value: "Custom Page Result" },
queryValue: {
id: "aa8aaba9-cdef-4012-b456-71823f70f7ef",
type: "group",
children1: {
"b99b8a89-89ab-4cde-b012-31823f718ff5": {
type: "rule",
properties: {
field: "c4296635-9f12-47b1-8153-c3a854649182",
value: ["custom-page"],
operator: "equal",
valueSrc: ["value"],
valueType: ["text"],
},
},
},
},
},
{
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
action: { type: "externalRedirectUrl", value: `${WEBAPP_URL}/pro` },
queryValue: {
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
type: "group",
children1: {
"998b9b9a-0123-4456-b89a-b1823f7232b9": {
type: "rule",
properties: {
field: "c4296635-9f12-47b1-8153-c3a854649182",
value: ["external-redirect"],
operator: "equal",
valueSrc: ["value"],
valueType: ["text"],
},
},
},
},
},
{
id: "aa8ba8b9-0123-4456-b89a-b182623406d8",
action: { type: "customPageMessage", value: "Multiselect(Legacy) chosen" },
queryValue: {
id: "aa8ba8b9-0123-4456-b89a-b182623406d8",
type: "group",
children1: {
"b98a8abb-cdef-4012-b456-718262343d27": {
type: "rule",
properties: {
field: multiSelectLegacyFieldUuid,
value: [["Option-2"]],
operator: "multiselect_equals",
valueSrc: ["value"],
valueType: ["multiselect"],
},
},
},
},
},
{
id: "bb9ea8b9-0123-4456-b89a-b182623406d8",
action: { type: "customPageMessage", value: "Multiselect chosen" },
queryValue: {
id: "aa8ba8b9-0123-4456-b89a-b182623406d8",
type: "group",
children1: {
"b98a8abb-cdef-4012-b456-718262343d27": {
type: "rule",
properties: {
field: multiSelectFieldUuid,
value: [[multiSelectOption2Uuid]],
operator: "multiselect_equals",
valueSrc: ["value"],
valueType: ["multiselect"],
},
},
},
},
},
{
id: "898899aa-4567-489a-bcde-f1823f708646",
action: { type: "customPageMessage", value: "Fallback Message" },
isFallback: true,
queryValue: { id: "898899aa-4567-489a-bcde-f1823f708646", type: "group" },
},
],
fields: [
{
id: "c4296635-9f12-47b1-8153-c3a854649182",
type: "text",
label: "Test field",
required: true,
},
{
id: multiSelectLegacyFieldUuid,
type: "multiselect",
label: "Multi Select(with Legacy `selectText`)",
identifier: "multi",
selectText: "Option-1\nOption-2",
required: false,
},
{
id: multiSelectFieldUuid,
type: "multiselect",
label: "Multi Select",
identifier: "multi-new-format",
options: [
{
id: multiSelectOption1Uuid,
label: "Option-1",
},
{
id: multiSelectOption2Uuid,
label: "Option-2",
},
],
required: false,
},
{
id: legacySelectFieldUuid,
type: "select",
label: "Legacy Select",
identifier: "test-select",
selectText: "Option-1\nOption-2",
required: false,
},
{
id: selectFieldUuid,
type: "select",
label: "Select",
identifier: "test-select-new-format",
options: [
{
id: selectOption1Uuid,
label: "Option-1",
},
{
id: selectOption2Uuid,
label: "Option-2",
},
],
required: false,
},
],
user: {
connect: {
id: _user.id,
},
},
name: seededForm.name,
},
});
}
const user = await prisma.user.findUniqueOrThrow({
where: { id: _user.id },
include: userIncludes,
@@ -664,7 +480,94 @@ export const createUsersFixture = (
}
}
}
const userFixture = createUserFixture(user, store.page);
if (scenario.seedRoutingForms) {
const firstTeamMembership = await prisma.membership.findFirstOrThrow({
where: {
userId: _user.id,
team: {
isOrganization: false,
},
},
});
if (!firstTeamMembership) {
throw new Error("No sub-team created");
}
await createRoutingForm({
userId: _user.id,
teamId: firstTeamMembership.teamId,
formType: scenario.seedRoutingFormWithAttributeRouting ? "attributeRouting" : "default",
...(scenario.seedRoutingFormWithAttributeRouting && {
attributeRouting: {
attributes: [
{
name: "Department",
type: "SINGLE_SELECT" as const,
options: ["Engineering", "Sales", "Marketing", "Product", "Design"],
},
{
name: "Location",
type: "SINGLE_SELECT" as const,
options: ["New York", "London", "Tokyo", "Berlin", "Remote"],
},
{
name: "Skills",
type: "MULTI_SELECT" as const,
options: ["JavaScript", "React", "Node.js", "Python", "Design", "Sales"],
},
{
name: "Years of Experience",
type: "NUMBER" as const,
},
{
name: "Bio",
type: "TEXT" as const,
},
],
assignments: [
{
memberIndex: 0,
attributeValues: {
Location: ["New York"],
Skills: ["JavaScript"],
},
},
{
memberIndex: 1,
attributeValues: {
Location: ["London"],
Skills: ["React", "JavaScript"],
},
},
],
teamEvents: [
{
title: "Team Sales",
slug: "team-sales",
schedulingType: "ROUND_ROBIN",
assignAllTeamMembers: true,
length: 60,
description: "Team Sales",
},
{
title: "Team Javascript",
slug: "team-javascript",
schedulingType: "ROUND_ROBIN",
assignAllTeamMembers: true,
length: 60,
description: "Team Javascript",
},
],
},
}),
});
}
const finalUser = await prisma.user.findUniqueOrThrow({
where: { id: user.id },
include: userIncludes,
});
const userFixture = createUserFixture(finalUser, store.page);
store.users.push(userFixture);
return userFixture;
},
@@ -781,7 +684,7 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
getFirstTeamMembership: async () => {
const memberships = await prisma.membership.findMany({
where: { userId: user.id },
include: { team: true },
include: { team: true, user: true },
});
const membership = memberships
@@ -0,0 +1,334 @@
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
import { expect, describe, it, beforeEach, afterEach, vi } from "vitest";
import { createAttributes } from "../organizationHelpers";
// Mock console.log to avoid noise in tests
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {
// Intentionally empty to suppress console output in tests
});
// Default mock attributes matching the original hardcoded values
const mockAttributes = [
{
name: "Department",
type: "SINGLE_SELECT" as const,
options: ["Engineering", "Sales", "Marketing", "Product", "Design"],
},
{
name: "Location",
type: "SINGLE_SELECT" as const,
options: ["New York", "London", "Tokyo", "Berlin", "Remote"],
},
{
name: "Skills",
type: "MULTI_SELECT" as const,
options: ["JavaScript", "React", "Node.js", "Python", "Design", "Sales"],
},
{
name: "Years of Experience",
type: "NUMBER" as const,
},
{
name: "Bio",
type: "TEXT" as const,
},
];
// Default assignments matching the original hardcoded behavior
const mockAssignments = [
{
memberIndex: 0,
attributeValues: {
Location: ["New York"],
Skills: ["JavaScript"],
},
},
{
memberIndex: 1,
attributeValues: {
Location: ["London"],
Skills: ["React", "JavaScript"],
},
},
];
describe("createAttributes", () => {
beforeEach(async () => {
// Reset prismock state between tests
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (prismock as any).reset();
consoleSpy.mockClear();
});
afterEach(async () => {
consoleSpy.mockRestore();
});
it("should create all 5 mock attributes for an organization", async () => {
const orgId = 1;
// Create a membership for the organization
await prismock.membership.create({
data: {
id: 1,
userId: 1,
teamId: orgId,
role: "OWNER",
accepted: true,
},
});
const result = await createAttributes({
orgId,
attributes: mockAttributes,
assignments: mockAssignments,
});
// Verify all attributes were created
const attributes = await prismock.attribute.findMany({
where: { teamId: orgId },
include: { options: true },
});
expect(attributes).toHaveLength(5);
expect(result).toHaveLength(5);
// Check specific attributes
const departmentAttr = attributes.find((attr) => attr.name === "Department");
const locationAttr = attributes.find((attr) => attr.name === "Location");
const skillsAttr = attributes.find((attr) => attr.name === "Skills");
const experienceAttr = attributes.find((attr) => attr.name === "Years of Experience");
const bioAttr = attributes.find((attr) => attr.name === "Bio");
expect(departmentAttr).toBeDefined();
expect(departmentAttr?.type).toBe("SINGLE_SELECT");
expect(departmentAttr?.options).toHaveLength(5);
expect(locationAttr).toBeDefined();
expect(locationAttr?.type).toBe("SINGLE_SELECT");
expect(locationAttr?.options).toHaveLength(5);
expect(skillsAttr).toBeDefined();
expect(skillsAttr?.type).toBe("MULTI_SELECT");
expect(skillsAttr?.options).toHaveLength(6);
expect(experienceAttr).toBeDefined();
expect(experienceAttr?.type).toBe("NUMBER");
expect(experienceAttr?.options).toHaveLength(0); // No options created for NUMBER type
expect(bioAttr).toBeDefined();
expect(bioAttr?.type).toBe("TEXT");
expect(bioAttr?.options).toHaveLength(0); // No options created for TEXT type
});
it("should assign attribute values to all organization members", async () => {
const orgId = 1;
// Create multiple memberships
await prismock.membership.createMany({
data: [
{ id: 1, userId: 1, teamId: orgId, role: "OWNER", accepted: true },
{ id: 2, userId: 2, teamId: orgId, role: "MEMBER", accepted: true },
{ id: 3, userId: 3, teamId: orgId, role: "MEMBER", accepted: true },
],
});
await createAttributes({
orgId,
attributes: mockAttributes,
assignments: mockAssignments,
});
// Check that attribute assignments were created
const attributeAssignments = await prismock.attributeToUser.findMany();
const attributeOptions = await prismock.attributeOption.findMany({
include: { assignedUsers: true },
});
// Should have assignments only for specific first two members
// Member 1: Location (New York) + Skills (JavaScript) = 2 assignments
// Member 2: Location (London) + Skills (React + JavaScript) = 3 assignments
// Total = 5 assignments (no random assignments for other members/attributes)
expect(attributeAssignments.length).toBe(5);
// Should have 4 options with assigned users:
// Location: New York (assigned to user 1) + London (assigned to user 2) = 2 options
// Skills: JavaScript (assigned to user 1) + React & JavaScript (assigned to user 2) = 2 unique options
const customOptions = attributeOptions.filter((opt) => opt.assignedUsers.length > 0);
expect(customOptions.length).toBe(4);
});
it("should skip seeding if attributes already exist", async () => {
const orgId = 1;
// Create a membership
await prismock.membership.create({
data: {
id: 1,
userId: 1,
teamId: orgId,
role: "OWNER",
accepted: true,
},
});
// Pre-create one of the attributes
await prismock.attribute.create({
data: {
id: "attr-1",
name: "Department",
slug: "department",
type: "SINGLE_SELECT",
teamId: orgId,
enabled: true,
},
});
const result = await createAttributes({
orgId,
attributes: mockAttributes,
assignments: mockAssignments,
});
// Should return early and not create more attributes
expect(result).toBeUndefined();
const attributes = await prismock.attribute.findMany({
where: { teamId: orgId },
});
// Should still only have the 1 pre-existing attribute
expect(attributes).toHaveLength(1);
});
it("should handle organization with no members gracefully", async () => {
const orgId = 1;
// Don't create any memberships
const result = await createAttributes({
orgId,
attributes: mockAttributes,
assignments: mockAssignments,
});
// Should still create attributes
const attributes = await prismock.attribute.findMany({
where: { teamId: orgId },
});
expect(attributes).toHaveLength(5);
expect(result).toHaveLength(5);
// But no attribute assignments should be created
const attributeAssignments = await prismock.attributeToUser.findMany();
expect(attributeAssignments).toHaveLength(0);
});
it("should assign specific skills and locations to first two members", async () => {
const orgId = 1;
// Create exactly two memberships to test specific assignments
await prismock.membership.createMany({
data: [
{ id: 1, userId: 1, teamId: orgId, role: "OWNER", accepted: true },
{ id: 2, userId: 2, teamId: orgId, role: "MEMBER", accepted: true },
],
});
await createAttributes({
orgId,
attributes: mockAttributes,
assignments: mockAssignments,
});
// Get the attributes and their options
const skillsAttr = await prismock.attribute.findFirst({
where: { teamId: orgId, name: "Skills" },
include: { options: true },
});
const locationAttr = await prismock.attribute.findFirst({
where: { teamId: orgId, name: "Location" },
include: { options: true },
});
expect(skillsAttr).toBeDefined();
expect(locationAttr).toBeDefined();
// Check assignments for first member (should have JavaScript skill and New York location)
const firstMemberAssignments = await prismock.attributeToUser.findMany({
where: { memberId: 1 },
include: { attributeOption: true },
});
const firstMemberSkills = firstMemberAssignments
.filter((a) => a.attributeOption.attributeId === skillsAttr!.id)
.map((a) => a.attributeOption.value);
const firstMemberLocation = firstMemberAssignments
.filter((a) => a.attributeOption.attributeId === locationAttr!.id)
.map((a) => a.attributeOption.value);
expect(firstMemberSkills).toContain("JavaScript");
expect(firstMemberLocation).toContain("New York");
// Check assignments for second member (should have React and JavaScript skills, London location)
const secondMemberAssignments = await prismock.attributeToUser.findMany({
where: { memberId: 2 },
include: { attributeOption: true },
});
const secondMemberSkills = secondMemberAssignments
.filter((a) => a.attributeOption.attributeId === skillsAttr!.id)
.map((a) => a.attributeOption.value);
const secondMemberLocation = secondMemberAssignments
.filter((a) => a.attributeOption.attributeId === locationAttr!.id)
.map((a) => a.attributeOption.value);
expect(secondMemberSkills).toContain("JavaScript");
expect(secondMemberSkills).toContain("React");
expect(secondMemberLocation).toContain("London");
});
it("should generate correct slugs and enable all attributes", async () => {
const orgId = 123;
// Create a membership
await prismock.membership.create({
data: {
id: 1,
userId: 1,
teamId: orgId,
role: "OWNER",
accepted: true,
},
});
await createAttributes({
orgId,
attributes: mockAttributes,
assignments: mockAssignments,
});
const attributes = await prismock.attribute.findMany({
where: { teamId: orgId },
});
attributes.forEach((attr) => {
// All should be enabled
expect(attr.enabled).toBe(true);
// Should have correct slug format
expect(attr.slug).toMatch(/^org:123-/);
// Specific slug checks
if (attr.name === "Years of Experience") {
expect(attr.slug).toBe("org:123-years-of-experience");
}
});
});
});
@@ -0,0 +1,377 @@
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createRoutingForm } from "../routingFormHelpers";
// Mock console.log to avoid noise in tests
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {
// Intentionally empty to suppress console output during tests
});
describe("createRoutingForm", () => {
beforeEach(async () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
await prismock.reset();
consoleSpy.mockClear();
});
// Common attribute routing config for tests
const defaultAttributeRoutingConfig = {
attributes: [
{
name: "Department",
type: "SINGLE_SELECT" as const,
options: ["Engineering", "Sales", "Marketing", "Product", "Design"],
},
{
name: "Location",
type: "SINGLE_SELECT" as const,
options: ["New York", "London", "Tokyo", "Berlin", "Remote"],
},
{
name: "Skills",
type: "MULTI_SELECT" as const,
options: ["JavaScript", "React", "Node.js", "Python", "Design", "Sales"],
},
{
name: "Years of Experience",
type: "NUMBER" as const,
},
{
name: "Bio",
type: "TEXT" as const,
},
],
assignments: [
{
memberIndex: 0,
attributeValues: {
Location: ["New York"],
Skills: ["JavaScript"],
},
},
{
memberIndex: 1,
attributeValues: {
Location: ["London"],
Skills: ["React", "JavaScript"],
},
},
],
teamEvents: [
{
title: "Team Sales",
slug: "team-sales",
schedulingType: "ROUND_ROBIN" as const,
assignAllTeamMembers: true,
length: 60,
description: "Team Sales",
},
{
title: "Team Javascript",
slug: "team-javascript",
schedulingType: "ROUND_ROBIN" as const,
assignAllTeamMembers: true,
length: 60,
description: "Team Javascript",
},
],
};
describe("Default routing form", () => {
beforeEach(async () => {
// Create a test user for default form tests
await prismock.user.create({
data: {
id: 1,
username: "test-user",
email: "test@example.com",
name: "Test User",
},
});
});
it("should create a basic routing form", async () => {
const userId = 1;
const teamId = 1;
const form = await createRoutingForm({
userId,
teamId,
formType: "default",
});
expect(form).toBeDefined();
expect(form.name).toBe("Fixture Routing Form");
expect(form.userId).toBe(userId);
});
it("should create form with multiple route types", async () => {
const userId = 1;
const teamId = 1;
const form = await createRoutingForm({
userId,
teamId,
formType: "default",
});
expect(form.routes).toBeDefined();
const routes = form.routes as any[];
// Should have event redirect, custom page, external redirect, and multiselect routes
expect(routes.length).toBeGreaterThan(4);
const eventRedirectRoute = routes.find((r) => r.action?.type === "eventTypeRedirectUrl");
const customPageRoute = routes.find((r) => r.action?.type === "customPageMessage");
const externalRedirectRoute = routes.find((r) => r.action?.type === "externalRedirectUrl");
const fallbackRoute = routes.find((r) => r.isFallback === true);
expect(eventRedirectRoute).toBeDefined();
expect(customPageRoute).toBeDefined();
expect(externalRedirectRoute).toBeDefined();
expect(fallbackRoute).toBeDefined();
});
it("should create form with various field types", async () => {
const userId = 1;
const teamId = 1;
const form = await createRoutingForm({
userId,
teamId,
formType: "default",
});
const fields = form.fields as any[];
expect(fields).toBeDefined();
expect(fields.length).toBeGreaterThan(3);
const textField = fields.find((f) => f.type === "text");
const multiselectField = fields.find((f) => f.type === "multiselect");
const selectField = fields.find((f) => f.type === "select");
expect(textField).toBeDefined();
expect(multiselectField).toBeDefined();
expect(selectField).toBeDefined();
});
});
describe("Attribute routing form", () => {
beforeEach(async () => {
// Create a test user
await prismock.user.create({
data: {
id: 1,
username: "test-user",
email: "test@example.com",
name: "Test User",
},
});
// Setup organization and team structure for attribute routing
await prismock.team.createMany({
data: [
{ id: 100, name: "Test Org", isOrganization: true, slug: "test-org" },
{ id: 1, name: "Test Team", isOrganization: false, slug: "test-team", parentId: 100 },
],
});
await prismock.membership.createMany({
data: [
{ id: 1, userId: 1, teamId: 100, role: "OWNER", accepted: true }, // Org membership
{ id: 2, userId: 1, teamId: 1, role: "OWNER", accepted: true }, // Team membership
],
});
});
it("should create routing form with attribute routing", async () => {
const userId = 1;
const teamId = 1;
const result = await createRoutingForm({
userId,
teamId,
formType: "attributeRouting",
attributeRouting: defaultAttributeRoutingConfig,
});
expect(result).toBeDefined();
expect(result!.name).toBe("Form with Attribute Routing");
expect(result!.userId).toBe(userId);
expect(result!.teamId).toBe(teamId);
});
it("should create team event types for attribute routing", async () => {
const userId = 1;
const teamId = 1;
await createRoutingForm({
userId,
teamId,
formType: "attributeRouting",
attributeRouting: defaultAttributeRoutingConfig,
});
// Check that team event types were created
const salesEvent = await prismock.eventType.findFirst({
where: { slug: "team-sales", teamId },
});
const javascriptEvent = await prismock.eventType.findFirst({
where: { slug: "team-javascript", teamId },
});
expect(salesEvent).toBeDefined();
expect(salesEvent?.title).toBe("Team Sales");
expect(salesEvent?.schedulingType).toBe("ROUND_ROBIN");
expect(javascriptEvent).toBeDefined();
expect(javascriptEvent?.title).toBe("Team Javascript");
expect(javascriptEvent?.schedulingType).toBe("ROUND_ROBIN");
});
it("should create attributes for the organization", async () => {
const userId = 1;
const teamId = 1;
await createRoutingForm({
userId,
teamId,
formType: "attributeRouting",
attributeRouting: defaultAttributeRoutingConfig,
});
// Check that attributes were created for the organization
const attributes = await prismock.attribute.findMany({
where: { teamId: 100 }, // Organization ID
include: { options: true },
});
expect(attributes.length).toBeGreaterThan(0);
const skillsAttribute = attributes.find((attr) => attr.name === "Skills");
const locationAttribute = attributes.find((attr) => attr.name === "Location");
expect(skillsAttribute).toBeDefined();
expect(locationAttribute).toBeDefined();
});
it("should create routes with attribute-based routing logic", async () => {
const userId = 1;
const teamId = 1;
const result = await createRoutingForm({
userId,
teamId,
formType: "attributeRouting",
attributeRouting: defaultAttributeRoutingConfig,
});
const routes = result!.routes as any[];
expect(routes).toBeDefined();
expect(routes.length).toBeGreaterThanOrEqual(3);
// Should have routes with attributesQueryValue
const attributeRoutes = routes.filter((route) => route.attributesQueryValue);
expect(attributeRoutes.length).toBeGreaterThanOrEqual(2);
// Should have fallback route
const fallbackRoute = routes.find((route) => route.isFallback === true);
expect(fallbackRoute).toBeDefined();
});
it("should create form fields matching attributes", async () => {
const userId = 1;
const teamId = 1;
const result = await createRoutingForm({
userId,
teamId,
formType: "attributeRouting",
attributeRouting: defaultAttributeRoutingConfig,
});
const fields = result!.fields as any[];
expect(fields).toBeDefined();
expect(fields.length).toBe(5);
const locationField = fields.find((f) => f.label === "Location");
const skillsField = fields.find((f) => f.label === "skills");
const emailField = fields.find((f) => f.label === "Email");
const nameField = fields.find((f) => f.label === "Name");
const ratingField = fields.find((f) => f.label === "Rating");
expect(locationField?.type).toBe("select");
expect(skillsField?.type).toBe("multiselect");
expect(emailField?.type).toBe("email");
expect(nameField?.type).toBe("text");
expect(ratingField?.type).toBe("number");
// Check required fields
expect(locationField?.required).toBe(true);
expect(skillsField?.required).toBe(true);
expect(emailField?.required).toBe(true);
expect(nameField?.required).toBe(false);
expect(ratingField?.required).toBe(false);
});
it("should handle missing attributeRouting config", async () => {
const userId = 1;
const teamId = 1;
await expect(
createRoutingForm({
userId,
teamId,
formType: "attributeRouting",
})
).rejects.toThrow("attributeRouting config is required when formType is 'attributeRouting'");
});
it("should handle missing organization membership", async () => {
const userId = 2; // User without org membership
const teamId = 1;
// Create a test user without org membership
await prismock.user.create({
data: {
id: 2,
username: "test-user-2",
email: "test2@example.com",
name: "Test User 2",
},
});
// Only create team membership, not org membership
await prismock.membership.create({
data: { id: 3, userId: 2, teamId: 1, role: "MEMBER", accepted: true },
});
await expect(
createRoutingForm({
userId,
teamId,
formType: "attributeRouting",
attributeRouting: defaultAttributeRoutingConfig,
})
).rejects.toThrow();
});
it("should handle missing team", async () => {
const userId = 1;
const teamId = 999; // Non-existent team
await expect(
createRoutingForm({
userId,
teamId,
formType: "attributeRouting",
attributeRouting: defaultAttributeRoutingConfig,
})
).rejects.toThrow();
});
});
});
@@ -0,0 +1,177 @@
import { prisma } from "@calcom/prisma";
/**
* Example usage:
*
* // Basic usage with default assignments
* await createAttributes({
* orgId: 1,
* attributes: [
* {
* name: "Department",
* type: "SINGLE_SELECT",
* options: ["Engineering", "Sales", "Marketing"]
* }
* ],
* assignments: [
* {
* memberIndex: 0,
* attributeValues: {
* "Department": ["Engineering"]
* }
* }
* ]
* });
*
* // Create attributes without assignments
* await createAttributes({
* orgId: 1,
* attributes: [
* { name: "Bio", type: "TEXT" },
* { name: "Years of Experience", type: "NUMBER" }
* ]
* });
*/
interface AttributeConfig {
name: string;
type: "SINGLE_SELECT" | "MULTI_SELECT" | "TEXT" | "NUMBER";
options?: string[];
}
interface MemberAssignment {
memberIndex: number;
attributeValues: Record<string, string[]>; // attribute name -> selected values
}
interface SeedAttributesConfig {
orgId: number;
attributes: AttributeConfig[];
assignments?: MemberAssignment[];
}
/**
* Seeds attributes for an organization with configurable types, values, and assignments
* @param config - Configuration object containing orgId, attributes, and optional assignments
* @returns Array of created attributes with their options
*/
export async function createAttributes(config: SeedAttributesConfig) {
const { orgId, attributes: attributeConfigs, assignments = [] } = config;
console.log(`🎯 Seeding attributes for org ${orgId}`);
// Check if attributes already exist
const existingAttributes = await prisma.attribute.findMany({
where: {
teamId: orgId,
name: {
in: attributeConfigs.map((attr) => attr.name),
},
},
});
if (existingAttributes.length > 0) {
console.log(`Skipping attributes seed, attributes already exist`);
return;
}
const memberships = await prisma.membership.findMany({
where: {
teamId: orgId,
},
select: {
id: true,
userId: true,
},
});
console.log(`🎯 Creating attributes for org ${orgId}`);
const attributes: { id: string; name: string; options: { id: string; value: string }[] }[] = [];
for (const attr of attributeConfigs) {
const attribute = await prisma.attribute.create({
data: {
name: attr.name,
slug: `org:${orgId}-${attr.name.toLowerCase().replace(/ /g, "-")}`,
type: attr.type as "TEXT" | "NUMBER" | "SINGLE_SELECT" | "MULTI_SELECT",
teamId: orgId,
enabled: true,
options: attr.options
? {
create: attr.options.map((opt) => ({
value: opt,
slug: opt.toLowerCase().replace(/ /g, "-"),
})),
}
: undefined,
},
include: {
options: true,
},
});
attributes.push({
id: attribute.id,
name: attribute.name,
options: attribute.options.map((opt) => ({
id: opt.id,
value: opt.value,
})),
});
console.log(`\t📝 Created attribute: ${attr.name}`);
// Process assignments for this attribute
for (const assignment of assignments) {
const { memberIndex, attributeValues } = assignment;
// Check if this assignment has values for the current attribute
const valuesForAttribute = attributeValues[attr.name];
if (!valuesForAttribute || valuesForAttribute.length === 0) {
console.log(
`\t\t⏭️ Skipped ${attr.name} assignment for member ${memberIndex + 1} - no values specified`
);
continue;
}
// Get the member at the specified index
const member = memberships[memberIndex];
if (!member) {
console.log(`\t\t⚠️ Skipped ${attr.name} assignment - member at index ${memberIndex} not found`);
continue;
}
// Find the options for the specified values
const selectedOptions = attribute.options.filter((opt) => valuesForAttribute.includes(opt.value));
if (selectedOptions.length === 0) {
console.log(
`\t\t⚠️ Skipped ${attr.name} assignment for user ${
member.userId
} - no matching options found for values: ${valuesForAttribute.join(", ")}`
);
continue;
}
// Create assignments for each selected option
for (const option of selectedOptions) {
await prisma.attributeToUser.create({
data: {
memberId: member.id,
attributeOptionId: option.id,
},
});
}
const assignedValues = selectedOptions.map((opt) => opt.value);
console.log(
`\t\t✅ Assigned ${attr.name} [${assignedValues.map((v) => `"${v}"`).join(", ")}] to user ${
member.userId
} (member ${memberIndex + 1})`
);
}
}
return attributes;
}
@@ -0,0 +1,559 @@
import { uuid } from "short-uuid";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { prisma } from "@calcom/prisma";
import { createAttributes } from "./organizationHelpers";
import { createRoundRobinTeamEventType } from "./teamHelpers";
/**
* Configuration for form fields
*/
interface FieldConfig {
id?: string;
type: "text" | "email" | "select" | "multiselect" | "number";
label: string;
identifier?: string;
required?: boolean;
options?: { id: string; label: string }[];
selectText?: string;
}
/**
* Configuration for form routes
*/
interface RouteConfig {
id?: string;
action: {
type: "eventTypeRedirectUrl" | "customPageMessage" | "externalRedirectUrl";
value: string;
};
isFallback?: boolean;
queryValue?: any;
attributesQueryValue?: any;
fallbackAttributesQueryValue?: any;
}
/**
* Configuration for team events in attribute routing
*/
interface TeamEventConfig {
title: string;
slug: string;
schedulingType?: "ROUND_ROBIN" | "COLLECTIVE";
assignAllTeamMembers?: boolean;
length?: number;
description?: string;
}
/**
* Configuration for creating test routing forms
*/
interface CreateRoutingFormConfig {
userId: number;
teamId: number;
/**
* Whether to setup with attribute routing or not
*/
formType: "default" | "attributeRouting";
// Basic form settings
form?: {
name?: string;
description?: string;
fields?: FieldConfig[];
routes?: RouteConfig[];
};
// For attribute routing (required when formType is "attributeRouting")
attributeRouting?: {
orgId?: number; // If not provided, will find from userId
attributes: {
name: string;
type: "SINGLE_SELECT" | "MULTI_SELECT" | "TEXT" | "NUMBER";
options?: string[];
}[];
assignments: {
memberIndex: number;
attributeValues: Record<string, string[]>;
}[];
teamEvents: TeamEventConfig[];
};
}
/**
* Creates a test routing form with configurable options
*
* The routes are not configurable at the moment
* The hardcoded routes are:
* - Route-1: Skills:Javascript routes to team/team-javascript selecting only those team members who have JavaScript in their skills
* - Route-2: Skills:Sales routes to team/team-sales selecting only those team members who have Sales in their skills
* - Route-3: Fallback Message
*
* The hardcoded attributes are:
* - Location
* - Skills
* @param config - Configuration object containing all routing form settings
* @returns The created routing form
*/
export async function createRoutingForm(config: CreateRoutingFormConfig) {
const { userId, teamId, formType } = config;
if (formType === "attributeRouting") {
// Validate required config
if (!config.attributeRouting) {
throw new Error("attributeRouting config is required when formType is 'attributeRouting'");
}
// Find org membership
const orgMembership = await prisma.membership.findFirstOrThrow({
where: {
userId,
team: {
isOrganization: true,
},
},
});
if (!orgMembership) {
throw new Error("Organization membership not found");
}
const orgId = config.attributeRouting.orgId || orgMembership.teamId;
const team = await prisma.team.findUniqueOrThrow({
where: {
id: teamId,
},
});
// Use provided team events configuration
const teamEventsConfig = config.attributeRouting.teamEvents;
// Create team events
const teamEvents: { [key: string]: any } = {};
for (const eventConfig of teamEventsConfig) {
const eventType = await createRoundRobinTeamEventType({
teamId,
eventType: { ...eventConfig, length: eventConfig.length || 60 },
});
teamEvents[eventConfig.slug] = eventType;
}
// Use provided attributes and assignments configuration
const attributes = await createAttributes({
orgId,
attributes: config.attributeRouting.attributes,
assignments: config.attributeRouting.assignments,
});
if (!attributes) {
throw new Error("Attributes not found");
}
const skillAttribute = attributes.find((attr) => attr.name === "Skills")!;
const locationAttribute = attributes.find((attr) => attr.name === "Location")!;
console.log("Attributes", JSON.stringify(attributes, null, 2));
const javascriptEventRoute = {
id: "8a898988-89ab-4cde-b012-31823f708642",
value: `team/${team.slug}/team-javascript`,
};
const salesEventRoute = {
id: "8b2224b2-89ab-4cde-b012-31823f708642",
value: `team/${team.slug}/team-sales`,
};
const form = {
name: config.form?.name || "Form with Attribute Routing",
routes: [javascriptEventRoute, salesEventRoute],
formFieldLocation: {
id: "674c169a-e40a-492c-b4bb-6f5213873bd6",
},
formFieldSkills: {
id: "83316968-45bf-4c9d-b5d4-5368a8d2d2a8",
},
formFieldEmail: {
id: "dd28ffcf-7029-401e-bddb-ce2e7496a1c1",
},
formFieldName: {
id: "57734f65-8bbb-4065-9e71-fb7f0b7485f8",
},
formFieldRating: {
id: "f4e9fa6c-5c5d-4d8e-b15c-7f37e9d0c729",
},
};
const attributeRaw = {
location: {
id: "location-id",
options: [
{ id: "Location-London-uuid", value: "London" },
{ id: "Location-New-York-uuid", value: "New York" },
],
},
skills: {
id: "skills-id",
options: [
{ id: "Skills-JavaScript-uuid", value: "JavaScript" },
{ id: "Skills-Sales-uuid", value: "Sales" },
],
},
};
const formFieldSkillsOptions = attributeRaw.skills.options.map((opt) => ({
id: opt.id,
label: opt.value,
}));
const formFieldLocationOptions = attributeRaw.location.options.map((opt) => ({
id: opt.id,
label: opt.value,
}));
const createdForm = await prisma.app_RoutingForms_Form.create({
data: {
id: uuid(),
routes: [
{
id: javascriptEventRoute.id,
action: {
type: "eventTypeRedirectUrl",
value: javascriptEventRoute.value,
},
queryValue: {
id: "aaba9988-cdef-4012-b456-719300f53ef8",
type: "group",
children1: {
"b98b98a8-0123-4456-b89a-b19300f55277": {
type: "rule",
properties: {
field: form.formFieldSkills.id,
value: [
formFieldSkillsOptions.filter((opt) => opt.label === "JavaScript").map((opt) => opt.id),
],
operator: "multiselect_equals",
valueSrc: ["value"],
valueType: ["multiselect"],
valueError: [null],
},
},
},
},
attributesQueryValue: {
id: "ab99bbb9-89ab-4cde-b012-319300f53ef8",
type: "group",
children1: {
"b98b98a8-0123-4456-b89a-b19300f55277": {
type: "rule",
properties: {
field: skillAttribute.id,
value: [
skillAttribute.options.filter((opt) => opt.value === "JavaScript").map((opt) => opt.id),
],
operator: "multiselect_some_in",
valueSrc: ["value"],
valueType: ["multiselect"],
valueError: [null],
},
},
},
},
},
{
id: salesEventRoute.id,
action: {
type: "eventTypeRedirectUrl",
value: salesEventRoute.value,
},
queryValue: {
id: "aaba9948-cdef-4012-b456-719300f53ef8",
type: "group",
children1: {
"c98b98a8-1123-4456-e89a-a19300f55277": {
type: "rule",
properties: {
field: form.formFieldSkills.id,
value: [
formFieldSkillsOptions.filter((opt) => opt.label === "Sales").map((opt) => opt.id),
],
operator: "multiselect_equals",
valueSrc: ["value"],
valueType: ["multiselect"],
valueError: [null],
},
},
},
},
attributesQueryValue: {
id: "ab988888-89ab-4cde-b012-319300f53ef8",
type: "group",
children1: {
"b98b98a12-0123-4456-b89a-b19300f55277": {
type: "rule",
properties: {
field: skillAttribute.id,
value: [
skillAttribute.options.filter((opt) => opt.value === "Sales").map((opt) => opt.id),
],
operator: "multiselect_some_in",
valueSrc: ["value"],
valueType: ["multiselect"],
valueError: [null],
},
},
},
},
fallbackAttributesQueryValue: {
id: "a9888488-4567-489a-bcde-f19300f53ef8",
type: "group",
},
},
{
id: "148899aa-4567-489a-bcde-f1823f708646",
action: { type: "customPageMessage", value: "Fallback Message" },
isFallback: true,
queryValue: { id: "814899aa-4567-489a-bcde-f1823f708646", type: "group" },
},
],
fields: config.form?.fields || [
{
id: form.formFieldLocation.id,
type: "select",
label: "Location",
options: formFieldLocationOptions,
required: true,
},
{
id: form.formFieldSkills.id,
type: "multiselect",
label: "skills",
options: formFieldSkillsOptions,
required: true,
},
{
id: form.formFieldEmail.id,
type: "email",
label: "Email",
required: true,
},
{
id: form.formFieldName.id,
identifier: "name",
type: "text",
label: "Name",
required: false,
},
{
id: form.formFieldRating.id,
type: "number",
label: "Rating",
required: false,
},
],
team: {
connect: {
id: teamId,
},
},
user: {
connect: {
id: userId,
},
},
name: form.name,
},
});
console.log(`🎯 Created form ${createdForm.id}`, JSON.stringify(createdForm, null, 2));
return createdForm;
} else {
// Default routing form
const multiSelectOption2Uuid = "d1302635-9f12-17b1-9153-c3a854649182";
const multiSelectOption1Uuid = "d1292635-9f12-17b1-9153-c3a854649182";
const selectOption1Uuid = "d0292635-9f12-17b1-9153-c3a854649182";
const selectOption2Uuid = "d0302635-9f12-17b1-9153-c3a854649182";
const multiSelectLegacyFieldUuid = "d4292635-9f12-17b1-9153-c3a854649182";
const multiSelectFieldUuid = "d9892635-9f12-17b1-9153-c3a854649182";
const selectFieldUuid = "d1302635-9f12-17b1-9153-c3a854649182";
const legacySelectFieldUuid = "f0292635-9f12-17b1-9153-c3a854649182";
const form = await prisma.app_RoutingForms_Form.create({
data: {
routes: config.form?.routes || [
{
id: "8a898988-89ab-4cde-b012-31823f708642",
action: { type: "eventTypeRedirectUrl", value: "pro/30min" },
queryValue: {
id: "8a898988-89ab-4cde-b012-31823f708642",
type: "group",
children1: {
"8988bbb8-0123-4456-b89a-b1823f70c5ff": {
type: "rule",
properties: {
field: "c4296635-9f12-47b1-8153-c3a854649182",
value: ["event-routing"],
operator: "equal",
valueSrc: ["value"],
valueType: ["text"],
},
},
},
},
},
{
id: "aa8aaba9-cdef-4012-b456-71823f70f7ef",
action: { type: "customPageMessage", value: "Custom Page Result" },
queryValue: {
id: "aa8aaba9-cdef-4012-b456-71823f70f7ef",
type: "group",
children1: {
"b99b8a89-89ab-4cde-b012-31823f718ff5": {
type: "rule",
properties: {
field: "c4296635-9f12-47b1-8153-c3a854649182",
value: ["custom-page"],
operator: "equal",
valueSrc: ["value"],
valueType: ["text"],
},
},
},
},
},
{
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
action: { type: "externalRedirectUrl", value: `${WEBAPP_URL}/pro` },
queryValue: {
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
type: "group",
children1: {
"998b9b9a-0123-4456-b89a-b1823f7232b9": {
type: "rule",
properties: {
field: "c4296635-9f12-47b1-8153-c3a854649182",
value: ["external-redirect"],
operator: "equal",
valueSrc: ["value"],
valueType: ["text"],
},
},
},
},
},
{
id: "aa8ba8b9-0123-4456-b89a-b182623406d8",
action: { type: "customPageMessage", value: "Multiselect(Legacy) chosen" },
queryValue: {
id: "aa8ba8b9-0123-4456-b89a-b182623406d8",
type: "group",
children1: {
"b98a8abb-cdef-4012-b456-718262343d27": {
type: "rule",
properties: {
field: multiSelectLegacyFieldUuid,
value: [["Option-2"]],
operator: "multiselect_equals",
valueSrc: ["value"],
valueType: ["multiselect"],
},
},
},
},
},
{
id: "bb9ea8b9-0123-4456-b89a-b182623406d8",
action: { type: "customPageMessage", value: "Multiselect chosen" },
queryValue: {
id: "aa8ba8b9-0123-4456-b89a-b182623406d8",
type: "group",
children1: {
"b98a8abb-cdef-4012-b456-718262343d27": {
type: "rule",
properties: {
field: multiSelectFieldUuid,
value: [[multiSelectOption2Uuid]],
operator: "multiselect_equals",
valueSrc: ["value"],
valueType: ["multiselect"],
},
},
},
},
},
{
id: "898899aa-4567-489a-bcde-f1823f708646",
action: { type: "customPageMessage", value: "Fallback Message" },
isFallback: true,
queryValue: { id: "898899aa-4567-489a-bcde-f1823f708646", type: "group" },
},
],
fields: config.form?.fields || [
{
id: "c4296635-9f12-47b1-8153-c3a854649182",
type: "text",
label: "Test field",
required: true,
},
{
id: multiSelectLegacyFieldUuid,
type: "multiselect",
label: "Multi Select(with Legacy `selectText`)",
identifier: "multi",
selectText: "Option-1\nOption-2",
required: false,
},
{
id: multiSelectFieldUuid,
type: "multiselect",
label: "Multi Select",
identifier: "multi-new-format",
options: [
{
id: multiSelectOption1Uuid,
label: "Option-1",
},
{
id: multiSelectOption2Uuid,
label: "Option-2",
},
],
required: false,
},
{
id: legacySelectFieldUuid,
type: "select",
label: "Legacy Select",
identifier: "test-select",
selectText: "Option-1\nOption-2",
required: false,
},
{
id: selectFieldUuid,
type: "select",
label: "Select",
identifier: "test-select-new-format",
options: [
{
id: selectOption1Uuid,
label: "Option-1",
},
{
id: selectOption2Uuid,
label: "Option-2",
},
],
required: false,
},
],
user: {
connect: {
id: userId,
},
},
name: config.form?.name || "Fixture Routing Form",
},
});
return form;
}
}
@@ -0,0 +1,50 @@
import type { Prisma as PrismaType } from "@prisma/client";
import { prisma } from "@calcom/prisma";
/**
* Creates a team event type and assigns all team members as hosts
* @param teamId - The team ID
* @param eventType - Event type data to create
* @returns The created event type
*/
export async function createRoundRobinTeamEventType({
teamId,
eventType,
}: {
teamId: number;
eventType: PrismaType.EventTypeCreateInput;
}) {
const createdEventType = await prisma.eventType.create({
data: {
...eventType,
team: {
connect: {
id: teamId,
},
},
},
include: {
team: true,
},
});
const teamMemberships = await prisma.membership.findMany({
where: {
teamId,
},
});
// Add all team members as hosts (excluding the current user who's already added)
for (const membership of teamMemberships) {
await prisma.host.create({
data: {
userId: membership.userId,
eventTypeId: createdEventType.id,
isFixed: false, // ROUND_ROBIN scheduling type
},
});
}
return createdEventType;
}
+24 -7
View File
@@ -48,6 +48,7 @@
"calvideo_settings_description": "Configure video meeting preferences including recording permissions for hosts and attendees.",
"disable_recording_for_guests": "Disable recording for guests",
"enable_automatic_transcription": "Enable automatic transcription after joining the meeting",
"enable_automatic_recording": "Enable automatic recording after organizer joins the meeting",
"video_options": "Video Options",
"get_meeting_session_details": "Get Meeting Session Details",
"meeting_session_details": "Meeting Session Details",
@@ -75,6 +76,7 @@
"you_can_view_booking_details_with_this_url": "You can view the booking details from this url {{url}} and add the event to your calendar",
"no_options_available": "No options available",
"cancellation_reason": "Reason for cancellation (optional)",
"cancelled_by": "Cancelled By",
"cancellation_reason_host": "Reason for cancellation",
"cancellation_reason_placeholder": "Why are you cancelling?",
"notify_attendee_cancellation_reason_warning": "Cancellation reason will be shared with guests",
@@ -283,7 +285,7 @@
"guests": "Guests",
"guest": "Guest",
"web_conferencing_details_to_follow": "Web conferencing details to follow in the confirmation email.",
"confirmation":"Confirmation",
"confirmation": "Confirmation",
"what_booker_should_provide": "What your booker should provide to receive confirmations",
"404_the_user": "The username",
"username": "Username",
@@ -681,6 +683,7 @@
"send_invite_email": "Send an invite email",
"role": "Role",
"edit_role": "Edit Role",
"view_role": "View Role",
"edit_team": "Edit team",
"editing_user": "Editing user",
"editing_org": "Editing organization",
@@ -2165,12 +2168,14 @@
"popular_events": "Popular Events",
"no_event_types_found": "No event types found",
"average_event_duration": "Average Event Duration",
"most_booked_members": "Most Booked Members",
"least_booked_members": "Least Booked Members",
"most_booked_members": "Most Booked",
"least_booked_members": "Least Booked",
"events_created": "Events Created",
"events_completed": "Events Completed",
"events_cancelled": "Events Cancelled",
"events_rescheduled": "Events Rescheduled",
"events": "Events",
"performance": "Performance",
"from_last_period": "from last period",
"from_to_date_period": "From: {{startDate}} To: {{endDate}}",
"redirect_url_warning": "Adding a redirect will disable the success page. Make sure to mention \"Booking Confirmed\" on your custom success page.",
@@ -2194,6 +2199,10 @@
"error_charging_card": "Something went wrong charging the no-show fee. Please try again later.",
"collect_no_show_fee": "Collect no-show fee",
"no_show_fee_charged": "No-show fee charged",
"your_card_was_declined": "Payment declined. Please try a different card or contact your bank for assistance.",
"your_card_does_not_support_this_type_of_purchase": "This card type isn't supported for this purchase. Please use a different payment method.",
"amount_must_convert_to_at_least": "Minimum payment amount is $0.50 USD. Please increase your payment amount.",
"could_not_charge_card": "Could not charge card for payment.",
"insights": "Insights",
"routing_forms": "Routing Forms",
"testing_workflow_info_message": "When testing this workflow, be aware that Emails and SMS can only be scheduled at least 1 hour in advance",
@@ -2714,10 +2723,12 @@
"event_ratings": "Average Ratings",
"event_no_show": "No-Show (Host)",
"event_no_show_guest": "No-Show (Guest)",
"recent_ratings": "Recent ratings",
"recent_ratings": "Recent Ratings",
"no_ratings": "No ratings submitted",
"no_ratings_description": "Add a workflow with 'Rating' to collect ratings after meetings",
"most_no_show_host": "Most No Show Members",
"most_no_show_host": "Most No-Show",
"highest_rated": "Highest Rated",
"lowest_rated": "Lowest Rated",
"highest_rated_members": "Members with highest rated meetings",
"lowest_rated_members": "Members with lowest rated meetings",
"csat_score": "CSAT Score",
@@ -3137,7 +3148,7 @@
"display": "Display",
"could_not_find_slug_to_publish_org": "Could not find slug to publish the organization",
"picklist": "Picklist",
"most_cancelled_bookings": "Most Cancelled Bookings",
"most_cancelled_bookings": "Most Cancelled",
"interface_language": "Interface Language",
"visitors_browser_language": "Visitor's browser language",
"name_or_email": "Name or Email",
@@ -3322,7 +3333,7 @@
"pbac_desc_manage_team_insights": "Manage team insights",
"read_permission_auto_enabled_tooltip": "Read permission is automatically enabled when creating, updating, or deleting a resource",
"choose_restriction_schedule": "Add restriction schedule",
"choose_restriction_schedule_description": "Enable this if you want to restrict the availability of hosts for this event type based on a restriction schedule. This only removes the slots that are not in the restriction schedule, it does not add any new slots.",
"restriction_schedule_description": "Limit availability of hosts to only display slots that fall within a specified schedule.",
"use_booker_timezone": "Use booker's timezone",
"use_booker_timezone_info": "Apply the selected restriction schedule in the booker's timezone",
"load_balancing_warning": "This will disable Load Balancing on all your Round Robin event types",
@@ -3343,6 +3354,10 @@
"booking_not_allowed_by_restriction_schedule_error": "Booking outside restriction schedule availability.",
"restriction_schedule_not_found_error": "Restriction schedule not found",
"converted_image_size_limit_exceed": "Image size limit exceeded, please use a smaller image preferably in JPEG format",
"routing_funnel": "Routing Funnel",
"routing_funnel_total_submissions": "Total Submissions",
"routing_funnel_successful_routings": "Successful Routings",
"routing_funnel_accepted_bookings": "Accepted Bookings",
"license_key": "License key",
"license_selection_title": "Choose your license",
"license_selection_description": "Select how you want to license your Cal.com deployment",
@@ -3361,5 +3376,7 @@
"license_validation_failed": "Failed to validate license key",
"license_key_saved": "License key saved successfully",
"timezone_mismatch_tooltip": "You are viewing the report based on your profile timezone ({{userTimezone}}), while your browser is set to timezone ({{browserTimezone}})",
"failed_bookings_by_field": "Failed Bookings By Field",
"event_type_no_hosts": "No hosts are assigned to event type",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
File diff suppressed because it is too large Load Diff
@@ -30,4 +30,4 @@ This means if you have a table
You could show peer@cal.com an embed inside your application simply by using the value from AccountExecutive to build the link:
jane@acme.com → acme.cal.com/jane either with a string replace function or a simple regex.
jane@acme.com → acme.cal.com/jane either with a string replace function or a simple regex.

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