Compare commits

..
56 Commits
Author SHA1 Message Date
emrysal 77d1958c42 chore: release v5.6.4
Post release / release (push) Canceled after 0s
2025-08-22 01:49:37 +00:00
88f347f18c feat: improve unkey error handling with heavy logging for unknown errors (#23223)
* feat: improve unkey error handling with heavy logging for unknown errors

- Add onError handler to all Ratelimit instances to catch unknown service errors
- Log comprehensive error details including stack traces and identifiers
- Return fallback response allowing requests through instead of rejecting promises
- Preserve existing rate limit functionality for legitimate rate limit cases
- Maintain consistency with existing timeout fallback behavior

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

* Update rateLimit.ts

* chore: Bump @unkey/ratelimit version

* Add required props to rateLimiter onError response

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-08-21 23:37:01 +01:00
Somay ChauhanandDevin AI 09cf8885cf feat: add send & verify code flow to booker atom when email verification is turned on (#23074)
* feat: extract tRPC verification logic to platform libraries and create v2 atoms endpoints

- Extract verifyCodeUnAuthenticated, verifyCodeAuthenticated, and sendVerifyEmailCode logic from tRPC handlers into reusable platform library functions
- Create VerificationAtomsService and AtomsVerificationController following atoms pattern
- Add v2 endpoints: /atoms/verification/email/send-code, /atoms/verification/email/verify-code, /atoms/verification/email/verify-code-authenticated
- Update useVerifyEmail and useVerifyCode hooks to use new v2 endpoints instead of verified-resources endpoints
- Export verification functions from @calcom/platform-libraries following getPublicEvent pattern
- Integrate platform-specific verification hooks into BookerPlatformWrapper

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

* feat: update platform hooks to use v2 verification endpoints

- Update useVerifyEmail hook to use correct response type for v2 endpoint
- Update useVerifyCode hook to use v2 verification endpoint
- All verification functions now properly exported from platform libraries
- Type checking passes with no errors

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

* fix: update v2 API to use workspace version of platform-libraries

- Change package.json dependency from pinned version to workspace version
- Add proper error handling to verification service with NestJS HTTP exceptions
- Convert generic Error objects to BadRequestException and UnauthorizedException
- Ensure verification endpoints return proper HTTP status codes instead of 500 errors

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

* chore: update auto-generated files after package.json changes

- Update OpenAPI documentation for v2 verification endpoints
- Update yarn.lock after changing platform-libraries dependency

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

* refactor: extract verification logic from tRPC handlers into exportable functions

- Refactor sendVerifyEmailCode.handler.ts to extract core logic into sendVerifyEmailCode function
- Refactor verifyCodeUnAuthenticated.handler.ts to extract core logic into verifyCodeUnAuthenticated function
- Refactor organizations/verifyCode.handler.ts to extract core logic into verifyCodeAuthenticated function
- Update platform/libraries/index.ts to import from refactored handler files instead of standalone verification.ts
- Remove standalone verification.ts file as requested by user
- Keep original tRPC handlers as wrappers that call the extracted functions
- Maintain backward compatibility for existing tRPC endpoints

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

* extract logic into seperate functions

* Update index.ts

* refactor: remove unused type imports from verification-atom service

* feat: create v2 atoms input/output types for verification endpoints

- Add SendVerificationEmailInput and VerifyEmailCodeInput with validation decorators
- Add SendVerificationEmailOutput and VerifyEmailCodeOutput following v2 atoms patterns
- Update atoms verification controller to use custom types instead of platform types
- Auto-update openapi.json with new type definitions

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

* fix: add missing type imports to verification service

- Import ZVerifyCodeInputSchema from @calcom/prisma/zod-utils
- Import VerifyCodeAuthenticatedInput from organizations handler
- Import TSendVerifyEmailCodeSchema from sendVerifyEmailCode schema
- Resolves CI build failure in API v2 tests

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

* feat: add email verification check endpoint and refactor verification flow

* added Throttle

* added chagelog

* chore: update platform libraries to 0.0.317

* refactor: remove unused context and update email verification query key dependencies

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

* Update verifyCode.handler.ts

* chore: bump @calcom/platform-libraries from 0.0.319 to 0.0.320

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-21 15:06:42 +00:00
Romit f3d4eab951 fix: POST v2/bookings - recurrenceCount (with eventType reoccuring enabled) causes lengthInMinutes to be ignored (#23023)
* fix

* added tests

* chore
2025-08-21 16:54:33 +02:00
Anik Dhabal Babu 866b6cc233 fix: attendess are not showing (#23255) 2025-08-21 11:32:42 -03:00
沙什瓦特andDevanshu Sharma cafecad8ae feat: multi Select for "Event Type" in Insights (#23173)
* Multi select event type in insights

* Fixing multi select query generation

* Added null check for eventType filters

* Updated test to support multiselect

* Type fix

---------

Co-authored-by: Devanshu Sharma <devanshusharma658@gmail.com>
2025-08-21 13:30:21 +00:00
Benny Joo 0ea673e507 feat: update global feature flag to team feature flag for team booking page cache (#23154)
* add team id fetcher based on slugs

* export getOrgContext

* update RSC

* refactor

* further refactor
2025-08-21 21:34:45 +09:00
Eunjae Leeandcoderabbitai[bot] cf367c9340 feat: add attendee filters to /insights/routing (+ use RoutingFormResponseDenormalized) (#21297)
* fix: denormalize RoutingFormResponse with InsightsRoutingService

* fix tests

* fix type error

* update service usage

* fix attendee filters

* display attendee phone number

* use useInsightsRoutingParameters()

* remove unnecessary type check

* clean up

* fix type error

* fix wrong import

* fix integration tests

* Update packages/lib/server/service/__tests__/InsightsRoutingService.integration-test.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-08-21 14:09:22 +02:00
Alex van Andel d72e15649f chore: Remove maintenance mode middleware (#23251) 2025-08-21 11:12:54 +00:00
Somay Chauhan 89a3b77a1d feat: add hideEventMetadata prop (#23211)
* feat: add hideEventMetadata prop

* added changelog

* udpate

* undo extra changes
2025-08-21 09:00:51 +00:00
Somay Chauhan 38f6f71ae0 feat: add organizer.id to the BOOKING_CREATED webhook for requires confirmation event bookings (#23245) 2025-08-21 08:58:51 +00:00
Rajiv Sahal 40caa82e55 fix: onBookerStateChange prop for Booker atom (#23213)
* fix onBookerStateChange not working

* add changesets

* update changesets message
2025-08-21 08:51:48 +00:00
Joe Au-Yeung b9aee3b18c fix: Add guests via URL param even if field is hidden (#23229)
* Add guests via URL param even if field is hidden

* Clean up
2025-08-21 02:27:52 +00:00
Volnei Munhoz 3f8d2f2b38 chore: add coderabbit config file (#23200)
Post release / release (push) Canceled after 0s
* chore: add coderabbit config file

* remove wrong config

* add coderabbit instructions

* remove unnecessary paths

* add path instructions

* Fix duplicated instructions on yaml file

* Fix coderabbit.yaml syntax
2025-08-20 22:55:27 +00:00
emrysal c3731d9f17 chore: release v5.6.3 2025-08-20 22:49:28 +00:00
Vitor Monteiro 6e4b2d8886 fix: addOnLeading text field caret cropping (#23225) 2025-08-20 22:32:19 +00:00
cf13c06c57 chore: Upgrade boxyhq/saml-jackson to 1.52.2 (#23231)
* Upgrade jackson to 1.52.2

* Update apps/web/package.json

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Remove unused option prop

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-08-20 23:13:36 +01:00
Anik Dhabal Babu e309e72be3 fix: duplicate event created on attendee calendar (#23216) 2025-08-20 15:53:15 +00:00
b4d804de7c chore: hide cal branding for platform bookings (#22979)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
2025-08-20 12:27:09 -03:00
Volnei Munhoz f501ef3269 refactor: remove highperf endpoints (#23171)
* refactor: remove highperf endpoints

* fix trpc function name

* chore: remove isTeamEvent logic

* chore: remove isTeamEvent at all

* revert wrong change

* Revert "chore: remove isTeamEvent at all"

This reverts commit 6dbc8c31f16c40a7654a05240661d7892aa48bb8.

* revert wrong change
2025-08-20 15:12:31 +00:00
Volnei Munhoz 79f1074f93 chore: Remove Vercel functions memory config (#23125)
* chore:Remove Vercel memory config

* chore: remove empty vercel.json configs
2025-08-20 14:51:45 +00:00
Vansh GilhotraandKartik Saini 48aa6bf489 fix: Add missing CSS variable for switch component accent color in getting-started flow (#23129)
- Fix switch component thumb appearing all white when ON
- Add --cal-brand-accent CSS variable definitions for light and dark modes
- Ensures proper contrast: white accent on dark bg, black accent on light bg
- Resolves UI issue on /getting-started/connected-calendar and /getting-started/setup-availability pages

Fixes switch components using bg-brand-accent class that maps to var(--cal-brand-accent,white)
When CSS variable was undefined, it defaulted to white causing invisible thumbs.

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-08-20 18:29:03 +05:30
Hariom Balhara 8ed2aacd9e Add organization rewrite logs (#23210) 2025-08-20 11:51:55 +00:00
Hariom BalharaandDevin AI dfc5d914fc chore: Routing Forms - remove unused report.handler.ts and jsonLogicToPrisma functionality (#23032)
* feat: remove unused report.handler.ts and jsonLogicToPrisma functionality

- Remove unused packages/app-store/routing-forms/trpc/report.handler.ts
- Remove packages/app-store/routing-forms/jsonLogicToPrisma.ts implementation
- Remove packages/app-store/routing-forms/__tests__/jsonLogicToPrisma.test.ts test file

These files were no longer being used in the routing-forms package.

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

* feat: clean up remaining references to removed report functionality

- Remove unused report.schema.ts file
- Remove buildResponsesForReporting test file
- Remove report endpoint from router
- Update comment referencing jsonLogicToPrisma

All references to the removed jsonLogicToPrisma and report handler functionality have been cleaned up.

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

* Update packages/app-store/routing-forms/lib/getQueryBuilderConfig.ts

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-20 15:17:35 +05:30
Tim NeutkensandKeith Williams 0a11c45065 perf: Only run Sentry in production (#23167)
* Only run Sentry in production

* Only run Sentry in production for instrumentation-client

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2025-08-20 07:27:51 +00:00
Tim Neutkens 4ae5de5a3a Only apply autoprefixer in production (#23168) 2025-08-20 07:26:32 +00:00
github-actions[bot] e6959f3c7c chore: version packages (#23188)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-20 04:57:40 +00:00
emrysal 67af8b4973 chore: release v5.6.2
Post release / release (push) Canceled after 0s
2025-08-20 01:36:25 +00:00
Volnei Munhoz 7794f2596e chore: deprecated prisma default export (#23199) 2025-08-19 19:42:10 +00:00
167cf6e352 feat: add team schedules (#22954)
* feat: add team schedules

* fixup: implement feedback for teams controller

* cleanup unused functions

* fixup: implement PR feedback

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

* remove unused param and update summary

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

* update e2e tests

* add team schedule module

* update teams schedules module

* fixup: update guards

* init tests for teams schedules controller

* implement code rabbit feedback

* fixup: implement PR feedback

* fixup: remove getTeamSchedulesByUserIds

---------

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

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

---------

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

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-08-19 22:28:02 +09:00
Somay Chauhan 618ef63017 fix: Availability atom handleFormSubmit callbacks not triggering (#23174)
* fix: form submit callbacks trigger

* added changelog

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

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

* fix

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

* Create `LocationOptionContainer`

* Extract cal video settings to `CalVideoSettings`

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

* Unify naming

* Move location folder back under components

* Type fixes

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

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

* Use style profile to get brand colours

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

---------

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

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

---------

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

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

* update

* Update role-management.factory.ts

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

* Fix tsconfig.json paths path

* chore: fix root path on tsconfig.paths

* chore: add baseUrl to tsconfig.json

* chore: tsconfig.json issue

* try: CI without tsconfig paths

* chore: add only local paths on tsconfig.json

* chore: Fix docs

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

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

Fixes issue where canceled payments showed success page on rebooking

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

* test: add unit test for payment retry scenario

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

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

* update

* fix: restore calendar event deletion for round robin reassignment

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

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

* Update roundRobinReassignment.test.ts

* Update EventManager.ts

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

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

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

---------

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

* fixes

* revert api-v2 specific changes

* Add more tests

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

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

* refactor: Rename memberId to userId in TeamService methods

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

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

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

* package.json version

* fix unit teswtes

* No specs file

* fix unit tests

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

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

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

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

* Making tests clearer and easier to undestand

* chore: bump platform libs

* chore: bump platform libs

---------

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

* Create `getCalcomUrl` function

* Use `getCalcomURl` in generate metadata

* Use `getCalcomURl` in `buildCanonical`

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

* followup test
2025-08-17 20:26:14 +01:00
Anik Dhabal Babu 469a942b5a fix: multiple booking duration isn't working (#23140) 2025-08-17 17:08:27 +00:00
186 changed files with 6023 additions and 4026 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@calcom/atoms": patch
---
This PR fixes `onBookerStateChange` prop not working
+5
View File
@@ -0,0 +1,5 @@
---
"@calcom/atoms": minor
---
feat: add send & verify code flow to booker atom when email verification is turned on
+5
View File
@@ -0,0 +1,5 @@
---
"@calcom/atoms": minor
---
feat: added hideEventMetadata prop to the booker atom 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.
+187
View File
@@ -0,0 +1,187 @@
language: en-US
tone_instructions: ''
early_access: false
enable_free_tier: true
reviews:
profile: chill
request_changes_workflow: false
high_level_summary: false
high_level_summary_placeholder: '@coderabbitai summary'
high_level_summary_in_walkthrough: false
auto_title_placeholder: '@coderabbitai'
auto_title_instructions: ''
review_status: true
commit_status: true
fail_commit_status: false
collapse_walkthrough: false
changed_files_summary: false
sequence_diagrams: false
estimate_code_review_effort: false
assess_linked_issues: true
related_issues: false
related_prs: true
suggested_labels: false
auto_apply_labels: false
suggested_reviewers: false
auto_assign_reviewers: false
poem: false
labeling_instructions: []
path_filters:
- '!**/node_modules/**'
- '!**/dist/**'
- '!**/build/**'
- '!**/.next/**'
path_instructions:
- path: "**/*.{ts,tsx,js,jsx}"
instructions: "Flag default exports and encourage named exports. Named exports provide better tree-shaking, easier refactoring, and clearer imports. Exempt main components like pages, layouts, and components that serve as the primary export of a module."
abort_on_close: true
disable_cache: false
auto_review:
enabled: true
auto_incremental_review: true
ignore_title_keywords: []
labels: []
drafts: false
base_branches: []
finishing_touches:
docstrings:
enabled: true
unit_tests:
enabled: true
pre_merge_checks:
docstrings:
mode: warning
threshold: 80
title:
mode: warning
requirements: ''
description:
mode: warning
issue_assessment:
mode: warning
tools:
ast-grep:
rule_dirs: []
util_dirs: []
essential_rules: true
packages: []
shellcheck:
enabled: true
ruff:
enabled: true
markdownlint:
enabled: true
github-checks:
enabled: true
timeout_ms: 180000
languagetool:
enabled: true
enabled_rules: []
disabled_rules: []
enabled_categories: []
disabled_categories: []
enabled_only: false
level: default
biome:
enabled: true
hadolint:
enabled: true
swiftlint:
enabled: true
phpstan:
enabled: true
level: default
phpmd:
enabled: true
phpcs:
enabled: true
golangci-lint:
enabled: true
yamllint:
enabled: true
gitleaks:
enabled: true
checkov:
enabled: true
detekt:
enabled: true
eslint:
enabled: true
flake8:
enabled: true
rubocop:
enabled: true
buf:
enabled: true
regal:
enabled: true
actionlint:
enabled: true
pmd:
enabled: true
cppcheck:
enabled: true
semgrep:
enabled: true
circleci:
enabled: true
clippy:
enabled: true
sqlfluff:
enabled: true
prismaLint:
enabled: true
pylint:
enabled: true
oxc:
enabled: true
shopifyThemeCheck:
enabled: true
luacheck:
enabled: true
brakeman:
enabled: true
dotenvLint:
enabled: true
htmlhint:
enabled: true
checkmake:
enabled: true
osvScanner:
enabled: true
chat:
art: true
auto_reply: true
integrations:
jira:
usage: auto
linear:
usage: auto
knowledge_base:
opt_out: false
web_search:
enabled: true
code_guidelines:
enabled: true
filePatterns: []
learnings:
scope: auto
issues:
scope: auto
jira:
usage: auto
project_keys: []
linear:
usage: auto
team_keys: []
pull_requests:
scope: auto
mcp:
usage: auto
disabled_servers: []
code_generation:
docstrings:
language: en-US
path_instructions: []
unit_tests:
path_instructions: []
+2 -2
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
undecided:
- calcom-monorepo
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+3
View File
@@ -0,0 +1,3 @@
undecided:
- calcom-monorepo
- "@calcom/prisma"
+3
View File
@@ -0,0 +1,3 @@
undecided:
- calcom-monorepo
- "@calcom/prisma"
+1 -1
View File
@@ -199,7 +199,7 @@ There's some settings that you'll need to setup.
Under Vercel > Your API Project > Settings
In General > Build & Development Settings
BUILD COMMAND: `yarn turbo run build --scope=@calcom/api --include-dependencies --no-deps`
BUILD COMMAND: `yarn turbo run build --filter=@calcom/api --no-deps`
OUTPUT DIRECTORY: `apps/api/.next`
In Git > Ignored Build Step
-7
View File
@@ -1,7 +0,0 @@
{
"functions": {
"pages/api/slots/*.ts": {
"memory": 512
}
}
}
+1 -1
View File
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.312",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.320",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
@@ -15,11 +15,11 @@ import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { PlatformOAuthClient, Team, User } from "@prisma/client";
import * as request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
@@ -36,6 +36,7 @@ import {
CreateBookingInput_2024_08_13,
CreateEventTypeInput_2024_06_14,
EventTypeOutput_2024_06_14,
GetBookingOutput_2024_08_13,
GetBookingsOutput_2024_08_13,
} from "@calcom/platform-types";
@@ -50,9 +51,9 @@ describe("Managed user bookings 2024-08-13", () => {
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let schedulesRepositoryFixture: SchedulesRepositoryFixture;
let profilesRepositoryFixture: ProfileRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
const platformAdminEmail = `managed-users-bookings-2024-08-13-admin-${randomString()}@api.com`;
let platformAdmin: User;
@@ -67,6 +68,10 @@ describe("Managed user bookings 2024-08-13", () => {
let thirdManagedUser: CreateManagedUserData;
let firstManagedUserEventTypeId: number;
let eventTypeRequiresConfirmationId: number;
const orgAdminManagedUserEmail = `managed-user-bookings-2024-08-13-org-admin-${randomString()}@api.com`;
let orgAdminManagedUser: CreateManagedUserData;
let firstManagedUserBookingsCount = 0;
let secondManagedUserBookingsCount = 0;
@@ -85,9 +90,9 @@ describe("Managed user bookings 2024-08-13", () => {
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
schedulesRepositoryFixture = new SchedulesRepositoryFixture(moduleRef);
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
platformAdmin = await userRepositoryFixture.create({ email: platformAdminEmail });
@@ -95,6 +100,13 @@ describe("Managed user bookings 2024-08-13", () => {
name: `oauth-client-users-organization-${randomString()}`,
isPlatform: true,
isOrganization: true,
platformBilling: {
create: {
customerId: "cus_999",
plan: "SCALE",
subscriptionId: "sub_999",
},
},
});
oAuthClient = await createOAuthClient(organization.id);
@@ -243,6 +255,59 @@ describe("Managed user bookings 2024-08-13", () => {
thirdManagedUser = responseBody.data;
});
it(`should create org admin managed user`, async () => {
const requestBody: CreateManagedUserInput = {
email: orgAdminManagedUserEmail,
timeZone: managedUsersTimeZone,
weekStart: "Monday",
timeFormat: 24,
locale: Locales.FR,
name: "Org Admin Smith",
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
};
const response = await request(app.getHttpServer())
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users`)
.set("x-cal-secret-key", oAuthClient.secret)
.send(requestBody)
.expect(201);
const responseBody: CreateManagedUserOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.user.email).toEqual(
OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, requestBody.email)
);
expect(responseBody.data.accessToken).toBeDefined();
expect(responseBody.data.refreshToken).toBeDefined();
orgAdminManagedUser = responseBody.data;
await request(app.getHttpServer())
.post(`/v2/organizations/${organization.id}/memberships`)
.set("x-cal-client-id", oAuthClient.id)
.set("x-cal-secret-key", oAuthClient.secret)
.send({
userId: orgAdminManagedUser.user.id,
role: "ADMIN",
accepted: true,
})
.expect(201);
});
it("should create an event type requiring confirmation for first managed user", async () => {
const eventTypeRequiresConfirmation = await eventTypesRepositoryFixture.create(
{
title: `managed-user-bookings-event-type-requires-confirmation-${randomString()}`,
slug: `managed-user-bookings-event-type-requires-confirmation-${randomString()}`,
length: 60,
requiresConfirmation: true,
},
firstManagedUser.user.id
);
eventTypeRequiresConfirmationId = eventTypeRequiresConfirmation.id;
});
describe("bookings using original emails", () => {
it("managed user should be booked by managed user attendee and booking shows up in both users' bookings", async () => {
const body: CreateBookingInput_2024_08_13 = {
@@ -543,10 +608,175 @@ describe("Managed user bookings 2024-08-13", () => {
});
});
describe("booking confirmation by org admin", () => {
it("should allow org admin managed user to confirm booking using access token", async () => {
const bookingRequiringConfirmation = await bookingsRepositoryFixture.create({
user: {
connect: {
id: firstManagedUser.user.id,
},
},
startTime: new Date(Date.UTC(2030, 0, 9, 13, 0, 0)),
endTime: new Date(Date.UTC(2030, 0, 9, 14, 0, 0)),
title: "Booking requiring confirmation",
uid: `booking-requiring-confirmation-${randomString()}`,
eventType: {
connect: {
id: eventTypeRequiresConfirmationId,
},
},
location: "https://meet.google.com/abc-def-ghi",
customInputs: {},
metadata: {},
status: "PENDING",
responses: {
name: secondManagedUser.user.name,
email: secondManagedUserEmail,
},
attendees: {
create: {
email: secondManagedUserEmail,
name: secondManagedUser.user.name!,
locale: secondManagedUser.user.locale,
timeZone: secondManagedUser.user.timeZone,
},
},
});
const response = await request(app.getHttpServer())
.post(`/v2/bookings/${bookingRequiringConfirmation.uid}/confirm`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set("Authorization", `Bearer ${orgAdminManagedUser.accessToken}`)
.expect(200);
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const bookingData = responseBody.data as BookingOutput_2024_08_13;
expect(bookingData.status).toEqual("accepted");
expect(bookingData.uid).toEqual(bookingRequiringConfirmation.uid);
const confirmedBooking = await bookingsRepositoryFixture.getByUid(bookingRequiringConfirmation.uid);
expect(confirmedBooking?.status).toEqual("ACCEPTED");
});
it("should allow org admin managed user to reject booking using access token", async () => {
const bookingRequiringConfirmation = await bookingsRepositoryFixture.create({
user: {
connect: {
id: firstManagedUser.user.id,
},
},
startTime: new Date(Date.UTC(2030, 0, 9, 15, 0, 0)),
endTime: new Date(Date.UTC(2030, 0, 9, 16, 0, 0)),
title: "Booking requiring rejection",
uid: `booking-requiring-rejection-${randomString()}`,
eventType: {
connect: {
id: eventTypeRequiresConfirmationId,
},
},
location: "https://meet.google.com/abc-def-ghi",
customInputs: {},
metadata: {},
status: "PENDING",
responses: {
name: secondManagedUser.user.name,
email: secondManagedUserEmail,
},
attendees: {
create: {
email: secondManagedUserEmail,
name: secondManagedUser.user.name!,
locale: secondManagedUser.user.locale,
timeZone: secondManagedUser.user.timeZone,
},
},
});
const response = await request(app.getHttpServer())
.post(`/v2/bookings/${bookingRequiringConfirmation.uid}/decline`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set("Authorization", `Bearer ${orgAdminManagedUser.accessToken}`)
.expect(200);
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const bookingData = responseBody.data as BookingOutput_2024_08_13;
expect(bookingData.status).toEqual("rejected");
expect(bookingData.uid).toEqual(bookingRequiringConfirmation.uid);
const rejectedBooking = await bookingsRepositoryFixture.getByUid(bookingRequiringConfirmation.uid);
expect(rejectedBooking?.status).toEqual("REJECTED");
});
it("should return unauthorized when org admin tries to confirm regular user's booking", async () => {
const regularUser = await userRepositoryFixture.create({
email: `managed-user-bookings-2024-08-13-regular-user-${randomString()}@api.com`,
name: "Regular User",
});
const regularUserEventType = await eventTypesRepositoryFixture.create(
{
title: `regular-user-event-type-requires-confirmation-${randomString()}`,
slug: `regular-user-event-type-requires-confirmation-${randomString()}`,
length: 60,
requiresConfirmation: true,
},
regularUser.id
);
const regularUserBooking = await bookingsRepositoryFixture.create({
user: {
connect: {
id: regularUser.id,
},
},
startTime: new Date(Date.UTC(2030, 0, 9, 17, 0, 0)),
endTime: new Date(Date.UTC(2030, 0, 9, 18, 0, 0)),
title: "Regular user booking requiring confirmation",
uid: `regular-user-booking-${randomString()}`,
eventType: {
connect: {
id: regularUserEventType.id,
},
},
location: "https://meet.google.com/abc-def-ghi",
customInputs: {},
metadata: {},
status: "PENDING",
responses: {
name: "External Attendee",
email: "external@example.com",
},
attendees: {
create: {
email: "external@example.com",
name: "External Attendee",
locale: "en",
timeZone: "Europe/Rome",
},
},
});
await request(app.getHttpServer())
.post(`/v2/bookings/${regularUserBooking.uid}/confirm`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set("Authorization", `Bearer ${orgAdminManagedUser.accessToken}`)
.expect(401);
await userRepositoryFixture.delete(regularUser.id);
});
});
afterAll(async () => {
await userRepositoryFixture.delete(firstManagedUser.user.id);
await userRepositoryFixture.delete(secondManagedUser.user.id);
await userRepositoryFixture.delete(thirdManagedUser.user.id);
await userRepositoryFixture.delete(orgAdminManagedUser.user.id);
await userRepositoryFixture.delete(platformAdmin.id);
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
@@ -18,7 +18,6 @@ import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repo
import { BookingSeatRepositoryFixture } from "test/fixtures/repository/booking-seat.repository.fixture";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
@@ -27,7 +26,6 @@ import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@cal
import {
CancelBookingInput_2024_08_13,
CancelSeatedBookingInput_2024_08_13,
CreateRecurringSeatedBookingOutput_2024_08_13,
CreateSeatedBookingOutput_2024_08_13,
GetBookingOutput_2024_08_13,
GetBookingsOutput_2024_08_13,
@@ -35,11 +33,9 @@ import {
RescheduleSeatedBookingInput_2024_08_13,
} from "@calcom/platform-types";
import { CreateBookingInput_2024_08_13 } from "@calcom/platform-types";
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
import { Team } from "@calcom/prisma/client";
describe("Bookings Endpoints 2024-08-13", () => {
const googleMeetUrl = "https://meet.google.com/abc-def-ghi";
describe("Seated bookings", () => {
let app: INestApplication;
let organization: Team;
@@ -48,7 +44,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let bookingSeatRepositoryFixture: BookingSeatRepositoryFixture;
@@ -58,7 +53,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
let apiKeyString: string;
let seatedEventTypeId: number;
const maxRecurrenceCount = 3;
const seatedEventSlug = `seated-bookings-event-type-${randomString()}`;
@@ -83,7 +77,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
@@ -190,6 +183,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
absent: false,
seatUid,
bookingFieldsResponses: {
guests: [],
name: body.attendee.name,
...body.bookingFieldsResponses,
},
@@ -264,6 +258,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
absent: false,
seatUid: createdSeatedBooking.seatUid,
bookingFieldsResponses: {
guests: [],
name: createdSeatedBooking.attendees[0].name,
...createdSeatedBooking.attendees[0].bookingFieldsResponses,
},
@@ -278,6 +273,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
absent: false,
seatUid,
bookingFieldsResponses: {
guests: [],
name: body.attendee.name,
...body.bookingFieldsResponses,
},
@@ -517,6 +513,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
absent: false,
seatUid,
bookingFieldsResponses: {
guests: [],
name: body.attendee.name,
...body.bookingFieldsResponses,
},
@@ -21,7 +21,12 @@ 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 { CreateBookingInput_2024_08_13, BookingOutput_2024_08_13 } from "@calcom/platform-types";
import {
CreateBookingInput_2024_08_13,
BookingOutput_2024_08_13,
CreateRecurringBookingInput_2024_08_13,
RecurringBookingOutput_2024_08_13,
} from "@calcom/platform-types";
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Bookings Endpoints 2024-08-13", () => {
@@ -44,6 +49,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
const VARIABLE_LENGTH_EVENT_TYPE_SLUG = `variable-length-bookings-2024-08-13-event-type-${randomString()}`;
let normalEventType: EventType;
const NORMAL_EVENT_TYPE_SLUG = `variable-length-bookings-2024-08-13-event-type-${randomString()}`;
let variableLengthRecurringEventType: EventType;
const VARIABLE_LENGTH_RECURRING_EVENT_TYPE_SLUG = `variable-length-recurring-bookings-2024-08-13-event-type-${randomString()}`;
beforeAll(async () => {
const moduleRef = await withApiAuth(
@@ -105,6 +112,17 @@ describe("Bookings Endpoints 2024-08-13", () => {
user.id
);
variableLengthRecurringEventType = await eventTypesRepositoryFixture.create(
{
title: `variable-length-recurring-bookings-2024-08-13-event-type-${randomString()}`,
slug: VARIABLE_LENGTH_RECURRING_EVENT_TYPE_SLUG,
length: 15,
metadata: { multipleDuration: [15, 30, 60] },
recurringEvent: { freq: 2, count: 3, interval: 1 }, // Weekly, 3 times, every 1 week
},
user.id
);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
@@ -246,6 +264,63 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
describe("create recurring bookings", () => {
it("should create recurring bookings with custom lengthInMinutes", async () => {
const lengthInMinutes = 30;
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 1, 1, 14, 0, 0)).toISOString(),
eventTypeId: variableLengthRecurringEventType.id,
lengthInMinutes,
recurrenceCount: 2,
attendee: {
name: "Mr Recurring",
email: "mr_recurring@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: { status: string; data: RecurringBookingOutput_2024_08_13[] } = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(Array.isArray(responseBody.data)).toBe(true);
expect(responseBody.data).toHaveLength(2);
const firstBooking = responseBody.data[0];
const secondBooking = responseBody.data[1];
expect(firstBooking.duration).toEqual(lengthInMinutes);
expect(secondBooking.duration).toEqual(lengthInMinutes);
});
});
it("should reject recurring booking with invalid lengthInMinutes", async () => {
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 3, 1, 10, 0, 0)).toISOString(),
eventTypeId: variableLengthRecurringEventType.id,
lengthInMinutes: 45, // Not in allowed [15, 30, 60]
recurrenceCount: 2,
attendee: {
name: "Mr Invalid",
email: "mr_invalid@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
@@ -420,6 +420,9 @@ export class InputBookingsService_2024_08_13 {
throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} is not a recurring event`);
}
this.validateBookingLengthInMinutes(inputBooking, eventType);
const lengthInMinutes = inputBooking.lengthInMinutes ?? eventType.length;
const occurrence = recurringEventSchema.parse(eventType.recurringEvent);
const repeatsEvery = occurrence.interval;
@@ -456,7 +459,7 @@ export class InputBookingsService_2024_08_13 {
const location = inputLocation ? this.transformLocation(inputLocation) : undefined;
for (let i = 0; i < repeatsTimes; i++) {
const endTime = startTime.plus({ minutes: eventType.length });
const endTime = startTime.plus({ minutes: lengthInMinutes });
events.push({
start: startTime.toISO(),
@@ -702,6 +705,7 @@ export class InputBookingsService_2024_08_13 {
beforeUpdatedDate: queryParams.beforeUpdatedAt,
afterCreatedDate: queryParams.afterCreatedAt,
beforeCreatedDate: queryParams.beforeCreatedAt,
bookingUid: queryParams.bookingUid,
};
}
@@ -1,6 +1,5 @@
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { EventTypeOwnershipGuard } from "@/modules/event-types/guards/event-type-ownership.guard";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { Module } from "@nestjs/common";
@@ -2147,6 +2147,51 @@ describe("Event types Endpoints", () => {
});
});
describe("EventType Hidden Property", () => {
let createdEventTypeId: number;
it("should create an event type with hidden=true", async () => {
const createPayload = {
title: "Hidden Event",
slug: "hidden-event",
lengthInMinutes: 30,
hidden: true,
};
const response = await request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(createPayload)
.expect(201);
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const createdEventType = responseBody.data;
expect(createdEventType).toHaveProperty("id");
expect(createdEventType.title).toEqual(createPayload.title);
expect(createdEventType.slug).toEqual(createPayload.slug);
expect(createdEventType.lengthInMinutes).toEqual(createPayload.lengthInMinutes);
expect(createdEventType.hidden).toBe(true);
createdEventTypeId = createdEventType.id;
});
it("should update the hidden property to false", async () => {
const updatePayload = {
hidden: false,
};
const response = await request(app.getHttpServer())
.patch(`/api/v2/event-types/${createdEventTypeId}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(updatePayload)
.expect(200);
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const updatedEventType = responseBody.data;
+expect(updatedEventType.hidden).toBe(false);
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
@@ -89,6 +89,7 @@ type Input = Pick<
| "hideCalendarEventDetails"
| "hideOrganizerEmail"
| "calVideoSettings"
| "hidden"
>;
@Injectable()
@@ -127,6 +128,7 @@ export class OutputEventTypesService_2024_06_14 {
hideCalendarEventDetails,
hideOrganizerEmail,
calVideoSettings,
hidden,
} = databaseEventType;
const locations = this.transformLocations(databaseEventType.locations);
@@ -203,6 +205,7 @@ export class OutputEventTypesService_2024_06_14 {
hideCalendarEventDetails,
hideOrganizerEmail,
calVideoSettings,
hidden,
};
}
@@ -229,4 +229,19 @@ export class SchedulesRepository_2024_06_11 {
},
});
}
async getSchedulesByUserIds(userIds: number[], skip: number, take: number) {
return this.dbRead.prisma.schedule.findMany({
where: {
userId: {
in: userIds,
},
},
include: {
availability: true,
},
skip,
take,
});
}
}
@@ -4,10 +4,12 @@ import { AtomsConferencingAppsController } from "@/modules/atoms/controllers/ato
import { AtomsController } from "@/modules/atoms/controllers/atoms.controller";
import { AtomsEventTypesController } from "@/modules/atoms/controllers/atoms.event-types.controller";
import { AtomsSchedulesController } from "@/modules/atoms/controllers/atoms.schedules.controller";
import { AtomsVerificationController } from "@/modules/atoms/controllers/atoms.verification.controller";
import { AttributesAtomsService } from "@/modules/atoms/services/attributes-atom.service";
import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service";
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
import { SchedulesAtomsService } from "@/modules/atoms/services/schedules-atom.service";
import { VerificationAtomsService } from "@/modules/atoms/services/verification-atom.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
@@ -32,6 +34,7 @@ import { Module } from "@nestjs/common";
AtomsRepository,
UsersService,
SchedulesAtomsService,
VerificationAtomsService,
RedisService,
],
exports: [EventTypesAtomService],
@@ -40,6 +43,7 @@ import { Module } from "@nestjs/common";
AtomsEventTypesController,
AtomsConferencingAppsController,
AtomsSchedulesController,
AtomsVerificationController,
],
})
export class AtomsModule {}
@@ -0,0 +1,108 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { Throttle } from "@/lib/endpoint-throttler-decorator";
import { CheckEmailVerificationRequiredParams } from "@/modules/atoms/inputs/check-email-verification-required-params";
import { SendVerificationEmailInput } from "@/modules/atoms/inputs/send-verification-email.input";
import { VerifyEmailCodeInput } from "@/modules/atoms/inputs/verify-email-code.input";
import { SendVerificationEmailOutput } from "@/modules/atoms/outputs/send-verification-email.output";
import { VerifyEmailCodeOutput } from "@/modules/atoms/outputs/verify-email-code.output";
import { VerificationAtomsService } from "@/modules/atoms/services/verification-atom.service";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
Post,
Body,
UseGuards,
Version,
VERSION_NEUTRAL,
HttpCode,
HttpStatus,
Get,
Query,
} from "@nestjs/common";
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ApiResponse } from "@calcom/platform-types";
@Controller({
path: "/v2/atoms",
version: API_VERSIONS_VALUES,
})
@DocsTags("Atoms - verification endpoints for atoms")
@DocsExcludeController(true)
export class AtomsVerificationController {
constructor(private readonly verificationService: VerificationAtomsService) {}
@Post("/verification/email/send-code")
@Version(VERSION_NEUTRAL)
@HttpCode(HttpStatus.OK)
@Throttle({ limit: 3, ttl: 60000, blockDuration: 60000, name: "atoms_verification_email_send_code" })
async sendEmailVerificationCode(
@Body() body: SendVerificationEmailInput
): Promise<SendVerificationEmailOutput> {
const result = await this.verificationService.sendEmailVerificationCode({
email: body.email,
username: body.username,
language: body.language,
isVerifyingEmail: body.isVerifyingEmail,
});
return {
data: { sent: result.ok && !result.skipped },
status: SUCCESS_STATUS,
};
}
@Get("/verification/email/check")
@Version(VERSION_NEUTRAL)
@HttpCode(HttpStatus.OK)
async checkEmailVerificationRequired(
@Query() query: CheckEmailVerificationRequiredParams
): Promise<ApiResponse<boolean>> {
const required = await this.verificationService.checkEmailVerificationRequired({
email: query.email,
userSessionEmail: query.userSessionEmail,
});
return {
data: required,
status: SUCCESS_STATUS,
};
}
@Post("/verification/email/verify-code")
@Version(VERSION_NEUTRAL)
@HttpCode(HttpStatus.OK)
async verifyEmailCode(@Body() body: VerifyEmailCodeInput): Promise<VerifyEmailCodeOutput> {
const verified = await this.verificationService.verifyEmailCodeUnAuthenticated({
email: body.email,
code: body.code,
});
return {
data: { verified },
status: SUCCESS_STATUS,
};
}
@Post("/verification/email/verify-code-authenticated")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
@HttpCode(HttpStatus.OK)
async verifyEmailCodeAuthenticated(
@Body() body: VerifyEmailCodeInput,
@GetUser() user: UserWithProfile
): Promise<VerifyEmailCodeOutput> {
const verified = await this.verificationService.verifyEmailCodeAuthenticated(user, {
email: body.email,
code: body.code,
});
return {
data: { verified },
status: SUCCESS_STATUS,
};
}
}
@@ -0,0 +1,13 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsEmail, IsOptional, IsString } from "class-validator";
export class CheckEmailVerificationRequiredParams {
@ApiProperty({ example: "user@example.com" })
@IsEmail()
email!: string;
@ApiPropertyOptional({ example: "user@example.com" })
@IsOptional()
@IsString()
userSessionEmail?: string;
}
@@ -0,0 +1,23 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsEmail, IsOptional, IsString, IsBoolean } from "class-validator";
export class SendVerificationEmailInput {
@ApiProperty({ example: "user@example.com" })
@IsEmail()
email!: string;
@ApiPropertyOptional({ example: "johndoe" })
@IsOptional()
@IsString()
username?: string;
@ApiPropertyOptional({ example: "en" })
@IsOptional()
@IsString()
language?: string;
@ApiPropertyOptional({ example: true })
@IsOptional()
@IsBoolean()
isVerifyingEmail?: boolean;
}
@@ -0,0 +1,12 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEmail, IsString } from "class-validator";
export class VerifyEmailCodeInput {
@ApiProperty({ example: "user@example.com" })
@IsEmail()
email!: string;
@ApiProperty({ example: "123456" })
@IsString()
code!: string;
}
@@ -0,0 +1,25 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsString, ValidateNested, IsBoolean } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class SendVerificationEmailData {
@ApiProperty({ example: true })
@IsBoolean()
@Expose()
readonly sent!: boolean;
}
export class SendVerificationEmailOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsString()
@Expose()
readonly status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ValidateNested()
@Type(() => SendVerificationEmailData)
@Expose()
@ApiProperty({ type: SendVerificationEmailData })
readonly data!: SendVerificationEmailData;
}
@@ -0,0 +1,25 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsString, ValidateNested, IsBoolean } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class VerifyEmailCodeData {
@ApiProperty({ example: true })
@IsBoolean()
@Expose()
readonly verified!: boolean;
}
export class VerifyEmailCodeOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsString()
@Expose()
readonly status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ValidateNested()
@Type(() => VerifyEmailCodeData)
@Expose()
@ApiProperty({ type: VerifyEmailCodeData })
readonly data!: VerifyEmailCodeData;
}
@@ -0,0 +1,64 @@
import { CheckEmailVerificationRequiredParams } from "@/modules/atoms/inputs/check-email-verification-required-params";
import { SendVerificationEmailInput } from "@/modules/atoms/inputs/send-verification-email.input";
import { VerifyEmailCodeInput } from "@/modules/atoms/inputs/verify-email-code.input";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, BadRequestException, UnauthorizedException } from "@nestjs/common";
import {
verifyCodeUnAuthenticated,
verifyCodeAuthenticated,
sendEmailVerificationByCode,
checkEmailVerificationRequired,
} from "@calcom/platform-libraries";
@Injectable()
export class VerificationAtomsService {
async checkEmailVerificationRequired(input: CheckEmailVerificationRequiredParams) {
return await checkEmailVerificationRequired(input);
}
async verifyEmailCodeUnAuthenticated(input: VerifyEmailCodeInput) {
try {
return await verifyCodeUnAuthenticated(input);
} catch (error) {
if (error instanceof Error) {
if (error.message === "invalid_code") {
throw new BadRequestException("Invalid verification code");
}
if (error.message === "BAD_REQUEST") {
throw new BadRequestException("Email and code are required");
}
}
throw new BadRequestException("Verification failed");
}
}
async verifyEmailCodeAuthenticated(user: UserWithProfile, input: VerifyEmailCodeInput) {
try {
return await verifyCodeAuthenticated({
user,
email: input.email,
code: input.code,
});
} catch (error) {
if (error instanceof Error) {
if (error.message === "invalid_code") {
throw new BadRequestException("Invalid verification code");
}
if (error.message === "BAD_REQUEST") {
throw new BadRequestException("Email, code, and user ID are required");
}
}
throw new UnauthorizedException("Verification failed");
}
}
async sendEmailVerificationCode(input: SendVerificationEmailInput) {
return await sendEmailVerificationByCode({
email: input.email,
username: input.username,
language: input.language,
isVerifyingEmail: input.isVerifyingEmail,
});
}
}
@@ -11,6 +11,7 @@ import { OrganizationsTeamsBookingsModule } from "@/modules/organizations/teams/
import { OrganizationsUsersBookingsModule } from "@/modules/organizations/users/bookings/organizations-users-bookings.module";
import { RouterModule } from "@/modules/router/router.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { TeamsSchedulesModule } from "@/modules/teams/schedules/teams-schedules.module";
import { TimezoneModule } from "@/modules/timezones/timezones.module";
import { VerifiedResourcesModule } from "@/modules/verified-resources/verified-resources.module";
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
@@ -38,6 +39,7 @@ import { WebhooksModule } from "./webhooks/webhooks.module";
OrganizationsRoutingFormsModule,
VerifiedResourcesModule,
RouterModule,
TeamsSchedulesModule,
],
})
export class EndpointsModule implements NestModule {
@@ -66,6 +66,8 @@ describe("Organizations Bookings Endpoints 2024-08-13", () => {
let orgEventTypeId2: number;
let nonOrgEventTypeId: number;
let collectiveOrgBookingUid: string;
beforeAll(async () => {
const moduleRef = await withApiAuth(
orgUserEmail2,
@@ -91,12 +93,17 @@ describe("Organizations Bookings Endpoints 2024-08-13", () => {
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await organizationsRepositoryFixture.create({ name: "organization bookings" });
oAuthClient = await createOAuthClient(organization.id);
team1 = await teamRepositoryFixture.create({
name: "team orgs booking 1",
isOrganization: false,
parent: { connect: { id: organization.id } },
createdByOAuthClient: {
connect: {
id: oAuthClient.id,
},
},
});
oAuthClient = await createOAuthClient(organization.id);
nonOrgUser1 = await userRepositoryFixture.create({
email: nonOrgUserEmail1,
@@ -315,6 +322,7 @@ describe("Organizations Bookings Endpoints 2024-08-13", () => {
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
collectiveOrgBookingUid = data.uid;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts.length).toEqual(1);
@@ -669,6 +677,125 @@ describe("Organizations Bookings Endpoints 2024-08-13", () => {
);
});
});
describe("get by bookingUid param", () => {
it("should get a specific booking by bookingUid query param", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${organization.id}/bookings?bookingUid=${collectiveOrgBookingUid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(1);
expect(responseDataIsBooking(data[0])).toBe(true);
if (responseDataIsBooking(data[0])) {
const booking: BookingOutput_2024_08_13 = data[0];
expect(booking.uid).toEqual(collectiveOrgBookingUid);
expect(booking.eventTypeId).toEqual(orgEventTypeId);
expect(booking.status).toEqual("accepted");
expect(booking.attendees.length).toEqual(2);
expect(booking.attendees[0].name).toEqual("alice");
expect(booking.attendees[0].email).toEqual("alice@gmail.com");
} else {
throw new Error("Expected single booking but received different response type");
}
});
});
it("should return empty array for non-existent booking UID query param", async () => {
const nonExistentUid = "non-existent-booking-uid";
return request(app.getHttpServer())
.get(`/v2/organizations/${organization.id}/bookings?bookingUid=${nonExistentUid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.length).toEqual(0);
});
});
it("should return empty array for booking UID not belonging to the organization", async () => {
const regularUser = await userRepositoryFixture.create({
email: `org-bookings-regular-user-${Math.floor(Math.random() * 10000)}@api.com`,
name: "Regular User",
});
const regularUserEventType = await eventTypesRepositoryFixture.create(
{
title: `regular-user-event-type-${Math.floor(Math.random() * 10000)}`,
slug: `regular-user-event-type-${Math.floor(Math.random() * 10000)}`,
length: 60,
bookingFields: [],
locations: [],
},
regularUser.id
);
const regularUserBooking = await bookingsRepositoryFixture.create({
user: {
connect: {
id: regularUser.id,
},
},
startTime: new Date(Date.UTC(2030, 0, 15, 13, 0, 0)),
endTime: new Date(Date.UTC(2030, 0, 15, 14, 0, 0)),
title: "Regular user booking",
uid: `regular-user-booking-${Math.floor(Math.random() * 10000)}`,
eventType: {
connect: {
id: regularUserEventType.id,
},
},
location: "https://meet.google.com/regular-user",
customInputs: {},
metadata: {},
status: "ACCEPTED",
responses: {
name: "Regular Attendee",
email: "regular@example.com",
},
attendees: {
create: {
email: "regular@example.com",
name: "Regular Attendee",
locale: "en",
timeZone: "Europe/Rome",
},
},
});
return request(app.getHttpServer())
.get(`/v2/organizations/${organization.id}/bookings?bookingUid=${regularUserBooking.uid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.length).toEqual(0);
await userRepositoryFixture.delete(regularUser.id);
});
});
});
});
async function createOAuthClient(organizationId: number) {
@@ -74,6 +74,7 @@ type Input = Pick<
| "hideOrganizerEmail"
| "team"
| "calVideoSettings"
| "hidden"
>;
@Injectable()
@@ -2,6 +2,8 @@ import { CreateOrgMembershipDto } from "@/modules/organizations/memberships/inpu
import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
import { TeamService } from "@calcom/platform-libraries";
import { UpdateOrgMembershipDto } from "../inputs/update-organization-membership.input";
import { OrganizationsMembershipOutputService } from "./organizations-membership-output.service";
@@ -62,10 +64,24 @@ export class OrganizationsMembershipService {
}
async deleteOrgMembership(organizationId: number, membershipId: number) {
const membership = await this.organizationsMembershipRepository.deleteOrgMembership(
// Get the membership first to get the userId
const membership = await this.organizationsMembershipRepository.findOrgMembership(
organizationId,
membershipId
);
if (!membership) {
throw new NotFoundException(
`Membership with id ${membershipId} within organization id ${organizationId} not found`
);
}
await TeamService.removeMembers({
teamIds: [organizationId],
userIds: [membership.userId],
isOrg: true,
});
return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership);
}
@@ -1,5 +1,9 @@
import { OrganizationsEventTypesPrivateLinksController } from "@/ee/event-types-private-links/controllers/organizations-event-types-private-links.controller";
import { EventTypesPrivateLinksModule } from "@/ee/event-types-private-links/event-types-private-links.module";
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
import { InputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/input-schedules.service";
import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
@@ -18,13 +22,10 @@ import { OrganizationAttributesService } from "@/modules/organizations/attribute
import { OrganizationAttributeOptionRepository } from "@/modules/organizations/attributes/options/organization-attribute-options.repository";
import { OrganizationsAttributesOptionsController } from "@/modules/organizations/attributes/options/organizations-attributes-options.controller";
import { OrganizationAttributeOptionService } from "@/modules/organizations/attributes/options/services/organization-attributes-option.service";
import { OrganizationsConferencingController } from "@/modules/organizations/conferencing/organizations-conferencing.controller";
import { OrganizationsConferencingModule } from "@/modules/organizations/conferencing/organizations-conferencing.module";
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
import { OrganizationsDelegationCredentialModule } from "@/modules/organizations/delegation-credentials/organizations-delegation-credential.module";
import { OrganizationsEventTypesController } from "@/modules/organizations/event-types/organizations-event-types.controller";
import { EventTypesPrivateLinksModule } from "@/ee/event-types-private-links/event-types-private-links.module";
import { OrganizationsEventTypesPrivateLinksController } from "@/ee/event-types-private-links/controllers/organizations-event-types-private-links.controller";
import { OrganizationsEventTypesRepository } from "@/modules/organizations/event-types/organizations-event-types.repository";
import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/event-types/pipes/team-event-types-response.transformer";
import { InputOrganizationsEventTypesService } from "@/modules/organizations/event-types/services/input.service";
@@ -38,7 +39,6 @@ import { OrganizationsMembershipOutputService } from "@/modules/organizations/me
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
import { OrganizationsOrganizationsModule } from "@/modules/organizations/organizations/organizations-organizations.module";
import { OrganizationsSchedulesController } from "@/modules/organizations/schedules/organizations-schedules.controller";
import { OrganizationSchedulesRepository } from "@/modules/organizations/schedules/organizations-schedules.repository";
import { OrganizationsSchedulesService } from "@/modules/organizations/schedules/services/organizations-schedules.service";
import { OrganizationsStripeModule } from "@/modules/organizations/stripe/organizations-stripe.module";
import { OrganizationsStripeService } from "@/modules/organizations/stripe/services/organizations-stripe.service";
@@ -65,6 +65,7 @@ import { RedisModule } from "@/modules/redis/redis.module";
import { RedisService } from "@/modules/redis/redis.service";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
import { TeamsSchedulesService } from "@/modules/teams/schedules/services/teams-schedules.service";
import { TeamsModule } from "@/modules/teams/teams/teams.module";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UsersModule } from "@/modules/users/users.module";
@@ -102,7 +103,6 @@ import { Module } from "@nestjs/common";
OrganizationsTeamsService,
MembershipsRepository,
OrganizationsSchedulesService,
OrganizationSchedulesRepository,
OrganizationsUsersRepository,
OrganizationsUsersService,
EmailService,
@@ -144,6 +144,9 @@ import { Module } from "@nestjs/common";
TeamWorkflowsService,
WorkflowsInputService,
WorkflowsOutputService,
TeamsSchedulesService,
SchedulesService_2024_06_11,
InputSchedulesService_2024_06_11,
],
exports: [
OrganizationsService,
@@ -1,5 +1,5 @@
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { OutputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/output-schedules.service";
import { OrganizationSchedulesRepository } from "@/modules/organizations/schedules/organizations-schedules.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
@@ -8,8 +8,8 @@ import { ScheduleOutput_2024_06_11 } from "@calcom/platform-types";
@Injectable()
export class OrganizationsSchedulesService {
constructor(
private readonly organizationSchedulesService: OrganizationSchedulesRepository,
private readonly outputSchedulesService: OutputSchedulesService_2024_06_11,
private readonly schedulesRepository: SchedulesRepository_2024_06_11,
private readonly usersRepository: UsersRepository
) {}
@@ -17,7 +17,7 @@ export class OrganizationsSchedulesService {
const users = await this.usersRepository.getOrganizationUsers(organizationId);
const usersIds = users.map((user) => user.id);
const schedules = await this.organizationSchedulesService.getSchedulesByUserIds(usersIds, skip, take);
const schedules = await this.schedulesRepository.getSchedulesByUserIds(usersIds, skip, take);
const responseSchedules: ScheduleOutput_2024_06_11[] = [];
@@ -20,7 +20,6 @@ import {
OrgTeamMembershipOutputResponseDto,
} from "@/modules/organizations/teams/memberships/outputs/organization-teams-memberships.output";
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/teams/memberships/services/organizations-teams-memberships.service";
import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service";
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import {
Controller,
@@ -59,7 +58,6 @@ export class OrganizationsTeamsMembershipsController {
constructor(
private organizationsTeamsMembershipsService: OrganizationsTeamsMembershipsService,
private teamsEventTypesService: TeamsEventTypesService,
private readonly organizationsRepository: OrganizationsRepository
) {}
@@ -127,8 +125,6 @@ export class OrganizationsTeamsMembershipsController {
membershipId
);
await this.teamsEventTypesService.deleteUserTeamEventTypesAndHosts(membership.userId, teamId);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
@@ -3,6 +3,8 @@ import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/teams/member
import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
import { TeamService } from "@calcom/platform-libraries";
@Injectable()
export class OrganizationsTeamsMembershipsService {
constructor(
@@ -58,11 +60,21 @@ export class OrganizationsTeamsMembershipsService {
}
async deleteOrgTeamMembership(organizationId: number, teamId: number, membershipId: number) {
const teamMembership = await this.organizationsTeamsMembershipsRepository.deleteOrgTeamMembershipById(
// First get the membership to get the userId
const teamMembership = await this.organizationsTeamsMembershipsRepository.findOrgTeamMembership(
organizationId,
teamId,
membershipId
);
if (!teamMembership) {
throw new NotFoundException(
`Membership with id ${membershipId} not found in team ${teamId} of organization ${organizationId}`
);
}
await TeamService.removeMembers({ teamIds: [teamId], userIds: [teamMembership.userId], isOrg: false });
return teamMembership;
}
}
@@ -14,11 +14,12 @@ import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { IsUserInOrgTeam } from "@/modules/auth/guards/users/is-user-in-org-team.guard";
import { Controller, UseGuards, Get, Param, ParseIntPipe } from "@nestjs/common";
import { TeamsSchedulesService } from "@/modules/teams/schedules/services/teams-schedules.service";
import { Controller, UseGuards, Get, Param, ParseIntPipe, Query } from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { GetSchedulesOutput_2024_06_11 } from "@calcom/platform-types";
import { GetSchedulesOutput_2024_06_11, SkipTakePagination } from "@calcom/platform-types";
@Controller({
path: "/v2/organizations/:orgId/teams/:teamId",
@@ -29,7 +30,31 @@ import { GetSchedulesOutput_2024_06_11 } from "@calcom/platform-types";
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
@ApiHeader(OPTIONAL_API_KEY_HEADER)
export class OrganizationsTeamsSchedulesController {
constructor(private schedulesService: SchedulesService_2024_06_11) {}
constructor(
private schedulesService: SchedulesService_2024_06_11,
private teamsSchedulesService: TeamsSchedulesService
) {}
@UseGuards(IsTeamInOrg)
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@Get("/schedules")
@DocsTags("Orgs / Teams / Schedules")
@ApiOperation({ summary: "Get all team member schedules" })
async getTeamSchedules(
@Param("teamId", ParseIntPipe) teamId: number,
@Query() queryParams: SkipTakePagination
): Promise<GetSchedulesOutput_2024_06_11> {
const { skip, take } = queryParams;
const schedules = await this.teamsSchedulesService.getTeamSchedules(teamId, skip, take);
return {
status: SUCCESS_STATUS,
data: schedules,
};
}
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@@ -18,6 +18,7 @@ import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { GetSchedulesOutput_2024_06_11 } from "@calcom/platform-types";
import { ApiSuccessResponse, ScheduleOutput_2024_06_11 } from "@calcom/platform-types";
import { Team, Schedule } from "@calcom/prisma/client";
@@ -36,6 +37,8 @@ describe("Organizations Teams Schedules Endpoints", () => {
let org: Team;
let orgTeam: Team;
let nonOrgTeam: Team;
let userSchedule: Schedule;
let user2Schedule: Schedule;
const userEmail = `organizations-teams-schedules-admin-${randomString()}@api.com`;
@@ -73,6 +76,16 @@ describe("Organizations Teams Schedules Endpoints", () => {
username: userEmail2,
});
userSchedule = await scheduleRepositoryFixture.create({
user: {
connect: {
id: user.id,
},
},
name: `organizations-teams-schedules-user-schedule-${randomString()}`,
timeZone: "America/New_York",
});
user2Schedule = await scheduleRepositoryFixture.create({
user: {
connect: {
@@ -214,6 +227,32 @@ describe("Organizations Teams Schedules Endpoints", () => {
});
});
it("should get all the schedules of members in a team", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/schedules`)
.expect(200)
.then((response) => {
const responseBody: GetSchedulesOutput_2024_06_11 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(Array.isArray(responseBody.data)).toBe(true);
expect(responseBody.data.length).toEqual(2);
const userOneSchedule = responseBody.data.find((schedule) => schedule.id === userSchedule.id);
const userTwoSchedule = responseBody.data.find((schedule) => schedule.id === user2Schedule.id);
expect(userOneSchedule).toBeDefined();
expect(userTwoSchedule).toBeDefined();
expect(userOneSchedule?.id).toEqual(userSchedule.id);
expect(userOneSchedule?.name).toEqual(userSchedule.name);
expect(userOneSchedule?.timeZone).toEqual(userSchedule.timeZone);
expect(userTwoSchedule?.id).toEqual(user2Schedule.id);
expect(userTwoSchedule?.name).toEqual(user2Schedule.name);
expect(userOneSchedule?.timeZone).toEqual(user2Schedule.timeZone);
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await userRepositoryFixture.deleteByEmail(userToInviteViaApi.email);
@@ -12,7 +12,12 @@ import {
EMAIL,
WorkflowEmailAttendeeStepDto,
} from "@/modules/workflows/inputs/workflow-step.input";
import { BEFORE_EVENT, DAY } from "@/modules/workflows/inputs/workflow-trigger.input";
import {
BEFORE_EVENT,
DAY,
OnAfterEventTriggerDto,
OnBeforeEventTriggerDto,
} from "@/modules/workflows/inputs/workflow-trigger.input";
// Adjust path if needed
import { GetWorkflowOutput, GetWorkflowsOutput } from "@/modules/workflows/outputs/workflow.output";
import { INestApplication } from "@nestjs/common";
@@ -255,6 +260,10 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
"+37255555555"
);
expect(responseBody.data.steps.find((step) => step.stepNumber === 3)?.email).toEqual(authEmail);
const trigger = sampleCreateWorkflowDto.trigger as OnBeforeEventTriggerDto;
expect(responseBody.data.trigger?.offset?.value).toEqual(trigger.offset.value);
expect(responseBody.data.trigger?.offset?.unit).toEqual(trigger.offset.unit);
createdWorkflowId = responseBody.data.id;
createdWorkflow = responseBody.data;
});
@@ -330,6 +339,13 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
expect(step1).toBeDefined();
const partialUpdateDto: Partial<CreateWorkflowDto> = {
name: updatedName,
trigger: {
type: "afterEvent",
offset: {
unit: "minute",
value: 10,
},
},
steps: step1 ? [{ ...step1, sender: "updatedSender" } as WorkflowEmailAttendeeStepDto] : [],
};
expect(createdWorkflowId).toBeDefined();
@@ -348,6 +364,10 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
step1 && expect(responseBody.data.steps[0].id).toEqual(step1.id);
expect(responseBody.data.steps[0].sender).toEqual("updatedSender");
expect(responseBody.data.steps[1]?.id).toBeUndefined();
const trigger = partialUpdateDto.trigger as OnAfterEventTriggerDto;
expect(responseBody.data.trigger?.type).toEqual(trigger.type);
expect(responseBody.data.trigger?.offset?.value).toEqual(trigger.offset.value);
expect(responseBody.data.trigger?.offset?.unit).toEqual(trigger.offset.unit);
});
});
@@ -156,17 +156,4 @@ export class TeamsEventTypesService {
return this.eventTypesRepository.deleteEventType(eventTypeId);
}
async deleteUserTeamEventTypesAndHosts(userId: number, teamId: number) {
try {
await this.teamsEventTypesRepository.deleteUserManagedTeamEventTypes(userId, teamId);
await this.teamsEventTypesRepository.removeUserFromTeamEventTypesHosts(userId, teamId);
} catch (err) {
this.logger.error("Could not remove user from all team event-types.", {
error: err,
userId,
teamId,
});
}
}
}
@@ -3,7 +3,6 @@ import { API_KEY_HEADER } from "@/lib/docs/headers";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service";
import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/create-team-membership.input";
import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input";
import { CreateTeamMembershipOutput } from "@/modules/teams/memberships/outputs/create-team-membership.output";
@@ -45,10 +44,7 @@ import { SkipTakePagination } from "@calcom/platform-types";
export class TeamsMembershipsController {
private logger = new Logger("TeamsMembershipsController");
constructor(
private teamsMembershipsService: TeamsMembershipsService,
private teamsEventTypesService: TeamsEventTypesService
) {}
constructor(private teamsMembershipsService: TeamsMembershipsService) {}
@Roles("TEAM_ADMIN")
@Post("/")
@@ -127,7 +123,6 @@ export class TeamsMembershipsController {
membershipId,
body
);
if (!currentMembership.accepted && updatedMembership.accepted) {
try {
await updateNewTeamMemberEventTypes(updatedMembership.userId, teamId);
@@ -151,8 +146,6 @@ export class TeamsMembershipsController {
): Promise<DeleteTeamMembershipOutput> {
const membership = await this.teamsMembershipsService.deleteTeamMembership(teamId, membershipId);
await this.teamsEventTypesService.deleteUserTeamEventTypesAndHosts(membership.userId, teamId);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
@@ -3,6 +3,8 @@ import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/up
import { TeamsMembershipsRepository } from "@/modules/teams/memberships/teams-memberships.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
import { TeamService } from "@calcom/platform-libraries";
@Injectable()
export class TeamsMembershipsService {
constructor(private readonly teamsMembershipsRepository: TeamsMembershipsRepository) {}
@@ -41,10 +43,15 @@ export class TeamsMembershipsService {
}
async deleteTeamMembership(teamId: number, membershipId: number) {
const teamMembership = await this.teamsMembershipsRepository.deleteTeamMembershipById(
teamId,
membershipId
);
// First get the membership to get the userId
const teamMembership = await this.teamsMembershipsRepository.findTeamMembership(teamId, membershipId);
if (!teamMembership) {
throw new NotFoundException(`Membership with id ${membershipId} not found in team ${teamId}`);
}
await TeamService.removeMembers({ teamIds: [teamId], userIds: [teamMembership.userId], isOrg: false });
return teamMembership;
}
}
@@ -0,0 +1,41 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { TeamsSchedulesService } from "@/modules/teams/schedules/services/teams-schedules.service";
import { Controller, UseGuards, Get, Param, ParseIntPipe, Query } from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { GetSchedulesOutput_2024_06_11, SkipTakePagination } from "@calcom/platform-types";
@Controller({
path: "/v2/teams/:teamId/schedules",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, RolesGuard)
@DocsTags("Teams / Schedules")
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
export class TeamsSchedulesController {
constructor(private teamsSchedulesService: TeamsSchedulesService) {}
@Roles("TEAM_ADMIN")
@Get("/")
@ApiOperation({
summary: "Get all team member schedules",
})
async getTeamSchedules(
@Param("teamId", ParseIntPipe) teamId: number,
@Query() queryParams: SkipTakePagination
): Promise<GetSchedulesOutput_2024_06_11> {
const { skip, take } = queryParams;
const schedules = await this.teamsSchedulesService.getTeamSchedules(teamId, skip, take);
return {
status: SUCCESS_STATUS,
data: schedules,
};
}
}
@@ -0,0 +1,143 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { GetSchedulesOutput_2024_06_11 } from "@calcom/platform-types";
import { Team, Schedule } from "@calcom/prisma/client";
describe("Teams Schedules Endpoints", () => {
describe("User Authentication - User is Team Admin", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let scheduleRepositoryFixture: SchedulesRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let nonOrgTeam: Team;
let userSchedule: Schedule;
let user2Schedule: Schedule;
const userEmail = `teams-schedules-admin-${randomString()}@api.com`;
const userEmail2 = `teams-schedules-member-${randomString()}@api.com`;
let user: User;
let user2: User;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
scheduleRepositoryFixture = new SchedulesRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});
user2 = await userRepositoryFixture.create({
email: userEmail2,
username: userEmail2,
});
userSchedule = await scheduleRepositoryFixture.create({
user: {
connect: {
id: user.id,
},
},
name: `teams-schedules-user-schedule-${randomString()}`,
timeZone: "America/New_York",
});
user2Schedule = await scheduleRepositoryFixture.create({
user: {
connect: {
id: user2.id,
},
},
name: `teams-schedules-user2-schedule-${randomString()}`,
timeZone: "America/New_York",
});
nonOrgTeam = await teamsRepositoryFixture.create({
name: `teams-schedules-non-org-team-${randomString()}`,
isOrganization: false,
});
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: user.id } },
team: { connect: { id: nonOrgTeam.id } },
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: user2.id } },
team: { connect: { id: nonOrgTeam.id } },
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should get all the schedules of members in a team", async () => {
return request(app.getHttpServer())
.get(`/v2/teams/${nonOrgTeam.id}/schedules`)
.expect(200)
.then((response) => {
const responseBody: GetSchedulesOutput_2024_06_11 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(Array.isArray(responseBody.data)).toBe(true);
expect(responseBody.data.length).toEqual(2);
const userOneSchedule = responseBody.data.find((schedule) => schedule.id === userSchedule.id);
const userTwoSchedule = responseBody.data.find((schedule) => schedule.id === user2Schedule.id);
expect(userOneSchedule).toBeDefined();
expect(userTwoSchedule).toBeDefined();
expect(userOneSchedule?.id).toEqual(userSchedule.id);
expect(userOneSchedule?.name).toEqual(userSchedule.name);
expect(userOneSchedule?.timeZone).toEqual(userSchedule.timeZone);
expect(userTwoSchedule?.id).toEqual(user2Schedule.id);
expect(userTwoSchedule?.name).toEqual(user2Schedule.name);
expect(userOneSchedule?.timeZone).toEqual(user2Schedule.timeZone);
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await userRepositoryFixture.deleteByEmail(user2.email);
await teamsRepositoryFixture.delete(nonOrgTeam.id);
await app.close();
});
});
});
@@ -0,0 +1,28 @@
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { OutputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/output-schedules.service";
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
import { Injectable } from "@nestjs/common";
import { ScheduleOutput_2024_06_11 } from "@calcom/platform-types";
@Injectable()
export class TeamsSchedulesService {
constructor(
private readonly teamsRepository: TeamsRepository,
private readonly schedulesRepository: SchedulesRepository_2024_06_11,
private readonly outputSchedulesService: OutputSchedulesService_2024_06_11
) {}
async getTeamSchedules(teamId: number, skip = 0, take = 250) {
const userIds = await this.teamsRepository.getTeamUsersIds(teamId);
const schedules = await this.schedulesRepository.getSchedulesByUserIds(userIds, skip, take);
const responseSchedules: ScheduleOutput_2024_06_11[] = [];
for (const schedule of schedules) {
responseSchedules.push(await this.outputSchedulesService.getResponseSchedule(schedule));
}
return responseSchedules;
}
}
@@ -0,0 +1,43 @@
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { OutputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/output-schedules.service";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { OrganizationSchedulesRepository } from "@/modules/organizations/schedules/organizations-schedules.repository";
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { StripeService } from "@/modules/stripe/stripe.service";
import { TeamsMembershipsService } from "@/modules/teams/memberships/services/teams-memberships.service";
import { TeamsMembershipsRepository } from "@/modules/teams/memberships/teams-memberships.repository";
import { TeamsSchedulesController } from "@/modules/teams/schedules/controllers/teams-schedules.controller";
import { TeamsSchedulesService } from "@/modules/teams/schedules/services/teams-schedules.service";
import { TeamsController } from "@/modules/teams/teams/controllers/teams.controller";
import { TeamsService } from "@/modules/teams/teams/services/teams.service";
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, MembershipsModule, RedisModule],
providers: [
TeamsRepository,
TeamsService,
TeamsMembershipsRepository,
TeamsMembershipsService,
OutputSchedulesService_2024_06_11,
OrganizationSchedulesRepository,
StripeService,
UsersRepository,
AppsRepository,
CredentialsRepository,
OrganizationsRepository,
TeamsSchedulesService,
OrganizationsTeamsRepository,
SchedulesRepository_2024_06_11,
],
controllers: [TeamsController, TeamsSchedulesController],
exports: [TeamsRepository],
})
export class TeamsSchedulesModule {}
@@ -1,7 +1,7 @@
import { ApiProperty } from "@nestjs/swagger";
import { TimeUnit, WorkflowTriggerEvents } from "@prisma/client";
import { Type } from "class-transformer";
import { IsNumber, ValidateNested } from "class-validator";
import { IsIn, IsNumber, IsString, ValidateNested } from "class-validator";
export const BEFORE_EVENT = "beforeEvent";
export const EVENT_CANCELLED = "eventCancelled";
@@ -68,6 +68,8 @@ export class WorkflowTriggerOffsetDto {
value!: number;
@ApiProperty({ description: "Unit for the offset time", example: HOUR })
@IsString()
@IsIn(TIME_UNITS)
unit!: TimeUnitType;
}
@@ -75,6 +77,8 @@ export class BaseWorkflowTriggerDto {
@ApiProperty({
description: "Trigger type for the workflow",
})
@IsString()
@IsIn([WORKFLOW_TRIGGER_TYPES])
type!: WorkflowTriggerType;
}
@@ -82,6 +86,8 @@ export class OnCreationTriggerDto {
@ApiProperty({
description: "Trigger type for the workflow",
})
@IsString()
@IsIn([NEW_EVENT])
type: typeof NEW_EVENT = NEW_EVENT;
}
@@ -89,12 +95,16 @@ export class OnRescheduleTriggerDto {
@ApiProperty({
description: "Trigger type for the workflow",
})
@IsString()
@IsIn([RESCHEDULE_EVENT])
type: typeof RESCHEDULE_EVENT = RESCHEDULE_EVENT;
}
export class OnCancelTriggerDto {
@ApiProperty({
description: "Trigger type for the workflow",
})
@IsString()
@IsIn([EVENT_CANCELLED])
type: typeof EVENT_CANCELLED = EVENT_CANCELLED;
}
@@ -113,6 +123,8 @@ export class OnBeforeEventTriggerDto extends TriggerOffsetDTO {
description: "Trigger type for the workflow",
example: BEFORE_EVENT,
})
@IsString()
@IsIn([BEFORE_EVENT])
type: typeof BEFORE_EVENT = BEFORE_EVENT;
}
@@ -121,6 +133,8 @@ export class OnAfterEventTriggerDto extends TriggerOffsetDTO {
description: "Trigger type for the workflow",
example: AFTER_EVENT,
})
@IsString()
@IsIn([AFTER_EVENT])
type: typeof AFTER_EVENT = AFTER_EVENT;
}
@@ -129,6 +143,8 @@ export class OnAfterCalVideoGuestsNoShowTriggerDto extends TriggerOffsetDTO {
description: "Trigger type for the workflow",
example: AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
})
@IsString()
@IsIn([AFTER_GUESTS_CAL_VIDEO_NO_SHOW])
type: typeof AFTER_GUESTS_CAL_VIDEO_NO_SHOW = AFTER_GUESTS_CAL_VIDEO_NO_SHOW;
}
@@ -137,5 +153,7 @@ export class OnAfterCalVideoHostsNoShowTriggerDto extends TriggerOffsetDTO {
description: "Trigger type for the workflow",
example: AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
})
@IsString()
@IsIn([AFTER_HOSTS_CAL_VIDEO_NO_SHOW])
type: typeof AFTER_HOSTS_CAL_VIDEO_NO_SHOW = AFTER_HOSTS_CAL_VIDEO_NO_SHOW;
}
@@ -59,4 +59,8 @@ export class EventTypesRepositoryFixture {
async delete(eventTypeId: EventType["id"]) {
return this.prismaWriteClient.eventType.delete({ where: { id: eventTypeId } });
}
async findById(eventTypeId: EventType["id"]) {
return this.prismaReadClient.eventType.findUnique({ where: { id: eventTypeId } });
}
}
@@ -35,4 +35,8 @@ export class MembershipRepositoryFixture {
await this.prismaWriteClient.user.update({ where: { id: user.id }, data: { organizationId: org.id } });
return membership;
}
async findById(membershipId: Membership["id"]) {
return this.prismaReadClient.membership.findUnique({ where: { id: membershipId } });
}
}
@@ -1,6 +1,6 @@
import { CustomI18nProvider } from "app/CustomI18nProvider";
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps, SearchParams } from "app/_types";
import type { PageProps, Params, SearchParams } from "app/_types";
import { generateMeetingMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
@@ -15,20 +15,34 @@ import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
import LegacyPage from "~/team/type-view";
import type { PageProps as LegacyPageProps } from "~/team/type-view";
import CachedTeamBooker, { generateMetadata as generateCachedMetadata } from "./pageWithCachedData";
import CachedTeamBooker, {
generateMetadata as generateCachedMetadata,
getOrgContext,
} from "./pageWithCachedData";
import { getTeamId } from "./queries";
async function isCachedTeamBookingEnabled(params: Params, searchParams: SearchParams): Promise<boolean> {
if (searchParams.experimentalTeamBookingPageCache !== "true") return false;
const { teamSlug, currentOrgDomain, isValidOrgDomain } = await getOrgContext(params);
const orgSlug = isValidOrgDomain ? currentOrgDomain : null;
const teamId = await getTeamId(teamSlug, orgSlug);
if (!teamId) return false;
async function isCachedTeamBookingEnabled(searchParams: SearchParams): Promise<boolean> {
const featuresRepository = new FeaturesRepository(prisma);
const isGloballyEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
const isTeamFeatureEnabled = await featuresRepository.checkIfTeamHasFeature(
teamId,
"team-booking-page-cache"
);
return isGloballyEnabled && searchParams.experimentalTeamBookingPageCache === "true";
return isTeamFeatureEnabled;
}
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
if (await isCachedTeamBookingEnabled(await searchParams)) {
if (await isCachedTeamBookingEnabled(await params, await searchParams)) {
return await generateCachedMetadata({ params, searchParams });
}
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
const props = await getData(legacyCtx);
const { booking, isSEOIndexable, eventData, isBrandingHidden } = props;
@@ -68,7 +82,7 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const getData = withAppDirSsr<LegacyPageProps>(getServerSideProps);
const ServerPage = async ({ params, searchParams }: PageProps) => {
if (await isCachedTeamBookingEnabled(await searchParams)) {
if (await isCachedTeamBookingEnabled(await params, await searchParams)) {
return await CachedTeamBooker({ params, searchParams });
}
@@ -41,7 +41,7 @@ const _getTeamMetadataForBooking = (teamData: NonNullable<TeamData>, eventTypeId
};
};
async function _getOrgContext(params: Params) {
export async function getOrgContext(params: Params) {
const result = paramsSchema.safeParse({
slug: params?.slug,
type: params?.type,
@@ -74,7 +74,7 @@ const _getMultipleDurationValue = (
};
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const { currentOrgDomain, isValidOrgDomain, teamSlug, meetingSlug } = await _getOrgContext(await params);
const { currentOrgDomain, isValidOrgDomain, teamSlug, meetingSlug } = await getOrgContext(await params);
const teamData = await getCachedTeamData(teamSlug, currentOrgDomain);
if (!teamData) return {}; // should never happen
@@ -123,7 +123,7 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
};
const CachedTeamBooker = async ({ params, searchParams }: PageProps) => {
const { currentOrgDomain, isValidOrgDomain, teamSlug, meetingSlug } = await _getOrgContext(await params);
const { currentOrgDomain, isValidOrgDomain, teamSlug, meetingSlug } = await getOrgContext(await params);
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
// Handle org redirects
@@ -13,6 +13,7 @@ import { getTeamEventType } from "@calcom/features/eventtypes/lib/getTeamEventTy
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { NEXTJS_CACHE_TTL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { prisma } from "@calcom/prisma";
import type { SchedulingType } from "@calcom/prisma/enums";
@@ -158,3 +159,14 @@ export async function getCRMData(
crmRecordId,
};
}
export async function getTeamId(teamSlug: string, orgSlug: string | null): Promise<number | null> {
const teamRepo = new TeamRepository(prisma);
const team = await teamRepo.findFirstBySlugAndParentSlug({
slug: teamSlug,
parentSlug: orgSlug,
select: { id: true },
});
return team?.id ?? null;
}
@@ -45,11 +45,13 @@ const InstalledAppsWrapper = async ({ params }: PageProps) => {
createRouterCaller(appsRouter),
]);
const connectedCalendars = await calendarsCaller.connectedCalendars();
const installedCalendars = await appsCaller.integrations({
variant: "calendar",
onlyInstalled: true,
});
const [connectedCalendars, installedCalendars] = await Promise.all([
calendarsCaller.connectedCalendars(),
appsCaller.integrations({
variant: "calendar",
onlyInstalled: true,
}),
]);
return (
<InstalledApps
@@ -5,6 +5,7 @@ import { unstable_cache } from "next/cache";
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
import prisma from "@calcom/prisma";
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
import { TeamMembersView } from "~/teams/team-members-view";
@@ -36,8 +37,10 @@ const getCachedTeamRoles = unstable_cache(
const getCachedTeamAttributes = unstable_cache(
async (organizationId?: number) => {
if (!organizationId) return [];
const attributeRepo = new PrismaAttributeRepository(prisma);
try {
return await PrismaAttributeRepository.findAllByOrgIdWithOptions({ orgId: organizationId });
return await attributeRepo.findAllByOrgIdWithOptions({ orgId: organizationId });
} catch (error) {
return [];
}
@@ -4,6 +4,7 @@ import { unstable_cache } from "next/cache";
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
import prisma from "@calcom/prisma";
import { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router";
import { MembersView } from "~/members/members-view";
@@ -19,7 +20,9 @@ export const generateMetadata = async () =>
const getCachedAttributes = unstable_cache(
async (orgId: number) => {
return await PrismaAttributeRepository.findAllByOrgIdWithOptions({ orgId });
const attributeRepo = new PrismaAttributeRepository(prisma);
return await attributeRepo.findAllByOrgIdWithOptions({ orgId });
},
undefined,
{ revalidate: 3600, tags: ["viewer.attributes.list"] } // Cache for 1 hour
@@ -1,4 +1,3 @@
import { _generateMetadata } from "app/_utils";
import { notFound } from "next/navigation";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
+2 -1
View File
@@ -5,6 +5,7 @@ import { getLocale } from "@calcom/features/auth/lib/getLocale";
import type { AppImageProps, MeetingImageProps } from "@calcom/lib/OgImages";
import { constructAppImage, constructGenericImage, constructMeetingImage } from "@calcom/lib/OgImages";
import { IS_CALCOM, WEBAPP_URL, APP_NAME, SEO_IMG_OGIMG, CAL_URL } from "@calcom/lib/constants";
import { getCalcomUrl } from "@calcom/lib/getCalcomUrl";
import { buildCanonical } from "@calcom/lib/next-seo.config";
import { getTranslation } from "@calcom/lib/server/i18n";
import { truncateOnWord } from "@calcom/lib/text";
@@ -32,7 +33,7 @@ const _generateMetadataWithoutImage = async (
const description = getDescription(t);
const titleSuffix = `| ${APP_NAME}`;
const displayedTitle = title.includes(titleSuffix) || hideBranding ? title : `${title} ${titleSuffix}`;
const metadataBase = new URL(IS_CALCOM ? "https://cal.com" : WEBAPP_URL);
const metadataBase = new URL(IS_CALCOM ? getCalcomUrl() : WEBAPP_URL);
return {
title: title.length === 0 ? APP_NAME : displayedTitle,
+18 -3
View File
@@ -87,7 +87,10 @@ async function handler(req: NextRequest) {
return new Response(img.body, {
status: 200,
headers: { "Content-Type": "image/png", "cache-control": "max-age=0" },
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
},
});
} catch (error) {
if (error instanceof ZodError) {
@@ -116,7 +119,13 @@ async function handler(req: NextRequest) {
});
const img = new ImageResponse(<App name={name} description={description} slug={slug} />, ogConfig);
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
return new Response(img.body, {
status: 200,
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
},
});
} catch (error) {
if (error instanceof ZodError) {
return new Response(
@@ -144,7 +153,13 @@ async function handler(req: NextRequest) {
const img = new ImageResponse(<Generic title={title} description={description} />, ogConfig);
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
return new Response(img.body, {
status: 200,
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
},
});
} catch (error) {
if (error instanceof ZodError) {
return new Response(
+2 -1
View File
@@ -9,6 +9,7 @@ import Script from "next/script";
import "@calcom/embed-core/src/embed-iframe";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { IS_CALCOM, WEBAPP_URL } from "@calcom/lib/constants";
import { getCalcomUrl } from "@calcom/lib/getCalcomUrl";
import { buildCanonical } from "@calcom/lib/next-seo.config";
import { IconSprites } from "@calcom/ui/components/icon";
@@ -64,7 +65,7 @@ function PageWrapper(props: AppProps) {
// Set canonical to https://cal.com or self-hosted URL
canonical={
IS_CALCOM
? buildCanonical({ path, origin: "https://cal.com" }) // cal.com & .dev
? buildCanonical({ path, origin: getCalcomUrl() }) // cal.com & .dev
: buildCanonical({ path, origin: WEBAPP_URL }) // self-hosted
}
{...seoConfig.defaultNextSeo}
@@ -2,9 +2,11 @@ import { useMemo } from "react";
import { useFormContext } from "react-hook-form";
import type { UseFormGetValues, UseFormSetValue, Control, FormState } from "react-hook-form";
import type { TLocationOptions } from "@calcom/features/eventtypes/components/Locations";
import type { TEventTypeLocation } from "@calcom/features/eventtypes/components/Locations";
import Locations from "@calcom/features/eventtypes/components/Locations";
import type {
TLocationOptions,
TEventTypeLocation,
} from "@calcom/features/eventtypes/components/locations/Locations";
import Locations from "@calcom/features/eventtypes/components/locations/Locations";
import type { LocationFormValues } from "@calcom/features/eventtypes/lib/types";
import type { SingleValueLocationOption } from "@calcom/features/form/components/LocationSelect";
import { useLocale } from "@calcom/lib/hooks/useLocale";
+39 -33
View File
@@ -3,42 +3,48 @@
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN_CLIENT,
if (process.env.NODE_ENV === "production") {
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN_CLIENT,
sampleRate: parseFloat(process.env.SENTRY_SAMPLE_RATE ?? "1.0") || 1.0,
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
tracesSampleRate: parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE ?? "0.0") || 0.0,
sampleRate: parseFloat(process.env.SENTRY_SAMPLE_RATE ?? "1.0") || 1.0,
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
tracesSampleRate: parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE ?? "0.0") || 0.0,
// Define how likely Replay events are sampled.
// This sets the sample rate to be 10%. You may want this to be 100% while
// in development and sample at a lower rate in production
// replaysSessionSampleRate: parseFloat(process.env.SENTRY_REPLAYS_SESSION_SAMPLE_RATE ?? "0.0") || 0.0,
// Define how likely Replay events are sampled.
// This sets the sample rate to be 10%. You may want this to be 100% while
// in development and sample at a lower rate in production
// replaysSessionSampleRate: parseFloat(process.env.SENTRY_REPLAYS_SESSION_SAMPLE_RATE ?? "0.0") || 0.0,
// Define how likely Replay events are sampled when an error occurs.
// replaysOnErrorSampleRate: parseFloat(process.env.SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE ?? "0.0") || 0.0,
// Define how likely Replay events are sampled when an error occurs.
// replaysOnErrorSampleRate: parseFloat(process.env.SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE ?? "0.0") || 0.0,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: !!process.env.SENTRY_DEBUG,
beforeSend(event) {
if (
event.exception?.values?.some(
(e) =>
// Ignore fake error "UnhandledRejection: Non-Error promise rejection captured with value: Object Not Found Matching Id:3, MethodName:update, ParamCount:4"
// Raised GH issue: https://github.com/getsentry/sentry-javascript/issues/3440
e.value?.includes("Non-Error promise rejection captured with") ||
e.value?.includes("Object Not Found Matching Id")
)
) {
return null;
}
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: !!process.env.SENTRY_DEBUG,
beforeSend(event) {
if (
event.exception?.values?.some(
(e) =>
// Ignore fake error "UnhandledRejection: Non-Error promise rejection captured with value: Object Not Found Matching Id:3, MethodName:update, ParamCount:4"
// Raised GH issue: https://github.com/getsentry/sentry-javascript/issues/3440
e.value?.includes("Non-Error promise rejection captured with") ||
e.value?.includes("Object Not Found Matching Id")
)
) {
return null;
}
event.tags = {
...event.tags,
errorSource: "client",
};
return event;
},
});
event.tags = {
...event.tags,
errorSource: "client",
};
return event;
},
});
}
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
export function onRouterTransitionStart(url: string, navigationType: "push" | "replace" | "traverse") {
if (process.env.NODE_ENV === "production") {
Sentry.captureRouterTransitionStart(url, navigationType);
}
}
+16 -7
View File
@@ -1,13 +1,22 @@
import { type Instrumentation } from 'next'
import * as Sentry from "@sentry/nextjs";
export async function register() {
if (process.env.NEXT_PUBLIC_SENTRY_DSN && process.env.NEXT_RUNTIME === "nodejs") {
await import("./sentry.server.config");
}
if (process.env.NEXT_PUBLIC_SENTRY_DSN && process.env.NEXT_RUNTIME === "edge") {
await import("./sentry.edge.config");
if (process.env.NODE_ENV === "production") {
if (process.env.NEXT_PUBLIC_SENTRY_DSN && process.env.NEXT_RUNTIME === "nodejs") {
await import("./sentry.server.config");
}
if (process.env.NEXT_PUBLIC_SENTRY_DSN && process.env.NEXT_RUNTIME === "edge") {
await import("./sentry.edge.config");
}
}
}
export const onRequestError = Sentry.captureRequestError;
export const onRequestError: Instrumentation.onRequestError = ( err,
request,
context) => {
if (process.env.NODE_ENV === "production") {
Sentry.captureRequestError(err, request, context);
}
};
+2
View File
@@ -17,6 +17,7 @@ export const getEventTypesFromDB = async (id: number) => {
darkBrandColor: true,
email: true,
timeZone: true,
isPlatformManaged: true,
};
const eventType = await prisma.eventType.findUnique({
where: {
@@ -75,6 +76,7 @@ export const getEventTypesFromDB = async (id: number) => {
hideBranding: true,
},
},
createdByOAuthClientId: true,
},
},
workflows: {
@@ -1,34 +0,0 @@
import type { GetServerSidePropsContext } from "next";
import { getSession } from "next-auth/react";
import { UserPermissionRole } from "@calcom/prisma/enums";
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const session = await getSession(ctx);
if (!session || !session.user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
if (!isAdmin) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
return {
props: {
error: null,
migrated: null,
userId: session.user.id,
username: session.user.username,
},
};
}
@@ -1,29 +0,0 @@
import type { GetServerSidePropsContext } from "next";
import { getSession } from "next-auth/react";
import { UserPermissionRole } from "@calcom/prisma/enums";
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const session = await getSession(ctx);
if (!session || !session.user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
if (!isAdmin) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
return {
props: {},
};
}
@@ -1,29 +0,0 @@
import type { GetServerSidePropsContext } from "next";
import { getSession } from "next-auth/react";
import { UserPermissionRole } from "@calcom/prisma/enums";
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const session = await getSession(ctx);
if (!session || !session.user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
if (!isAdmin) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
return {
props: {},
};
}
@@ -1,29 +0,0 @@
import type { GetServerSidePropsContext } from "next";
import { getSession } from "next-auth/react";
import { UserPermissionRole } from "@calcom/prisma/enums";
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const session = await getSession(ctx);
if (!session || !session.user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
if (!isAdmin) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
return {
props: {},
};
}
-16
View File
@@ -60,22 +60,6 @@ const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
const reqWithEnrichedHeaders = enrichRequestWithHeaders({ req });
const requestHeaders = new Headers(reqWithEnrichedHeaders.headers);
if (!url.pathname.startsWith("/api")) {
//
// NOTE: When tRPC hits an error a 500 is returned, when this is received
// by the application the user is automatically redirected to /auth/login.
//
// - For this reason our matchers are sufficient for an app-wide maintenance page.
//
// Check whether the maintenance page should be shown
const isInMaintenanceMode = await safeGet<boolean>("isInMaintenanceMode");
// If is in maintenance mode, point the url pathname to the maintenance page
if (isInMaintenanceMode) {
reqWithEnrichedHeaders.nextUrl.pathname = `/maintenance`;
return NextResponse.rewrite(reqWithEnrichedHeaders.nextUrl);
}
}
const routingFormRewriteResponse = routingForms.handleRewrite(url);
if (routingFormRewriteResponse) {
return responseWithHeaders({ url, res: routingFormRewriteResponse, req: reqWithEnrichedHeaders });
@@ -156,7 +156,7 @@ export function AvailabilityList({ availabilities }: AvailabilityListProps) {
))}
</ul>
</div>
<div className="text-default mb-16 mt-4 hidden text-center text-sm md:block">
<div className="text-default mb-16 mt-4 block text-center text-sm">
{t("temporarily_out_of_office")}{" "}
<Link href="settings/my-account/out-of-office" className="underline">
{t("add_a_redirect")}
@@ -180,7 +180,7 @@ function BookingsContent({ status }: BookingsProps) {
columnHelper.accessor((row) => row.type === "data" && row.booking.user?.id, {
id: "userId",
header: t("member"),
enableColumnFilter: true,
enableColumnFilter: user?.isTeamAdminOrOwner ?? false,
enableSorting: false,
cell: () => null,
meta: {
@@ -228,16 +228,20 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
? { ...previousBooking, rescheduledBy: bookingInfo.user?.name }
: previousBooking;
const isPlatformBooking = eventType.users[0]?.isPlatformManaged || eventType.team?.createdByOAuthClientId;
return {
props: {
orgSlug: currentOrgDomain,
themeBasis: eventType.team ? eventType.team.slug : eventType.users[0]?.username,
hideBranding: await shouldHideBrandingForEvent({
eventTypeId: eventType.id,
team: eventType.team,
owner: eventType.users[0] ?? null,
organizationId: session?.user?.profile?.organizationId ?? session?.user?.org?.id ?? null,
}),
hideBranding: isPlatformBooking
? true
: await shouldHideBrandingForEvent({
eventTypeId: eventType.id,
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),
@@ -138,7 +138,8 @@ const OnboardingPage = (props: PageProps) => {
"dark:bg-brand dark:text-brand-contrast text-emphasis min-h-screen [--cal-brand:#111827] dark:[--cal-brand:#FFFFFF]",
"[--cal-brand-emphasis:#101010] dark:[--cal-brand-emphasis:#e1e1e1]",
"[--cal-brand-subtle:#9CA3AF]",
"[--cal-brand-text:#FFFFFF] dark:[--cal-brand-text:#000000]"
"[--cal-brand-text:#FFFFFF] dark:[--cal-brand-text:#000000]",
"[--cal-brand-accent:#FFFFFF] dark:[--cal-brand-accent:#000000]"
)}
data-testid="onboarding"
key={pathname}>
+490 -478
View File
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.6.0",
"version": "5.6.4",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -29,7 +29,7 @@
"yarn": "3.4.1"
},
"dependencies": {
"@boxyhq/saml-jackson": "1.42.0",
"@boxyhq/saml-jackson": "1.52.2",
"@calcom/app-store": "*",
"@calcom/app-store-cli": "*",
"@calcom/dayjs": "*",
@@ -77,7 +77,6 @@
"@tanstack/react-query": "^5.17.15",
"@team-plain/typescript-sdk": "^5.9.0",
"@types/turndown": "^5.0.1",
"@unkey/ratelimit": "^0.1.1",
"@upstash/redis": "^1.35.2",
"@vercel/edge-config": "^0.1.1",
"@vercel/edge-functions-ui": "^0.2.1",
@@ -1,6 +0,0 @@
import { wrapApiHandlerWithSentry } from "@sentry/nextjs";
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { highPerfRouter } from "@calcom/trpc/server/routers/viewer/highPerf/_router";
export default wrapApiHandlerWithSentry(createNextApiHandler(highPerfRouter), "/api/trpc/highPerf/[trpc]");
+29 -8
View File
@@ -479,7 +479,6 @@ test.describe("Bookings", () => {
const filterItemsConfig = [
{ key: "eventTypeId", name: "Event Type", testId: "add-filter-item-eventTypeId" },
{ key: "teamId", name: "Team", testId: "add-filter-item-teamId" },
{ key: "userId", name: "Member", testId: "add-filter-item-userId" },
{ key: "attendeeName", name: "Attendees Name", testId: "add-filter-item-attendeeName" },
{ key: "attendeeEmail", name: "Attendee Email", testId: "add-filter-item-attendeeEmail" },
{ key: "dateRange", name: "Date Range", testId: "add-filter-item-dateRange" },
@@ -488,8 +487,16 @@ test.describe("Bookings", () => {
const getFilterItemLocator = (page: Page, testId: string) => page.locator(`[data-testid="${testId}"]`);
test.beforeEach(async ({ page, users }) => {
const user = await users.create();
const setup = async ({
isAdmin,
page,
users,
}: {
isAdmin: boolean;
page: Page;
users: Fixtures["users"];
}) => {
const user = isAdmin ? await users.create(undefined, { hasTeam: true }) : await users.create();
await user.apiLogin();
const bookingsGetResponse = page.waitForResponse((response) =>
/\/api\/trpc\/bookings\/get.*/.test(response.url())
@@ -498,9 +505,10 @@ test.describe("Bookings", () => {
await bookingsGetResponse;
await page.locator('[data-testid="add-filter-button"]').click();
await expect(page.locator(searchInputSelector)).toBeVisible();
});
};
test("should show all filter items initially and after clearing search", async ({ page }) => {
test("should show all filter items initially and after clearing search", async ({ page, users }) => {
await setup({ isAdmin: false, page, users });
const searchInput = page.locator(searchInputSelector);
// Initial check: all defined filter items should be visible
@@ -524,7 +532,18 @@ test.describe("Bookings", () => {
}
});
test("search should be case-insensitive", async ({ page }) => {
test("should show admin-only filter", async ({ page, users }) => {
await setup({ isAdmin: true, page, users });
await expect(
getFilterItemLocator(page, "add-filter-item-userId"),
`Item "Member" should be visible initially`
).toBeVisible();
});
test("search should be case-insensitive", async ({ page, users }) => {
await setup({ isAdmin: true, page, users });
const searchInput = page.locator(searchInputSelector);
// Search for "member" (lowercase)
@@ -534,7 +553,8 @@ test.describe("Bookings", () => {
await expect(getFilterItemLocator(page, "add-filter-item-teamId")).toBeHidden();
});
test("should individually find each filter item by its full name", async ({ page }) => {
test("should individually find each filter item by its full name", async ({ page, users }) => {
await setup({ isAdmin: false, page, users });
const searchInput = page.locator(searchInputSelector);
for (const targetItem of filterItemsConfig) {
@@ -550,7 +570,8 @@ test.describe("Bookings", () => {
}
});
test("should show no items for a non-matching search term", async ({ page }) => {
test("should show no items for a non-matching search term", async ({ page, users }) => {
await setup({ isAdmin: false, page, users });
const searchInput = page.locator(searchInputSelector);
const nonExistentTerm = "NonExistentFilterXYZ123";
await searchInput.fill(nonExistentTerm);
@@ -6,6 +6,7 @@ import { test } from "./lib/fixtures";
import {
bookTimeSlot,
confirmReschedule,
confirmBooking,
doOnOrgDomain,
selectFirstAvailableTimeSlotNextMonth,
selectSecondAvailableTimeSlotNextMonth,
@@ -98,6 +99,58 @@ test("dynamic booking info prefilled by query params", async ({ page, users }) =
activeState = await listItemLocator.getAttribute("data-active");
expect(activeState).toEqual("false");
});
test("multiple duration selection updates event length correctly", async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
await test.step("update event title to include duration placeholder", async () => {
await page.goto("/event-types");
await page.waitForSelector('[data-testid="event-types"]');
await page.click(`text=Multiple duration`);
await page.waitForSelector('[data-testid="event-title"]');
await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toHaveAttribute(
"aria-current",
"page"
);
await page.getByTestId("vertical-tab-event_advanced_tab_title").click();
await page.fill('[name="eventName"]', "{Event duration} event btwn {Organiser} {Scheduler}");
await page.locator('[data-testid="update-eventtype"]').click();
await page.waitForResponse("/api/trpc/eventTypes/update?batch=1");
});
await page.goto(`/${user.username}/multiple-duration`);
await page.locator('[data-testid="multiple-choice-30mins"]').waitFor({ state: "visible" });
await test.step("verify default 30min duration is selected", async () => {
const duration30 = page.getByTestId("multiple-choice-30mins");
const activeState = await duration30.getAttribute("data-active");
expect(activeState).toEqual("true");
});
await test.step("book with 90min duration and verify title", async () => {
await page.getByTestId("multiple-choice-90mins").click();
const duration90 = page.getByTestId("multiple-choice-90mins");
const activeState = await duration90.getAttribute("data-active");
expect(activeState).toEqual("true");
await selectFirstAvailableTimeSlotNextMonth(page);
await page.fill('[name="name"]', "Test User");
await page.fill('[name="email"]', "test@example.com");
await confirmBooking(page);
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
const bookingTitle = page.locator('[data-testid="booking-title"]');
await expect(bookingTitle).toBeVisible();
const titleText = await bookingTitle.textContent();
expect(titleText).toContain("90");
});
});
// eslint-disable-next-line playwright/no-skipped-test
test.skip("it contains the right event details", async ({ page }) => {
const response = await page.goto(`http://acme.cal.local:3000/owner1+member1`);
+7 -2
View File
@@ -1,6 +1,11 @@
module.exports = {
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
if (process.env.NODE_ENV === "production") {
config.plugins.autoprefixer = {};
}
module.exports = config;
@@ -3472,5 +3472,6 @@
"webhook_metadata": "Metadata",
"stats": "Stats",
"booking_status": "Booking status",
"phone": "Phone",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
-12
View File
@@ -41,18 +41,6 @@
},
"pages/api/trpc/workflows/[trpc].ts": {
"maxDuration": 800
},
"pages/api/trpc/highPerf/[trpc].ts": {
"memory": 3008
},
"pages/api/trpc/appRoutingForms/[trpc].ts": {
"memory": 2048
},
"pages/router/embed.tsx": {
"memory": 2048
},
"pages/api/book/event.ts": {
"memory": 2048
}
}
}
+1
View File
@@ -129,6 +129,7 @@ Below is a list of props that can be passed to the booker atom.
| 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
+1 -1
View File
@@ -76,7 +76,7 @@ Sometimes, yarn install might fail during deployment on Vercel, in which case, y
<Step title="Override the build command">
Override to:
```
cd ../.. && yarn build --include-dependencies --no-deps
cd ../.. && yarn build --no-deps
```
</Step>
<Step title="Hit Deploy">
+2 -2
View File
@@ -33,8 +33,8 @@ RUN set -eux; \
apt-get clean && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives && \
yarn config set httpTimeout 1200000 && \
npx turbo prune --scope=@calcom/web --docker && \
npx turbo prune --scope=@calcom/api --docker && \
npx turbo prune --filter=@calcom/web --docker && \
npx turbo prune --filter=@calcom/api --docker && \
yarn install && \
yarn turbo run build --filter=@calcom/api
+13 -14
View File
@@ -28,23 +28,23 @@
"edit-app-template": "yarn app-store edit-template",
"delete-app-template": "yarn app-store delete-template",
"build": "turbo run build --filter=@calcom/web...",
"build:ai": "turbo run build --scope=\"@calcom/ai\"",
"build:ai": "turbo run build --filter=\"@calcom/ai\"",
"clean": "find . -name node_modules -o -name .next -o -name .turbo -o -name dist -type d -prune | xargs rm -rf",
"db-deploy": "turbo run db-deploy",
"db-seed": "turbo run db-seed",
"db-studio": "yarn prisma studio",
"deploy": "turbo run deploy",
"dev:all": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/website\" --scope=\"@calcom/console\"",
"dev:ai": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/api-proxy\" --scope=\"@calcom/api\" --scope=\"@calcom/ai\"",
"dev:api": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/api-proxy\" --scope=\"@calcom/api\"",
"dev:api:console": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/api-proxy\" --scope=\"@calcom/api\" --scope=\"@calcom/console\"",
"dev:console": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/console\"",
"dev:swagger": "turbo run dev --scope=\"@calcom/api-proxy\" --scope=\"@calcom/api\" --scope=\"@calcom/swagger\"",
"dev:website": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/website\"",
"dev": "turbo run dev --scope=\"@calcom/web\"",
"build-storybook": "turbo run build --scope=\"@calcom/storybook\"",
"dev:all": "turbo run dev --filter=\"@calcom/web\" --filter=\"@calcom/website\" --filter=\"@calcom/console\"",
"dev:ai": "turbo run dev --filter=\"@calcom/web\" --filter=\"@calcom/api-proxy\" --filter=\"@calcom/api\" --filter=\"@calcom/ai\"",
"dev:api": "turbo run dev --filter=\"@calcom/web\" --filter=\"@calcom/api-proxy\" --filter=\"@calcom/api\"",
"dev:api:console": "turbo run dev --filter=\"@calcom/web\" --filter=\"@calcom/api-proxy\" --filter=\"@calcom/api\" --filter=\"@calcom/console\"",
"dev:console": "turbo run dev --filter=\"@calcom/web\" --filter=\"@calcom/console\"",
"dev:swagger": "turbo run dev --filter=\"@calcom/api-proxy\" --filter=\"@calcom/api\" --filter=\"@calcom/swagger\"",
"dev:website": "turbo run dev --filter=\"@calcom/web\" --filter=\"@calcom/website\"",
"dev": "turbo run dev --filter=\"@calcom/web\"",
"build-storybook": "turbo run build --filter=\"@calcom/storybook\"",
"dx": "turbo run dx",
"i-dev": "infisical run -- turbo run dev --scope=\"@calcom/web\"",
"i-dev": "infisical run -- turbo run dev --filter=\"@calcom/web\"",
"i-dx": "infisical run -- turbo run dx",
"i-gen-web-example-env": "infisical secrets generate-example-env --tags=web > .env.example",
"i-gen-app-store-example-env": "infisical secrets generate-example-env --tags=appstore > .env.appStore.example",
@@ -61,7 +61,7 @@
"pre-commit": "lint-staged",
"predev": "echo 'Checking env files'",
"prisma": "yarn workspace @calcom/prisma prisma",
"start": "turbo run start --scope=\"@calcom/web\"",
"start": "turbo run start --filter=\"@calcom/web\"",
"tdd": "vitest watch",
"e2e": "NEXT_PUBLIC_IS_E2E=1 yarn playwright test --project=@calcom/web",
"e2e:app-store": "QUICK=true yarn playwright test --project=@calcom/app-store",
@@ -118,13 +118,12 @@
"@daily-co/daily-js": "^0.76.0",
"@evyweb/ioctopus": "^1.2.0",
"@next/third-parties": "^14.2.5",
"@unkey/ratelimit": "^2.1.2",
"@vercel/functions": "^1.4.0",
"city-timezones": "^1.2.1",
"date-fns-tz": "^3.2.0",
"eslint": "^8.34.0",
"p-limit": "^6.2.0",
"turbo": "^1.10.1"
"turbo": "^2.5.5"
},
"resolutions": {
"types-ramda": "0.29.4",
@@ -10,6 +10,7 @@ import type { FreeBusyArgs } from "@calcom/features/calendar-cache/calendar-cach
import { getTimeMax, getTimeMin } from "@calcom/features/calendar-cache/lib/datesForCache";
import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser";
import { uniqueBy } from "@calcom/lib/array";
import { ORGANIZER_EMAIL_EXEMPT_DOMAINS } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
@@ -85,10 +86,16 @@ export default class GoogleCalendarService implements Calendar {
const selectedHostDestinationCalendar = event.destinationCalendar?.find(
(cal) => cal.credentialId === this.credential.id
);
const isOrganizerExempt = ORGANIZER_EMAIL_EXEMPT_DOMAINS?.split(",")
.filter((domain) => domain.trim() !== "")
.some((domain) => event.organizer.email.toLowerCase().endsWith(domain.toLowerCase()));
const eventAttendees = event.attendees.map(({ id: _id, ...rest }) => ({
...rest,
responseStatus: "accepted",
}));
const attendees: calendar_v3.Schema$EventAttendee[] = [
{
...event.organizer,
@@ -100,7 +107,7 @@ export default class GoogleCalendarService implements Calendar {
// We use || instead of ?? here to handle empty strings
email: hostExternalCalendarId || selectedHostDestinationCalendar?.externalId || event.organizer.email,
},
...eventAttendees,
...(event.hideOrganizerEmail && !isOrganizerExempt ? [] : eventAttendees),
];
if (event.team?.members) {
@@ -661,22 +661,30 @@ export default class Office365CalendarService implements Calendar {
);
};
private handleErrorJsonOffice365Calendar = <Type>(response: Response): Promise<Type | string> => {
private async handleErrorJsonOffice365Calendar<Type>(response: Response): Promise<Type | string> {
if (response.headers.get("content-encoding") === "gzip") {
return response.text();
}
if (response.status === 204) {
return new Promise((resolve) => resolve({} as Type));
return {} as Type;
}
if (!response.ok && response.status < 200 && response.status >= 300) {
response.json().then(console.log);
throw Error(response.statusText);
if (!response.ok) {
let errorBody: string | object;
try {
errorBody = await response.json();
} catch (e) {
errorBody = await response.text();
}
this.log.error(
`handleErrorJsonOffice365Calendar: Office365 API request failed with status ${response.status}`,
errorBody
);
}
return response.json();
};
}
async getMainTimeZone(): Promise<string> {
try {
@@ -1,363 +0,0 @@
import { describe, expect, it, afterEach, vi } from "vitest";
import { jsonLogicToPrisma } from "../jsonLogicToPrisma";
afterEach(() => {
vi.resetAllMocks();
});
describe("jsonLogicToPrisma(Reporting)", () => {
describe("Text Operand", () => {
it("should support 'Equals' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: { and: [{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "A"] }] },
});
expect(prismaWhere).toEqual({
AND: [
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: "A",
},
},
],
});
});
it("should support 'Not Equals' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: { and: [{ "!=": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "abc"] }] },
});
expect(prismaWhere).toEqual({
AND: [
{
NOT: {
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: "abc",
},
},
},
],
});
});
it("should support 'Contains' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: { and: [{ in: ["A", { var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }] }] },
});
expect(prismaWhere).toEqual({
AND: [
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
string_contains: "A",
},
},
],
});
});
it("should support 'Not Contains' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: { and: [{ "!": { in: ["a", { var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }] } }] },
});
expect(prismaWhere).toEqual({
AND: [
{
NOT: {
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
string_contains: "a",
},
},
},
],
});
});
describe("Number Type", () => {
it("should support 'greater than' operator", () => {
let prismaWhere = jsonLogicToPrisma({
logic: {
and: [
{
">": [
{
var: "a0d113a8-8e40-49b7-87b1-7f4ab57d226f",
},
// Giving a string here to test that it is converted to a number
"100",
],
},
],
},
});
expect(prismaWhere).toEqual({
AND: [{ response: { path: ["a0d113a8-8e40-49b7-87b1-7f4ab57d226f", "value"], gt: 100 } }],
});
prismaWhere = jsonLogicToPrisma({
logic: {
and: [
{
">": [
{
var: "a0d113a8-8e40-49b7-87b1-7f4ab57d226f",
},
// A number would also work
100,
],
},
],
},
});
expect(prismaWhere).toEqual({
AND: [{ response: { path: ["a0d113a8-8e40-49b7-87b1-7f4ab57d226f", "value"], gt: 100 } }],
});
});
it("should support 'greater than or equal to' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: {
and: [
{
">=": [
{
var: "a0d113a8-8e40-49b7-87b1-7f4ab57d226f",
},
// Giving a string here to test that it is converted to a number
"100",
],
},
],
},
});
expect(prismaWhere).toEqual({
AND: [{ response: { path: ["a0d113a8-8e40-49b7-87b1-7f4ab57d226f", "value"], gte: 100 } }],
});
});
it("should support 'less than' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: {
and: [
{
"<": [
{
var: "a0d113a8-8e40-49b7-87b1-7f4ab57d226f",
},
// Giving a string here to test that it is converted to a number
"100",
],
},
],
},
});
expect(prismaWhere).toEqual({
AND: [{ response: { path: ["a0d113a8-8e40-49b7-87b1-7f4ab57d226f", "value"], lt: 100 } }],
});
});
it("should support 'less than or equal to' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: {
and: [
{
"<=": [
{
var: "a0d113a8-8e40-49b7-87b1-7f4ab57d226f",
},
// Giving a string here to test that it is converted to a number
"100",
],
},
],
},
});
expect(prismaWhere).toEqual({
AND: [{ response: { path: ["a0d113a8-8e40-49b7-87b1-7f4ab57d226f", "value"], lte: 100 } }],
});
});
it("'Equals' operator should query with string as well as number", () => {
const prismaWhere = jsonLogicToPrisma({
logic: { and: [{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "1"] }] },
});
expect(prismaWhere).toEqual({
AND: [
{
OR: [
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: "1",
},
},
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: 1,
},
},
],
},
],
});
});
});
});
describe("MultiSelect", () => {
it("should support 'Equals' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: {
and: [
{ all: [{ var: "267c7817-81a5-4bef-9d5b-d0faa4cd0d71" }, { in: [{ var: "" }, ["C", "D"]] }] },
],
},
});
expect(prismaWhere).toEqual({
AND: [
{
response: { path: ["267c7817-81a5-4bef-9d5b-d0faa4cd0d71", "value"], array_contains: ["C", "D"] },
},
],
});
});
it("should support 'Any In' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: { and: [{ in: [{ var: "267c7817-81a5-4bef-9d5b-d0faa4cd0d71" }, ["C", "D"]] }] },
});
expect(prismaWhere).toEqual({
AND: [
{
OR: [
{
response: {
path: ["267c7817-81a5-4bef-9d5b-d0faa4cd0d71", "value"],
equals: "C",
},
},
{
response: {
path: ["267c7817-81a5-4bef-9d5b-d0faa4cd0d71", "value"],
equals: "D",
},
},
],
},
],
});
});
it("should support 'Not In' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: { and: [{ "!": { in: [{ var: "267c7817-81a5-4bef-9d5b-d0faa4cd0d71" }, ["C", "D"]] } }] },
});
expect(prismaWhere).toEqual({
AND: [
{
NOT: {
OR: [
{
response: {
path: ["267c7817-81a5-4bef-9d5b-d0faa4cd0d71", "value"],
equals: "C",
},
},
{
response: {
path: ["267c7817-81a5-4bef-9d5b-d0faa4cd0d71", "value"],
equals: "D",
},
},
],
},
},
],
});
});
it("should support where All Match ['Equals', 'Equals'] operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: {
and: [
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "a"] },
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "b"] },
],
},
});
expect(prismaWhere).toEqual({
AND: [
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: "a",
},
},
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: "b",
},
},
],
});
});
it("should support where Any Match ['Equals', 'Equals'] operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: {
or: [
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "a"] },
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "b"] },
],
},
});
expect(prismaWhere).toEqual({
OR: [
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: "a",
},
},
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: "b",
},
},
],
});
});
it("should support where None Match ['Equals', 'Equals'] operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: {
"!": {
or: [
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "abc"] },
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "abcd"] },
],
},
},
});
expect(prismaWhere).toEqual({
NOT: {
OR: [
{ response: { path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"], equals: "abc" } },
{ response: { path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"], equals: "abcd" } },
],
},
});
});
});
});
@@ -1,276 +0,0 @@
// It can have many shapes, so just use any and we rely on unit tests to test all those scenarios.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type LogicData = Partial<Record<keyof typeof OPERATOR_MAP, any>>;
type NegatedLogicData = {
"!": LogicData;
};
export type JsonLogicQuery = {
logic: {
and?: LogicData[];
or?: LogicData[];
"!"?: {
and?: LogicData[];
or?: LogicData[];
};
} | null;
};
type PrismaWhere = {
AND?: ReturnType<typeof convertQueriesToPrismaWhereClause>[];
OR?: ReturnType<typeof convertQueriesToPrismaWhereClause>[];
NOT?: PrismaWhere;
};
const OPERATOR_MAP = {
"==": {
operator: "equals",
secondaryOperand: null,
},
in: {
operator: "string_contains",
secondaryOperand: null,
},
"!=": {
operator: "NOT.equals",
secondaryOperand: null,
},
"!": {
operator: "equals",
secondaryOperand: "",
},
"!!": {
operator: "NOT.equals",
secondaryOperand: "",
},
">": {
operator: "gt",
secondaryOperand: null,
},
">=": {
operator: "gte",
secondaryOperand: null,
},
"<": {
operator: "lt",
secondaryOperand: null,
},
"<=": {
operator: "lte",
secondaryOperand: null,
},
all: {
operator: "array_contains",
secondaryOperand: null,
},
};
/**
* Operators supported on array of basic queries
*/
const GROUP_OPERATOR_MAP = {
and: "AND",
or: "OR",
"!": "NOT",
} as const;
const NumberOperators = [">", ">=", "<", "<="];
const negatePrismaWhereClauseIfNeeded = (data: any, isNegation: boolean) => {
if (!isNegation) {
return data;
}
return {
NOT: {
...data,
},
};
};
const convertSingleQueryToPrismaWhereClause = (
operatorName: keyof typeof OPERATOR_MAP,
logicData: LogicData,
isNegation: boolean
) => {
const mappedOperator = OPERATOR_MAP[operatorName].operator;
const staticSecondaryOperand = OPERATOR_MAP[operatorName].secondaryOperand;
isNegation = isNegation || mappedOperator.startsWith("NOT.");
const prismaOperator = mappedOperator.replace("NOT.", "");
const operands =
logicData[operatorName] instanceof Array ? logicData[operatorName] : [logicData[operatorName]];
if (operatorName === "in" && operands[0]?.var && Array.isArray(operands[1])) {
// Case A: Item "in" array
// operands[0]: { var: ... }
// operands[1]: items to test against
// Convert 'in' operator to union of OR clauses
return negatePrismaWhereClauseIfNeeded(
{
OR: (operands[1] ?? []).map((value) => ({
response: {
path: [operands[0].var, "value"],
[`${OPERATOR_MAP["=="].operator}`]: value,
},
})),
},
isNegation
);
}
if (operatorName === "in" && typeof operands[0] === "string" && operands[1]?.var) {
// Case B: String "in" string
// operands[0]: string to test against
// operands[1]: { var: ... }
return negatePrismaWhereClauseIfNeeded(
{
response: {
path: [operands[1].var, "value"],
[`${prismaOperator}`]: operands[0],
},
},
isNegation
);
}
const mainOperand = operands[0].var;
let secondaryOperand;
if (operatorName === "all") {
secondaryOperand = operands[1].in[1];
} else {
secondaryOperand = staticSecondaryOperand || operands[1] || "";
}
const isNumberOperator = NumberOperators.includes(operatorName);
const secondaryOperandAsNumber = typeof secondaryOperand === "string" ? Number(secondaryOperand) : null;
if (secondaryOperandAsNumber && isNumberOperator) {
// We know that it's number operator so Prisma should query number
// Note that if we get string values in DB(e.g. '100'), those values can't be filtered with number operators.
return negatePrismaWhereClauseIfNeeded(
{
response: {
path: [mainOperand, "value"],
[`${prismaOperator}`]: secondaryOperandAsNumber,
},
},
isNegation
);
}
if (secondaryOperandAsNumber && !isNumberOperator) {
// We know that it's not number operator but the input field might have been a number and thus stored value in DB as number.
// Also, even for input type=number we might accidentally get string value(e.g. '100'). So, let reporting do it's best job with both number and string.
return negatePrismaWhereClauseIfNeeded(
{
OR: [
{
response: {
path: [mainOperand, "value"],
// Query as string e.g. equals '100'
[`${prismaOperator}`]: secondaryOperand,
},
},
{
response: {
path: [mainOperand, "value"],
// Query as number e.g. equals 100
[`${prismaOperator}`]: secondaryOperandAsNumber,
},
},
],
},
isNegation
);
}
return negatePrismaWhereClauseIfNeeded(
{
response: {
path: [mainOperand, "value"],
[`${prismaOperator}`]: secondaryOperand,
},
},
isNegation
);
};
const isNegation = (logicData: LogicData | NegatedLogicData) => {
if ("!" in logicData) {
const negatedLogicData = logicData["!"];
for (const [operatorName] of Object.entries(OPERATOR_MAP)) {
if (negatedLogicData[operatorName]) {
return true;
}
}
}
return false;
};
const convertQueriesToPrismaWhereClause = (logicData: LogicData) => {
const _isNegation = isNegation(logicData);
if (_isNegation) {
logicData = logicData["!"];
}
for (const [key] of Object.entries(OPERATOR_MAP)) {
const operatorName = key as keyof typeof OPERATOR_MAP;
if (logicData[operatorName]) {
return convertSingleQueryToPrismaWhereClause(operatorName, logicData, _isNegation);
}
}
};
export const jsonLogicToPrisma = (query: JsonLogicQuery) => {
try {
let logic = query.logic;
if (!logic) {
return {};
}
let prismaWhere: PrismaWhere = {};
let negateLogic = false;
// Case: Negation of "Any of these"
// Example: {"logic":{"!":{"or":[{"==":[{"var":"505d3c3c-aa71-4220-93a9-6fd1e1087939"},"1"]},{"==":[{"var":"505d3c3c-aa71-4220-93a9-6fd1e1087939"},"1"]}]}}}
if (logic["!"]) {
logic = logic["!"];
negateLogic = true;
}
// Case: All of these
if (logic.and) {
const where: PrismaWhere["AND"] = (prismaWhere[GROUP_OPERATOR_MAP["and"]] = []);
logic.and.forEach((and) => {
const res = convertQueriesToPrismaWhereClause(and);
if (!res) {
return;
}
where.push(res);
});
}
// Case: Any of these
else if (logic.or) {
const where: PrismaWhere["OR"] = (prismaWhere[GROUP_OPERATOR_MAP["or"]] = []);
logic.or.forEach((or) => {
const res = convertQueriesToPrismaWhereClause(or);
if (!res) {
return;
}
where.push(res);
});
}
if (negateLogic) {
prismaWhere = { NOT: { ...prismaWhere } };
}
return prismaWhere;
} catch (e) {
console.log("Error converting to prisma `where`", JSON.stringify(query), "Error is ", e);
return {};
}
};
@@ -84,7 +84,7 @@ export function getQueryBuilderConfigForFormFields(form: Pick<RoutingForm, "fiel
delete initialConfigCopy.operators.is_empty;
delete initialConfigCopy.operators.is_not_empty;
// Between and Not between aren't directly supported by prisma. So, we need to update jsonLogicToPrisma to generate gte and lte query for between. It can be implemented later.
// These operators can be implemented later if needed.
delete initialConfigCopy.operators.between;
delete initialConfigCopy.operators.not_between;
@@ -9,7 +9,6 @@ import { ZFormQueryInputSchema } from "./formQuery.schema";
import { ZGetAttributesForTeamInputSchema } from "./getAttributesForTeam.schema";
import { ZGetIncompleteBookingSettingsInputSchema } from "./getIncompleteBookingSettings.schema";
import { forms } from "./procedures/forms";
import { ZReportInputSchema } from "./report.schema";
import { ZSaveIncompleteBookingSettingsInputSchema } from "./saveIncompleteBookingSettings.schema";
// eslint-disable-next-line @typescript-eslint/ban-types
@@ -76,11 +75,6 @@ const appRoutingForms = router({
return handler({ ctx, input });
}),
report: authedProcedure.input(ZReportInputSchema).query(async ({ ctx, input }) => {
const handler = await getHandler("report", () => import("./report.handler"));
return handler({ ctx, input });
}),
getAttributesForTeam: authedProcedure
.input(ZGetAttributesForTeamInputSchema)
.query(async ({ ctx, input }) => {
@@ -1,158 +0,0 @@
import { v4 as uuidv4 } from "uuid";
import { describe, it, expect } from "vitest";
import { buildResponsesForReporting } from "./report.handler";
describe("buildResponsesForReporting", () => {
it("for fields with options, it should return the labels of the options", () => {
const field1Id = uuidv4();
const field2Id = uuidv4();
const responsesFromDb = [
{
[field1Id]: { value: "value1" },
[field2Id]: { value: ["option1", "option2"] },
},
];
const fields = [
{ id: field1Id, label: "Field 1" },
{
id: field2Id,
label: "Field 2",
options: [
{ id: "option1", label: "Option 1" },
{ id: "option2", label: "Option 2" },
],
},
];
const expectedResponses = [["value1", "Option 1, Option 2"]];
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
expect(responses).toEqual(expectedResponses);
expect(headers).toEqual(["Field 1", "Field 2"]);
});
it("for fields with options having null id, it should return what DB provided(as that would be labels only).", () => {
const field1Id = uuidv4();
const field2Id = uuidv4();
const responsesFromDb = [
{
[field1Id]: { value: "value1" },
[field2Id]: { value: ["Option 1", "Option 2"] },
},
];
const fields = [
{ id: field1Id, label: "Field 1" },
{
id: field2Id,
label: "Field 2",
options: [
{ id: null, label: "Option 1" },
{ id: null, label: "Option 2" },
],
},
];
const expectedResponses = [["value1", "Option 1, Option 2"]];
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
expect(responses).toEqual(expectedResponses);
expect(headers).toEqual(["Field 1", "Field 2"]);
});
it("for fields with no options but having array value, it should return the value as is", () => {
const field2Id = uuidv4();
const responsesFromDb = [
{
[field2Id]: { value: ["Option 1", "Option 2"] },
},
];
const fields = [
{
id: field2Id,
label: "Field 2",
},
];
const expectedResponses = [["Option 1, Option 2"]];
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
expect(responses).toEqual(expectedResponses);
expect(headers).toEqual(["Field 2"]);
});
it("should correctly handle numeric responses converting them to strings", () => {
const field1Id = uuidv4();
const field2Id = uuidv4();
const responsesFromDb = [
{
[field1Id]: { value: 1 },
[field2Id]: { value: [1, 2] },
},
];
const fields = [
{ id: field1Id, label: "Field 1" },
{
id: field2Id,
label: "Field 2",
options: [
{ id: null, label: "Option 1" },
{ id: null, label: "Option 2" },
],
},
];
const expectedResponses = [["1", "1, 2"]];
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
expect(responses).toEqual(expectedResponses);
expect(headers).toEqual(["Field 1", "Field 2"]);
});
it("should handle empty responses", () => {
const field1Id = uuidv4();
const responsesFromDb = [{}];
const fields = [{ id: field1Id, label: "Field 1" }];
const expectedResponses = [[""]];
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
expect(responses).toEqual(expectedResponses);
expect(headers).toEqual(["Field 1"]);
});
it("should show correct header for deleted fields", () => {
const field1Id = uuidv4();
const field2Id = uuidv4();
const responsesFromDb = [
{
[field1Id]: { value: "value1" },
[field2Id]: { value: ["Option 1", "Option 2"] },
},
];
const fields = [
{ id: field1Id, label: "Field 1" },
{
id: field2Id,
label: "Field 2",
deleted: true,
options: [
{ id: "option1", label: "Option 1" },
{ id: "option2", label: "Option 2" },
],
},
];
const expectedResponses = [["value1", "Option 1, Option 2"]];
const expectedHeaders = ["Field 1", "Field 2(Deleted)"];
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
expect(responses).toEqual(expectedResponses);
expect(headers).toEqual(expectedHeaders);
});
});
@@ -1,182 +0,0 @@
import type { z } from "zod";
import logger from "@calcom/lib/logger";
import type { PrismaClient } from "@calcom/prisma";
import type { App_RoutingForms_FormResponse } from "@calcom/prisma/client";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
import { jsonLogicToPrisma } from "../jsonLogicToPrisma";
import { getSerializableForm } from "../lib/getSerializableForm";
import { ensureStringOrStringArray, getLabelsFromOptionIds } from "../lib/reportingUtils";
import type { FormResponse } from "../types/types";
import type { zodFieldView } from "../zod";
import type { TReportInputSchema } from "./report.schema";
interface ReportHandlerOptions {
ctx: {
prisma: PrismaClient;
user: NonNullable<TrpcSessionUser>;
};
input: TReportInputSchema;
}
const makeFormatDate = (locale: string, timeZone: string) => {
const formatDate = (date: Date): string => {
return new Intl.DateTimeFormat(locale, {
timeZone,
dateStyle: "medium",
timeStyle: "short",
}).format(date);
};
return formatDate;
};
const getRows = async ({ ctx: { prisma }, input }: ReportHandlerOptions) => {
// Can be any prisma `where` clause
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const prismaWhere: Record<string, any> = input.jsonLogicQuery
? jsonLogicToPrisma(input.jsonLogicQuery)
: {};
const skip = input.cursor ?? 0;
const take = input.limit ? input.limit + 1 : 50;
logger.debug(
`Built Prisma where ${JSON.stringify(prismaWhere)} from jsonLogicQuery ${JSON.stringify(
input.jsonLogicQuery
)}`
);
const rows = await prisma.app_RoutingForms_FormResponse.findMany({
where: {
formId: input.formId,
...prismaWhere,
},
include: {
routedToBooking: {
select: {
createdAt: true,
user: {
select: { id: true, name: true, email: true },
},
assignmentReason: {
orderBy: {
createdAt: "desc",
},
take: 1,
},
},
},
},
orderBy: {
createdAt: "desc",
},
take,
skip,
});
return { skip, take, rows };
};
export const reportHandler = async (options: ReportHandlerOptions) => {
const {
ctx: { prisma },
input,
} = options;
const form = await prisma.app_RoutingForms_Form.findUnique({
where: {
id: input.formId,
},
});
if (!form) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Form not found",
});
}
// TODO: Second argument is required to return deleted operators.
const serializedForm = await getSerializableForm({ form, withDeletedFields: true });
const { rows, skip, take } = await getRows(options);
const fields = serializedForm?.fields || [];
const { responses, headers } = buildResponsesForReporting({
responsesFromDb: rows.map((r) => r.response),
fields,
});
const areThereNoResultsOrLessThanAskedFor = !rows.length || rows.length < take;
return presenter({
rows,
options,
headers,
responses,
nextCursor: areThereNoResultsOrLessThanAskedFor ? null : skip + rows.length,
});
};
/**
* This is a temporary solution to make the report work. It should be incorporated into the report data itself.
* Right now we cannot filter by Routed To and Booked At because they are not part of the response data.
*/
function presenter(args: {
rows: Awaited<ReturnType<typeof getRows>>["rows"];
options: ReportHandlerOptions;
headers: string[];
responses: string[][];
nextCursor: number | null;
}) {
const { headers, responses, nextCursor, options, rows } = args;
const { ctx } = options;
const formatDate = makeFormatDate(ctx.user.locale, ctx.user.timeZone);
return {
nextCursor,
headers: [...headers, "Routed To", "Assignment Reason", "Booked At", "Submitted At"],
responses: responses.map((r, i) => {
const currentRow = rows[i];
return [
...r,
currentRow.routedToBooking?.user?.email || "",
currentRow.routedToBooking?.assignmentReason.length
? currentRow.routedToBooking.assignmentReason[0].reasonString
: "",
currentRow.routedToBooking?.createdAt ? formatDate(currentRow.routedToBooking.createdAt) : "",
formatDate(currentRow.createdAt),
];
}),
};
}
export default reportHandler;
export function buildResponsesForReporting({
responsesFromDb,
fields,
}: {
responsesFromDb: App_RoutingForms_FormResponse["response"][];
fields: Pick<z.infer<typeof zodFieldView>, "id" | "options" | "label" | "deleted">[];
}) {
const headers = fields.map((f) => f.label + (f.deleted ? "(Deleted)" : ""));
const responses: string[][] = [];
responsesFromDb.forEach((r) => {
const rowResponses: string[] = [];
responses.push(rowResponses);
fields.forEach((field) => {
if (!r) {
return;
}
const response = r as FormResponse;
const value = response[field.id]?.value || "";
if (field.options) {
const optionIds = ensureStringOrStringArray(value);
const labels = getLabelsFromOptionIds({ options: field.options, optionIds });
rowResponses.push(labels.join(", "));
} else {
const arrayOfValues = value instanceof Array ? value : [value];
const transformedValue = arrayOfValues.join(", ");
rowResponses.push(transformedValue);
}
});
});
return { responses, headers };
}
@@ -1,12 +0,0 @@
import z from "zod";
export const ZReportInputSchema = z.object({
limit: z.number().default(50),
formId: z.string(),
jsonLogicQuery: z.object({
logic: z.union([z.record(z.any()), z.null()]),
}),
cursor: z.number().nullish(), // <-- "cursor" needs to exist when using useInfiniteQuery, but can be any type
});
export type TReportInputSchema = z.infer<typeof ZReportInputSchema>;
+19 -17
View File
@@ -404,23 +404,25 @@ const BookerComponent = ({
src={orgBannerUrl}
/>
)}
<EventMeta
selectedTimeslot={selectedTimeslot}
classNames={{
eventMetaContainer: customClassNames?.eventMetaCustomClassNames?.eventMetaContainer,
eventMetaTitle: customClassNames?.eventMetaCustomClassNames?.eventMetaTitle,
eventMetaTimezoneSelect:
customClassNames?.eventMetaCustomClassNames?.eventMetaTimezoneSelect,
}}
event={event.data}
isPending={event.isPending}
isPlatform={isPlatform}
isPrivateLink={!!hashedLink}
locale={userLocale}
timeZones={timeZones}
roundRobinHideOrgAndTeam={roundRobinHideOrgAndTeam}>
{eventMetaChildren}
</EventMeta>
{!hideEventTypeDetails && (
<EventMeta
selectedTimeslot={selectedTimeslot}
classNames={{
eventMetaContainer: customClassNames?.eventMetaCustomClassNames?.eventMetaContainer,
eventMetaTitle: customClassNames?.eventMetaCustomClassNames?.eventMetaTitle,
eventMetaTimezoneSelect:
customClassNames?.eventMetaCustomClassNames?.eventMetaTimezoneSelect,
}}
event={event.data}
isPending={event.isPending}
isPlatform={isPlatform}
isPrivateLink={!!hashedLink}
locale={userLocale}
timeZones={timeZones}
roundRobinHideOrgAndTeam={roundRobinHideOrgAndTeam}>
{eventMetaChildren}
</EventMeta>
)}
{layout !== BookerLayouts.MONTH_VIEW &&
!(layout === "mobile" && bookerState === "booking") && (
<div className="mt-auto px-5 py-3">
@@ -55,7 +55,7 @@ export const EventMeta = ({
timeZones,
children,
selectedTimeslot,
roundRobinHideOrgAndTeam
roundRobinHideOrgAndTeam,
}: {
event?: Pick<
BookerEvent,
@@ -4,7 +4,7 @@ import { useEffect, useRef } from "react";
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { useShouldShowArrows } from "@calcom/features/apps/components/AllApps";
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import type { BookerEvent } from "@calcom/features/bookings/types";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import classNames from "@calcom/ui/classNames";
@@ -45,7 +45,7 @@ export const EventDuration = ({
const { t } = useLocale();
const itemRefs = useRef<(HTMLLIElement | null)[]>([]);
const isPlatform = useIsPlatform();
const [selectedDuration, setSelectedDuration, state] = useBookerStore((state) => [
const [selectedDuration, setSelectedDuration, state] = useBookerStoreContext((state) => [
state.selectedDuration,
state.setSelectedDuration,
state.state,
@@ -13,6 +13,7 @@ export const getCalEventResponses = ({
bookingFields,
booking,
responses,
seatsEnabled,
}: {
// If the eventType has been deleted and a booking is Accepted later on, then bookingFields will be null and we can't know the label of fields. So, we should store the label as well in the DB
// Also, it is no longer straightforward to identify if a field is system field or not
@@ -32,6 +33,7 @@ export const getCalEventResponses = ({
};
}>;
responses?: z.infer<typeof bookingResponsesDbSchema>;
seatsEnabled?: boolean;
}) => {
const calEventUserFieldsResponses = {} as NonNullable<CalendarEvent["userFieldsResponses"]>;
const calEventResponses = {} as NonNullable<CalendarEvent["responses"]>;
@@ -61,8 +63,7 @@ export const getCalEventResponses = ({
throw new Error(`Missing label for booking field "${field.name}"`);
}
// If the guests field is hidden (disableGuests is set on the event type) don't try and infer guests from attendees list
if (field.name == "guests" && field.hidden) {
if (field.name == "guests" && !!seatsEnabled) {
backwardCompatibleResponses[field.name] = [];
}
@@ -550,6 +550,9 @@ async function handler(
eventType.schedulingType === SchedulingType.ROUND_ROBIN
) {
const bookingRepo = new BookingRepository(prisma);
const requiresPayment = !Number.isNaN(paymentAppData.price) && paymentAppData.price > 0;
const existingBooking = await bookingRepo.getValidBookingFromEventTypeForAttendee({
eventTypeId,
bookerEmail,
@@ -559,13 +562,20 @@ async function handler(
});
if (existingBooking) {
const hasPayments = existingBooking.payment.length > 0;
const isPaidBooking = existingBooking.paid || !hasPayments;
const shouldShowPaymentForm = requiresPayment && !isPaidBooking;
const firstPayment = shouldShowPaymentForm ? existingBooking.payment[0] : undefined;
const bookingResponse = {
...existingBooking,
user: {
...existingBooking.user,
email: null,
},
paymentRequired: false,
paymentRequired: shouldShowPaymentForm,
seatReferenceUid: "",
};
@@ -574,8 +584,8 @@ async function handler(
luckyUsers: bookingResponse.userId ? [bookingResponse.userId] : [],
isDryRun,
...(isDryRun ? { troubleshooterData } : {}),
paymentUid: undefined,
paymentId: undefined,
paymentUid: firstPayment?.uid,
paymentId: firstPayment?.id,
};
}
}
@@ -1909,7 +1919,7 @@ async function handler(
if (!isDryRun) {
sendRoundRobinRescheduledEmailsAndSMS(
copyEventAdditionalInfo,
{ ...copyEventAdditionalInfo, iCalUID },
rescheduledMembers,
eventType.metadata
);
@@ -64,6 +64,7 @@ const _getBookingData = async <T extends z.ZodType>({
getCalEventResponses({
bookingFields: eventType.bookingFields,
responses,
seatsEnabled: !!eventType.seatsPerTimeSlot,
});
return {
...parsedBody,
@@ -7,6 +7,8 @@
*
* They don't intend to test what the apps logic should do, but rather test if the apps are called with the correct data. For testing that, once should write tests within each app.
*/
import prismaMock from "../../../../../../tests/libs/__mocks__/prisma";
import {
createBookingScenario,
getDate,
@@ -3277,6 +3279,122 @@ describe("handleNewBooking", () => {
},
timeout
);
test(
`Payment retry scenario - should return existing payment UID and prevent duplicate bookings when retrying canceled payment`,
async ({ emails }) => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential(), getStripeAppCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
});
const scenarioData = getScenarioData({
eventTypes: [
{
id: 1,
title: "Paid Event",
description: "It's a test Paid Event",
slotInterval: 30,
requiresConfirmation: false,
metadata: {
apps: {
stripe: {
price: 100,
enabled: true,
currency: "usd",
},
},
},
length: 30,
users: [
{
id: 101,
},
],
},
],
organizer,
apps: [TestData.apps["stripe-payment"]],
});
await createBookingScenario(scenarioData);
mockSuccessfulVideoMeetingCreation({
metadataLookupKey: "dailyvideo",
});
const { paymentUid } = mockPaymentApp({
metadataLookupKey: "stripe",
appStoreLookupKey: "stripepayment",
});
mockCalendarToHaveNoBusySlots("googlecalendar");
const mockBookingData = getMockRequestDataForBooking({
data: {
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: "New York" },
},
},
});
const firstBooking = await handleNewBooking({
bookingData: mockBookingData,
});
expect(firstBooking).toEqual(
expect.objectContaining({
paymentUid: expect.any(String),
paymentRequired: true,
uid: expect.any(String),
})
);
const firstBookingInDb = await prismaMock.booking.findUnique({
where: { uid: firstBooking.uid },
include: { payment: true },
});
expect(firstBookingInDb).toBeTruthy();
expect(firstBookingInDb?.payment).toHaveLength(1);
expect(firstBookingInDb?.paid).toBe(false);
const secondBooking = await handleNewBooking({
bookingData: mockBookingData,
});
expect(secondBooking.uid).toBe(firstBooking.uid);
expect(secondBooking.paymentUid).toBe(firstBooking.paymentUid);
expect(secondBooking.paymentRequired).toBe(true);
const bookingsInDb = await prismaMock.booking.findMany({
where: {
eventTypeId: 1,
attendees: {
some: {
email: booker.email,
},
},
},
include: { payment: true },
});
expect(bookingsInDb).toHaveLength(1);
expect(bookingsInDb[0].payment).toHaveLength(1);
expect(bookingsInDb[0].uid).toBe(firstBooking.uid);
},
timeout
);
});
});

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