Compare commits

...
141 Commits
Author SHA1 Message Date
Alex van Andel 5c5d221c76 chore: release v6.0.0
Post release / release (push) Canceled after 0s
Release Docker / Release AMD64 (push) Canceled after 0s
Release Docker / Release ARM (push) Canceled after 0s
2025-12-15 17:40:14 +00:00
Alex van Andel 852daa0175 chore: release v6.0.0 2025-12-15 17:16:33 +00:00
Volnei Munhoz 1123b56a09 chore/upgrade_deps (#25873) 2025-12-15 17:04:17 +00:00
Dhairyashil Shinde 12e07f20d4 feat: Add Holidays feature to block availability on public holidays (#25561)
* feat: add holidays feature for automatic availability blocking- Add UserHolidaySettings model for storing user preferences- Generate static holiday data for 20 countries using date-holidays- Create HolidayService for runtime holiday queries- Add TRPC router with endpoints for country selection and holiday toggles- Create Holidays tab UI in Availability page with conflict warnings- Integrate holiday blocking into getUserAvailability calculation- Show holiday indicator on blocked dates in booker page- Add warning in OOO modal when dates overlap with holidays

* feat: add optimizations, tests, and code quality improvements

- Add memoization/caching to HolidayService for better performance
- Optimize checkConflicts DB query with OR conditions for specific dates
- Add HolidayService unit tests (10 tests)
- Add error handling with Alert component for failed queries
- Memoize HolidayListItem component to prevent unnecessary re-renders
- Extract magic numbers into constants.ts
- Use TRPCError consistently in handlers
- Add missing i18n keys for error messages
- Update handlers to follow Cal.com patterns (default exports, minimal comments)
- Add regeneration instructions in constants

* refactor: replace static JSON with Google Calendar API integration

- Add GoogleHolidayService to fetch holidays from Google Calendar public calendars
- Add HolidayCache Prisma model for caching API responses
- Add GOOGLE_CALENDAR_API_KEY and HOLIDAY_CACHE_DAYS env variables
- Support 38 countries via Google Calendar holiday calendars
- Update HolidayService methods to async with database caching
- Update all TRPC handlers for async holiday methods
- Fix UI to display holiday dates correctly
- Remove static holidays.json and generate script

* use we instead of calcom in i18n message

* address cubics comments

* move holidays from availability to ooo

* public holidays filter for holidays

* follow i18n _one and _other pattern

* remove holiday feature flag

* revert lint command code change

* revert lint command code change 2.0

* revert lint command code change 3.0

* bye bye my christmas emoji :crying-emoji

* remove comments

* refactor(holidays): add repository pattern, split services, and add tests

- Create HolidayRepository for database operations
- Split GoogleHolidayService into GoogleCalendarClient and HolidayCacheService
- Add dependency injection to HolidayService and HolidayCacheService
- Update TRPC handlers to use HolidayRepository
- Add tests for HolidayRepository and calculateHolidayBlockedDates
- Update calendar IDs to use official holiday format (244 countries + religions)

* fix: address PR review feedback

- Remove unused date-holidays package
- Add pluralization for and_more_holidays_with_conflicts translation
- DRY: spread GOOGLE_RELIGIOUS_HOLIDAY_CALENDARS into GOOGLE_HOLIDAY_CALENDARS
- Add select to userHolidaySettings query in getUserAvailability
- Optimize checkConflicts with pre-computed timestamps

* refactor(holidays): apply proxy pattern and move logic to service

- Rename HolidayCacheService to HolidayServiceCachingProxy (proxy pattern)
- Remove HOLIDAY_CACHE_DAYS env var, use constant directly
- Add isSupportedCountry() method to HolidayService
- Add getUserSettings() and updateSettings() to HolidayService
- Move toggleHoliday logic from handler to service
- Move checkConflicts logic from handler to service
- Add findBookingsInDateRanges() to HolidayRepository
- Simplify all handlers to just call service methods

* feat(bookings): add backend validation to prevent booking on holidays

Adds explicit holiday conflict validation during booking creation to
handle the race condition where a host enables a holiday after a guest
selects a date but before they submit the booking.

Changes:
- Add checkHolidayConflict validation with HolidayRepository integration
- Integrate ensureNoHolidayConflict in RegularBookingService
- Add BookingOnHoliday error code with proper HTTP 400 response
- Handle holiday error display in BookEventForm with name interpolation
- Hide trace ID for expected validation errors
- Add unit tests for holiday conflict validation

* fix failing type check

* fix: address PR review comments for holiday feature

- Change error code from BAD_REQUEST to INTERNAL_SERVER_ERROR in
  toggleHoliday handler (errors are internal failures, not bad input)
- Refactor ensureNoHolidayConflict to use Promise.all for parallel
  user checking instead of sequential loop

* refactor: use Promise.all with logging in loop for holiday check

- Check all users in parallel using Promise.all
- Log conflicts inside the loop as they are detected
- Wait for all checks to complete before throwing error

* fix(holidays): use host timezone for holiday conflict checks

The holiday feature was checking booking dates against holidays using
server/local timezone instead of the host's timezone. This caused
bookings near midnight boundaries to incorrectly pass or fail the
holiday check.

Example: A booking at Dec 24th 8PM UTC (which is Dec 25th in IST)
was not being blocked for an Indian host with Christmas as a holiday.

Changes:
- Add .utc() to holiday date formatting for consistency
- Fetch host's timezone in checkHolidayConflict
- Convert booking time to host's timezone before comparison
- Add findUserSettingsWithTimezone to HolidayRepository
- Update error message to clarify it's the host's local time
- Add timezone edge case tests

* fix(holidays): align holiday blocking with OOO pattern for consistent timezone handling

- Simplify calculateHolidayBlockedDates to match OOO pattern using dayjs.utc()
- Fix date range query to use full day bounds (startOfDay/endOfDay) so holidays
  stored at midnight UTC are correctly found during booking validation
- Remove separate checkHolidayConflict booking-time validation - holidays now
  block through oooExcludedDateRanges like OOO does
- Remove getHolidayOnDate from HolidayService (no longer needed)
- Remove findUserSettingsWithTimezone from HolidayRepository (no longer needed)
- Clean up related tests

Holiday blocking now works exactly like OOO:
1. Holidays are added to datesOutOfOffice in calculateHolidayBlockedDates
2. buildDateRanges processes them via processOOO with .tz(timeZone, true)
3. oooExcludedDateRanges excludes those dates from availability
4. ensureAvailableUsers uses oooExcludedDateRanges to block bookings

This ensures consistent timezone handling where Dec 25th blocks all hours
of Dec 25th in the host's timezone, regardless of booker's timezone.

* update test

* update .env.example file
2025-12-15 16:39:10 +00:00
Carina WollendorferandCarinaWolli 18415e35a7 fix browser extension redirect (#25885)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-12-15 16:32:59 +00:00
Udit Takkar 856792e1e7 fix: slots ooo bug (#25878) 2025-12-15 16:19:11 +00:00
52d5261b51 feat(booking): implement cross-page/week navigation in booking details sheet with view persistence (#25545)
* display UTM parameters

* persist view (list | calendar) in localStorage

* move to next page on clicking "next" of last item in the page

improve navigation

^ Conflicts:
^	apps/web/modules/bookings/components/BookingCalendarContainer.tsx

* fix navigation on calendar view

* Delete apps/web/modules/bookings/NAVIGATION_IMPLEMENTATION.md

* avoid page size being 0

* check feature flag on user level

* use map to improve useBookingListData

* address feedback

* feat(bookings): add smart navigation for calendar view

- Add sort option to tRPC bookings.get schema and handler
- Create NAVIGATION_PROBE_WINDOW_MONTHS constant (3 months)
- Create useNearestFutureBooking hook to find nearest future booking
- Create useNearestPastBooking hook to find nearest past booking
- Update useCalendarNavigationCapabilities to use probe results
- Update BookingCalendarContainer to wire up probe hooks

This enables the booking details sheet to:
- Disable next/prev buttons when no bookings exist in that direction
- Jump directly to the week containing the nearest booking

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

* fix type error

* feat: add booking selection state when using slideover (#25637)

* implement booking selection when using slideover

* remove pixel shift

* fix

* auto scroll to selected event on calendar

* make DateValues header sticky when scrolling

---------

Co-authored-by: Eunjae Lee <hey@eunjae.dev>

* prefetch previous / next weeks on calendar view

* clean up classes

* handle "fetched & but no data" situation more correctly in useCalendarAutoSelector

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2025-12-15 16:04:00 +00:00
Morgan 53776d28e6 fix: atoms vite config (#25883)
* fix: atoms vite config

* fixup! fix: atoms vite config

* fixup! fixup! fix: atoms vite config
2025-12-15 12:31:31 -03:00
Volnei Munhoz 2b666f3e91 Fix CI for Companion (#25886) 2025-12-15 15:22:35 +00:00
Anik Dhabal Babu 7562ad2a16 refactor: Fix N+1 queries and optimize database operations (#25813)
* refactor: prisma N+1 Queries

* addressed review
2025-12-15 14:14:19 +00:00
Volnei Munhoz 5626bceeff fix companion CI (#25881) 2025-12-15 11:00:50 -03:00
Dhairyashil Shinde e312d1c044 feat: add gradient indicator for scrollable event descriptions (#25460)
* feat: add gradient indicator for scrollable event descriptions

- Create EventDescription component with dynamic gradient overlay
- Show gradient only when description content overflows (180px max-height)
- Use Tailwind v4 gradient utilities for optimal styling
- Replace inline description rendering in EventMeta with new component
- Minimal implementation: 28 lines, no useEffect, single state variable

* add top gradient and optimize ScrollableWithGradients component

* add top gradient and optimize ScrollableWithGradients component

* refactor: extract reusable ScrollableWithGradients component with top/bottom gradients

* adjust gradient fades
2025-12-15 14:14:28 +01:00
Dhairyashil ShindeandAnik Dhabal Babu 9050f9b61f fix: dynamic group organizer order (#25474)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-12-15 11:28:45 +00:00
2218a45d83 feat: extract core booking audit infrastructure from PR 25125 (#25729)
* feat: extract core booking audit infrastructure from PR 25125

This PR contains only the core booking audit infrastructure changes from PR 25125,
excluding integration changes with booking flows.

Included:
- All packages/features/booking-audit/* (core audit services, actions, repository)
- packages/features/di/containers/BookingAuditViewerService.container.ts
- packages/features/tasker/tasker.ts (audit task types)
- packages/features/bookings/lib/types/actor.ts (actor types for audit)
- packages/features/bookings/repositories/BookingRepository.ts (getFromRescheduleUid method)
- apps/web/modules/booking/logs/views/booking-logs-view.tsx (UI for viewing audit logs)
- apps/web/public/static/locales/en/common.json (translations)

Excluded (integration changes):
- packages/trpc/server/* (tRPC handlers)
- packages/features/ee/round-robin/* (round-robin integration)
- packages/features/bookings/lib/handleCancelBooking.ts
- packages/features/bookings/lib/handleConfirmation.ts
- packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts
- packages/features/bookings/lib/service/RegularBookingService.ts
- apps/api/v2/* (API v2 integration)

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

* fix: make booking audit interfaces backwards-compatible with main

- Add queueAudit method back to BookingAuditProducerService interface for backwards compatibility
- Implement queueAudit method in BookingAuditTaskerProducerService
- Make userTimeZone parameter optional in BookingAuditViewerService
- Add BookingAuditTaskProducerActionData type for legacy queueAudit method
- Use any generics in BookingAuditActionServiceRegistry (matching PR 25125)
- Fix type assertions in BookingAuditTaskConsumer

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

* fix switch eslint and ts

* feat: enhance BookingAuditViewerService with logging and type improvements

- Added ISimpleLogger dependency to BookingAuditViewerService for better error handling.
- Updated actor type in enriched audit logs to use AuditActorType for improved type safety.
- Replaced console.error with logger for error reporting when no rescheduled log is found.

---------

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-12-15 10:51:30 +00:00
Udit Takkar 4e1d05a7e7 feat: dist tracing 4 (#25574)
* feat: distributed tracing 2

* feat: distributed tracing 2

* refactor: feedback

* refactor: feedback

* fix: type error

* fix: trpc error

* feat: distributed tracing - 3

* chore: translation

* refactor: improvements

* fix: feedback

* chore: remove

* feat: dist tracing 4

* fix: type errors

* fix: type errors

* fix: remove string type

* fix: type errors
2025-12-15 10:43:22 +00:00
devin-ai-integration[bot]andlauris@cal.com 0d4108dd69 chore: bump @calcom/atoms version to 2.1.0 (#25831)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: lauris@cal.com <lauris@cal.com>
2025-12-15 09:26:47 +00:00
Dhairyashil Shinde 6e2e826001 fix(ooo): make show note publicly consistent across all booking views (#25827)
- Update weekly view to display OOO when showNotePublicly is true
- Update column view to include days with public notes in schedule
- Add notes and showNotePublicly props to weekly view calendar
- Disable show note publicly checkbox when notes field is empty
2025-12-15 10:24:10 +01:00
Anik Dhabal Babu 84a4dd4147 fix: one integration test flake (#25832) 2025-12-15 09:09:01 +00:00
Benny JooandVolnei Munhoz deca7ce40a refactor: move calAIPhone-specific repositories to features from lib (#25840)
* add prisma agent repository to features

* add

* wip

* wip

* wip

* Update apps/web/app/api/webhooks/retell-ai/route.ts

Co-authored-by: Volnei Munhoz <volnei@cal.com>

* ts fix

* ts fix

---------

Co-authored-by: Volnei Munhoz <volnei@cal.com>
2025-12-15 13:43:24 +05:30
5394d80969 chore: Cleanup some outdated pages files (#25600)
* chore: Cleanup some outdated pages files

* revert: endpoint

* revert: endpoint

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-12-15 09:16:59 +05:30
Udit Takkar fd8f2a9ff6 fix: checkout session completed (#25767)
* fix: checkout session completed

* refactor: code quality
2025-12-15 09:12:13 +05:30
Alex van Andel 28f9c8f4cf chore: release v5.9.15
Post release / release (push) Canceled after 0s
Release Docker / Release AMD64 (push) Canceled after 0s
Release Docker / Release ARM (push) Canceled after 0s
2025-12-15 00:14:28 +00:00
Volnei Munhoz 37884d26e3 chore: bump next versions (#25864) 2025-12-15 00:13:42 +00:00
Peer Richelsen e46df92492 small wxt related fixes (#25866) 2025-12-14 21:01:08 +01:00
Beto 35cf827cb1 feat(companion): Event types screen update (#25847) 2025-12-14 20:59:48 +01:00
Beto e9c3d7e40c feat: upgrade to sdk 55 canary (#25810) 2025-12-14 20:15:40 +01:00
Dhairyashil Shinde c218a0afad feat(companion): Add caching, redesign ui, and refactor code (#25654) 2025-12-14 14:53:38 +00:00
Keith Williams b9a09117e3 chore: Remove team-labels action (#25862) 2025-12-14 14:31:13 +00:00
an_ifrah24 550caa6665 fix: added conditional check for the unkey logger to reduce the number of logs (#25858) 2025-12-14 12:35:57 +00:00
Benny Joo a8f43da472 refactor: Move Users Admin TRPC router from features package to trpc layer (#25816)
* move users admin router to trpc package

* users admin router

* update import path

* fix
2025-12-14 08:45:20 -03:00
43b6c63294 refactor: Move insights trpc router from features package to trpc layer (#25778)
* update imports

* move trpc router from features to trpc

* update imports

* wip

* update imports

* wip

* wip

* wip

* fix import

* create objectToCsv.ts

* rename

* fix: restore proper types and static imports in insights handlers

- Add proper type definitions for all handler options instead of using 'any'
- Restore static imports for PermissionCheckService and MembershipRole
- Remove dynamic imports that were introduced during refactoring

This ensures the refactoring is pure with no behavioral changes.

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix: update getEventTypeList import path after utils.ts deletion

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* remove handlers

* fix

* refactor

* wip

* rm

* wip

* select only needed + fix type error

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-12-14 08:44:36 -03:00
Benny Joo 9ef650ceed refactor: Move OrganizationOnboardingRepository from lib to /features/organizations (#25854)
* mv organization onboarding repository

* update imports
2025-12-14 08:42:21 -03:00
Benny Joo e394a03365 refactor: create DestinationCalendarRepository in /features/calendars (#25851)
* mv destination calendar repo

* fix
2025-12-14 08:41:13 -03:00
Benny Joo 972f983824 refactor: create PrismaOOORepository in /features/ooo (#25850)
* move to features

* update imports
2025-12-14 08:39:58 -03:00
sean-brydon 68a8fade89 add tests (#25566) 2025-12-14 19:14:15 +09:00
Benny Joo d81993892a refactor: create HostRepository in /features/host (#25842)
* mv test

* mv host repository

* update imports
2025-12-13 11:04:57 +00:00
Eesh MidhaandUdit Takkar d034e7aff7 fix: enforce team-only access on premium toggles in cal video (#25680) (#25681)
* fix(cal-video): enforce team-only access on premium toggles

* fix(cal-video): disable redirect URL field and add upgrade badge for non-team users

* chore: improvments

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
2025-12-12 21:04:30 +00:00
sean-brydon 13103c0881 feat: Improve error handling in ratelimit (#25223)
* Improve error handling in ratelimit

* Update route.ts

* Update route.ts

* Update route.ts

* address feedback
2025-12-12 20:39:23 +00:00
Dhairyashil Shinde 983af06787 fix: allow two dots in exclude email (#24968)
* add test and fix two dots bug in exclude email

* add test and fix two dots bug in exclude email
2025-12-13 02:06:42 +05:30
Benny Joo 29076d681b refactor: create CalVideoSettingsRepository in features package (#25841)
* add new repo

* wip
2025-12-12 19:53:28 +00:00
3cadf105d5 feat: add skipContactCreation toggle to EventTypeAppCard component (#25756)
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-12-12 18:55:19 +00:00
Peer Richelsen 411483a447 added OAuth badges (#25830) 2025-12-12 17:11:21 +00:00
Kartik Labhshetwar 223c967f81 style: add spacing and rounded corners to toolbar dropdown items (#25829) 2025-12-12 15:59:24 +00:00
Carina WollendorferandCarinaWolli 440b69138e treat web version as extension (#25834)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-12-12 15:05:33 +00:00
5a3f15357c fix: filter slots by requested date range to prevent date override leakage (#25822)
* fix: filter slots by requested date range to prevent date override leakage

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

* refactor: move filterSlotsByRequestedDateRange to private method

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-12 11:28:07 -03:00
Anik Dhabal Babu e46c1bb40c fix delete payment app (#25801) 2025-12-12 14:24:45 +00:00
Anik Dhabal Babu 758b820a92 fix: slugified eventype slug (#25791) 2025-12-12 14:02:00 +00:00
sean-brydon b46fb82695 feat: minium reschedule notice on events (#25575)
* wip

* add i18n and essential tests

* fix UI and add migration

* fixes

* add logic on book event

* restore comment

* remove dev debug box

* extract min reschedule notice to util

* one more replace

* add to disable reschedule component

* tidy up state

* restore lock

* restore file

* fix zod utils

* respond to cubic feedback

* fix type error

* unit tests

* add zod util

* build tests with nul minReshceuldeNotice

* fix stuff
2025-12-12 10:53:01 -03:00
Alex van Andel 71c9567169 fix: Lazy loaded Stripe proxy to avoid crash on BillingService instantiation (#25400) 2025-12-12 19:18:06 +05:30
Hariom BalharaandDevin AI 5918e66cc5 docs: link embed events to help center to avoid duplicate maintenance (#25811)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-12 13:41:15 +00:00
2ad24048b6 fix: use relative redirects in booking confirmation routes to fix localhost redirect issue (#25794)
* fix: use relative redirects in booking confirmation routes to fix localhost redirect issue

When Cal.com runs behind a reverse proxy, request.url may return the internal
server URL (localhost:3000) instead of the external URL. This causes users to
be redirected to localhost after confirming bookings via email links.

This fix changes the redirect URLs from using url.origin (which could be
localhost) to using relative URLs via new URL(path, request.url). This ensures
the browser resolves the redirect against whatever domain the user actually
requested, fixing the issue for self-hosted deployments behind proxies.

Fixes #20358

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

* test: add tests for booking confirmation redirect URL construction

Add unit tests for verify-booking-token and link API routes to ensure:
- Redirect URLs preserve the request origin (not hardcoded localhost)
- Redirect URLs use the correct booking path (/booking/{uid})
- Error messages are properly encoded in query params
- POST handler returns correct 303 status code

These tests verify the fix for #20358 where users were redirected to
localhost:3000 instead of the proper production URL after confirming
bookings via email links.

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

* fix: add second argument to GET/POST calls in test files to fix type errors

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-12 19:02:54 +05:30
Lauris SkraucisandPallav a69fe2ed00 docs: how to fetch managed event type slots (#24977)
Co-authored-by: Pallav <90088723+Pallava-Joshi@users.noreply.github.com>
2025-12-12 14:12:59 +02:00
sean-brydon a5000cb3b7 fix not found nc command (#25823) 2025-12-12 12:05:01 +00:00
Anik Dhabal Babu eb0cf10f67 fix: webhook template (#25783) 2025-12-12 11:55:09 +00:00
Eunjae Lee 728e1173cb chore: update AGENTS.md on running unit tests for specific test name (#25820) 2025-12-12 11:48:30 +00:00
Lauris Skraucis b4e773da69 chore: atoms rrHostSubsetIds changelog for release (#25819) 2025-12-12 10:49:19 +00:00
Hariom BalharaandDevin AI bf38eb8cb1 refactor: Use sessionStorage instead of localStorage for decoy booking data (#24450)
* feat: Add async spam check integration and decoy booking response

- Integrate SpamCheckService with handleNewBooking workflow
- Implement parallel spam check execution for minimal performance impact
- Add decoy booking response with localStorage-based success page
- Extract organization ID from event type for org-specific blocking
- Add comprehensive test coverage for spam detection scenarios
- Create reusable components for booking success cards
- Implement fail-open behavior to never block legitimate bookings

This builds on the spam blocker DI infrastructure from PR #24040 by
adding the actual integration into the booking flow and implementing
the decoy response mechanism to avoid revealing spam detection to
malicious actors.

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

* Do checks in paralle

* Fix leaking host name in title

* Reduce expoiry time localstorage

* refactor: Use sessionStorage instead of localStorage for decoy booking data

- Replace localStorage with sessionStorage for automatic expiration on tab close
- Remove timestamp tracking and TTL logic (no longer needed)
- Improve privacy by auto-clearing data when browser tab/window closes
- Update documentation to reflect sessionStorage behavior

This change addresses privacy concerns by ensuring decoy booking data
(including attendee email) is automatically removed when the user closes
the tab, rather than persisting for 5 minutes or requiring manual cleanup.

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

* feat: Add sessionStorage wrapper to webstorage module

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

* Reset RegularBookingService.ts to main's version exactly

* feat: Add 5-minute expiration timeout to decoy booking data

- Adds timestamp to DecoyBookingData interface
- Checks expiration when retrieving booking data
- Automatically removes expired data from sessionStorage
- Provides defense-in-depth against potential misuse
- Works alongside sessionStorage auto-clear on tab close

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-12 07:42:44 -03:00
Hariom Balhara 010ee0d066 add-tests- usePrefetch (#23918) 2025-12-12 07:39:57 -03:00
sean-brydon f0a7d69c7e fix (#25818) 2025-12-12 10:26:56 +00:00
Anik Dhabal Babu cb2509683d fix: unit test flake (#25780) 2025-12-12 10:28:29 +05:30
Alex van Andel e2ac05e82f chore: release v5.9.14
Post release / release (push) Canceled after 0s
Release Docker / Release AMD64 (push) Canceled after 0s
Release Docker / Release ARM (push) Canceled after 0s
2025-12-12 00:52:39 +00:00
Volnei Munhoz 34ab02702e fix react version (#25809) 2025-12-12 06:15:25 +05:30
Pasquale Vitiello 1da118a534 chore: update Next.js (#25807) 2025-12-12 00:42:45 +00:00
Kartik LabhshetwarandDhairyashil acf2f53daf fix: hide insights submenu for free users (#25754)
* fix: hide insights submenu for free users

* fix: allow navigation access while plan pending

* refactor: update upgrade link to insights in navigation

* fix: addressed all the issues

* fix: prevent flash of premium features for free users during loading

Change from optimistic to pessimistic loading so free users don't
briefly see the Insights submenu while plan status is being fetched.

---------

Co-authored-by: Dhairyashil <dhairyashil10101010@gmail.com>
2025-12-11 21:04:13 +00:00
Kartik LabhshetwarandUdit Takkar c7bb28d64c fix: show hidden badge on mobile view for event types (#25701)
* fix: show hidden badge on mobile view for event types

* fix: update event type hidden toggle in infinite list

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-12-11 20:31:51 +00:00
Kartik Labhshetwarandcubic-dev-ai[bot] 8dc8d87ad0 feat: add time shift badge for recurring events with shifted local times (#25568)
* feat: highlight recurring bookings with time shift badge across DST

* Update packages/lib/__tests__/timeShift.test.ts

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

* refactor: show time shift badge only on first shift in bookings view and occurrences

* refactor: update recurring bookings display logic and wrap booking title text

* refactor: add getFirstShiftFlags helper for time shift flags and update components

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-12-11 20:18:00 +00:00
Udit Takkar da4fec9c27 chore: add missing translation (#25753) 2025-12-12 01:45:43 +05:30
Hariom BalharaandDevin AI 0ecfd78e47 fix: Update timeZoneSchema to handle +00:00 timezone case and maintain IANA compliance (#25776)
* fix: Update timeZoneSchema to handle +00:00 timezone case and maintain IANA compliance

* fix: Use superRefine to properly handle +00:00 timezone and transform to UTC

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

* feat: Add logging for non-IANA timezone cases

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

* refactor: Simplify timeZoneSchema to use .refine() instead of superRefine

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

* chore: Remove warn log for +00:00 timezone to avoid spam

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

* chore: Remove logger from timeZone schema

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-11 19:46:13 +00:00
Udit Takkar f007fe8407 feat: system admin blocklist backend (#25077)
* feat: admin blocklist backend

* chore:

* refactor: use DI

* fix: tests

* refactor; improve error

* refactor; improve error

* perf: use bulk

* chore: some more

* refactor: feedback

* test: update test

* refactor: improvements

* refactor: improvements

* fix: remove

* refactor: move to service

* refactor: move to service

* chore: add logs

* refactor: remove default
2025-12-11 19:41:29 +00:00
Kartik LabhshetwarandDhairyashil e1324bd9c3 fix(settings): improve spacing between navigation child items (#25631)
* fix(settings): improve spacing between navigation child items

* chore: removing prettier formatting

* style: update dropdown item class names for consistent rounding

* style: add spacing and hover margin to navigation items

* style: add hover margin top to navigation item

* apply same ui changes for team profile menu

---------

Co-authored-by: Dhairyashil <dhairyashil10101010@gmail.com>
2025-12-11 19:09:32 +00:00
Alex van Andel ca152d73f2 chore: release v5.9.13
Post release / release (push) Canceled after 0s
Release Docker / Release AMD64 (push) Canceled after 0s
Release Docker / Release ARM (push) Canceled after 0s
2025-12-11 12:44:04 +00:00
Hariom Balhara 6f0c09d2be feat: add booker lifecycle SDK events for embed tracking (#25569)
* feat: add embed prerendering support and enhance event handling

- Introduced `useIsEmbedPrerendering` hook to determine if the embed is in prerender mode.
- Updated `useAvailabilityEvents` to prevent firing events during prerendering.
- Added `bookerLoadedEvent` and `availabilityRefreshed` types to `EventDataMap`.
- Implemented `useFirebookerLoadedEvent` to manage firing the booker loaded event conditionally.
- Refactored event firing logic in `useSchedule` and `BookerWebWrapper` components to utilize new hooks.

* feat: add embed prerendering support and enhance event handling

- Introduced `useIsEmbedPrerendering` hook to determine if the embed is in prerender mode.
- Updated `useAvailabilityEvents` to prevent firing events during prerendering.
- Added `bookerLoadedEvent` and `availabilityRefreshed` types to `EventDataMap`.
- Implemented `useFirebookerLoadedEvent` to manage firing the booker loaded event conditionally.
- Refactored event firing logic in `useSchedule` and `BookerWebWrapper` components to utilize new hooks.

* feat: enhance embed event handling and introduce link reopening detection

- Added `useEmbedReopened` hook to track when the embed is reopened.
- Updated `BookerWebWrapper` to reset event firing state upon embed reopening.
- Refactored event firing logic to use `bookerViewed` instead of `bookerLoadedEvent`.
- Introduced scheduling for event firing in `useSchedule` to ensure correct order of events during prerendering.

* feat: add lifecycle diagrams for inline and modal embeds

- Introduced `inline-embed-lifecycle.mermaid` and `modal-embed-lifecycle.mermaid` files to visualize the lifecycle events and states of inline and modal embeds.
- Updated `LIFECYCLE.md` to reference the new diagrams and provide a clearer explanation of the embed lifecycle processes.
- Added `modal-prerendering-flow.mermaid` to illustrate the prerendering flow for modal embeds.
- Enhanced the routing playground with new features and improved event handling for availability and booking events.

* refactor: update event handling for booker lifecycle events

- Replaced `availabilityLoaded` event with `bookerReady` to better reflect the state when the booker view is fully loaded and ready for interaction.
- Updated related documentation and diagrams to reflect changes in event triggers and descriptions.
- Adjusted internal state management to track `viewId` instead of `reopenCount` for distinguishing between initial views and reopens.
- Added tests for new event handling logic to ensure correct firing of `bookerViewed`, `bookerReopened`, and `bookerReady` events.

* refactor: enhance embed event handling and state management

- Updated event handling for booker lifecycle events, replacing `resetViewVariables` with `resetPageData` to manage page-specific state.
- Introduced new utility functions for managing event firing states and reload initiation.
- Refactored `fireBookerViewedEvent` and `fireBookerReadyEvent` to utilize the new state management functions.
- Added comprehensive tests for the updated event handling logic and state resets to ensure correct functionality across various scenarios.

* refactor: update embed iframe configuration and utility functions

- Reduced `slotsStaleTimeMs` from 30 seconds to 10 seconds and `iframeForceReloadThresholdMs` from 100 seconds to 30 seconds for improved responsiveness.
- Refactored utility functions to use `isBrowser` for client-side checks instead of `isClientSide`.
- Removed unused `isPrerendering` function and updated related documentation for clarity.
- Enhanced event handling by exporting `useBookerEmbedEvents` from the appropriate module for better accessibility.

* refactor: update embed iframe configuration and utility functions

- Reduced `slotsStaleTimeMs` from 30 seconds to 10 seconds and `iframeForceReloadThresholdMs` from 100 seconds to 30 seconds for improved responsiveness.
- Refactored utility functions to use `isBrowser` for client-side checks instead of `isClientSide`.
- Removed unused `isPrerendering` function and updated related documentation for clarity.
- Enhanced event handling by exporting `useBookerEmbedEvents` from the appropriate module for better accessibility.

* fix cubic feedback
2025-12-11 09:11:38 -03:00
Dhairyashil Shinde f6642c17f8 feat: optimize getLuckyUser to skip data fetching for single user (#25337)
- Add early return when only one user is available
- Prevents unnecessary database queries, calendar API calls, and OOO checks
- Add comprehensive test to verify optimization works correctly

Fixes #19503 [CAL-5212]
2025-12-11 08:50:57 -03:00
Dhairyashil Shinde 26b4385679 feat: Add public OOO notes display on booking pages (#25471)
* feat: ooo message on booking page

* make ooo days selectable even when no redirect booking

* handle long notes

* remove unused i18n key

* Private notes stay private on the server, No accidental data leaks through client-side payloads

* address cubics comments

* fix: replace toggle with checkbox for OOO note visibility

- Replace Switch with Checkbox for "show note publicly" option
- Remove "OOO Message:" prefix from displayed notes on booking page
- Update i18n text to "Show note on public booking page"
- Remove unused ooo_message i18n key

* fix the accessibility issue by using proper htmlFor and id association

* only allow selecting OOO dates when the note is public
2025-12-11 11:31:59 +00:00
Dhairyashil Shinde b220420630 fix: reset Cloudflare Turnstile token on signup errors (#24927)
* fix: cloudflare turnstile token reset

* fix: silently reset turnstile on invalid token error

* refactor: remove unused forwardRef logic from Turnstile component

- Remove forwardRef, useImperativeHandle, and useRef imports
- Remove unused TurnstileInstance type export
- Simplify to a plain function component
- The ref-based reset was replaced by key-based remount in signup-view

* refactor: remove redundant cfToken validation check

The submit button is already disabled when cfToken is missing,
making this defensive check unreachable during normal form flow.

* revert prettier formatiing

* chore: revert yarn.lock changes

* refactor(auth): use shared constant for cloudflare token error message

Replace hardcoded "Invalid cloudflare token" string with an exported
constant to prevent silent breakage if the error message changes.
2025-12-11 16:45:01 +05:30
Morgan 7d5e9a4e0e chore: hide rrHostSubsetIds and rrHostSubsetEnabled from api docs for now (#25769) 2025-12-11 11:12:10 +00:00
Rajiv Sahal 5253f26c94 fix: css for checkbox in light mode (#25782) 2025-12-11 10:48:59 +00:00
sean-brydon 9b09544c62 fix: try submit instead of form manual submition (#25789)
* try submit

* add toaster and logs
2025-12-11 10:35:09 +00:00
44104e1ba4 feat: gated features modal (#25296)
* feat: Modal for gated features

* use zustand

* refactor

* Apply suggestions from code review

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

* Update packages/ui/components/card/Card.tsx

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

* dismiss button fix and url fix

* fix: broken redirect to teams

* fix: type check and review comments

* add learn more tracking back

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-12-11 11:14:31 +01:00
Kartik Labhshetwar 191dcc45b5 style: add mb-2 to workflows page flex container (#25787) 2025-12-11 10:07:31 +00:00
Benny Joo 5de8576473 remove duplicate code for buildBaseWhereCondition (#25779) 2025-12-11 08:25:19 +00:00
Udit Takkar 6440c60c96 fix: use safeStringify (#25766) 2025-12-11 07:27:33 +00:00
Alex van Andel 016ae6ea4b fix: Add orgSlug to slots.getSchedule call (#25397)
* fix: Add orgSlug to slots.getSchedule call

* Don't pass null if orgSlug is missing
2025-12-11 16:10:40 +09:00
AbhishekandDhairyashil Shinde d3f7308b9f fix(validation): disallow whitespace-only team name (#25467)
* fix(validation): disallow whitespace-only team name

* fix validation at team profile

* fix validation for title at event type

---------

Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com>
2025-12-11 07:09:05 +00:00
Anik Dhabal Babu 060176cd08 Fix: Hide team members in social preview when team is private (#25770) 2025-12-11 04:17:56 +00:00
Dhairyashil Shindeandcubic-dev-ai[bot] a306a77678 feat: Include booking question response in csv file from insights page (#25454)
* feat: booking-question-phone-csv-insights-page

* feat: Include booking question responses in insights CSV download (#25453)

- Add custom question responses to CSV export
- Parse booking responses and eventType bookingFields
- Filter out system fields (email, name, etc.)
- Map field names to display labels
- Add dynamic columns for each custom question

Performance optimizations:
- Cache parsed bookingFields by eventTypeId (99% fewer parsing operations)
- Use Map for O(1) field label lookups (100x faster than array.find)
- Single-pass data processing (75% fewer iterations)
- Optimized attendee processing (67% reduction in loops)
- Reduced memory allocations (3x less memory usage)

Estimated 3-5x performance improvement for large datasets

* refactor: Remove optimization-related comments, keep code flow comments

* fix: Include attendee phone numbers in CSV export

- Add phoneNumber field to attendees and seatsReferences query
- Collect phone numbers from attendees during processing
- Add attendeePhone1, attendeePhone2, etc. columns to CSV output
- Fixes missing attendee phone numbers in insights CSV download

* fix: Handle falsy values correctly in custom responses

Previously, falsy values like 0 or false were incorrectly skipped
because !answer treats them as empty. This caused issues with:
- Numeric answers: 0, -1, etc.
- Boolean answers: false
- Empty strings: '' (intentionally skipped)

Now explicitly check for null, undefined, empty arrays, and empty strings
while allowing legitimate falsy values (0, false) to pass through.

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

* feat(insights): add phone numbers to CSV export

- Add attendeePhone1, attendeePhone2, etc. columns with fallback to booking responses
- Add columns for custom phone-type booking questions
- Exclude system phone fields (attendeePhoneNumber, smsReminderNumber) from duplicate columns

* fix: address PR review comments

- Move SYSTEM_PHONE_FIELDS constant to packages/lib/bookings/SystemField.ts
- Restore comments

---------

Co-authored-by: cubic-dev-ai[bot] <cubic-dev-ai[bot]@users.noreply.github.com>
2025-12-10 14:51:09 +00:00
Eunjae Lee 3986d61f40 feat(bookings): improve bookings redesign (#25251)
* use booking.uid instead of booking.id for url param

* show timezone on calendar

* fix type

* restore horizontal tab and remove header and subtitle

* clean up sidebar items

* fix event propagation from attendees

* fetch all statuses except for cancelled on calendar view

* clean up styles of the badges on BookingListItem

* fix useMediaQuery

* add close button to the header

* add assignment reason to the details sheet

* use separator row

* use ToggleGroup for the top bookings tab

* move ViewToggleButton

* resize the action button

* remove wrong prop

* fix type error

* fix type error

* hide view toggle button on mobile (and fix the breakpoint)

* remove unused e2e tests

* fix e2e tests

* hide toggle button when feature flag is off

* update skeleton

* improve attendees on booking list item and slide over

* improve attendee dropdown

* fix type error

* move query to containers

* select attendee email

* infinite fetching for calendar view

* update styles

* fix compatibility

* fix: add backward compatibility for status field in getAllUserBookings

* increase calendar height

* fix type error

* support Member filter only for admin / owners

* add debug log (TEMP)

* add event border color

* show Reject / Accept buttons on BookingDetailsSheet

* move description section to the top

* update When section

* update style of Who section

* add CancelBookingDialog WIP

* fix CancelBookingDialog

* increase clickable area

* add schedule info section WIP

* fix flaky reject button

* fixing reschedule info WIP

* add fromReschedule index to Booking

* improve rescheduled information

* improve reassignment

* fix type error

* fix unit test

* respect user's weekStart value on the booking calendar view

* update debug log

* improve payment section

* clean up

* fix log message

* reposition filters on list view

* fix bookings controller api2 e2e test

* clean up file by extracting logic into custom hooks

* rename files

* merge BookingCalendar into its container

* extract logic into separate hook files

* remove redundant logic

* rearrange items on calendar view

* add WeekPicker

* extract filter button

* responsive header on list view

* horizontal scroll for ToggleGroup WIP

* fix type error

* fix cancelling recurring event

* address feedback

* fix e2e tests

* fix unit test

* fix e2e tests

* make hover style more visible for ToggleGroup

* fix margin on CancelBookingDialog

* update styles on the slide over (mostly font weight)

* update style of CancelBookingDialog

* update styles

* update margin top for the header

* refactor getBookingDetails handler

* fix gap in who section

* auto-filter the current user on the calendar view

* calculate calendar height considering top banners

* improve booking details sheet interaction without overlay

* update calendar event styles

* update reject dialog style

* put uid first in the query params

* fix class name

* memoize functions in useMediaQuery

* query attendee with id instead of email

* update margins

* replace TRPCError with ErrorWithCode

* move calculation outside loop

* remove dead code
2025-12-10 13:40:04 +00:00
Eunjae LeeandDevin AI 41b71d845c docs: add ErrorWithCode vs TRPCError guidelines and packages/features import restrictions (#25751)
* docs: add ErrorWithCode vs TRPCError guidelines and packages/features import restrictions

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

* fix: correct ErrorWithCode syntax with proper imports and Factory pattern

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

* docs: update AGENTS.md with ErrorWithCode guidance for non-tRPC files

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

* docs: reduce duplication by linking to knowledge-base.md for error handling details

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-10 13:39:27 +00:00
sean-brydon e6bfdd9ebb fix (#25764) 2025-12-10 13:22:40 +00:00
devin-ai-integration[bot]andlauris@cal.com f4223d1657 docs: fix grammar, redundant word, and simplify OAuth client docs (#25747)
* docs: fix grammar and redundant word in managed organizations guide

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

* docs: simplify OAuth client fetching paragraphs per Pedro's feedback

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: lauris@cal.com <lauris@cal.com>
2025-12-10 17:17:23 +05:30
Volnei Munhoz b0bf0aaec2 upgrade lingo.dev (#25748) 2025-12-10 11:41:39 +00:00
e4fde4dc16 fix: Playwright user cleanup deadlock in E2E (#25677)
* fix: increase Docker build MAX_OLD_SPACE_SIZE to 8192 to fix OOM

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

* fix

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-10 17:04:09 +05:30
Kartik Labhshetwar 6c21a12a88 fix(phone-input): resolve styling inconsistencies in focus states and hover effects (#25744) 2025-12-10 11:25:38 +00:00
sean-brydon 34e1122667 watch invites to ensure reactivity i (#25755) 2025-12-10 11:22:31 +00:00
6b62557a92 feat: add hostSubsetIds parameter for round robin host filtering (#25627)
* feat: add hostSubsetIds parameter for round robin host filtering

Add support for filtering round robin event type hosts via API v2.
When hostSubsetIds is provided, only the specified hosts are considered
for availability calculation and booking assignment.

Changes:
- Add hostSubsetIds to slots API input (GET /slots/available)
- Add hostSubsetIds to booking API input (POST /bookings)
- Update _findQualifiedHostsWithDelegationCredentials to filter by hostSubsetIds
- Pass hostSubsetIds through all layers: API -> tRPC -> slots/booking services

This allows API consumers to request availability and create bookings
for a subset of hosts within a round robin event type.

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

* chore: add e2e tests

* chore: add enableHostSubset team event-type setting

* fixup! chore: add enableHostSubset team event-type setting

* fix tests

* fix tests

* improve isWithinRRHostSubset

* rename to rrHost subset

* fix ai review

* fix: add booker platform wrapper rrHostSubsetIds prop

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-10 10:37:39 +00:00
300b9c916d fix: preserve metadata fields during partial event type updates (#25621)
* fix: preserve metadata fields during partial event type updates

- Fix multipleDuration (lengthInMinutesOptions) being reset to undefined on partial updates
- Fix bookerLayouts being reset to undefined on partial updates
- Fix requiresConfirmationThreshold being reset to undefined on partial updates
- Add e2e test for lengthInMinutesOptions preservation

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

* test: expand metadata preservation test to include bookerLayouts and confirmationPolicy

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-10 10:33:01 +00:00
devin-ai-integration[bot]andlauris@cal.com 5ac374374c chore: delete changeset files for atoms release (#25746)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: lauris@cal.com <lauris@cal.com>
2025-12-10 15:43:12 +05:30
Benny Joo 09a4247aa1 refactor: migrate schedule utils from TRPC to ScheduleRepository to break circular dependency (#24764)
* remove util files

* wip

* wip

* wip

* wip

* wip

* wip

* add test file

* refactors

* refactors

* fix type error

* simplify

* host repository test

* di
2025-12-10 12:01:48 +02:00
Volnei MunhozandDevin AI eb01e2f9d8 fix(companion): update react-native-reanimated to Expo 54 compatible version (#25741)
The Android build was failing with errors like:
- cannot find symbol: ReactViewBackgroundDrawable
- cannot find symbol: TRACE_TAG_REACT_JAVA_BRIDGE
- cannot find symbol: getRuntimeExecutor()
- no suitable method found for updateLayout

Root cause: react-native-reanimated ~3.10.1 is incompatible with React Native 0.81.5 (used by Expo SDK 54).

Fix: Update to Expo 54 bundled versions using 'npx expo install':
- react-native-reanimated: ~3.10.1 → ~4.1.1
- react-native-worklets: ^0.6.1 → 0.5.1

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-10 01:24:50 +00:00
Alex van Andel 20efb566b2 chore: release v5.9.12
Post release / release (push) Canceled after 0s
Release Docker / Release AMD64 (push) Canceled after 0s
Release Docker / Release ARM (push) Canceled after 0s
2025-12-10 01:09:32 +00:00
d9ebf26e21 chore: standarize rate limit structure (#25736)
* feat: add toggle to opt out of booking title translation in instant meetings (#25547)

* feat: add toggle to opt out of booking title translation in instant meetings

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

* fix: add autoTranslateTitleEnabled to test builder

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

* fix: respect autoTranslateTitleEnabled toggle in InstantBookingCreateService

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

* fix: fetch autoTranslateTitleEnabled directly in InstantBookingCreateService

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

* refactor: rename autoTranslateTitleEnabled to autoTranslateInstantMeetingTitleEnabled

- Renamed field to clarify it only applies to instant meeting title translation
- Moved Prisma query to EventTypeRepository.findInstantMeetingConfigById()
- Removed title translation logic from update.handler.ts (flag only controls instant meeting title)
- Updated all references across the codebase
- Added new i18n translation keys for the renamed field

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

* fix: add autoTranslateInstantMeetingTitleEnabled to test destructuring patterns

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

* fix: use Prisma.TeamCreateInput type for metadata in test helper

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

* fix: add autoTranslateInstantMeetingTitleEnabled to mockUpdatedEventType in test

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

* refactor: use generic findByIdMinimal instead of business-specific method

- Add autoTranslateInstantMeetingTitleEnabled to eventTypeSelect constant
- Remove findInstantMeetingConfigById from EventTypeRepository
- Update InstantBookingCreateService to use findByIdMinimal

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

* refactor: remove autoTranslateInstantMeetingTitleEnabled from eventTypeSelect (not needed for findByIdMinimal)

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

* feat: add autoTranslateInstantMeetingTitleEnabled to event type form defaults

- Rename shouldTranslateTitle to shouldAutoTranslateInstantMeetingTitle for clarity
- Add autoTranslateInstantMeetingTitleEnabled to findById and findByIdForOrgAdmin selects
- Add autoTranslateInstantMeetingTitleEnabled to useEventTypeForm defaultValues

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

* fix: only update autoTranslateInstantMeetingTitleEnabled when explicitly provided

Fixes issue where omitting the field in update requests would overwrite
the saved opt-out setting with the default value (true). Now the field
is only included in the update payload when explicitly provided.

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

---------

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

* chore: Create standard for rate limits

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-10 01:04:21 +00:00
Volnei Munhoz cb13874574 Fix react version for lingo.dev (#25740) 2025-12-10 01:01:37 +00:00
Volnei Munhoz 1fa02d957e sync yarn.lock (#25737) 2025-12-09 20:19:22 +00:00
Benny JooandDevin AI 529ca159f7 refactor: remove TRPCError handling from getServerErrorFromUnknown (#25691)
* refactor: remove TRPCError handling from getServerErrorFromUnknown

This removes the @trpc/server dependency from packages/lib by removing
TRPCError handling from getServerErrorFromUnknown.ts.

packages/lib is meant to be a general utility library that other packages
depend on, so it shouldn't import tRPC. The callers that need to handle
TRPCError (like onErrorHandler.ts and defaultResponder.ts) already handle
it explicitly before calling getServerErrorFromUnknown, so this change
has no behavioral impact.

Changes:
- Remove TRPCError and getHTTPStatusCodeFromError imports
- Remove TRPCError instanceof check from getServerErrorFromUnknown
- Update docstring to note that TRPCError is not handled
- Remove TRPCError-related tests (they belong in the tRPC layer)
- Fix lint warnings in Prisma error tests (replace 'as any' with proper type)

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* wip

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-09 16:21:55 -03:00
devin-ai-integration[bot]andlauris@cal.com be12ac75b4 docs: add managed organizations documentation (#25735)
- Create docs/platform/guides/managed-orgs.mdx with guide for creating managed organizations and their OAuth clients via the API
- Add entry to docs/mint.json navigation menu in the Platform Guides section

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: lauris@cal.com <lauris@cal.com>
2025-12-09 18:49:54 +00:00
Eunjae LeeandDevin AI 68cdfd42b1 docs: add Repository + DTO pattern guidance for Prisma type isolation (#25730)
* docs: add Repository + DTO pattern guidance for Prisma type isolation

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

* docs: add 'Why this pattern?' section explaining benefits of Repository + DTO

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

* docs: condense Repository + DTO pattern guidance for readability

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-09 18:26:02 +00:00
Pedro CastroandUdit Takkar 3ebac0194e fix: block Google Meet installation without Google Calendar dependency (#25513)
* fix: block Google Meet installation without Google Calendar dependency

  - Prevent users from installing Google Meet when Google Calendar is not connected
  - Update AppDependencyComponent to show error state for unmet dependencies
  - Fix InstallAppButtonChild to respect disabled prop from dependencies check
  - Ensure consistent dependency validation across App Store and app detail pages

  Fixes #25497

* test: add unit tests for dependency validation
- Add tests for AppDependencyComponent (8 tests)
- Add tests for InstallAppButtonChild (5 tests)
- Cover dependency blocking behavior
- Test visual state changes (bg-error/bg-subtle)

* test: resolve type-check error in InstallAppButtonChild tests

- Add complete MockCredential type definition
- Create factory function to generate typed mock credentials
- Eliminate code duplication across test cases

* fix: remove redundant disabled prop assignment

Disabled state is already passed through via props.disableInstall
which gets converted to props.disabled in InstallAppButtonWithoutPlanCheck.
This change addresses the review comment to avoid unnecessary duplication

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-12-09 17:56:20 +00:00
Rajiv Sahal 6bdf320640 fix: dont pass any default values for new oauth client creation (#25734) 2025-12-09 16:34:59 +00:00
Lauris Skraucis cdfdce533e chore: atoms release changelog and package.json (#25733) 2025-12-09 16:28:33 +00:00
PallavandAmit Sharma 5e29a7fa2a feat: add option to ignore guests in HubSpot integration and update related schemas (#25727)
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
2025-12-09 15:21:33 +00:00
Pallavandcubic-dev-ai[bot] 97377c4554 docs: updated the steps to connect hubspot (#25724)
* updated the stpes to connect the app

* chore

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

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-12-09 15:06:43 +00:00
Eunjae LeeandDevin AI acc82f808d docs: instruct to use ast-grep > ripgrep > grep for searching (#25725)
* docs: instruct to use ripgrep with grep fallback

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

* docs: update search tool preference to ast-grep > ripgrep > grep

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-09 14:13:06 +00:00
3a59cd41cc feat: add toggle to opt out of booking title translation in instant meetings (#25547)
* feat: add toggle to opt out of booking title translation in instant meetings

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

* fix: add autoTranslateTitleEnabled to test builder

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

* fix: respect autoTranslateTitleEnabled toggle in InstantBookingCreateService

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

* fix: fetch autoTranslateTitleEnabled directly in InstantBookingCreateService

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

* refactor: rename autoTranslateTitleEnabled to autoTranslateInstantMeetingTitleEnabled

- Renamed field to clarify it only applies to instant meeting title translation
- Moved Prisma query to EventTypeRepository.findInstantMeetingConfigById()
- Removed title translation logic from update.handler.ts (flag only controls instant meeting title)
- Updated all references across the codebase
- Added new i18n translation keys for the renamed field

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

* fix: add autoTranslateInstantMeetingTitleEnabled to test destructuring patterns

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

* fix: use Prisma.TeamCreateInput type for metadata in test helper

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

* fix: add autoTranslateInstantMeetingTitleEnabled to mockUpdatedEventType in test

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

* refactor: use generic findByIdMinimal instead of business-specific method

- Add autoTranslateInstantMeetingTitleEnabled to eventTypeSelect constant
- Remove findInstantMeetingConfigById from EventTypeRepository
- Update InstantBookingCreateService to use findByIdMinimal

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

* refactor: remove autoTranslateInstantMeetingTitleEnabled from eventTypeSelect (not needed for findByIdMinimal)

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

* feat: add autoTranslateInstantMeetingTitleEnabled to event type form defaults

- Rename shouldTranslateTitle to shouldAutoTranslateInstantMeetingTitle for clarity
- Add autoTranslateInstantMeetingTitleEnabled to findById and findByIdForOrgAdmin selects
- Add autoTranslateInstantMeetingTitleEnabled to useEventTypeForm defaultValues

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

* fix: only update autoTranslateInstantMeetingTitleEnabled when explicitly provided

Fixes issue where omitting the field in update requests would overwrite
the saved opt-out setting with the default value (true). Now the field
is only included in the update payload when explicitly provided.

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-09 13:51:51 +00:00
Anik Dhabal Babu 6ae2ce8ed9 fix: workflow email is not sending on form submission (#25722)
* fix: email not sending on form submission

* Remove handleCheckoutSessionExpired function

Removed the handleCheckoutSessionExpired function and its registration in webhookHandlers.
2025-12-09 13:24:41 +00:00
Anik Dhabal Babu 476460dad8 fix: Add custom payload template support for form submission webhooks (#25719)
* fix: Add custom payload template support for form submission webhooks

* delete

* Update applyTemplate function signature

Refactor applyTemplate function to accept specific data type.
2025-12-09 13:22:13 +00:00
Rajiv Sahal 19cadd18e3 fix: preserve description for event type (#25726) 2025-12-09 13:05:24 +00:00
Hariom Balhara e20cc960f1 feat: add uuid plumbing from API v2 to packages/features (#25721)
## What does this PR do?

This PR adds user UUID plumbing from API v2 controllers through to packages/features functions. This is preparatory work extracted from PR #25125 to support future audit logging functionality.

**Key changes:**
- API v2 controllers (2024-04-15, 2024-08-13) now extract and pass `userUuid` to downstream functions
- `handleMarkNoShow` and `CancelBookingInput` types now accept optional `userUuid` parameter
- `UserRepository.findUnlockedUserForSession` now selects `uuid` field
- Session middleware now includes `uuid` in the returned user object
- Fixed lint warning: changed `PromiseSettledResult<any>` to `PromiseSettledResult<unknown>`

**Refactoring (optimization):**
- Renamed `getOwnerId` → `getOwner` and `getOwnerIdRescheduledBooking` → `getOwnerRescheduledBooking`
- These methods now return `{ id: number; uuid: string } | null` instead of just `number | undefined`
- This eliminates redundant database calls by fetching user id and uuid in a single query

**Important:** This is plumbing-only - packages/features receives the `userUuid` but does not use it directly (note the `_userUuid` prefix). The actual audit logging usage will come in a follow-up PR.

Requested by: @hariombalhara (hariom@cal.com)

Link to Devin run: https://app.devin.ai/sessions/545209189f6347cd807bf1b336f9ac40

## Mandatory Tasks (DO NOT REMOVE)

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

## How should this be tested?

1. Run type checks: `yarn type-check:ci --force`
2. Verify the changes compile without new type errors related to uuid
3. The `userUuid` parameters are optional, so existing functionality should work unchanged

## Checklist

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

## Human Review Checklist

- [ ] Verify the `userUuid` parameter is intentionally unused (prefixed with `_userUuid`) - this is plumbing for future audit logging
- [ ] Verify all callers of `getOwner` and `getOwnerRescheduledBooking` correctly handle the new `{ id, uuid } | null` return type
- [ ] Confirm the change from `undefined` to `null` as the "not found" return value is handled consistently
- [ ] Verify the `uuid` field exists in the User model schema
2025-12-09 17:29:55 +05:30
Hariom BalharaandDevin AI f350542d0d feat: add booking audit action services for lib/actions folder (#25720)
Extract lib/actions folder changes from booking-audit-more-infra branch:
- AcceptedAuditActionService
- AttendeeAddedAuditActionService
- AttendeeNoShowUpdatedAuditActionService
- AttendeeRemovedAuditActionService
- CancelledAuditActionService
- CreatedAuditActionService (modified)
- HostNoShowUpdatedAuditActionService
- IAuditActionService (modified)
- LocationChangedAuditActionService
- ReassignmentAuditActionService
- RejectedAuditActionService
- RescheduleRequestedAuditActionService
- RescheduledAuditActionService

Also includes common/changeSchemas.ts required for type-checks to pass.

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-09 14:29:41 +05:30
Peer RichelsenandDhairyashil Shinde 257a49ce88 fix(companion): replaced localhost with prod url (#25576)
* replaced localhost with prod url

* nit

---------

Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com>
2025-12-09 07:47:17 +00:00
sean-brydon 75b93cebdb fix: troubleshooter team events + improve race condition (#25704)
* fix query to bring back teams + fix hover state

* fix team issues with troubleshooter
2025-12-09 07:37:21 +00:00
Anik Dhabal Babu 161ebdbbec perf: Fix N+1 queries and optimize database operations (#25630)
* perf: batch database operations and fix N+1 queries

* delete

* Fix condition for checking CalVideo location activity
2025-12-09 07:32:30 +00:00
Kartik Labhshetwar 978c5bdb95 fix(insights): align call history empty state with search (#25718) 2025-12-09 07:19:10 +00:00
Alex van Andel 895d039efc chore: release v5.9.11
Post release / release (push) Canceled after 0s
Release Docker / Release AMD64 (push) Canceled after 0s
Release Docker / Release ARM (push) Canceled after 0s
2025-12-09 03:08:28 +00:00
Anik Dhabal Babu 9f566bc8fc fix: orgbanner (#25706) 2025-12-08 20:35:11 +05:30
Volnei Munhoz 518e541177 chore: upgrade husky (#25698)
* bump husky version

* fix new husky version warning

* Fix husky version
2025-12-08 10:47:29 -03:00
Anik Dhabal Babu c1be922ee1 perf: Workflows are taking too long to load (#25705)
* perf: workflows are too slow

* Refactor eventTypeRepository for improved clarity

* Reintroduce ErrorCode and ErrorWithCode imports
2025-12-08 18:31:46 +05:30
Volnei Munhoz 967bba1e80 Remove coderrabit config (#25702) 2025-12-08 12:27:28 +00:00
Hariom Balhara 0fc26f782f feat: Add support to audit and view audit log with Booking CREATED action (#25468)
## What does this PR do?

This PR introduces the booking audit infrastructure with a viewer service to fetch, enrich, and format audit logs. This is the first iteration that includes only the CREATED action with versioned schema

Note: Additional audit actions, UI improvements to match with Figma will be taken care of in followup PRs, to ensure the PR size remains as small as possible(as it is large enough already)

Demo - Loom - https://www.loom.com/share/22c2f38b052b480b8be3716e1608eaf6

### Key Changes

**New Booking Audit Package** (`packages/features/booking-audit/`):

- `BookingAuditViewerService` - Fetches, enriches, and formats audit logs for display
- `BookingAuditTaskConsumer` - Processes audit tasks asynchronously via Tasker
- `CreatedAuditActionService` (v1) - Handles RECORD_CREATED action with versioned schema

**Repository Layer**:

- `BookingAuditRepository` - CRUD operations for audit records
- `AuditActorRepository` - Actor management (users, guests, system)
- `UserRepository.findByUuid()` - User lookup for actor enrichment

**UI Components**:

- New page at `/booking/logs/[bookinguid]` for viewing audit history
- Filterable audit log list with search, type, and actor filters
- Expandable log entries showing detailed change information

**Infrastructure**:

- `bookingAudit` Tasker task type for async processing
- `booking-audit` feature flag (disabled by default)
- tRPC endpoint `viewer.bookings.getAuditLogs`

### Updates since last revision

- **Refactored `Task` class to `TaskRepository`**: Converted all static methods to instance methods following the repository pattern. A singleton `Task` is exported for backward compatibility, so existing call sites continue to work unchanged.

### Important Notes for Reviewers

- **Permission check is TODO**: `checkPermissions()` in `BookingAuditViewerService` is not yet implemented
- **Only CREATED action supported**: Other actions will throw an error - additional actions will be added iteratively in follow-up PRs
- **Feature flag controlled**: System is behind `booking-audit` flag, disabled by default
- **UI strings need i18n**: Some UI text in `booking-logs-view.tsx` may need translation

### Human Review Checklist

- [ ] Verify permission enforcement strategy for audit log access
- [ ] Review DI wiring in module files
- [ ] Confirm error handling in `BookingAuditTaskConsumer` is appropriate for retry scenarios
- [ ] Check if UI strings need to be added to translation files
- [ ] Verify `TaskRepository` refactoring maintains backward compatibility (singleton export `Task` should work for all existing call sites)

## Mandatory Tasks (DO NOT REMOVE)

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

## How should this be tested?

Run the integration tests:

```bash
TZ=UTC yarn test packages/features/booking-audit/lib/service/BookingAuditViewerService.integration-test.ts
TZ=UTC yarn test packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.integration-test.ts
```

To test the UI:

1. Enable the `booking-audit` feature flag in the database
2. Create a booking (only CREATED action is supported in this PR)
3. Navigate to `/booking/logs/{bookingUid}` to view the audit trail

## Checklist

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

<!-- Link to Devin run: https://app.devin.ai/sessions/a8ae61c253b549429401c9651dcbcf44 -->
<!-- Requested by: hariom@cal.com (@hariombalhara) -->
2025-12-08 17:38:58 +05:30
sean-brydon fadd824d12 feat: onboarding v3 QA final roast (#25667)
* remove leading none

* remove back button if coming from teams/org onboarding

* add skip now
2025-12-08 11:35:44 +00:00
Volnei MunhozandDevin AI c1b7b6e83d fix: prevent OOO dialog from closing on outside click (#25699)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-08 11:23:09 +00:00
Volnei Munhoz e82a53a442 add fallback discovery for oauth (#25656) 2025-12-08 11:15:31 +00:00
a6578fdcd2 perf: handleChildrenEventType link old event-type with workflows using create/findMany (#25653)
* perf: link old event-type with workflows using create/findMany

* test: fix handleChildrenEventTypes test mocks for new workflow linking implementation

Update test to match the new implementation that uses findMany + createMany
instead of upsert for linking workflows to old event types.

- Add mock for workflowsOnEventTypes.findMany to return empty array
- Update assertion from upsert to findMany + createMany

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-08 13:11:21 +02:00
Eunjae LeeandDevin AI 4e68bcb021 docs: improve AGENTS.md and rename .agents to agents/ (#25665)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-08 11:06:59 +00:00
1e673f1df4 perf: handleChildrenEventTypes createManyAndReturn (#25652)
* perf: handleChildrenEventTypes createManyAndReturn

* fix: update test mocks for createManyAndReturn

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

* fix: use full EventType shape in createManyAndReturn mock return values

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-08 10:21:57 +00:00
Morgan b91f27dc80 perf: handleChildrenEventType eventtype user connect raw sql (#25650)
* perf: handleChildrenEventType eventtype user connect raw sql

* fixup! perf: handleChildrenEventType eventtype user connect raw sql

* remove user connect with prisma
2025-12-08 11:41:25 +02:00
Benny Joo becae27b45 refactor: migrate verifyCodeUnAuthenticated from TRPC layer to features layer (#25639)
* rm

* update test

* verifyCodeUnAuthenticated

* wip
2025-12-08 10:54:46 +02:00
Benny JooandDevin AI 584f5581fa refactor: move handleNotificationWhenNoSlots from trpc to features/slots (#25538)
Move handleNotificationWhenNoSlots.ts and its test file from
packages/trpc/server/routers/viewer/slots/ to packages/features/slots/
to resolve circular dependency issue where packages/features imports
from packages/trpc.

This file is not TRPC-specific and belongs in the features package.

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-08 10:54:21 +02:00
Anik Dhabal Babu e10c4c1cb5 fix: unable to reject the booking from email (#25673)
* fix: unable to reject the booking

* Refactor error handling for booking confirmation
2025-12-08 12:32:19 +05:30
Syed Ali Shahbaz c78552ce60 fix: OnDelete SetNull for watchlist delete on watchlistAudit (#25690)
* OnDelete SetNull for watchlist delete on watchlistAudit

* make watchlistId nullable

* type fix
2025-12-08 05:22:01 +00:00
Morgan 9631d0a64b chore: update add members to team (#25685) 2025-12-08 03:48:15 +00:00
1182460d5c chore: add permission checks to invite member and list team members handlers (#25679)
* chore: update invite team member handler

* chore: update invite team member handler

* fix: add mock for PermissionCheckService in inviteMember handler tests

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

* fix: add mocks for PBAC dependencies in inviteMember handler tests

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-07 16:32:55 +00:00
675 changed files with 34806 additions and 27655 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@calcom/atoms": minor
---
Fix Booker atom verify email dialog infinite re-render when entering verification code
-5
View File
@@ -1,5 +0,0 @@
---
"@calcom/atoms": minor
---
This PR introduces two variants for the calendar view atom, a generic calendar view and an event type specific view containing bookings data and busy times from connected calendars.
-5
View File
@@ -1,5 +0,0 @@
---
"@calcom/atoms": minor
---
Refactor SettingsHeader to accept onBackButtonClick callback instead of using useRouter directly.
-5
View File
@@ -1,5 +0,0 @@
---
"@calcom/atoms": minor
---
Add defaultPhoneCountry prop to BookerPlatformWrapper with ISO 3166-1 alpha-2 type safety
-5
View File
@@ -1,5 +0,0 @@
---
"@calcom/atoms": patch
---
This PR fixes the issue of connect atoms not working inside iframes. It also updates the atoms exports to include the `useAvailableSlots` hook.
+5
View File
@@ -0,0 +1,5 @@
---
"@calcom/atoms": minor
---
feat: Booker accepts rrHostSubsetIds to specify round robin hosts
-5
View File
@@ -1,5 +0,0 @@
---
"@calcom/atoms": major
---
feat: use tailwind v4 (breaking change)
-187
View File
@@ -1,187 +0,0 @@
language: en-US
tone_instructions: ''
early_access: false
enable_free_tier: true
reviews:
profile: chill
request_changes_workflow: false
high_level_summary: false
high_level_summary_placeholder: '@coderabbitai summary'
high_level_summary_in_walkthrough: false
auto_title_placeholder: '@coderabbitai'
auto_title_instructions: ''
review_status: true
commit_status: true
fail_commit_status: false
collapse_walkthrough: false
changed_files_summary: false
sequence_diagrams: false
estimate_code_review_effort: false
assess_linked_issues: true
related_issues: false
related_prs: true
suggested_labels: false
auto_apply_labels: false
suggested_reviewers: false
auto_assign_reviewers: false
poem: false
labeling_instructions: []
path_filters:
- '!**/node_modules/**'
- '!**/dist/**'
- '!**/build/**'
- '!**/.next/**'
path_instructions:
- path: "**/*.{ts,tsx,js,jsx}"
instructions: "Flag default exports and encourage named exports. Named exports provide better tree-shaking, easier refactoring, and clearer imports. Exempt main components like pages, layouts, and components that serve as the primary export of a module."
abort_on_close: true
disable_cache: false
auto_review:
enabled: true
auto_incremental_review: true
ignore_title_keywords: []
labels: []
drafts: false
base_branches: []
finishing_touches:
docstrings:
enabled: true
unit_tests:
enabled: true
pre_merge_checks:
docstrings:
mode: warning
threshold: 80
title:
mode: warning
requirements: ''
description:
mode: warning
issue_assessment:
mode: warning
tools:
ast-grep:
rule_dirs: []
util_dirs: []
essential_rules: true
packages: []
shellcheck:
enabled: true
ruff:
enabled: true
markdownlint:
enabled: true
github-checks:
enabled: true
timeout_ms: 180000
languagetool:
enabled: true
enabled_rules: []
disabled_rules: []
enabled_categories: []
disabled_categories: []
enabled_only: false
level: default
biome:
enabled: true
hadolint:
enabled: true
swiftlint:
enabled: true
phpstan:
enabled: true
level: default
phpmd:
enabled: true
phpcs:
enabled: true
golangci-lint:
enabled: true
yamllint:
enabled: true
gitleaks:
enabled: true
checkov:
enabled: true
detekt:
enabled: true
eslint:
enabled: true
flake8:
enabled: true
rubocop:
enabled: true
buf:
enabled: true
regal:
enabled: true
actionlint:
enabled: true
pmd:
enabled: true
cppcheck:
enabled: true
semgrep:
enabled: true
circleci:
enabled: true
clippy:
enabled: true
sqlfluff:
enabled: true
prismaLint:
enabled: true
pylint:
enabled: true
oxc:
enabled: true
shopifyThemeCheck:
enabled: true
luacheck:
enabled: true
brakeman:
enabled: true
dotenvLint:
enabled: true
htmlhint:
enabled: true
checkmake:
enabled: true
osvScanner:
enabled: true
chat:
art: true
auto_reply: true
integrations:
jira:
usage: auto
linear:
usage: auto
knowledge_base:
opt_out: false
web_search:
enabled: true
code_guidelines:
enabled: true
filePatterns: []
learnings:
scope: auto
issues:
scope: auto
jira:
usage: auto
project_keys: []
linear:
usage: auto
team_keys: []
pull_requests:
scope: auto
mcp:
usage: auto
disabled_servers: []
code_generation:
docstrings:
language: en-US
path_instructions: []
unit_tests:
path_instructions: []
+8
View File
@@ -127,6 +127,14 @@ NEXT_PUBLIC_HELPSCOUT_KEY=
NEXT_PUBLIC_FRESHCHAT_TOKEN=
NEXT_PUBLIC_FRESHCHAT_HOST=
# For holiday feature:
# Step-by-step: Get a Google Calendar API Key
# 1. Go to Google Cloud Console: https://console.cloud.google.com/
# 2. Select or Create a Project
# 3. Enable Google Calendar API (APIs & Services → Library , Search for Google Calendar API)
# 4. Create the API Key (APIs & Services → Credentials)
GOOGLE_CALENDAR_API_KEY=
# Google OAuth credentials
# To enable Login with Google you need to:
# 1. Set `GOOGLE_API_CREDENTIALS` below
+4
View File
@@ -0,0 +1,4 @@
/.yarn/** linguist-vendored
/.yarn/releases/* binary
/.yarn/plugins/**/* binary
/.pnp.* binary linguist-generated
+13 -8
View File
@@ -14,24 +14,29 @@ jobs:
name: Build Companion App & Extension
runs-on: buildjet-4vcpu-ubuntu-2204
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js
uses: actions/setup-node@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
node-version: 20
cache: npm
cache-dependency-path: companion/package-lock.json
bun-version: 1.3.0
cache: true
cache-dependency-path: companion/bun.lock
- name: Install dependencies
working-directory: companion
run: npm ci
run: bun install --frozen-lockfile
- name: Build Expo web app
working-directory: companion
env:
EXPO_NO_TELEMETRY: 1
CI: true
run: npx expo export --platform web
run: bunx expo export --platform web
- name: Build browser extension
working-directory: companion
run: npm run ext:build
run: bun run ext:build
-12
View File
@@ -15,18 +15,6 @@ jobs:
- uses: actions/labeler@v5
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
team-labels:
permissions:
contents: read
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: equitybee/team-label-action@main
with:
repo-token: ${{ secrets.EQUITY_BEE_TEAM_LABELER_ACTION_TOKEN }}
organization-name: calcom
ignore-labels: "admin, app-store, ai, api, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, dba, devops, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, self-hosted, sre, teams, webhooks, workflows, zapier"
apply-labels-from-issue:
runs-on: ubuntu-latest
-3
View File
@@ -1,6 +1,3 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
yarn lint-staged
yarn app-store:build && git add packages/app-store/*.generated.*
+202 -19
View File
@@ -1,30 +1,213 @@
# Cal.com Development Guide for AI Agents
**📖 Complete documentation is in the [.agents/](.agents/) directory.**
You are a senior Cal.com engineer working in a Yarn/Turbo monorepo. You prioritize type safety, security, and small, reviewable diffs.
## Quick Reference
## Do
### Essential Commands
- Use `select` instead of `include` in Prisma queries for performance and security
- Use `import type { X }` for TypeScript type imports
- Use early returns to reduce nesting: `if (!booking) return null;`
- Use `ErrorWithCode` for errors in non-tRPC files (services, repositories, utilities); use `TRPCError` only in tRPC routers
- Use conventional commits: `feat:`, `fix:`, `refactor:`
- Create PRs in draft mode by default
- Run `yarn type-check:ci --force` before concluding CI failures are unrelated to your changes
- Import directly from source files, not barrel files (e.g., `@calcom/ui/components/button` not `@calcom/ui`)
- Add translations to `apps/web/public/static/locales/en/common.json` for all UI strings
- Use `date-fns` or native `Date` instead of Day.js when timezone awareness isn't needed
- Put permission checks in `page.tsx`, never in `layout.tsx`
- Use `ast-grep` for searching if available; otherwise use `rg` (ripgrep), then fall back to `grep`
- `yarn dev` - Start development server
- `yarn build` - Build all packages
- `yarn lint:fix` - Lint and fix code
- `yarn type-check` - TypeScript checking
- `yarn test <filename> -- --integrationTestsOnly` - Run integration tests
- `yarn e2e <filename> --grep "<testName>"` - Run specific E2E test
## Don't
## Tool Preferences
- Never use `as any` - use proper type-safe solutions instead
- Never expose `credential.key` field in API responses or queries
- Never commit secrets or API keys
- Never modify `*.generated.ts` files directly - they're created by app-store-cli
- Never put business logic in repositories - that belongs in Services
- Never use barrel imports from index.ts files
- Never skip running type checks before pushing
- Never create large PRs (>500 lines or >10 files) - split them instead
### Search Tools Priority
## Commands
Use tools in this order of preference:
### File-scoped (preferred for speed)
1. **ast-grep** - For AST-based code searches (if available)
2. **rg (ripgrep)** - For fast text searches
3. **grep** - As fallback for text searches
```bash
# Type check - always run on changed files
yarn type-check:ci --force
## 📚 Detailed Documentation
# Lint single file
yarn eslint --fix path/to/file.tsx
- **[.agents/README.md](.agents/README.md)** - Complete development guide
- **[.agents/commands.md](.agents/commands.md)** - All build, test & dev commands
- **[.agents/knowledge-base.md](.agents/knowledge-base.md)** - Knowledge base & best practices
# Format single file
yarn prettier --write path/to/file.tsx
# Unit test specific file
yarn vitest run path/to/file.test.ts
# Unit test specific file + specific test
yarn vitest run path/to/file.test.ts --testNamePattern="specific test name"
# Integration test specific file
yarn test path/to/file.integration-test.ts -- --integrationTestsOnly
# Integration test specific file + specific test
yarn test path/to/file.integration-test.ts --testNamePattern="specific test name" -- --integrationTestsOnly
# E2E test specific file
PLAYWRIGHT_HEADLESS=1 yarn e2e path/to/file.e2e.ts
# E2E test specific file + specific test
PLAYWRIGHT_HEADLESS=1 yarn e2e path/to/file.e2e.ts --grep "specific test name"
```
### Project-wide (use sparingly)
```bash
# Development
yarn dev # Start dev server
yarn dx # Dev with database setup
# Build & check
yarn build # Build all packages
yarn lint:fix # Lint and fix all
yarn type-check # Type check all
# Tests (use TZ=UTC for consistency)
TZ=UTC yarn test # All unit tests
yarn e2e # All E2E tests
# Database
yarn prisma generate # Regenerate types after schema changes
yarn workspace @calcom/prisma db-migrate # Run migrations
```
## Boundaries
### Always do
- Run type check on changed files before committing
- Run relevant tests before pushing
- Use `select` in Prisma queries
- Follow conventional commits for PR titles
### Ask first
- Adding new dependencies
- Schema changes to `packages/prisma/schema.prisma`
- Changes affecting multiple packages
- Deleting files
- Running full build or E2E suites
### Never do
- Commit secrets, API keys, or `.env` files
- Expose `credential.key` in any query
- Use `as any` type casting
- Force push or rebase shared branches
- Modify generated files directly
## Project Structure
```
apps/web/ # Main Next.js application
packages/prisma/ # Database schema (schema.prisma) and migrations
packages/trpc/ # tRPC API layer (routers in server/routers/)
packages/ui/ # Shared UI components
packages/features/ # Feature-specific code
packages/app-store/ # Third-party integrations
packages/lib/ # Shared utilities
```
### Key files
- Routes: `apps/web/app/` (App Router)
- Database schema: `packages/prisma/schema.prisma`
- tRPC routers: `packages/trpc/server/routers/`
- Translations: `apps/web/public/static/locales/en/common.json`
- Workflow constants: `packages/features/ee/workflows/lib/constants.ts`
## Tech Stack
- **Framework**: Next.js 13+ (App Router in some areas)
- **Language**: TypeScript (strict)
- **Database**: PostgreSQL with Prisma ORM
- **API**: tRPC for type-safe APIs
- **Auth**: NextAuth.js
- **Styling**: Tailwind CSS
- **Testing**: Vitest (unit), Playwright (E2E)
- **i18n**: next-i18next
## Code Examples
### Good error handling
```typescript
// Good - Descriptive error with context
throw new Error(`Unable to create booking: User ${userId} has no available time slots for ${date}`);
// Bad - Generic error
throw new Error("Booking failed");
```
For which error class to use (`ErrorWithCode` vs `TRPCError`) and concrete examples, see [Error Types in knowledge-base.md](agents/knowledge-base.md#error-types).
### Good Prisma query
```typescript
// Good - Use select for performance and security
const booking = await prisma.booking.findFirst({
select: {
id: true,
title: true,
user: {
select: {
id: true,
name: true,
email: true,
}
}
}
});
// Bad - Include fetches all fields including sensitive ones
const booking = await prisma.booking.findFirst({
include: { user: true }
});
```
### Good imports
```typescript
// Good - Type imports and direct paths
import type { User } from "@prisma/client";
import { Button } from "@calcom/ui/components/button";
// Bad - Regular import for types, barrel imports
import { User } from "@prisma/client";
import { Button } from "@calcom/ui";
```
## PR Checklist
- [ ] Title follows conventional commits: `feat(scope): description`
- [ ] Type check passes: `yarn type-check:ci --force`
- [ ] Lint passes: `yarn lint:fix`
- [ ] Relevant tests pass
- [ ] Diff is small and focused (<500 lines, <10 files)
- [ ] No secrets or API keys committed
- [ ] UI strings added to translation files
- [ ] Created as draft PR
## When Stuck
- Ask a clarifying question before making large speculative changes
- Propose a short plan for complex tasks
- Open a draft PR with notes if unsure about approach
- Fix type errors before test failures - they're often the root cause
- Run `yarn prisma generate` if you see missing enum/type errors
## Extended Documentation
For detailed information, see the `agents/` directory:
- **[agents/README.md](agents/README.md)** - Architecture overview and patterns
- **[agents/commands.md](agents/commands.md)** - Complete command reference
- **[agents/knowledge-base.md](agents/knowledge-base.md)** - Domain knowledge and best practices
- **[agents/coding-standards.md](agents/coding-standards.md)** - Coding standards with examples
+3
View File
@@ -77,6 +77,9 @@ RUN scripts/replace-placeholder.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${N
FROM node:20 AS runner
WORKDIR /calcom
RUN apt-get update && apt-get install -y --no-install-recommends netcat-openbsd wget && rm -rf /var/lib/apt/lists/*
COPY --from=builder-two /calcom ./
ARG NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000
ENV NEXT_PUBLIC_WEBAPP_URL=$NEXT_PUBLIC_WEBAPP_URL \
+2 -2
View File
@@ -836,12 +836,12 @@ following
1. Open [HubSpot Developer](https://developer.hubspot.com/) and sign into your account, or create a new one.
2. From within the home of the Developer account page, go to "Manage apps".
3. Click "Create app" button top right.
3. Click "Create legacy app" button top right and select public app.
4. Fill in any information you want in the "App info" tab
5. Go to tab "Auth"
6. Now copy the Client ID and Client Secret to your `.env` file into the `HUBSPOT_CLIENT_ID` and `HUBSPOT_CLIENT_SECRET` fields.
7. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/hubspot/callback` replacing Cal.com URL with the URI at which your application runs.
8. In the "Scopes" section at the bottom of the page, make sure you select "Read" and "Write" for scope called `crm.objects.contacts`
8. In the "Scopes" section at the bottom of the page, make sure you select "Read" and "Write" for scopes called `crm.objects.contacts` and `crm.lists`.
9. Click the "Save" button at the bottom footer.
10. You're good to go. Now you can see any booking in Cal.com created as a meeting in HubSpot for your contacts.
@@ -27,6 +27,12 @@ import { User } from "@prisma/client";
- Use React children and context instead of passing props through multiple components
### ORM and Types
- Never import `@calcom/prisma/client` in features, services, UI, or handlers.
- Use repository DTOs or domain types instead.
- See "Repository + DTO Pattern and Method Conventions" in the knowledge base for details and examples.
### Security Rules
```typescript
+8 -2
View File
@@ -28,11 +28,17 @@
- `yarn test` - Run unit tests (vitest)
- `yarn test <filename>` - Run tests for specific file
- `yarn test <filename> -- --integrationTestsOnly` - Run integration tests for specific file
- `yarn test <filename> -t "<testName>" -- --integrationTestsOnly` - Run specific test by name
- `yarn test <filename> -t "<testName>"` - Run specific test by name for specific file
- `yarn tdd` - Run tests in watch mode
- `yarn test:ui` - Run tests with UI interface
### Integration Tests
- `yarn test -- --integrationTestsOnly` - Run integration tests (vitest)
- `yarn test <filename> -- --integrationTestsOnly` - Run integration tests for specific file
- `yarn test <filename> -t "<testName>" -- --integrationTestsOnly` - Run specific integration test by name for specific file
### End-to-End Tests
- `yarn e2e` - Run end-to-end tests (Playwright)
@@ -225,16 +225,38 @@ throw new Error("Booking failed");
### Error Types
Use `ErrorWithCode` for files that are not directly coupled to tRPC. The tRPC package has a middleware called `errorConversionMiddleware` that automatically converts `ErrorWithCode` instances into `TRPCError` instances.
```typescript
// ✅ Good - Use proper error classes
// ✅ Good - Use ErrorWithCode in non-tRPC files (services, repositories, utilities)
import { ErrorCode } from "@calcom/lib/errorCodes";
import { ErrorWithCode } from "@calcom/lib/errors";
// Option 1: Using constructor with ErrorCode enum
throw new ErrorWithCode(ErrorCode.BookingNotFound, "Booking not found");
// Option 2: Using the Factory pattern for common HTTP errors
throw ErrorWithCode.Factory.Forbidden("You don't have permission to view this");
throw ErrorWithCode.Factory.NotFound("Resource not found");
throw ErrorWithCode.Factory.BadRequest("Invalid input");
// ✅ Good - Use TRPCError only in tRPC routers/procedures
import { TRPCError } from "@trpc/server";
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid booking time slot",
});
// ❌ Bad - Using TRPCError in non-tRPC files
import { TRPCError } from "@trpc/server";
// Don't use TRPCError in services, repositories, or utility files
```
### packages/features Import Restrictions
Files in `packages/features/**` should NOT import from `trpc`. This keeps the features package decoupled from the tRPC layer, making the code more reusable and testable. Use `ErrorWithCode` for error handling in these files, and let the tRPC middleware handle the conversion.
## Basic Performance Guidelines
- Aim for O(n) or O(n log n) complexity, avoid O(n²)
@@ -306,9 +328,54 @@ import { Button } from "@calcom/ui/components/button";
- **Tests**: Same as source file + `.test.ts` or `.spec.ts`
- **Avoid**: Dot-suffixes like `.service.ts`, `.repository.ts` (except for tests, types, specs)
## Repository Method Conventions
## Repository + DTO Pattern and Method Conventions
Repositories should follow consistent naming and design patterns to promote reusability and maintainability.
We use a Repository + DTO pattern to isolate Prisma and the database from business logic. Repositories are the only layer that talks to Prisma and database models. All services, tRPC handlers, API controllers, workflows, and UI should depend on DTOs or domain types, not Prisma types.
DTOs (Data Transfer Objects) are simple TypeScript types or interfaces that represent our domain data in an ORM-agnostic way and are returned from repositories.
### Why this pattern?
- **Prevent type leaks**: Prisma type changes stay inside repositories instead of breaking services, handlers, and UI across dozens of files.
- **Enable safe refactors**: Business logic works against stable DTOs, so you can change storage details without touching higher layers.
- **Keep ORM swappable**: If we ever change ORM, only repository implementations change; services and handlers stay the same.
### What not to do: tight Prisma coupling in business logic
Do not import Prisma types in business logic. Prisma types belong only in the data access layer.
```typescript
// ❌ Bad – business logic depends on Prisma
import type { Webhook } from "@calcom/prisma/client";
export function sendPayload(webhook: Webhook) { /* ... */ }
// ✅ Good – business logic depends on DTO + repository
export interface WebhookDTO { id: string; url: string; }
export interface IWebhookRepository { findById(id: string): Promise<WebhookDTO | null>; }
export async function sendPayload(repo: IWebhookRepository, id: string) {
const webhook = await repo.findById(id);
// ...
}
```
### DTOs in repositories
Repositories should expose DTOs from their public methods and keep Prisma types internal. Inside the repository, use `Prisma.*GetPayload` and map rows to DTOs. The public methods should only return DTOs, never Prisma models.
```typescript
// DTO – ORM-agnostic type exported from the repository module
export interface BookingDTO {
id: string;
userId: number;
startTime: Date;
status: "PENDING" | "CONFIRMED" | "CANCELLED";
}
```
### Prisma boundaries
- **Allowed**: `packages/prisma`, repository implementations (`packages/features/**/repositories/*Repository.ts`), and low-level data access infrastructure.
- **Not allowed**: `packages/features/**` business logic (non-repository), `packages/trpc/**` handlers, `apps/web/**`, `apps/api/v2/**` services/controllers, and workflow/webhook/service layers.
### Method Naming Rules
+1 -1
View File
@@ -35,7 +35,7 @@
"@sentry/nextjs": "^9.15.0",
"bcryptjs": "^2.4.3",
"memory-cache": "^0.2.0",
"next": "^15.4.8",
"next": "15.4.10",
"next-api-middleware": "^1.0.1",
"next-axiom": "^0.17.0",
"next-swagger-doc": "^0.3.6",
@@ -125,7 +125,7 @@ export class BookingsController_2024_04_15 {
private readonly instantBookingCreateService: InstantBookingCreateService,
private readonly eventTypeRepository: PrismaEventTypeRepository,
private readonly teamRepository: PrismaTeamRepository
) {}
) { }
@Get("/")
@UseGuards(ApiAuthGuard)
@@ -270,6 +270,7 @@ export class BookingsController_2024_04_15 {
const res = await handleCancelBooking({
bookingData: bookingRequest.body,
userId: bookingRequest.userId,
userUuid: bookingRequest.userUuid,
arePlatformEmailsEnabled: bookingRequest.arePlatformEmailsEnabled,
platformClientId: bookingRequest.platformClientId,
platformCancelUrl: bookingRequest.platformCancelUrl,
@@ -300,7 +301,7 @@ export class BookingsController_2024_04_15 {
@Permissions([BOOKING_WRITE])
@UseGuards(ApiAuthGuard)
async markNoShow(
@GetUser("id") userId: number,
@GetUser() user: UserWithProfile,
@Body() body: MarkNoShowInput_2024_04_15,
@Param("bookingUid") bookingUid: string
): Promise<MarkNoShowOutput_2024_04_15> {
@@ -309,7 +310,8 @@ export class BookingsController_2024_04_15 {
bookingUid: bookingUid,
attendees: body.attendees,
noShowHost: body.noShowHost,
userId,
userId: user.id,
userUuid: user.uuid,
});
return { status: SUCCESS_STATUS, data: markNoShowResponse };
@@ -378,7 +380,7 @@ export class BookingsController_2024_04_15 {
): Promise<ApiResponse<InstantBookingCreateResult>> {
const oAuthClientId =
clientId?.toString() || (await this.getOAuthClientIdFromEventType(body.eventTypeId));
req.userId = (await this.getOwnerId(req)) ?? -1;
req.userId = (await this.getOwner(req))?.id ?? -1;
try {
const bookingReq = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
const instantMeeting = await this.instantBookingCreateService.createBooking({
@@ -405,30 +407,45 @@ export class BookingsController_2024_04_15 {
throw new InternalServerErrorException("Could not create instant booking.");
}
private async getOwnerId(req: Request): Promise<number | undefined> {
private async getOwner(req: Request): Promise<{ id: number; uuid: string } | null> {
try {
const bearerToken = req.get("Authorization")?.replace("Bearer ", "");
if (bearerToken) {
if (isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")) {
const strippedApiKey = stripApiKey(bearerToken, this.config.get<string>("api.keyPrefix"));
const apiKeyHash = sha256Hash(strippedApiKey);
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
return keyData?.userId;
} else {
// Access Token
const ownerId = await this.oAuthFlowService.getOwnerId(bearerToken);
return ownerId;
}
if (!bearerToken) {
return null;
}
let ownerId: number | null = null;
if (isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")) {
const strippedApiKey = stripApiKey(bearerToken, this.config.get<string>("api.keyPrefix"));
const apiKeyHash = sha256Hash(strippedApiKey);
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
ownerId = keyData?.userId ?? null;
} else {
// Access Token
ownerId = await this.oAuthFlowService.getOwnerId(bearerToken);
}
if (!ownerId) {
return null;
}
const user = await this.usersRepository.findById(ownerId);
if (!user) {
return null;
}
return { id: user.id, uuid: user.uuid };
} catch (err) {
this.logger.error(err);
return null;
}
}
private async getOwnerIdRescheduledBooking(
private async getOwnerRescheduledBooking(
request: Request,
platformClientId?: string
): Promise<number | undefined> {
): Promise<{ id: number; uuid: string } | null> {
if (
platformClientId &&
request.body.rescheduledBy &&
@@ -440,13 +457,15 @@ export class BookingsController_2024_04_15 {
);
}
if (request.body.rescheduledBy) {
if (request.body.rescheduledBy !== request.body.responses.email) {
return (await this.usersRepository.findByEmail(request.body.rescheduledBy))?.id;
if (request.body.rescheduledBy && request.body.rescheduledBy !== request.body.responses.email) {
const user = await this.usersRepository.findByEmail(request.body.rescheduledBy);
if (!user) {
return null;
}
return { id: user.id, uuid: user.uuid };
}
return undefined;
return null;
}
private async getOAuthClientIdFromEventType(eventTypeId: number): Promise<string | undefined> {
@@ -498,7 +517,8 @@ export class BookingsController_2024_04_15 {
}
}
const userId = await this.getOwnerId(req);
const owner = await this.getOwner(req);
const userId = owner?.id;
if (!userId) {
throw new UnauthorizedException(
@@ -561,12 +581,16 @@ export class BookingsController_2024_04_15 {
oAuthClientId?: string,
platformBookingLocation?: string,
isEmbed?: string
): Promise<NextApiRequest & { userId?: number } & OAuthRequestParams> {
): Promise<NextApiRequest & { userId?: number; userUuid?: string } & OAuthRequestParams> {
const requestId = req.get("X-Request-Id");
const clone = { ...req };
const userId = clone.body.rescheduleUid
? await this.getOwnerIdRescheduledBooking(req, oAuthClientId)
: await this.getOwnerId(req);
const owner = clone.body.rescheduleUid
? await this.getOwnerRescheduledBooking(req, oAuthClientId)
: await this.getOwner(req);
const userId = owner?.id;
const userUuid = owner?.uuid;
const oAuthParams = oAuthClientId
? await this.getOAuthClientsParams(oAuthClientId, this.transformToBoolean(isEmbed))
: DEFAULT_PLATFORM_PARAMS;
@@ -577,7 +601,7 @@ export class BookingsController_2024_04_15 {
oAuthClientId,
...oAuthParams,
});
Object.assign(clone, { userId, ...oAuthParams, platformBookingLocation });
Object.assign(clone, { userId, userUuid, ...oAuthParams, platformBookingLocation });
clone.body = {
...clone.body,
noEmail: !oAuthParams.arePlatformEmailsEnabled,
@@ -586,7 +610,7 @@ export class BookingsController_2024_04_15 {
if (oAuthClientId) {
await this.setPlatformAttendeesEmails(clone.body, oAuthClientId);
}
return clone as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
return clone as unknown as NextApiRequest & { userId?: number; userUuid?: string } & OAuthRequestParams;
}
async setPlatformAttendeesEmails(
@@ -612,9 +636,12 @@ export class BookingsController_2024_04_15 {
oAuthClientId?: string,
platformBookingLocation?: string,
isEmbed?: string
): Promise<NextApiRequest & { userId?: number } & OAuthRequestParams> {
): Promise<NextApiRequest & { userId?: number; userUuid?: string } & OAuthRequestParams> {
const clone = { ...req };
const userId = (await this.getOwnerId(req)) ?? -1;
const owner = await this.getOwner(req);
const userId = owner?.id ?? -1;
const userUuid = owner?.uuid;
const oAuthParams = oAuthClientId
? await this.getOAuthClientsParams(oAuthClientId, this.transformToBoolean(isEmbed))
: DEFAULT_PLATFORM_PARAMS;
@@ -628,6 +655,7 @@ export class BookingsController_2024_04_15 {
});
Object.assign(clone, {
userId,
userUuid,
...oAuthParams,
platformBookingLocation,
noEmail: !oAuthParams.arePlatformEmailsEnabled,
@@ -636,7 +664,7 @@ export class BookingsController_2024_04_15 {
if (oAuthClientId) {
await this.setPlatformAttendeesEmails(clone.body, oAuthClientId);
}
return clone as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
return clone as unknown as NextApiRequest & { userId?: number; userUuid?: string } & OAuthRequestParams;
}
private handleBookingErrors(
@@ -11,6 +11,7 @@ import {
ValidateNested,
isEmail,
Validate,
IsInt,
} from "class-validator";
import { ValidationOptions, registerDecorator } from "class-validator";
@@ -26,7 +27,7 @@ function ValidateBookingName(validationOptions?: ValidationOptions) {
propertyName: propertyName,
options: validationOptions,
validator: {
validate(value: any) {
validate(value: string | Record<string, string>): boolean {
if (typeof value === "string") {
return value.trim().length > 0;
}
@@ -226,4 +227,16 @@ export class CreateBookingInput_2024_04_15 {
@IsOptional()
@ApiPropertyOptional()
crmOwnerRecordType?: string;
/* @ApiPropertyOptional({
type: [Number],
description:
"For round robin event types, filter available hosts to only consider the specified subset of host user IDs. This allows you to book with specific hosts within a round robin event type.",
example: [1, 2, 3],
}) */
@ApiHideProperty()
@IsOptional()
@IsArray()
@IsInt({ each: true })
rrHostSubsetIds?: number[];
}
@@ -397,18 +397,18 @@ export class BookingsController_2024_08_13 {
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
async markNoShow(
@Param("bookingUid") bookingUid: string,
@Body() body: MarkAbsentBookingInput_2024_08_13,
@GetUser("id") ownerId: number
): Promise<MarkAbsentBookingOutput_2024_08_13> {
const booking = await this.bookingsService.markAbsent(bookingUid, ownerId, body);
async markNoShow(
@Param("bookingUid") bookingUid: string,
@Body() body: MarkAbsentBookingInput_2024_08_13,
@GetUser() user: ApiAuthGuardUser
): Promise<MarkAbsentBookingOutput_2024_08_13> {
const booking = await this.bookingsService.markAbsent(bookingUid, user.id, body, user.uuid);
return {
status: SUCCESS_STATUS,
data: booking,
};
}
return {
status: SUCCESS_STATUS,
data: booking,
};
}
@Post("/:bookingUid/reassign")
@HttpCode(HttpStatus.OK)
@@ -64,12 +64,14 @@ describe("Bookings Endpoints 2024-08-13", () => {
let team1EventTypeId: number;
let team2EventTypeId: number;
let team2RREventTypeId: number;
let phoneOnlyEventTypeId: number;
let collectiveEventWithoutHostsId: number;
let roundRobinEventWithoutHostsId: number;
const team1EventTypeSlug = `team-bookings-event-type-${randomString()}`;
const team2EventTypeSlug = `team-bookings-event-type-${randomString()}`;
const team2RREventTypeSlug = `team-bookings-rr-event-type-${randomString()}`;
const phoneOnlyEventTypeSlug = `team-bookings-event-type-${randomString()}`;
let phoneBasedBooking: BookingOutput_2024_08_13;
@@ -339,6 +341,50 @@ describe("Bookings Endpoints 2024-08-13", () => {
team2EventTypeId = team2EventType.id;
const team2RREventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "ROUND_ROBIN",
team: {
connect: { id: team2.id },
},
title: `team-bookings-2024-08-13-event-type-rr-${randomString()}`,
slug: team2RREventTypeSlug,
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
rrHostSubsetEnabled: true,
});
team2RREventTypeId = team2RREventType.id;
await hostsRepositoryFixture.create({
isFixed: false,
user: {
connect: {
id: teamUser2.id,
},
},
eventType: {
connect: {
id: team2RREventType.id,
},
},
});
await hostsRepositoryFixture.create({
isFixed: true,
user: {
connect: {
id: teamUser.id,
},
},
eventType: {
connect: {
id: team2RREventType.id,
},
},
});
await hostsRepositoryFixture.create({
isFixed: true,
user: {
@@ -603,6 +649,163 @@ describe("Bookings Endpoints 2024-08-13", () => {
}
});
});
it("should create a team 2 RR booking and use rrHostSubsetIds to force teamUser2 as host ", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 12, 0, 0)).toISOString(),
eventTypeId: team2RREventTypeId,
attendee: {
name: "bob2",
email: "bob2@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
meetingUrl: "https://meet.google.com/abc-def-ghi",
rrHostSubsetIds: [teamUser2.id],
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts.length).toEqual(1);
expect(data.hosts[0].id).toEqual(teamUser2.id);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString());
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(team2RREventTypeId);
expect(data.attendees.length).toEqual(1);
expect(data.attendees[0]).toEqual({
name: body.attendee.name,
email: body.attendee.email,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(data.meetingUrl).toEqual(body.meetingUrl);
expect(data.absentHost).toEqual(false);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
});
it("should create a team 2 RR booking and use rrHostSubsetIds to force teamUser as host ", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 12, 0, 0)).toISOString(),
eventTypeId: team2RREventTypeId,
attendee: {
name: "bob",
email: "bob@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
meetingUrl: "https://meet.google.com/abc-def-ghi",
rrHostSubsetIds: [teamUser.id],
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts.length).toEqual(1);
expect(data.hosts[0].id).toEqual(teamUser.id);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString());
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(team2RREventTypeId);
expect(data.attendees.length).toEqual(1);
expect(data.attendees[0]).toEqual({
name: body.attendee.name,
email: body.attendee.email,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(data.meetingUrl).toEqual(body.meetingUrl);
expect(data.absentHost).toEqual(false);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
});
it("should create a team 2 RR booking and use rrHostSubsetIds to force teamUser and teamUser2 as host ", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 14, 0, 0)).toISOString(),
eventTypeId: team2RREventTypeId,
attendee: {
name: "bob",
email: "bob@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
meetingUrl: "https://meet.google.com/abc-def-ghi",
rrHostSubsetIds: [teamUser.id, teamUser2.id],
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts.length).toEqual(1);
expect(data.hosts[0].id).toEqual(teamUser.id);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString());
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(team2RREventTypeId);
expect(data.attendees.length).toEqual(2);
expect(data.attendees.find((a) => a.email === body.attendee.email)).toBeDefined();
expect(data.attendees.find((a) => a.email === teamUser2.email)).toBeDefined();
expect(data.meetingUrl).toEqual(body.meetingUrl);
expect(data.absentHost).toEqual(false);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
});
});
describe("get team bookings", () => {
@@ -639,7 +842,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(1);
expect(data.length).toEqual(3);
expect(data[0].eventTypeId).toEqual(team2EventTypeId);
});
});
@@ -684,7 +887,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(3);
expect(data.length).toEqual(5);
expect(data.find((booking) => booking.eventTypeId === team1EventTypeId)).toBeDefined();
expect(data.find((booking) => booking.eventTypeId === team2EventTypeId)).toBeDefined();
});
@@ -845,6 +1048,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
await userRepositoryFixture.deleteByEmail(teamUser.email);
await userRepositoryFixture.deleteByEmail(teamUserEmail2);
await bookingsRepositoryFixture.deleteAllBookings(teamUser.id, teamUser.email);
await bookingsRepositoryFixture.deleteAllBookings(teamUser2.id, teamUser2.email);
await app.close();
});
});
@@ -919,34 +919,40 @@ export class BookingsService_2024_08_13 {
return await this.getBooking(recurringBookingUid, authUser);
}
async markAbsent(bookingUid: string, bookingOwnerId: number, body: MarkAbsentBookingInput_2024_08_13) {
const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body);
const bookingBefore = await this.bookingsRepository.getByUid(bookingUid);
async markAbsent(
bookingUid: string,
bookingOwnerId: number,
body: MarkAbsentBookingInput_2024_08_13,
userUuid?: string
) {
const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body);
const bookingBefore = await this.bookingsRepository.getByUid(bookingUid);
if (!bookingBefore) {
throw new NotFoundException(`Booking with uid=${bookingUid} not found.`);
}
if (!bookingBefore) {
throw new NotFoundException(`Booking with uid=${bookingUid} not found.`);
}
const nowUtc = DateTime.utc();
const bookingStartTimeUtc = DateTime.fromJSDate(bookingBefore.startTime, { zone: "utc" });
const nowUtc = DateTime.utc();
const bookingStartTimeUtc = DateTime.fromJSDate(bookingBefore.startTime, { zone: "utc" });
if (nowUtc < bookingStartTimeUtc) {
throw new BadRequestException(
`Bookings can only be marked as absent after their scheduled start time. Current time in UTC+0: ${nowUtc.toISO()}, Booking start time in UTC+0: ${bookingStartTimeUtc.toISO()}`
);
}
if (nowUtc < bookingStartTimeUtc) {
throw new BadRequestException(
`Bookings can only be marked as absent after their scheduled start time. Current time in UTC+0: ${nowUtc.toISO()}, Booking start time in UTC+0: ${bookingStartTimeUtc.toISO()}`
);
}
const platformClientParams = bookingBefore?.eventTypeId
? await this.platformBookingsService.getOAuthClientParams(bookingBefore.eventTypeId)
: undefined;
const platformClientParams = bookingBefore?.eventTypeId
? await this.platformBookingsService.getOAuthClientParams(bookingBefore.eventTypeId)
: undefined;
await handleMarkNoShow({
bookingUid,
attendees: bodyTransformed.attendees,
noShowHost: bodyTransformed.noShowHost,
userId: bookingOwnerId,
platformClientParams,
});
await handleMarkNoShow({
bookingUid,
attendees: bodyTransformed.attendees,
noShowHost: bodyTransformed.noShowHost,
userId: bookingOwnerId,
userUuid,
platformClientParams,
});
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid);
@@ -188,6 +188,7 @@ export class InputBookingsService_2024_08_13 {
location,
},
...this.getRoutingFormData(inputBooking.routing),
rrHostSubsetIds: inputBooking.rrHostSubsetIds,
};
}
@@ -484,6 +485,7 @@ export class InputBookingsService_2024_08_13 {
},
schedulingType: eventType.schedulingType,
...this.getRoutingFormData(inputBooking.routing),
rrHostSubsetIds: inputBooking.rrHostSubsetIds,
});
switch (timeBetween) {
@@ -1486,6 +1486,7 @@ describe("Event types Endpoints", () => {
locations: [],
schedulingType: "COLLECTIVE",
team: { connect: { id: team.id } },
rrHostSubsetEnabled: true,
});
return request(app.getHttpServer())
@@ -1498,6 +1499,7 @@ describe("Event types Endpoints", () => {
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(teamEventType.id);
expect(responseBody.data.teamId).toEqual(team.id);
expect(responseBody.data.rrHostSubsetEnabled).toEqual(true);
await teamRepositoryFixture.delete(team.id);
});
});
@@ -27,6 +27,7 @@ import {
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, BadRequestException } from "@nestjs/common";
import { slugify } from "@calcom/platform-libraries";
import { getApps, getUsersCredentialsIncludeServiceAccountKey } from "@calcom/platform-libraries/app-store";
import {
validateCustomEventName,
@@ -129,6 +130,7 @@ export class InputEventTypesService_2024_06_14 {
useDestinationCalendarEmail,
disableGuests,
bookerActiveBookingsLimit,
slug,
...rest
} = inputEventType;
const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy);
@@ -144,6 +146,8 @@ export class InputEventTypesService_2024_06_14 {
? this.transformInputBookerActiveBookingsLimit(bookerActiveBookingsLimit)
: {};
const slugifiedSlug = slugify(slug);
const metadata: EventTypeMetadata = {
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
requiresConfirmationThreshold:
@@ -153,6 +157,7 @@ export class InputEventTypesService_2024_06_14 {
const eventType = {
...rest,
slug: slugifiedSlug,
length: lengthInMinutes,
locations: locationsTransformed,
bookingFields: this.transformInputBookingFields(effectiveBookingFields),
@@ -209,6 +214,7 @@ export class InputEventTypesService_2024_06_14 {
useDestinationCalendarEmail,
disableGuests,
bookerActiveBookingsLimit,
slug,
...rest
} = inputEventType;
const eventTypeDb = await this.eventTypesRepository.getEventTypeWithMetaData(eventTypeId);
@@ -229,14 +235,21 @@ export class InputEventTypesService_2024_06_14 {
const metadata: EventTypeMetadata = {
...metadataTransformed,
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
requiresConfirmationThreshold:
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
multipleDuration: lengthInMinutesOptions,
...(bookerLayouts !== undefined
? { bookerLayouts: this.transformInputBookerLayouts(bookerLayouts) }
: {}),
...(confirmationPolicy !== undefined
? {
requiresConfirmationThreshold:
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
}
: {}),
...(lengthInMinutesOptions !== undefined ? { multipleDuration: lengthInMinutesOptions } : {}),
};
const eventType = {
...rest,
...(slug ? { slug: slugify(slug) } : {}),
length: lengthInMinutes,
locations: locations ? this.transformInputLocations(locations) : undefined,
bookingFields: effectiveBookingFields
@@ -1,6 +1,7 @@
import { SchedulesController_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/controllers/schedules.controller";
import { SchedulesRepository_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.repository";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
@@ -8,7 +9,7 @@ import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, UsersModule, TokensModule],
providers: [SchedulesRepository_2024_04_15, SchedulesService_2024_04_15],
providers: [SchedulesRepository_2024_04_15, SchedulesService_2024_04_15, PrismaScheduleRepository],
controllers: [SchedulesController_2024_04_15],
exports: [SchedulesService_2024_04_15, SchedulesRepository_2024_04_15],
})
@@ -1,12 +1,12 @@
import { CreateAvailabilityInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-availability.input";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { SchedulesRepository_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.repository";
import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.repository";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { updateSchedule } from "@calcom/platform-libraries/schedules";
import { ScheduleRepository } from "@calcom/platform-libraries/schedules";
import type { UpdateScheduleInput_2024_04_15 } from "@calcom/platform-types";
import type { PrismaClient } from "@calcom/prisma";
import type { Schedule } from "@calcom/prisma/client";
@@ -16,7 +16,8 @@ export class SchedulesService_2024_04_15 {
constructor(
private readonly schedulesRepository: SchedulesRepository_2024_04_15,
private readonly usersRepository: UsersRepository,
private readonly dbWrite: PrismaWriteService
private readonly dbWrite: PrismaWriteService,
private readonly prismaScheduleRepository: PrismaScheduleRepository
) {}
async createUserDefaultSchedule(userId: number, timeZone: string) {
@@ -53,8 +54,7 @@ export class SchedulesService_2024_04_15 {
const user = await this.usersRepository.findById(userId);
if (!user?.defaultScheduleId) return null;
return await ScheduleRepository.findDetailedScheduleById({
return await this.prismaScheduleRepository.findDetailedScheduleById({
scheduleId: user.defaultScheduleId,
isManagedEventType: undefined,
userId,
@@ -70,7 +70,7 @@ export class SchedulesService_2024_04_15 {
throw new NotFoundException(`User with ID=${userId} does not exist.`);
}
const existingSchedule = await ScheduleRepository.findDetailedScheduleById({
const existingSchedule = await this.prismaScheduleRepository.findDetailedScheduleById({
scheduleId: scheduleId,
isManagedEventType: undefined,
userId,
@@ -88,7 +88,11 @@ export class SchedulesService_2024_04_15 {
}
async getUserSchedules(userId: number, timeZone: string, defaultScheduleId: number | null) {
return ScheduleRepository.findManyDetailedScheduleByUserId({ userId, timeZone, defaultScheduleId });
return this.prismaScheduleRepository.findManyDetailedScheduleByUserId({
userId,
timeZone,
defaultScheduleId,
});
}
async updateUserSchedule(
@@ -5,6 +5,7 @@ import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.rep
import { PrismaHostRepository } from "@/lib/repositories/prisma-host.repository";
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
import { BookingAuditProducerService } from "@/lib/services/booking-audit-producer.service";
import { BookingEmailSmsService } from "@/lib/services/booking-emails-sms-service";
import { BookingEventHandlerService } from "@/lib/services/booking-event-handler.service";
import { CheckBookingAndDurationLimitsService } from "@/lib/services/check-booking-and-duration-limits.service";
@@ -16,6 +17,7 @@ import { BookingEmailAndSmsSyncTaskerService } from "@/lib/services/tasker/booki
import { BookingEmailAndSmsTaskService } from "@/lib/services/tasker/booking-emails-sms-task.service";
import { BookingEmailAndSmsTasker } from "@/lib/services/tasker/booking-emails-sms-tasker.service";
import { BookingEmailAndSmsTriggerTaskerService } from "@/lib/services/tasker/booking-emails-sms-trigger-tasker.service";
import { TaskerService } from "@/lib/services/tasker.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { Module, Scope } from "@nestjs/common";
@@ -35,6 +37,7 @@ import { Module, Scope } from "@nestjs/common";
},
scope: Scope.TRANSIENT,
},
BookingAuditProducerService,
BookingEventHandlerService,
CheckBookingAndDurationLimitsService,
CheckBookingLimitsService,
@@ -45,6 +48,7 @@ import { Module, Scope } from "@nestjs/common";
BookingEmailAndSmsSyncTaskerService,
BookingEmailAndSmsTriggerTaskerService,
BookingEmailAndSmsTasker,
TaskerService,
RegularBookingService,
],
exports: [RegularBookingService],
@@ -0,0 +1,16 @@
import { TaskerService } from "@/lib/services/tasker.service";
import { Logger } from "@/lib/logger.bridge";
import { Injectable } from "@nestjs/common";
import { BookingAuditTaskerProducerService } from "@calcom/platform-libraries/bookings";
@Injectable()
export class BookingAuditProducerService extends BookingAuditTaskerProducerService {
constructor(taskerService: TaskerService, bridgeLogger: Logger) {
super({
tasker: taskerService.getTasker(),
log: bridgeLogger,
});
}
}
@@ -4,14 +4,20 @@ import { BookingEventHandlerService as BaseBookingEventHandlerService } from "@c
import { Logger } from "@/lib/logger.bridge";
import { BookingAuditProducerService } from "./booking-audit-producer.service";
import { HashedLinkService } from "./hashed-link.service";
@Injectable()
export class BookingEventHandlerService extends BaseBookingEventHandlerService {
constructor(hashedLinkService: HashedLinkService, bridgeLogger: Logger) {
constructor(
hashedLinkService: HashedLinkService,
bridgeLogger: Logger,
bookingAuditProducerService: BookingAuditProducerService
) {
super({
log: bridgeLogger,
hashedLinkService,
bookingAuditProducerService,
});
}
}
@@ -0,0 +1,18 @@
import { Injectable } from "@nestjs/common";
import { getTasker, type Tasker } from "@calcom/platform-libraries";
@Injectable()
export class TaskerService {
private readonly tasker: Tasker;
constructor() {
this.tasker = getTasker();
}
getTasker(): Tasker {
return this.tasker;
}
}
@@ -32,8 +32,8 @@ export class SchedulesAtomsService {
const user = await this.usersRepository.findById(userId);
if (!user?.defaultScheduleId) return null;
return await ScheduleRepository.findDetailedScheduleById({
const scheduleRepo = new ScheduleRepository(this.dbWrite.prisma as unknown as PrismaClient);
return await scheduleRepo.findDetailedScheduleById({
scheduleId: scheduleId ?? user.defaultScheduleId,
isManagedEventType,
userId,
@@ -389,6 +389,7 @@ describe("Organizations Event Types Endpoints", () => {
expect(data.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
expect(data.color).toEqual(body.color);
expect(data.rrHostSubsetEnabled).toEqual(false);
expect(data.successRedirectUrl).toEqual("https://masterchief.com/argentina/flan/video/1234");
expect(data.emailSettings).toEqual(body.emailSettings);
collectiveEventType = responseBody.data;
@@ -537,6 +538,7 @@ describe("Organizations Event Types Endpoints", () => {
const data = responseBody.data;
expect(data.title).toEqual(collectiveEventType.title);
expect(data.hosts.length).toEqual(2);
expect(data.rrHostSubsetEnabled).toEqual(false);
evaluateHost(collectiveEventType.hosts[0], data.hosts[0]);
evaluateHost(collectiveEventType.hosts[1], data.hosts[1]);
@@ -1142,6 +1144,7 @@ describe("Organizations Event Types Endpoints", () => {
hideCalendarEventDetails: true,
hideOrganizerEmail: true,
lockTimeZoneToggleOnBookingPage: true,
rrHostSubsetEnabled: true,
color: {
darkThemeHex: "#292929",
lightThemeHex: "#fafafa",
@@ -1175,6 +1178,7 @@ describe("Organizations Event Types Endpoints", () => {
expect(data.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
expect(data.color).toEqual(body.color);
expect(data.rrHostSubsetEnabled).toEqual(true);
expect(data.successRedirectUrl).toEqual("https://masterchief.com/argentina/flan/video/1234");
collectiveEventType = responseBody.data;
});
@@ -1294,6 +1298,80 @@ describe("Organizations Event Types Endpoints", () => {
expect(updatedEventType.bookingRequiresAuthentication).toEqual(false);
});
it("should preserve metadata fields when doing partial update", async () => {
const createBody: CreateTeamEventTypeInput_2024_06_14 = {
title: "Coding consultation with metadata",
slug: `organizations-event-types-metadata-${randomString()}`,
description: "Our team will review your codebase.",
lengthInMinutes: 30,
lengthInMinutesOptions: [15, 30, 60],
locations: [
{
type: "integration",
integration: "cal-video",
},
],
schedulingType: "COLLECTIVE",
hosts: [
{
userId: teammate1.id,
},
],
bookerLayouts: {
enabledLayouts: [
BookerLayoutsInputEnum_2024_06_14.column,
BookerLayoutsInputEnum_2024_06_14.month,
BookerLayoutsInputEnum_2024_06_14.week,
],
defaultLayout: BookerLayoutsInputEnum_2024_06_14.month,
},
confirmationPolicy: {
type: ConfirmationPolicyEnum.TIME,
noticeThreshold: {
count: 60,
unit: NoticeThresholdUnitEnum.MINUTES,
},
blockUnconfirmedBookingsInBooker: true,
},
};
const createResponse = await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${team.id}/event-types`)
.send(createBody)
.expect(201);
const createdEventType: TeamEventTypeOutput_2024_06_14 = createResponse.body.data;
// Verify all metadata fields are set correctly on creation
expect(createdEventType.lengthInMinutesOptions).toBeDefined();
expect(createdEventType.lengthInMinutesOptions).toEqual([15, 30, 60]);
expect(createdEventType.bookerLayouts).toBeDefined();
expect(createdEventType.bookerLayouts).toEqual(createBody.bookerLayouts);
expect(createdEventType.confirmationPolicy).toBeDefined();
expect(createdEventType.confirmationPolicy).toEqual(createBody.confirmationPolicy);
// Now do a partial update that only changes a different field (not metadata fields)
const updateBody: UpdateTeamEventTypeInput_2024_06_14 = {
bookingRequiresAuthentication: false,
};
const updateResponse = await request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${createdEventType.id}`)
.send(updateBody)
.expect(200);
const updatedEventType: TeamEventTypeOutput_2024_06_14 = updateResponse.body.data;
// Verify that all metadata fields are preserved and not reset to undefined
expect(updatedEventType.lengthInMinutesOptions).toBeDefined();
expect(updatedEventType.lengthInMinutesOptions).toEqual([15, 30, 60]);
expect(updatedEventType.bookerLayouts).toBeDefined();
expect(updatedEventType.bookerLayouts).toEqual(createBody.bookerLayouts);
expect(updatedEventType.confirmationPolicy).toBeDefined();
expect(updatedEventType.confirmationPolicy).toEqual(createBody.confirmationPolicy);
expect(updatedEventType.bookingRequiresAuthentication).toEqual(false);
});
function evaluateHost(expected: Host, received: Host | undefined) {
expect(expected.userId).toEqual(received?.userId);
expect(expected.mandatory).toEqual(received?.mandatory);
@@ -8,6 +8,7 @@ import { UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { SchedulingType } from "@calcom/platform-libraries";
import { slugify } from "@calcom/platform-libraries";
import { EventTypeMetadata } from "@calcom/platform-libraries/event-types";
import {
CreateTeamEventTypeInput_2024_06_14,
@@ -42,11 +43,13 @@ export class InputOrganizationsEventTypesService {
teamId: number,
inputEventType: CreateTeamEventTypeInput_2024_06_14
) {
const slugifiedInputEventType = { ...inputEventType, slug: slugify(inputEventType.slug) };
await this.validateInputLocations(teamId, inputEventType.locations);
await this.validateHosts(teamId, inputEventType.hosts);
await this.validateTeamEventTypeSlug(teamId, inputEventType.slug);
await this.validateTeamEventTypeSlug(teamId, slugifiedInputEventType.slug);
const transformedBody = await this.transformInputCreateTeamEventType(teamId, inputEventType);
const transformedBody = await this.transformInputCreateTeamEventType(teamId, slugifiedInputEventType);
await this.inputEventTypesService.validateEventTypeInputs({
seatsPerTimeSlot: transformedBody.seatsPerTimeSlot,
@@ -75,13 +78,21 @@ export class InputOrganizationsEventTypesService {
teamId: number,
inputEventType: UpdateTeamEventTypeInput_2024_06_14
) {
const slugifiedInputEventType = inputEventType.slug
? { ...inputEventType, slug: slugify(inputEventType.slug) }
: inputEventType;
await this.validateInputLocations(teamId, inputEventType.locations);
await this.validateHosts(teamId, inputEventType.hosts);
if (inputEventType.slug) {
await this.validateTeamEventTypeSlug(teamId, inputEventType.slug);
if (slugifiedInputEventType.slug) {
await this.validateTeamEventTypeSlug(teamId, slugifiedInputEventType.slug);
}
const transformedBody = await this.transformInputUpdateTeamEventType(eventTypeId, teamId, inputEventType);
const transformedBody = await this.transformInputUpdateTeamEventType(
eventTypeId,
teamId,
slugifiedInputEventType
);
await this.inputEventTypesService.validateEventTypeInputs({
eventTypeId: eventTypeId,
@@ -88,6 +88,7 @@ type Input = Pick<
| "rescheduleWithSameRoundRobinHost"
| "maxActiveBookingPerBookerOfferReschedule"
| "maxActiveBookingsPerBooker"
| "rrHostSubsetEnabled"
>;
@Injectable()
@@ -103,8 +104,14 @@ export class OutputOrganizationsEventTypesService {
const emailSettings = this.transformEmailSettings(metadata);
const { teamId, userId, parentId, assignAllTeamMembers, rescheduleWithSameRoundRobinHost } =
databaseEventType;
const {
teamId,
userId,
parentId,
assignAllTeamMembers,
rescheduleWithSameRoundRobinHost,
rrHostSubsetEnabled,
} = databaseEventType;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { ownerId, users, ...rest } = this.outputEventTypesService.getResponseEventType(
0,
@@ -139,6 +146,7 @@ export class OutputOrganizationsEventTypesService {
theme: databaseEventType?.team?.theme,
},
rescheduleWithSameRoundRobinHost,
rrHostSubsetEnabled,
};
}
@@ -53,6 +53,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
const teammateEmailOne = `slots-2024-09-04-user-1-team-slots-${randomString()}`;
let teammateApiKeyString: string;
const teammateEmailTwo = `slots-2024-09-04-user-2-team-slots-${randomString()}`;
const teammateEmailThree = `slots-2024-09-04-user-3-team-slots-${randomString()}`;
let teammateTwoApiKeyString: string;
const outsiderEmail = `slots-2024-09-04-unrelated-team-slots-${randomString()}`;
@@ -63,6 +64,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
let team: Team;
let teammateOne: User;
let teammateTwo: User;
let teammateThree: User;
let collectiveEventTypeId: number;
let collectiveEventTypeSlug: string;
let collectiveEventTypeWithoutHostsId: number;
@@ -114,6 +116,12 @@ describe("Slots 2024-09-04 Endpoints", () => {
username: teammateEmailTwo,
});
teammateThree = await userRepositoryFixture.create({
email: teammateEmailThree,
name: teammateEmailThree,
username: teammateEmailThree,
});
outsider = await userRepositoryFixture.create({
email: outsiderEmail,
name: outsiderEmail,
@@ -155,6 +163,13 @@ describe("Slots 2024-09-04 Endpoints", () => {
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: teammateThree.id } },
team: { connect: { id: team.id } },
accepted: true,
});
const collectiveEventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: {
@@ -243,7 +258,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
bookingFields: [],
locations: [],
users: {
connect: [{ id: teammateOne.id }, { id: teammateTwo.id }],
connect: [{ id: teammateOne.id }, { id: teammateTwo.id }, { id: teammateThree.id }],
},
hosts: {
create: [
@@ -255,8 +270,13 @@ describe("Slots 2024-09-04 Endpoints", () => {
userId: teammateTwo.id,
isFixed: false,
},
{
userId: teammateThree.id,
isFixed: false,
},
],
},
rrHostSubsetEnabled: true,
});
roundRobinEventTypeWithoutFixedHostsId = roundRobinEventTypeWithoutFixedHosts.id;
@@ -274,7 +294,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
bookingFields: [],
locations: [],
users: {
connect: [{ id: teammateOne.id }, { id: teammateTwo.id }],
connect: [{ id: teammateOne.id }, { id: teammateTwo.id }, { id: teammateThree.id }],
},
hosts: {
create: [
@@ -286,8 +306,13 @@ describe("Slots 2024-09-04 Endpoints", () => {
userId: teammateTwo.id,
isFixed: false,
},
{
userId: teammateThree.id,
isFixed: false,
},
],
},
rrHostSubsetEnabled: true,
});
roundRobinEventTypeWithFixedAndNonFixedHostsId = roundRobinEventTypeWithFixedAndNonFixedHosts.id;
@@ -301,6 +326,17 @@ describe("Slots 2024-09-04 Endpoints", () => {
await schedulesService.createUserSchedule(teammateOne.id, userSchedule);
await schedulesService.createUserSchedule(teammateTwo.id, userSchedule);
await schedulesService.createUserSchedule(teammateThree.id, {
...userSchedule,
availability: [
{
days: ["Monday", "Friday"],
startTime: "09:00",
endTime: "17:00",
},
],
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
@@ -360,6 +396,79 @@ describe("Slots 2024-09-04 Endpoints", () => {
});
});
it("should get round robin team event without fixed hosts slots in UTC with subsetIds for teammateThree who has a smaller schedule", async () => {
return request(app.getHttpServer())
.get(
`/v2/slots?eventTypeId=${roundRobinEventTypeWithoutFixedHostsId}&start=2050-09-05&end=2050-09-09&rrHostSubsetIds[]=${teammateThree.id}`
)
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(200)
.then(async (response) => {
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const slots = responseBody.data;
expect(slots).toBeDefined();
const days = Object.keys(slots);
expect(days.length).toEqual(2);
});
});
it("should get round robin team event without fixed hosts slots in UTC with subsetIds for teammateOne", async () => {
return request(app.getHttpServer())
.get(
`/v2/slots?eventTypeId=${roundRobinEventTypeWithoutFixedHostsId}&start=2050-09-05&end=2050-09-09&rrHostSubsetIds[]=${teammateOne.id}`
)
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(200)
.then(async (response) => {
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const slots = responseBody.data;
expect(slots).toBeDefined();
const days = Object.keys(slots);
expect(days.length).toEqual(5);
expect(slots).toEqual(expectedSlotsUTC);
});
});
it("should get round robin team event with and without fixed hosts slots in UTC with subsetIds for teammateOne(fixed) and teammateThree(not fixed) ", async () => {
return request(app.getHttpServer())
.get(
`/v2/slots?eventTypeId=${roundRobinEventTypeWithFixedAndNonFixedHostsId}&start=2050-09-05&end=2050-09-09&rrHostSubsetIds[]=${teammateOne.id}&rrHostSubsetIds[]=${teammateThree.id}`
)
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(200)
.then(async (response) => {
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const slots = responseBody.data;
expect(slots).toBeDefined();
const days = Object.keys(slots);
expect(days.length).toEqual(2);
});
});
it("should get round robin team event with and without fixed hosts slots in UTC with subsetIds for teammateOne(fixed) and teammateTwo(not fixed) ", async () => {
return request(app.getHttpServer())
.get(
`/v2/slots?eventTypeId=${roundRobinEventTypeWithFixedAndNonFixedHostsId}&start=2050-09-05&end=2050-09-09&rrHostSubsetIds[]=${teammateOne.id}&rrHostSubsetIds[]=${teammateTwo.id}`
)
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(200)
.then(async (response) => {
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const slots = responseBody.data;
expect(slots).toBeDefined();
const days = Object.keys(slots);
expect(days.length).toEqual(5);
expect(slots).toEqual(expectedSlotsUTC);
});
});
it("should not be able reserve a team event type slot with custom duration if no auth is provided", async () => {
await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
@@ -748,6 +857,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(teammateOne.email);
await userRepositoryFixture.deleteByEmail(teammateTwo.email);
await userRepositoryFixture.deleteByEmail(teammateThree.email);
await userRepositoryFixture.deleteByEmail(outsiderEmail);
await teamRepositoryFixture.delete(team.id);
await bookingsRepositoryFixture.deleteById(collectiveBookingId);
@@ -64,7 +64,7 @@ export class SlotsController_2024_09_04 {
description: `
There are 4 ways to get available slots for event type of an individual user:
1. By event type id. Event type id can be of user and team event types. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'
1. By event type id. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'
2. By event type slug + username. Example '/v2/slots?eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'
@@ -74,7 +74,10 @@ export class SlotsController_2024_09_04 {
And 3 ways to get available slots for team event type:
1. By team event type id. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'
1. By team event type id. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'.
**Note for managed event types**: Managed event types are templates that create individual child event types for each team member. You cannot fetch slots for the parent managed event type directly. Instead, you must:
- Find the child event type IDs (the ones assigned to specific users)
- Use those child event type IDs to fetch slots as individual user event types using as described in the individual user section above.
2. By team event type slug + team slug. Example '/v2/slots?eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'
@@ -30,6 +30,7 @@ export type InternalGetSlotsQuery = {
timeZone: string | undefined;
orgSlug: string | null | undefined;
rescheduleUid: string | null;
rrHostSubsetIds?: number[];
};
export type InternalGetSlotsQueryWithRouting = InternalGetSlotsQuery & {
@@ -79,6 +80,7 @@ export class SlotsInputService_2024_09_04 {
timeZone,
orgSlug,
rescheduleUid,
rrHostSubsetIds: query.rrHostSubsetIds,
};
}
@@ -13,7 +13,7 @@ import Page from "~/org/[orgSlug]/instant-meeting/team/[slug]/[type]/instant-mee
export const generateMetadata = async ({ params, searchParams }: _PageProps) => {
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
const { isBrandingHidden, eventData } = await getData(context);
const { isBrandingHidden, eventData, teamIsPrivate } = await getData(context);
const profileName = eventData?.profile.name ?? "";
const profileImage = eventData?.profile.image;
@@ -22,12 +22,15 @@ export const generateMetadata = async ({ params, searchParams }: _PageProps) =>
const meeting = {
title,
profile: { name: profileName, image: profileImage },
users: [
...(eventData?.users || []).map((user) => ({
name: `${user.name}`,
username: `${user.username}`,
})),
],
// Hide team member names in preview if team is private
users: teamIsPrivate
? []
: [
...(eventData?.users || []).map((user) => ({
name: `${user.name}`,
username: `${user.username}`,
})),
],
};
const decodedParams = decodeParams(await params);
const metadata = await generateMeetingMetadata(
@@ -91,15 +91,20 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const profileName = enrichedEventType.profile.name ?? "";
const profileImage = enrichedEventType.profile.image;
const teamIsPrivate = teamData.isPrivate;
const meeting = {
title,
profile: { name: profileName, image: profileImage },
users: [
...(enrichedEventType?.subsetOfUsers || []).map((user) => ({
name: `${user.name}`,
username: `${user.username}`,
})),
],
// Hide team member names in preview if team is private
users: teamIsPrivate
? []
: [
...(enrichedEventType?.subsetOfUsers || []).map((user) => ({
name: `${user.name}`,
username: `${user.username}`,
})),
],
};
const { hideBranding, isSEOIndexable } = _getTeamMetadataForBooking(teamData, enrichedEventType.id);
@@ -0,0 +1,48 @@
// Added as a separate route for now to ease the testing of the audit logs feature
// It partially matches the figma design - https://www.figma.com/design/wleA2SR6rn60EK7ORxAfMy/Cal.com-New-Features?node-id=5641-6732&p=f
// TOOD: Move it to the booking page side bar later
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
import type { PageProps } from "app/_types";
import { _generateMetadata, getTranslate } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import BookingLogsView from "~/booking/logs/views/booking-logs-view";
export const generateMetadata = async ({ params }: { params: Promise<{ uid: string }> }) =>
await _generateMetadata(
(t) => t("booking_history"),
(t) => t("booking_history_description"),
undefined,
undefined,
`/booking/${(await params).uid}/logs`
);
const Page = async ({ params }: PageProps) => {
const resolvedParams = await params;
const bookingUid = resolvedParams.uid;
if (!bookingUid || typeof bookingUid !== "string") {
redirect("/bookings/upcoming");
}
const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
redirect("/auth/login");
}
return (
<ShellMainAppDir heading={t("booking_history")} subtitle={t("booking_history_description")}>
<BookingLogsView bookingUid={bookingUid} />
</ShellMainAppDir>
);
};
export default Page;
@@ -1,64 +0,0 @@
"use client";
import { useQueryState } from "nuqs";
import { useEffect } from "react";
import { activeFiltersParser } from "@calcom/features/data-table/lib/parsers";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
import { ToggleGroup } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { viewParser, type BookingView } from "~/bookings/lib/viewParser";
export function ViewToggleButton() {
const { t } = useLocale();
const [view, setView] = useQueryState(
"view",
viewParser.withDefault("list").withOptions({ clearOnDefault: true })
);
const [, setActiveFilters] = useQueryState("activeFilters", activeFiltersParser);
const isMobile = !useMediaQuery("(min-width: 640px)");
useEffect(() => {
// Force list view on mobile
if (isMobile && view === "calendar") {
// Clear dateRange filter when forcing calendar to list view, same as manual toggle
setActiveFilters((prev) => prev?.filter((filter) => filter.f !== "dateRange") ?? []);
setView("list");
}
}, [isMobile, view, setView, setActiveFilters]);
return (
<div className="hidden sm:block">
<ToggleGroup
value={view}
onValueChange={(value) => {
if (!value) return;
const newView = value as BookingView;
// When switching from calendar to list view, remove the dateRange filter
if (view === "calendar" && newView === "list") {
setActiveFilters((prev) => prev?.filter((filter) => filter.f !== "dateRange") ?? []);
}
setView(newView);
}}
options={[
{
value: "list",
label: "",
tooltip: t("list_view"),
iconLeft: <Icon name="menu" className="h-4 w-4" />,
},
{
value: "calendar",
label: "",
tooltip: t("calendar_view"),
iconLeft: <Icon name="calendar" className="h-4 w-4" />,
},
]}
/>
</div>
);
}
@@ -1,6 +1,6 @@
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
import type { PageProps } from "app/_types";
import { _generateMetadata, getTranslate } from "app/_utils";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { z } from "zod";
@@ -16,8 +16,6 @@ import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { validStatuses } from "~/bookings/lib/validStatuses";
import BookingsList from "~/bookings/views/bookings-view";
import { ViewToggleButton } from "./ViewToggleButton";
const querySchema = z.object({
status: z.enum(validStatuses),
});
@@ -36,7 +34,6 @@ const Page = async ({ params }: PageProps) => {
if (!parsed.success) {
redirect("/bookings/upcoming");
}
const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
let canReadOthersBookings = false;
@@ -56,18 +53,13 @@ const Page = async ({ params }: PageProps) => {
canReadOthersBookings = teamIdsWithPermission.length > 0;
}
const userProfile = session?.user?.profile;
const orgId = userProfile?.organizationId ?? session?.user.org?.id;
const featuresRepository = new FeaturesRepository(prisma);
const bookingsV3Enabled = orgId
? await featuresRepository.checkIfTeamHasFeature(orgId, "bookings-v3")
const bookingsV3Enabled = session?.user?.id
? await featuresRepository.checkIfUserHasFeature(session.user.id, "bookings-v3")
: false;
return (
<ShellMainAppDir
heading={t("bookings")}
subtitle={t("bookings_description")}
CTA={bookingsV3Enabled ? <ViewToggleButton /> : null}>
<ShellMainAppDir>
<BookingsList
status={parsed.data.status}
userId={session?.user?.id}
@@ -19,7 +19,7 @@ export const generateMetadata = async () => {
);
};
const ServerPage = async () => {
const ServerPage = async (props: { searchParams: Promise<{ fromTeamOnboarding?: string }> }) => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
@@ -28,8 +28,10 @@ const ServerPage = async () => {
const userEmail = session.user.email || "";
const userName = session.user.name || "";
const searchParams = await props.searchParams;
const fromTeamOnboarding = searchParams?.fromTeamOnboarding === "true";
return <PersonalSettingsView userEmail={userEmail} userName={userName} />;
return <PersonalSettingsView userEmail={userEmail} userName={userName} fromTeamOnboarding={fromTeamOnboarding} />;
};
export default ServerPage;
@@ -37,13 +37,41 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
href: "/settings/my-account",
icon: "user",
children: [
{ name: "profile", href: "/settings/my-account/profile", trackingMetadata: { section: "my_account", page: "profile" } },
{ name: "general", href: "/settings/my-account/general", trackingMetadata: { section: "my_account", page: "general" } },
{ name: "calendars", href: "/settings/my-account/calendars", trackingMetadata: { section: "my_account", page: "calendars" } },
{ name: "conferencing", href: "/settings/my-account/conferencing", trackingMetadata: { section: "my_account", page: "conferencing" } },
{ name: "appearance", href: "/settings/my-account/appearance", trackingMetadata: { section: "my_account", page: "appearance" } },
{ name: "out_of_office", href: "/settings/my-account/out-of-office", trackingMetadata: { section: "my_account", page: "out_of_office" } },
{ name: "push_notifications", href: "/settings/my-account/push-notifications", trackingMetadata: { section: "my_account", page: "push_notifications" } },
{
name: "profile",
href: "/settings/my-account/profile",
trackingMetadata: { section: "my_account", page: "profile" },
},
{
name: "general",
href: "/settings/my-account/general",
trackingMetadata: { section: "my_account", page: "general" },
},
{
name: "calendars",
href: "/settings/my-account/calendars",
trackingMetadata: { section: "my_account", page: "calendars" },
},
{
name: "conferencing",
href: "/settings/my-account/conferencing",
trackingMetadata: { section: "my_account", page: "conferencing" },
},
{
name: "appearance",
href: "/settings/my-account/appearance",
trackingMetadata: { section: "my_account", page: "appearance" },
},
{
name: "out_of_office",
href: "/settings/my-account/out-of-office",
trackingMetadata: { section: "my_account", page: "out_of_office" },
},
{
name: "push_notifications",
href: "/settings/my-account/push-notifications",
trackingMetadata: { section: "my_account", page: "push_notifications" },
},
// TODO
// { name: "referrals", href: "/settings/my-account/referrals" },
],
@@ -53,16 +81,34 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
href: "/settings/security",
icon: "key",
children: [
{ name: "password", href: "/settings/security/password", trackingMetadata: { section: "security", page: "password" } },
{ name: "impersonation", href: "/settings/security/impersonation", trackingMetadata: { section: "security", page: "impersonation" } },
{ name: "2fa_auth", href: "/settings/security/two-factor-auth", trackingMetadata: { section: "security", page: "2fa_auth" } },
{
name: "password",
href: "/settings/security/password",
trackingMetadata: { section: "security", page: "password" },
},
{
name: "impersonation",
href: "/settings/security/impersonation",
trackingMetadata: { section: "security", page: "impersonation" },
},
{
name: "2fa_auth",
href: "/settings/security/two-factor-auth",
trackingMetadata: { section: "security", page: "2fa_auth" },
},
],
},
{
name: "billing",
href: "/settings/billing",
icon: "credit-card",
children: [{ name: "manage_billing", href: "/settings/billing", trackingMetadata: { section: "billing", page: "manage_billing" } }],
children: [
{
name: "manage_billing",
href: "/settings/billing",
trackingMetadata: { section: "billing", page: "manage_billing" },
},
],
},
{
name: "developer",
@@ -70,9 +116,21 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
icon: "terminal",
children: [
//
{ name: "webhooks", href: "/settings/developer/webhooks", trackingMetadata: { section: "developer", page: "webhooks" } },
{ name: "api_keys", href: "/settings/developer/api-keys", trackingMetadata: { section: "developer", page: "api_keys" } },
{ name: "admin_api", href: "/settings/organizations/admin-api", trackingMetadata: { section: "developer", page: "admin_api" } },
{
name: "webhooks",
href: "/settings/developer/webhooks",
trackingMetadata: { section: "developer", page: "webhooks" },
},
{
name: "api_keys",
href: "/settings/developer/api-keys",
trackingMetadata: { section: "developer", page: "api_keys" },
},
{
name: "admin_api",
href: "/settings/organizations/admin-api",
trackingMetadata: { section: "developer", page: "admin_api" },
},
// TODO: Add profile level for embeds
// { name: "embeds", href: "/v2/settings/developer/embeds" },
],
@@ -97,13 +155,13 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
},
...(orgBranding
? [
{
name: "members",
href: `${WEBAPP_URL}/settings/organizations/${orgBranding.slug}/members`,
isExternalLink: true,
trackingMetadata: { section: "organization", page: "members" },
},
]
{
name: "members",
href: `${WEBAPP_URL}/settings/organizations/${orgBranding.slug}/members`,
isExternalLink: true,
trackingMetadata: { section: "organization", page: "members" },
},
]
: []),
{
name: "privacy_and_security",
@@ -146,31 +204,83 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
icon: "lock",
children: [
//
{ name: "features", href: "/settings/admin/flags", trackingMetadata: { section: "admin", page: "features" } },
{ name: "license", href: "/auth/setup?step=1", trackingMetadata: { section: "admin", page: "license" } },
{ name: "impersonation", href: "/settings/admin/impersonation", trackingMetadata: { section: "admin", page: "impersonation" } },
{ name: "apps", href: "/settings/admin/apps/calendar", trackingMetadata: { section: "admin", page: "apps" } },
{ name: "users", href: "/settings/admin/users", trackingMetadata: { section: "admin", page: "users" } },
{ name: "organizations", href: "/settings/admin/organizations", trackingMetadata: { section: "admin", page: "organizations" } },
{ name: "lockedSMS", href: "/settings/admin/lockedSMS", trackingMetadata: { section: "admin", page: "locked_sms" } },
{ name: "oAuth", href: "/settings/admin/oAuth", trackingMetadata: { section: "admin", page: "oauth" } },
{ name: "Workspace Platforms", href: "/settings/admin/workspace-platforms", trackingMetadata: { section: "admin", page: "workspace_platforms" } },
{ name: "Playground", href: "/settings/admin/playground", trackingMetadata: { section: "admin", page: "playground" } },
{
name: "features",
href: "/settings/admin/flags",
trackingMetadata: { section: "admin", page: "features" },
},
{
name: "license",
href: "/auth/setup?step=1",
trackingMetadata: { section: "admin", page: "license" },
},
{
name: "impersonation",
href: "/settings/admin/impersonation",
trackingMetadata: { section: "admin", page: "impersonation" },
},
{
name: "apps",
href: "/settings/admin/apps/calendar",
trackingMetadata: { section: "admin", page: "apps" },
},
{
name: "users",
href: "/settings/admin/users",
trackingMetadata: { section: "admin", page: "users" },
},
{
name: "organizations",
href: "/settings/admin/organizations",
trackingMetadata: { section: "admin", page: "organizations" },
},
{
name: "lockedSMS",
href: "/settings/admin/lockedSMS",
trackingMetadata: { section: "admin", page: "locked_sms" },
},
{
name: "oAuth",
href: "/settings/admin/oAuth",
trackingMetadata: { section: "admin", page: "oauth" },
},
{
name: "Workspace Platforms",
href: "/settings/admin/workspace-platforms",
trackingMetadata: { section: "admin", page: "workspace_platforms" },
},
{
name: "Playground",
href: "/settings/admin/playground",
trackingMetadata: { section: "admin", page: "playground" },
},
],
},
];
tabs.find((tab) => {
if (tab.name === "security" && !HOSTED_CAL_FEATURES) {
tab.children?.push({ name: "sso_configuration", href: "/settings/security/sso", trackingMetadata: { section: "security", page: "sso_configuration" } });
tab.children?.push({
name: "sso_configuration",
href: "/settings/security/sso",
trackingMetadata: { section: "security", page: "sso_configuration" },
});
// TODO: Enable dsync for self hosters
// tab.children?.push({ name: "directory_sync", href: "/settings/security/dsync" });
}
if (tab.name === "admin" && IS_CALCOM) {
tab.children?.push({ name: "create_org", href: "/settings/organizations/new", trackingMetadata: { section: "admin", page: "create_org" } });
tab.children?.push({
name: "create_org",
href: "/settings/organizations/new",
trackingMetadata: { section: "admin", page: "create_org" },
});
}
if (tab.name === "admin" && IS_CALCOM) {
tab.children?.push({ name: "create_license_key", href: "/settings/license-key/new", trackingMetadata: { section: "admin", page: "create_license_key" } });
tab.children?.push({
name: "create_license_key",
href: "/settings/license-key/new",
trackingMetadata: { section: "admin", page: "create_license_key" },
});
}
});
@@ -442,8 +552,9 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
setTeamMenuState(newTeamMenuState);
}
}}
aria-label={`${team.name} ${teamMenuState[index].teamMenuOpen ? t("collapse_menu") : t("expand_menu")
}`}>
aria-label={`${team.name} ${
teamMenuState[index].teamMenuOpen ? t("collapse_menu") : t("expand_menu")
}`}>
<div className="me-3">
{teamMenuState[index].teamMenuOpen ? (
<Icon name="chevron-down" className="h-4 w-4" />
@@ -451,7 +562,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
<Icon name="chevron-right" className="h-4 w-4" />
)}
</div>
{ }
{}
{!team.parentId && (
<Image
src={getPlaceholderAvatar(team.logoUrl, team.name)}
@@ -469,7 +580,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
)}
</button>
</CollapsibleTrigger>
<CollapsibleContent className="stack-y-0.5" id={`team-content-${team.id}`}>
<CollapsibleContent className="flex flex-col space-y-1" id={`team-content-${team.id}`}>
{team.accepted && (
<VerticalTabItem
name={t("profile")}
@@ -494,9 +605,9 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore this exists wtf?
(team.isOrgAdmin && team.isOrgAdmin)) && (
<>
{/* TODO */}
{/* <VerticalTabItem
<>
{/* TODO */}
{/* <VerticalTabItem
name={t("general")}
href={`${WEBAPP_URL}/settings/my-account/appearance`}
textClassNames="px-3 text-emphasis font-medium text-sm"
@@ -628,7 +739,7 @@ const SettingsSidebarContainer = ({
className="text-subtle h-[16px] w-[16px] stroke-[2px] ltr:mr-3 rtl:ml-3 md:mt-0"
/>
)}
{ }
{}
{!tab.icon && tab?.avatar && (
<Image
width={16}
@@ -647,7 +758,7 @@ const SettingsSidebarContainer = ({
</Skeleton>
</div>
</div>
<div className="stack-y-px">
<div className="flex flex-col space-y-1">
{tab.children?.map((child, index) => (
<div key={child.href} className="flex items-start gap-2">
<VerticalTabItem
@@ -772,10 +883,11 @@ const SettingsSidebarContainer = ({
setOtherTeamMenuState(newOtherTeamMenuState);
}
}}
aria-label={`${otherTeam.name} ${otherTeamMenuState[index].teamMenuOpen
? t("collapse_menu")
: t("expand_menu")
}`}>
aria-label={`${otherTeam.name} ${
otherTeamMenuState[index].teamMenuOpen
? t("collapse_menu")
: t("expand_menu")
}`}>
<div className="me-3">
{otherTeamMenuState[index].teamMenuOpen ? (
<Icon name="chevron-down" className="h-4 w-4" />
@@ -783,7 +895,7 @@ const SettingsSidebarContainer = ({
<Icon name="chevron-right" className="h-4 w-4" />
)}
</div>
{ }
{}
{!otherTeam.parentId && (
<Image
src={getPlaceholderAvatar(otherTeam.logoUrl, otherTeam.name)}
@@ -797,12 +909,16 @@ const SettingsSidebarContainer = ({
</button>
</CollapsibleTrigger>
<CollapsibleContent
className="stack-y-0.5"
className="flex flex-col space-y-1"
id={`other-team-content-${otherTeam.id}`}>
<VerticalTabItem
name={t("profile")}
href={`/settings/organizations/teams/other/${otherTeam.id}/profile`}
trackingMetadata={{ section: "other_team", page: "profile", teamId: otherTeam.id }}
trackingMetadata={{
section: "other_team",
page: "profile",
teamId: otherTeam.id,
}}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="px-2! me-5 h-7 w-auto"
disableChevron
@@ -810,7 +926,11 @@ const SettingsSidebarContainer = ({
<VerticalTabItem
name={t("members")}
href={`/settings/organizations/teams/other/${otherTeam.id}/members`}
trackingMetadata={{ section: "other_team", page: "members", teamId: otherTeam.id }}
trackingMetadata={{
section: "other_team",
page: "members",
teamId: otherTeam.id,
}}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="px-2! me-5 h-7 w-auto"
disableChevron
@@ -29,7 +29,7 @@ async function handler(req: NextRequest) {
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: piiHasher.hash(ip),
identifier: `forgotPassword:${piiHasher.hash(ip)}`,
});
try {
+11 -2
View File
@@ -7,6 +7,9 @@ import { z } from "zod";
import { validPassword } from "@calcom/features/auth/lib/validPassword";
import { hashPassword } from "@calcom/lib/auth/hashPassword";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import getIP from "@calcom/lib/getIP";
import { piiHasher } from "@calcom/lib/server/PiiHasher";
import prisma from "@calcom/prisma";
import { IdentityProvider } from "@calcom/prisma/enums";
@@ -36,8 +39,14 @@ async function handler(req: NextRequest) {
// token verified, delete the cookie / a resubmit on failure requires a new csrf token.
cookieStore.delete("calcom.csrf_token");
// rate-limited there is a low, very low chance that a password request stays valid long enough
// to brute force 3.8126967e+40 options.
const remoteIp = getIP(req);
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: `api:reset-password:${piiHasher.hash(remoteIp)}`,
});
// Note: There is a low, very low chance that a password request stays valid long enough
// to brute force 3.8126967e+40 options, but rate limiting provides additional protection.
const maybeRequest = await prisma.resetPasswordRequest.findFirstOrThrow({
where: {
id: rawRequestId,
+8
View File
@@ -5,10 +5,12 @@ import { NextResponse, type NextRequest } from "next/server";
import calcomSignupHandler from "@calcom/feature-auth/signup/handlers/calcomHandler";
import selfHostedSignupHandler from "@calcom/feature-auth/signup/handlers/selfHostedHandler";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
import getIP from "@calcom/lib/getIP";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { piiHasher } from "@calcom/lib/server/PiiHasher";
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
import { prisma } from "@calcom/prisma";
import { signupSchema } from "@calcom/prisma/zod-utils";
@@ -38,6 +40,12 @@ async function handler(req: NextRequest) {
const remoteIp = getIP(req);
// Use a try catch instead of returning res every time
try {
// Rate limit: 10 signups per 60 seconds per IP
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: `api:signup:${piiHasher.hash(remoteIp)}`,
});
const body = await parseRequestData(req);
await checkCfTurnstileToken({
token: req.headers.get("cf-access-token") as string,
@@ -7,6 +7,7 @@ import type { NextRequest } from "next/server";
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import { totpAuthenticatorCheck } from "@calcom/lib/totp";
import prisma from "@calcom/prisma";
@@ -27,6 +28,11 @@ async function handler(req: NextRequest) {
return NextResponse.json({ error: ErrorCode.InternalServerError }, { status: 500 });
}
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: `api:totp-disable:${session.user.id}`,
});
const user = await prisma.user.findUnique({ where: { id: session.user.id }, include: { password: true } });
if (!user) {
@@ -6,6 +6,7 @@ import type { NextRequest } from "next/server";
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import { totpAuthenticatorCheck } from "@calcom/lib/totp";
import prisma from "@calcom/prisma";
@@ -25,6 +26,11 @@ async function postHandler(req: NextRequest) {
return NextResponse.json({ error: ErrorCode.InternalServerError }, { status: 500 });
}
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: `api:totp-enable:${session.user.id}`,
});
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user) {
console.error(`Session references user that no longer exists.`);
@@ -10,6 +10,7 @@ import qrcode from "qrcode";
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { symmetricEncrypt } from "@calcom/lib/crypto";
import prisma from "@calcom/prisma";
import { IdentityProvider } from "@calcom/prisma/enums";
@@ -29,6 +30,11 @@ async function postHandler(req: NextRequest) {
return NextResponse.json({ error: ErrorCode.InternalServerError }, { status: 500 });
}
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: `api:totp-setup:${session.user.id}`,
});
const user = await prisma.user.findUnique({ where: { id: session.user.id }, include: { password: true } });
if (!user) {
+12
View File
@@ -5,6 +5,9 @@ import type { NextRequest } from "next/server";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import getIP from "@calcom/lib/getIP";
import { piiHasher } from "@calcom/lib/server/PiiHasher";
import { bookingCancelWithCsrfSchema } from "@calcom/prisma/zod-utils";
import { validateCsrfToken } from "@calcom/web/lib/validateCsrfToken";
@@ -26,6 +29,15 @@ async function handler(req: NextRequest) {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
// Rate limit: 10 booking cancellations per 60 seconds per user (or IP if not authenticated)
const identifier = session?.user?.id
? `api:cancel-user:${session.user.id}`
: `api:cancel-ip:${piiHasher.hash(getIP(req))}`;
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier,
});
const result = await handleCancelBooking({
bookingData,
userId: session?.user?.id || -1,
@@ -3,8 +3,8 @@ import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import dayjs from "@calcom/dayjs";
import { ScheduleRepository } from "@calcom/features/schedules/repositories/ScheduleRepository";
import prisma from "@calcom/prisma";
import { getDefaultScheduleId } from "@calcom/trpc/server/routers/viewer/availability/util";
const travelScheduleSelect = {
id: true,
@@ -40,7 +40,8 @@ async function postHandler(request: NextRequest) {
},
});
const defaultScheduleId = await getDefaultScheduleId(user.id, prisma);
const scheduleRepository = new ScheduleRepository(prisma);
const defaultScheduleId = await scheduleRepository.getDefaultScheduleId(user.id);
if (!user.defaultScheduleId) {
// set default schedule if not already set
@@ -0,0 +1,208 @@
import type { NextRequest } from "next/server";
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("app/api/defaultResponderForAppDir", () => ({
defaultResponderForAppDir:
(handler: (req: NextRequest) => Promise<Response>) =>
(req: NextRequest, _context: { params: Promise<Record<string, string>> }) =>
handler(req),
}));
vi.mock("next/headers", () => ({
headers: vi.fn().mockResolvedValue(new Headers()),
cookies: vi.fn().mockResolvedValue({ getAll: () => [] }),
}));
vi.mock("next/server", () => ({
NextResponse: {
redirect: vi.fn((url: string | URL, init?: { status?: number }) => {
const location = typeof url === "string" ? url : url.toString();
return {
status: init?.status ?? 302,
headers: {
get: (name: string) => (name.toLowerCase() === "location" ? location : null),
},
} as unknown as Response;
}),
},
}));
vi.mock("@calcom/lib/crypto", () => ({
symmetricDecrypt: vi.fn().mockReturnValue(
JSON.stringify({
bookingUid: "test-booking-uid",
userId: 1,
})
),
}));
vi.mock("@calcom/prisma", () => ({
default: {
booking: {
findUniqueOrThrow: vi.fn().mockResolvedValue({
id: 1,
uid: "test-booking-uid",
recurringEventId: null,
}),
},
user: {
findUniqueOrThrow: vi.fn().mockResolvedValue({
id: 1,
locale: "en",
}),
},
},
}));
vi.mock("@calcom/trpc/server/createContext", () => ({
createContext: vi.fn().mockResolvedValue({}),
}));
vi.mock("@calcom/trpc/server/routers/viewer/bookings/_router", () => ({
bookingsRouter: {},
}));
vi.mock("@calcom/trpc/server/trpc", () => ({
createCallerFactory: vi.fn().mockReturnValue(() => ({
confirm: vi.fn().mockResolvedValue({}),
})),
}));
vi.mock("@lib/buildLegacyCtx", () => ({
buildLegacyRequest: vi.fn().mockReturnValue({}),
}));
// Import after mocks are set up
import { GET } from "../route";
const createMockRequest = (url: string): NextRequest => {
const urlObj = new URL(url);
return {
method: "GET",
url,
nextUrl: {
searchParams: urlObj.searchParams,
},
} as unknown as NextRequest;
};
describe("link route", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET handler - redirect URL construction", () => {
it("should redirect to booking page with the same origin as the request", async () => {
const baseUrl = "https://app.example.com/api/link?action=accept&token=encrypted-token";
const req = createMockRequest(baseUrl);
const res = await GET(req, { params: Promise.resolve({}) });
const location = res.headers.get("location");
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe("https://app.example.com");
expect(redirectUrl.pathname).toBe("/booking/test-booking-uid");
});
it("should preserve custom domain origin in redirect URL", async () => {
const baseUrl = "https://custom-domain.company.com/api/link?action=accept&token=encrypted-token";
const req = createMockRequest(baseUrl);
const res = await GET(req, { params: Promise.resolve({}) });
const location = res.headers.get("location");
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe("https://custom-domain.company.com");
expect(location).not.toContain("localhost");
});
it("should preserve self-hosted domain origin in redirect URL", async () => {
const baseUrl = "https://calcom.internal.company.net/api/link?action=reject&token=encrypted-token";
const req = createMockRequest(baseUrl);
const res = await GET(req, { params: Promise.resolve({}) });
const location = res.headers.get("location");
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe("https://calcom.internal.company.net");
expect(location).not.toContain("localhost");
});
it("should construct redirect URLs relative to the request URL for various origins", async () => {
const testOrigins = [
"https://app.cal.com",
"https://acme.cal.com",
"https://calcom.company.internal",
"http://192.168.1.100:3000",
];
for (const origin of testOrigins) {
vi.clearAllMocks();
const baseUrl = `${origin}/api/link?action=accept&token=encrypted-token`;
const req = createMockRequest(baseUrl);
const res = await GET(req, { params: Promise.resolve({}) });
const location = res.headers.get("location");
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe(origin);
expect(redirectUrl.pathname).toBe("/booking/test-booking-uid");
}
});
});
describe("GET handler - error handling", () => {
it("should redirect with error message when TRPC throws an error", async () => {
const { TRPCError } = await import("@trpc/server");
// Mock createCallerFactory to throw a TRPCError
const { createCallerFactory } = await import("@calcom/trpc/server/trpc");
vi.mocked(createCallerFactory).mockReturnValue(() => ({
confirm: vi.fn().mockRejectedValue(new TRPCError({ code: "BAD_REQUEST", message: "Custom error" })),
}));
const baseUrl = "https://app.example.com/api/link?action=accept&token=encrypted-token";
const req = createMockRequest(baseUrl);
const res = await GET(req, { params: Promise.resolve({}) });
const location = res.headers.get("location");
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe("https://app.example.com");
expect(redirectUrl.pathname).toBe("/booking/test-booking-uid");
expect(redirectUrl.searchParams.get("error")).toBe("Custom error");
});
it("should preserve origin in error redirect URL", async () => {
const { TRPCError } = await import("@trpc/server");
// Mock createCallerFactory to throw a TRPCError
const { createCallerFactory } = await import("@calcom/trpc/server/trpc");
vi.mocked(createCallerFactory).mockReturnValue(() => ({
confirm: vi.fn().mockRejectedValue(new TRPCError({ code: "INTERNAL_SERVER_ERROR" })),
}));
const baseUrl = "https://self-hosted.company.org/api/link?action=accept&token=encrypted-token";
const req = createMockRequest(baseUrl);
const res = await GET(req, { params: Promise.resolve({}) });
const location = res.headers.get("location");
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe("https://self-hosted.company.org");
expect(location).not.toContain("localhost");
});
});
});
+2 -3
View File
@@ -59,7 +59,6 @@ const createSessionGetter = (userId: number) => async () => {
};
async function handler(request: NextRequest) {
const url = new URL(request.url);
const searchParams = request.nextUrl.searchParams;
const { action, token, reason } = querySchema.parse(Object.fromEntries(searchParams.entries()));
@@ -120,10 +119,10 @@ async function handler(request: NextRequest) {
} catch (e) {
let message = "Error confirming booking";
if (e instanceof TRPCError) message = (e as TRPCError).message;
return NextResponse.redirect(`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent(message)}`);
return NextResponse.redirect(new URL(`/booking/${bookingUid}?error=${encodeURIComponent(message)}`, request.url));
}
return NextResponse.redirect(`${url.origin}/booking/${bookingUid}`);
return NextResponse.redirect(new URL(`/booking/${bookingUid}`, request.url));
}
export const GET = defaultResponderForAppDir(handler);
@@ -43,6 +43,7 @@ async function getHandler(req: NextRequest) {
// If onboarding-v3 is enabled AND organizationOnboardingId exists, redirect to onboarding flow
if (isOnboardingV3Enabled && organizationOnboardingId) {
params.append("fromTeamOnboarding", "true");
const redirectUrl = new URL(
`/onboarding/personal/settings?${params.toString()}`,
WEBAPP_URL
@@ -0,0 +1,222 @@
import type { NextRequest } from "next/server";
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("app/api/defaultResponderForAppDir", () => ({
defaultResponderForAppDir:
(handler: (req: NextRequest) => Promise<Response>) =>
(req: NextRequest, _context: { params: Promise<Record<string, string>> }) =>
handler(req),
}));
vi.mock("app/api/parseRequestData", () => ({
parseRequestData: vi.fn().mockResolvedValue({}),
}));
vi.mock("next/headers", () => ({
headers: vi.fn().mockResolvedValue(new Headers()),
cookies: vi.fn().mockResolvedValue({ getAll: () => [] }),
}));
vi.mock("next/server", () => ({
NextResponse: {
redirect: vi.fn((url: string | URL, init?: { status?: number }) => {
const location = typeof url === "string" ? url : url.toString();
return {
status: init?.status ?? 302,
headers: {
get: (name: string) => (name.toLowerCase() === "location" ? location : null),
},
} as unknown as Response;
}),
},
}));
vi.mock("@calcom/prisma", () => ({
default: {
booking: {
findUnique: vi.fn(),
update: vi.fn(),
},
user: {
findUniqueOrThrow: vi.fn(),
},
},
}));
vi.mock("@calcom/trpc/server/createContext", () => ({
createContext: vi.fn().mockResolvedValue({}),
}));
vi.mock("@calcom/trpc/server/routers/viewer/bookings/_router", () => ({
bookingsRouter: {},
}));
vi.mock("@calcom/trpc/server/trpc", () => ({
createCallerFactory: vi.fn().mockReturnValue(() => ({
confirm: vi.fn().mockResolvedValue({}),
})),
}));
vi.mock("@lib/buildLegacyCtx", () => ({
buildLegacyRequest: vi.fn().mockReturnValue({}),
}));
// Import after mocks are set up
import { GET, POST } from "../route";
const createMockRequest = (url: string, method: "GET" | "POST" = "GET"): NextRequest => {
const urlObj = new URL(url);
return {
method,
url,
nextUrl: {
searchParams: urlObj.searchParams,
},
} as unknown as NextRequest;
};
describe("verify-booking-token route", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET handler", () => {
it("should redirect to booking page with error when action is reject (requires POST)", async () => {
const baseUrl =
"https://app.example.com/api/verify-booking-token?action=reject&token=test-token&bookingUid=abc123&userId=42";
const req = createMockRequest(baseUrl, "GET");
const res = await GET(req, { params: Promise.resolve({}) });
const location = res.headers.get("location");
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe("https://app.example.com");
expect(redirectUrl.pathname).toBe("/booking/abc123");
expect(redirectUrl.searchParams.get("error")).toBe("Rejection requires POST method");
});
it("should redirect with error when query params are invalid (missing action)", async () => {
const baseUrl =
"https://app.example.com/api/verify-booking-token?token=test-token&bookingUid=abc123&userId=42";
const req = createMockRequest(baseUrl, "GET");
const res = await GET(req, { params: Promise.resolve({}) });
const location = res.headers.get("location");
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe("https://app.example.com");
expect(redirectUrl.pathname).toBe("/booking/abc123");
expect(redirectUrl.searchParams.get("error")).toBe("Error confirming booking");
});
it("should redirect with error when query params are invalid (missing token)", async () => {
const baseUrl =
"https://app.example.com/api/verify-booking-token?action=accept&bookingUid=abc123&userId=42";
const req = createMockRequest(baseUrl, "GET");
const res = await GET(req, { params: Promise.resolve({}) });
const location = res.headers.get("location");
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe("https://app.example.com");
expect(redirectUrl.pathname).toBe("/booking/abc123");
expect(redirectUrl.searchParams.get("error")).toBe("Error confirming booking");
});
it("should preserve the request origin in redirect URL (not hardcode localhost)", async () => {
const baseUrl =
"https://custom-domain.example.org/api/verify-booking-token?action=reject&token=t&bookingUid=booking-uid&userId=1";
const req = createMockRequest(baseUrl, "GET");
const res = await GET(req, { params: Promise.resolve({}) });
const location = res.headers.get("location");
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe("https://custom-domain.example.org");
expect(location).not.toContain("localhost");
});
it("should handle empty bookingUid in error case", async () => {
const baseUrl = "https://app.example.com/api/verify-booking-token?token=test-token&userId=42";
const req = createMockRequest(baseUrl, "GET");
const res = await GET(req, { params: Promise.resolve({}) });
const location = res.headers.get("location");
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.pathname).toBe("/booking/");
expect(redirectUrl.searchParams.get("error")).toBe("Error confirming booking");
});
});
describe("POST handler", () => {
it("should redirect with status 303 when query params are invalid", async () => {
const baseUrl =
"https://app.example.com/api/verify-booking-token?token=test-token&bookingUid=abc123&userId=42";
const req = createMockRequest(baseUrl, "POST");
const res = await POST(req, { params: Promise.resolve({}) });
expect(res.status).toBe(303);
const location = res.headers.get("location");
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe("https://app.example.com");
expect(redirectUrl.pathname).toBe("/booking/abc123");
expect(redirectUrl.searchParams.get("error")).toBe("Error confirming booking");
});
it("should preserve the request origin in POST redirect URL", async () => {
const baseUrl =
"https://self-hosted.company.com/api/verify-booking-token?bookingUid=uid123&token=t&userId=1";
const req = createMockRequest(baseUrl, "POST");
const res = await POST(req, { params: Promise.resolve({}) });
const location = res.headers.get("location");
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe("https://self-hosted.company.com");
expect(location).not.toContain("localhost");
});
});
describe("redirect URL construction", () => {
it("should construct redirect URLs relative to the request URL, not hardcoded origins", async () => {
const testOrigins = [
"https://app.cal.com",
"https://acme.cal.com",
"https://calcom.company.internal",
"http://192.168.1.100:3000",
];
for (const origin of testOrigins) {
vi.clearAllMocks();
const baseUrl = `${origin}/api/verify-booking-token?action=reject&token=t&bookingUid=test-uid&userId=1`;
const req = createMockRequest(baseUrl, "GET");
const res = await GET(req, { params: Promise.resolve({}) });
const location = res.headers.get("location");
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.origin).toBe(origin);
expect(redirectUrl.pathname).toBe("/booking/test-uid");
}
});
});
});
+13 -11
View File
@@ -29,7 +29,6 @@ const querySchema = z.object({
});
async function getHandler(request: NextRequest) {
const url = new URL(request.url);
const queryParams = Object.fromEntries(request.nextUrl.searchParams.entries());
try {
@@ -38,21 +37,20 @@ async function getHandler(request: NextRequest) {
if (action === DirectAction.REJECT) {
// Rejections should use POST method
return NextResponse.redirect(
`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Rejection requires POST method")}`
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Rejection requires POST method")}`, request.url)
);
}
return await handleBookingAction(action, token, bookingUid, userId, request, undefined);
} catch (error) {
} catch {
const bookingUid = queryParams.bookingUid || "";
return NextResponse.redirect(
`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, request.url)
);
}
}
async function postHandler(request: NextRequest) {
const url = new URL(request.url);
const queryParams = Object.fromEntries(request.nextUrl.searchParams.entries());
try {
@@ -61,10 +59,11 @@ async function postHandler(request: NextRequest) {
const { reason } = z.object({ reason: z.string().optional() }).parse(body || {});
return await handleBookingAction(action, token, bookingUid, userId, request, reason);
} catch (error) {
} catch {
const bookingUid = queryParams.bookingUid || "";
return NextResponse.redirect(
`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, request.url),
{ status: 303 }
);
}
}
@@ -77,14 +76,14 @@ async function handleBookingAction(
request: NextRequest,
reason?: string
) {
const url = new URL(request.url);
const booking = await prisma.booking.findUnique({
where: { oneTimePassword: token },
});
if (!booking) {
return NextResponse.redirect(
`${url.origin}/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, request.url),
{ status: 303 }
);
}
@@ -140,7 +139,10 @@ async function handleBookingAction(
} catch (e) {
let message = "Error confirming booking";
if (e instanceof TRPCError) message = (e as TRPCError).message;
return NextResponse.redirect(`${url.origin}/booking/${booking.uid}?error=${encodeURIComponent(message)}`);
return NextResponse.redirect(
new URL(`/booking/${booking.uid}?error=${encodeURIComponent(message)}`, request.url),
{ status: 303 }
);
}
await prisma.booking.update({
@@ -148,7 +150,7 @@ async function handleBookingAction(
data: { oneTimePassword: null },
});
return NextResponse.redirect(`${url.origin}/booking/${booking.uid}`);
return NextResponse.redirect(new URL(`/booking/${booking.uid}`, request.url), { status: 303 });
}
export const GET = defaultResponderForAppDir(getHandler);
@@ -91,13 +91,13 @@ vi.mock("@calcom/emails/email-manager", () => ({
const mockFindByPhoneNumber = vi.fn();
const mockFindByProviderAgentId = vi.fn();
vi.mock("@calcom/lib/server/repository/PrismaPhoneNumberRepository", () => ({
vi.mock("@calcom/features/calAIPhone/repositories/PrismaPhoneNumberRepository", () => ({
PrismaPhoneNumberRepository: vi.fn().mockImplementation(() => ({
findByPhoneNumber: mockFindByPhoneNumber,
})),
}));
vi.mock("@calcom/lib/server/repository/PrismaAgentRepository", () => ({
vi.mock("@calcom/features/calAIPhone/repositories/PrismaAgentRepository", () => ({
PrismaAgentRepository: vi.fn().mockImplementation(() => ({
findByProviderAgentId: mockFindByProviderAgentId,
})),
+3 -3
View File
@@ -4,12 +4,12 @@ import { NextResponse } from "next/server";
import { Retell } from "retell-sdk";
import { z } from "zod";
import { PrismaAgentRepository } from "@calcom/features/calAIPhone/repositories/PrismaAgentRepository";
import { PrismaPhoneNumberRepository } from "@calcom/features/calAIPhone/repositories/PrismaPhoneNumberRepository";
import { CreditService } from "@calcom/features/ee/billing/credit-service";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { PrismaAgentRepository } from "@calcom/lib/server/repository/PrismaAgentRepository";
import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository";
import prisma from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import { CreditUsageType } from "@calcom/prisma/enums";
const log = logger.getSubLogger({ prefix: ["retell-ai-webhook"] });
+1 -2
View File
@@ -184,8 +184,7 @@ export const AppPage = ({
enabled: !!dependencies,
});
const disableInstall =
dependencyData.data && dependencyData.data.some((dependency) => !dependency.installed);
const disableInstall = dependencyData.data ? dependencyData.data.some((dependency) => !dependency.installed) : false;
// const disableInstall = requiresGCal && !gCalInstalled.data;
@@ -0,0 +1,103 @@
import { render, screen } from "@testing-library/react";
import { vi } from "vitest";
import { InstallAppButtonChild } from "./InstallAppButtonChild";
// Mock credential type
type MockCredential = {
id: number;
delegatedToId: string;
userId: number;
user: { email: string };
key: { access_token: string };
invalid: boolean;
teamId: null;
team: null;
delegationCredentialId: string;
type:
| `${string}_calendar`
| `${string}_messaging`
| `${string}_payment`
| `${string}_video`
| `${string}_other`
| `${string}_automation`
| `${string}_analytics`
| `${string}_crm`
| `${string}_other_calendar`;
appId: string;
};
// Factory function to create mock credentials
const createMockCredential = (overrides: Partial<MockCredential> = {}): MockCredential => ({
id: 1,
type: "google_calendar" as const,
userId: 1,
delegatedToId: "delegation-123",
teamId: null,
team: null,
user: { email: "test@example.com" },
key: { access_token: "mock_token_123" },
delegationCredentialId: "delegation-123",
appId: "google-calendar",
invalid: false,
...overrides,
});
// Mock the useLocale hook
vi.mock("@calcom/lib/hooks/useLocale", () => ({
useLocale: () => ({
t: (key: string) => {
const translations = {
install_app: "Install App",
install_another: "Install Another",
start_paid_trial: "Start Free Trial",
subscribe: "Subscribe",
};
return translations[key as keyof typeof translations] || key;
},
}),
}));
describe("InstallAppButtonChild", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("is disabled when credentials exist and multiInstall is false", () => {
const mockCredentials = [createMockCredential()];
render(<InstallAppButtonChild multiInstall={false} credentials={mockCredentials} />);
const button = screen.getByTestId("install-app-button");
expect(button).toBeDisabled();
});
it("is enabled when credentials exist and multiInstall is true", () => {
const mockCredentials = [createMockCredential()];
render(<InstallAppButtonChild multiInstall={true} credentials={mockCredentials} />);
const button = screen.getByTestId("install-app-button");
expect(button).not.toBeDisabled();
});
it("is disabled when disabled prop is true regardless of credentials", () => {
render(<InstallAppButtonChild disabled={true} multiInstall={true} credentials={[]} />);
const button = screen.getByTestId("install-app-button");
expect(button).toBeDisabled();
});
it("combines disabled prop with credential logic correctly", () => {
const mockCredentials = [createMockCredential()];
render(<InstallAppButtonChild disabled={true} multiInstall={false} credentials={mockCredentials} />);
const button = screen.getByTestId("install-app-button");
expect(button).toBeDisabled();
});
it("is enabled when no blocking conditions exist", () => {
render(<InstallAppButtonChild multiInstall={false} credentials={[]} disabled={false} />);
const button = screen.getByTestId("install-app-button");
expect(button).not.toBeDisabled();
});
});
@@ -17,6 +17,7 @@ export const InstallAppButtonChild = ({
const { t } = useLocale();
const shouldDisableInstallation = !multiInstall ? !!(credentials && credentials.length) : false;
const isDisabled = shouldDisableInstallation || props.disabled;
// Paid apps don't support team installs at the moment
// Also, cal.ai(the only paid app at the moment) doesn't support team install either
@@ -25,7 +26,7 @@ export const InstallAppButtonChild = ({
<Button
data-testid="install-app-button"
{...props}
disabled={shouldDisableInstallation}
disabled={isDisabled}
color="primary"
size="base">
{paid.trial ? t("start_paid_trial") : t("subscribe")}
@@ -37,7 +38,7 @@ export const InstallAppButtonChild = ({
<Button
data-testid="install-app-button"
{...props}
disabled={shouldDisableInstallation}
disabled={isDisabled}
color="primary"
size="base">
{multiInstall ? t("install_another") : t("install_app")}
@@ -6,12 +6,13 @@ import { Button } from "@calcom/ui/components/button";
import { ConfirmDialog } from "./confirmDialog";
interface IWipeMyCalActionButtonProps {
className?: string;
bookingsEmpty: boolean;
bookingStatus: "upcoming" | "recurring" | "past" | "cancelled" | "unconfirmed";
}
const WipeMyCalActionButton = (props: IWipeMyCalActionButtonProps) => {
const { bookingsEmpty, bookingStatus } = props;
const { className, bookingsEmpty, bookingStatus } = props;
const [openDialog, setOpenDialog] = useState(false);
const { isSuccess, isPending, data } = trpc.viewer.apps.integrations.useQuery({
variant: "other",
@@ -28,7 +29,7 @@ const WipeMyCalActionButton = (props: IWipeMyCalActionButtonProps) => {
return (
<>
{data && isSuccess && !isPending && credentialId && (
<div className="mb-4">
<div className={className}>
<ConfirmDialog isOpenDialog={openDialog} setIsOpenDialog={setOpenDialog} />
<Button color="primary" onClick={() => setOpenDialog(true)} data-testid="wipe-today-button">
Wipe Today
@@ -0,0 +1,51 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { useBookingConfirmation } from "./hooks/useBookingConfirmation";
interface AcceptBookingButtonProps {
bookingId: number;
bookingUid: string;
recurringEventId?: string | null;
isRecurring?: boolean;
isTabRecurring?: boolean;
isTabUnconfirmed?: boolean;
size?: "sm" | "base" | "lg";
color?: "primary" | "secondary" | "minimal" | "destructive";
className?: string;
}
export function AcceptBookingButton({
bookingId,
bookingUid,
recurringEventId,
isRecurring = false,
isTabRecurring = false,
isTabUnconfirmed = false,
size = "base",
color = "primary",
className,
}: AcceptBookingButtonProps) {
const { t } = useLocale();
const { bookingConfirm, isPending } = useBookingConfirmation({
isRecurring,
isTabRecurring,
isTabUnconfirmed,
});
const confirmLabel = (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("confirm_all") : t("confirm");
return (
<Button
color={color}
size={size}
className={className}
onClick={() => bookingConfirm({ bookingId, confirmed: true, recurringEventId })}
disabled={isPending}
data-booking-uid={bookingUid}
data-testid="confirm">
{confirmLabel}
</Button>
);
}
+342 -343
View File
@@ -1,5 +1,5 @@
import Link from "next/link";
import { useState } from "react";
import { useState, useEffect, useRef } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { getPaymentAppData } from "@calcom/app-store/_utils/payments/getPaymentAppData";
@@ -8,7 +8,6 @@ import { getSuccessPageLocationMessage, guessEventLocationType } from "@calcom/a
import dayjs from "@calcom/dayjs";
// TODO: Use browser locale, implement Intl in Dayjs maybe?
import "@calcom/dayjs/locales";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { formatTime } from "@calcom/lib/dayjs";
import { useCopy } from "@calcom/lib/hooks/useCopy";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -23,7 +22,6 @@ import type { Ensure } from "@calcom/types/utils";
import classNames from "@calcom/ui/classNames";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
import {
Dropdown,
DropdownItem,
@@ -35,30 +33,30 @@ import {
DropdownMenuTrigger,
DropdownMenuPortal,
} from "@calcom/ui/components/dropdown";
import { TextAreaField } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { MeetingTimeInTimezones } from "@calcom/ui/components/popover";
import { TableActions } from "@calcom/ui/components/table";
import type { ActionType } from "@calcom/ui/components/table";
import { showToast } from "@calcom/ui/components/toast";
import { Tooltip } from "@calcom/ui/components/tooltip";
import assignmentReasonBadgeTitleMap from "@lib/booking/assignmentReasonBadgeTitleMap";
import { buildBookingLink } from "../../modules/bookings/lib/buildBookingLink";
import { useBookingDetailsSheetStore } from "../../modules/bookings/store/bookingDetailsSheetStore";
import type { BookingAttendee } from "../../modules/bookings/types";
import { AcceptBookingButton } from "./AcceptBookingButton";
import { RejectBookingButton } from "./RejectBookingButton";
import { BookingActionsDropdown } from "./actions/BookingActionsDropdown";
import {
useBookingActionsStoreContext,
BookingActionsStoreProvider,
} from "./actions/BookingActionsStoreProvider";
import {
getPendingActions,
getCancelEventAction,
shouldShowPendingActions,
shouldShowRecurringCancelAction,
shouldShowIndividualReportButton,
type BookingActionContext,
getReportAction,
isActionDisabled,
} from "./actions/bookingActions";
import type { BookingItemProps } from "./types";
@@ -139,6 +137,7 @@ const ConditionalLink = ({
function BookingListItem(booking: BookingItemProps) {
const parsedBooking = buildParsedBooking(booking);
const itemRef = useRef<HTMLDivElement>(null);
const { userTimeZone, userTimeFormat, userEmail } = booking.loggedInUser;
const { onClick } = booking;
@@ -146,36 +145,26 @@ function BookingListItem(booking: BookingItemProps) {
t,
i18n: { language },
} = useLocale();
const utils = trpc.useUtils();
const [rejectionReason, setRejectionReason] = useState<string>("");
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
const attendeeList = booking.attendees.map((attendee) => {
return {
name: attendee.name,
email: attendee.email,
id: attendee.id,
noShow: attendee.noShow || false,
phoneNumber: attendee.phoneNumber,
};
});
// Get selected booking UID from store
// The provider should always be available when BookingListItem is rendered (bookingsV3Enabled is true)
const selectedBookingUid = useBookingDetailsSheetStore((state) => state.selectedBookingUid);
const isSelected = !!selectedBookingUid && selectedBookingUid === booking.uid;
const mutation = trpc.viewer.bookings.confirm.useMutation({
onSuccess: (data) => {
if (data?.status === BookingStatus.REJECTED) {
setRejectionDialogIsOpen(false);
showToast(t("booking_rejection_success"), "success");
} else {
showToast(t("booking_confirmation_success"), "success");
}
utils.viewer.bookings.invalidate();
utils.viewer.me.bookingUnconfirmedCount.invalidate();
},
onError: () => {
showToast(t("booking_confirmation_failed"), "error");
utils.viewer.bookings.invalidate();
},
});
// Scroll into view when this booking becomes selected
useEffect(() => {
if (isSelected && itemRef.current) {
itemRef.current.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
}, [isSelected]);
const attendeeList = booking.attendees.map((attendee) => ({
...attendee,
noShow: attendee.noShow || false,
}));
const isUpcoming = new Date(booking.endTime) >= new Date();
const isOngoing = isUpcoming && new Date() >= new Date(booking.startTime);
@@ -217,22 +206,6 @@ function BookingListItem(booking: BookingItemProps) {
const isDisabledRescheduling = booking.eventType.disableRescheduling;
const cardCharged = booking?.payment[0]?.success;
const bookingConfirm = async (confirm: boolean) => {
let body = {
bookingId: booking.id,
confirmed: confirm,
reason: rejectionReason,
};
/**
* Only pass down the recurring event id when we need to confirm the entire series, which happens in
* the "Recurring" tab and "Unconfirmed" tab, to support confirming discretionally in the "Recurring" tab.
*/
if ((isTabRecurring || isTabUnconfirmed) && isRecurring) {
body = Object.assign({}, body, { recurringEventId: booking.recurringEventId });
}
mutation.mutate(body);
};
const getSeatReferenceUid = () => {
return userSeat?.referenceUid;
};
@@ -265,20 +238,6 @@ function BookingListItem(booking: BookingItemProps) {
t,
} as BookingActionContext;
const basePendingActions = getPendingActions(actionContext);
const pendingActions: ActionType[] = basePendingActions.map((action) => ({
...action,
onClick:
action.id === "reject"
? () => setRejectionDialogIsOpen(true)
: action.id === "confirm"
? () => bookingConfirm(true)
: undefined,
disabled: action.disabled || mutation.isPending,
})) as ActionType[];
const cancelEventAction = getCancelEventAction(actionContext);
const RequestSentMessage = () => {
return (
<Badge startIcon="send" size="md" variant="gray" data-testid="request_reschedule_sent">
@@ -313,6 +272,7 @@ function BookingListItem(booking: BookingItemProps) {
const showPendingPayment = paymentAppData.enabled && booking.payment.length && !booking.paid;
const setIsOpenReportDialog = useBookingActionsStoreContext((state) => state.setIsOpenReportDialog);
const setIsCancelDialogOpen = useBookingActionsStoreContext((state) => state.setIsCancelDialogOpen);
const reportAction = getReportAction(actionContext);
const reportActionWithHandler = {
@@ -321,232 +281,243 @@ function BookingListItem(booking: BookingItemProps) {
};
return (
<>
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
<div>
<TextAreaField
name="rejectionReason"
label={
<>
{t("rejection_reason")}
<span className="text-subtle font-normal"> (Optional)</span>
</>
}
value={rejectionReason}
onChange={(e) => setRejectionReason(e.target.value)}
/>
</div>
<DialogFooter>
<DialogClose />
<Button
disabled={mutation.isPending}
data-testid="rejection-confirm"
onClick={() => {
bookingConfirm(false);
}}>
{t("rejection_confirmation")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div
data-testid="booking-item"
data-today={String(booking.isToday)}
className="hover:bg-cal-muted group w-full">
<div className="flex flex-col sm:flex-row">
<div className="sm:min-w-48 hidden align-top ltr:pl-3 rtl:pr-6 sm:table-cell">
<div className="flex h-full items-center">
{eventTypeColor && (
<div className="h-[70%] w-0.5" style={{ backgroundColor: eventTypeColor }} />
)}
<ConditionalLink onClick={onClick} bookingLink={bookingLink} className="ml-3">
<div className="cursor-pointer py-4">
<div className="text-emphasis text-sm leading-6">{startTime}</div>
<div className="text-subtle text-sm">
{formatTime(booking.startTime, userTimeFormat, userTimeZone)} -{" "}
{formatTime(booking.endTime, userTimeFormat, userTimeZone)}
<MeetingTimeInTimezones
timeFormat={userTimeFormat}
userTimezone={userTimeZone}
startTime={booking.startTime}
endTime={booking.endTime}
attendees={booking.attendees}
/>
</div>
{!isPending && (
<div>
{(provider?.label ||
(typeof locationToDisplay === "string" &&
locationToDisplay?.startsWith("https://"))) &&
locationToDisplay.startsWith("http") && (
<a
href={locationToDisplay}
onClick={(e) => e.stopPropagation()}
target="_blank"
title={locationToDisplay}
rel="noreferrer"
className="text-sm leading-6 text-blue-600 hover:underline dark:text-blue-400">
<div className="flex items-center gap-2">
{provider?.iconUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={provider.iconUrl}
width={16}
height={16}
className="h-4 w-4 rounded-sm"
alt={`${provider?.label} logo`}
/>
)}
{provider?.label
? t("join_event_location", { eventLocationType: provider?.label })
: t("join_meeting")}
</div>
</a>
)}
</div>
)}
</div>
</ConditionalLink>
</div>
</div>
<div data-testid="title-and-attendees" className={`w-full px-4${isRejected ? "line-through" : ""}`}>
<ConditionalLink onClick={onClick} bookingLink={bookingLink}>
{/* Time and Badges for mobile */}
<div className="w-full pb-2 pt-4 sm:hidden">
<div className="flex w-full items-center justify-between sm:hidden">
<div className="text-emphasis text-sm leading-6">{startTime}</div>
<div className="text-subtle pr-2 text-sm">
{formatTime(booking.startTime, userTimeFormat, userTimeZone)} -{" "}
{formatTime(booking.endTime, userTimeFormat, userTimeZone)}
<MeetingTimeInTimezones
timeFormat={userTimeFormat}
userTimezone={userTimeZone}
startTime={booking.startTime}
endTime={booking.endTime}
attendees={booking.attendees}
/>
</div>
</div>
{isPending && (
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
{t("unconfirmed")}
</Badge>
)}
{booking.eventType?.team && (
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="gray">
{booking.eventType.team.name}
</Badge>
)}
{showPendingPayment && (
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
{t("pending_payment")}
</Badge>
)}
{isRescheduled && (
<Tooltip content={`${t("rescheduled_by")} ${booking.rescheduler}`}>
<Badge variant="orange" className="ltr:mr-2 rtl:ml-2 sm:hidden">
{t("rescheduled")}
</Badge>
</Tooltip>
)}
{recurringDates !== undefined && (
<div className="text-muted text-sm sm:hidden">
<RecurringBookingsTooltip
userTimeFormat={userTimeFormat}
userTimeZone={userTimeZone}
booking={booking}
recurringDates={recurringDates}
/>
</div>
)}
</div>
<div
ref={itemRef}
data-testid="booking-item"
data-today={String(booking.isToday)}
data-booking-list-item="true"
data-booking-uid={booking.uid}
className={classNames(
"group relative w-full transition-all duration-100 ease-out",
"hover:bg-cal-muted",
isSelected &&
"bg-cal-muted before:bg-brand-default rounded-r-md before:absolute before:left-0 before:top-0 before:h-full before:w-1"
)}>
<div className="flex flex-col sm:flex-row">
<div className="sm:min-w-48 hidden align-top ltr:pl-3 rtl:pr-6 sm:table-cell">
<div className="flex h-full items-center">
{eventTypeColor && <div className="h-[70%] w-0.5" style={{ backgroundColor: eventTypeColor }} />}
<ConditionalLink onClick={onClick} bookingLink={bookingLink} className="ml-3">
<div className="cursor-pointer py-4">
<div
title={title}
className={classNames(
"max-w-10/12 sm:max-w-56 text-emphasis break-words text-sm font-medium leading-6 md:max-w-full",
isCancelled ? "line-through" : ""
)}>
{title}
<span> </span>
{showPendingPayment && (
<Badge className="hidden sm:inline-flex" variant="orange">
{t("pending_payment")}
</Badge>
)}
</div>
{booking.description && (
<div
className="max-w-10/12 sm:max-w-32 md:max-w-52 xl:max-w-80 text-default truncate text-sm"
title={booking.description}>
&quot;{booking.description}&quot;
</div>
)}
{booking.attendees.length !== 0 && (
<DisplayAttendees
attendees={attendeeList}
user={booking.user}
currentEmail={userEmail}
bookingUid={booking.uid}
isBookingInPast={isBookingInPast}
<div className="text-emphasis text-sm leading-6">{startTime}</div>
<div className="text-subtle text-sm">
{formatTime(booking.startTime, userTimeFormat, userTimeZone)} -{" "}
{formatTime(booking.endTime, userTimeFormat, userTimeZone)}
<MeetingTimeInTimezones
timeFormat={userTimeFormat}
userTimezone={userTimeZone}
startTime={booking.startTime}
endTime={booking.endTime}
attendees={booking.attendees}
/>
)}
{isCancelled && booking.rescheduled && (
<div className="mt-2 inline-block md:hidden">
<RequestSentMessage />
</div>
{!isPending && (
<div>
{(provider?.label ||
(typeof locationToDisplay === "string" && locationToDisplay?.startsWith("https://"))) &&
locationToDisplay.startsWith("http") && (
<a
href={locationToDisplay}
onClick={(e) => e.stopPropagation()}
target="_blank"
title={locationToDisplay}
rel="noreferrer"
className="text-sm leading-6 text-blue-600 hover:underline dark:text-blue-400">
<div className="flex items-center gap-2">
{provider?.iconUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={provider.iconUrl}
width={16}
height={16}
className="h-4 w-4 rounded-sm"
alt={`${provider?.label} logo`}
/>
)}
{provider?.label
? t("join_event_location", { eventLocationType: provider?.label })
: t("join_meeting")}
</div>
</a>
)}
</div>
)}
</div>
</ConditionalLink>
</div>
<div className="stack-y-2 sm:stack-y-0 flex w-full flex-col flex-wrap items-end justify-end space-x-2 py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:flex-row sm:flex-nowrap sm:items-start sm:pl-0">
{shouldShowPendingActions(actionContext) && <TableActions actions={pendingActions} />}
<BookingActionsDropdown booking={booking} context="list" />
{shouldShowRecurringCancelAction(actionContext) && <TableActions actions={[cancelEventAction]} />}
{shouldShowIndividualReportButton(actionContext) && (
<div className="flex items-center space-x-2">
<Button
type="button"
variant="icon"
color="destructive"
StartIcon={reportActionWithHandler.icon}
onClick={reportActionWithHandler.onClick}
disabled={reportActionWithHandler.disabled}
data-testid={reportActionWithHandler.id}
className="min-h-[34px] min-w-[34px]"
tooltip={reportActionWithHandler.label}
/>
</div>
)}
{isRejected && <div className="text-subtle text-sm">{t("rejected")}</div>}
{isCancelled && booking.rescheduled && (
<div className="hidden h-full items-center md:flex">
<RequestSentMessage />
</div>
)}
</div>
</div>
<BookingItemBadges
booking={booking}
isPending={isPending}
recurringDates={recurringDates}
userTimeFormat={userTimeFormat}
userTimeZone={userTimeZone}
isRescheduled={isRescheduled}
/>
<div
data-testid="title-and-attendees"
className={classNames("flex-1 px-4", isRejected && "line-through")}>
<ConditionalLink onClick={onClick} bookingLink={bookingLink} className="flex h-full flex-col">
{/* Time and Badges for mobile */}
<div className="w-full pb-2 pt-4 sm:hidden">
<div className="flex w-full items-center justify-between sm:hidden">
<div className="text-emphasis text-sm leading-6">{startTime}</div>
<div className="text-subtle pr-2 text-sm">
{formatTime(booking.startTime, userTimeFormat, userTimeZone)} -{" "}
{formatTime(booking.endTime, userTimeFormat, userTimeZone)}
<MeetingTimeInTimezones
timeFormat={userTimeFormat}
userTimezone={userTimeZone}
startTime={booking.startTime}
endTime={booking.endTime}
attendees={booking.attendees}
/>
</div>
</div>
{isPending && (
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
{t("unconfirmed")}
</Badge>
)}
{booking.eventType?.team && (
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="gray">
{booking.eventType.team.name}
</Badge>
)}
{showPendingPayment && (
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
{t("pending_payment")}
</Badge>
)}
{isRescheduled && (
<Tooltip content={`${t("rescheduled_by")} ${booking.rescheduler}`}>
<Badge variant="orange" className="ltr:mr-2 rtl:ml-2 sm:hidden">
{t("rescheduled")}
</Badge>
</Tooltip>
)}
{recurringDates !== undefined && (
<div className="text-muted text-sm sm:hidden">
<RecurringBookingsTooltip
userTimeFormat={userTimeFormat}
userTimeZone={userTimeZone}
booking={booking}
recurringDates={recurringDates}
/>
</div>
)}
</div>
<div className="cursor-pointer py-4">
<div
title={title}
className={classNames(
"max-w-10/12 text-emphasis sm:max-w-56 break-words text-sm font-medium leading-6 md:max-w-full",
isCancelled ? "line-through" : ""
)}>
{title}
<span> </span>
{showPendingPayment && (
<Badge className="hidden sm:inline-flex" variant="orange">
{t("pending_payment")}
</Badge>
)}
</div>
{booking.description && (
<div
className="max-w-10/12 text-default sm:max-w-32 md:max-w-52 xl:max-w-80 truncate text-sm"
title={booking.description}>
&quot;{booking.description}&quot;
</div>
)}
{booking.attendees.length !== 0 && (
<DisplayAttendees
attendees={attendeeList}
user={booking.user}
currentEmail={userEmail}
bookingUid={booking.uid}
isBookingInPast={isBookingInPast}
/>
)}
{isCancelled && booking.rescheduled && (
<div className="mt-2 inline-block md:hidden">
<RequestSentMessage />
</div>
)}
</div>
</ConditionalLink>
</div>
<div className="flex flex-col flex-wrap items-end justify-end gap-2 py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:flex-shrink-0 sm:flex-row sm:flex-nowrap sm:items-start sm:pl-0">
{shouldShowPendingActions(actionContext) && (
<div className="flex space-x-2 rtl:space-x-reverse">
<RejectBookingButton
bookingId={booking.id}
bookingUid={booking.uid}
recurringEventId={booking.recurringEventId}
isRecurring={isRecurring}
isTabRecurring={isTabRecurring}
isTabUnconfirmed={isTabUnconfirmed}
/>
<AcceptBookingButton
bookingId={booking.id}
bookingUid={booking.uid}
recurringEventId={booking.recurringEventId}
isRecurring={isRecurring}
isTabRecurring={isTabRecurring}
isTabUnconfirmed={isTabUnconfirmed}
/>
</div>
)}
{shouldShowRecurringCancelAction(actionContext) && (
<Button
className="whitespace-nowrap"
key="cancel"
data-testid="cancel"
onClick={(e) => {
e.stopPropagation();
setIsCancelDialogOpen(true);
}}
StartIcon="circle-x"
disabled={isActionDisabled("cancel", actionContext)}
data-booking-uid={booking.uid}
color="destructive">
{t("cancel_all_remaining")}
</Button>
)}
{isCancelled && booking.rescheduled && (
<div className="hidden items-center md:flex">
<RequestSentMessage />
</div>
)}
{shouldShowIndividualReportButton(actionContext) && (
<div className="flex items-center gap-2">
<Button
type="button"
variant="icon"
color="destructive"
StartIcon={reportActionWithHandler.icon}
onClick={reportActionWithHandler.onClick}
disabled={reportActionWithHandler.disabled}
data-testid={reportActionWithHandler.id}
className="min-h-[34px] min-w-[34px]"
tooltip={reportActionWithHandler.label}
/>
</div>
)}
<BookingActionsDropdown booking={booking} context="list" />
</div>
</div>
</>
<BookingItemBadges
booking={booking}
isPending={isPending}
isRejected={isRejected}
recurringDates={recurringDates}
userTimeFormat={userTimeFormat}
userTimeZone={userTimeZone}
isRescheduled={isRescheduled}
/>
</div>
);
}
const BookingItemBadges = ({
booking,
isPending,
isRejected,
recurringDates,
userTimeFormat,
userTimeZone,
@@ -554,6 +525,7 @@ const BookingItemBadges = ({
}: {
booking: BookingItemProps;
isPending: boolean;
isRejected: boolean;
recurringDates: Date[] | undefined;
userTimeFormat: number | null | undefined;
userTimeZone: string | undefined;
@@ -575,6 +547,11 @@ const BookingItemBadges = ({
</Badge>
</Tooltip>
)}
{isRejected && !isRescheduled && booking.assignmentReason.length === 0 && (
<Badge variant="gray" className="ltr:mr-2 rtl:ml-2">
{t("rejected")}
</Badge>
)}
{booking.eventType?.team && (
<Badge className="ltr:mr-2 rtl:ml-2" variant="gray">
{booking.eventType.team.name}
@@ -727,21 +704,13 @@ const FirstAttendee = ({
);
};
type AttendeeProps = {
name?: string;
email: string;
phoneNumber: string | null;
id: number;
noShow: boolean;
};
type NoShowProps = {
bookingUid: string;
isBookingInPast: boolean;
};
const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
const { email, name, bookingUid, isBookingInPast, noShow, phoneNumber } = attendeeProps;
const Attendee = (attendeeProps: BookingAttendee & NoShowProps) => {
const { email, name, bookingUid, isBookingInPast, noShow, phoneNumber, user } = attendeeProps;
const { t } = useLocale();
const utils = trpc.useUtils();
@@ -758,6 +727,8 @@ const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
},
});
const displayName = user?.name || name || user?.email || email;
return (
<Dropdown open={openDropdown} onOpenChange={setOpenDropdown}>
<DropdownMenuTrigger asChild>
@@ -767,10 +738,10 @@ const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
className="radix-state-open:text-blue-500 transition hover:text-blue-500">
{noShow ? (
<>
{name || email} <Icon name="eye-off" className="inline h-4" />
{displayName} <Icon name="eye-off" className="inline h-4" />
</>
) : (
<>{name || email}</>
<>{displayName}</>
)}
</button>
</DropdownMenuTrigger>
@@ -825,20 +796,12 @@ const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
};
type GroupedAttendeeProps = {
attendees: AttendeeProps[];
attendees: BookingAttendee[];
bookingUid: string;
};
const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
const { bookingUid } = groupedAttendeeProps;
const attendees = groupedAttendeeProps.attendees.map((attendee) => {
return {
id: attendee.id,
email: attendee.email,
name: attendee.name,
noShow: attendee.noShow || false,
};
});
const { bookingUid, attendees } = groupedAttendeeProps;
const { t } = useLocale();
const utils = trpc.useUtils();
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
@@ -850,11 +813,14 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
showToast(err.message, "error");
},
});
const { control, handleSubmit } = useForm<{
attendees: AttendeeProps[];
}>({
type FormValues = {
attendees: Array<{ id: number; email: string; noShow: boolean }>;
};
const { control, handleSubmit } = useForm<FormValues>({
defaultValues: {
attendees,
attendees: attendees.map((a) => ({ id: a.id, email: a.email, noShow: a.noShow || false })),
},
mode: "onBlur",
});
@@ -864,8 +830,11 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
name: "attendees",
});
const onSubmit = (data: { attendees: AttendeeProps[] }) => {
const filteredData = data.attendees.slice(1);
const onSubmit = (data: FormValues) => {
const filteredData = data.attendees.slice(1).map((attendee) => ({
email: attendee.email,
noShow: attendee.noShow,
}));
noShowMutation.mutate({ bookingUid, attendees: filteredData });
setOpenDropdown(false);
};
@@ -882,30 +851,46 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
{t("plus_more", { count: attendees.length - 1 })}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuContent className="min-w-[300px]">
<DropdownMenuLabel className="text-xs font-medium uppercase">
{t("mark_as_no_show_title")}
</DropdownMenuLabel>
<form onSubmit={handleSubmit(onSubmit)}>
{fields.slice(1).map((field, index) => (
<Controller
key={field.id}
name={`attendees.${index + 1}.noShow`}
control={control}
render={({ field: { onChange, value } }) => (
<DropdownMenuCheckboxItem
checked={value || false}
onCheckedChange={onChange}
className="pr-8 focus:outline-none"
onClick={(e) => {
e.preventDefault();
onChange(!value);
}}>
<span className={value ? "line-through" : ""}>{field.email}</span>
</DropdownMenuCheckboxItem>
)}
/>
))}
{fields.slice(1).map((field, index) => {
const attendee = attendees[index + 1];
const displayName =
attendee.user?.name || attendee.name || attendee.user?.email || attendee.email;
const hasName = attendee.name || attendee.user?.name;
return (
<Controller
key={field.id}
name={`attendees.${index + 1}.noShow`}
control={control}
render={({ field: { onChange, value } }) => (
<DropdownMenuCheckboxItem
checked={value || false}
onCheckedChange={onChange}
className="focus:outline-none"
onClick={(e) => {
e.preventDefault();
onChange(!value);
}}>
<div className={`w-full ${value ? "line-through" : ""}`}>
{hasName ? (
<>
<div>{displayName}</div>
<div className="text-subtle text-xs">{field.email}</div>
</>
) : (
<div>{field.email}</div>
)}
</div>
</DropdownMenuCheckboxItem>
)}
/>
);
})}
<DropdownMenuSeparator />
<div className="flex justify-end p-2">
<Button
@@ -924,7 +909,7 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
);
};
const GroupedGuests = ({ guests }: { guests: AttendeeProps[] }) => {
const GroupedGuests = ({ guests }: { guests: BookingAttendee[] }) => {
const [openDropdown, setOpenDropdown] = useState(false);
const { t } = useLocale();
const { copyToClipboard, isCopied } = useCopy();
@@ -944,23 +929,37 @@ const GroupedGuests = ({ guests }: { guests: AttendeeProps[] }) => {
{t("plus_more", { count: guests.length - 1 })}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuContent className="min-w-[300px]">
<DropdownMenuLabel className="text-xs font-medium uppercase">{t("guests")}</DropdownMenuLabel>
{guests.slice(1).map((guest) => (
<DropdownMenuItem key={guest.id}>
<DropdownItem
className="pr-6 focus:outline-none"
StartIcon={selectedEmail === guest.email ? "circle-check" : undefined}
onClick={(e) => {
e.preventDefault();
setSelectedEmail(guest.email);
}}>
<span className={`${selectedEmail !== guest.email ? "pl-6" : ""}`}>{guest.email}</span>
</DropdownItem>
</DropdownMenuItem>
))}
{guests.slice(1).map((guest) => {
const displayName = guest.user?.name || guest.name || guest.user?.email || guest.email;
const hasName = guest.name || guest.user?.name;
return (
<DropdownMenuItem key={guest.id}>
<DropdownItem
className="pr-6 focus:outline-none"
StartIcon={selectedEmail === guest.email ? "circle-check" : undefined}
onClick={(e) => {
e.preventDefault();
setSelectedEmail(guest.email);
}}>
<div className={`${selectedEmail !== guest.email ? "pl-6" : ""}`}>
{hasName ? (
<>
<div>{displayName}</div>
<div className="text-subtle text-xs">{guest.email}</div>
</>
) : (
<div>{guest.email}</div>
)}
</div>
</DropdownItem>
</DropdownMenuItem>
);
})}
<DropdownMenuSeparator />
<div className="flex justify-end space-x-2 p-2">
<div className="flex justify-end gap-2 p-2">
<Link href={`mailto:${selectedEmail}`}>
<Button
color="secondary"
@@ -995,7 +994,7 @@ const DisplayAttendees = ({
bookingUid,
isBookingInPast,
}: {
attendees: AttendeeProps[];
attendees: BookingAttendee[];
user: UserProps | null;
currentEmail?: string | null;
bookingUid: string;
@@ -1005,7 +1004,7 @@ const DisplayAttendees = ({
attendees.sort((a, b) => a.id - b.id);
return (
<div className="text-emphasis text-sm">
<div className="text-emphasis text-sm" onClick={(e) => e.stopPropagation()}>
{user && <FirstAttendee user={user} currentEmail={currentEmail} />}
{attendees.length > 1 ? <span>,&nbsp;</span> : <span>&nbsp;{t("and")}&nbsp;</span>}
<Attendee {...attendees[0]} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
+31 -13
View File
@@ -7,9 +7,11 @@ import { shouldChargeNoShowCancellationFee } from "@calcom/features/bookings/lib
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRefreshData } from "@calcom/lib/hooks/useRefreshData";
import type { RecurringEvent } from "@calcom/types/Calendar";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { Label, Select, TextArea, CheckboxField } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { showToast } from "@calcom/ui/components/toast";
interface InternalNotePresetsSelectProps {
internalNotePresets: { id: number; name: string }[];
@@ -40,7 +42,7 @@ const InternalNotePresetsSelect = ({
setCancellationReason("");
} else {
setShowOtherInput(false);
onPresetSelect && onPresetSelect(option);
onPresetSelect?.(option);
}
};
@@ -50,7 +52,7 @@ const InternalNotePresetsSelect = ({
<Select
className="mb-2"
options={[
...internalNotePresets?.map((preset) => ({
...internalNotePresets.map((preset) => ({
label: preset.name,
value: preset.id,
})),
@@ -105,7 +107,10 @@ type Props = {
};
isHost: boolean;
internalNotePresets: { id: number; name: string; cancellationReason: string | null }[];
renderContext: "booking-single-view" | "dialog";
eventTypeMetadata?: Record<string, unknown> | null;
showErrorAsToast?: boolean;
onCanceled?: () => void;
};
export default function CancelBooking(props: Props) {
@@ -168,12 +173,13 @@ export default function CancelBooking(props: Props) {
node.scrollIntoView({ behavior: "smooth" });
node.focus();
}
}, []);
const isRenderedAsCancelDialog = props.renderContext === "dialog";
return (
<>
{error && (
{error && !props.showErrorAsToast && (
<div className="mt-8">
<div className="bg-error mx-auto flex h-12 w-12 items-center justify-center rounded-full">
<Icon name="x" className="h-6 w-6 text-red-600" />
@@ -186,7 +192,7 @@ export default function CancelBooking(props: Props) {
</div>
)}
{!error && (
<div className="mt-5 sm:mt-6">
<div className={classNames(isRenderedAsCancelDialog ? "-mt-2 mb-8" : "mt-5 sm:mt-6")}>
{props.isHost && props.internalNotePresets.length > 0 && (
<>
<InternalNotePresetsSelect
@@ -219,13 +225,13 @@ export default function CancelBooking(props: Props) {
placeholder={t("cancellation_reason_placeholder")}
value={cancellationReason}
onChange={(e) => setCancellationReason(e.target.value)}
className="mb-4 mt-2 w-full "
className={classNames("mb-4 w-full", !isRenderedAsCancelDialog && "mt-2")}
rows={3}
/>
{isCancellationUserHost ? (
<div className="-mt-2 mb-4 flex items-center gap-2">
<Icon name="info" className="text-subtle h-4 w-4" />
<p className="text-default text-subtle text-sm leading-none">
<p className="text-subtle text-sm leading-none">
{t("notify_attendee_cancellation_reason_warning")}
</p>
</div>
@@ -248,7 +254,11 @@ export default function CancelBooking(props: Props) {
</div>
)}
<div className="flex flex-col-reverse rtl:space-x-reverse ">
<div className="ml-auto flex w-full space-x-4 ">
<div
className={classNames(
"ml-auto flex w-full space-x-4",
isRenderedAsCancelDialog && "justify-end"
)}>
<Button
className="ml-auto"
color="secondary"
@@ -295,15 +305,23 @@ export default function CancelBooking(props: Props) {
booking: bookingWithCancellationReason,
});
refreshData();
if (props.onCanceled) {
props.onCanceled();
}
} else {
const data = await res.json();
setLoading(false);
setError(
const errorMessage =
data.message ||
`${t("error_with_status_code_occured", { status: res.status })} ${t(
"please_try_again"
)}`
);
`${t("error_with_status_code_occured", { status: res.status })} ${t(
"please_try_again"
)}`;
if (props.showErrorAsToast) {
showToast(errorMessage, "error");
} else {
setError(errorMessage);
}
}
}}
loading={loading}>
@@ -0,0 +1,62 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { RejectionReasonDialog } from "../dialog/RejectionReasonDialog";
import { useBookingConfirmation } from "./hooks/useBookingConfirmation";
interface RejectBookingButtonProps {
bookingId: number;
bookingUid: string;
recurringEventId?: string | null;
isRecurring?: boolean;
isTabRecurring?: boolean;
isTabUnconfirmed?: boolean;
size?: "sm" | "base" | "lg";
color?: "primary" | "secondary" | "minimal" | "destructive";
className?: string;
}
export function RejectBookingButton({
bookingId,
bookingUid,
recurringEventId,
isRecurring = false,
isTabRecurring = false,
isTabUnconfirmed = false,
size = "base",
color = "secondary",
className,
}: RejectBookingButtonProps) {
const { t } = useLocale();
const { bookingConfirm, handleReject, isPending, rejectionDialogIsOpen, setRejectionDialogIsOpen } =
useBookingConfirmation({
isRecurring,
isTabRecurring,
isTabUnconfirmed,
});
const rejectLabel = (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("reject_all") : t("reject");
return (
<>
<RejectionReasonDialog
isOpenDialog={rejectionDialogIsOpen}
setIsOpenDialog={setRejectionDialogIsOpen}
onConfirm={(reason) => bookingConfirm({ bookingId, confirmed: false, recurringEventId, reason })}
isPending={isPending}
/>
<Button
color={color}
size={size}
className={className}
onClick={handleReject}
disabled={isPending}
data-booking-uid={bookingUid}
data-testid="reject">
{rejectLabel}
</Button>
</>
);
}
+10 -8
View File
@@ -5,18 +5,12 @@ import { SkeletonText } from "@calcom/ui/components/skeleton";
function SkeletonLoader() {
return (
<ul className="divide-subtle border-subtle bg-default animate-pulse divide-y rounded-md border sm:overflow-hidden">
{/* TODAY header */}
<li className="bg-muted flex items-center px-6 py-4">
<SkeletonText className="h-4 w-16" />
</li>
<SkeletonHeader />
<SkeletonItem />
<SkeletonItem />
<SkeletonItem />
{/* NEXT header */}
<li className="bg-muted px-4 py-2">
<SkeletonText className="h-4 w-16" />
</li>
<SkeletonHeader />
<SkeletonItem />
<SkeletonItem />
</ul>
@@ -25,6 +19,14 @@ function SkeletonLoader() {
export default SkeletonLoader;
function SkeletonHeader() {
return (
<li className="bg-muted flex items-center px-6 py-4">
<SkeletonText className="h-4 w-16" />
</li>
);
}
function SkeletonItem() {
return (
<li className="group flex w-full items-start justify-between px-4 py-4 sm:px-6">
@@ -125,6 +125,7 @@ const mockProps = {
eventType: {},
},
internalNotePresets: [],
renderContext: "booking-single-view" as const,
};
const mockBookingWithoutCancellationFee = {
@@ -1,14 +1,14 @@
import { useState } from "react";
import type { z } from "zod";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { MeetingSessionDetailsDialog } from "@calcom/features/ee/video/MeetingSessionDetailsDialog";
import ViewRecordingsDialog from "@calcom/features/ee/video/ViewRecordingsDialog";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc/react";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
import { Dialog, DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
import {
Dropdown,
DropdownItem,
@@ -19,18 +19,20 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@calcom/ui/components/dropdown";
import { TextAreaField } from "@calcom/ui/components/form";
import type { ActionType } from "@calcom/ui/components/table";
import { showToast } from "@calcom/ui/components/toast";
import { AddGuestsDialog } from "@components/dialog/AddGuestsDialog";
import { CancelBookingDialog } from "@components/dialog/CancelBookingDialog";
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
import { ReassignDialog } from "@components/dialog/ReassignDialog";
import { RejectionReasonDialog } from "@components/dialog/RejectionReasonDialog";
import { ReportBookingDialog } from "@components/dialog/ReportBookingDialog";
import { RerouteDialog } from "@components/dialog/RerouteDialog";
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
import { useBookingConfirmation } from "../hooks/useBookingConfirmation";
import type { BookingItemProps } from "../types";
import { useBookingActionsStoreContext } from "./BookingActionsStoreProvider";
import {
@@ -72,11 +74,24 @@ export function BookingActionsDropdown({
const { t } = useLocale();
const utils = trpc.useUtils();
// Use store for all dialog states
const rejectionReason = useBookingActionsStoreContext((state) => state.rejectionReason);
const setRejectionReason = useBookingActionsStoreContext((state) => state.setRejectionReason);
const rejectionDialogIsOpen = useBookingActionsStoreContext((state) => state.rejectionDialogIsOpen);
const setRejectionDialogIsOpen = useBookingActionsStoreContext((state) => state.setRejectionDialogIsOpen);
const isRecurring = booking.recurringEventId !== null;
const isTabRecurring = booking.listingStatus === "recurring";
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
// Use the booking confirmation hook for confirm/reject logic
const {
bookingConfirm,
handleReject,
rejectionDialogIsOpen,
setRejectionDialogIsOpen,
isPending: isConfirmPending,
} = useBookingConfirmation({
isRecurring,
isTabRecurring,
isTabUnconfirmed,
});
// Use store for all other dialog states
const chargeCardDialogIsOpen = useBookingActionsStoreContext((state) => state.chargeCardDialogIsOpen);
const setChargeCardDialogIsOpen = useBookingActionsStoreContext((state) => state.setChargeCardDialogIsOpen);
const viewRecordingsDialogIsOpen = useBookingActionsStoreContext(
@@ -105,6 +120,8 @@ export function BookingActionsDropdown({
const setIsOpenReportDialog = useBookingActionsStoreContext((state) => state.setIsOpenReportDialog);
const rerouteDialogIsOpen = useBookingActionsStoreContext((state) => state.rerouteDialogIsOpen);
const setRerouteDialogIsOpen = useBookingActionsStoreContext((state) => state.setRerouteDialogIsOpen);
const isCancelDialogOpen = useBookingActionsStoreContext((state) => state.isCancelDialogOpen);
const setIsCancelDialogOpen = useBookingActionsStoreContext((state) => state.setIsCancelDialogOpen);
const cardCharged = booking?.payment[0]?.success;
@@ -128,23 +145,6 @@ export function BookingActionsDropdown({
},
});
const mutation = trpc.viewer.bookings.confirm.useMutation({
onSuccess: (data) => {
if (data?.status === "REJECTED") {
setRejectionDialogIsOpen(false);
showToast(t("booking_rejection_success"), "success");
} else {
showToast(t("booking_confirmation_success"), "success");
}
utils.viewer.bookings.invalidate();
utils.viewer.me.bookingUnconfirmedCount.invalidate();
},
onError: () => {
showToast(t("booking_confirmation_failed"), "error");
utils.viewer.bookings.invalidate();
},
});
const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({
onSuccess: () => {
showToast(t("location_updated"), "success");
@@ -170,7 +170,6 @@ export function BookingActionsDropdown({
const isRejected = booking.status === "REJECTED";
const isPending = booking.status === "PENDING";
const isRescheduled = booking.fromReschedule !== null;
const isRecurring = booking.recurringEventId !== null;
const getBookingStatus = (): "upcoming" | "past" | "cancelled" | "rejected" => {
if (isCancelled) return "cancelled";
@@ -179,15 +178,15 @@ export function BookingActionsDropdown({
return "upcoming";
};
const isTabRecurring = booking.listingStatus === "recurring";
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
const isBookingFromRoutingForm = !!booking.routedFromRoutingFormReponse && !!booking.eventType?.team;
const userEmail = booking.loggedInUser.userEmail;
const userSeat = booking.seatsReferences.find((seat) => !!userEmail && seat.attendee?.email === userEmail);
const isAttendee = !!userSeat;
// Check if the logged-in user is the host/owner of the booking
const isHost = booking.loggedInUser.userId === booking.user?.id;
const isCalVideoLocation =
!booking.location ||
booking.location === "integrations:daily" ||
@@ -200,19 +199,6 @@ export function BookingActionsDropdown({
return userSeat?.referenceUid;
};
const bookingConfirm = async (confirm: boolean) => {
let body = {
bookingId: booking.id,
confirmed: confirm,
reason: rejectionReason,
};
if ((isTabRecurring || isTabUnconfirmed) && isRecurring) {
body = Object.assign({}, body, { recurringEventId: booking.recurringEventId });
}
mutation.mutate(body);
};
const saveLocation = async ({
newLocation,
credentialId,
@@ -268,12 +254,17 @@ export function BookingActionsDropdown({
shouldShowPending && context === "details" ? getPendingActions(actionContext) : [];
const pendingActions: ActionType[] = basePendingActions.map((action) => ({
...action,
disabled: mutation.isPending,
disabled: isConfirmPending,
onClick:
action.id === "confirm"
? () => bookingConfirm(true)
? () =>
bookingConfirm({
bookingId: booking.id,
confirmed: true,
recurringEventId: booking.recurringEventId,
})
: action.id === "reject"
? () => setRejectionDialogIsOpen(true)
? () => handleReject()
: undefined,
})) as ActionType[];
@@ -482,35 +473,39 @@ export function BookingActionsDropdown({
isOpen={isNoShowDialogOpen}
/>
)}
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
<div>
<TextAreaField
name="rejectionReason"
label={
<>
{t("rejection_reason")}
<span className="text-subtle font-normal"> (Optional)</span>
</>
}
value={rejectionReason}
onChange={(e) => setRejectionReason(e.target.value)}
/>
</div>
<DialogFooter>
<DialogClose />
<Button
disabled={mutation.isPending}
data-testid="rejection-confirm"
onClick={() => {
bookingConfirm(false);
}}>
{t("rejection_confirmation")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<CancelBookingDialog
isOpenDialog={isCancelDialogOpen}
setIsOpenDialog={setIsCancelDialogOpen}
booking={{
uid: booking.uid,
id: booking.id,
title: booking.title,
startTime: new Date(booking.startTime),
payment: booking.payment,
}}
profile={{
name: booking.user?.name || null,
slug: booking.user?.username || null,
}}
recurringEvent={booking.eventType?.recurringEvent || null}
team={booking.eventType?.team?.name}
teamId={booking.eventType?.team?.id}
allRemainingBookings={isTabRecurring && isRecurring}
seatReferenceUid={getSeatReferenceUid()}
currentUserEmail={booking.loggedInUser.userEmail}
bookingCancelledEventProps={{
booking: booking,
organizer: {
name: booking.user?.name || "Nameless",
email: booking.userPrimaryEmail || booking.user?.email || "Email-less",
timeZone: booking.user?.timeZone,
},
eventType: booking.eventType,
}}
isHost={isHost}
internalNotePresets={[]}
eventTypeMetadata={booking.eventType?.metadata}
/>
{isBookingFromRoutingForm &&
parsedBooking.eventType &&
parsedBooking.eventType.id !== undefined &&
@@ -575,6 +570,19 @@ export function BookingActionsDropdown({
return (
<>
{dialogs}
<RejectionReasonDialog
isOpenDialog={rejectionDialogIsOpen}
setIsOpenDialog={setRejectionDialogIsOpen}
onConfirm={(reason) =>
bookingConfirm({
bookingId: booking.id,
confirmed: false,
recurringEventId: booking.recurringEventId,
reason,
})
}
isPending={isConfirmPending}
/>
<Dropdown modal={false}>
<DropdownMenuTrigger asChild data-testid="booking-actions-dropdown">
<Button
@@ -582,7 +590,7 @@ export function BookingActionsDropdown({
color="secondary"
size={size}
StartIcon="ellipsis"
className={className}
className={classNames("px-2", className)}
// Prevent click from bubbling to parent row/container click handlers
onClick={(e) => e.stopPropagation()}
/>
@@ -604,7 +612,7 @@ export function BookingActionsDropdown({
e.stopPropagation();
action.onClick?.(e);
}}
data-bookingid={action.bookingId}
data-booking-uid={action.bookingUid}
data-testid={action.id}
className={action.disabled ? "text-muted" : undefined}>
{action.label}
@@ -627,7 +635,7 @@ export function BookingActionsDropdown({
e.stopPropagation();
action.onClick?.(e);
}}
data-bookingid={action.bookingId}
data-booking-uid={action.bookingUid}
data-testid={action.id}
className={action.disabled ? "text-muted" : undefined}>
{action.label}
@@ -648,7 +656,7 @@ export function BookingActionsDropdown({
action.onClick?.(e);
}}
disabled={action.disabled}
data-bookingid={action.bookingId}
data-booking-uid={action.bookingUid}
data-testid={action.id}
className={action.disabled ? "text-muted" : undefined}>
{action.label}
@@ -685,13 +693,12 @@ export function BookingActionsDropdown({
type="button"
color={cancelEventAction.color}
StartIcon={cancelEventAction.icon}
href={cancelEventAction.disabled ? undefined : cancelEventAction.href}
onClick={(e) => {
e.stopPropagation();
cancelEventAction.onClick?.(e);
setIsCancelDialogOpen(true);
}}
disabled={cancelEventAction.disabled}
data-bookingid={cancelEventAction.bookingId}
data-booking-uid={cancelEventAction.bookingUid}
data-testid={cancelEventAction.id}
className={cancelEventAction.disabled ? "text-muted" : undefined}>
{cancelEventAction.label}
@@ -138,7 +138,7 @@ describe("Booking Actions", () => {
expect(actions).toHaveLength(2);
expect(actions[0]).toEqual({
id: "confirm",
bookingId: 1,
bookingUid: "booking-123",
label: "confirm",
icon: "check",
disabled: false,
@@ -180,10 +180,10 @@ describe("Booking Actions", () => {
expect(action).toEqual({
id: "cancel",
label: "cancel_event",
href: "/booking/booking-123?cancel=true",
icon: "circle-x",
color: "destructive",
disabled: false,
bookingUid: "booking-123",
});
});
@@ -212,7 +212,6 @@ describe("Booking Actions", () => {
});
const action = getCancelEventAction(context);
expect(action.href).toContain("allRemainingBookings=true");
expect(action.label).toBe("cancel_all_remaining");
});
});
@@ -394,7 +393,16 @@ describe("Booking Actions", () => {
...createMockContext().booking,
status: BookingStatus.ACCEPTED,
paid: true,
payment: [{ paymentOption: "HOLD", amount: 1000, currency: "usd", success: true }],
payment: [
{
paymentOption: "HOLD",
amount: 1000,
currency: "usd",
success: true,
appId: null,
refunded: false,
},
],
},
});
const actions = getAfterEventActions(context);
@@ -1,3 +1,4 @@
import { isWithinMinimumRescheduleNotice } from "@calcom/features/bookings/lib/reschedule/isWithinMinimumRescheduleNotice";
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
import type { ActionType } from "@calcom/ui/components/table";
@@ -45,7 +46,7 @@ export function getPendingActions(context: BookingActionContext): ActionType[] {
if ((isPending && !showPendingPayment) || (showPendingPayment && booking.paid)) {
actions.push({
id: "confirm",
bookingId: booking.id,
bookingUid: booking.uid,
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("confirm_all") : t("confirm"),
icon: "check" as const,
disabled: false, // This would be controlled by mutation state in the component
@@ -63,18 +64,15 @@ export function getPendingActions(context: BookingActionContext): ActionType[] {
}
export function getCancelEventAction(context: BookingActionContext): ActionType {
const { booking, isTabRecurring, isRecurring, getSeatReferenceUid, t } = context;
const seatReferenceUid = getSeatReferenceUid();
const { booking, isTabRecurring, isRecurring, t } = context;
return {
id: "cancel",
label: isTabRecurring && isRecurring ? t("cancel_all_remaining") : t("cancel_event"),
href: `/booking/${booking.uid}?cancel=true${
isTabRecurring && isRecurring ? "&allRemainingBookings=true" : ""
}${booking.seatsReferences.length && seatReferenceUid ? `&seatReferenceUid=${seatReferenceUid}` : ""}`,
icon: "circle-x",
color: "destructive",
disabled: isActionDisabled("cancel", context),
bookingUid: booking.uid,
};
}
@@ -125,8 +123,12 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
? `?seatReferenceUid=${seatReferenceUid}`
: ""
}`,
disabled:
(isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || isDisabledRescheduling,
disabled: isActionDisabled("reschedule", {
...context,
booking,
isBookingInPast,
isDisabledRescheduling,
}),
},
{
id: "reschedule_request",
@@ -134,9 +136,12 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
iconClassName: "rotate-45 w-[16px] -translate-x-0.5 ",
label: t("send_reschedule_request"),
disabled:
(isBookingInPast && !booking.eventType.allowReschedulingPastBookings) ||
isDisabledRescheduling ||
booking.seatsReferences.length > 0,
isActionDisabled("reschedule_request", {
...context,
booking,
isBookingInPast,
isDisabledRescheduling,
}) || booking.seatsReferences.length > 0,
},
isBookingFromRoutingForm
? {
@@ -232,12 +237,29 @@ export function shouldShowIndividualReportButton(context: BookingActionContext):
}
export function isActionDisabled(actionId: string, context: BookingActionContext): boolean {
const { booking, isBookingInPast, isDisabledRescheduling, isDisabledCancelling } = context;
const { booking, isBookingInPast, isDisabledRescheduling, isDisabledCancelling, isAttendee } = context;
switch (actionId) {
case "reschedule":
case "reschedule_request":
return (isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || isDisabledRescheduling;
// Only apply minimum reschedule notice restriction if user is NOT the organizer
// If user is an attendee (or not authenticated), apply the restriction
const isUserOrganizer =
!isAttendee &&
booking.loggedInUser?.userId &&
booking.user?.id &&
booking.loggedInUser.userId === booking.user.id;
const isWithinMinimumNotice =
!isUserOrganizer &&
isWithinMinimumRescheduleNotice(
new Date(booking.startTime),
booking.eventType.minimumRescheduleNotice ?? null
);
return (
(isBookingInPast && !booking.eventType.allowReschedulingPastBookings) ||
isDisabledRescheduling ||
isWithinMinimumNotice
);
case "cancel":
return isDisabledCancelling || isBookingInPast;
case "view_recordings":
+7 -8
View File
@@ -16,6 +16,7 @@ export type BookingActionsStore = {
isOpenAddGuestsDialog: boolean;
isOpenReportDialog: boolean;
rerouteDialogIsOpen: boolean;
isCancelDialogOpen: boolean;
// Dialog setters
setRejectionDialogIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
@@ -29,10 +30,7 @@ export type BookingActionsStore = {
setIsOpenAddGuestsDialog: React.Dispatch<React.SetStateAction<boolean>>;
setIsOpenReportDialog: React.Dispatch<React.SetStateAction<boolean>>;
setRerouteDialogIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
// Rejection reason state
rejectionReason: string;
setRejectionReason: (reason: string) => void;
setIsCancelDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
export const createBookingActionsStore = () => {
@@ -49,6 +47,7 @@ export const createBookingActionsStore = () => {
isOpenAddGuestsDialog: false,
isOpenReportDialog: false,
rerouteDialogIsOpen: false,
isCancelDialogOpen: false,
// Dialog setters
setRejectionDialogIsOpen: (isOpen) =>
@@ -98,9 +97,9 @@ export const createBookingActionsStore = () => {
set((state) => ({
rerouteDialogIsOpen: typeof isOpen === "function" ? isOpen(state.rerouteDialogIsOpen) : isOpen,
})),
// Rejection reason state
rejectionReason: "",
setRejectionReason: (reason: string) => set({ rejectionReason: reason }),
setIsCancelDialogOpen: (isOpen) =>
set((state) => ({
isCancelDialogOpen: typeof isOpen === "function" ? isOpen(state.isCancelDialogOpen) : isOpen,
})),
}));
};
@@ -0,0 +1,74 @@
import { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { BookingStatus } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import { showToast } from "@calcom/ui/components/toast";
interface UseBookingConfirmationOptions {
isRecurring?: boolean;
isTabRecurring?: boolean;
isTabUnconfirmed?: boolean;
}
interface BookingConfirmParams {
bookingId: number;
confirmed: boolean;
recurringEventId?: string | null;
reason?: string;
}
export function useBookingConfirmation(options: UseBookingConfirmationOptions = {}) {
const { t } = useLocale();
const utils = trpc.useUtils();
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
const { isRecurring = false, isTabRecurring = false, isTabUnconfirmed = false } = options;
const mutation = trpc.viewer.bookings.confirm.useMutation({
onSuccess: (data) => {
if (data?.status === BookingStatus.REJECTED) {
setRejectionDialogIsOpen(false);
showToast(t("booking_rejection_success"), "success");
} else {
showToast(t("booking_confirmation_success"), "success");
}
utils.viewer.bookings.invalidate();
utils.viewer.me.bookingUnconfirmedCount.invalidate();
},
onError: (error) => {
showToast(error.message || t("booking_confirmation_failed"), "error");
utils.viewer.bookings.invalidate();
},
});
const bookingConfirm = ({ bookingId, confirmed, recurringEventId, reason }: BookingConfirmParams) => {
let body = {
bookingId,
confirmed,
reason: reason || "",
};
/**
* Only pass down the recurring event id when we need to confirm the entire series, which happens in
* the "Recurring" tab and "Unconfirmed" tab, to support confirming discretionally in the "Recurring" tab.
*/
if ((isTabRecurring || isTabUnconfirmed) && isRecurring && recurringEventId) {
body = Object.assign({}, body, { recurringEventId });
}
mutation.mutate(body);
};
const handleReject = () => {
setRejectionDialogIsOpen(true);
};
return {
bookingConfirm,
handleReject,
rejectionDialogIsOpen,
setRejectionDialogIsOpen,
isPending: mutation.isPending,
};
}
+3 -1
View File
@@ -1,6 +1,8 @@
import type { RouterInputs, RouterOutputs } from "@calcom/trpc/react";
export type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["filters"]["status"];
export type BookingListingStatus = NonNullable<
RouterInputs["viewer"]["bookings"]["get"]["filters"]["statuses"]
>[number];
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
@@ -0,0 +1,122 @@
import type { Dispatch, SetStateAction } from "react";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { RecurringEvent } from "@calcom/types/Calendar";
import { DialogContent, DialogHeader } from "@calcom/ui/components/dialog";
import { showToast } from "@calcom/ui/components/toast";
import CancelBooking from "@components/booking/CancelBooking";
interface ICancelBookingDialog {
isOpenDialog: boolean;
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
booking: {
uid: string;
id: number;
title: string;
startTime: Date;
payment?: {
amount: number;
currency: string;
success: boolean;
paymentOption?: string | null;
appId?: string | null;
refunded?: boolean;
}[];
};
profile: {
name: string | null;
slug: string | null;
};
recurringEvent: RecurringEvent | null;
team?: string | null;
teamId?: number;
allRemainingBookings?: boolean;
seatReferenceUid?: string;
currentUserEmail?: string;
bookingCancelledEventProps: {
booking: unknown;
organizer: {
name: string;
email: string;
timeZone?: string;
};
eventType: unknown;
};
isHost: boolean;
internalNotePresets?: { id: number; name: string; cancellationReason: string | null }[];
eventTypeMetadata?: Record<string, unknown> | null;
}
export const CancelBookingDialog = (props: ICancelBookingDialog) => {
const { t } = useLocale();
const {
isOpenDialog,
setIsOpenDialog,
booking,
profile,
recurringEvent,
team,
teamId,
allRemainingBookings = false,
seatReferenceUid,
currentUserEmail,
bookingCancelledEventProps,
isHost,
internalNotePresets = [],
eventTypeMetadata,
} = props;
const utils = trpc.useUtils();
// Get the first payment if it exists and map to expected format
const payment =
booking.payment && booking.payment.length > 0
? {
amount: booking.payment[0].amount,
currency: booking.payment[0].currency,
appId: booking.payment[0].appId || null,
}
: null;
const handleCanceled = () => {
utils.viewer.bookings.invalidate();
showToast(t("booking_cancelled"), "success");
setIsOpenDialog(false);
};
return (
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent enableOverflow data-testid="cancel-dialog">
<DialogHeader title={allRemainingBookings ? t("cancel_all_remaining") : t("cancel_event")} />
<CancelBooking
renderContext="dialog"
booking={{
uid: booking.uid,
id: booking.id,
title: booking.title,
startTime: booking.startTime,
payment: payment,
}}
profile={profile}
recurringEvent={recurringEvent}
team={team}
teamId={teamId}
setIsCancellationMode={setIsOpenDialog}
theme={null}
allRemainingBookings={allRemainingBookings}
seatReferenceUid={seatReferenceUid}
currentUserEmail={currentUserEmail}
bookingCancelledEventProps={bookingCancelledEventProps}
isHost={isHost}
internalNotePresets={internalNotePresets}
eventTypeMetadata={eventTypeMetadata}
showErrorAsToast={true}
onCanceled={handleCanceled}
/>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,62 @@
import type { Dispatch, SetStateAction } from "react";
import { useState, useEffect } from "react";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
import { TextAreaField } from "@calcom/ui/components/form";
interface RejectionReasonDialogProps {
isOpenDialog: boolean;
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
onConfirm: (reason: string) => void;
isPending?: boolean;
}
export function RejectionReasonDialog({
isOpenDialog,
setIsOpenDialog,
onConfirm,
isPending = false,
}: RejectionReasonDialogProps) {
const { t } = useLocale();
const [rejectionReason, setRejectionReason] = useState("");
useEffect(() => {
if (!isOpenDialog) {
setRejectionReason("");
}
}, [isOpenDialog]);
const handleConfirm = () => {
onConfirm(rejectionReason);
};
return (
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
<div>
<TextAreaField
name="rejectionReason"
label={
<>
{t("rejection_reason")}
<span className="text-subtle font-normal"> ({t("optional")})</span>
</>
}
value={rejectionReason}
onChange={(e) => setRejectionReason(e.target.value)}
/>
</div>
<DialogFooter>
<DialogClose color="secondary" />
<Button disabled={isPending} data-testid="rejection-confirm" onClick={handleConfirm}>
{t("rejection_confirmation")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -82,7 +82,8 @@ export const OAuthClientForm = ({
<input
{...register(`${permissionKey}Read`)}
id={`${permissionKey}Read`}
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
className="border-default bg-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 transition checked:border-transparent checked:bg-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
style={{ accentColor: "#ffffff" }}
type="checkbox"
disabled={!!defaultValues}
/>
@@ -94,7 +95,8 @@ export const OAuthClientForm = ({
<input
{...register(`${permissionKey}Write`)}
id={`${permissionKey}Write`}
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
className="border-default bg-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 transition checked:border-transparent checked:bg-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
style={{ accentColor: "#ffffff" }}
type="checkbox"
disabled={!!defaultValues}
/>
@@ -162,36 +164,6 @@ export const OAuthClientForm = ({
);
})}
</div>
{/** <div className="mt-6">
<Controller
control={control}
name="logo"
render={({ field: { value } }) => (
<>
<Label>Client logo</Label>
<div className="flex items-center">
<Avatar
alt=""
imageSrc={value}
fallback={<Icon name="plus" className="text-subtle h-4 w-4" />}
size="sm"
/>
<div className="ms-4">
<ImageUploader
target="avatar"
id="vatar-upload"
buttonMsg="Upload"
imageSrc={value}
handleAvatarChange={(newAvatar: string) => {
setValue("logo", newAvatar);
}}
/>
</div>
</div>
</>
)}
/>
</div> */}
<div className="mt-6">
<Tooltip content={t("booking_redirect_uri")}>
<TextField
@@ -229,7 +201,8 @@ export const OAuthClientForm = ({
<input
{...register("areEmailsEnabled")}
id="areEmailsEnabled"
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
className="border-default bg-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 transition checked:border-transparent checked:bg-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
style={{ accentColor: "#ffffff" }}
type="checkbox"
disabled={isFormDisabled}
/>
@@ -242,7 +215,8 @@ export const OAuthClientForm = ({
<input
{...register("areCalendarEventsEnabled")}
id="areCalendarEventsEnabled"
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
className="border-default bg-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 transition checked:border-transparent checked:bg-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
style={{ accentColor: "#ffffff" }}
type="checkbox"
disabled={isFormDisabled}
/>
@@ -263,7 +237,8 @@ export const OAuthClientForm = ({
<input
{...register("areDefaultEventTypesEnabled")}
id="areDefaultEventTypesEnabled"
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
className="border-default bg-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 transition checked:border-transparent checked:bg-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
style={{ accentColor: "#ffffff" }}
type="checkbox"
disabled={isFormDisabled}
/>
+4 -3
View File
@@ -8,6 +8,7 @@ import { usePathname, useSearchParams } from "next/navigation";
import { NuqsAdapter } from "nuqs/adapters/next/app";
import DynamicPostHogProvider from "@calcom/features/ee/event-tracking/lib/posthog/providerDynamic";
import DynamicPostHogPageView from "@calcom/features/ee/event-tracking/lib/posthog/web/pageViewDynamic";
import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider";
import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic";
import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/providerDynamic";
@@ -16,12 +17,11 @@ import { useFlags } from "@calcom/features/flags/hooks";
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
import useIsThemeSupported from "@lib/hooks/useIsThemeSupported";
import { useNuqsParams } from "@lib/hooks/useNuqsParams";
import type { WithLocaleProps } from "@lib/withLocale";
import type { PageWrapperProps } from "@components/PageWrapperAppDir";
import DynamicPostHogPageView from "@calcom/features/ee/event-tracking/lib/posthog/web/pageViewDynamic";
import { getThemeProviderProps } from "./getThemeProviderProps";
// Workaround for https://github.com/vercel/next.js/issues/8592
@@ -112,6 +112,7 @@ const AppProviders = (props: PageWrapperProps) => {
// No need to have intercom on public pages - Good for Page Performance
const isBookingPage = useIsBookingPage();
const isThemeSupported = useIsThemeSupported();
const nuqsParams = useNuqsParams();
const RemainingProviders = (
<>
@@ -121,7 +122,7 @@ const AppProviders = (props: PageWrapperProps) => {
nonce={props.nonce}
isThemeSupported={isThemeSupported}
isBookingPage={props.isBookingPage || isBookingPage}>
<NuqsAdapter>
<NuqsAdapter {...nuqsParams}>
<FeatureFlagsProvider>
{props.isBookingPage || isBookingPage ? (
<OrgBrandProvider>{props.children}</OrgBrandProvider>
+15 -16
View File
@@ -1,12 +1,11 @@
/**
* PAGES ROUTER ONLY - Used exclusively by Next.js Pages Router (_app.tsx)
*
*
* Currently only serves the /router endpoint (routing forms redirect page).
* DO NOT add new features here - this file will be deprecated once we remove apps/web/pages.
*
*
* For App Router, use app-providers-app-dir.tsx instead.
*/
import { TooltipProvider } from "@radix-ui/react-tooltip";
import { dir } from "i18next";
import type { Session } from "next-auth";
@@ -15,7 +14,6 @@ import { appWithTranslation } from "next-i18next";
import type { SSRConfig } from "next-i18next/dist/types/types";
import { ThemeProvider } from "next-themes";
import type { AppProps as NextAppProps, AppProps as NextJsAppProps } from "next/app";
import dynamic from "next/dynamic";
import { NuqsAdapter } from "nuqs/adapters/next/pages";
import type { ParsedUrlQuery } from "querystring";
import type { PropsWithChildren, ReactNode } from "react";
@@ -28,6 +26,7 @@ import { FeatureProvider } from "@calcom/features/flags/context/provider";
import { useFlags } from "@calcom/features/flags/hooks";
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
import { useNuqsParams } from "@lib/hooks/useNuqsParams";
import type { WithLocaleProps } from "@lib/withLocale";
import { useViewerI18n } from "@components/I18nLanguageHandler";
@@ -61,7 +60,6 @@ export type AppProps = Omit<
err?: Error;
};
type AppPropsWithChildren = AppProps & {
children: ReactNode;
};
@@ -136,8 +134,8 @@ const enum ThemeSupport {
type CalcomThemeProps = PropsWithChildren<
Pick<AppProps, "router"> &
Pick<AppProps["pageProps"], "themeBasis"> &
Pick<AppProps["Component"], "isBookingPage" | "isThemeSupported">
Pick<AppProps["pageProps"], "themeBasis"> &
Pick<AppProps["Component"], "isBookingPage" | "isThemeSupported">
>;
const CalcomThemeProvider = (props: CalcomThemeProps) => {
// Use namespace of embed to ensure same namespaced embed are displayed with same theme. This allows different embeds on the same website to be themed differently
@@ -211,8 +209,8 @@ function getThemeProviderProps({
? ThemeSupport.Booking
: // if isThemeSupported is explicitly false, we don't use theme there
props.isThemeSupported === false
? ThemeSupport.None
: ThemeSupport.App;
? ThemeSupport.None
: ThemeSupport.App;
const isBookingPageThemeSupportRequired = themeSupport === ThemeSupport.Booking;
const themeBasis = props.themeBasis;
@@ -236,13 +234,13 @@ function getThemeProviderProps({
const storageKey = isEmbedMode
? // Same Namespace, Same Organizer but different themes would still work seamless and not cause theme flicker
// Even though it's recommended to use different namespaces when you want to theme differently on the same page but if the embeds are on different pages, the problem can still arise
`embed-theme-${embedNamespace}${appearanceIdSuffix}${embedExplicitlySetThemeSuffix}`
// Even though it's recommended to use different namespaces when you want to theme differently on the same page but if the embeds are on different pages, the problem can still arise
`embed-theme-${embedNamespace}${appearanceIdSuffix}${embedExplicitlySetThemeSuffix}`
: themeSupport === ThemeSupport.App
? "app-theme"
: isBookingPageThemeSupportRequired
? `booking-theme${appearanceIdSuffix}`
: undefined;
? "app-theme"
: isBookingPageThemeSupportRequired
? `booking-theme${appearanceIdSuffix}`
: undefined;
return {
storageKey,
@@ -278,6 +276,7 @@ const AppProviders = (props: AppPropsWithChildren) => {
(typeof props.Component.isBookingPage === "function"
? props.Component.isBookingPage({ router: props.router })
: props.Component.isBookingPage) || isBookingPage;
const nuqsParams = useNuqsParams();
const RemainingProviders = (
<CustomI18nextProvider {...props}>
@@ -287,7 +286,7 @@ const AppProviders = (props: AppPropsWithChildren) => {
isThemeSupported={props.Component.isThemeSupported}
isBookingPage={props.Component.isBookingPage || isBookingPage}
router={props.router}>
<NuqsAdapter>
<NuqsAdapter {...nuqsParams}>
<FeatureFlagsProvider>
{_isBookingPage ? (
<OrgBrandProvider>{props.children}</OrgBrandProvider>
+1
View File
@@ -44,6 +44,7 @@ export const getEventTypesFromDB = async (id: number) => {
hideOrganizerEmail: true,
disableCancelling: true,
disableRescheduling: true,
minimumRescheduleNotice: true,
disableGuests: true,
timeZone: true,
profile: {
@@ -11,7 +11,7 @@ const assignmentReasonBadgeTitleMap = (assignmentReason: AssignmentReasonEnum) =
case AssignmentReasonEnum.REROUTED:
return "rerouted";
case AssignmentReasonEnum.SALESFORCE_ASSIGNMENT:
return "salesforce_assignment";
return "salesforce_assigned";
default:
return "routed";
}
-20
View File
@@ -1,20 +0,0 @@
// lets refactor and move this into packages/lib/hooks/
import { useState, useEffect } from "react";
const useMediaQuery = (query: string) => {
const [matches, setMatches] = useState(false);
useEffect(() => {
const media = window.matchMedia(query);
if (media.matches !== matches) {
setMatches(media.matches);
}
const listener = () => setMatches(media.matches);
window.addEventListener("resize", listener);
return () => window.removeEventListener("resize", listener);
}, [matches, query]);
return matches;
};
export default useMediaQuery;
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { usePathname } from "next/navigation";
import { useMemo } from "react";
export function useNuqsParams() {
const pathname = usePathname();
const isBookingsPage = pathname?.startsWith("/bookings/");
return useMemo(() => {
if (isBookingsPage) {
return { processUrlSearchParams: putUidFirstInQueryParams };
}
return {};
}, [isBookingsPage]);
}
function putUidFirstInQueryParams(searchParams: URLSearchParams) {
const params = new URLSearchParams(searchParams);
const entries = Array.from(params.entries());
// Clear existing params
entries.forEach(([key]) => params.delete(key));
// Sort entries with "uid" first, then alphabetically
const sortedEntries = entries.sort(([keyA], [keyB]) => {
if (keyA === "uid") return -1;
if (keyB === "uid") return 1;
return keyA.localeCompare(keyB);
});
// Re-add sorted entries
sortedEntries.forEach(([key, value]) => params.append(key, value));
return params;
}
@@ -28,6 +28,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
},
select: {
id: true,
isPrivate: true,
hideBranding: true,
parent: {
select: {
@@ -86,6 +87,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
team,
}),
themeBasis: null,
teamIsPrivate: team.isPrivate,
},
};
};
@@ -47,6 +47,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
},
select: {
...bookingMinimalSelect,
userId: true,
responses: true,
eventType: {
select: {
@@ -59,6 +60,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
allowReschedulingPastBookings: true,
disableRescheduling: true,
allowReschedulingCancelledBookings: true,
minimumRescheduleNotice: true,
team: {
select: {
id: true,
@@ -122,17 +124,21 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
booking: {
uid,
status: booking.status,
startTime: booking.startTime,
endTime: booking.endTime,
responses: booking.responses,
userId: booking.userId,
eventType: {
disableRescheduling: !!eventType?.disableRescheduling,
allowReschedulingPastBookings: eventType.allowReschedulingPastBookings,
allowBookingFromCancelledBookingReschedule: !!eventType.allowReschedulingCancelledBookings,
minimumRescheduleNotice: eventType.minimumRescheduleNotice,
teamId: eventType.team?.id ?? null,
},
},
eventUrl,
forceRescheduleForCancelledBooking: allowRescheduleForCancelledBooking,
currentUserId: session?.user?.id ?? null,
bookingSeat,
});
@@ -0,0 +1,413 @@
/**
* TODO: Move it to features/booking-audit
*/
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import dayjs from "@calcom/dayjs";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { Icon } from "@calcom/ui/components/icon";
import { SkeletonText } from "@calcom/ui/components/skeleton";
import { FilterSearchField, Select } from "@calcom/ui/components/form";
import { Avatar } from "@calcom/ui/components/avatar";
import ServerTrans from "@calcom/lib/components/ServerTrans";
import type { AuditActorType } from "@calcom/features/booking-audit/lib/repository/IAuditActorRepository";
interface BookingLogsViewProps {
bookingUid: string;
}
type TranslationComponent = {
type: "link";
href: string;
};
type TranslationWithParams = {
key: string;
params?: Record<string, string | number>;
components?: TranslationComponent[];
};
type AuditLog = {
id: string;
action: string;
type: string;
timestamp: string;
data: Record<string, unknown> | null;
actionDisplayTitle: TranslationWithParams;
displayFields?: Array<{ labelKey: string; valueKey: string }>;
actor: {
type: AuditActorType;
displayName: string | null;
displayEmail: string | null;
displayAvatar: string | null;
};
};
interface BookingLogsFiltersProps {
searchTerm: string;
onSearchChange: (value: string) => void;
actorFilter: AuditActorType | null;
onActorFilterChange: (value: AuditActorType | null) => void;
actorOptions: Array<{ label: string; value: AuditActorType }>;
}
interface BookingLogsTimelineProps {
logs: AuditLog[];
}
const getActionIcon = (action: string) => {
switch (action) {
case "CREATED":
return "calendar";
case "CANCELLED":
case "REJECTED":
return "ban";
case "ACCEPTED":
return "check";
case "RESCHEDULED":
case "RESCHEDULE_REQUESTED":
return "pencil";
case "REASSIGNMENT":
case "ATTENDEE_ADDED":
case "ATTENDEE_REMOVED":
return "user-check";
case "LOCATION_CHANGED":
return "map-pin";
case "HOST_NO_SHOW_UPDATED":
case "ATTENDEE_NO_SHOW_UPDATED":
return "ban";
default:
return "sparkles";
}
};
const getActorRoleLabel = (actorType: AuditActorType): string | null => {
switch (actorType) {
case "GUEST":
return "Guest";
case "ATTENDEE":
return "Attendee";
case "SYSTEM":
return null;
case "USER":
return null;
default:
return null;
}
};
function BookingLogsFilters({
searchTerm,
onSearchChange,
actorFilter,
onActorFilterChange,
actorOptions,
}: BookingLogsFiltersProps) {
const { t } = useLocale();
return (
<div className="flex flex-wrap gap-2 items-start">
<div className="flex-1 min-w-[200px]">
<FilterSearchField
size="sm"
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
containerClassName=""
/>
</div>
<div className="min-w-[140px]">
<Select
size="sm"
value={actorFilter ? { label: `${t("actor")}: ${actorFilter}`, value: actorFilter } : { label: `${t("actor")}: ${t("all")}`, value: "" }}
onChange={(option) => {
if (!option) return;
onActorFilterChange((option.value as AuditActorType) || null);
}}
options={[{ label: `${t("actor")}: ${t("all")}`, value: "" }, ...actorOptions.map(opt => ({ ...opt, label: `${t("actor")}: ${opt.label}` }))]}
/>
</div>
</div>
);
}
/**
* Renders the action display title with support for Trans component interpolation
* Handles translations with embedded components (e.g., links) for proper i18n support
*/
function ActionTitle({ actionDisplayTitle }: { actionDisplayTitle: TranslationWithParams }) {
const { t } = useLocale();
if (actionDisplayTitle.components?.length) {
return (
<ServerTrans
t={t}
i18nKey={actionDisplayTitle.key}
values={actionDisplayTitle.params}
components={actionDisplayTitle.components.map((comp) =>
comp.type === "link" ? (
<Link
key={comp.href}
href={comp.href}
className="text-emphasis underline hover:no-underline"
/>
) : (
<span key={comp.href} />
)
)}
/>
);
}
return <>{t(actionDisplayTitle.key, actionDisplayTitle.params)}</>;
}
interface JsonViewerProps {
data: Record<string, unknown> | null;
}
function JsonViewer({ data }: JsonViewerProps) {
if (!data || Object.keys(data).length === 0) {
return null;
}
const jsonString = JSON.stringify(data, null, 2);
const lines = jsonString.split("\n");
const lineCount = lines.length;
const lineNumberWidth = Math.max(2, Math.ceil(Math.log10(lineCount)) + 1);
return (
<div className="bg-default p-3 rounded-md text-[10px] overflow-x-auto font-mono">
{lines.map((line, idx) => (
<div key={idx} className="flex gap-2">
<span
className="text-subtle select-none text-right shrink-0"
style={{ minWidth: `${lineNumberWidth}ch` }}
>
{idx + 1}
</span>
<span className="text-default whitespace-pre">{line || " "}</span>
</div>
))}
</div>
);
}
function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
const { t } = useLocale();
const [expandedLogIds, setExpandedLogIds] = useState<Set<string>>(new Set());
const [showJsonMap, setShowJsonMap] = useState<Record<string, boolean>>({});
const toggleExpand = (logId: string) => {
setExpandedLogIds((prev) => {
const newSet = new Set(prev);
if (newSet.has(logId)) {
newSet.delete(logId);
} else {
newSet.add(logId);
}
return newSet;
});
};
const toggleJson = (logId: string) => {
setShowJsonMap((prev) => ({
...prev,
[logId]: !prev[logId],
}));
};
if (logs.length === 0) {
return (
<div className="text-center py-12">
<p className="text-muted">{t("no_audit_logs_found")}</p>
</div>
);
}
return (
<div className="flex flex-col gap-0.5">
{logs.map((log, index) => {
const isLast = index === logs.length - 1;
const isExpanded = expandedLogIds.has(log.id);
const showJson = showJsonMap[log.id] || false;
const actorRole = getActorRoleLabel(log.actor.type);
return (
<div key={log.id} className="flex gap-1">
<div className="flex flex-col items-center self-stretch">
<div className="pt-2 shrink-0">
<div className="bg-subtle rounded-[3.556px] p-1 flex items-center justify-center w-4 h-4">
<Icon name={getActionIcon(log.action)} className="h-3 w-3 text-subtle" />
</div>
</div>
{!isLast && (
<div className="w-px bg-subtle flex-1 min-h-0" />
)}
</div>
<div className="flex-1 rounded-lg py-2">
<div className="px-3 mb-2">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-emphasis leading-4">
<ActionTitle actionDisplayTitle={log.actionDisplayTitle} />
</h3>
<div className="flex items-center gap-1 mt-1 text-xs text-subtle">
{log.actor.displayAvatar && (
<Avatar
size="xs"
imageSrc={log.actor.displayAvatar}
alt={log.actor.displayName || ""}
/>
)}
<span>
{log.actor.displayName}
{actorRole && ` (${actorRole})`}
</span>
<span>•</span>
<span>{dayjs(log.timestamp).fromNow()}</span>
</div>
</div>
</div>
</div>
<div className="px-3">
<div className="bg-muted rounded-lg border border-muted">
<div className="py-1">
<Button
color="minimal"
size="sm"
onClick={() => toggleExpand(log.id)}
StartIcon={isExpanded ? "chevron-down" : "chevron-right"}
className="justify-start text-xs font-medium text-subtle h-6">
{isExpanded ? t("hide_details") : t("show_details")}
</Button>
</div>
{isExpanded && (
<div className="bg-default rounded-lg m-0.5 text-xs">
{/* Render displayFields if available, otherwise show type */}
{log.displayFields && log.displayFields.length > 0 ? (
log.displayFields.map((field, idx) => (
<div key={idx} className="flex items-start gap-2 py-2 border-b px-3 border-subtle">
<span className="font-medium text-emphasis min-w-[80px]">{t(field.labelKey)}</span>
<span className="text-[#096638] font-medium">{t(field.valueKey)}</span>
</div>
))
) : null}
<div className="flex items-start gap-2 py-2 border-b px-3 border-subtle">
<span className="font-medium text-emphasis min-w-[80px]">{t("actor")}</span>
<span className="text-default">{log.actor.displayName || log.actor.type}</span>
</div>
<div className="flex items-start gap-2 py-2 px-3 border-b border-subtle">
<span className="font-medium text-emphasis min-w-[80px]">
{t("timestamp")}
</span>
<span className="text-default">
{dayjs(log.timestamp).format("YYYY-MM-DD HH:mm:ss")}
</span>
</div>
{log.data && Object.keys(log.data).length > 0 && (
<div>
<div className="flex flex-col items-start gap-2 py-1 px-3 border-b border-subtle">
<Button
color="minimal"
size="sm"
onClick={() => toggleJson(log.id)}
StartIcon={showJson ? "chevron-down" : "chevron-right"}
className="-ml-3 h-6 px-2 font-medium text-xs">
{t("json")}
</Button>
</div>
<div>
{showJson && <JsonViewer data={log.data} />}
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
);
}
export default function BookingLogsView({ bookingUid }: BookingLogsViewProps) {
const router = useRouter();
const [searchTerm, setSearchTerm] = useState("");
const [actorFilter, setActorFilter] = useState<AuditActorType | null>(null);
const { t } = useLocale();
const { data, isLoading, error } = trpc.viewer.bookings.getAuditLogs.useQuery({
bookingUid,
});
if (error) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<p className="text-red-600 font-medium">{t("error_loading_booking_logs")}</p>
<p className="text-sm text-gray-500 mt-2">{error.message}</p>
<Button className="mt-4" onClick={() => router.back()}>
{t("go_back")}
</Button>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="space-y-4">
<SkeletonText className="h-12 w-full" />
<SkeletonText className="h-24 w-full" />
<SkeletonText className="h-24 w-full" />
<SkeletonText className="h-24 w-full" />
</div>
);
}
const auditLogs = data?.auditLogs || [];
// Apply filters
const filteredLogs = auditLogs.filter((log) => {
const matchesSearch =
!searchTerm ||
log.action.toLowerCase().includes(searchTerm.toLowerCase()) ||
log.actor.displayName?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesActor = !actorFilter || log.actor.type === actorFilter;
return matchesSearch && matchesActor;
});
const uniqueActorTypes = Array.from(new Set(auditLogs.map((log) => log.actor.type))) as AuditActorType[];
const actorOptions = uniqueActorTypes.map((actorType) => ({
label: actorType,
value: actorType,
}));
return (
<div className="space-y-6">
<BookingLogsFilters
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
actorFilter={actorFilter}
onActorFilterChange={setActorFilter}
actorOptions={actorOptions}
/>
<BookingLogsTimeline logs={filteredLogs} />
</div>
);
}
@@ -0,0 +1,249 @@
"use client";
import { useReactTable, getCoreRowModel, getSortedRowModel } from "@tanstack/react-table";
import React, { useMemo, useEffect } from "react";
import dayjs from "@calcom/dayjs";
import { DataTableFilters } from "@calcom/features/data-table";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import { Alert } from "@calcom/ui/components/alert";
import { Button } from "@calcom/ui/components/button";
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
import { Icon } from "@calcom/ui/components/icon";
import { useBookingCalendarData } from "~/bookings/hooks/useBookingCalendarData";
import { useBookingFilters } from "~/bookings/hooks/useBookingFilters";
import { useCalendarAllowedFilters } from "~/bookings/hooks/useCalendarAllowedFilters";
import { useCalendarAutoSelector } from "~/bookings/hooks/useCalendarAutoSelector";
import { useCalendarNavigationCapabilities } from "~/bookings/hooks/useCalendarNavigationCapabilities";
import { useCurrentWeekStart } from "~/bookings/hooks/useCurrentWeekStart";
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
import { buildFilterColumns, getFilterColumnVisibility } from "../columns/filterColumns";
import { getWeekStart } from "../lib/weekUtils";
import { BookingDetailsSheetStoreProvider } from "../store/bookingDetailsSheetStore";
import type { RowData, BookingListingStatus, BookingsGetOutput } from "../types";
import { BookingCalendarView } from "./BookingCalendarView";
import { BookingDetailsSheet } from "./BookingDetailsSheet";
import { ViewToggleButton } from "./ViewToggleButton";
import { WeekPicker } from "./WeekPicker";
// For calendar view, fetch all statuses except cancelled
const STATUSES: BookingListingStatus[] = ["upcoming", "unconfirmed", "recurring", "past"];
interface BookingCalendarContainerProps {
status: BookingListingStatus;
permissions: {
canReadOthersBookings: boolean;
};
bookingsV3Enabled: boolean;
}
interface BookingCalendarInnerProps extends BookingCalendarContainerProps {
data?: {
bookings: BookingsGetOutput["bookings"];
recurringInfo: BookingsGetOutput["recurringInfo"];
totalCount: BookingsGetOutput["totalCount"];
};
allowedFilterIds: string[];
isPending: boolean;
hasError: boolean;
errorMessage?: string;
hasNextPage: boolean;
isFetched: boolean;
isFetchingNextPage: boolean;
}
function BookingCalendarInner({
status,
permissions,
bookingsV3Enabled,
data,
allowedFilterIds,
hasError,
errorMessage,
hasNextPage,
isFetched,
isFetchingNextPage,
}: BookingCalendarInnerProps) {
const { t } = useLocale();
const user = useMeQuery().data;
const { currentWeekStart, setCurrentWeekStart, userWeekStart } = useCurrentWeekStart();
const rowData = useBookingCalendarData({ data, status });
// Extract bookings from table data
const bookings = useMemo(() => {
return rowData
.filter((row): row is Extract<RowData, { type: "data" }> => row.type === "data")
.map((row) => row.booking);
}, [rowData]);
// Handle auto-selection for calendar view
useCalendarAutoSelector(bookings, hasNextPage, isFetched, isFetchingNextPage);
const goToPreviousWeek = () => {
setCurrentWeekStart(currentWeekStart.subtract(1, "week"));
};
const goToNextWeek = () => {
setCurrentWeekStart(currentWeekStart.add(1, "week"));
};
const goToToday = () => {
setCurrentWeekStart(getWeekStart(dayjs(), userWeekStart));
};
const ErrorView = errorMessage ? (
<Alert severity="error" title={t("something_went_wrong")} message={errorMessage} />
) : undefined;
const columns = useMemo(() => {
return buildFilterColumns({ t, permissions, status }).filter((column) =>
allowedFilterIds.includes(column.id || "")
);
}, [allowedFilterIds, t, permissions, status]);
const getFacetedUniqueValues = useFacetedUniqueValues();
const table = useReactTable<RowData>({
data: rowData,
columns,
initialState: {
columnVisibility: getFilterColumnVisibility(),
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedUniqueValues,
});
return (
<>
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<WeekPicker
currentWeekStart={currentWeekStart}
userWeekStart={userWeekStart}
onDateChange={setCurrentWeekStart}
/>
{allowedFilterIds.length > 0 && <DataTableFilters.FilterBar table={table} />}
</div>
<div className="flex items-center gap-2">
<Button color="secondary" onClick={goToToday} className="capitalize">
{t("today")}
</Button>
<ButtonGroup combined>
<Button color="secondary" onClick={goToPreviousWeek}>
<span className="sr-only">{t("view_previous_week")}</span>
<Icon name="chevron-left" className="h-4 w-4" />
</Button>
<Button color="secondary" onClick={goToNextWeek}>
<span className="sr-only">{t("view_next_week")}</span>
<Icon name="chevron-right" className="h-4 w-4" />
</Button>
</ButtonGroup>
<ViewToggleButton bookingsV3Enabled={bookingsV3Enabled} />
</div>
</div>
{hasError && ErrorView ? (
ErrorView
) : (
<BookingCalendarView
bookings={bookings}
currentWeekStart={currentWeekStart}
onWeekStartChange={setCurrentWeekStart}
/>
)}
<BookingDetailsSheet
userTimeZone={user?.timeZone}
userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat}
userId={user?.id}
userEmail={user?.email}
/>
</>
);
}
export function BookingCalendarContainer(props: BookingCalendarContainerProps) {
const { canReadOthersBookings } = props.permissions;
const { userIds } = useBookingFilters();
const { currentWeekStart, setCurrentWeekStart, userWeekStart } = useCurrentWeekStart();
const allowedFilterIds = useCalendarAllowedFilters({
canReadOthersBookings,
});
const query = trpc.viewer.bookings.get.useInfiniteQuery(
{
limit: 100, // Use max limit for calendar view
filters: {
statuses: STATUSES,
userIds,
// Always fetch only the current week for calendar view
afterStartDate: currentWeekStart.startOf("day").toISOString(),
beforeEndDate: currentWeekStart.add(6, "day").endOf("day").toISOString(),
},
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
staleTime: 5 * 60 * 1000, // 5 minutes - data is considered fresh
gcTime: 30 * 60 * 1000, // 30 minutes - cache retention time
}
);
const { isFetched, hasNextPage, isFetchingNextPage, fetchNextPage } = query;
// Automatically fetch all pages until no more data
useEffect(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
// Flatten all pages into a single data object
const data = useMemo(() => {
if (!query.data?.pages) return undefined;
// Combine all bookings from all pages
const allBookings = query.data.pages.flatMap((page) => page.bookings);
// recurringInfo is the same across all pages (queried globally by user, not per page)
const recurringInfo = query.data.pages[0]?.recurringInfo ?? [];
// totalCount is the same across all pages
const totalCount = query.data.pages[0]?.totalCount ?? 0;
return { bookings: allBookings, recurringInfo, totalCount };
}, [query.data?.pages]);
const bookings = useMemo(() => data?.bookings ?? [], [data?.bookings]);
// Create navigation capabilities for calendar view
// This hook handles probe queries and prefetching internally
const capabilities = useCalendarNavigationCapabilities({
currentWeekStart,
setCurrentWeekStart,
userWeekStart,
filters: { statuses: STATUSES, userIds },
});
return (
<BookingDetailsSheetStoreProvider bookings={bookings} capabilities={capabilities}>
<BookingCalendarInner
{...props}
data={data}
allowedFilterIds={allowedFilterIds}
isPending={query.isPending}
hasError={!!query.error}
errorMessage={query.error?.message}
hasNextPage={hasNextPage}
isFetched={isFetched}
isFetchingNextPage={isFetchingNextPage}
/>
</BookingDetailsSheetStoreProvider>
);
}
@@ -6,44 +6,28 @@ import dayjs from "@calcom/dayjs";
import { useTimePreferences } from "@calcom/features/bookings/lib";
import { Calendar } from "@calcom/features/calendars/weeklyview";
import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useBanners } from "@calcom/features/shell/banners/useBanners";
import { useGetTheme } from "@calcom/lib/hooks/useTheme";
import { Button } from "@calcom/ui/components/button";
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
import { Icon } from "@calcom/ui/components/icon";
import { useBookingDetailsSheetStore } from "../store/bookingDetailsSheetStore";
import type { BookingOutput } from "../types";
type BookingsCalendarViewProps = {
type BookingCalendarViewProps = {
bookings: BookingOutput[];
currentWeekStart: dayjs.Dayjs;
onWeekStartChange: (weekStart: dayjs.Dayjs) => void;
isPending?: boolean;
};
export function BookingsCalendarView({
export function BookingCalendarView({
bookings,
currentWeekStart,
onWeekStartChange,
isPending = false,
}: BookingsCalendarViewProps) {
const setSelectedBookingId = useBookingDetailsSheetStore((state) => state.setSelectedBookingId);
const { t } = useLocale();
}: BookingCalendarViewProps) {
const setSelectedBookingUid = useBookingDetailsSheetStore((state) => state.setSelectedBookingUid);
const selectedBookingUid = useBookingDetailsSheetStore((state) => state.selectedBookingUid);
const { timezone } = useTimePreferences();
const { resolvedTheme, forcedTheme } = useGetTheme();
const goToPreviousWeek = () => {
onWeekStartChange(currentWeekStart.subtract(1, "week"));
};
const goToNextWeek = () => {
onWeekStartChange(currentWeekStart.add(1, "week"));
};
const goToToday = () => {
onWeekStartChange(dayjs().startOf("week"));
};
const { bannersHeight } = useBanners();
const startDate = useMemo(() => currentWeekStart.toDate(), [currentWeekStart]);
const endDate = useMemo(() => currentWeekStart.add(6, "day").toDate(), [currentWeekStart]);
@@ -84,58 +68,17 @@ export function BookingsCalendarView({
options: {
status: booking.status,
...(eventTypeColor && { color: eventTypeColor }),
bookingId: booking.id,
bookingUid: booking.uid,
},
};
});
}, [bookings, currentWeekStart, resolvedTheme, forcedTheme]);
const weekStart = currentWeekStart;
const weekEnd = currentWeekStart.add(6, "day");
const startMonth = weekStart.format("MMM");
const endMonth = weekEnd.format("MMM");
const year = weekEnd.format("YYYY");
const weekRange =
startMonth === endMonth ? (
<>
<span className="text-emphasis">{`${startMonth} ${weekStart.format("D")} - ${weekEnd.format(
"D"
)}`}</span>
<span className="text-muted">, {year}</span>
</>
) : (
<>
<span className="text-emphasis">{`${weekStart.format("MMM D")} - ${weekEnd.format("MMM D")}`}</span>
<span className="text-muted">, {year}</span>
</>
);
return (
<div className="border-subtle flex h-[calc(100vh-260px)] min-h-[600px] flex-col rounded-2xl border">
<div className="mx-4 mt-4 flex items-center justify-between py-1.5">
<div className="flex items-center gap-2">
<h2 className="text-xl font-semibold">{weekRange}</h2>
{isPending && <Icon name="refresh-cw" className="text-muted h-4 w-4 animate-spin" />}
</div>
<div className="flex items-center gap-2">
<Button color="secondary" onClick={goToToday} className="capitalize leading-4">
{t("today")}
</Button>
<ButtonGroup combined>
<Button color="secondary" onClick={goToPreviousWeek}>
<span className="sr-only">{t("view_previous_week")}</span>
<Icon name="chevron-left" className="h-4 w-4" />
</Button>
<Button color="secondary" onClick={goToNextWeek}>
<span className="sr-only">{t("view_next_week")}</span>
<Icon name="chevron-right" className="h-4 w-4" />
</Button>
</ButtonGroup>
</div>
</div>
<div className="flex-1 overflow-y-auto overflow-x-hidden rounded-2xl">
<>
<div
className="border-subtle flex flex-1 flex-col overflow-y-auto overflow-x-hidden rounded-2xl border"
style={{ height: `calc(100vh - 6rem - ${bannersHeight}px)` }}>
<Calendar
timezone={timezone}
sortEvents
@@ -149,15 +92,17 @@ export function BookingsCalendarView({
showBackgroundPattern={false}
showBorder={false}
borderColor="subtle"
selectedBookingUid={selectedBookingUid}
onEventClick={(event) => {
const bookingId = event.options?.bookingId;
if (bookingId) {
setSelectedBookingId(bookingId);
const bookingUid = event.options?.bookingUid;
if (bookingUid) {
setSelectedBookingUid(bookingUid);
}
}}
showTimezone
hideHeader
/>
</div>
</div>
</>
);
}
@@ -1,5 +1,6 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
import { z } from "zod";
@@ -8,9 +9,16 @@ import { useBookingLocation } from "@calcom/features/bookings/hooks";
import { shouldShowFieldInCustomResponses } from "@calcom/lib/bookings/SystemField";
import { formatPrice } from "@calcom/lib/currencyConversions";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
import { bookingMetadataSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import { BookingStatus } from "@calcom/prisma/enums";
import {
bookingMetadataSchema,
eventTypeBookingFields,
EventTypeMetaDataSchema,
} from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc/react";
import type { RecurringEvent } from "@calcom/types/Calendar";
import classNames from "@calcom/ui/classNames";
import { Avatar } from "@calcom/ui/components/avatar";
@@ -26,13 +34,15 @@ import {
SheetTitle,
} from "@calcom/ui/components/sheet";
import assignmentReasonBadgeTitleMap from "@lib/booking/assignmentReasonBadgeTitleMap";
import { AcceptBookingButton } from "../../../components/booking/AcceptBookingButton";
import { RejectBookingButton } from "../../../components/booking/RejectBookingButton";
import { BookingActionsDropdown } from "../../../components/booking/actions/BookingActionsDropdown";
import { BookingActionsStoreProvider } from "../../../components/booking/actions/BookingActionsStoreProvider";
import type { BookingListingStatus } from "../../../components/booking/types";
import {
useBookingDetailsSheetStore,
useBookingDetailsSheetStoreApi,
} from "../store/bookingDetailsSheetStore";
import { usePaymentStatus } from "../hooks/usePaymentStatus";
import { useBookingDetailsSheetStore } from "../store/bookingDetailsSheetStore";
import type { BookingOutput } from "../types";
import { JoinMeetingButton } from "./JoinMeetingButton";
@@ -86,35 +96,55 @@ function BookingDetailsSheetInner({
}: BookingDetailsSheetInnerProps) {
const { t } = useLocale();
// Get navigation state directly from the store
const hasNext = useBookingDetailsSheetStore((state) => state.hasNext());
const hasPrevious = useBookingDetailsSheetStore((state) => state.hasPrevious());
const setSelectedBookingId = useBookingDetailsSheetStore((state) => state.setSelectedBookingId);
// Fetch additional booking details for reschedule information
const { data: bookingDetails } = trpc.viewer.bookings.getBookingDetails.useQuery(
{ uid: booking.uid },
{
// Keep data fresh but don't refetch too aggressively
staleTime: 5 * 60 * 1000, // 5 minutes
}
);
// Get navigation state from the store in a single selector
const navigation = useBookingDetailsSheetStore((state) => {
const hasNextInArray = state.hasNextInArray();
const hasPreviousInArray = state.hasPreviousInArray();
const isLastInArray = state.isLastInArray();
const isFirstInArray = state.isFirstInArray();
return {
navigateNext: state.navigateNext,
navigatePrevious: state.navigatePrevious,
isTransitioning: state.isTransitioning,
setSelectedBookingUid: state.setSelectedBookingUid,
canGoNext: hasNextInArray || (isLastInArray && state.capabilities?.canNavigateToNextPeriod()),
canGoPrev: hasPreviousInArray || (isFirstInArray && state.capabilities?.canNavigateToPreviousPeriod()),
};
});
const handleClose = () => {
setSelectedBookingId(null);
navigation.setSelectedBookingUid(null);
};
const storeApi = useBookingDetailsSheetStoreApi();
const handleNext = () => {
const nextId = storeApi.getState().getNextBookingId();
if (nextId !== null) {
setSelectedBookingId(nextId);
}
navigation.navigateNext();
};
const handlePrevious = () => {
const prevId = storeApi.getState().getPreviousBookingId();
if (prevId !== null) {
setSelectedBookingId(prevId);
}
navigation.navigatePrevious();
};
const startTime = dayjs(booking.startTime).tz(userTimeZone);
const endTime = dayjs(booking.endTime).tz(userTimeZone);
const getStatusBadge = () => {
const statusBadge = useMemo(() => {
if (booking.rescheduled) {
return {
variant: "red" as const,
label: t("rescheduled"),
};
}
switch (booking.status) {
case "ACCEPTED":
return { variant: "green" as const, label: t("confirmed") };
@@ -127,9 +157,9 @@ function BookingDetailsSheetInner({
default:
return { variant: "gray" as const, label: booking.status };
}
};
}, [booking.status, booking.rescheduled, t]);
const statusBadge = getStatusBadge();
const isPending = booking.status === BookingStatus.PENDING;
const parsedMetadata = bookingMetadataSchema.safeParse(booking.metadata ?? null);
const bookingMetadata = parsedMetadata.success ? parsedMetadata.data : null;
@@ -148,18 +178,43 @@ function BookingDetailsSheetInner({
.map(([question, answer]) => [question, answer] as [string, unknown])
: [];
const reason = booking.assignmentReason?.[0];
const reasonTitle = reason && assignmentReasonBadgeTitleMap(reason.reasonEnum);
return (
<Sheet open={true} onOpenChange={handleClose}>
<SheetContent className="overflow-y-auto">
<SheetHeader
showCloseButton={false}
rightContent={
<Sheet open={true} onOpenChange={handleClose} modal={false}>
<SheetContent
className="overflow-y-auto"
hideOverlay
onInteractOutside={(e) => {
// Check if the click is on a booking list item
const target = e.target as HTMLElement;
const isBookingListItem = target.closest("[data-booking-list-item]");
if (isBookingListItem) {
// Prevent closing when clicking a booking list item
// The item's onClick will handle opening the sheet with the new booking
e.preventDefault();
}
// If clicking elsewhere, allow the default behavior (close the sheet)
}}>
<SheetHeader showCloseButton={false} className="mt-0 w-full">
<div className="flex items-center justify-between gap-x-4">
<div className="flex min-w-0 flex-col gap-y-1">
<BookingHeaderBadges
statusBadge={statusBadge}
reasonTitle={reasonTitle}
booking={booking}
recurringInfo={recurringInfo}
/>
</div>
<div className="flex gap-2">
<Button
variant="icon"
color="minimal"
size="sm"
color="secondary"
StartIcon="chevron-up"
disabled={!hasPrevious}
disabled={!navigation.canGoPrev || navigation.isTransitioning}
onClick={(e) => {
e.preventDefault();
handlePrevious();
@@ -167,41 +222,54 @@ function BookingDetailsSheetInner({
/>
<Button
variant="icon"
color="minimal"
size="sm"
color="secondary"
StartIcon="chevron-down"
disabled={!hasNext}
disabled={!navigation.canGoNext || navigation.isTransitioning}
onClick={(e) => {
e.preventDefault();
handleNext();
}}
/>
<Button
variant="icon"
size="sm"
color="secondary"
StartIcon="x"
onClick={(e) => {
e.preventDefault();
handleClose();
}}
/>
</div>
}>
<div className="flex items-center gap-2">
<Badge variant={statusBadge.variant} className="capitalize">
{statusBadge.label}
</Badge>
{booking.eventType.team && <Badge variant="gray">{booking.eventType.team.name}</Badge>}
{recurringInfo && (
<Badge variant="gray">
<Icon name="repeat" className="mr-1 h-3 w-3" />
{recurringInfo.count}
</Badge>
)}
</div>
</SheetHeader>
<SheetBody className="stack-y-6">
<div className="stack-y-6">
<div className="stack-y-1">
<SheetTitle className="text-2xl font-semibold">{booking.title}</SheetTitle>
<p className="text-subtle text-sm">
{startTime.format("dddd, MMMM D, YYYY h:mma")} - {endTime.format("h:mma")} (
{userTimeZone || startTime.format("Z")})
</p>
<SheetBody className="-mt-3">
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-1">
<SheetTitle className="text-emphasis flex items-center gap-3 text-xl font-semibold">
<div className="bg-emphasis w-0.5 shrink-0 self-stretch rounded-lg"></div>
<span>{booking.title}</span>
</SheetTitle>
</div>
<RescheduleRequestMessage booking={booking} />
<WhenSection
rescheduled={booking.rescheduled || false}
startTime={startTime}
endTime={endTime}
timeZone={userTimeZone}
previousBooking={bookingDetails?.previousBooking}
/>
<OldRescheduledBookingInfo
booking={booking}
rescheduledToBooking={bookingDetails?.rescheduledToBooking}
/>
<NewRescheduledBookingInfo booking={booking} />
<CancelledBookingInfo booking={booking} />
<WhoSection booking={booking} />
@@ -209,49 +277,66 @@ function BookingDetailsSheetInner({
<RecurringInfoSection recurringInfo={recurringInfo} />
<PaymentSection booking={booking} />
<AssignmentReasonSection booking={booking} />
{booking.payment?.[0] && <PaymentSection booking={booking} payment={booking.payment[0]} />}
<SlotsSection booking={booking} />
<AdditionalNotesSection booking={booking} />
<CustomQuestionsSection
customResponses={customResponses}
bookingFields={booking.eventType?.bookingFields}
/>
<DescriptionSection booking={booking} />
<TrackingSection tracking={bookingDetails?.tracking} />
</div>
</SheetBody>
<SheetFooter className="bg-muted border-subtle -mx-4 -mb-4 border-t pt-0 sm:-mx-6 sm:-my-6">
<div className="flex w-full flex-row items-center justify-between gap-2 px-4 pb-4 pt-4">
<Button color="secondary" StartIcon="x" onClick={handleClose}>
{t("close")}
</Button>
<div className="flex w-full min-w-0 flex-row flex-wrap items-center justify-end gap-2 px-4 pb-4 pt-4">
{isPending ? (
<>
<RejectBookingButton
bookingId={booking.id}
bookingUid={booking.uid}
recurringEventId={booking.recurringEventId}
isRecurring={!!booking.recurringEventId}
/>
<AcceptBookingButton
bookingId={booking.id}
bookingUid={booking.uid}
recurringEventId={booking.recurringEventId}
isRecurring={!!booking.recurringEventId}
/>
</>
) : (
!booking.rescheduled && (
<JoinMeetingButton
location={booking.location}
metadata={booking.metadata}
bookingStatus={booking.status}
/>
)
)}
<div className="flex gap-2">
<JoinMeetingButton
location={booking.location}
metadata={booking.metadata}
bookingStatus={booking.status}
/>
<BookingActionsDropdown
booking={{
...booking,
listingStatus: booking.status.toLowerCase() as BookingListingStatus,
recurringInfo: undefined,
loggedInUser: {
userId,
userTimeZone,
userTimeFormat: userTimeFormat ?? null,
userEmail,
},
isToday: false,
}}
usePortal={false}
context="details"
/>
</div>
<BookingActionsDropdown
booking={{
...booking,
listingStatus: booking.status.toLowerCase() as BookingListingStatus,
recurringInfo: undefined,
loggedInUser: {
userId,
userTimeZone,
userTimeFormat: userTimeFormat ?? null,
userEmail,
},
isToday: false,
}}
usePortal={false}
context="details"
/>
</div>
</SheetFooter>
</SheetContent>
@@ -259,17 +344,62 @@ function BookingDetailsSheetInner({
);
}
function RescheduleRequestMessage({ booking }: { booking: BookingOutput }) {
function WhenSection({
rescheduled,
startTime,
endTime,
timeZone,
previousBooking,
}: {
rescheduled: boolean;
startTime: dayjs.Dayjs;
endTime: dayjs.Dayjs;
timeZone?: string;
previousBooking?: { uid: string; startTime: Date; endTime: Date } | null;
}) {
const { t } = useLocale();
if (booking.status !== "CANCELLED" || !booking.rescheduled) {
return null;
}
return (
<Section title={t("when")}>
{previousBooking?.startTime && previousBooking?.endTime && (
<div className="text-default flex flex-col text-sm line-through opacity-60">
<DisplayTimestamp
startTime={previousBooking.startTime}
endTime={previousBooking.endTime}
timeZone={timeZone}
/>
</div>
)}
<div
className={classNames(
"text-emphasis flex flex-col text-sm font-medium",
rescheduled && "line-through"
)}>
<DisplayTimestamp startTime={startTime} endTime={endTime} timeZone={timeZone} />
</div>
</Section>
);
}
function DisplayTimestamp({
startTime,
endTime,
timeZone,
}: {
startTime: Date | dayjs.Dayjs;
endTime: Date | dayjs.Dayjs;
timeZone?: string;
}) {
const start = startTime instanceof Date ? dayjs(startTime).tz(timeZone) : startTime;
const end = endTime instanceof Date ? dayjs(endTime).tz(timeZone) : endTime;
return (
<Badge startIcon="send" size="md" variant="gray" data-testid="request_reschedule_sent">
{t("reschedule_request_sent")}
</Badge>
<>
<span>{start.format("dddd, MMMM D, YYYY")}</span>
<span>
{start.format("h:mma")} - {end.format("h:mma")} ({timeZone || start.format("Z")})
</span>
</>
);
}
@@ -277,35 +407,52 @@ function WhoSection({ booking }: { booking: BookingOutput }) {
const { t } = useLocale();
return (
<Section title={t("who")}>
<div className="stack-y-4">
<div className="mt-2 flex flex-col gap-3">
{booking.user && (
<div className="flex items-center gap-4">
<Avatar
size="md"
imageSrc={getPlaceholderAvatar(null, booking.user.name)}
alt={booking.user.name || ""}
imageSrc={
booking.user.avatarUrl
? getUserAvatarUrl(booking.user)
: getPlaceholderAvatar(null, booking.user.name || booking.user.email)
}
alt={booking.user.name || booking.user.email || ""}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-emphasis truncate text-sm font-medium">{booking.user.name}</p>
<Badge variant="blue" size="sm">
<p className="text-emphasis truncate text-sm leading-[1.2]">
{booking.user.name || booking.user.email}
</p>
<Badge variant="purple" size="sm" className="capitalize">
{t("host")}
</Badge>
</div>
<p className="text-default truncate text-sm">{booking.user.email}</p>
<p className="text-default truncate text-sm leading-[1.2]">{booking.user.email}</p>
</div>
</div>
)}
{booking.attendees.map((attendee, idx) => (
<div key={idx} className="flex items-center gap-4">
<Avatar size="md" imageSrc={getPlaceholderAvatar(null, attendee.name)} alt={attendee.name} />
<div className="min-w-0 flex-1">
<p className="text-emphasis truncate text-sm font-medium">{attendee.name}</p>
<p className="text-default truncate text-sm">{attendee.email}</p>
{booking.attendees.map((attendee, idx) => {
const name = attendee.user?.name || attendee.name || attendee.user?.email || attendee.email;
return (
<div key={idx} className="flex items-center gap-4">
<Avatar
size="md"
imageSrc={
attendee.user?.avatarUrl
? getUserAvatarUrl(attendee.user)
: getPlaceholderAvatar(null, name)
}
alt={name}
/>
<div className="min-w-0 flex-1">
<p className="text-emphasis truncate text-sm leading-[1.2]">{name}</p>
<p className="text-default truncate text-sm leading-[1.2]">{attendee.email}</p>
</div>
</div>
</div>
))}
);
})}
</div>
</Section>
);
@@ -321,6 +468,10 @@ function WhereSection({ booking, meta }: { booking: BookingOutput; meta: Booking
bookingStatus: booking.status,
});
if (booking.rescheduled) {
return null;
}
if (!locationToDisplay) {
return null;
}
@@ -345,7 +496,7 @@ function WhereSection({ booking, meta }: { booking: BookingOutput; meta: Booking
/>
)}
<div className="flex min-w-0 items-baseline gap-1">
<span className="text-default shrink-0">{provider?.label}:</span>
<span className="text-emphasis shrink-0 font-medium">{provider?.label}:</span>
<a
href={locationToDisplay}
target="_blank"
@@ -372,7 +523,7 @@ function RecurringInfoSection({
return (
<Section title={t("recurring_event")}>
<p className="text-default text-sm">
<p className="text-emphasis text-sm font-medium">
{getEveryFreqFor({
t,
recurringEvent: recurringInfo.recurringEvent,
@@ -383,19 +534,60 @@ function RecurringInfoSection({
);
}
function PaymentSection({ booking }: { booking: BookingOutput }) {
function AssignmentReasonSection({ booking }: { booking: BookingOutput }) {
const { t } = useLocale();
if (!booking.paid || !Array.isArray(booking.payment) || booking.payment.length === 0) {
if (!booking.assignmentReason || booking.assignmentReason.length === 0) {
return null;
}
const payment = booking.payment[0];
// we fetch only one assignment reason.
const reason = booking.assignmentReason[0];
if (!reason.reasonString) {
return null;
}
return (
<Section title={t("assignment_reason")}>
<div className="text-emphasis text-sm font-medium">{reason.reasonString}</div>
</Section>
);
}
function PaymentSection({
booking,
payment,
}: {
booking: BookingOutput;
payment: NonNullable<BookingOutput["payment"]>[number];
}) {
const { t } = useLocale();
// Get refund policy from event type metadata
const parsedEventTypeMetadata = booking.eventType?.metadata
? EventTypeMetaDataSchema.safeParse(booking.eventType.metadata)
: null;
const eventTypeMetadata = parsedEventTypeMetadata?.success ? parsedEventTypeMetadata.data : null;
const refundPolicy = eventTypeMetadata?.apps?.stripe?.refundPolicy;
const refundDaysCount = eventTypeMetadata?.apps?.stripe?.refundDaysCount;
const paymentStatusMessage = usePaymentStatus({
bookingStatus: booking.status,
startTime: booking.startTime,
eventTypeTeamId: booking.eventType?.teamId,
userId: booking.user?.id,
payment,
refundPolicy,
refundDaysCount,
});
const formattedPrice = formatPrice(payment.amount, payment.currency);
return (
<Section title={t("payment")}>
<p className="text-default text-sm">{formattedPrice}</p>
<p className="text-emphasis text-sm font-medium">{formattedPrice}</p>
{paymentStatusMessage && <p className="text-subtle text-xs">{paymentStatusMessage}</p>}
</Section>
);
}
@@ -416,7 +608,7 @@ function SlotsSection({ booking }: { booking: BookingOutput }) {
return (
<Section title={t("slots")}>
<p className="text-default text-sm">{t("slots_taken", { takenSeats, totalSeats })}</p>
<p className="text-emphasis text-sm font-medium">{t("slots_taken", { takenSeats, totalSeats })}</p>
</Section>
);
}
@@ -470,7 +662,7 @@ function CustomQuestionsSection({
const title: string = fieldLabels.get(fieldNameStr) ?? fieldNameStr;
return (
<Section key={idx} title={title}>
<p className="text-default text-sm">{String(answer)}</p>
<p className="text-emphasis text-sm font-medium">{String(answer)}</p>
</Section>
);
})}
@@ -478,7 +670,124 @@ function CustomQuestionsSection({
);
}
function DescriptionSection({ booking }: { booking: BookingOutput }) {
// Old booking that was rescheduled away
function OldRescheduledBookingInfo({
booking,
rescheduledToBooking,
}: {
booking: BookingOutput;
rescheduledToBooking?: { uid: string } | null;
}) {
const { t } = useLocale();
// Only show for old rescheduled bookings
if (!booking.rescheduled) {
return null;
}
const cancellationReason = booking.cancellationReason || booking.rejectionReason;
const rescheduledBy = booking.rescheduledBy;
const cancelledBy = booking.cancelledBy;
return (
<>
{rescheduledToBooking?.uid && (
<Section title={t("rescheduled")}>
<Link href={`/booking/${rescheduledToBooking.uid}`}>
<div className="text-default flex items-center gap-1 text-sm underline">
{t("view_booking")}
<Icon name="external-link" className="h-4 w-4" />
</div>
</Link>
</Section>
)}
{rescheduledBy && (
<Section title={t("rescheduled_by")}>
<p className="text-emphasis text-sm font-medium">{rescheduledBy}</p>
</Section>
)}
{cancellationReason && (
<Section title={t("reason")}>
<p className="text-emphasis whitespace-pre-wrap text-sm font-medium">{cancellationReason}</p>
</Section>
)}
{cancelledBy && (
<Section title={t("cancelled_by")}>
<p className="text-emphasis text-sm font-medium">{cancelledBy}</p>
</Section>
)}
</>
);
}
// New booking that replaced an old one
function NewRescheduledBookingInfo({ booking }: { booking: BookingOutput }) {
const { t } = useLocale();
// Only show for new bookings that replaced an old one
if (!booking.fromReschedule) {
return null;
}
const cancellationReason = booking.cancellationReason || booking.rejectionReason;
const rescheduledBy = booking.rescheduler;
return (
<>
<Section title={t("rescheduled_by")}>
{rescheduledBy && <p className="text-emphasis text-sm font-medium">{rescheduledBy}</p>}
<Link href={`/booking/${booking.fromReschedule}`}>
<div className="text-default flex items-center gap-1 text-sm underline">
{t("original_booking")}
<Icon name="external-link" className="h-4 w-4" />
</div>
</Link>
</Section>
{cancellationReason && (
<Section title={t("reschedule_reason")}>
<p className="text-emphasis whitespace-pre-wrap text-sm font-medium">{cancellationReason}</p>
</Section>
)}
</>
);
}
// Purely cancelled or rejected (not rescheduled)
function CancelledBookingInfo({ booking }: { booking: BookingOutput }) {
const { t } = useLocale();
// Only show for cancelled/rejected bookings that were NOT rescheduled
const isCancelled = booking.status === BookingStatus.CANCELLED || booking.status === BookingStatus.REJECTED;
const wasRescheduled = booking.rescheduled === true;
if (!isCancelled || wasRescheduled) {
return null;
}
const cancellationReason = booking.cancellationReason || booking.rejectionReason;
const cancelledBy = booking.cancelledBy;
if (!cancellationReason && !cancelledBy) {
return null;
}
return (
<>
{cancelledBy && (
<Section title={t("cancelled_by")}>
<p className="text-emphasis text-sm font-medium">{cancelledBy}</p>
</Section>
)}
{cancellationReason && (
<Section title={t("reason")}>
<p className="text-emphasis whitespace-pre-wrap text-sm font-medium">{cancellationReason}</p>
</Section>
)}
</>
);
}
function AdditionalNotesSection({ booking }: { booking: BookingOutput }) {
const { t } = useLocale();
if (!booking.description) {
@@ -486,8 +795,87 @@ function DescriptionSection({ booking }: { booking: BookingOutput }) {
}
return (
<Section title={t("description")}>
<p className="text-default whitespace-pre-wrap text-sm">{booking.description}</p>
<Section title={t("additional_notes")}>
<p className="text-emphasis whitespace-pre-wrap text-sm font-medium">{booking.description}</p>
</Section>
);
}
function BookingHeaderBadges({
statusBadge,
reasonTitle,
booking,
recurringInfo,
}: {
statusBadge: { variant: "red" | "green" | "orange" | "gray"; label: string };
reasonTitle: string | undefined;
booking: BookingOutput;
recurringInfo: { count: number; recurringEvent: RecurringEvent } | null;
}) {
const { t } = useLocale();
const payment = booking.payment?.[0];
return (
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Badge variant={statusBadge.variant} className="capitalize">
{statusBadge.label}
</Badge>
{reasonTitle && (
<Badge variant="gray" className="capitalize">
{reasonTitle}
</Badge>
)}
{booking.eventType.team && <Badge variant="gray">{booking.eventType.team.name}</Badge>}
{booking.paid && !payment ? (
<Badge variant="orange">{t("error_collecting_card")}</Badge>
) : booking.paid ? (
<Badge variant="green" data-testid="paid_badge">
{payment?.paymentOption === "HOLD" ? t("card_held") : t("paid")}
</Badge>
) : null}
{recurringInfo && (
<Badge variant="gray">
<Icon name="repeat" className="mr-1 h-3 w-3" />
{recurringInfo.count}
</Badge>
)}
</div>
);
}
function TrackingSection({
tracking,
}: {
tracking?: {
utm_source: string | null;
utm_medium: string | null;
utm_campaign: string | null;
utm_term: string | null;
utm_content: string | null;
} | null;
}) {
const { t } = useLocale();
if (!tracking) {
return null;
}
const utmEntries = Object.entries(tracking).filter(([_, value]) => Boolean(value));
if (utmEntries.length === 0) {
return null;
}
return (
<Section title={t("utm_params")}>
<div className="text-default text-sm">
{utmEntries.map(([key, value]) => (
<div key={key} className="mb-1 last:mb-0">
<span className="font-medium">{key}</span>:{" "}
<code className="bg-subtle text-default rounded px-1 py-0.5 font-mono text-xs">{value}</code>
</div>
))}
</div>
</Section>
);
}
@@ -502,8 +890,8 @@ function Section({
children: React.ReactNode;
}) {
return (
<div className={classNames("stack-y-1", className)}>
<h3 className="text-subtle text-xs font-semibold">{title}</h3>
<div className={classNames("flex flex-col gap-1", className)}>
<h3 className="text-subtle text-xs font-medium">{title}</h3>
{children}
</div>
);
@@ -2,7 +2,7 @@
import type { Table as ReactTable } from "@tanstack/react-table";
import { DataTableWrapper, DataTableFilters, DataTableSegment } from "@calcom/features/data-table";
import { DataTableWrapper } from "@calcom/features/data-table";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
@@ -18,7 +18,7 @@ const descriptionByStatus: Record<BookingListingStatus, string> = {
unconfirmed: "unconfirmed_bookings",
};
type BookingsListViewProps = {
type BookingListViewProps = {
status: BookingListingStatus;
table: ReactTable<RowData>;
isPending: boolean;
@@ -27,14 +27,14 @@ type BookingsListViewProps = {
hasError?: boolean;
};
export function BookingsList({
export function BookingList({
status,
table,
isPending,
totalRowCount,
ErrorView,
hasError,
}: BookingsListViewProps) {
}: BookingListViewProps) {
const { t } = useLocale();
return (
@@ -48,18 +48,7 @@ export function BookingsList({
totalRowCount={totalRowCount}
variant="compact"
paginationMode="standard"
ToolbarLeft={
<>
<DataTableFilters.FilterBar table={table} />
</>
}
ToolbarRight={
<>
<DataTableFilters.ClearFiltersButton />
<DataTableSegment.SaveButton />
<DataTableSegment.Select />
</>
}
separatorClassName="py-4 pl-6 text-xs uppercase leading-4"
LoaderView={<SkeletonLoader />}
EmptyView={
<div className="flex items-center justify-center pt-2 xl:pt-0">
@@ -0,0 +1,298 @@
"use client";
import { useReactTable, getCoreRowModel, getSortedRowModel } from "@tanstack/react-table";
import { useRouter } from "next/navigation";
import React, { useState, useMemo, useEffect, useCallback } from "react";
import dayjs from "@calcom/dayjs";
import {
useDataTable,
DataTableFilters,
DataTableSegment,
useDisplayedFilterCount,
} from "@calcom/features/data-table";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import { Alert } from "@calcom/ui/components/alert";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { ToggleGroup } from "@calcom/ui/components/form";
import { WipeMyCalActionButton } from "@calcom/web/components/apps/wipemycalother/wipeMyCalActionButton";
import { useBookingFilters } from "~/bookings/hooks/useBookingFilters";
import { useBookingListColumns } from "~/bookings/hooks/useBookingListColumns";
import { useBookingListData } from "~/bookings/hooks/useBookingListData";
import { useBookingStatusTab } from "~/bookings/hooks/useBookingStatusTab";
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
import { useListAutoSelector } from "~/bookings/hooks/useListAutoSelector";
import { useListNavigationCapabilities } from "~/bookings/hooks/useListNavigationCapabilities";
import {
BookingDetailsSheetStoreProvider,
useBookingDetailsSheetStore,
} from "../store/bookingDetailsSheetStore";
import type { RowData, BookingListingStatus, BookingsGetOutput } from "../types";
import { BookingDetailsSheet } from "./BookingDetailsSheet";
import { BookingList } from "./BookingList";
import { ViewToggleButton } from "./ViewToggleButton";
interface FilterButtonProps {
table: ReturnType<typeof useReactTable<RowData>>;
displayedFilterCount: number;
setShowFilters: (value: boolean | ((prev: boolean) => boolean)) => void;
}
function FilterButton({ table, displayedFilterCount, setShowFilters }: FilterButtonProps) {
const { t } = useLocale();
if (displayedFilterCount === 0) {
return <DataTableFilters.AddFilterButton table={table} />;
}
return (
<Button
color="secondary"
StartIcon="list-filter"
className="h-full"
size="sm"
onClick={() => setShowFilters((value) => !value)}>
{t("filter")}
<Badge variant="gray" className="ml-1">
{displayedFilterCount}
</Badge>
</Button>
);
}
interface BookingListContainerProps {
status: BookingListingStatus;
permissions: {
canReadOthersBookings: boolean;
};
bookingsV3Enabled: boolean;
}
interface BookingListInnerProps extends BookingListContainerProps {
data?: BookingsGetOutput;
isPending: boolean;
hasError: boolean;
errorMessage?: string;
totalRowCount?: number;
bookings: BookingsGetOutput["bookings"];
}
function BookingListInner({
status,
permissions,
bookings,
bookingsV3Enabled,
data,
isPending,
hasError,
errorMessage,
totalRowCount,
}: BookingListInnerProps) {
const { t } = useLocale();
const user = useMeQuery().data;
const setSelectedBookingUid = useBookingDetailsSheetStore((state) => state.setSelectedBookingUid);
const router = useRouter();
const [showFilters, setShowFilters] = useState(true);
// Handle auto-selection for list view
useListAutoSelector(bookings);
const ErrorView = errorMessage ? (
<Alert severity="error" title={t("something_went_wrong")} message={errorMessage} />
) : undefined;
const handleBookingClick = useCallback(
(bookingUid: string) => {
setSelectedBookingUid(bookingUid);
},
[setSelectedBookingUid]
);
const columns = useBookingListColumns({
user,
status,
canReadOthersBookings: permissions.canReadOthersBookings,
bookingsV3Enabled,
handleBookingClick,
});
const finalData = useBookingListData({ data, status, userTimeZone: user?.timeZone });
const getFacetedUniqueValues = useFacetedUniqueValues();
const displayedFilterCount = useDisplayedFilterCount();
const { currentTab, tabOptions } = useBookingStatusTab();
useEffect(() => {
if (displayedFilterCount === 0) {
// reset to true, so it shows filters as soon as any filter is applied
setShowFilters(true);
}
}, [displayedFilterCount]);
const table = useReactTable<RowData>({
data: finalData,
columns,
initialState: {
columnVisibility: {
eventTypeId: false,
teamId: false,
userId: false,
attendeeName: false,
attendeeEmail: false,
dateRange: false,
bookingUid: false,
},
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedUniqueValues,
});
const isEmpty = !data?.bookings || data.bookings.length === 0;
return (
<>
<div className="flex flex-wrap items-center gap-2">
{/* Desktop: full width on first row, Mobile: full width on first row with horizontal scroll */}
<div className="w-full md:w-auto">
<div className="overflow-x-auto md:overflow-visible">
<ToggleGroup
value={currentTab}
onValueChange={(value) => {
if (!value) return;
const selectedTab = tabOptions.find((tab) => tab.value === value);
if (selectedTab?.href) {
router.push(selectedTab.href);
}
}}
options={tabOptions}
/>
</div>
</div>
{/* Desktop: second item on first row, Mobile: first item on second row */}
<FilterButton
table={table}
displayedFilterCount={displayedFilterCount}
setShowFilters={setShowFilters}
/>
{/* Desktop: auto-pushed to right via flex-grow spacer, Mobile: continue on second row */}
<div className="hidden grow md:block" />
<DataTableSegment.Select shortLabel />
{bookingsV3Enabled && <ViewToggleButton bookingsV3Enabled={bookingsV3Enabled} />}
</div>
{displayedFilterCount > 0 && showFilters && (
<div className="mt-3 flex flex-wrap items-center gap-2">
<DataTableFilters.ActiveFilters table={table} />
<DataTableFilters.AddFilterButton table={table} variant="minimal" />
{/* Desktop: auto-pushed to right via flex-grow spacer */}
<div className="hidden flex-grow md:block" />
<DataTableFilters.ClearFiltersButton />
<DataTableSegment.SaveButton />
</div>
)}
{status === "upcoming" && !isEmpty && (
<WipeMyCalActionButton className="mt-4" bookingStatus={status} bookingsEmpty={isEmpty} />
)}
<div className="mt-4">
<BookingList
status={status}
table={table}
isPending={isPending}
totalRowCount={totalRowCount}
ErrorView={ErrorView}
hasError={hasError}
/>
</div>
{bookingsV3Enabled && (
<BookingDetailsSheet
userTimeZone={user?.timeZone}
userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat}
userId={user?.id}
userEmail={user?.email}
/>
)}
</>
);
}
export function BookingListContainer(props: BookingListContainerProps) {
const { limit, offset, setPageIndex } = useDataTable();
const { eventTypeIds, teamIds, userIds, dateRange, attendeeName, attendeeEmail, bookingUid } =
useBookingFilters();
// Build query input once - shared between query and prefetching
const queryInput = useMemo(
() => ({
limit,
offset,
filters: {
statuses: [props.status],
eventTypeIds,
teamIds,
userIds,
attendeeName,
attendeeEmail,
bookingUid,
afterStartDate: dateRange?.startDate
? dayjs(dateRange?.startDate).startOf("day").toISOString()
: undefined,
beforeEndDate: dateRange?.endDate ? dayjs(dateRange?.endDate).endOf("day").toISOString() : undefined,
},
}),
[
limit,
offset,
props.status,
eventTypeIds,
teamIds,
userIds,
attendeeName,
attendeeEmail,
bookingUid,
dateRange,
]
);
const query = trpc.viewer.bookings.get.useQuery(queryInput, {
staleTime: 5 * 60 * 1000, // 5 minutes - data is considered fresh
gcTime: 30 * 60 * 1000, // 30 minutes - cache retention time
});
const bookings = useMemo(() => query.data?.bookings ?? [], [query.data?.bookings]);
// Always call the hook and provide navigation capabilities
// The BookingDetailsSheet is only rendered when bookingsV3Enabled is true (see line 212)
const capabilities = useListNavigationCapabilities({
limit,
offset,
totalCount: query.data?.totalCount,
setPageIndex,
queryInput,
});
return (
<BookingDetailsSheetStoreProvider bookings={bookings} capabilities={capabilities}>
<BookingListInner
{...props}
data={query.data}
isPending={query.isPending}
hasError={!!query.error}
errorMessage={query.error?.message}
totalRowCount={query.data?.totalCount}
bookings={bookings}
/>
</BookingDetailsSheetStoreProvider>
);
}
@@ -1,89 +0,0 @@
"use client";
import type { Table as ReactTable } from "@tanstack/react-table";
import { useCallback } from "react";
import dayjs from "@calcom/dayjs";
import {
DataTableFilters,
DataTableSegment,
useDataTable,
ColumnFilterType,
} from "@calcom/features/data-table";
import { CUSTOM_PRESET } from "@calcom/features/data-table/lib/dateRange";
import type { RowData, BookingListingStatus, BookingOutput } from "../types";
import { BookingsCalendarView } from "./BookingsCalendarView";
type BookingsCalendarProps = {
status: BookingListingStatus;
table: ReactTable<RowData>;
isPending?: boolean;
currentWeekStart: dayjs.Dayjs;
setCurrentWeekStart: (
value: dayjs.Dayjs | ((old: dayjs.Dayjs) => dayjs.Dayjs | null) | null
) => Promise<URLSearchParams>;
bookings: BookingOutput[];
ErrorView?: React.ReactNode;
hasError?: boolean;
};
const COLUMN_IDS_TO_HIDE = ["dateRange"];
export function BookingsCalendar({
table,
isPending = false,
currentWeekStart,
setCurrentWeekStart,
bookings,
ErrorView,
hasError,
}: BookingsCalendarProps) {
const { updateFilter } = useDataTable();
const handleWeekStartChange = useCallback(
(newWeekStart: dayjs.Dayjs) => {
setCurrentWeekStart(newWeekStart);
// Always set the date range to match the current week exactly
const startDate = newWeekStart.toDate();
const endDate = newWeekStart.add(6, "day").toDate();
updateFilter("dateRange", {
type: ColumnFilterType.DATE_RANGE,
data: {
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
preset: CUSTOM_PRESET.value,
},
});
},
[updateFilter, setCurrentWeekStart]
);
return (
<>
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<DataTableFilters.FilterBar table={table} columnIdsToHide={COLUMN_IDS_TO_HIDE} />
</div>
<div className="flex items-center gap-2">
<DataTableFilters.ClearFiltersButton />
<DataTableSegment.SaveButton />
<DataTableSegment.Select />
</div>
</div>
{hasError && ErrorView ? (
ErrorView
) : (
<BookingsCalendarView
bookings={bookings}
currentWeekStart={currentWeekStart}
onWeekStartChange={handleWeekStartChange}
isPending={isPending}
/>
)}
</>
);
}
@@ -1,162 +0,0 @@
"use client";
import { useReactTable, getCoreRowModel, getSortedRowModel } from "@tanstack/react-table";
import { createParser, useQueryState } from "nuqs";
import { useMemo } from "react";
import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
import { buildFilterColumns, getFilterColumnVisibility } from "../columns/filterColumns";
import { BookingDetailsSheetStoreProvider } from "../store/bookingDetailsSheetStore";
import type { RowData, BookingListingStatus, BookingsGetOutput, BookingOutput } from "../types";
import { BookingDetailsSheet } from "./BookingDetailsSheet";
import { BookingsCalendar } from "./BookingsCalendar";
const weekStartParser = createParser({
parse: (value: string) => {
const parsed = dayjs(value);
return parsed.isValid() ? parsed.startOf("week") : dayjs().startOf("week");
},
serialize: (value: dayjs.Dayjs) => value.format("YYYY-MM-DD"),
});
interface BookingsCalendarContainerProps {
status: BookingListingStatus;
permissions: {
canReadOthersBookings: boolean;
};
data?: BookingsGetOutput;
isPending?: boolean;
ErrorView?: React.ReactNode;
hasError?: boolean;
}
export function BookingsCalendarContainer({
status,
permissions,
data,
isPending = false,
ErrorView,
hasError,
}: BookingsCalendarContainerProps) {
const { t } = useLocale();
const user = useMeQuery().data;
const [currentWeekStart, setCurrentWeekStart] = useQueryState(
"weekStart",
weekStartParser.withDefault(dayjs().startOf("week"))
);
const columns = useMemo(() => {
return buildFilterColumns({ t, permissions, status });
}, [t, permissions, status]);
const getFacetedUniqueValues = useFacetedUniqueValues();
/**
* Transform raw booking data into RowData format for the calendar view
* - Deduplicates recurring bookings for recurring/unconfirmed/cancelled tabs
* - Attaches recurring info and isToday flag to each booking
*/
const rowData = useMemo<RowData[]>(() => {
if (!data?.bookings) {
return [];
}
// For recurring/unconfirmed/cancelled tabs: track recurring series to show only one representative booking per series
// Key: recurringEventId, Value: array of all bookings in that series
const shownBookings: Record<string, BookingOutput[]> = {};
const filterBookings = (booking: BookingOutput) => {
// Deduplicate recurring bookings for specific status tabs
// This ensures we show only ONE booking per recurring series instead of all occurrences
if (status === "recurring" || status == "unconfirmed" || status === "cancelled") {
// Non-recurring bookings are always shown
if (!booking.recurringEventId) {
return true;
}
// If we've already encountered this recurring series
if (
shownBookings[booking.recurringEventId] !== undefined &&
shownBookings[booking.recurringEventId].length > 0
) {
// Store this occurrence but DON'T display it (return false to filter out)
shownBookings[booking.recurringEventId].push(booking);
return false;
}
// First occurrence of this recurring series - show it and start tracking
shownBookings[booking.recurringEventId] = [booking];
}
return true;
};
return data.bookings.filter(filterBookings).map((booking) => {
const bookingDate = dayjs(booking.startTime).tz(user?.timeZone);
const today = dayjs().tz(user?.timeZone).format("YYYY-MM-DD");
const bookingDateStr = bookingDate.format("YYYY-MM-DD");
return {
type: "data" as const,
booking,
isToday: bookingDateStr === today,
recurringInfo: data.recurringInfo.find((info) => info.recurringEventId === booking.recurringEventId),
};
});
}, [data, status, user?.timeZone]);
const table = useReactTable<RowData>({
data: rowData,
columns,
initialState: {
columnVisibility: getFilterColumnVisibility(),
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedUniqueValues,
});
// Extract bookings from table data and filter by current week
const bookings = useMemo(() => {
const weekStart = currentWeekStart;
const weekEnd = currentWeekStart.add(6, "day");
return rowData
.filter((row): row is Extract<RowData, { type: "data" }> => row.type === "data")
.map((row) => row.booking)
.filter((booking) => {
const bookingStart = dayjs(booking.startTime);
return (
(bookingStart.isAfter(weekStart) || bookingStart.isSame(weekStart, "day")) &&
bookingStart.isBefore(weekEnd.endOf("day"))
);
});
}, [rowData, currentWeekStart]);
return (
<BookingDetailsSheetStoreProvider bookings={bookings}>
<BookingsCalendar
status={status}
table={table}
isPending={isPending}
currentWeekStart={currentWeekStart}
setCurrentWeekStart={setCurrentWeekStart}
bookings={bookings}
ErrorView={ErrorView}
hasError={hasError}
/>
<BookingDetailsSheet
userTimeZone={user?.timeZone}
userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat}
userId={user?.id}
userEmail={user?.email}
/>
</BookingDetailsSheetStoreProvider>
);
}
@@ -1,346 +0,0 @@
"use client";
import { useReactTable, getCoreRowModel, getSortedRowModel, createColumnHelper } from "@tanstack/react-table";
import { useMemo, useCallback } from "react";
import dayjs from "@calcom/dayjs";
import { ColumnFilterType } from "@calcom/features/data-table";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import BookingListItem from "@calcom/web/components/booking/BookingListItem";
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
import {
BookingDetailsSheetStoreProvider,
useBookingDetailsSheetStore,
} from "../store/bookingDetailsSheetStore";
import type { RowData, BookingListingStatus, BookingsGetOutput, BookingOutput } from "../types";
import { BookingDetailsSheet } from "./BookingDetailsSheet";
import { BookingsList } from "./BookingsList";
interface BookingsListContainerProps {
status: BookingListingStatus;
permissions: {
canReadOthersBookings: boolean;
};
data?: BookingsGetOutput;
isPending: boolean;
enableDetailsSheet: boolean;
totalRowCount?: number;
ErrorView?: React.ReactNode;
hasError?: boolean;
}
function BookingsListInner({
status,
permissions,
data,
isPending,
enableDetailsSheet,
totalRowCount,
ErrorView,
hasError,
}: BookingsListContainerProps) {
const { t } = useLocale();
const user = useMeQuery().data;
const setSelectedBookingId = useBookingDetailsSheetStore((state) => state.setSelectedBookingId);
const handleBookingClick = useCallback(
(bookingId: number) => {
setSelectedBookingId(bookingId);
},
[setSelectedBookingId]
);
// Define table columns for filtering (hidden columns used for filter UI)
const columns = useMemo(() => {
const columnHelper = createColumnHelper<RowData>();
return [
columnHelper.accessor((row) => row.type === "data" && row.booking.eventType.id, {
id: "eventTypeId",
header: t("event_type"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.MULTI_SELECT,
},
},
}),
columnHelper.accessor((row) => row.type === "data" && row.booking.eventType.team?.id, {
id: "teamId",
header: t("team"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.MULTI_SELECT,
},
},
}),
columnHelper.accessor((row) => row.type === "data" && row.booking.user?.id, {
id: "userId",
header: t("member"),
enableColumnFilter: permissions.canReadOthersBookings,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.MULTI_SELECT,
},
},
}),
columnHelper.accessor((row) => row, {
id: "attendeeName",
header: t("attendee_name"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.TEXT,
},
},
}),
columnHelper.accessor((row) => row, {
id: "attendeeEmail",
header: t("attendee_email_variable"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.TEXT,
},
},
}),
columnHelper.accessor((row) => row, {
id: "dateRange",
header: t("date_range"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.DATE_RANGE,
dateRangeOptions: {
range: status === "past" ? "past" : "custom",
},
},
},
}),
columnHelper.accessor((row) => row.type === "data" && row.booking.uid, {
id: "bookingUid",
header: t("booking_uid"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.TEXT,
textOptions: {
allowedOperators: ["equals"],
},
},
},
}),
columnHelper.display({
id: "customView",
cell: (props) => {
if (props.row.original.type === "data") {
const { booking, recurringInfo, isToday } = props.row.original;
return (
<BookingListItem
key={booking.id}
isToday={isToday}
loggedInUser={{
userId: user?.id,
userTimeZone: user?.timeZone,
userTimeFormat: user?.timeFormat,
userEmail: user?.email,
}}
listingStatus={status}
recurringInfo={recurringInfo}
{...(enableDetailsSheet && { onClick: () => handleBookingClick(booking.id) })}
{...booking}
/>
);
} else if (props.row.original.type === "today") {
return (
<p className="text-subtle bg-subtle w-full py-4 pl-6 text-xs font-semibold uppercase leading-4">
{t("today")}
</p>
);
} else if (props.row.original.type === "next") {
return (
<p className="text-subtle bg-subtle w-full py-4 pl-6 text-xs font-semibold uppercase leading-4">
{t("next")}
</p>
);
}
},
}),
];
}, [user, status, t, permissions.canReadOthersBookings, enableDetailsSheet, handleBookingClick]);
/**
* Transform raw bookings into flat list (excluding today's bookings for "upcoming" status)
* - Deduplicates recurring bookings for recurring/unconfirmed/cancelled tabs
* - For "upcoming" status, filters out today's bookings (they're shown in separate "Today" section)
*/
const flatData = useMemo<RowData[]>(() => {
// For recurring/unconfirmed/cancelled tabs: track recurring series to show only one representative booking per series
// Key: recurringEventId, Value: array of all bookings in that series
const shownBookings: Record<string, BookingOutput[]> = {};
const filterBookings = (booking: BookingOutput) => {
// Deduplicate recurring bookings for specific status tabs
// This ensures we show only ONE booking per recurring series instead of all occurrences
if (status === "recurring" || status == "unconfirmed" || status === "cancelled") {
// Non-recurring bookings are always shown
if (!booking.recurringEventId) {
return true;
}
// If we've already encountered this recurring series
if (
shownBookings[booking.recurringEventId] !== undefined &&
shownBookings[booking.recurringEventId].length > 0
) {
// Store this occurrence but DON'T display it (return false to filter out)
shownBookings[booking.recurringEventId].push(booking);
return false;
}
// First occurrence of this recurring series - show it and start tracking
shownBookings[booking.recurringEventId] = [booking];
} else if (status === "upcoming") {
// For "upcoming" tab, exclude today's bookings (they're shown separately in the "Today" section)
return (
dayjs(booking.startTime).tz(user?.timeZone).format("YYYY-MM-DD") !==
dayjs().tz(user?.timeZone).format("YYYY-MM-DD")
);
}
return true;
};
return (
data?.bookings.filter(filterBookings).map((booking) => ({
type: "data",
booking,
recurringInfo: data?.recurringInfo.find((info) => info.recurringEventId === booking.recurringEventId),
isToday: false,
})) || []
);
}, [data, status, user?.timeZone]);
// Extract today's bookings for the "Today" section (only used in "upcoming" status)
const bookingsToday = useMemo<RowData[]>(() => {
return (data?.bookings ?? [])
.filter(
(booking: BookingOutput) =>
dayjs(booking.startTime).tz(user?.timeZone).format("YYYY-MM-DD") ===
dayjs().tz(user?.timeZone).format("YYYY-MM-DD")
)
.map((booking) => ({
type: "data" as const,
booking,
recurringInfo: data?.recurringInfo.find((info) => info.recurringEventId === booking.recurringEventId),
isToday: true,
}));
}, [data, user?.timeZone]);
// Combine data with section separators for "upcoming" tab
const finalData = useMemo<RowData[]>(() => {
// For other statuses, just return the flat list
if (status !== "upcoming") {
return flatData;
}
// For "upcoming" status, organize into "Today" and "Next" sections
const merged: RowData[] = [];
if (bookingsToday.length > 0) {
merged.push({ type: "today" as const }, ...bookingsToday);
}
if (flatData.length > 0) {
merged.push({ type: "next" as const }, ...flatData);
}
return merged;
}, [bookingsToday, flatData, status]);
const getFacetedUniqueValues = useFacetedUniqueValues();
const table = useReactTable<RowData>({
data: finalData,
columns,
initialState: {
columnVisibility: {
eventTypeId: false,
teamId: false,
userId: false,
attendeeName: false,
attendeeEmail: false,
dateRange: false,
bookingUid: false,
},
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedUniqueValues,
});
return (
<>
<BookingsList
status={status}
table={table}
isPending={isPending}
totalRowCount={totalRowCount}
ErrorView={ErrorView}
hasError={hasError}
/>
{enableDetailsSheet && (
<BookingDetailsSheet
userTimeZone={user?.timeZone}
userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat}
userId={user?.id}
userEmail={user?.email}
/>
)}
</>
);
}
export function BookingsListContainer({
status,
permissions,
data,
isPending,
enableDetailsSheet,
totalRowCount,
ErrorView,
hasError,
}: BookingsListContainerProps) {
// Extract bookings from data for BookingDetailsSheet
const bookings = useMemo(() => data?.bookings ?? [], [data]);
return (
<BookingDetailsSheetStoreProvider bookings={bookings}>
<BookingsListInner
status={status}
permissions={permissions}
data={data}
isPending={isPending}
enableDetailsSheet={enableDetailsSheet}
totalRowCount={totalRowCount}
ErrorView={ErrorView}
hasError={hasError}
/>
</BookingDetailsSheetStoreProvider>
);
}

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