Compare commits

...
Author SHA1 Message Date
emrysal bfc4f7b66c chore: release v5.4.15
Post release / release (push) Canceled after 0s
2025-07-07 10:30:57 +00:00
9dca13305f feat: pbac core ui (#21471)
* migration plus feature flag

* show navigation route + inital roles migration

* add a check permission use case to take in the feature flags for a team

* bulk update script

* inital frontend work for displaying roles

* move to a more "anemic domain models" approach

* update test to match new DDD strutcture

* fix tests

* update transaction call back types to include trx

* align fe types after transaction to DDD

* move away from usecases to a more domain tailored approach

* get permissions per resource and map them to domain permission string

* update permision logic

* correctly get the logic for *.* permissions on owner

* wip sheet logic for ssr

* role list

* use nuqs for sheet parsing on handle change

* fox

* improve hook usage

* enable PBAC router

* delete modal etc

* i18n and inital rough layout of roles + permisions creating

* add color to migrations

* add colors and new method to tests

* move hooks out of infra into client with provider

* move hooks out of infra into client with provider

* memo features and ensure render once

* remove comment

* seed color

* use role colours

* match i18n

* add custom color picker to edit/create form

* fix advanced mode toggle

* more work on adv permission group

* update migrations

* abstract lots of core form logic to a custom hook

* improve UX for selecting all and toggling all

* improve code quality and use domain mappers in role repositoryu

* call server action to revalidate cache

* call invalidate cache on delete

* fix re-render + improves update logic wip

* fix txn for assinging role to member

* wip on assigning users custom roles

* fix repo

* update logic for checking if users can update roles

* remove member from permission check

* check users permission and assign roles

* move to factory approach

* move default rolesIDs to constant

* add facuted values to table

* display custom role in table

* fix type error

* fix role filter

* check pbac feature flag to see what column to filter on

* push repo mocks and other mocks to fix unit tests

* fix and add test for empty permissions when creating a role

* pass updates to repo so we actually update roles

* fix types

* fix types

* restore lock changes

* fix role service test for new updates section

* fix updated at types

* update mocks to use feature repository mock

* remove roletype from db in model

* prevent multiple queries

* fix typeof in role model

* fix and migrate i18n to one registery

* fix and update i18n to be in registery

* fix type error + fall back in service instead of repo for BL

* more type errors

* update members faceted values to bennys refactor

* fix types

* remove the _resource from type conditionally

* fix managment factory types to expose PBAC enbaled obol

* narrow down types

* wip fix for types

* more fix types

* cast role

* fix tests

* attempt of fixing _resoucre key access type

* attempt of fixing _resoucre key access type

* seperate migraations to batches

* add invalidate time to team features

* restore router to main

* push main lock

* Update packages/features/pbac/domain/types/permission-registry.ts

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

* Update packages/features/pbac/domain/mappers/PermissionMapper.ts

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

* Update packages/prisma/migrations/20250527091330_add_color_to_pbac_role/migration.sql

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

* Update packages/prisma/migrations/20250617070118_update_memberships_one_time/migration.sql

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

* Update apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/AdvancedPermissionGroup.tsx

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

* Apply suggestions from code review

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

* fix mapped type

* restore migration

* skip _resource

* use partical

* fix type errors in tests and hooks

* Simplified the role field in the editSchema to use z.nativeEnum(MembershipRole)

* fix type errors for editsheet

* fix type errors for editsheet

* Apply suggestion from comment 2151515295

* remove footer since we dont have docs yet

* add i18n

* lock all toggle chevron

* use prisma

* tidy up old manage permission

* fix i18n

* remove can manage from role permission check

* auto select read

* address benny feedback

* fix type

* fix type

* update function name due to merge

* fix types

* update tests to match new membership method from merge

* address cubic feedback

---------

Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-07-07 10:20:24 +01:00
Joe Au-Yeung 0ab9823c9a chore: add createdAt field to SelectedCalendar and DestinationCalendar (#22071)
* Add createdAt

* Address feedback

* Make date fields nullable

* Type fixes

* Type fix

* Fix tests
2025-07-05 17:04:38 +01:00
zomars b3fd4f307f chore: release v5.4.14
Post release / release (push) Canceled after 0s
2025-07-04 20:28:41 +00:00
Alex van Andel 06ed0de456 Quick eslint fixup 2025-07-04 21:10:52 +01:00
devin-ai-integration[bot]andalex@cal.com 79b6278883 refactor: replace Prisma.validator<Select>() with satisfies syntax (#22270)
* refactor: replace Prisma.validator<Select>() with satisfies syntax

- Convert all Prisma.validator<Prisma.SomeSelect>()({...}) patterns to {...} satisfies Prisma.SomeSelect
- Update import { Prisma } to import type { Prisma } where only used for types
- Maintain existing functionality while modernizing TypeScript syntax
- Covers 89+ files across packages/prisma/selects, repository classes, tRPC handlers, and API modules

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

* refactor: complete remaining Prisma.validator conversions

- Update test fixture files with satisfies syntax
- Apply lint-staged formatting fixes
- Complete refactoring of all remaining files

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

* revert: remove unintended platform library update

- Revert yarn.lock changes that updated @calcom/platform-libraries from 0.0.236 to 0.0.239
- This was an unintended side effect of the refactoring process
- Keep only the intended Prisma.validator → satisfies syntax changes

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

* fix: update ESLint plugin references to correct package name

- Change @calcom/eslint to @calcom/eslint-plugin-eslint in eslint-preset.js
- Resolves 'Failed to load plugin @calcom/eslint' error causing CI failures

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: alex@cal.com <me@alexvanandel.com>
2025-07-04 21:08:05 +01:00
Joe Au-Yeung 673823cb97 fix: Workflow scanning (#22273) 2025-07-04 10:49:06 -07:00
abhix4 15b26dff73 prevent footer from overlapping content in SearchDialog (#22266) 2025-07-04 15:46:08 +00:00
Ajeet Pratap SinghandKartik Saini 2303f2e9e1 chore: remove the duplicate keys from the common.json (#21975)
Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-07-04 18:34:29 +05:30
056b821070 feat: Add routing-forms record response endpoint with available slots (#22239)
* feat: Add routing-forms record response endpoint with available slots

* fix: resolve TypeScript error in handleResponse.test.ts

- Fix type mismatch where mockResponse was passed as identifierKeyedResponse
- identifierKeyedResponse expects Record<string, string | string[]> structure
- Updated test to pass correct data structure for type compatibility

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

* fix: correct POST endpoint parameter handling and request body parsing in routing forms responses controller

- Change @Query() to @Body() decorator for POST request data in controller
- Update service method to accept parsed body data directly
- Remove incorrect URLSearchParams parsing of request.body object
- Fix getRoutingUrl method to use form response data parameter

This resolves API v2 test failures by following proper NestJS patterns for POST request handling.

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

* Pass teamMemberEmail as well

* Devin fixes reverted

* Keep all routing related props together in both endpoints

* Remove newly added slots props from Slots documentation as they are used through internal fn call only

* fix test

* Pass skipContactOwner

* Pass crmAppSlug and crmOwnerRecordGType and add more tests

* handle external redirect case and form not found case

* hide props

* chore: bump platform libs

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: hariom@cal.com <hariom@cal.com>
Co-authored-by: cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-07-04 12:33:13 +00:00
Lauris Skraucis c90fded6b7 refactor: filter out admins and owners from platform event types hosts (#22258) 2025-07-04 14:10:31 +02:00
Anik Dhabal Babu aa6f1a7f93 feat: Waiting room setting for Zoom (#22256) 2025-07-04 17:38:07 +05:30
0cdd7c9605 refactor: create team service (#22036)
* refactor: create team service

* refactor: finish

* tests: add unit test for team service

* Add alternative approach to testing

---------

Co-authored-by: Hariom <hariombalhara@gmail.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-07-04 07:45:44 +00:00
Anik Dhabal Babu f40a1c92ef fix: Duplicate calendar event when a guest is added (#22247) 2025-07-04 03:37:56 +00:00
Keith WilliamsandDevin AI 739ef153f6 docs: improve English naturalness in API v2 documentation (#22130)
* docs: improve English naturalness in API v2 documentation

- Fix awkward API summary 'Find out when is an event type ready to be booked' to 'Get available time slots for an event type'
- Correct 'setup' vs 'set up' usage throughout documentation
- Fix OAuth capitalization consistency
- Improve 'api' to 'API' capitalization in v2 docs
- Enhance readability while preserving technical accuracy

Fixes unnatural English phrasing in titles, headers, descriptions, and summaries across API v2 documentation files.

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

* docs: add missing articles 'the' in API v2 documentation

- Fix 'How to Set Up API' to 'How to Set Up the API' in titles
- Add 'the' before 'API' in descriptions and summaries
- Fix grammar error 'all you need to is' to 'all you need to do is'
- Improve naturalness while maintaining technical accuracy

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

* docs: fix unnatural English in API v2 @ApiOperation summaries

- Fix grammatically incorrect 'conferencing apps oauths callback' to 'Conferencing app OAuth callback'
- Replace 'ooo' with 'out-of-office' in user-facing summaries for clarity
- Ensure consistent sentence case capitalization (only first letter capitalized)
- Remove trailing periods from summaries for consistency
- Fix awkward phrasing like 'Get by attribute id all of...' to more natural English
- Revert 'Introduction to the API v2' back to 'Introduction to API v2'
- Improve naturalness while maintaining technical accuracy

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

* Update apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/slots.controller.ts

* Update docs/api-reference/v2/openapi.json

* docs: fix setup/set up usage - use 'backend setup' (noun) not 'backend set up' (verb)

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

* docs: fix setup/set up verb usage - use 'set up' (verb) not 'setup' (verb)

- Fix 'You have to setup' → 'You have to set up' in quickstart.mdx
- Fix 'Setup environment variables' → 'Set up environment variables'
- Fix 'Setup root of your app' → 'Set up root of your app'
- Fix 'prompted to setup' → 'prompted to set up' in setup.mdx

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

* Ran v2 locally to regen the doc json files

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-03 23:47:09 +00:00
850ac8092f perf: optimize date range intersection algorithm from O(n²) to O(n log n) (#22076)
* feat: optimize date range intersection algorithm from O(n²) to O(n log n)

- Replace nested forEach loops with two-pointer approach
- Sort arrays once and traverse efficiently
- Maintains exact same functionality and API
- Improves performance for team scheduling scenarios

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

* feat: add comprehensive stress test for intersect function performance

- Add performance comparison test showing 22x improvement from O(n²) to O(n log n)
- Include edge cases testing for correctness validation
- Test with realistic data sizes (50 date ranges per user)
- Demonstrate identical results between old and new algorithms
- Provide timing measurements and performance logging

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

* refactor: update stress test to focus on optimized algorithm performance

- Remove old O(n²) algorithm comparison logic from test
- Focus stress test on current optimized intersect function performance
- Test with 400 total date ranges (4 users × 100 ranges each)
- Maintain realistic data sizes for team scheduling scenarios
- Execution time: 10.33ms for 400 ranges with 97 intersections found

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

* docs: restore explanatory comment in intersect function

- Add back comment explaining intersection logic as requested in PR review
- Comment clarifies when intersected time ranges are added to results array
- Addresses GitHub feedback from hbjORbj to revert comment removal

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

* refactor: remove unnecessary intersectNew variable in edge cases test

- Use intersect function directly instead of intersectNew variable assignment
- Addresses GitHub feedback from keithwillcode to clean up test code
- No functional changes to test logic or assertions

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

* test: add comprehensive test coverage for intersect function

- Add 27 comprehensive test cases covering all edge cases and scenarios
- Test empty inputs, single arrays, overlapping ranges, containment
- Add team scheduling scenarios and performance testing
- Test unsorted input handling, cross-day scenarios, time precision
- Ensure comprehensive coverage without knowing algorithm implementation
- All tests pass with optimized O(n log n) algorithm
- Performance test shows 8.50ms execution time for 400 date ranges

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

* perf: improve intersect function in slots (#22087)

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-07-04 00:00:31 +01:00
Omar LópezandBenny Joo 5b1eb48e6b perf: optimize google's getAvailability with caching (#22191)
* refactor: optimize google's getAvailability with caching

Refactored the getAvailability method to improve readability and efficiency by extracting logic into helper methods for cache checking, calendar ID retrieval, and chunked API fetching. Added early cache lookup for selected calendar IDs, improved cache hit logging, and modularized the code for easier maintenance and future enhancements.

* Update CalendarService.test.ts

* Update CalendarService.ts

* Update CalendarService.test.ts

* Update CalendarService.ts

* Update CalendarService.test.ts

* Update CalendarService.ts

* Apply suggestion from @hbjORbj

Co-authored-by: Benny Joo <sldisek783@gmail.com>

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-07-03 22:53:56 +00:00
zomars da6fc9f2d5 chore: release v5.4.13
Post release / release (push) Canceled after 0s
2025-07-03 21:10:47 +00:00
Keith Williamsandcubic-dev-ai[bot] 20434856e9 fix: Don't continue retrying SMS reminders infinitely (#22251)
* fix: Don't continue retrying SMS reminders infinitely

* Update packages/features/ee/workflows/api/scheduleSMSReminders.ts

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

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-07-03 21:02:08 +00:00
Joe Au-YeungandUdit Takkar 7f891adff2 feat: Salesforce - write assignment reason to record (#22223)
* Create `assignmentReasonRepository`

* Write `assignmentReason` to booking

* Type fix

* Clean up

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-07-03 20:56:36 +00:00
eb66fbe054 feat: add Video Options dropdown with meeting session details (#22147)
* feat: add Video Options dropdown with meeting session details

- Add Video Options dropdown to BookingListItem for Daily video bookings
- Move recording actions from TableActions to new dropdown
- Add meeting session details feature with participant info, join times, duration
- Create new tRPC endpoint for fetching Daily.co meeting information
- Add getMeetingInformation method to VideoApiAdapter
- Create MeetingSessionDetailsDialog component following ViewRecordingsDialog pattern
- Add translation strings for new UI elements
- Update VideoApiAdapter TypeScript interface

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

* fix: bugs and types

* Update packages/app-store/dailyvideo/lib/VideoApiAdapter.ts

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

* Update packages/app-store/dailyvideo/lib/VideoApiAdapter.ts

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-07-04 01:47:48 +05:30
Lauris Skraucis 29122934a9 fix: atoms local (#22245)
* Revert "fix: atoms for local development (#22178)"

This reverts commit d74d99dde7.

* chore: remove comment

* feat: local dev scripts

* feat: local dev scripts
2025-07-03 16:45:23 +00:00
manthansubhash01andKartik Saini 0825cce82c fix(ui): improve delete popup spacing on tablets and small screens (#22042)
Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-07-03 16:19:19 +00:00
fd964131d1 fix: add formatPeriodFull method for different date formatting in insights (#22202)
* feat: add formatPeriodStraightforward method for comprehensive date formatting

- Add new formatPeriodStraightforward method that always shows full date information
- Update DateRange interface to include formattedDateStraightforward field
- Modify getDateRanges to generate both formatted versions
- Add comprehensive unit tests for the new straightforward formatting
- Maintain backward compatibility with existing formatPeriod method

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

* refactor: rename formatPeriodStraightforward to formatPeriodFull

- Rename method from formatPeriodStraightforward to formatPeriodFull
- Rename interface field from formattedDateStraightforward to formattedDateFull
- Update all test cases to use new method name
- All tests continue to pass with new naming

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

* test: add comprehensive getDateRanges formatting comparison tests

- Add 7 new test cases that call getDateRanges directly
- Demonstrate side-by-side comparison of formattedDate vs formattedDateFull
- Cover daily, weekly, and monthly view formatting differences
- Include real-world usage examples showing practical benefits
- All 65 tests pass including new formatting comparison tests

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

* refactor: extract formattedDate arrays in getDateRanges comparison tests

- Refactor all 6 tests in 'getDateRanges - Formatting Comparison' suite
- Extract formattedDate and formattedDateFull arrays separately
- Use clean expect statements for easier visual review
- Remove startDate/endDate checks as requested
- Maintain all existing test scenarios and expected values

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

* clean up

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-07-03 16:01:44 +00:00
Lauris Skraucis 77d9c15fed fix: atoms local dev (#22240)
* fix: atoms for local development

* fix: atoms for local development
2025-07-03 16:42:57 +02:00
Morgan b21295f268 chore: add user org id to user context apiv2 axiom (#22236) 2025-07-03 12:09:55 +00:00
5769553659 chore: add sharp as depdency on apiv1 (#22204)
* fix: Process base64 avatar image (#22165)

* fix: process base64 avatar image

* better name

* more

* fix import

* Attempt to fix sharp dependency issue by upgrading next

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-07-03 12:52:14 +01:00
devin-ai-integration[bot]andalex@cal.com d23d68bf2d fix: add explicit type annotation to CalComAdapter function (#22230)
* fix: add explicit type annotation to CalComAdapter function

Resolves TS7056 error where inferred type exceeded maximum length
the compiler will serialize. Added custom CalComAdapterType type
annotation to provide explicit typing while preserving existing
functionality of the authentication adapter.

The custom type annotation matches the actual implementation structure
and resolves the TypeScript compilation error without breaking the
existing NextAuth adapter functionality.

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

* fix: replace any types with proper NextAuth Adapter typing

- Import Awaitable type from next-auth for proper async typing
- Define CalComAdapter type using Cal.com's actual Prisma types
- Remove all any types following TypeScript best practices
- Maintain compatibility with NextAuth's expected interface
- Resolves TS7056 compilation error with explicit typing

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: alex@cal.com <me@alexvanandel.com>
2025-07-03 11:37:15 +01:00
Morgan d04c7f58a1 chore: stringify user context ids apiv2 axiom (#22234) 2025-07-03 10:12:32 +00:00
Keith WilliamsandDevin AI 93e2426760 perf: optimize filterRedundantDateRanges from O(n²) to O(n log n) (#22093)
* perf: optimize filterRedundantDateRanges from O(n²) to O(n log n)

- Replace nested loop with optimized algorithm that leverages sorted ranges
- Add valueOf caching to avoid repeated .valueOf() calls (similar to PR #22076)
- Implement early termination for ranges that start after current range ends
- Handle identical ranges correctly by keeping first occurrence
- Add comprehensive test coverage with 8 new test cases covering:
  - Multiple nested containments
  - Identical ranges
  - Same start/end time edge cases
  - Invalid ranges (end before start)
  - Large dataset performance (100 ranges)
  - Touching ranges (adjacent ranges)

All 11 tests pass, maintaining behavioral compatibility while improving performance.

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

* revert: remove valueOf caching optimization, keep O(n log n) algorithm

- Remove cached valueOf() variables to address user feedback
- Keep algorithmic optimization with early termination logic
- Maintain O(n log n) complexity through sorted range leveraging
- All 11 tests continue to pass with identical functionality

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

* fix: correct identical range handling in O(n log n) optimization

- Remove complex conditional logic that caused incorrect filtering of identical ranges
- Revert to simple containment check while preserving O(n log n) performance
- All 10 comprehensive unit tests now pass
- Maintains early termination optimization for performance gains

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

* fix: correct identical range handling to keep first occurrence

- Update test expectation from 0 to 1 for three identical ranges
- Modify implementation to keep first occurrence of identical ranges
- Maintain O(n log n) performance optimization
- All 10 unit tests now pass

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

* feat: implement interval tree for O(n log n) worst-case complexity

- Replace nested loop with interval tree data structure
- Achieve O(n log n) worst-case complexity vs previous O(n²)
- Maintain identical range handling logic
- Preserve all existing test compatibility
- Performance improvements: 1.08x-2.46x speedup across scenarios
- Note: Enterprise pattern correctness issue requires investigation

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

* feat: implement interval tree for O(n log n) worst-case complexity

- Replace segment tree with interval tree data structure
- Achieve O(n log n) worst-case complexity vs previous O(n²)
- Maintain identical range handling logic
- All unit tests (10/10) and integration tests (5/5) pass
- Type checking passes with no errors
- Still investigating enterprise pattern correctness issue (499 vs 379 results)

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

* feat: implement interval tree for O(n log n) worst-case complexity

- Replace nested loop with interval tree data structure
- Achieve O(n log n) worst-case complexity vs previous O(n²)
- Maintain identical range handling logic
- Preserve all existing test compatibility

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

* fix: remove redundant sort in interval tree constructor

- Fix index misalignment issue causing correctness problems
- Remove duplicate sorting of nodes after mapping
- Maintain proper index references for containment queries

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

* feat: implement interval tree for O(n log n) worst-case complexity

- Replace nested loop with interval tree data structure
- Achieve O(n log n) worst-case complexity vs previous O(n²)
- Maintain identical range handling logic
- Preserve all existing test compatibility

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

* fix: simplify interval tree logic to match original behavior

- Remove complex identical range handling logic
- Filter out any range that has containing intervals
- Achieve O(n log n) worst-case complexity with correct behavior
- Fix 'should handle three identical ranges' test failure

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

* feat: implement interval tree for O(n log n) worst-case complexity

- Replace hybrid algorithm with interval tree data structure
- Achieve O(n log n) worst-case complexity vs previous O(n²)
- Fix identical range handling to keep first occurrence
- Maintain all existing test compatibility
- Update test expectation for three identical ranges to return 1 result

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

* fix: resolve TypeScript errors in interval tree implementation

- Add explicit type annotations for sort function parameters
- Use Array.from() for Map iteration to ensure TypeScript compatibility
- Maintain O(n log n) worst-case complexity with type safety

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

* feat: implement interval tree for O(n log n) worst-case complexity

- Replace hybrid algorithm with pure interval tree data structure
- Achieve O(n log n) worst-case complexity vs previous O(n²)
- Maintain balanced tree structure for efficient containment queries
- Preserve all existing test compatibility
- Performance improvements: 1.12x-2.36x speedup depending on scenario

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

* refactor: extract IntervalTree to generic reusable implementation

- Move IntervalTree and IntervalNode to separate packages/lib/intervalTree.ts
- Make IntervalTree generic with type parameter <T> and function parameters for start/end extraction
- Update filterRedundantDateRanges.ts to use generic IntervalTree implementation
- Maintain O(n log n) worst-case complexity and all existing functionality
- All unit tests (10/10) and integration tests (5/5) passing

Addresses GitHub comment from @keithwillcode requesting generic, reusable interval tree

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

* fix: correct containment logic in interval tree implementation

- Fix unconditional 'return false' that incorrectly filtered non-contained ranges
- Maintain O(n log n) complexity while ensuring correct containment behavior
- Add test case for overlapping but non-containing ranges
- Addresses cubic-dev-ai[bot] comment on PR #22093

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

* test: add comprehensive test coverage for cubic-dev-ai bug scenario

- Add test for overlapping but non-containing ranges
- Add test for complex overlapping pattern that exposed the cubic-dev-ai bug
- These tests would have caught the unconditional 'return false;' logic error
- Addresses test coverage gap that allowed the bug to slip through

The original test suite focused on clear containment scenarios but missed
complex overlapping patterns where interval tree finds false positives
that need proper filtering logic.

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

* refactor: separate IntervalTree structure from search algorithm

- Extract ContainmentSearchAlgorithm to separate class
- Make IntervalTree generic and reusable for different search patterns
- Maintain existing API compatibility for filterRedundantDateRanges
- Address GitHub feedback from @keithwillcode

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

* refactor: make IntervalTree truly generic by moving node construction outside

- Extract createIntervalNodes helper function for date-specific node construction
- Remove start/end/maxEnd property handling from IntervalTree constructor
- Make IntervalTree accept pre-constructed nodes instead of raw items
- Move date-specific logic to filterRedundantDateRanges caller
- Addresses GitHub feedback from @keithwillcode about generic tree structure
- Maintains O(n log n) performance and all existing functionality

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

* perf: eliminate redundant sorting in IntervalTree implementation

- Remove duplicate sort in IntervalTree constructor
- Sort interval nodes once in filterRedundantDateRanges after creation
- Addresses @Udit-takkar's performance feedback on PR #22093
- Maintains O(n log n) complexity while reducing constant factors

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

* fix: remove redundant sorting of interval nodes

- Eliminates unnecessary .sort() operation on interval nodes
- Nodes are already sorted from initial sortedRanges sort
- Addresses @keithwillcode's performance feedback

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-03 10:59:33 +01:00
Hariom BalharaandAlex van Andel 9f6c05f389 fix: Avoid prerender duplicate calls for same calLink that could arise with faulty implementation (#21982)
* fix: Avoid prerender duplicate calls

* Update README

* Ensure "noAction" action occurs if nothing changes

* fix bug

* Introduce connect method for reliable re-use

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-07-03 10:51:13 +01:00
Hariom BalharaandAnik Dhabal Babu f8a1bd7b94 fix: Don't default to ROUND_ROBIN as user bookings don't have it set (#22210)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-07-03 10:15:55 +05:30
Omar LópezandDevin AI 02967961f3 fix: use triggering user's email/name in draft-release workflow (#22229)
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-03 02:49:00 +00:00
Keith WilliamsandDevin AI 053b8b5c0a fix: resolve race condition in ready-for-e2e label checking (#22222)
- Simplify conditional logic in check-label job output
- Remove redundant condition that caused race condition
- Maintain event filtering to only run when ready-for-e2e label is added
- Fixes issue where workflow failed despite script finding the label correctly

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-03 00:21:43 +00:00
Keith Williams d59efd3ba0 chore: Ignore 'dba' for labeling (#22216) 2025-07-02 23:36:02 +00:00
GitHub Actions ce56cd1ab0 chore: release v5.4.12
Post release / release (push) Canceled after 0s
2025-07-02 21:05:34 +00:00
Romit 54b6deee19 fix (#22117) 2025-07-02 23:06:28 +05:30
abhix4 8315641724 fix: the icon in ooo empty view in light mode (#22208) 2025-07-02 22:50:20 +05:30
Anik Dhabal Babu aa3086cfae fix: Can cancel an already cancelled booking through the API (#22128)
* fix: can cancel already cancelled booking

* add test

* fix test

* update

* Update bookings.service.ts

* add commnet

* update

* tweak
2025-07-02 08:40:57 -04:00
devin-ai-integration[bot]andmorgan@cal.com b599ce4919 feat: add user context logging to request interceptor (#22180)
- Import extractUserContext function from @/lib/extract-user-context
- Extract user context from request in intercept method
- Add user context to logger output using spread operator
- Follow same pattern as exception filters for consistency

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
2025-07-01 21:08:31 +00:00
Benny Joo 52f16604c1 feat: Use a separate project for Client Sentry (#22183) 2025-07-01 19:26:51 +00:00
Benny Joo c98c06062b revert: Process Base64 Avatar Image (#22185)
* Revert "chore: add test for updateProfilePhotoGoogle (#22169)"

This reverts commit 67032ddaf1.

* Revert "fix: Process base64 avatar image (#22165)"

This reverts commit d8d8bc4d00.
2025-07-01 18:38:32 +00:00
Carina WollendorferandCarinaWolli 4963d247dc fix: don't use unpublished org for credit usage (#22154)
* only return publichsed orgs

* fix twilioWebhook tests

* code clean up

* add missing import

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-07-01 16:04:13 +00:00
9405bc9553 feat: enable stripe invoices for credit purchases (#22096)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-07-01 16:01:53 +00:00
8a080b91b9 feat: add timezone indicator on insights for unmatched timezone (#21568)
* feat: adds timezone indicator on insights informing user that user settings timezone is used instead of browser timezone

* added i18 helper for translation

* chore: use entities.decodeHTML instead of custom decoder

* feat(insights): align TimezoneBadge with new timeZone prop flow

* chore

* chore: smaller icon

* do not escape value

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-07-01 12:49:21 +00:00
Lauris Skraucis 4aa05fe523 docs: releasing atoms (#22160) 2025-07-01 17:54:47 +05:30
Lauris Skraucis d74d99dde7 fix: atoms for local development (#22178)
* fix: atoms for local development

* fix: atoms for local development
2025-07-01 17:33:22 +05:30
Parteek malikandSomay Chauhan 0ee137efbb fix: passing props (#21670)
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
2025-07-01 11:21:25 +00:00
GitHub Actions 90f55670dc chore: release v5.4.11
Post release / release (push) Canceled after 0s
2025-07-01 11:06:49 +00:00
Anik Dhabal Babu 9c53f392ca fix: Optional Question ' Leaves Variable Tag in Event Name (#22175) 2025-07-01 10:50:41 +00:00
Morganandcubic-dev-ai[bot] cc19a81875 chore: deprecate platform libraries 0.0.2 (#22149)
* chore: deprecate platform libraries 0.0.2

* fixup! chore: deprecate platform libraries 0.0.2

* Update packages/lib/server/repository/schedule.ts

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

* Update apps/api/v2/src/ee/schedules/schedules_2024_04_15/services/schedules.service.ts

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

* chore: bump platform libs

* Update apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.ts

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

* chore: bump platform libs

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-07-01 10:23:58 +00:00
Benny Joo 67032ddaf1 chore: add test for updateProfilePhotoGoogle (#22169)
* fix: process base64 avatar image

* better name

* more

* fix import

* add test

* util

* clean up

* address
2025-07-01 11:06:57 +01:00
2fe71c3695 refactor: destination calendars when rescheduling (#21873)
* refactor: destination calendars when rescheduling

* test: add unit test for collective evnet type

* chore: remove log

* chore: bump libraries

* fix: test

* chore: bump libraries

* fix: tests

* reset lock file

* Revert "reset lock file"

This reverts commit 4d48b678778b0daa19624338b302478e4655d200.

* Revert "Revert "reset lock file""

This reverts commit a7c3120c926a0d37a2b55a6ff8d2a070969ec0f4.

* regenerate lock file

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2025-07-01 12:07:48 +03:00
21422fe027 feat: enhance API v2 exception filters with user context for improved Axiom observability (#22157)
* feat: enhance API v2 exception filters with user context for improved Axiom observability

- Add extractUserContext utility to safely extract user/org/team context from request
- Enhance all 5 exception filters (HTTP, TRPC, Prisma, Zod, Calendar) with user context
- Include userId, userEmail, organizationId, teamId in Axiom logs when available
- Improve observability for debugging and monitoring authenticated requests

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

* chore: update yarn.lock from lint-staged hooks

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

* fix: use request.organization instead of request.organizationId

- Address GitHub comment feedback from ThyMinimalDev
- Extract organization ID from organization object for consistency
- Follows same pattern as user and team extraction

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

* fix: revert to using request.organizationId as requested in GitHub comment

- Change back from request.organization to request.organizationId
- This aligns with how auth strategies actually populate the request object
- Addresses GitHub comment from ThyMinimalDev

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-07-01 10:05:21 +03:00
Devanshu Sharma 95575291ff feat: add Finnish locale to Day.js locales (#22174) 2025-07-01 07:01:32 +00:00
KartikandKartik Saini 3e61a0633d prevent info icon from repositioning when text wraps on small screens (#22132)
Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-07-01 09:32:41 +05:30
abhix4 85c9a790f2 fix: prevent location input from losing focus after each key stroke (#22167)
* fix: prevent location input from losing focus after each key stroke

* typo
2025-07-01 03:54:35 +00:00
Omar López e17d55457f refactor: cookie domain logic in defaultCookies (#22168) 2025-06-30 20:21:16 +00:00
Benny Joo d8d8bc4d00 fix: Process base64 avatar image (#22165)
* fix: process base64 avatar image

* better name

* more

* fix import
2025-06-30 21:08:46 +01:00
Udit TakkarandAlex van Andel 55cc1aea59 perf: optimize listWithTeamHandler query (#22069)
* perf: optimize listWithTeamHandler query

* chore: update query

* chore: add accepted true

* Update packages/trpc/server/routers/viewer/eventTypes/listWithTeam.handler.ts

* test: add integration test for the sql query

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-06-30 18:35:49 +00:00
GitHub Actions ae383164bc chore: release v5.4.10
Post release / release (push) Canceled after 0s
2025-06-30 13:22:55 +00:00
GitHub Actions 0d8863fe8e chore: release v5.4.10 2025-06-30 13:02:06 +00:00
GitHub Actions 51f6654635 chore: release v5.5.0 2025-06-30 12:39:41 +00:00
Eunjae LeeandDevin AI fa11e10bcb feat: remove 3-attendee limitation from CSV export (#22092)
Co-authored-by: eunjae@cal.com <hey@eunjae.dev>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-06-30 12:28:54 +00:00
github-actions[bot] a8f5bd6fe9 chore: version packages (#22152)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-30 11:16:15 +00:00
Lauris Skraucis a1c725bb35 refactor: atoms peer deps (#22153) 2025-06-30 10:22:45 +00:00
Anik Dhabal BabuandHariom Balhara e4eeb11a61 fix: fix request reschedule - not allowing reschedule of cancelled booking (#22115)
* fix: fix broken request reschdule feature

* Update getServerSideProps.ts

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-06-30 09:56:45 +00:00
Udit TakkarandHariom 3b0bb25251 fix: status code or router/embed rate limiting (#21968)
* fix: status code or router embed rate limiting

* fix: type err

* fix: handle api v2

* fix: status code

* chore: remove unused

* chore: update unit test

* chore: unit test

* refactor: make res required

* chore: remove unused

* chore: add a wrapper in getserversideprops

* chore: remlove log

* Add ratelLimiting unit test

---------

Co-authored-by: Hariom <hariombalhara@gmail.com>
2025-06-30 14:58:44 +05:30
Lauris Skraucis d27490e6f2 fix: atoms dist size (#22148)
* fix: reduce atoms dist size

* test changesets entry

* bump version
2025-06-30 09:26:08 +00:00
Anik Dhabal BabuandUdit Takkar b3c191b553 fix: truncate zoom meeting agenda (#22137)
* fix truncate zoom agenda

* type error

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-06-30 08:45:13 +00:00
Anik Dhabal Babuandcubic-dev-ai[bot] 0b977e615d fix: exclude opt out message from body (#22100)
* fix: exclude opt out

* Update packages/features/ee/workflows/api/scheduleSMSReminders.ts

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

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-06-30 08:17:36 +00:00
github-actions[bot]andLauris Skraucis 979e1fd28b chore: version packages (#22143)
* chore: version packages

* Update package.json

* Update CHANGELOG.md

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
2025-06-30 07:51:11 +00:00
Lauris Skraucis 3437c4331d fix: changesets atoms size (#22134)
* fix: changesets atoms npm package size

* test changesets entry
2025-06-30 07:22:09 +00:00
292 changed files with 13510 additions and 3485 deletions
+1
View File
@@ -164,6 +164,7 @@ SENTRY_PROJECT=
SENTRY_AUTH_TOKEN=
# Used for capturing exceptions and logging messages
NEXT_PUBLIC_SENTRY_DSN=
NEXT_PUBLIC_SENTRY_DSN_CLIENT=
SENTRY_DEBUG=
SENTRY_MAX_SPANS=
SENTRY_SAMPLE_RATE=
+18 -2
View File
@@ -27,8 +27,24 @@ jobs:
- name: Configure git
run: |
git config --local user.email "github-actions@github.com"
git config --local user.name "GitHub Actions"
# Define authorized users and their emails using organization secret
# Expected format: JSON object with username -> email mapping
# Example: {"zomars": "zomars@cal.com", "peer": "peer@cal.com", "username3": "email3@cal.com"}
USER_EMAILS='${{ secrets.RELEASE_USER_EMAILS }}'
# Extract email for the triggering user
USER_EMAIL=$(echo "$USER_EMAILS" | jq -r --arg user "${{ github.actor }}" '.[$user] // empty')
# Fail if user is not authorized or not in mapping
if [ -z "$USER_EMAIL" ] || [ "$USER_EMAIL" = "null" ]; then
echo "Error: User '${{ github.actor }}' is not authorized to run the release workflow."
echo "Only authorized team members can trigger releases."
echo "Contact your administrator to be added to the authorized users list."
exit 1
fi
git config --local user.email "$USER_EMAIL"
git config --local user.name "${{ github.actor }}"
- uses: ./.github/actions/yarn-install
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
with:
repo-token: ${{ secrets.EQUITY_BEE_TEAM_LABELER_ACTION_TOKEN }}
organization-name: calcom
ignore-labels: "admin, app-store, ai, authentication, automated-testing, devops, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
ignore-labels: "admin, app-store, ai, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, dba, devops, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
apply-labels-from-issue:
runs-on: ubuntu-latest
+1 -1
View File
@@ -46,7 +46,7 @@ jobs:
permissions:
pull-requests: read
outputs:
run-e2e: ${{ steps.check-if-pr-has-label.outputs.run-e2e == 'true' && (github.event.action != 'labeled' || (github.event.action == 'labeled' && github.event.label.name == 'ready-for-e2e')) }}
run-e2e: ${{ steps.check-if-pr-has-label.outputs.run-e2e == 'true' && (github.event.action != 'labeled' || github.event.label.name == 'ready-for-e2e') }}
steps:
- name: Check if PR exists with ready-for-e2e label for this SHA
id: check-if-pr-has-label
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
View File
View File
View File
+8
View File
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
View File
+8
View File
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+1 -1
View File
@@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
-3
View File
@@ -5,9 +5,6 @@ const plugins = [withAxiom];
/** @type {import("next").NextConfig} */
const nextConfig = {
experimental: {
instrumentationHook: true,
},
transpilePackages: [
"@calcom/app-store",
"@calcom/dayjs",
+1 -1
View File
@@ -33,7 +33,7 @@
"@sentry/nextjs": "^9.15.0",
"bcryptjs": "^2.4.3",
"memory-cache": "^0.2.0",
"next": "^13.5.9",
"next": "^15.3.0",
"next-api-middleware": "^1.0.1",
"next-axiom": "^0.17.0",
"next-swagger-doc": "^0.3.6",
@@ -93,6 +93,8 @@ describe("POST /api/selected-calendars", () => {
watchAttempts: 0,
maxAttempts: 3,
unwatchAttempts: 0,
createdAt: new Date(),
updatedAt: new Date(),
});
await handler(req, res);
@@ -136,6 +138,8 @@ describe("POST /api/selected-calendars", () => {
watchAttempts: 0,
maxAttempts: 3,
unwatchAttempts: 0,
createdAt: new Date(),
updatedAt: new Date(),
});
await handler(req, res);
+1 -2
View File
@@ -38,8 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.231",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.239",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
@@ -232,6 +232,15 @@ export class BookingsController_2024_04_15 {
}
if (bookingUid) {
const { bookingInfo } = await getBookingInfo(bookingUid);
if (!bookingInfo) {
throw new NotFoundException(`Booking with UID=${bookingUid} does not exist.`);
}
if (bookingInfo.status === "CANCELLED") {
throw new BadRequestException(
`Can't cancel booking with uid=${bookingUid} because it has been cancelled already. Please provide uid of a booking that is not cancelled.`
);
}
try {
req.body.uid = bookingUid;
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
@@ -2553,6 +2553,59 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
describe("cant't cancel already cancelled booking", () => {
it("should not be able to cancel alraedy cancelled booking", async () => {
const cancelledBooking = await bookingsRepositoryFixture.create({
status: "CANCELLED",
user: {
connect: {
id: user.id,
},
},
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
title: "peer coding lets goo",
uid: `cancelled-booking-${randomString()}`,
eventType: {
connect: {
id: eventTypeId,
},
},
location: "integrations:daily",
customInputs: {},
metadata: {},
responses: {
name: "Oldie",
email: "oldie@gmail.com",
},
attendees: {
create: {
email: "oldie@gmail.com",
name: "Oldie",
locale: "lv",
timeZone: "Europe/Rome",
},
},
});
const body: CancelBookingInput_2024_08_13 = {
cancellationReason: "Going on a vacation",
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings/${cancelledBooking.uid}/cancel`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.expect(400);
expect(response.body.error.message).toEqual(
`Can't cancel booking with uid=${cancelledBooking.uid} because it has been cancelled already. Please provide uid of a booking that is not cancelled.`
);
await bookingsRepositoryFixture.deleteById(cancelledBooking.id);
});
});
describe("calendar events", () => {
beforeEach(() => {
jest.restoreAllMocks();
@@ -186,6 +186,20 @@ export class InputBookingsService_2024_08_13 {
guests,
location,
},
...this.getRoutingFormData(inputBooking.routing),
};
}
private getRoutingFormData(routing: { teamMemberIds?: number[]; responseId?: number; teamMemberEmail?: string; skipContactOwner?: boolean; crmAppSlug?: string; crmOwnerRecordType?: string } | undefined) {
if (!routing) return null;
return {
routedTeamMemberIds: routing.teamMemberIds,
routingFormResponseId: routing.responseId,
teamMemberEmail: routing.teamMemberEmail,
skipContactOwner: routing.skipContactOwner,
crmAppSlug: routing.crmAppSlug,
crmOwnerRecordType: routing.crmOwnerRecordType,
};
}
@@ -453,6 +467,7 @@ export class InputBookingsService_2024_08_13 {
location,
},
schedulingType: eventType.schedulingType,
...this.getRoutingFormData(inputBooking.routing),
});
switch (timeBetween) {
@@ -711,6 +726,12 @@ export class InputBookingsService_2024_08_13 {
throw new NotFoundException(`Booking with uid=${bookingUid} not found`);
}
if (booking.status === "CANCELLED") {
throw new BadRequestException(
`Can't cancel booking with uid=${bookingUid} because it has been cancelled already. Please provide uid of a booking that is not cancelled.`
);
}
const oAuthClientParams = booking.eventTypeId
? await this.platformBookingsService.getOAuthClientParams(booking.eventTypeId)
: undefined;
@@ -1,9 +1,9 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import type { Prisma } from "@prisma/client";
const credentialForCalendarRepositorySelect = Prisma.validator<Prisma.CredentialSelect>()({
const credentialForCalendarRepositorySelect = {
id: true,
appId: true,
type: true,
@@ -16,7 +16,7 @@ const credentialForCalendarRepositorySelect = Prisma.validator<Prisma.Credential
teamId: true,
key: true,
invalid: true,
});
} satisfies Prisma.CredentialSelect;
@Injectable()
export class CalendarsRepository {
@@ -157,7 +157,7 @@ export class CalendarsController {
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Get("/:calendar/connect")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Get oAuth connect URL" })
@ApiOperation({ summary: "Get OAuth connect URL" })
@ApiQuery({
name: "redir",
required: false,
@@ -14,7 +14,7 @@ import {
NotFoundException,
} from "@nestjs/common";
import { User } from "@prisma/client";
import { Prisma } from "@prisma/client";
import type { Prisma } from "@prisma/client";
import { DateTime } from "luxon";
import { z } from "zod";
@@ -18,6 +18,7 @@ import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
@@ -33,13 +34,19 @@ import {
HttpStatus,
Delete,
Query,
Headers,
InternalServerErrorException,
ParseIntPipe,
} from "@nestjs/common";
import { ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import { getPublicEvent, getEventTypesByViewer } from "@calcom/platform-libraries-0.0.2";
import {
EVENT_TYPE_READ,
EVENT_TYPE_WRITE,
SUCCESS_STATUS,
X_CAL_CLIENT_ID,
} from "@calcom/platform-constants";
import { getPublicEvent, getEventTypesByViewer } from "@calcom/platform-libraries/event-types";
import { PrismaClient } from "@calcom/prisma";
@Controller({
@@ -51,7 +58,8 @@ import { PrismaClient } from "@calcom/prisma";
export class EventTypesController_2024_04_15 {
constructor(
private readonly eventTypesService: EventTypesService_2024_04_15,
private readonly prismaReadService: PrismaReadService
private readonly prismaReadService: PrismaReadService,
private readonly organizationsRepository: OrganizationsRepository
) {}
@Post("/")
@@ -109,19 +117,30 @@ export class EventTypesController_2024_04_15 {
async getPublicEventType(
@Param("username") username: string,
@Param("eventSlug") eventSlug: string,
@Query() queryParams: GetPublicEventTypeQueryParams_2024_04_15
@Query() queryParams: GetPublicEventTypeQueryParams_2024_04_15,
@Headers(X_CAL_CLIENT_ID) clientId?: string
): Promise<GetEventTypePublicOutput> {
try {
let orgSlug = queryParams.org;
if (clientId && !orgSlug && username.includes(`-${clientId}`)) {
const org = await this.organizationsRepository.findTeamIdAndSlugFromClientId(clientId).catch(() => null);
if (org) {
orgSlug = org.slug;
}
}
const event = await getPublicEvent(
username.toLowerCase(),
eventSlug,
queryParams.isTeamEvent,
queryParams.org || null,
orgSlug ?? null,
this.prismaReadService.prisma as unknown as PrismaClient,
// We should be fine allowing unpublished orgs events to be servable through platform because Platform access is behind license
// If there is ever a need to restrict this, we can introduce a new query param `fromRedirectOfNonOrgLink`
true
);
return {
data: event,
status: SUCCESS_STATUS,
@@ -2,6 +2,7 @@ import { EventTypesController_2024_04_15 } from "@/ee/event-types/event-types_20
import { EventTypesRepository_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.repository";
import { EventTypesService_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/services/event-types.service";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { SelectedCalendarsModule } from "@/modules/selected-calendars/selected-calendars.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
@@ -9,7 +10,14 @@ import { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, MembershipsModule, TokensModule, UsersModule, SelectedCalendarsModule],
imports: [
PrismaModule,
MembershipsModule,
TokensModule,
UsersModule,
SelectedCalendarsModule,
OrganizationsModule,
],
providers: [EventTypesRepository_2024_04_15, EventTypesService_2024_04_15],
controllers: [EventTypesController_2024_04_15],
exports: [EventTypesService_2024_04_15, EventTypesRepository_2024_04_15],
@@ -1721,7 +1721,7 @@ describe("Event types Endpoints", () => {
description: "undefined booking fields",
length: 40,
hidden: false,
slug: "undefined-booking-fields",
slug: `undefined-booking-fields-${randomString()}`,
locations: [],
schedulingType: SchedulingType.ROUND_ROBIN,
};
@@ -1751,6 +1751,14 @@ describe("Event types Endpoints", () => {
disableOnPrefill: false,
hidden: false,
},
{
disableOnPrefill: false,
hidden: true,
isDefault: true,
required: false,
slug: "attendeePhoneNumber",
type: "phone",
},
{
isDefault: true,
type: "radioInput",
@@ -46,11 +46,10 @@ export class SchedulesController_2024_04_15 {
@Body() bodySchedule: CreateScheduleInput_2024_04_15
): Promise<CreateScheduleOutput_2024_04_15> {
const schedule = await this.schedulesService.createUserSchedule(user.id, bodySchedule);
const scheduleFormatted = await this.schedulesService.formatScheduleForAtom(user, schedule);
return {
status: SUCCESS_STATUS,
data: scheduleFormatted,
data: schedule,
};
}
@@ -60,13 +59,10 @@ export class SchedulesController_2024_04_15 {
@GetUser() user: UserWithProfile
): Promise<GetDefaultScheduleOutput_2024_04_15 | null> {
const schedule = await this.schedulesService.getUserScheduleDefault(user.id);
const scheduleFormatted = schedule
? await this.schedulesService.formatScheduleForAtom(user, schedule)
: null;
return {
status: SUCCESS_STATUS,
data: scheduleFormatted,
data: schedule,
};
}
@@ -77,23 +73,25 @@ export class SchedulesController_2024_04_15 {
@Param("scheduleId") scheduleId: number
): Promise<GetScheduleOutput_2024_04_15> {
const schedule = await this.schedulesService.getUserSchedule(user.id, scheduleId);
const scheduleFormatted = await this.schedulesService.formatScheduleForAtom(user, schedule);
return {
status: SUCCESS_STATUS,
data: scheduleFormatted,
data: schedule,
};
}
@Get("/")
@Permissions([SCHEDULE_READ])
async getSchedules(@GetUser() user: UserWithProfile): Promise<GetSchedulesOutput_2024_04_15> {
const schedules = await this.schedulesService.getUserSchedules(user.id);
const schedulesFormatted = await this.schedulesService.formatSchedulesForAtom(user, schedules);
const schedules = await this.schedulesService.getUserSchedules(
user.id,
user.timeZone,
user.defaultScheduleId
);
return {
status: SUCCESS_STATUS,
data: schedulesFormatted,
data: schedules,
};
}
@@ -3,7 +3,7 @@ import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import type { Prisma } from "@prisma/client";
@Injectable()
export class SchedulesRepository_2024_04_15 {
@@ -1,26 +1,22 @@
import { CreateAvailabilityInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-availability.input";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/outputs/schedule.output";
import { SchedulesRepository_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.repository";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { Schedule } from "@prisma/client";
import { User } from "@prisma/client";
import type { ScheduleWithAvailabilities } from "@calcom/platform-libraries-0.0.2";
import { updateScheduleHandler } from "@calcom/platform-libraries-0.0.2";
import {
transformWorkingHoursForClient,
transformAvailabilityForClient,
transformDateOverridesForClient,
} from "@calcom/platform-libraries-0.0.2";
import { updateSchedule } from "@calcom/platform-libraries/schedules";
import { ScheduleRepository } from "@calcom/platform-libraries/schedules";
import { UpdateScheduleInput_2024_04_15 } from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
@Injectable()
export class SchedulesService_2024_04_15 {
constructor(
private readonly schedulesRepository: SchedulesRepository_2024_04_15,
private readonly usersRepository: UsersRepository
private readonly usersRepository: UsersRepository,
private readonly dbWrite: PrismaWriteService
) {}
async createUserDefaultSchedule(userId: number, timeZone: string) {
@@ -48,7 +44,9 @@ export class SchedulesService_2024_04_15 {
await this.usersRepository.setDefaultSchedule(userId, createdSchedule.id);
}
return createdSchedule;
const formattedSchedule = await this.getUserSchedule(userId, createdSchedule.id);
return formattedSchedule;
}
async getUserScheduleDefault(userId: number) {
@@ -56,11 +54,29 @@ export class SchedulesService_2024_04_15 {
if (!user?.defaultScheduleId) return null;
return this.schedulesRepository.getScheduleById(user.defaultScheduleId);
return await ScheduleRepository.findDetailedScheduleById({
scheduleId: user.defaultScheduleId,
isManagedEventType: undefined,
userId,
timeZone: user.timeZone,
defaultScheduleId: user.defaultScheduleId,
});
}
async getUserSchedule(userId: number, scheduleId: number) {
const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId);
const user = await this.usersRepository.findById(userId);
if (!user) {
throw new NotFoundException(`User with ID=${userId} does not exist.`);
}
const existingSchedule = await ScheduleRepository.findDetailedScheduleById({
scheduleId: scheduleId,
isManagedEventType: undefined,
userId,
timeZone: user.timeZone,
defaultScheduleId: user.defaultScheduleId,
});
if (!existingSchedule) {
throw new NotFoundException(`Schedule with ID=${scheduleId} does not exist.`);
@@ -71,8 +87,8 @@ export class SchedulesService_2024_04_15 {
return existingSchedule;
}
async getUserSchedules(userId: number) {
return this.schedulesRepository.getSchedulesByUserId(userId);
async getUserSchedules(userId: number, timeZone: string, defaultScheduleId: number | null) {
return ScheduleRepository.findManyDetailedScheduleByUserId({ userId, timeZone, defaultScheduleId });
}
async updateUserSchedule(
@@ -89,7 +105,6 @@ export class SchedulesService_2024_04_15 {
this.checkUserOwnsSchedule(user.id, existingSchedule);
const schedule = await this.getUserSchedule(user.id, Number(scheduleId));
const scheduleFormatted = await this.formatScheduleForAtom(user, schedule);
if (!bodySchedule.schedule) {
// note(Lauris): When updating an availability in cal web app, lets say only its name, also
@@ -97,12 +112,16 @@ export class SchedulesService_2024_04_15 {
// and they have same shape, so to match shapes I attach "scheduleFormatted.availability" to reflect
// schedule that would be passed by the web app. If we don't, then updating schedule name will erase
// schedule.
bodySchedule.schedule = scheduleFormatted.availability;
bodySchedule.schedule = schedule.availability;
}
return updateScheduleHandler({
input: { scheduleId: Number(scheduleId), ...bodySchedule },
ctx: { user },
return updateSchedule({
input: {
scheduleId: Number(scheduleId),
...bodySchedule,
},
user,
prisma: this.dbWrite.prisma as unknown as PrismaClient,
});
}
@@ -118,44 +137,6 @@ export class SchedulesService_2024_04_15 {
return this.schedulesRepository.deleteScheduleById(scheduleId);
}
async formatScheduleForAtom(user: User, schedule: ScheduleWithAvailabilities): Promise<ScheduleOutput> {
const usersSchedulesCount = await this.schedulesRepository.getUserSchedulesCount(user.id);
return this.transformScheduleForAtom(schedule, usersSchedulesCount, user);
}
async formatSchedulesForAtom(
user: User,
schedules: ScheduleWithAvailabilities[]
): Promise<ScheduleOutput[]> {
const usersSchedulesCount = await this.schedulesRepository.getUserSchedulesCount(user.id);
return Promise.all(
schedules.map((schedule) => this.transformScheduleForAtom(schedule, usersSchedulesCount, user))
);
}
async transformScheduleForAtom(
schedule: ScheduleWithAvailabilities,
userSchedulesCount: number,
user: Pick<User, "id" | "defaultScheduleId" | "timeZone">
): Promise<ScheduleOutput> {
const timeZone = schedule.timeZone || user.timeZone;
const defaultSchedule = await this.getUserScheduleDefault(user.id);
return {
id: schedule.id,
name: schedule.name,
isManaged: schedule.userId !== user.id,
workingHours: transformWorkingHoursForClient(schedule),
schedule: schedule.availability,
availability: transformAvailabilityForClient(schedule),
timeZone,
dateOverrides: transformDateOverridesForClient(schedule, timeZone),
isDefault: defaultSchedule?.id === schedule.id,
isLastSchedule: userSchedulesCount <= 1,
readOnly: schedule.userId !== user.id,
};
}
checkUserOwnsSchedule(userId: number, schedule: Pick<Schedule, "id" | "userId">) {
if (userId !== schedule.userId) {
throw new ForbiddenException(`User with ID=${userId} does not own schedule with ID=${schedule.id}`);
@@ -1,3 +1,4 @@
import { extractUserContext } from "@/lib/extract-user-context";
import { filterReqHeaders } from "@/lib/filterReqHeaders";
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
import { Request } from "express";
@@ -39,6 +40,7 @@ export class CalendarServiceExceptionFilter implements ExceptionFilter {
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
response.setHeader("X-Request-Id", requestId.toString());
const userContext = extractUserContext(request);
this.logger.error(`Calendar Service Exception Filter: ${exception?.message}`, {
exception,
body: request.body,
@@ -46,6 +48,7 @@ export class CalendarServiceExceptionFilter implements ExceptionFilter {
url: request.url,
method: request.method,
requestId,
...userContext,
});
response.status(400).json({
@@ -1,3 +1,4 @@
import { extractUserContext } from "@/lib/extract-user-context";
import { filterReqHeaders } from "@/lib/filterReqHeaders";
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Logger } from "@nestjs/common";
import { Request } from "express";
@@ -16,6 +17,7 @@ export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
const statusCode = exception.getStatus();
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
response.setHeader("X-Request-Id", requestId.toString());
const userContext = extractUserContext(request);
this.logger.error(`Http Exception Filter: ${exception?.message}`, {
exception,
body: request.body,
@@ -23,6 +25,7 @@ export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
url: request.url,
method: request.method,
requestId,
...userContext,
});
response.status(statusCode).json({
@@ -1,3 +1,4 @@
import { extractUserContext } from "@/lib/extract-user-context";
import { filterReqHeaders } from "@/lib/filterReqHeaders";
import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
import { Catch, HttpStatus, Logger } from "@nestjs/common";
@@ -43,6 +44,7 @@ export class PrismaExceptionFilter implements ExceptionFilter {
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
response.setHeader("X-Request-Id", requestId.toString());
const userContext = extractUserContext(request);
this.logger.error(`PrismaError: ${error.message}`, {
error,
body: request.body,
@@ -50,6 +52,7 @@ export class PrismaExceptionFilter implements ExceptionFilter {
url: request.url,
method: request.method,
requestId,
...userContext,
});
let message = "There was an error, please try again later.";
@@ -1,3 +1,4 @@
import { extractUserContext } from "@/lib/extract-user-context";
import { filterReqHeaders } from "@/lib/filterReqHeaders";
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
import { Request } from "express";
@@ -120,6 +121,7 @@ export class TRPCExceptionFilter implements ExceptionFilter {
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
response.setHeader("X-Request-Id", requestId.toString());
const userContext = extractUserContext(request);
this.logger.error(`TRPC Exception Filter: ${exception?.message}`, {
exception,
body: request.body,
@@ -127,6 +129,7 @@ export class TRPCExceptionFilter implements ExceptionFilter {
url: request.url,
method: request.method,
requestId,
...userContext,
});
response.status(statusCode).json({
@@ -1,3 +1,4 @@
import { extractUserContext } from "@/lib/extract-user-context";
import { filterReqHeaders } from "@/lib/filterReqHeaders";
import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
import { Catch, HttpStatus, Logger } from "@nestjs/common";
@@ -18,6 +19,7 @@ export class ZodExceptionFilter implements ExceptionFilter {
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
response.setHeader("X-Request-Id", requestId.toString());
const userContext = extractUserContext(request);
this.logger.error(`ZodError: ${error.message}`, {
error,
body: request.body,
@@ -25,6 +27,7 @@ export class ZodExceptionFilter implements ExceptionFilter {
url: request.url,
method: request.method,
requestId,
...userContext,
});
response.status(HttpStatus.BAD_REQUEST).json({
@@ -0,0 +1,38 @@
import { Request } from "express";
import { Team } from "@calcom/prisma/client";
import { ApiAuthGuardUser } from "../modules/auth/strategies/api-auth/api-auth.strategy";
export interface UserContext {
userId?: string;
userEmail?: string;
organizationId?: string;
teamId?: string;
userOrgId?: string;
}
export function extractUserContext(request: Request): UserContext {
const context: UserContext = {};
const user = (request as any).user as ApiAuthGuardUser | undefined;
if (user) {
context.userId = String(user.id);
context.userEmail = user.email;
if (user.profiles?.[0]?.organizationId) {
context.userOrgId = String(user.profiles?.[0].organizationId);
}
}
const organizationId = (request as any).organizationId as number | undefined;
if (organizationId) {
context.organizationId = String(organizationId);
}
const team = (request as any).team as Team | undefined;
if (team) {
context.teamId = String(team.id);
}
return context;
}
@@ -1,3 +1,4 @@
import { extractUserContext } from "@/lib/extract-user-context";
import { CallHandler, ExecutionContext, Injectable, NestInterceptor, Logger } from "@nestjs/common";
import { Request, Response } from "express";
import { tap } from "rxjs/operators";
@@ -14,6 +15,7 @@ export class ResponseInterceptor implements NestInterceptor {
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
response.setHeader("X-Request-Id", requestId.toString());
const { method, url } = request;
const userContext = extractUserContext(request);
const startTime = Date.now();
return next.handle().pipe(
@@ -39,6 +41,7 @@ export class ResponseInterceptor implements NestInterceptor {
responseTime,
responseBody: jsonBodyString,
timestamp: new Date().toISOString(),
...userContext,
});
})
);
@@ -95,7 +95,9 @@ 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);
const onlyManagedTeamMembers = eventType.teamMembers.filter(
(user) => user.isPlatformManaged && user.membership === "MEMBER"
);
eventType.teamMembers = onlyManagedTeamMembers;
return eventType;
@@ -1,7 +1,6 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { Prisma } from "@prisma/client";
@Injectable()
export class BookingSeatRepository {
@@ -5,7 +5,7 @@ import { calendar_v3 } from "@googleapis/calendar";
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { NotFoundException } from "@nestjs/common";
import { Logger } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import type { Prisma } from "@prisma/client";
import { JWT } from "googleapis-common";
import { DelegationCredentialRepository, OAuth2UniversalSchema } from "@calcom/platform-libraries/app-store";
@@ -87,7 +87,7 @@ export class ConferencingController {
@HttpCode(HttpStatus.OK)
@UseGuards(ApiAuthGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({ summary: "Get OAuth conferencing app auth url" })
@ApiOperation({ summary: "Get OAuth conferencing app auth URL" })
@ApiParam({
name: "app",
description: "Conferencing application type",
@@ -130,7 +130,7 @@ export class ConferencingController {
@Get("/:app/oauth/callback")
@UseGuards()
@Redirect(undefined, 301)
@ApiOperation({ summary: "conferencing apps oauths callback" })
@ApiOperation({ summary: "Conferencing app OAuth callback" })
@ApiParam({
name: "app",
description: "Conferencing application type",
@@ -1,7 +1,7 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import type { Prisma } from "@prisma/client";
import { GOOGLE_MEET_TYPE } from "@calcom/platform-constants";
@@ -9,7 +9,7 @@ import { GOOGLE_MEET_TYPE } from "@calcom/platform-constants";
export class ConferencingRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
private readonly credentialSelect = Prisma.validator<Prisma.CredentialSelect>()({
private readonly credentialSelect = {
id: true,
type: true,
userId: true,
@@ -20,7 +20,7 @@ export class ConferencingRepository {
billingCycleStart: true,
invalid: true,
delegationCredentialId: true,
});
} satisfies Prisma.CredentialSelect;
async findConferencingApps(userId: number) {
return this.dbRead.prisma.credential.findMany({
@@ -1,7 +1,7 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import type { Prisma } from "@prisma/client";
import { APPS_TYPE_ID_MAPPING } from "@calcom/platform-constants";
import { credentialForCalendarServiceSelect } from "@calcom/platform-libraries";
@@ -92,6 +92,8 @@ describe("Platform Destination Calendar Endpoints", () => {
publisher: "",
url: "",
email: "",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
// calendars: {
// externalId:
@@ -118,6 +120,8 @@ describe("Platform Destination Calendar Endpoints", () => {
id: 0,
delegationCredentialId: null,
domainWideDelegationCredentialId: null,
createdAt: new Date(),
updatedAt: new Date(),
},
})
);
@@ -2,7 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { Injectable } from "@nestjs/common";
import { v4 as uuidv4 } from "uuid";
import { Prisma } from "@calcom/prisma/client";
import type { Prisma } from "@calcom/prisma/client";
import { PrismaWriteService } from "../../prisma/prisma-write.service";
@@ -134,7 +134,7 @@ export class OrganizationsAttributesOptionsController {
@Roles("ORG_MEMBER")
@PlatformPlan("ESSENTIALS")
@Get("/attributes/:attributeId/options/assigned")
@ApiOperation({ summary: "Get by attribute id all of the attribute options that are assigned to users" })
@ApiOperation({ summary: "Get all assigned attribute options by attribute ID" })
async getOrganizationAttributeAssignedOptions(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("attributeId") attributeId: string,
@@ -158,7 +158,7 @@ export class OrganizationsAttributesOptionsController {
@Roles("ORG_MEMBER")
@PlatformPlan("ESSENTIALS")
@Get("/attributes/slugs/:attributeSlug/options/assigned")
@ApiOperation({ summary: "Get by attribute slug all of the attribute options that are assigned to users" })
@ApiOperation({ summary: "Get all assigned attribute options by attribute slug" })
async getOrganizationAttributeAssignedOptionsBySlug(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("attributeSlug") attributeSlug: string,
@@ -34,7 +34,7 @@ export class OrganizationsBookingsController {
constructor(private readonly bookingsService: BookingsService_2024_08_13) {}
@Get("/")
@ApiOperation({ summary: "Get Organization Bookings" })
@ApiOperation({ summary: "Get organization bookings" })
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@HttpCode(HttpStatus.OK)
@@ -105,7 +105,7 @@ export class OrganizationsConferencingController {
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@Get("/teams/:teamId/conferencing/:app/oauth/auth-url")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Get OAuth conferencing app's auth url for a team" })
@ApiOperation({ summary: "Get OAuth conferencing app's auth URL for a team" })
async getTeamOAuthUrl(
@Req() req: Request,
@Headers("Authorization") authorization: string,
@@ -52,7 +52,7 @@ export class OrganizationsDelegationCredentialController {
@HttpCode(HttpStatus.CREATED)
@Roles("ORG_ADMIN")
@PlatformPlan("SCALE")
@ApiOperation({ summary: "Save delegation credentials for your organization." })
@ApiOperation({ summary: "Save delegation credentials for your organization" })
async createDelegationCredential(
@Param("orgId", ParseIntPipe) orgId: number,
@GetUser() delegatedServiceAccountUser: User,
@@ -72,7 +72,7 @@ export class OrganizationsDelegationCredentialController {
@Patch("/:credentialId")
@Roles("ORG_ADMIN")
@PlatformPlan("SCALE")
@ApiOperation({ summary: "Update delegation credentials of your organization." })
@ApiOperation({ summary: "Update delegation credentials of your organization" })
async updateDelegationCredential(
@Param("orgId", ParseIntPipe) orgId: number,
@GetUser() delegatedServiceAccountUser: User,
@@ -2,7 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { Prisma } from "@calcom/prisma/client";
import type { Prisma } from "@calcom/prisma/client";
@Injectable()
export class OrganizationsDelegationCredentialRepository {
@@ -69,7 +69,7 @@ export class OrganizationsRepository {
return id;
}
async findTeamIdFromClientId(clientId: string) {
async findTeamIdAndSlugFromClientId(clientId: string) {
return this.dbRead.prisma.team.findFirstOrThrow({
where: {
platformOAuthClient: {
@@ -80,6 +80,7 @@ export class OrganizationsRepository {
},
select: {
id: true,
slug: true,
},
});
}
@@ -4,7 +4,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { MembershipUserSelect } from "@/modules/teams/memberships/teams-memberships.repository";
import { Injectable } from "@nestjs/common";
import { Prisma } from "@calcom/prisma/client";
import type { Prisma } from "@calcom/prisma/client";
import { UpdateOrgMembershipDto } from "./inputs/update-organization-membership.input";
@@ -3,7 +3,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { SkipTakePagination } from "@calcom/platform-types";
import { Prisma } from "@calcom/prisma/client";
import type { Prisma } from "@calcom/prisma/client";
@Injectable()
export class ManagedOrganizationsRepository {
@@ -37,7 +37,13 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
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({
@@ -72,6 +78,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
role: "OWNER",
user: { connect: { id: user.id } },
team: { connect: { id: org.id } },
accepted: true,
});
await profileRepositoryFixture.create({
@@ -93,14 +100,74 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
apiKeyString = `${keyString}`;
// 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: team.id,
},
});
routingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
data: {
name: "Test Routing Form",
description: "Test Description",
disabled: false,
routes: JSON.stringify([]),
fields: JSON.stringify([]),
settings: JSON.stringify({}),
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/${team.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: team.id,
userId: user.id,
},
@@ -175,6 +242,353 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
});
});
describe(`POST /v2/organizations/:orgId/routing-forms/:routingFormId/responses`, () => {
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`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1",
})
.expect(403);
});
it("should return 404 when routing form does not exist", async () => {
return request(app.getHttpServer())
.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}` })
.send({
question1: "answer1",
})
.expect(404);
});
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`)
.send({
question1: "answer1",
})
.expect(401);
});
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`)
.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();
expect(typeof data.slots).toBe("object");
});
});
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`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question2: "answer2", // Missing required question1
})
.expect(400);
});
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`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "different-answer", // This won't match any route
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.routingCustomMessage).toBeDefined();
expect(data.routingCustomMessage).toBe("Fallback Message");
});
});
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}` })
.send({
question1: "answer1",
question2: "answer2",
})
.expect(400);
// 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}` })
.send({
question1: "answer1",
question2: "answer2",
})
.expect(400);
});
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`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1",
})
.expect(400);
});
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`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1",
})
.expect(400);
});
it("should return 403 when user lacks permission to access organization", async () => {
// Create a new user without organization access
const unauthorizedUser = await userRepositoryFixture.create({
email: `unauthorized-user-${randomString()}@api.com`,
});
const { keyString: unauthorizedApiKey } = await apiKeysRepositoryFixture.createApiKey(unauthorizedUser.id, null);
const response = await 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_${unauthorizedApiKey}` })
.send({
question1: "answer1",
});
expect(response.status).toBe(403);
// Clean up
await prismaWriteService.prisma.user.delete({
where: { id: unauthorizedUser.id },
});
});
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`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.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.routing?.queuedResponseId).toBeDefined();
});
});
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/${team.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: 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`)
.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 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({
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: team.id,
userId: user.id,
},
});
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${org.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())
@@ -9,13 +9,28 @@ import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.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";
import { Body, Controller, Get, Param, Patch, Query, UseGuards, ParseIntPipe } from "@nestjs/common";
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
ParseIntPipe,
Req,
} 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";
import { UpdateRoutingFormResponseInput } from "../inputs/update-routing-form-response.input";
import { CreateRoutingFormResponseOutput } from "../outputs/create-routing-form-response.output";
import { UpdateRoutingFormResponseOutput } from "../outputs/update-routing-form-response.output";
@Controller({
@@ -56,6 +71,29 @@ export class OrganizationsRoutingFormsResponsesController {
};
}
@Post("/")
@ApiOperation({ summary: "Create routing form response and get available slots" })
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
async createRoutingFormResponse(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("routingFormId") routingFormId: string,
@Query() query: CreateRoutingFormResponseInput,
@Req() request: Request
): Promise<CreateRoutingFormResponseOutput> {
const result = await this.organizationsRoutingFormsResponsesService.createRoutingFormResponseWithSlots(
orgId,
routingFormId,
query,
request
);
return {
status: SUCCESS_STATUS,
data: result,
};
}
@Patch("/:responseId")
@ApiOperation({ summary: "Update routing form response" })
@Roles("ORG_ADMIN")
@@ -0,0 +1,23 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsBoolean, IsOptional } from "class-validator";
import { GetAvailableSlotsInput_2024_09_04 } from "@calcom/platform-types";
export class CreateRoutingFormResponseInput extends GetAvailableSlotsInput_2024_09_04 {
@Transform(({ value }: { value: string | boolean }) => {
if (typeof value === 'boolean') return value;
if (typeof value === 'string') {
return value.toLowerCase() === 'true';
}
return undefined;
})
@IsBoolean()
@IsOptional()
@ApiPropertyOptional({
type: Boolean,
description: "Whether to queue the form response.",
example: true,
})
queueResponse?: boolean;
}
@@ -4,7 +4,9 @@ import { OrganizationsTeamsRoutingFormsResponsesOutputService } from "@/modules/
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { RoutingFormsModule } from "@/modules/routing-forms/routing-forms.module";
import { SlotsModule_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
import { Module } from "@nestjs/common";
import { OrganizationsRoutingFormsResponsesController } from "./controllers/organizations-routing-forms-responses.controller";
@@ -14,7 +16,7 @@ import { OrganizationsRoutingFormsResponsesService } from "./services/organizati
import { OrganizationsRoutingFormsService } from "./services/organizations-routing-forms.service";
@Module({
imports: [PrismaModule, StripeModule, RedisModule, RoutingFormsModule],
imports: [PrismaModule, StripeModule, RedisModule, RoutingFormsModule, SlotsModule_2024_09_04],
providers: [
MembershipsRepository,
OrganizationsRepository,
@@ -22,6 +24,7 @@ import { OrganizationsRoutingFormsService } from "./services/organizations-routi
OrganizationsRoutingFormsService,
OrganizationsRoutingFormsResponsesService,
OrganizationsTeamsRoutingFormsResponsesOutputService,
TeamsEventTypesRepository,
],
controllers: [OrganizationsRoutingFormsController, OrganizationsRoutingFormsResponsesController],
})
@@ -0,0 +1,136 @@
import { ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, ValidateNested } from "class-validator";
import { ApiResponseWithoutData, SlotsOutput_2024_09_04, RangeSlotsOutput_2024_09_04 } from "@calcom/platform-types";
class Routing {
@ApiProperty({
type: String,
description: "The ID of the queued form response. Only present if the form response was queued.",
example: "123",
})
@IsString()
@IsOptional()
@ApiPropertyOptional()
queuedResponseId?: string | null;
@ApiProperty({
type: Number,
description: "The ID of the routing form response.",
example: 123,
})
@IsInt()
@IsOptional()
@ApiPropertyOptional()
responseId?: number | null;
@ApiProperty({
type: [Number],
description: "Array of team member IDs that were routed to handle this booking.",
example: [101, 102],
})
@IsArray()
@IsInt({ each: true })
teamMemberIds!: number[];
@ApiPropertyOptional({
type: String,
description: "The email of the team member assigned to handle this booking.",
example: "john.doe@example.com",
})
@IsString()
@IsOptional()
teamMemberEmail?: string;
@ApiPropertyOptional({
type: Boolean,
description: "Whether to skip contact owner assignment from CRM integration.",
example: true,
})
@IsBoolean()
@IsOptional()
skipContactOwner?: boolean;
@ApiPropertyOptional({
type: String,
description: "The CRM application slug for integration.",
example: "salesforce",
})
@IsString()
@IsOptional()
crmAppSlug?: string;
@ApiPropertyOptional({
type: String,
description: "The CRM owner record type for contact assignment.",
example: "Account",
})
@IsString()
@IsOptional()
crmOwnerRecordType?: string;
}
export class CreateRoutingFormResponseOutputData {
@ApiPropertyOptional({
type: Number,
description: "The ID of the event type that was routed to.",
example: 123,
})
@IsNumber()
@IsOptional()
eventTypeId?: number;
@ValidateNested()
@ApiProperty({ type: Routing })
@Type(() => Routing)
@ApiPropertyOptional({
type: Routing,
description: "The routing information.",
example: {
eventTypeId: 123,
routing: {
teamMemberIds: [101, 102],
teamMemberEmail: "john.doe@example.com",
skipContactOwner: true,
},
},
})
@ValidateNested()
@Type(() => Routing)
routing?: Routing;
@IsString()
@IsOptional()
@ApiPropertyOptional({
type: String,
description: "A custom message to be displayed to the user in case of routing to a custom page.",
example: "This is a custom message.",
})
routingCustomMessage?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional({
type: String,
description: "The external redirect URL to be used in case of routing to a non cal.com event type URL.",
example: "https://example.com/",
})
routingExternalRedirectUrl?: string;
@ValidateNested()
@ApiProperty({
oneOf: [
{ $ref: getSchemaPath(SlotsOutput_2024_09_04) },
{ $ref: getSchemaPath(RangeSlotsOutput_2024_09_04) },
],
})
@Type(() => Object)
slots?: SlotsOutput_2024_09_04 | RangeSlotsOutput_2024_09_04;
}
export class CreateRoutingFormResponseOutput extends ApiResponseWithoutData {
@ValidateNested()
@ApiProperty({ type: CreateRoutingFormResponseOutputData })
@Type(() => CreateRoutingFormResponseOutputData)
data!: CreateRoutingFormResponseOutputData;
}
@@ -1,12 +1,23 @@
import { OrganizationsRoutingFormsRepository } from "@/modules/organizations/routing-forms/organizations-routing-forms.repository";
import { OrganizationsTeamsRoutingFormsResponsesOutputService } from "@/modules/organizations/teams/routing-forms/services/organizations-teams-routing-forms-responses-output.service";
import { Injectable } from "@nestjs/common";
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 { 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";
@Injectable()
export class OrganizationsRoutingFormsResponsesService {
constructor(
private readonly organizationsRoutingFormsRepository: OrganizationsRoutingFormsRepository,
private readonly outputService: OrganizationsTeamsRoutingFormsResponsesOutputService
private readonly outputService: OrganizationsTeamsRoutingFormsResponsesOutputService,
private readonly slotsService: SlotsService_2024_09_04,
private readonly teamsEventTypesRepository: TeamsEventTypesRepository
) {}
async getOrganizationRoutingFormResponses(
@@ -52,4 +63,194 @@ 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];
}
}
@@ -65,7 +65,7 @@ export class OrganizationsStripeController {
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@Get("/connect")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Get stripe connect URL for a team" })
@ApiOperation({ summary: "Get Stripe connect URL for a team" })
async getTeamStripeConnectUrl(
@Req() req: Request,
@Headers("Authorization") authorization: string,
@@ -104,7 +104,7 @@ export class OrganizationsStripeController {
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@Get("/check")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Check team stripe connection" })
@ApiOperation({ summary: "Check team Stripe connection" })
async checkTeamStripeConnection(
@Param("teamId", ParseIntPipe) teamId: number
): Promise<StripCredentialsCheckOutputResponseDto> {
@@ -116,7 +116,7 @@ export class OrganizationsStripeController {
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@Get("/save")
@Redirect(undefined, 301)
@ApiOperation({ summary: "Save stripe credentials" })
@ApiOperation({ summary: "Save Stripe credentials" })
async save(
@Query("state") state: string,
@Query("code") code: string,
@@ -6,7 +6,9 @@ import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/inde
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { RoutingFormsModule } from "@/modules/routing-forms/routing-forms.module";
import { SlotsModule_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
import { Module } from "@nestjs/common";
import { OrganizationsTeamsRoutingFormsResponsesController } from "./controllers/organizations-teams-routing-forms-responses.controller";
@@ -18,7 +20,7 @@ import { OrganizationsTeamsRoutingFormsResponsesService } from "./services/organ
import { OrganizationsTeamsRoutingFormsService } from "./services/organizations-teams-routing-forms.service";
@Module({
imports: [PrismaModule, StripeModule, RedisModule, RoutingFormsModule],
imports: [PrismaModule, StripeModule, RedisModule, RoutingFormsModule, SlotsModule_2024_09_04, TeamsEventTypesModule],
providers: [
OrganizationsTeamsRoutingFormsService,
OrganizationsTeamsRoutingFormsResponsesService,
@@ -34,7 +34,7 @@ export class OrganizationsUsersBookingsController {
@Get("/")
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@ApiOperation({ summary: "Get all bookings of an organization user" })
@ApiOperation({ summary: "Get all bookings for an organization user" })
async getOrganizationUserBookings(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("userId", ParseIntPipe) userId: number,
@@ -1,7 +1,7 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import type { Prisma } from "@prisma/client";
@Injectable()
export class OrganizationsUsersRepository {
@@ -66,7 +66,7 @@ export class OrganizationsUsersOOOController {
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsUserInOrg)
@ApiOperation({ summary: "Get all ooo entries of a user" })
@ApiOperation({ summary: "Get all out-of-office entries for a user" })
async getOrganizationUserOOO(
@Param("userId", ParseIntPipe) userId: number,
@Query() query: GetOutOfOfficeEntryFiltersDTO
@@ -84,7 +84,7 @@ export class OrganizationsUsersOOOController {
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsUserInOrg)
@ApiOperation({ summary: "Create an ooo entry for user" })
@ApiOperation({ summary: "Create an out-of-office entry for a user" })
async createOrganizationUserOOO(
@Param("userId", ParseIntPipe) userId: number,
@Body() input: CreateOutOfOfficeEntryDto
@@ -100,7 +100,7 @@ export class OrganizationsUsersOOOController {
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsUserInOrg, IsUserOOO)
@ApiOperation({ summary: "Update ooo entry of a user" })
@ApiOperation({ summary: "Update an out-of-office entry for a user" })
async updateOrganizationUserOOO(
@Param("userId", ParseIntPipe) userId: number,
@Param("oooId", ParseIntPipe) oooId: number,
@@ -118,7 +118,7 @@ export class OrganizationsUsersOOOController {
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsUserInOrg, IsUserOOO)
@ApiOperation({ summary: "Delete ooo entry of a user" })
@ApiOperation({ summary: "Delete an out-of-office entry for a user" })
async deleteOrganizationUserOOO(
@Param("oooId", ParseIntPipe) oooId: number
): Promise<UserOooOutputResponseDto> {
@@ -132,7 +132,7 @@ export class OrganizationsUsersOOOController {
@Get("/ooo")
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@ApiOperation({ summary: "Get all OOO entries of org users" })
@ApiOperation({ summary: "Get all out-of-office entries for organization users" })
async getOrganizationUsersOOO(
@Param("orgId", ParseIntPipe) orgId: number,
@Query() query: GetOrgUsersOutOfOfficeEntryFiltersDTO
@@ -25,9 +25,9 @@ export class RouterController {
@Param("formId") formId: string,
@Body() body?: Record<string, string>
): Promise<void | (ApiResponse<unknown> & { redirect: boolean })> {
const params = Object.fromEntries(new URLSearchParams(body ?? {}));
const routedUrlData = await getRoutedUrl({ req: request, query: { ...params, form: formId } });
if (routedUrlData?.notFound) {
throw new NotFoundException("Route not found. Please check the provided form parameter.");
}
@@ -37,6 +37,13 @@ export class RouterController {
}
if (routedUrlData?.props) {
if (routedUrlData.props.errorMessage) {
return {
status: "error",
error: { code: "ROUTING_ERROR", message: routedUrlData.props.errorMessage },
redirect: false,
};
}
return { status: "success", data: { message: routedUrlData.props.message ?? "" }, redirect: false };
}
@@ -54,6 +61,7 @@ export class RouterController {
) {
return this.handleRedirectWithContactOwner(routingUrl, routingSearchParams);
}
console.log("handleRedirect Regular called", { destination });
return { status: "success", data: destination, redirect: true };
}
@@ -62,6 +70,7 @@ export class RouterController {
routingUrl: URL,
routingSearchParams: URLSearchParams
): Promise<ApiResponse<unknown> & { redirect: boolean }> {
console.log("handleRedirectWithContactOwner called", { routingUrl, routingSearchParams });
const pathNameParams = routingUrl.pathname.split("/");
const eventTypeSlug = pathNameParams[pathNameParams.length - 1];
const teamId = Number(routingSearchParams.get("cal.teamId"));
@@ -23,7 +23,6 @@ export class RoutingFormsService {
if (!eventTypeId) {
throw new NotFoundException("Event type not found.");
}
const slots = await this.slotsService.getAvailableSlots({
type: ById_2024_09_04_type,
eventTypeId,
@@ -58,7 +58,7 @@ export class SlotsController_2024_09_04 {
@Get("/")
@ApiOperation({
summary: "Find out when is an event type ready to be booked.",
summary: "Get available time slots for an event type",
description: `
There are 4 ways to get available slots for event type of an individual user:
@@ -290,7 +290,7 @@ export class SlotsController_2024_09_04 {
@Patch("/reservations/:uid")
@ApiOperation({
summary: "Updated reserved a slot",
summary: "Update a reserved slot",
})
@HttpCode(HttpStatus.OK)
async updateReservedSlot(
@@ -10,7 +10,6 @@ import { DateTime } from "luxon";
import { dynamicEvent } from "@calcom/platform-libraries";
import {
ById_2024_09_04,
ByUsernameAndEventTypeSlug_2024_09_04,
ByTeamSlugAndEventTypeSlug_2024_09_04,
GetSlotsInput_2024_09_04,
@@ -47,6 +46,10 @@ 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,
@@ -59,6 +62,10 @@ export class SlotsInputService_2024_09_04 {
timeZone,
orgSlug,
rescheduleUid,
routedTeamMemberIds,
skipContactOwner,
teamMemberEmail,
routingFormResponseId,
};
}
@@ -47,6 +47,7 @@ export class SlotsService_2024_09_04 {
const availableSlots: TimeSlots = await getAvailableSlots({
input: {
...queryTransformed,
routingFormResponseId: queryTransformed.routingFormResponseId ?? undefined,
},
ctx: {},
});
@@ -49,7 +49,7 @@ export class StripeController {
@Get("/connect")
@UseGuards(ApiAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Get stripe connect URL" })
@ApiOperation({ summary: "Get Stripe connect URL" })
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
async redirect(
@Req() req: Request,
@@ -79,7 +79,7 @@ export class StripeController {
@Get("/save")
@UseGuards()
@Redirect(undefined, 301)
@ApiOperation({ summary: "Save stripe credentials" })
@ApiOperation({ summary: "Save Stripe credentials" })
/**
* Handles saving Stripe credentials.
* If both orgId and teamId are present in the callback state, the request is proxied to the organization/team-level endpoint;
@@ -151,7 +151,7 @@ export class StripeController {
@UseGuards(ApiAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({ summary: "Check stripe connection" })
@ApiOperation({ summary: "Check Stripe connection" })
async check(@GetUser() user: UserWithProfile): Promise<StripCredentialsCheckOutputResponseDto> {
return await this.stripeService.checkIfIndividualStripeAccountConnected(user.id);
}
@@ -4,7 +4,7 @@ import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/cr
import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input";
import { Injectable } from "@nestjs/common";
import { Prisma } from "@calcom/prisma/client";
import type { Prisma } from "@calcom/prisma/client";
export const MembershipUserSelect: Prisma.UserSelect = {
username: true,
@@ -85,7 +85,7 @@ export class WebhooksController {
@Get("/")
@ApiOperation({
summary: "Get all webooks",
summary: "Get all webhooks",
description: "Gets a paginated list of webhooks for the authenticated user.",
})
async getWebhooks(
+303 -24
View File
@@ -1123,7 +1123,7 @@
"/v2/organizations/{orgId}/attributes/{attributeId}/options/assigned": {
"get": {
"operationId": "OrganizationsAttributesOptionsController_getOrganizationAttributeAssignedOptions",
"summary": "Get by attribute id all of the attribute options that are assigned to users",
"summary": "Get all assigned attribute options by attribute ID",
"parameters": [
{
"name": "Authorization",
@@ -1215,7 +1215,7 @@
"/v2/organizations/{orgId}/attributes/slugs/{attributeSlug}/options/assigned": {
"get": {
"operationId": "OrganizationsAttributesOptionsController_getOrganizationAttributeAssignedOptionsBySlug",
"summary": "Get by attribute slug all of the attribute options that are assigned to users",
"summary": "Get all assigned attribute options by attribute slug",
"parameters": [
{
"name": "Authorization",
@@ -1467,7 +1467,7 @@
"/v2/organizations/{orgId}/bookings": {
"get": {
"operationId": "OrganizationsBookingsController_getAllOrgTeamBookings",
"summary": "Get Organization Bookings",
"summary": "Get organization bookings",
"parameters": [
{
"name": "Authorization",
@@ -1763,7 +1763,7 @@
"/v2/organizations/{orgId}/delegation-credentials": {
"post": {
"operationId": "OrganizationsDelegationCredentialController_createDelegationCredential",
"summary": "Save delegation credentials for your organization.",
"summary": "Save delegation credentials for your organization",
"parameters": [
{
"name": "Authorization",
@@ -1831,7 +1831,7 @@
"/v2/organizations/{orgId}/delegation-credentials/{credentialId}": {
"patch": {
"operationId": "OrganizationsDelegationCredentialController_updateDelegationCredential",
"summary": "Update delegation credentials of your organization.",
"summary": "Update delegation credentials of your organization",
"parameters": [
{
"name": "Authorization",
@@ -2542,6 +2542,126 @@
"tags": [
"Orgs / Routing forms"
]
},
"post": {
"operationId": "OrganizationsRoutingFormsResponsesController_createRoutingFormResponse",
"summary": "Create routing form response and get available slots",
"parameters": [
{
"name": "Authorization",
"in": "header",
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "orgId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "routingFormId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "start",
"required": true,
"in": "query",
"description": "\n Time starting from which available slots should be checked.\n \n Must be in UTC timezone as ISO 8601 datestring.\n \n You can pass date without hours which defaults to start of day or specify hours:\n 2024-08-13 (will have hours 00:00:00 aka at very beginning of the date) or you can specify hours manually like 2024-08-13T09:00:00Z\n ",
"example": "2050-09-05",
"schema": {
"type": "string"
}
},
{
"name": "end",
"required": true,
"in": "query",
"description": "\n Time until which available slots should be checked.\n \n Must be in UTC timezone as ISO 8601 datestring.\n \n You can pass date without hours which defaults to end of day or specify hours:\n 2024-08-20 (will have hours 23:59:59 aka at the very end of the date) or you can specify hours manually like 2024-08-20T18:00:00Z",
"example": "2050-09-06",
"schema": {
"type": "string"
}
},
{
"name": "timeZone",
"required": false,
"in": "query",
"description": "Time zone in which the available slots should be returned. Defaults to UTC.",
"example": "Europe/Rome",
"schema": {
"type": "string"
}
},
{
"name": "duration",
"required": false,
"in": "query",
"description": "If event type has multiple possible durations then you can specify the desired duration here. Also, if you are fetching slots for a dynamic event then you can specify the duration her which defaults to 30, meaning that returned slots will be each 30 minutes long.",
"example": "60",
"schema": {
"type": "number"
}
},
{
"name": "format",
"required": false,
"in": "query",
"description": "Format of slot times in response. Use 'range' to get start and end times.",
"example": "range",
"schema": {
"enum": [
"range",
"time"
],
"type": "string"
}
},
{
"name": "bookingUidToReschedule",
"required": false,
"in": "query",
"description": "The unique identifier of the booking being rescheduled. When provided will ensure that the original booking time appears within the returned available slots when rescheduling.",
"example": "abc123def456",
"schema": {
"type": "string"
}
},
{
"name": "queueResponse",
"required": false,
"in": "query",
"description": "Whether to queue the form response.",
"example": true,
"schema": {
"type": "boolean"
}
}
],
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateRoutingFormResponseOutput"
}
}
}
}
},
"tags": [
"Orgs / Routing forms"
]
}
},
"/v2/organizations/{orgId}/routing-forms/{routingFormId}/responses/{responseId}": {
@@ -3454,7 +3574,7 @@
"/v2/organizations/{orgId}/teams/{teamId}/conferencing/{app}/oauth/auth-url": {
"get": {
"operationId": "OrganizationsConferencingController_getTeamOAuthUrl",
"summary": "Get OAuth conferencing app's auth url for a team",
"summary": "Get OAuth conferencing app's auth URL for a team",
"parameters": [
{
"name": "Authorization",
@@ -5761,7 +5881,7 @@
"/v2/organizations/{orgId}/users/{userId}/bookings": {
"get": {
"operationId": "OrganizationsUsersBookingsController_getOrganizationUserBookings",
"summary": "Get all bookings of an organization user",
"summary": "Get all bookings for an organization user",
"parameters": [
{
"name": "Authorization",
@@ -6048,7 +6168,7 @@
"/v2/organizations/{orgId}/users/{userId}/ooo": {
"get": {
"operationId": "OrganizationsUsersOOOController_getOrganizationUserOOO",
"summary": "Get all ooo entries of a user",
"summary": "Get all out-of-office entries for a user",
"parameters": [
{
"name": "Authorization",
@@ -6150,7 +6270,7 @@
},
"post": {
"operationId": "OrganizationsUsersOOOController_createOrganizationUserOOO",
"summary": "Create an ooo entry for user",
"summary": "Create an out-of-office entry for a user",
"parameters": [
{
"name": "Authorization",
@@ -6211,7 +6331,7 @@
"/v2/organizations/{orgId}/users/{userId}/ooo/{oooId}": {
"patch": {
"operationId": "OrganizationsUsersOOOController_updateOrganizationUserOOO",
"summary": "Update ooo entry of a user",
"summary": "Update an out-of-office entry for a user",
"parameters": [
{
"name": "Authorization",
@@ -6278,7 +6398,7 @@
},
"delete": {
"operationId": "OrganizationsUsersOOOController_deleteOrganizationUserOOO",
"summary": "Delete ooo entry of a user",
"summary": "Delete an out-of-office entry for a user",
"parameters": [
{
"name": "Authorization",
@@ -6329,7 +6449,7 @@
"/v2/organizations/{orgId}/ooo": {
"get": {
"operationId": "OrganizationsUsersOOOController_getOrganizationUsersOOO",
"summary": "Get all OOO entries of org users",
"summary": "Get all out-of-office entries for organization users",
"parameters": [
{
"name": "Authorization",
@@ -8362,7 +8482,7 @@
"/v2/calendars/{calendar}/connect": {
"get": {
"operationId": "CalendarsController_redirect",
"summary": "Get oAuth connect URL",
"summary": "Get OAuth connect URL",
"parameters": [
{
"name": "Authorization",
@@ -8657,7 +8777,7 @@
"/v2/conferencing/{app}/oauth/auth-url": {
"get": {
"operationId": "ConferencingController_redirect",
"summary": "Get OAuth conferencing app auth url",
"summary": "Get OAuth conferencing app auth URL",
"parameters": [
{
"name": "Authorization",
@@ -8718,7 +8838,7 @@
"/v2/conferencing/{app}/oauth/callback": {
"get": {
"operationId": "ConferencingController_save",
"summary": "conferencing apps oauths callback",
"summary": "Conferencing app OAuth callback",
"parameters": [
{
"name": "state",
@@ -10488,7 +10608,7 @@
"/v2/organizations/{orgId}/teams/{teamId}/stripe/connect": {
"get": {
"operationId": "OrganizationsStripeController_getTeamStripeConnectUrl",
"summary": "Get stripe connect URL for a team",
"summary": "Get Stripe connect URL for a team",
"parameters": [
{
"name": "Authorization",
@@ -10551,7 +10671,7 @@
"/v2/organizations/{orgId}/teams/{teamId}/stripe/check": {
"get": {
"operationId": "OrganizationsStripeController_checkTeamStripeConnection",
"summary": "Check team stripe connection",
"summary": "Check team Stripe connection",
"parameters": [
{
"name": "teamId",
@@ -10582,7 +10702,7 @@
"/v2/organizations/{orgId}/teams/{teamId}/stripe/save": {
"get": {
"operationId": "OrganizationsStripeController_save",
"summary": "Save stripe credentials",
"summary": "Save Stripe credentials",
"parameters": [
{
"name": "state",
@@ -11131,7 +11251,7 @@
"/v2/slots": {
"get": {
"operationId": "SlotsController_2024_09_04_getAvailableSlots",
"summary": "Find out when is an event type ready to be booked.",
"summary": "Get available time slots for an event type",
"description": "\n There are 4 ways to get available slots for event type of an individual user:\n\n 1. By event type id. Event type id can be of user and team event types. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By event type slug + username. Example '/v2/slots?eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 3. By event type slug + username + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 4. By usernames only (used for dynamic event type - there is no specific event but you want to know when 2 or more people are available). Example '/v2/slots?usernames=alice,bob&username=bob&organizationSlug=org-slug&start=2050-09-05&end=2050-09-06'. As you see you also need to provide the slug of the organization to which each user in the 'usernames' array belongs.\n\n And 3 ways to get available slots for team event type:\n\n 1. By team event type id. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By team event type slug + team slug. Example '/v2/slots?eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n 3. By team event type slug + team slug + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n All of them require \"start\" and \"end\" query parameters which define the time range for which available slots should be checked.\n Optional parameters are:\n - timeZone: Time zone in which the available slots should be returned. Defaults to UTC.\n - duration: Only use for event types that allow multiple durations or for dynamic event types. If not passed for multiple duration event types defaults to default duration. For dynamic event types defaults to 30 aka each returned slot is 30 minutes long. So duration=60 means that returned slots will be each 60 minutes long.\n - format: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use \"range\" as value.\n - bookingUidToReschedule: When rescheduling an existing booking, provide the booking's unique identifier to exclude its time slot from busy time calculations. This ensures the original booking time appears as available for rescheduling.\n ",
"parameters": [
{
@@ -11437,7 +11557,7 @@
},
"patch": {
"operationId": "SlotsController_2024_09_04_updateReservedSlot",
"summary": "Updated reserved a slot",
"summary": "Update a reserved slot",
"parameters": [
{
"name": "cal-api-version",
@@ -11530,7 +11650,7 @@
"/v2/stripe/connect": {
"get": {
"operationId": "StripeController_redirect",
"summary": "Get stripe connect URL",
"summary": "Get Stripe connect URL",
"parameters": [
{
"name": "Authorization",
@@ -11562,7 +11682,7 @@
"/v2/stripe/save": {
"get": {
"operationId": "StripeController_save",
"summary": "Save stripe credentials",
"summary": "Save Stripe credentials",
"parameters": [
{
"name": "state",
@@ -11601,7 +11721,7 @@
"/v2/stripe/check": {
"get": {
"operationId": "StripeController_check",
"summary": "Check stripe connection",
"summary": "Check Stripe connection",
"parameters": [
{
"name": "Authorization",
@@ -13219,7 +13339,7 @@
},
"get": {
"operationId": "WebhooksController_getWebhooks",
"summary": "Get all webooks",
"summary": "Get all webhooks",
"description": "Gets a paginated list of webhooks for the authenticated user.",
"parameters": [
{
@@ -23931,6 +24051,51 @@
"timeZone"
]
},
"Routing": {
"type": "object",
"properties": {
"responseId": {
"type": "number",
"description": "The ID of the routing form response that determined this booking assignment.",
"example": 123
},
"teamMemberIds": {
"description": "Array of team member IDs that were routed to handle this booking.",
"example": [
101,
102
],
"type": "array",
"items": {
"type": "number"
}
},
"teamMemberEmail": {
"type": "string",
"description": "The email of the team member assigned to handle this booking.",
"example": "john.doe@example.com"
},
"skipContactOwner": {
"type": "boolean",
"description": "Whether to skip contact owner assignment from CRM integration.",
"example": true
},
"crmAppSlug": {
"type": "string",
"description": "The CRM application slug for integration.",
"example": "salesforce"
},
"crmOwnerRecordType": {
"type": "string",
"description": "The CRM owner record type for contact assignment.",
"example": "Account"
}
},
"required": [
"responseId",
"teamMemberIds"
]
},
"CreateBookingInput_2024_08_13": {
"type": "object",
"properties": {
@@ -24036,6 +24201,21 @@
"type": "number",
"example": 30,
"description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking."
},
"routing": {
"description": "Routing information from routing forms that determined the booking assignment. Both responseId and teamMemberIds are required if provided.",
"example": {
"responseId": 123,
"teamMemberIds": [
101,
102
]
},
"allOf": [
{
"$ref": "#/components/schemas/Routing"
}
]
}
},
"required": [
@@ -24149,6 +24329,21 @@
"example": 30,
"description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking."
},
"routing": {
"description": "Routing information from routing forms that determined the booking assignment. Both responseId and teamMemberIds are required if provided.",
"example": {
"responseId": 123,
"teamMemberIds": [
101,
102
]
},
"allOf": [
{
"$ref": "#/components/schemas/Routing"
}
]
},
"instant": {
"type": "boolean",
"description": "Flag indicating if the booking is an instant booking. Only available for team events.",
@@ -24267,6 +24462,21 @@
"example": 30,
"description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking."
},
"routing": {
"description": "Routing information from routing forms that determined the booking assignment. Both responseId and teamMemberIds are required if provided.",
"example": {
"responseId": 123,
"teamMemberIds": [
101,
102
]
},
"allOf": [
{
"$ref": "#/components/schemas/Routing"
}
]
},
"recurrenceCount": {
"type": "number",
"description": "The number of recurrences. If not provided then event type recurrence count will be used. Can't be more than\n event type recurrence count",
@@ -26600,6 +26810,75 @@
"data"
]
},
"CreateRoutingFormResponseOutputData": {
"type": "object",
"properties": {
"eventTypeId": {
"type": "number",
"description": "The ID of the event type that was routed to.",
"example": 123
},
"routing": {
"description": "The routing information.",
"example": {
"eventTypeId": 123,
"routing": {
"teamMemberIds": [
101,
102
],
"teamMemberEmail": "john.doe@example.com",
"skipContactOwner": true
}
},
"allOf": [
{
"$ref": "#/components/schemas/Routing"
}
]
},
"routingCustomMessage": {
"type": "string",
"description": "A custom message to be displayed to the user in case of routing to a custom page.",
"example": "This is a custom message."
},
"routingExternalRedirectUrl": {
"type": "string",
"description": "The external redirect URL to be used in case of routing to a non cal.com event type URL.",
"example": "https://example.com/"
},
"slots": {
"oneOf": [
{
"$ref": "#/components/schemas/SlotsOutput_2024_09_04"
},
{
"$ref": "#/components/schemas/RangeSlotsOutput_2024_09_04"
}
]
}
}
},
"CreateRoutingFormResponseOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/CreateRoutingFormResponseOutputData"
}
},
"required": [
"status",
"data"
]
},
"RequestEmailVerificationInput": {
"type": "object",
"properties": {
@@ -14,11 +14,13 @@ export class ApiKeysRepositoryFixture {
async createApiKey(userId: number, expiresAt: Date | null, teamId?: number) {
const keyString = randomBytes(16).toString("hex");
const hashedKey = createHash("sha256").update(keyString).digest("hex");
console.log("createApiKey Fixutre", { hashedKey, keyString });
const apiKey = await this.prismaWriteClient.apiKey.create({
data: {
userId,
teamId,
hashedKey: createHash("sha256").update(keyString).digest("hex"),
hashedKey,
expiresAt: expiresAt,
},
});
@@ -2,7 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { Prisma } from "@calcom/prisma/client";
import type { Prisma } from "@calcom/prisma/client";
export class AttendeeRepositoryFixture {
private prismaReadClient: PrismaReadService["prisma"];
@@ -2,7 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { Prisma } from "@calcom/prisma/client";
import type { Prisma } from "@calcom/prisma/client";
export class BookingSeatRepositoryFixture {
private prismaReadClient: PrismaReadService["prisma"];
@@ -3,7 +3,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { Booking, User } from "@prisma/client";
import { Prisma } from "@calcom/prisma/client";
import type { Prisma } from "@calcom/prisma/client";
export class BookingsRepositoryFixture {
private prismaReadClient: PrismaReadService["prisma"];
@@ -1,7 +1,7 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { Prisma } from "@prisma/client";
import type { Prisma } from "@prisma/client";
export class CredentialsRepositoryFixture {
private prismaReadClient: PrismaReadService["prisma"];
@@ -4,7 +4,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { EventType } from "@prisma/client";
import { Prisma } from "@calcom/prisma/client";
import type { Prisma } from "@calcom/prisma/client";
export class EventTypesRepositoryFixture {
private prismaReadClient: PrismaReadService["prisma"];
@@ -2,7 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { Prisma } from "@calcom/prisma/client";
import type { Prisma } from "@calcom/prisma/client";
export class HostsRepositoryFixture {
private prismaReadClient: PrismaReadService["prisma"];
@@ -1,7 +1,7 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { Prisma } from "@prisma/client";
import type { Prisma } from "@prisma/client";
export class ProfileRepositoryFixture {
private prismaReadClient: PrismaReadService["prisma"];
@@ -3,7 +3,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { Schedule } from "@prisma/client";
import { Prisma } from "@calcom/prisma/client";
import type { Prisma } from "@calcom/prisma/client";
export class SchedulesRepositoryFixture {
private prismaReadClient: PrismaReadService["prisma"];
@@ -1,7 +1,7 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { Prisma } from "@prisma/client";
import type { Prisma } from "@prisma/client";
export class VerifiedResourcesRepositoryFixtures {
private prismaReadClient: PrismaReadService["prisma"];
@@ -1,7 +1,7 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { Prisma } from "@prisma/client";
import type { Prisma } from "@prisma/client";
export class WebhookRepositoryFixture {
private primaReadClient: PrismaReadService["prisma"];
@@ -2,7 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { Prisma } from "@calcom/prisma/client";
import type { Prisma } from "@calcom/prisma/client";
export class WorkflowRepositoryFixture {
private prismaReadClient: PrismaReadService["prisma"];
@@ -75,6 +75,8 @@ export class CalendarsServiceMock {
id: 0,
delegationCredentialId: null,
domainWideDelegationCredentialId: null,
createdAt: new Date(),
updatedAt: new Date(),
},
} satisfies Awaited<ReturnType<typeof CalendarsService.prototype.getCalendars>>;
}
@@ -70,9 +70,7 @@ export const ComplexExample: React.FC = () => (
<div className="flex flex-col items-center gap-2">
<Dropdown>
<DropdownMenuTrigger asChild>
<button className="cursor-pointer">
<Button variant="icon" color="secondary" StartIcon="calendar" />
</button>
<Button variant="icon" color="secondary" StartIcon="calendar" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownItem>Rename</DropdownItem>
@@ -5,6 +5,7 @@ import { unstable_cache } from "next/cache";
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 { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
import { TRPCError } from "@trpc/server";
@@ -37,7 +38,7 @@ export const ServerTeamsListing = async ({
if (token) {
try {
teamNameFromInvite = await TeamRepository.inviteMemberByToken(token, userId);
teamNameFromInvite = await TeamService.inviteMemberByToken(token, userId);
} catch (e) {
errorMsgFromInvite = "Error while fetching teams";
if (e instanceof TRPCError) errorMsgFromInvite = e.message;
@@ -10,6 +10,8 @@ import React, { useEffect, useState, useMemo } from "react";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import type { OrganizationBranding } from "@calcom/features/ee/organizations/context/provider";
import type { TeamFeatures } from "@calcom/features/flags/config";
import { useIsFeatureEnabledForTeam } from "@calcom/features/flags/hooks/useIsFeatureEnabledForTeam";
import Shell from "@calcom/features/shell/Shell";
import { HOSTED_CAL_FEATURES, IS_CALCOM, WEBAPP_URL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
@@ -177,7 +179,13 @@ const organizationAdminKeys = [
"delegation_credential",
];
const useTabs = ({ isDelegationCredentialEnabled }: { isDelegationCredentialEnabled: boolean }) => {
const useTabs = ({
isDelegationCredentialEnabled,
isPbacEnabled,
}: {
isDelegationCredentialEnabled: boolean;
isPbacEnabled: boolean;
}) => {
const session = useSession();
const { data: user } = trpc.viewer.me.get.useQuery({ includePasswordAdded: true });
const orgBranding = useOrgBranding();
@@ -213,6 +221,14 @@ const useTabs = ({ isDelegationCredentialEnabled }: { isDelegationCredentialEnab
});
}
// Add pbac menu item only if feature flag is enabled
if (isPbacEnabled) {
newArray.push({
name: "roles_and_permissions",
href: "/settings/organizations/roles",
});
}
return {
...tab,
children: newArray,
@@ -272,6 +288,7 @@ interface SettingsSidebarContainerProps {
className?: string;
navigationIsOpenedOnMobile?: boolean;
bannersHeight?: number;
teamFeatures?: Record<number, TeamFeatures>;
}
const TeamListCollapsible = () => {
@@ -438,6 +455,7 @@ const SettingsSidebarContainer = ({
className = "",
navigationIsOpenedOnMobile,
bannersHeight,
teamFeatures,
}: SettingsSidebarContainerProps) => {
const searchParams = useCompatSearchParams();
const orgBranding = useOrgBranding();
@@ -449,11 +467,24 @@ const SettingsSidebarContainer = ({
}[]
>();
const session = useSession();
const { data } = trpc.viewer.delegationCredential.check.useQuery(undefined, {
enabled: !!session.data?.user?.org,
const organizationId = session.data?.user?.org?.id;
const isDelegationCredentialEnabled = useIsFeatureEnabledForTeam({
teamFeatures,
teamId: organizationId,
feature: "delegation-credential",
});
const isPbacEnabled = useIsFeatureEnabledForTeam({
teamFeatures,
teamId: organizationId,
feature: "pbac",
});
const tabsWithPermissions = useTabs({
isDelegationCredentialEnabled: data?.hasDelegationCredential ?? false,
isDelegationCredentialEnabled,
isPbacEnabled,
});
const { data: otherTeams } = trpc.viewer.organizations.listOtherTeams.useQuery(undefined, {
@@ -727,9 +758,10 @@ const MobileSettingsContainer = (props: { onSideContainerOpen?: () => void }) =>
export type SettingsLayoutProps = {
children: React.ReactNode;
containerClassName?: string;
teamFeatures?: Record<number, TeamFeatures>;
} & ComponentProps<typeof Shell>;
export default function SettingsLayoutAppDirClient({ children, ...rest }: SettingsLayoutProps) {
export default function SettingsLayoutAppDirClient({ children, teamFeatures, ...rest }: SettingsLayoutProps) {
const pathname = usePathname();
const state = useState(false);
const [sideContainerOpen, setSideContainerOpen] = state;
@@ -761,6 +793,7 @@ export default function SettingsLayoutAppDirClient({ children, ...rest }: Settin
<SidebarContainerElement
sideContainerOpen={sideContainerOpen}
setSideContainerOpen={setSideContainerOpen}
teamFeatures={teamFeatures}
/>
}
drawerState={state}
@@ -778,10 +811,18 @@ export default function SettingsLayoutAppDirClient({ children, ...rest }: Settin
);
}
type SidebarContainerElementProps = {
sideContainerOpen: boolean;
bannersHeight?: number;
setSideContainerOpen: React.Dispatch<React.SetStateAction<boolean>>;
teamFeatures?: Record<number, TeamFeatures>;
};
const SidebarContainerElement = ({
sideContainerOpen,
bannersHeight,
setSideContainerOpen,
teamFeatures,
}: SidebarContainerElementProps) => {
const { t } = useLocale();
return (
@@ -797,13 +838,8 @@ const SidebarContainerElement = ({
<SettingsSidebarContainer
navigationIsOpenedOnMobile={sideContainerOpen}
bannersHeight={bannersHeight}
teamFeatures={teamFeatures}
/>
</>
);
};
type SidebarContainerElementProps = {
sideContainerOpen: boolean;
bannersHeight?: number;
setSideContainerOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
@@ -1,14 +1,28 @@
import { unstable_cache } from "next/cache";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import React from "react";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import type { TeamFeatures } from "@calcom/features/flags/config";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import type { SettingsLayoutProps } from "./SettingsLayoutAppDirClient";
import SettingsLayoutAppDirClient from "./SettingsLayoutAppDirClient";
const getTeamFeatures = unstable_cache(
async (teamId: number) => {
const featuresRepository = new FeaturesRepository();
return await featuresRepository.getTeamFeatures(teamId);
},
["team-features"],
{
revalidate: 120,
}
);
export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const userId = session?.user?.id;
@@ -16,9 +30,21 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
return redirect("/auth/login");
}
let teamFeatures: Record<number, TeamFeatures> | null = null;
const orgId = session?.user?.profile?.organizationId ?? session?.user.org?.id;
// For now we only grab organization features but it would be nice to fetch these on the server side for specific team feature flags
if (orgId) {
const features = await getTeamFeatures(orgId);
if (features) {
teamFeatures = {
[orgId]: features,
};
}
}
return (
<>
<SettingsLayoutAppDirClient {...props} />
<SettingsLayoutAppDirClient {...props} teamFeatures={teamFeatures ?? {}} />
</>
);
}
@@ -0,0 +1,143 @@
"use client";
import { useState } from "react";
import type { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { PERMISSION_REGISTRY, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import classNames from "@calcom/ui/classNames";
import { Icon } from "@calcom/ui/components/icon";
import { Tooltip } from "@calcom/ui/components/tooltip";
import { Checkbox, Label } from "@calcom/ui/form";
import { usePermissions } from "./usePermissions";
interface AdvancedPermissionGroupProps {
resource: Resource;
selectedPermissions: string[];
onChange: (permissions: string[]) => void;
}
const INTERNAL_DATAACCESS_KEY = "_resource";
export function AdvancedPermissionGroup({
resource,
selectedPermissions,
onChange,
}: AdvancedPermissionGroupProps) {
const { t } = useLocale();
const { toggleSinglePermission, toggleResourcePermissionLevel } = usePermissions();
const resourceConfig = PERMISSION_REGISTRY[resource];
const [isExpanded, setIsExpanded] = useState(false);
const isAllResources = resource === "*";
const allResourcesSelected = selectedPermissions.includes("*.*");
// Get all possible permissions for this resource
const allPermissions = isAllResources
? ["*.*"]
: Object.entries(resourceConfig)
.filter(([action]) => action !== INTERNAL_DATAACCESS_KEY)
.map(([action]) => `${resource}.${action}`);
// Check if all permissions for this resource are selected
const isAllSelected = isAllResources
? allResourcesSelected
: allPermissions.every((p) => selectedPermissions.includes(p));
const handleToggleAll = (e: React.MouseEvent) => {
e.stopPropagation(); // Stop event from triggering parent click
onChange(toggleResourcePermissionLevel(resource, isAllSelected ? "none" : "all", selectedPermissions));
};
// Helper function to check if read permission is auto-enabled
const isReadAutoEnabled = (action: string) => {
if (action === CrudAction.Read) return false;
if (action === CrudAction.Create || action === CrudAction.Update || action === CrudAction.Delete) {
return selectedPermissions.includes(`${resource}.${CrudAction.Read}`);
}
return false;
};
return (
<div className="bg-muted border-subtle mb-2 rounded-xl border">
<button
type="button"
className="flex cursor-pointer items-center justify-between gap-1.5 p-4"
onClick={() => setIsExpanded(!isExpanded)}>
<Icon
name={isAllResources ? "chevron-right" : "chevron-down"}
className={classNames(
"h-4 w-4 transition-transform",
isExpanded && !isAllResources ? "rotate-180" : ""
)}
/>
<div className="flex items-center gap-2">
<Checkbox
checked={isAllSelected}
onCheckedChange={() => handleToggleAll}
onClick={handleToggleAll}
/>
<span className="text-default text-sm font-medium leading-none">
{t(resourceConfig._resource?.i18nKey || "")}
</span>
<span className="text-muted text-sm font-medium leading-none">{t("all_permissions")}</span>
</div>
</button>
{isExpanded && !isAllResources && (
<div
className="bg-default border-muted m-1 flex flex-col gap-2.5 rounded-xl border p-3"
onClick={(e) => e.stopPropagation()} // Stop clicks in the permission list from affecting parent
>
{Object.entries(resourceConfig).map(([action, actionConfig]) => {
const permission = `${resource}.${action}`;
if (action === INTERNAL_DATAACCESS_KEY) {
return null;
}
const isChecked = selectedPermissions.includes(permission);
const isReadPermission = action === CrudAction.Read;
const isAutoEnabled = isReadAutoEnabled(action);
return (
<div key={action} className="flex items-center">
<Checkbox
id={permission}
checked={isChecked}
className="mr-2"
onCheckedChange={(checked) => {
onChange(toggleSinglePermission(permission, !!checked, selectedPermissions));
}}
onClick={(e) => e.stopPropagation()} // Stop checkbox clicks from affecting parent
/>
<div
className="flex items-center gap-2"
onClick={(e) => e.stopPropagation()} // Stop label clicks from affecting parent
>
<Label htmlFor={permission} className="mb-0">
<span className={classNames(isAutoEnabled && "text-muted-foreground")}>
{t(actionConfig?.i18nKey || "")}
</span>
</Label>
<span className="text-sm text-gray-500">
{t(
actionConfig && "descriptionI18nKey" in actionConfig
? actionConfig.descriptionI18nKey
: ""
)}
</span>
{isAutoEnabled && (
<Tooltip content={t("read_permission_auto_enabled_tooltip")}>
<Icon name="info" className="text-muted-foreground h-3 w-3" />
</Tooltip>
)}
</div>
</div>
);
})}{" "}
</div>
)}
</div>
);
}
@@ -0,0 +1,17 @@
"use client";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/button";
import { useRoleSheetState } from "../hooks/useRoleQueryStates";
export function CreateRoleCTA() {
const { t } = useLocale();
const { setIsOpen } = useRoleSheetState();
return (
<Button StartIcon="plus" onClick={() => setIsOpen(true)}>
{t("create_role")}
</Button>
);
}
@@ -0,0 +1,65 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Dialog } from "@calcom/ui/components/dialog";
import { ConfirmationDialogContent } from "@calcom/ui/components/dialog";
import { DropdownItem } from "@calcom/ui/components/dropdown";
import { showToast } from "@calcom/ui/components/toast";
import { revalidateTeamRoles } from "../actions";
interface DeleteRoleModalProps {
roleId: string;
roleName: string;
teamId: number;
onDeleted?: () => void;
}
export function DeleteRoleModal({ roleId, roleName, teamId, onDeleted }: DeleteRoleModalProps) {
const { t } = useLocale();
const [isModalOpen, setIsModalOpen] = useState(false);
const router = useRouter();
const deleteMutation = trpc.viewer.pbac.deleteRole.useMutation({
onSuccess: async () => {
showToast(t("role_deleted_successfully"), "success");
setIsModalOpen(false);
await revalidateTeamRoles();
router.refresh();
onDeleted?.();
},
onError: (error) => {
showToast(error.message || t("error_deleting_role"), "error");
},
});
return (
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DropdownItem StartIcon="trash" color="destructive" onClick={() => setIsModalOpen(true)}>
{t("delete")}
</DropdownItem>
<ConfirmationDialogContent
variety="danger"
title={t("delete_role")}
confirmBtnText={t("delete")}
cancelBtnText={t("cancel")}
onConfirm={() => {
deleteMutation.mutate({
teamId,
roleId,
});
}}
loadingText={t("deleting_role")}>
<p className="mb-4">
{t("confirm_delete_role", {
roleName,
})}
</p>
</ConfirmationDialogContent>
</Dialog>
);
}
@@ -0,0 +1,49 @@
"use client";
import { useRef, useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
export interface RoleColorPickerProps {
value: string;
onChange: (color: string) => void;
}
export default function RoleColorPicker({ value, onChange }: RoleColorPickerProps) {
const { t } = useLocale();
const [, setCustomColor] = useState(value);
const colorInputRef = useRef<HTMLInputElement>(null);
const handleCustomColorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newColor = e.target.value;
setCustomColor(newColor);
onChange(newColor);
};
const handleColorSquareClick = () => {
colorInputRef.current?.click();
};
return (
<div className="mt-6">
<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">
<div
className="h-5 w-5 rounded border shadow-sm"
style={{ backgroundColor: value }}
aria-label={t("select_color")}
/>
</button>
<input
ref={colorInputRef}
type="color"
value={value}
onChange={handleCustomColorChange}
className="sr-only"
aria-hidden="true"
/>
</div>
);
}
@@ -0,0 +1,261 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useState, useMemo, useEffect } from "react";
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 { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/button";
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from "@calcom/ui/components/sheet";
import { showToast } from "@calcom/ui/components/toast";
import { Form, TextField, Checkbox, Label } from "@calcom/ui/form";
import { revalidateTeamRoles } from "../actions";
import { AdvancedPermissionGroup } from "./AdvancedPermissionGroup";
import RoleColorPicker from "./RoleColorPicker";
import { SimplePermissionItem } from "./SimplePermissionItem";
type Role = {
id: string;
name: string;
description?: string;
teamId?: number;
color?: string;
createdAt: Date;
updatedAt: Date;
type: "SYSTEM" | "CUSTOM";
permissions: {
id: string;
resource: string;
action: string;
}[];
};
const formSchema = z.object({
name: z.string().min(1, { message: "role_name_required" }),
description: z.string().optional(),
isAdvancedMode: z.boolean().default(false),
permissions: z.array(z.string()),
color: z.string().default("#FF5733"),
});
type FormValues = z.infer<typeof formSchema>;
interface RoleSheetProps {
role?: Role;
open: boolean;
onOpenChange: (open: boolean) => void;
teamId: number;
}
export function RoleSheet({ role, open, onOpenChange, teamId }: RoleSheetProps) {
const { t } = useLocale();
const router = useRouter();
const isEditing = Boolean(role);
const [searchQuery, setSearchQuery] = useState("");
const defaultValues = useMemo(
() => ({
name: role?.name || "",
description: role?.description || "",
color: role?.color || "#FF5733",
isAdvancedMode: false,
permissions: role?.permissions?.map((p) => `${p.resource}.${p.action}`) || [],
}),
[role]
); // Memoize default values based on role
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues,
});
// Update form values when role changes
useEffect(() => {
// Only reset if the sheet is open to prevent unnecessary resets
if (open) {
if (role) {
form.reset({
name: role.name,
description: role.description || "",
color: role.color || "#FF5733",
isAdvancedMode: form.getValues("isAdvancedMode"), // Preserve the current mode
permissions: role.permissions.map((p) => `${p.resource}.${p.action}`),
});
} else {
form.reset({
name: "",
description: "",
color: "#FF5733",
isAdvancedMode: false,
permissions: [],
});
}
}
}, [role, form, open]);
const { isAdvancedMode, permissions, color } = form.watch();
const filteredResources = useMemo(() => {
return Object.keys(PERMISSION_REGISTRY).filter((resource) =>
t(
PERMISSION_REGISTRY[resource as Resource][
CrudAction.All as keyof (typeof PERMISSION_REGISTRY)[Resource]
]?.i18nKey || ""
)
.toLowerCase()
.includes(searchQuery.toLowerCase())
);
}, [searchQuery, t]);
const createMutation = trpc.viewer.pbac.createRole.useMutation({
onSuccess: async () => {
showToast(t("role_created_successfully"), "success");
form.reset();
onOpenChange(false);
await revalidateTeamRoles();
router.refresh();
},
onError: (error) => {
showToast(error.message || t("error_creating_role"), "error");
},
});
const updateMutation = trpc.viewer.pbac.updateRole.useMutation({
onSuccess: async () => {
showToast(t("role_updated_successfully"), "success");
onOpenChange(false);
await revalidateTeamRoles();
router.refresh();
},
onError: (error) => {
showToast(error.message || t("error_updating_role"), "error");
},
});
const onSubmit = (values: FormValues) => {
// Store the color in localStorage
const roleKey = isEditing && role ? role.id : `new_role_${values.name}`;
localStorage.setItem(`role_color_${roleKey}`, values.color);
if (isEditing && role) {
updateMutation.mutate({
teamId,
roleId: role.id,
name: values.name,
permissions: values.permissions as any,
color: values.color,
});
} else {
createMutation.mutate({
teamId,
name: values.name,
description: values.description,
color: values.color,
permissions: values.permissions as any,
});
}
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent>
<SheetHeader>
<SheetTitle>{isEditing ? t("edit_role") : t("create_role")}</SheetTitle>
</SheetHeader>
<Form form={form} handleSubmit={onSubmit}>
<div className="space-y-4 py-5">
<div className="flex items-end justify-end gap-2">
<div className="flex-1">
<TextField
label={t("role_name")}
{...form.register("name")}
placeholder={t("role_name_placeholder")}
/>
</div>
<RoleColorPicker
value={color}
onChange={(value) => form.setValue("color", value, { shouldDirty: true })}
/>
</div>
<div className="">
{isAdvancedMode ? (
<div className="space-y-4">
<div>
<div className="mb-2 flex items-center justify-between">
<Label className="mb-0">{t("permissions")}</Label>
<div className="flex items-center gap-2">
<Checkbox
checked={form.watch("isAdvancedMode")}
onCheckedChange={(checked: boolean) => form.setValue("isAdvancedMode", checked)}
/>
<span className="text-sm">{t("advanced")}</span>
</div>
</div>
<TextField
id="permissions_search"
placeholder={t("search_permissions")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{filteredResources.map((resource) => (
<AdvancedPermissionGroup
key={resource}
resource={resource as Resource}
selectedPermissions={permissions}
onChange={(newPermissions) => form.setValue("permissions", newPermissions)}
/>
))}
</div>
) : (
<div className="bg-muted rounded-xl p-1">
<div className="flex items-center justify-between px-3 py-2">
<Label>{t("permissions")}</Label>
<div className="flex items-center gap-2">
<Checkbox
checked={form.watch("isAdvancedMode")}
onCheckedChange={(checked: boolean) => form.setValue("isAdvancedMode", checked)}
/>
<span className="text-sm">{t("advanced")}</span>
</div>
</div>
<div className="bg-default border-subtle divide-subtle divide-y rounded-[10px] border">
{Object.keys(PERMISSION_REGISTRY).map((resource) => (
<SimplePermissionItem
key={resource}
resource={resource}
permissions={permissions}
onChange={(newPermissions) => form.setValue("permissions", newPermissions)}
/>
))}
</div>
</div>
)}
</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>
</Form>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,174 @@
"use client";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Badge } from "@calcom/ui/badge";
import { Button } from "@calcom/ui/button";
import classNames from "@calcom/ui/classNames";
import {
Dropdown,
DropdownMenuContent,
DropdownItem,
DropdownMenuTrigger,
} from "@calcom/ui/components/dropdown";
import { useRoleStates } from "../hooks/useRoleQueryStates";
import { DeleteRoleModal } from "./DeleteRoleModal";
import { RoleSheet } from "./RoleSheet";
type Role = {
id: string;
name: string;
description?: string;
teamId?: number;
color?: string;
createdAt: Date;
updatedAt: Date;
type: "SYSTEM" | "CUSTOM";
permissions: {
id: string;
resource: string;
action: string;
}[];
};
type Roles = Role[];
type Permissions = {
canCreate: boolean;
canUpdate: boolean;
canDelete: boolean;
canRead: boolean;
};
interface RolesListProps {
roles: Roles;
permissions: Permissions;
initialSelectedRole?: Role;
initialSheetOpen?: boolean;
teamId: number;
}
export function RolesList({
roles,
permissions,
initialSelectedRole,
initialSheetOpen,
teamId,
}: RolesListProps) {
const { t } = useLocale();
const { isOpen, setIsOpen, selectedRoleId, setSelectedRoleId, handleSheetOpenChange } = useRoleStates(
initialSheetOpen,
initialSelectedRole?.id
);
const selectedRole = roles.find((role) => role.id === selectedRoleId);
return (
<>
<div className="mt-4">
<div className="bg-muted border-muted flex flex-col rounded-xl border p-[1px]">
{/* Roles list header */}
<div className="px-5 py-4">
<h2 className="text-default text-sm font-semibold leading-none">{t("role")}</h2>
</div>
{/* Role List Items */}
<div className="bg-default border-subtle divide-subtle flex flex-col divide-y rounded-[10px] border">
{roles.map((role) => (
<RoleItem
role={role}
key={role.id}
onClick={() => {
// Cant edit system roles
if (role.type === "SYSTEM") {
return;
}
if (permissions.canUpdate) {
setSelectedRoleId(role.id);
setIsOpen(true);
}
}}
canUpdate={permissions.canUpdate}
permissions={permissions}
teamId={teamId}
/>
))}
</div>
{/* Footer */}
<div className="px-5 py-4 ">
{/* Commenting out for now as we don't have a learn more permissions page in docs */}
{/* <p className="text-subtle text-sm font-medium leading-tight">{t("learn_more_permissions")}</p> */}
</div>
</div>
</div>
<RoleSheet
role={selectedRole}
open={isOpen ?? false}
onOpenChange={handleSheetOpenChange}
teamId={teamId}
/>
</>
);
}
function RoleItem({
role,
onClick,
canUpdate,
permissions,
teamId,
}: {
role: Role;
onClick: () => void;
canUpdate: boolean;
permissions: Permissions;
teamId: number;
}) {
const { t } = useLocale();
const showDropdown = role.type !== "SYSTEM" && (permissions.canUpdate || permissions.canDelete);
return (
<div
className={classNames(
"border-subtle flex p-3",
canUpdate && role.type !== "SYSTEM" && "hover:bg-subtle cursor-pointer"
)}
onClick={onClick}>
<div className="flex w-full items-center gap-3 truncate">
{/* Icon */}
<div className="flex items-center justify-center">
<div className="h-3 w-3 rounded-full" style={{ backgroundColor: role.color ?? "gray" }} />
</div>
{/* Role Name */}
<div className="text-default w-24 truncate text-sm font-semibold leading-none">
<span>{role.name}</span>
</div>
<Badge variant={role.type === "SYSTEM" ? "grayWithoutHover" : "green"}>
{t(role.type === "SYSTEM" ? "default_role" : "custom_role")}
</Badge>
{showDropdown && (
<div className="ml-auto" onClick={(e) => e.stopPropagation()}>
<Dropdown>
<DropdownMenuTrigger asChild>
<Button variant="icon" color="secondary" StartIcon="ellipsis" />
</DropdownMenuTrigger>
<DropdownMenuContent className="flex flex-col">
{permissions.canUpdate && (
<DropdownItem StartIcon="pencil" onClick={onClick}>
{t("edit")}
</DropdownItem>
)}
{permissions.canDelete && role.type !== "SYSTEM" && (
<DeleteRoleModal roleId={role.id} roleName={role.name} teamId={teamId} />
)}
</DropdownMenuContent>
</Dropdown>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,39 @@
"use client";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SkeletonText } from "@calcom/ui/components/skeleton";
export const SkeletonLoader = () => {
const { t } = useLocale();
return (
<div className="mt-4">
<div className="bg-muted border-muted flex flex-col rounded-xl border p-[1px]">
{/* Roles list header */}
<div className="px-5 py-4">
<h2 className="text-default text-sm font-semibold leading-none">{t("role")}</h2>
</div>
{/* Role List Items */}
<div className="bg-default border-subtle divide divide-subtle flex flex-col rounded-[10px] border">
{/* Generate 3 skeleton items */}
{[...Array(3)].map((_, index) => (
<div key={index} className="border-subtle flex border-b p-3">
<div className="flex items-center gap-3 truncate">
{/* Icon skeleton */}
<div className="flex items-center justify-center">
<div className="h-3 w-3 animate-pulse rounded-full bg-gray-200" />
</div>
{/* Role name skeleton */}
<SkeletonText className="h-4 w-20" />
{/* Badge skeleton */}
<div className="h-5 w-24 animate-pulse rounded-full bg-gray-200" />
</div>
</div>
))}
</div>
<div className="px-5 py-4 ">
<p className="text-subtle text-sm font-medium leading-tight">{t("learn_more_permissions")}</p>
</div>
</div>
</div>
);
};
@@ -0,0 +1,47 @@
"use client";
import type { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { PERMISSION_REGISTRY } from "@calcom/features/pbac/domain/types/permission-registry";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { ToggleGroup } from "@calcom/ui/components/form";
import type { PermissionLevel } from "./usePermissions";
import { usePermissions } from "./usePermissions";
interface SimplePermissionItemProps {
resource: string;
permissions: string[];
onChange: (permissions: string[]) => void;
}
export function SimplePermissionItem({ resource, permissions, onChange }: SimplePermissionItemProps) {
const { t } = useLocale();
const { getResourcePermissionLevel, toggleResourcePermissionLevel } = usePermissions();
const isAllResources = resource === "*";
const options = isAllResources
? [
{ value: "none", label: t("none") },
{ value: "all", label: t("all") },
]
: [
{ value: "none", label: t("none") },
{ value: "read", label: t("read_only") },
{ value: "all", label: t("all") },
];
return (
<div className="flex items-center justify-between px-3 py-2">
<span className="text-default text-sm font-medium leading-none">
{t(PERMISSION_REGISTRY[resource as Resource]._resource?.i18nKey || resource)}
</span>
<ToggleGroup
onValueChange={(val) => {
if (val) onChange(toggleResourcePermissionLevel(resource, val as PermissionLevel, permissions));
}}
value={getResourcePermissionLevel(resource, permissions)}
options={options}
/>
</div>
);
}
@@ -0,0 +1,10 @@
import { createSearchParamsCache, parseAsBoolean, parseAsString } from "nuqs/server";
export const roleParsers = {
// Sheet visibility state
"role-sheet": parseAsBoolean.withDefault(false),
// Selected role ID for editing
role: parseAsString.withDefault(""),
};
export const roleSearchParamsCache = createSearchParamsCache(roleParsers);
@@ -0,0 +1,159 @@
import { CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { PERMISSION_REGISTRY } from "@calcom/features/pbac/domain/types/permission-registry";
export type PermissionLevel = "none" | "read" | "all";
interface UsePermissionsReturn {
hasAllPermissions: (permissions: string[]) => boolean;
getResourcePermissionLevel: (resource: string, permissions: string[]) => PermissionLevel;
toggleResourcePermissionLevel: (
resource: string,
level: PermissionLevel,
currentPermissions: string[]
) => string[];
toggleSinglePermission: (permission: string, enabled: boolean, currentPermissions: string[]) => string[];
}
export function usePermissions(): UsePermissionsReturn {
const getAllPossiblePermissions = () => {
const permissions: string[] = [];
Object.entries(PERMISSION_REGISTRY).forEach(([resource, config]) => {
if (resource !== "*") {
Object.keys(config).forEach((action) => {
permissions.push(`${resource}.${action}`);
});
}
});
return permissions;
};
const hasAllPermissions = (permissions: string[]) => {
return Object.entries(PERMISSION_REGISTRY).every(([resource, config]) => {
if (resource === "*") return true;
return Object.keys(config).every((action) => permissions.includes(`${resource}.${action}`));
});
};
const getResourcePermissionLevel = (resource: string, permissions: string[]): PermissionLevel => {
if (resource === "*") {
return permissions.includes("*.*") ? "all" : "none";
}
const resourceConfig = PERMISSION_REGISTRY[resource as keyof typeof PERMISSION_REGISTRY];
if (!resourceConfig) return "none";
const allResourcePerms = Object.keys(resourceConfig).map((action) => `${resource}.${action}`);
const hasAllPerms = allResourcePerms.every((p) => permissions.includes(p));
const hasReadPerm = permissions.includes(`${resource}.${CrudAction.Read}`);
if (hasAllPerms) return "all";
if (hasReadPerm) return "read";
return "none";
};
const toggleResourcePermissionLevel = (
resource: string,
level: PermissionLevel,
currentPermissions: string[]
): string[] => {
// First, remove *.* since we're modifying permissions
let newPermissions = currentPermissions.filter((p) => p !== "*.*");
if (resource === "*") {
if (level === "all") {
// When selecting all resources, add *.* and all individual permissions
const allPossiblePerms = getAllPossiblePermissions();
newPermissions = ["*.*", ...allPossiblePerms];
}
// When deselecting all resources, we've already removed *.* above
} else {
// Filter out current resource permissions
newPermissions = newPermissions.filter((p) => !p.startsWith(`${resource}.`));
const resourceConfig = PERMISSION_REGISTRY[resource as keyof typeof PERMISSION_REGISTRY];
if (!resourceConfig) return currentPermissions;
switch (level) {
case "none":
// No permissions to add, just keep other permissions
break;
case "read":
// Only add read permission
newPermissions.push(`${resource}.${CrudAction.Read}`);
break;
case "all":
// Add all permissions for this resource
const allResourcePerms = Object.keys(resourceConfig).map((action) => `${resource}.${action}`);
newPermissions.push(...allResourcePerms);
break;
}
// Only add *.* back if all permissions are now selected
if (hasAllPermissions(newPermissions)) {
newPermissions.push("*.*");
}
}
return newPermissions;
};
const toggleSinglePermission = (
permission: string,
enabled: boolean,
currentPermissions: string[]
): string[] => {
// First, remove *.* since we're modifying individual permissions
let newPermissions = currentPermissions.filter((p) => p !== "*.*");
// Parse the permission to get resource and action
const [resource, action] = permission.split(".");
if (enabled) {
// Add the requested permission
newPermissions.push(permission);
// If enabling create, update, or delete, automatically enable read permission
if (action === CrudAction.Create || action === CrudAction.Update || action === CrudAction.Delete) {
const readPermission = `${resource}.${CrudAction.Read}`;
if (!newPermissions.includes(readPermission)) {
newPermissions.push(readPermission);
}
}
} else {
// When disabling a permission, check if we need to disable related permissions
if (action === CrudAction.Read) {
// If disabling read, also disable create, update, and delete since they depend on read
const dependentActions = [CrudAction.Create, CrudAction.Update, CrudAction.Delete];
dependentActions.forEach((dependentAction) => {
const dependentPermission = `${resource}.${dependentAction}`;
newPermissions = newPermissions.filter((p) => p !== dependentPermission);
});
} else if (
action === CrudAction.Create ||
action === CrudAction.Update ||
action === CrudAction.Delete
) {
// If disabling create, update, or delete, just remove that specific permission
// Read permission remains enabled
newPermissions = newPermissions.filter((p) => p !== permission);
} else {
// For other actions (custom actions), just remove the specific permission
newPermissions = newPermissions.filter((p) => p !== permission);
}
}
// Only add *.* back if all permissions are now selected
if (hasAllPermissions(newPermissions)) {
newPermissions.push("*.*");
}
return newPermissions;
};
return {
hasAllPermissions,
getResourcePermissionLevel,
toggleResourcePermissionLevel,
toggleSinglePermission,
};
}
@@ -0,0 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
export async function revalidateTeamRoles() {
revalidatePath("/settings/organizations/roles");
}

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