Compare commits

..
145 Commits
Author SHA1 Message Date
Alex van Andel 8d04a23bce chore: release v6.0.3
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-20 01:00:01 +00:00
dd8477c0bf fix(typo): correct spelling of organization in error message (#26035)
Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com>
2025-12-20 00:16:18 +00:00
BetoandDhairyashil aebfd4919b feat(companion): Update availability screen to use liquid glass (#26041)
* Use previous header for android

* use activity instead of ternary statements

* update layout to use header buttons in availability screen

* fix styles and add context menu

* add set as default action

---------

Co-authored-by: Dhairyashil <dhairyashil10101010@gmail.com>
2025-12-19 19:05:26 +00:00
Dhairyashil Shinde 69a857ad98 feat(companion): Multi-browser extension support (Chrome, Firefox, Safari, Edge, Brave) and fix text node errors (#26063)
* feat(companion): add multi-browser OAuth support for extension

- Add browser-specific OAuth client ID and redirect URI configuration
- Support Chrome, Firefox, Safari, Edge, and Brave browsers
- Add build scripts for each browser target (ext:build-{browser})
- Fix Brave Shields compatibility for production builds
- Add EXTENSION_SETUP.md documentation

BREAKING CHANGE: None - backward compatible with existing Chrome config

* fix(companion): replace && conditional rendering with ternary operators

Replace all `{condition && (<Component />)}` patterns with explicit
`{condition ? (<Component />) : null}` syntax across the companion app.

This fixes "Unexpected text node: . A text node cannot be a child of
a <View>" errors that occur with react-native-css-interop when using
&& conditional rendering in React Native web builds.

Files updated:
- app/event-type-detail.tsx (16)
- app/booking-detail.tsx (10)
- app/(tabs)/bookings.tsx (8)
- app/(tabs)/availability.tsx (6)
- app/availability-detail.tsx (5)
- app/(tabs)/(event-types)/index.tsx (4)
- components/event-type-detail/tabs/LimitsTab.tsx (9)
- components/event-type-detail/tabs/BasicsTab.tsx (3)
- components/event-type-detail/tabs/AdvancedTab.tsx (3)
- components/event-type-detail/tabs/RecurringTab.tsx (1)
- components/event-type-detail/tabs/AvailabilityTab.tsx (1)
- components/event-type-list-item/EventTypeListItem.ios.tsx (3)
- components/BookingActionsModal.tsx (2)
- components/Tooltip.tsx (1)
- components/LocationsList.tsx (1)
- components/Header.tsx (1)
- components/EmptyScreen.tsx (1)

* correct command for edge

* dont break ci

* deslop ai
2025-12-19 23:50:20 +05:30
Pedro Castro af4a10c008 fix(seo): prevent booking confirmation pages from being indexed (#26058)
Booking confirmation pages contain PII (names, emails, phone numbers)
and should not be indexed by search engines

- Add robots noindex to /booking/[uid]/page.tsx
- Add robots noindex to /booking/[uid]/embed/page.tsx

Follows same pattern as not-found.tsx
2025-12-19 13:34:49 -03:00
Pedro Castro 90a83a611c docs: add ListEventTypes atom documentation (#25775)
- Add list-event-types.mdx with Quick start, Props, Demo video, and Combining with EventTypeSettings sections
- Add list_event_types_light.png screenshot
- Update mint.json navigation to include new page
2025-12-19 13:23:07 -03:00
sean-brydon b33d8c64df add turbo repo (#26051) 2025-12-19 14:27:19 +00:00
Carina WollendorferandCarinaWolli 73b276015c fix: remove pkce check for refreshToken endpoint (#26050)
* remove pkce check for refreshToken endpoint

* adjust e2e tests

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-12-19 15:18:49 +01:00
Abhishek 9c223fc718 fix: settings icon at app setup - event types (#26048)
* fix/setting-icon-app-card

* fix: settings icon app card
2025-12-19 14:13:58 +00:00
Eesh MidhaandRajiv Sahal 6910b85301 refactor(dialog): standardize icon container styling (#26036)
Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
2025-12-19 19:22:04 +05:30
125ed4f6f7 feat: update favicons with new Cal design (#25392)
* feat: add environment-based favicons (prod vs dev)

- Add cal-prod.png (dark/gray) favicon for production environments
- Add cal-dev.png (red) favicon for development/localhost environments
- Update FAVICON_16 and FAVICON_32 constants to switch based on IS_PRODUCTION

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

* feat: add higher resolution icons for all icon types (apple-touch, android-chrome, mstile)

- Add 180x180 apple-touch-icon for prod and dev
- Add 150x150 mstile icons for prod and dev
- Add 192x192 and 256x256 android-chrome icons for prod and dev
- Update all icon constants to use IS_PRODUCTION switching

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

* refactor: simplify favicon to single new design

- Remove environment-based favicon switching
- Update all icon files with new Cal favicon design
- Remove dev/prod icon variants
- Update constants to use static icon paths

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

* feat: add favicon.ico with new Cal favicon design

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

* chore: update favicon icons with final design

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

* chore: update favicon icons with final design v3

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-19 13:12:14 +00:00
Kartik Labhshetwar 2cab5475ce fix: improve CustomEmailTextField styling and focus behavior (#26024) 2025-12-19 13:08:28 +00:00
Syed Ali Shahbaz 5032d2716d fix: Add email to user select in credential to ensure consistent type (#26020)
* Add email to user select

* type fix
2025-12-19 13:03:04 +00:00
3ccf001e1d fix: add name property to delegation credentials for type compatibility (#26022)
* fix: add name property to delegation credentials for type compatibility

The MultiDisconnectIntegration component expects credentials with user.name property,
but delegation credentials only had user.email. This caused a type mismatch when
spreading both credential types together in appCredentialsByType handler.

Added name: null to the delegation credentials user object to ensure type compatibility.

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

* fix: update getUserDisplayName to check value instead of just property existence

The narrowing logic was causing TypeScript to infer 'never' type because
after adding name property to delegation credentials, the 'name in user'
check always passes. Now we check if name has a truthy value before using it.

Also fixed lint warning by using optional chaining for onSuccess callback.

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

* fix: add explicit return type and typeof checks to getUserDisplayName

The function now has an explicit return type of string | null and uses
typeof checks to ensure proper type narrowing. This prevents TypeScript
from inferring a wider return type that includes {} when the user object
has properties with non-string types.

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

* test: update delegation credential test to include name property

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-19 12:21:48 +00:00
4c282ea9ca feat: add promotion codes support to SMS credit purchases (#23774)
Co-authored-by: peer@cal.com <peer@cal.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-12-19 08:57:13 -03:00
Anik Dhabal Babu 1135e47338 fix: render markdown in custom page redirect messages (#25987)
Before:-
<img width="1986" height="762" alt="image (26)" src="https://github.com/user-attachments/assets/90e16a5c-a8f1-4269-8873-8854a82c012e" />
<img width="3456" height="2068" alt="image (27)" src="https://github.com/user-attachments/assets/6714a1d5-454d-4fe7-b6ce-84120353a10a" />
After:-
<img width="977" height="456" alt="Screenshot 2025-12-17 at 9 09 22 PM" src="https://github.com/user-attachments/assets/449611ab-8d84-41e2-a342-0e334562cec5" />
2025-12-19 16:44:06 +05:30
Udit TakkarandAnik Dhabal Babu 68e670ae06 fix: hide OOO slots across timezones (#25966)
* fix: use organizer timezone

* fix: use organizer timezone

* fix: use organizer timezone

* fix type error

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-12-19 08:12:39 -03:00
sean-brydon 8b13979a41 chore: implement posthog tracking company email upgrade point (#25977)
* implement posthog tracking

* track dismiss
2025-12-19 10:39:30 +00:00
4beebd227b fix: flaky E2E tests and refactor (#25974)
* fix: flaky E2E tests and refactor

* fix

* fix: week limit tests to use same week for pre-booking and UI booking

The week limit tests were failing because the pre-booking was created in
week 1 but the UI booking was done in week 2. Since weekly limits are
per-week, the pre-booking didn't count toward the limit in week 2.

Fixed by keeping both bookings in the same week:
- Pre-booking on Monday (satisfies daily limit, counts toward weekly)
- UI booking on Tuesday (same week, hits weekly limit of 2)

This ensures the weekly limit is properly tested and all remaining
weekdays in the week get blocked after hitting the limit.

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-19 07:35:47 -03:00
Prateek Dwivedi 853358d9c4 fix(logging): replace console.log with logger.error in editLocation handler (#26037) 2025-12-19 08:15:44 +00:00
PallavandAnik Dhabal Babu 7dcf4ee5c8 fix: prevent custom calendar for team event (#26033)
* fix: prevent custom calendar for team event

* Remove commented-out disableRescheduling code

Removed commented-out code for disabling rescheduling.

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-12-19 07:22:27 +00:00
42f2c170aa feat: add coss-ui core package (#25984)
* Install/add components in base coss-ui package

* fix components imports

* add yarn script to pull

* Update packagejson

* remove skeleton duplicates

* feat: allow importing components with the cli

* fix: update exports in package.json to reference styles.css instead of globals.css

* add lucide dep

* refactor: re-pull components

* refactor: remove unused registries section from components.json

* refactor: implement changes suggested by Volnei

* refactor(wip): implemet coss ui components on the profile page

* fix src/src problem

* update command to overwrite existing components

* pulled from registry

* restore globals.css

* feat: add pull script

* feat: enhance pull script with validation and error handling

* remove duplicate components

* refactor: reimport coss component and fix ts errors

* fix: components pulling

* chore: regenerate yarn.lock

* chore: remove tailwindcss/forms

* trying different moduleResolution

* TypeScript requires module to also be "NodeNext".

* remove unnecessary changes

* type fixes plus resolve @coss/ui

* use bundler tsconfig

* add missing deps to features and ui

* Add bundler to other packages

* fix module enext resolution with bundler

* remove reduant files

* remove package json from features

* revert unrelated change to the PR

---------

Co-authored-by: pasqualevitiello <pasqualevitiello@gmail.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-12-18 19:57:21 +00:00
Peer RichelsenandDhairyashil 3423777ee3 feat: gmail, outlook, yahoo links during email verification (#26038)
* added gmail, outlook, yahoo links during email verification

* added proton

* enable for self-hosters too

* undo i18n

* Discard changes to companion/bun.lock

* nit

* refactor: code quality improvments

---------

Co-authored-by: Dhairyashil <dhairyashil10101010@gmail.com>
2025-12-18 19:56:38 +00:00
Dhairyashil ShindeandVolnei Munhoz c8a8c37b45 fix(companion): prevent localhost URL from being baked into production extension builds (#26032)
* fix(companion): separate dev and prod build scripts to prevent localhost in production

- Add ext:build-dev and ext:build-prod scripts with clear separation
- Add BUILD_FOR_STORE flag to force production URL in store builds
- Add visual build indicators (store / dev) for clarity
- Prevent EXPO_PUBLIC_COMPANION_DEV_URL from leaking into production builds

* update commands to work for ci

* update version

---------

Co-authored-by: Volnei Munhoz <volnei@cal.com>
2025-12-18 19:19:04 +00:00
Benny JooandAnik Dhabal Babu f291c1c4e7 fix: add mergeWithEnglishFallback for i18n translations (#26016)
* fix

* fix

* fix

* fix

* Improve translation cache handling in i18n.ts

Refactor translation cache retrieval and fallback logic.

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-12-18 16:47:54 +00:00
Hariom BalharaandDevin AI 71792bdeaa fix: ensure linkFailed event fires for 404, 500, and 403 errors in embeds (#25261)
- Add CalComPageStatus handling in NotFound and ErrorPage components using useLayoutEffect
- Remove redundant pageStatus logic from PageWrapperAppDir.tsx since App Router error/notFound pages set status themselves
- Refactor embed-iframe.ts: split checkPageStatusAndHandleError into hasPageError() and handlePageError()
- Add page status checks before firing linkReady to catch errors set after initialization
- Ensures linkFailed event fires correctly for all error status codes in embed scenarios

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-18 16:30:32 +00:00
Eunjae Lee 945ded951f fix: integration test of booking audit service (#26027) 2025-12-18 15:26:03 +00:00
Pedro CastroandMorgan 975fb21d86 feat(atoms): add ListEventTypes atom to enable event type management (#25047) (#25113)
* feat: add ListEventTypes atom (#25047)

* feat: add ListEventTypes atom for Platform (#25047)

- Add EventTypeListItem component in @calcom/features
- Add ListEventTypesPlatformWrapper with delete functionality
- Implement useAtomGetAllEventTypes hook
- Update backend to include slug, description, length
- Add 11 unit tests (65% coverage)
- Add 3 i18n keys

No breaking changes. Backward compatible.

* refactor(eventtypes): improve EventTypeListItem readability

- Extract EventTypeContent and EventTypeActions sub-components
- Add formatEventTypeDuration helper (90min → '1h 30m')
- Use Badge for duration display (consistency with EventTypeDescription)
- Use polymorphic Wrapper component (DRY)
- Add comprehensive tests
- Reduce main component size

Addresses @volnei feedback

* refactor: replace window.confirm with ConfirmationDialog

- Replace native window.confirm with ConfirmationDialogContent

- Update test mocks for Dialog components

- Fix HTML nesting (div instead of p for Badge container)

- Follows Cal.com pattern from AvailabilitySettings

Addresses @ThyMinimalDev feedback

* fix: add aria-label to options button for accessibility

* test: update getBulkEventTypes test to include new fields (description, slug, length)

* refactor: move EventTypeListItem to atoms package

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-12-18 12:24:41 -03:00
Anik Dhabal Babuandcubic-dev-ai[bot] b20b52a1f8 fix: update tempOrgRediret on updating org slug (#25838)
* fix: update tempOrgRediret on updating s
lug

* Update packages/trpc/server/routers/viewer/organizations/adminUpdate.handler.ts

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

* refactor

* fix

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-12-18 13:18:56 +00:00
Benny Joo 176aeaafd8 refactor: trpc test file is importing @calcom/web (#25979)
* add eslint config

* refactor test
2025-12-18 10:10:46 -03:00
Anik Dhabal Babu 9ba867922a fix type error (#26021) 2025-12-18 12:55:54 +00:00
Hariom BalharaandDevin AI f1011ddd08 chore: Improves the core booking audit architecture by adding new capabilities and simplifying the existing interfaces. (#25872)
* feat(booking-audit): extract core audit system changes from PR 25125

This PR extracts core audit infrastructure changes without integration changes:

1. New action services introduced:
   - SeatBookedAuditActionService
   - SeatRescheduledAuditActionService

2. Simplification of ActionService interface:
   - Streamlined IAuditActionService interface
   - Reduced TypeScript burden with cleaner type definitions

3. ActionSource support:
   - Added BookingAuditSource enum (API_V1, API_V2, WEBAPP, WEBHOOK, UNKNOWN)
   - Added source and operationId fields to BookingAudit model

4. New AuditAction types:
   - SEAT_BOOKED
   - SEAT_RESCHEDULED
   - APP actor type

5. New BookingAuditAccessService:
   - Permission-based access control for audit logs
   - Added readTeamAuditLogs and readOrgAuditLogs permissions

6. Fixes in the logs viewer flow:
   - Enhanced BookingAuditViewerService with improved filtering
   - Local AttendeeRepository for actor enrichment

Changes are contained within packages/features/booking-audit with minimal
outside changes (permission registry only).

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

* refactor(booking-audit): streamline action handling and enhance localization

- Replaced action icon retrieval with a mapping object for improved clarity and performance.
- Introduced constants for actor role labels to simplify role retrieval.
- Added new localization strings for audit log permission errors and organization requirements.
- Updated various service and repository interfaces to enhance type safety and clarity.
- Removed deprecated architecture documentation and adjusted related imports for consistency.

These changes aim to improve code maintainability and user experience in the booking audit system.

* fix(booking-audit): enhance actor role localization and operation ID tracking

- Updated actor role labels in the booking logs view to use lowercase for consistency.
- Improved localization by wrapping actor role display in a translation function.
- Added operationId field to audit logs for better correlation of actions across multiple bookings.
- Enhanced BookingAuditViewerService to include operationId in enriched audit logs.
- Updated integration tests to verify consistent operationId across related audit logs.

These changes aim to improve localization accuracy and facilitate better tracking of user actions in the booking audit system.

* feat: integrate credential repository and enhance app actor handling

- Added CredentialRepository to manage app credentials, including a method to find credentials by ID.
- Updated BookingAudit system to support app actors identified by credential ID, improving actor attribution and audit clarity.
- Introduced a new utility function to map app slugs to display names, enhancing the user experience in audit logs.
- Modified relevant interfaces and types to accommodate the new credential handling and app actor structure.
- Enhanced BookingAuditViewerService to display app names based on credentials, ensuring accurate representation in audit logs.

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-18 12:46:24 +00:00
9019be58ed fix: allow required check to pass when E2E tests are skipped due to missing label (#26006)
* fix: allow required check to pass when E2E tests are skipped due to missing label

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

* revert: remove unnecessary integration-test condition change

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

* chore: trigger fresh CI run

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

* all should pass to allow merge

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-18 08:49:12 -03:00
Eunjae LeeandDevin AI cef8610925 feat: add enabled column to UserFeatures and TeamFeatures for tri-state semantics (#25765)
* feat: add enabled column to UserFeatures and TeamFeatures for tri-state semantics

- Add enabled Boolean column to UserFeatures model with default true
- Add enabled Boolean column to TeamFeatures model with default true
- Update FeaturesRepository to use tri-state semantics:
  - enabled=true: feature is explicitly enabled
  - enabled=false: feature is explicitly disabled (blocks inheritance)
  - No row: inherit from team/org level
- Update SQL queries to check enabled=true for feature access
- Add enableFeatureForTeam method to interface and implementation

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

* update comments

* add integration tests

* add more test

* select enabled only

* no @default(true)

* fix types and tests

* add missing enabled

* add missing enabled

* rename enableFeatureForTeam to updateFeatureForTeam and support FeatureState

* refactor: rename updateFeatureForTeam to setTeamFeatureState

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

* fix integration test

* fix tests

* add more tests

* add missing enabled

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-18 07:16:20 -03:00
Syed Ali Shahbaz 056922b6ee feat: add webhook versioning (#23861)
* add webhook version schema

* version the code

* update version from numeric to date val

* migration

* update schema and build factory

* update string

* move version picker

* tooltip instead of infobadge

* --

* fix type

* --

* fix type

* fix type

* --

* fix messed up merge

* improvements to payloadfactory

* extract version off of DB and instead keep it in IWebhookRepository

* fix webhookform

* fix type safety and routing ambiguity

* scalable with easier factory extensions and base definition

* fix types

* --

* --

* clean up prisma/client type imports

* fix

* type fix

* type fix

* cleanup

* add tests and registry changes

* unintended file inclusion

* type-fix

* select in repo

* --

* explicit return type

* --

* fix type

* fixes

* feedback 1

* feedback 2

* use enum instead of string

* fixes
2025-12-18 09:36:22 +02:00
Dhairyashil Shinde 42bd4b4057 fix: make isReschedule required in checkIfBookerEmailIsBlocked (#26002) 2025-12-17 19:52:10 -03:00
sean-brydonandcubic-dev-ai[bot] 64767cec4f chore: add onboarding events in posthog (#25981)
* add onboarding events in posthog

* Update apps/web/modules/onboarding/organization/teams/organization-teams-view.tsx

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-17 22:43:54 +00:00
Volnei MunhozandPeer Richelsen e84550ba76 chore: Isolate companion build (#25985)
* Isolate companion build

* Fix typo

* Update .github/workflows/pr.yml

* Improve branch protection

* integration tests missing

* fix typo

* fix to only have required

* fix to only have required

* fix to only have required

* adjust to keep as before

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-12-17 22:42:10 +00:00
Keith Williams e6d0253950 chore: Change atoms build command to build-npm (#25996)
* chore: Change atoms build command to build-npm to avoid turbo compilations

* Updated turbo.json atoms#build reference

* Updated atoms production build for CI
2025-12-17 16:30:54 -03:00
Dhairyashil ShindeandPeer Richelsen 471d1caac9 fix(companion): Fix Gmail OAuth integration (#25978)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-12-17 15:13:54 -03:00
Keith WilliamsandDevin AI 15b1675db1 perf: reduce TypeScript type bloat in tRPC package via z.ZodType annotations (#25845)
* fix: prevent Prisma conditional types from leaking into .d.ts files

This PR addresses TypeScript performance issues caused by Prisma's conditional types leaking through the type graph:

1. Replace Prisma.UserGetPayload with explicit UpdatedUserResult type in updateProfile.handler.ts
2. Replace Prisma.EventTypeGetPayload with explicit UpdatedEventTypeResult type in update.handler.ts
3. Replace Prisma.OutOfOfficeEntryGetPayload with explicit OOOEntryResult type in outOfOfficeCreateOrUpdate.handler.ts
4. Replace Prisma.CredentialGetPayload with explicit Credential type in getUserConnectedApps.handler.ts
5. Fix inconsistent DI usage in EventTypeRepository - use this.prismaClient instead of global prisma singleton

These changes prevent massive recursive Prisma types from propagating through the type graph and being emitted in .d.ts files, which improves TypeScript performance.

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

* fix: correct reasonId type to number | null in OOOEntryResult

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

* perf: add z.ZodType annotations to reduce .d.ts file sizes

- Annotate exported Zod schemas with z.ZodType<T> to prevent full Zod generic tree from being emitted in declaration files
- eventTypes/types.d.ts reduced from 231KB to 115KB (50% reduction)
- _app.d.ts reduced from 782KB to 753KB (3.7% reduction)
- viewer/_router.d.ts reduced from 722KB to 695KB (3.7% reduction)
- workflows/getAllActiveWorkflows.schema.d.ts significantly reduced
- routing-forms/formMutation.schema.d.ts significantly reduced

Files modified:
- packages/trpc/server/routers/viewer/eventTypes/types.ts
- packages/trpc/server/routers/viewer/workflows/getAllActiveWorkflows.schema.ts
- packages/trpc/server/routers/apps/routing-forms/formMutation.schema.ts
- packages/features/eventtypes/lib/types.ts

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

* perf: comprehensive z.ZodType annotations to reduce .d.ts file sizes

Applied z.ZodType<T> annotations to 89 schema files across the tRPC package.
This prevents TypeScript from emitting the full Zod generic tree in .d.ts files,
reducing declaration file sizes for downstream consumers.

Files modified include schemas in:
- viewer/teams (round-robin, managed events, invitations, etc.)
- viewer/bookings (get, find, confirm, etc.)
- viewer/eventTypes (get, delete, getByViewer, etc.)
- viewer/workflows (list, delete, verify, etc.)
- viewer/auth (changePassword, verifyPassword, etc.)
- viewer/apiKeys (create, delete, edit, etc.)
- viewer/sso (get, update, delete, updateOIDC)
- viewer/oAuth (addClient, generateAuthCode)
- viewer/calendars (setDestinationCalendar)
- viewer/deploymentSetup (update, validateLicense)
- apps/routing-forms (formQuery, deleteForm, etc.)
- publicViewer (submitRating, markHostAsNoShow, etc.)
- loggedInViewer (eventTypeOrder, routingFormOrder, etc.)

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

* fix: revert slots/types.ts z.ZodType annotation that caused type mismatch

The slots/types.ts schema has a transform that converts duration from
string to number, which makes the z.ZodType<T> annotation incompatible.
Reverting to original to fix Unit test failure.

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

* fix: use 3-generic z.ZodType pattern for schemas with .default() modifiers

For schemas with .default() modifiers, the input and output types differ:
- Input type: field is optional (what callers send)
- Output type: field is required (what handlers receive after parsing)

This commit:
1. Fixes get.schema.ts (offset has .default(0))
2. Fixes removeMember.schema.ts (isOrg has .default(false))
3. Fixes resendInvitation.schema.ts (isOrg has .default(false))
4. Fixes listMembers.schema.ts (limit has .default(10))
5. Fixes getByViewer.schema.ts (limit has .default(10))
6. Reverts eventTypes/types.ts (complex transforms hard to model)
7. Reverts features/eventtypes/lib/types.ts (complex transforms hard to model)

The 3-generic pattern z.ZodType<Output, z.ZodTypeDef, Input> properly models
the difference between input and output types while still preventing the full
Zod generic tree from being emitted in .d.ts files.

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

* fix: use 3-generic z.ZodType pattern for schemas with transforms/defaults

- organizations/update.schema.ts: orgId has .transform() that converts string to number, so input type is string | number but output type is number
- publicViewer/event.schema.ts: fromRedirectOfNonOrgLink has .default(false), so input has it optional but output has it required

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

* fix: use 3-generic z.ZodType pattern for updateProfile.schema.ts

Address reviewer feedback: isDeleted field in secondaryEmails has .default(false),
so input type has it optional but output type has it required.

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

* chore: remove explanatory comments from schema files

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

* fix: use 3-generic z.ZodType pattern for addClient.schema.ts enablePkce field

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

* fix: address Hariom's PR feedback

- Rename TGetInputSchemaInput to TGetInputRawSchema in get.schema.ts
- Use Prisma-free JsonValue type from @calcom/types/Json in updateProfile.handler.ts
- Fix publish.schema.ts with 3-generic pattern for z.coerce.number()

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

* fix: add sort field to TGetInputSchema types

Added sort field to both TGetInputRawSchema and TGetInputSchema types
to match the Zod schema that was updated in main branch.

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-17 15:06:57 -03:00
Calcom Bot c20f6cbdd6 feat: update translations via @LingoDotDev (#24385)
Hey team,

[**Lingo.dev**](https://lingo.dev) here with fresh translations!

### In this update

- Added missing translations
- Performed brand voice, context and glossary checks
- Enhanced translations using Lingo.dev Localization Engine

### Next Steps

- [x] Review the changes
- [x] Merge when ready
2025-12-17 13:30:35 -03:00
Benny Joo 03dd0c1501 refactor: Move SelectedCalendarRepository and WorkflowRelationsRepository from /lib to /features (#25982)
* wip

* wip
2025-12-17 16:05:03 +00:00
Beto 84ecb0e568 chore(companion): update dependencies to latest expo canary version (#25948)
* chore: update dependencies and update event types screen header

* refactor: simplify EventTypeListItem component by removing unused imports and code

- Removed unnecessary imports such as Tooltip and Svg.
- Cleaned up the component by eliminating commented-out code and unused props.
- Streamlined the button rendering in the context menu for better readability.

* feat: enhance EventTypes header with blur effect and clean up code

- Added blurEffect property to Stack.Header for improved visual aesthetics.
- Removed commented-out code to streamline the EventTypes component.
- Updated search bar properties for better user experience.

* refactor: update TabLayout and EventTypes for improved platform handling

- Simplified TabLayout by removing the liquid glass effect check and adding a WebTabs fallback for web support.
- Updated EventTypes header to conditionally apply blur effect based on liquid glass availability, enhancing visual consistency across platforms.

* feat: enhance TabLayout with MaterialCommunityIcons for improved UI

- Updated TabLayout to use MaterialCommunityIcons for tab icons, enhancing visual consistency.
- Added softwareKeyboardLayoutMode to app.json for better keyboard handling on mobile devices.

* feat: update TabLayout to prevent transparent background on iOS 18

- Added disableTransparentOnScrollEdge prop to NativeTabs to ensure a solid background on iOS 18 and older versions.
2025-12-17 15:37:52 +00:00
Anik Dhabal BabuandDevin AI 27775e5d03 fix: preserve newlines and links in calendar event descriptions (#25868)
The event type description is stored as HTML from the rich text editor.
When converting to plain text for calendar events, the HTML tags were
being stripped without preserving the formatting, causing:
- Newlines to be lost (text runs together)
- Links to be broken

This fix:
- Converts <br>, </p>, </div>, </li>, </h1-6> tags to newlines
- Preserves links in readable format: 'text (url)' or just 'url'
- Normalizes multiple newlines to max 2 for cleaner output

Affects all calendar integrations (Google, Outlook, etc.)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-17 15:02:12 +00:00
Benny Joo 0986864835 refactor: Move PrismaRoutingFormRepository from lib to /features/routing-forms (#25975)
* wip

* update import paths
2025-12-17 14:26:17 +00:00
Benny Joo cb7844fd22 refactor: Migrate repositories/services from /lib to /features (#25925)
* deployment

* update imports

* booking report

* update import paths

* watch list

* watch list

* api key

* api key

* selected slots

* wip

* event type translation

* work flow step

* booking reference

* fix tests

* fix

* fix

* migrate

* wip

* address

* fix
2025-12-17 14:14:50 +00:00
Eunjae LeeandDevin AI aaaff0705b fix(bookings): enable past date selection for cancelled bookings (#25644)
* refactor(data-table): clean up DateRangeFilter range options

- Replace 'past' | 'custom' with 'past' | 'future' | 'any' | 'customOnly'
- Add direction field to PresetOption for preset compatibility filtering
- Derive presets visibility automatically based on compatible presets
- Update bookings list to use new range values:
  - past -> 'past'
  - upcoming -> 'future'
  - unconfirmed/recurring/cancelled -> 'any'

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

* feat(playground): add DateRangeFilter playground page with E2E tests

- Add playground page at /settings/admin/playground/date-range-filter
- Demonstrate all 4 range options: past, future, any, customOnly
- Add link to playground index page
- Add E2E tests for presets visibility and date restrictions

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

* fix(playground): use correct meta.filter pattern for column filter config

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

* clean up the playground esign

* add unit tests instead of e2e

* fix the implementation

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-17 13:47:51 +00:00
Benny Joo dd27d077ca refactor: Create TravelScheduleRepository and PrismaOrgMembershipRepository in /features (#25853)
* move repo to travelSchedule

* update imports

* mv

* update imports
2025-12-17 10:24:44 -03:00
Carina WollendorferandCarinaWolli 5fe3714edd feat: auto skip consent screen for trusted oauth clients (#25640)
* auto constent for trusted clients

* add loading state

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-12-17 13:00:12 +00:00
Eunjae LeeandDevin AI e0a28af46e feat(bookings): conditionally show heading/subtitle based on view (#25889)
* feat(bookings): conditionally show heading/subtitle based on view

- Add heading and subtitle props back to ShellMainAppDir in page.tsx
- Use headerClassName marker to identify the heading element
- Add useLayoutEffect in BookingCalendarContainer to hide heading when calendar view is mounted
- Restore heading visibility when switching back to list view

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

* refactor(bookings): extract shell heading visibility into reusable hook

- Create useBookingsShellHeadingVisibility hook with explicit visible parameter
- Move visibility logic from BookingCalendarContainer to BookingsContent
- Use data attribute to track if we hid the header (idempotent/Strict-Mode-safe)
- Explicit show/hide based on view state instead of relying on unmount

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

* refactor(bookings): simplify shell heading visibility hook

Remove HIDDEN_DATA_ATTR tracking since the header wrapper won't have
a hidden class from any other source

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-17 12:46:12 +00:00
Kartik Labhshetwar 970af8bd6a fix: change button color to destructive in WorkflowListPage (#25965) 2025-12-17 12:42:00 +00:00
Dhairyashil Shinde 2f1dd92677 feat(companion): open cal.com/app on new tab and fix extension/mobile UX issues (#25962)
* feat(companion): improve extension UX for new tab and fix mobile app issues

- Open cal.com/app when extension clicked on restricted pages (chrome://newtab, etc.)
- Auto-open sidebar when redirected to cal.com/app with ?openExtension=true
- Fix duplicate search bar in event types on web/extension
- Fix tab order mismatch on extension (Event Types, Bookings, Availability, More)
- Add logout confirmation modal for web since Alert.alert doesn't work
- Default COMPANION_DEV_URL to empty string for production builds

* fix(companion): use URL API for query parameter cleanup

Replace regex-based URL cleanup with URL API to properly handle
all query parameter positions. The previous regex produced invalid
URLs when openExtension=true was the first of multiple parameters
(e.g., ?openExtension=true&foo=bar became &foo=bar instead of ?foo=bar). fix(companion): wait for page load before auto-opening sidebar

Fix race condition where sidebar wouldn't auto-open on first visit
to cal.com/app. On uncached pages, now waits for the load event
before opening, while cached pages use a shorter delay.
2025-12-17 17:14:37 +05:30
Udit Takkar 61c8c9d970 chore: pass guests (#25961) 2025-12-17 11:43:42 +00:00
Abhishek 0604c960a4 fix: popover ios issue (#25696)
* fix: popover ios issue

* add comments

* more comments
2025-12-17 10:14:56 +00:00
Eunjae Lee c361b9bbc5 chore: enable booking redesign for e2e (#25642)
* enable bookings-v3 feature flag for e2e tests WIP

* fix e2e

* update comments
2025-12-17 10:09:09 +00:00
Volnei Munhoz c2d275551a Remove next build eslint and ts checks disabled for CI (#25950) 2025-12-17 03:43:00 +00:00
Volnei Munhoz 0ea7f1944f fix types (#25952)
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-17 02:56:15 +00:00
Alex van Andel 1af7bfa20e chore: release v6.0.2 2025-12-17 02:38:43 +00:00
Volnei Munhoz 641b249e92 chore: release amd as latest (#25949)
* release amd as latest

* remove dockerfile change

* Apply suggestions from code review

changed from linux/amd64 to amd64

* Revert removed comment

* introduce latest as parameter

* introduce latest as parameter
2025-12-17 02:35:55 +00:00
Volnei Munhoz f3f5725563 chore/fix onboarding types (#25951) 2025-12-17 02:09:26 +00:00
Dhairyashil Shinde a2a77376b3 fix: companion extension oauth (#25939)
* fix: companion extension oauth

* fix: ios build

* address cubics comments
2025-12-16 22:22:44 +00:00
Hariom Balhara ee298af4eb fix: update email validation to include pm.me and other email provider domain (#25879)
* fix: update email validation to include pm.me domain and improve error handling in extractDomainFromEmail function

* feat(organizations): add pm.me and additional personal email domains

- Add pm.me (ProtonMail short domain) to personal email providers list
- Add googlemail.com, ymail.com, msn.com, mac.com, and other common personal email domains
- Add Mail.com variants (email.com, post.com, consultant.com, etc.)
- Add protonmail.ch, gmx.de, naver.com, and other regional providers
- Update all three locations: utils.ts, orgCreationUtils.ts, and test file
- Fix ESLint warnings for unnecessary escape characters in regex
2025-12-16 18:45:35 +00:00
Rakshit Sisodiya 47a5fc7bf7 fix: allow organizations to sign up with existing usernames (#25941)
* fix: allow organizations to sign up with existing usernames

- Update usernameCheck to check organization context when currentOrgDomain is provided
- Organizations can now use usernames that exist in other orgs or global namespace
- Only checks username availability within the specific organization
- Fixes issue where org signups were blocked by global username conflicts

Refs #25800

* Update username.ts
2025-12-16 17:47:59 +00:00
Volnei Munhoz a1a5f2477d fix check types (#25907) 2025-12-16 17:07:32 +00:00
sean-brydon 94e0d476d9 fix: skip plan on invite to team (#25924)
* fix: skip plan on invite to team

* address cubic
2025-12-16 14:44:35 +00:00
45fcabef8c chore: replace GH_ACCESS_TOKEN with GitHub App token (#25518)
* chore: replace GH_ACCESS_TOKEN with GitHub App token

Replace personal access token (GH_ACCESS_TOKEN) with GitHub App token
generation using actions/create-github-app-token@v1 for improved security.

Updated workflows:
- draft-release.yml
- post-release.yml
- cleanup.yml
- cleanup-report.yml
- publish-report.yml
- i18n.yml

Benefits:
- Short-lived tokens (1 hour) vs long-lived PATs
- Fine-grained, scoped permissions
- Not tied to individual user accounts
- Better audit trail

Required setup:
- Create a GitHub App with contents:write permission
- Install the app on calcom/cal.com and calcom/test-results-2
- Add RELEASE_APP_ID and RELEASE_APP_PRIVATE_KEY secrets

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

* chore: rename secrets to CI_CAL_APP_ID and CI_CAL_APP_PRIVATE_KEY

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

* chore: use reusable workflow from calcom-ci-secrets for token generation

This isolates the GitHub App private key in a separate repository,
ensuring that compromised actions in cal.com can only access short-lived
tokens, not the key itself.

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

* chore: use explicit secret passing instead of secrets: inherit

Only pass the two required secrets (CI_CAL_APP_ID, CI_CAL_APP_PRIVATE_KEY)
to the reusable workflow instead of inheriting all secrets.

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

* chore: remove secrets block - using environment secrets from calcom-ci-secrets

Secrets are now loaded from the ci-github-app environment in
calcom-ci-secrets, so cal.com workflows no longer need to pass them.

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

* chore: revert to explicit secret passing

Environment secrets in cross-repo reusable workflows don't work as expected.
Secrets must be passed explicitly from the caller repository.

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

* chore: generate GitHub App token directly in workflows

Remove dependency on calcom-ci-secrets reusable workflow.
Token generation is now inline using actions/create-github-app-token
pinned to SHA 7e473efe3cb98aa54f8d4bac15400b15fad77d94 (v2.2.0).

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-12-16 14:12:56 +00:00
Anik Dhabal Babu 593ed461c8 slugify lenient (#25923) 2025-12-16 14:01:54 +00:00
Morgan b8827aca52 fix: atoms css scoping (#25921)
* fix: atoms css scoping

* fixup! fix: atoms css scoping
2025-12-16 19:20:46 +05:30
Benny JooandVolnei Munhoz c3bcd623f6 refactor: create CreditsRepository in /features/credits (#25843)
* mv test

* mv repo

* update imports

* Update packages/features/ee/billing/api/webhook/_checkout.session.completed.ts

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

* fix test

---------

Co-authored-by: Volnei Munhoz <volnei@cal.com>
2025-12-16 10:42:27 -03:00
Kartik Labhshetwar 272cf43322 fix: add dark mode support to emptypage skeleton and layout (#25913)
* fix: add dark mode support to emptypage skeleton and layout

* style: remove dark blur effect from EmptyPage component

* style: add dark mode divide subtle to EmptyPage component
2025-12-16 12:45:23 +00:00
Anik Dhabal Babu a8e2ba4663 Revert "fix: improve E2E test stability and reduce flakiness (#25897)" (#25919)
This reverts commit b83f8dd6b8.
2025-12-16 12:30:50 +00:00
3082d6c2f3 feat: export all booking question fields in csv for non seated events in insights (#25837)
* feat(insights): include all booking question fields in CSV export for non-seated events

- For non-seated events: export all custom booking question fields (phone, text, number, select, multiselect, url, checkbox, textarea) excluding system fields
- For seated events: preserve existing behavior (only phone-type fields)
- Add extractFieldValue helper to handle all field value types
- Fix phone fallback logic to only use actual phone field values
- Optimize cache lookup with single get() instead of has() + get()

* refactor(insights): extract CSV data transformation logic into separate module

- Extract manipulation logic from getCsvData into csvDataTransformer.ts
- Add extractFieldValue, isSystemField, and other helper functions
- Add processBookingsForCsv and transformBookingsForCsv functions
- Add comprehensive snapshot tests with dummy data
- Tests demonstrate how input becomes output for various scenarios

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

* fix(insights): update BookingWithAttendees type to match Prisma query result

- Change noShow field type from boolean to boolean | null
- Fixes type error TS2345 in InsightsBookingBaseService.ts

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <hey@eunjae.dev>
2025-12-16 10:35:21 +00:00
Benny JooandDevin AI f98acb7301 refactor: migrate workflows utilities from trpc to features layer (#25534)
* refactor: migrate workflows utilities from trpc to features layer

Move workflow utility functions from packages/trpc/server/routers/viewer/workflows/util.ts
to packages/features/ee/workflows/lib/workflowUtils.ts to break circular dependencies.

Functions migrated:
- isAuthorized
- getAllWorkflowsFromEventType
- scheduleWorkflowNotifications
- scheduleBookingReminders

Changes:
- Created new workflowUtils.ts in features layer with migrated functions
- Added getBookingsForWorkflowReminders to WorkflowRepository (no prisma outside repositories)
- Updated all imports in features layer to use new location
- Updated trpc util.ts to re-export from features for backward compatibility
- Fixed pre-existing lint warning (any -> unknown) in handleMarkNoShow.ts

This is part of the effort to remove circular dependencies between
packages/features and packages/trpc.

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

* refactor: remove re-exports from trpc util.ts, update imports to use features layer

Per user request, removed the backward compatibility re-exports from
packages/trpc/server/routers/viewer/workflows/util.ts and updated all
imports in the trpc package to import directly from the features layer.

Files updated to import from @calcom/features/ee/workflows/lib/workflowUtils:
- confirm.handler.ts (getAllWorkflowsFromEventType)
- delete.handler.ts (isAuthorized)
- update.handler.ts (isAuthorized, scheduleWorkflowNotifications)
- getAllActiveWorkflows.handler.ts (getAllWorkflowsFromEventType)
- get.handler.ts (isAuthorized)
- util.test.ts (isAuthorized)
- delete.handler.test.ts (isAuthorized)

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

* fix: update test imports to use features layer for workflow utilities

Updated test files to import scheduleBookingReminders and
scheduleWorkflowNotifications from @calcom/features/ee/workflows/lib/workflowUtils
instead of @calcom/trpc/server/routers/viewer/workflows/util.

Files updated:
- packages/features/ee/workflows/lib/test/workflows.test.ts
- packages/features/tasker/tasks/scanWorkflowBody.test.ts

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

* refactor: split workflowUtils.ts into individual files

Split the monolithic workflowUtils.ts into separate files for each function:
- isAuthorized.ts - Authorization check for workflow access
- getAllWorkflowsFromEventType.ts - Get workflows for an event type
- scheduleWorkflowNotifications.ts - Schedule workflow notifications
- scheduleBookingReminders.ts - Schedule booking reminders

The workflowUtils.ts now re-exports from these individual files for
backward compatibility.

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

* fix import

* fix more

* wip

* wip

* remove workflowUtils

* wip

* refactor deleteRemindersOfActiveOnIds

* fix

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-16 10:17:46 +00:00
Anik Dhabal Babu 4e3ce77de4 fix: custom replyTo is not set (#25867) 2025-12-16 04:20:03 +00:00
InD3v 0b5b76d594 fix: adjust padding in timezone select, event advanced edit icon, and sidebar bottom nav (#25877) 2025-12-16 01:11:10 +00:00
Pallav b6935666e5 fix: disable button routing (#25894) 2025-12-16 01:10:37 +00:00
Alex van Andel b25a2bf202 chore: release v6.0.1
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-16 01:09:06 +00:00
Abhishek 06d4c7d102 fix lint (#25870) 2025-12-16 01:08:05 +00:00
Dhairyashil Shinde 5b9dd09f0d feat(holidays): improve UI/UX and add contextual emojis (#25895)
* refactor(holidays): improve code quality and address PR review feedback

- Move HolidaysView.tsx from /features to /apps/web/modules following
  the pattern of keeping trpc-using components in /apps/web/modules
- Fix prisma import to use named import: `import { prisma }`
- Fix timezone bug in checkConflicts: use dayjs.utc() for consistent
  date boundaries regardless of server timezone
- Fix toggleHoliday validation to include both current and next year
  holidays, matching the holidays displayed to users
- Implement dependency injection pattern for holiday repository:
  - Create PrismaHolidayRepository with instance methods
  - Add HOLIDAY_REPOSITORY DI token and module
  - Update containers to load holiday repository module
  - Update calculateHolidayBlockedDates to use injected repository
- Remove stale HOLIDAY_CACHE_DAYS env declaration (now a constant)
- Update tests to mock repository instead of prisma directly

* feat(holidays): improve UI responsiveness and add country flags

- Add country flag emojis to holidays dropdown using Unicode regional
  indicator symbols
- Handle edge cases for religious holidays (Hindu, Christian, etc.)
  which dont have 2-letter country codes
- Increase country dropdown width to 180px for better readability
- Make OOO/Holidays tabs responsive: use Select dropdown on mobile,
  ToggleGroup on desktop
- Make + Add button responsive: show only + icon on mobile,
  full text on desktop
- Fix PrismaHolidayRepository import to use @calcom/prisma for
  consistency with other repositories

* feat(holidays): add contextual emojis for holidays

- Add keyword-based emoji mapping for 80+ holidays
- Display holiday emojis in styled containers on settings page
- Add country flag emojis to dropdown using Unicode regional indicators
- Use dynamic emojis on booking page unavailable dates

* fix(holidays): address PR review feedback

- Fix emoji ordering: move Chinese/Lunar New Year before generic new year to prevent incorrect match
- Fix i18n: use t() instead of hardcoded text more in conflict warning
- Fix a11y: use sr-only pattern for mobile button to maintain screen reader accessibility

* fix failing test: packages/features/availability/lib/calculateHolidayBlockedDates.test.ts

* fix failing test: packages/features/availability/lib/calculateHolidayBlockedDates.test.ts

* fix failing tests and builds
2025-12-16 00:40:04 +00:00
Alex van Andel db2caa17ce fix: Allow single-label domains and missing dots (#25899) 2025-12-16 05:10:22 +05:30
b83f8dd6b8 fix: improve E2E test stability and reduce flakiness (#25897)
* fix: improve E2E test stability and reduce flakiness

- Replace hardcoded waits with proper Playwright waitFor assertions in slot selection
- Add explicit waits for calendar and time slot elements to be visible before clicking
- Replace fixed 2s wait in gotoRoutingLink with networkidle wait
- Replace fixed 5s email wait with retry logic (10 retries, 500ms intervals)
- Use unique usernames with timestamps to avoid parallel test collisions
- Use features fixture instead of direct prisma calls for feature flag mutations
- Fix login.e2e.ts to use unique username instead of hardcoded 'pro'
- Fix signup.e2e.ts to use unique usernames and proper feature flag handling
- Remove unused 'users' parameter from tests that don't need it

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

* perf: replace waitForTimeout with smart waits for faster E2E tests

- Replace 30 waitForTimeout calls with proper Playwright waits
- Use waitFor({ state: 'visible' }) for element visibility
- Use waitForLoadState('networkidle') for page load completion
- Use waitForFunction for localStorage state changes

Performance improvements:
- limit-tab.e2e.ts: 10s fixed wait -> element visibility wait
- booking-duplicate-api-calls.e2e.ts: 5s fixed wait -> networkidle
- change-theme.e2e.ts: 3s fixed wait -> localStorage state check
- team-invitation.e2e.ts: multiple 500ms-3s waits -> element waits
- booking-seats.e2e.ts: 2s waits -> dropdown visibility waits
- embed-code-generator.e2e.ts: 1s waits -> iframe visibility waits
- organization-privacy.e2e.ts: 500ms waits -> element/networkidle waits
- organization-invitation.e2e.ts: 500ms-1s waits -> element waits
- analyticsApps.e2e.ts: 1s wait -> networkidle
- integrations.e2e.ts: 1s wait -> calendar element wait
- fixtures/apps.ts: 1s waits -> element visibility waits

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-15 20:19:47 -03:00
Beto 883ac103f8 fix: android redirect (#25888)
* fix android redirect

* remove logs
2025-12-15 18:14:42 +00:00
Volnei Munhoz d13adc8ffd Fix user parameter (#25884) 2025-12-15 18:07:28 +00:00
Kartik Labhshetwar d64255be47 fix: change sidebar hover background to subtle when locale ready (#25876) 2025-12-15 17:50:03 +00:00
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
894 changed files with 51245 additions and 28363 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@calcom/atoms": minor
---
feat: Booker accepts rrHostSubsetIds to specify round robin hosts
+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
@@ -34,6 +34,10 @@ inputs:
description: "Whether to push the built image"
required: false
default: "false"
use-as-latest:
description: "Publish as latest"
required: false
default: "false"
runs:
using: "composite"
@@ -61,7 +65,7 @@ runs:
docker.io/calcom/cal.com
ghcr.io/calcom/cal.com
flavor: |
latest=${{ !github.event.release.prerelease }}
latest=${{ !github.event.release.prerelease && inputs.use-as-latest == 'true' }}
suffix=${{ inputs.platform-suffix }}
- name: Copy env
+1 -1
View File
@@ -37,5 +37,5 @@ jobs:
export NODE_OPTIONS="--max_old_space_size=8192"
rm -rf packages/platform/atoms/node_modules
yarn install
yarn workspace @calcom/atoms run build
yarn workspace @calcom/atoms run build-npm
shell: bash
+2
View File
@@ -3,6 +3,8 @@ on:
workflow_call:
env:
NODE_OPTIONS: --max-old-space-size=4096
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
permissions:
contents: read
jobs:
+9 -1
View File
@@ -11,12 +11,20 @@ jobs:
cleanup-report:
runs-on: ubuntu-latest
steps:
- name: Generate GitHub App token
id: generate-token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94
with:
app-id: ${{ secrets.CI_CAL_APP_ID }}
private-key: ${{ secrets.CI_CAL_APP_PRIVATE_KEY }}
repositories: 'test-results-2'
- name: Checkout GitHub Pages Branch
uses: actions/checkout@v4
with:
repository: calcom/test-results-2
ref: gh-pages
token: ${{ secrets.GH_ACCESS_TOKEN }}
token: ${{ steps.generate-token.outputs.token }}
- name: Set Git User
run: |
+10 -2
View File
@@ -22,12 +22,20 @@ jobs:
# Contains all reports for deleted branch
BRANCH_REPORTS_DIR: reports/${{ github.event.ref }}
steps:
- name: Generate GitHub App token
id: generate-token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94
with:
app-id: ${{ secrets.CI_CAL_APP_ID }}
private-key: ${{ secrets.CI_CAL_APP_PRIVATE_KEY }}
repositories: 'test-results-2'
- name: Checkout GitHub Pages Branch
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
repository: calcom/test-results-2
ref: gh-pages
token: ${{ secrets.GH_ACCESS_TOKEN }}
token: ${{ steps.generate-token.outputs.token }}
- name: Set Git User
# see: https://github.com/actions/checkout/issues/13#issuecomment-724415212
run: |
+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
+9 -2
View File
@@ -17,13 +17,20 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Generate GitHub App token
id: generate-token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94
with:
app-id: ${{ secrets.CI_CAL_APP_ID }}
private-key: ${{ secrets.CI_CAL_APP_PRIVATE_KEY }}
repositories: 'cal.com'
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: 'main'
token: ${{ secrets.GH_ACCESS_TOKEN }}
token: ${{ steps.generate-token.outputs.token }}
- name: Configure git
run: |
+9 -1
View File
@@ -16,6 +16,14 @@ jobs:
contents: write
pull-requests: write
steps:
- name: Generate GitHub App token
id: generate-token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94
with:
app-id: ${{ secrets.CI_CAL_APP_ID }}
private-key: ${{ secrets.CI_CAL_APP_PRIVATE_KEY }}
repositories: 'cal.com'
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
@@ -23,7 +31,7 @@ jobs:
cache: 'npm'
- uses: lingodotdev/lingo.dev@main
env:
GH_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }}
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
with:
api-key: ${{ secrets.CI_LINGO_DOT_DEV_API_KEY }}
pull-request: true
-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
+12 -1
View File
@@ -6,16 +6,27 @@ on:
tags:
- "*"
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Generate GitHub App token
id: generate-token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94
with:
app-id: ${{ secrets.CI_CAL_APP_ID }}
private-key: ${{ secrets.CI_CAL_APP_PRIVATE_KEY }}
repositories: 'cal.com'
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: 'main'
token: ${{ secrets.GH_ACCESS_TOKEN }}
token: ${{ steps.generate-token.outputs.token }}
- name: Configure git
run: |
+31 -5
View File
@@ -24,6 +24,7 @@ jobs:
pull-requests: read
outputs:
has-files-requiring-all-checks: ${{ steps.filter.outputs.has-files-requiring-all-checks }}
has_companion: ${{ steps.filter.outputs.has_companion }}
commit-sha: ${{ steps.get_sha.outputs.commit-sha }}
steps:
- uses: actions/checkout@v4
@@ -33,7 +34,9 @@ jobs:
with:
filters: |
has-files-requiring-all-checks:
- "!(**.md|**.mdx|.github/CODEOWNERS|docs/**|help/**|apps/web/public/static/locales/**/common.json|i18n.lock)"
- "!(companion/**|**.md|**.mdx|.github/CODEOWNERS|docs/**|help/**|apps/web/public/static/locales/**/common.json|i18n.lock)"
has_companion:
- "companion/**"
- name: Get Latest Commit SHA
id: get_sha
run: |
@@ -148,8 +151,8 @@ jobs:
build-companion:
name: Companion builds
needs: [changes, check-label, deps]
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
needs: [changes, check-label]
if: needs.changes.outputs.has_companion == 'true'
uses: ./.github/workflows/companion-build.yml
secrets: inherit
@@ -256,11 +259,34 @@ jobs:
e2e-api-v2,
e2e-embed,
e2e-embed-react,
e2e-app-store
e2e-app-store,
]
if: always()
runs-on: buildjet-2vcpu-ubuntu-2204
steps:
- name: fail if conditional jobs failed
if: needs.changes.outputs.has-files-requiring-all-checks == 'true' && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled'))
run: exit 1
if: |
(
needs.changes.outputs.has-files-requiring-all-checks == 'true' &&
(
needs.lint.result != 'success' ||
needs.type-check.result != 'success' ||
needs.unit-test.result != 'success' ||
needs.build.result != 'success' ||
needs.build-api-v1.result != 'success' ||
needs.build-api-v2.result != 'success' ||
needs.build-atoms.result != 'success' ||
needs.build-docs.result != 'success' ||
needs.integration-test.result != 'success' ||
needs.e2e.result != 'success' ||
needs.e2e-api-v2.result != 'success' ||
needs.e2e-embed.result != 'success' ||
needs.e2e-embed-react.result != 'success' ||
needs.e2e-app-store.result != 'success'
)
) ||
(
needs.changes.outputs.has_companion == 'true' &&
needs.build-companion.result != 'success'
)
+9 -1
View File
@@ -13,12 +13,20 @@ jobs:
HTML_REPORT_URL_PATH: reports/${{ github.head_ref }}/${{ github.run_id }}/${{ github.run_attempt }}
BRANCH_REPORTS_DIR: reports/${{ github.head_ref }}
steps:
- name: Generate GitHub App token
id: generate-token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94
with:
app-id: ${{ secrets.CI_CAL_APP_ID }}
private-key: ${{ secrets.CI_CAL_APP_PRIVATE_KEY }}
repositories: 'test-results-2'
- name: Checkout GitHub Pages Branch
uses: actions/checkout@v4
with:
repository: calcom/test-results-2
ref: gh-pages
token: ${{ secrets.GH_ACCESS_TOKEN }}
token: ${{ steps.generate-token.outputs.token }}
- name: Set Git User
# see: https://github.com/actions/checkout/issues/13#issuecomment-724415212
run: |
+1
View File
@@ -40,6 +40,7 @@ jobs:
postgres-db: ${{ env.POSTGRES_DB }}
database-host: ${{ env.DATABASE_HOST }}
push-image: "true"
use-as-latest: "true"
- name: Notify Slack on Success
if: success()
+9
View File
@@ -45,11 +45,20 @@ 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)
+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 \
+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)
+5 -5
View File
@@ -11,9 +11,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ILicenseKeyService } from "@calcom/ee/common/server/LicenseKeyService";
import LicenseKeyService, { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
import { PrismaApiKeyRepository } from "@calcom/lib/server/repository/PrismaApiKeyRepository";
import type { IDeploymentRepository } from "@calcom/lib/server/repository/deployment.interface";
import { ApiKeyService } from "@calcom/lib/server/service/ApiKeyService";
import { PrismaApiKeyRepository } from "@calcom/features/ee/api-keys/repositories/PrismaApiKeyRepository";
import { ApiKeyService } from "@calcom/features/ee/api-keys/services/ApiKeyService";
import type { IDeploymentRepository } from "@calcom/features/ee/deployment/repositories/IDeploymentRepository";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { isAdminGuard } from "../utils/isAdmin";
@@ -21,11 +21,11 @@ import { isLockedOrBlocked } from "../utils/isLockedOrBlocked";
import { ScopeOfAdmin } from "../utils/scopeOfAdmin";
import { verifyApiKey } from "./verifyApiKey";
vi.mock("@calcom/lib/server/service/ApiKeyService", () => ({
vi.mock("@calcom/features/ee/api-keys/services/ApiKeyService", () => ({
ApiKeyService: vi.fn(),
}));
vi.mock("@calcom/lib/server/repository/PrismaApiKeyRepository", () => ({
vi.mock("@calcom/features/ee/api-keys/repositories/PrismaApiKeyRepository", () => ({
PrismaApiKeyRepository: vi.fn(),
}));
+3 -3
View File
@@ -2,10 +2,10 @@ import type { NextMiddleware } from "next-api-middleware";
import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
import { PrismaApiKeyRepository } from "@calcom/features/ee/api-keys/repositories/PrismaApiKeyRepository";
import { ApiKeyService } from "@calcom/features/ee/api-keys/services/ApiKeyService";
import { DeploymentRepository } from "@calcom/features/ee/deployment/repositories/DeploymentRepository";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { PrismaApiKeyRepository } from "@calcom/lib/server/repository/PrismaApiKeyRepository";
import { DeploymentRepository } from "@calcom/lib/server/repository/deployment";
import { ApiKeyService } from "@calcom/lib/server/service/ApiKeyService";
import { prisma } from "@calcom/prisma";
import { isAdminGuard } from "../utils/isAdmin";
+4
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import { WEBHOOK_TRIGGER_EVENTS } from "@calcom/features/webhooks/lib/constants";
import { WebhookVersion } from "@calcom/features/webhooks/lib/interface/IWebhookRepository";
import { WebhookSchema } from "@calcom/prisma/zod/modelSchema/WebhookSchema";
const schemaWebhookBaseBodyParams = WebhookSchema.pick({
@@ -21,6 +22,7 @@ export const schemaWebhookCreateParams = z
eventTypeId: z.number().optional(),
userId: z.number().optional(),
secret: z.string().optional().nullable(),
version: z.nativeEnum(WebhookVersion).optional(),
// API shouldn't mess with Apps webhooks yet (ie. Zapier)
// appId: z.string().optional().nullable(),
})
@@ -33,6 +35,7 @@ export const schemaWebhookEditBodyParams = schemaWebhookBaseBodyParams
z.object({
eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array().optional(),
secret: z.string().optional().nullable(),
version: z.nativeEnum(WebhookVersion).optional(),
})
)
.partial()
@@ -44,6 +47,7 @@ export const schemaWebhookReadPublic = WebhookSchema.pick({
eventTypeId: true,
payloadTemplate: true,
eventTriggers: true,
version: true,
// FIXME: We have some invalid urls saved in the DB
// subscriberUrl: true,
/** @todo: find out how to properly add back and validate those. */
+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.9",
"next": "15.4.10",
"next-api-middleware": "^1.0.1",
"next-axiom": "^0.17.0",
"next-swagger-doc": "^0.3.6",
+1 -1
View File
@@ -161,7 +161,7 @@ vi.mock("@calcom/features/ee/workflows/lib/getAllWorkflows", () => ({
workflowSelect: {},
}));
vi.mock("@calcom/trpc/server/routers/viewer/workflows/util", () => ({
vi.mock("@calcom/features/ee/workflows/lib/getAllWorkflowsFromEventType", () => ({
getAllWorkflowsFromEventType: vi.fn().mockResolvedValue([]),
}));
@@ -27,6 +27,7 @@ import {
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, BadRequestException } from "@nestjs/common";
import { slugifyLenient } 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 = slugifyLenient(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);
@@ -243,6 +249,7 @@ export class InputEventTypesService_2024_06_14 {
const eventType = {
...rest,
...(slug ? { slug: slugifyLenient(slug) } : {}),
length: lengthInMinutes,
locations: locations ? this.transformInputLocations(locations) : undefined,
bookingFields: effectiveBookingFields
@@ -1,6 +1,7 @@
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
import { PrismaHolidayRepository } from "@/lib/repositories/prisma-holiday.repository";
import { PrismaMembershipRepository } from "@/lib/repositories/prisma-membership.repository";
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository";
@@ -23,6 +24,7 @@ import { Module } from "@nestjs/common";
imports: [PrismaWorkerModule],
providers: [
PrismaOOORepository,
PrismaHolidayRepository,
PrismaScheduleRepository,
PrismaBookingRepository,
PrismaSelectedSlotRepository,
@@ -0,0 +1,13 @@
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { PrismaHolidayRepository as PrismaHolidayRepositoryLib } from "@calcom/platform-libraries/repositories";
import type { PrismaClient } from "@calcom/prisma";
@Injectable()
export class PrismaHolidayRepository extends PrismaHolidayRepositoryLib {
constructor(private readonly dbWrite: PrismaWriteService) {
super(dbWrite.prisma as unknown as PrismaClient);
}
}
@@ -1,14 +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";
import { BookingAuditTaskerProducerService, getAuditActorRepository } from "@calcom/platform-libraries/bookings";
@Injectable()
export class BookingAuditProducerService extends BookingAuditTaskerProducerService {
constructor(taskerService: TaskerService) {
constructor(taskerService: TaskerService, bridgeLogger: Logger) {
super({
tasker: taskerService.getTasker(),
log: bridgeLogger,
auditActorRepository: getAuditActorRepository(),
});
}
}
@@ -1,5 +1,6 @@
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
import { PrismaHolidayRepository } from "@/lib/repositories/prisma-holiday.repository";
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable } from "@nestjs/common";
@@ -12,13 +13,15 @@ export class UserAvailabilityService extends BaseUserAvailabilityService {
oooRepoDependency: PrismaOOORepository,
bookingRepository: PrismaBookingRepository,
eventTypeRepository: PrismaEventTypeRepository,
redisService: RedisService
redisService: RedisService,
holidayRepository: PrismaHolidayRepository
) {
super({
oooRepo: oooRepoDependency,
bookingRepo: bookingRepository,
eventTypeRepo: eventTypeRepository,
redisClient: redisService,
holidayRepo: holidayRepository,
});
}
}
@@ -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 { slugifyLenient } 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: slugifyLenient(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: slugifyLenient(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,
@@ -112,7 +112,7 @@ describe("Organizations Roles Endpoints", () => {
});
await featuresRepositoryFixture.create({ slug: "pbac", enabled: true });
await featuresRepositoryFixture.enableFeatureForTeam(pbacEnabledOrganization.id, "pbac");
await featuresRepositoryFixture.setTeamFeatureState(pbacEnabledOrganization.id, "pbac", "enabled");
// Create memberships
await membershipRepositoryFixture.create({
@@ -58,7 +58,7 @@ describe("Organizations Roles Permissions Endpoints", () => {
});
await featuresRepositoryFixture.create({ slug: "pbac", enabled: true });
await featuresRepositoryFixture.enableFeatureForTeam(pbacEnabledOrganization.id, "pbac");
await featuresRepositoryFixture.setTeamFeatureState(pbacEnabledOrganization.id, "pbac", "enabled");
// Create user + membership in org
pbacOrgUserWithRolePermission = await userRepositoryFixture.create({
@@ -126,7 +126,7 @@ describe("Organizations Roles Endpoints", () => {
});
await featuresRepositoryFixture.create({ slug: "pbac", enabled: true });
await featuresRepositoryFixture.enableFeatureForTeam(pbacEnabledTeam.id, "pbac");
await featuresRepositoryFixture.setTeamFeatureState(pbacEnabledTeam.id, "pbac", "enabled");
// Create memberships
await membershipRepositoryFixture.create({
@@ -67,7 +67,7 @@ describe("Organizations Teams Roles Permissions Endpoints", () => {
});
await featuresRepositoryFixture.create({ slug: "pbac", enabled: true });
await featuresRepositoryFixture.enableFeatureForTeam(pbacEnabledTeam.id, "pbac");
await featuresRepositoryFixture.setTeamFeatureState(pbacEnabledTeam.id, "pbac", "enabled");
// Create user + membership in org
pbacOrgUserWithRolePermission = await userRepositoryFixture.create({
@@ -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'
@@ -1,7 +1,7 @@
import { ApiProperty, ApiPropertyOptional, PartialType } from "@nestjs/swagger";
import { IsArray, IsBoolean, IsEnum, IsOptional, IsString } from "class-validator";
import { WebhookTriggerEvents } from "@calcom/platform-libraries";
import { WebhookTriggerEvents, WebhookVersion } from "@calcom/platform-libraries";
export class CreateWebhookInputDto {
@IsString()
@@ -48,6 +48,15 @@ export class CreateWebhookInputDto {
@IsOptional()
@ApiPropertyOptional()
secret?: string;
@IsOptional()
@IsEnum(WebhookVersion)
@ApiPropertyOptional({
description: "The version of the webhook",
example: WebhookVersion.V_2021_10_20,
enum: WebhookVersion,
})
version?: WebhookVersion;
}
export class UpdateWebhookInputDto extends PartialType(CreateWebhookInputDto) {}
@@ -28,21 +28,38 @@ export class FeaturesRepositoryFixture {
});
}
async enableFeatureForTeam(teamId: number, featureId: string, assignedBy = "test") {
await this.prismaWriteClient.teamFeatures.upsert({
where: {
teamId_featureId: {
async setTeamFeatureState(
teamId: number,
featureId: string,
state: "enabled" | "disabled" | "inherit",
assignedBy = "test"
) {
if (state === "enabled" || state === "disabled") {
await this.prismaWriteClient.teamFeatures.upsert({
where: {
teamId_featureId: {
teamId,
featureId,
},
},
create: {
teamId,
featureId,
assignedBy,
enabled: state === "enabled",
},
update: {
enabled: state === "enabled",
},
});
} else if (state === "inherit") {
await this.prismaWriteClient.teamFeatures.deleteMany({
where: {
teamId,
featureId,
},
},
create: {
teamId,
featureId,
assignedBy,
},
update: {},
});
});
}
}
async disableFeatureForTeam(teamId: number, featureSlug: string) {
@@ -13,6 +13,13 @@ import {
type PageProps as ClientPageProps,
} from "~/bookings/views/bookings-single-view.getServerSideProps";
export const metadata = {
robots: {
index: false,
follow: false,
},
};
const getEmbedData = withEmbedSsrAppDir<ClientPageProps>(getServerSideProps);
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
@@ -16,13 +16,15 @@ import {
type PageProps as ClientPageProps,
} from "~/bookings/views/bookings-single-view.getServerSideProps";
const getData = withAppDirSsr<ClientPageProps>(getServerSideProps);
export const generateMetadata = async ({ params, searchParams }: _PageProps) => {
const { bookingInfo, eventType, recurringBookings, orgSlug } = await getData(
buildLegacyCtx(await headers(), await cookies(), await params, await searchParams)
);
const needsConfirmation = bookingInfo.status === BookingStatus.PENDING && eventType.requiresConfirmation;
return await _generateMetadata(
const metadata = await _generateMetadata(
(t) =>
t(`booking_${needsConfirmation ? "submitted" : "confirmed"}${recurringBookings ? "_recurring" : ""}`),
(t) =>
@@ -31,9 +33,15 @@ export const generateMetadata = async ({ params, searchParams }: _PageProps) =>
getOrgFullOrigin(orgSlug),
`/booking/${(await params).uid}`
);
};
const getData = withAppDirSsr<ClientPageProps>(getServerSideProps);
return {
...metadata,
robots: {
index: false,
follow: false,
},
};
};
const ServerPage = async ({ params, searchParams }: _PageProps) => {
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
@@ -32,11 +32,7 @@ const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
const eventLocale = props.eventData?.interfaceLanguage;
const ns = "common";
let translations;
if (eventLocale) {
const ns = "common";
translations = await loadTranslations(eventLocale, ns);
}
const translations = await loadTranslations(eventLocale ?? "en", ns);
if ((props as TeamTypePageProps)?.teamId) {
return eventLocale ? (
@@ -68,11 +68,7 @@ const ServerPage = async ({ params, searchParams }: PageProps) => {
const eventLocale = props.eventData?.interfaceLanguage;
const ns = "common";
let translations;
if (eventLocale) {
const ns = "common";
translations = await loadTranslations(eventLocale, ns);
}
const translations = await loadTranslations(eventLocale ?? "en", ns);
if ((props as TeamTypePageProps)?.teamId) {
return eventLocale ? (
@@ -1,6 +1,6 @@
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { _generateMetadata, getTranslate } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { z } from "zod";
@@ -34,6 +34,7 @@ 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;
@@ -53,15 +54,16 @@ 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>
<ShellMainAppDir
heading={t("bookings")}
subtitle={t("bookings_description")}
headerClassName="bookings-shell-heading">
<BookingsList
status={parsed.data.status}
userId={session?.user?.id}
@@ -19,6 +19,7 @@ import useGetBrandingColours from "@calcom/lib/getBrandColours";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
import { markdownToSafeHTMLClient } from "@calcom/lib/markdownToSafeHTMLClient";
import { navigateInTopWindow } from "@calcom/lib/navigateInTopWindow";
import { trpc } from "@calcom/trpc/react";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
@@ -208,7 +209,10 @@ function RoutingForm({ form, profile, ...restProps }: Props) {
<div className="mx-auto my-0 max-w-3xl md:my-24">
<div className="w-full max-w-4xl ltr:mr-2 rtl:ml-2">
<div className="main sm:border-subtle bg-default -mx-4 rounded-md border border-neutral-200 p-4 py-6 sm:mx-0 sm:px-8">
<div className="text-emphasis">{customPageMessage}</div>
<div
className="text-emphasis prose prose-sm dark:prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: markdownToSafeHTMLClient(customPageMessage) }}
/>
</div>
</div>
</div>
@@ -0,0 +1,150 @@
"use client";
import { useReactTable, getCoreRowModel, createColumnHelper } from "@tanstack/react-table";
import { useMemo } from "react";
import { ColumnFilterType, DateRangeFilter, DataTableProvider } from "@calcom/features/data-table";
import type { DateRangeFilterOptions } from "@calcom/features/data-table/lib/types";
type DemoRow = {
id: number;
name: string;
date: string;
};
const columnHelper = createColumnHelper<DemoRow>();
type ScenarioProps = {
id: string;
title: string;
description: string;
expected: string;
range: DateRangeFilterOptions["range"];
};
const scenarios: ScenarioProps[] = [
{
id: "past",
title: 'Range: "past"',
description: "Restricts date selection to past dates only. Shows presets that are past-compatible.",
expected:
"Presets visible: Today, Last 7 days, Last 30 days, Month to date, Year to date, Custom. Calendar maxDate = today.",
range: "past",
},
{
id: "future",
title: 'Range: "future"',
description: "Restricts date selection to future dates only. Shows only future-compatible presets.",
expected: "Presets visible: Custom only (presets with direction 'any'). Calendar minDate = today.",
range: "future",
},
{
id: "any",
title: 'Range: "any"',
description: "No date restrictions. Shows all presets.",
expected: "All presets visible. No calendar date restrictions.",
range: "any",
},
{
id: "customOnly",
title: 'Range: "customOnly"',
description: "Forces custom date picker only. Always hides presets dropdown.",
expected: "No presets dropdown. Only calendar picker visible when opened. No date restrictions.",
range: "customOnly",
},
];
function ScenarioCard({ scenario }: { scenario: ScenarioProps }) {
const columns = useMemo(
() => [
columnHelper.accessor("name", {
header: "Name",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("date", {
id: "dateRange",
header: "Date Range",
cell: (info) => info.getValue(),
enableColumnFilter: true,
meta: {
filter: {
type: ColumnFilterType.DATE_RANGE,
dateRangeOptions: {
range: scenario.range,
},
},
},
}),
],
[scenario.range]
);
const data = useMemo<DemoRow[]>(
() => [
{ id: 1, name: "Demo Item 1", date: "2024-01-15" },
{ id: 2, name: "Demo Item 2", date: "2024-02-20" },
],
[]
);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
// Get the column definition to pass to DateRangeFilter
const dateRangeColumn = table.getAllColumns().find((col) => col.id === "dateRange");
const columnMeta = dateRangeColumn?.columnDef.meta as
| { filter?: { type: string; dateRangeOptions?: DateRangeFilterOptions } }
| undefined;
const dateRangeOptions = columnMeta?.filter?.dateRangeOptions;
return (
<div className="border-subtle mb-8 rounded-lg border p-6" data-testid={`drf-scenario-${scenario.id}`}>
<h3 className="text-emphasis mb-2 text-lg font-semibold">{scenario.title}</h3>
<p className="text-default mb-2 text-sm">{scenario.description}</p>
<p className="text-subtle mb-4 text-xs">
<strong>Expected:</strong> {scenario.expected}
</p>
<div className="mt-4">
<DataTableProvider tableIdentifier={`playground-date-range-${scenario.id}`}>
{dateRangeColumn && (
<DateRangeFilter
column={{
id: dateRangeColumn.id,
title: dateRangeColumn.columnDef.header as string,
type: "dr",
}}
options={dateRangeOptions}
showColumnName={false}
showClearButton={false}
/>
)}
</DataTableProvider>
</div>
</div>
);
}
export default function DateRangeFilterPlayground() {
return (
<div className="space-y-6">
<div>
<h1 className="text-emphasis text-2xl font-bold">DateRangeFilter Playground</h1>
<p className="text-default mt-2">
This page demonstrates the different <code>range</code> options for the DateRangeFilter component.
</p>
<p className="text-subtle mt-1 text-sm">
The <code>range</code> option controls both date restrictions and presets visibility. Presets
visibility is derived automatically based on compatible presets.
</p>
</div>
{scenarios.map((scenario) => (
<ScenarioCard key={scenario.id} scenario={scenario} />
))}
</div>
);
}
@@ -2,19 +2,32 @@ import { _generateMetadata, getTranslate } from "app/_utils";
import Link from "next/link";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { Icon } from "@calcom/ui/components/icon";
const LINKS = [
{
title: "Routing Funnel",
description: "Visualize booking conversion flow and routing patterns",
href: "/settings/admin/playground/routing-funnel",
icon: "filter" as const,
},
{
title: "Bookings by Hour",
description: "View booking distribution across different hours",
href: "/settings/admin/playground/bookings-by-hour",
icon: "chart-bar" as const,
},
{
title: "Weekly Calendar",
description: "Interactive weekly calendar view for scheduling",
href: "/settings/admin/playground/weekly-calendar",
icon: "calendar" as const,
},
{
title: "Date Range Filter",
description: "Test date range selection and filtering components",
href: "/settings/admin/playground/date-range-filter",
icon: "calendar-days" as const,
},
];
@@ -31,16 +44,27 @@ const Page = async () => {
const t = await getTranslate();
return (
<SettingsHeader title={t("playground")} description={t("admin_playground_description")}>
<div>
<ul className="mt-8">
<div className="mt-6">
<div className="bg-default border-subtle divide-subtle flex flex-col divide-y rounded-md border">
{LINKS.map((link) => (
<li key={link.title}>
<Link href={link.href} className="list-item list-disc font-medium underline">
{link.title} →
</Link>
</li>
<Link
key={link.title}
href={link.href}
className="hover:bg-muted group flex items-center gap-4 p-5 transition-colors">
<div className="bg-emphasis/10 group-hover:bg-emphasis/20 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md transition-colors">
<Icon name={link.icon} className="text-emphasis h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-emphasis text-sm font-semibold leading-none">{link.title}</h3>
<p className="text-subtle mt-2 text-sm">{link.description}</p>
</div>
<Icon
name="arrow-right"
className="text-subtle group-hover:text-emphasis h-4 w-4 flex-shrink-0 transition-colors"
/>
</Link>
))}
</ul>
</div>
</div>
</SettingsHeader>
);
@@ -6,7 +6,7 @@ import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequir
import { UsersEditView } from "@calcom/features/ee/users/pages/users-edit-view";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import prisma from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
const userIdSchema = z.object({ id: z.coerce.number() });
@@ -41,6 +41,7 @@ const Page = async ({ params }: { params: Params }) => {
const userRepo = new UserRepository(prisma);
const user = await userRepo.adminFindById(input.data.id);
const t = await getTranslate();
return (
@@ -4,8 +4,8 @@ import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { PrismaApiKeyRepository } from "@calcom/features/ee/api-keys/repositories/PrismaApiKeyRepository";
import { APP_NAME } from "@calcom/lib/constants";
import { PrismaApiKeyRepository } from "@calcom/lib/server/repository/PrismaApiKeyRepository";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -1,7 +1,6 @@
import type { PageProps } from "app/_types";
import { _generateMetadata, getTranslate } from "app/_utils";
import { _generateMetadata } from "app/_utils";
import SettingsHeaderWithBackButton from "@calcom/features/settings/appDir/SettingsHeaderWithBackButton";
import { WebhookRepository } from "@calcom/features/webhooks/lib/repository/WebhookRepository";
import { EditWebhookView } from "@calcom/features/webhooks/pages/webhook-edit-view";
import { APP_NAME } from "@calcom/lib/constants";
@@ -16,21 +15,13 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
);
const Page = async ({ params: _params }: PageProps) => {
const t = await getTranslate();
const params = await _params;
const id = typeof params?.id === "string" ? params.id : undefined;
const webhookRepository = WebhookRepository.getInstance();
const webhook = await webhookRepository.findByWebhookId(id);
return (
<SettingsHeaderWithBackButton
title={t("edit_webhook")}
description={t("add_webhook_description", { appName: APP_NAME })}
borderInShellHeader={true}>
<EditWebhookView webhook={webhook} />
</SettingsHeaderWithBackButton>
);
return <EditWebhookView webhook={webhook} />;
};
export default Page;
@@ -20,7 +20,7 @@ import SettingsLayoutAppDirClient from "./SettingsLayoutAppDirClient";
const getTeamFeatures = unstable_cache(
async (teamId: number) => {
const featuresRepository = new FeaturesRepository(prisma);
return await featuresRepository.getTeamFeatures(teamId);
return await featuresRepository.getEnabledTeamFeatures(teamId);
},
["team-features"],
{
@@ -10,7 +10,7 @@ import prisma from "@calcom/prisma";
import type { OAuthTokenPayload } from "@calcom/types/oauth";
async function handler(req: NextRequest) {
const { client_id, client_secret, grant_type, refresh_token, code_verifier } = await parseUrlFormData(req);
const { client_id, client_secret, grant_type, refresh_token } = await parseUrlFormData(req);
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
return NextResponse.json({ message: "CALENDSO_ENCRYPTION_KEY is not set" }, { status: 500 });
@@ -62,11 +62,6 @@ async function handler(req: NextRequest) {
return NextResponse.json({ error: "invalid_grant" }, { status: 400 });
}
const pkceError = OAuthService.verifyPKCE(client, decodedRefreshToken, code_verifier);
if (pkceError) {
return NextResponse.json({ error: pkceError.error }, { status: pkceError.status });
}
const accessTokenExpiresIn = 1800; // 30 minutes
const payloadAuthToken: OAuthTokenPayload = {
@@ -83,11 +78,6 @@ async function handler(req: NextRequest) {
scope: decodedRefreshToken.scope,
token_type: "Refresh Token",
clientId: client_id,
// Preserve PKCE information for any client that used PKCE originally
...(decodedRefreshToken.codeChallenge && {
codeChallenge: decodedRefreshToken.codeChallenge,
codeChallengeMethod: decodedRefreshToken.codeChallengeMethod,
}),
};
const access_token = jwt.sign(payloadAuthToken, secretKey, {
+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,
@@ -8,7 +8,7 @@ import { CalendarCacheEventRepository } from "@calcom/features/calendar-subscrip
import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService";
import { CalendarSyncService } from "@calcom/features/calendar-subscription/lib/sync/CalendarSyncService";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository";
import { SelectedCalendarRepository } from "@calcom/features/selectedCalendar/repositories/SelectedCalendarRepository";
import { prisma } from "@calcom/prisma";
import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir";
@@ -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);
@@ -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");
}
});
});
});
+8 -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,10 @@ 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 }
);
}
@@ -78,14 +76,13 @@ 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 }
);
}
@@ -143,7 +140,7 @@ async function handleBookingAction(
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)}`,
new URL(`/booking/${booking.uid}?error=${encodeURIComponent(message)}`, request.url),
{ status: 303 }
);
}
@@ -153,7 +150,7 @@ async function handleBookingAction(
data: { oneTimePassword: null },
});
return NextResponse.redirect(`${url.origin}/booking/${booking.uid}`, { status: 303 });
return NextResponse.redirect(new URL(`/booking/${booking.uid}`, request.url), { status: 303 });
}
export const GET = defaultResponderForAppDir(getHandler);
@@ -11,7 +11,7 @@ import { CalendarCacheEventService } from "@calcom/features/calendar-subscriptio
import { CalendarSyncService } from "@calcom/features/calendar-subscription/lib/sync/CalendarSyncService";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import logger from "@calcom/lib/logger";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository";
import { SelectedCalendarRepository } from "@calcom/features/selectedCalendar/repositories/SelectedCalendarRepository";
import { prisma } from "@calcom/prisma";
import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir";
@@ -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 -1
View File
@@ -2,8 +2,8 @@
import { revalidateTag } from "next/cache";
import { TravelScheduleRepository } from "@calcom/features/travelSchedule/repositories/TravelScheduleRepository";
import { NEXTJS_CACHE_TTL } from "@calcom/lib/constants";
import { TravelScheduleRepository } from "@calcom/lib/server/repository/travelSchedule";
import { unstable_cache } from "@calcom/lib/unstable_cache";
const CACHE_TAGS = {
+1 -1
View File
@@ -44,7 +44,7 @@ export const viewport = {
export const metadata = {
icons: {
icon: "/favicon.ico",
icon: "/api/logo?type=favicon-32",
apple: "/api/logo?type=apple-touch-icon",
other: [
{
+11 -6
View File
@@ -2,6 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useLayoutEffect } from "react";
import {
getOrgDomainConfigFromHostname,
@@ -40,9 +41,8 @@ function getPageInfo(pathname: string, host: string) {
return {
username: currentOrgDomain ?? "",
pageType: PageType.ORG,
url: `${WEBSITE_URL}/signup?callbackUrl=settings/organizations/new%3Fslug%3D${
currentOrgDomain?.replace("/", "") ?? ""
}`,
url: `${WEBSITE_URL}/signup?callbackUrl=settings/organizations/new%3Fslug%3D${currentOrgDomain?.replace("/", "") ?? ""
}`,
};
}
}
@@ -55,6 +55,12 @@ export function NotFound({ host }: { host: string }) {
const isSubpage = pathname?.includes("/", 2) || isBookingSuccessPage;
const isInsights = pathname?.startsWith("/insights");
useLayoutEffect(() => {
if (typeof window !== "undefined") {
window.CalComPageStatus = "404";
}
}, []);
const links = [
{
title: t("enterprise"),
@@ -156,9 +162,8 @@ export function NotFound({ host }: { host: string }) {
<span className="focus:outline-none">
<span className="absolute inset-0" aria-hidden="true" />
{t("register")}{" "}
<strong className="text-green-500">{`${
pageType === PageType.TEAM ? `${new URL(WEBSITE_URL).host}/team/` : ""
}${username}${pageType === PageType.ORG ? `.${subdomainSuffix()}` : ""}`}</strong>
<strong className="text-green-500">{`${pageType === PageType.TEAM ? `${new URL(WEBSITE_URL).host}/team/` : ""
}${username}${pageType === PageType.ORG ? `.${subdomainSuffix()}` : ""}`}</strong>
</span>
</span>
</h3>
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "../../packages/coss-ui/src/styles/globals.css",
"baseColor": "zinc",
"cssVariables": true
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"hooks": "@coss/ui/hooks",
"utils": "@coss/ui/lib/utils",
"ui": "@coss/ui/components"
}
}
-20
View File
@@ -1,8 +1,5 @@
"use client";
import { usePathname } from "next/navigation";
import Script from "next/script";
import "@calcom/embed-core/src/embed-iframe";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
@@ -16,16 +13,6 @@ export type PageWrapperProps = Readonly<{
}>;
function PageWrapper(props: PageWrapperProps) {
const pathname = usePathname();
let pageStatus = "200";
if (pathname === "/404") {
pageStatus = "404";
} else if (pathname === "/500") {
pageStatus = "500";
} else if (pathname === "/403") {
pageStatus = "403";
}
// On client side don't let nonce creep into DOM
// It also avoids hydration warning that says that Client has the nonce value but server has "" because browser removes nonce attributes before DOM is built
@@ -41,13 +28,6 @@ function PageWrapper(props: PageWrapperProps) {
<>
<AppProviders {...providerProps}>
<>
<Script
nonce={nonce}
id="page-status"
// It is strictly not necessary to disable, but in a future update of react/no-danger this will error.
// And we don't want it to error here anyways
dangerouslySetInnerHTML={{ __html: `window.CalComPageStatus = '${pageStatus}'` }}
/>
{props.requiresLicense ? (
<LicenseRequired>{props.children}</LicenseRequired>
) : (
@@ -39,7 +39,7 @@ export function MultiDisconnectIntegration({ credentials, onSuccess }: Props) {
const mutation = trpc.viewer.credentials.delete.useMutation({
onSuccess: () => {
showToast(t("app_removed_successfully"), "success");
onSuccess && onSuccess();
onSuccess?.();
setConfirmationDialogOpen(false);
},
onError: () => {
@@ -52,12 +52,12 @@ export function MultiDisconnectIntegration({ credentials, onSuccess }: Props) {
},
});
const getUserDisplayName = (user: (typeof credentials)[number]["user"]) => {
const getUserDisplayName = (user: (typeof credentials)[number]["user"]): string | null => {
if (!user) return null;
// Check if 'name' property exists on user
if ("name" in user) return user.name;
// Otherwise use email if available
if ("email" in user) return user.email;
// Check if 'name' property exists and has a truthy string value
if ("name" in user && typeof user.name === "string" && user.name) return user.name;
// Otherwise use email if available and it's a string
if ("email" in user && typeof user.email === "string" && user.email) return user.email;
return null;
};
@@ -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";
@@ -41,6 +41,7 @@ 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";
@@ -136,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;
@@ -144,6 +146,21 @@ function BookingListItem(booking: BookingItemProps) {
i18n: { language },
} = useLocale();
// 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;
// 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,
@@ -265,10 +282,17 @@ function BookingListItem(booking: BookingItemProps) {
return (
<div
ref={itemRef}
data-testid="booking-item"
data-today={String(booking.isToday)}
data-booking-list-item="true"
className="hover:bg-cal-muted group w-full">
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">
@@ -381,7 +405,7 @@ function BookingListItem(booking: BookingItemProps) {
<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",
"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}
@@ -395,7 +419,7 @@ function BookingListItem(booking: BookingItemProps) {
</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"
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>
@@ -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";
@@ -122,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",
@@ -131,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
? {
@@ -229,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":
@@ -257,8 +257,8 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
}
}}>
<div className="flex flex-row md:space-x-3">
<div className="bg-subtle mx-auto hidden h-12 w-12 shrink-0 items-center justify-center rounded-full sm:mx-0 sm:h-10 sm:w-10 md:flex">
<Icon name="map-pin" className="text-emphasis h-6 w-6" />
<div className="bg-subtle hidden h-10 w-10 shrink-0 justify-center rounded-full md:flex">
<Icon name="map-pin" className="m-auto h-6 w-6" />
</div>
<div className="w-full md:pt-1">
<DialogHeader title={t("edit_location")} />
+9 -1
View File
@@ -1,6 +1,6 @@
"use client";
import React from "react";
import { useLayoutEffect } from "react";
import "@calcom/embed-core/src/embed-iframe";
import { HttpError } from "@calcom/lib/http-error";
@@ -59,6 +59,14 @@ export const ErrorPage: React.FC<Props> = (props) => {
window.location.reload();
props.reset?.();
};
// useLayoutEffect runs synchronously before browser paint, ensuring it's set early
useLayoutEffect(() => {
if (statusCode && typeof window !== "undefined") {
window.CalComPageStatus = statusCode.toString();
}
}, [statusCode]);
return (
<>
<div className="bg-subtle flex h-screen">
@@ -44,12 +44,14 @@ const CustomEmailTextField = ({
return (
<>
<div
className={`border-default mt-2 flex w-full items-center rounded-[10px] border ${
inputFocus ? "ring-brand-default border-neutral-300 ring-2" : ""
className={`mt-2 flex w-full items-center rounded-[10px] border transition-all ${
inputFocus
? "border-emphasis shadow-outline-gray-focused"
: "border-default hover:border-emphasis shadow-outline-gray-rested"
}`}>
<Input
{...formMethods.register(formMethodFieldName)}
className="focus:ring-none! focus:shadow-none! flex-1 border-none bg-transparent px-3 py-1.5 text-sm outline-none"
className="ring-0 focus:ring-0 focus:shadow-none! flex-1 border-none bg-transparent px-3 py-1.5 text-sm outline-none"
data-testid={dataTestId}
onFocus={() => setInputFocus(true)}
onBlur={() => setInputFocus(false)}
+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: {
@@ -1,7 +1,7 @@
import { BookingReferenceRepository } from "@calcom/features/bookingReference/repositories/BookingReferenceRepository";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { BookingReferenceRepository } from "@calcom/lib/server/repository/bookingReference";
const log = logger.getSubLogger({ prefix: ["daily-video-webhook-handler"] });
@@ -2,6 +2,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import type { ApiSuccessResponse } from "@calcom/platform-types";
import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { WebhookVersion } from "@calcom/features/webhooks/lib/interface/IWebhookRepository";
type Input = {
active: boolean;
@@ -19,6 +20,7 @@ type Output = {
triggers: WebhookTriggerEvents[];
secret?: string;
payloadTemplate: string | undefined | null;
version?: WebhookVersion;
};
export const useOAuthClientWebhooks = (clientId: string) => {
@@ -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,
});
@@ -69,7 +69,9 @@ export default function Authorize() {
}
if (client?.redirectUri) {
window.location.href = `${client.redirectUri}${client.redirectUri.includes("?") ? "&" : "?"}${errorParams.toString()}`;
window.location.href = `${client.redirectUri}${
client.redirectUri.includes("?") ? "&" : "?"
}${errorParams.toString()}`;
}
},
});
@@ -89,6 +91,18 @@ export default function Authorize() {
}
}, [isPendingProfiles]);
// Auto-authorize trusted clients
useEffect(() => {
if (client?.isTrusted && selectedAccount) {
generateAuthCodeMutation.mutate({
clientId: client_id as string,
scopes,
codeChallenge: code_challenge || undefined,
codeChallengeMethod: (code_challenge_method as "S256") || undefined,
});
}
}, [client?.isTrusted, selectedAccount]);
useEffect(() => {
if (status === "unauthenticated") {
const urlSearchParams = new URLSearchParams({
@@ -108,9 +122,20 @@ export default function Authorize() {
return <div>{t("unauthorized")}</div>;
}
// Don't show UI for trusted clients, they'll auto-authorize
if (client.isTrusted && selectedAccount) {
return (
<div className="flex justify-center pt-32">
<div className="flex items-center space-x-3">
<span className="text-lg font-medium text-gray-700">Authorizing...</span>
</div>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center">
<div className="bg-default border-subtle mt-2 max-w-xl rounded-md border px-9 pb-3 pt-2">
<div className="bg-default border-subtle mt-2 max-w-xl rounded-md border px-9 pt-2 pb-3">
<div className="flex items-center justify-center">
<Avatar
alt=""
@@ -127,7 +152,7 @@ export default function Authorize() {
</div>
</div>
</div>
<h1 className="px-5 pb-5 pt-3 text-center text-2xl font-bold tracking-tight">
<h1 className="px-5 pt-3 pb-5 text-center text-2xl font-bold tracking-tight">
{t("access_cal_account", { clientName: client.name, appName: APP_NAME })}
</h1>
<div className="mb-1 text-sm font-medium">{t("select_account_team")}</div>
@@ -141,7 +166,7 @@ export default function Authorize() {
defaultValue={selectedAccount || mappedProfiles[0]}
options={mappedProfiles}
/>
<div className="mb-4 mt-5 font-medium">{t("allow_client_to", { clientName: client.name })}</div>
<div className="mt-5 mb-4 font-medium">{t("allow_client_to", { clientName: client.name })}</div>
<ul className="stack-y-4 text-sm">
<li className="relative pl-5">
<span className="absolute left-0">&#10003;</span>{" "}
@@ -166,7 +191,7 @@ export default function Authorize() {
<span className="absolute left-0">&#10003;</span> {t("access_bookings")}
</li>
</ul>
<div className="bg-subtle mb-8 mt-8 flex rounded-md p-3">
<div className="bg-subtle mt-8 mb-8 flex rounded-md p-3">
<div>
<Icon name="info" className="mr-1 mt-0.5 h-4 w-4" />
</div>
+50 -14
View File
@@ -2,17 +2,40 @@
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import posthog from "posthog-js";
import { useEffect } from "react";
import { useFlagMap } from "@calcom/features/flags/context/provider";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import useEmailVerifyCheck from "@calcom/trpc/react/hooks/useEmailVerifyCheck";
import { showToast } from "@calcom/ui/components/toast";
import { Button } from "@calcom/ui/components/button";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import { useFlagMap } from "@calcom/features/flags/context/provider";
import { showToast } from "@calcom/ui/components/toast";
const EMAIL_CLIENTS = [
{
name: "Gmail",
icon: "/email-clients/gmail.svg",
href: 'https://mail.google.com/mail/u/0/#search/%22api%2Fauth%2Fverify-email%22',
},
{
name: "Outlook",
icon: "/email-clients/outlook.svg",
href: "https://outlook.live.com/mail/0/",
},
{
name: "Yahoo",
icon: "/email-clients/yahoo.svg",
href: "https://mail.yahoo.com/d/search?p=Cal.com",
},
{
name: "Proton",
icon: "/email-clients/proton.svg",
href: "https://mail.proton.me",
},
] as const;
function VerifyEmailPage() {
const { data } = useEmailVerifyCheck();
@@ -47,17 +70,30 @@ function VerifyEmailPage() {
description={t("verify_email_page_body", { email: session?.user?.email, appName: APP_NAME })}
className="bg-default"
buttonRaw={
<Button
color="minimal"
className="underline"
loading={mutation.isPending}
onClick={() => {
posthog.capture("verify_email_resend_clicked");
showToast(t("send_email"), "success");
mutation.mutate();
}}>
{t("resend_email")}
</Button>
<>
<div className="mb-4 flex flex-wrap items-center gap-2">
{EMAIL_CLIENTS.map(({ name, icon, href }) => (
<Button
key={name}
color="secondary"
href={href}
target="_blank"
rel="noopener noreferrer">
<img src={icon} alt={name} className="me-1 h-4 w-4" /> {name}
</Button>
))}
</div>
<Button
color="minimal"
loading={mutation.isPending}
onClick={() => {
posthog.capture("verify_email_resend_clicked");
showToast(t("send_email"), "success");
mutation.mutate();
}}>
{t("resend_email")}
</Button>
</>
}
/>
</div>
@@ -3,40 +3,57 @@
*/
"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 { Icon, type IconName } 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;
source: string;
displayJson?: Record<string, unknown> | null;
actionDisplayTitle: TranslationWithParams;
displayFields?: Array<{ labelKey: string; valueKey: string }> | null;
actor: {
type: string;
type: AuditActorType;
displayName: string | null;
displayEmail: string | null;
displayAvatar: string | null;
};
};
interface BookingLogsFiltersProps {
searchTerm: string;
onSearchChange: (value: string) => void;
typeFilter: string | null;
onTypeFilterChange: (value: string | null) => void;
actorFilter: string | null;
onActorFilterChange: (value: string | null) => void;
typeOptions: Array<{ label: string; value: string }>;
actorOptions: Array<{ label: string; value: string }>;
}
@@ -44,40 +61,35 @@ 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 ACTION_ICON_MAP: Record<string, IconName> = {
CREATED: "calendar",
CANCELLED: "ban",
REJECTED: "ban",
ACCEPTED: "check",
RESCHEDULED: "pencil",
RESCHEDULE_REQUESTED: "pencil",
REASSIGNMENT: "user-check",
ATTENDEE_ADDED: "user-check",
ATTENDEE_REMOVED: "user-check",
LOCATION_CHANGED: "map-pin",
HOST_NO_SHOW_UPDATED: "ban",
ATTENDEE_NO_SHOW_UPDATED: "ban",
} as const;
const ACTOR_ROLE_LABEL_MAP: Record<AuditActorType, string | null> = {
GUEST: "guest",
ATTENDEE: "attendee",
SYSTEM: null,
USER: null,
APP: null,
} as const;
function BookingLogsFilters({
searchTerm,
onSearchChange,
typeFilter,
onTypeFilterChange,
actorFilter,
onActorFilterChange,
typeOptions,
actorOptions,
}: BookingLogsFiltersProps) {
const { t } = useLocale();
@@ -93,18 +105,6 @@ function BookingLogsFilters({
/>
</div>
<div className="min-w-[140px]">
<Select
size="sm"
value={typeFilter ? { label: `${t("type")}: ${t(`audit_action.${typeFilter.toLowerCase()}`)}`, value: typeFilter } : { label: `${t("type")}: ${t("all")}`, value: "" }}
onChange={(option) => {
if (!option) return;
onTypeFilterChange(option.value || null);
}}
options={[{ label: `${t("type")}: ${t("all")}`, value: "" }, ...typeOptions.map(opt => ({ ...opt, label: `${t("type")}: ${opt.label}` }))]}
/>
</div>
<div className="min-w-[140px]">
<Select
size="sm"
@@ -113,13 +113,75 @@ function BookingLogsFilters({
if (!option) return;
onActorFilterChange(option.value || null);
}}
options={[{ label: `${t("actor")}: ${t("all")}`, value: "" }, ...actorOptions.map(opt => ({ ...opt, label: `${t("actor")}: ${opt.label}` }))]}
options={[{ label: t("all"), value: "" }, ...actorOptions]}
/>
</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());
@@ -157,15 +219,14 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
{logs.map((log, index) => {
const isLast = index === logs.length - 1;
const isExpanded = expandedLogIds.has(log.id);
const actionDisplay = t(`audit_action.${log.action.toLowerCase()}`);
const showJson = showJsonMap[log.id] || false;
const actorRole = ACTOR_ROLE_LABEL_MAP[log.actor.type] ?? null;
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" />
<Icon name={ACTION_ICON_MAP[log.action] ?? "sparkles"} className="h-3 w-3 text-subtle" />
</div>
</div>
{!isLast && (
@@ -178,78 +239,88 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
<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">
{actionDisplay}
<ActionTitle actionDisplayTitle={log.actionDisplayTitle} />
</h3>
<div className="flex items-center gap-1 mt-1 text-xs text-subtle">
<span>{log.actor.displayName}</span>
{log.actor.displayAvatar && (
<Avatar
size="xs"
imageSrc={log.actor.displayAvatar}
alt={log.actor.displayName || ""}
/>
)}
<span>
{log.actor.displayName}
{actorRole && <span>{` (${t(actorRole)})`}</span>}
</span>
<span>•</span>
<span>{dayjs(log.timestamp).fromNow()}</span>
</div>
</div>
<Button
color="minimal"
size="sm"
variant="icon"
StartIcon="ellipsis"
className="h-6 w-6"
/>
</div>
</div>
<div className="px-3">
<div className="bg-muted rounded-[10px] py-1">
<Button
color="minimal"
size="sm"
onClick={() => toggleExpand(log.id)}
StartIcon={isExpanded ? "chevron-down" : "chevron-right"}
className="w-full justify-start text-xs font-medium text-subtle h-6">
{isExpanded ? t("hide_details") : t("show_details")}
</Button>
<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 border-b px-3 border-subtle">
<span className="font-medium text-emphasis min-w-[80px]">{t("source")}</span>
<span className="text-default">{log.source}</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.displayJson && Object.keys(log.displayJson).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.displayJson} />}
</div>
</div>
)}
</div>
)}
</div>
</div>
{isExpanded && (
<div className="mt-2 px-3">
<div className="space-y-2 text-xs">
<div className="flex items-start gap-2">
<span className="font-medium text-emphasis min-w-[80px]">{t("type")}</span>
<span className="text-default">{log.type}</span>
</div>
<div className="flex items-start gap-2">
<span className="font-medium text-emphasis min-w-[80px]">{t("actor")}</span>
<span className="text-default">{log.actor.type}</span>
</div>
<div className="flex items-start gap-2">
<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 className="pt-2">
<Button
color="minimal"
size="sm"
onClick={() => toggleJson(log.id)}
StartIcon={showJson ? "chevron-down" : "chevron-right"}
className="mb-1 h-6 px-0 font-medium">
{t("json")}
</Button>
{showJson && (
<pre className="bg-subtle p-3 rounded-md text-[10px] overflow-x-auto text-default font-mono border border-subtle">
{JSON.stringify(log.data, null, 2)}
</pre>
)}
</div>
)}
</div>
</div>
)}
</div>
</div>
);
@@ -258,16 +329,55 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
);
}
function useBookingLogsFilters(
auditLogs: AuditLog[],
searchTerm: string,
actorFilter: string | null
) {
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.displayName === actorFilter;
return matchesSearch && matchesActor;
});
const uniqueActorNames = Array.from(
new Set(
auditLogs
.map((log) => log.actor.displayName)
.filter((name): name is string => Boolean(name))
)
);
const actorOptions = uniqueActorNames.map((actorName) => ({
label: actorName,
value: actorName,
}));
return { filteredLogs, actorOptions };
}
export default function BookingLogsView({ bookingUid }: BookingLogsViewProps) {
const router = useRouter();
const [searchTerm, setSearchTerm] = useState("");
const [typeFilter, setTypeFilter] = useState<string | null>(null);
const [actorFilter, setActorFilter] = useState<string | null>(null);
const { t } = useLocale();
const { data, isLoading, error } = trpc.viewer.bookings.getAuditLogs.useQuery({
bookingUid,
});
const auditLogs = data?.auditLogs || [];
const { filteredLogs, actorOptions } = useBookingLogsFilters(
auditLogs,
searchTerm,
actorFilter
);
if (error) {
return (
<div className="flex items-center justify-center py-12">
@@ -293,44 +403,13 @@ export default function BookingLogsView({ bookingUid }: BookingLogsViewProps) {
);
}
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 matchesType = !typeFilter || log.action === typeFilter;
const matchesActor = !actorFilter || log.actor.type === actorFilter;
return matchesSearch && matchesType && matchesActor;
});
const uniqueTypes = Array.from(new Set(auditLogs.map((log) => log.action)));
const uniqueActorTypes = Array.from(new Set(auditLogs.map((log) => log.actor.type)));
const typeOptions = uniqueTypes.map((type) => ({
label: t(`audit_action.${type.toLowerCase()}`),
value: type,
}));
const actorOptions = uniqueActorTypes.map((actorType) => ({
label: actorType,
value: actorType,
}));
return (
<div className="space-y-6">
<BookingLogsFilters
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
typeFilter={typeFilter}
onTypeFilterChange={setTypeFilter}
actorFilter={actorFilter}
onActorFilterChange={setActorFilter}
typeOptions={typeOptions}
actorOptions={actorOptions}
/>
@@ -109,7 +109,7 @@ export function buildFilterColumns({ t, permissions, status }: BuildFilterColumn
filter: {
type: ColumnFilterType.DATE_RANGE,
dateRangeOptions: {
range: status === "past" ? "past" : "custom",
range: status === "past" ? "past" : status === "cancelled" ? "any" : "future", // upcoming, unconfirmed, recurring are all future-only
},
},
},
@@ -16,6 +16,8 @@ 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";
@@ -36,6 +38,7 @@ interface BookingCalendarContainerProps {
permissions: {
canReadOthersBookings: boolean;
};
bookingsV3Enabled: boolean;
}
interface BookingCalendarInnerProps extends BookingCalendarContainerProps {
@@ -48,20 +51,39 @@ interface BookingCalendarInnerProps extends BookingCalendarContainerProps {
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"));
};
@@ -86,8 +108,6 @@ function BookingCalendarInner({
const getFacetedUniqueValues = useFacetedUniqueValues();
const rowData = useBookingCalendarData({ data, status });
const table = useReactTable<RowData>({
data: rowData,
columns,
@@ -99,13 +119,6 @@ function BookingCalendarInner({
getFacetedUniqueValues,
});
// 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]);
return (
<>
<div className="mb-4 flex items-center justify-between">
@@ -132,7 +145,7 @@ function BookingCalendarInner({
<Icon name="chevron-right" className="h-4 w-4" />
</Button>
</ButtonGroup>
<ViewToggleButton />
<ViewToggleButton bookingsV3Enabled={bookingsV3Enabled} />
</div>
</div>
{hasError && ErrorView ? (
@@ -158,7 +171,7 @@ function BookingCalendarInner({
export function BookingCalendarContainer(props: BookingCalendarContainerProps) {
const { canReadOthersBookings } = props.permissions;
const { userIds } = useBookingFilters();
const { currentWeekStart } = useCurrentWeekStart();
const { currentWeekStart, setCurrentWeekStart, userWeekStart } = useCurrentWeekStart();
const allowedFilterIds = useCalendarAllowedFilters({
canReadOthersBookings,
@@ -182,8 +195,9 @@ export function BookingCalendarContainer(props: BookingCalendarContainerProps) {
}
);
const { isFetched, hasNextPage, isFetchingNextPage, fetchNextPage } = query;
// Automatically fetch all pages until no more data
const { hasNextPage, isFetchingNextPage, fetchNextPage } = query;
useEffect(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
@@ -208,8 +222,17 @@ export function BookingCalendarContainer(props: BookingCalendarContainerProps) {
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}>
<BookingDetailsSheetStoreProvider bookings={bookings} capabilities={capabilities}>
<BookingCalendarInner
{...props}
data={data}
@@ -217,6 +240,9 @@ export function BookingCalendarContainer(props: BookingCalendarContainerProps) {
isPending={query.isPending}
hasError={!!query.error}
errorMessage={query.error?.message}
hasNextPage={hasNextPage}
isFetched={isFetched}
isFetchingNextPage={isFetchingNextPage}
/>
</BookingDetailsSheetStoreProvider>
);
@@ -24,6 +24,7 @@ export function BookingCalendarView({
onWeekStartChange,
}: BookingCalendarViewProps) {
const setSelectedBookingUid = useBookingDetailsSheetStore((state) => state.setSelectedBookingUid);
const selectedBookingUid = useBookingDetailsSheetStore((state) => state.selectedBookingUid);
const { timezone } = useTimePreferences();
const { resolvedTheme, forcedTheme } = useGetTheme();
const { bannersHeight } = useBanners();
@@ -91,6 +92,7 @@ export function BookingCalendarView({
showBackgroundPattern={false}
showBorder={false}
borderColor="subtle"
selectedBookingUid={selectedBookingUid}
onEventClick={(event) => {
const bookingUid = event.options?.bookingUid;
if (bookingUid) {
@@ -42,10 +42,7 @@ import { BookingActionsDropdown } from "../../../components/booking/actions/Book
import { BookingActionsStoreProvider } from "../../../components/booking/actions/BookingActionsStoreProvider";
import type { BookingListingStatus } from "../../../components/booking/types";
import { usePaymentStatus } from "../hooks/usePaymentStatus";
import {
useBookingDetailsSheetStore,
useBookingDetailsSheetStoreApi,
} from "../store/bookingDetailsSheetStore";
import { useBookingDetailsSheetStore } from "../store/bookingDetailsSheetStore";
import type { BookingOutput } from "../types";
import { JoinMeetingButton } from "./JoinMeetingButton";
@@ -103,35 +100,38 @@ function BookingDetailsSheetInner({
const { data: bookingDetails } = trpc.viewer.bookings.getBookingDetails.useQuery(
{ uid: booking.uid },
{
enabled: Boolean(booking.rescheduled || booking.fromReschedule),
// Keep data fresh but don't refetch too aggressively
staleTime: 5 * 60 * 1000, // 5 minutes
}
);
// Get navigation state directly from the store
const hasNext = useBookingDetailsSheetStore((state) => state.hasNext());
const hasPrevious = useBookingDetailsSheetStore((state) => state.hasPrevious());
const setSelectedBookingUid = useBookingDetailsSheetStore((state) => state.setSelectedBookingUid);
// 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 = () => {
setSelectedBookingUid(null);
navigation.setSelectedBookingUid(null);
};
const storeApi = useBookingDetailsSheetStoreApi();
const handleNext = () => {
const nextUid = storeApi.getState().getNextBookingUid();
if (nextUid !== null) {
setSelectedBookingUid(nextUid);
}
navigation.navigateNext();
};
const handlePrevious = () => {
const prevUid = storeApi.getState().getPreviousBookingUid();
if (prevUid !== null) {
setSelectedBookingUid(prevUid);
}
navigation.navigatePrevious();
};
const startTime = dayjs(booking.startTime).tz(userTimeZone);
@@ -214,7 +214,7 @@ function BookingDetailsSheetInner({
size="sm"
color="secondary"
StartIcon="chevron-up"
disabled={!hasPrevious}
disabled={!navigation.canGoPrev || navigation.isTransitioning}
onClick={(e) => {
e.preventDefault();
handlePrevious();
@@ -225,7 +225,7 @@ function BookingDetailsSheetInner({
size="sm"
color="secondary"
StartIcon="chevron-down"
disabled={!hasNext}
disabled={!navigation.canGoNext || navigation.isTransitioning}
onClick={(e) => {
e.preventDefault();
handleNext();
@@ -289,6 +289,8 @@ function BookingDetailsSheetInner({
customResponses={customResponses}
bookingFields={booking.eventType?.bookingFields}
/>
<TrackingSection tracking={bookingDetails?.tracking} />
</div>
</SheetBody>
@@ -841,6 +843,43 @@ function BookingHeaderBadges({
);
}
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>
);
}
function Section({
title,
className,
@@ -25,6 +25,8 @@ 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,
@@ -77,11 +79,13 @@ interface BookingListInnerProps extends BookingListContainerProps {
hasError: boolean;
errorMessage?: string;
totalRowCount?: number;
bookings: BookingsGetOutput["bookings"];
}
function BookingListInner({
status,
permissions,
bookings,
bookingsV3Enabled,
data,
isPending,
@@ -95,6 +99,9 @@ function BookingListInner({
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;
@@ -180,7 +187,7 @@ function BookingListInner({
<div className="hidden grow md:block" />
<DataTableSegment.Select shortLabel />
{bookingsV3Enabled && <ViewToggleButton />}
{bookingsV3Enabled && <ViewToggleButton bookingsV3Enabled={bookingsV3Enabled} />}
</div>
{displayedFilterCount > 0 && showFilters && (
<div className="mt-3 flex flex-wrap items-center gap-2">
@@ -221,12 +228,13 @@ function BookingListInner({
}
export function BookingListContainer(props: BookingListContainerProps) {
const { limit, offset } = useDataTable();
const { limit, offset, setPageIndex } = useDataTable();
const { eventTypeIds, teamIds, userIds, dateRange, attendeeName, attendeeEmail, bookingUid } =
useBookingFilters();
const query = trpc.viewer.bookings.get.useQuery(
{
// Build query input once - shared between query and prefetching
const queryInput = useMemo(
() => ({
limit,
offset,
filters: {
@@ -242,17 +250,40 @@ export function BookingListContainer(props: BookingListContainerProps) {
: undefined,
beforeEndDate: dateRange?.endDate ? dayjs(dateRange?.endDate).endOf("day").toISOString() : undefined,
},
},
{
staleTime: 5 * 60 * 1000, // 5 minutes - data is considered fresh
gcTime: 30 * 60 * 1000, // 30 minutes - cache retention time
}
}),
[
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}>
<BookingDetailsSheetStoreProvider bookings={bookings} capabilities={capabilities}>
<BookingListInner
{...props}
data={query.data}
@@ -260,6 +291,7 @@ export function BookingListContainer(props: BookingListContainerProps) {
hasError={!!query.error}
errorMessage={query.error?.message}
totalRowCount={query.data?.totalCount}
bookings={bookings}
/>
</BookingDetailsSheetStoreProvider>
);
@@ -1,6 +1,5 @@
"use client";
import { useQueryState } from "nuqs";
import { useEffect } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -8,14 +7,15 @@ 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 "../lib/viewParser";
import { useBookingsView } from "../hooks/useBookingsView";
export function ViewToggleButton() {
type ViewToggleButtonProps = {
bookingsV3Enabled: boolean;
};
export function ViewToggleButton({ bookingsV3Enabled }: ViewToggleButtonProps) {
const { t } = useLocale();
const [view, setView] = useQueryState(
"view",
viewParser.withDefault("list").withOptions({ clearOnDefault: true })
);
const [view, setView] = useBookingsView({ bookingsV3Enabled });
const isMobile = useMediaQuery("(max-width: 768px)");
useEffect(() => {
@@ -33,9 +33,9 @@ export function ViewToggleButton() {
<div className="hidden sm:block">
<ToggleGroup
value={view}
onValueChange={(value) => {
onValueChange={(value: "list" | "calendar") => {
if (!value) return;
setView(value as BookingView);
setView(value);
}}
options={[
{
@@ -98,7 +98,7 @@ export function useBookingListColumns({
filter: {
type: ColumnFilterType.DATE_RANGE,
dateRangeOptions: {
range: status === "past" ? "past" : "custom",
range: status === "past" ? "past" : status === "cancelled" ? "any" : "future", // upcoming, unconfirmed, recurring are all future-only
},
},
},
@@ -26,6 +26,18 @@ export function useBookingListData({
userTimeZone?: string;
}) {
const { t } = useLocale();
// Build a Map for recurringInfo lookups
const recurringInfoMap = useMemo(() => {
const map = new Map<string, NonNullable<typeof data>["recurringInfo"][number]>();
for (const info of data?.recurringInfo ?? []) {
if (info.recurringEventId) {
map.set(info.recurringEventId, info);
}
}
return map;
}, [data?.recurringInfo]);
/**
* Transform raw bookings into flat list (excluding today's bookings for "upcoming" status)
* - Deduplicates recurring bookings for recurring/unconfirmed/cancelled tabs
@@ -70,11 +82,11 @@ export function useBookingListData({
data?.bookings.filter(filterBookings).map((booking) => ({
type: "data" as const,
booking,
recurringInfo: data?.recurringInfo.find((info) => info.recurringEventId === booking.recurringEventId),
recurringInfo: booking.recurringEventId ? recurringInfoMap.get(booking.recurringEventId) : undefined,
isToday: false,
})) || []
);
}, [data, status, userTimeZone]);
}, [data?.bookings, recurringInfoMap, status, userTimeZone]);
// Extract today's bookings for the "Today" section (only used in "upcoming" status)
const bookingsToday = useMemo<BookingRowData[]>(() => {
@@ -88,10 +100,10 @@ export function useBookingListData({
.map((booking) => ({
type: "data" as const,
booking,
recurringInfo: data?.recurringInfo.find((info) => info.recurringEventId === booking.recurringEventId),
recurringInfo: booking.recurringEventId ? recurringInfoMap.get(booking.recurringEventId) : undefined,
isToday: true,
}));
}, [data, userTimeZone]);
}, [data?.bookings, recurringInfoMap, userTimeZone]);
// Combine data with section separators for "upcoming" tab
const finalData = useMemo<RowData[]>(() => {
@@ -0,0 +1,25 @@
"use client";
import { useLayoutEffect } from "react";
const MARKER_CLASS = "bookings-shell-heading";
export function useBookingsShellHeadingVisibility({ visible }: { visible: boolean }) {
useLayoutEffect(() => {
const marker = document.querySelector(`.${MARKER_CLASS}`);
if (!marker) return;
const headerWrapper = marker.closest("header")?.parentElement;
if (!headerWrapper) return;
if (visible) {
headerWrapper.classList.remove("hidden");
} else {
headerWrapper.classList.add("hidden");
}
return () => {
headerWrapper.classList.remove("hidden");
};
}, [visible]);
}
@@ -0,0 +1,104 @@
import { createParser, useQueryState } from "nuqs";
import { useEffect, useRef, useSyncExternalStore } from "react";
import { localStorage } from "@calcom/lib/webstorage";
const STORAGE_KEY = "bookings-preferred-view";
type BookingView = "list" | "calendar";
const viewParser = createParser({
parse: (value: string) => {
if (value === "calendar") return "calendar";
return "list";
},
serialize: (value: BookingView) => value,
});
// Create a store for localStorage value
const createLocalStorageStore = () => {
let listeners: Array<() => void> = [];
const subscribe = (listener: () => void) => {
listeners.push(listener);
return () => {
listeners = listeners.filter((l) => l !== listener);
};
};
const getSnapshot = (): BookingView => {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === "list" || stored === "calendar") {
return stored;
}
return "list";
};
const getServerSnapshot = (): BookingView => {
return "list";
};
const notify = () => {
listeners.forEach((listener) => listener());
};
return { subscribe, getSnapshot, getServerSnapshot, notify };
};
const localStorageStore = createLocalStorageStore();
type UseBookingsViewOptions = {
bookingsV3Enabled: boolean;
};
export function useBookingsView({ bookingsV3Enabled }: UseBookingsViewOptions) {
// Always use "list" as the default for useQueryState to keep instances in sync
const [_view, setView] = useQueryState("view", viewParser.withDefault("list"));
// Track if we've completed the initial sync to prevent race conditions
const isInitializedRef = useRef(false);
// Read from localStorage using useSyncExternalStore
const storedView = useSyncExternalStore(
localStorageStore.subscribe,
localStorageStore.getSnapshot,
localStorageStore.getServerSnapshot
);
// Force view to be "list" if calendar view is disabled
const view = bookingsV3Enabled ? _view : "list";
// Sync localStorage value to URL on initial mount
useEffect(() => {
// Only sync if there's no URL parameter AND localStorage has a non-default value
const urlHasViewParam =
typeof window !== "undefined" && new URLSearchParams(window.location.search).has("view");
if (!urlHasViewParam && storedView !== "list" && _view !== storedView) {
setView(storedView);
} else {
// No sync needed, mark as initialized
isInitializedRef.current = true;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Mark as initialized when _view matches storedView after initial sync
useEffect(() => {
if (!isInitializedRef.current && _view === storedView) {
isInitializedRef.current = true;
}
}, [_view, storedView]);
// Sync to localStorage when view changes (only if initialized)
useEffect(() => {
if (!isInitializedRef.current) return;
if (bookingsV3Enabled && view && view !== storedView) {
localStorage.setItem(STORAGE_KEY, view);
localStorageStore.notify(); // Notify all subscribers
}
}, [view, storedView, bookingsV3Enabled]);
return [view, setView] as const;
}
@@ -0,0 +1,84 @@
import { useEffect, useRef } from "react";
import { useBookingDetailsSheetStore } from "../store/bookingDetailsSheetStore";
import type { BookingsGetOutput } from "../types";
/**
* Calendar-specific auto-selection logic hook.
* Handles auto-selecting bookings when navigating across weeks in calendar view.
*
* - For "first": Selects as soon as the first page loads (immediate)
* - For "last": Waits for all pages to load to ensure we get the actual last booking
*/
export function useCalendarAutoSelector(
bookings: BookingsGetOutput["bookings"],
hasNextPage: boolean,
isFetched: boolean,
isFetchingNextPage: boolean
) {
const pendingSelection = useBookingDetailsSheetStore((state) => state.pendingSelection);
const setSelectedBookingUid = useBookingDetailsSheetStore((state) => state.setSelectedBookingUid);
const clearPendingSelection = useBookingDetailsSheetStore((state) => state.clearPendingSelection);
const setIsTransitioning = useBookingDetailsSheetStore((state) => state.setIsTransitioning);
const bookingsRef = useRef(bookings);
useEffect(() => {
// Early return if no pending selection
if (!pendingSelection) {
return;
}
const hasBookingsChanged = bookings !== bookingsRef.current;
bookingsRef.current = bookings;
if (!hasBookingsChanged) return;
if (pendingSelection === "first" && isFetched && bookings.length === 0) {
// data fetching is finished but there is no booking to select
setIsTransitioning(false);
clearPendingSelection();
return;
}
// For "first", we can select immediately when the first page arrives
if (pendingSelection === "first" && bookings.length > 0) {
setSelectedBookingUid(bookings[0].uid);
setIsTransitioning(false);
clearPendingSelection();
return;
}
// For "last", wait until all pages are loaded to ensure we get the actual last booking
if (pendingSelection === "last") {
const isAllDataLoaded = !hasNextPage && !isFetchingNextPage;
if (isAllDataLoaded && bookings.length === 0) {
// data fetching is finished but there is no booking to select
setIsTransitioning(false);
clearPendingSelection();
return;
}
// Wait for all data to load AND for bookings to actually arrive
if (!isAllDataLoaded || bookings.length === 0) {
return;
}
const lastBooking = bookings[bookings.length - 1];
setSelectedBookingUid(lastBooking.uid);
// Clear transition state and pending selection after handling
setIsTransitioning(false);
clearPendingSelection();
}
}, [
bookings,
hasNextPage,
isFetched,
isFetchingNextPage,
pendingSelection,
setSelectedBookingUid,
clearPendingSelection,
setIsTransitioning,
]);
}
@@ -0,0 +1,126 @@
import { useCallback, useMemo, useEffect } from "react";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { trpc } from "@calcom/trpc/react";
import { getWeekStart } from "../lib/weekUtils";
import type { NavigationCapabilities } from "../store/bookingDetailsSheetStore";
import type { BookingListingStatus } from "../types";
import { useNearestFutureBooking } from "./useNearestFutureBooking";
import { useNearestPastBooking } from "./useNearestPastBooking";
interface UseCalendarNavigationCapabilitiesProps {
currentWeekStart: Dayjs;
setCurrentWeekStart: (date: Dayjs) => void;
userWeekStart: number;
/** Filters to use for probe queries and prefetching */
filters: {
statuses: BookingListingStatus[];
userIds?: number[];
};
}
/**
* Calendar view navigation capabilities adapter.
* Provides week-based navigation logic for the booking details sheet.
*
* This hook:
* - Uses probe queries to find nearest bookings in each direction
* - Jumps directly to the week containing the nearest booking (not just adjacent week)
* - Disables navigation buttons when no bookings exist in that direction
* - Prefetches booking data for the target weeks to improve navigation performance
*/
export function useCalendarNavigationCapabilities({
currentWeekStart,
setCurrentWeekStart,
userWeekStart,
filters,
}: UseCalendarNavigationCapabilitiesProps): NavigationCapabilities {
const trpcUtils = trpc.useUtils();
// Probe queries for navigation - find nearest bookings in each direction
const { nearestBooking: nearestFutureBooking } = useNearestFutureBooking({
currentWeekStart,
filters,
});
const { nearestBooking: nearestPastBooking } = useNearestPastBooking({
currentWeekStart,
filters,
});
const hasFutureBooking = !!nearestFutureBooking;
const hasPastBooking = !!nearestPastBooking;
const nextBookingDate = nearestFutureBooking?.startTime?.toString() ?? null;
const prevBookingDate = nearestPastBooking?.startTime?.toString() ?? null;
// Calculate target week starts for prefetching
const nextWeekStart = useMemo(() => {
if (!nextBookingDate) return null;
return getWeekStart(dayjs(nextBookingDate), userWeekStart);
}, [nextBookingDate, userWeekStart]);
const prevWeekStart = useMemo(() => {
if (!prevBookingDate) return null;
return getWeekStart(dayjs(prevBookingDate), userWeekStart);
}, [prevBookingDate, userWeekStart]);
// Prefetch bookings for the next week with bookings
// Uses prefetchInfinite to match the useInfiniteQuery used in BookingCalendarContainer
useEffect(() => {
if (!nextWeekStart) return;
trpcUtils.viewer.bookings.get.prefetchInfinite({
limit: 100,
filters: {
statuses: filters.statuses,
userIds: filters.userIds,
afterStartDate: nextWeekStart.startOf("day").toISOString(),
beforeEndDate: nextWeekStart.add(6, "day").endOf("day").toISOString(),
},
});
}, [nextWeekStart, filters.statuses, filters.userIds, trpcUtils]);
// Prefetch bookings for the previous week with bookings
// Uses prefetchInfinite to match the useInfiniteQuery used in BookingCalendarContainer
useEffect(() => {
if (!prevWeekStart) return;
trpcUtils.viewer.bookings.get.prefetchInfinite({
limit: 100,
filters: {
statuses: filters.statuses,
userIds: filters.userIds,
afterStartDate: prevWeekStart.startOf("day").toISOString(),
beforeEndDate: prevWeekStart.add(6, "day").endOf("day").toISOString(),
},
});
}, [prevWeekStart, filters.statuses, filters.userIds, trpcUtils]);
const canNavigateToNextPeriod = useCallback(() => hasFutureBooking, [hasFutureBooking]);
const canNavigateToPreviousPeriod = useCallback(() => hasPastBooking, [hasPastBooking]);
const requestNextPeriod = useCallback(() => {
if (!nextBookingDate) return;
const targetWeekStart = getWeekStart(dayjs(nextBookingDate), userWeekStart);
setCurrentWeekStart(targetWeekStart);
}, [nextBookingDate, setCurrentWeekStart, userWeekStart]);
const requestPreviousPeriod = useCallback(() => {
if (!prevBookingDate) return;
const targetWeekStart = getWeekStart(dayjs(prevBookingDate), userWeekStart);
setCurrentWeekStart(targetWeekStart);
}, [prevBookingDate, setCurrentWeekStart, userWeekStart]);
return useMemo(
() => ({
canNavigateToNextPeriod,
canNavigateToPreviousPeriod,
requestNextPeriod,
requestPreviousPeriod,
}),
[canNavigateToNextPeriod, canNavigateToPreviousPeriod, requestNextPeriod, requestPreviousPeriod]
);
}
@@ -0,0 +1,37 @@
import { useEffect, useRef } from "react";
import { useBookingDetailsSheetStore } from "../store/bookingDetailsSheetStore";
import type { BookingsGetOutput } from "../types";
/**
* List-specific auto-selection logic hook.
* Handles auto-selecting bookings when navigating across pages in list view.
* For list view, data is loaded per page, so we can select immediately when bookings change.
*/
export function useListAutoSelector(bookings: BookingsGetOutput["bookings"]) {
const pendingSelection = useBookingDetailsSheetStore((state) => state.pendingSelection);
const setSelectedBookingUid = useBookingDetailsSheetStore((state) => state.setSelectedBookingUid);
const clearPendingSelection = useBookingDetailsSheetStore((state) => state.clearPendingSelection);
const setIsTransitioning = useBookingDetailsSheetStore((state) => state.setIsTransitioning);
const bookingsRef = useRef(bookings);
useEffect(() => {
const hasBookingsChanged = bookings !== bookingsRef.current;
// Always track current bookings to avoid stale ref when pendingSelection is later set
bookingsRef.current = bookings;
// Early return if no pending selection
if (!pendingSelection) return;
if (!hasBookingsChanged) return;
if (bookings.length > 0) {
const bookingToSelect = pendingSelection === "first" ? bookings[0] : bookings[bookings.length - 1];
setSelectedBookingUid(bookingToSelect.uid);
}
// Always clear transition state and pending selection after handling
setIsTransitioning(false);
clearPendingSelection();
}, [bookings, pendingSelection, setSelectedBookingUid, clearPendingSelection, setIsTransitioning]);
}
@@ -0,0 +1,75 @@
import { useMemo, useEffect } from "react";
import { trpc } from "@calcom/trpc/react";
import type { NavigationCapabilities } from "../store/bookingDetailsSheetStore";
interface UseListNavigationCapabilitiesProps {
limit: number;
offset: number;
totalCount: number | undefined;
setPageIndex: (index: number) => void;
queryInput: Parameters<typeof trpc.viewer.bookings.get.useQuery>[0];
}
/**
* List view navigation capabilities adapter.
* Provides pagination-specific navigation logic for the booking details sheet.
*
* This hook:
* - Calculates page boundaries based on limit/offset
* - Prefetches the next page when available (using the same query params as parent)
* - Provides methods to navigate between pages
*/
export function useListNavigationCapabilities({
limit,
offset,
totalCount,
setPageIndex,
queryInput,
}: UseListNavigationCapabilitiesProps): NavigationCapabilities {
const trpcUtils = trpc.useUtils();
const currentPageIndex = limit > 0 ? offset / limit : 0;
// Calculate if there are more pages
const hasNextPage = useMemo(() => {
if (!totalCount) return false;
return offset + limit < totalCount;
}, [offset, limit, totalCount]);
const hasPreviousPage = offset > 0;
// Build query params for next page by reusing parent's query input
const nextPageParams = useMemo(
() => ({
...queryInput,
offset: (currentPageIndex + 1) * limit,
}),
[queryInput, currentPageIndex, limit]
);
// Prefetch next page when it exists
useEffect(() => {
if (hasNextPage) {
trpcUtils.viewer.bookings.get.prefetch(nextPageParams);
}
}, [hasNextPage, nextPageParams, trpcUtils]);
return useMemo(
() => ({
canNavigateToNextPeriod: () => hasNextPage,
canNavigateToPreviousPeriod: () => hasPreviousPage,
requestNextPeriod: () => {
if (!hasNextPage) return;
setPageIndex(currentPageIndex + 1);
},
requestPreviousPeriod: () => {
if (!hasPreviousPage) return;
setPageIndex(currentPageIndex - 1);
},
}),
[hasNextPage, hasPreviousPage, currentPageIndex, setPageIndex]
);
}
@@ -0,0 +1,54 @@
import type { Dayjs } from "@calcom/dayjs";
import { trpc } from "@calcom/trpc/react";
import { NAVIGATION_PROBE_WINDOW_MONTHS } from "../lib/constants";
import type { BookingListingStatus } from "../types";
interface UseNearestFutureBookingProps {
currentWeekStart: Dayjs;
filters: {
statuses: BookingListingStatus[];
userIds?: number[];
};
enabled?: boolean;
}
/**
* Probe hook to find the nearest future booking after the current week.
* Used for calendar view navigation to determine if there are any bookings
* in the future and to jump directly to the week containing that booking.
*
* Uses a broad date range (NAVIGATION_PROBE_WINDOW_MONTHS) but only fetches
* 1 booking (the nearest one) to minimize data transfer.
*/
export function useNearestFutureBooking({
currentWeekStart,
filters,
enabled = true,
}: UseNearestFutureBookingProps) {
// Search from the end of current week to NAVIGATION_PROBE_WINDOW_MONTHS months ahead
const afterDate = currentWeekStart.add(1, "week").startOf("day");
const beforeDate = currentWeekStart.add(NAVIGATION_PROBE_WINDOW_MONTHS, "month").endOf("day");
const query = trpc.viewer.bookings.get.useQuery(
{
filters: {
statuses: filters.statuses,
userIds: filters.userIds,
afterStartDate: afterDate.toISOString(),
beforeEndDate: beforeDate.toISOString(),
},
limit: 1, // Only need the nearest one
// Default sort for "upcoming" status is ASC, which gives us the nearest future booking
},
{
enabled,
staleTime: 5 * 60 * 1000, // 5 minutes
}
);
return {
nearestBooking: query.data?.bookings[0] ?? null,
isLoading: query.isLoading,
};
}
@@ -0,0 +1,55 @@
import type { Dayjs } from "@calcom/dayjs";
import { trpc } from "@calcom/trpc/react";
import { NAVIGATION_PROBE_WINDOW_MONTHS } from "../lib/constants";
import type { BookingListingStatus } from "../types";
interface UseNearestPastBookingProps {
currentWeekStart: Dayjs;
filters: {
statuses: BookingListingStatus[];
userIds?: number[];
};
enabled?: boolean;
}
/**
* Probe hook to find the nearest past booking before the current week.
* Used for calendar view navigation to determine if there are any bookings
* in the past and to jump directly to the week containing that booking.
*
* Uses a broad date range (NAVIGATION_PROBE_WINDOW_MONTHS) but only fetches
* 1 booking (the nearest one) to minimize data transfer.
* Uses descending sort to get the closest past booking first.
*/
export function useNearestPastBooking({
currentWeekStart,
filters,
enabled = true,
}: UseNearestPastBookingProps) {
// Search from NAVIGATION_PROBE_WINDOW_MONTHS months ago to the start of current week
const afterDate = currentWeekStart.subtract(NAVIGATION_PROBE_WINDOW_MONTHS, "month").startOf("day");
const beforeDate = currentWeekStart.startOf("day");
const query = trpc.viewer.bookings.get.useQuery(
{
filters: {
statuses: filters.statuses,
userIds: filters.userIds,
afterStartDate: afterDate.toISOString(),
beforeEndDate: beforeDate.toISOString(),
},
limit: 1, // Only need the nearest one
sort: { sortStart: "desc" }, // Get the closest past booking first
},
{
enabled,
staleTime: 5 * 60 * 1000, // 5 minutes
}
);
return {
nearestBooking: query.data?.bookings[0] ?? null,
isLoading: query.isLoading,
};
}
@@ -0,0 +1,6 @@
/**
* Number of months to look ahead/behind when probing for nearest bookings
* in calendar view navigation. This determines how far the navigation
* probe queries will search for bookings.
*/
export const NAVIGATION_PROBE_WINDOW_MONTHS = 3;
@@ -1,11 +0,0 @@
import { createParser } from "nuqs";
export const viewParser = createParser({
parse: (value: string) => {
if (value === "calendar") return "calendar";
return "list";
},
serialize: (value: "list" | "calendar") => value,
});
export type BookingView = "list" | "calendar";
@@ -1,27 +1,75 @@
"use client";
import React from "react";
import React, { useEffect, useRef, useState } from "react";
import { createStore, useStore } from "zustand";
import { useSelectedBookingUid } from "../hooks/useSelectedBookingUid";
import type { BookingOutput } from "../types";
export type PendingSelectionType = "first" | "last" | null;
/**
* Capabilities interface for view-specific navigation logic.
* This allows different views (list/calendar) to provide their own
* navigation implementations without the core store knowing about them.
*/
export interface NavigationCapabilities {
/**
* Check if navigation to the previous period is possible
* (e.g., previous page in list view, previous week in calendar view)
*/
canNavigateToPreviousPeriod: () => boolean;
/**
* Check if navigation to the next period is possible
* (e.g., next page in list view, next week in calendar view)
*/
canNavigateToNextPeriod: () => boolean;
/**
* Request navigation to the previous period.
* This should trigger the view state update (e.g., page index change).
* The parent component will handle fetching data and updating bookings.
*/
requestPreviousPeriod: () => void;
/**
* Request navigation to the next period.
* This should trigger the view state update (e.g., page index change).
* The parent component will handle fetching data and updating bookings.
*/
requestNextPeriod: () => void;
}
interface BookingDetailsSheetStore {
// State
// Core state (view-agnostic)
selectedBookingUid: string | null;
bookings: BookingOutput[];
isTransitioning: boolean;
pendingSelection: PendingSelectionType;
// Actions
// Injected capabilities (provided by adapters)
capabilities: NavigationCapabilities | null;
// Core actions
setSelectedBookingUid: (uid: string | null) => void;
setBookings: (bookings: BookingOutput[]) => void;
setCapabilities: (capabilities: NavigationCapabilities | null) => void;
clearSelection: () => void;
clearPendingSelection: () => void;
setIsTransitioning: (isTransitioning: boolean) => void;
// Computed getters (used via selectors)
// Navigation methods (delegates to capabilities)
navigateNext: () => Promise<void>;
navigatePrevious: () => Promise<void>;
// Simple getters (no view-specific logic)
getSelectedBooking: () => BookingOutput | null;
getNextBookingUid: () => string | null;
getPreviousBookingUid: () => string | null;
hasNext: () => boolean;
hasPrevious: () => boolean;
getCurrentIndex: () => number;
hasNextInArray: () => boolean;
hasPreviousInArray: () => boolean;
isFirstInArray: () => boolean;
isLastInArray: () => boolean;
}
type BookingDetailsSheetStoreType = ReturnType<typeof createBookingDetailsSheetStore>;
@@ -31,53 +79,90 @@ const createBookingDetailsSheetStore = (initialBookings: BookingOutput[] = []) =
// Initial state
selectedBookingUid: null,
bookings: initialBookings,
isTransitioning: false,
pendingSelection: null,
capabilities: null,
// Actions
setSelectedBookingUid: (uid) => set({ selectedBookingUid: uid }),
setSelectedBookingUid: (uid) => {
set({ selectedBookingUid: uid });
},
setBookings: (bookings) => set({ bookings }),
setCapabilities: (capabilities) => set({ capabilities }),
clearSelection: () => set({ selectedBookingUid: null }),
clearPendingSelection: () => set({ pendingSelection: null }),
setIsTransitioning: (isTransitioning) => set({ isTransitioning }),
// Computed getters
// Core getters
getSelectedBooking: () => {
const state = get();
if (!state.selectedBookingUid) return null;
return state.bookings.find((booking) => booking.uid === state.selectedBookingUid) ?? null;
},
getNextBookingUid: () => {
const state = get();
if (!state.selectedBookingUid) return null;
const currentIndex = state.bookings.findIndex((booking) => booking.uid === state.selectedBookingUid);
if (currentIndex === -1 || currentIndex >= state.bookings.length - 1) return null;
return state.bookings[currentIndex + 1].uid;
getCurrentIndex: () => {
const { bookings, selectedBookingUid } = get();
if (!selectedBookingUid) return -1;
return bookings.findIndex((b) => b.uid === selectedBookingUid);
},
getPreviousBookingUid: () => {
const state = get();
if (!state.selectedBookingUid) return null;
const currentIndex = state.bookings.findIndex((booking) => booking.uid === state.selectedBookingUid);
if (currentIndex <= 0) return null;
return state.bookings[currentIndex - 1].uid;
hasNextInArray: () => {
const { bookings, getCurrentIndex } = get();
const index = getCurrentIndex();
return index >= 0 && index < bookings.length - 1;
},
hasNext: () => {
const state = get();
if (!state.selectedBookingUid) return false;
const currentIndex = state.bookings.findIndex((booking) => booking.uid === state.selectedBookingUid);
return currentIndex >= 0 && currentIndex < state.bookings.length - 1;
hasPreviousInArray: () => {
const { getCurrentIndex } = get();
return getCurrentIndex() > 0;
},
hasPrevious: () => {
const state = get();
if (!state.selectedBookingUid) return false;
isFirstInArray: () => get().getCurrentIndex() === 0,
const currentIndex = state.bookings.findIndex((booking) => booking.uid === state.selectedBookingUid);
return currentIndex > 0;
isLastInArray: () => {
const { bookings, getCurrentIndex } = get();
return getCurrentIndex() === bookings.length - 1;
},
// Navigation methods
navigateNext: async () => {
const state = get();
// Try navigating within current array first
if (state.hasNextInArray()) {
const nextIndex = state.getCurrentIndex() + 1;
set({ selectedBookingUid: state.bookings[nextIndex].uid });
return;
}
// Need to navigate to next period
if (!state.capabilities?.canNavigateToNextPeriod()) return;
// Set pending selection to "first" and mark as transitioning
set({ isTransitioning: true, pendingSelection: "first" });
// Trigger page/week change synchronously - the parent component will handle the data fetch
state.capabilities.requestNextPeriod();
},
navigatePrevious: async () => {
const state = get();
// Try navigating within current array first
if (state.hasPreviousInArray()) {
const prevIndex = state.getCurrentIndex() - 1;
set({ selectedBookingUid: state.bookings[prevIndex].uid });
return;
}
// Need to navigate to previous period
if (!state.capabilities?.canNavigateToPreviousPeriod()) {
return;
}
// Set pending selection to "last" and mark as transitioning
set({ isTransitioning: true, pendingSelection: "last" });
// Trigger page/week change synchronously - the parent component will handle the data fetch
state.capabilities.requestPreviousPeriod();
},
}));
};
@@ -87,20 +172,34 @@ const BookingDetailsSheetStoreContext = React.createContext<BookingDetailsSheetS
export function BookingDetailsSheetStoreProvider({
children,
bookings,
capabilities,
}: {
children: React.ReactNode;
bookings: BookingOutput[];
capabilities?: NavigationCapabilities | null;
}) {
const [store] = React.useState(() => createBookingDetailsSheetStore(bookings));
const [store] = useState(() => createBookingDetailsSheetStore(bookings));
const [selectedBookingUidFromUrl, setSelectedBookingUidToUrl] = useSelectedBookingUid();
const previousBookingsRef = useRef<BookingOutput[]>(bookings);
// Update bookings in store
useEffect(() => {
const previousBookings = previousBookingsRef.current;
const hasBookingsChanged = bookings !== previousBookings;
if (!hasBookingsChanged) return;
// Update bookings when they change
React.useEffect(() => {
store.getState().setBookings(bookings);
previousBookingsRef.current = bookings;
}, [bookings, store]);
// Update capabilities when they change
useEffect(() => {
store.getState().setCapabilities(capabilities ?? null);
}, [capabilities, store]);
// Sync Store → URL
React.useEffect(() => {
useEffect(() => {
const unsubscribe = store.subscribe((state) => {
const storeUid = state.selectedBookingUid;
if (storeUid !== selectedBookingUidFromUrl) {
@@ -112,7 +211,7 @@ export function BookingDetailsSheetStoreProvider({
}, [selectedBookingUidFromUrl, setSelectedBookingUidToUrl, store]);
// Sync URL → Store
React.useEffect(() => {
useEffect(() => {
const currentStoreUid = store.getState().selectedBookingUid;
if (currentStoreUid !== selectedBookingUidFromUrl) {
store.getState().setSelectedBookingUid(selectedBookingUidFromUrl);

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