Compare commits

...
Author SHA1 Message Date
github-actions[bot] e6959f3c7c chore: version packages (#23188)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-20 04:57:40 +00:00
emrysal 67af8b4973 chore: release v5.6.2
Post release / release (push) Canceled after 0s
2025-08-20 01:36:25 +00:00
Volnei Munhoz 7794f2596e chore: deprecated prisma default export (#23199) 2025-08-19 19:42:10 +00:00
167cf6e352 feat: add team schedules (#22954)
* feat: add team schedules

* fixup: implement feedback for teams controller

* cleanup unused functions

* fixup: implement PR feedback

* fixup: move over teams schedule controller inside of teams module

* remove unused param and update summary

* fixup: move over teams schedules controller inside of teams module

* update e2e tests

* add team schedule module

* update teams schedules module

* fixup: update guards

* init tests for teams schedules controller

* implement code rabbit feedback

* fixup: implement PR feedback

* fixup: remove getTeamSchedulesByUserIds

---------

Co-authored-by: Ryukemeister <sahalrajiv6900@gmail.com>
Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
2025-08-19 17:03:55 +03:00
Lauris SkraucisandSomay Chauhan 3d778ba481 fix: get organization bookings bookingUid parameter (#23186)
* fix: get organization bookings bookingUid parameter

* chore: bump @calcom/platform-libraries from 0.0.315 to 0.0.318

---------

Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
2025-08-19 15:46:40 +02:00
b8f0000b59 feat: hide member filter for users with only MEMBER role (#23097)
- Conditionally disable userId column filter based on isTeamAdminOrOwner flag
- Prevents MEMBER-only users from encountering backend permission errors
- Users with ADMIN/OWNER role in any team can still access the filter

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>
2025-08-19 22:28:02 +09:00
Somay Chauhan 618ef63017 fix: Availability atom handleFormSubmit callbacks not triggering (#23174)
* fix: form submit callbacks trigger

* added changelog

* fix: type-error
2025-08-19 17:46:26 +05:30
Morgan ae8e633716 fix: correct impossible logical condition in Office365 calendar error handling #23151 (#23185) 2025-08-19 12:10:28 +00:00
Somay Chauhan 4a23832388 refactor: update microsoft platform slug to office365 for consistency (#23179)
* refactor: update Microsoft platform slug to office365 for consistency

* Update server.test.ts
2025-08-19 15:04:47 +03:00
Benny Joo 02e486d757 perf: og image caching v1 (#23180)
* perf: og image caching v1

* fix

* fix
2025-08-19 08:54:39 -03:00
Anik Dhabal Babu af6e24f433 fix: error oening date override input dialog (#23182) 2025-08-19 11:00:33 +00:00
Joe Au-Yeung f9e24f51a7 refactor: Extract <CalVideoSettings /> from `<Locations /> (#23113)
* Create `CalVideoSettings` type

* Create `LocationOptionContainer`

* Extract cal video settings to `CalVideoSettings`

* Refactor `<Locations />` component to use `<CalVideoSettings />`

* Unify naming

* Move location folder back under components

* Type fixes

* Delete console.log
2025-08-19 10:24:28 +00:00
Morgan 9e9f815809 refactor: replace static methods of HostRepository and PrismaAttributeRepository (#23175) 2025-08-19 07:59:16 +00:00
Lauris Skraucis a91561f3c2 fix: allow org admin to accept/reject booking (#23164)
* fix: allow org admin to accept/reject booking

* chore: bump platform libraries
2025-08-19 12:10:24 +05:30
Joe Au-YeungandHariom Balhara ff79064ca7 fix: Managed event types - children inherit team's appearance settings (#23116)
* Get parent event type team's appearance settings

* Use style profile to get brand colours

* Update packages/features/eventtypes/lib/getPublicEvent.ts

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-08-19 05:52:27 +00:00
Radhey MugdalandKartik Saini 1a573e1619 feat: v2 api hidden event types (#22715)
* feat: v2 api hidden event types

* feat: Add 'hidden' property to Input type in output.service.ts

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-08-19 11:07:12 +05:30
沙什瓦特 511be7386c Fixed scroll issue while configuring workflow (#23142) 2025-08-19 11:03:54 +05:30
emrysal 2a0991c259 chore: release v5.6.1
Post release / release (push) Canceled after 0s
2025-08-18 23:55:48 +00:00
Eunjae LeeandDevin AI 2e454c9723 refactor: rename insights routing service files (#22917)
- Rename insightsRoutingDI.ts -> InsightsRoutingDIService.ts
- Rename insightsRoutingBase.ts -> InsightsRoutingBaseService.ts
- Update all import statements to use new file names
- No functional changes, only file renames and import updates

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-18 15:15:21 +00:00
Anik Dhabal Babu 7e6d94c8e0 fix: team admin/owner can't change others member role (#23161)
* fix: team admin/owner can't change member role

* update

* Update role-management.factory.ts

* address review
2025-08-18 14:57:22 +00:00
Volnei Munhoz e18b790ccd chore: Upgrade Turborepo from 1 to 2.5 (#23101)
* upgrade turborepo from 1 to 2.5

* Fix tsconfig.json paths path

* chore: fix root path on tsconfig.paths

* chore: add baseUrl to tsconfig.json

* chore: tsconfig.json issue

* try: CI without tsconfig paths

* chore: add only local paths on tsconfig.json

* chore: Fix docs

* Fix missing env var on turbo.json
2025-08-18 11:17:27 -03:00
Morgan 0274417ad1 fix: api v2 workflow controller trigger validation (#23159) 2025-08-18 16:15:40 +03:00
a8a3a93ace fix: redirect to existing payment session on paid booking retry (#23096)
* fix: redirect to existing payment page on booking retry

- Extract existing payment UID from unpaid bookings
- Return paymentUid and paymentId when payment form should be shown
- Prevents payment form bypass by redirecting to original payment session
- Maintains payment continuity without creating duplicate bookings

Fixes issue where canceled payments showed success page on rebooking

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

* test: add unit test for payment retry scenario

- Test verifies existing payment UID is returned on booking retry
- Ensures no duplicate bookings are created in database
- Validates paymentRequired flag is set correctly for unpaid bookings
- Covers the payment redirect fix in handleNewBooking.ts

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

* update

* fix: restore calendar event deletion for round robin reassignment

- Add deleteEventsAndMeetings call back to EventManager.reschedule() when changedOrganizer is true
- Add unit test to verify calendar deletion during round robin reassignment
- Fixes regression where calendar events weren't deleted from original host's calendar

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

* Update roundRobinReassignment.test.ts

* Update EventManager.ts

* test: update payment retry test title to single descriptive line

- Changed from numbered list format to concise single line description
- Test functionality remains unchanged and passes successfully

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-18 11:44:40 +00:00
88bbab216e fix: Consistently remove a member from organization/sub-team/team - through all the APIs and webapp actions (#22806)
* Fix membership deletion handling and add tests

* fixes

* revert api-v2 specific changes

* Add more tests

* refactor: Move removeMember function into TeamService as private static methods

- Moved removeMember and all related helper functions from separate file into TeamService class
- Made all functions private static methods instead of exporting them
- Deleted the original removeMember.ts file since it's no longer needed
- Updated imports to use the new location
- All integration tests pass successfully

* refactor: Rename memberId to userId in TeamService methods

- Renamed memberId parameter to userId in removeMember private method
- Renamed memberIds parameter to userIds in removeMembers public method
- Updated all calls to removeMembers to use userIds instead of memberIds
- Parameter names now accurately reflect that they are user IDs, not membership IDs

* test: add service unit tests and simplify e2e tests for membership deletion

- Add unit tests for membership deletion services
- Remove redundant deletion behavior tests from controllers
- Keep only happy path tests in e2e controller tests
- Fix unused imports and variables

* package.json version

* fix unit teswtes

* No specs file

* fix unit tests

* fix: Add platform-libraries build step to E2E API v2 workflow

The E2E API v2 tests were failing with TypeScript compilation errors because
TeamService imports from @calcom/platform-libraries require the package to be
built first to generate the dist/ folder with compiled exports.

This adds the same build step that was added to unit-tests.yml to resolve
the module resolution errors.

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

* Making tests clearer and easier to undestand

* chore: bump platform libs

* chore: bump platform libs

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: cal.com <morgan@cal.com>
2025-08-18 08:38:32 -03:00
Chinmay Patil f74434a282 fix: show temporarily out-of-office message on all screen sizes (#23130) 2025-08-18 16:56:49 +05:30
Joe Au-Yeung ed6f9d8252 fix: Add cal.eu to IS_CALCOM (#23111)
* Add cal.eu to `IS_CALCOM`

* Create `getCalcomUrl` function

* Use `getCalcomURl` in generate metadata

* Use `getCalcomURl` in `buildCanonical`

* Linting fix
2025-08-18 16:04:48 +05:30
Benny Joo a19ccab16a perf: use promise.all in /apps/installed (#23146) 2025-08-18 10:11:22 +05:30
Benny Joo 77369a13f1 chore: remove unused orgMigrations files (#23056) 2025-08-17 22:18:14 -03:00
Anik Dhabal Babu 8231356d2a test: E2E test to verify event multiple duration works correctly (#23144)
* fix: multiple booking duration isn't working

* followup test
2025-08-17 20:26:14 +01:00
Anik Dhabal Babu 469a942b5a fix: multiple booking duration isn't working (#23140) 2025-08-17 17:08:27 +00:00
keithwillcode 0afe6cf5d1 chore: release v5.6.0
Post release / release (push) Canceled after 0s
2025-08-15 15:32:31 +00:00
github-actions[bot] 75a161fe35 chore: version packages (#23120)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-15 13:13:03 +00:00
Morgan eb1ed107ba chore: bump atoms changeset silentlyHandleCalendarFailures (#23119)
* chore: bump atoms changeset silentlyHandleCalendarFailures

* update dock booker

* fixup! update dock booker
2025-08-15 12:40:10 +00:00
13e2e340ac refactor: convert handleNotificationWhenNoSlots to service class with DI (#23055)
* refactor: convert handleNotificationWhenNoSlots to service class with DI

- Convert standalone handleNotificationWhenNoSlots function to NoSlotsNotificationService class
- Add INoSlotsNotificationService interface following existing patterns
- Create NoSlotsNotification DI module and add to container
- Add NO_SLOTS_NOTIFICATION_SERVICE tokens to DI_TOKENS
- Inject service into AvailableSlotsService dependencies
- Update AvailableSlotsService to use injected service instead of direct function call
- Update all test cases to use service class pattern
- Follows existing DI patterns used by other services like BusyTimesService and CheckBookingLimitsService

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

* refactor: move Prisma calls to repositories and create dedicated DI container

- Add findOrganizationSettingsBySlug and findTeamSlugById methods to TeamRepository
- Add findTeamAdminsByTeamId method to MembershipRepository
- Create MembershipRepository DI module and tokens
- Update NoSlotsNotificationService to inject TeamRepository, MembershipRepository, and Redis client
- Create dedicated DI container for NoSlotsNotificationService
- Update AvailableSlots DI container to include MembershipRepository
- Replace direct Prisma and Redis calls with repository methods and injected client
- Update tests to use DI container instead of direct service instantiation
- Fix Redis interface import path

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

* chore: DI noSlotsNotification service

* chore: bump library

---------

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-08-15 08:30:04 -03:00
0af62da16b feat: add dry run events for SDK (dryRunBookingSuccessfulV2 and dryRunRescheduleBookingSuccessfulV2) (#23072)
* feat: add dry run events for SDK (dryRunBookingSuccessfulV2 and dryRunRescheduleBookingSuccessfulV2)

- Add new event types dryRunBookingSuccessfulV2 and dryRunRescheduleBookingSuccessfulV2 to EventDataMap
- Create payload functions that exclude uid field for dry run events
- Fire dry run events when isDryRun is true in both regular and recurring booking flows
- Add unit tests to verify dry run event payloads are correct and exclude uid field
- Ensures dry run mode no longer skips event firing but uses appropriate dry run variants

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

* refactor: make dry run events more DRY by reusing types and payload logic

- Create BaseBookingEventPayload type to eliminate duplication between regular and dry run events
- Use intersection types to compose final event types in sdk-action-manager.ts
- Create getBaseBookingEventPayload function for common payload generation logic
- Export dry run payload functions and import them in tests instead of redefining
- Fix missing videoCallUrl field in rescheduleBookingSuccessfulV2 type

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

* Remove test file

---------

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>
2025-08-15 05:21:08 +00:00
emrysal 9b323cbcc8 chore: release v5.5.18
Post release / release (push) Canceled after 0s
2025-08-15 01:13:15 +00:00
18a0e2d75c fix: event not removed from calendar when reassigned (#23099)
* fix: event not removed from calendar when reassign

* fix test

* tweak

* add test

* test: add comprehensive test for calendar event deletion during round robin reassignment

- Remove existing tests and replace with focused test for organizer change scenario
- Verify deleteEventsAndMeetings is called when organizer changes
- Mock reschedule method to simulate real behavior while tracking deletion calls
- Test verifies original host's calendar events are properly deleted
- Ensures booking reassignment and email notifications work correctly

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

* update

* revert roundRobinReassignment.test.ts

* revert

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-14 20:42:36 +00:00
Benny Joo 87c89a9968 perf: check feature flag in layout level for /settings/organizations/new routes (#23057)
* remove getServerSideProps

* add layout

* update pages
2025-08-14 13:12:21 -07:00
e237749abd fix: gracefully handle calendar failures when bypassBusyCalendarTimes is enabled (#23061)
* fix: gracefully handle calendar failures when bypassBusyCalendarTimes is enabled

- When _bypassCalendarBusyTimes parameter is true and third-party calendar fetching fails
- System now falls back to internal bookings only instead of throwing error
- Maintains existing error behavior when bypass is disabled

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

* feat: add _silentCalendarFailures parameter to handle calendar failures gracefully

- Add silentlyHandleCalendarFailures parameter to getBusyTimes service
- Add _silentCalendarFailures parameter to getUserAvailability schema
- Add _silentCalendarFailures parameter to tRPC slots endpoint
- Add _silentCalendarFailures parameter to API v2 slots/available endpoint
- Parameter attempts calendar calls first, falls back silently on failure
- Different from bypassCalendarBusyTimes which skips calendar calls entirely

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

* refactor: remove bypassBusyCalendarTimes logic from error handling

- Keep only silentlyHandleCalendarFailures logic in getBusyTimes error handling
- Remove redundant bypassBusyCalendarTimes condition from calendar failure handling
- Maintain existing bypassBusyCalendarTimes logic that skips calendar calls entirely

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

* feat: add silentlyHandleCalendarFailures prop to Booker atom

- Pass silentlyHandleCalendarFailures prop from BookerPlatformWrapper to useAvailableSlots
- Forward prop as _silentCalendarFailures parameter to API v2 slots/available endpoint
- Complete the parameter flow from Booker atom to underlying service layer
- Enables silent handling of calendar provider failures in Booker component

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

* chore: fix type and bump library

* chore: bump library

---------

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: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-08-14 18:41:53 +03:00
Alex van AndelandDevin AI 994a36d039 fix: add authentication guard to /apps/installed routes (#23092)
* fix: add authentication guard to /apps/installed routes

- Add session validation before making tRPC calls in server component
- Redirect unauthenticated users to /auth/login instead of throwing 500 errors
- Follows existing authentication patterns used throughout the codebase
- Fixes 500 error when accessing /apps/installed/calendar and other categories

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

* fix: Missing return; was that so hard, DevinAI?

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-14 14:50:56 +00:00
Benny Joo c989817373 fix: more precise error code for event type duplication handler (#23059)
* fix: unique constraint violation

* refactor

* add test

* fix space

* clean up
2025-08-14 11:35:12 -03:00
github-actions[bot] e3fb7340aa chore: version packages (#23090)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-14 16:28:31 +02:00
Benny Joo e4ef2832cd chore: upgrade next.js to 15.4.5 (#23079)
* upgrade

* update yarn lock

* fix type check
2025-08-14 10:36:20 -03:00
Benny Joo 881602cf1c refactor: Get rid of usages of Next.js private interfaces in our codebase (#23080)
* chore: do not use nextjs private interfaces

* fix type check

* fix

* fix

* refactor
2025-08-14 09:51:49 -03:00
4c01f171ae fix: multiple widgets for Booker atom (#22925)
* fix manage test error

* fix manage test error

* update useBookerContext

* wrap store provider around booker

* replace useStore with useBookerStoreContext

* replace useBookerStore with useBookerStoreContext

* add initializer function for booker store provider

* update props

* fixup: pass more props

* export StoreInitializeType type

* replace useBookerStore with useBookerStoreContext

* fixup: dont wrap BookerStoreProvider around booker directly

* wrap BookerStoreProvider around booker web wrapper and fix local storage import

* fix: wrap test components with BookerStoreProvider to fix failing unit tests

- Create reusable test utility in test-utils.tsx with comprehensive mock store
- Update Booker.test.tsx to use context-based testing approach
- Fix DatePicker tests in both bookings and calendars packages
- Simulate auto-advance behavior for month navigation tests
- All 14 previously failing tests now pass

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

* add changesets

* fix: complete migration from useBookerStore to useBookerStoreContext

- Updated all remaining files to use useBookerStoreContext instead of useBookerStore
- Fixed BookingFields.test.tsx by wrapping test component with BookerStoreProvider
- Ensures all Booker components work within the new context-based store pattern
- Resolves failing unit tests in CI by completing the store migration

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

* fix: add missing useBookerStoreContext import in BookerWebWrapper.tsx

- Fixes TypeScript errors in CI where useBookerStoreContext was used but not imported
- Completes the migration from useBookerStore to useBookerStoreContext pattern
- All tests pass locally after this fix

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

* Revert "fix: add missing useBookerStoreContext import in BookerWebWrapper.tsx"

This reverts commit 12947ca63f46c42e5aee74c78176ef230e2bf2cf.

* Revert "fix: complete migration from useBookerStore to useBookerStoreContext"

This reverts commit 87d837f2d30e4f9259d2be786049d21856f4eef0.

* fixup: useBookerStoreContext and useInitializeBookerStoreContext for booker web wrapper

* fixup: failing E2E tests

* fixup: email embed breaking

* fixup: troubleshooter crashing

* replace useBookerstore with useBookerStoreContext

---------

Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
Co-authored-by: Ryukemeister <sahalrajiv6900@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-08-14 17:57:59 +05:30
Anik Dhabal Babu d9817fcb07 fix: prevent cross-team app visibility in organizations (#23085) 2025-08-14 11:50:30 +00:00
Anik Dhabal Babu a784af3e47 fix: Add org-level payment credential fallback for no-show fee charging (#23071)
* fix: feat: Add org-level payment credential fallback for team bookings

* use repository pattern
2025-08-14 11:30:38 +00:00
Syed Ali Shahbaz c268925330 feat: org/team event type private links endpoints (#23048)
* init

* added test

* remove unnecessary comments

* remove unnecessary userId
2025-08-14 10:21:39 +00:00
Lauris Skraucis 378e8e0330 fix: managed organization bookings (#23078)
* fix: private link update input

* fix: scope admin memberships to org and org teams

* chore: update platform libraries
2025-08-14 09:30:37 +00:00
github-actions[bot] 8b3fbb672b chore: version packages (#22950)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-14 09:18:13 +00:00
Alex van AndelandHariom Balhara 2d5a86774e fix: Type error in inviteMember.handler test (#23070)
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-08-14 01:48:09 +00:00
emrysal 0865104a77 chore: release v5.5.17
Post release / release (push) Canceled after 0s
2025-08-14 00:13:46 +00:00
1914b37ee4 feat: add PATCH endpoint for updating Google Calendar events (#22339)
* feat: add PATCH endpoint for updating Google Calendar events

- Create UpdateUnifiedCalendarEventInput DTO with optional fields for partial updates
- Add PATCH /:calendar/event/:eventUid endpoint to CalUnifiedCalendarsController
- Extend GoogleCalendarService with updateEventDetails method
- Support updating title, description, start/end times for Google Calendar events
- Follow existing patterns for validation, error handling, and response transformation
- Initially support Google Calendar only with extensibility for other providers

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

* feat: expand PATCH endpoint to support all calendar event fields

- Add comprehensive field support to UpdateUnifiedCalendarEventInput DTO
- Include locations, attendees, status, and hosts fields with proper validation
- Expand GoogleCalendarService.updateEventDetails to handle complex field transformations
- Add helper methods for response status and event status mapping
- Preserve existing transformation logic from GoogleCalendarEventOutputPipe
- Support conferenceData updates with conferenceDataVersion parameter

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

* fix: add support for simple location field in Google Calendar events

- Add handling for geographic location field in addition to conferenceData
- Filter non-video locations for the simple location string field
- Preserve existing conferenceData support for video meetings
- Verified against Google Calendar API documentation

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

* refactor: extract transformation logic into GoogleCalendarEventInputPipe

- Create GoogleCalendarEventInputPipe following existing patterns
- Extract transformation logic from updateEventDetails method
- Move mapping methods to the new pipe for better organization
- Improve code reusability and maintainability

Addresses PR comment requesting transformation logic extraction.

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

* refactor: remove locations field and simplify calendar event input handling

* update docs

* refactor: remove hosts field from the update endpoint

* fix: exclude organizer from attendees list in Google Calendar event transformation

* feat: implement organizer preservation logic in PATCH endpoint

- Fetch existing Google Calendar event data before transformation
- Extract organizer attendees from existing event and merge with user-provided attendees
- Prevent accidental removal of organizers when users update attendees
- Add custom interface for GoogleCalendarEventInputPipe to support optional existingEvent parameter
- Preserve organizers unless user explicitly provides hosts field in update request

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

* feat: add hosts array handling to PATCH endpoint

- Add hosts field back to UpdateUnifiedCalendarEventInput DTO
- Implement transformAttendeesWithHostsHandling method in GoogleCalendarEventInputPipe
- Convert hosts to attendees with organizer: true for Google Calendar API
- When hosts are provided, they replace existing organizers (not merge)
- Maintain backward compatibility with existing organizer preservation logic

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

* feat: implement sophisticated attendee update logic with preservation and deletion support

- Add UpdateCalendarEventAttendee class with action: 'delete' support
- Implement attendee preservation logic that starts with existing Google Calendar attendees
- Support explicit attendee deletion using action: 'delete' field
- Update existing attendees when provided without action field
- Add new attendees that don't exist in current event
- Preserve organizer status when updating existing attendees
- Fetch existing event data when attendees OR hosts are being updated
- Maintain hosts array handling that replaces existing organizers

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

* feat: add host management and attendee deletion to calendar event API

* refactor: improve readability of transformAttendeesWithHostsHandling and restrict hosts to responseStatus updates only

- Break down complex transformAttendeesWithHostsHandling method into smaller, more readable helper functions:
  - preserveExistingAttendees: handles existing attendee preservation
  - processAttendeeDeletions: handles attendee deletion logic
  - processAttendeeUpdatesAndAdditions: handles attendee updates and additions
  - replaceHostsWithUpdatedOnes: handles host replacement with restrictions
- Create UpdateCalendarEventHost class that only allows responseStatus updates
- Restrict hosts array to prevent changes to name, email, and other fields
- Preserve existing host displayName from Google Calendar when updating responseStatus

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

* feat: restrict host updates to responseStatus only, preserve email and name from Google Calendar data

- Remove email field from UpdateCalendarEventHost class
- Update replaceHostsWithUpdatedOnes to preserve all host data except responseStatus
- Apply responseStatus updates to all existing organizers from Google Calendar
- Prevent users from updating host email or name fields

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

* Revert "feat: restrict host updates to responseStatus only, preserve email and name from Google Calendar data"

This reverts commit 22d07ce0cb13e71661522b2fd8a237d8b6e0809c.

* refactor: replace optional flag with organizer property in calendar event attendees

* refactor: remove organizer field from calendar event attendee input

* feat: add comprehensive test suites for calendar transformation pipes

- Create google-calendar-event-input.pipe.spec.ts with 67 test cases
- Create get-calendar-event-details-output-pipe.spec.ts with 35 test cases
- Follow event-types transformer test patterns with separate describe blocks
- Cover all transformation functions including edge cases and null handling
- Test attendee preservation, deletion, host management, and status transformations
- Ensure comprehensive coverage for sophisticated attendee update logic

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

* fix: remove invalid optional properties from calendar pipe tests

- Remove optional: false from line 85 in 'should transform attendees without existing event' test case
- Remove optional: true from line 388 in 'should update existing attendee' test case
- Remove optional: false from line 421 in 'should add new attendee' test case
- Update expected outputs to match actual pipe implementation behavior
- All 30 tests now pass locally

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

* fix: add platform library path mappings to Jest configuration

- Resolves module resolution errors for @calcom/platform-libraries/* imports in Jest
- Ensures test suite can run without 'Cannot find module' errors
- Adds comprehensive path mappings matching TypeScript configuration

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

* fix: add @calcom/dayjs path mapping to Jest configuration

- Add missing moduleNameMapper entry for @calcom/dayjs to resolve TypeScript path mappings in Jest
- Fixes TypeError: Cannot read properties of undefined (reading 'extend') in calendar pipe tests
- Calendar transformation pipe tests now pass successfully

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

* fix: configure Jest to use dayjs mock system for consistent test environment

- Remove @calcom/dayjs path mapping from Jest config
- Add setupFilesAfterEnv to automatically import dayjs mock
- Update dayjs mock with comprehensive jest.fn() implementation
- Add @calcom/platform-libraries/repositories and @calcom/prisma/client mappings
- Resolves dayjs TypeError in slots.service.spec.ts and other test files

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

* fix: complete shared Google event data with required properties for test suites

- Add missing required properties to sharedGoogleEvent in both test files
- Fix TypeScript compilation errors in calendar transformation pipe tests
- Correct test assertion for attendees filtering logic in output pipe
- All 11 test suites now pass with 199 tests total

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

* undo extra changes

* better tests

* fix: update calendar API endpoints to use plural events in path

* update documentation

* fix: Restrict updates to only the responseStatus field for hosts

* feat: add PATCH endpoint for updating Google Calendar events

- Create UpdateUnifiedCalendarEventInput DTO with optional fields for partial updates
- Add PATCH /:calendar/event/:eventUid endpoint to CalUnifiedCalendarsController
- Extend GoogleCalendarService with updateEventDetails method
- Support updating title, description, start/end times for Google Calendar events
- Follow existing patterns for validation, error handling, and response transformation
- Initially support Google Calendar only with extensibility for other providers

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

* feat: expand PATCH endpoint to support all calendar event fields

- Add comprehensive field support to UpdateUnifiedCalendarEventInput DTO
- Include locations, attendees, status, and hosts fields with proper validation
- Expand GoogleCalendarService.updateEventDetails to handle complex field transformations
- Add helper methods for response status and event status mapping
- Preserve existing transformation logic from GoogleCalendarEventOutputPipe
- Support conferenceData updates with conferenceDataVersion parameter

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

* fix: add support for simple location field in Google Calendar events

- Add handling for geographic location field in addition to conferenceData
- Filter non-video locations for the simple location string field
- Preserve existing conferenceData support for video meetings
- Verified against Google Calendar API documentation

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

* refactor: extract transformation logic into GoogleCalendarEventInputPipe

- Create GoogleCalendarEventInputPipe following existing patterns
- Extract transformation logic from updateEventDetails method
- Move mapping methods to the new pipe for better organization
- Improve code reusability and maintainability

Addresses PR comment requesting transformation logic extraction.

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

* refactor: remove locations field and simplify calendar event input handling

* update docs

* refactor: remove hosts field from the update endpoint

* fix: exclude organizer from attendees list in Google Calendar event transformation

* feat: implement organizer preservation logic in PATCH endpoint

- Fetch existing Google Calendar event data before transformation
- Extract organizer attendees from existing event and merge with user-provided attendees
- Prevent accidental removal of organizers when users update attendees
- Add custom interface for GoogleCalendarEventInputPipe to support optional existingEvent parameter
- Preserve organizers unless user explicitly provides hosts field in update request

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

* feat: add hosts array handling to PATCH endpoint

- Add hosts field back to UpdateUnifiedCalendarEventInput DTO
- Implement transformAttendeesWithHostsHandling method in GoogleCalendarEventInputPipe
- Convert hosts to attendees with organizer: true for Google Calendar API
- When hosts are provided, they replace existing organizers (not merge)
- Maintain backward compatibility with existing organizer preservation logic

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

* feat: implement sophisticated attendee update logic with preservation and deletion support

- Add UpdateCalendarEventAttendee class with action: 'delete' support
- Implement attendee preservation logic that starts with existing Google Calendar attendees
- Support explicit attendee deletion using action: 'delete' field
- Update existing attendees when provided without action field
- Add new attendees that don't exist in current event
- Preserve organizer status when updating existing attendees
- Fetch existing event data when attendees OR hosts are being updated
- Maintain hosts array handling that replaces existing organizers

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

* feat: add host management and attendee deletion to calendar event API

* refactor: improve readability of transformAttendeesWithHostsHandling and restrict hosts to responseStatus updates only

- Break down complex transformAttendeesWithHostsHandling method into smaller, more readable helper functions:
  - preserveExistingAttendees: handles existing attendee preservation
  - processAttendeeDeletions: handles attendee deletion logic
  - processAttendeeUpdatesAndAdditions: handles attendee updates and additions
  - replaceHostsWithUpdatedOnes: handles host replacement with restrictions
- Create UpdateCalendarEventHost class that only allows responseStatus updates
- Restrict hosts array to prevent changes to name, email, and other fields
- Preserve existing host displayName from Google Calendar when updating responseStatus

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

* feat: restrict host updates to responseStatus only, preserve email and name from Google Calendar data

- Remove email field from UpdateCalendarEventHost class
- Update replaceHostsWithUpdatedOnes to preserve all host data except responseStatus
- Apply responseStatus updates to all existing organizers from Google Calendar
- Prevent users from updating host email or name fields

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

* Revert "feat: restrict host updates to responseStatus only, preserve email and name from Google Calendar data"

This reverts commit 22d07ce0cb13e71661522b2fd8a237d8b6e0809c.

* feat: add comprehensive test suites for calendar transformation pipes

- Create google-calendar-event-input.pipe.spec.ts with 67 test cases
- Create get-calendar-event-details-output-pipe.spec.ts with 35 test cases
- Follow event-types transformer test patterns with separate describe blocks
- Cover all transformation functions including edge cases and null handling
- Test attendee preservation, deletion, host management, and status transformations
- Ensure comprehensive coverage for sophisticated attendee update logic

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

* refactor: replace optional flag with organizer property in calendar event attendees

* refactor: remove organizer field from calendar event attendee input

* fix: remove invalid optional properties from calendar pipe tests

- Remove optional: false from line 85 in 'should transform attendees without existing event' test case
- Remove optional: true from line 388 in 'should update existing attendee' test case
- Remove optional: false from line 421 in 'should add new attendee' test case
- Update expected outputs to match actual pipe implementation behavior
- All 30 tests now pass locally

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

* fix: complete shared Google event data with required properties for test suites

- Add missing required properties to sharedGoogleEvent in both test files
- Fix TypeScript compilation errors in calendar transformation pipe tests
- Correct test assertion for attendees filtering logic in output pipe
- All 11 test suites now pass with 199 tests total

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

* fix: resolve TypeScript compilation errors in calendar transformation pipe tests

- Add comprehensive Jest mocks for @calcom/prisma/client, @calcom/dayjs, CalendarManager, and delegationCredential
- Fix RedisService AbortSignal.timeout() usage
- Update UserAvailabilityService import path to use @calcom/lib/getUserAvailability
- Configure Jest moduleNameMapper to handle ES module imports and package.json mocking
- All 11 test suites now pass with 199 tests total

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

* fix: use proper CacheService dependency injection in AvailableSlotsService

- Replace direct CacheService instantiation with dependency injection
- Update CacheService import path to use correct calendar-cache module
- Fix repository and service dependencies for proper type safety
- Resolves TypeScript compilation errors in slots service tests

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

* fix: remove duplicate test file causing TypeScript compilation error

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

* undo extra changes

* undo extra changes

* fix: remove the action: "delete" key from attendees

* fix: simplify tests

* update docs

* undo: remove hosts

* Update pre-commit

* feat: add alternate route for calendar event retrieval and simplify test fixtures

* remove userId -- unused

* undo

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: somay@cal.com <somay@cal.com>
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
2025-08-13 17:24:19 +01:00
Lauris Skraucis 58ef0abb99 Revert "fix: managed orgs get bookings (#23063)" (#23066)
This reverts commit 13ba680a73.
2025-08-13 15:54:49 +00:00
Lauris Skraucis 13ba680a73 fix: managed orgs get bookings (#23063)
* fix: bookings handler scope to provided orgId

* fix: private link input body

* tests

* chore: bump platform libs

* fix: cleanup test org

* refactor get handler

* refactor test
2025-08-13 14:58:10 +00:00
Benny JooandAlex van Andel f40c6da93b fix: use server fetched user for all sub pages in /getting-started (#23064)
* use server fetched user for all sub pages

* Wrap setAnyInstalledVideoApps call in useEffect

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-08-13 14:35:30 +00:00
Somay Chauhan fc2e81e89a feat: add toggle for no availability dialog (#23052)
* feat: add toggle for no availability dialog

* Create great-hands-lick.md

* fix: correct spelling from showNoAvailabilityDailog to showNoAvailabilityDialog

* remove comment and redundant
2025-08-13 13:21:56 +00:00
Somay Chauhan 293dd1a140 Fix: lock timezone on booking page breaks atoms (#23060)
* fix: eventTypeSettings atom crashed on enabling `Lock timezone on booking page`

* add changelog
2025-08-13 13:03:35 +00:00
Hariom Balhara b7fda31678 fix: Issues observed when inviting members to organization either through teams migration during onboarding or explicit user invite (#22901)
* fix: make team migrations sequential to avoid race conditions

Instead of processing team migrations in parallel using Promise.all,
process them sequentially to avoid race conditions when creating
profiles and memberships for team members.

This simplifies the implementation and avoids the need for complex
retry logic or database-level concurrency controls.

* Promise.all accepts an array of promises and not an array with array in it as the first item. This fixes the issue where these promises were unhandled

* Fix the issue where an existing pending membership with team stays pending even though theorganization membership gets created as accepted
2025-08-13 10:48:17 +00:00
Benny Joo 94a04f0dc2 remove network level caching for i18n service (#23058) 2025-08-13 09:39:00 +00:00
RomitandDevanshu Sharma 2338f85dcf fix: fixed team event form ui (#22005)
* fixed team event form ui

* edge cases for some mobiles

* revert with removed padding

* chore

---------

Co-authored-by: Devanshu Sharma <devanshusharma658@gmail.com>
2025-08-13 05:51:22 +00:00
KartikandKartik Saini fbaccc8e5f fix: prevent dropdown arrow from centering when Select height increases (#22877)
Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-08-13 04:38:48 +00:00
Alex van Andel 750db38d38 fix: Crash of onboarding connected-video page (#23047) 2025-08-13 02:10:22 +00:00
229 changed files with 9668 additions and 6293 deletions
-9
View File
@@ -1,9 +0,0 @@
---
"@calcom/atoms": minor
---
Added new callback functions to the handleFormSubmit method in the EventTypeSettings and AvailabilitySettings atoms. The handleFormSubmit method now accepts an optional callbacks object with the following properties:
- **onSuccess**: Called when the form submission is successful, allowing additional logic to be executed after the update.
- **onError**: Called when an error occurs during form submission, providing details about the error to handle specific cases or display custom messages.
-5
View File
@@ -1,5 +0,0 @@
---
"@calcom/atoms": minor
---
feat: style calendar settings and availability overrides
-5
View File
@@ -1,5 +0,0 @@
---
"@calcom/atoms": minor
---
booker atom: allow toggling org and team info when booking round robin
+2 -2
View File
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+3
View File
@@ -0,0 +1,3 @@
undecided:
- calcom-monorepo
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
View File
+1 -1
View File
@@ -199,7 +199,7 @@ There's some settings that you'll need to setup.
Under Vercel > Your API Project > Settings
In General > Build & Development Settings
BUILD COMMAND: `yarn turbo run build --scope=@calcom/api --include-dependencies --no-deps`
BUILD COMMAND: `yarn turbo run build --filter=@calcom/api --no-deps`
OUTPUT DIRECTORY: `apps/api/.next`
In Git > Ignored Build Step
+1 -1
View File
@@ -33,7 +33,7 @@
"@sentry/nextjs": "^9.15.0",
"bcryptjs": "^2.4.3",
"memory-cache": "^0.2.0",
"next": "^15.3.0",
"next": "^15.4.5",
"next-api-middleware": "^1.0.1",
"next-axiom": "^0.17.0",
"next-swagger-doc": "^0.3.6",
+1 -1
View File
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.300",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.318",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
@@ -15,11 +15,11 @@ import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { PlatformOAuthClient, Team, User } from "@prisma/client";
import * as request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
@@ -36,6 +36,7 @@ import {
CreateBookingInput_2024_08_13,
CreateEventTypeInput_2024_06_14,
EventTypeOutput_2024_06_14,
GetBookingOutput_2024_08_13,
GetBookingsOutput_2024_08_13,
} from "@calcom/platform-types";
@@ -50,9 +51,9 @@ describe("Managed user bookings 2024-08-13", () => {
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let schedulesRepositoryFixture: SchedulesRepositoryFixture;
let profilesRepositoryFixture: ProfileRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
const platformAdminEmail = `managed-users-bookings-2024-08-13-admin-${randomString()}@api.com`;
let platformAdmin: User;
@@ -67,6 +68,10 @@ describe("Managed user bookings 2024-08-13", () => {
let thirdManagedUser: CreateManagedUserData;
let firstManagedUserEventTypeId: number;
let eventTypeRequiresConfirmationId: number;
const orgAdminManagedUserEmail = `managed-user-bookings-2024-08-13-org-admin-${randomString()}@api.com`;
let orgAdminManagedUser: CreateManagedUserData;
let firstManagedUserBookingsCount = 0;
let secondManagedUserBookingsCount = 0;
@@ -85,9 +90,9 @@ describe("Managed user bookings 2024-08-13", () => {
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
schedulesRepositoryFixture = new SchedulesRepositoryFixture(moduleRef);
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
platformAdmin = await userRepositoryFixture.create({ email: platformAdminEmail });
@@ -95,6 +100,13 @@ describe("Managed user bookings 2024-08-13", () => {
name: `oauth-client-users-organization-${randomString()}`,
isPlatform: true,
isOrganization: true,
platformBilling: {
create: {
customerId: "cus_999",
plan: "SCALE",
subscriptionId: "sub_999",
},
},
});
oAuthClient = await createOAuthClient(organization.id);
@@ -243,6 +255,59 @@ describe("Managed user bookings 2024-08-13", () => {
thirdManagedUser = responseBody.data;
});
it(`should create org admin managed user`, async () => {
const requestBody: CreateManagedUserInput = {
email: orgAdminManagedUserEmail,
timeZone: managedUsersTimeZone,
weekStart: "Monday",
timeFormat: 24,
locale: Locales.FR,
name: "Org Admin Smith",
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
};
const response = await request(app.getHttpServer())
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users`)
.set("x-cal-secret-key", oAuthClient.secret)
.send(requestBody)
.expect(201);
const responseBody: CreateManagedUserOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.user.email).toEqual(
OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, requestBody.email)
);
expect(responseBody.data.accessToken).toBeDefined();
expect(responseBody.data.refreshToken).toBeDefined();
orgAdminManagedUser = responseBody.data;
await request(app.getHttpServer())
.post(`/v2/organizations/${organization.id}/memberships`)
.set("x-cal-client-id", oAuthClient.id)
.set("x-cal-secret-key", oAuthClient.secret)
.send({
userId: orgAdminManagedUser.user.id,
role: "ADMIN",
accepted: true,
})
.expect(201);
});
it("should create an event type requiring confirmation for first managed user", async () => {
const eventTypeRequiresConfirmation = await eventTypesRepositoryFixture.create(
{
title: `managed-user-bookings-event-type-requires-confirmation-${randomString()}`,
slug: `managed-user-bookings-event-type-requires-confirmation-${randomString()}`,
length: 60,
requiresConfirmation: true,
},
firstManagedUser.user.id
);
eventTypeRequiresConfirmationId = eventTypeRequiresConfirmation.id;
});
describe("bookings using original emails", () => {
it("managed user should be booked by managed user attendee and booking shows up in both users' bookings", async () => {
const body: CreateBookingInput_2024_08_13 = {
@@ -543,10 +608,175 @@ describe("Managed user bookings 2024-08-13", () => {
});
});
describe("booking confirmation by org admin", () => {
it("should allow org admin managed user to confirm booking using access token", async () => {
const bookingRequiringConfirmation = await bookingsRepositoryFixture.create({
user: {
connect: {
id: firstManagedUser.user.id,
},
},
startTime: new Date(Date.UTC(2030, 0, 9, 13, 0, 0)),
endTime: new Date(Date.UTC(2030, 0, 9, 14, 0, 0)),
title: "Booking requiring confirmation",
uid: `booking-requiring-confirmation-${randomString()}`,
eventType: {
connect: {
id: eventTypeRequiresConfirmationId,
},
},
location: "https://meet.google.com/abc-def-ghi",
customInputs: {},
metadata: {},
status: "PENDING",
responses: {
name: secondManagedUser.user.name,
email: secondManagedUserEmail,
},
attendees: {
create: {
email: secondManagedUserEmail,
name: secondManagedUser.user.name!,
locale: secondManagedUser.user.locale,
timeZone: secondManagedUser.user.timeZone,
},
},
});
const response = await request(app.getHttpServer())
.post(`/v2/bookings/${bookingRequiringConfirmation.uid}/confirm`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set("Authorization", `Bearer ${orgAdminManagedUser.accessToken}`)
.expect(200);
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const bookingData = responseBody.data as BookingOutput_2024_08_13;
expect(bookingData.status).toEqual("accepted");
expect(bookingData.uid).toEqual(bookingRequiringConfirmation.uid);
const confirmedBooking = await bookingsRepositoryFixture.getByUid(bookingRequiringConfirmation.uid);
expect(confirmedBooking?.status).toEqual("ACCEPTED");
});
it("should allow org admin managed user to reject booking using access token", async () => {
const bookingRequiringConfirmation = await bookingsRepositoryFixture.create({
user: {
connect: {
id: firstManagedUser.user.id,
},
},
startTime: new Date(Date.UTC(2030, 0, 9, 15, 0, 0)),
endTime: new Date(Date.UTC(2030, 0, 9, 16, 0, 0)),
title: "Booking requiring rejection",
uid: `booking-requiring-rejection-${randomString()}`,
eventType: {
connect: {
id: eventTypeRequiresConfirmationId,
},
},
location: "https://meet.google.com/abc-def-ghi",
customInputs: {},
metadata: {},
status: "PENDING",
responses: {
name: secondManagedUser.user.name,
email: secondManagedUserEmail,
},
attendees: {
create: {
email: secondManagedUserEmail,
name: secondManagedUser.user.name!,
locale: secondManagedUser.user.locale,
timeZone: secondManagedUser.user.timeZone,
},
},
});
const response = await request(app.getHttpServer())
.post(`/v2/bookings/${bookingRequiringConfirmation.uid}/decline`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set("Authorization", `Bearer ${orgAdminManagedUser.accessToken}`)
.expect(200);
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const bookingData = responseBody.data as BookingOutput_2024_08_13;
expect(bookingData.status).toEqual("rejected");
expect(bookingData.uid).toEqual(bookingRequiringConfirmation.uid);
const rejectedBooking = await bookingsRepositoryFixture.getByUid(bookingRequiringConfirmation.uid);
expect(rejectedBooking?.status).toEqual("REJECTED");
});
it("should return unauthorized when org admin tries to confirm regular user's booking", async () => {
const regularUser = await userRepositoryFixture.create({
email: `managed-user-bookings-2024-08-13-regular-user-${randomString()}@api.com`,
name: "Regular User",
});
const regularUserEventType = await eventTypesRepositoryFixture.create(
{
title: `regular-user-event-type-requires-confirmation-${randomString()}`,
slug: `regular-user-event-type-requires-confirmation-${randomString()}`,
length: 60,
requiresConfirmation: true,
},
regularUser.id
);
const regularUserBooking = await bookingsRepositoryFixture.create({
user: {
connect: {
id: regularUser.id,
},
},
startTime: new Date(Date.UTC(2030, 0, 9, 17, 0, 0)),
endTime: new Date(Date.UTC(2030, 0, 9, 18, 0, 0)),
title: "Regular user booking requiring confirmation",
uid: `regular-user-booking-${randomString()}`,
eventType: {
connect: {
id: regularUserEventType.id,
},
},
location: "https://meet.google.com/abc-def-ghi",
customInputs: {},
metadata: {},
status: "PENDING",
responses: {
name: "External Attendee",
email: "external@example.com",
},
attendees: {
create: {
email: "external@example.com",
name: "External Attendee",
locale: "en",
timeZone: "Europe/Rome",
},
},
});
await request(app.getHttpServer())
.post(`/v2/bookings/${regularUserBooking.uid}/confirm`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set("Authorization", `Bearer ${orgAdminManagedUser.accessToken}`)
.expect(401);
await userRepositoryFixture.delete(regularUser.id);
});
});
afterAll(async () => {
await userRepositoryFixture.delete(firstManagedUser.user.id);
await userRepositoryFixture.delete(secondManagedUser.user.id);
await userRepositoryFixture.delete(thirdManagedUser.user.id);
await userRepositoryFixture.delete(orgAdminManagedUser.user.id);
await userRepositoryFixture.delete(platformAdmin.id);
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
@@ -702,6 +702,7 @@ export class InputBookingsService_2024_08_13 {
beforeUpdatedDate: queryParams.beforeUpdatedAt,
afterCreatedDate: queryParams.afterCreatedAt,
beforeCreatedDate: queryParams.beforeCreatedAt,
bookingUid: queryParams.bookingUid,
};
}
@@ -4,37 +4,21 @@ import { Permissions } from "@/modules/auth/decorators/permissions/permissions.d
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { EventTypeOwnershipGuard } from "@/modules/event-types/guards/event-type-ownership.guard";
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
UseGuards,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags, OmitType } from "@nestjs/swagger";
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UseGuards } from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import {
EVENT_TYPE_READ,
EVENT_TYPE_WRITE,
SUCCESS_STATUS,
} from "@calcom/platform-constants";
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import {
CreatePrivateLinkInput,
CreatePrivateLinkOutput,
DeletePrivateLinkOutput,
GetPrivateLinksOutput,
UpdatePrivateLinkInput,
UpdatePrivateLinkOutput,
UpdatePrivateLinkBody,
} from "@calcom/platform-types";
import { PrivateLinksService } from "../services/private-links.service";
class UpdatePrivateLinkBody extends OmitType(UpdatePrivateLinkInput, ["linkId"] as const) {}
@Controller({
path: "/v2/event-types/:eventTypeId/private-links",
})
@@ -67,10 +51,9 @@ export class EventTypesPrivateLinksController {
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({ summary: "Get all private links for an event type" })
async getPrivateLinks(
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@GetUser("id") userId: number
@Param("eventTypeId", ParseIntPipe) eventTypeId: number
): Promise<GetPrivateLinksOutput> {
const privateLinks = await this.privateLinksService.getPrivateLinks(eventTypeId, userId);
const privateLinks = await this.privateLinksService.getPrivateLinks(eventTypeId);
return {
status: SUCCESS_STATUS,
@@ -86,11 +69,10 @@ export class EventTypesPrivateLinksController {
async updatePrivateLink(
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Param("linkId") linkId: string,
@Body() body: UpdatePrivateLinkBody,
@GetUser("id") userId: number
@Body() body: UpdatePrivateLinkBody
): Promise<UpdatePrivateLinkOutput> {
const updateInput = { ...body, linkId };
const privateLink = await this.privateLinksService.updatePrivateLink(eventTypeId, userId, updateInput);
const privateLink = await this.privateLinksService.updatePrivateLink(eventTypeId, updateInput);
return {
status: SUCCESS_STATUS,
@@ -105,10 +87,9 @@ export class EventTypesPrivateLinksController {
@ApiOperation({ summary: "Delete a private link for an event type" })
async deletePrivateLink(
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Param("linkId") linkId: string,
@GetUser("id") userId: number
@Param("linkId") linkId: string
): Promise<DeletePrivateLinkOutput> {
await this.privateLinksService.deletePrivateLink(eventTypeId, userId, linkId);
await this.privateLinksService.deletePrivateLink(eventTypeId, linkId);
return {
status: SUCCESS_STATUS,
@@ -119,5 +100,3 @@ export class EventTypesPrivateLinksController {
};
}
}
@@ -0,0 +1,121 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import {
OPTIONAL_API_KEY_HEADER,
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
} from "@/lib/docs/headers";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service";
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UseGuards } from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import {
CreatePrivateLinkInput,
CreatePrivateLinkOutput,
DeletePrivateLinkOutput,
GetPrivateLinksOutput,
UpdatePrivateLinkInput,
UpdatePrivateLinkOutput,
} from "@calcom/platform-types";
import { PrivateLinksService } from "../services/private-links.service";
@Controller({
path: "/v2/organizations/:orgId/teams/:teamId/event-types/:eventTypeId/private-links",
version: API_VERSIONS_VALUES,
})
@DocsTags("Orgs / Teams / Event Types / Private Links")
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
@ApiHeader(OPTIONAL_API_KEY_HEADER)
export class OrganizationsEventTypesPrivateLinksController {
constructor(
private readonly privateLinksService: PrivateLinksService,
private readonly teamsEventTypesService: TeamsEventTypesService
) {}
@Post("/")
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@ApiOperation({ summary: "Create a private link for a team event type" })
async createPrivateLink(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Body() body: CreatePrivateLinkInput
): Promise<CreatePrivateLinkOutput> {
await this.teamsEventTypesService.validateEventTypeExists(teamId, eventTypeId);
// Use teamId as the seed for link generation in org/team context
const privateLink = await this.privateLinksService.createPrivateLink(eventTypeId, teamId, body);
return {
status: SUCCESS_STATUS,
data: privateLink,
};
}
@Get("/")
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@ApiOperation({ summary: "Get all private links for a team event type" })
async getPrivateLinks(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("eventTypeId", ParseIntPipe) eventTypeId: number
): Promise<GetPrivateLinksOutput> {
await this.teamsEventTypesService.validateEventTypeExists(teamId, eventTypeId);
const privateLinks = await this.privateLinksService.getPrivateLinks(eventTypeId);
return {
status: SUCCESS_STATUS,
data: privateLinks,
};
}
@Patch("/:linkId")
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@ApiOperation({ summary: "Update a private link for a team event type" })
async updatePrivateLink(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Param("linkId") linkId: string,
@Body() body: Omit<UpdatePrivateLinkInput, "linkId">
): Promise<UpdatePrivateLinkOutput> {
await this.teamsEventTypesService.validateEventTypeExists(teamId, eventTypeId);
const updateInput: UpdatePrivateLinkInput = { ...body, linkId };
const privateLink = await this.privateLinksService.updatePrivateLink(eventTypeId, updateInput);
return {
status: SUCCESS_STATUS,
data: privateLink,
};
}
@Delete("/:linkId")
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@ApiOperation({ summary: "Delete a private link for a team event type" })
async deletePrivateLink(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Param("linkId") linkId: string
): Promise<DeletePrivateLinkOutput> {
await this.teamsEventTypesService.validateEventTypeExists(teamId, eventTypeId);
await this.privateLinksService.deletePrivateLink(eventTypeId, linkId);
return {
status: SUCCESS_STATUS,
data: {
linkId,
message: "Private link deleted successfully",
},
};
}
}
@@ -1,18 +1,18 @@
import { Module } from "@nestjs/common";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { EventTypeOwnershipGuard } from "@/modules/event-types/guards/event-type-ownership.guard";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { Module } from "@nestjs/common";
import { EventTypesPrivateLinksController } from "./controllers/event-types-private-links.controller";
import { PrivateLinksRepository } from "./private-links.repository";
import { PrivateLinksInputService } from "./services/private-links-input.service";
import { PrivateLinksOutputService } from "./services/private-links-output.service";
import { PrivateLinksService } from "./services/private-links.service";
import { PrivateLinksRepository } from "./private-links.repository";
@Module({
imports: [TokensModule, OAuthClientModule, PrismaModule, EventTypesModule_2024_06_14],
imports: [TokensModule, PrismaModule, EventTypesModule_2024_06_14],
controllers: [EventTypesPrivateLinksController],
providers: [
PrivateLinksService,
@@ -21,7 +21,6 @@ import { PrivateLinksRepository } from "./private-links.repository";
PrivateLinksRepository,
EventTypeOwnershipGuard,
],
exports: [PrivateLinksService],
})
export class EventTypesPrivateLinksModule {}
@@ -25,7 +25,10 @@ export class PrivateLinksRepository {
});
}
async create(eventTypeId: number, link: { link: string; expiresAt: Date | null; maxUsageCount?: number | null }) {
async create(
eventTypeId: number,
link: { link: string; expiresAt: Date | null; maxUsageCount?: number | null }
) {
return this.dbWrite.prisma.hashedLink.create({
data: {
eventTypeId,
@@ -36,7 +39,10 @@ export class PrivateLinksRepository {
});
}
async update(eventTypeId: number, link: { link: string; expiresAt: Date | null; maxUsageCount?: number | null }) {
async update(
eventTypeId: number,
link: { link: string; expiresAt: Date | null; maxUsageCount?: number | null }
) {
return this.dbWrite.prisma.hashedLink.updateMany({
where: { eventTypeId, link: link.link },
data: {
@@ -50,5 +56,3 @@ export class PrivateLinksRepository {
return this.dbWrite.prisma.hashedLink.deleteMany({ where: { eventTypeId, link: linkId } });
}
}
@@ -1,6 +1,12 @@
import { Injectable } from "@nestjs/common";
import { plainToClass } from "class-transformer";
import {
PrivateLinkOutput,
TimeBasedPrivateLinkOutput,
UsageBasedPrivateLinkOutput,
} from "@calcom/platform-types";
export type PrivateLinkData = {
id: string;
eventTypeId: number;
@@ -10,7 +16,6 @@ export type PrivateLinkData = {
maxUsageCount?: number | null;
usageCount?: number;
};
import { PrivateLinkOutput, TimeBasedPrivateLinkOutput, UsageBasedPrivateLinkOutput } from "@calcom/platform-types";
@Injectable()
export class PrivateLinksOutputService {
@@ -39,5 +44,3 @@ export class PrivateLinksOutputService {
return data.map((item) => this.transformToOutput(item));
}
}
@@ -1,14 +1,13 @@
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
import { generateHashedLink, isLinkExpired } from "@calcom/platform-libraries/private-links";
import { CreatePrivateLinkInput, PrivateLinkOutput, UpdatePrivateLinkInput } from "@calcom/platform-types";
import { PrivateLinksRepository } from "@/ee/event-types-private-links/private-links.repository";
import { PrivateLinksInputService } from "@/ee/event-types-private-links/services/private-links-input.service";
import {
PrivateLinksOutputService,
type PrivateLinkData,
} from "@/ee/event-types-private-links/services/private-links-output.service";
import { PrivateLinksRepository } from "@/ee/event-types-private-links/private-links.repository";
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
import { generateHashedLink, isLinkExpired } from "@calcom/platform-libraries/private-links";
import { CreatePrivateLinkInput, PrivateLinkOutput, UpdatePrivateLinkInput } from "@calcom/platform-types";
@Injectable()
export class PrivateLinksService {
@@ -48,7 +47,7 @@ export class PrivateLinksService {
}
}
async getPrivateLinks(eventTypeId: number, userId: number): Promise<PrivateLinkOutput[]> {
async getPrivateLinks(eventTypeId: number): Promise<PrivateLinkOutput[]> {
try {
const links = await this.repo.listByEventTypeId(eventTypeId);
const mapped: PrivateLinkData[] = links.map((l) => ({
@@ -69,11 +68,7 @@ export class PrivateLinksService {
}
}
async updatePrivateLink(
eventTypeId: number,
userId: number,
input: UpdatePrivateLinkInput
): Promise<PrivateLinkOutput> {
async updatePrivateLink(eventTypeId: number, input: UpdatePrivateLinkInput): Promise<PrivateLinkOutput> {
try {
const transformedInput = this.inputService.transformUpdateInput(input);
const updatedResult = await this.repo.update(eventTypeId, {
@@ -107,7 +102,7 @@ export class PrivateLinksService {
}
}
async deletePrivateLink(eventTypeId: number, userId: number, linkId: string): Promise<void> {
async deletePrivateLink(eventTypeId: number, linkId: string): Promise<void> {
try {
const { count } = await this.repo.delete(eventTypeId, linkId);
if (count === 0) {
@@ -124,5 +119,3 @@ export class PrivateLinksService {
}
}
}
@@ -2147,6 +2147,51 @@ describe("Event types Endpoints", () => {
});
});
describe("EventType Hidden Property", () => {
let createdEventTypeId: number;
it("should create an event type with hidden=true", async () => {
const createPayload = {
title: "Hidden Event",
slug: "hidden-event",
lengthInMinutes: 30,
hidden: true,
};
const response = await request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(createPayload)
.expect(201);
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const createdEventType = responseBody.data;
expect(createdEventType).toHaveProperty("id");
expect(createdEventType.title).toEqual(createPayload.title);
expect(createdEventType.slug).toEqual(createPayload.slug);
expect(createdEventType.lengthInMinutes).toEqual(createPayload.lengthInMinutes);
expect(createdEventType.hidden).toBe(true);
createdEventTypeId = createdEventType.id;
});
it("should update the hidden property to false", async () => {
const updatePayload = {
hidden: false,
};
const response = await request(app.getHttpServer())
.patch(`/api/v2/event-types/${createdEventTypeId}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(updatePayload)
.expect(200);
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const updatedEventType = responseBody.data;
+expect(updatedEventType.hidden).toBe(false);
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
@@ -6,7 +6,6 @@ import { UpdateEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2
import { EventTypeResponseTransformPipe } from "@/ee/event-types/event-types_2024_06_14/pipes/event-type-response.transformer";
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
import { VERSION_2024_06_14_VALUE } from "@/lib/api-versions";
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
@@ -42,7 +41,6 @@ import {
GetEventTypesQuery_2024_06_14,
CreateEventTypeInput_2024_06_14,
EventTypeOutput_2024_06_14,
} from "@calcom/platform-types";
@Controller({
@@ -89,6 +89,7 @@ type Input = Pick<
| "hideCalendarEventDetails"
| "hideOrganizerEmail"
| "calVideoSettings"
| "hidden"
>;
@Injectable()
@@ -127,6 +128,7 @@ export class OutputEventTypesService_2024_06_14 {
hideCalendarEventDetails,
hideOrganizerEmail,
calVideoSettings,
hidden,
} = databaseEventType;
const locations = this.transformLocations(databaseEventType.locations);
@@ -203,6 +205,7 @@ export class OutputEventTypesService_2024_06_14 {
hideCalendarEventDetails,
hideOrganizerEmail,
calVideoSettings,
hidden,
};
}
@@ -1,6 +1,7 @@
import { BookingsModule_2024_04_15 } from "@/ee/bookings/2024-04-15/bookings.module";
import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module";
import { CalendarsModule } from "@/ee/calendars/calendars.module";
import { EventTypesPrivateLinksModule } from "@/ee/event-types-private-links/event-types-private-links.module";
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { GcalModule } from "@/ee/gcal/gcal.module";
@@ -14,7 +15,6 @@ import { SlotsModule_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.m
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
import { TeamsMembershipsModule } from "@/modules/teams/memberships/teams-memberships.module";
import { TeamsModule } from "@/modules/teams/teams/teams.module";
import { EventTypesPrivateLinksModule } from "@/ee/event-types-private-links/event-types-private-links.module";
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
import { Module } from "@nestjs/common";
@@ -229,4 +229,19 @@ export class SchedulesRepository_2024_06_11 {
},
});
}
async getSchedulesByUserIds(userIds: number[], skip: number, take: number) {
return this.dbRead.prisma.schedule.findMany({
where: {
userId: {
in: userIds,
},
},
include: {
availability: true,
},
skip,
take,
});
}
}
@@ -1,6 +1,7 @@
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
import { PrismaMembershipRepository } from "@/lib/repositories/prisma-membership.repository";
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository";
import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.repository";
@@ -12,6 +13,7 @@ import { BusyTimesService } from "@/lib/services/busy-times.service";
import { CacheService } from "@/lib/services/cache.service";
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { FilterHostsService } from "@/lib/services/filter-hosts.service";
import { NoSlotsNotificationService } from "@/lib/services/no-slots-notification.service";
import { QualifiedHostsService } from "@/lib/services/qualified-hosts.service";
import { UserAvailabilityService } from "@/lib/services/user-availability.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
@@ -31,6 +33,7 @@ import { Module } from "@nestjs/common";
PrismaTeamRepository,
RedisService,
PrismaFeaturesRepository,
PrismaMembershipRepository,
CheckBookingLimitsService,
CacheService,
AvailableSlotsService,
@@ -38,6 +41,7 @@ import { Module } from "@nestjs/common";
BusyTimesService,
FilterHostsService,
QualifiedHostsService,
NoSlotsNotificationService,
],
exports: [AvailableSlotsService],
})
@@ -0,0 +1,12 @@
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { PrismaMembershipRepository as PrismaMembershipRepositoryLib } from "@calcom/platform-libraries/repositories";
import { PrismaClient } from "@calcom/prisma";
@Injectable()
export class PrismaMembershipRepository extends PrismaMembershipRepositoryLib {
constructor(private readonly dbWrite: PrismaWriteService) {
super(dbWrite.prisma as unknown as PrismaClient);
}
}
@@ -10,6 +10,7 @@ import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository"
import { BusyTimesService } from "@/lib/services/busy-times.service";
import { CacheService } from "@/lib/services/cache.service";
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { NoSlotsNotificationService } from "@/lib/services/no-slots-notification.service";
import { QualifiedHostsService } from "@/lib/services/qualified-hosts.service";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable } from "@nestjs/common";
@@ -35,7 +36,8 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
checkBookingLimitsService: CheckBookingLimitsService,
cacheService: CacheService,
userAvailabilityService: UserAvailabilityService,
busyTimesService: BusyTimesService
busyTimesService: BusyTimesService,
noSlotsNotificationService: NoSlotsNotificationService
) {
super({
oooRepo: oooRepoDependency,
@@ -53,6 +55,7 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
busyTimesService,
qualifiedHostsService,
featuresRepo: featuresRepository,
noSlotsNotificationService,
});
}
}
@@ -0,0 +1,21 @@
import { PrismaMembershipRepository } from "@/lib/repositories/prisma-membership.repository";
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable } from "@nestjs/common";
import { NoSlotsNotificationService as BaseNoSlotsNotificationService } from "@calcom/platform-libraries/slots";
@Injectable()
export class NoSlotsNotificationService extends BaseNoSlotsNotificationService {
constructor(
teamRepository: PrismaTeamRepository,
membershipRepository: PrismaMembershipRepository,
redisService: RedisService
) {
super({
teamRepo: teamRepository,
membershipRepo: membershipRepository,
redisClient: redisService,
});
}
}
@@ -2,10 +2,21 @@ import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { GetUnifiedCalendarEventOutput } from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event";
import { UpdateUnifiedCalendarEventInput } from "@/modules/cal-unified-calendars/inputs/update-unified-calendar-event.input";
import { GetUnifiedCalendarEventOutput } from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event.output";
import { GoogleCalendarEventOutputPipe } from "@/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe";
import { GoogleCalendarService } from "@/modules/cal-unified-calendars/services/google-calendar.service";
import { Controller, Get, Param, UseGuards, HttpCode, HttpStatus, BadRequestException } from "@nestjs/common";
import {
Controller,
Get,
Param,
UseGuards,
HttpCode,
HttpStatus,
BadRequestException,
Patch,
Body,
} from "@nestjs/common";
import { ApiTags as DocsTags, ApiParam, ApiHeader, ApiOperation } from "@nestjs/swagger";
import { GOOGLE_CALENDAR, SUCCESS_STATUS } from "@calcom/platform-constants";
@@ -30,6 +41,7 @@ export class CalUnifiedCalendarsController {
type: String,
})
@Get("/:calendar/event/:eventUid")
@Get("/:calendar/events/:eventUid")
@HttpCode(HttpStatus.OK)
@UseGuards(ApiAuthGuard, PermissionsGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@@ -54,4 +66,42 @@ export class CalUnifiedCalendarsController {
data: transformedEvent,
};
}
@ApiParam({
name: "calendar",
enum: [GOOGLE_CALENDAR],
type: String,
})
@ApiParam({
name: "eventUid",
description:
"The Google Calendar event ID. You can retrieve this by getting booking references from the following endpoints:\n\n- For team events: https://cal.com/docs/api-reference/v2/orgs-teams-bookings/get-booking-references-for-a-booking\n\n- For user events: https://cal.com/docs/api-reference/v2/bookings/get-booking-references-for-a-booking",
type: String,
})
@Patch("/:calendar/events/:eventUid")
@HttpCode(HttpStatus.OK)
@UseGuards(ApiAuthGuard, PermissionsGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Update meeting details in calendar",
description: "Updates event information in the specified calendar provider",
})
async updateCalendarEvent(
@Param("calendar") calendar: string,
@Param("eventUid") eventUid: string,
@Body() updateData: UpdateUnifiedCalendarEventInput
): Promise<GetUnifiedCalendarEventOutput> {
if (calendar !== GOOGLE_CALENDAR) {
throw new BadRequestException("Event updates are currently only available for Google Calendar");
}
const updatedEvent = await this.googleCalendarService.updateEventDetails(eventUid, updateData);
const transformedEvent = new GoogleCalendarEventOutputPipe().transform(updatedEvent);
return {
status: SUCCESS_STATUS,
data: transformedEvent,
};
}
}
@@ -0,0 +1,134 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsISO8601, IsOptional, IsString, ValidateNested, IsEnum, IsArray } from "class-validator";
import { CalendarEventStatus, CalendarEventResponseStatus } from "../outputs/get-unified-calendar-event.output";
export class UpdateCalendarEventAttendee {
@IsString()
@ApiPropertyOptional({
type: String,
description: "Email address of the attendee",
})
email!: string;
@IsString()
@IsOptional()
@ApiPropertyOptional({
type: String,
description: "Display name of the attendee",
})
name?: string;
@IsEnum(CalendarEventResponseStatus)
@IsOptional()
@ApiPropertyOptional({
enum: CalendarEventResponseStatus,
enumName: "CalendarEventResponseStatus",
nullable: true,
description: "Response status of the attendee",
})
responseStatus?: CalendarEventResponseStatus | null;
@IsOptional()
@ApiPropertyOptional({
nullable: true,
description: "Indicates if this attendee is the current user",
})
self?: boolean;
@IsOptional()
@ApiPropertyOptional({
nullable: true,
description: "Indicates if this attendee's attendance is optional",
})
optional?: boolean;
@IsOptional()
@ApiPropertyOptional({
nullable: true,
description: "Indicates if this attendee is the host",
})
host?: boolean;
}
export class UpdateDateTimeWithZone {
@IsISO8601()
@IsOptional()
@ApiPropertyOptional({ type: "string", format: "date-time" })
time?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
timeZone?: string;
}
export class UpdateUnifiedCalendarEventInput {
@ValidateNested()
@Type(() => UpdateDateTimeWithZone)
@IsOptional()
@ApiPropertyOptional({
type: "object",
properties: {
time: { type: "string", format: "date-time" },
timeZone: { type: "string" },
},
description: "Start date and time of the calendar event with timezone information",
})
start?: UpdateDateTimeWithZone;
@ValidateNested()
@Type(() => UpdateDateTimeWithZone)
@IsOptional()
@ApiPropertyOptional({
type: "object",
properties: {
time: { type: "string", format: "date-time" },
timeZone: { type: "string" },
},
description: "End date and time of the calendar event with timezone information",
})
end?: UpdateDateTimeWithZone;
@IsString()
@IsOptional()
@ApiPropertyOptional({
type: String,
description: "Title of the calendar event",
})
title?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional({
type: String,
nullable: true,
description: "Detailed description of the calendar event",
})
description?: string | null;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => UpdateCalendarEventAttendee)
@ApiPropertyOptional({
type: [UpdateCalendarEventAttendee],
nullable: true,
description:
"List of attendees. CAUTION: You must pass the entire array with all updated values. Any attendees not included in this array will be removed from the event.",
})
attendees?: UpdateCalendarEventAttendee[];
@IsEnum(CalendarEventStatus)
@IsOptional()
@ApiPropertyOptional({
enum: CalendarEventStatus,
enumName: "CalendarEventStatus",
nullable: true,
description: "Status of the event (accepted, pending, declined, cancelled)",
example: CalendarEventStatus.ACCEPTED,
})
status?: CalendarEventStatus | null;
}
@@ -225,6 +225,22 @@ export class CalendarEventHost {
responseStatus!: CalendarEventResponseStatus | null;
}
export class calendarEventOwner {
@IsString()
@ApiProperty({
description: "Email address of the event host",
})
email!: string;
@IsString()
@IsOptional()
@ApiPropertyOptional({
nullable: true,
description: "Display name of the event host",
})
name?: string;
}
export class CalendarEventAttendee {
@IsString()
@ApiProperty({
@@ -264,6 +280,14 @@ export class CalendarEventAttendee {
description: "Indicates if this attendee's attendance is optional",
})
optional?: boolean;
@IsOptional()
@ApiPropertyOptional({
nullable: true,
description: "Indicates if this attendee is the host",
})
host?: boolean;
}
export class DateTimeWithZone {
@@ -384,6 +408,16 @@ export class UnifiedCalendarEventOutput {
})
hosts?: CalendarEventHost[];
@IsOptional()
@ValidateNested({ each: true })
@Type(() => calendarEventOwner)
@ApiPropertyOptional({
type: calendarEventOwner,
nullable: true,
description: "The calendar account that owns this event. This is the primary calendar where the event is stored and cannot be modified without appropriate permissions. Changing this would require moving the event to a different calendar",
})
calendarEventOwner?: calendarEventOwner;
@IsEnum(CALENDARS)
@ApiProperty({
enum: CALENDARS,
@@ -0,0 +1,103 @@
import { GoogleCalendarEventResponse } from "../get-calendar-event-details-output-pipe";
export const createGoogleCalendarEventFixture = (
overrides: Partial<GoogleCalendarEventResponse> = {}
): GoogleCalendarEventResponse => {
const baseEvent: GoogleCalendarEventResponse = {
kind: "calendar#event",
etag: "test-etag",
id: "test-event-id",
status: "confirmed",
htmlLink: "https://calendar.google.com/event",
created: "2024-01-01T00:00:00Z",
updated: "2024-01-01T00:00:00Z",
summary: "Test Meeting",
description: "Test description",
creator: {
email: "creator@example.com",
displayName: "Creator Name",
},
organizer: {
email: "organizer@example.com",
displayName: "Organizer Name",
},
start: {
dateTime: "2024-01-15T10:00:00Z",
timeZone: "America/New_York",
},
end: {
dateTime: "2024-01-15T11:00:00Z",
timeZone: "America/New_York",
},
iCalUID: "test-ical-uid",
sequence: 0,
attendees: [
{
email: "attendee@example.com",
displayName: "Attendee Name",
responseStatus: "accepted",
organizer: false,
},
{
email: "organizer@example.com",
displayName: "Organizer Name",
responseStatus: "accepted",
organizer: true,
},
],
conferenceData: {
conferenceId: "abc-def-ghi",
entryPoints: [
{
entryPointType: "video",
uri: "https://meet.google.com/abc-def-ghi",
label: "meet.google.com/abc-def-ghi",
},
],
conferenceSolution: {
key: {
type: "hangoutsMeet",
},
name: "Google Meet",
iconUri:
"https://fonts.gstatic.com/s/i/productlogos/meet_2020q4/v6/web-512dp/logo_meet_2020q4_color_2x_web_512dp.png",
},
},
hangoutLink: "https://meet.google.com/abc-def-ghi",
};
return { ...baseEvent, ...overrides };
};
export const googleEventWithConferenceData = createGoogleCalendarEventFixture({
conferenceData: {
conferenceId: "test-conference-id",
entryPoints: [
{
entryPointType: "video",
uri: "https://meet.google.com/abc-def-ghi",
label: "Google Meet",
pin: "123456",
regionCode: "US",
},
{
entryPointType: "phone",
uri: "tel:+1-555-123-4567",
label: "Phone",
pin: "789012",
},
],
},
});
export const googleEventWithLocationOnly = createGoogleCalendarEventFixture({
location: "123 Main St, City, State",
conferenceData: undefined,
hangoutLink: undefined,
});
export const googleEventWithHangoutLink = createGoogleCalendarEventFixture({
hangoutLink: "https://hangouts.google.com/call/abc123",
conferenceData: undefined,
location: undefined,
});
@@ -0,0 +1,201 @@
import {
CalendarEventStatus,
CalendarEventResponseStatus,
} from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event.output";
import {
createGoogleCalendarEventFixture,
googleEventWithConferenceData,
googleEventWithLocationOnly,
googleEventWithHangoutLink,
} from "./__fixtures__/google-calendar-event.fixture";
import {
GoogleCalendarEventOutputPipe,
GoogleCalendarEventResponse,
} from "./get-calendar-event-details-output-pipe";
describe("GoogleCalendarEventOutputPipe", () => {
let pipe: GoogleCalendarEventOutputPipe;
let sharedGoogleEvent: GoogleCalendarEventResponse;
beforeAll(() => {
sharedGoogleEvent = createGoogleCalendarEventFixture();
});
beforeEach(() => {
pipe = new GoogleCalendarEventOutputPipe();
});
describe("transform", () => {
it("should transform Google Calendar event to unified format", () => {
const result = pipe.transform(sharedGoogleEvent);
expect(result.id).toBe("test-event-id");
expect(result.title).toBe("Test Meeting");
expect(result.description).toBe("Test description");
expect(result.source).toBe("google");
expect(result.status).toBe(CalendarEventStatus.ACCEPTED);
expect(result.start.time).toBe("2024-01-15T10:00:00Z");
expect(result.start.timeZone).toBe("America/New_York");
expect(result.end.time).toBe("2024-01-15T11:00:00Z");
expect(result.end.timeZone).toBe("America/New_York");
expect(result.attendees).toHaveLength(2);
});
});
describe("transformDateTimeWithZone", () => {
it("should transform Google date time to unified format", () => {
const googleDateTime = {
dateTime: "2024-01-15T10:00:00Z",
timeZone: "America/New_York",
};
const result = pipe["transformDateTimeWithZone"](googleDateTime);
expect(result.time).toBe("2024-01-15T10:00:00Z");
expect(result.timeZone).toBe("America/New_York");
});
});
describe("transformAttendeeResponseStatus", () => {
it("should transform accepted status", () => {
const result = pipe["transformAttendeeResponseStatus"]("accepted");
expect(result).toBe(CalendarEventResponseStatus.ACCEPTED);
});
it("should transform tentative status", () => {
const result = pipe["transformAttendeeResponseStatus"]("tentative");
expect(result).toBe(CalendarEventResponseStatus.PENDING);
});
it("should transform declined status", () => {
const result = pipe["transformAttendeeResponseStatus"]("declined");
expect(result).toBe(CalendarEventResponseStatus.DECLINED);
});
it("should transform needsaction status", () => {
const result = pipe["transformAttendeeResponseStatus"]("needsaction");
expect(result).toBe(CalendarEventResponseStatus.NEEDS_ACTION);
});
it("should handle case insensitive status", () => {
const result = pipe["transformAttendeeResponseStatus"]("ACCEPTED");
expect(result).toBe(CalendarEventResponseStatus.ACCEPTED);
});
it("should handle unknown status", () => {
const result = pipe["transformAttendeeResponseStatus"]("unknown");
expect(result).toBeNull();
});
it("should handle null status", () => {
const result = pipe["transformAttendeeResponseStatus"](undefined);
expect(result).toBeNull();
});
});
describe("transformEventStatus", () => {
it("should transform confirmed status", () => {
const result = pipe["transformEventStatus"]("confirmed");
expect(result).toBe(CalendarEventStatus.ACCEPTED);
});
it("should transform tentative status", () => {
const result = pipe["transformEventStatus"]("tentative");
expect(result).toBe(CalendarEventStatus.PENDING);
});
it("should transform cancelled status", () => {
const result = pipe["transformEventStatus"]("cancelled");
expect(result).toBe(CalendarEventStatus.CANCELLED);
});
it("should handle case insensitive status", () => {
const result = pipe["transformEventStatus"]("CONFIRMED");
expect(result).toBe(CalendarEventStatus.ACCEPTED);
});
it("should handle unknown status", () => {
const result = pipe["transformEventStatus"]("unknown");
expect(result).toBeNull();
});
it("should handle null status", () => {
const result = pipe["transformEventStatus"](undefined);
expect(result).toBeNull();
});
});
describe("transformOrganizer", () => {
it("should return calendarEventOwner organizer exist", () => {
const eventWithoutOrganizerAttendees = createGoogleCalendarEventFixture({
organizer: { email: "organizer@example.com" },
attendees: [
{
email: "attendee@example.com",
displayName: "Regular Attendee",
responseStatus: "accepted",
organizer: false,
},
],
});
const result = pipe.transform(eventWithoutOrganizerAttendees);
expect(result?.calendarEventOwner?.email).toBe("organizer@example.com");
});
});
describe("transformLocations", () => {
it("should transform conference data entry points", () => {
const result = pipe["transformLocations"](googleEventWithConferenceData);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
type: "video",
url: "https://meet.google.com/abc-def-ghi",
label: "Google Meet",
pin: "123456",
regionCode: "US",
});
expect(result[1]).toEqual({
type: "phone",
url: "tel:+1-555-123-4567",
label: "Phone",
pin: "789012",
regionCode: undefined,
});
});
it("should fallback to location field", () => {
const result = pipe["transformLocations"](googleEventWithLocationOnly);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: "video",
url: "123 Main St, City, State",
});
});
it("should fallback to hangout link", () => {
const result = pipe["transformLocations"](googleEventWithHangoutLink);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: "video",
url: "https://hangouts.google.com/call/abc123",
});
});
it("should return empty array when no location data", () => {
const eventWithoutLocation = createGoogleCalendarEventFixture({
location: undefined,
conferenceData: undefined,
hangoutLink: undefined,
});
const result = pipe["transformLocations"](eventWithoutLocation);
expect(result).toEqual([]);
});
});
});
@@ -3,7 +3,7 @@ import {
CalendarEventStatus,
DateTimeWithZone,
UnifiedCalendarEventOutput,
} from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event";
} from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event.output";
import { PipeTransform, Injectable } from "@nestjs/common";
export interface GoogleCalendarEventResponse {
@@ -94,14 +94,12 @@ export class GoogleCalendarEventOutputPipe
calendarEvent.locations = this.transformLocations(googleEvent);
if (googleEvent.attendees && googleEvent.attendees.length > 0) {
calendarEvent.attendees = googleEvent.attendees
.filter((attendee) => !attendee.organizer)
.map((attendee) => {
calendarEvent.attendees = googleEvent.attendees.map((attendee) => {
return {
email: attendee.email,
name: attendee.displayName,
responseStatus: this.transformAttendeeResponseStatus(attendee.responseStatus),
organizer: attendee.organizer,
host: attendee.organizer,
self: attendee.self,
optional: attendee.optional,
};
@@ -110,7 +108,8 @@ export class GoogleCalendarEventOutputPipe
calendarEvent.status = this.transformEventStatus(googleEvent.status);
calendarEvent.hosts = this.transformHosts(googleEvent);
calendarEvent.calendarEventOwner = googleEvent.organizer
calendarEvent.hosts = this.transformHosts(googleEvent)
calendarEvent.source = "google";
@@ -196,6 +195,8 @@ export class GoogleCalendarEventOutputPipe
regionCode: entryPoint.regionCode,
};
});
} else if (googleEvent.location) {
return [{ type: "video", url: googleEvent.location }];
} else if (googleEvent.hangoutLink) {
return [{ type: "video", url: googleEvent.hangoutLink }];
}
@@ -0,0 +1,121 @@
import { GoogleCalendarEventResponse } from "@/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe";
import { Injectable } from "@nestjs/common";
import {
UpdateUnifiedCalendarEventInput,
UpdateDateTimeWithZone,
} from "../inputs/update-unified-calendar-event.input";
import { CalendarEventResponseStatus, CalendarEventStatus } from "../outputs/get-unified-calendar-event.output";
// Common interfaces for Google Calendar types
interface GoogleCalendarDateTime {
dateTime: string;
timeZone: string;
}
interface GoogleCalendarAttendee {
email: string;
displayName?: string;
responseStatus: string;
organizer?: boolean;
}
interface GoogleCalendarEventInputTransform {
transform(
updateData: UpdateUnifiedCalendarEventInput,
fetchedEvent?: GoogleCalendarEventResponse | null
): any;
}
@Injectable()
export class GoogleCalendarEventInputPipe implements GoogleCalendarEventInputTransform {
transform(
updateData: UpdateUnifiedCalendarEventInput,
): any {
const updatePayload: any = {};
if (updateData.title !== undefined) {
updatePayload.summary = updateData.title;
}
if (updateData.description !== undefined) {
updatePayload.description = updateData.description;
}
if (updateData.start) {
updatePayload.start = this.transformDateTimeWithZone(updateData.start);
}
if (updateData.end) {
updatePayload.end = this.transformDateTimeWithZone(updateData.end);
}
if (updateData.attendees !== undefined) {
updatePayload.attendees = this.transformAttendees(
updateData.attendees,
);
}
if (updateData.status !== undefined) {
updatePayload.status = this.transformEventStatus(updateData.status);
}
return updatePayload;
}
private transformDateTimeWithZone(dateTime: UpdateDateTimeWithZone): GoogleCalendarDateTime {
return {
dateTime: dateTime.time || "",
timeZone: dateTime.timeZone || "",
};
}
private transformAttendees(
inputAttendees: NonNullable<UpdateUnifiedCalendarEventInput["attendees"]>,
): GoogleCalendarAttendee[] {
return inputAttendees.map((attendee) => {
return {
email: attendee.email,
displayName: attendee.name,
responseStatus: this.transformResponseStatus(attendee.responseStatus),
organizer: attendee.host,
self: attendee.self,
optional: attendee.optional,
};
});
}
private transformResponseStatus(responseStatus?: CalendarEventResponseStatus | null): string {
if (!responseStatus) return "needsAction";
switch (responseStatus) {
case CalendarEventResponseStatus.ACCEPTED:
return "accepted";
case CalendarEventResponseStatus.PENDING:
return "tentative";
case CalendarEventResponseStatus.DECLINED:
return "declined";
case CalendarEventResponseStatus.NEEDS_ACTION:
return "needsAction";
default:
return "needsAction";
}
}
private transformEventStatus(status?: CalendarEventStatus | null): string {
if (!status) return "confirmed";
switch (status) {
case CalendarEventStatus.ACCEPTED:
return "confirmed";
case CalendarEventStatus.PENDING:
return "tentative";
case CalendarEventStatus.CANCELLED:
return "cancelled";
case CalendarEventStatus.DECLINED:
return "cancelled";
default:
return "confirmed";
}
}
}
@@ -0,0 +1,222 @@
import {
UpdateUnifiedCalendarEventInput,
UpdateDateTimeWithZone,
} from "../inputs/update-unified-calendar-event.input";
import { CalendarEventResponseStatus, CalendarEventStatus } from "../outputs/get-unified-calendar-event.output";
import { GoogleCalendarEventResponse } from "./get-calendar-event-details-output-pipe";
import { GoogleCalendarEventInputPipe } from "./google-calendar-event-input-pipe";
describe("GoogleCalendarEventInputPipe", () => {
let pipe: GoogleCalendarEventInputPipe;
let sharedGoogleEvent: GoogleCalendarEventResponse;
beforeAll(() => {
sharedGoogleEvent = {
kind: "calendar#event",
etag: "test-etag",
id: "test-event-id",
status: "confirmed",
htmlLink: "https://calendar.google.com/event",
created: "2024-01-01T00:00:00Z",
updated: "2024-01-01T00:00:00Z",
summary: "Test Meeting",
description: "Test description",
start: {
dateTime: "2024-01-15T10:00:00Z",
timeZone: "America/New_York",
},
end: {
dateTime: "2024-01-15T11:00:00Z",
timeZone: "America/New_York",
},
iCalUID: "test-ical-uid",
sequence: 0,
attendees: [
{
email: "attendee1@example.com",
displayName: "Attendee One",
responseStatus: "accepted",
organizer: false,
},
{
email: "attendee2@example.com",
displayName: "Attendee Two",
responseStatus: "needsAction",
organizer: false,
},
{
email: "organizer@example.com",
displayName: "Organizer",
responseStatus: "accepted",
organizer: true,
},
],
creator: {
email: "organizer@example.com",
displayName: "Organizer",
},
organizer: {
email: "organizer@example.com",
displayName: "Organizer",
},
};
});
beforeEach(() => {
pipe = new GoogleCalendarEventInputPipe();
});
describe("transform", () => {
it("should transform basic event fields", () => {
const input: UpdateUnifiedCalendarEventInput = {
title: "Updated Meeting",
description: "Updated description",
start: {
time: "2024-01-15T10:00:00Z",
timeZone: "America/New_York",
},
end: {
time: "2024-01-15T11:00:00Z",
timeZone: "America/New_York",
},
status: CalendarEventStatus.ACCEPTED,
};
const expectedOutput = {
summary: "Updated Meeting",
description: "Updated description",
start: {
dateTime: "2024-01-15T10:00:00Z",
timeZone: "America/New_York",
},
end: {
dateTime: "2024-01-15T11:00:00Z",
timeZone: "America/New_York",
},
status: "confirmed",
};
const result = pipe.transform(input);
expect(result).toEqual(expectedOutput);
});
it("should handle partial updates", () => {
const input: UpdateUnifiedCalendarEventInput = {
title: "Only Title Update",
};
const expectedOutput = {
summary: "Only Title Update",
};
const result = pipe.transform(input);
expect(result).toEqual(expectedOutput);
});
it("should handle null description", () => {
const input: UpdateUnifiedCalendarEventInput = {
description: null,
};
const expectedOutput = {
description: null,
};
const result = pipe.transform(input);
expect(result).toEqual(expectedOutput);
});
});
describe("transformDateTimeWithZone", () => {
it("should transform date time with timezone", () => {
const input: UpdateDateTimeWithZone = {
time: "2024-01-15T10:00:00Z",
timeZone: "America/New_York",
};
const expectedOutput = {
dateTime: "2024-01-15T10:00:00Z",
timeZone: "America/New_York",
};
const result = pipe["transformDateTimeWithZone"](input);
expect(result).toEqual(expectedOutput);
});
it("should handle empty time and timezone", () => {
const input: UpdateDateTimeWithZone = {};
const expectedOutput = {
dateTime: "",
timeZone: "",
};
const result = pipe["transformDateTimeWithZone"](input);
expect(result).toEqual(expectedOutput);
});
});
describe("transformResponseStatus", () => {
it("should transform ACCEPTED status", () => {
const result = pipe["transformResponseStatus"](CalendarEventResponseStatus.ACCEPTED);
expect(result).toBe("accepted");
});
it("should transform PENDING status", () => {
const result = pipe["transformResponseStatus"](CalendarEventResponseStatus.PENDING);
expect(result).toBe("tentative");
});
it("should transform DECLINED status", () => {
const result = pipe["transformResponseStatus"](CalendarEventResponseStatus.DECLINED);
expect(result).toBe("declined");
});
it("should transform NEEDS_ACTION status", () => {
const result = pipe["transformResponseStatus"](CalendarEventResponseStatus.NEEDS_ACTION);
expect(result).toBe("needsAction");
});
it("should handle null status", () => {
const result = pipe["transformResponseStatus"](null);
expect(result).toBe("needsAction");
});
it("should handle undefined status", () => {
const result = pipe["transformResponseStatus"](undefined);
expect(result).toBe("needsAction");
});
});
describe("transformEventStatus", () => {
it("should transform ACCEPTED status", () => {
const result = pipe["transformEventStatus"](CalendarEventStatus.ACCEPTED);
expect(result).toBe("confirmed");
});
it("should transform PENDING status", () => {
const result = pipe["transformEventStatus"](CalendarEventStatus.PENDING);
expect(result).toBe("tentative");
});
it("should transform CANCELLED status", () => {
const result = pipe["transformEventStatus"](CalendarEventStatus.CANCELLED);
expect(result).toBe("cancelled");
});
it("should transform DECLINED status", () => {
const result = pipe["transformEventStatus"](CalendarEventStatus.DECLINED);
expect(result).toBe("cancelled");
});
it("should handle null status", () => {
const result = pipe["transformEventStatus"](null);
expect(result).toBe("confirmed");
});
it("should handle undefined status", () => {
const result = pipe["transformEventStatus"](undefined);
expect(result).toBe("confirmed");
});
});
});
@@ -10,7 +10,8 @@ import { JWT } from "googleapis-common";
import { DelegationCredentialRepository, OAuth2UniversalSchema } from "@calcom/platform-libraries/app-store";
import { UnifiedCalendarEventOutput } from "../outputs/get-unified-calendar-event";
import { UpdateUnifiedCalendarEventInput } from "../inputs/update-unified-calendar-event.input";
import { GoogleCalendarEventInputPipe } from "../pipes/google-calendar-event-input-pipe";
@Injectable()
export class GoogleCalendarService {
@@ -53,6 +54,44 @@ export class GoogleCalendarService {
}
}
async updateEventDetails(
eventUid: string,
updateData: UpdateUnifiedCalendarEventInput
): Promise<GoogleCalendarEventResponse> {
const bookingReference =
await this.bookingReferencesRepository.getBookingReferencesIncludeSensitiveCredentials(eventUid);
if (!bookingReference) {
throw new NotFoundException("Booking reference not found");
}
const ownerUserEmail = bookingReference?.booking?.user?.email;
const calendar = await this.getAuthorizedCalendarInstance(
ownerUserEmail,
bookingReference.credential?.key,
bookingReference.delegationCredential
);
const updatePayload = new GoogleCalendarEventInputPipe().transform(updateData);
try {
const event = await calendar.events.patch({
calendarId: bookingReference?.externalCalendarId ?? "primary",
eventId: bookingReference?.uid,
requestBody: updatePayload,
});
if (!event.data) {
throw new NotFoundException("Failed to update meeting");
}
return event.data as GoogleCalendarEventResponse;
} catch (error) {
throw new NotFoundException("Failed to update meeting details");
}
}
/**
* Gets an authorized Google Calendar instance
* Tries delegation credentials first, falls back to direct OAuth
@@ -11,6 +11,7 @@ import { OrganizationsTeamsBookingsModule } from "@/modules/organizations/teams/
import { OrganizationsUsersBookingsModule } from "@/modules/organizations/users/bookings/organizations-users-bookings.module";
import { RouterModule } from "@/modules/router/router.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { TeamsSchedulesModule } from "@/modules/teams/schedules/teams-schedules.module";
import { TimezoneModule } from "@/modules/timezones/timezones.module";
import { VerifiedResourcesModule } from "@/modules/verified-resources/verified-resources.module";
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
@@ -38,6 +39,7 @@ import { WebhooksModule } from "./webhooks/webhooks.module";
OrganizationsRoutingFormsModule,
VerifiedResourcesModule,
RouterModule,
TeamsSchedulesModule,
],
})
export class EndpointsModule implements NestModule {
@@ -40,5 +40,3 @@ export class EventTypeOwnershipGuard implements CanActivate {
return true;
}
}
@@ -0,0 +1,551 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { OrganizationsTeamsBookingsModule } from "@/modules/organizations/teams/bookings/organizations-teams-bookings.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture";
import { ManagedOrganizationsRepositoryFixture } from "test/fixtures/repository/managed-organizations.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { randomString } from "@calcom/lib/random";
import {
CAL_API_VERSION_HEADER,
SUCCESS_STATUS,
VERSION_2024_08_13,
X_CAL_CLIENT_ID,
X_CAL_SECRET_KEY,
} from "@calcom/platform-constants";
import {
BookingOutput_2024_08_13,
RecurringBookingOutput_2024_08_13,
GetBookingsOutput_2024_08_13,
GetSeatedBookingOutput_2024_08_13,
} from "@calcom/platform-types";
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Organizations Bookings Endpoints 2024-08-13", () => {
describe("Manager and managed organizations bookings", () => {
let app: INestApplication;
let managerOrganization: Team;
let managedOrganization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let oAuthClient: PlatformOAuthClient;
let teamRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let hostsRepositoryFixture: HostsRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
let managedOrganizationsRepositoryFixture: ManagedOrganizationsRepositoryFixture;
const managerOrgUserEmail = "manager-org-user-1-bookings@api.com";
const managerOrgUserEmail2 = "manager-org-user-2-bookings@api.com";
const managedOrgUserEmail = "managed-org-user-1-bookings@api.com";
const nonOrgUserEmail1 = "non-org-user-1-bookings@api.com";
let managerOrgUser1: User;
let managerOrgUser2: User;
let managedOrgUser: User;
let nonOrgUser1: User;
let managerOrgTeam1: Team;
let managedOrgTeam1: Team;
let managerOrgEventTypeId: number;
let managedOrgEventTypeId: number;
let nonOrgEventTypeId: number;
beforeAll(async () => {
const moduleRef = await withApiAuth(
managerOrgUserEmail,
Test.createTestingModule({
imports: [AppModule, OrganizationsTeamsBookingsModule],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.overrideGuard(PlatformPlanGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
managedOrganizationsRepositoryFixture = new ManagedOrganizationsRepositoryFixture(moduleRef);
await setupManagerOrganization();
await setupManagedOrganization();
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
async function setupManagerOrganization() {
managerOrganization = await organizationsRepositoryFixture.create({
name: "organization bookings",
isPlatform: true,
});
managerOrgTeam1 = await teamRepositoryFixture.create({
name: "team orgs booking 1",
isOrganization: false,
parent: { connect: { id: managerOrganization.id } },
});
oAuthClient = await createOAuthClient(managerOrganization.id);
nonOrgUser1 = await userRepositoryFixture.create({
email: nonOrgUserEmail1,
locale: "it",
name: "NonOrgUser1Bookings",
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
managerOrgUser1 = await userRepositoryFixture.create({
email: managerOrgUserEmail,
locale: "it",
name: "orgUser1Bookings",
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
managerOrgUser2 = await userRepositoryFixture.create({
email: managerOrgUserEmail2,
locale: "it",
name: "orgUser2Bookings",
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(managerOrgUser1.id, userSchedule);
await schedulesService.createUserSchedule(nonOrgUser1.id, userSchedule);
await schedulesService.createUserSchedule(managerOrgUser2.id, userSchedule);
const orgEventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: {
connect: { id: managerOrgTeam1.id },
},
title: "Collective Event Type",
slug: "manager-org-bookings-collective-event-type",
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
await profileRepositoryFixture.create({
uid: `usr-${managerOrgUser1.id}`,
username: managerOrgUserEmail,
organization: {
connect: {
id: managerOrganization.id,
},
},
user: {
connect: {
id: managerOrgUser1.id,
},
},
});
await profileRepositoryFixture.create({
uid: `usr-${managerOrgUser2.id}`,
username: managerOrgUserEmail2,
organization: {
connect: {
id: managerOrganization.id,
},
},
user: {
connect: {
id: managerOrgUser2.id,
},
},
});
await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: managerOrgUser1.id } },
team: { connect: { id: managerOrganization.id } },
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: managerOrgUser2.id } },
team: { connect: { id: managerOrganization.id } },
accepted: true,
});
const nonOrgEventType = await eventTypesRepositoryFixture.create(
{
title: "Non Org Event Type",
slug: "non-org-event-type",
length: 60,
bookingFields: [],
locations: [],
},
nonOrgUser1.id
);
managerOrgEventTypeId = orgEventType.id;
nonOrgEventTypeId = nonOrgEventType.id;
await hostsRepositoryFixture.create({
isFixed: true,
user: {
connect: {
id: managerOrgUser2.id,
},
},
eventType: {
connect: {
id: managerOrgEventTypeId,
},
},
});
await bookingsRepositoryFixture.create({
user: {
connect: {
id: managerOrgUser2.id,
},
},
startTime: new Date(Date.UTC(2030, 0, 9, 13, 0, 0)),
endTime: new Date(Date.UTC(2030, 0, 9, 14, 0, 0)),
title: "Manager Org Collective Booking",
uid: `manager-org-collective-${randomString()}`,
eventType: {
connect: {
id: managerOrgEventTypeId,
},
},
location: "https://meet.google.com/abc-def-ghi",
customInputs: {},
metadata: {},
responses: {
name: "alice",
email: "alice@gmail.com",
},
attendees: {
create: {
email: "alice@gmail.com",
name: "alice",
locale: "es",
timeZone: "Europe/Madrid",
},
},
});
await bookingsRepositoryFixture.create({
user: {
connect: {
id: nonOrgUser1.id,
},
},
startTime: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)),
endTime: new Date(Date.UTC(2030, 0, 8, 14, 0, 0)),
title: "Non-Org Booking 1",
uid: `non-org-booking-1-${randomString()}`,
eventType: {
connect: {
id: nonOrgEventTypeId,
},
},
location: "https://meet.google.com/jkl-mno-pqr",
customInputs: {},
metadata: {},
responses: {
name: managerOrgUser2.name ?? "",
email: managerOrgUserEmail2,
},
attendees: {
create: {
email: managerOrgUserEmail2,
name: managerOrgUser2.name ?? "",
locale: "en",
timeZone: managerOrgUser2.timeZone ?? "Europe/Madrid",
},
},
});
}
async function setupManagedOrganization() {
managedOrganization = await organizationsRepositoryFixture.create({
name: "organization bookings",
isPlatform: true,
});
await managedOrganizationsRepositoryFixture.createManagedOrganization(
managerOrganization.id,
managedOrganization.id
);
managedOrgTeam1 = await teamRepositoryFixture.create({
name: "managed org team 1",
isOrganization: false,
parent: { connect: { id: managedOrganization.id } },
});
managedOrgUser = await userRepositoryFixture.create({
email: managedOrgUserEmail,
locale: "it",
name: "ManagedOrgUser1Bookings",
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
await profileRepositoryFixture.create({
uid: `usr-${managedOrgUser.id}`,
username: managedOrgUserEmail,
organization: {
connect: {
id: managedOrganization.id,
},
},
user: {
connect: {
id: managedOrgUser.id,
},
},
});
await profileRepositoryFixture.create({
uid: `usr-${managerOrgUser1.id}`,
username: managerOrgUserEmail,
organization: {
connect: {
id: managedOrganization.id,
},
},
user: {
connect: {
id: managerOrgUser1.id,
},
},
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: managedOrgUser.id } },
team: { connect: { id: managedOrganization.id } },
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: managerOrgUser1.id } },
team: { connect: { id: managedOrganization.id } },
accepted: true,
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(managedOrgUser.id, userSchedule);
const managedOrgEventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: {
connect: { id: managedOrgTeam1.id },
},
title: "Collective Event Type",
slug: "managed-org-bookings-collective-event-type",
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
managedOrgEventTypeId = managedOrgEventType.id;
await hostsRepositoryFixture.create({
isFixed: true,
user: {
connect: {
id: managedOrgUser.id,
},
},
eventType: {
connect: {
id: managedOrgEventType.id,
},
},
});
await bookingsRepositoryFixture.create({
user: {
connect: {
id: managedOrgUser.id,
},
},
startTime: new Date(Date.UTC(2030, 0, 11, 13, 0, 0)),
endTime: new Date(Date.UTC(2030, 0, 11, 14, 0, 0)),
title: "Managed Org Booking",
uid: `managed-org-booking-${randomString()}`,
eventType: {
connect: {
id: managedOrgEventTypeId,
},
},
location: "https://meet.google.com/ghi-jkl-mno",
customInputs: {},
metadata: {},
responses: {
name: "charlie",
email: "charlie@gmail.com",
},
attendees: {
create: {
email: "charlie@gmail.com",
name: "charlie",
locale: "en",
timeZone: "Europe/Madrid",
},
},
});
}
describe("get manager organization bookings", () => {
it("should get bookings by organizationId for manager organization", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${managerOrganization.id}/bookings`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(2);
expect(data.map((booking) => booking.eventTypeId).sort()).toEqual(
[managerOrgEventTypeId, nonOrgEventTypeId].sort()
);
});
});
it("should get bookings by organizationId and userId", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${managerOrganization.id}/bookings?userIds=${managerOrgUser2.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(2);
expect(data.map((booking) => booking.eventTypeId).sort()).toEqual(
[managerOrgEventTypeId, nonOrgEventTypeId].sort()
);
});
});
});
describe("get managed organization bookings", () => {
it("should get bookings by organizationId for managed organization", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${managedOrganization.id}/bookings`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(1);
expect(data[0].eventTypeId).toEqual(managedOrgEventTypeId);
});
});
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
areCalendarEventsEnabled: false,
areEmailsEnabled: false,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(managerOrganization.id);
await userRepositoryFixture.deleteByEmail(managerOrgUser1.email);
await userRepositoryFixture.deleteByEmail(nonOrgUser1.email);
await bookingsRepositoryFixture.deleteAllBookings(managerOrgUser1.id, managerOrgUser1.email);
await bookingsRepositoryFixture.deleteAllBookings(nonOrgUser1.id, nonOrgUser1.email);
await app.close();
});
});
});
@@ -66,6 +66,8 @@ describe("Organizations Bookings Endpoints 2024-08-13", () => {
let orgEventTypeId2: number;
let nonOrgEventTypeId: number;
let collectiveOrgBookingUid: string;
beforeAll(async () => {
const moduleRef = await withApiAuth(
orgUserEmail2,
@@ -91,12 +93,17 @@ describe("Organizations Bookings Endpoints 2024-08-13", () => {
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await organizationsRepositoryFixture.create({ name: "organization bookings" });
oAuthClient = await createOAuthClient(organization.id);
team1 = await teamRepositoryFixture.create({
name: "team orgs booking 1",
isOrganization: false,
parent: { connect: { id: organization.id } },
createdByOAuthClient: {
connect: {
id: oAuthClient.id,
},
},
});
oAuthClient = await createOAuthClient(organization.id);
nonOrgUser1 = await userRepositoryFixture.create({
email: nonOrgUserEmail1,
@@ -315,6 +322,7 @@ describe("Organizations Bookings Endpoints 2024-08-13", () => {
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
collectiveOrgBookingUid = data.uid;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts.length).toEqual(1);
@@ -669,6 +677,125 @@ describe("Organizations Bookings Endpoints 2024-08-13", () => {
);
});
});
describe("get by bookingUid param", () => {
it("should get a specific booking by bookingUid query param", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${organization.id}/bookings?bookingUid=${collectiveOrgBookingUid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(1);
expect(responseDataIsBooking(data[0])).toBe(true);
if (responseDataIsBooking(data[0])) {
const booking: BookingOutput_2024_08_13 = data[0];
expect(booking.uid).toEqual(collectiveOrgBookingUid);
expect(booking.eventTypeId).toEqual(orgEventTypeId);
expect(booking.status).toEqual("accepted");
expect(booking.attendees.length).toEqual(2);
expect(booking.attendees[0].name).toEqual("alice");
expect(booking.attendees[0].email).toEqual("alice@gmail.com");
} else {
throw new Error("Expected single booking but received different response type");
}
});
});
it("should return empty array for non-existent booking UID query param", async () => {
const nonExistentUid = "non-existent-booking-uid";
return request(app.getHttpServer())
.get(`/v2/organizations/${organization.id}/bookings?bookingUid=${nonExistentUid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.length).toEqual(0);
});
});
it("should return empty array for booking UID not belonging to the organization", async () => {
const regularUser = await userRepositoryFixture.create({
email: `org-bookings-regular-user-${Math.floor(Math.random() * 10000)}@api.com`,
name: "Regular User",
});
const regularUserEventType = await eventTypesRepositoryFixture.create(
{
title: `regular-user-event-type-${Math.floor(Math.random() * 10000)}`,
slug: `regular-user-event-type-${Math.floor(Math.random() * 10000)}`,
length: 60,
bookingFields: [],
locations: [],
},
regularUser.id
);
const regularUserBooking = await bookingsRepositoryFixture.create({
user: {
connect: {
id: regularUser.id,
},
},
startTime: new Date(Date.UTC(2030, 0, 15, 13, 0, 0)),
endTime: new Date(Date.UTC(2030, 0, 15, 14, 0, 0)),
title: "Regular user booking",
uid: `regular-user-booking-${Math.floor(Math.random() * 10000)}`,
eventType: {
connect: {
id: regularUserEventType.id,
},
},
location: "https://meet.google.com/regular-user",
customInputs: {},
metadata: {},
status: "ACCEPTED",
responses: {
name: "Regular Attendee",
email: "regular@example.com",
},
attendees: {
create: {
email: "regular@example.com",
name: "Regular Attendee",
locale: "en",
timeZone: "Europe/Rome",
},
},
});
return request(app.getHttpServer())
.get(`/v2/organizations/${organization.id}/bookings?bookingUid=${regularUserBooking.uid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.length).toEqual(0);
await userRepositoryFixture.delete(regularUser.id);
});
});
});
});
async function createOAuthClient(organizationId: number) {
@@ -0,0 +1,183 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import * as request from "supertest";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { CreatePrivateLinkInput } from "@calcom/platform-types";
describe("Organizations / Teams / Event Types / Private Links Endpoints", () => {
let app: INestApplication;
let orgFixture: OrganizationRepositoryFixture;
let teamFixture: TeamRepositoryFixture;
let userFixture: UserRepositoryFixture;
let eventTypesFixture: EventTypesRepositoryFixture;
let org: any;
let team: any;
let user: any;
let eventType: any;
const userEmail = `org-private-links-user-${randomString()}@api.com`;
beforeAll(async () => {
const testingModuleBuilder = withApiAuth(
userEmail,
Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter],
imports: [AppModule, UsersModule, TokensModule],
})
)
// Bypass org admin plan and admin API checks and roles in this e2e
.overrideGuard(PlatformPlanGuard)
.useValue({ canActivate: () => true })
.overrideGuard(IsAdminAPIEnabledGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
// Keep IsOrgGuard and IsTeamInOrg to validate org/team path integrity
.overrideGuard(ApiAuthGuard)
.useValue({ canActivate: () => true });
const moduleRef = await testingModuleBuilder.compile();
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
orgFixture = new OrganizationRepositoryFixture(moduleRef);
teamFixture = new TeamRepositoryFixture(moduleRef);
userFixture = new UserRepositoryFixture(moduleRef);
eventTypesFixture = new EventTypesRepositoryFixture(moduleRef);
user = await userFixture.create({
email: userEmail,
username: `org-private-links-user-${randomString()}`,
name: "Test User",
});
org = await orgFixture.create({
name: `org-private-links-org-${randomString()}`,
slug: `org-private-links-org-${randomString()}`,
isOrganization: true,
});
team = await teamFixture.create({
name: `org-private-links-team-${randomString()}`,
isOrganization: false,
parent: { connect: { id: org.id } },
});
// Create a team-owned event type
eventType = await eventTypesFixture.createTeamEventType({
title: `org-private-links-event-type-${randomString()}`,
slug: `org-private-links-event-type-${randomString()}`,
length: 30,
locations: [],
team: { connect: { id: team.id } },
});
await app.init();
});
it("POST /v2/organizations/:orgId/teams/:teamId/event-types/:eventTypeId/private-links - create", async () => {
const body: CreatePrivateLinkInput = { maxUsageCount: 5 };
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${eventType.id}/private-links`)
.set("Authorization", "Bearer test")
.send(body)
.expect(201);
expect(response.body.status).toBe(SUCCESS_STATUS);
expect(response.body.data.linkId).toBeDefined();
expect(response.body.data.maxUsageCount).toBe(5);
expect(response.body.data.usageCount).toBeDefined();
});
it("GET /v2/organizations/:orgId/teams/:teamId/event-types/:eventTypeId/private-links - list", async () => {
const response = await request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${eventType.id}/private-links`)
.set("Authorization", "Bearer test")
.expect(200);
expect(response.body.status).toBe(SUCCESS_STATUS);
expect(Array.isArray(response.body.data)).toBe(true);
});
it("PATCH /v2/organizations/:orgId/teams/:teamId/event-types/:eventTypeId/private-links/:linkId - update", async () => {
// create first
const createResp = await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${eventType.id}/private-links`)
.set("Authorization", "Bearer test")
.send({ maxUsageCount: 3 })
.expect(201);
const linkId = createResp.body.data.linkId as string;
const response = await request(app.getHttpServer())
.patch(
`/v2/organizations/${org.id}/teams/${team.id}/event-types/${eventType.id}/private-links/${linkId}`
)
.set("Authorization", "Bearer test")
.send({ maxUsageCount: 10 })
.expect(200);
expect(response.body.status).toBe(SUCCESS_STATUS);
expect(response.body.data.maxUsageCount).toBe(10);
});
it("DELETE /v2/organizations/:orgId/teams/:teamId/event-types/:eventTypeId/private-links/:linkId - delete", async () => {
// create first
const createResp = await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${eventType.id}/private-links`)
.set("Authorization", "Bearer test")
.send({ maxUsageCount: 2 })
.expect(201);
const linkId = createResp.body.data.linkId as string;
const response = await request(app.getHttpServer())
.delete(
`/v2/organizations/${org.id}/teams/${team.id}/event-types/${eventType.id}/private-links/${linkId}`
)
.set("Authorization", "Bearer test")
.expect(200);
expect(response.body.status).toBe(SUCCESS_STATUS);
expect(response.body.data.linkId).toBe(linkId);
});
afterAll(async () => {
try {
if (eventType?.id) {
await eventTypesFixture.delete(eventType.id);
}
if (team?.id) {
await teamFixture.delete(team.id);
}
if (org?.id) {
await orgFixture.delete(org.id);
}
if (user?.email) {
await userFixture.deleteByEmail(user.email);
}
} catch {}
await app.close();
});
});
@@ -74,6 +74,7 @@ type Input = Pick<
| "hideOrganizerEmail"
| "team"
| "calVideoSettings"
| "hidden"
>;
@Injectable()
@@ -2,6 +2,8 @@ import { CreateOrgMembershipDto } from "@/modules/organizations/memberships/inpu
import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
import { TeamService } from "@calcom/platform-libraries";
import { UpdateOrgMembershipDto } from "../inputs/update-organization-membership.input";
import { OrganizationsMembershipOutputService } from "./organizations-membership-output.service";
@@ -62,10 +64,24 @@ export class OrganizationsMembershipService {
}
async deleteOrgMembership(organizationId: number, membershipId: number) {
const membership = await this.organizationsMembershipRepository.deleteOrgMembership(
// Get the membership first to get the userId
const membership = await this.organizationsMembershipRepository.findOrgMembership(
organizationId,
membershipId
);
if (!membership) {
throw new NotFoundException(
`Membership with id ${membershipId} within organization id ${organizationId} not found`
);
}
await TeamService.removeMembers({
teamIds: [organizationId],
userIds: [membership.userId],
isOrg: true,
});
return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership);
}
@@ -1,5 +1,9 @@
import { OrganizationsEventTypesPrivateLinksController } from "@/ee/event-types-private-links/controllers/organizations-event-types-private-links.controller";
import { EventTypesPrivateLinksModule } from "@/ee/event-types-private-links/event-types-private-links.module";
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
import { InputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/input-schedules.service";
import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
@@ -18,7 +22,6 @@ import { OrganizationAttributesService } from "@/modules/organizations/attribute
import { OrganizationAttributeOptionRepository } from "@/modules/organizations/attributes/options/organization-attribute-options.repository";
import { OrganizationsAttributesOptionsController } from "@/modules/organizations/attributes/options/organizations-attributes-options.controller";
import { OrganizationAttributeOptionService } from "@/modules/organizations/attributes/options/services/organization-attributes-option.service";
import { OrganizationsConferencingController } from "@/modules/organizations/conferencing/organizations-conferencing.controller";
import { OrganizationsConferencingModule } from "@/modules/organizations/conferencing/organizations-conferencing.module";
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
import { OrganizationsDelegationCredentialModule } from "@/modules/organizations/delegation-credentials/organizations-delegation-credential.module";
@@ -36,7 +39,6 @@ import { OrganizationsMembershipOutputService } from "@/modules/organizations/me
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
import { OrganizationsOrganizationsModule } from "@/modules/organizations/organizations/organizations-organizations.module";
import { OrganizationsSchedulesController } from "@/modules/organizations/schedules/organizations-schedules.controller";
import { OrganizationSchedulesRepository } from "@/modules/organizations/schedules/organizations-schedules.repository";
import { OrganizationsSchedulesService } from "@/modules/organizations/schedules/services/organizations-schedules.service";
import { OrganizationsStripeModule } from "@/modules/organizations/stripe/organizations-stripe.module";
import { OrganizationsStripeService } from "@/modules/organizations/stripe/services/organizations-stripe.service";
@@ -63,6 +65,7 @@ import { RedisModule } from "@/modules/redis/redis.module";
import { RedisService } from "@/modules/redis/redis.service";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
import { TeamsSchedulesService } from "@/modules/teams/schedules/services/teams-schedules.service";
import { TeamsModule } from "@/modules/teams/teams/teams.module";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UsersModule } from "@/modules/users/users.module";
@@ -91,6 +94,7 @@ import { Module } from "@nestjs/common";
OrganizationsStripeModule,
OrganizationsTeamsRoutingFormsModule,
OrganizationsConferencingModule,
EventTypesPrivateLinksModule,
],
providers: [
OrganizationsRepository,
@@ -99,7 +103,6 @@ import { Module } from "@nestjs/common";
OrganizationsTeamsService,
MembershipsRepository,
OrganizationsSchedulesService,
OrganizationSchedulesRepository,
OrganizationsUsersRepository,
OrganizationsUsersService,
EmailService,
@@ -141,6 +144,9 @@ import { Module } from "@nestjs/common";
TeamWorkflowsService,
WorkflowsInputService,
WorkflowsOutputService,
TeamsSchedulesService,
SchedulesService_2024_06_11,
InputSchedulesService_2024_06_11,
],
exports: [
OrganizationsService,
@@ -177,6 +183,7 @@ import { Module } from "@nestjs/common";
OrganizationsTeamsSchedulesController,
OrganizationsUsersOOOController,
OrganizationTeamWorkflowsController,
OrganizationsEventTypesPrivateLinksController,
],
})
export class OrganizationsModule {}
@@ -1,5 +1,5 @@
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { OutputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/output-schedules.service";
import { OrganizationSchedulesRepository } from "@/modules/organizations/schedules/organizations-schedules.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
@@ -8,8 +8,8 @@ import { ScheduleOutput_2024_06_11 } from "@calcom/platform-types";
@Injectable()
export class OrganizationsSchedulesService {
constructor(
private readonly organizationSchedulesService: OrganizationSchedulesRepository,
private readonly outputSchedulesService: OutputSchedulesService_2024_06_11,
private readonly schedulesRepository: SchedulesRepository_2024_06_11,
private readonly usersRepository: UsersRepository
) {}
@@ -17,7 +17,7 @@ export class OrganizationsSchedulesService {
const users = await this.usersRepository.getOrganizationUsers(organizationId);
const usersIds = users.map((user) => user.id);
const schedules = await this.organizationSchedulesService.getSchedulesByUserIds(usersIds, skip, take);
const schedules = await this.schedulesRepository.getSchedulesByUserIds(usersIds, skip, take);
const responseSchedules: ScheduleOutput_2024_06_11[] = [];
@@ -20,7 +20,6 @@ import {
OrgTeamMembershipOutputResponseDto,
} from "@/modules/organizations/teams/memberships/outputs/organization-teams-memberships.output";
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/teams/memberships/services/organizations-teams-memberships.service";
import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service";
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import {
Controller,
@@ -59,7 +58,6 @@ export class OrganizationsTeamsMembershipsController {
constructor(
private organizationsTeamsMembershipsService: OrganizationsTeamsMembershipsService,
private teamsEventTypesService: TeamsEventTypesService,
private readonly organizationsRepository: OrganizationsRepository
) {}
@@ -127,8 +125,6 @@ export class OrganizationsTeamsMembershipsController {
membershipId
);
await this.teamsEventTypesService.deleteUserTeamEventTypesAndHosts(membership.userId, teamId);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
@@ -3,6 +3,8 @@ import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/teams/member
import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
import { TeamService } from "@calcom/platform-libraries";
@Injectable()
export class OrganizationsTeamsMembershipsService {
constructor(
@@ -58,11 +60,21 @@ export class OrganizationsTeamsMembershipsService {
}
async deleteOrgTeamMembership(organizationId: number, teamId: number, membershipId: number) {
const teamMembership = await this.organizationsTeamsMembershipsRepository.deleteOrgTeamMembershipById(
// First get the membership to get the userId
const teamMembership = await this.organizationsTeamsMembershipsRepository.findOrgTeamMembership(
organizationId,
teamId,
membershipId
);
if (!teamMembership) {
throw new NotFoundException(
`Membership with id ${membershipId} not found in team ${teamId} of organization ${organizationId}`
);
}
await TeamService.removeMembers({ teamIds: [teamId], userIds: [teamMembership.userId], isOrg: false });
return teamMembership;
}
}
@@ -14,11 +14,12 @@ import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { IsUserInOrgTeam } from "@/modules/auth/guards/users/is-user-in-org-team.guard";
import { Controller, UseGuards, Get, Param, ParseIntPipe } from "@nestjs/common";
import { TeamsSchedulesService } from "@/modules/teams/schedules/services/teams-schedules.service";
import { Controller, UseGuards, Get, Param, ParseIntPipe, Query } from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { GetSchedulesOutput_2024_06_11 } from "@calcom/platform-types";
import { GetSchedulesOutput_2024_06_11, SkipTakePagination } from "@calcom/platform-types";
@Controller({
path: "/v2/organizations/:orgId/teams/:teamId",
@@ -29,7 +30,31 @@ import { GetSchedulesOutput_2024_06_11 } from "@calcom/platform-types";
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
@ApiHeader(OPTIONAL_API_KEY_HEADER)
export class OrganizationsTeamsSchedulesController {
constructor(private schedulesService: SchedulesService_2024_06_11) {}
constructor(
private schedulesService: SchedulesService_2024_06_11,
private teamsSchedulesService: TeamsSchedulesService
) {}
@UseGuards(IsTeamInOrg)
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@Get("/schedules")
@DocsTags("Orgs / Teams / Schedules")
@ApiOperation({ summary: "Get all team member schedules" })
async getTeamSchedules(
@Param("teamId", ParseIntPipe) teamId: number,
@Query() queryParams: SkipTakePagination
): Promise<GetSchedulesOutput_2024_06_11> {
const { skip, take } = queryParams;
const schedules = await this.teamsSchedulesService.getTeamSchedules(teamId, skip, take);
return {
status: SUCCESS_STATUS,
data: schedules,
};
}
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@@ -18,6 +18,7 @@ import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { GetSchedulesOutput_2024_06_11 } from "@calcom/platform-types";
import { ApiSuccessResponse, ScheduleOutput_2024_06_11 } from "@calcom/platform-types";
import { Team, Schedule } from "@calcom/prisma/client";
@@ -36,6 +37,8 @@ describe("Organizations Teams Schedules Endpoints", () => {
let org: Team;
let orgTeam: Team;
let nonOrgTeam: Team;
let userSchedule: Schedule;
let user2Schedule: Schedule;
const userEmail = `organizations-teams-schedules-admin-${randomString()}@api.com`;
@@ -73,6 +76,16 @@ describe("Organizations Teams Schedules Endpoints", () => {
username: userEmail2,
});
userSchedule = await scheduleRepositoryFixture.create({
user: {
connect: {
id: user.id,
},
},
name: `organizations-teams-schedules-user-schedule-${randomString()}`,
timeZone: "America/New_York",
});
user2Schedule = await scheduleRepositoryFixture.create({
user: {
connect: {
@@ -214,6 +227,32 @@ describe("Organizations Teams Schedules Endpoints", () => {
});
});
it("should get all the schedules of members in a team", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/schedules`)
.expect(200)
.then((response) => {
const responseBody: GetSchedulesOutput_2024_06_11 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(Array.isArray(responseBody.data)).toBe(true);
expect(responseBody.data.length).toEqual(2);
const userOneSchedule = responseBody.data.find((schedule) => schedule.id === userSchedule.id);
const userTwoSchedule = responseBody.data.find((schedule) => schedule.id === user2Schedule.id);
expect(userOneSchedule).toBeDefined();
expect(userTwoSchedule).toBeDefined();
expect(userOneSchedule?.id).toEqual(userSchedule.id);
expect(userOneSchedule?.name).toEqual(userSchedule.name);
expect(userOneSchedule?.timeZone).toEqual(userSchedule.timeZone);
expect(userTwoSchedule?.id).toEqual(user2Schedule.id);
expect(userTwoSchedule?.name).toEqual(user2Schedule.name);
expect(userOneSchedule?.timeZone).toEqual(user2Schedule.timeZone);
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await userRepositoryFixture.deleteByEmail(userToInviteViaApi.email);
@@ -12,7 +12,12 @@ import {
EMAIL,
WorkflowEmailAttendeeStepDto,
} from "@/modules/workflows/inputs/workflow-step.input";
import { BEFORE_EVENT, DAY } from "@/modules/workflows/inputs/workflow-trigger.input";
import {
BEFORE_EVENT,
DAY,
OnAfterEventTriggerDto,
OnBeforeEventTriggerDto,
} from "@/modules/workflows/inputs/workflow-trigger.input";
// Adjust path if needed
import { GetWorkflowOutput, GetWorkflowsOutput } from "@/modules/workflows/outputs/workflow.output";
import { INestApplication } from "@nestjs/common";
@@ -255,6 +260,10 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
"+37255555555"
);
expect(responseBody.data.steps.find((step) => step.stepNumber === 3)?.email).toEqual(authEmail);
const trigger = sampleCreateWorkflowDto.trigger as OnBeforeEventTriggerDto;
expect(responseBody.data.trigger?.offset?.value).toEqual(trigger.offset.value);
expect(responseBody.data.trigger?.offset?.unit).toEqual(trigger.offset.unit);
createdWorkflowId = responseBody.data.id;
createdWorkflow = responseBody.data;
});
@@ -330,6 +339,13 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
expect(step1).toBeDefined();
const partialUpdateDto: Partial<CreateWorkflowDto> = {
name: updatedName,
trigger: {
type: "afterEvent",
offset: {
unit: "minute",
value: 10,
},
},
steps: step1 ? [{ ...step1, sender: "updatedSender" } as WorkflowEmailAttendeeStepDto] : [],
};
expect(createdWorkflowId).toBeDefined();
@@ -348,6 +364,10 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
step1 && expect(responseBody.data.steps[0].id).toEqual(step1.id);
expect(responseBody.data.steps[0].sender).toEqual("updatedSender");
expect(responseBody.data.steps[1]?.id).toBeUndefined();
const trigger = partialUpdateDto.trigger as OnAfterEventTriggerDto;
expect(responseBody.data.trigger?.type).toEqual(trigger.type);
expect(responseBody.data.trigger?.offset?.value).toEqual(trigger.offset.value);
expect(responseBody.data.trigger?.offset?.unit).toEqual(trigger.offset.unit);
});
});
@@ -156,17 +156,4 @@ export class TeamsEventTypesService {
return this.eventTypesRepository.deleteEventType(eventTypeId);
}
async deleteUserTeamEventTypesAndHosts(userId: number, teamId: number) {
try {
await this.teamsEventTypesRepository.deleteUserManagedTeamEventTypes(userId, teamId);
await this.teamsEventTypesRepository.removeUserFromTeamEventTypesHosts(userId, teamId);
} catch (err) {
this.logger.error("Could not remove user from all team event-types.", {
error: err,
userId,
teamId,
});
}
}
}
@@ -3,7 +3,6 @@ import { API_KEY_HEADER } from "@/lib/docs/headers";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service";
import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/create-team-membership.input";
import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input";
import { CreateTeamMembershipOutput } from "@/modules/teams/memberships/outputs/create-team-membership.output";
@@ -45,10 +44,7 @@ import { SkipTakePagination } from "@calcom/platform-types";
export class TeamsMembershipsController {
private logger = new Logger("TeamsMembershipsController");
constructor(
private teamsMembershipsService: TeamsMembershipsService,
private teamsEventTypesService: TeamsEventTypesService
) {}
constructor(private teamsMembershipsService: TeamsMembershipsService) {}
@Roles("TEAM_ADMIN")
@Post("/")
@@ -127,7 +123,6 @@ export class TeamsMembershipsController {
membershipId,
body
);
if (!currentMembership.accepted && updatedMembership.accepted) {
try {
await updateNewTeamMemberEventTypes(updatedMembership.userId, teamId);
@@ -151,8 +146,6 @@ export class TeamsMembershipsController {
): Promise<DeleteTeamMembershipOutput> {
const membership = await this.teamsMembershipsService.deleteTeamMembership(teamId, membershipId);
await this.teamsEventTypesService.deleteUserTeamEventTypesAndHosts(membership.userId, teamId);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
@@ -3,6 +3,8 @@ import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/up
import { TeamsMembershipsRepository } from "@/modules/teams/memberships/teams-memberships.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
import { TeamService } from "@calcom/platform-libraries";
@Injectable()
export class TeamsMembershipsService {
constructor(private readonly teamsMembershipsRepository: TeamsMembershipsRepository) {}
@@ -41,10 +43,15 @@ export class TeamsMembershipsService {
}
async deleteTeamMembership(teamId: number, membershipId: number) {
const teamMembership = await this.teamsMembershipsRepository.deleteTeamMembershipById(
teamId,
membershipId
);
// First get the membership to get the userId
const teamMembership = await this.teamsMembershipsRepository.findTeamMembership(teamId, membershipId);
if (!teamMembership) {
throw new NotFoundException(`Membership with id ${membershipId} not found in team ${teamId}`);
}
await TeamService.removeMembers({ teamIds: [teamId], userIds: [teamMembership.userId], isOrg: false });
return teamMembership;
}
}
@@ -0,0 +1,41 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { TeamsSchedulesService } from "@/modules/teams/schedules/services/teams-schedules.service";
import { Controller, UseGuards, Get, Param, ParseIntPipe, Query } from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { GetSchedulesOutput_2024_06_11, SkipTakePagination } from "@calcom/platform-types";
@Controller({
path: "/v2/teams/:teamId/schedules",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, RolesGuard)
@DocsTags("Teams / Schedules")
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
export class TeamsSchedulesController {
constructor(private teamsSchedulesService: TeamsSchedulesService) {}
@Roles("TEAM_ADMIN")
@Get("/")
@ApiOperation({
summary: "Get all team member schedules",
})
async getTeamSchedules(
@Param("teamId", ParseIntPipe) teamId: number,
@Query() queryParams: SkipTakePagination
): Promise<GetSchedulesOutput_2024_06_11> {
const { skip, take } = queryParams;
const schedules = await this.teamsSchedulesService.getTeamSchedules(teamId, skip, take);
return {
status: SUCCESS_STATUS,
data: schedules,
};
}
}
@@ -0,0 +1,143 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { GetSchedulesOutput_2024_06_11 } from "@calcom/platform-types";
import { Team, Schedule } from "@calcom/prisma/client";
describe("Teams Schedules Endpoints", () => {
describe("User Authentication - User is Team Admin", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let scheduleRepositoryFixture: SchedulesRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let nonOrgTeam: Team;
let userSchedule: Schedule;
let user2Schedule: Schedule;
const userEmail = `teams-schedules-admin-${randomString()}@api.com`;
const userEmail2 = `teams-schedules-member-${randomString()}@api.com`;
let user: User;
let user2: User;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
scheduleRepositoryFixture = new SchedulesRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});
user2 = await userRepositoryFixture.create({
email: userEmail2,
username: userEmail2,
});
userSchedule = await scheduleRepositoryFixture.create({
user: {
connect: {
id: user.id,
},
},
name: `teams-schedules-user-schedule-${randomString()}`,
timeZone: "America/New_York",
});
user2Schedule = await scheduleRepositoryFixture.create({
user: {
connect: {
id: user2.id,
},
},
name: `teams-schedules-user2-schedule-${randomString()}`,
timeZone: "America/New_York",
});
nonOrgTeam = await teamsRepositoryFixture.create({
name: `teams-schedules-non-org-team-${randomString()}`,
isOrganization: false,
});
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: user.id } },
team: { connect: { id: nonOrgTeam.id } },
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: user2.id } },
team: { connect: { id: nonOrgTeam.id } },
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should get all the schedules of members in a team", async () => {
return request(app.getHttpServer())
.get(`/v2/teams/${nonOrgTeam.id}/schedules`)
.expect(200)
.then((response) => {
const responseBody: GetSchedulesOutput_2024_06_11 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(Array.isArray(responseBody.data)).toBe(true);
expect(responseBody.data.length).toEqual(2);
const userOneSchedule = responseBody.data.find((schedule) => schedule.id === userSchedule.id);
const userTwoSchedule = responseBody.data.find((schedule) => schedule.id === user2Schedule.id);
expect(userOneSchedule).toBeDefined();
expect(userTwoSchedule).toBeDefined();
expect(userOneSchedule?.id).toEqual(userSchedule.id);
expect(userOneSchedule?.name).toEqual(userSchedule.name);
expect(userOneSchedule?.timeZone).toEqual(userSchedule.timeZone);
expect(userTwoSchedule?.id).toEqual(user2Schedule.id);
expect(userTwoSchedule?.name).toEqual(user2Schedule.name);
expect(userOneSchedule?.timeZone).toEqual(user2Schedule.timeZone);
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await userRepositoryFixture.deleteByEmail(user2.email);
await teamsRepositoryFixture.delete(nonOrgTeam.id);
await app.close();
});
});
});
@@ -0,0 +1,28 @@
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { OutputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/output-schedules.service";
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
import { Injectable } from "@nestjs/common";
import { ScheduleOutput_2024_06_11 } from "@calcom/platform-types";
@Injectable()
export class TeamsSchedulesService {
constructor(
private readonly teamsRepository: TeamsRepository,
private readonly schedulesRepository: SchedulesRepository_2024_06_11,
private readonly outputSchedulesService: OutputSchedulesService_2024_06_11
) {}
async getTeamSchedules(teamId: number, skip = 0, take = 250) {
const userIds = await this.teamsRepository.getTeamUsersIds(teamId);
const schedules = await this.schedulesRepository.getSchedulesByUserIds(userIds, skip, take);
const responseSchedules: ScheduleOutput_2024_06_11[] = [];
for (const schedule of schedules) {
responseSchedules.push(await this.outputSchedulesService.getResponseSchedule(schedule));
}
return responseSchedules;
}
}
@@ -0,0 +1,43 @@
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { OutputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/output-schedules.service";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { OrganizationSchedulesRepository } from "@/modules/organizations/schedules/organizations-schedules.repository";
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { StripeService } from "@/modules/stripe/stripe.service";
import { TeamsMembershipsService } from "@/modules/teams/memberships/services/teams-memberships.service";
import { TeamsMembershipsRepository } from "@/modules/teams/memberships/teams-memberships.repository";
import { TeamsSchedulesController } from "@/modules/teams/schedules/controllers/teams-schedules.controller";
import { TeamsSchedulesService } from "@/modules/teams/schedules/services/teams-schedules.service";
import { TeamsController } from "@/modules/teams/teams/controllers/teams.controller";
import { TeamsService } from "@/modules/teams/teams/services/teams.service";
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, MembershipsModule, RedisModule],
providers: [
TeamsRepository,
TeamsService,
TeamsMembershipsRepository,
TeamsMembershipsService,
OutputSchedulesService_2024_06_11,
OrganizationSchedulesRepository,
StripeService,
UsersRepository,
AppsRepository,
CredentialsRepository,
OrganizationsRepository,
TeamsSchedulesService,
OrganizationsTeamsRepository,
SchedulesRepository_2024_06_11,
],
controllers: [TeamsController, TeamsSchedulesController],
exports: [TeamsRepository],
})
export class TeamsSchedulesModule {}
@@ -1,7 +1,7 @@
import { ApiProperty } from "@nestjs/swagger";
import { TimeUnit, WorkflowTriggerEvents } from "@prisma/client";
import { Type } from "class-transformer";
import { IsNumber, ValidateNested } from "class-validator";
import { IsIn, IsNumber, IsString, ValidateNested } from "class-validator";
export const BEFORE_EVENT = "beforeEvent";
export const EVENT_CANCELLED = "eventCancelled";
@@ -68,6 +68,8 @@ export class WorkflowTriggerOffsetDto {
value!: number;
@ApiProperty({ description: "Unit for the offset time", example: HOUR })
@IsString()
@IsIn(TIME_UNITS)
unit!: TimeUnitType;
}
@@ -75,6 +77,8 @@ export class BaseWorkflowTriggerDto {
@ApiProperty({
description: "Trigger type for the workflow",
})
@IsString()
@IsIn([WORKFLOW_TRIGGER_TYPES])
type!: WorkflowTriggerType;
}
@@ -82,6 +86,8 @@ export class OnCreationTriggerDto {
@ApiProperty({
description: "Trigger type for the workflow",
})
@IsString()
@IsIn([NEW_EVENT])
type: typeof NEW_EVENT = NEW_EVENT;
}
@@ -89,12 +95,16 @@ export class OnRescheduleTriggerDto {
@ApiProperty({
description: "Trigger type for the workflow",
})
@IsString()
@IsIn([RESCHEDULE_EVENT])
type: typeof RESCHEDULE_EVENT = RESCHEDULE_EVENT;
}
export class OnCancelTriggerDto {
@ApiProperty({
description: "Trigger type for the workflow",
})
@IsString()
@IsIn([EVENT_CANCELLED])
type: typeof EVENT_CANCELLED = EVENT_CANCELLED;
}
@@ -113,6 +123,8 @@ export class OnBeforeEventTriggerDto extends TriggerOffsetDTO {
description: "Trigger type for the workflow",
example: BEFORE_EVENT,
})
@IsString()
@IsIn([BEFORE_EVENT])
type: typeof BEFORE_EVENT = BEFORE_EVENT;
}
@@ -121,6 +133,8 @@ export class OnAfterEventTriggerDto extends TriggerOffsetDTO {
description: "Trigger type for the workflow",
example: AFTER_EVENT,
})
@IsString()
@IsIn([AFTER_EVENT])
type: typeof AFTER_EVENT = AFTER_EVENT;
}
@@ -129,6 +143,8 @@ export class OnAfterCalVideoGuestsNoShowTriggerDto extends TriggerOffsetDTO {
description: "Trigger type for the workflow",
example: AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
})
@IsString()
@IsIn([AFTER_GUESTS_CAL_VIDEO_NO_SHOW])
type: typeof AFTER_GUESTS_CAL_VIDEO_NO_SHOW = AFTER_GUESTS_CAL_VIDEO_NO_SHOW;
}
@@ -137,5 +153,7 @@ export class OnAfterCalVideoHostsNoShowTriggerDto extends TriggerOffsetDTO {
description: "Trigger type for the workflow",
example: AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
})
@IsString()
@IsIn([AFTER_HOSTS_CAL_VIDEO_NO_SHOW])
type: typeof AFTER_HOSTS_CAL_VIDEO_NO_SHOW = AFTER_HOSTS_CAL_VIDEO_NO_SHOW;
}
+690 -225
View File
@@ -4384,6 +4384,292 @@
]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/event-types/{eventTypeId}/private-links": {
"post": {
"operationId": "OrganizationsEventTypesPrivateLinksController_createPrivateLink",
"summary": "Create a private link for a team event type",
"parameters": [
{
"name": "Authorization",
"in": "header",
"description": "For non-platform customers - value must be `Bearer <token>` where `<token>` is api key prefixed with cal_",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-secret-key",
"in": "header",
"description": "For platform customers - OAuth client secret key",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-client-id",
"in": "header",
"description": "For platform customers - OAuth client ID",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "teamId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreatePrivateLinkInput"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreatePrivateLinkOutput"
}
}
}
}
},
"tags": [
"Orgs / Teams / Event Types / Private Links"
]
},
"get": {
"operationId": "OrganizationsEventTypesPrivateLinksController_getPrivateLinks",
"summary": "Get all private links for a team event type",
"parameters": [
{
"name": "Authorization",
"in": "header",
"description": "For non-platform customers - value must be `Bearer <token>` where `<token>` is api key prefixed with cal_",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-secret-key",
"in": "header",
"description": "For platform customers - OAuth client secret key",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-client-id",
"in": "header",
"description": "For platform customers - OAuth client ID",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "teamId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetPrivateLinksOutput"
}
}
}
}
},
"tags": [
"Orgs / Teams / Event Types / Private Links"
]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/event-types/{eventTypeId}/private-links/{linkId}": {
"patch": {
"operationId": "OrganizationsEventTypesPrivateLinksController_updatePrivateLink",
"summary": "Update a private link for a team event type",
"parameters": [
{
"name": "Authorization",
"in": "header",
"description": "For non-platform customers - value must be `Bearer <token>` where `<token>` is api key prefixed with cal_",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-secret-key",
"in": "header",
"description": "For platform customers - OAuth client secret key",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-client-id",
"in": "header",
"description": "For platform customers - OAuth client ID",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "teamId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "linkId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdatePrivateLinkOutput"
}
}
}
}
},
"tags": [
"Orgs / Teams / Event Types / Private Links"
]
},
"delete": {
"operationId": "OrganizationsEventTypesPrivateLinksController_deletePrivateLink",
"summary": "Delete a private link for a team event type",
"parameters": [
{
"name": "Authorization",
"in": "header",
"description": "For non-platform customers - value must be `Bearer <token>` where `<token>` is api key prefixed with cal_",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-secret-key",
"in": "header",
"description": "For platform customers - OAuth client secret key",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-client-id",
"in": "header",
"description": "For platform customers - OAuth client ID",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "teamId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "linkId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeletePrivateLinkOutput"
}
}
}
}
},
"tags": [
"Orgs / Teams / Event Types / Private Links"
]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/memberships": {
"get": {
"operationId": "OrganizationsTeamsMembershipsController_getAllOrgTeamMemberships",
@@ -8563,6 +8849,69 @@
]
}
},
"/v2/calendars/{calendar}/events/{eventUid}": {
"patch": {
"operationId": "CalUnifiedCalendarsController_updateCalendarEvent",
"summary": "Update meeting details in calendar",
"description": "Updates event information in the specified calendar provider",
"parameters": [
{
"name": "calendar",
"required": true,
"in": "path",
"schema": {
"enum": [
"google"
],
"type": "string"
}
},
{
"name": "eventUid",
"required": true,
"in": "path",
"description": "The Google Calendar event ID. You can retrieve this by getting booking references from the following endpoints:\n\n- For team events: https://cal.com/docs/api-reference/v2/orgs-teams-bookings/get-booking-references-for-a-booking\n\n- For user events: https://cal.com/docs/api-reference/v2/bookings/get-booking-references-for-a-booking",
"schema": {
"type": "string"
}
},
{
"name": "Authorization",
"in": "header",
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateUnifiedCalendarEventInput"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetUnifiedCalendarEventOutput"
}
}
}
}
},
"tags": [
"Cal Unified Calendars"
]
}
},
"/v2/calendars/ics-feed/save": {
"post": {
"operationId": "CalendarsController_createIcsFeed",
@@ -23726,6 +24075,213 @@
}
}
},
"CreatePrivateLinkInput": {
"type": "object",
"properties": {
"expiresAt": {
"type": "string",
"description": "Expiration date for time-based links",
"format": "date-time",
"example": "2024-12-31T23:59:59.000Z"
},
"maxUsageCount": {
"type": "number",
"description": "Maximum number of times the link can be used. If omitted and expiresAt is not provided, defaults to 1 (one time use).",
"example": 10,
"minimum": 1,
"default": 1
}
}
},
"TimeBasedPrivateLinkOutput": {
"type": "object",
"properties": {
"linkId": {
"type": "string",
"description": "The private link ID",
"example": "abc123def456"
},
"eventTypeId": {
"type": "number",
"description": "Event type ID this link belongs to",
"example": 123
},
"isExpired": {
"type": "boolean",
"description": "Whether the link is currently expired",
"example": false
},
"bookingUrl": {
"type": "string",
"description": "Full booking URL for this private link",
"format": "uri",
"example": "https://cal.com/d/abc123def456/30min"
},
"expiresAt": {
"type": "string",
"description": "Expiration date for this time-based link",
"format": "date-time",
"example": "2025-12-31T23:59:59.000Z"
}
},
"required": [
"linkId",
"eventTypeId",
"isExpired",
"bookingUrl",
"expiresAt"
]
},
"UsageBasedPrivateLinkOutput": {
"type": "object",
"properties": {
"linkId": {
"type": "string",
"description": "The private link ID",
"example": "abc123def456"
},
"eventTypeId": {
"type": "number",
"description": "Event type ID this link belongs to",
"example": 123
},
"isExpired": {
"type": "boolean",
"description": "Whether the link is currently expired",
"example": false
},
"bookingUrl": {
"type": "string",
"description": "Full booking URL for this private link",
"format": "uri",
"example": "https://cal.com/d/abc123def456/30min"
},
"maxUsageCount": {
"type": "number",
"description": "Maximum number of times this link can be used",
"example": 10
},
"usageCount": {
"type": "number",
"description": "Current usage count for this link",
"example": 3
}
},
"required": [
"linkId",
"eventTypeId",
"isExpired",
"bookingUrl",
"maxUsageCount",
"usageCount"
]
},
"CreatePrivateLinkOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"description": "Created private link data (either time-based or usage-based)",
"oneOf": [
{
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
},
{
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
}
]
}
},
"required": [
"status",
"data"
]
},
"GetPrivateLinksOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"type": "array",
"description": "Array of private links for the event type (mix of time-based and usage-based)",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
},
{
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
}
]
}
}
},
"required": [
"status",
"data"
]
},
"UpdatePrivateLinkOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"description": "Updated private link data (either time-based or usage-based)",
"oneOf": [
{
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
},
{
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
}
]
}
},
"required": [
"status",
"data"
]
},
"DeletePrivateLinkOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"type": "object",
"description": "Deleted link information",
"properties": {
"linkId": {
"type": "string",
"example": "abc123def456"
},
"message": {
"type": "string",
"example": "Private link deleted successfully"
}
}
}
},
"required": [
"status",
"data"
]
},
"StripConnectOutputDto": {
"type": "object",
"properties": {
@@ -25574,6 +26130,23 @@
"data"
]
},
"UpdatePrivateLinkBody": {
"type": "object",
"properties": {
"expiresAt": {
"format": "date-time",
"type": "string",
"description": "New expiration date for time-based links",
"example": "2024-12-31T23:59:59.000Z"
},
"maxUsageCount": {
"type": "number",
"description": "New maximum number of times the link can be used",
"example": 10,
"minimum": 1
}
}
},
"MeOrgOutput": {
"type": "object",
"properties": {
@@ -28219,230 +28792,6 @@
"data"
]
},
"CreatePrivateLinkInput": {
"type": "object",
"properties": {
"expiresAt": {
"type": "string",
"description": "Expiration date for time-based links",
"format": "date-time",
"example": "2024-12-31T23:59:59.000Z"
},
"maxUsageCount": {
"type": "number",
"description": "Maximum number of times the link can be used. If omitted and expiresAt is not provided, defaults to 1 (one time use).",
"example": 10,
"minimum": 1,
"default": 1
}
}
},
"TimeBasedPrivateLinkOutput": {
"type": "object",
"properties": {
"linkId": {
"type": "string",
"description": "The private link ID",
"example": "abc123def456"
},
"eventTypeId": {
"type": "number",
"description": "Event type ID this link belongs to",
"example": 123
},
"isExpired": {
"type": "boolean",
"description": "Whether the link is currently expired",
"example": false
},
"bookingUrl": {
"type": "string",
"description": "Full booking URL for this private link",
"format": "uri",
"example": "https://cal.com/d/abc123def456/30min"
},
"expiresAt": {
"type": "string",
"description": "Expiration date for this time-based link",
"format": "date-time",
"example": "2025-12-31T23:59:59.000Z"
}
},
"required": [
"linkId",
"eventTypeId",
"isExpired",
"bookingUrl",
"expiresAt"
]
},
"UsageBasedPrivateLinkOutput": {
"type": "object",
"properties": {
"linkId": {
"type": "string",
"description": "The private link ID",
"example": "abc123def456"
},
"eventTypeId": {
"type": "number",
"description": "Event type ID this link belongs to",
"example": 123
},
"isExpired": {
"type": "boolean",
"description": "Whether the link is currently expired",
"example": false
},
"bookingUrl": {
"type": "string",
"description": "Full booking URL for this private link",
"format": "uri",
"example": "https://cal.com/d/abc123def456/30min"
},
"maxUsageCount": {
"type": "number",
"description": "Maximum number of times this link can be used",
"example": 10
},
"usageCount": {
"type": "number",
"description": "Current usage count for this link",
"example": 3
}
},
"required": [
"linkId",
"eventTypeId",
"isExpired",
"bookingUrl",
"maxUsageCount",
"usageCount"
]
},
"CreatePrivateLinkOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"description": "Created private link data (either time-based or usage-based)",
"oneOf": [
{
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
},
{
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
}
]
}
},
"required": [
"status",
"data"
]
},
"GetPrivateLinksOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"type": "array",
"description": "Array of private links for the event type (mix of time-based and usage-based)",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
},
{
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
}
]
}
}
},
"required": [
"status",
"data"
]
},
"UpdatePrivateLinkBody": {
"type": "object",
"properties": {
"expiresAt": {
"format": "date-time",
"type": "string",
"description": "New expiration date for time-based links",
"example": "2024-12-31T23:59:59.000Z"
},
"maxUsageCount": {
"type": "number",
"description": "New maximum number of times the link can be used",
"example": 10,
"minimum": 1
}
}
},
"UpdatePrivateLinkOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"description": "Updated private link data (either time-based or usage-based)",
"oneOf": [
{
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
},
{
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
}
]
}
},
"required": [
"status",
"data"
]
},
"DeletePrivateLinkOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"type": "object",
"description": "Deleted link information",
"properties": {
"linkId": {
"type": "string",
"example": "abc123def456"
},
"message": {
"type": "string",
"example": "Private link deleted successfully"
}
}
}
},
"required": [
"status",
"data"
]
},
"UserWebhookOutputDto": {
"type": "object",
"properties": {
@@ -28782,7 +29131,7 @@
},
"CalendarEventResponseStatus": {
"type": "string",
"description": "Host's response to the invitation",
"description": "Response status of the attendee",
"enum": [
"accepted",
"pending",
@@ -28815,6 +29164,11 @@
"type": "boolean",
"nullable": true,
"description": "Indicates if this attendee's attendance is optional"
},
"host": {
"type": "boolean",
"nullable": true,
"description": "Indicates if this attendee is the host"
}
},
"required": [
@@ -28853,6 +29207,23 @@
"email"
]
},
"calendarEventOwner": {
"type": "object",
"properties": {
"email": {
"type": "string",
"description": "Email address of the event host"
},
"name": {
"type": "string",
"nullable": true,
"description": "Display name of the event host"
}
},
"required": [
"email"
]
},
"CalendarSource": {
"type": "string",
"description": "Calendar integration source (e.g., Google Calendar, Office 365, Apple Calendar). Currently only Google Calendar is supported.",
@@ -28949,6 +29320,15 @@
"$ref": "#/components/schemas/CalendarEventHost"
}
},
"calendarEventOwner": {
"nullable": true,
"description": "The calendar account that owns this event. This is the primary calendar where the event is stored and cannot be modified without appropriate permissions. Changing this would require moving the event to a different calendar",
"allOf": [
{
"$ref": "#/components/schemas/calendarEventOwner"
}
]
},
"source": {
"example": "google",
"$ref": "#/components/schemas/CalendarSource"
@@ -28982,6 +29362,91 @@
"data"
]
},
"UpdateCalendarEventAttendee": {
"type": "object",
"properties": {
"email": {
"type": "string",
"description": "Email address of the attendee"
},
"name": {
"type": "string",
"description": "Display name of the attendee"
},
"responseStatus": {
"nullable": true,
"$ref": "#/components/schemas/CalendarEventResponseStatus"
},
"self": {
"type": "boolean",
"nullable": true,
"description": "Indicates if this attendee is the current user"
},
"optional": {
"type": "boolean",
"nullable": true,
"description": "Indicates if this attendee's attendance is optional"
},
"host": {
"type": "boolean",
"nullable": true,
"description": "Indicates if this attendee is the host"
}
}
},
"UpdateUnifiedCalendarEventInput": {
"type": "object",
"properties": {
"start": {
"type": "object",
"properties": {
"time": {
"type": "string",
"format": "date-time"
},
"timeZone": {
"type": "string"
}
},
"description": "Start date and time of the calendar event with timezone information"
},
"end": {
"type": "object",
"properties": {
"time": {
"type": "string",
"format": "date-time"
},
"timeZone": {
"type": "string"
}
},
"description": "End date and time of the calendar event with timezone information"
},
"title": {
"type": "string",
"description": "Title of the calendar event"
},
"description": {
"type": "string",
"nullable": true,
"description": "Detailed description of the calendar event"
},
"attendees": {
"nullable": true,
"description": "List of attendees. CAUTION: You must pass the entire array with all updated values. Any attendees not included in this array will be removed from the event.",
"type": "array",
"items": {
"$ref": "#/components/schemas/UpdateCalendarEventAttendee"
}
},
"status": {
"nullable": true,
"example": "accepted",
"$ref": "#/components/schemas/CalendarEventStatus"
}
}
},
"RequestEmailVerificationInput": {
"type": "object",
"properties": {
@@ -59,4 +59,8 @@ export class EventTypesRepositoryFixture {
async delete(eventTypeId: EventType["id"]) {
return this.prismaWriteClient.eventType.delete({ where: { id: eventTypeId } });
}
async findById(eventTypeId: EventType["id"]) {
return this.prismaReadClient.eventType.findUnique({ where: { id: eventTypeId } });
}
}
@@ -34,4 +34,13 @@ export class ManagedOrganizationsRepositoryFixture {
},
});
}
async createManagedOrganization(managerOrganizationId: number, managedOrganizationId: number) {
return this.prismaWriteClient.managedOrganization.create({
data: {
managerOrganizationId,
managedOrganizationId,
},
});
}
}
@@ -35,4 +35,8 @@ export class MembershipRepositoryFixture {
await this.prismaWriteClient.user.update({ where: { id: user.id }, data: { organizationId: org.id } });
return membership;
}
async findById(membershipId: Membership["id"]) {
return this.prismaReadClient.membership.findUnique({ where: { id: membershipId } });
}
}
@@ -1,13 +1,17 @@
import { createRouterCaller } from "app/_trpc/context";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { AppCategories } from "@calcom/prisma/enums";
import { appsRouter } from "@calcom/trpc/server/routers/viewer/apps/_router";
import { calendarsRouter } from "@calcom/trpc/server/routers/viewer/calendars/_router";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import InstalledApps from "~/apps/installed/[category]/installed-category-view";
const querySchema = z.object({
@@ -31,16 +35,23 @@ const InstalledAppsWrapper = async ({ params }: PageProps) => {
redirect("/apps/installed/calendar");
}
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
return redirect("/auth/login");
}
const [calendarsCaller, appsCaller] = await Promise.all([
createRouterCaller(calendarsRouter),
createRouterCaller(appsRouter),
]);
const connectedCalendars = await calendarsCaller.connectedCalendars();
const installedCalendars = await appsCaller.integrations({
variant: "calendar",
onlyInstalled: true,
});
const [connectedCalendars, installedCalendars] = await Promise.all([
calendarsCaller.connectedCalendars(),
appsCaller.integrations({
variant: "calendar",
onlyInstalled: true,
}),
]);
return (
<InstalledApps
@@ -5,6 +5,7 @@ import { unstable_cache } from "next/cache";
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
import prisma from "@calcom/prisma";
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
import { TeamMembersView } from "~/teams/team-members-view";
@@ -36,8 +37,10 @@ const getCachedTeamRoles = unstable_cache(
const getCachedTeamAttributes = unstable_cache(
async (organizationId?: number) => {
if (!organizationId) return [];
const attributeRepo = new PrismaAttributeRepository(prisma);
try {
return await PrismaAttributeRepository.findAllByOrgIdWithOptions({ orgId: organizationId });
return await attributeRepo.findAllByOrgIdWithOptions({ orgId: organizationId });
} catch (error) {
return [];
}
@@ -4,6 +4,7 @@ import { unstable_cache } from "next/cache";
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
import prisma from "@calcom/prisma";
import { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router";
import { MembersView } from "~/members/members-view";
@@ -19,7 +20,9 @@ export const generateMetadata = async () =>
const getCachedAttributes = unstable_cache(
async (orgId: number) => {
return await PrismaAttributeRepository.findAllByOrgIdWithOptions({ orgId });
const attributeRepo = new PrismaAttributeRepository(prisma);
return await attributeRepo.findAllByOrgIdWithOptions({ orgId });
},
undefined,
{ revalidate: 3600, tags: ["viewer.attributes.list"] } // Cache for 1 hour
@@ -1,10 +1,4 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/settings/organizations/new/getServerSideProps";
import LegacyPage, { LayoutWrapper } from "~/settings/organizations/new/about-view";
@@ -17,10 +11,7 @@ export const generateMetadata = async () =>
"/settings/organizations/new/about"
);
const getData = withAppDirSsr(getServerSideProps);
const ServerPage = async ({ params, searchParams }: PageProps) => {
await getData(buildLegacyCtx(await headers(), await cookies(), await params, await searchParams));
const ServerPage = async () => {
return (
<LayoutWrapper>
<LegacyPage />
@@ -1,10 +1,4 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/settings/organizations/new/getServerSideProps";
import LegacyPage, { LayoutWrapper } from "~/settings/organizations/new/add-teams-view";
@@ -17,10 +11,7 @@ export const generateMetadata = async () =>
"/settings/organizations/new/add-teams"
);
const getData = withAppDirSsr(getServerSideProps);
const ServerPage = async ({ params, searchParams }: PageProps) => {
await getData(buildLegacyCtx(await headers(), await cookies(), await params, await searchParams));
const ServerPage = async () => {
return (
<LayoutWrapper>
<LegacyPage />
@@ -1,11 +1,4 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies } from "next/headers";
import { headers } from "next/headers";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/settings/organizations/new/getServerSideProps";
import LegacyPage, { LayoutWrapper } from "~/settings/organizations/new/onboarding-handover";
@@ -18,10 +11,7 @@ export const generateMetadata = async () =>
"/settings/organizations/new/handover"
);
const getData = withAppDirSsr(getServerSideProps);
const ServerPage = async ({ params, searchParams }: PageProps) => {
await getData(buildLegacyCtx(await headers(), await cookies(), await params, await searchParams));
const ServerPage = async () => {
return (
<LayoutWrapper>
<LegacyPage />
@@ -1,21 +1,15 @@
import type { GetServerSidePropsResult } from "next";
import { notFound } from "next/navigation";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { prisma } from "@calcom/prisma";
export const getServerSideProps = async (): Promise<GetServerSidePropsResult<{ isOrg: boolean }>> => {
export default async function Layout({ children }: { children: React.ReactNode }) {
const featuresRepository = new FeaturesRepository(prisma);
const organizations = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations");
// Check if organizations are enabled
if (!organizations) {
return {
notFound: true,
};
return notFound();
}
return {
props: {
isOrg: true,
},
};
};
return children;
}
@@ -1,10 +1,4 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/settings/organizations/new/getServerSideProps";
import LegacyPage, { LayoutWrapper } from "~/settings/organizations/new/onboard-members-view";
@@ -17,10 +11,7 @@ export const generateMetadata = async () =>
"/settings/organizations/new/onboard-members"
);
const getData = withAppDirSsr(getServerSideProps);
const ServerPage = async ({ params, searchParams }: PageProps) => {
await getData(buildLegacyCtx(await headers(), await cookies(), await params, await searchParams));
const ServerPage = async () => {
return (
<LayoutWrapper>
<LegacyPage />
@@ -1,13 +1,7 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/settings/organizations/new/getServerSideProps";
import LegacyPage, { LayoutWrapper } from "~/settings/organizations/new/create-new-view";
export const generateMetadata = async () =>
@@ -19,14 +13,7 @@ export const generateMetadata = async () =>
"/settings/organizations/new"
);
type Props = {
isOrg: boolean;
};
const getData = withAppDirSsr<Props>(getServerSideProps);
const ServerPage = async ({ params, searchParams }: PageProps) => {
await getData(buildLegacyCtx(await headers(), await cookies(), await params, await searchParams));
const ServerPage = async () => {
return (
<LayoutWrapper>
<LicenseRequired>
@@ -1,10 +1,4 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/settings/organizations/new/getServerSideProps";
import LegacyPage, { LayoutWrapper } from "~/settings/organizations/new/payment-status-view";
@@ -17,10 +11,7 @@ export const generateMetadata = async () =>
"/settings/organizations/new/status"
);
const getData = withAppDirSsr(getServerSideProps);
const ServerPage = async ({ params, searchParams }: PageProps) => {
await getData(buildLegacyCtx(await headers(), await cookies(), await params, await searchParams));
const ServerPage = async () => {
return (
<LayoutWrapper>
<LegacyPage />
+2 -2
View File
@@ -13,5 +13,5 @@ export type PageProps = {
export type LayoutProps = { params: Promise<Params>; children: React.ReactElement };
export { type ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
export { type ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
export type ReadonlyHeaders = Awaited<ReturnType<typeof import("next/headers").headers>>;
export type ReadonlyRequestCookies = Awaited<ReturnType<typeof import("next/headers").cookies>>;
+2 -1
View File
@@ -5,6 +5,7 @@ import { getLocale } from "@calcom/features/auth/lib/getLocale";
import type { AppImageProps, MeetingImageProps } from "@calcom/lib/OgImages";
import { constructAppImage, constructGenericImage, constructMeetingImage } from "@calcom/lib/OgImages";
import { IS_CALCOM, WEBAPP_URL, APP_NAME, SEO_IMG_OGIMG, CAL_URL } from "@calcom/lib/constants";
import { getCalcomUrl } from "@calcom/lib/getCalcomUrl";
import { buildCanonical } from "@calcom/lib/next-seo.config";
import { getTranslation } from "@calcom/lib/server/i18n";
import { truncateOnWord } from "@calcom/lib/text";
@@ -32,7 +33,7 @@ const _generateMetadataWithoutImage = async (
const description = getDescription(t);
const titleSuffix = `| ${APP_NAME}`;
const displayedTitle = title.includes(titleSuffix) || hideBranding ? title : `${title} ${titleSuffix}`;
const metadataBase = new URL(IS_CALCOM ? "https://cal.com" : WEBAPP_URL);
const metadataBase = new URL(IS_CALCOM ? getCalcomUrl() : WEBAPP_URL);
return {
title: title.length === 0 ? APP_NAME : displayedTitle,
+1 -1
View File
@@ -200,7 +200,7 @@ async function getHandler(request: NextRequest) {
buffer = await optimizeImage({
buffer,
contentType: detectContentType(buffer) ?? "image/jpeg",
contentType: (await detectContentType(buffer)) ?? "image/jpeg",
quality: 100,
width: logoDefinition.w,
height: logoDefinition.h, // optional
+18 -3
View File
@@ -87,7 +87,10 @@ async function handler(req: NextRequest) {
return new Response(img.body, {
status: 200,
headers: { "Content-Type": "image/png", "cache-control": "max-age=0" },
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
},
});
} catch (error) {
if (error instanceof ZodError) {
@@ -116,7 +119,13 @@ async function handler(req: NextRequest) {
});
const img = new ImageResponse(<App name={name} description={description} slug={slug} />, ogConfig);
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
return new Response(img.body, {
status: 200,
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
},
});
} catch (error) {
if (error instanceof ZodError) {
return new Response(
@@ -144,7 +153,13 @@ async function handler(req: NextRequest) {
const img = new ImageResponse(<Generic title={title} description={description} />, ogConfig);
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
return new Response(img.body, {
status: 200,
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
},
});
} catch (error) {
if (error instanceof ZodError) {
return new Response(
+2 -1
View File
@@ -9,6 +9,7 @@ import Script from "next/script";
import "@calcom/embed-core/src/embed-iframe";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { IS_CALCOM, WEBAPP_URL } from "@calcom/lib/constants";
import { getCalcomUrl } from "@calcom/lib/getCalcomUrl";
import { buildCanonical } from "@calcom/lib/next-seo.config";
import { IconSprites } from "@calcom/ui/components/icon";
@@ -64,7 +65,7 @@ function PageWrapper(props: AppProps) {
// Set canonical to https://cal.com or self-hosted URL
canonical={
IS_CALCOM
? buildCanonical({ path, origin: "https://cal.com" }) // cal.com & .dev
? buildCanonical({ path, origin: getCalcomUrl() }) // cal.com & .dev
: buildCanonical({ path, origin: WEBAPP_URL }) // self-hosted
}
{...seoConfig.defaultNextSeo}
@@ -2,9 +2,11 @@ import { useMemo } from "react";
import { useFormContext } from "react-hook-form";
import type { UseFormGetValues, UseFormSetValue, Control, FormState } from "react-hook-form";
import type { TLocationOptions } from "@calcom/features/eventtypes/components/Locations";
import type { TEventTypeLocation } from "@calcom/features/eventtypes/components/Locations";
import Locations from "@calcom/features/eventtypes/components/Locations";
import type {
TLocationOptions,
TEventTypeLocation,
} from "@calcom/features/eventtypes/components/locations/Locations";
import Locations from "@calcom/features/eventtypes/components/locations/Locations";
import type { LocationFormValues } from "@calcom/features/eventtypes/lib/types";
import type { SingleValueLocationOption } from "@calcom/features/form/components/LocationSelect";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -1,7 +1,11 @@
import { type TFunction } from "i18next";
import { useEffect, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { userMetadata } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import type { RouterOutputs } from "@calcom/trpc/react";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { List } from "@calcom/ui/components/list";
@@ -12,10 +16,18 @@ import { StepConnectionLoader } from "../components/StepConnectionLoader";
interface ConnectedAppStepProps {
nextStep: () => void;
isPageLoading: boolean;
user: RouterOutputs["viewer"]["me"]["get"];
}
const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
const { nextStep, isPageLoading } = props;
const ConnectedVideoStepInner = ({
t,
setAnyInstalledVideoApps,
user,
}: {
t: TFunction;
setAnyInstalledVideoApps: Dispatch<SetStateAction<boolean>>;
user: RouterOutputs["viewer"]["me"]["get"];
}) => {
const { data: queryConnectedVideoApps, isPending } = trpc.viewer.apps.integrations.useQuery({
variant: "conferencing",
onlyInstalled: false,
@@ -30,47 +42,59 @@ const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
sortByMostPopular: true,
sortByInstalledFirst: true,
});
const { data } = useMeQuery();
const { t } = useLocale();
const metadata = userMetadata.parse(data?.metadata);
const hasAnyInstalledVideoApps = queryConnectedVideoApps?.items.some(
(item) => item.userCredentialIds.length > 0
);
useEffect(() => {
setAnyInstalledVideoApps(Boolean(hasAnyInstalledVideoApps));
}, [hasAnyInstalledVideoApps, setAnyInstalledVideoApps]);
if (isPending) {
return <StepConnectionLoader />;
}
const result = userMetadata.safeParse(user.metadata);
if (!result.success) {
return <StepConnectionLoader />;
}
const { data: metadata } = result;
const defaultConferencingApp = metadata?.defaultConferencingApp?.appSlug;
return (
<>
{!isPending && (
<List className="bg-default border-subtle divide-subtle scroll-bar mx-1 max-h-[45vh] divide-y !overflow-y-scroll rounded-md border p-0 sm:mx-0">
{queryConnectedVideoApps?.items &&
queryConnectedVideoApps?.items.map((item) => {
if (item.slug === "daily-video") return null; // we dont want to show daily here as it is installed by default
return (
<li key={item.name}>
{item.name && item.logo && (
<AppConnectionItem
type={item.type}
title={item.name}
isDefault={item.slug === defaultConferencingApp}
description={item.description}
dependencyData={item.dependencyData}
logo={item.logo}
slug={item.slug}
installed={item.userCredentialIds.length > 0}
defaultInstall={
!defaultConferencingApp && item.appData?.location?.linkType === "dynamic"
}
/>
)}
</li>
);
})}
</List>
)}
<List className="bg-default border-subtle divide-subtle scroll-bar mx-1 max-h-[45vh] divide-y !overflow-y-scroll rounded-md border p-0 sm:mx-0">
{queryConnectedVideoApps?.items &&
queryConnectedVideoApps?.items.map((item) => {
if (item.slug === "daily-video") return null; // we dont want to show daily here as it is installed by default
return (
<li key={item.name}>
{item.name && item.logo && (
<AppConnectionItem
type={item.type}
title={item.name}
isDefault={item.slug === defaultConferencingApp}
description={item.description}
dependencyData={item.dependencyData}
logo={item.logo}
slug={item.slug}
installed={item.userCredentialIds.length > 0}
defaultInstall={!defaultConferencingApp && item.appData?.location?.linkType === "dynamic"}
/>
)}
</li>
);
})}
</List>
);
};
{isPending && <StepConnectionLoader />}
const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
const { nextStep, isPageLoading, user } = props;
const { t } = useLocale();
const [hasAnyInstalledVideoApps, setAnyInstalledVideoApps] = useState(false);
return (
<>
<ConnectedVideoStepInner setAnyInstalledVideoApps={setAnyInstalledVideoApps} t={t} user={user} />
<Button
EndIcon="arrow-right"
data-testid="save-video-button"
@@ -10,6 +10,7 @@ import { md } from "@calcom/lib/markdownIt";
import { telemetryEventTypes } from "@calcom/lib/telemetry";
import turndown from "@calcom/lib/turndownService";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { UserAvatar } from "@calcom/ui/components/avatar";
import { Button } from "@calcom/ui/components/button";
import { Editor } from "@calcom/ui/components/editor";
@@ -21,8 +22,11 @@ type FormData = {
bio: string;
};
const UserProfile = () => {
const [user] = trpc.viewer.me.get.useSuspenseQuery();
interface UserProfileProps {
user: RouterOutputs["viewer"]["me"]["get"];
}
const UserProfile = ({ user }: UserProfileProps) => {
const { t } = useLocale();
const avatarRef = useRef<HTMLInputElement>(null);
const { setValue, handleSubmit, getValues } = useForm<FormData>({
@@ -13,6 +13,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useTelemetry } from "@calcom/lib/hooks/useTelemetry";
import { telemetryEventTypes } from "@calcom/lib/telemetry";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { Input } from "@calcom/ui/components/form";
@@ -21,11 +22,11 @@ import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability";
interface IUserSettingsProps {
nextStep: () => void;
hideUsername?: boolean;
user: RouterOutputs["viewer"]["me"]["get"];
}
const UserSettings = (props: IUserSettingsProps) => {
const { nextStep } = props;
const [user] = trpc.viewer.me.get.useSuspenseQuery();
const { nextStep, user } = props;
const { t } = useLocale();
const { setTimezone: setSelectedTimeZone, timezone: selectedTimeZone } = useTimePreferences();
const telemetry = useTelemetry();
+1 -2
View File
@@ -1,8 +1,7 @@
import type { SearchParams } from "app/_types";
import { type Params } from "app/_types";
import type { ReadonlyHeaders, ReadonlyRequestCookies } from "app/_types";
import type { GetServerSidePropsContext, NextApiRequest } from "next";
import { type ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
import { type ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
const createProxifiedObject = (object: Record<string, string>) =>
new Proxy(object, {
@@ -1,34 +0,0 @@
import type { GetServerSidePropsContext } from "next";
import { getSession } from "next-auth/react";
import { UserPermissionRole } from "@calcom/prisma/enums";
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const session = await getSession(ctx);
if (!session || !session.user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
if (!isAdmin) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
return {
props: {
error: null,
migrated: null,
userId: session.user.id,
username: session.user.username,
},
};
}
@@ -1,29 +0,0 @@
import type { GetServerSidePropsContext } from "next";
import { getSession } from "next-auth/react";
import { UserPermissionRole } from "@calcom/prisma/enums";
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const session = await getSession(ctx);
if (!session || !session.user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
if (!isAdmin) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
return {
props: {},
};
}
@@ -1,29 +0,0 @@
import type { GetServerSidePropsContext } from "next";
import { getSession } from "next-auth/react";
import { UserPermissionRole } from "@calcom/prisma/enums";
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const session = await getSession(ctx);
if (!session || !session.user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
if (!isAdmin) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
return {
props: {},
};
}
@@ -1,29 +0,0 @@
import type { GetServerSidePropsContext } from "next";
import { getSession } from "next-auth/react";
import { UserPermissionRole } from "@calcom/prisma/enums";
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const session = await getSession(ctx);
if (!session || !session.user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
if (!isAdmin) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
return {
props: {},
};
}
@@ -156,7 +156,7 @@ export function AvailabilityList({ availabilities }: AvailabilityListProps) {
))}
</ul>
</div>
<div className="text-default mb-16 mt-4 hidden text-center text-sm md:block">
<div className="text-default mb-16 mt-4 block text-center text-sm">
{t("temporarily_out_of_office")}{" "}
<Link href="settings/my-account/out-of-office" className="underline">
{t("add_a_redirect")}
@@ -180,7 +180,7 @@ function BookingsContent({ status }: BookingsProps) {
columnHelper.accessor((row) => row.type === "data" && row.booking.user?.id, {
id: "userId",
header: t("member"),
enableColumnFilter: true,
enableColumnFilter: user?.isTeamAdminOrOwner ?? false,
enableSorting: false,
cell: () => null,
meta: {
@@ -162,20 +162,20 @@ const OnboardingPage = (props: PageProps) => {
<StepCard>
<Suspense fallback={<Icon name="loader" />}>
{currentStep === "user-settings" && (
<UserSettings nextStep={goToNextStep} hideUsername={from === "signup"} />
<UserSettings nextStep={goToNextStep} hideUsername={from === "signup"} user={user} />
)}
{currentStep === "connected-calendar" && (
<ConnectedCalendars nextStep={goToNextStep} isPageLoading={isNextStepLoading} />
)}
{currentStep === "connected-video" && (
<ConnectedVideoStep nextStep={goToNextStep} isPageLoading={isNextStepLoading} />
<ConnectedVideoStep nextStep={goToNextStep} isPageLoading={isNextStepLoading} user={user} />
)}
{currentStep === "setup-availability" && (
<SetupAvailability nextStep={goToNextStep} defaultScheduleId={user.defaultScheduleId} />
)}
{currentStep === "user-profile" && <UserProfile />}
{currentStep === "user-profile" && <UserProfile user={user} />}
</Suspense>
</StepCard>
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.5.16",
"version": "5.6.2",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -106,7 +106,7 @@
"memory-cache": "^0.2.0",
"micro": "^10.0.1",
"mime-types": "^2.1.35",
"next": "^15.3.0",
"next": "^15.4.5",
"next-auth": "^4.22.1",
"next-axiom": "^0.17.0",
"next-collect": "^0.2.1",
+3 -3
View File
@@ -1,6 +1,6 @@
import type { IncomingMessage } from "http";
import type { NextPageContext } from "next";
import { SessionProvider } from "next-auth/react";
import type { AppContextType } from "next/dist/shared/lib/utils";
import React from "react";
import CacheProvider from "react-inlinesvg/provider";
@@ -32,8 +32,8 @@ declare global {
}
}
MyApp.getInitialProps = async (ctx: AppContextType) => {
const { req } = ctx.ctx;
MyApp.getInitialProps = async ({ ctx }: { ctx: NextPageContext }) => {
const { req } = ctx;
let newLocale = "en";
+29 -8
View File
@@ -479,7 +479,6 @@ test.describe("Bookings", () => {
const filterItemsConfig = [
{ key: "eventTypeId", name: "Event Type", testId: "add-filter-item-eventTypeId" },
{ key: "teamId", name: "Team", testId: "add-filter-item-teamId" },
{ key: "userId", name: "Member", testId: "add-filter-item-userId" },
{ key: "attendeeName", name: "Attendees Name", testId: "add-filter-item-attendeeName" },
{ key: "attendeeEmail", name: "Attendee Email", testId: "add-filter-item-attendeeEmail" },
{ key: "dateRange", name: "Date Range", testId: "add-filter-item-dateRange" },
@@ -488,8 +487,16 @@ test.describe("Bookings", () => {
const getFilterItemLocator = (page: Page, testId: string) => page.locator(`[data-testid="${testId}"]`);
test.beforeEach(async ({ page, users }) => {
const user = await users.create();
const setup = async ({
isAdmin,
page,
users,
}: {
isAdmin: boolean;
page: Page;
users: Fixtures["users"];
}) => {
const user = isAdmin ? await users.create(undefined, { hasTeam: true }) : await users.create();
await user.apiLogin();
const bookingsGetResponse = page.waitForResponse((response) =>
/\/api\/trpc\/bookings\/get.*/.test(response.url())
@@ -498,9 +505,10 @@ test.describe("Bookings", () => {
await bookingsGetResponse;
await page.locator('[data-testid="add-filter-button"]').click();
await expect(page.locator(searchInputSelector)).toBeVisible();
});
};
test("should show all filter items initially and after clearing search", async ({ page }) => {
test("should show all filter items initially and after clearing search", async ({ page, users }) => {
await setup({ isAdmin: false, page, users });
const searchInput = page.locator(searchInputSelector);
// Initial check: all defined filter items should be visible
@@ -524,7 +532,18 @@ test.describe("Bookings", () => {
}
});
test("search should be case-insensitive", async ({ page }) => {
test("should show admin-only filter", async ({ page, users }) => {
await setup({ isAdmin: true, page, users });
await expect(
getFilterItemLocator(page, "add-filter-item-userId"),
`Item "Member" should be visible initially`
).toBeVisible();
});
test("search should be case-insensitive", async ({ page, users }) => {
await setup({ isAdmin: true, page, users });
const searchInput = page.locator(searchInputSelector);
// Search for "member" (lowercase)
@@ -534,7 +553,8 @@ test.describe("Bookings", () => {
await expect(getFilterItemLocator(page, "add-filter-item-teamId")).toBeHidden();
});
test("should individually find each filter item by its full name", async ({ page }) => {
test("should individually find each filter item by its full name", async ({ page, users }) => {
await setup({ isAdmin: false, page, users });
const searchInput = page.locator(searchInputSelector);
for (const targetItem of filterItemsConfig) {
@@ -550,7 +570,8 @@ test.describe("Bookings", () => {
}
});
test("should show no items for a non-matching search term", async ({ page }) => {
test("should show no items for a non-matching search term", async ({ page, users }) => {
await setup({ isAdmin: false, page, users });
const searchInput = page.locator(searchInputSelector);
const nonExistentTerm = "NonExistentFilterXYZ123";
await searchInput.fill(nonExistentTerm);
@@ -6,6 +6,7 @@ import { test } from "./lib/fixtures";
import {
bookTimeSlot,
confirmReschedule,
confirmBooking,
doOnOrgDomain,
selectFirstAvailableTimeSlotNextMonth,
selectSecondAvailableTimeSlotNextMonth,
@@ -98,6 +99,58 @@ test("dynamic booking info prefilled by query params", async ({ page, users }) =
activeState = await listItemLocator.getAttribute("data-active");
expect(activeState).toEqual("false");
});
test("multiple duration selection updates event length correctly", async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
await test.step("update event title to include duration placeholder", async () => {
await page.goto("/event-types");
await page.waitForSelector('[data-testid="event-types"]');
await page.click(`text=Multiple duration`);
await page.waitForSelector('[data-testid="event-title"]');
await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toHaveAttribute(
"aria-current",
"page"
);
await page.getByTestId("vertical-tab-event_advanced_tab_title").click();
await page.fill('[name="eventName"]', "{Event duration} event btwn {Organiser} {Scheduler}");
await page.locator('[data-testid="update-eventtype"]').click();
await page.waitForResponse("/api/trpc/eventTypes/update?batch=1");
});
await page.goto(`/${user.username}/multiple-duration`);
await page.locator('[data-testid="multiple-choice-30mins"]').waitFor({ state: "visible" });
await test.step("verify default 30min duration is selected", async () => {
const duration30 = page.getByTestId("multiple-choice-30mins");
const activeState = await duration30.getAttribute("data-active");
expect(activeState).toEqual("true");
});
await test.step("book with 90min duration and verify title", async () => {
await page.getByTestId("multiple-choice-90mins").click();
const duration90 = page.getByTestId("multiple-choice-90mins");
const activeState = await duration90.getAttribute("data-active");
expect(activeState).toEqual("true");
await selectFirstAvailableTimeSlotNextMonth(page);
await page.fill('[name="name"]', "Test User");
await page.fill('[name="email"]', "test@example.com");
await confirmBooking(page);
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
const bookingTitle = page.locator('[data-testid="booking-title"]');
await expect(bookingTitle).toBeVisible();
const titleText = await bookingTitle.textContent();
expect(titleText).toContain("90");
});
});
// eslint-disable-next-line playwright/no-skipped-test
test.skip("it contains the right event details", async ({ page }) => {
const response = await page.goto(`http://acme.cal.local:3000/owner1+member1`);
File diff suppressed because it is too large Load Diff
+2
View File
@@ -127,6 +127,8 @@ Below is a list of props that can be passed to the booker atom.
| timeZones | No | Array of valid IANA timezones to be used in the booker. Eg. ["Asia/Kolkata", "Europe/London"] |
| onTimeslotsLoaded | No | Callback function triggered once the available timeslots have been fetched. |
| roundRobinHideOrgAndTeam | No | Boolean indicating if the organization and team should be hidden in the booker atom sidebar for round robin scheduling type, defaults to false. |
| showNoAvailabilityDialog | No | Boolean indicating if the no availability dialog should be shown, defaults to true. |
| silentlyHandleCalendarFailures | No | Boolean when true the booker still displays slots when the third party calendars credentials are invalid or expired, Booker may show stale availability when enabled |
## Styling
+1 -1
View File
@@ -76,7 +76,7 @@ Sometimes, yarn install might fail during deployment on Vercel, in which case, y
<Step title="Override the build command">
Override to:
```
cd ../.. && yarn build --include-dependencies --no-deps
cd ../.. && yarn build --no-deps
```
</Step>
<Step title="Hit Deploy">
+2 -2
View File
@@ -33,8 +33,8 @@ RUN set -eux; \
apt-get clean && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives && \
yarn config set httpTimeout 1200000 && \
npx turbo prune --scope=@calcom/web --docker && \
npx turbo prune --scope=@calcom/api --docker && \
npx turbo prune --filter=@calcom/web --docker && \
npx turbo prune --filter=@calcom/api --docker && \
yarn install && \
yarn turbo run build --filter=@calcom/api
+13 -13
View File
@@ -28,23 +28,23 @@
"edit-app-template": "yarn app-store edit-template",
"delete-app-template": "yarn app-store delete-template",
"build": "turbo run build --filter=@calcom/web...",
"build:ai": "turbo run build --scope=\"@calcom/ai\"",
"build:ai": "turbo run build --filter=\"@calcom/ai\"",
"clean": "find . -name node_modules -o -name .next -o -name .turbo -o -name dist -type d -prune | xargs rm -rf",
"db-deploy": "turbo run db-deploy",
"db-seed": "turbo run db-seed",
"db-studio": "yarn prisma studio",
"deploy": "turbo run deploy",
"dev:all": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/website\" --scope=\"@calcom/console\"",
"dev:ai": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/api-proxy\" --scope=\"@calcom/api\" --scope=\"@calcom/ai\"",
"dev:api": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/api-proxy\" --scope=\"@calcom/api\"",
"dev:api:console": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/api-proxy\" --scope=\"@calcom/api\" --scope=\"@calcom/console\"",
"dev:console": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/console\"",
"dev:swagger": "turbo run dev --scope=\"@calcom/api-proxy\" --scope=\"@calcom/api\" --scope=\"@calcom/swagger\"",
"dev:website": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/website\"",
"dev": "turbo run dev --scope=\"@calcom/web\"",
"build-storybook": "turbo run build --scope=\"@calcom/storybook\"",
"dev:all": "turbo run dev --filter=\"@calcom/web\" --filter=\"@calcom/website\" --filter=\"@calcom/console\"",
"dev:ai": "turbo run dev --filter=\"@calcom/web\" --filter=\"@calcom/api-proxy\" --filter=\"@calcom/api\" --filter=\"@calcom/ai\"",
"dev:api": "turbo run dev --filter=\"@calcom/web\" --filter=\"@calcom/api-proxy\" --filter=\"@calcom/api\"",
"dev:api:console": "turbo run dev --filter=\"@calcom/web\" --filter=\"@calcom/api-proxy\" --filter=\"@calcom/api\" --filter=\"@calcom/console\"",
"dev:console": "turbo run dev --filter=\"@calcom/web\" --filter=\"@calcom/console\"",
"dev:swagger": "turbo run dev --filter=\"@calcom/api-proxy\" --filter=\"@calcom/api\" --filter=\"@calcom/swagger\"",
"dev:website": "turbo run dev --filter=\"@calcom/web\" --filter=\"@calcom/website\"",
"dev": "turbo run dev --filter=\"@calcom/web\"",
"build-storybook": "turbo run build --filter=\"@calcom/storybook\"",
"dx": "turbo run dx",
"i-dev": "infisical run -- turbo run dev --scope=\"@calcom/web\"",
"i-dev": "infisical run -- turbo run dev --filter=\"@calcom/web\"",
"i-dx": "infisical run -- turbo run dx",
"i-gen-web-example-env": "infisical secrets generate-example-env --tags=web > .env.example",
"i-gen-app-store-example-env": "infisical secrets generate-example-env --tags=appstore > .env.appStore.example",
@@ -61,7 +61,7 @@
"pre-commit": "lint-staged",
"predev": "echo 'Checking env files'",
"prisma": "yarn workspace @calcom/prisma prisma",
"start": "turbo run start --scope=\"@calcom/web\"",
"start": "turbo run start --filter=\"@calcom/web\"",
"tdd": "vitest watch",
"e2e": "NEXT_PUBLIC_IS_E2E=1 yarn playwright test --project=@calcom/web",
"e2e:app-store": "QUICK=true yarn playwright test --project=@calcom/app-store",
@@ -124,7 +124,7 @@
"date-fns-tz": "^3.2.0",
"eslint": "^8.34.0",
"p-limit": "^6.2.0",
"turbo": "^1.10.1"
"turbo": "^2.5.5"
},
"resolutions": {
"types-ramda": "0.29.4",

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