Compare commits

...
Author SHA1 Message Date
Lauris Skraucis 31da30b982 fix: specify atoms package.json repository (#24828) 2025-10-31 14:25:26 +00:00
Lauris Skraucis d149f8385e fix: run changesets on github hosted runner (#24825)
* fix: run changesets on github hosted runner

* refactor: run on ubuntu latest
2025-10-31 13:02:09 +00:00
Hariom BalharaandDevin AI ea5a0bd5f0 fix: Add Web Crypto feature detection for BotID in React Native WebView (#24790)
* fix: Add Web Crypto feature detection for BotID in React Native WebView

Fixes #24360

- Add feature detection to check for window.crypto availability before initializing BotID
- Skip BotID initialization in React Native WebView environments
- Prevents 'undefined is not an object (evaluating crypto)' error during booking
- BotID requires Web Crypto API which is not available in React Native WebView

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

* refactor: Use minimal feature detection for BotID initialization

- Remove React Native WebView-specific check
- Use pure feature detection for Web Crypto API
- Check for window.crypto.getRandomValues availability
- Add SSR guard with typeof window check

This approach is more robust and environment-agnostic, working across
all environments that lack Web Crypto support, not just React Native.

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-31 12:42:30 +00:00
github-actions[bot] 67c9fd92e1 chore: version packages (#24818)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-31 13:07:10 +01:00
50be997753 fix: Allow all team members to view hidden fields and UTM tracking data (#24619)
* Allow team admins to see hidden fields and UTM tracking data

- Add team admin permission check for booking data visibility
- Team admins can now view hidden booking field answers
- Team admins can now view UTM tracking parameters
- Support for managed events (parent team admins can view child event data)
- Maintain backward compatibility with existing host permissions

Fixes team admin access to booking details as requested in issue.

* feat: allow all team members to view hidden fields and UTM data

Previously only team admins could view hidden booking field answers and UTM tracking parameters. Now all team members can see this data, providing better transparency within teams.

- Changed from isTeamAdmin to isTeamMember check
- Team members (not just admins) can now view hidden booking fields
- Team members can now view UTM tracking parameters
- Maintains host permission (organizers can still see everything)
- Supports managed events (parent team members can view child event data)

* Address reviewer feedback: use existing membership pattern

- Remove custom checkIfUserIsTeamAdmin function
- Use existing userId_teamId pattern with prisma.membership.findUnique
- Revert PrismaPlugin and variable name changes
- Maintain same functionality with cleaner implementation

* fix: use existing isTeamAdmin function and remove unnecessary UI changes

* fix: Allow all team members to see hidden fields instead of just admins

- Replace isTeamAdmin with isTeamMember in booking view permissions
- Rename isLoggedInUserTeamAdmin to isLoggedInUserTeamMember for clarity
- All team members can now view hidden fields and UTM tracking data

Addresses feedback from @hariombalhara to allow all team members (not just admins) to see hidden booking data.

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

* fix: Correct import path for isTeamMember function

- Change import from non-existent @calcom/lib/server/queries/teams
- Use correct path @calcom/features/ee/teams/lib/queries
- Fixes type check error: Cannot find module

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

* refactor: Optimize isTeamMember check to call function only once

- Extract teamId using nullish coalescing operator
- Single isTeamMember call instead of two
- Cleaner and more efficient logic

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

* simplify

---------

Co-authored-by: naaa760 <neh6a683@gmail.com>
Co-authored-by: neha <neha@posthog.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-31 12:05:02 +00:00
Eunjae LeeandDevin AI f00c14d0c1 feat: implement booking calendar view with weekly layout (#24563)
* feat: implement booking calendar view with weekly layout

- Create reusable WeekCalendarView component that displays bookings in a weekly calendar format
- Replace EmptyScreen in BookingsCalendar with the new calendar view
- Calendar view includes:
  - Week navigation with Today, Previous, and Next buttons
  - 7-day week view with time slots from 12 AM to 11 PM
  - Bookings displayed as colored blocks positioned by time
  - Support for event type colors and status-based colors
  - Responsive design that fills the viewport
  - Hover tooltips showing booking details
- Filters remain functional at the top of the view

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

* refactor: use existing Calendar component from weeklyview

- Replace custom calendar implementation with the existing Calendar component
- Use parseEventTypeColor to properly handle event type colors
- Simplify implementation by leveraging existing calendar infrastructure
- Maintain week navigation and filtering functionality

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

* fix imports

* fix: replace isSameOrAfter with isAfter || isSame

- isSameOrAfter method does not exist in dayjs
- Use combination of isAfter and isSame instead

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

* remove useBookerTime dependency from weekly calendar view

* modify date range filters

* initial callback

* sort events

* clean up FilterBar

* add showBackgroundPattern

* update styles

* update style

* update styles

* fix type error

* fix error

* update styles

* update styles

* update event colors

* rename component

* persist weekStart on the url

* use FilterBar

* apply feedback

* extract BorderColor type

* use client

* clean up

* adjust styles

* color-code events

* rename borderColor to color

* restore class name

* add feature flag

* update class name

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-31 11:13:41 +00:00
Anik Dhabal Babu 8d4dd0241a fix: disable request reschedule for seated event (#24793) 2025-10-31 10:43:06 +00:00
chauhan_s 90f97c3c92 fix: preserve attendee names during round robin host reassignment when reassignmentReason is required (#24771) 2025-10-31 10:41:35 +00:00
Alex van Andel 1407886612 chore: Set shouldServeCache to falsy, unless specified input (#24778) 2025-10-31 10:34:31 +00:00
Eunjae LeeandDevin AI 139e47fe3b feat: include EventType.seatsPerTimeSlot in bookings get handler response (#24813)
- Add seatsPerTimeSlot field to EventType selection in bookings query
- Refactor conditional array pushes to use if statements instead of && operator to fix lint warnings

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-31 07:33:17 -03:00
Lauris Skraucis 9d4ecf2baa fix: changesets node version (#24812) 2025-10-31 10:27:55 +00:00
Dhairyashil ShindeandUdit Takkar 9d1cf3e70e feat: link advanced event type settings to docs (#24629)
* feat: link advanced event type settings to docs

* Limits and Assignment tab settings

* remove testing code

* add noopener noreferrer rel to links

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-10-31 15:47:11 +05:30
sean-brydon 6beb279a27 chore: add missing metadata i18n strings (#24814)
## What does this PR do?

- Adds new localization strings for various onboarding sections including team and organization settings

## Visual Demo (For contributors especially)

N/A - This PR adds a new component for animations and localization strings that will be used in the onboarding flow.

## Mandatory Tasks (DO NOT REMOVE)

- [ ] I have self-reviewed the code.
- [ ] I have updated the developer docs in /docs if this PR makes changes that would require a documentation change. If N/A, write N/A here and check the checkbox.
- [ ] I confirm automated tests are in place that prove my fix is effective or that my feature works.

## How should this be tested?

- Navigate through the onboarding flow to verify that page transitions are smooth with the new animation component
- Check that the new localization strings appear correctly in the UI for team and organization setup screens
- Verify that the `AnimatePresence` component properly handles exit animations when moving between onboarding steps

## Checklist

- I have read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- My code follows the style guidelines of this project
- I have commented my code, particularly in hard-to-understand areas
- I have checked if my changes generate no new warnings
2025-10-31 10:05:22 +00:00
sean-brydon 69e4b12f52 fix: Use index DB instead of local storage for onboarding (#24798)
## What does this PR do?

Implements an IndexedDB storage adapter for the onboarding store to replace localStorage. This allows storing much larger data (up to 50% of available disk space) compared to localStorage's limited quota, making it suitable for storing base64-encoded images like logos and banners during onboarding. The implementation includes a fallback to localStorage if IndexedDB is unavailable.

Sometimes when you uploaded a large image or banner you get this error. Even if its within the bounds of 10mb (which is what we allow)
![image.png](https://app.graphite.dev/user-attachments/assets/f9fad1b6-5c8a-438b-8948-ea299c2ce832.png)

## Visual Demo (For contributors especially)

N/A - This is an infrastructure change with no visual component.

## Mandatory Tasks (DO NOT REMOVE)

- [x] I have self-reviewed the code.
- [x] I have updated the developer docs in /docs if this PR makes changes that would require a documentation change. N/A
- [x] I confirm automated tests are in place that prove my fix is effective or that my feature works.

## How should this be tested?

- Test the onboarding flow and verify that data persists between page refreshes
- Test uploading large images during onboarding to ensure they're properly stored
- Test in browsers with IndexedDB disabled to verify the localStorage fallback works correctly
- Verify that the onboarding state is properly maintained when navigating through the onboarding steps

## Checklist

- I have read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- My code follows the style guidelines of this project
- I have commented my code, particularly in hard-to-understand areas
- I have checked if my changes generate no new warnings
2025-10-31 09:53:14 +00:00
Anik Dhabal Babu b8098ed3ab test(e2e): optimize email verification timeout to 3 seconds (#24799) 2025-10-31 15:14:44 +05:30
sean-brydon db55b11034 chore(onboarding): redirect if part of orgs or not company email (#24800) 2025-10-31 08:13:23 +00:00
sean-brydon 2273b7ea24 chore: add signout button on onboarding v3 layout (#24801)
<img width="2778" height="1633" alt="CleanShot 2025-10-30 at 16 42 23" src="https://github.com/user-attachments/assets/45d84980-26c9-4cb4-ac85-21dc174dba35" />



<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Added a sign-out button to the onboarding v3 layout and refactored Getting Started to use the shared OnboardingLayout for a consistent header and structure.

- **New Features**
  - Sign-out button in the onboarding footer using next-auth (redirects to /auth/logout).
  - Localized button label via useLocale.

- **Refactors**
  - Getting Started view now wraps content with OnboardingLayout.
  - Removed duplicated header and progress UI from the view.
  - Simplified content container and spacing; layout handles max width and flex.

<sup>Written for commit 830bfd1. Summary will update automatically on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
2025-10-31 08:07:37 +00:00
0a90212980 chore: CalendarEventBuilder - generate calendarEvent without PII using booking (#24678)
* chore: BookingHydrationService - generate calendarEvent without PII

* chore: wip add build calendarEvent from booking in CalEventBuilder

* chore: build calendar event from booking

* remove hydration service

* test: add comprehensive test for CalendarEventBuilder.fromBooking with all properties

- Add vi.mock() calls for external dependencies (getBookerBaseUrl, getTranslation, getCalEventResponses)
- Add comprehensive test 'should create a complete calendar event with all properties using a booking'
- Test covers team scheduling, video calls, seats/webinar, recurring events, custom fields, and all configuration flags
- Fix import paths in CalendarEventBuilder.ts to use @calcom/features prefix
- Fix timestamp format issues in existing tests to use toISOString()
- Fix destinationCalendar length expectations to account for user calendar
- Fix responses format in seats tests to match bookingResponsesSchema

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-31 07:31:50 +00:00
Alex van AndelandDevin AI 2efcfab715 Fix isEventTypeLoggingEnabled bug where empty string matches eventTypeId 0 (#24804)
When BOOKING_LOGGING_EVENT_IDS is set to an empty string, the current
implementation splits it into [''] and Number('') returns 0, causing
any event type with ID 0 to incorrectly trigger debug logging.

This fix:
- Filters out empty/invalid event IDs (NaN, <=0)
- Filters out empty usernames
- Only matches valid, positive event type IDs

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-30 19:32:17 +00:00
Anik Dhabal Babu 3c1dfe8b74 fix: return the actual error message for hitpay (#24792) 2025-10-30 17:58:04 +00:00
Rajiv Sahal e47ddf9835 chore: add handleCreateRecurringBooking prop to booker atom (#24786)
* chore: update Booker platform wrapper props

* fix: pass handle create recurring prop to useHandleBookEvent if prop present

* chore: add changesets
2025-10-30 15:50:17 +00:00
08c62b554e chore: update Booker atom docs (#24755)
* chore: update booker atom docs

* chore: implement PR feedback

* Update docs/platform/atoms/booker.mdx

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

---------

Co-authored-by: chauhan_s <somaychauhan98@gmail.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-10-30 15:46:45 +01:00
b0b94b91fe fix: allow org admin to cancel and reschedule seated bookings (#24640)
* refactor: move org admin related logic to org server

* fix: update cancellation logic to make sure org admin can cancel seated bookings of a team user

* fix: import path

* update bookings repository

* fix: update reschedule endpoint logic to let org admin reschedule bookings for a user

* refactor: make logic more simple

* chore: update platform libraries

* more refactors

* fix: add check to make sure org admin can reschedule booking

* chore: remove unused comments

* test: add e2e tests for org admin reschedule and cancel seated bookings

- Add seated event type creation for testing
- Add test for org admin rescheduling a seated booking for a managed user
- Add test for org admin canceling a full seated booking for a managed user
- Add test for org admin canceling a specific seat in a seated booking

These tests verify the functionality added in PR #24640 which allows
org admins to reschedule and cancel seated bookings for users in their
organization.

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

* chore: add cubic feedback

* fix: tests for seated booking management by org admin

* chore: implement PR feedback

* fixup

* chore: update docs

* fixup: get optional user from request and then pass it down to getBookingForReschedule

* fix: validate seatUid before checking booking cancellation status

Move canRescheduleBooking call to happen after input validation
(including seatUid validation for seated bookings) but before the
actual booking creation. This ensures that when trying to reschedule
a seated booking without providing seatUid, users get the proper
'seatUid required' error instead of 'booking has been cancelled' error.

Fixes failing e2e test: 'should not be able to reschedule seated
booking if seatUid is not provided'

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-30 16:43:05 +02:00
Eunjae LeeandDevin AI af61b6d341 feat: move bookings tabs to sidebar navigation (#24605)
* feat: move bookings tabs to sidebar navigation

- Add bookings sub-items (Upcoming, Unconfirmed, Recurring, Past, Cancelled) to sidebar navigation
- Remove HorizontalTabs component from bookings view
- Add redirect from /bookings to /bookings/upcoming for default navigation
- Parent Bookings menu item now automatically navigates to Upcoming sub-item

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

* clean up

* add e2e tests

* address feedback

* add horizontal tab back

* useBuildHref

* update preserveQueryParams logic

* put back lint rule

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-30 15:35:26 +01:00
Udit Takkar 28693aba96 feat: distributed tracking (#24717) 2025-10-30 14:12:22 +00:00
2dc0e2a6f2 fix: use highest role for org admins accessing sub team event types (#24748)
* fix: use highest role for org admins accessing sub team event types

When an org admin who is also a member of a sub team (but only as MEMBER role) tries to update a sub team event type, they were being denied access. This was because the permission check logic was using the team MEMBER role instead of checking both team and org roles and using the highest one.

This fix updates PermissionCheckService.checkPermission() and checkPermissions() to use the highest role between team membership and org membership, similar to how getEventTypePermissions() already works.

Changes:
- Added getHighestRole() helper method to determine the highest role between two roles
- Updated checkPermission() to check both team and org membership and use the highest role
- Updated checkPermissions() to use the same logic
- Added test case for org admin who is team member updating sub team event type

Fixes the issue where org admins get 'permission required eventType.update' errors when updating sub team event types.

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

* test: add PBAC-enabled test case for org admin accessing sub team event types

Adds a test case to verify that when PBAC is enabled, org admins who are also members of a sub team (but only with MEMBER role) can still access sub team event types through their org-level ADMIN permissions.

This complements the existing fallback (PBAC disabled) test case and ensures both code paths handle org admin permissions correctly.

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-30 13:59:48 +00:00
sean-brydon 6cbe1c1cf1 chore: Implement onboarding redirect logic in event-types and main page components (#24785)
## What does this PR do?

Currently we load /event-types for a few seconds while the onboarding hook catches up. This adds that logic to the serverside and not just client side to ensure onboarding is triggered

Adds server-side onboarding redirect checks to prevent users from accessing event types pages before completing onboarding. This implementation:

- Creates a new `checkOnboardingRedirect` utility function in a dedicated file
- Applies the redirect check on both the root page and event-types page
- Optimizes performance by using organizationId from session when available
- Handles email verification requirements before redirecting to onboarding
- Supports both legacy and v3 onboarding paths based on feature flags

## How should this be tested?

- Create a new user account that hasn't completed onboarding
- Attempt to access the root page or event-types page directly
- Verify you're redirected to the appropriate onboarding flow
- Test with email verification feature flag enabled/disabled
- Test with onboarding-v3 feature flag enabled/disabled
- Verify users who have completed onboarding can access event-types normally
- Verify organization users aren't redirected to onboarding

## Video Demo

Before:

[CleanShot 2025-10-30 at 10.54.41.mp4 <span class="graphite__hidden">(uploaded via Graphite)</span> <img class="graphite__hidden" src="https://app.graphite.dev/user-attachments/thumbnails/8282decc-a00d-4bc8-9215-fe1c4809fd8f.mp4" />](https://app.graphite.dev/user-attachments/video/8282decc-a00d-4bc8-9215-fe1c4809fd8f.mp4)

After

## [CleanShot 2025-10-30 at 10.54.06.mp4 <span class="graphite__hidden">(uploaded via Graphite)</span> <img class="graphite__hidden" src="https://app.graphite.dev/user-attachments/thumbnails/2acc7601-6c8c-496a-af4d-08dadef9aa2d.mp4" />](https://app.graphite.dev/user-attachments/video/2acc7601-6c8c-496a-af4d-08dadef9aa2d.mp4)

## Checklist

- [x] I have self-reviewed the code
- [x] I have updated the developer docs in /docs if this PR makes changes that would require a documentation change
- [x] I confirm automated tests are in place that prove my fix is effective or that my feature works
2025-10-30 13:44:11 +00:00
Rodrigo Ehlers 1c2da58d2a feat: add separator support to data table (#24752)
* feat: add separator support to data table

* chore: remove unnecessary comments

* chore: move files to more appropriate places

* fix: border bottom
2025-10-30 14:41:21 +01:00
Lauris Skraucis 7bfdaa3d64 fix: changesets release to npm (#24788)
* fix: changesets release to npm

* chore: resolve merge conflict
2025-10-30 13:33:56 +00:00
c38d2afcf5 fix: Optimized Slots setting not updating on children event types (#24208)
* fixed : Optimized Slots setting not updating on children event types

* updated tests to expect showOptimizedSlots sync

* fixed : Optimized Slots setting not updating on children event types

---------

Co-authored-by: Devanshu Sharma <devanshusharma658@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-10-30 12:34:48 +00:00
chauhan_s 807848cce2 fix: require notice threshold when type time (#24784)
* feat: enhance validation for confirmation policy notice threshold

* feat: add confirmation policy validation tests for event types
2025-10-30 14:20:56 +02:00
chauhan_s e9d409c0d5 fix: Modified teamName to be null when host is not fixed (#24728)
* fix: Modified teamName to be null when host is not fixed

* feat: Added test cases for round robin bookings reassignment with fixed and non-fixed hosts

* feat: improve team name handling in round robin reassignment

* refactor: improve booking title validation in reassignment tests
2025-10-30 14:00:25 +02:00
github-actions[bot] 0c43d60039 chore: version packages (#24729)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-30 11:53:22 +01:00
sean-brydon 1173822344 chore: invites to submit hook instead of relying on state (#24744)
## What does this PR do?
Pass invites to submit hook instead of relying on state. State can be out of sync on last step as its not commited onChange but onsubmit. 

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Pass the invite list directly to the onboarding submit hook to avoid stale state and ensure the final step submits the correct invites. Fixes missed or outdated invites during onboarding.

- **Bug Fixes**
  - submitOnboarding now accepts invites as a parameter and uses them for submission.
  - OrganizationInviteView passes invitesData to submitOnboarding instead of relying on store.invites.

<!-- End of auto-generated description by cubic. -->
2025-10-30 09:16:14 +00:00
Anik Dhabal Babu a934cec51a fix: exclude attendee reponses from calendar (#24739) 2025-10-30 07:06:42 +00:00
Hariom Balhara 257acc3f71 fix: Auto Assignment of team member to event when that member is (#24731)
auto-accepted during direct invite to sub-team
2025-10-30 09:42:41 +05:30
Alex van Andel 91063c41f8 chore: Determine to serve cache high level (#24756)
* chore: Determine to serve cache high level

* Fix invalid import

* fix: Cache test
2025-10-29 22:08:23 +00:00
Keith Williams 1e1298fdbc chore: Removed unneeded yarn reinstall API v2 Docker (#24772) 2025-10-29 19:19:52 +02:00
95120e1ff4 feat: add bookingRequiresAuthentication validation to 2024-04-15 booking controller (#24735)
* feat: add bookingRequiresAuthentication validation to 2024-04-15 booking controller

- Add checkBookingRequiresAuthentication method to validate authentication requirements
- Check if user is event type owner, host, team admin/owner, or org admin/owner
- Add comprehensive e2e tests for bookingRequiresAuthentication feature
- Ensure parity with 2024-08-13 controller implementation
- Fix type issue in setPlatformAttendeesEmails method

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

* refactor: move Prisma calls to repository pattern

- Add findByIdIncludeHostsAndTeamMembers method to EventTypeRepository
- Inject PrismaEventTypeRepository and PrismaTeamRepository into controller
- Replace direct Prisma calls with repository methods in checkBookingRequiresAuthentication
- Use getTeamByIdIfUserIsAdmin for org admin/owner check
- Add repositories to BookingsModule_2024_04_15 providers

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

* handle httpException in handleBookingErrors

* test: add test case for authenticated but unauthorized user booking

- Create second user who is not authorized to book the event type
- Verify that authenticated user without proper permissions receives 403 Forbidden
- Test validates that bookingRequiresAuthentication properly checks authorization levels
- Cleanup unauthorized user in afterAll hook

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

* fix: add accepted filter to team members and handle org-owned event types

Addresses PR comments from cubic-dev-ai and @ThyMinimalDev:

1. Add accepted: true filter to team.members query
   - Prevents pending team invitations from being treated as authorized
   - Also filter by role to only fetch ADMIN and OWNER roles
   - Reduces payload size and improves query performance

2. Add isOrganization field to team select
   - Enables proper handling of org-owned event types

3. Update authorization logic for org-owned event types
   - Handle case where team.isOrganization is true with no parent
   - Ensure org admins/owners are properly authorized for org-owned events
   - Matches behavior of 2024-08-13 controller

Changes:
- packages/features/eventtypes/repositories/eventTypeRepository.ts:
  * Add where clause to members query with accepted: true and role filter
  * Add isOrganization: true to team select
- apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts:
  * Update authorization logic to handle org-owned event types
  * Check if team.isOrganization is true when no parentId exists

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-29 16:48:16 +00:00
Keith WilliamsandDevin AI ee00f0d52c feat: Add official docker support (#24672)
* feat: Add official Docker support

* Adding scarf data support

* Comment out pushing the image for now

* Getting env vars ported

* Renamed the job to Release instead of Remote Release

* Move the Dockerfile and docker-compose files to monorepo root

* Remove Slack notifications for failures for now

* Show database container status

* Setting env directly for testing

* Removing env var

* Adding container logs

* Change the volume

* fixing file paths

* Double-quotes wrecking things

* Fixing /calcom paths

* Update permission for scripts

* Fixed the Slack notification

* Updated Slack notification emojis

* Checking the workflow_dispatch input for checkout

* Commenting out the tag checkout for now since our new Docker files are not in main

* Added .dockerignore

* Remove the scarf data export

* Removed extra empty line

* refactor: Create reusable Docker build action for AMD64 and ARM support

- Extract common Docker build logic into reusable composite action
- Create separate workflows for AMD64 and ARM builds that run in parallel
- Both workflows use the same reusable action with platform-specific parameters
- ARM builds use ubuntu-24.04-arm runner and add -arm suffix to tags
- AMD64 builds use buildjet-4vcpu-ubuntu-2204 runner
- Remove old monolithic release-docker.yaml workflow

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

* Revert "refactor: Create reusable Docker build action for AMD64 and ARM support"

This reverts commit 66d2c1741e094e8d39b928b109edaf67b6a1cc8e.

* refactor: Add parallel AMD64 and ARM Docker builds using reusable action

- Create reusable composite action in .github/actions/docker-build-and-test
- Extract common Docker build, test, and push logic into the action
- Update release-docker.yaml to have two parallel jobs:
  - release-amd64: Builds for linux/amd64 on buildjet-4vcpu-ubuntu-2204
  - release-arm: Builds for arm64 on ubuntu-24.04-arm with -arm suffix
- Both jobs use the same reusable action with platform-specific parameters
- Maintains existing functionality while enabling parallel builds

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

* Update the ARM action to run on buildjet 4vCPU ARM

* Move the Dockerfile to apps/web

* Revert "Move the Dockerfile to apps/web"

This reverts commit fd91ebe5b4285cfa3416e6f869f567329ece8b23.

* Revert the arm machine back off build jet

* Use node 20

* Set push to true

* Remove Dockerfile.render

* Removed commented Docker lines

* Fixed read me

* Updated README for Docker support

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-10-29 15:31:02 +00:00
Udit TakkarandKeith Williams 246abe0dd9 perf: add docker ignore in root path (#24585)
* chore: add docker ignore in root path

* chore: add docker ignore in root path

* chore: remove

* chore: remove some more

* chore: remove some more

* Removed unnecessary whitespace

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2025-10-29 14:16:57 +00:00
Benny Joo 14e845ea1f perf: og image caching v2 (#23189) 2025-10-29 10:36:17 -03:00
8ba65c4450 fix: prevent base64 logo/banner storage in organization onboarding (#24761)
* fix: prevent base64 logo/banner storage in organization onboarding

- Add processOnboardingBrandAssets helper method to BaseOnboardingService
- Process base64 images and upload them before storing in database
- Use uploadAvatar with userId to avoid foreign key issues before Team exists
- Handle both create and update/resume flows
- Ensure OrganizationOnboarding and Team records store regular URLs not base64
- Fixes header size issues when logoUrl is added to session cookies

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

* fix: use processed URLs from OrganizationOnboarding record

- Update SelfHostedOnboardingService to use organizationOnboarding.logo/bannerUrl instead of raw input
- Update BillingEnabledOrgOnboardingService payment intent to use processed URLs
- Ensures Team records receive processed URLs, not base64 data

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

* refactor: remove unused variables and imports

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

* refactor: extract image processing to helper method and add tests

- Created private processImageField() method to eliminate code duplication
- Uses regex for more robust data URI and URL detection
- Processes logo and bannerUrl in parallel with Promise.all
- Added 2 important tests for base64 image processing:
  1. Verifies base64 conversion with correct resize options (bannerUrl uses maxSize: 1500)
  2. Validates undefined vs null semantics (no-op vs explicit clear)
- Fixed pre-existing lint warnings by replacing 'any' types with proper types

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

* Improve code

* Fixup organizationId

* simplify

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-10-29 18:39:56 +05:30
sean-brydon aff494f21c chore: refactor onboarding v3 to use reusable components (#24711)
\## What does this PR do?

Refactors the onboarding UI components to use shared layout components across personal and organization onboarding flows. This change:

- Creates reusable `OnboardingCard` and `OnboardingLayout` components
- Implements these components across all onboarding views
- Maintains consistent UI and reduces code duplication
- Improves maintainability by centralizing layout logic

## Visual Demo (For contributors especially)

#### Image Demo:

Before this change, each onboarding view had its own layout implementation with duplicated header, footer, and card structure. After this change, the shared components provide consistent styling and structure.

## Mandatory Tasks (DO NOT REMOVE)

- [x] I have self-reviewed the code.
- [x] I have updated the developer docs in /docs if this PR makes changes that would require a documentation change. If N/A, write N/A here and check the checkbox.
- [x] I confirm automated tests are in place that prove my fix is effective or that my feature works.

## How should this be tested?

- Navigate through the onboarding flow for both personal and organization accounts
- Verify that all screens maintain consistent layout and styling
- Check that progress indicators correctly show the current step
- Ensure all functionality (form submissions, navigation between steps) works as expected

## Checklist

- I have read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- My code follows the style guidelines of this project
- I have commented my code, particularly in hard-to-understand areas
- I have checked if my changes generate no new warnings
2025-10-29 12:51:21 +00:00
Lauris Skraucisandchauhan_s 18ad7e348e chore: make atoms release depend on trusted publisher (#24769)
Co-authored-by: chauhan_s <somaychauhan98@gmail.com>
2025-10-29 12:26:33 +00:00
145 changed files with 7232 additions and 1539 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@calcom/atoms": minor
---
Fix Vite “process is undefined” error by defining process.env in atoms build.
+36
View File
@@ -0,0 +1,36 @@
# Dependencies
node_modules
**/node_modules
# Build outputs
.next
**/.next
dist
**/dist
build
**/build
# Git
.git
.github
# Testing
coverage
**/coverage
test-results
**/test-results
.turbo
**/.turbo
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# OS
.DS_Store
Thumbs.db
# Examples and docs
docs
@@ -0,0 +1,182 @@
name: "Docker Build and Test"
description: "Reusable action to build and test Docker images for Cal.com"
inputs:
platform:
description: "Target platform (linux/amd64 or arm64)"
required: true
platform-suffix:
description: "Suffix to add to image tags (e.g., -arm)"
required: false
default: ""
dockerhub-username:
description: "Docker Hub username"
required: true
dockerhub-token:
description: "Docker Hub token"
required: true
github-token:
description: "GitHub token for GHCR"
required: true
postgres-user:
description: "PostgreSQL user"
required: true
postgres-password:
description: "PostgreSQL password"
required: true
postgres-db:
description: "PostgreSQL database name"
required: true
database-host:
description: "Database host"
required: true
push-image:
description: "Whether to push the built image"
required: false
default: "false"
runs:
using: "composite"
steps:
- name: Log in to the Docker Hub registry
uses: docker/login-action@v3
with:
username: ${{ inputs.dockerhub-username }}
password: ${{ inputs.dockerhub-token }}
logout: true
- name: Log in to the Github Container registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ inputs.github-token }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
docker.io/calendso/calendso
docker.io/calcom/cal.com
ghcr.io/calcom/cal.com
flavor: |
latest=${{ !github.event.release.prerelease }}
suffix=${{ inputs.platform-suffix }}
- name: Copy env
shell: bash
run: |
grep -o '^[^#]*' .env.example > .env
cat .env >> $GITHUB_ENV
echo "DATABASE_HOST=localhost:5432" >> $GITHUB_ENV
eval $(sed -e '/^#/d' -e 's/^/export /' -e 's/$/;/' .env) ;
- name: Start database
shell: bash
run: |
docker compose up -d database
- name: Show database logs and container status
shell: bash
run: |
echo "--- Container Status ---"
docker compose ps database
echo "--- Container Logs ---"
docker compose logs database
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: |
network=container:database
buildkitd-flags: |
--allow-insecure-entitlement security.insecure --allow-insecure-entitlement network.host
- name: Build image
id: docker_build
uses: docker/build-push-action@v6
with:
context: ./
file: ./Dockerfile
load: true
push: false
platforms: ${{ inputs.platform }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000
NEXT_PUBLIC_API_V2_URL=http://localhost:5555/api/v2
NEXT_PUBLIC_LICENSE_CONSENT=agree
DATABASE_URL=postgresql://${{ inputs.postgres-user }}:${{ inputs.postgres-password }}@${{ inputs.database-host }}/${{ inputs.postgres-db }}
DATABASE_DIRECT_URL=postgresql://${{ inputs.postgres-user }}:${{ inputs.postgres-password }}@${{ inputs.database-host }}/${{ inputs.postgres-db }}
- name: Test runtime
shell: bash
run: |
tags="${{ steps.meta.outputs.tags }}"
IFS=',' read -ra ADDR <<< "$tags"
tag=${ADDR[0]}
docker run --rm --network stack \
-p 3000:3000 \
-e DATABASE_URL=postgresql://${{ inputs.postgres-user }}:${{ inputs.postgres-password }}@database/${{ inputs.postgres-db }} \
-e DATABASE_DIRECT_URL=postgresql://${{ inputs.postgres-user }}:${{ inputs.postgres-password }}@database/${{ inputs.postgres-db }} \
-e NEXTAUTH_SECRET=${{ env.NEXTAUTH_SECRET }} \
-e CALENDSO_ENCRYPTION_KEY=${{ env.CALENDSO_ENCRYPTION_KEY }} \
$tag &
server_pid=$!
echo "Waiting for the server to start..."
sleep 120
echo http://localhost:3000/auth/login
for i in {1..60}; do
echo "Checking server health ($i/60)..."
response=$(curl -o /dev/null -s -w "%{http_code}" http://localhost:3000/auth/login)
echo "HTTP Status Code: $response"
if [[ "$response" == "200" ]] || [[ "$response" == "307" ]]; then
echo "Server is healthy"
kill $server_pid
exit 0
fi
sleep 1
done
echo "Server health check failed"
kill $server_pid
exit 1
env:
NEXTAUTH_SECRET: "EI4qqDpcfdvf4A+0aQEEx8JjHxHSy4uWiZw/F32K+pA="
CALENDSO_ENCRYPTION_KEY: "0zfLtY99wjeLnsM7qsa8xsT+Q0oSgnOL"
- name: Push image
id: docker_push
uses: docker/build-push-action@v6
if: ${{ inputs.push-image == 'true' && !github.event.release.prerelease }}
with:
context: ./
file: ./Dockerfile
push: true
platforms: ${{ inputs.platform }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000
NEXT_PUBLIC_API_V2_URL=http://localhost:5555/api/v2
NEXT_PUBLIC_LICENSE_CONSENT=agree
DATABASE_URL=postgresql://${{ inputs.postgres-user }}:${{ inputs.postgres-password }}@${{ inputs.database-host }}/${{ inputs.postgres-db }}
DATABASE_DIRECT_URL=postgresql://${{ inputs.postgres-user }}:${{ inputs.postgres-password }}@${{ inputs.database-host }}/${{ inputs.postgres-db }}
- name: Image digest
shell: bash
run: echo ${{ steps.docker_build.outputs.digest }}
- name: Cleanup
shell: bash
if: always()
run: |
docker compose down
+16 -9
View File
@@ -6,23 +6,31 @@ on:
- main
concurrency: ${{ github.workflow }}-${{ github.ref }}
permissions: {} # remove permissions and set explicitly under "permissions:" below
permissions:
id-token: write # for NPM provenance - developers can verify where and how it was built
contents: write # to create release by changesets/action below
pull-requests: write # to create pull request by changesets/action below
jobs:
release:
if: github.repository == 'calcom/cal.com' # prevent this action from running on forks
permissions:
id-token: write # for NPM provenance - developers can verify where and how it was built
contents: write # to create release by changesets/action below
pull-requests: write # to create pull request by changesets/action below
name: Release
runs-on: buildjet-4vcpu-ubuntu-2204
runs-on: ubuntu-latest
steps:
- name: Checkout Repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node and registry for OIDC
uses: actions/setup-node@v4
with:
node-version: "20.17.0"
registry-url: "https://registry.npmjs.org"
always-auth: false
- name: Setup node, yarn and install dependencies
uses: ./.github/actions/yarn-install
- name: Install npm at version supporting OIDC
run: npm install -g npm@11.5.1
- name: Create Release Pull Request or Publish to npm
id: changesets
uses: changesets/action@v1
@@ -33,8 +41,7 @@ jobs:
title: "chore: version packages" # PR title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_CONFIG_PROVENANCE: "true"
VITE_BOOKER_EMBED_OAUTH_CLIENT_ID: ${{ secrets.VITE_BOOKER_EMBED_OAUTH_CLIENT_ID }}
VITE_BOOKER_EMBED_API_URL: ${{ secrets.VITE_BOOKER_EMBED_API_URL }}
NEXT_PUBLIC_WEBAPP_URL: ${{ secrets.NEXT_PUBLIC_WEBAPP_URL }}
NEXT_PUBLIC_WEBAPP_URL: ${{ secrets.NEXT_PUBLIC_WEBAPP_URL }}
+91 -29
View File
@@ -1,10 +1,15 @@
name: "Release Docker"
on: # yamllint disable-line rule:truthy
release:
types:
- created
- published
env:
POSTGRES_USER: "unicorn_user"
POSTGRES_PASSWORD: "magical_password"
POSTGRES_DB: "calendso"
DATABASE_HOST: "database:5432"
on:
push:
tags:
- "v*"
# in case manual trigger is needed
workflow_dispatch:
inputs:
@@ -12,11 +17,9 @@ on: # yamllint disable-line rule:truthy
description: "v{Major}.{Minor}.{Patch}"
jobs:
release:
name: "Remote Release"
runs-on: "ubuntu-latest"
release-amd64:
name: "Release AMD64"
runs-on: buildjet-4vcpu-ubuntu-2204
steps:
- name: checkout
uses: actions/checkout@v4
@@ -24,25 +27,84 @@ jobs:
- name: "Determine tag"
run: 'echo "RELEASE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV'
- name: "Run remote release workflow"
uses: "actions/github-script@v6"
- name: Build and test Docker image
uses: ./.github/actions/docker-build-and-test
with:
# Requires a personal access token with Actions Read and write permissions on calcom/docker.
github-token: "${{ secrets.DOCKER_REPO_ACCESS_TOKEN }}"
script: |
try {
const response = await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: 'docker',
workflow_id: 'create-release.yaml',
ref: 'main',
inputs: {
"RELEASE_TAG": process.env.RELEASE_TAG
},
});
platform: "linux/amd64"
platform-suffix: ""
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
github-token: ${{ secrets.GITHUB_TOKEN }}
postgres-user: ${{ env.POSTGRES_USER }}
postgres-password: ${{ env.POSTGRES_PASSWORD }}
postgres-db: ${{ env.POSTGRES_DB }}
database-host: ${{ env.DATABASE_HOST }}
push-image: "true"
console.log(response);
} catch (error) {
console.error(error);
core.setFailed(error.message);
- name: Notify Slack on Success
if: success()
uses: slackapi/slack-github-action@v1.24.0
with:
payload: |
{
"text": ":large_green_circle: Workflow *${{ github.workflow }}* (AMD64) succeeded in job *${{ github.job }}*.\nSee: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_WEBHOOK_URL }}
- name: Notify Slack on Failure
if: failure()
uses: slackapi/slack-github-action@v1.24.0
with:
payload: |
{
"text": ":red_circle: Workflow *${{ github.workflow }}* (AMD64) failed in job *${{ github.job }}*.\nSee: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_WEBHOOK_URL }}
release-arm:
name: "Release ARM"
runs-on: ubuntu-24.04-arm
steps:
- name: checkout
uses: actions/checkout@v4
- name: "Determine tag"
run: 'echo "RELEASE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV'
- name: Build and test Docker image
uses: ./.github/actions/docker-build-and-test
with:
platform: "arm64"
platform-suffix: "-arm"
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
github-token: ${{ secrets.GITHUB_TOKEN }}
postgres-user: ${{ env.POSTGRES_USER }}
postgres-password: ${{ env.POSTGRES_PASSWORD }}
postgres-db: ${{ env.POSTGRES_DB }}
database-host: ${{ env.DATABASE_HOST }}
push-image: "true"
- name: Notify Slack on Success
if: success()
uses: slackapi/slack-github-action@v1.24.0
with:
payload: |
{
"text": ":large_green_circle: Workflow *${{ github.workflow }}* (ARM) succeeded in job *${{ github.job }}*.\nSee: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_WEBHOOK_URL }}
- name: Notify Slack on Failure
if: failure()
uses: slackapi/slack-github-action@v1.24.0
with:
payload: |
{
"text": ":red_circle: Workflow *${{ github.workflow }}* (ARM) failed in job *${{ github.job }}*.\nSee: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_WEBHOOK_URL }}
+91
View File
@@ -0,0 +1,91 @@
FROM --platform=$BUILDPLATFORM node:20 AS builder
WORKDIR /calcom
## If we want to read any ENV variable from .env file, we need to first accept and pass it as an argument to the Dockerfile
ARG NEXT_PUBLIC_LICENSE_CONSENT
ARG NEXT_PUBLIC_WEBSITE_TERMS_URL
ARG NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL
ARG CALCOM_TELEMETRY_DISABLED
ARG DATABASE_URL
ARG NEXTAUTH_SECRET=secret
ARG CALENDSO_ENCRYPTION_KEY=secret
ARG MAX_OLD_SPACE_SIZE=4096
ARG NEXT_PUBLIC_API_V2_URL
ARG CSP_POLICY
## We need these variables as required by Next.js build to create rewrites
ARG NEXT_PUBLIC_SINGLE_ORG_SLUG
ARG ORGANIZATIONS_ENABLED
ENV NEXT_PUBLIC_WEBAPP_URL=http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER \
NEXT_PUBLIC_API_V2_URL=$NEXT_PUBLIC_API_V2_URL \
NEXT_PUBLIC_LICENSE_CONSENT=$NEXT_PUBLIC_LICENSE_CONSENT \
NEXT_PUBLIC_WEBSITE_TERMS_URL=$NEXT_PUBLIC_WEBSITE_TERMS_URL \
NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL=$NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL \
CALCOM_TELEMETRY_DISABLED=$CALCOM_TELEMETRY_DISABLED \
DATABASE_URL=$DATABASE_URL \
DATABASE_DIRECT_URL=$DATABASE_URL \
NEXTAUTH_SECRET=${NEXTAUTH_SECRET} \
CALENDSO_ENCRYPTION_KEY=${CALENDSO_ENCRYPTION_KEY} \
NEXT_PUBLIC_SINGLE_ORG_SLUG=$NEXT_PUBLIC_SINGLE_ORG_SLUG \
ORGANIZATIONS_ENABLED=$ORGANIZATIONS_ENABLED \
NODE_OPTIONS=--max-old-space-size=${MAX_OLD_SPACE_SIZE} \
BUILD_STANDALONE=true \
CSP_POLICY=$CSP_POLICY
COPY package.json yarn.lock .yarnrc.yml playwright.config.ts turbo.json i18n.json ./
COPY .yarn ./.yarn
COPY apps/web ./apps/web
COPY apps/api/v2 ./apps/api/v2
COPY packages ./packages
COPY tests ./tests
RUN yarn config set httpTimeout 1200000
RUN npx turbo prune --scope=@calcom/web --scope=@calcom/trpc --docker
RUN yarn install
# Build and make embed servable from web/public/embed folder
RUN yarn workspace @calcom/trpc run build
RUN yarn --cwd packages/embeds/embed-core workspace @calcom/embed-core run build
RUN yarn --cwd apps/web workspace @calcom/web run build
RUN rm -rf node_modules/.cache .yarn/cache apps/web/.next/cache
FROM node:20 AS builder-two
WORKDIR /calcom
ARG NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000
ENV NODE_ENV=production
COPY package.json .yarnrc.yml turbo.json i18n.json ./
COPY .yarn ./.yarn
COPY --from=builder /calcom/yarn.lock ./yarn.lock
COPY --from=builder /calcom/node_modules ./node_modules
COPY --from=builder /calcom/packages ./packages
COPY --from=builder /calcom/apps/web ./apps/web
COPY --from=builder /calcom/packages/prisma/schema.prisma ./prisma/schema.prisma
COPY scripts scripts
RUN chmod +x scripts/*
# Save value used during this build stage. If NEXT_PUBLIC_WEBAPP_URL and BUILT_NEXT_PUBLIC_WEBAPP_URL differ at
# run-time, then start.sh will find/replace static values again.
ENV NEXT_PUBLIC_WEBAPP_URL=$NEXT_PUBLIC_WEBAPP_URL \
BUILT_NEXT_PUBLIC_WEBAPP_URL=$NEXT_PUBLIC_WEBAPP_URL
RUN scripts/replace-placeholder.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${NEXT_PUBLIC_WEBAPP_URL}
FROM node:20 AS runner
WORKDIR /calcom
COPY --from=builder-two /calcom ./
ARG NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000
ENV NEXT_PUBLIC_WEBAPP_URL=$NEXT_PUBLIC_WEBAPP_URL \
BUILT_NEXT_PUBLIC_WEBAPP_URL=$NEXT_PUBLIC_WEBAPP_URL
ENV NODE_ENV=production
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=30s --retries=5 \
CMD wget --spider http://localhost:3000 || exit 1
CMD ["/calcom/scripts/start.sh"]
+264 -9
View File
@@ -4,7 +4,7 @@
<img src="https://user-images.githubusercontent.com/8019099/210054112-5955e812-a76e-4160-9ddd-58f2c72f1cce.png" alt="Logo">
</a>
<h3 align="center">Cal.com (formerly Calendso)</h3>
<h3 align="center">Cal.com</h3>
<p align="center">
The open-source Calendly successor.
@@ -175,6 +175,7 @@ yarn dx
```
#### Development tip
1. Add `export NODE_OPTIONS=“--max-old-space-size=16384”` to your shell script to increase the memory limit for the node process. Alternatively, you can run this in your terminal before running the app. Replace 16384 with the amount of RAM you want to allocate to the node process.
2. Add `NEXT_PUBLIC_LOGGER_LEVEL={level}` to your .env file to control the logging verbosity for all tRPC queries and mutations.\
@@ -226,7 +227,7 @@ for Logger level to be set at info, for example.
3. Now open your psql shell with the DB you created: `psql -h localhost -U postgres -d <DB name>`
4. Inside the psql shell execute `\conninfo`. And you will get the following info.
4. Inside the psql shell execute `\conninfo`. And you will get the following info.
![image](https://user-images.githubusercontent.com/39329182/236612291-51d87f69-6dc1-4a23-bf4d-1ca1754e0a35.png)
5. Now extract all the info and add it to your DATABASE_URL. The url would look something like this
@@ -375,15 +376,268 @@ Executable doesn't exist at /Users/alice/Library/Caches/ms-playwright/chromium-1
### Docker
The Docker configuration for Cal.com is an effort powered by people within the community.
**Official support**: Our team will begin to officially support the Dockerfile and docker-compose resources in this
repository.
If you want to contribute to the Docker repository, [reply here](https://github.com/calcom/docker/discussions/32).
**Important**: Cal.com will **not** be supporting installations that use these Docker resources. While we provide and maintain the Docker configurations, support for Docker-based installations is the responsibility of the user.
The Docker configuration can be found [in our docker repository](https://github.com/calcom/docker).
This image can be found on DockerHub at [https://hub.docker.com/r/calcom/cal.com](https://hub.docker.com/r/calcom/cal.com).
Issues with Docker? Find your answer or open a new discussion [here](https://github.com/calcom/docker/discussions) to ask the community.
**Note for ARM Users**: Use the {version}-arm suffix for pulling images. Example: `docker pull calcom/cal.com:v5.6.19-arm`.
Cal.com, Inc. does not provide official support for Docker, but we will accept fixes and documentation. Use at your own risk.
#### Requirements
Make sure you have `docker` & `docker compose` installed on the server / system. Both are installed by most docker utilities, including Docker Desktop and Rancher Desktop.
Note: `docker compose` without the hyphen is now the primary method of using docker-compose, per the Docker documentation.
#### (Most users) Running Cal.com with Docker Compose
If you are evaluating Cal.com or running with minimal to no modifications, this option is for you.
1. Clone calcom/cal.com
```bash
git clone --recursive https://github.com/calcom/cal.com.git
```
2. Change into the directory
```bash
cd cal.com
```
3. Prepare your configuration: Rename `.env.example` to `.env` and then update `.env`
```bash
cp .env.example .env
```
Most configurations can be left as-is, but for configuration options see [Important Run-time variables](#important-run-time-variables) below.
**Push Notifications (VAPID Keys)**
If you see an error like:
```
Error: No key set vapidDetails.publicKey
```
This means your environment variables for Web Push are missing.
You must generate and set `NEXT_PUBLIC_VAPID_PUBLIC_KEY` and `VAPID_PRIVATE_KEY`.
Generate them with:
```bash
npx web-push generate-vapid-keys
```
Then update your `.env` file:
```env
NEXT_PUBLIC_VAPID_PUBLIC_KEY=your_public_key_here
VAPID_PRIVATE_KEY=your_private_key_here
```
Do **not** commit real keys to `.env.example` — only placeholders.
Update the appropriate values in your .env file, then proceed.
4. (optional) Pre-Pull the images by running the following command:
```bash
docker compose pull
```
This will use the default image locations as specified by `image:` in the docker-compose.yaml file.
Note: To aid with support, by default Scarf.sh is used as registry proxy for download metrics.
5. Start Cal.com via docker compose
(Most basic users, and for First Run) To run the complete stack, which includes a local Postgres database, Cal.com web app, and Prisma Studio:
```bash
docker compose up -d
```
To run Cal.com web app and Prisma Studio against a remote database, ensure that DATABASE_URL is configured for an available database and run:
```bash
docker compose up -d calcom studio
```
To run only the Cal.com web app, ensure that DATABASE_URL is configured for an available database and run:
```bash
docker compose up -d calcom
```
**Note: to run in attached mode for debugging, remove `-d` from your desired run command.**
6. Open a browser to [http://localhost:3000](http://localhost:3000), or your defined NEXT_PUBLIC_WEBAPP_URL. The first time you run Cal.com, a setup wizard will initialize. Define your first user, and you're ready to go!
#### Updating Cal.com
1. Stop the Cal.com stack
```bash
docker compose down
```
2. Pull the latest changes
```bash
docker compose pull
```
3. Update env vars as necessary.
4. Re-start the Cal.com stack
```bash
docker compose up -d
```
#### (Advanced users) Build and Run Cal.com
1. Clone calcom/docker.
```bash
git clone https://github.com/calcom/cal.com.git calcom-docker
```
2. Change into the directory
```bash
cd calcom-docker
```
3. Update the calcom submodule. This project depends on the Cal.com source code, which is included here as a Git submodule. To make sure you get everything you need, update the submodule with the command below.
```bash
git submodule update --remote --init
```
Note: DO NOT use recursive submodule update, otherwise you will receive a git authentication error.
4. Rename `.env.example` to `.env` and then update `.env`
For configuration options see [Build-time variables](#build-time-variables) below. Update the appropriate values in your .env file, then proceed.
5. Build the Cal.com docker image:
Note: Due to application configuration requirements, an available database is currently required during the build process.
a) If hosting elsewhere, configure the `DATABASE_URL` in the .env file, and skip the next step
b) If a local or temporary database is required, start a local database via docker compose.
```bash
docker compose up -d database
```
6. Build Cal.com via docker compose (DOCKER_BUILDKIT=0 must be provided to allow a network bridge to be used at build time. This requirement will be removed in the future)
```bash
DOCKER_BUILDKIT=0 docker compose build calcom
```
7. Start Cal.com via docker compose
(Most basic users, and for First Run) To run the complete stack, which includes a local Postgres database, Cal.com web app, and Prisma Studio:
```bash
docker compose up -d
```
To run Cal.com web app and Prisma Studio against a remote database, ensure that DATABASE_URL is configured for an available database and run:
```bash
docker compose up -d calcom studio
```
To run only the Cal.com web app, ensure that DATABASE_URL is configured for an available database and run:
```bash
docker compose up -d calcom
```
**Note: to run in attached mode for debugging, remove `-d` from your desired run command.**
8. Open a browser to [http://localhost:3000](http://localhost:3000), or your defined NEXT_PUBLIC_WEBAPP_URL. The first time you run Cal.com, a setup wizard will initialize. Define your first user, and you're ready to go!
#### Configuration
##### Important Run-time variables
These variables must also be provided at runtime
| Variable | Description | Required | Default |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------- |
| DATABASE_URL | database url with credentials - if using a connection pooler, this setting should point there | required | `postgresql://unicorn_user:magical_password@database:5432/calendso` |
| CALCOM_LICENSE_KEY | Enterprise License Key | optional | |
| NEXT_PUBLIC_WEBAPP_URL | Base URL of the site. NOTE: if this value differs from the value used at build-time, there will be a slight delay during container start (to update the statically built files). | optional | `http://localhost:3000` |
| NEXTAUTH_URL | Location of the auth server. By default, this is the Cal.com docker instance itself. | optional | `{NEXT_PUBLIC_WEBAPP_URL}/api/auth` |
| NEXTAUTH_SECRET | must match build variable | required | `secret` |
| CALENDSO_ENCRYPTION_KEY | must match build variable | required | `secret` |
##### Build-time variables
If building the image yourself, these variables must be provided at the time of the docker build, and can be provided by updating the .env file. Currently, if you require changes to these variables, you must follow the instructions to build and publish your own image.
Updating these variables is not required for evaluation, but is required for running in production. Instructions for generating variables can be found in the [Cal.com instructions](https://github.com/calcom/cal.com)
| Variable | Description | Required | Default |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -------- | ------------------------------------------------------------------- |
| DATABASE_URL | database url with credentials - if using a connection pooler, this setting should point there | required | `postgresql://unicorn_user:magical_password@database:5432/calendso` |
| MAX_OLD_SPACE_SIZE | Needed for Nodejs/NPM build options | required | 4096 |
| NEXT_PUBLIC_LICENSE_CONSENT | license consent - true/false | required | |
| NEXTAUTH_SECRET | Cookie encryption key | required | `secret` |
| CALENDSO_ENCRYPTION_KEY | Authentication encryption key | required | `secret` |
| NEXT_PUBLIC_WEBAPP_URL | Base URL injected into static files | optional | `http://localhost:3000` |
| NEXT_PUBLIC_WEBSITE_TERMS_URL | custom URL for terms and conditions website | optional | `https://cal.com/terms` |
| NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL | custom URL for privacy policy website | optional | `https://cal.com/privacy` |
| NEXT_PUBLIC_API_V2_URL | URL for the v2 API, only required for custom integrations or custom booking experiences using [Cal.com Platform](https://cal.com/platform) | optional | |
| CALCOM_TELEMETRY_DISABLED | Allow Cal.com to collect anonymous usage data (set to `1` to disable) | optional | |
| NEXT_PUBLIC_SINGLE_ORG_SLUG | Required if ORGANIZATIONS_ENABLED is true | optional | |
| ORGANIZATIONS_ENABLED | Used for Enterprise or Organizations plan | optional | |
#### Troubleshooting
##### SSL edge termination
If running behind a load balancer which handles SSL certificates, you will need to add the environmental variable `NODE_TLS_REJECT_UNAUTHORIZED=0` to prevent requests from being rejected. Only do this if you know what you are doing and trust the services/load-balancers directing traffic to your service.
##### Failed to commit changes: Invalid 'prisma.user.create()'
Certain versions may have trouble creating a user if the field `metadata` is empty. Using an empty json object `{}` as the field value should resolve this issue. Also, the `id` field will autoincrement, so you may also try leaving the value of `id` as empty.
##### CLIENT_FETCH_ERROR
If you experience this error, it may be the way the default Auth callback in the server is using the WEBAPP_URL as a base url. The container does not necessarily have access to the same DNS as your local machine, and therefor needs to be configured to resolve to itself. You may be able to correct this by configuring `NEXTAUTH_URL=http://localhost:3000/api/auth`, to help the backend loop back to itself.
```
docker-calcom-1 | @calcom/web:start: [next-auth][error][CLIENT_FETCH_ERROR]
docker-calcom-1 | @calcom/web:start: https://next-auth.js.org/errors#client_fetch_error request to http://testing.localhost:3000/api/auth/session failed, reason: getaddrinfo ENOTFOUND testing.localhost {
docker-calcom-1 | @calcom/web:start: error: {
docker-calcom-1 | @calcom/web:start: message: 'request to http://testing.localhost:3000/api/auth/session failed, reason: getaddrinfo ENOTFOUND testing.localhost',
docker-calcom-1 | @calcom/web:start: stack: 'FetchError: request to http://testing.localhost:3000/api/auth/session failed, reason: getaddrinfo ENOTFOUND testing.localhost\n' +
docker-calcom-1 | @calcom/web:start: ' at ClientRequest.<anonymous> (/calcom/node_modules/next/dist/compiled/node-fetch/index.js:1:65756)\n' +
docker-calcom-1 | @calcom/web:start: ' at ClientRequest.emit (node:events:513:28)\n' +
docker-calcom-1 | @calcom/web:start: ' at ClientRequest.emit (node:domain:489:12)\n' +
docker-calcom-1 | @calcom/web:start: ' at Socket.socketErrorListener (node:_http_client:494:9)\n' +
docker-calcom-1 | @calcom/web:start: ' at Socket.emit (node:events:513:28)\n' +
docker-calcom-1 | @calcom/web:start: ' at Socket.emit (node:domain:489:12)\n' +
docker-calcom-1 | @calcom/web:start: ' at emitErrorNT (node:internal/streams/destroy:157:8)\n' +
docker-calcom-1 | @calcom/web:start: ' at emitErrorCloseNT (node:internal/streams/destroy:122:3)\n' +
docker-calcom-1 | @calcom/web:start: ' at processTicksAndRejections (node:internal/process/task_queues:83:21)',
docker-calcom-1 | @calcom/web:start: name: 'FetchError'
docker-calcom-1 | @calcom/web:start: },
docker-calcom-1 | @calcom/web:start: url: 'http://testing.localhost:3000/api/auth/session',
docker-calcom-1 | @calcom/web:start: message: 'request to http://testing.localhost:3000/api/auth/session failed, reason: getaddrinfo ENOTFOUND testing.localhost'
docker-calcom-1 | @calcom/web:start: }
```
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=81cda9f7-a102-453b-ac01-51c35650bd70" />
### Railway
@@ -425,7 +679,7 @@ See the [roadmap project](https://cal.com/roadmap) for a list of proposed featur
Cal.com, Inc. is a commercial open source company, which means some parts of this open source repository require a commercial license. The concept is called "Open Core" where the core technology (99%) is fully open source, licensed under [AGPLv3](https://opensource.org/license/agpl-v3) and the last 1% is covered under a commercial license (["/ee" Enterprise Edition](https://github.com/calcom/cal.com/tree/main/packages/features/ee)) which we believe is entirely relevant for larger organisations that require enterprise features. Enterprise features are built by the core engineering team of Cal.com, Inc. which is hired in full-time. Find their compensation on https://cal.com/open.
> [!NOTE]
> [!NOTE]
> Our philosophy is simple, all "Singleplayer APIs" are open-source under AGPLv3. All commercial "Multiplayer APIs" are under a commercial license.
| | AGPLv3 | EE |
@@ -497,8 +751,8 @@ Don't code but still want to contribute? Join our [Discussions](https://github.c
- Set CSP_POLICY="non-strict" env variable, which enables [Strict CSP](https://web.dev/strict-csp/) except for unsafe-inline in style-src . If you have some custom changes in your instance, you might have to make some code change to make your instance CSP compatible. Right now it enables strict CSP only on login page and on other SSR pages it is enabled in Report only mode to detect possible issues. On, SSG pages it is still not supported.
## Single Org Mode
Refer to docs [here](./docs/self-hosting/guides/organization/single-organization-setup) for a detailed documentation with screenshots.
Refer to docs [here](./docs/self-hosting/guides/organization/single-organization-setup) for a detailed documentation with screenshots.
## Integrations
@@ -655,6 +909,7 @@ following
We use changesets to generate changelogs and publish public packages (packages with `private: true` are ignored).
An example of good readme is [atoms readme](https://github.com/calcom/cal.com/blob/main/packages/platform/atoms/README.md). Every public package must:
1. Follow semantic versioning when using changesets.
2. Mark breaking changes using `❗️Breaking change`
-6
View File
@@ -16,14 +16,8 @@ ENV DATABASE_URL=${DATABASE_URL}
COPY . .
RUN yarn install
# Build prisma schema and make sure that it is linked to v2 node_modules
RUN yarn workspace @calcom/api-v2 run generate-schemas
RUN rm -rf apps/api/v2/node_modules
RUN yarn install
RUN yarn workspace @calcom/platform-libraries run build
RUN yarn workspace @calcom/api-v2 run build
EXPOSE 80
@@ -9,6 +9,8 @@ import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/
import { InstantBookingModule } from "@/lib/modules/instant-booking.module";
import { RecurringBookingModule } from "@/lib/modules/recurring-booking.module";
import { RegularBookingModule } from "@/lib/modules/regular-booking.module";
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { BillingModule } from "@/modules/billing/billing.module";
@@ -55,6 +57,8 @@ import { Module } from "@nestjs/common";
AppsRepository,
CalendarsRepository,
SelectedCalendarsRepository,
PrismaEventTypeRepository,
PrismaTeamRepository,
],
controllers: [BookingsController_2024_04_15],
})
@@ -507,6 +507,122 @@ describe("Bookings Endpoints 2024-04-15", () => {
});
});
describe("event type booking requires authentication", () => {
let eventTypeRequiringAuthenticationId: number;
let unauthorizedUser: User;
let unauthorizedUserApiKeyString: string;
beforeAll(async () => {
const eventTypeRequiringAuthentication = await eventTypesRepositoryFixture.create(
{
title: `event-type-requiring-authentication-${randomString()}`,
slug: `event-type-requiring-authentication-${randomString()}`,
length: 60,
requiresConfirmation: true,
bookingRequiresAuthentication: true,
},
user.id
);
eventTypeRequiringAuthenticationId = eventTypeRequiringAuthentication.id;
const unauthorizedUserEmail = `unauthorized-user-${randomString()}@api.com`;
unauthorizedUser = await userRepositoryFixture.create({
email: unauthorizedUserEmail,
});
const { keyString } = await apiKeysRepositoryFixture.createApiKey(unauthorizedUser.id, null);
unauthorizedUserApiKeyString = keyString;
});
afterAll(async () => {
if (unauthorizedUser) {
await userRepositoryFixture.deleteByEmail(unauthorizedUser.email);
}
});
it("can't be booked without credentials", async () => {
const body: CreateBookingInput_2024_04_15 = {
start: "2040-05-23T09:30:00.000Z",
end: "2040-05-23T10:30:00.000Z",
eventTypeId: eventTypeRequiringAuthenticationId,
timeZone: "Europe/London",
language: "en",
metadata: {},
hashedLink: "",
responses: {
name: "External Attendee",
email: "external@example.com",
location: {
value: "link",
optionValue: "",
},
},
};
await request(app.getHttpServer()).post("/v2/bookings").send(body).expect(401);
});
it("can't be booked with unauthorized user credentials", async () => {
const body: CreateBookingInput_2024_04_15 = {
start: "2040-05-23T10:30:00.000Z",
end: "2040-05-23T11:30:00.000Z",
eventTypeId: eventTypeRequiringAuthenticationId,
timeZone: "Europe/London",
language: "en",
metadata: {},
hashedLink: "",
responses: {
name: "External Attendee",
email: "external@example.com",
location: {
value: "link",
optionValue: "",
},
},
};
await request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set({ Authorization: `Bearer cal_test_${unauthorizedUserApiKeyString}` })
.expect(403);
});
it("can be booked with event type owner credentials", async () => {
const body: CreateBookingInput_2024_04_15 = {
start: "2040-05-23T11:30:00.000Z",
end: "2040-05-23T12:30:00.000Z",
eventTypeId: eventTypeRequiringAuthenticationId,
timeZone: "Europe/London",
language: "en",
metadata: {},
hashedLink: "",
responses: {
name: "External Attendee",
email: "external@example.com",
location: {
value: "link",
optionValue: "",
},
},
};
const response = await request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(201);
const responseBody: ApiSuccessResponse<RegularBookingCreateResult> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.id).toBeDefined();
if (responseBody.data.id) {
await bookingsRepositoryFixture.deleteById(responseBody.data.id);
}
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
@@ -7,13 +7,20 @@ import { MarkNoShowOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/ma
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key";
import { VERSION_2024_04_15, VERSION_2024_06_11, VERSION_2024_06_14 } from "@/lib/api-versions";
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
import { InstantBookingCreateService } from "@/lib/services/instant-booking-create.service";
import { RecurringBookingService } from "@/lib/services/recurring-booking.service";
import { RegularBookingService } from "@/lib/services/regular-booking.service";
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
import {
AuthOptionalUser,
GetOptionalUser,
} from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { OptionalApiAuthGuard } from "@/modules/auth/guards/optional-api-auth/optional-api-auth.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { BillingService } from "@/modules/billing/services/billing.service";
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
@@ -38,6 +45,8 @@ import {
NotFoundException,
UseGuards,
BadRequestException,
UnauthorizedException,
ForbiddenException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { ApiQuery, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
@@ -113,7 +122,9 @@ export class BookingsController_2024_04_15 {
private readonly usersService: UsersService,
private readonly regularBookingService: RegularBookingService,
private readonly recurringBookingService: RecurringBookingService,
private readonly instantBookingCreateService: InstantBookingCreateService
private readonly instantBookingCreateService: InstantBookingCreateService,
private readonly eventTypeRepository: PrismaEventTypeRepository,
private readonly teamRepository: PrismaTeamRepository
) {}
@Get("/")
@@ -166,8 +177,12 @@ export class BookingsController_2024_04_15 {
}
@Get("/:bookingUid/reschedule")
async getBookingForReschedule(@Param("bookingUid") bookingUid: string): Promise<ApiResponse<unknown>> {
const booking = await getBookingForReschedule(bookingUid);
@UseGuards(OptionalApiAuthGuard)
async getBookingForReschedule(
@Param("bookingUid") bookingUid: string,
@GetOptionalUser() user: AuthOptionalUser
): Promise<ApiResponse<unknown>> {
const booking = await getBookingForReschedule(bookingUid, user?.id);
if (!booking) {
throw new NotFoundException(`Booking with UID=${bookingUid} does not exist.`);
@@ -190,6 +205,7 @@ export class BookingsController_2024_04_15 {
clientId?.toString() || (await this.getOAuthClientIdFromEventType(body.eventTypeId));
const { orgSlug, locationUrl } = body;
try {
await this.checkBookingRequiresAuthentication(req, body.eventTypeId);
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl, isEmbed);
const booking = await this.regularBookingService.createBooking({
bookingData: bookingRequest.body,
@@ -443,6 +459,48 @@ export class BookingsController_2024_04_15 {
return oAuthClientParams.platformClientId;
}
private async checkBookingRequiresAuthentication(req: Request, eventTypeId: number): Promise<void> {
const eventType = await this.eventTypeRepository.findByIdIncludeHostsAndTeamMembers({
id: eventTypeId,
});
if (!eventType?.bookingRequiresAuthentication) {
return;
}
const userId = await this.getOwnerId(req);
if (!userId) {
throw new UnauthorizedException(
"This event type requires authentication. Please provide valid credentials."
);
}
const isEventTypeOwner = eventType.userId === userId;
const isHost = eventType.hosts.some((host) => host.userId === userId);
const isTeamAdminOrOwner =
eventType.team?.members.some((member) => member.userId === userId) ?? false;
let isOrgAdminOrOwner = false;
if (eventType.team?.parentId) {
const orgTeam = await this.teamRepository.getTeamByIdIfUserIsAdmin({
userId,
teamId: eventType.team.parentId,
});
isOrgAdminOrOwner = !!orgTeam;
} else if (eventType.team?.isOrganization) {
isOrgAdminOrOwner = isTeamAdminOrOwner;
}
const isAuthorized = isEventTypeOwner || isHost || isTeamAdminOrOwner || isOrgAdminOrOwner;
if (!isAuthorized) {
throw new ForbiddenException(
"You are not authorized to book this event type. You must be the event type owner, a host, a team admin/owner, or an organization admin/owner."
);
}
}
private async getOAuthClientsParams(clientId: string, isEmbed = false): Promise<OAuthRequestParams> {
const res = { ...DEFAULT_PLATFORM_PARAMS };
@@ -502,7 +560,10 @@ export class BookingsController_2024_04_15 {
return clone as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
}
async setPlatformAttendeesEmails(requestBody: any, oAuthClientId: string): Promise<void> {
async setPlatformAttendeesEmails(
requestBody: { responses?: { email?: string; guests?: string[] } },
oAuthClientId: string
): Promise<void> {
if (requestBody?.responses?.email) {
requestBody.responses.email = await this.platformBookingsService.getPlatformAttendeeEmail(
requestBody.responses.email,
@@ -564,6 +625,9 @@ export class BookingsController_2024_04_15 {
if (err instanceof Error) {
const error = err as Error;
if (err instanceof HttpException) {
throw new HttpException(err.getResponse(), err.getStatus());
}
if (Object.values(ErrorCode).includes(error.message as unknown as ErrorCode)) {
throw new HttpException(error.message, 400);
}
@@ -290,7 +290,7 @@ export class BookingsController_2024_08_13 {
{ $ref: getSchemaPath(RescheduleSeatedBookingInput_2024_08_13) },
],
},
description: `Accepts different types of reschedule booking input: Reschedule Booking (Option 1) or Reschedule Seated Booking (Option 2).
description: `Accepts different types of reschedule booking input: Reschedule Booking (Option 1) or Reschedule Seated Booking (Option 2). If you're rescheduling a seated booking as org admin of booking host, pass booking input for Reschedule Booking (Option 1) along with your access token in the request header.
If you are rescheduling a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or
you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.`,
@@ -328,7 +328,7 @@ export class BookingsController_2024_08_13 {
\nCancelling seated bookings:
It is possible to cancel specific seat within a booking as an attendee or all of the seats as the host.
\n1. As an attendee - provide :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and seatUid in the request body \`{"seatUid": "123-123-123"}\` . This will remove this particular attendance from the booking.
\n2. As the host - host can cancel booking for all attendees aka for every seat. Provide :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and cancellationReason in the request body \`{"cancellationReason": "Will travel"}\` and \`Authorization: Bearer token\` request header where token is event type owner (host) credential. This will cancel the booking for all attendees.
\n2. As the host or org admin of host - host can cancel booking for all attendees aka for every seat, this also applies to org admins. Provide :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and cancellationReason in the request body \`{"cancellationReason": "Will travel"}\` and \`Authorization: Bearer token\` request header where token is event type owner (host) credential. This will cancel the booking for all attendees.
\nCancelling recurring seated bookings:
For recurring seated bookings it is not possible to cancel all of them with 1 call
@@ -70,6 +70,7 @@ describe("Managed user bookings 2024-08-13", () => {
let firstManagedUserEventTypeId: number;
let eventTypeRequiresConfirmationId: number;
let seatedEventTypeId: number;
const orgAdminManagedUserEmail = `managed-user-bookings-2024-08-13-org-admin-${randomString()}@api.com`;
let orgAdminManagedUser: CreateManagedUserData;
@@ -309,6 +310,20 @@ describe("Managed user bookings 2024-08-13", () => {
eventTypeRequiresConfirmationId = eventTypeRequiresConfirmation.id;
});
it("should create a seated event type for first managed user", async () => {
const seatedEventType = await eventTypesRepositoryFixture.create(
{
title: `managed-user-bookings-seated-event-type-${randomString()}`,
slug: `managed-user-bookings-seated-event-type-${randomString()}`,
length: 30,
seatsPerTimeSlot: 5,
seatsShowAttendees: true,
},
firstManagedUser.user.id
);
seatedEventTypeId = seatedEventType.id;
});
describe("bookings using original emails", () => {
it("managed user should be booked by managed user attendee and booking shows up in both users' bookings", async () => {
const body: CreateBookingInput_2024_08_13 = {
@@ -773,6 +788,107 @@ describe("Managed user bookings 2024-08-13", () => {
});
});
describe("seated booking management by org admin", () => {
let seatedBookingUid: string;
it("should create a seated booking with multiple attendees", async () => {
const bodyOne: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 10, 10, 0, 0)).toISOString(),
eventTypeId: seatedEventTypeId,
attendee: {
name: secondManagedUser.user.name!,
email: secondManagedUser.user.email,
timeZone: secondManagedUser.user.timeZone,
language: secondManagedUser.user.locale,
},
};
const responseOne = await request(app.getHttpServer())
.post("/v2/bookings")
.send(bodyOne)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201);
const responseBodyForFirstAttendee: ApiSuccessResponse<BookingOutput_2024_08_13> = responseOne.body;
expect(responseBodyForFirstAttendee.status).toEqual(SUCCESS_STATUS);
expect(responseBodyForFirstAttendee.data).toBeDefined();
const bookingDataForFirstAttendee = responseBodyForFirstAttendee.data as BookingOutput_2024_08_13;
seatedBookingUid = bookingDataForFirstAttendee.uid;
expect(bookingDataForFirstAttendee.attendees).toBeDefined();
expect(bookingDataForFirstAttendee.attendees.length).toBeGreaterThan(0);
const bodyTwo: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 10, 10, 0, 0)).toISOString(),
eventTypeId: seatedEventTypeId,
attendee: {
name: thirdManagedUser.user.name!,
email: thirdManagedUser.user.email,
timeZone: thirdManagedUser.user.timeZone,
language: thirdManagedUser.user.locale,
},
};
const responseTwo = await request(app.getHttpServer())
.post("/v2/bookings")
.send(bodyTwo)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201);
const responseBodyForSecondAttendee: ApiSuccessResponse<BookingOutput_2024_08_13> = responseTwo.body;
expect(responseBodyForSecondAttendee.status).toEqual(SUCCESS_STATUS);
expect(responseBodyForSecondAttendee.data).toBeDefined();
const bookingDataForSecondAttendee = responseBodyForSecondAttendee.data as BookingOutput_2024_08_13;
expect(bookingDataForSecondAttendee.attendees).toBeDefined();
expect(bookingDataForSecondAttendee.attendees.length).toBeGreaterThan(0);
});
it("should allow org admin to reschedule all seats in a seated booking for a managed user", async () => {
const newStartTime = new Date(Date.UTC(2030, 0, 10, 11, 0, 0)).toISOString();
const response = await request(app.getHttpServer())
.post(`/v2/bookings/${seatedBookingUid}/reschedule`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set("Authorization", `Bearer ${orgAdminManagedUser.accessToken}`)
.send({
start: newStartTime,
})
.expect(201);
const responseBody: ApiSuccessResponse<BookingOutput_2024_08_13> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const bookingData = responseBody.data as BookingOutput_2024_08_13;
expect(bookingData.start).toEqual(newStartTime);
seatedBookingUid = bookingData.uid;
});
it("should allow org admin to cancel all seats in a seated booking for a managed user", async () => {
const response = await request(app.getHttpServer())
.post(`/v2/bookings/${seatedBookingUid}/cancel`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set("Authorization", `Bearer ${orgAdminManagedUser.accessToken}`)
.send({
cancellationReason: "Org admin cancelled the booking",
})
.expect(200);
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const bookingData = responseBody.data as BookingOutput_2024_08_13;
expect(bookingData.status).toEqual("cancelled");
expect(bookingData.uid).toEqual(seatedBookingUid);
const cancelledBooking = await bookingsRepositoryFixture.getByUid(seatedBookingUid);
expect(cancelledBooking?.status).toEqual("CANCELLED");
});
});
describe("event type booking requires authentication", () => {
let eventTypeRequiringAuthenticationId: number;
@@ -23,10 +23,13 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import type { CreateBookingInput_2024_08_13 } from "@calcom/platform-types";
import type { Booking, User, PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Bookings Endpoints 2024-08-13", () => {
describe("Reassign bookings", () => {
let app: INestApplication;
@@ -47,12 +50,23 @@ describe("Bookings Endpoints 2024-08-13", () => {
const teamUserEmail = `reassign-bookings-2024-08-13-user1-${randomString()}@api.com`;
const teamUserEmail2 = `reassign-bookings-2024-08-13-user2-${randomString()}@api.com`;
const teamUserEmail3 = `reassign-bookings-2024-08-13-user3-${randomString()}@api.com`;
let teamUser1: User;
let teamUser2: User;
let teamUser3: User;
let teamRoundRobinEventTypeId: number;
let teamRoundRobinFixedHostEventTypeId: number;
let teamRoundRobinNonFixedEventTypeId: number;
let teamRoundRobinWithRescheduleReasonEventTypeId: number;
let teamRoundRobinNonFixedEventTypeTitle: string;
let teamRoundRobinFixedHostEventTypeTitle: string;
let teamRoundRobinWithRescheduleReasonEventTypeTitle: string;
let roundRobinBooking: Booking;
let rescheduleReasonBookingUid: string;
let rescheduleReasonBookingInitialHostId: number;
beforeAll(async () => {
const moduleRef = await withApiAuth(
@@ -96,23 +110,37 @@ describe("Bookings Endpoints 2024-08-13", () => {
teamUser1 = await userRepositoryFixture.create({
email: teamUserEmail,
locale: "it",
locale: "en",
name: `reassign-bookings-2024-08-13-user1-${randomString()}`,
});
teamUser2 = await userRepositoryFixture.create({
email: teamUserEmail2,
locale: "it",
locale: "en",
name: `reassign-bookings-2024-08-13-user2-${randomString()}`,
});
teamUser3 = await userRepositoryFixture.create({
email: teamUserEmail3,
locale: "en",
name: `reassign-bookings-2024-08-13-user3-${randomString()}`,
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: `reassign-bookings-2024-08-13-schedule-${randomString()}`,
timeZone: "Europe/Rome",
isDefault: true,
availabilities: [
{
days: [0, 1, 2, 3, 4, 5, 6], // All days of the week
startTime: new Date(Date.UTC(2024, 0, 1, 0, 0, 0)), // 00:00 UTC
endTime: new Date(Date.UTC(2024, 0, 1, 23, 59, 0)), // 23:59 UTC
},
],
};
await schedulesService.createUserSchedule(teamUser1.id, userSchedule);
await schedulesService.createUserSchedule(teamUser2.id, userSchedule);
await schedulesService.createUserSchedule(teamUser3.id, userSchedule);
await profileRepositoryFixture.create({
uid: `usr-${teamUser1.id}`,
@@ -144,6 +172,21 @@ describe("Bookings Endpoints 2024-08-13", () => {
},
});
await profileRepositoryFixture.create({
uid: `usr-${teamUser3.id}`,
username: teamUserEmail3,
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: teamUser3.id,
},
},
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
team: { connect: { id: team.id } },
@@ -158,6 +201,13 @@ describe("Bookings Endpoints 2024-08-13", () => {
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
team: { connect: { id: team.id } },
user: { connect: { id: teamUser3.id } },
accepted: true,
});
const team1EventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "ROUND_ROBIN",
team: {
@@ -230,12 +280,187 @@ describe("Bookings Endpoints 2024-08-13", () => {
create: {
email: "bob@gmail.com",
name: "Bob",
locale: "it",
locale: "en",
timeZone: "Europe/Rome",
},
},
});
const teamNonFixedEventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "ROUND_ROBIN",
team: {
connect: { id: team.id },
},
users: {
connect: [{ id: teamUser2.id }, { id: teamUser3.id }],
},
title: `reassign-bookings-2024-08-13-non-fixed-event-type-${randomString()}`,
slug: `reassign-bookings-2024-08-13-non-fixed-event-type-${randomString()}`,
length: 60,
assignAllTeamMembers: false,
bookingFields: [],
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
});
teamRoundRobinNonFixedEventTypeId = teamNonFixedEventType.id;
teamRoundRobinNonFixedEventTypeTitle = teamNonFixedEventType.title;
await hostsRepositoryFixture.create({
isFixed: false,
user: {
connect: {
id: teamUser2.id,
},
},
eventType: {
connect: {
id: teamNonFixedEventType.id,
},
},
});
await hostsRepositoryFixture.create({
isFixed: false,
user: {
connect: {
id: teamUser3.id,
},
},
eventType: {
connect: {
id: teamNonFixedEventType.id,
},
},
});
const team2EventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "ROUND_ROBIN",
team: {
connect: { id: team.id },
},
users: {
connect: [{ id: teamUser1.id }, { id: teamUser2.id }, { id: teamUser3.id }],
},
title: `reassign-bookings-2024-08-13-fixed-event-type-${randomString()}`,
slug: `reassign-bookings-2024-08-13-fixed-event-type-${randomString()}`,
length: 60,
assignAllTeamMembers: false,
bookingFields: [],
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
});
teamRoundRobinFixedHostEventTypeId = team2EventType.id;
teamRoundRobinFixedHostEventTypeTitle = team2EventType.title;
await hostsRepositoryFixture.create({
isFixed: true,
user: {
connect: {
id: teamUser1.id,
},
},
eventType: {
connect: {
id: team2EventType.id,
},
},
});
await hostsRepositoryFixture.create({
isFixed: false,
user: {
connect: {
id: teamUser2.id,
},
},
eventType: {
connect: {
id: team2EventType.id,
},
},
});
await hostsRepositoryFixture.create({
isFixed: false,
user: {
connect: {
id: teamUser3.id,
},
},
eventType: {
connect: {
id: team2EventType.id,
},
},
});
const teamEventTypeWithRescheduleReason = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "ROUND_ROBIN",
team: {
connect: { id: team.id },
},
users: {
connect: [{ id: teamUser1.id }, { id: teamUser2.id }],
},
title: `reassign-bookings-2024-08-13-reschedule-reason-event-type-${randomString()}`,
slug: `reassign-bookings-2024-08-13-reschedule-reason-event-type-${randomString()}`,
length: 60,
assignAllTeamMembers: false,
bookingFields: [
{
name: "rescheduleReason",
type: "textarea",
defaultLabel: "Reason for rescheduling",
required: true,
sources: [
{
id: "default",
type: "default",
label: "Default",
},
],
editable: "system",
views: [
{
id: "reschedule",
label: "Reschedule View",
},
],
},
],
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
});
teamRoundRobinWithRescheduleReasonEventTypeId = teamEventTypeWithRescheduleReason.id;
teamRoundRobinWithRescheduleReasonEventTypeTitle = teamEventTypeWithRescheduleReason.title;
await hostsRepositoryFixture.create({
isFixed: false,
user: {
connect: {
id: teamUser1.id,
},
},
eventType: {
connect: {
id: teamEventTypeWithRescheduleReason.id,
},
},
});
await hostsRepositoryFixture.create({
isFixed: false,
user: {
connect: {
id: teamUser2.id,
},
},
eventType: {
connect: {
id: teamEventTypeWithRescheduleReason.id,
},
},
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
@@ -307,6 +532,198 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
it("should have correct title when reassigning round robin booking with non-fixed host", async () => {
const nonFixedHostBookingBody: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2050, 0, 9, 13, 0, 0)).toISOString(),
eventTypeId: teamRoundRobinNonFixedEventTypeId,
attendee: {
name: "Charlie",
email: "charlie@gmail.com",
timeZone: "Europe/Rome",
language: "en",
},
meetingUrl: "https://meet.google.com/abc-def-ghi",
};
const createResponse = await request(app.getHttpServer())
.post("/v2/bookings")
.send(nonFixedHostBookingBody)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201);
const bookingUid = createResponse.body.data.uid;
const booking = await bookingsRepositoryFixture.getByUid(bookingUid);
expect(booking).toBeDefined();
expect(booking?.userId).toEqual(teamUser2.id);
const expectedInitialTitle = `${teamRoundRobinNonFixedEventTypeTitle} between ${teamUser2.name} and Charlie`;
expect(booking?.title).toEqual(expectedInitialTitle);
return request(app.getHttpServer())
.post(`/v2/bookings/${bookingUid}/reassign`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: ReassignBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: ReassignBookingOutput_2024_08_13["data"] = responseBody.data;
expect(data.bookingUid).toEqual(bookingUid);
expect(data.reassignedTo.id).toEqual(teamUser3.id);
const reassigned = await bookingsRepositoryFixture.getByUid(bookingUid);
expect(reassigned?.userId).toEqual(teamUser3.id);
const expectedReassignedTitle = `${teamRoundRobinNonFixedEventTypeTitle} between ${teamUser3.name} and Charlie`;
expect(reassigned?.title).toEqual(expectedReassignedTitle);
});
});
it("should have correct title when reassigning round robin booking with fixed host", async () => {
const fixedHostBookingBody: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId: teamRoundRobinFixedHostEventTypeId,
attendee: {
name: "Alice",
email: "alice@gmail.com",
timeZone: "Europe/Rome",
language: "en",
},
meetingUrl: "https://meet.google.com/abc-def-ghi",
};
const createResponse = await request(app.getHttpServer())
.post("/v2/bookings")
.send(fixedHostBookingBody)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201);
const bookingUid = createResponse.body.data.uid;
const booking = await bookingsRepositoryFixture.getByUid(bookingUid);
expect(booking).toBeDefined();
expect(booking?.userId).toEqual(teamUser1.id);
const expectedInitialTitle = `${teamRoundRobinFixedHostEventTypeTitle} between ${team.name} and Alice`;
expect(booking?.title).toEqual(expectedInitialTitle);
return request(app.getHttpServer())
.post(`/v2/bookings/${bookingUid}/reassign/${teamUser3.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: ReassignBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: ReassignBookingOutput_2024_08_13["data"] = responseBody.data;
expect(data.bookingUid).toEqual(bookingUid);
expect(data.reassignedTo.id).toEqual(teamUser1.id);
const reassigned = await bookingsRepositoryFixture.getByUid(bookingUid);
expect(reassigned?.userId).toEqual(teamUser1.id);
const expectedReassignedTitle = `${teamRoundRobinFixedHostEventTypeTitle} between ${team.name} and Alice`;
expect(reassigned?.title).toEqual(expectedReassignedTitle);
});
});
it("should preserve attendee name when reassigning round robin host manually with rescheduleReason required", async () => {
const bookingBody: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2050, 0, 10, 13, 0, 0)).toISOString(),
eventTypeId: teamRoundRobinWithRescheduleReasonEventTypeId,
attendee: {
name: "David",
email: "david@gmail.com",
timeZone: "Europe/Rome",
language: "en",
},
meetingUrl: "https://meet.google.com/abc-def-ghi",
};
const createResponse = await request(app.getHttpServer())
.post("/v2/bookings")
.send(bookingBody)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201);
const bookingUid = createResponse.body.data.uid;
rescheduleReasonBookingUid = bookingUid;
const booking = await bookingsRepositoryFixture.getByUid(bookingUid);
expect(booking).toBeDefined();
expect(booking?.userId).toBeDefined();
const initialHostId = booking!.userId!;
rescheduleReasonBookingInitialHostId = initialHostId;
const expectedInitialTitle = `${teamRoundRobinWithRescheduleReasonEventTypeTitle} between ${
booking?.userId === teamUser1.id ? teamUser1.name : teamUser2.name
} and David`;
expect(booking?.title).toEqual(expectedInitialTitle);
expect(booking?.title).not.toContain("Nameless");
const reassignToHostId = initialHostId === teamUser1.id ? teamUser2.id : teamUser1.id;
const reassignToHostName = reassignToHostId === teamUser1.id ? teamUser1.name : teamUser2.name;
return request(app.getHttpServer())
.post(`/v2/bookings/${bookingUid}/reassign/${reassignToHostId}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: ReassignBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: ReassignBookingOutput_2024_08_13["data"] = responseBody.data;
expect(data.bookingUid).toEqual(bookingUid);
expect(data.reassignedTo.id).toEqual(reassignToHostId);
const reassigned = await bookingsRepositoryFixture.getByUid(bookingUid);
expect(reassigned?.userId).toEqual(reassignToHostId);
const expectedReassignedTitle = `${teamRoundRobinWithRescheduleReasonEventTypeTitle} between ${reassignToHostName} and David`;
expect(reassigned?.title).toEqual(expectedReassignedTitle);
expect(reassigned?.title).not.toContain("Nameless");
});
});
it("should preserve attendee name when reassigning round robin host automatically with rescheduleReason required", async () => {
const bookingUid = rescheduleReasonBookingUid;
const booking = await bookingsRepositoryFixture.getByUid(bookingUid);
expect(booking).toBeDefined();
expect(booking?.userId).toBeDefined();
const currentHostId = booking!.userId!;
const initialHostId = rescheduleReasonBookingInitialHostId;
const initialHostName = initialHostId === teamUser1.id ? teamUser1.name : teamUser2.name;
expect(currentHostId).not.toEqual(initialHostId);
return request(app.getHttpServer())
.post(`/v2/bookings/${bookingUid}/reassign`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: ReassignBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: ReassignBookingOutput_2024_08_13["data"] = responseBody.data;
expect(data.bookingUid).toEqual(bookingUid);
expect(data.reassignedTo.id).toEqual(initialHostId);
const autoReassigned = await bookingsRepositoryFixture.getByUid(bookingUid);
expect(autoReassigned?.userId).toEqual(initialHostId);
const expectedAutoReassignedTitle = `${teamRoundRobinWithRescheduleReasonEventTypeTitle} between ${initialHostName} and David`;
expect(autoReassigned?.title).toEqual(expectedAutoReassignedTitle);
expect(autoReassigned?.title).not.toContain("Nameless");
});
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
@@ -325,9 +742,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(teamUser1.email);
await userRepositoryFixture.deleteByEmail(teamUserEmail2);
await userRepositoryFixture.deleteByEmail(teamUserEmail3);
await bookingsRepositoryFixture.deleteAllBookings(teamUser1.id, teamUser1.email);
await bookingsRepositoryFixture.deleteAllBookings(teamUser2.id, teamUser2.email);
await bookingsRepositoryFixture.deleteAllBookings(teamUser3.id, teamUser3.email);
await app.close();
});
});
});
});
@@ -50,6 +50,20 @@ export class BookingsRepository_2024_08_13 {
});
}
async getByUidWithUserIdAndSeatsReferencesCount(bookingUid: string) {
return this.dbRead.prisma.booking.findUnique({
where: {
uid: bookingUid,
},
select: {
userId: true,
seatsReferences: {
take: 1,
},
},
});
}
async getByUid(bookingUid: string) {
return this.dbRead.prisma.booking.findUnique({
where: {
@@ -49,6 +49,7 @@ import {
confirmBookingHandler,
getCalendarLinks,
} from "@calcom/platform-libraries";
import { PrismaOrgMembershipRepository } from "@calcom/platform-libraries/bookings";
import {
CreateBookingInput_2024_08_13,
CreateBookingInput,
@@ -64,6 +65,7 @@ import {
RescheduleBookingInput,
CancelBookingInput,
} from "@calcom/platform-types";
import type { RescheduleSeatedBookingInput_2024_08_13 } from "@calcom/platform-types";
import type { PrismaClient } from "@calcom/prisma";
import type { EventType, User, Team } from "@calcom/prisma/client";
@@ -762,16 +764,26 @@ export class BookingsService_2024_08_13 {
authUser: AuthOptionalUser
) {
try {
await this.canRescheduleBooking(bookingUid);
const isIndividualSeatRequest = this.isRescheduleSeatedBody(body);
const isIndividualSeatReschedule = await this.shouldRescheduleIndividualSeat(
bookingUid,
isIndividualSeatRequest,
authUser
);
const bookingRequest = await this.inputService.createRescheduleBookingRequest(
request,
bookingUid,
body
body,
isIndividualSeatReschedule
);
await this.canRescheduleBooking(bookingUid);
const booking = await this.regularBookingService.createBooking({
bookingData: bookingRequest.body,
bookingMeta: {
userId: bookingRequest.userId,
userId: bookingRequest.userId ?? authUser?.id,
hostname: bookingRequest.headers?.host || "",
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
@@ -840,6 +852,57 @@ export class BookingsService_2024_08_13 {
return booking;
}
async shouldRescheduleIndividualSeat(
bookingUid: string,
isIndividualSeatReschedule: boolean,
authUser: AuthOptionalUser
) {
const booking = await this.bookingsRepository.getByUidWithUserIdAndSeatsReferencesCount(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
}
const hasSeatsPresent = booking.seatsReferences.length > 0;
if (!hasSeatsPresent) return false;
return await this.isIndividualSeatOrOrgAdminReschedule(
isIndividualSeatReschedule,
booking.userId,
authUser?.id
);
}
async isIndividualSeatOrOrgAdminReschedule(
isIndividualSeatReschedule: boolean,
bookingUserId: number | null,
authUserId?: number | null
) {
if (isIndividualSeatReschedule) {
return true;
}
if (!authUserId) {
throw new Error(`No auth user found`);
}
if (!bookingUserId) {
throw new Error(`No user found for booking`);
}
const isOrgAdmin = await PrismaOrgMembershipRepository.isLoggedInUserOrgAdminOfBookingHost(
authUserId,
bookingUserId
);
return isOrgAdmin;
}
isRescheduleSeatedBody(body: RescheduleBookingInput): body is RescheduleSeatedBookingInput_2024_08_13 {
return "seatUid" in body;
}
async cancelBooking(
request: Request,
bookingUid: string,
@@ -507,11 +507,13 @@ export class InputBookingsService_2024_08_13 {
async createRescheduleBookingRequest(
request: Request,
bookingUid: string,
body: RescheduleBookingInput
body: RescheduleBookingInput,
isIndividualSeatReschedule: boolean
): Promise<BookingRequest> {
const bodyTransformed = this.isRescheduleSeatedBody(body)
? await this.transformInputRescheduleSeatedBooking(bookingUid, body)
: await this.transformInputRescheduleBooking(bookingUid, body);
const bodyTransformed =
isIndividualSeatReschedule && "seatUid" in body
? await this.transformInputRescheduleSeatedBooking(bookingUid, body)
: await this.transformInputRescheduleBooking(bookingUid, body, isIndividualSeatReschedule);
const oAuthClientParams = await this.platformBookingsService.getOAuthClientParams(
bodyTransformed.eventTypeId
@@ -606,7 +608,11 @@ export class InputBookingsService_2024_08_13 {
};
}
async transformInputRescheduleBooking(bookingUid: string, inputBooking: RescheduleBookingInput_2024_08_13) {
async transformInputRescheduleBooking(
bookingUid: string,
inputBooking: RescheduleBookingInput_2024_08_13,
isIndividualSeatReschedule: boolean
) {
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid=${bookingUid} not found`);
@@ -618,7 +624,7 @@ export class InputBookingsService_2024_08_13 {
if (!eventType) {
throw new NotFoundException(`Event type with id=${booking.eventTypeId} not found`);
}
if (eventType.seatsPerTimeSlot) {
if (eventType.seatsPerTimeSlot && !isIndividualSeatReschedule) {
throw new BadRequestException(
`Booking with uid=${bookingUid} is a seated booking which means you have to provide seatUid in the request body to specify which seat specifically you want to reschedule. First, fetch the booking using https://cal.com/docs/api-reference/v2/bookings/get-a-booking and then within the attendees array you will find the seatUid of the booking you want to reschedule. Second, provide the seatUid in the request body to specify which seat specifically you want to reschedule using the reschedule endpoint https://cal.com/docs/api-reference/v2/bookings/reschedule-a-booking#option-2`
);
@@ -652,7 +658,6 @@ export class InputBookingsService_2024_08_13 {
const startTime = DateTime.fromISO(inputBooking.start, { zone: "utc" }).setZone(attendee.timeZone);
const endTime = startTime.plus({ minutes: eventType.length });
return {
start: startTime.toISO(),
end: endTime.toISO(),
@@ -22,29 +22,17 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_06_14 } from "@calcom/platform-constants";
import {
BookingWindowPeriodInputTypeEnum_2024_06_14,
BookerLayoutsInputEnum_2024_06_14,
ConfirmationPolicyEnum,
NoticeThresholdUnitEnum,
FrequencyInput,
} from "@calcom/platform-enums";
import { BookingWindowPeriodInputTypeEnum_2024_06_14, BookerLayoutsInputEnum_2024_06_14, ConfirmationPolicyEnum, NoticeThresholdUnitEnum, FrequencyInput } from "@calcom/platform-enums";
import { SchedulingType } from "@calcom/platform-libraries";
import {
type ApiSuccessResponse,
type CreateEventTypeInput_2024_06_14,
type EventTypeOutput_2024_06_14,
type GuestsDefaultFieldOutput_2024_06_14,
type NameDefaultFieldInput_2024_06_14,
type NotesDefaultFieldInput_2024_06_14,
type SplitNameDefaultFieldOutput_2024_06_14,
type UpdateEventTypeInput_2024_06_14,
} from "@calcom/platform-types";
import { type ApiSuccessResponse, type CreateEventTypeInput_2024_06_14, type EventTypeOutput_2024_06_14, type GuestsDefaultFieldOutput_2024_06_14, type NameDefaultFieldInput_2024_06_14, type NotesDefaultFieldInput_2024_06_14, type SplitNameDefaultFieldOutput_2024_06_14, type UpdateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
import { FAILED_RECURRING_EVENT_TYPE_WITH_BOOKER_LIMITS_ERROR_MESSAGE } from "@calcom/platform-types/event-types/event-types_2024_06_14/inputs/validators/CantHaveRecurrenceAndBookerActiveBookingsLimit";
import { REQUIRES_AT_LEAST_ONE_PROPERTY_ERROR } from "@calcom/platform-types/utils/RequiresOneOfPropertiesWhenNotDisabled";
import type { PlatformOAuthClient, Team, User, Schedule, EventType } from "@calcom/prisma/client";
const orderBySlug = (a: { slug: string }, b: { slug: string }) => {
if (a.slug < b.slug) return -1;
if (a.slug > b.slug) return 1;
@@ -1641,6 +1629,197 @@ describe("Event types Endpoints", () => {
}
await app.close();
});
describe("confirmationPolicy", () => {
describe("negative tests", () => {
it("should not create an event type with type 'time' without noticeThreshold", async () => {
const body: CreateEventTypeInput_2024_06_14 = {
title: "Event requiring confirmation with time",
slug: "event-confirmation-time-missing-threshold",
description: "This event requires confirmation based on time.",
lengthInMinutes: 60,
confirmationPolicy: {
type: ConfirmationPolicyEnum.TIME,
blockUnconfirmedBookingsInBooker: true,
},
};
const response = await request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.send(body)
.expect(400);
expect(response.body.error).toBeDefined();
expect(response.status).toBe(400);
});
it("should not update an event type to type 'time' without noticeThreshold", async () => {
// First create an event type with type 'always'
const createBody: CreateEventTypeInput_2024_06_14 = {
title: "Event requiring confirmation always",
slug: "event-confirmation-always-to-time",
description: "This event always requires confirmation.",
lengthInMinutes: 60,
confirmationPolicy: {
type: ConfirmationPolicyEnum.ALWAYS,
blockUnconfirmedBookingsInBooker: true,
},
};
const createResponse = await request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.send(createBody)
.expect(201);
const createdEventType = createResponse.body.data;
// Try to update to type 'time' without noticeThreshold
const updateBody: UpdateEventTypeInput_2024_06_14 = {
confirmationPolicy: {
type: ConfirmationPolicyEnum.TIME,
blockUnconfirmedBookingsInBooker: true,
},
};
const updateResponse = await request(app.getHttpServer())
.patch(`/api/v2/event-types/${createdEventType.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.send(updateBody)
.expect(400);
expect(updateResponse.body.error).toBeDefined();
expect(updateResponse.status).toBe(400);
// Cleanup
await eventTypesRepositoryFixture.delete(createdEventType.id);
});
});
describe("positive tests", () => {
let eventTypeWithConfirmationTimeId: number;
it("should create an event type with type 'always'", async () => {
const body: CreateEventTypeInput_2024_06_14 = {
title: "Event requiring confirmation always",
slug: "event-confirmation-always",
description: "This event always requires confirmation.",
lengthInMinutes: 60,
confirmationPolicy: {
type: ConfirmationPolicyEnum.ALWAYS,
blockUnconfirmedBookingsInBooker: true,
},
};
return request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const createdEventType = responseBody.data;
expect(createdEventType).toHaveProperty("id");
expect(createdEventType.title).toEqual(body.title);
expect(createdEventType.confirmationPolicy).toEqual(body.confirmationPolicy);
await eventTypesRepositoryFixture.delete(createdEventType.id);
});
});
it("should create an event type with type 'time' and noticeThreshold", async () => {
const body: CreateEventTypeInput_2024_06_14 = {
title: "Event requiring confirmation with time",
slug: "event-confirmation-time-with-threshold",
description: "This event requires confirmation based on time.",
lengthInMinutes: 60,
confirmationPolicy: {
type: ConfirmationPolicyEnum.TIME,
noticeThreshold: {
unit: NoticeThresholdUnitEnum.HOURS,
count: 24,
},
blockUnconfirmedBookingsInBooker: true,
},
};
return request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const createdEventType = responseBody.data;
expect(createdEventType).toHaveProperty("id");
expect(createdEventType.title).toEqual(body.title);
expect(createdEventType.confirmationPolicy).toEqual(body.confirmationPolicy);
eventTypeWithConfirmationTimeId = createdEventType.id;
});
});
it("should update an event type from type 'time' to 'always'", async () => {
const body: UpdateEventTypeInput_2024_06_14 = {
confirmationPolicy: {
type: ConfirmationPolicyEnum.ALWAYS,
blockUnconfirmedBookingsInBooker: false,
},
};
return request(app.getHttpServer())
.patch(`/api/v2/event-types/${eventTypeWithConfirmationTimeId}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.send(body)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const updatedEventType = responseBody.data;
const policy = updatedEventType.confirmationPolicy as any;
expect(policy?.type).toEqual(ConfirmationPolicyEnum.ALWAYS);
expect(policy?.blockUnconfirmedBookingsInBooker).toEqual(false);
expect(policy?.noticeThreshold).toBeUndefined();
});
});
it("should update an event type from type 'always' to 'time' with noticeThreshold", async () => {
const body: UpdateEventTypeInput_2024_06_14 = {
confirmationPolicy: {
type: ConfirmationPolicyEnum.TIME,
noticeThreshold: {
unit: NoticeThresholdUnitEnum.MINUTES,
count: 120,
},
blockUnconfirmedBookingsInBooker: true,
},
};
return request(app.getHttpServer())
.patch(`/api/v2/event-types/${eventTypeWithConfirmationTimeId}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.send(body)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const updatedEventType = responseBody.data;
const policy = updatedEventType.confirmationPolicy as any;
expect(policy?.type).toEqual(ConfirmationPolicyEnum.TIME);
expect(policy?.noticeThreshold).toEqual({
unit: NoticeThresholdUnitEnum.MINUTES,
count: 120,
});
expect(policy?.blockUnconfirmedBookingsInBooker).toEqual(true);
await eventTypesRepositoryFixture.delete(eventTypeWithConfirmationTimeId);
});
});
});
});
});
describe("Handle event-types booking fields", () => {
@@ -2614,4 +2793,4 @@ describe("Event types Endpoints", () => {
await app.close();
});
});
});
});
+3
View File
@@ -0,0 +1,3 @@
.git
.github
.env.example
@@ -6,7 +6,9 @@ import { redirect } from "next/navigation";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -52,12 +54,18 @@ const Page = async ({ params }: PageProps) => {
canReadOthersBookings = teamIdsWithPermission.length > 0;
}
const featuresRepository = new FeaturesRepository(prisma);
const isCalendarViewEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
"booking-calendar-view"
);
return (
<ShellMainAppDir heading={t("bookings")} subtitle={t("bookings_description")}>
<BookingsList
status={parsed.data.status}
userId={session?.user?.id}
permissions={{ canReadOthersBookings }}
isCalendarViewEnabled={isCalendarViewEnabled}
/>
</ShellMainAppDir>
);
@@ -6,6 +6,7 @@ import { unstable_cache } from "next/cache";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { checkOnboardingRedirect } from "@calcom/features/auth/lib/onboardingUtils";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
import { eventTypesRouter } from "@calcom/trpc/server/routers/viewer/eventTypes/_router";
@@ -53,6 +54,17 @@ const Page = async ({ searchParams }: PageProps) => {
return redirect("/auth/login");
}
// Check if user needs onboarding and redirect before fetching event types data
// Use organizationId from session profile if available to avoid extra query
const organizationId = session.user.profile?.organizationId ?? null;
const onboardingPath = await checkOnboardingRedirect(session.user.id, {
checkEmailVerification: true,
organizationId,
});
if (onboardingPath) {
return redirect(onboardingPath);
}
const t = await getTranslate();
const filters = getTeamsFiltersFromQuery(_searchParams);
const userEventGroupsData = await getCachedEventGroups(_headers, _cookies, filters);
@@ -17,21 +17,21 @@ export const generateMetadata = async ({ params }: _PageProps) => {
if (!p.success) {
return notFound();
}
const props = await getStaticProps(p.data.slug);
const slugFromUrl = p.data.slug;
const props = await getStaticProps(slugFromUrl);
if (!props) {
notFound();
}
const { name, logo, description } = props.data;
const { name, logo, dirName: appStoreDirSlug, slug: appSlug, description } = props.data;
return await generateAppMetadata(
{ slug: logo, name, description },
{ slug: appStoreDirSlug ?? appSlug, logoUrl: logo, name, description },
() => name,
() => description,
undefined,
undefined,
`/apps/${p.data.slug}`
`/apps/${appSlug}`
);
};
@@ -0,0 +1,36 @@
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { isCompanyEmail } from "@calcom/features/ee/organizations/lib/utils";
import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { prisma } from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export default async function OrganizationOnboardingLayout({ children }: { children: React.ReactNode }) {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
return redirect("/auth/login");
}
const userEmail = session.user.email || "";
const userId = session.user.id;
const gettingStartedPath = await OnboardingPathService.getGettingStartedPath(prisma);
if (!isCompanyEmail(userEmail)) {
return redirect(gettingStartedPath);
}
const userRepository = new UserRepository(prisma);
const { organizations } = await userRepository.findOrganizations({ userId });
if (organizations.length > 0) {
return redirect(gettingStartedPath);
}
return children;
}
+7 -7
View File
@@ -66,10 +66,10 @@ export const _generateMetadata = async (
);
const image =
SEO_IMG_OGIMG +
constructGenericImage({
(await constructGenericImage({
title: metadata.title,
description: metadata.description,
});
}));
return {
...metadata,
@@ -80,7 +80,7 @@ export const _generateMetadata = async (
};
};
export const _generateMetadataForStaticPage = (
export const _generateMetadataForStaticPage = async (
title: string,
description: string,
hideBranding?: boolean,
@@ -108,10 +108,10 @@ export const _generateMetadataForStaticPage = (
};
const image =
SEO_IMG_OGIMG +
constructGenericImage({
(await constructGenericImage({
title: metadata.title,
description: metadata.description,
});
}));
return {
...metadata,
@@ -137,7 +137,7 @@ export const generateMeetingMetadata = async (
origin,
pathname
);
const image = SEO_IMG_OGIMG + constructMeetingImage(meeting);
const image = SEO_IMG_OGIMG + (await constructMeetingImage(meeting));
return {
...metadata,
@@ -164,7 +164,7 @@ export const generateAppMetadata = async (
pathname
);
const image = SEO_IMG_OGIMG + constructAppImage({ ...app, description: metadata.description });
const image = SEO_IMG_OGIMG + (await constructAppImage({ ...app, description: metadata.description }));
return {
...metadata,
@@ -1,6 +1,8 @@
import { NextRequest } from "next/server";
import { describe, expect, test, vi, beforeEach } from "vitest";
import { getOGImageVersion } from "@calcom/lib/OgImages";
import { GET } from "../route";
vi.mock("next/og", () => ({
@@ -14,14 +16,19 @@ vi.mock("next/og", () => ({
})),
}));
vi.mock("@calcom/lib/OgImages", () => ({
Meeting: vi.fn(() => null),
App: vi.fn(() => null),
Generic: vi.fn(() => null),
}));
vi.mock("@calcom/lib/OgImages", async (importOriginal) => {
return await importOriginal();
});
vi.mock("@calcom/lib/constants", () => ({
WEBAPP_URL: "http://localhost:3000",
vi.mock(import("@calcom/lib/constants"), async (importOriginal) => {
return await importOriginal();
});
vi.mock("@calcom/web/public/app-store/svg-hashes.json", () => ({
default: {
huddle01video: "81a0653b",
zoomvideo: "d1c78abf",
},
}));
global.fetch = vi.fn();
@@ -82,7 +89,7 @@ describe("GET /api/social/og/image", () => {
const response = await GET(request);
expect(response.status).toBe(404);
expect(await response.text()).toBe("What you're looking for is not here..");
expect(await response.text()).toBe("Wrong image type");
});
test("returns 404 when invalid type parameter is provided", async () => {
@@ -90,7 +97,7 @@ describe("GET /api/social/og/image", () => {
const response = await GET(request);
expect(response.status).toBe(404);
expect(await response.text()).toBe("What you're looking for is not here..");
expect(await response.text()).toBe("Wrong image type");
});
});
@@ -107,4 +114,33 @@ describe("GET /api/social/og/image", () => {
expect(await response.text()).toBe("Internal server error");
});
});
describe("getOGImageVersion with SVG hash", () => {
test("app type: ETag changes when SVG hash is provided", async () => {
const etagWithoutHash = await getOGImageVersion("app");
const etagWithHash = await getOGImageVersion("app", {
slug: "huddle01video",
svgHash: "81a0653b",
});
expect(etagWithoutHash).toBeTruthy();
expect(etagWithHash).toBeTruthy();
expect(etagWithHash).not.toBe(etagWithoutHash);
});
test("app type: different SVG hashes produce different ETags", async () => {
const etagHash1 = await getOGImageVersion("app", {
slug: "huddle01video",
svgHash: "81a0653b",
});
const etagHash2 = await getOGImageVersion("app", {
slug: "zoomvideo",
svgHash: "d1c78abf",
});
expect(etagHash1).toBeTruthy();
expect(etagHash2).toBeTruthy();
expect(etagHash1).not.toBe(etagHash2);
});
});
});
+28 -8
View File
@@ -3,7 +3,7 @@ import type { NextRequest } from "next/server";
import type { SatoriOptions } from "satori";
import { z, ZodError } from "zod";
import { Meeting, App, Generic } from "@calcom/lib/OgImages";
import { Meeting, App, Generic, getOGImageVersion } from "@calcom/lib/OgImages";
import { WEBAPP_URL } from "@calcom/lib/constants";
export const runtime = "edge";
@@ -22,6 +22,7 @@ const appSchema = z.object({
name: z.string(),
description: z.string(),
slug: z.string(),
logoUrl: z.string(),
});
const genericSchema = z.object({
@@ -74,6 +75,7 @@ async function handler(req: NextRequest) {
imageType,
});
const etag = await getOGImageVersion("meeting");
const img = new ImageResponse(
(
<Meeting
@@ -89,7 +91,9 @@ async function handler(req: NextRequest) {
status: 200,
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
"Cache-Control":
"public, max-age=31536000, immutable, s-maxage=31536000, stale-while-revalidate=31536000",
ETag: `"${etag}"`,
},
});
} catch (error) {
@@ -111,19 +115,32 @@ async function handler(req: NextRequest) {
}
case "app": {
try {
const { name, description, slug } = appSchema.parse({
const { name, description, slug, logoUrl } = appSchema.parse({
name: searchParams.get("name"),
description: searchParams.get("description"),
slug: searchParams.get("slug"),
logoUrl: searchParams.get("logoUrl"),
imageType,
});
const img = new ImageResponse(<App name={name} description={description} slug={slug} />, ogConfig);
// Get SVG hash for the app
const svgHashesModule = await import("@calcom/web/public/app-store/svg-hashes.json");
const SVG_HASHES = svgHashesModule.default ?? {};
const svgHash = SVG_HASHES[slug] ?? undefined;
const etag = await getOGImageVersion("app", { svgHash });
const img = new ImageResponse(
<App name={name} description={description} slug={slug} logoUrl={logoUrl} />,
ogConfig
);
return new Response(img.body, {
status: 200,
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
"Cache-Control":
"public, max-age=31536000, immutable, s-maxage=31536000, stale-while-revalidate=31536000",
ETag: `"${etag}"`,
},
});
} catch (error) {
@@ -151,13 +168,16 @@ async function handler(req: NextRequest) {
imageType,
});
const etag = await getOGImageVersion("generic");
const img = new ImageResponse(<Generic title={title} description={description} />, ogConfig);
return new Response(img.body, {
status: 200,
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
"Cache-Control":
"public, max-age=31536000, immutable, s-maxage=31536000, stale-while-revalidate=31536000",
ETag: `"${etag}"`,
},
});
} catch (error) {
@@ -178,9 +198,9 @@ async function handler(req: NextRequest) {
}
default:
return new Response("What you're looking for is not here..", { status: 404 });
return new Response("Wrong image type", { status: 404 });
}
} catch (error) {
} catch {
return new Response("Internal server error", { status: 500 });
}
}
+4
View File
@@ -0,0 +1,4 @@
declare module "@calcom/web/public/app-store/svg-hashes.json" {
const value: Record<string, string>;
export default value;
}
+4 -7
View File
@@ -10,13 +10,10 @@ import { lucideIconList } from "../../../../packages/ui/components/icon/icon-lis
import { IconGrid } from "./IconGrid";
export const dynamic = "force-static";
export const metadata: Metadata = _generateMetadataForStaticPage(
"Icons Showcase",
"",
undefined,
undefined,
"/icons"
);
export async function generateMetadata(): Promise<Metadata> {
return await _generateMetadataForStaticPage("Icons Showcase", "", undefined, undefined, "/icons");
}
const interFont = Inter({ subsets: ["latin"], variable: "--font-inter", preload: true, display: "swap" });
const calFont = localFont({
+12
View File
@@ -1,6 +1,7 @@
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { checkOnboardingRedirect } from "@calcom/features/auth/lib/onboardingUtils";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -11,6 +12,17 @@ const RedirectPage = async () => {
if (!session?.user?.id) {
redirect("/auth/login");
}
// Check if user needs onboarding and redirect before going to event-types
const organizationId = session.user.profile?.organizationId ?? null;
const onboardingPath = await checkOnboardingRedirect(session.user.id, {
checkEmailVerification: true,
organizationId,
});
if (onboardingPath) {
redirect(onboardingPath);
}
redirect("/event-types");
};
@@ -128,7 +128,9 @@ 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,
(isBookingInPast && !booking.eventType.allowReschedulingPastBookings) ||
isDisabledRescheduling ||
booking.seatsReferences.length > 0,
},
isBookingFromRoutingForm
? {
@@ -202,7 +202,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
id="locationInput"
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
required
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
defaultValue={
defaultLocation ? defaultLocation[eventLocationType.defaultValueVariable] : undefined
}
+6 -1
View File
@@ -50,7 +50,11 @@ export function onRouterTransitionStart(url: string, navigationType: "push" | "r
}
}
process.env.NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER === "1" &&
if (
process.env.NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER === "1" &&
typeof window !== "undefined" &&
typeof window.crypto?.getRandomValues === "function"
) {
initBotId({
protect: [
{
@@ -59,3 +63,4 @@ process.env.NEXT_PUBLIC_VERCEL_USE_BOTID_IN_BOOKER === "1" &&
},
],
});
}
@@ -1,23 +1,96 @@
"use client";
import type { Table as ReactTable } from "@tanstack/react-table";
import { createParser, useQueryState } from "nuqs";
import { useMemo, useCallback } from "react";
import { DataTableFilters, DataTableSegment } from "@calcom/features/data-table";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import dayjs from "@calcom/dayjs";
import {
DataTableFilters,
DataTableSegment,
useDataTable,
useFilterValue,
ZDateRangeFilterValue,
ColumnFilterType,
} from "@calcom/features/data-table";
import { CUSTOM_PRESET } from "@calcom/features/data-table/lib/dateRange";
import type { RowData, BookingListingStatus } from "../types";
import { BookingsCalendarView } from "./BookingsCalendarView";
type BookingsCalendarViewProps = {
status: BookingListingStatus;
table: ReactTable<RowData>;
};
const COLUMN_IDS_TO_HIDE = ["dateRange"];
const weekStartParser = createParser({
parse: (value: string) => {
const parsed = dayjs(value);
return parsed.isValid() ? parsed.startOf("week") : dayjs().startOf("week");
},
serialize: (value: dayjs.Dayjs) => value.format("YYYY-MM-DD"),
});
export function BookingsCalendar({ table }: BookingsCalendarViewProps) {
const { rows } = table.getRowModel();
const { updateFilter } = useDataTable();
const dateRange = useFilterValue("dateRange", ZDateRangeFilterValue)?.data;
const [currentWeekStart, setCurrentWeekStart] = useQueryState(
"weekStart",
weekStartParser.withDefault(dayjs().startOf("week"))
);
const bookings = useMemo(() => {
return rows
.filter((row) => row.original.type === "data")
.map((row) => (row.original.type === "data" ? row.original.booking : null))
.filter((booking): booking is NonNullable<typeof booking> => booking !== null);
}, [rows]);
const handleWeekStartChange = useCallback(
(newWeekStart: dayjs.Dayjs) => {
setCurrentWeekStart(newWeekStart);
const startDate = newWeekStart.toDate();
const endDate = newWeekStart.add(6, "day").toDate();
if (!dateRange) {
return;
}
const rangeStart = dateRange.startDate ? new Date(dateRange.startDate) : null;
const rangeEnd = dateRange.endDate ? new Date(dateRange.endDate) : null;
const needsStartUpdate = !rangeStart || startDate < rangeStart;
const needsEndUpdate = !rangeEnd || endDate > rangeEnd;
if (!needsStartUpdate && !needsEndUpdate) {
return;
}
const newStartDate = needsStartUpdate ? startDate : rangeStart;
const newEndDate = needsEndUpdate ? endDate : rangeEnd;
updateFilter("dateRange", {
type: ColumnFilterType.DATE_RANGE,
data: {
startDate: newStartDate.toISOString(),
endDate: newEndDate.toISOString(),
preset: CUSTOM_PRESET.value,
},
});
},
[dateRange, updateFilter, setCurrentWeekStart]
);
return (
<>
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<DataTableFilters.FilterBar table={table} />
<DataTableFilters.FilterBar table={table} columnIdsToHide={COLUMN_IDS_TO_HIDE} />
</div>
<div className="flex items-center gap-2">
@@ -26,9 +99,11 @@ export function BookingsCalendar({ table }: BookingsCalendarViewProps) {
<DataTableSegment.Select />
</div>
</div>
<div className="flex items-center justify-center pt-2 xl:pt-0">
<EmptyScreen Icon="calendar" headline="Calendar view" description="Calendar view is coming soon." />
</div>
<BookingsCalendarView
bookings={bookings}
currentWeekStart={currentWeekStart}
onWeekStartChange={handleWeekStartChange}
/>
</>
);
}
@@ -0,0 +1,150 @@
"use client";
import { useMemo, useEffect } from "react";
import dayjs from "@calcom/dayjs";
import { useTimePreferences } from "@calcom/features/bookings/lib";
import { Calendar } from "@calcom/features/calendars/weeklyview";
import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useGetTheme } from "@calcom/lib/hooks/useTheme";
import { Button } from "@calcom/ui/components/button";
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
import { Icon } from "@calcom/ui/components/icon";
import type { BookingOutput } from "../types";
type BookingsCalendarViewProps = {
bookings: BookingOutput[];
currentWeekStart: dayjs.Dayjs;
onWeekStartChange: (weekStart: dayjs.Dayjs) => void;
};
export function BookingsCalendarView({
bookings,
currentWeekStart,
onWeekStartChange,
}: BookingsCalendarViewProps) {
const { t } = useLocale();
const { timezone } = useTimePreferences();
const { resolvedTheme, forcedTheme } = useGetTheme();
const goToPreviousWeek = () => {
onWeekStartChange(currentWeekStart.subtract(1, "week"));
};
const goToNextWeek = () => {
onWeekStartChange(currentWeekStart.add(1, "week"));
};
const goToToday = () => {
onWeekStartChange(dayjs().startOf("week"));
};
const startDate = useMemo(() => currentWeekStart.toDate(), [currentWeekStart]);
const endDate = useMemo(() => currentWeekStart.add(6, "day").toDate(), [currentWeekStart]);
useEffect(() => {
onWeekStartChange(currentWeekStart);
}, []);
const events = useMemo<CalendarEvent[]>(() => {
const hasDarkTheme = !forcedTheme && resolvedTheme === "dark";
return bookings
.filter((booking) => {
const bookingStart = dayjs(booking.startTime);
return (
(bookingStart.isAfter(currentWeekStart) || bookingStart.isSame(currentWeekStart)) &&
bookingStart.isBefore(currentWeekStart.add(7, "day"))
);
})
.sort((a, b) => {
const startDiff = new Date(a.startTime).getTime() - new Date(b.startTime).getTime();
if (startDiff !== 0) return startDiff;
return new Date(a.endTime).getTime() - new Date(b.endTime).getTime();
})
.map((booking, idx) => {
// Parse eventTypeColor and extract the appropriate color based on theme
const eventTypeColor =
booking.eventType?.eventTypeColor &&
booking.eventType.eventTypeColor[hasDarkTheme ? "darkEventTypeColor" : "lightEventTypeColor"];
return {
id: idx,
title: booking.title,
start: new Date(booking.startTime),
end: new Date(booking.endTime),
options: {
status: booking.status,
...(eventTypeColor && { color: eventTypeColor }),
},
};
});
}, [bookings, currentWeekStart, resolvedTheme, forcedTheme]);
const weekStart = currentWeekStart;
const weekEnd = currentWeekStart.add(6, "day");
const startMonth = weekStart.format("MMM");
const endMonth = weekEnd.format("MMM");
const year = weekEnd.format("YYYY");
const weekRange =
startMonth === endMonth ? (
<>
<span className="text-emphasis">{`${startMonth} ${weekStart.format("D")} - ${weekEnd.format(
"D"
)}`}</span>
<span className="text-muted">, {year}</span>
</>
) : (
<>
<span className="text-emphasis">{`${weekStart.format("MMM D")} - ${weekEnd.format("MMM D")}`}</span>
<span className="text-muted">, {year}</span>
</>
);
return (
<div className="border-subtle flex h-[calc(100vh-260px)] min-h-[600px] flex-col rounded-2xl border">
<div className="mx-4 mt-4 flex items-center justify-between py-1.5">
<div className="flex items-center gap-2">
<h2 className="text-xl font-semibold">{weekRange}</h2>
</div>
<div className="flex items-center gap-2">
<Button color="secondary" onClick={goToToday} className="capitalize leading-4">
{t("today")}
</Button>
<ButtonGroup combined>
<Button color="secondary" onClick={goToPreviousWeek}>
<span className="sr-only">{t("view_previous_week")}</span>
<Icon name="chevron-left" className="h-4 w-4" />
</Button>
<Button color="secondary" onClick={goToNextWeek}>
<span className="sr-only">{t("view_next_week")}</span>
<Icon name="chevron-right" className="h-4 w-4" />
</Button>
</ButtonGroup>
</div>
</div>
<div className="flex-1 overflow-y-auto overflow-x-hidden rounded-2xl">
<Calendar
timezone={timezone}
sortEvents
startHour={0}
endHour={23}
events={events}
startDate={startDate}
endDate={endDate}
gridCellsPerHour={4}
hoverEventDuration={0}
showBackgroundPattern={false}
showBorder={false}
borderColor="subtle"
onEventClick={(_event) => {}}
hideHeader
/>
</div>
</div>
);
}
@@ -7,6 +7,7 @@ import { orgDomainConfig } from "@calcom/ee/organizations/lib/orgDomains";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import getBookingInfo from "@calcom/features/bookings/lib/getBookingInfo";
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { isTeamMember } from "@calcom/features/ee/teams/lib/queries";
import { getDefaultEvent } from "@calcom/features/eventtypes/lib/defaultEvents";
import { getBrandingForEventType } from "@calcom/features/profile/lib/getBranding";
import { shouldHideBrandingForEvent } from "@calcom/features/profile/lib/hideBranding";
@@ -183,6 +184,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
};
const isLoggedInUserHost = checkIfUserIsHost(userId);
const eventTeamId = eventType.team?.id ?? eventType.parent?.teamId;
const isLoggedInUserTeamMember = !!(userId && eventTeamId && (await isTeamMember(userId, eventTeamId)));
const canViewHiddenData = isLoggedInUserHost || isLoggedInUserTeamMember;
if (bookingInfo !== null && eventType.seatsPerTimeSlot) {
await handleSeatsEventTypeOnBooking(eventType, bookingInfo, seatReferenceUid, isLoggedInUserHost);
@@ -202,8 +207,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
},
});
if (!isLoggedInUserHost) {
// Removing hidden fields from responses
if (!canViewHiddenData) {
for (const key in bookingInfo.responses) {
const field = eventTypeRaw.bookingFields.find((field) => field.name === key);
if (field && !!field.hidden) {
@@ -215,7 +219,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const { currentOrgDomain } = orgDomainConfig(context.req);
async function getInternalNotePresets(teamId: number | null) {
if (!teamId || !isLoggedInUserHost) return [];
if (!teamId || !canViewHiddenData) return [];
return await prisma.internalNotePreset.findMany({
where: {
teamId,
@@ -233,8 +237,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
// Filter out organizer information if hideOrganizerEmail is true
const sanitizedPreviousBooking =
eventType.hideOrganizerEmail &&
previousBooking &&
previousBooking.rescheduledBy === bookingInfo.user?.email
previousBooking &&
previousBooking.rescheduledBy === bookingInfo.user?.email
? { ...previousBooking, rescheduledBy: bookingInfo.user?.name }
: previousBooking;
@@ -247,11 +251,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
hideBranding: isPlatformBooking
? true
: await shouldHideBrandingForEvent({
eventTypeId: eventType.id,
team: eventType?.parent?.team ?? eventType?.team,
owner: eventType.users[0] ?? null,
organizationId: session?.user?.profile?.organizationId ?? session?.user?.org?.id ?? null,
}),
eventTypeId: eventType.id,
team: eventType?.parent?.team ?? eventType?.team,
owner: eventType.users[0] ?? null,
organizationId: session?.user?.profile?.organizationId ?? session?.user?.org?.id ?? null,
}),
profile,
eventType,
recurringBookings: await getRecurringBookings(bookingInfo.recurringEventId),
@@ -264,6 +268,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
requiresLoginToUpdate,
rescheduledToUid,
isLoggedInUserHost,
canViewHiddenData,
internalNotePresets: internalNotes,
},
};
@@ -100,7 +100,14 @@ export default function Success(props: PageProps) {
const pathname = usePathname();
const searchParams = useCompatSearchParams();
const { eventType, bookingInfo, previousBooking, requiresLoginToUpdate, rescheduledToUid } = props;
const {
eventType,
bookingInfo,
previousBooking,
requiresLoginToUpdate,
rescheduledToUid,
canViewHiddenData,
} = props;
const {
allRemainingBookings,
@@ -381,7 +388,6 @@ export default function Success(props: PageProps) {
const isRescheduled = bookingInfo?.rescheduled;
const canCancelOrReschedule = !eventType?.disableCancelling || !eventType?.disableRescheduling;
const _canCancelAndReschedule = !eventType?.disableCancelling && !eventType?.disableRescheduling;
const canCancel = !eventType?.disableCancelling;
const canReschedule = !eventType?.disableRescheduling;
@@ -715,7 +721,7 @@ export default function Success(props: PageProps) {
</div>
</>
)}
{!!utmParams && isHost && (
{!!utmParams && canViewHiddenData && (
<>
<div className="mt-9 pr-2 font-medium sm:pr-0">{t("utm_params")}</div>
<div className="col-span-2 mb-2 ml-3 mt-9 sm:ml-0">
@@ -737,7 +743,7 @@ export default function Success(props: PageProps) {
{showUtmParams && (
<div className="col-span-2 mb-2 mt-2">
{Object.entries(utmParams).filter(([_, value]) => Boolean(value)).length >
0 ? (
0 ? (
<ul className="list-disc space-y-1 p-1 pl-5 sm:w-80">
{Object.entries(utmParams)
.filter(([_, value]) => Boolean(value))
@@ -845,11 +851,10 @@ export default function Success(props: PageProps) {
<span className="text-default inline">
<span className="underline" data-testid="reschedule-link">
<Link
href={`/reschedule/${seatReferenceUid || bookingInfo?.uid}${
currentUserEmail
? `?rescheduledBy=${encodeURIComponent(currentUserEmail)}`
: ""
}`}
href={`/reschedule/${seatReferenceUid || bookingInfo?.uid}${currentUserEmail
? `?rescheduledBy=${encodeURIComponent(currentUserEmail)}`
: ""
}`}
legacyBehavior>
{t("reschedule")}
</Link>
@@ -23,7 +23,6 @@ import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import { Alert } from "@calcom/ui/components/alert";
import type { HorizontalTabItemProps } from "@calcom/ui/components/navigation";
import { HorizontalTabs } from "@calcom/ui/components/navigation";
import type { VerticalTabItemProps } from "@calcom/ui/components/navigation";
import { WipeMyCalActionButton } from "@calcom/web/components/apps/wipemycalother/wipeMyCalActionButton";
import BookingListItem from "@components/booking/BookingListItem";
@@ -41,6 +40,7 @@ type BookingsProps = {
permissions: {
canReadOthersBookings: boolean;
};
isCalendarViewEnabled: boolean;
};
function useSystemSegments(userId?: number) {
@@ -90,14 +90,15 @@ const viewParser = createParser({
serialize: (value: "list" | "calendar") => value,
});
function BookingsContent({ status, permissions }: BookingsProps) {
const [view] = useQueryState("view", viewParser.withDefault("list"));
function BookingsContent({ status, permissions, isCalendarViewEnabled }: BookingsProps) {
const [_view] = useQueryState("view", viewParser.withDefault("list"));
// Force view to be "list" if calendar view is disabled
const view = isCalendarViewEnabled ? _view : "list";
const { t } = useLocale();
const user = useMeQuery().data;
const searchParams = useSearchParams();
// Generate dynamic tabs that preserve query parameters
const tabs: (VerticalTabItemProps | HorizontalTabItemProps)[] = useMemo(() => {
const tabs: HorizontalTabItemProps[] = useMemo(() => {
const queryString = searchParams?.toString() || "";
const baseTabConfigs = [
@@ -392,7 +393,7 @@ function BookingsContent({ status, permissions }: BookingsProps) {
return (
<div className="flex flex-col">
<div className="flex flex-row flex-wrap justify-between">
<div className="flex flex-row flex-wrap justify-between lg:hidden">
<HorizontalTabs
tabs={tabs.map((tab) => ({
...tab,
+1 -3
View File
@@ -86,9 +86,7 @@ function InsightsPageContent() {
className="flex flex-wrap items-center gap-2"
data-testid={`insights-filters-${isAll}-${teamId}-${userId}`}>
<OrgTeamsFilter />
<DataTableFilters.AddFilterButton table={table} hideWhenFilterApplied />
<DataTableFilters.ActiveFilters table={table} />
<DataTableFilters.AddFilterButton table={table} variant="sm" showWhenFilterApplied />
<DataTableFilters.FilterBar table={table} />
<DataTableFilters.ClearFiltersButton exclude={["startTime", "createdAt"]} />
<div className="grow" />
<Download />
@@ -8,11 +8,11 @@ import classNames from "@calcom/ui/classNames";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { type IconName } from "@calcom/ui/components/icon";
import { Logo } from "@calcom/ui/components/logo";
import { RadioAreaGroup } from "@calcom/ui/components/radio";
import { OnboardingContinuationPrompt } from "../components/onboarding-continuation-prompt";
import { PlanIcon } from "../components/plan-icon";
import { OnboardingLayout } from "../personal/_components/OnboardingLayout";
import { useOnboardingStore, type PlanType } from "../store/onboarding-store";
type OnboardingViewProps = {
@@ -77,28 +77,10 @@ export const OnboardingView = ({ userName, userEmail }: OnboardingViewProps) =>
});
return (
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip">
<>
<OnboardingContinuationPrompt />
{/* Header */}
<div className="flex w-full items-center justify-between px-6 py-4">
<Logo className="h-5 w-auto" />
{/* Progress dots - centered */}
<div className="absolute left-1/2 flex -translate-x-1/2 items-center justify-center gap-1">
<div className="bg-emphasis h-1.5 w-1.5 rounded-full" />
<div className="bg-subtle h-1 w-1 rounded-full" />
<div className="bg-subtle h-1 w-1 rounded-full" />
<div className="bg-subtle h-1 w-1 rounded-full" />
</div>
<div className="bg-muted flex items-center gap-2 rounded-full px-3 py-2">
<p className="text-emphasis font-mediumu text-sm leading-none">{userEmail}</p>
</div>
</div>
{/* Main content */}
<div className="flex h-full w-full items-start justify-center px-6 py-8">
<div className="flex w-full max-w-[600px] flex-col gap-6">
<OnboardingLayout userEmail={userEmail} currentStep={1}>
<div className="flex w-full flex-col gap-6">
{/* Card */}
<div className="bg-muted border-muted relative rounded-xl border p-1">
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
@@ -173,7 +155,7 @@ export const OnboardingView = ({ userName, userEmail }: OnboardingViewProps) =>
</div>
</div>
</div>
</div>
</div>
</OnboardingLayout>
</>
);
};
@@ -16,7 +16,11 @@ export const useSubmitOnboarding = () => {
const intentToCreateOrg = trpc.viewer.organizations.intentToCreateOrg.useMutation();
const submitOnboarding = async (store: OnboardingState, userEmail: string) => {
const submitOnboarding = async (
store: OnboardingState,
userEmail: string,
invitesToSubmit: OnboardingState["invites"]
) => {
setIsSubmitting(true);
setError(null);
@@ -26,7 +30,6 @@ export const useSubmitOnboarding = () => {
organizationDetails,
organizationBrand,
teams,
invites,
inviteRole,
resetOnboarding,
} = store;
@@ -46,7 +49,7 @@ export const useSubmitOnboarding = () => {
}));
// Prepare invites data
const invitedMembersData = invites
const invitedMembersData = invitesToSubmit
.filter((invite) => invite.email.trim().length > 0)
.map((invite) => ({
email: invite.email,
@@ -7,8 +7,9 @@ import { HexColorPicker } from "react-colorful";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { Logo } from "@calcom/ui/components/logo";
import { OnboardingCard } from "../../personal/_components/OnboardingCard";
import { OnboardingLayout } from "../../personal/_components/OnboardingLayout";
import { useOnboardingStore } from "../../store/onboarding-store";
type OrganizationBrandViewProps = {
@@ -116,70 +117,137 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
};
return (
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip rounded-xl">
{/* Header */}
<div className="flex w-full items-center justify-between px-6 py-4">
<Logo className="h-5 w-auto" />
<OnboardingLayout userEmail={userEmail} currentStep={2}>
<OnboardingCard
title={t("onboarding_org_brand_title")}
subtitle={t("onboarding_org_brand_subtitle")}
footer={
<>
<Button color="minimal" className="rounded-[10px]" onClick={handleSkip}>
{t("ill_do_this_later")}
</Button>
<Button color="primary" className="rounded-[10px]" onClick={handleContinue}>
{t("continue")}
</Button>
</>
}>
{/* Form */}
<div className="bg-default border-muted w-full rounded-[10px] border">
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
<div className="flex w-full flex-col items-start">
<div className="flex w-full gap-6 px-5 py-4">
{/* Left side - Form */}
<div className="flex w-full flex-col gap-6">
{/* Brand Color */}
<div className="flex w-full flex-col gap-6">
<p className="text-emphasis text-sm font-medium leading-4">{t("brand_color")}</p>
<div className="flex w-full items-center gap-2">
<p className="text-subtle w-[98px] overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium leading-4">
{t("onboarding_primary_color_label")}
</p>
<BrandColorPicker
value={brandColor}
onChange={(value) => {
setBrandColor(value);
setOrganizationBrand({ color: value });
}}
t={t}
/>
</div>
</div>
{/* Progress dots - centered */}
<div className="absolute left-1/2 flex -translate-x-1/2 items-center justify-center gap-1">
<div className="bg-emphasis h-1 w-1 rounded-full" />
<div className="bg-emphasis h-1.5 w-1.5 rounded-full" />
<div className="bg-subtle h-1 w-1 rounded-full" />
<div className="bg-subtle h-1 w-1 rounded-full" />
</div>
{/* Logo Upload */}
<div className="flex w-full flex-col gap-2">
<p className="text-emphasis text-sm font-medium leading-4">{t("logo")}</p>
<div className="flex items-center gap-2">
<div className="bg-muted border-muted relative h-16 w-16 shrink-0 overflow-hidden rounded-md border">
{logoPreview && (
<img
src={logoPreview}
alt={t("onboarding_logo_preview_alt")}
className="h-full w-full object-cover"
/>
)}
</div>
<div className="flex flex-col gap-2">
<Button
color="secondary"
size="sm"
onClick={() => document.getElementById("logo-upload")?.click()}>
{t("upload")}
</Button>
<input
id="logo-upload"
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleLogoChange(e.target.files?.[0] || null)}
/>
</div>
</div>
<p className="text-subtle text-xs font-normal leading-3">{t("onboarding_logo_size_hint")}</p>
</div>
<div className="bg-muted flex items-center gap-2 rounded-full px-3 py-2">
<p className="text-emphasis text-sm font-medium leading-none">{userEmail}</p>
</div>
</div>
{/* Main content */}
<div className="flex h-full w-full items-start justify-center px-6 py-8">
<div className="relative flex w-full max-w-[600px] flex-col gap-6">
{/* Card */}
<div className="bg-muted border-muted relative rounded-xl border p-1">
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
{/* Card Header */}
<div className="flex w-full gap-1.5 px-5 py-4">
<div className="flex w-full flex-col gap-1">
<h1 className="font-cal text-xl font-semibold leading-6">{t("onboarding_org_brand_title")}</h1>
<p className="text-subtle text-sm font-medium leading-tight">
{t("onboarding_org_brand_subtitle")}
</p>
{/* Banner Upload */}
<div className="flex w-full flex-col gap-2">
<p className="text-emphasis text-sm font-medium leading-4">
{t("onboarding_banner_label")}
</p>
<div className="flex w-full flex-col gap-2">
<div className="bg-muted border-muted relative h-[92px] w-full overflow-hidden rounded-md border">
{bannerPreview && (
<img
src={bannerPreview}
alt={t("onboarding_banner_preview_alt")}
className="h-full w-full object-cover"
/>
)}
</div>
<div className="flex flex-col gap-2">
<Button
color="secondary"
size="sm"
className="w-fit"
onClick={() => document.getElementById("banner-upload")?.click()}>
{t("upload")}
</Button>
<input
id="banner-upload"
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleBannerChange(e.target.files?.[0] || null)}
/>
</div>
</div>
<p className="text-subtle text-xs font-normal leading-3">
{t("onboarding_banner_size_hint")}
</p>
</div>
</div>
</div>
{/* Form */}
<div className="bg-default border-muted w-full rounded-[10px] border">
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
<div className="flex w-full flex-col items-start">
<div className="flex w-full gap-6 px-5 py-4">
{/* Left side - Form */}
<div className="flex w-full flex-col gap-6">
{/* Brand Color */}
<div className="flex w-full flex-col gap-6">
<p className="text-emphasis text-sm font-medium leading-4">{t("brand_color")}</p>
<div className="flex w-full items-center gap-2">
<p className="text-subtle w-[98px] overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium leading-4">
{t("onboarding_primary_color_label")}
</p>
<BrandColorPicker
value={brandColor}
onChange={(value) => {
setBrandColor(value);
setOrganizationBrand({ color: value });
}}
t={t}
/>
</div>
</div>
{/* Right side - Preview */}
<div className="bg-muted border-muted flex hidden h-[328px] w-full grow overflow-hidden rounded-[10px] border p-5 md:block">
<div className="flex flex-col gap-2.5">
<p className="text-subtle text-sm font-medium leading-4">{t("preview")}</p>
<div className="border-subtle bg-default relative flex w-[110%] flex-col gap-2.5 rounded-md border px-5 pb-5 pt-[74px]">
{/* Banner preview */}
<div className="bg-muted border-muted absolute left-1 top-1 h-[92px] w-[272px] overflow-hidden rounded-[4px] border">
{bannerPreview && (
<img
src={bannerPreview}
alt={t("onboarding_banner_preview_alt")}
className="h-full w-full object-cover"
/>
)}
</div>
{/* Logo Upload */}
<div className="flex w-full flex-col gap-2">
<p className="text-emphasis text-sm font-medium leading-4">{t("logo")}</p>
<div className="flex items-center gap-2">
<div className="bg-muted border-muted relative h-16 w-16 shrink-0 overflow-hidden rounded-md border">
{/* Content */}
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 p-1">
<div className="flex flex-col gap-3">
{/* Logo preview */}
<div className="bg-muted z-20 h-9 w-9 shrink-0 overflow-hidden rounded-md border-2 border-[var(--cal-bg)]">
{logoPreview && (
<img
src={logoPreview}
@@ -188,141 +256,38 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
/>
)}
</div>
<div className="flex flex-col gap-2">
<Button
color="secondary"
size="sm"
onClick={() => document.getElementById("logo-upload")?.click()}>
{t("upload")}
</Button>
<input
id="logo-upload"
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleLogoChange(e.target.files?.[0] || null)}
/>
<p className="text-subtle text-sm font-medium capitalize leading-4 ">
{organizationDetails.name || t("onboarding_preview_nameless")}
</p>
</div>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3">
<p className="font-cal text-xl leading-5 tracking-[0.2px]">
{t("onboarding_preview_example_title")}
</p>
<p className="text-subtle text-sm font-medium leading-5">
{t("onboarding_preview_example_description")}
</p>
</div>
</div>
<p className="text-subtle text-xs font-normal leading-3">
{t("onboarding_logo_size_hint")}
</p>
</div>
{/* Banner Upload */}
<div className="flex w-full flex-col gap-2">
<p className="text-emphasis text-sm font-medium leading-4">{t("onboarding_banner_label")}</p>
<div className="flex w-full flex-col gap-2">
<div className="bg-muted border-muted relative h-[92px] w-full overflow-hidden rounded-md border">
{bannerPreview && (
<img
src={bannerPreview}
alt={t("onboarding_banner_preview_alt")}
className="h-full w-full object-cover"
/>
)}
<div className="flex flex-col gap-1">
{[134, 104, 84, 104].map((width, i) => (
<div key={i} className="flex items-center gap-2 p-1">
<div className="bg-subtle h-5 w-5 shrink-0 rounded-full" />
<div className="bg-subtle h-2.5 rounded-full" style={{ width: `${width}px` }} />
</div>
<div className="flex flex-col gap-2">
<Button
color="secondary"
size="sm"
className="w-fit"
onClick={() => document.getElementById("banner-upload")?.click()}>
{t("upload")}
</Button>
<input
id="banner-upload"
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleBannerChange(e.target.files?.[0] || null)}
/>
</div>
</div>
<p className="text-subtle text-xs font-normal leading-3">
{t("onboarding_banner_size_hint")}
</p>
</div>
</div>
{/* Right side - Preview */}
<div className="bg-muted border-muted flex hidden h-[328px] w-full grow overflow-hidden rounded-[10px] border p-5 md:block">
<div className="flex flex-col gap-2.5">
<p className="text-subtle text-sm font-medium leading-4">{t("preview")}</p>
<div className="border-subtle bg-default relative flex w-[110%] flex-col gap-2.5 rounded-md border px-5 pb-5 pt-[74px]">
{/* Banner preview */}
<div className="bg-muted border-muted absolute left-1 top-1 h-[92px] w-[272px] overflow-hidden rounded-[4px] border">
{bannerPreview && (
<img
src={bannerPreview}
alt={t("onboarding_banner_preview_alt")}
className="h-full w-full object-cover"
/>
)}
</div>
{/* Content */}
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 p-1">
<div className="flex flex-col gap-3">
{/* Logo preview */}
<div className="bg-muted z-20 h-9 w-9 shrink-0 overflow-hidden rounded-md border-2 border-[var(--cal-bg)]">
{logoPreview && (
<img
src={logoPreview}
alt={t("onboarding_logo_preview_alt")}
className="h-full w-full object-cover"
/>
)}
</div>
<p className="text-subtle text-sm font-medium capitalize leading-4 ">
{organizationDetails.name || t("onboarding_preview_nameless")}
</p>
</div>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3">
<p className="font-cal text-xl leading-5 tracking-[0.2px]">
{t("onboarding_preview_example_title")}
</p>
<p className="text-subtle text-sm font-medium leading-5">
{t("onboarding_preview_example_description")}
</p>
</div>
</div>
</div>
<div className="flex flex-col gap-1">
{[134, 104, 84, 104].map((width, i) => (
<div key={i} className="flex items-center gap-2 p-1">
<div className="bg-subtle h-5 w-5 shrink-0 rounded-full" />
<div
className="bg-subtle h-2.5 rounded-full"
style={{ width: `${width}px` }}
/>
</div>
))}
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">
<Button color="minimal" className="rounded-[10px]" onClick={handleSkip}>
{t("ill_do_this_later")}
</Button>
<Button color="primary" className="rounded-[10px]" onClick={handleContinue}>
{t("continue")}
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
</OnboardingCard>
</OnboardingLayout>
);
};
@@ -6,9 +6,9 @@ import { useEffect, useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { Label, TextField, TextArea } from "@calcom/ui/components/form";
import { Logo } from "@calcom/ui/components/logo";
import { OnboardingContinuationPrompt } from "../../components/onboarding-continuation-prompt";
import { OnboardingCard } from "../../personal/_components/OnboardingCard";
import { OnboardingLayout } from "../../personal/_components/OnboardingLayout";
import { useOnboardingStore } from "../../store/onboarding-store";
import { ValidatedOrganizationSlug } from "./validated-organization-slug";
@@ -76,101 +76,64 @@ export const OrganizationDetailsView = ({ userEmail }: OrganizationDetailsViewPr
};
return (
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip rounded-xl">
{/* Header */}
<div className="flex w-full items-center justify-between px-6 py-4">
<Logo className="h-5 w-auto" />
<OnboardingLayout userEmail={userEmail} currentStep={2}>
<OnboardingCard
title={t("onboarding_org_details_title")}
subtitle={t("onboarding_org_details_subtitle")}
footer={
<Button
color="primary"
className="rounded-[10px]"
onClick={handleContinue}
disabled={!isSlugValid || !organizationName || !organizationLink}>
{t("continue")}
</Button>
}>
{/* Form */}
<div className="bg-default border-muted w-full rounded-[10px] border">
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
<div className="flex w-full flex-col items-start">
<div className="flex w-full gap-6 px-5 py-5">
<div className="flex w-full flex-col gap-4 rounded-xl">
{/* Organization Name */}
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis text-sm font-medium leading-4">
{t("organization_name")}
</Label>
<TextField
value={organizationName}
onChange={(e) => setOrganizationName(e.target.value)}
placeholder={t("organization_name")}
className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
/>
</div>
{/* Progress dots - centered */}
<div className="absolute left-1/2 flex -translate-x-1/2 items-center justify-center gap-1">
<div className="bg-emphasis h-1 w-1 rounded-full" />
<div className="bg-emphasis h-1.5 w-1.5 rounded-full" />
<div className="bg-subtle h-1 w-1 rounded-full" />
<div className="bg-subtle h-1 w-1 rounded-full" />
</div>
{/* Organization Link */}
<ValidatedOrganizationSlug
value={organizationLink}
onChange={handleSlugChange}
onValidationChange={setIsSlugValid}
/>
<div className="bg-muted flex items-center gap-2 rounded-full px-3 py-2">
<p className="text-emphasis text-sm font-medium leading-none">{userEmail}</p>
</div>
</div>
{/* Main content */}
<div className="flex h-full w-full items-start justify-center px-6 py-8">
<div className="flex w-full max-w-[600px] flex-col gap-6">
{/* Card */}
<div className="bg-muted border-muted relative rounded-xl border p-1">
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
{/* Card Header */}
<div className="flex w-full gap-1.5 px-5 py-4">
<div className="flex w-full flex-col gap-1">
<h1 className="font-cal text-xl font-semibold leading-6">
{t("onboarding_org_details_title")}
</h1>
<p className="text-subtle text-sm font-medium leading-tight">
{t("onboarding_org_details_subtitle")}
</p>
</div>
</div>
{/* Form */}
<div className="bg-default border-muted w-full rounded-[10px] border">
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
<div className="flex w-full flex-col items-start">
<div className="flex w-full gap-6 px-5 py-5">
<div className="flex w-full flex-col gap-4 rounded-xl">
{/* Organization Name */}
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis text-sm font-medium leading-4">
{t("organization_name")}
</Label>
<TextField
value={organizationName}
onChange={(e) => setOrganizationName(e.target.value)}
placeholder={t("organization_name")}
className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
/>
</div>
{/* Organization Link */}
<ValidatedOrganizationSlug
value={organizationLink}
onChange={handleSlugChange}
onValidationChange={setIsSlugValid}
/>
{/* Organization Bio */}
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis text-sm font-medium leading-4">
{t("onboarding_org_bio_label")}
</Label>
<TextArea
value={organizationBio}
onChange={(e) => setOrganizationBio(e.target.value)}
placeholder={t("onboarding_org_bio_placeholder")}
rows={4}
className="border-default rounded-lg border px-2 py-2 text-sm leading-tight"
/>
</div>
</div>
</div>
{/* Organization Bio */}
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis text-sm font-medium leading-4">
{t("onboarding_org_bio_label")}
</Label>
<TextArea
value={organizationBio}
onChange={(e) => setOrganizationBio(e.target.value)}
placeholder={t("onboarding_org_bio_placeholder")}
rows={4}
className="border-default rounded-lg border px-2 py-2 text-sm leading-tight"
/>
</div>
</div>
</div>
{/* Footer */}
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">
<Button
color="primary"
className="rounded-[10px]"
onClick={handleContinue}
disabled={!isSlugValid || !organizationName || !organizationLink}>
{t("continue")}
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
</OnboardingCard>
</OnboardingLayout>
);
};
@@ -63,7 +63,7 @@ export const OrganizationInviteView = ({ userEmail }: OrganizationInviteViewProp
const handleSubmitOnboarding = async (invitesData: FormValues["invites"]) => {
// Save invites to store before submitting
setInvites(invitesData);
await submitOnboarding(store, userEmail);
await submitOnboarding(store, userEmail, invitesData);
};
const handleContinue = async (data?: FormValues) => {
@@ -9,8 +9,9 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { Form, TextField } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { Logo } from "@calcom/ui/components/logo";
import { OnboardingCard } from "../../personal/_components/OnboardingCard";
import { OnboardingLayout } from "../../personal/_components/OnboardingLayout";
import { useOnboardingStore } from "../../store/onboarding-store";
type OrganizationTeamsViewProps = {
@@ -63,107 +64,74 @@ export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps)
});
return (
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip rounded-xl">
{/* Header */}
<div className="flex w-full items-center justify-between px-6 py-4">
<Logo className="h-5 w-auto" />
{/* Progress dots - centered */}
<div className="absolute left-1/2 flex -translate-x-1/2 items-center justify-center gap-1">
<div className="bg-emphasis h-1 w-1 rounded-full" />
<div className="bg-emphasis h-1 w-1 rounded-full" />
<div className="bg-emphasis h-1.5 w-1.5 rounded-full" />
<div className="bg-subtle h-1 w-1 rounded-full" />
</div>
<div className="bg-muted flex items-center gap-2 rounded-full px-3 py-2">
<p className="text-emphasis text-sm font-medium leading-none">{userEmail}</p>
</div>
</div>
{/* Main content */}
<div className="flex h-full w-full items-start justify-center px-6 py-8">
<div className="flex w-full max-w-[600px] flex-col gap-4">
{/* Card */}
<div className="bg-muted border-muted relative rounded-xl border p-1">
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
{/* Card Header */}
<div className="flex w-full gap-1.5 px-5 py-4">
<div className="flex w-full flex-col gap-1">
<h1 className="font-cal text-xl font-semibold leading-6">{t("onboarding_org_teams_title")}</h1>
<p className="text-subtle text-sm font-medium leading-tight">
{t("onboarding_org_teams_subtitle")}
</p>
<OnboardingLayout userEmail={userEmail} currentStep={3}>
<OnboardingCard
title={t("onboarding_org_teams_title")}
subtitle={t("onboarding_org_teams_subtitle")}
footer={
<>
<Button type="button" color="minimal" className="rounded-[10px]" onClick={handleSkip}>
{t("onboarding_skip_for_now")}
</Button>
<Button
type="submit"
form="teams-form"
color="primary"
className="rounded-[10px]"
disabled={!hasValidTeams}>
{t("continue")}
</Button>
</>
}>
<Form id="teams-form" form={form} handleSubmit={handleContinue} className="w-full">
<div className="bg-default border-subtle w-full rounded-md border">
<div className="flex w-full flex-col gap-8 px-5 py-5">
<div className="flex w-full flex-col gap-2">
<div className="flex flex-col gap-1">
<p className="text-emphasis text-sm font-medium leading-4">{t("team")}</p>
</div>
</div>
{/* Form */}
<Form form={form} handleSubmit={handleContinue} className="w-full">
<div className="bg-default border-subtle w-full rounded-md border">
<div className="flex w-full flex-col gap-8 px-5 py-5">
<div className="flex w-full flex-col gap-2">
<div className="flex flex-col gap-1">
<p className="text-emphasis text-sm font-medium leading-4">{t("team")}</p>
{/* Team fields */}
<div className="flex flex-col gap-2">
{fields.map((field, index) => (
<div key={field.id} className="flex w-full items-end gap-0.5">
<div className="flex-1">
<TextField
labelSrOnly
{...form.register(`teams.${index}.name`)}
placeholder={t("team")}
className="h-7 w-full rounded-[10px] text-sm"
/>
</div>
{/* Team fields */}
<div className="flex flex-col gap-2">
{fields.map((field, index) => (
<div key={field.id} className="flex w-full items-end gap-0.5">
<div className="flex-1">
<TextField
labelSrOnly
{...form.register(`teams.${index}.name`)}
placeholder={t("team")}
className="h-7 w-full rounded-[10px] text-sm"
/>
</div>
<Button
type="button"
color="minimal"
variant="icon"
size="sm"
className="h-7 w-7"
disabled={fields.length === 1}
onClick={() => remove(index)}>
<Icon name="x" className="h-4 w-4" />
</Button>
</div>
))}
</div>
{/* Add button */}
<Button
type="button"
color="secondary"
color="minimal"
variant="icon"
size="sm"
StartIcon="plus"
className="w-fit"
onClick={() => append({ name: "" })}>
{t("add")}
className="h-7 w-7"
disabled={fields.length === 1}
onClick={() => remove(index)}>
<Icon name="x" className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
{/* Footer */}
<div className="relative flex w-full items-center justify-end gap-1 px-5 py-4">
<Button
type="button"
color="minimal"
className="absolute left-[368px] rounded-[10px]"
onClick={handleSkip}>
{t("onboarding_skip_for_now")}
</Button>
<Button type="submit" color="primary" className="rounded-[10px]" disabled={!hasValidTeams}>
{t("continue")}
</Button>
</div>
</Form>
{/* Add button */}
<Button
type="button"
color="secondary"
size="sm"
StartIcon="plus"
className="w-fit"
onClick={() => append({ name: "" })}>
{t("add")}
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
</Form>
</OnboardingCard>
</OnboardingLayout>
);
};
@@ -23,7 +23,7 @@ export const OnboardingCard = ({ title, subtitle, children, footer, isLoading }:
</div>
{/* Content */}
<div className="flex w-full flex-col gap-4 px-5 py-5">
<div className="flex w-full flex-col gap-4">
{isLoading ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<SkeletonText className="h-40 w-full" />
@@ -1,5 +1,10 @@
"use client";
import { signOut } from "next-auth/react";
import type { ReactNode } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { Logo } from "@calcom/ui/components/logo";
type OnboardingLayoutProps = {
@@ -9,6 +14,12 @@ type OnboardingLayoutProps = {
};
export const OnboardingLayout = ({ userEmail, currentStep, children }: OnboardingLayoutProps) => {
const { t } = useLocale();
const handleSignOut = () => {
signOut({ callbackUrl: "/auth/logout" });
};
return (
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip rounded-xl">
{/* Header */}
@@ -33,9 +44,16 @@ export const OnboardingLayout = ({ userEmail, currentStep, children }: Onboardin
</div>
{/* Main content */}
<div className="flex h-full w-full items-start justify-center px-6 py-8">
<div className="flex w-full flex-1 items-start justify-center px-6 py-8">
<div className="flex w-full max-w-[600px] flex-col gap-4">{children}</div>
</div>
{/* Footer with signout button */}
<div className="flex w-full items-center justify-center px-6 py-6">
<Button onClick={handleSignOut} color="minimal">
{t("sign_out")}
</Button>
</div>
</div>
);
};
@@ -13,11 +13,12 @@ import { Button } from "@calcom/ui/components/button";
import { Editor } from "@calcom/ui/components/editor";
import { Label } from "@calcom/ui/components/form";
import { ImageUploader } from "@calcom/ui/components/image-uploader";
import { Logo } from "@calcom/ui/components/logo";
import { showToast } from "@calcom/ui/components/toast";
import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability";
import { OnboardingCard } from "../_components/OnboardingCard";
import { OnboardingLayout } from "../_components/OnboardingLayout";
import { useOnboardingStore } from "../../store/onboarding-store";
type PersonalProfileViewProps = {
@@ -93,128 +94,89 @@ export const PersonalProfileView = ({ userEmail }: PersonalProfileViewProps) =>
}
if (!user) {
return null; // or a loading spinner
return null;
}
return (
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip rounded-xl">
{/* Header */}
<div className="flex w-full items-center justify-between px-6 py-4">
<Logo className="h-5 w-auto" />
<OnboardingLayout userEmail={userEmail} currentStep={2}>
<OnboardingCard
title={t("complete_your_profile")}
subtitle={t("personal_profile_subtitle")}
footer={
<Button
color="primary"
className="rounded-[10px]"
onClick={onSubmit}
loading={mutation.isPending}
disabled={mutation.isPending}>
{t("continue")}
</Button>
}>
{/* Form */}
<div className="bg-default border-muted w-full rounded-[10px] border">
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
<div className="flex w-full flex-col items-start">
<form onSubmit={onSubmit} className="flex w-full gap-6 px-5 py-5">
<div className="flex w-full flex-col gap-6 rounded-xl">
{/* Avatar */}
<div className="flex w-full flex-col gap-2">
<Label className="text-emphasis text-sm font-medium leading-4">{t("profile_photo")}</Label>
<div className="flex flex-row items-center justify-start gap-4 rtl:justify-end">
{user && <UserAvatar size="lg" user={user} previewSrc={imageSrc} />}
<input
ref={avatarRef}
type="hidden"
name="avatar"
id="avatar"
defaultValue={imageSrc}
/>
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("add_profile_photo")}
handleAvatarChange={(newAvatar) => {
if (avatarRef.current) {
avatarRef.current.value = newAvatar;
}
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value"
)?.set;
nativeInputValueSetter?.call(avatarRef.current, newAvatar);
const ev2 = new Event("input", { bubbles: true });
avatarRef.current?.dispatchEvent(ev2);
updateProfileHandler(newAvatar);
}}
imageSrc={imageSrc}
/>
</div>
</div>
{/* Progress dots - centered */}
<div className="absolute left-1/2 flex -translate-x-1/2 items-center justify-center gap-1">
<div className="bg-emphasis h-1 w-1 rounded-full" />
<div className="bg-emphasis h-1.5 w-1.5 rounded-full" />
<div className="bg-subtle h-1 w-1 rounded-full" />
<div className="bg-subtle h-1 w-1 rounded-full" />
</div>
{/* Username */}
<div className="flex w-full flex-col gap-1.5">
<UsernameAvailabilityField />
</div>
<div className="bg-muted flex items-center gap-2 rounded-full px-3 py-2">
<p className="text-emphasis text-sm font-medium leading-none">{userEmail}</p>
</div>
</div>
{/* Main content */}
<div className="flex h-full w-full items-start justify-center px-6 py-8">
<div className="flex w-full max-w-[600px] flex-col gap-6">
{/* Card */}
<div className="bg-muted border-muted relative rounded-xl border p-1">
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
{/* Card Header */}
<div className="flex w-full gap-1.5 px-5 py-4">
<div className="flex w-full flex-col gap-1">
<h1 className="font-cal text-xl font-semibold leading-6">{t("complete_your_profile")}</h1>
<p className="text-subtle text-sm font-medium leading-tight">
{t("personal_profile_subtitle")}
</p>
</div>
</div>
{/* Form */}
<div className="bg-default border-muted w-full rounded-[10px] border">
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
<div className="flex w-full flex-col items-start">
<form onSubmit={onSubmit} className="flex w-full gap-6 px-5 py-5">
<div className="flex w-full flex-col gap-6 rounded-xl">
{/* Avatar */}
<div className="flex w-full flex-col gap-2">
<Label className="text-emphasis text-sm font-medium leading-4">
{t("profile_photo")}
</Label>
<div className="flex flex-row items-center justify-start gap-4 rtl:justify-end">
{user && <UserAvatar size="lg" user={user} previewSrc={imageSrc} />}
<input
ref={avatarRef}
type="hidden"
name="avatar"
id="avatar"
defaultValue={imageSrc}
/>
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("add_profile_photo")}
handleAvatarChange={(newAvatar) => {
if (avatarRef.current) {
avatarRef.current.value = newAvatar;
}
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value"
)?.set;
nativeInputValueSetter?.call(avatarRef.current, newAvatar);
const ev2 = new Event("input", { bubbles: true });
avatarRef.current?.dispatchEvent(ev2);
updateProfileHandler(newAvatar);
}}
imageSrc={imageSrc}
/>
</div>
</div>
{/* Username */}
<div className="flex w-full flex-col gap-1.5">
<UsernameAvailabilityField />
</div>
{/* Bio */}
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis mb-1 text-sm font-medium leading-4">
{t("about")}
</Label>
<Editor
getText={() => md.render(getValues("bio") || user?.bio || "")}
setText={(value: string) => setValue("bio", turndown(value))}
excludedToolbarItems={["blockType"]}
firstRender={firstRender}
setFirstRender={setFirstRender}
/>
<p className="text-subtle text-xs font-normal leading-3">
{t("few_sentences_about_yourself")}
</p>
</div>
</div>
</form>
{/* Bio */}
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis mb-1 text-sm font-medium leading-4">{t("about")}</Label>
<Editor
getText={() => md.render(getValues("bio") || user?.bio || "")}
setText={(value: string) => setValue("bio", turndown(value))}
excludedToolbarItems={["blockType"]}
firstRender={firstRender}
setFirstRender={setFirstRender}
/>
<p className="text-subtle text-xs font-normal leading-3">
{t("few_sentences_about_yourself")}
</p>
</div>
</div>
</div>
{/* Footer */}
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">
<Button
color="primary"
className="rounded-[10px]"
onClick={onSubmit}
loading={mutation.isPending}
disabled={mutation.isPending}>
{t("continue")}
</Button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</OnboardingCard>
</OnboardingLayout>
);
};
@@ -14,10 +14,11 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { Label, TextField } from "@calcom/ui/components/form";
import { Logo } from "@calcom/ui/components/logo";
import { OnboardingContinuationPrompt } from "../../components/onboarding-continuation-prompt";
import { useOnboardingStore } from "../../store/onboarding-store";
import { OnboardingCard } from "../_components/OnboardingCard";
import { OnboardingLayout } from "../_components/OnboardingLayout";
type PersonalSettingsViewProps = {
userEmail: string;
@@ -79,101 +80,68 @@ export const PersonalSettingsView = ({ userEmail, userName }: PersonalSettingsVi
});
return (
<div className="bg-default flex min-h-screen w-full flex-col items-start rounded-xl">
<>
<OnboardingContinuationPrompt />
{/* Header */}
<div className="flex w-full items-center justify-between px-6 py-4">
<Logo className="h-5 w-auto" />
<OnboardingLayout userEmail={userEmail} currentStep={1}>
<OnboardingCard
title={t("welcome_to_cal_com")}
subtitle={t("personal_settings_subtitle")}
footer={
<Button
color="primary"
className="rounded-[10px]"
onClick={handleContinue}
loading={mutation.isPending}
disabled={mutation.isPending || !form.formState.isValid}>
{t("continue")}
</Button>
}>
{/* Form */}
<div className="bg-default border-muted w-full rounded-[10px] border">
<div className="rounded-inherit flex w-full flex-col items-start">
<div className="flex w-full flex-col items-start">
<div className="flex w-full gap-6 px-5 py-5">
<div className="flex w-full flex-col gap-4 rounded-xl">
{/* Name */}
<div className="flex w-full flex-col gap-1.5">
<TextField
label={t("full_name")}
{...form.register("name")}
value={name}
onChange={(e) => {
setName(e.target.value);
form.setValue("name", e.target.value);
}}
placeholder="John Doe"
className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
/>
{form.formState.errors.name && (
<p className="text-error text-sm">{form.formState.errors.name.message}</p>
)}
</div>
{/* Progress dots - centered */}
<div className="absolute left-1/2 flex -translate-x-1/2 items-center justify-center gap-1">
<div className="bg-emphasis h-1.5 w-1.5 rounded-full" />
<div className="bg-subtle h-1 w-1 rounded-full" />
<div className="bg-subtle h-1 w-1 rounded-full" />
<div className="bg-subtle h-1 w-1 rounded-full" />
</div>
<div className="bg-muted flex items-center gap-2 rounded-full px-3 py-2">
<p className="text-emphasis text-sm font-medium leading-none">{userEmail}</p>
</div>
</div>
{/* Main content */}
<div className="flex h-full w-full items-start justify-center px-6 py-8">
<div className="flex w-full max-w-[600px] flex-col gap-6">
{/* Card */}
<div className="bg-muted border-muted relative rounded-xl border p-1">
<div className="rounded-inherit flex w-full flex-col items-start ">
{/* Card Header */}
<div className="flex w-full gap-1.5 px-5 py-4">
<div className="flex w-full flex-col gap-1">
<h1 className="font-cal text-xl font-semibold leading-6">{t("welcome_to_cal_com")}</h1>
<p className="text-subtle text-sm font-medium leading-tight">
{t("personal_settings_subtitle")}
</p>
</div>
</div>
{/* Form */}
<div className="bg-default border-muted w-full rounded-[10px] border">
<div className="rounded-inherit flex w-full flex-col items-start">
<div className="flex w-full flex-col items-start">
<div className="flex w-full gap-6 px-5 py-5">
<div className="flex w-full flex-col gap-4 rounded-xl">
{/* Name */}
<div className="flex w-full flex-col gap-1.5">
<TextField
label={t("full_name")}
{...form.register("name")}
value={name}
onChange={(e) => {
setName(e.target.value);
form.setValue("name", e.target.value);
}}
placeholder="John Doe"
className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
/>
{form.formState.errors.name && (
<p className="text-error text-sm">{form.formState.errors.name.message}</p>
)}
</div>
{/* Timezone */}
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis mb-0 text-sm font-medium leading-4">
{t("timezone")}
</Label>
<TimezoneSelect
value={selectedTimeZone}
onChange={({ value }) => setSelectedTimeZone(value)}
className="rounded-[10px] text-sm"
/>
<p className="text-subtle text-xs font-normal leading-3">
{t("current_time")}{" "}
{dayjs().tz(selectedTimeZone).format("LT").toString().toLowerCase()}
</p>
</div>
</div>
{/* Timezone */}
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis mb-0 text-sm font-medium leading-4">
{t("timezone")}
</Label>
<TimezoneSelect
value={selectedTimeZone}
onChange={({ value }) => setSelectedTimeZone(value)}
size="sm"
/>
<p className="text-subtle text-xs font-normal leading-3">
{t("current_time")}{" "}
{dayjs().tz(selectedTimeZone).format("LT").toString().toLowerCase()}
</p>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">
<Button
color="primary"
className="rounded-[10px]"
onClick={handleContinue}
loading={mutation.isPending}
disabled={mutation.isPending || !form.formState.isValid}>
{t("continue")}
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
</OnboardingCard>
</OnboardingLayout>
</>
);
};
@@ -0,0 +1,132 @@
import { createJSONStorage, type StateStorage } from "zustand/middleware";
/**
* IndexedDB storage adapter for onboarding store.
* Supports much larger storage limits than localStorage (typically 50% of available disk space),
* making it suitable for storing large base64-encoded images (logos, banners, etc.).
* Falls back to localStorage if IndexedDB is not available.
*/
const DB_NAME = "cal-onboarding-idb";
const DB_VERSION = 1;
const STORE_NAME = "keyval";
// Cache database connection to avoid opening/closing repeatedly
let dbPromise: Promise<IDBDatabase> | null = null;
function getDB(): Promise<IDBDatabase> {
if (dbPromise) {
return dbPromise;
}
dbPromise = new Promise((resolve, reject) => {
if (typeof window === "undefined" || !window.indexedDB) {
reject(new Error("IndexedDB is not available"));
return;
}
const request = window.indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => {
dbPromise = null;
reject(request.error);
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
};
request.onsuccess = () => {
const db = request.result;
// Reset promise on close so we can reconnect if needed
db.onclose = () => {
dbPromise = null;
};
resolve(db);
};
});
return dbPromise;
}
async function getItem(key: string): Promise<string | null> {
try {
const db = await getDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORE_NAME], "readonly");
const store = transaction.objectStore(STORE_NAME);
const request = store.get(key);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result ?? null);
});
} catch {
// Fallback to localStorage if IndexedDB fails
try {
return window.localStorage.getItem(key);
} catch {
return null;
}
}
}
async function setItem(key: string, value: string): Promise<void> {
try {
const db = await getDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORE_NAME], "readwrite");
const store = transaction.objectStore(STORE_NAME);
const request = store.put(value, key);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
} catch {
// Fallback to localStorage if IndexedDB fails (will throw if quota exceeded)
try {
window.localStorage.setItem(key, value);
} catch {
// Storage quota exceeded even in fallback - not much we can do
}
}
}
async function removeItem(key: string): Promise<void> {
try {
const db = await getDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORE_NAME], "readwrite");
const store = transaction.objectStore(STORE_NAME);
const request = store.delete(key);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
} catch {
// Fallback to localStorage if IndexedDB fails
try {
window.localStorage.removeItem(key);
} catch {
// Ignore errors on remove
}
}
}
/**
* IndexedDB storage adapter that implements StateStorage interface.
* This provides much larger storage capacity than localStorage, making it suitable for
* storing large base64-encoded images (logos, banners, etc.).
*/
const indexedDBStateStorage: StateStorage = {
getItem,
setItem,
removeItem,
};
/**
* IndexedDB storage adapter that implements the PersistStorage interface for zustand persist.
* Uses createJSONStorage to handle JSON serialization/deserialization automatically.
*/
export const onboardingIndexedDBStorage = createJSONStorage(() => indexedDBStateStorage);
@@ -1,6 +1,8 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { onboardingIndexedDBStorage } from "./onboarding-storage";
export type PlanType = "personal" | "team" | "organization";
export type InviteRole = "MEMBER" | "ADMIN";
@@ -160,7 +162,8 @@ export const useOnboardingStore = create<OnboardingState>()(
resetOnboarding: () => set(initialState),
}),
{
name: "cal-onboarding-storage", // localStorage key
name: "cal-onboarding-storage", // Storage key
storage: onboardingIndexedDBStorage, // Use IndexedDB instead of localStorage for larger capacity
// Optional: Only persist certain fields
partialize: (state) => ({
selectedPlan: state.selectedPlan,
+182 -2
View File
@@ -1,5 +1,6 @@
import { expect } from "@playwright/test";
import { applySelectFilter } from "./filter-helpers";
import { test } from "./lib/fixtures";
test.describe.configure({ mode: "parallel" });
@@ -15,10 +16,9 @@ test.describe("Booking Filters", () => {
teammates: [{ name: teamMateName }],
});
const allUsers = await users.get();
const allUsers = users.get();
const memberUser = allUsers.find((user) => user.name === teamMateName);
// eslint-disable-next-line playwright/no-conditional-in-test
if (!memberUser) {
throw new Error("user should exist");
}
@@ -48,4 +48,184 @@ test.describe("Booking Filters", () => {
await page.locator('[data-testid="add-filter-button"]').click();
await expect(page.locator('[data-testid="add-filter-item-userId"]')).toBeVisible();
});
test("Query params should be preserved when switching between bookings tabs", async ({ page, users }) => {
const owner = await users.create(
{ name: "Owner User" },
{
hasTeam: true,
isOrg: true,
teammates: [{ name: "Team Member 1" }, { name: "Team Member 2" }],
}
);
await owner.apiLogin();
const bookingsGetResponse = page.waitForResponse((response) =>
/\/api\/trpc\/bookings\/get.*/.test(response.url())
);
await page.goto(`/bookings/upcoming`, { waitUntil: "domcontentloaded" });
await bookingsGetResponse;
await applySelectFilter(page, "userId", "Owner User");
await expect(page).toHaveURL(/.*userId.*/);
const urlWithFilters = page.url();
expect(urlWithFilters).toContain("userId");
const searchParams = new URL(urlWithFilters).searchParams;
const activeFilters = searchParams.get("activeFilters");
expect(activeFilters).toBeTruthy();
const pastBookingsGetResponse = page.waitForResponse((response) =>
/\/api\/trpc\/bookings\/get.*/.test(response.url())
);
await page.getByTestId("past-test").click();
await expect(page).toHaveURL(/\/bookings\/past/);
await pastBookingsGetResponse;
const pastUrl = page.url();
expect(pastUrl).toContain("/bookings/past");
expect(pastUrl).toContain("userId");
const pastSearchParams = new URL(pastUrl).searchParams;
expect(pastSearchParams.get("activeFilters")).toBe(activeFilters);
const cancelledBookingsGetResponse = page.waitForResponse((response) =>
/\/api\/trpc\/bookings\/get.*/.test(response.url())
);
await page.getByTestId("cancelled-test").click();
await expect(page).toHaveURL(/\/bookings\/cancelled/);
await cancelledBookingsGetResponse;
const cancelledUrl = page.url();
expect(cancelledUrl).toContain("/bookings/cancelled");
expect(cancelledUrl).toContain("userId");
const cancelledSearchParams = new URL(cancelledUrl).searchParams;
expect(cancelledSearchParams.get("activeFilters")).toBe(activeFilters);
});
test("Query params should be preserved when switching between bookings tabs in calendar view", async ({
page,
users,
}) => {
const owner = await users.create(
{ name: "Owner User" },
{
hasTeam: true,
isOrg: true,
teammates: [{ name: "Team Member 1" }, { name: "Team Member 2" }],
}
);
await owner.apiLogin();
const bookingsGetResponse = page.waitForResponse((response) =>
/\/api\/trpc\/bookings\/get.*/.test(response.url())
);
await page.goto(`/bookings/upcoming?view=calendar`, { waitUntil: "domcontentloaded" });
await bookingsGetResponse;
await applySelectFilter(page, "userId", "Owner User");
await expect(page).toHaveURL(/.*userId.*/);
const urlWithFilters = page.url();
expect(urlWithFilters).toContain("userId");
const searchParams = new URL(urlWithFilters).searchParams;
const activeFilters = searchParams.get("activeFilters");
expect(activeFilters).toBeTruthy();
expect(searchParams.get("view")).toBe("calendar");
const pastBookingsGetResponse = page.waitForResponse((response) =>
/\/api\/trpc\/bookings\/get.*/.test(response.url())
);
await page.getByTestId("past-test").click();
await expect(page).toHaveURL(/\/bookings\/past/);
await pastBookingsGetResponse;
const pastUrl = page.url();
expect(pastUrl).toContain("/bookings/past");
expect(pastUrl).toContain("userId");
const pastSearchParams = new URL(pastUrl).searchParams;
expect(pastSearchParams.get("activeFilters")).toBe(activeFilters);
expect(pastSearchParams.get("view")).toBe("calendar");
const cancelledBookingsGetResponse = page.waitForResponse((response) =>
/\/api\/trpc\/bookings\/get.*/.test(response.url())
);
await page.getByTestId("cancelled-test").click();
await expect(page).toHaveURL(/\/bookings\/cancelled/);
await cancelledBookingsGetResponse;
const cancelledUrl = page.url();
expect(cancelledUrl).toContain("/bookings/cancelled");
expect(cancelledUrl).toContain("userId");
const cancelledSearchParams = new URL(cancelledUrl).searchParams;
expect(cancelledSearchParams.get("activeFilters")).toBe(activeFilters);
expect(cancelledSearchParams.get("view")).toBe("calendar");
});
test("Query params should NOT be preserved when navigating from a non-bookings page", async ({
page,
users,
}) => {
const owner = await users.create(undefined, {
hasTeam: true,
isOrg: true,
});
await owner.apiLogin();
await page.goto(`/event-types?someParam=value`);
await expect(page).toHaveURL(/.*someParam=value.*/);
const upcomingBookingsGetResponse = page.waitForResponse((response) =>
/\/api\/trpc\/bookings\/get.*/.test(response.url())
);
await page.getByTestId("bookings-test").click();
await page.getByTestId("upcoming-test").click();
await expect(page).toHaveURL(/\/bookings\/upcoming/);
await upcomingBookingsGetResponse;
const upcomingUrl = page.url();
expect(upcomingUrl).toContain("/bookings/upcoming");
expect(upcomingUrl).not.toContain("someParam=value");
});
test("Query params should NOT be preserved when navigating from a bookings page to a non-bookings page", async ({
page,
users,
}) => {
const owner = await users.create(
{ name: "Owner User" },
{
hasTeam: true,
isOrg: true,
teammates: [{ name: "Team Member 1" }, { name: "Team Member 2" }],
}
);
await owner.apiLogin();
const bookingsGetResponse = page.waitForResponse((response) =>
/\/api\/trpc\/bookings\/get.*/.test(response.url())
);
await page.goto(`/bookings/upcoming`, { waitUntil: "domcontentloaded" });
await bookingsGetResponse;
await applySelectFilter(page, "userId", "Owner User");
await expect(page).toHaveURL(/.*userId.*/);
const urlWithFilters = page.url();
expect(urlWithFilters).toContain("userId");
await page.getByTestId("event_types_page_title-test").click();
await expect(page).toHaveURL(/\/event-types/);
const eventTypeSearchParams = new URL(page.url()).searchParams;
expect(eventTypeSearchParams.get("activeFilters")).toBeFalsy();
});
});
+6
View File
@@ -275,11 +275,17 @@ export async function getInviteLink(page: Page) {
export async function getEmailsReceivedByUser({
emails,
userEmail,
waitForEmailMs = 5000,
}: {
emails?: ReturnType<typeof createEmailsFixture>;
userEmail: string;
waitForEmailMs?: number;
}): Promise<Messages | null> {
if (!emails) return null;
// Wait for email to be sent/received
await new Promise((resolve) => setTimeout(resolve, waitForEmailMs));
const matchingEmails = await emails.search(userEmail, "to");
if (!matchingEmails?.total) {
console.log(
@@ -15,9 +15,6 @@ export async function expectInvitationEmailToBeReceived(
returnLink?: string
) {
if (!emails) return null;
// We need to wait for the email to go through, otherwise it will fail
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(2000);
const receivedEmails = await getEmailsReceivedByUser({ emails, userEmail });
expect(receivedEmails?.total).toBe(1);
const [firstReceivedEmail] = (receivedEmails as Messages).items;
-4
View File
@@ -295,10 +295,6 @@ test.describe("Email Signup Flow Test", async () => {
userEmail: userToCreate.email,
});
// We need to wait for emails to be sent
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(5000);
expect(receivedEmails?.total).toBe(1);
const verifyEmail = receivedEmails?.items[0];
-1
View File
@@ -15,7 +15,6 @@ export async function expectInvitationEmailToBeReceived(
) {
if (!emails) return null;
await page.waitForTimeout(2000);
const receivedEmails = await getEmailsReceivedByUser({ emails, userEmail });
expect(receivedEmails?.total).toBe(1);
+28 -17
View File
@@ -367,8 +367,8 @@
"available_apps_lower_case": "Available apps",
"available_apps_desc": "View popular apps below and explore more in our <0>App Store</0>",
"fixed_host_helper": "Add anyone who needs to attend the event. <0>Learn more</0>",
"round_robin_groups_helper": "People in a group take turns, one person of each group will show up for the event.",
"round_robin_helper": "People in the group take turns and only one person will show up for the event.",
"round_robin_groups_helper": "People in a group take turns, one person of each group will show up for the event. <0>Learn more</0>",
"round_robin_helper": "People in the group take turns and only one person will show up for the event. <0>Learn more</0>",
"check_email_reset_password": "Check your email. We sent you a link to reset your password.",
"finish": "Finish",
"organization_general_description": "Manage settings for your team language and timezone",
@@ -1056,11 +1056,11 @@
"edit": "Edit",
"add_input": "Add an Input",
"disable_notes": "Hide notes in calendar",
"disable_notes_description": "For privacy reasons, additional inputs and notes will be hidden in the calendar entry. They will still be sent to your email.",
"requires_confirmation_description": "The booking needs to be manually confirmed before it is pushed to your calendar and a confirmation is sent.",
"disable_notes_description": "For privacy reasons, additional inputs and notes will be hidden in the calendar entry. They will still be sent to your email. <0>Learn more</0>",
"requires_confirmation_description": "The booking needs to be manually confirmed before it is pushed to your calendar and a confirmation is sent. <0>Learn more</0>",
"requires_confirmation_will_block_slot_description": "Unconfirmed bookings still block calendar slots.",
"recurring_event": "Recurring Event",
"recurring_event_description": "People can subscribe for recurring events",
"recurring_event_description": "People can subscribe for recurring events. <0>Learn more</0>",
"connect_and_join": "Connect and Join",
"cannot_be_used_with_paid_event_types": "It cannot be used with paid event types",
"warning_payment_instant_meeting_event": "Instant Meetings are not supported with recurring events and payment apps yet",
@@ -1076,7 +1076,7 @@
"copy_private_link_to_event": "Copy private link to event",
"private_link_description": "Generate a private URL to share without exposing your {{appName}} username",
"multiple_private_links_title": "Private Links",
"multiple_private_links_description": "Generate private URLs without exposing the username, with configurable expiry and usage limits",
"multiple_private_links_description": "Generate private URLs without exposing the username, with configurable expiry and usage limits. <0>Learn more</0>",
"add_a_multiple_private_link": "Add new link",
"multiple_private_link_copied": "Private link copied",
"invitees_can_schedule": "Invitees can schedule",
@@ -1280,7 +1280,7 @@
"redirect_url_description": "Redirect to a custom URL after a successful booking",
"duplicate": "Duplicate",
"offer_seats": "Offer seats",
"offer_seats_description": "Offer seats for booking. This automatically disables guest & opt-in bookings.",
"offer_seats_description": "Offer seats for booking. This automatically disables guest & opt-in bookings. <0>Learn more</0>",
"seats_available_one": "Seat available",
"seats_available_other": "Seats available",
"seats_nearly_full": "Seats almost full",
@@ -1315,11 +1315,11 @@
"enabled_after_update_description": "The private link will work after saving",
"confirm_delete_api_key": "Revoke this API key",
"disable_rescheduling": "Disable Rescheduling",
"description_disable_rescheduling": "Guests can no longer reschedule the event with calendar invite or email",
"description_disable_rescheduling": "Guests can no longer reschedule the event with calendar invite or email. <0>Learn more</0>",
"allow_rescheduling_cancelled_bookings": "Allow booking through reschedule link",
"description_allow_rescheduling_cancelled_bookings": "When enabled, users will be able to create a new booking when trying to reschedule a cancelled booking",
"disable_cancelling": "Disable Cancelling",
"description_disable_cancelling": "Guests can no longer cancel the event with calendar invite or email",
"description_disable_cancelling": "Guests can no longer cancel the event with calendar invite or email. <0>Learn more</0>",
"revoke_api_key": "Revoke API key",
"api_key_copied": "API key copied!",
"api_key_expires_on": "The API key will expire on",
@@ -1901,13 +1901,13 @@
"every_app_published": "Every app published on the {{appName}} App Store is open source and thoroughly tested via peer reviews. Nevertheless, {{companyName}} does not endorse or certify these apps unless they are published by {{appName}}. If you encounter inappropriate content or behaviour please report it.",
"report_app": "Report app",
"limit_booking_frequency": "Limit booking frequency",
"limit_booking_frequency_description": "Limit how many times this event can be booked",
"limit_booking_frequency_description": "Limit how many times this event can be booked. <0>Learn more</0>",
"only_show_first_available_slot": "Only show the first slot of each day as available",
"only_show_first_available_slot_description": "This will limit your availability for this event type to one slot per day, scheduled at the earliest available time.",
"limit_total_booking_duration": "Limit total booking duration",
"limit_total_booking_duration_description": "Limit total amount of time that this event can be booked",
"booker_upcoming_limit": "Limit number of upcoming bookings per booker",
"booker_booking_limit_description": "Limit the number of active bookings a booker can make for this event type",
"booker_booking_limit_description": "Limit the number of active bookings a booker can make for this event type. <0>Learn more</0>",
"recurring_event_doesnt_support_booker_booking_limit": "Recurring events don't support booker booking limit",
"add_limit": "Add Limit",
"team_name_required": "Team name required",
@@ -1975,7 +1975,7 @@
"saml_sp_entity_id_copied": "SP Entity ID copied!",
"add_calendar": "Add Calendar",
"limit_future_bookings": "Limit future bookings",
"limit_future_bookings_description": "Limit how far in the future this event can be booked",
"limit_future_bookings_description": "Limit how far in the future this event can be booked. <0>Learn more</0>",
"no_event_types": "No event types setup",
"no_event_types_description": "{{name}} has not setup any event types for you to book.",
"billing_frequency": "Billing Frequency",
@@ -2231,7 +2231,7 @@
"under_maintenance_description": "The {{appName}} team are performing scheduled maintenance. If you have any questions, please contact support.",
"event_type_seats": "{{numberOfSeats}} seats",
"booking_questions_title": "Booking questions",
"booking_questions_description": "Customize the questions asked on the booking page",
"booking_questions_description": "Customize the questions asked on the booking page. <0>Learn more</0>",
"add_a_booking_question": "Add a question",
"identifier": "Identifier",
"duplicate_email": "Email is duplicate",
@@ -2715,7 +2715,7 @@
"locked": "Locked",
"unlocked": "Unlocked",
"lock_timezone_toggle_on_booking_page": "Lock timezone on booking page",
"description_lock_timezone_toggle_on_booking_page": "To lock the timezone on booking page, useful for in-person events.",
"description_lock_timezone_toggle_on_booking_page": "To lock the timezone on booking page, useful for in-person events. <0>Learn more</0>",
"event_setup_multiple_payment_apps_error": "You can only have one payment app enabled per event type.",
"number_in_international_format": "Please enter number in international format.",
"install_calendar": "Install Calendar",
@@ -3232,7 +3232,7 @@
"routed": "Routed",
"reassigned": "Reassigned",
"allow_rescheduling_past_events": "Allow rescheduling past events",
"allow_rescheduling_past_events_description": "Enabling this option allows for past events to be rescheduled",
"allow_rescheduling_past_events_description": "Enabling this option allows for past events to be rescheduled. <0>Learn more</0>",
"rerouted": "Rerouted",
"salesforce_assigned": "Salesforce assignment",
"router_position": "Router Position",
@@ -3459,7 +3459,7 @@
"hide_organizer_email": "Hide organizer's email",
"platform_customer_cant_create_organization": "You are already part of a platform organization which is why you can't create a new organization. Each user can only belong to one organization. Please, create a new cal.com account with a different email and use that account to subscribe to the organizations plan.",
"organization_customer_cant_create_platform": "You are already part of an organization which is why you can't create a platform organization. Each user can only belong to one organization. Please, create a new cal.com account with a different email and use that account to subscribe to the platform plan.",
"hide_organizer_email_description": "Hide organizer's email address from the booking screen, email notifications, and calendar events",
"hide_organizer_email_description": "Hide organizer's email address from the booking screen, email notifications, and calendar events. <0>Learn more</0>",
"you_and_conjunction": "You &",
"select_verified_email": "Select a verified email",
"add_verified_email": "Add verified email",
@@ -3694,7 +3694,7 @@
"license_key_saved": "License key saved successfully",
"timezone_mismatch_tooltip": "You are viewing the report based on your profile timezone ({{userTimezone}}), while your browser is set to timezone ({{browserTimezone}})",
"show_optimized_slots": "Optimized slots",
"show_optimized_slots_description": "Arrange time slots to optimize availability.",
"show_optimized_slots_description": "Arrange time slots to optimize availability. <0>Learn more</0>",
"failed_bookings_by_field": "Failed Bookings By Field",
"failed_bookings": "Failed bookings",
"event_type_no_hosts": "No hosts are assigned to event type",
@@ -3903,5 +3903,16 @@
"onboarding_plan_team_badge": "$15/user/mo",
"onboarding_plan_organization_badge": "$37/user/mo",
"onboarding_plan_organization_description": "Robust scheduling for larger teams looking to have more control, privacy, and security.",
"view_previous_week": "View previous week",
"view_next_week": "View next week",
"invite_teammates": "Invite Teammates",
"organization_brand": "Organization Brand",
"organization_details": "Organization Details",
"organization_teams": "Organization Teams",
"personal_profile": "Personal Profile",
"personal_settings": "Personal Settings",
"team_brand": "Team Brand",
"team_details": "Team Details",
"team_invite": "Team Invite",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+18 -3
View File
@@ -1,21 +1,24 @@
const fs = require("fs");
const path = require("path");
const glob = require("glob");
const crypto = require("crypto");
const copyAppStoreStatic = () => {
// Get all static files from app-store packages
const staticFiles = glob.sync("../../packages/app-store/**/static/**/*", { nodir: true });
// Object to store icon SVG hashes
const SVG_HASHES = {};
staticFiles.forEach((file) => {
// Extract app name from path
const appNameMatch = file.match(/app-store\/(.*?)\/static/);
if (!appNameMatch) return;
const appName = appNameMatch[1];
const appDirName = appNameMatch[1];
const fileName = path.basename(file);
// Create destination directory if it doesn't exist
const destDir = path.join(process.cwd(), "public", "app-store", appName);
const destDir = path.join(process.cwd(), "public", "app-store", appDirName);
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
@@ -23,8 +26,20 @@ const copyAppStoreStatic = () => {
// Copy file to destination (Turborepo caching handles change detection)
const destPath = path.join(destDir, fileName);
fs.copyFileSync(file, destPath);
// If it's an icon SVG file, compute hash
if (fileName.includes("icon") && fileName.endsWith(".svg")) {
const content = fs.readFileSync(file, "utf8");
const hash = crypto.createHash("md5").update(content).digest("hex").slice(0, 8);
SVG_HASHES[appDirName] = hash;
}
console.log(`Copied ${file} to ${destPath}`);
});
// Write SVG hashes to a JSON file
const hashFilePath = path.join(process.cwd(), "public", "app-store", "svg-hashes.json");
fs.writeFileSync(hashFilePath, JSON.stringify(SVG_HASHES, null, 2));
};
// Run the copy function
@@ -126,7 +126,6 @@ describe("handleChildrenEventTypes", () => {
autoTranslateDescriptionEnabled,
includeNoShowInRRCalculation,
instantMeetingScheduleId,
showOptimizedSlots,
...evType
} = mockFindFirstEventType({
id: 123,
@@ -189,7 +188,6 @@ describe("handleChildrenEventTypes", () => {
assignRRMembersUsingSegment,
includeNoShowInRRCalculation,
instantMeetingScheduleId,
showOptimizedSlots,
...evType
} = mockFindFirstEventType({
metadata: { managedEventConfig: {} },
@@ -300,7 +298,6 @@ describe("handleChildrenEventTypes", () => {
includeNoShowInRRCalculation,
instantMeetingScheduleId,
assignRRMembersUsingSegment,
showOptimizedSlots,
...evType
} = mockFindFirstEventType({
id: 123,
@@ -366,7 +363,6 @@ describe("handleChildrenEventTypes", () => {
assignRRMembersUsingSegment,
rrSegmentQueryValue,
useEventLevelSelectedCalendars,
showOptimizedSlots,
...evType
} = mockFindFirstEventType({
metadata: { managedEventConfig: {} },
@@ -432,7 +428,6 @@ describe("handleChildrenEventTypes", () => {
includeNoShowInRRCalculation,
instantMeetingScheduleId,
assignRRMembersUsingSegment,
showOptimizedSlots,
...evType
} = mockFindFirstEventType({
metadata: { managedEventConfig: {} },
@@ -456,7 +451,6 @@ describe("handleChildrenEventTypes", () => {
requiresBookerEmailVerification: false,
lockTimeZoneToggleOnBookingPage: false,
useEventTypeDestinationCalendarEmail: false,
showOptimizedSlots: false,
workflows: [],
parentId: 1,
locations: [],
+138
View File
@@ -0,0 +1,138 @@
# Use postgres/example user/password credentials
volumes:
database-data:
redis-data:
networks:
stack:
name: stack
external: false
services:
database:
container_name: database
image: postgres
restart: always
volumes:
- database-data:/var/lib/postgresql
environment:
- POSTGRES_USER=unicorn_user
- POSTGRES_PASSWORD=magical_password
- POSTGRES_DB=calendso
networks:
- stack
redis:
container_name: redis
image: redis:latest
restart: always
volumes:
- redis-data:/data
networks:
- stack
ports:
- "${REDIS_PORT:-6379}:6379"
calcom:
image: calcom.docker.scarf.sh/calcom/cal.com
build:
context: .
dockerfile: Dockerfile
args:
NEXT_PUBLIC_WEBAPP_URL: ${NEXT_PUBLIC_WEBAPP_URL}
NEXT_PUBLIC_API_V2_URL: ${NEXT_PUBLIC_API_V2_URL}
NEXT_PUBLIC_LICENSE_CONSENT: ${NEXT_PUBLIC_LICENSE_CONSENT}
NEXT_PUBLIC_WEBSITE_TERMS_URL: ${NEXT_PUBLIC_WEBSITE_TERMS_URL}
NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL: ${NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL}
NEXT_PUBLIC_SINGLE_ORG_SLUG: ${NEXT_PUBLIC_SINGLE_ORG_SLUG}
ORGANIZATIONS_ENABLED: ${ORGANIZATIONS_ENABLED}
CALCOM_TELEMETRY_DISABLED: ${CALCOM_TELEMETRY_DISABLED}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
CALENDSO_ENCRYPTION_KEY: ${CALENDSO_ENCRYPTION_KEY}
DATABASE_URL: ${DATABASE_URL}
DATABASE_DIRECT_URL: ${DATABASE_URL}
CSP_POLICY: ${CSP_POLICY}
restart: always
networks:
- stack
ports:
- 3000:3000
env_file: .env
environment:
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${DATABASE_HOST}/${POSTGRES_DB}
- DATABASE_DIRECT_URL=${DATABASE_URL}
depends_on:
- database
calcom-api:
container_name: calcom-api
build:
context: ./calcom
dockerfile: apps/api/v2/Dockerfile
args:
DATABASE_URL: ${DATABASE_URL}
DATABASE_DIRECT_URL: ${DATABASE_URL}
restart: always
networks:
- stack
ports:
- "${API_PORT:-80}:${API_PORT:-80}"
env_file: .env
environment:
- NODE_ENV=${NODE_ENV}
- API_PORT=${API_PORT:-80}
- API_URL=${API_URL}
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${DATABASE_HOST}/${POSTGRES_DB}
- DATABASE_READ_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${DATABASE_HOST}/${POSTGRES_DB}
- DATABASE_WRITE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${DATABASE_HOST}/${POSTGRES_DB}
- DATABASE_DIRECT_URL=${DATABASE_URL}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- JWT_SECRET=${JWT_SECRET}
- REDIS_URL=${REDIS_URL}
- LOG_LEVEL=${LOG_LEVEL}
- API_KEY_PREFIX=${API_KEY_PREFIX}
- WEB_APP_URL=${WEB_APP_URL}
- IS_E2E=${IS_E2E:-false}
- REWRITE_API_V2_PREFIX=${REWRITE_API_V2_PREFIX:-true}
- CALCOM_LICENSE_KEY=${CALCOM_LICENSE_KEY}
- NEXT_PUBLIC_VAPID_PUBLIC_KEY=${NEXT_PUBLIC_VAPID_PUBLIC_KEY}
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
- STRIPE_API_KEY=${STRIPE_API_KEY}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
- STRIPE_PRICE_ID_STARTER=${STRIPE_PRICE_ID_STARTER}
- STRIPE_PRICE_ID_STARTER_OVERAGE=${STRIPE_PRICE_ID_STARTER_OVERAGE}
- STRIPE_PRICE_ID_ESSENTIALS=${STRIPE_PRICE_ID_ESSENTIALS}
- STRIPE_PRICE_ID_ESSENTIALS_OVERAGE=${STRIPE_PRICE_ID_ESSENTIALS_OVERAGE}
- STRIPE_PRICE_ID_ENTERPRISE=${STRIPE_PRICE_ID_ENTERPRISE}
- STRIPE_PRICE_ID_ENTERPRISE_OVERAGE=${STRIPE_PRICE_ID_ENTERPRISE_OVERAGE}
- STRIPE_TEAM_MONTHLY_PRICE_ID=${STRIPE_TEAM_MONTHLY_PRICE_ID}
- IS_TEAM_BILLING_ENABLED=${IS_TEAM_BILLING_ENABLED:-false}
- AXIOM_DATASET=${AXIOM_DATASET}
- AXIOM_TOKEN=${AXIOM_TOKEN}
- LOGGER_BRIDGE_LOG_LEVEL=${LOGGER_BRIDGE_LOG_LEVEL}
- DOCS_URL=${DOCS_URL}
- GET_LICENSE_KEY_URL=${GET_LICENSE_KEY_URL}
depends_on:
- database
- redis
# Optional use of Prisma Studio. In production, comment out or remove the section below to prevent unwanted access to your database.
studio:
image: calcom.docker.scarf.sh/calcom/cal.com
restart: always
networks:
- stack
ports:
- 5555:5555
env_file: .env
environment:
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${DATABASE_HOST}/${POSTGRES_DB}
- DATABASE_DIRECT_URL=${DATABASE_URL}
depends_on:
- database
command:
- npx
- prisma
- studio
# END SECTION: Optional use of Prisma Studio.
+3 -3
View File
@@ -7957,7 +7957,7 @@
"post": {
"operationId": "BookingsController_2024_08_13_createBooking",
"summary": "Create a booking",
"description": "\n POST /v2/bookings is used to create regular bookings, recurring bookings and instant bookings. The request bodies for all 3 are almost the same except:\n If eventTypeId in the request body is id of a regular event, then regular booking is created.\n\n If it is an id of a recurring event type, then recurring booking is created.\n\n Meaning that the request bodies are equal but the outcome depends on what kind of event type it is with the goal of making it as seamless for developers as possible.\n\n For team event types it is possible to create instant meeting. To do that just pass `\"instant\": true` to the request body.\n\n The start needs to be in UTC aka if the timezone is GMT+2 in Rome and meeting should start at 11, then UTC time should have hours 09:00 aka without time zone.\n\n Finally, there are 2 ways to book an event type belonging to an individual user:\n 1. Provide `eventTypeId` in the request body.\n 2. Provide `eventTypeSlug` and `username` and optionally `organizationSlug` if the user with the username is within an organization.\n\n And 2 ways to book and event type belonging to a team:\n 1. Provide `eventTypeId` in the request body.\n 2. Provide `eventTypeSlug` and `teamSlug` and optionally `organizationSlug` if the team with the teamSlug is within an organization.\n\n If you are creating a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or\n you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.\n\n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
"description": "\n POST /v2/bookings is used to create regular bookings, recurring bookings and instant bookings. The request bodies for all 3 are almost the same except:\n If eventTypeId in the request body is id of a regular event, then regular booking is created.\n\n If it is an id of a recurring event type, then recurring booking is created.\n\n Meaning that the request bodies are equal but the outcome depends on what kind of event type it is with the goal of making it as seamless for developers as possible.\n\n For team event types it is possible to create instant meeting. To do that just pass `\"instant\": true` to the request body.\n\n The start needs to be in UTC aka if the timezone is GMT+2 in Rome and meeting should start at 11, then UTC time should have hours 09:00 aka without time zone.\n\n Finally, there are 2 ways to book an event type belonging to an individual user:\n 1. Provide `eventTypeId` in the request body.\n 2. Provide `eventTypeSlug` and `username` and optionally `organizationSlug` if the user with the username is within an organization.\n\n And 2 ways to book and event type belonging to a team:\n 1. Provide `eventTypeId` in the request body.\n 2. Provide `eventTypeSlug` and `teamSlug` and optionally `organizationSlug` if the team with the teamSlug is within an organization.\n\n If you are creating a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or\n you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.\n\n For event types that have SMS reminders workflow, you need to pass the attendee's phone number in the request body via `attendee.phoneNumber` (e.g., \"+19876543210\" in international format). This is an optional field, but becomes required when SMS reminders are enabled for the event type. For the complete attendee object structure, see the [attendee object](https://cal.com/docs/api-reference/v2/bookings/create-a-booking#body-attendee) documentation.\n\n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
"parameters": [
{
"name": "cal-api-version",
@@ -8483,7 +8483,7 @@
],
"requestBody": {
"required": true,
"description": "Accepts different types of reschedule booking input: Reschedule Booking (Option 1) or Reschedule Seated Booking (Option 2).\n\n If you are rescheduling a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or\n you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.",
"description": "Accepts different types of reschedule booking input: Reschedule Booking (Option 1) or Reschedule Seated Booking (Option 2). If you're rescheduling a seated booking as org admin of booking host, pass booking input for Reschedule Booking (Option 1) along with your access token in the request header.\n\n If you are rescheduling a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or\n you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.",
"content": {
"application/json": {
"schema": {
@@ -8518,7 +8518,7 @@
"post": {
"operationId": "BookingsController_2024_08_13_cancelBooking",
"summary": "Cancel a booking",
"description": ":bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.\n \n \nCancelling normal bookings:\n If the booking is not seated and not recurring simply pass :bookingUid in the request URL `/bookings/:bookingUid/cancel` and optionally cancellationReason in the request body `{\"cancellationReason\": \"Will travel\"}`.\n\n \nCancelling seated bookings:\n It is possible to cancel specific seat within a booking as an attendee or all of the seats as the host.\n \n1. As an attendee - provide :bookingUid in the request URL `/bookings/:bookingUid/cancel` and seatUid in the request body `{\"seatUid\": \"123-123-123\"}` . This will remove this particular attendance from the booking.\n \n2. As the host - host can cancel booking for all attendees aka for every seat. Provide :bookingUid in the request URL `/bookings/:bookingUid/cancel` and cancellationReason in the request body `{\"cancellationReason\": \"Will travel\"}` and `Authorization: Bearer token` request header where token is event type owner (host) credential. This will cancel the booking for all attendees.\n \n \nCancelling recurring seated bookings:\n For recurring seated bookings it is not possible to cancel all of them with 1 call\n like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.\n \n If you are cancelling a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or\n you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.\n\n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
"description": ":bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.\n \n \nCancelling normal bookings:\n If the booking is not seated and not recurring, simply pass :bookingUid in the request URL `/bookings/:bookingUid/cancel` and optionally cancellationReason in the request body `{\"cancellationReason\": \"Will travel\"}`.\n\n \nCancelling seated bookings:\n It is possible to cancel specific seat within a booking as an attendee or all of the seats as the host.\n \n1. As an attendee - provide :bookingUid in the request URL `/bookings/:bookingUid/cancel` and seatUid in the request body `{\"seatUid\": \"123-123-123\"}` . This will remove this particular attendance from the booking.\n \n2. As the host or org admin of host - host can cancel booking for all attendees aka for every seat, this also applies to org admins. Provide :bookingUid in the request URL `/bookings/:bookingUid/cancel` and cancellationReason in the request body `{\"cancellationReason\": \"Will travel\"}` and `Authorization: Bearer token` request header where token is event type owner (host) credential. This will cancel the booking for all attendees.\n \n \nCancelling recurring seated bookings:\n For recurring seated bookings it is not possible to cancel all of them with 1 call\n like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.\n \n If you are cancelling a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or\n you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.\n\n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
"parameters": [
{
"name": "cal-api-version",
+136 -75
View File
@@ -77,65 +77,67 @@ For a demonstration of the booker atom along with calendar overlay, please refer
<p></p>
We offer all kinds of customizations to the booker atom via props and customClassNames.
We offer all kinds of customizations to the booker atom via props and customClassNames.
Below is a list of props that can be passed to the booker atom.
<p></p>
| Name | Required | Description |
|:----------------------------|:----------|:-------------------------------------------------------------------------------------------------------|
| username | Yes | Username of the person whose schedule is to be displayed |
| eventSlug | Yes | Unique slug created for a particular event |
| orgBannerUrl | No | URL of the user's current organization |
| customClassNames | No | To pass in custom classnames from outside for styling the atom |
| month | No | The exact month for displaying a user's availability; defaults to the current month |
| selectedDate | No | Default selected date for which the slot picker opens |
| startTime | No | Custom start time for the Booker that allows users to decide the first available date. Accepts JavaScript Date object or date string in format `YYYY-MM-DD` (e.g., `"2025-08-20"` or `new Date("2025-08-20")`) |
| hideBranding | No | For hiding any branding on the booker |
| isAway | No | Sets the booker component to the away state |
| allowsDynamicBooking | No | Boolean indicating if the booking is a dynamic booking |
| bookingData | No | Data for rescheduling a booking passed in via this prop |
| defaultFormValues | No | Prefilled values for booking form fields like name, email, guests, notes, reschedule reason, etc. |
| isTeamEvent | No | Boolean indicating if it is a team event |
| duration | No | Refers to a multiple-duration event type; selects default if not passed |
| durationConfig | No | Configures selectable options for a multi-duration event type |
| hashedLink | No | Refers to the private link from event types page |
| isInstantMeeting | No | Boolean indicating if the booking is an instant meeting |
| rescheduleUid | No | Unique ID generated during rescheduling |
| bookingUid | No | Unique ID generated during booking |
| locationUrl | No | Custom meeting link URL instead of a Cal.com link |
| firstName | No | First name of the attendee |
| lastName | No | Last name of the attendee |
| guests | No | Invite a guest to join a meeting |
| name | No | Host name |
| onCreateBookingSuccess | No | Callback function for successful booking creation |
| onCreateBookingError | No | Callback function triggered on booking creation failure |
| onCreateRecurringBookingSuccess | No | Callback function for successful recurring booking creation |
| onCreateRecurringBookingError | No | Callback function triggered on recurring booking creation failure |
| onCreateInstantBookingSuccess | No | Callback function for successful instant booking creation |
| onCreateInstantBookingError | No | Callback function triggered on instant booking creation failure |
| onReserveSlotSuccess | No | Callback function for successful slot reservation |
| onReserveSlotError | No | Callback function triggered on slot reservation failure |
| onDeleteSlotSuccess | No | Callback function for successful slot deletion |
| onDeleteSlotError | No | Callback function triggered on slot deletion failure
| view | No | Specifies the layout of the booker atom into column, week, or month view |
| metadata | No | Used to pass custom metadata values into the booker. Metadata should be an object eg: `{ bookingSource: "website", userRole: "admin" }` |
| bannerUrl | No | Adds custom banner to the booker atom |
| onBookerStateChange | No | Callback function that is triggered when the state of the booker atom changes. |
| allowUpdatingUrlParams | No | Boolean indicating if the URL parameters should be updated, defaults to false. |
| confirmButtonDisabled | No | Boolean indicating if the submit button should be disabled, defaults to false. |
| timeZones | No | Array of valid IANA timezones to be used in the booker. Eg. ["Asia/Kolkata", "Europe/London"] |
| onTimeslotsLoaded | No | Callback function triggered once the available timeslots have been fetched. |
| roundRobinHideOrgAndTeam | No | Boolean indicating if the organization and team should be hidden in the booker atom sidebar for round robin scheduling type, defaults to false. |
| showNoAvailabilityDialog | No | Boolean indicating if the no availability dialog should be shown, defaults to true. |
| silentlyHandleCalendarFailures | No | Boolean when true the booker still displays slots when the third party calendars credentials are invalid or expired, Booker may show stale availability when enabled |
| hideEventMetadata | No | Boolean that controls the visibility of the event metadata sidebar. When `true`, hides the left sidebar containing event details like title, description, duration, and host information. Defaults to `false`. |
| Name | Required | Description |
| :------------------------------ | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| username | Yes | Username of the person whose schedule is to be displayed |
| eventSlug | Yes | Unique slug created for a particular event |
| orgBannerUrl | No | URL of the user's current organization |
| customClassNames | No | To pass in custom classnames from outside for styling the atom |
| month | No | The exact month for displaying a user's availability; defaults to the current month |
| selectedDate | No | Default selected date for which the slot picker opens |
| startTime | No | Custom start time for the Booker that allows users to decide the first available date. Accepts JavaScript Date object or date string in format `YYYY-MM-DD` (e.g., `"2025-08-20"` or `new Date("2025-08-20")`) |
| hideBranding | No | For hiding any branding on the booker |
| isAway | No | Sets the booker component to the away state |
| allowsDynamicBooking | No | Boolean indicating if the booking is a dynamic booking |
| bookingData | No | Data for rescheduling a booking passed in via this prop |
| defaultFormValues | No | Prefilled values for booking form fields like name, email, guests, notes, reschedule reason, etc. |
| isTeamEvent | No | Boolean indicating if it is a team event |
| duration | No | Refers to a multiple-duration event type; selects default if not passed |
| durationConfig | No | Configures selectable options for a multi-duration event type |
| hashedLink | No | Refers to the private link from event types page |
| isInstantMeeting | No | Boolean indicating if the booking is an instant meeting |
| bookingUid | No | Unique ID generated during booking creation |
| rescheduleUid | No | Unique ID generated during booking creation, same as bookingUid |
| locationUrl | No | Custom meeting link URL instead of a Cal.com link |
| firstName | No | First name of the attendee |
| lastName | No | Last name of the attendee |
| guests | No | Invite a guest to join a meeting |
| name | No | Host name |
| onCreateBookingSuccess | No | Callback function for successful booking creation |
| onCreateBookingError | No | Callback function triggered on booking creation failure |
| onCreateRecurringBookingSuccess | No | Callback function for successful recurring booking creation |
| onCreateRecurringBookingError | No | Callback function triggered on recurring booking creation failure |
| onCreateInstantBookingSuccess | No | Callback function for successful instant booking creation |
| onCreateInstantBookingError | No | Callback function triggered on instant booking creation failure |
| onReserveSlotSuccess | No | Callback function for successful slot reservation |
| onReserveSlotError | No | Callback function triggered on slot reservation failure |
| onDeleteSlotSuccess | No | Callback function for successful slot deletion |
| onDeleteSlotError | No | Callback function triggered on slot deletion failure |
| view | No | Specifies the layout of the booker atom into column, week, or month view |
| metadata | No | Used to pass custom metadata values into the booker. Metadata should be an object eg: `{ bookingSource: "website", userRole: "admin" }` |
| bannerUrl | No | Adds custom banner to the booker atom |
| onBookerStateChange | No | Callback function that is triggered when the state of the booker atom changes. |
| allowUpdatingUrlParams | No | Boolean indicating if the URL parameters should be updated, defaults to false. |
| confirmButtonDisabled | No | Boolean indicating if the submit button should be disabled, defaults to false. |
| timeZones | No | Array of valid IANA timezones to be used in the booker. Eg. ["Asia/Kolkata", "Europe/London"] |
| onTimeslotsLoaded | No | Callback function triggered once the available timeslots have been fetched. |
| roundRobinHideOrgAndTeam | No | Boolean indicating if the organization and team should be hidden in the booker atom sidebar for round robin scheduling type, defaults to false. |
| showNoAvailabilityDialog | No | Boolean indicating if the no availability dialog should be shown, defaults to true. |
| silentlyHandleCalendarFailures | No | Boolean when true the booker still displays slots when the third party calendars credentials are invalid or expired, Booker may show stale availability when enabled |
| hideEventMetadata | No | Boolean that controls the visibility of the event metadata sidebar. When `true`, hides the left sidebar containing event details like title, description, duration, and host information. Defaults to `false`. |
## Styling
Booker atom accepts custom styles via the `customClassNames` prop. This prop is an object that contains root level styles and nested objects for styling different parts of the booker component. Each nested object groups related styles for a specific section of the booker (e.g., eventMeta, datePicker, availableTimeSlots, etc).
Here is an example booker with root level style `bookerContainer` and nested object `datePickerCustomClassNames` with `datePickerDatesActive` style:
```js
import { Booker } from "@calcom/atoms";
@@ -159,43 +161,49 @@ export default function Booker( props : BookerProps ) {
Below is a list of **customClassNames** properties grouped by their parent objects:
### Root Level Styles
| Property | Description |
|:---------|:-------------|
| Property | Description |
| :-------------- | :------------------------------------------- |
| bookerContainer | Adds styling to the whole of the booker atom |
### Event Meta Styles (`eventMetaCustomClassNames`)
| Property | Description |
|:---------|:-------------|
| eventMetaContainer | Styles the event meta component containing details about an event |
| eventMetaTitle | Adds styles to the event meta title |
| eventMetaTimezoneSelect | Adds styles to the event meta timezone selector |
| Property | Description |
| :---------------------- | :---------------------------------------------------------------- |
| eventMetaContainer | Styles the event meta component containing details about an event |
| eventMetaTitle | Adds styles to the event meta title |
| eventMetaTimezoneSelect | Adds styles to the event meta timezone selector |
### Date Picker Styles (`datePickerCustomClassNames`)
| Property | Description |
|:---------|:-------------|
| datePickerContainer | Adds styling to the date picker |
| datePickerTitle | Styles the date picker title |
| datePickerDays | Adds styling to all days in the date picker |
| datePickerDate | Adds styling to all dates in the date picker |
| datePickerDatesActive | Styles only the dates with available slots |
| datePickerToggle | Styles the left and right toggle buttons |
| Property | Description |
| :-------------------- | :------------------------------------------- |
| datePickerContainer | Adds styling to the date picker |
| datePickerTitle | Styles the date picker title |
| datePickerDays | Adds styling to all days in the date picker |
| datePickerDate | Adds styling to all dates in the date picker |
| datePickerDatesActive | Styles only the dates with available slots |
| datePickerToggle | Styles the left and right toggle buttons |
### Available Time Slots Styles (`availableTimeSlotsCustomClassNames`)
| Property | Description |
|:---------|:-------------|
| availableTimeSlotsContainer | Adds styling to the available time slots component |
| availableTimeSlotsHeaderContainer | Styles only the header container |
| availableTimeSlotsTitle | Adds styles to the title |
| availableTimeSlotsTimeFormatToggle | Adds styles to the format toggle buttons |
| availableTimes | Styles all the available times container |
| Property | Description |
| :--------------------------------- | :------------------------------------------------- |
| availableTimeSlotsContainer | Adds styling to the available time slots component |
| availableTimeSlotsHeaderContainer | Styles only the header container |
| availableTimeSlotsTitle | Adds styles to the title |
| availableTimeSlotsTimeFormatToggle | Adds styles to the format toggle buttons |
| availableTimes | Styles all the available times container |
### Confirmation Step Styles (`confirmStep`)
| Property | Description |
|:---------|:-------------|
| Property | Description |
| :------------ | :-------------------------------------------- |
| confirmButton | Styles the confirm button in the booking form |
| backButton | Styles the back button in the booking form |
| backButton | Styles the back button in the booking form |
Here is an example with more custom styles:
```js
import { Booker } from "@calcom/atoms";
@@ -225,4 +233,57 @@ export default function Booker( props : BookerProps ) {
</>
)
}
```
```
## Rescheduling a booking
The booker atom also supports rescheduling a booking. To reschedule a booking, you need to pass in the `rescheduleUid` prop to the booker atom along with the other necessary props. This will allow the booker to display the reschedule form and handle the rescheduling process. The `rescheduleUid` is the booking uid you get when you create a booking.
Here is an example of how to use the booker atom for rescheduling an individual event:
```js
import { Booker } from "@calcom/atoms";
export default function Booker( props : BookerProps ) {
return (
<>
<Booker
eventSlug={props.eventTypeSlug}
username={props.calUsername}
// rescheduleUid is the booking uid you get when you create a booking
// it is only required when rescheduling a booking
rescheduleUid={props.rescheduleUid}
onCreateBookingSuccess={() => {
console.log("Booking rescheduled successfully!");
}}
/>
</>
)
}
```
For team events, you need to pass isTeamEvent and teamId prop to the booker along with the other necessary props. This will allow the booker to display the reschedule form and handle the rescheduling process. The `teamId` is the team id you get when you create a team event.
Here is an example of how to use the booker atom for rescheduling a team event:
```js
import { Booker } from "@calcom/atoms";
export default function Booker( props : BookerProps ) {
return (
<>
<Booker
eventSlug={props.eventTypeSlug}
// rescheduleUid is the booking uid you get when you create a booking
// it is only required when rescheduling a booking
rescheduleUid={props.rescheduleUid}
isTeamEvent={true}
teamId={props.teamId}
onCreateBookingSuccess={() => {
console.log("Booking rescheduled successfully!");
}}
/>
</>
)
}
```
+17 -20
View File
@@ -13,7 +13,8 @@ import { CalendarServiceMap } from "../calendar.services.generated";
const log = logger.getSubLogger({ prefix: ["CalendarManager"] });
export const getCalendar = async (
credential: CredentialForCalendarService | null
credential: CredentialForCalendarService | null,
shouldServeCache?: boolean,
): Promise<Calendar | null> => {
if (!credential || !credential.key) return null;
let { type: calendarType } = credential;
@@ -41,12 +42,8 @@ export const getCalendar = async (
log.warn(`calendar of type ${calendarType} is not implemented`);
return null;
}
// check if Calendar Cache is supported and enabled
if (CalendarCacheEventService.isCalendarTypeSupported(calendarType)) {
log.debug(
`Using regular CalendarService for credential ${credential.id} (not Google or Office365 Calendar)`
);
// if shouldServeCache is not supplied, determine on the fly.
if (typeof shouldServeCache === "undefined") {
const featuresRepository = new FeaturesRepository(prisma);
const [isCalendarSubscriptionCacheEnabled, isCalendarSubscriptionCacheEnabledForUser] = await Promise.all(
[
@@ -59,19 +56,19 @@ export const getCalendar = async (
),
]
);
if (isCalendarSubscriptionCacheEnabled && isCalendarSubscriptionCacheEnabledForUser) {
log.debug(`Calendar Cache is enabled, using CalendarCacheService for credential ${credential.id}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const originalCalendar = new CalendarService(credential as any);
if (originalCalendar) {
// return cacheable calendar
const calendarCacheEventRepository = new CalendarCacheEventRepository(prisma);
return new CalendarCacheWrapper({
originalCalendar,
calendarCacheEventRepository,
});
}
shouldServeCache = isCalendarSubscriptionCacheEnabled && isCalendarSubscriptionCacheEnabledForUser;
}
if (CalendarCacheEventService.isCalendarTypeSupported(calendarType) && shouldServeCache) {
log.debug(`Calendar Cache is enabled, using CalendarCacheService for credential ${credential.id}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const originalCalendar = new CalendarService(credential as any);
if (originalCalendar) {
// return cacheable calendar
const calendarCacheEventRepository = new CalendarCacheEventRepository(prisma);
return new CalendarCacheWrapper({
originalCalendar,
calendarCacheEventRepository,
});
}
}
+4 -4
View File
@@ -32,10 +32,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
throw new Error("Unable to create user credential for Alby");
}
} catch (error: unknown) {
if (error instanceof Error) {
return res.status(500).json({ message: error.message });
}
return res.status(500);
const message =
error instanceof Error ? error.message : typeof error === "string" ? error : JSON.stringify(error);
return res.status(500).json({ message });
}
return res.status(200).json({ url: "/apps/hitpay/setup" });
+1 -1
View File
@@ -312,7 +312,7 @@ export const privacyFilteredLocations = (locations: LocationObject[]): PrivacyFi
if (location.displayLocationPublicly || !eventLocationType) {
return location;
} else {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { address: _1, link: _2, hostPhoneNumber: _3, ...privacyFilteredLocation } = location;
logger.debug("Applied Privacy Filter", location, privacyFilteredLocation);
return privacyFilteredLocation;
@@ -113,7 +113,7 @@ export default class Office365CalendarService implements Calendar {
invalidateTokenObject: () => oAuthManagerHelper.invalidateCredential(credential.id),
expireAccessToken: () => oAuthManagerHelper.markTokenAsExpired(credential),
updateTokenObject: (tokenObject) => {
if (!Boolean(credential.delegatedTo)) {
if (!credential.delegatedTo) {
return oAuthManagerHelper.updateTokenObject({ tokenObject, credentialId: credential.id });
}
return Promise.resolve();
@@ -947,8 +947,6 @@ test.describe("Routing Forms", () => {
const newUser = await addNewUserToTeam({ users, teamId });
await goToRoutingLinkAndSubmit({ page, formId });
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(2000);
const receivedEmailsOwner = await getEmailsReceivedByUser({ emails, userEmail: owner.email });
expect(receivedEmailsOwner?.total).toBe(1);
const receivedEmailsNewUser = await getEmailsReceivedByUser({ emails, userEmail: newUser.email });
@@ -965,8 +963,6 @@ test.describe("Routing Forms", () => {
const newUser = await addNewUserToTeam({ users, teamId });
await goToRoutingLinkAndSubmit({ page, formId });
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(2000);
const receivedEmailsOwner = await getEmailsReceivedByUser({ emails, userEmail: owner.email });
expect(receivedEmailsOwner?.total).toBe(1);
const receivedEmailsNewUser = await getEmailsReceivedByUser({ emails, userEmail: newUser.email });
File diff suppressed because it is too large Load Diff
+213 -2
View File
@@ -1,11 +1,54 @@
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer";
import type { TFunction } from "i18next";
import type { TimeFormat } from "@calcom/lib/timeFormat";
import type { Prisma } from "@calcom/prisma/client";
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
import { getTranslation } from "@calcom/lib/server/i18n";
import { getTimeFormatStringFromUserTimeFormat, type TimeFormat } from "@calcom/lib/timeFormat";
import type { Attendee, DestinationCalendar, Prisma, User } from "@calcom/prisma/client";
import type { SchedulingType } from "@calcom/prisma/enums";
import { bookingResponses as bookingResponsesSchema } from "@calcom/prisma/zod-utils";
import type { CalendarEvent, Person, CalEventResponses, AppsStatus } from "@calcom/types/Calendar";
import type { VideoCallData } from "@calcom/types/VideoApiAdapter";
type BookingForCalEventBuilder = NonNullable<
Awaited<ReturnType<BookingRepository["getBookingForCalEventBuilder"]>>
>;
type BookingMetaOptions = {
conferenceCredentialId?: number;
platformClientId?: string;
platformRescheduleUrl?: string;
platformCancelUrl?: string;
platformBookingUrl?: string;
};
async function _buildPersonFromUser(
user: Pick<User, "id" | "name" | "locale" | "username" | "email" | "timeFormat" | "timeZone">
) {
const translate = await getTranslation(user.locale ?? "en", "common");
return {
id: user.id,
name: user.name || "Nameless",
email: user.email,
username: user.username || undefined,
timeZone: user.timeZone,
language: { translate, locale: user.locale ?? "en" },
timeFormat: getTimeFormatStringFromUserTimeFormat(user.timeFormat),
} satisfies Person;
}
async function _buildPersonFromAttendee(attendee: Pick<Attendee, "locale" | "name" | "timeZone" | "email">) {
const translate = await getTranslation(attendee.locale ?? "en", "common");
return {
name: attendee.name ?? "",
email: attendee.email,
timeZone: attendee.timeZone,
language: { translate, locale: attendee.locale ?? "en" },
} satisfies Person;
}
export class CalendarEventBuilder {
private event: Partial<CalendarEvent>;
@@ -17,6 +60,174 @@ export class CalendarEventBuilder {
return new CalendarEventBuilder(event);
}
/**
* Builds a CalendarEventBuilder instance from a booking.
*/
static async fromBooking(
booking: BookingForCalEventBuilder,
meta: BookingMetaOptions = {}
): Promise<CalendarEventBuilder> {
const { uid, user, eventType } = booking;
if (!user) throw new Error(`Booking ${uid} is missing an organizer — user may have been deleted.`);
if (!eventType) throw new Error(`Booking ${uid} is missing eventType — it may have been deleted.`);
const builder = new CalendarEventBuilder();
const {
description,
attendees,
references,
title,
startTime,
endTime,
location,
responses,
customInputs,
iCalUID,
iCalSequence,
oneTimePassword,
seatsReferences,
} = booking;
const {
conferenceCredentialId,
platformRescheduleUrl = "",
platformClientId = "",
platformCancelUrl = "",
platformBookingUrl = "",
} = meta;
const organizerPerson = await _buildPersonFromUser(user);
const attendeesList = await Promise.all(attendees.map(_buildPersonFromAttendee));
const additionalNotes = description || undefined;
const videoRef = references.find((r) => r.type.endsWith("_video"));
const videoCallData = videoRef
? {
type: videoRef.type,
id: videoRef.meetingId,
password: videoRef.meetingPassword,
url: videoRef.meetingUrl,
}
: undefined;
const organizationId = user.profiles?.[0]?.organizationId ?? null;
const bookerUrl = await getBookerBaseUrl(eventType.team?.parentId ?? organizationId);
const parsedBookingResponses = bookingResponsesSchema.safeParse(responses);
const bookingResponses = parsedBookingResponses.success ? parsedBookingResponses.data : null;
const calEventResponses = getCalEventResponses({
booking,
bookingFields: eventType.bookingFields,
});
// custom inputs are the old system to record booking responses
const parsedCustomInputs =
typeof customInputs === "object" ? (customInputs as Record<string, string>) : null;
const recurring = parseRecurringEvent(eventType.recurringEvent) ?? undefined;
// Base builder setup
builder
.withBasicDetails({
bookerUrl,
title,
startTime: startTime.toISOString(),
endTime: endTime.toISOString(),
additionalNotes,
})
.withEventType({
id: eventType.id,
slug: eventType.slug,
description: eventType.description,
hideCalendarNotes: eventType.hideCalendarNotes,
hideCalendarEventDetails: eventType.hideCalendarEventDetails,
hideOrganizerEmail: eventType.hideOrganizerEmail,
schedulingType: eventType.schedulingType,
seatsPerTimeSlot: eventType.seatsPerTimeSlot,
seatsShowAttendees: !!eventType.seatsShowAttendees,
seatsShowAvailabilityCount: !!eventType.seatsShowAvailabilityCount,
customReplyToEmail: eventType.customReplyToEmail,
disableRescheduling: eventType.disableRescheduling ?? false,
disableCancelling: eventType.disableCancelling ?? false,
})
.withOrganizer(organizerPerson)
.withAttendees(attendeesList)
.withMetadataAndResponses({
additionalNotes,
customInputs: parsedCustomInputs,
responses: calEventResponses.responses,
userFieldsResponses: calEventResponses.userFieldsResponses,
})
.withLocation({ location, conferenceCredentialId })
.withIdentifiers({ iCalUID: iCalUID || undefined, iCalSequence })
.withConfirmation({
requiresConfirmation: !!eventType.requiresConfirmation,
isConfirmedByDefault: !eventType.requiresConfirmation,
})
.withPlatformVariables({
platformClientId,
platformRescheduleUrl,
platformCancelUrl,
platformBookingUrl,
})
.withRecurring(recurring)
.withUid(uid)
.withOneTimePassword(oneTimePassword);
// Seats
if (seatsReferences?.length && bookingResponses) {
const currentSeat = seatsReferences.find(
(s) =>
s.attendee.email === bookingResponses.email ||
(bookingResponses.attendeePhoneNumber &&
s.attendee.phoneNumber === bookingResponses.attendeePhoneNumber)
);
if (currentSeat) builder.withAttendeeSeatId(currentSeat.referenceUid);
}
// Video
if (videoCallData && videoCallData.id && videoCallData.password && videoCallData.url) {
builder
.withVideoCallData({
...videoCallData,
id: videoCallData.id,
password: videoCallData.password,
url: videoCallData.url,
})
.withAppsStatus([
{ appName: videoCallData.type, type: videoCallData.type, success: 1, failures: 0, errors: [] },
]);
} else {
builder.withAppsStatus([]);
}
// Team & calendars
if (eventType.team) {
const hostsWithoutOrganizer = await Promise.all(
eventType.hosts.filter((h) => h.user.email !== user.email).map((h) => _buildPersonFromUser(h.user))
);
const hostCalendars = [
...eventType.hosts.map((h) => h.user.destinationCalendar).filter(Boolean),
user.destinationCalendar,
].filter(Boolean) as NonNullable<DestinationCalendar>[];
builder
.withTeam({
id: eventType.team.id,
name: eventType.team.name || "",
members: hostsWithoutOrganizer,
})
.withDestinationCalendar(hostCalendars);
} else if (user.destinationCalendar) {
builder.withDestinationCalendar([user.destinationCalendar]);
}
return builder;
}
withBasicDetails({
bookerUrl,
title,
@@ -0,0 +1,68 @@
import dayjs from "@calcom/dayjs";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { prisma } from "@calcom/prisma";
const ONBOARDING_INTRODUCED_AT = dayjs("September 1 2021").toISOString();
/**
* Checks if a user needs onboarding on the server side.
* Returns the onboarding path if redirect is needed, null otherwise.
*
* @param userId - The user ID to check
* @param options - Optional configuration
* @param options.checkEmailVerification - Whether to check if email verification is required
* @param options.organizationId - Optional organizationId from session (to avoid extra query)
*/
export async function checkOnboardingRedirect(
userId: number,
options?: {
checkEmailVerification?: boolean;
organizationId?: number | null;
}
): Promise<string | null> {
// Query user data needed for onboarding check using UserRepository
const userRepository = new UserRepository(prisma);
const user = await userRepository.findById({ id: userId });
if (!user) {
return null;
}
// Use provided organizationId or query it via ProfileRepository
let organizationId: number | null;
if (options?.organizationId !== undefined) {
organizationId = options.organizationId;
} else {
const profile = await ProfileRepository.findFirstForUserId({ userId });
organizationId = profile?.organizationId ?? null;
}
// Check if user should be shown onboarding
const shouldShowOnboarding =
!user.completedOnboarding && !organizationId && dayjs(user.createdDate).isAfter(ONBOARDING_INTRODUCED_AT);
if (!shouldShowOnboarding) {
return null;
}
// Check email verification if needed
const featuresRepository = new FeaturesRepository(prisma);
if (options?.checkEmailVerification) {
const emailVerificationEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
"email-verification"
);
if (!user.emailVerified && user.identityProvider === "CAL" && emailVerificationEnabled) {
// User needs email verification, redirect to verification page
return "/auth/verify-email";
}
}
// Determine which onboarding path to use
const onboardingV3Enabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("onboarding-v3");
return onboardingV3Enabled ? "/onboarding/getting-started" : "/getting-started";
}
@@ -28,6 +28,7 @@ import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server/i18n";
import { PrismaOrgMembershipRepository } from "@calcom/lib/server/repository/PrismaOrgMembershipRepository";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
// TODO: Prisma import would be used from DI in a followup PR when we remove `handler` export
import prisma from "@calcom/prisma";
@@ -144,8 +145,18 @@ async function handler(input: CancelBookingInput) {
const userIsOwnerOfEventType = bookingToDelete.eventType.owner?.id === userId;
if (!userIsHost && !userIsOwnerOfEventType) {
throw new HttpError({ statusCode: 401, message: "User not a host of this event" });
const userIsOrgAdminOfBookingUser =
userId &&
(await PrismaOrgMembershipRepository.isLoggedInUserOrgAdminOfBookingHost(
userId,
bookingToDelete.userId
));
if (!userIsHost && !userIsOwnerOfEventType && !userIsOrgAdminOfBookingUser) {
throw new HttpError({
statusCode: 401,
message: "User not a host of this event or an admin of the booking user",
});
}
}
@@ -1,8 +1,8 @@
// eslint-disable-next-line no-restricted-imports
import dayjs from "@calcom/dayjs";
import { refreshCredentials } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/refreshCredentials";
import EventManager from "@calcom/features/bookings/lib/EventManager";
import { refreshCredentials } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/refreshCredentials";
import { HttpError } from "@calcom/lib/http-error";
import { PrismaOrgMembershipRepository } from "@calcom/lib/server/repository/PrismaOrgMembershipRepository";
import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import type { Person } from "@calcom/types/Calendar";
@@ -98,9 +98,17 @@ const rescheduleSeatedBooking = async (
};
if (!bookingSeat) {
const isOrgAdmin =
reqUserId &&
seatedBooking.user &&
(await PrismaOrgMembershipRepository.isLoggedInUserOrgAdminOfBookingHost(
reqUserId,
seatedBooking.user?.id
));
// if no bookingSeat is given and the userId != owner, 401.
// if no bookingSeat is given, also check if the request user is an org admin of the booking user
// TODO: Next step; Evaluate ownership, what about teams?
if (seatedBooking.user?.id !== reqUserId) {
if (seatedBooking.user?.id !== reqUserId && !isOrgAdmin) {
throw new HttpError({ statusCode: 401 });
}
@@ -9,11 +9,12 @@ export function isEventTypeLoggingEnabled({
usernameOrTeamName instanceof Array ? usernameOrTeamName : [usernameOrTeamName];
// eslint-disable-next-line turbo/no-undeclared-env-vars
const bookingLoggingEventIds = process.env.BOOKING_LOGGING_EVENT_IDS || "";
const isEnabled = bookingLoggingEventIds.split(",").some((id) => {
if (Number(id.trim()) === eventTypeId) {
return true;
}
});
const validEventIds = bookingLoggingEventIds
.split(",")
.map((id) => Number(id.trim()))
.filter((id) => !isNaN(id) && id > 0);
const isEnabled = eventTypeId && validEventIds.includes(eventTypeId);
if (isEnabled) {
return true;
@@ -21,7 +22,12 @@ export function isEventTypeLoggingEnabled({
// eslint-disable-next-line turbo/no-undeclared-env-vars
const bookingLoggingUsername = process.env.BOOKING_LOGGING_USER_OR_TEAM_NAME || "";
return bookingLoggingUsername.split(",").some((u) => {
return usernameOrTeamnamesList.some((foundUsername) => foundUsername === u.trim());
const validUsernames = bookingLoggingUsername
.split(",")
.map((u) => u.trim())
.filter((u) => u.length > 0);
return validUsernames.some((username) => {
return usernameOrTeamnamesList.some((foundUsername) => foundUsername === username);
});
}
@@ -481,19 +481,6 @@ async function handler(
skipCalendarSyncTaskCreation = false,
} = input;
const {
/**
* TODO: In a followup PR, we aim to remove prisma dependency and instead inject the repositories as dependencies.
* This would require moving multiple queries to appropriate repositories.
*/
prismaClient: prisma,
bookingRepository,
userRepository,
cacheService,
checkBookingAndDurationLimitsService,
luckyUserService,
} = deps;
const isPlatformBooking = !!platformClientId;
const eventType = await getEventType({
@@ -533,7 +520,7 @@ async function handler(
reroutingFormResponses,
routingFormResponseId,
_isDryRun: isDryRun = false,
_shouldServeCache,
_shouldServeCache: shouldServeCache = false,
...reqBody
} = bookingData;
@@ -641,7 +628,7 @@ async function handler(
) {
const requiresPayment = !Number.isNaN(paymentAppData.price) && paymentAppData.price > 0;
const existingBooking = await bookingRepository.getValidBookingFromEventTypeForAttendee({
const existingBooking = await deps.bookingRepository.getValidBookingFromEventTypeForAttendee({
eventTypeId,
bookerEmail,
bookerPhoneNumber,
@@ -678,7 +665,7 @@ async function handler(
}
}
const shouldServeCache = await cacheService.getShouldServeCache(_shouldServeCache, eventType.team?.id);
// const shouldServeCache = await cacheService.getShouldServeCache(_shouldServeCache, eventType.team?.id);
const isTeamEventType =
!!eventType.schedulingType && ["COLLECTIVE", "ROUND_ROBIN"].includes(eventType.schedulingType);
@@ -747,7 +734,7 @@ async function handler(
if (routingFormResponseId === undefined) {
throw new HttpError({ statusCode: 400, message: "Missing routingFormResponseId" });
}
routingFormResponse = await prisma.app_RoutingForms_FormResponse.findUnique({
routingFormResponse = await deps.prismaClient.app_RoutingForms_FormResponse.findUnique({
where: {
id: routingFormResponseId,
},
@@ -790,7 +777,7 @@ async function handler(
});
if (!skipEventLimitsCheck) {
await checkBookingAndDurationLimitsService.checkBookingAndDurationLimits({
await deps.checkBookingAndDurationLimitsService.checkBookingAndDurationLimits({
eventType,
reqBodyStart: reqBody.start,
reqBodyRescheduleUid: reqBody.rescheduleUid,
@@ -802,7 +789,7 @@ async function handler(
let availableUsers: IsFixedAwareUser[] = [];
if (eventType.seatsPerTimeSlot) {
const booking = await prisma.booking.findFirst({
const booking = await deps.prismaClient.booking.findFirst({
where: {
eventTypeId: eventType.id,
startTime: new Date(dayjs(reqBody.start).utc().format()),
@@ -1010,7 +997,7 @@ async function handler(
memberId: eventTypeWithUsers.users[0].id ?? null,
teamId: eventType.teamId,
});
const newLuckyUser = await luckyUserService.getLuckyUser({
const newLuckyUser = await deps.luckyUserService.getLuckyUser({
// find a lucky user that is not already in the luckyUsers array
availableUsers: freeUsers,
// only hosts from the same group
@@ -1207,7 +1194,7 @@ async function handler(
: [];
const guestEmails = (reqGuests || []).map((email) => extractBaseEmail(email).toLowerCase());
const guestUsers = await userRepository.findManyByEmailsWithEmailVerificationSettings({
const guestUsers = await deps.userRepository.findManyByEmailsWithEmailVerificationSettings({
emails: guestEmails,
});
@@ -1291,7 +1278,7 @@ async function handler(
});
// For bookings made before introducing iCalSequence, assume that the sequence should start at 1. For new bookings start at 0.
const iCalSequence = getICalSequence(originalRescheduledBooking);
const organizerOrganizationProfile = await prisma.profile.findFirst({
const organizerOrganizationProfile = await deps.prismaClient.profile.findFirst({
where: {
userId: organizerUser.id,
},
@@ -1795,7 +1782,7 @@ async function handler(
// Save description to bookingSeat
const uniqueAttendeeId = uuid();
await prisma.bookingSeat.create({
await deps.prismaClient.bookingSeat.create({
data: {
referenceUid: uniqueAttendeeId,
data: {
@@ -2161,7 +2148,7 @@ async function handler(
if (!isDryRun && evt.iCalUID !== booking.iCalUID) {
// The eventManager could change the iCalUID. At this point we can update the DB record
await prisma.booking.update({
await deps.prismaClient.booking.update({
where: {
id: booking.id,
},
@@ -2284,7 +2271,7 @@ async function handler(
if (bookingRequiresPayment) {
loggerWithEventDetails.debug(`Booking ${organizerUser.username} requires payment`);
// Load credentials.app.categories
const credentialPaymentAppCategories = await prisma.credential.findMany({
const credentialPaymentAppCategories = await deps.prismaClient.credential.findMany({
where: {
...(paymentAppData.credentialId ? { id: paymentAppData.credentialId } : { userId: organizerUser.id }),
app: {
@@ -2502,7 +2489,7 @@ async function handler(
try {
if (!isDryRun) {
await prisma.booking.update({
await deps.prismaClient.booking.update({
where: {
uid: booking.uid,
},
@@ -1173,6 +1173,153 @@ export class BookingRepository {
});
}
async getBookingForCalEventBuilder(bookingId: number) {
return await this.prismaClient.booking.findUnique({
where: { id: bookingId },
select: {
uid: true,
title: true,
startTime: true,
endTime: true,
description: true,
customInputs: true,
responses: true,
metadata: true,
location: true,
iCalUID: true,
iCalSequence: true,
oneTimePassword: true,
attendees: {
select: {
name: true,
email: true,
timeZone: true,
locale: true,
phoneNumber: true,
},
},
user: {
// Organizer
select: {
id: true,
name: true,
email: true,
username: true,
timeZone: true,
locale: true,
timeFormat: true,
destinationCalendar: true,
profiles: { select: { organizationId: true } },
},
},
// destination calendar of the Organizer
destinationCalendar: true,
eventType: {
select: {
id: true,
slug: true,
description: true,
hideCalendarNotes: true,
hideCalendarEventDetails: true,
hideOrganizerEmail: true,
schedulingType: true,
seatsPerTimeSlot: true,
seatsShowAttendees: true,
seatsShowAvailabilityCount: true,
customReplyToEmail: true,
disableRescheduling: true,
disableCancelling: true,
requiresConfirmation: true,
recurringEvent: true,
bookingFields: true,
metadata: true,
eventName: true,
team: {
select: {
id: true,
name: true,
parentId: true,
members: {
select: {
user: {
select: {
id: true,
name: true,
email: true,
username: true,
timeZone: true,
locale: true,
timeFormat: true,
},
},
},
},
},
},
users: {
select: {
id: true,
name: true,
email: true,
username: true,
timeZone: true,
locale: true,
timeFormat: true,
destinationCalendar: true,
},
},
hosts: {
select: {
userId: true,
isFixed: true,
user: {
select: {
id: true,
name: true,
email: true,
username: true,
timeZone: true,
locale: true,
timeFormat: true,
destinationCalendar: true,
},
},
},
},
workflows: {
select: {
workflow: {
select: {
id: true,
},
},
},
},
},
},
references: {
select: {
type: true,
meetingId: true,
meetingPassword: true,
meetingUrl: true,
},
where: {
type: {
endsWith: "_video",
},
},
},
seatsReferences: {
select: {
id: true,
referenceUid: true,
attendee: { select: { id: true, email: true, phoneNumber: true } },
},
},
},
});
}
async findByIdIncludeDestinationCalendar(bookingId: number) {
return await this.prismaClient.booking.findUnique({
where: {
+3 -3
View File
@@ -21,19 +21,19 @@ export const createPhoneCallSchema = requiredFields.merge(
.string()
.optional()
.transform((val) => {
return !!val ? val : undefined;
return val ? val : undefined;
}),
guestEmail: z
.string()
.optional()
.transform((val) => {
return !!val ? val : undefined;
return val ? val : undefined;
}),
guestCompany: z
.string()
.optional()
.transform((val) => {
return !!val ? val : undefined;
return val ? val : undefined;
}),
beginMessage: z.string().optional(),
generalPrompt: z.string().optional(),
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { IFeaturesRepository } from "@calcom/features/flags/features.repository.interface";
import { CacheService } from "./getShouldServeCache";
import { CalendarSubscriptionService } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionService";
describe("CacheService.getShouldServeCache", () => {
const mockFeaturesRepository: IFeaturesRepository = {
@@ -57,7 +58,7 @@ describe("CacheService.getShouldServeCache", () => {
const result = await cacheService.getShouldServeCache(undefined, 123);
expect(result).toBe(true);
expect(mockFeaturesRepository.checkIfTeamHasFeature).toHaveBeenCalledWith(123, "calendar-cache-serve");
expect(mockFeaturesRepository.checkIfTeamHasFeature).toHaveBeenCalledWith(123, CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_CACHE_FEATURE);
});
it("should check feature repository when teamId is provided and return false if feature is disabled", async () => {
@@ -66,7 +67,7 @@ describe("CacheService.getShouldServeCache", () => {
const result = await cacheService.getShouldServeCache(undefined, 456);
expect(result).toBe(false);
expect(mockFeaturesRepository.checkIfTeamHasFeature).toHaveBeenCalledWith(456, "calendar-cache-serve");
expect(mockFeaturesRepository.checkIfTeamHasFeature).toHaveBeenCalledWith(456, CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_CACHE_FEATURE);
});
});
@@ -86,7 +87,7 @@ describe("CacheService.getShouldServeCache", () => {
const result = await cacheService.getShouldServeCache(undefined, 999);
expect(result).toBe(true);
expect(mockFeaturesRepository.checkIfTeamHasFeature).toHaveBeenCalledWith(999, "calendar-cache-serve");
expect(mockFeaturesRepository.checkIfTeamHasFeature).toHaveBeenCalledWith(999, CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_CACHE_FEATURE);
});
});
});
@@ -1,4 +1,5 @@
import type { IFeaturesRepository } from "@calcom/features/flags/features.repository.interface";
import { CalendarSubscriptionService } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionService";
export interface ICacheService {
featuresRepository: IFeaturesRepository;
@@ -10,6 +11,6 @@ export class CacheService {
async getShouldServeCache(shouldServeCache?: boolean | undefined, teamId?: number) {
if (typeof shouldServeCache === "boolean") return shouldServeCache;
if (!teamId) return false;
return await this.dependencies.featuresRepository.checkIfTeamHasFeature(teamId, "calendar-cache-serve");
return await this.dependencies.featuresRepository.checkIfTeamHasFeature(teamId, CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_CACHE_FEATURE);
}
}
@@ -65,7 +65,6 @@ export const LargeCalendar = ({
start: new Date(booking.start),
end: new Date(booking.end),
options: {
borderColor: "black",
status: booking.status.toUpperCase() as BookingStatus,
"data-test-id": "troubleshooter-busy-event",
className: "border-[1.5px]",
@@ -517,5 +517,10 @@ const processEvent = (calEvent: CalendarEvent): CalendarServiceEvent => {
calendarEvent.attendees = [];
}
if (calEvent.seatsPerTimeSlot){
calendarEvent.responses = null;
calendarEvent.userFieldsResponses = null;
}
return calendarEvent;
};
@@ -94,7 +94,7 @@ const getCalendarsEvents = async (
const calendarAndCredentialPairs = await Promise.all(
calendarCredentials.map(async (credential) => {
const calendar = await getCalendar(credential);
const calendar = await getCalendar(credential, shouldServeCache);
return [calendar, credential] as const;
})
);
@@ -193,7 +193,7 @@ function getServerUrlFromCalendarExternalId(externalId: string): string | null {
try {
const url = new URL(externalId);
return `${url.protocol}//${url.host}`;
} catch (error) {
} catch {
return null;
}
}
@@ -215,7 +215,7 @@ function getServerUrlFromCredential(credential: CredentialForCalendarService): s
const url = new URL(decryptedData.url);
return `${url.protocol}//${url.host}`;
} catch (error) {
} catch {
return null;
}
}
@@ -13,7 +13,7 @@ export const events: CalendarEvent[] = [
end: startDate.add(45, "minutes").toDate(),
options: {
allDay: false,
borderColor: "#ff0000",
color: "#ff0000",
status: "ACCEPTED",
},
source: "Booking",
@@ -37,7 +37,7 @@ export const events: CalendarEvent[] = [
source: "Booking",
options: {
status: "PENDING",
borderColor: "#ff0000",
color: "#ff0000",
allDay: false,
},
},
@@ -31,6 +31,9 @@ export function Calendar(props: CalendarComponentProps) {
const availableTimeslots = useCalendarStore((state) => state.availableTimeslots);
const hideHeader = useCalendarStore((state) => state.hideHeader);
const timezone = useCalendarStore((state) => state.timezone);
const showBackgroundPattern = useCalendarStore((state) => state.showBackgroundPattern);
const showBorder = useCalendarStore((state) => state.showBorder ?? true);
const borderColor = useCalendarStore((state) => state.borderColor ?? "default");
const days = useMemo(() => getDaysBetweenDates(startDate, endDate), [startDate, endDate]);
@@ -64,23 +67,41 @@ export function Calendar(props: CalendarComponentProps) {
<div
style={{ width: "165%" }}
className="flex h-full max-w-full flex-none flex-col sm:max-w-none md:max-w-full">
<DateValues containerNavRef={containerNav} days={days} />
<DateValues
containerNavRef={containerNav}
days={days}
showBorder={showBorder}
borderColor={borderColor}
/>
<div className="relative flex flex-auto">
<CurrentTime />
<div className="bg-default dark:bg-muted ring-muted border-default sticky left-0 z-10 w-14 flex-none border-l border-r ring-1" />
<CurrentTime timezone={timezone} />
<div
className={classNames(
"bg-default dark:bg-muted ring-muted sticky left-0 z-10 w-16 flex-none ring-1",
showBorder &&
(borderColor === "subtle"
? "border-subtle border-l border-r"
: "border-default border-l border-r")
)}
/>
<div
className="grid flex-auto grid-cols-1 grid-rows-1 [--disabled-gradient-background:#F8F9FB] [--disabled-gradient-foreground:#E6E7EB] dark:[--disabled-gradient-background:#262626] dark:[--disabled-gradient-foreground:#393939]"
style={{
backgroundColor: "var(--disabled-gradient-background)",
background:
"repeating-linear-gradient(-45deg, var(--disabled-gradient-background), var(--disabled-gradient-background) 2.5px, var(--disabled-gradient-foreground) 2.5px, var(--disabled-gradient-foreground) 5px)",
}}>
style={
showBackgroundPattern === false
? undefined
: {
backgroundColor: "var(--disabled-gradient-background)",
background:
"repeating-linear-gradient(-45deg, var(--disabled-gradient-background), var(--disabled-gradient-background) 2.5px, var(--disabled-gradient-foreground) 2.5px, var(--disabled-gradient-foreground) 5px)",
}
}>
<HorizontalLines
hours={hours}
numberOfGridStopsPerCell={usersCellsStopsPerHour}
containerOffsetRef={containerOffset}
borderColor={borderColor}
/>
<VerticalLines days={days} />
<VerticalLines days={days} borderColor={borderColor} />
<SchedulerColumns
offsetHeight={containerOffset.current?.offsetHeight}
@@ -113,6 +134,7 @@ export function Calendar(props: CalendarComponentProps) {
{availableTimeslots ? (
<AvailableCellsForDay
key={days[i].toISOString()}
timezone={timezone}
day={days[i]}
startHour={startHour}
availableSlots={availableTimeslots}
@@ -4,12 +4,16 @@ import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import classNames from "@calcom/ui/classNames";
import type { BorderColor } from "../../types/common";
type Props = {
showBorder: boolean;
borderColor: BorderColor;
days: dayjs.Dayjs[];
containerNavRef: React.RefObject<HTMLDivElement>;
};
export function DateValues({ days, containerNavRef }: Props) {
export function DateValues({ showBorder, borderColor, days, containerNavRef }: Props) {
const { i18n } = useLocale();
const formatDate = (date: dayjs.Dayjs): string => {
return new Intl.DateTimeFormat(i18n.language, { weekday: "short" }).format(date.toDate());
@@ -17,8 +21,12 @@ export function DateValues({ days, containerNavRef }: Props) {
return (
<div
ref={containerNavRef}
className="bg-default dark:bg-muted border-b-subtle rtl:border-r-default sticky top-[var(--calendar-dates-sticky-offset,0px)] z-[80] flex-none border-b border-r sm:pr-8">
<div className="text-subtle flex text-sm leading-6 sm:hidden" data-dayslength={days.length}>
className={classNames(
"bg-default dark:bg-default sticky top-[var(--calendar-dates-sticky-offset,0px)] z-[80] flex-none border-b sm:pr-8",
borderColor === "subtle" ? "border-b-subtle" : "border-b-default",
showBorder && (borderColor === "subtle" ? "border-r-subtle border-r" : "border-r-default border-r")
)}>
<div className="text-subtle flex leading-6 sm:hidden" data-dayslength={days.length}>
{days.map((day) => {
const isToday = dayjs().isSame(day, "day");
return (
@@ -29,8 +37,8 @@ export function DateValues({ days, containerNavRef }: Props) {
{day.format("dd")}{" "}
<span
className={classNames(
"text-emphasis mt-1 flex h-8 w-8 items-center justify-center font-semibold",
isToday && "bg-inverted text-inverted rounded-full"
"text-emphasis mt-1 flex h-8 w-8 items-center justify-center font-medium",
isToday && "bg-inverted text-inverted rounded-sm"
)}>
{day.format("D")}
</span>
@@ -38,8 +46,14 @@ export function DateValues({ days, containerNavRef }: Props) {
);
})}
</div>
<div className="text-subtle -mr-px hidden auto-cols-fr text-sm leading-6 sm:flex ">
<div className="border-default col-end-1 w-14 ltr:border-l" />
<div className="text-subtle -mr-px hidden auto-cols-fr leading-6 sm:flex">
<div
className={classNames(
"col-end-1 w-16",
showBorder &&
(borderColor === "subtle" ? "border-l-subtle border-l" : "border-l-default border-l")
)}
/>
{days.map((day) => {
const isToday = dayjs().isSame(day, "day");
return (
@@ -47,14 +61,14 @@ export function DateValues({ days, containerNavRef }: Props) {
key={day.toString()}
className={classNames(
"flex flex-1 items-center justify-center py-3 text-xs font-medium uppercase",
isToday && "font-bold"
isToday && "text-default"
)}>
<span>
{formatDate(day)}{" "}
<span
className={classNames(
"items-center justify-center p-1",
isToday && "bg-brand-default text-brand rounded-full"
isToday && "bg-brand-default text-brand rounded-md"
)}>
{day.format("DD")}
</span>
@@ -1,8 +1,8 @@
import { useEffect, useState, useRef } from "react";
import dayjs from "@calcom/dayjs";
import { useTimePreferences } from "@calcom/features/bookings/lib";
import { useBookerTime } from "../../../../bookings/Booker/components/hooks/useBookerTime";
import { useCalendarStore } from "../../state/store";
function calculateMinutesFromStart(startHour: number, currentHour: number, currentMinute: number) {
@@ -11,7 +11,8 @@ function calculateMinutesFromStart(startHour: number, currentHour: number, curre
return currentMinuteOfDay - startMinute;
}
export function CurrentTime() {
export function CurrentTime({ timezone }: { timezone: string }) {
const { timeFormat } = useTimePreferences();
const currentTimeRef = useRef<HTMLDivElement>(null);
const [scrolledIntoView, setScrolledIntoView] = useState(false);
const [currentTimePos, setCurrentTimePos] = useState<number | null>(null);
@@ -19,7 +20,6 @@ export function CurrentTime() {
startHour: state.startHour || 0,
endHour: state.endHour || 23,
}));
const { timeFormat, timezone } = useBookerTime();
useEffect(() => {
// Set the container scroll position based on the current time.
@@ -39,11 +39,10 @@ export function CurrentTime() {
if (!currentTimeRef.current || scrolledIntoView) return;
// Within a small timeout so element has time to render.
setTimeout(() => {
// eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- Doesn't seem to cause any issue. Put it under condition if needed
// Doesn't seem to cause any issue. Put it under condition if needed
currentTimeRef?.current?.scrollIntoView({ block: "center" });
setScrolledIntoView(true);
}, 100);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [startHour, endHour, scrolledIntoView, timezone]);
return (
@@ -55,7 +54,7 @@ export function CurrentTime() {
top: `calc(${currentTimePos}*var(--one-minute-height) + var(--calendar-offset-top))`,
zIndex: 70,
}}>
<div className="w-14 pr-2 text-right">{dayjs().tz(timezone).format(timeFormat)}</div>
<div className="w-16 pr-2 text-right">{dayjs().tz(timezone).format(timeFormat)}</div>
<div className="bg-inverted h-3 w-px" />
<div className="bg-inverted h-px w-screen" />
</div>
@@ -3,10 +3,10 @@ import { shallow } from "zustand/shallow";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { useTimePreferences } from "@calcom/features/bookings/lib";
import classNames from "@calcom/ui/classNames";
import { OutOfOfficeInSlots } from "../../../../bookings/Booker/components/OutOfOfficeInSlots";
import { useBookerTime } from "../../../../bookings/Booker/components/hooks/useBookerTime";
import { useCalendarStore } from "../../state/store";
import type { CalendarAvailableTimeslots } from "../../types/state";
import type { GridCellToDateProps } from "../../utils";
@@ -33,13 +33,13 @@ export function EmptyCell(props: EmptyCellProps) {
}
type AvailableCellProps = {
timezone: string;
availableSlots: CalendarAvailableTimeslots;
day: GridCellToDateProps["day"];
startHour: GridCellToDateProps["startHour"];
};
export function AvailableCellsForDay({ availableSlots, day, startHour }: AvailableCellProps) {
const { timezone } = useBookerTime();
export function AvailableCellsForDay({ timezone, availableSlots, day, startHour }: AvailableCellProps) {
const date = dayjs(day);
const dateFormatted = date.format("YYYY-MM-DD");
const slotsForToday = availableSlots && availableSlots[dateFormatted];
@@ -136,7 +136,7 @@ type CellProps = {
};
function Cell({ isDisabled, topOffsetMinutes, timeSlot }: CellProps) {
const { timeFormat } = useBookerTime();
const { timeFormat } = useTimePreferences();
const { onEmptyCellClick, hoverEventDuration } = useCalendarStore(
(state) => ({
@@ -163,7 +163,7 @@ function Cell({ isDisabled, topOffsetMinutes, timeSlot }: CellProps) {
top: topOffsetMinutes ? `calc(${topOffsetMinutes}*var(--one-minute-height))` : undefined,
}}
onClick={() => {
onEmptyCellClick && onEmptyCellClick(timeSlot.toDate());
onEmptyCellClick?.(timeSlot.toDate());
}}>
{!isDisabled && hoverEventDuration !== 0 && (
<div
@@ -1,8 +1,8 @@
import { cva } from "class-variance-authority";
import dayjs from "@calcom/dayjs";
import { Tooltip } from "@calcom/ui/components/tooltip";
import classNames from "@calcom/ui/classNames";
import { Tooltip } from "@calcom/ui/components/tooltip";
import type { CalendarEvent } from "../../types/events";
@@ -15,15 +15,15 @@ type EventProps = {
};
const eventClasses = cva(
"group flex h-full w-full overflow-y-auto rounded-[6px] px-[6px] text-xs font-semibold leading-5 opacity-80",
"group flex h-full w-full overflow-x-hidden overflow-y-auto rounded-[6px] px-[6px] text-xs leading-5 opacity-80 border-default font-medium",
{
variants: {
status: {
ACCEPTED: "bg-subtle hover:bg-emphasis text-emphasis border-[1px] border-gray-900",
PENDING: "bg-default text-emphasis border-[1px] border-dashed border-gray-900",
REJECTED: "",
CANCELLED: "",
AWAITING_HOST: "",
ACCEPTED: "bg-subtle hover:bg-emphasis text-emphasis border-[1px] border-default",
PENDING: "bg-muted text-emphasis border-[1px] border-dashed border-default",
REJECTED: "bg-muted border-[1px] text-subtle line-through",
CANCELLED: "bg-muted border-[1px] text-subtle line-through",
AWAITING_HOST: "bg-muted text-emphasis border-[1px] border-dashed border-default",
},
disabled: {
true: "hover:cursor-default",
@@ -33,14 +33,6 @@ const eventClasses = cva(
true: "bg-inverted text-inverted border-[1px] border-transparent",
false: "",
},
borderColor: {
ACCEPTED: "border-gray-900",
PENDING: "border-gray-900",
REJECTED: "border-gray-900",
CANCELLED: "border-gray-900",
AWAITING_HOST: "",
custom: "",
},
},
}
);
@@ -55,14 +47,6 @@ export function Event({
const selected = currentlySelectedEventId === event.id;
const { options } = event;
const borderColor = options?.borderColor ? "custom" : options?.status;
const styles = options?.borderColor
? {
borderColor: options?.borderColor,
}
: {};
const Component = onEventClick ? "button" : "div";
return (
@@ -74,32 +58,36 @@ export function Event({
status: options?.status,
disabled,
selected,
borderColor,
}),
eventDuration > 30 && "flex-col py-1",
options?.className
)}>
{options?.color && (
<div
className="-ml-1.5 mr-1.5 h-full w-[3px] shrink-0"
style={{ backgroundColor: options.color }}></div>
)}
style={styles}>
<div
className={classNames(
"flex w-full gap-2 overflow-hidden overflow-ellipsis whitespace-nowrap text-left leading-4",
eventDuration <= 30 && "items-center"
)}>
<span>{event.title}</span>
{eventDuration <= 30 && !event.options?.hideTime && (
<p className="text-subtle w-full whitespace-nowrap text-left text-[10px] leading-none">
<div className={classNames("flex", eventDuration > 30 && "flex-col py-1")}>
<div
className={classNames(
"flex w-full gap-2 overflow-hidden overflow-ellipsis whitespace-nowrap text-left leading-4",
eventDuration <= 30 && "items-center"
)}>
<span>{event.title}</span>
{eventDuration <= 30 && !event.options?.hideTime && (
<p className="text-subtle mt-1 w-full whitespace-nowrap text-left text-[10px] leading-none">
{dayjs(event.start).format("HH:mm")} - {dayjs(event.end).format("HH:mm")}
</p>
)}
</div>
{eventDuration > 30 && !event.options?.hideTime && (
<p className="text-subtle mt-1 text-left text-[10px] leading-none">
{dayjs(event.start).format("HH:mm")} - {dayjs(event.end).format("HH:mm")}
</p>
)}
{eventDuration > 45 && event.description && (
<p className="text-subtle mt-1 text-left text-[10px] leading-none">{event.description}</p>
)}
</div>
{eventDuration > 30 && !event.options?.hideTime && (
<p className="text-subtle text-left text-[10px] leading-none">
{dayjs(event.start).format("HH:mm")} - {dayjs(event.end).format("HH:mm")}
</p>
)}
{eventDuration > 45 && event.description && (
<p className="text-subtle text-left text-[10px] leading-none">{event.description}</p>
)}
</Component>
</Tooltip>
);
@@ -2,15 +2,19 @@ import { useId } from "react";
import type dayjs from "@calcom/dayjs";
import { useTimePreferences } from "@calcom/features/bookings/lib";
import classNames from "@calcom/ui/classNames";
import type { BorderColor } from "../../types/common";
export const HorizontalLines = ({
hours,
numberOfGridStopsPerCell,
containerOffsetRef,
borderColor,
}: {
hours: dayjs.Dayjs[];
numberOfGridStopsPerCell: number;
containerOffsetRef: React.RefObject<HTMLDivElement>;
borderColor: BorderColor;
}) => {
const { timeFormat } = useTimePreferences();
// We need to force the minute to zero, because otherwise in ex GMT+5.5, it would show :30 minute times (but at the positino of :00)
@@ -19,7 +23,10 @@ export const HorizontalLines = ({
return (
<div
className=" divide-default pointer-events-none relative z-[60] col-start-1 col-end-2 row-start-1 grid divide-y"
className={classNames(
"pointer-events-none relative z-[60] col-start-1 col-end-2 row-start-1 grid divide-y",
borderColor === "subtle" ? "divide-subtle" : "divide-default"
)}
style={{
gridTemplateRows: `repeat(${hours.length}, minmax(var(--gridDefaultSize),1fr)`,
}}>
@@ -1,6 +1,9 @@
import type dayjs from "@calcom/dayjs";
import classNames from "@calcom/ui/classNames";
export const VerticalLines = ({ days }: { days: dayjs.Dayjs[] }) => {
import type { BorderColor } from "../../types/common";
export const VerticalLines = ({ days, borderColor }: { days: dayjs.Dayjs[]; borderColor: BorderColor }) => {
const isRTL = () => {
let userLanguage = "en"; // Default to 'en' if navigator is not defined
@@ -15,8 +18,10 @@ export const VerticalLines = ({ days }: { days: dayjs.Dayjs[] }) => {
return (
<div
className="divide-default pointer-events-none relative z-[60] col-start-1 col-end-2 row-start-1 grid
auto-cols-auto grid-rows-1 divide-x sm:pr-8"
className={classNames(
"pointer-events-none relative z-[60] col-start-1 col-end-2 row-start-1 grid auto-cols-auto grid-rows-1 divide-x sm:pr-8",
borderColor === "subtle" ? "divide-subtle" : "divide-default"
)}
dir={direction}
style={{
direction: direction,
@@ -20,6 +20,9 @@ const defaultState: CalendarComponentProps = {
endHour: 23,
gridCellsPerHour: 4,
timezone: CURRENT_TIMEZONE,
showBackgroundPattern: true,
showBorder: true,
borderColor: "default",
};
export const useCalendarStore = create<CalendarStoreProps>((set) => ({
@@ -0,0 +1 @@
export type BorderColor = "default" | "subtle";
@@ -11,7 +11,7 @@ export interface CalendarEvent {
status?: BookingStatus;
hideTime?: boolean;
allDay?: boolean;
borderColor?: string;
color?: string;
className?: string;
"data-test-id"?: string;
};
@@ -1,6 +1,7 @@
import type { IFromUser, IToUser } from "@calcom/features/availability/lib/getUserAvailability";
import type { TimeRange } from "@calcom/types/schedule";
import type { BorderColor } from "./common";
import type { CalendarEvent } from "./events";
export type View = "month" | "week" | "day";
@@ -131,6 +132,21 @@ export type CalendarState = {
* Timezone to use for displaying times in the calendar
*/
timezone: string;
/**
* Show the background pattern for unavailable areas
* @default true
*/
showBackgroundPattern?: boolean;
/**
* Show borders on the leftmost and rightmost sides of the calendar container
* @default true
*/
showBorder?: boolean;
/**
* Border color for calendar cells
* @default "default"
*/
borderColor?: BorderColor;
};
export type CalendarComponentProps = CalendarPublicActions & CalendarState & { isPending?: boolean };
+258 -126
View File
@@ -129,7 +129,7 @@ const columns = [
},
{
id: "role",
header: "Role",
header: "Role",
accessorKey: "role",
meta: {
type: ColumnFilterType.SINGLE_SELECT,
@@ -146,7 +146,7 @@ function UserTable() {
});
const pathname = usePathname();
const tableIdentifier = "hard-coded idenfidier" // or pathname;
const tableIdentifier = "hard-coded idenfidier"; // or pathname;
return (
<DataTableProvider tableIdentifier={tableIdentifier}>
@@ -182,13 +182,13 @@ The context provider that manages all table state including filters, sorting, pa
```tsx
interface DataTableProviderProps {
tableIdentifier: string; // Unique identifier for the table (throws if empty)
tableIdentifier: string; // Unique identifier for the table (throws if empty)
children: React.ReactNode;
useSegments?: UseSegments; // Custom segment hook
defaultPageSize?: number; // Default: 10
ctaContainerClassName?: string; // CSS class for CTA container
segments?: FilterSegmentOutput[]; // Provided segments
timeZone?: string; // Timezone for date filters
useSegments?: UseSegments; // Custom segment hook
defaultPageSize?: number; // Default: 10
ctaContainerClassName?: string; // CSS class for CTA container
segments?: FilterSegmentOutput[]; // Provided segments
timeZone?: string; // Timezone for date filters
preferredSegmentId?: SegmentIdentifier | null;
systemSegments?: SystemFilterSegment[];
}
@@ -264,19 +264,18 @@ type DataTableWrapperProps<TData> = {
// Toolbar slots
ToolbarLeft?: React.ReactNode;
ToolbarRight?: React.ReactNode;
// Loading states
EmptyView?: React.ReactNode;
LoaderView?: React.ReactNode;
} & (
// Infinite pagination
} & ( // Infinite pagination
| {
paginationMode: "infinite";
hasNextPage: boolean;
fetchNextPage: () => void;
isFetching: boolean;
}
// Standard pagination
// Standard pagination
| {
paginationMode: "standard";
hasNextPage?: never;
@@ -298,6 +297,150 @@ The core table component with column resizing and pinning support.
- **Accessibility** - Full keyboard navigation support
- **Optional virtualization** - Available for infinite mode (use with caution)
## Separator Rows
The DataTable supports separator rows that act as visual group headers between regular data rows. Separators are useful for grouping related data and providing visual organization.
### Type Definitions
```tsx
export type SeparatorRow = {
type: "separator";
label: string;
className?: string;
};
export type DataTableRow<TData> = TData | SeparatorRow;
export function isSeparatorRow<TData>(row: DataTableRow<TData>): row is SeparatorRow {
return typeof row === "object" && row !== null && "type" in row && row.type === "separator";
}
```
### Usage
Separator rows are defined in your data array alongside regular rows:
```tsx
const data = [
{ type: "separator", label: "Active Users" },
{ id: 1, name: "Alice", status: "active" },
{ id: 2, name: "Bob", status: "active" },
{ type: "separator", label: "Inactive Users" },
{ id: 3, name: "Charlie", status: "inactive" },
];
<DataTableWrapper
table={table}
hideSeparatorsOnSort={true}
separatorClassName="custom-separator"
// ... other props
/>;
```
### Configuration Options
#### Props
- `hideSeparatorsOnSort?: boolean` (default: `true`) - Hide separators when sorting is active
- `hideSeparatorsOnFilter?: boolean` (default: `false`) - Hide separators when filtering is active
- `separatorClassName?: string` - Global CSS class applied to all separator rows
#### Per-Separator Styling
Each separator can have its own styling:
```tsx
const data = [
{
type: "separator",
label: "Active Users",
className: "bg-green-50 text-green-900",
},
{ id: 1, name: "Alice" },
{
type: "separator",
label: "Inactive Users",
className: "bg-red-50 text-red-900",
},
{ id: 2, name: "Bob" },
];
```
### Behavior with Sorting and Filtering
**Default Behavior:**
- Separators are visible when no sorting/filtering is applied
- Separators automatically hide when sorting is applied (if `hideSeparatorsOnSort` is true)
- Separators reappear when sorting is cleared
**Why Hide on Sort?**
When users sort by a column, the logical grouping may no longer make sense. For example, if you group by status but then sort by name, the status groups become meaningless. Hiding separators during sorting provides a cleaner, more intuitive experience.
**Custom Behavior:**
You can disable automatic hiding by setting `hideSeparatorsOnSort={false}` and handle separator positioning in your data preparation logic.
### Styling
Separator rows use the same styling as table headers and span the full width of the table:
```css
.separator-row {
display: flex;
width: 100%;
border: none;
}
.separator-row > div {
background-color: var(--muted);
font-weight: 600;
color: var(--emphasis);
padding: 0.5rem 0.75rem;
}
```
You can customize styling using:
- `separatorClassName` prop for global styling
- `className` property on individual separator objects
- CSS targeting `.separator-row` class
### Example: User Management with Status Groups
```tsx
function UserTable() {
const data = useMemo(() => {
const users = getUsers();
const groupedUsers = [];
// Add active users group
const activeUsers = users.filter((u) => u.status === "active");
if (activeUsers.length > 0) {
groupedUsers.push({ type: "separator", label: "Active Users" });
groupedUsers.push(...activeUsers);
}
// Add inactive users group
const inactiveUsers = users.filter((u) => u.status === "inactive");
if (inactiveUsers.length > 0) {
groupedUsers.push({ type: "separator", label: "Inactive Users" });
groupedUsers.push(...inactiveUsers);
}
return groupedUsers;
}, [users]);
return (
<DataTableWrapper
table={table}
hideSeparatorsOnSort={true}
separatorClassName="uppercase text-xs tracking-wide"
/>
);
}
```
## Filter System
The DataTable supports 5 filter types with various operators and options.
@@ -343,6 +486,7 @@ The DataTable supports 5 filter types with various operators and options.
```
**Available operators:**
- `equals` - Exact match
- `notEquals` - Not equal
- `contains` - Contains substring
@@ -361,6 +505,7 @@ The DataTable supports 5 filter types with various operators and options.
```
**Available operators:**
- `eq` - Equal to
- `neq` - Not equal to
- `gt` - Greater than
@@ -422,20 +567,20 @@ const table = useReactTable({
switch (columnId) {
case "teamId":
return convertFacetedValuesToMap(
teams.map(team => ({
teams.map((team) => ({
label: team.name,
value: team.id
value: team.id,
}))
);
case "role":
return convertFacetedValuesToMap([
{ label: "Admin", value: "admin" },
{ label: "Member", value: "member" }
{ label: "Member", value: "member" },
]);
default:
return new Map();
}
}
},
});
```
@@ -448,7 +593,7 @@ export function useFacetedUniqueValues() {
const eventTypes = useEventTypes();
const { data: teams } = trpc.viewer.teams.list.useQuery();
const { data: members } = trpc.viewer.teams.listSimpleMembers.useQuery();
return useCallback(
(_: Table<any>, columnId: string) => (): Map<FacetedValue, number> => {
if (columnId === "eventTypeId") {
@@ -495,7 +640,7 @@ const table = useReactTable({
}
}
return new Map();
}
},
});
```
@@ -556,19 +701,15 @@ const systemSegments: SystemFilterSegment[] = [
f: "status",
v: {
type: ColumnFilterType.SINGLE_SELECT,
data: "active"
}
}
data: "active",
},
},
],
sorting: [{ id: "lastLogin", desc: true }],
}
},
];
<DataTableProvider
systemSegments={systemSegments}
>
{/* ... */}
</DataTableProvider>
<DataTableProvider systemSegments={systemSegments}>{/* ... */}</DataTableProvider>;
```
### User Segments
@@ -607,14 +748,11 @@ Segments saved by users with personal or team scope:
Traditional page-based pagination is the recommended approach:
```tsx
<DataTableWrapper
paginationMode="standard"
totalRowCount={totalCount}
table={table}
/>
<DataTableWrapper paginationMode="standard" totalRowCount={totalCount} table={table} />
```
**Features:**
- Page numbers and navigation
- Configurable page sizes
- Total count display
@@ -640,11 +778,13 @@ Alternative infinite scroll mode with known limitations:
```
**Features:**
- Automatic loading on scroll
- Virtualized rendering
- Fixed container height (80dvh)
**Known Issues:**
- Virtualized infinite loading has several problems
- Can cause performance and UX issues
- Standard mode was introduced to address these problems
@@ -672,8 +812,8 @@ Container and utility components for table toolbars:
<DataTableToolbar.ClearFiltersButton />
// Custom action button
<DataTableToolbar.CTA
color="secondary"
<DataTableToolbar.CTA
color="secondary"
StartIcon="download"
onClick={handleExport}
>
@@ -686,19 +826,17 @@ Container and utility components for table toolbars:
For bulk actions when rows are selected:
```tsx
{numberOfSelectedRows > 0 && (
<DataTableSelectionBar.Root>
<p>{t("number_selected", { count: numberOfSelectedRows })}</p>
<DataTableSelectionBar.Button
color="destructive"
icon="trash-2"
onClick={handleBulkDelete}
>
Delete Selected
</DataTableSelectionBar.Button>
</DataTableSelectionBar.Root>
)}
{
numberOfSelectedRows > 0 && (
<DataTableSelectionBar.Root>
<p>{t("number_selected", { count: numberOfSelectedRows })}</p>
<DataTableSelectionBar.Button color="destructive" icon="trash-2" onClick={handleBulkDelete}>
Delete Selected
</DataTableSelectionBar.Button>
</DataTableSelectionBar.Root>
);
}
```
## Advanced Usage
@@ -719,7 +857,7 @@ const { data } = trpc.users.list.useQuery({
limit,
offset,
sorting,
filters: columnFilters
filters: columnFilters,
});
```
@@ -758,10 +896,11 @@ const whereClause: Prisma.MembershipWhereInput = {
})),
},
teamId: organizationId,
...(roleFilter && makeWhereClause({
columnName: "role",
filterValue: roleFilter.value,
})),
...(roleFilter &&
makeWhereClause({
columnName: "role",
filterValue: roleFilter.value,
})),
};
```
@@ -774,7 +913,7 @@ For performance-critical queries, use raw SQL with `makeSqlCondition`:
async getFilterConditions(): Promise<Prisma.Sql | null> {
const conditions: Prisma.Sql[] = [];
const columnFilters = this.filters.columnFilters || [];
// Convert columnFilters array to object for easier access
const filtersMap = columnFilters.reduce((acc, filter) => {
acc[filter.id] = filter;
@@ -815,7 +954,7 @@ For complex cases where you need to manipulate filter data before sending to the
// packages/features/insights/hooks/useInsightsRoutingParameters.ts
export function useInsightsRoutingParameters() {
const { scope, selectedTeamId } = useInsightsOrgTeams();
// Get date range filter and manipulate it
const createdAtRange = useFilterValue("createdAt", ZDateRangeFilterValue)?.data;
const startDate = useChangeTimeZoneWithPreservedLocalTime(
@@ -825,7 +964,7 @@ export function useInsightsRoutingParameters() {
.toISOString();
}, [createdAtRange?.startDate])
);
// Get other column filters excluding the manipulated ones
const columnFilters = useColumnFilters({
exclude: ["createdAt"],
@@ -863,15 +1002,8 @@ The DataTable system provides utility functions for both Prisma and raw SQL appr
Access the DataTable context:
```tsx
const {
activeFilters,
sorting,
columnVisibility,
pageIndex,
pageSize,
searchTerm,
selectedSegment,
} = useDataTable();
const { activeFilters, sorting, columnVisibility, pageIndex, pageSize, searchTerm, selectedSegment } =
useDataTable();
```
#### useColumnFilters
@@ -903,16 +1035,15 @@ Create custom filter implementations:
```tsx
function CustomStatusFilter({ column }: { column: Column<any> }) {
const { updateFilter } = useDataTable();
return (
<Select
onValueChange={(value) =>
onValueChange={(value) =>
updateFilter(column.id, {
type: ColumnFilterType.SINGLE_SELECT,
data: value
data: value,
})
}
>
}>
{/* Custom filter UI */}
</Select>
);
@@ -926,12 +1057,15 @@ Use portals for toolbar actions:
```tsx
const { ctaContainerRef } = useDataTable();
{ctaContainerRef.current && createPortal(
<div className="flex gap-2">
<Button>Custom Action</Button>
</div>,
ctaContainerRef.current
)}
{
ctaContainerRef.current &&
createPortal(
<div className="flex gap-2">
<Button>Custom Action</Button>
</div>,
ctaContainerRef.current
);
}
```
## Real-world Examples
@@ -960,14 +1094,13 @@ From `packages/features/users/components/UserTable/UserListTable.tsx`:
<DataTableSegment.SaveButton />
<DataTableSegment.Select />
</>
}
>
}>
{/* Selection bar for bulk actions */}
{numberOfSelectedRows > 0 && (
<DataTableSelectionBar.Root>
<p>{t("number_selected", { count: numberOfSelectedRows })}</p>
<DeleteBulkUsers
users={table.getSelectedRowModel().flatRows.map(row => row.original)}
users={table.getSelectedRowModel().flatRows.map((row) => row.original)}
onRemove={() => table.toggleAllPageRowsSelected(false)}
/>
</DataTableSelectionBar.Root>
@@ -991,9 +1124,7 @@ From `apps/web/modules/bookings/components/BookingsList.tsx`:
totalRowCount={query.data?.totalCount}
variant="compact"
paginationMode="standard"
ToolbarLeft={
<DataTableFilters.FilterBar table={table} />
}
ToolbarLeft={<DataTableFilters.FilterBar table={table} />}
ToolbarRight={
<>
<DataTableFilters.ClearFiltersButton />
@@ -1038,8 +1169,7 @@ From `packages/features/ee/teams/components/MemberList.tsx`:
<DataTableSegment.SaveButton />
<DataTableSegment.Select />
</>
}
>
}>
{/* Bulk selection and actions */}
{numberOfSelectedRows > 0 && (
<DataTableSelectionBar.Root>
@@ -1056,23 +1186,21 @@ From `packages/features/ee/teams/components/MemberList.tsx`:
```tsx
// Filter value types
type FilterValue =
type FilterValue =
| SingleSelectFilterValue
| MultiSelectFilterValue
| MultiSelectFilterValue
| TextFilterValue
| NumberFilterValue
| DateRangeFilterValue;
// Active filter structure
type ActiveFilter = {
f: string; // field/column ID
v?: FilterValue; // filter value
f: string; // field/column ID
v?: FilterValue; // filter value
};
// Segment types
type SegmentIdentifier =
| { id: string; type: "system" }
| { id: number; type: "user" };
type SegmentIdentifier = { id: string; type: "system" } | { id: number; type: "user" };
// Column filter metadata
type ColumnFilterMeta = {
@@ -1187,29 +1315,29 @@ type CombinedFilterSegment = SystemFilterSegmentInternal | UserFilterSegment;
```tsx
// ✅ Good: Memoize column definitions
const columns = useMemo(() => [
{
id: "name",
header: "Name",
accessorKey: "name",
meta: { type: ColumnFilterType.TEXT },
},
], []);
const columns = useMemo(
() => [
{
id: "name",
header: "Name",
accessorKey: "name",
meta: { type: ColumnFilterType.TEXT },
},
],
[]
);
// ✅ Good: Extract filter logic
const useUserFilters = () => {
const columnFilters = useColumnFilters();
return useMemo(() =>
transformFiltersForAPI(columnFilters),
[columnFilters]
);
return useMemo(() => transformFiltersForAPI(columnFilters), [columnFilters]);
};
// ✅ Good: Separate concerns
function UserTableContainer() {
const filters = useUserFilters();
const { data, isPending } = useUsers(filters);
return (
<DataTableProvider>
<UserTable data={data} isPending={isPending} />
@@ -1226,17 +1354,20 @@ function UserTableContainer() {
function useTableData() {
const columnFilters = useColumnFilters();
const { sorting, pageIndex, pageSize, searchTerm } = useDataTable();
const queryParams = useMemo(() => ({
filters: columnFilters,
sorting,
page: pageIndex,
limit: pageSize,
search: searchTerm,
}), [columnFilters, sorting, pageIndex, pageSize, searchTerm]);
const queryParams = useMemo(
() => ({
filters: columnFilters,
sorting,
page: pageIndex,
limit: pageSize,
search: searchTerm,
}),
[columnFilters, sorting, pageIndex, pageSize, searchTerm]
);
return useQuery({
queryKey: ['table-data', queryParams],
queryKey: ["table-data", queryParams],
queryFn: () => fetchData(queryParams),
});
}
@@ -1247,13 +1378,14 @@ function useTableData() {
```tsx
function useStatusFilterOptions() {
const { data: statuses } = useStatuses();
return useMemo(() =>
statuses?.map(status => ({
label: status.name,
value: status.id,
section: status.category,
})) || [],
return useMemo(
() =>
statuses?.map((status) => ({
label: status.name,
value: status.id,
section: status.category,
})) || [],
[statuses]
);
}
@@ -1270,15 +1402,15 @@ const BOOKING_SEGMENTS: SystemFilterSegment[] = [
activeFilters: [
{
f: "status",
v: { type: ColumnFilterType.SINGLE_SELECT, data: "confirmed" }
v: { type: ColumnFilterType.SINGLE_SELECT, data: "confirmed" },
},
{
f: "startTime",
v: {
type: ColumnFilterType.DATE_RANGE,
data: { preset: "future", startDate: null, endDate: null }
}
}
v: {
type: ColumnFilterType.DATE_RANGE,
data: { preset: "future", startDate: null, endDate: null },
},
},
],
sorting: [{ id: "startTime", desc: false }],
},
@@ -4,7 +4,6 @@ import type { Row } from "@tanstack/react-table";
import { flexRender } from "@tanstack/react-table";
import type { Table as ReactTableType, Header, HeaderGroup } from "@tanstack/react-table";
import { useVirtualizer, type Virtualizer, type VirtualItem } from "@tanstack/react-virtual";
// eslint-disable-next-line no-restricted-imports
import kebabCase from "lodash/kebabCase";
import { useEffect, useState, memo, useMemo } from "react";
@@ -24,6 +23,8 @@ import {
import { useColumnSizingVars } from "../hooks";
import { useColumnResizing } from "../hooks/useColumnResizing";
import type { SeparatorRow } from "../lib/separator";
import { isSeparatorRow } from "../lib/separator";
export type DataTablePropsFromWrapper<TData> = {
table: ReactTableType<TData>;
@@ -40,6 +41,9 @@ export type DataTablePropsFromWrapper<TData> = {
rowClassName?: string;
paginationMode?: "infinite" | "standard";
hasWrapperContext?: boolean;
hideSeparatorsOnSort?: boolean;
hideSeparatorsOnFilter?: boolean;
separatorClassName?: string;
};
export type DataTableProps<TData> = DataTablePropsFromWrapper<TData> & {
@@ -66,6 +70,9 @@ export function DataTable<TData>({
rowClassName,
paginationMode = "infinite",
hasWrapperContext = false,
hideSeparatorsOnSort = true,
hideSeparatorsOnFilter = false,
separatorClassName,
...rest
}: DataTableProps<TData> & React.ComponentPropsWithoutRef<"div">) {
const { rows } = table.getRowModel();
@@ -196,6 +203,10 @@ export function DataTable<TData>({
onRowMouseclick={onRowMouseclick}
paginationMode={paginationMode}
rowClassName={rowClassName}
hideSeparatorsOnSort={hideSeparatorsOnSort}
hideSeparatorsOnFilter={hideSeparatorsOnFilter}
separatorClassName={separatorClassName}
tableContainerRef={tableContainerRef}
/>
) : (
<DataTableBody
@@ -208,6 +219,10 @@ export function DataTable<TData>({
onRowMouseclick={onRowMouseclick}
paginationMode={paginationMode}
rowClassName={rowClassName}
hideSeparatorsOnSort={hideSeparatorsOnSort}
hideSeparatorsOnFilter={hideSeparatorsOnFilter}
separatorClassName={separatorClassName}
tableContainerRef={tableContainerRef}
/>
)}
</TableNew>
@@ -228,7 +243,11 @@ const MemoizedTableBody = memo(
prev.isPending === next.isPending &&
prev.onRowMouseclick === next.onRowMouseclick &&
prev.paginationMode === next.paginationMode &&
prev.rowClassName === next.rowClassName
prev.rowClassName === next.rowClassName &&
prev.hideSeparatorsOnSort === next.hideSeparatorsOnSort &&
prev.hideSeparatorsOnFilter === next.hideSeparatorsOnFilter &&
prev.separatorClassName === next.separatorClassName &&
prev.tableContainerRef === next.tableContainerRef
) as typeof DataTableBody;
type DataTableBodyProps<TData> = {
@@ -241,6 +260,10 @@ type DataTableBodyProps<TData> = {
onRowMouseclick?: (row: Row<TData>) => void;
paginationMode?: "infinite" | "standard";
rowClassName?: string;
hideSeparatorsOnSort?: boolean;
hideSeparatorsOnFilter?: boolean;
separatorClassName?: string;
tableContainerRef: React.RefObject<HTMLDivElement>;
};
type RowToRender<TData> = {
@@ -248,9 +271,22 @@ type RowToRender<TData> = {
virtualItem?: VirtualItem;
};
function SeparatorRowRenderer({ separator, className }: { separator: SeparatorRow; className?: string }) {
return (
<div
className={classNames(
"bg-muted text-emphasis w-full px-3 py-2 font-semibold",
separator.className,
className
)}>
{separator.label}
</div>
);
}
function DataTableBody<TData>({
table,
rowVirtualizer,
rowVirtualizer: _rowVirtualizer,
rows,
testId,
variant,
@@ -258,21 +294,45 @@ function DataTableBody<TData>({
onRowMouseclick,
paginationMode,
rowClassName,
hideSeparatorsOnSort = true,
hideSeparatorsOnFilter = false,
separatorClassName,
tableContainerRef,
}: DataTableBodyProps<TData> & { paginationMode?: "infinite" | "standard" }) {
const { t } = useLocale();
const virtualItems = rowVirtualizer.getVirtualItems();
const tableHeight = paginationMode === "infinite" ? rowVirtualizer.getTotalSize() : "auto";
const rowsToRender = useMemo<RowToRender<TData>[]>(
() =>
paginationMode === "infinite"
? virtualItems.map((virtualItem) => ({
row: rows[virtualItem.index] as Row<TData>,
virtualItem,
}))
: rows.map((row) => ({ row })),
[paginationMode, virtualItems, rows]
);
const hasActiveSorting = table.getState().sorting.length > 0;
const hasActiveFilters = table.getState().columnFilters.length > 0;
const filteredRows = useMemo(() => {
if ((hideSeparatorsOnSort && hasActiveSorting) || (hideSeparatorsOnFilter && hasActiveFilters)) {
return rows.filter((row) => !isSeparatorRow(row.original));
}
return rows;
}, [rows, hideSeparatorsOnSort, hideSeparatorsOnFilter, hasActiveSorting, hasActiveFilters]);
const filteredRowVirtualizer = useVirtualizer({
count: filteredRows.length,
estimateSize: () => 100,
getScrollElement: () => tableContainerRef.current,
measureElement:
typeof window !== "undefined" && navigator.userAgent.indexOf("Firefox") === -1
? (element) => element?.getBoundingClientRect().height
: undefined,
overscan: 10,
});
const virtualItems = filteredRowVirtualizer.getVirtualItems();
const tableHeight = paginationMode === "infinite" ? filteredRowVirtualizer.getTotalSize() : "auto";
const rowsToRender = useMemo<RowToRender<TData>[]>(() => {
return paginationMode === "infinite"
? virtualItems.map((virtualItem) => ({
row: filteredRows[virtualItem.index] as Row<TData>,
virtualItem,
}))
: filteredRows.map((row) => ({ row }));
}, [paginationMode, virtualItems, filteredRows]);
if (!isPending && rowsToRender.length === 0) {
return (
@@ -291,50 +351,74 @@ function DataTableBody<TData>({
className="border-subtle relative grid border-t"
data-testid={testId}
style={{ height: tableHeight }}>
{rowsToRender.map(({ row, virtualItem }) => (
<TableRow
ref={virtualItem ? (node) => rowVirtualizer.measureElement(node) : undefined}
key={row.id}
data-index={virtualItem?.index} //needed for dynamic row height measurement
data-state={row.getIsSelected() && "selected"}
onClick={() => onRowMouseclick && onRowMouseclick(row)}
style={{
display: "flex",
...(virtualItem && {
position: "absolute",
transform: `translateY(${virtualItem.start}px)`,
width: "100%",
}),
}}
className={classNames(onRowMouseclick && "hover:cursor-pointer", "group", rowClassName)}>
{row.getVisibleCells().map((cell) => {
const column = cell.column;
return (
<TableCell
key={cell.id}
data-testid={`data-table-td-${cell.column.id}`}
style={{
...(column.getIsPinned() === "left" && { left: `${column.getStart("left")}px` }),
...(column.getIsPinned() === "right" && { right: `${column.getStart("right")}px` }),
width: `var(--col-${kebabCase(cell.column.id)}-size)`,
}}
className={classNames(
"bg-default group-hover:!bg-muted group-data-[state=selected]:bg-subtle flex shrink-0 items-center overflow-hidden",
variant === "compact" && "p-0",
column.getIsPinned() &&
"bg-default group-hover:!bg-muted group-data-[state=selected]:bg-subtle sm:sticky"
)}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
);
})}
</TableRow>
))}
{rowsToRender.map(({ row, virtualItem }) => {
const isSeparator = isSeparatorRow(row.original);
if (isSeparator) {
return (
<TableRow
ref={virtualItem ? (node) => filteredRowVirtualizer.measureElement(node) : undefined}
key={row.id}
data-index={virtualItem?.index}
style={{
display: "flex",
width: "100%",
...(virtualItem && {
position: "absolute",
transform: `translateY(${virtualItem.start}px)`,
}),
}}
className="hover:bg-subtle border-subtle flex w-full border-b">
<SeparatorRowRenderer separator={row.original as SeparatorRow} className={separatorClassName} />
</TableRow>
);
}
return (
<TableRow
ref={virtualItem ? (node) => filteredRowVirtualizer.measureElement(node) : undefined}
key={row.id}
data-index={virtualItem?.index} // needed for dynamic row height measurement
data-state={row.getIsSelected() && "selected"}
onClick={() => onRowMouseclick && onRowMouseclick(row)}
style={{
display: "flex",
...(virtualItem && {
position: "absolute",
transform: `translateY(${virtualItem.start}px)`,
width: "100%",
}),
}}
className={classNames(onRowMouseclick && "hover:cursor-pointer", "group", rowClassName)}>
{row.getVisibleCells().map((cell) => {
const column = cell.column;
return (
<TableCell
key={cell.id}
data-testid={`data-table-td-${cell.column.id}`}
style={{
...(column.getIsPinned() === "left" && { left: `${column.getStart("left")}px` }),
...(column.getIsPinned() === "right" && { right: `${column.getStart("right")}px` }),
width: `var(--col-${kebabCase(cell.column.id)}-size)`,
}}
className={classNames(
"bg-default group-hover:!bg-muted group-data-[state=selected]:bg-subtle flex shrink-0 items-center overflow-hidden",
variant === "compact" && "p-0",
column.getIsPinned() &&
"bg-default group-hover:!bg-muted group-data-[state=selected]:bg-subtle sm:sticky"
)}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
);
})}
</TableRow>
);
})}
</TableBody>
);
}
const TableHeadLabel = ({ header }: { header: Header<any, any> }) => {
const TableHeadLabel = <TData,>({ header }: { header: Header<TData, unknown> }) => {
const [open, setOpen] = useState(false);
const { t } = useLocale();
@@ -1,7 +1,6 @@
"use client";
import type { Row, VisibilityState } from "@tanstack/react-table";
// eslint-disable-next-line no-restricted-imports
import { noop } from "lodash";
import { useEffect, useRef } from "react";
@@ -63,6 +62,9 @@ export function DataTableWrapper<TData>({
tableContainerRef: externalRef,
paginationMode,
onRowMouseclick,
hideSeparatorsOnSort,
hideSeparatorsOnFilter,
separatorClassName,
}: DataTableWrapperProps<TData>) {
const internalRef = useRef<HTMLDivElement>(null);
const tableContainerRef = externalRef || internalRef;
@@ -78,7 +80,7 @@ export function DataTableWrapper<TData>({
useEffect(() => {
const mergedColumnVisibility = {
...(table.initialState?.columnVisibility || {}),
...table.initialState?.columnVisibility,
...columnVisibility,
} satisfies VisibilityState;
@@ -134,6 +136,9 @@ export function DataTableWrapper<TData>({
paginationMode={paginationMode}
onRowMouseclick={onRowMouseclick}
hasWrapperContext={true}
hideSeparatorsOnSort={hideSeparatorsOnSort}
hideSeparatorsOnFilter={hideSeparatorsOnFilter}
separatorClassName={separatorClassName}
onScroll={
paginationMode === "infinite"
? (e: Pick<React.UIEvent<HTMLDivElement, UIEvent>, "target">) =>
@@ -1,8 +1,6 @@
"use client";
import { type Table } from "@tanstack/react-table";
// eslint-disable-next-line no-restricted-imports
import { Fragment } from "react";
import { useDataTable, useFilterableColumns } from "../../hooks";
import { ColumnFilterType } from "../../lib/types";
@@ -12,9 +10,10 @@ import { FilterPopover } from "./FilterPopover";
// Add the new ActiveFilters component
interface ActiveFiltersProps<TData> {
table: Table<TData>;
columnIdsToHide?: string[];
}
export function ActiveFilters<TData>({ table }: ActiveFiltersProps<TData>) {
export function ActiveFilters<TData>({ table, columnIdsToHide }: ActiveFiltersProps<TData>) {
const { activeFilters } = useDataTable();
const filterableColumns = useFilterableColumns(table);
@@ -22,7 +21,12 @@ export function ActiveFilters<TData>({ table }: ActiveFiltersProps<TData>) {
<>
{activeFilters.map((filter) => {
const column = filterableColumns.find((col) => col.id === filter.f);
if (!column) return null;
if (!column) {
return null;
}
if (columnIdsToHide?.includes(column.id)) {
return null;
}
if (column.type === ColumnFilterType.DATE_RANGE) {
return (
@@ -1,7 +1,6 @@
"use client";
import { type Table } from "@tanstack/react-table";
// eslint-disable-next-line no-restricted-imports
import startCase from "lodash/startCase";
import { forwardRef, useState } from "react";
@@ -17,17 +16,10 @@ import { useDataTable, useFilterableColumns } from "../../hooks";
export interface AddFilterButtonProps<TData> {
table: Table<TData>;
variant?: "base" | "sm";
hideWhenFilterApplied?: boolean;
showWhenFilterApplied?: boolean;
}
function AddFilterButtonComponent<TData>(
{
table,
variant = "base",
hideWhenFilterApplied = false,
showWhenFilterApplied = false,
}: AddFilterButtonProps<TData>,
{ table, variant = "base" }: AddFilterButtonProps<TData>,
ref: React.Ref<HTMLButtonElement>
) {
const { t } = useLocale();
@@ -39,14 +31,6 @@ function AddFilterButtonComponent<TData>(
(column) => !activeFilters?.some((filter) => filter.f === column.id)
);
if (hideWhenFilterApplied && activeFilters?.length > 0) {
return null;
}
if (showWhenFilterApplied && activeFilters?.length === 0) {
return null;
}
if (variant === "sm" && availableColumns.length === 0) {
return null;
}
@@ -1,18 +1,30 @@
import { type Table } from "@tanstack/react-table";
"use client";
import { type Table } from "@tanstack/react-table";
import { useMemo } from "react";
import { useDataTable } from "../../hooks";
import { ActiveFilters } from "./ActiveFilters";
import { AddFilterButton } from "./AddFilterButton";
interface FilterBarProps<TData> {
table: Table<TData>;
columnIdsToHide?: string[];
}
export function FilterBar<TData>({ table }: FilterBarProps<TData>) {
export function FilterBar<TData>({ table, columnIdsToHide }: FilterBarProps<TData>) {
const { activeFilters } = useDataTable();
const displayedFilterCount = useMemo(() => {
return (activeFilters ?? []).filter((filter) =>
columnIdsToHide ? !columnIdsToHide.includes(filter.f) : true
).length;
}, [activeFilters, columnIdsToHide]);
return (
<>
<AddFilterButton table={table} hideWhenFilterApplied />
<ActiveFilters table={table} />
<AddFilterButton table={table} variant="sm" showWhenFilterApplied />
{displayedFilterCount === 0 && <AddFilterButton table={table} />}
<ActiveFilters table={table} columnIdsToHide={columnIdsToHide} />
{displayedFilterCount > 0 && <AddFilterButton table={table} variant="sm" />}
</>
);
}
+1
View File
@@ -2,6 +2,7 @@ export * from "./components";
export * from "./lib/types";
export * from "./lib/utils";
export * from "./lib/serializers";
export * from "./lib/separator";
export * from "./DataTableProvider";
export * from "./hooks/useColumnResizing";
export * from "./hooks";
@@ -0,0 +1,11 @@
export type SeparatorRow = {
type: "separator";
label: string;
className?: string;
};
export type DataTableRow<TData> = TData | SeparatorRow;
export function isSeparatorRow<TData>(row: DataTableRow<TData>): row is SeparatorRow {
return typeof row === "object" && row !== null && "type" in row && row.type === "separator";
}

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